diff --git a/.gitignore b/.gitignore index b6076286c..2b833484a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +_ .vscode dist node_modules diff --git a/README.md b/README.md index 8d3f315af..23f4cec04 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,31 @@ We first introduce you to the basic development paradigms like *Model-View-Contr This repository contains following tutorials: - [Quickstart](./packages/quickstart/) - [Walkthrough](./packages/walkthrough/) +- [Navigation and Routing](./packages/navigation/) +- [OData V4](./packages/odatav4/) + +## Running locally + +The repository is set up as an npm workspaces monorepo. Each tutorial step under `packages/*/steps/*` is a self-contained app you can run standalone, and the root build orchestrator produces a unified preview that mirrors the published GitHub Pages site. + +```sh +# 1) install dependencies for every step +npm install + +# 2) build every tutorial step (produces dist//build/NN/ apps + ZIP downloads) +npm run build + +# 3) serve the rendered tutorial pages with working Live Preview links +npm start +``` + +After `npm start`, open (or any other tutorial's `packages//`). The dev server renders the markdown overview, rewrites the published `https://ui5.github.io/tutorials/...` URLs to point at the local `dist/`, and serves the built apps from the same origin. + +To run a single step directly without going through the build: + +```sh +npm start -w ui5.tutorial.walkthrough.step15 +``` ## How to obtain support @@ -26,4 +51,4 @@ If you wish to contribute code, offer fixes or improvements, please send a pull ## License -Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. This project is licensed under the Apache Software License, version 2.0 except as noted otherwise in the [LICENSE](LICENSE) file. +Copyright (c) 2026 SAP SE or an SAP affiliate company. All rights reserved. This project is licensed under the Apache Software License, version 2.0 except as noted otherwise in the [LICENSE](LICENSE) file. diff --git a/package-lock.json b/package-lock.json index b7fdf14a8..421f60275 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2437,6 +2437,34 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/@openui5/types": { + "version": "1.149.0", + "resolved": "https://registry.npmjs.org/@openui5/types/-/types-1.149.0.tgz", + "integrity": "sha512-BYpoS/T4V1q8yWHobrVmpfWJwRzvmqvj+48eTIwV5wsALoOuAxwLmZ8DHKGK1/mxyhOSKDRm/jpYqWPoTlTTdA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/jquery": "3.5.13", + "@types/qunit": "2.5.4" + } + }, + "node_modules/@openui5/types/node_modules/@types/jquery": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.13.tgz", + "integrity": "sha512-ZxJrup8nz/ZxcU0vantG+TPdboMhB24jad2uSap50zE7Q9rUeYlCF25kFMSmHR33qoeOgqcdHEp3roaookC0Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/sizzle": "*" + } + }, + "node_modules/@openui5/types/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-parser/binding-android-arm-eabi": { "version": "0.137.0", "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz", @@ -3342,16 +3370,6 @@ "@types/node": "*" } }, - "node_modules/@types/jquery": { - "version": "3.5.34", - "resolved": "https://registry.npmjs.org/@types/jquery/-/jquery-3.5.34.tgz", - "integrity": "sha512-3m3939S3erqmTLJANS/uy0B6V7BorKx7RorcGZVjZ62dF5PAGbKEDZK1CuLtKombJkFA2T1jl8LAIIs7IV6gBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/sizzle": "*" - } - }, "node_modules/@types/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3398,24 +3416,6 @@ "license": "MIT", "peer": true }, - "node_modules/@types/openui5": { - "version": "1.149.0", - "resolved": "https://registry.npmjs.org/@types/openui5/-/openui5-1.149.0.tgz", - "integrity": "sha512-x7ylZEVwCXi/zvvHINMqzvmoF26O8DT2dvMcSyd2qXmfRyMPhT523mEOl5kWUl9S3S7DFEzs2/NtYJGejvBrmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/jquery": "~3.5.13", - "@types/qunit": "^2.5.4" - } - }, - "node_modules/@types/qunit": { - "version": "2.19.14", - "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.19.14.tgz", - "integrity": "sha512-ou2RMtwyDnW1btrMnDMZeL6V5/yRRbuHKrRC6y8IuzDljjVzw6wPCVFb8p4qJD0NkUkf3BdZeJJIBzjK1BUUDQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/sizzle": { "version": "2.3.10", "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.10.tgz", @@ -20007,167 +20007,279 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/ui5.quickstart.step01": { + "node_modules/ui5.tutorial.navigation.step01": { + "resolved": "packages/navigation/steps/01", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step02": { + "resolved": "packages/navigation/steps/02", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step03": { + "resolved": "packages/navigation/steps/03", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step04": { + "resolved": "packages/navigation/steps/04", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step05": { + "resolved": "packages/navigation/steps/05", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step06": { + "resolved": "packages/navigation/steps/06", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step07": { + "resolved": "packages/navigation/steps/07", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step08": { + "resolved": "packages/navigation/steps/08", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step09": { + "resolved": "packages/navigation/steps/09", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step10": { + "resolved": "packages/navigation/steps/10", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step11": { + "resolved": "packages/navigation/steps/11", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step12": { + "resolved": "packages/navigation/steps/12", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step13": { + "resolved": "packages/navigation/steps/13", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step14": { + "resolved": "packages/navigation/steps/14", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step15": { + "resolved": "packages/navigation/steps/15", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step16": { + "resolved": "packages/navigation/steps/16", + "link": true + }, + "node_modules/ui5.tutorial.navigation.step17": { + "resolved": "packages/navigation/steps/17", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step01": { + "resolved": "packages/odatav4/steps/01", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step02": { + "resolved": "packages/odatav4/steps/02", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step03": { + "resolved": "packages/odatav4/steps/03", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step04": { + "resolved": "packages/odatav4/steps/04", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step05": { + "resolved": "packages/odatav4/steps/05", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step06": { + "resolved": "packages/odatav4/steps/06", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step07": { + "resolved": "packages/odatav4/steps/07", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step08": { + "resolved": "packages/odatav4/steps/08", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step09": { + "resolved": "packages/odatav4/steps/09", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step10": { + "resolved": "packages/odatav4/steps/10", + "link": true + }, + "node_modules/ui5.tutorial.odatav4.step11": { + "resolved": "packages/odatav4/steps/11", + "link": true + }, + "node_modules/ui5.tutorial.quickstart.step01": { "resolved": "packages/quickstart/steps/01", "link": true }, - "node_modules/ui5.quickstart.step02": { + "node_modules/ui5.tutorial.quickstart.step02": { "resolved": "packages/quickstart/steps/02", "link": true }, - "node_modules/ui5.quickstart.step03": { + "node_modules/ui5.tutorial.quickstart.step03": { "resolved": "packages/quickstart/steps/03", "link": true }, - "node_modules/ui5.walkthrough.step01": { + "node_modules/ui5.tutorial.walkthrough.step01": { "resolved": "packages/walkthrough/steps/01", "link": true }, - "node_modules/ui5.walkthrough.step02": { + "node_modules/ui5.tutorial.walkthrough.step02": { "resolved": "packages/walkthrough/steps/02", "link": true }, - "node_modules/ui5.walkthrough.step03": { + "node_modules/ui5.tutorial.walkthrough.step03": { "resolved": "packages/walkthrough/steps/03", "link": true }, - "node_modules/ui5.walkthrough.step04": { + "node_modules/ui5.tutorial.walkthrough.step04": { "resolved": "packages/walkthrough/steps/04", "link": true }, - "node_modules/ui5.walkthrough.step05": { + "node_modules/ui5.tutorial.walkthrough.step05": { "resolved": "packages/walkthrough/steps/05", "link": true }, - "node_modules/ui5.walkthrough.step06": { + "node_modules/ui5.tutorial.walkthrough.step06": { "resolved": "packages/walkthrough/steps/06", "link": true }, - "node_modules/ui5.walkthrough.step07": { + "node_modules/ui5.tutorial.walkthrough.step07": { "resolved": "packages/walkthrough/steps/07", "link": true }, - "node_modules/ui5.walkthrough.step08": { + "node_modules/ui5.tutorial.walkthrough.step08": { "resolved": "packages/walkthrough/steps/08", "link": true }, - "node_modules/ui5.walkthrough.step09": { + "node_modules/ui5.tutorial.walkthrough.step09": { "resolved": "packages/walkthrough/steps/09", "link": true }, - "node_modules/ui5.walkthrough.step10": { + "node_modules/ui5.tutorial.walkthrough.step10": { "resolved": "packages/walkthrough/steps/10", "link": true }, - "node_modules/ui5.walkthrough.step11": { + "node_modules/ui5.tutorial.walkthrough.step11": { "resolved": "packages/walkthrough/steps/11", "link": true }, - "node_modules/ui5.walkthrough.step12": { + "node_modules/ui5.tutorial.walkthrough.step12": { "resolved": "packages/walkthrough/steps/12", "link": true }, - "node_modules/ui5.walkthrough.step13": { + "node_modules/ui5.tutorial.walkthrough.step13": { "resolved": "packages/walkthrough/steps/13", "link": true }, - "node_modules/ui5.walkthrough.step14": { + "node_modules/ui5.tutorial.walkthrough.step14": { "resolved": "packages/walkthrough/steps/14", "link": true }, - "node_modules/ui5.walkthrough.step15": { + "node_modules/ui5.tutorial.walkthrough.step15": { "resolved": "packages/walkthrough/steps/15", "link": true }, - "node_modules/ui5.walkthrough.step16": { + "node_modules/ui5.tutorial.walkthrough.step16": { "resolved": "packages/walkthrough/steps/16", "link": true }, - "node_modules/ui5.walkthrough.step17": { + "node_modules/ui5.tutorial.walkthrough.step17": { "resolved": "packages/walkthrough/steps/17", "link": true }, - "node_modules/ui5.walkthrough.step18": { + "node_modules/ui5.tutorial.walkthrough.step18": { "resolved": "packages/walkthrough/steps/18", "link": true }, - "node_modules/ui5.walkthrough.step19": { + "node_modules/ui5.tutorial.walkthrough.step19": { "resolved": "packages/walkthrough/steps/19", "link": true }, - "node_modules/ui5.walkthrough.step20": { + "node_modules/ui5.tutorial.walkthrough.step20": { "resolved": "packages/walkthrough/steps/20", "link": true }, - "node_modules/ui5.walkthrough.step21": { + "node_modules/ui5.tutorial.walkthrough.step21": { "resolved": "packages/walkthrough/steps/21", "link": true }, - "node_modules/ui5.walkthrough.step22": { + "node_modules/ui5.tutorial.walkthrough.step22": { "resolved": "packages/walkthrough/steps/22", "link": true }, - "node_modules/ui5.walkthrough.step23": { + "node_modules/ui5.tutorial.walkthrough.step23": { "resolved": "packages/walkthrough/steps/23", "link": true }, - "node_modules/ui5.walkthrough.step24": { + "node_modules/ui5.tutorial.walkthrough.step24": { "resolved": "packages/walkthrough/steps/24", "link": true }, - "node_modules/ui5.walkthrough.step25": { + "node_modules/ui5.tutorial.walkthrough.step25": { "resolved": "packages/walkthrough/steps/25", "link": true }, - "node_modules/ui5.walkthrough.step26": { + "node_modules/ui5.tutorial.walkthrough.step26": { "resolved": "packages/walkthrough/steps/26", "link": true }, - "node_modules/ui5.walkthrough.step27": { + "node_modules/ui5.tutorial.walkthrough.step27": { "resolved": "packages/walkthrough/steps/27", "link": true }, - "node_modules/ui5.walkthrough.step28": { + "node_modules/ui5.tutorial.walkthrough.step28": { "resolved": "packages/walkthrough/steps/28", "link": true }, - "node_modules/ui5.walkthrough.step29": { + "node_modules/ui5.tutorial.walkthrough.step29": { "resolved": "packages/walkthrough/steps/29", "link": true }, - "node_modules/ui5.walkthrough.step30": { + "node_modules/ui5.tutorial.walkthrough.step30": { "resolved": "packages/walkthrough/steps/30", "link": true }, - "node_modules/ui5.walkthrough.step31": { + "node_modules/ui5.tutorial.walkthrough.step31": { "resolved": "packages/walkthrough/steps/31", "link": true }, - "node_modules/ui5.walkthrough.step32": { + "node_modules/ui5.tutorial.walkthrough.step32": { "resolved": "packages/walkthrough/steps/32", "link": true }, - "node_modules/ui5.walkthrough.step33": { + "node_modules/ui5.tutorial.walkthrough.step33": { "resolved": "packages/walkthrough/steps/33", "link": true }, - "node_modules/ui5.walkthrough.step34": { + "node_modules/ui5.tutorial.walkthrough.step34": { "resolved": "packages/walkthrough/steps/34", "link": true }, - "node_modules/ui5.walkthrough.step35": { + "node_modules/ui5.tutorial.walkthrough.step35": { "resolved": "packages/walkthrough/steps/35", "link": true }, - "node_modules/ui5.walkthrough.step36": { + "node_modules/ui5.tutorial.walkthrough.step36": { "resolved": "packages/walkthrough/steps/36", "link": true }, - "node_modules/ui5.walkthrough.step37": { + "node_modules/ui5.tutorial.walkthrough.step37": { "resolved": "packages/walkthrough/steps/37", "link": true }, - "node_modules/ui5.walkthrough.step38": { + "node_modules/ui5.tutorial.walkthrough.step38": { "resolved": "packages/walkthrough/steps/38", "link": true }, @@ -20739,11 +20851,363 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/navigation/steps/01": { + "name": "ui5.tutorial.navigation.step01", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/02": { + "name": "ui5.tutorial.navigation.step02", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/03": { + "name": "ui5.tutorial.navigation.step03", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/04": { + "name": "ui5.tutorial.navigation.step04", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/05": { + "name": "ui5.tutorial.navigation.step05", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/06": { + "name": "ui5.tutorial.navigation.step06", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/07": { + "name": "ui5.tutorial.navigation.step07", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/08": { + "name": "ui5.tutorial.navigation.step08", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/09": { + "name": "ui5.tutorial.navigation.step09", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/10": { + "name": "ui5.tutorial.navigation.step10", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/11": { + "name": "ui5.tutorial.navigation.step11", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/12": { + "name": "ui5.tutorial.navigation.step12", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/13": { + "name": "ui5.tutorial.navigation.step13", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/14": { + "name": "ui5.tutorial.navigation.step14", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/15": { + "name": "ui5.tutorial.navigation.step15", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/16": { + "name": "ui5.tutorial.navigation.step16", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/navigation/steps/17": { + "name": "ui5.tutorial.navigation.step17", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/01": { + "name": "ui5.tutorial.odatav4.step01", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/02": { + "name": "ui5.tutorial.odatav4.step02", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/03": { + "name": "ui5.tutorial.odatav4.step03", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/04": { + "name": "ui5.tutorial.odatav4.step04", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/05": { + "name": "ui5.tutorial.odatav4.step05", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/06": { + "name": "ui5.tutorial.odatav4.step06", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/07": { + "name": "ui5.tutorial.odatav4.step07", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/08": { + "name": "ui5.tutorial.odatav4.step08", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/08/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, + "packages/odatav4/steps/09": { + "name": "ui5.tutorial.odatav4.step09", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/10": { + "name": "ui5.tutorial.odatav4.step10", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/11": { + "name": "ui5.tutorial.odatav4.step11", + "version": "1.0.0", + "devDependencies": { + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } + }, + "packages/odatav4/steps/11/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/quickstart/steps/01": { - "name": "ui5.quickstart.step01", + "name": "ui5.tutorial.quickstart.step01", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20752,10 +21216,10 @@ } }, "packages/quickstart/steps/02": { - "name": "ui5.quickstart.step02", + "name": "ui5.tutorial.quickstart.step02", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20764,10 +21228,10 @@ } }, "packages/quickstart/steps/03": { - "name": "ui5.quickstart.step03", + "name": "ui5.tutorial.quickstart.step03", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20776,14 +21240,14 @@ } }, "packages/walkthrough/steps/01": { - "name": "ui5.walkthrough.step01", + "name": "ui5.tutorial.walkthrough.step01", "version": "1.0.0", "devDependencies": { "@ui5/cli": "^4.0.53" } }, "packages/walkthrough/steps/02": { - "name": "ui5.walkthrough.step02", + "name": "ui5.tutorial.walkthrough.step02", "version": "1.0.0", "devDependencies": { "@ui5/cli": "^4.0.53", @@ -20794,10 +21258,10 @@ } }, "packages/walkthrough/steps/03": { - "name": "ui5.walkthrough.step03", + "name": "ui5.tutorial.walkthrough.step03", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20806,10 +21270,10 @@ } }, "packages/walkthrough/steps/04": { - "name": "ui5.walkthrough.step04", + "name": "ui5.tutorial.walkthrough.step04", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20818,10 +21282,10 @@ } }, "packages/walkthrough/steps/05": { - "name": "ui5.walkthrough.step05", + "name": "ui5.tutorial.walkthrough.step05", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20830,10 +21294,10 @@ } }, "packages/walkthrough/steps/06": { - "name": "ui5.walkthrough.step06", + "name": "ui5.tutorial.walkthrough.step06", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20842,10 +21306,10 @@ } }, "packages/walkthrough/steps/07": { - "name": "ui5.walkthrough.step07", + "name": "ui5.tutorial.walkthrough.step07", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20854,10 +21318,10 @@ } }, "packages/walkthrough/steps/08": { - "name": "ui5.walkthrough.step08", + "name": "ui5.tutorial.walkthrough.step08", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20866,10 +21330,10 @@ } }, "packages/walkthrough/steps/09": { - "name": "ui5.walkthrough.step09", + "name": "ui5.tutorial.walkthrough.step09", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20878,10 +21342,10 @@ } }, "packages/walkthrough/steps/10": { - "name": "ui5.walkthrough.step10", + "name": "ui5.tutorial.walkthrough.step10", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20890,10 +21354,10 @@ } }, "packages/walkthrough/steps/11": { - "name": "ui5.walkthrough.step11", + "name": "ui5.tutorial.walkthrough.step11", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20902,10 +21366,10 @@ } }, "packages/walkthrough/steps/12": { - "name": "ui5.walkthrough.step12", + "name": "ui5.tutorial.walkthrough.step12", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20914,10 +21378,10 @@ } }, "packages/walkthrough/steps/13": { - "name": "ui5.walkthrough.step13", + "name": "ui5.tutorial.walkthrough.step13", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20926,10 +21390,10 @@ } }, "packages/walkthrough/steps/14": { - "name": "ui5.walkthrough.step14", + "name": "ui5.tutorial.walkthrough.step14", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20938,10 +21402,10 @@ } }, "packages/walkthrough/steps/15": { - "name": "ui5.walkthrough.step15", + "name": "ui5.tutorial.walkthrough.step15", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20950,10 +21414,10 @@ } }, "packages/walkthrough/steps/16": { - "name": "ui5.walkthrough.step16", + "name": "ui5.tutorial.walkthrough.step16", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20962,10 +21426,10 @@ } }, "packages/walkthrough/steps/17": { - "name": "ui5.walkthrough.step17", + "name": "ui5.tutorial.walkthrough.step17", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20974,10 +21438,10 @@ } }, "packages/walkthrough/steps/18": { - "name": "ui5.walkthrough.step18", + "name": "ui5.tutorial.walkthrough.step18", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20986,10 +21450,10 @@ } }, "packages/walkthrough/steps/19": { - "name": "ui5.walkthrough.step19", + "name": "ui5.tutorial.walkthrough.step19", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -20998,10 +21462,10 @@ } }, "packages/walkthrough/steps/20": { - "name": "ui5.walkthrough.step20", + "name": "ui5.tutorial.walkthrough.step20", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21010,10 +21474,10 @@ } }, "packages/walkthrough/steps/21": { - "name": "ui5.walkthrough.step21", + "name": "ui5.tutorial.walkthrough.step21", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21022,10 +21486,10 @@ } }, "packages/walkthrough/steps/22": { - "name": "ui5.walkthrough.step22", + "name": "ui5.tutorial.walkthrough.step22", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21034,10 +21498,10 @@ } }, "packages/walkthrough/steps/23": { - "name": "ui5.walkthrough.step23", + "name": "ui5.tutorial.walkthrough.step23", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21046,10 +21510,10 @@ } }, "packages/walkthrough/steps/24": { - "name": "ui5.walkthrough.step24", + "name": "ui5.tutorial.walkthrough.step24", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21058,10 +21522,10 @@ } }, "packages/walkthrough/steps/25": { - "name": "ui5.walkthrough.step25", + "name": "ui5.tutorial.walkthrough.step25", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21071,10 +21535,10 @@ } }, "packages/walkthrough/steps/26": { - "name": "ui5.walkthrough.step26", + "name": "ui5.tutorial.walkthrough.step26", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21084,10 +21548,11 @@ } }, "packages/walkthrough/steps/27": { - "name": "ui5.walkthrough.step27", + "name": "ui5.tutorial.walkthrough.step27", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21096,11 +21561,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/27/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/28": { - "name": "ui5.walkthrough.step28", + "name": "ui5.tutorial.walkthrough.step28", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21109,11 +21582,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/28/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/29": { - "name": "ui5.walkthrough.step29", + "name": "ui5.tutorial.walkthrough.step29", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21122,11 +21603,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/29/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/30": { - "name": "ui5.walkthrough.step30", + "name": "ui5.tutorial.walkthrough.step30", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21135,11 +21624,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/30/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/31": { - "name": "ui5.walkthrough.step31", + "name": "ui5.tutorial.walkthrough.step31", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21148,11 +21645,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/31/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/32": { - "name": "ui5.walkthrough.step32", + "name": "ui5.tutorial.walkthrough.step32", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", @@ -21161,11 +21666,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/32/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/33": { - "name": "ui5.walkthrough.step33", + "name": "ui5.tutorial.walkthrough.step33", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "@ui5/ts-interface-generator": "^0.11.0", "typescript": "^6.0.3", @@ -21175,11 +21688,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/33/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/34": { - "name": "ui5.walkthrough.step34", + "name": "ui5.tutorial.walkthrough.step34", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "@ui5/ts-interface-generator": "^0.11.0", "typescript": "^6.0.3", @@ -21189,11 +21710,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/34/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/35": { - "name": "ui5.walkthrough.step35", + "name": "ui5.tutorial.walkthrough.step35", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "@ui5/ts-interface-generator": "^0.11.0", "typescript": "^6.0.3", @@ -21203,11 +21732,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/35/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/36": { - "name": "ui5.walkthrough.step36", + "name": "ui5.tutorial.walkthrough.step36", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "@ui5/ts-interface-generator": "^0.11.0", "typescript": "^6.0.3", @@ -21217,11 +21754,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/36/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/37": { - "name": "ui5.walkthrough.step37", + "name": "ui5.tutorial.walkthrough.step37", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "@ui5/ts-interface-generator": "^0.11.0", "typescript": "^6.0.3", @@ -21231,11 +21776,19 @@ "ui5-tooling-transpile": "^3.11.0" } }, + "packages/walkthrough/steps/37/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" + }, "packages/walkthrough/steps/38": { - "name": "ui5.walkthrough.step38", + "name": "ui5.tutorial.walkthrough.step38", "version": "1.0.0", "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", "@ui5/cli": "^4.0.53", "@ui5/ts-interface-generator": "^0.11.0", "local-web-server": "^5.4.0", @@ -21245,6 +21798,13 @@ "ui5-middleware-simpleproxy": "^3.7.1", "ui5-tooling-transpile": "^3.11.0" } + }, + "packages/walkthrough/steps/38/node_modules/@types/qunit": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/@types/qunit/-/qunit-2.5.4.tgz", + "integrity": "sha512-VHi2lEd4/zp8OOouf43JXGJJ5ZxHvdLL1dU0Yakp6Iy73SjpuXl7yjwAwmh1qhTv8krDgHteSwaySr++uXX9YQ==", + "dev": true, + "license": "MIT" } } } diff --git a/packages/navigation/README.md b/packages/navigation/README.md new file mode 100644 index 000000000..3a2cde4a6 --- /dev/null +++ b/packages/navigation/README.md @@ -0,0 +1,67 @@ + + +# Navigation and Routing Tutorial + +SAPUI5 comes with a powerful routing API that helps you control the state of your application efficiently. This tutorial will illustrate all major features and APIs related to navigation and routing in SAPUI5 apps by creating a simple and easy to understand mobile app. It represents a set of best practices for applying the navigation and routing features of SAPUI5 to your applications. + +In classical Web applications, the server determines which resource is requested based on the URL pattern of the request and serves it accordingly. The server-side logic controls how the requested resource or page is displayed in an appropriate way. + +In single-page applications, only one page is initially requested from the server and additional resources are dynamically loaded using client-side logic. The user only navigates within this page. The navigation is persisted in the hash instead of the server path or URL parameters. + +For example, a classical Web application might display the employee’s resume page when URL `http:////employees/resume.html?id=3` or `http:////employees/3/resume` is called. A single-page application instead would do the same thing by using a hash-based URL like `http:////#/employees/3/resume`. + +The information in the hash, namely everything that is following the `#` character, is interpreted by the router. + +> :note: +> This tutorial does not handle cross-app navigation with the SAP Fiori launchpad. However, the concepts described in this tutorial are also fundamental for navigation and routing between apps in the SAP Fiori launchpad. + +We will create a simple app displaying the data of a company’s employees to show typical navigation patterns and routing features. The complete flow of the application can be seen in the figure below. We'll start with the home page which lets users do the following: + +- Display a *Not Found* page + +- Navigate to a list of employees and drill further down to see a *Details* page for each employee + +- Show an *Employee Overview* that they can search and sort + +## Page flow of the final app + +![Page flow of the final app](assets/Tutorial_Navigation_and_Routing_Screen_Flow_92cdce7.png "Page flow of the final app") + +Throughout this tutorial we will add features for navigating to pages and bookmarking them. We will add backward and forward navigation with common transition animations \(slide, show, flip, etc.\). We will add more pages to the app and navigate between them to show typical use cases. We will even learn how to implement features for bookmarking a specific search, table sorting via filters, and dialogs. + +> :tip: +> You don't have to do all tutorial steps sequentially, you can also jump directly to any step you want. Just download the code from the previous step, and start there. +> +> You can view and download the files for all steps in the Demo Kit at [Navigation and Routing](https://sdk.openui5.org/#/entity/sap.ui.core.tutorial.navigation). Copy the code to your workspace and make sure that the application runs by calling the `webapp/index.html` file. Depending on your development environment you might have to adjust resource paths and configuration entries. +> +> For more information check the [Downloading Code for a Tutorial Step](https://sdk.openui5.org/topic/8b49fc198bf04b2d9800fc37fecbb218.html#loio8b49fc198bf04b2d9800fc37fecbb218/tutorials_download) section of the tutorials overview page [Get Started: Setup, Tutorials, and Demo Apps](https://sdk.openui5.org/topic/8b49fc198bf04b2d9800fc37fecbb218). + +*** + +### In this Tutorial + +The tutorial consists of the following steps. To start, just open the first link β€” you'll be guided from there. + +- **[Step 1: Set Up the Initial App](./steps/01/README.md "We start by setting up a simple app for this tutorial. The app displays mock data only and mimics real OData back-end calls with the mock server as you have seen in the *Walkthrough* tutorial.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/01/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-01.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-01-js.zip)
) +- **[Step 2: Enable Routing](./steps/02/README.md "In this step we will modify the app and introduce routing. Instead of having the home page of the app hard coded we will configure a router to wire multiple views together when our app is called.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/02/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-02.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-02-js.zip)
) +- **[Step 3: Catch Invalid Hashes](./steps/03/README.md "Sometimes it is important to display an indication that the requested resource was not found.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/03/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-03.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-03-js.zip)
) +- **[Step 4: Add a *Back* Button to *Not Found* Page](./steps/04/README.md "When we are on the *Not Found* page because of an invalid hash, we want to get back to our app to select another page.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/04/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-04.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-04-js.zip)
) +- **[Step 5: Display a Target Without Changing the Hash](./steps/05/README.md "In this step, you will learn more about targets and how to display a target from the routing configuration manually.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/05/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-05.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-05-js.zip)
) +- **[Step 6: Navigate to Routes with Hard-Coded Patterns](./steps/06/README.md "In this step, we'll create a second button on the home page, with which we can navigate to a simple list of employees.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/06/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-06.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-06-js.zip)
) +- **[Step 7: Navigate to Routes with Mandatory Parameters](./steps/07/README.md "In this step, we implement a feature that allows the user to click on an employee in the list to see additional details of the employee.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/07/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-07.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-07-js.zip)
) +- **[Step 8: Navigate with Flip Transition](./steps/08/README.md "In this step, we want to illustrate how to navigate to a page with a custom transition animation. Both forward and backward navigation will use the β€œflip” transition but with a different direction.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/08/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-08.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-08-js.zip)
) +- **[Step 9: Allow Bookmarkable Tabs with Optional Query Parameters](./steps/09/README.md "The resume view contains four tabs as we have seen in the previous step of this tutorial. However, when the user navigates to the resume page, only the first tab is displayed initially.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/09/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-09.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-09-js.zip)
) +- **[Step 10: Implement β€œLazy Loading”](./steps/10/README.md "In the previous steps, we have implemented a Resume view that uses tabs to display data. The complete content of all the tabs is loaded once, no matter which tab is currently displayed.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/10/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-10.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-10-js.zip)
) +- **[Step 11: Assign Multiple Targets](./steps/11/README.md "In this step, we will add a new button to the home page to illustrate the usage of multiple targets for a route.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/11/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-11.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-11-js.zip)
) +- **[Step 12: Make a Search Bookmarkable](./steps/12/README.md "In this step we will make the search bookmarkable. This allows users to search for employees in the *Employees* table and they can bookmark their search query or share the URL.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/12/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-12.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-12-js.zip)
) +- **[Step 13: Make Table Sorting Bookmarkable](./steps/13/README.md "In this step, we will create a button at the top of the table which will change the sorting of the table.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/13/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-13.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-13-js.zip)
) +- **[Step 14: Make Dialogs Bookmarkable](./steps/14/README.md "In this step, we want to allow bookmarking of the dialog box that is opened when the user clicks the *Sort* button.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/14/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-14.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-14-js.zip)
) +- **[Step 15: Reuse an Existing Route](./steps/15/README.md "The *Employees* table displays employee data. However, the resumes of the employees are not accessible from this view yet.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/15/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-15.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-15-js.zip)
) +- **[Step 16: Handle Invalid Hashes by Listening to Bypassed Events](./steps/16/README.md "So far we have created many useful routes in our app. In the very early steps we have also made sure that a *Not Found* page is displayed in case the app was called with an invalid hash.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/16/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-16.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-16-js.zip)
) +- **[Step 17: Listen to Matched Events of Any Route](./steps/17/README.md "In the previous step, we have listened for bypassed events to detect possible technical issues with our app.")** ([πŸ”— Live Preview](https://ui5.github.io/tutorials/navigation/build/17/index-cdn.html) \|
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-17.zip)
[πŸ“₯ Download Solution](https://ui5.github.io/tutorials/navigation/navigation-step-17-js.zip)
) + +*** + +## License + +Copyright (c) 2026 SAP SE or an SAP affiliate company. All rights reserved. This project is licensed under the Apache Software License, version 2.0 except as noted otherwise in the [LICENSE](../../LICENSE) file. diff --git a/packages/navigation/assets/Tutorial_Navigation_and_Routing_Screen_Flow_92cdce7.png b/packages/navigation/assets/Tutorial_Navigation_and_Routing_Screen_Flow_92cdce7.png new file mode 100644 index 000000000..60f45b5f0 Binary files /dev/null and b/packages/navigation/assets/Tutorial_Navigation_and_Routing_Screen_Flow_92cdce7.png differ diff --git a/packages/navigation/steps/01/README.md b/packages/navigation/steps/01/README.md new file mode 100644 index 000000000..18b0aadc4 --- /dev/null +++ b/packages/navigation/steps/01/README.md @@ -0,0 +1,96 @@ + + +# Step 1: Set Up the Initial App + +We start by setting up a simple app for this tutorial. The app displays mock data only and mimics real OData back-end calls with the mock server as you have seen in the *Walkthrough* tutorial. + +The structure and data model created in this step will be used throughout the rest of this tutorial. The initial app created in this step will be extended in the subsequent steps to illustrate the navigation and routing features of SAPUI5. + +## Preview + +### Initial app with a simple button + +![Initial app with a simple button](assets/Tutorial_Navigation_and_Routing_Step_01a.png "Initial app with a simple button") + +## Setup + +1. To set up your project for this tutorial, download the files at [Navigation and Routing - Step 1](https://ui5.github.io/tutorials/navigation/navigation-step-01.zip). + +2. Extract the downloaded `.zip` file at the desired location on your local machine. + +3. Open a shell in the extracted folder and execute `npm install`. + +4. Execute `npm start` to start the web server and to open a new browser window hosting your newly created `index.html`. + +You should have the same files as displayed in the following figure: + +### Folder structure with downloaded files + +```text +webapp/ +β”œβ”€β”€ Component.?s +β”œβ”€β”€ controller/ +β”‚ └── App.controller.?s +β”œβ”€β”€ i18n/ +β”‚ └── i18n.properties +β”œβ”€β”€ index-cdn.html +β”œβ”€β”€ index.html +β”œβ”€β”€ initMockServer.?s +β”œβ”€β”€ localService/ +β”‚ β”œβ”€β”€ metadata.xml +β”‚ β”œβ”€β”€ mockdata/ +β”‚ β”‚ β”œβ”€β”€ Employees.json +β”‚ β”‚ └── Resumes.json +β”‚ └── mockserver.?s +β”œβ”€β”€ manifest.json +└── view/ + └── App.view.xml +``` + +> :note: +> The content of the `localService` folder will not be changed in this tutorial. The `i18n` folder will always contain the `i18n.properties` file only. Therefore, we will show both subfolders collapsed in the following steps. + +## The Initial App + +With the downloaded coding, you have an initial app with recommended settings that provides the basic features of an SAPUI5 app: + +- **Home Page** + + The home page of our app is defined in the `webapp/index.html` file. In this file we bootstrap SAPUI5 and tell the runtime where to find our custom resources. Furthermore, we initialize the `MockServer` to simulate back-end requests as we do not have a real back-end service throughout this tutorial. Finally, we instantiate the application component, assign it to a `sap.m.Shell` control, and place the shell into the body. The corresponding `Component.ts` file in the `webapp` folder will be extended throughout this tutorial. + +- **Data** + + In the `webapp/localService/mockserver.ts` file, we configure the mock server. Using the mock server in this tutorial allows us to easily run the code even without network connection and without the need of having a remote server for our application data. + + The `metadata.xml` file used by the mock server describes our OData service. The service only has two OData entities: + + - Employee + + An `employee` has typical properties like `FirstName` and `LastName` as well as a navigation property to a resume entity referenced by a `ResumeID`. Of course, the entity also has an ID property: `EmployeeID`. The corresponding `EntitySet` is `Employees`. The actual test data containing several employees is located in the `webapp/localService/mockdata/Employees.json` file. + + - Resume + + In our case, we want to keep the resume of employees very simple. Therefore, we just have simple properties of type `Edm.String`. The properties are `Information`, `Projects`, `Hobbies` and `Notes`; all of them contain textual information. The entity has an ID property `ResumeID` and the corresponding `EntitySet` is `Resumes`. The resume data for an employee is located in file `webapp/localService/mockdata/Resumes.json`. + +- **Configuration of the App** + + In the `webapp/manifest.json` descriptor file, we configure our app. The descriptor file contains the following most interesting sections: + + - `sap.app` + + In this section we reference an `i18n.properties` file and use a special syntax to bind the texts for the `title` and `description` properties. + + In the `dataSources` part, we tell our app where to find our OData service `employeeRemote`. As you might guess, the `uri` correlates to the `rootUri` of our mock server instance which can be found in `webapp/localService/mockserver.ts`. It is important that these two paths match to allow our mock server to provide the test data we defined above. The `localUri` is used to determine the location of the `metadata.xml` file. + + - `sap.ui5` + + Under `sap.ui5` we declare with the `rootView` parameter that our `ui5.tutorial.navigation.view.App` view shall be loaded and used as the `rootView` for our app. Furthermore, we define two `models` to be automatically instantiated and bound to the `i18n` component and a default model `""`. The latter references our `employeeRemote` `dataSource` which is declared in our `sap.app` section as an OData 2.0 data source. The `i18n` file can be found at `webapp/i18n/i18n.properties`. This data source will be mocked by our mock server. + +So far we have a basic app that does not really have any navigation or routing implemented. This will change in the next steps when we implement our first navigation features. + +*** + + +*** + +**Next:** [Step 2: Enable Routing](../02/README.md) diff --git a/packages/navigation/steps/01/assets/Tutorial_Navigation_and_Routing_Step_01a.png b/packages/navigation/steps/01/assets/Tutorial_Navigation_and_Routing_Step_01a.png new file mode 100644 index 000000000..9f3fa4bd6 Binary files /dev/null and b/packages/navigation/steps/01/assets/Tutorial_Navigation_and_Routing_Step_01a.png differ diff --git a/packages/navigation/steps/01/package.json b/packages/navigation/steps/01/package.json new file mode 100644 index 000000000..2b1038a8a --- /dev/null +++ b/packages/navigation/steps/01/package.json @@ -0,0 +1,19 @@ +{ + "name": "ui5.tutorial.navigation.step01", + "private": true, + "version": "1.0.0", + "author": "SAP SE", + "description": "UI5 Demo App - Navigation Tutorial", + "scripts": { + "start": "ui5 serve -o index.html", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } +} diff --git a/packages/navigation/steps/01/tsconfig.json b/packages/navigation/steps/01/tsconfig.json new file mode 100644 index 000000000..0b86951e7 --- /dev/null +++ b/packages/navigation/steps/01/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2023", + "types": [ + "@openui5/types" + ], + "skipLibCheck": true, + "allowJs": true, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "paths": { + "ui5/tutorial/navigation/*": [ + "./webapp/*" + ] + }, + "strict": false, + "strictNullChecks": false + }, + "exclude": [ + "./webapp/test/e2e/**/*" + ], + "include": [ + "./webapp/**/*" + ] +} diff --git a/packages/navigation/steps/01/ui5.yaml b/packages/navigation/steps/01/ui5.yaml new file mode 100644 index 000000000..2e95456f0 --- /dev/null +++ b/packages/navigation/steps/01/ui5.yaml @@ -0,0 +1,24 @@ +specVersion: "4.0" +metadata: + name: "ui5.tutorial.navigation" +type: application +framework: + name: OpenUI5 + version: "1.148.1" + libraries: + - name: sap.m + - name: sap.ui.core + - name: sap.ui.layout + - name: themelib_sap_horizon +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression + - name: ui5-middleware-serveframework + afterMiddleware: compression + - name: ui5-middleware-livereload + afterMiddleware: compression diff --git a/packages/navigation/steps/01/webapp/Component.ts b/packages/navigation/steps/01/webapp/Component.ts new file mode 100644 index 000000000..63d712f2c --- /dev/null +++ b/packages/navigation/steps/01/webapp/Component.ts @@ -0,0 +1,12 @@ +import UIComponent from "sap/ui/core/UIComponent"; + +/** + * @namespace ui5.tutorial.navigation + */ +export default class Component extends UIComponent { + + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], + manifest: "json" + }; +} diff --git a/packages/navigation/steps/01/webapp/controller/App.controller.ts b/packages/navigation/steps/01/webapp/controller/App.controller.ts new file mode 100644 index 000000000..48bd34e84 --- /dev/null +++ b/packages/navigation/steps/01/webapp/controller/App.controller.ts @@ -0,0 +1,11 @@ +import Controller from "sap/ui/core/mvc/Controller"; + +/** + * @namespace ui5.tutorial.navigation.controller + */ +export default class App extends Controller { + + public onInit(): void { + + } +} diff --git a/packages/navigation/steps/01/webapp/i18n/i18n.properties b/packages/navigation/steps/01/webapp/i18n/i18n.properties new file mode 100644 index 000000000..be58132c8 --- /dev/null +++ b/packages/navigation/steps/01/webapp/i18n/i18n.properties @@ -0,0 +1,6 @@ +# App Descriptor +appTitle=Navigation & Routing Tutorial +appDescription=A simple app that explains how to use navigation and routing features of SAPUI5 + +iWantToNavigate=I want to navigate +homePageTitle=Home diff --git a/packages/navigation/steps/01/webapp/index-cdn.html b/packages/navigation/steps/01/webapp/index-cdn.html new file mode 100644 index 000000000..f012c70af --- /dev/null +++ b/packages/navigation/steps/01/webapp/index-cdn.html @@ -0,0 +1,21 @@ + + + + + + Navigation and Routing Tutorial + + + +
+ + diff --git a/packages/navigation/steps/01/webapp/index.html b/packages/navigation/steps/01/webapp/index.html new file mode 100644 index 000000000..41251762e --- /dev/null +++ b/packages/navigation/steps/01/webapp/index.html @@ -0,0 +1,21 @@ + + + + + + Navigation and Routing Tutorial + + + +
+ + diff --git a/packages/navigation/steps/01/webapp/initMockServer.ts b/packages/navigation/steps/01/webapp/initMockServer.ts new file mode 100644 index 000000000..66e2bfadd --- /dev/null +++ b/packages/navigation/steps/01/webapp/initMockServer.ts @@ -0,0 +1,9 @@ +import mockserver from "ui5/tutorial/navigation/localService/mockserver"; +import MessageBox from "sap/m/MessageBox"; + +mockserver.init().catch((error: Error) => { + MessageBox.error(error.message); +}).finally(() => { + // initialize the embedded component on the HTML page + import("sap/ui/core/ComponentSupport"); +}); diff --git a/packages/navigation/steps/01/webapp/localService/metadata.xml b/packages/navigation/steps/01/webapp/localService/metadata.xml new file mode 100644 index 000000000..8cb9b07d6 --- /dev/null +++ b/packages/navigation/steps/01/webapp/localService/metadata.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/navigation/steps/01/webapp/localService/mockdata/Employees.json b/packages/navigation/steps/01/webapp/localService/mockdata/Employees.json new file mode 100644 index 000000000..02595688f --- /dev/null +++ b/packages/navigation/steps/01/webapp/localService/mockdata/Employees.json @@ -0,0 +1,155 @@ +[ + { + "EmployeeID": 1, + "LastName": "Davolio", + "FirstName": "Nancy", + "Address": "507 - 20th Ave. E. Apt. 2A", + "City": "Seattle", + "Region": "WA", + "PostalCode": "98122", + "Country": "USA", + "HomePhone": "(206) 555-9857", + "ResumeID": 1, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(1)/Resume" + } + } + }, + { + "EmployeeID": 2, + "LastName": "Fuller", + "FirstName": "Andrew", + "Address": "908 W. Capital Way", + "City": "Tacoma", + "Region": "WA", + "PostalCode": "98401", + "Country": "USA", + "HomePhone": "(206) 555-9482", + "ResumeID": 2, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(2)/Resume" + } + } + }, + { + "EmployeeID": 3, + "LastName": "Leverling", + "FirstName": "Janet", + "Address": "722 Moss Bay Blvd.", + "City": "Kirkland", + "Region": "WA", + "PostalCode": "98033", + "Country": "USA", + "HomePhone": "(206) 555-3412", + "ResumeID": 3, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(3)/Resume" + } + } + }, + { + "EmployeeID": 4, + "LastName": "Peacock", + "FirstName": "Margaret", + "Address": "4110 Old Redmond Rd.", + "City": "Redmond", + "Region": "WA", + "PostalCode": "98052", + "Country": "USA", + "HomePhone": "(206) 555-8122", + "ResumeID": 4, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(4)/Resume" + } + } + }, + { + "EmployeeID": 5, + "LastName": "Buchanan", + "FirstName": "Steven", + "Address": "14 Garrett Hill", + "City": "London", + "Region": null, + "PostalCode": "SW1 8JR", + "Country": "UK", + "HomePhone": "(71) 555-4848", + "ResumeID": 5, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(5)/Resume" + } + } + }, + { + "EmployeeID": 6, + "LastName": "Suyama", + "FirstName": "Michael", + "Address": "Coventry House Miner Rd.", + "City": "London", + "Region": null, + "PostalCode": "EC2 7JR", + "Country": "UK", + "HomePhone": "(71) 555-7773", + "ResumeID": 6, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(6)/Resume" + } + } + }, + { + "EmployeeID": 7, + "LastName": "King", + "FirstName": "Robert", + "Address": "Edgeham Hollow Winchester Way", + "City": "London", + "Region": null, + "PostalCode": "RG1 9SP", + "Country": "UK", + "HomePhone": "(71) 555-5598", + "ResumeID": 7, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(7)/Resume" + } + } + }, + { + "EmployeeID": 8, + "LastName": "Callahan", + "FirstName": "Laura", + "Address": "4726 - 11th Ave. N.E.", + "City": "Seattle", + "Region": "WA", + "PostalCode": "98105", + "Country": "USA", + "HomePhone": "(206) 555-1189", + "ResumeID": 8, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(8)/Resume" + } + } + }, + { + "EmployeeID": 9, + "LastName": "Dodsworth", + "FirstName": "Anne", + "Address": "7 Houndstooth Rd.", + "City": "London", + "Region": null, + "PostalCode": "WG2 7LT", + "Country": "UK", + "HomePhone": "(71) 555-4444", + "ResumeID": 9, + "Resume": { + "__deferred": { + "uri": "/here/goes/your/serviceUrl/Employees(9)/Resume" + } + } + } +] diff --git a/packages/navigation/steps/01/webapp/localService/mockdata/Resumes.json b/packages/navigation/steps/01/webapp/localService/mockdata/Resumes.json new file mode 100644 index 000000000..24cb6a684 --- /dev/null +++ b/packages/navigation/steps/01/webapp/localService/mockdata/Resumes.json @@ -0,0 +1,65 @@ +[ + { + "ResumeID": 1, + "Information": "Information of Nancy Davolio", + "Projects": "Projects of Nancy Davolio", + "Hobbies": "Hobbies of Nancy Davolio", + "Notes": "Notes of Nancy Davolio" + }, + { + "ResumeID": 2, + "Information": "Information of Andrew Fuller", + "Projects": "Projects of Andrew Fuller", + "Hobbies": "Hobbies of Andrew Fuller", + "Notes": "Notes of Andrew Fuller" + }, + { + "ResumeID": 3, + "Information": "Information of Janet Leverling", + "Projects": "Projects of Janet Leverling", + "Hobbies": "Hobbies of Janet Leverling", + "Notes": "Notes of Janet Leverling" + }, + { + "ResumeID": 4, + "Information": "Information of Margaret Peacock", + "Projects": "Projects of Margaret Peacock", + "Hobbies": "Hobbies of Margaret Peacock", + "Notes": "Notes of Margaret Peacock" + }, + { + "ResumeID": 5, + "Information": "Information of Steven Buchanan", + "Projects": "Projects of Steven Buchanan", + "Hobbies": "Hobbies of Steven Buchanan", + "Notes": "Notes of Steven Buchanan" + }, + { + "ResumeID": 6, + "Information": "Information of Michael Suyama", + "Projects": "Projects of Michael Suyama", + "Hobbies": "Hobbies of Michael Suyama", + "Notes": "Notes of Michael Suyama" + }, + { + "ResumeID": 7, + "Information": "Information of Robert King", + "Projects": "Projects of Robert King", + "Hobbies": "Hobbies of Robert King", + "Notes": "Notes of Robert King" + }, + { + "ResumeID": 8, + "Information": "Information of Laura Callahan", + "Projects": "Projects of Laura Callahan", + "Hobbies": "Hobbies of Laura Callahan", + "Notes": "Notes of Laura Callahan" + }, + { + "ResumeID": 9, + "Information": "Information of Anne Dodsworth", + "Projects": "Projects of Anne Dodsworth", + "Hobbies": "Hobbies of Anne Dodsworth", + "Notes": "Notes of Anne Dodsworth" + } +] diff --git a/packages/navigation/steps/01/webapp/localService/mockserver.ts b/packages/navigation/steps/01/webapp/localService/mockserver.ts new file mode 100644 index 000000000..cd552596d --- /dev/null +++ b/packages/navigation/steps/01/webapp/localService/mockserver.ts @@ -0,0 +1,50 @@ +import MockServer from "sap/ui/core/util/MockServer"; +import JSONModel from "sap/ui/model/json/JSONModel"; +import Log from "sap/base/Log"; + +const appNamespace = "ui5/tutorial/navigation/"; +const pathToJsonFiles = appNamespace + "localService/mockdata"; + +export default { + init(): Promise { + return new Promise((resolve, reject) => { + const manifestUrl = sap.ui.require.toUrl(appNamespace + "manifest.json"); + const manifestModel = new JSONModel(manifestUrl); + + manifestModel.attachRequestCompleted(() => { + const jsonFilesUrl = sap.ui.require.toUrl(pathToJsonFiles); + const dataSource = manifestModel.getProperty("/sap.app/dataSources/employeeRemote"); + const metadataUrl = sap.ui.require.toUrl(appNamespace + dataSource.settings.localUri); + + // create + const server = new MockServer({ + rootUri: dataSource.uri + }); + + // configure + MockServer.config({ + autoRespond: true, + autoRespondAfter: 500 + }); + + // simulate + server.simulate(metadataUrl, { + sMockdataBaseUrl: jsonFilesUrl + }); + + // start + server.start(); + + Log.info("Running the app with mock data"); + resolve(); + }); + + manifestModel.attachRequestFailed(() => { + const errorMessage = "Failed to load application manifest"; + + Log.error(errorMessage); + reject(new Error(errorMessage)); + }); + }); + } +}; diff --git a/packages/navigation/steps/01/webapp/manifest.json b/packages/navigation/steps/01/webapp/manifest.json new file mode 100644 index 000000000..38eefc1bb --- /dev/null +++ b/packages/navigation/steps/01/webapp/manifest.json @@ -0,0 +1,74 @@ +{ + "_version": "2.8.0", + "sap.app": { + "id": "ui5.tutorial.navigation", + "type": "application", + "i18n": { + "bundleUrl": "i18n/i18n.properties", + "supportedLocales": [ + "" + ], + "fallbackLocale": "" + }, + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "applicationVersion": { + "version": "1.0.0" + }, + "dataSources": { + "employeeRemote": { + "uri": "/here/goes/your/serviceUrl/", + "type": "OData", + "settings": { + "odataVersion": "2.0", + "localUri": "localService/metadata.xml" + } + } + } + }, + "sap.ui": { + "technology": "UI5", + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.ui5": { + "contentDensities": { + "compact": true, + "cozy": true + }, + "rootView": { + "viewName": "ui5.tutorial.navigation.view.App", + "type": "XML", + "id": "app" + }, + "dependencies": { + "minUI5Version": "1.148", + "libs": { + "sap.m": {}, + "sap.ui.core": {} + } + }, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "ui5.tutorial.navigation.i18n.i18n", + "supportedLocales": [ + "" + ], + "fallbackLocale": "", + "async": true + } + }, + "": { + "dataSource": "employeeRemote", + "settings": { + "defaultCountMode": "None" + } + } + } + } +} diff --git a/packages/navigation/steps/01/webapp/view/App.view.xml b/packages/navigation/steps/01/webapp/view/App.view.xml new file mode 100644 index 000000000..980d965d7 --- /dev/null +++ b/packages/navigation/steps/01/webapp/view/App.view.xml @@ -0,0 +1,17 @@ + + + + + + + + +... +``` + +We add the **headerContent** aggregation to the **Page** and insert the new **Button**. We add the **onResetDataSource** event handler to the **press** event. + +## webapp/i18n/i18n.properties + +```ini +... +# Toolbar +... +#XBUT: Button text for reset changes +resetChangesButtonText=Restart Tutorial +... +# Messages +... +#XMSG: Message for changes reverted +sourceResetSuccessMessage=All changes reverted back to start +``` + +We add the missing texts to the properties file. + +And now we are done! We built a simple application with user data from an OData V4 service. We can display, edit, create, and delete users. And we use OData V4 features such as batch groups and automatic type detection. + +**Related Information** + +[Bindings](../04_Essentials/bindings-54e0ddf.md "Bindings connect SAPUI5 view elements to model data, allowing changes in the model to be reflected in the view element and vice versa.") + +[OData Operations](../04_Essentials/odata-operations-b54f789.md "The OData V4 model supports OData operations (ActionImport, FunctionImport, bound Actions and bound Functions). Unbound parameters are limited to primitive values.") + +*** + +**Next:** [Step 9: List-Detail Scenario](../09/README.md) + +**Previous:** [Step 7: Delete](../07/README.md) diff --git a/packages/odatav4/steps/08/assets/Tutorial_OData_V4_Step_8_e518deb.png b/packages/odatav4/steps/08/assets/Tutorial_OData_V4_Step_8_e518deb.png new file mode 100644 index 000000000..bea20688b Binary files /dev/null and b/packages/odatav4/steps/08/assets/Tutorial_OData_V4_Step_8_e518deb.png differ diff --git a/packages/odatav4/steps/08/package.json b/packages/odatav4/steps/08/package.json new file mode 100644 index 000000000..7af4b236a --- /dev/null +++ b/packages/odatav4/steps/08/package.json @@ -0,0 +1,20 @@ +{ + "name": "ui5.tutorial.odatav4.step08", + "version": "1.0.0", + "author": "SAP SE", + "description": "OpenUI5 TypeScript Tutorial β€” OData V4: Step 8 β€” OData Operations", + "private": true, + "scripts": { + "start": "ui5 serve -o index.html", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } +} diff --git a/packages/odatav4/steps/08/tsconfig.json b/packages/odatav4/steps/08/tsconfig.json new file mode 100644 index 000000000..ae17cb7b2 --- /dev/null +++ b/packages/odatav4/steps/08/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "types": [ + "@openui5/types", + "@types/qunit" + ], + "skipLibCheck": true, + "allowJs": true, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "paths": { + "ui5/tutorial/odatav4/*": [ + "./webapp/*" + ] + }, + "strict": false, + "strictNullChecks": false + }, + "include": [ + "./webapp/**/*" + ] +} diff --git a/packages/odatav4/steps/08/ui5.yaml b/packages/odatav4/steps/08/ui5.yaml new file mode 100644 index 000000000..4861ad50b --- /dev/null +++ b/packages/odatav4/steps/08/ui5.yaml @@ -0,0 +1,25 @@ +specVersion: "4.0" +metadata: + name: "ui5.tutorial.odatav4" +type: application +framework: + name: OpenUI5 + version: "1.148.1" + libraries: + - name: sap.m + - name: sap.f + - name: sap.ui.layout + - name: sap.ui.core + - name: themelib_sap_horizon +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression + - name: ui5-middleware-serveframework + afterMiddleware: compression + - name: ui5-middleware-livereload + afterMiddleware: compression diff --git a/packages/odatav4/steps/08/webapp/Component.ts b/packages/odatav4/steps/08/webapp/Component.ts new file mode 100644 index 000000000..170cbb94c --- /dev/null +++ b/packages/odatav4/steps/08/webapp/Component.ts @@ -0,0 +1,27 @@ +// Component.ts β€” UI5 OData V4 Tutorial +import UIComponent from "sap/ui/core/UIComponent"; +import { createDeviceModel } from "./model/models"; + +/** + * @namespace ui5.tutorial.odatav4 + */ +export default class Component extends UIComponent { + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], + manifest: "json" + }; + + /** + * The component is initialized by UI5 automatically during the startup of the app and calls + * the init method once. + * @public + * @override + */ + init(): void { + // call the base component's init function + super.init(); + + // set the device model + this.setModel(createDeviceModel(), "device"); + } +} diff --git a/packages/odatav4/steps/08/webapp/controller/App.controller.ts b/packages/odatav4/steps/08/webapp/controller/App.controller.ts new file mode 100644 index 000000000..00c7e6b8d --- /dev/null +++ b/packages/odatav4/steps/08/webapp/controller/App.controller.ts @@ -0,0 +1,271 @@ +import Messaging from "sap/ui/core/Messaging"; +import Controller from "sap/ui/core/mvc/Controller"; +import MessageToast from "sap/m/MessageToast"; +import MessageBox from "sap/m/MessageBox"; +import Sorter from "sap/ui/model/Sorter"; +import Filter from "sap/ui/model/Filter"; +import FilterOperator from "sap/ui/model/FilterOperator"; +import FilterType from "sap/ui/model/FilterType"; +import JSONModel from "sap/ui/model/json/JSONModel"; +import ResourceModel from "sap/ui/model/resource/ResourceModel"; +import ResourceBundle from "sap/base/i18n/ResourceBundle"; +import Component from "sap/ui/core/Component"; +import List from "sap/m/List"; +import type ColumnListItem from "sap/m/ColumnListItem"; +import type SearchField from "sap/m/SearchField"; +import type ListBinding from "sap/ui/model/ListBinding"; +import type Event from "sap/ui/base/Event"; +import type Input from "sap/m/Input"; +import type Context from "sap/ui/model/odata/v4/Context"; +import type ODataModel from "sap/ui/model/odata/v4/ODataModel"; +import type ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding"; + +/** + * @namespace ui5.tutorial.odatav4.controller + */ +export default class App extends Controller { + + private _bTechnicalErrors = false; + + /** + * Hook for initializing the controller + */ + onInit(): void { + const messageModel = Messaging.getMessageModel(); + const messageModelBinding = messageModel.bindList("/", undefined, [], + new Filter("technical", FilterOperator.EQ, true)); + const viewModel = new JSONModel({ + busy: false, + hasUIChanges: false, + usernameEmpty: false, + order: 0 + }); + + this.getView().setModel(viewModel, "appView"); + this.getView().setModel(messageModel, "message"); + + messageModelBinding.attachChange(this.onMessageBindingChange, this); + this._bTechnicalErrors = false; + } + + /* =========================================================== */ + /* begin: event handlers */ + /* =========================================================== */ + + /** + * Create a new entry. + */ + onCreate(): void { + const list = this.byId("peopleList") as List; + const binding = list.getBinding("items") as ODataListBinding; + // Create a new entry through the table's list binding + const context = binding.create({ Age: "18" }); + + this._setUIChanges(true); + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", true); + + // Select and focus the table row that contains the newly created entry + list.getItems().some((item) => { + const columnItem = item as ColumnListItem; + if (columnItem.getBindingContext() === context) { + columnItem.focus(); + columnItem.setSelected(true); + return true; + } + return false; + }); + } + + /** + * Delete an entry. + */ + onDelete(): void { + const selected = (this.byId("peopleList") as List).getSelectedItem() as ColumnListItem | null; + + if (selected) { + const context = selected.getBindingContext() as Context; + const userName = context.getProperty("UserName") as string; + void context.delete().then(() => { + MessageToast.show(this._getText("deletionSuccessMessage", [userName])); + }, (error2: Error & { canceled?: boolean }) => { + this._setUIChanges(); + if (error2.canceled) { + MessageToast.show(this._getText("deletionRestoredMessage", [userName])); + return; + } + MessageBox.error(error2.message + ": " + userName); + }); + this._setUIChanges(); + } + } + + /** + * Lock UI when changing data in the input controls + */ + onInputChange(evt: Event): void { + if ((evt as unknown as { getParameter(n: string): unknown }).getParameter("escPressed")) { + this._setUIChanges(); + } else { + this._setUIChanges(true); + // Check if the username in the changed table row is empty and set the appView + // property accordingly + const ctx = (evt.getSource() as Input).getParent()?.getBindingContext(); + if (ctx && ctx.getProperty("UserName")) { + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", false); + } + } + } + + /** + * Refresh the data. + */ + onRefresh(): void { + const binding = (this.byId("peopleList") as List).getBinding("items") as ODataListBinding; + + if (binding.hasPendingChanges()) { + MessageBox.error(this._getText("refreshNotPossibleMessage")); + return; + } + binding.refresh(); + MessageToast.show(this._getText("refreshSuccessMessage")); + } + + /** + * Reset any unsaved changes. + */ + onResetChanges(): void { + ((this.byId("peopleList") as List).getBinding("items") as ODataListBinding).resetChanges(); + // If there were technical errors, cancelling changes resets them. + this._bTechnicalErrors = false; + this._setUIChanges(false); + } + + /** + * Reset the data source. + */ + onResetDataSource(): void { + const model = this.getView().getModel() as ODataModel; + const operation = model.bindContext("/ResetDataSource(...)"); + + void operation.invoke().then(() => { + model.refresh(); + MessageToast.show(this._getText("sourceResetSuccessMessage")); + }, (error2: Error) => { + MessageBox.error(error2.message); + }); + } + + /** + * Save changes to the source. + */ + onSave(): void { + const success = () => { + this._setBusy(false); + MessageToast.show(this._getText("changesSentMessage")); + this._setUIChanges(false); + }; + const error = (error2: Error) => { + this._setBusy(false); + this._setUIChanges(false); + MessageBox.error(error2.message); + }; + + this._setBusy(true); // Lock UI until submitBatch is resolved. + (this.getView().getModel() as ODataModel).submitBatch("peopleGroup").then(success, error); + // If there were technical errors, a new save resets them. + this._bTechnicalErrors = false; + } + + /** + * Search for the term in the search field. + */ + onSearch(): void { + const view = this.getView(); + const value = (view.byId("searchField") as SearchField).getValue(); + const filter = new Filter("LastName", FilterOperator.Contains, value); + + ((view.byId("peopleList") as List).getBinding("items") as ListBinding).filter(filter, FilterType.Application); + } + + /** + * Sort the table according to the last name. + * Cycles between the three sorting states "none", "ascending" and "descending" + */ + onSort(): void { + const view = this.getView(); + const states: (string | undefined)[] = [undefined, "asc", "desc"]; + const stateTextIds = ["sortNone", "sortAscending", "sortDescending"]; + let order = (view.getModel("appView") as JSONModel).getProperty("/order") as number; + + // Cycle between the states + order = (order + 1) % states.length; + const order2 = states[order]; + + (view.getModel("appView") as JSONModel).setProperty("/order", order); + ((view.byId("peopleList") as List).getBinding("items") as ListBinding) + .sort(order2 ? new Sorter("LastName", order2 === "desc") : []); + + const message = this._getText("sortMessage", [this._getText(stateTextIds[order])]); + MessageToast.show(message); + } + + onMessageBindingChange(event: Event): void { + const contexts = (event.getSource() as ListBinding).getContexts(); + let messageOpen = false; + + if (messageOpen || !contexts.length) { + return; + } + + // Extract and remove the technical messages + const messages = contexts.map((context: Context) => context.getObject()); + Messaging.removeMessages(messages); + + this._setUIChanges(true); + this._bTechnicalErrors = true; + MessageBox.error((messages[0] as { message: string }).message, { + id: "serviceErrorMessageBox", + onClose: () => { + messageOpen = false; + } + }); + + messageOpen = true; + } + + /* =========================================================== */ + /* end: event handlers */ + /* =========================================================== */ + + /** + * Convenience method for retrieving a translatable text. + */ + _getText(textId: string, args?: unknown[]): string { + const bundle = ((this.getOwnerComponent() as Component).getModel("i18n") as ResourceModel).getResourceBundle() as ResourceBundle; + return bundle.getText(textId, args as string[]); + } + + /** + * Set hasUIChanges flag in View Model + * @param bHasUIChanges - set or clear hasUIChanges; if undefined, the hasPendingChanges-function + * of the OdataV4 model determines the result + */ + _setUIChanges(hasUIChanges?: boolean): void { + if (this._bTechnicalErrors) { + // If there is currently a technical error, then force 'true'. + hasUIChanges = true; + } else if (hasUIChanges === undefined) { + hasUIChanges = (this.getView().getModel() as ODataModel).hasPendingChanges(); + } + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/hasUIChanges", hasUIChanges); + } + + /** + * Set busy flag in View Model + */ + _setBusy(isBusy: boolean): void { + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/busy", isBusy); + } +} diff --git a/packages/odatav4/steps/08/webapp/i18n/i18n.properties b/packages/odatav4/steps/08/webapp/i18n/i18n.properties new file mode 100644 index 000000000..cca336272 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/i18n/i18n.properties @@ -0,0 +1,78 @@ +# App Descriptor +#XTIT: Application name +appTitle=OData V4 - Step 8: OData Operations + +#YDES: Application description +appDescription=OData V4 Tutorial + +#XTIT: Page Title +peoplePageTitle=My Users + +# Toolbar +#XBUT: Button text for save +saveButtonText=Save + +#XBUT: Button text for reset changes +resetChangesButtonText=Restart Tutorial + +#XBUT: Button text for cancel +cancelButtonText=Cancel + +#XTOL: Tooltip for sort +sortButtonText=Sort by Last Name + +#XBUT: Button text for add user +createButtonText=Add User + +#XBUT: Button text for delete user +deleteButtonText=Delete User + +#XTOL: Tooltip for refresh data +refreshButtonText=Refresh Data + +#XTXT: Placeholder text for search field +searchFieldPlaceholder=Type in a last name + +# Table Area +#XFLD: Label for User Name +userNameLabelText=User Name + +#XFLD: Label for First Name +firstNameLabelText=First Name + +#XFLD: Label for Last Name +lastNameLabelText=Last Name + +#XFLD: Label for Age +ageLabelText=Age + +# Messages +#XMSG: Message for user changes sent to the service +changesSentMessage=User data sent to the server + +#XMSG: Message for user deleted +deletionSuccessMessage=User {0} deleted + +#XMSG: Message for user restored (undeleted) +deletionRestoredMessage=User {0} restored + +#XMSG: Message for changes reverted +sourceResetSuccessMessage=All changes reverted back to start + +#XMSG: Message for refresh failed +refreshNotPossibleMessage=Before refreshing, please save or revert your changes + +#XMSG: Message for refresh succeeded +refreshSuccessMessage=Data refreshed + +#MSG: Message for sorting +sortMessage=Users sorted by {0} + +#MSG: Suffix for sorting by LastName, ascending +sortAscending=last name, ascending + +#MSG: Suffix for sorting by LastName, descending +sortDescending=last name, descending + +#MSG: Suffix for no sorting +sortNone=the sequence on the server diff --git a/packages/odatav4/steps/08/webapp/index-cdn.html b/packages/odatav4/steps/08/webapp/index-cdn.html new file mode 100644 index 000000000..692229401 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/index-cdn.html @@ -0,0 +1,22 @@ + + + + + + OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/08/webapp/index.html b/packages/odatav4/steps/08/webapp/index.html new file mode 100644 index 000000000..b1e54667d --- /dev/null +++ b/packages/odatav4/steps/08/webapp/index.html @@ -0,0 +1,22 @@ + + + + + + OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/08/webapp/initMockServer.ts b/packages/odatav4/steps/08/webapp/initMockServer.ts new file mode 100644 index 000000000..0150138fe --- /dev/null +++ b/packages/odatav4/steps/08/webapp/initMockServer.ts @@ -0,0 +1,11 @@ +// initMockServer.ts β€” bootstraps the mock server before the component starts +import MessageBox from "sap/m/MessageBox"; +import mockserver from "./localService/mockserver"; + +// initialize the mock server +mockserver.init().catch((oError: Error) => { + MessageBox.error(oError.message); +}).finally(() => { + // initialize the embedded component on the HTML page + void import("sap/ui/core/ComponentSupport"); +}); diff --git a/packages/odatav4/steps/08/webapp/localService/metadata.xml b/packages/odatav4/steps/08/webapp/localService/metadata.xml new file mode 100644 index 000000000..fe46ab7a5 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/localService/metadata.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Name + + + + + + + + + + + + + diff --git a/packages/odatav4/steps/08/webapp/localService/mockdata/people.json b/packages/odatav4/steps/08/webapp/localService/mockdata/people.json new file mode 100644 index 000000000..758030eaa --- /dev/null +++ b/packages/odatav4/steps/08/webapp/localService/mockdata/people.json @@ -0,0 +1,399 @@ +{ + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(id))/$metadata#People(Age,FirstName,LastName,UserName,Friends,BestFriend,HomeAddress)", + "value": [ + { + "Age": 23, + "FirstName": "Angel", + "LastName": "Huffman", + "UserName": "angelhuffman", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "clydeguess", + "HomeAddress": { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + }, + { + "Age": 44, + "FirstName": "Clyde", + "LastName": "Guess", + "UserName": "clydeguess", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "angelhuffman", + "HomeAddress": { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 19, + "FirstName": "Elaine", + "LastName": "Stewart", + "UserName": "elainestewart", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "genevievereeves", + "HomeAddress": { + "Address": "308 Negra Arroyo Ln.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 37, + "FirstName": "Genevieve", + "LastName": "Reeves", + "UserName": "genevievereeves", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "elainestewart", + "HomeAddress": { + "Address": "89 Jefferson Way Suite 2", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "WA" + } + } + }, + { + "Age": 25, + "FirstName": "Georgina", + "LastName": "Barlow", + "UserName": "georginabarlow", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "javieralfred", + "HomeAddress": { + "Address": "31 Spooner Street", + "City": { + "Name": "Quahog", + "CountryRegion": "United States", + "Region": "RI" + } + } + }, + { + "Age": 19, + "FirstName": "Javier", + "LastName": "Alfred", + "UserName": "javieralfred", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "georginabarlow", + "HomeAddress": { + "Address": "55 Grizzly Peak Rd.", + "City": { + "Name": "Butte", + "CountryRegion": "United States", + "Region": "MT" + } + } + }, + { + "Age": 26, + "FirstName": "Joni", + "LastName": "Rosales", + "UserName": "jonirosales", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "keithpinckney", + "HomeAddress": { + "Address": "742 Evergreen Terrace", + "City": { + "Name": "Springfield", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 41, + "FirstName": "Keith", + "LastName": "Pinckney", + "UserName": "keithpinckney", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "jonirosales", + "HomeAddress": { + "Address": "1600 Pennsylvania Avenue NW", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 30, + "FirstName": "Krista", + "LastName": "Kemp", + "UserName": "kristakemp", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "laurelosborn", + "HomeAddress": { + "Address": "Statue of Liberty", + "City": { + "Name": "New York", + "CountryRegion": "United States", + "Region": "NY" + } + } + }, + { + "Age": 29, + "FirstName": "Laurel", + "LastName": "Osborn", + "UserName": "laurelosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "kristakemp", + "HomeAddress": { + "Address": "4059 Mt Lee Dr. Hollywood", + "City": { + "Name": "Los Angelos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 53, + "FirstName": "Marshall", + "LastName": "Garay", + "UserName": "marshallgaray", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "ronaldmundy", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 66, + "FirstName": "Ronald", + "LastName": "Mundy", + "UserName": "ronaldmundy", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "marshallgaray", + "HomeAddress": { + "Address": "89 Chiaroscuro Rd.", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 51, + "FirstName": "Russell", + "LastName": "Whyte", + "UserName": "russellwhyte", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "ryantheriault", + "HomeAddress": { + "Address": "Tony Stark Mansion, Point Dume", + "City": { + "Name": "Malibu", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 57, + "FirstName": "Ryan", + "LastName": "Theriault", + "UserName": "ryantheriault", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "russellwhyte", + "HomeAddress": { + "Address": "2311 N. Los Robles Ave. Apt 4A", + "City": { + "Name": "Pasadena", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 34, + "FirstName": "Sallie", + "LastName": "Sampson", + "UserName": "salliesampson", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "sandyosborn", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 18, + "FirstName": "Sandy", + "LastName": "Osborn", + "UserName": "sandyosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "salliesampson", + "HomeAddress": { + "Address": "Grove Street, Ganton", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 24, + "FirstName": "Scott", + "LastName": "Ketchum", + "UserName": "scottketchum", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "ursulabright", + "HomeAddress": { + "Address": "Portola Drive, Rockford Hills", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 31, + "FirstName": "Ursula", + "LastName": "Bright", + "UserName": "ursulabright", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "scottketchum", + "HomeAddress": { + "Address": "First St. SE", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 40, + "FirstName": "Vincent", + "LastName": "Calabrese", + "UserName": "vincentcalabrese", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "willieashmore", + "HomeAddress": { + "Address": "4 Privet Drive", + "City": { + "Name": "Little Whinging", + "CountryRegion": "Great Britain", + "Region": "SRY" + } + } + }, + { + "Age": 45, + "FirstName": "Willie", + "LastName": "Ashmore", + "UserName": "willieashmore", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "vincentcalabrese", + "HomeAddress": { + "Address": "124 Conch St.", + "City": { + "Name": "Bikini Bottom", + "CountryRegion": "Pacific Ocean", + "Region": "N/D" + } + } + } + ] +} diff --git a/packages/odatav4/steps/08/webapp/localService/mockserver.ts b/packages/odatav4/steps/08/webapp/localService/mockserver.ts new file mode 100644 index 000000000..7ca0307f4 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/localService/mockserver.ts @@ -0,0 +1,782 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ +import JSONModel from "sap/ui/model/json/JSONModel"; +import Log from "sap/base/Log"; + +// Pull sinon from the UI5 third-party shim. The shim has no TS typings; +// we treat the imported value as a structural any so the mock implementation +// stays close to the original JavaScript. +// eslint-disable-next-line @typescript-eslint/no-require-imports +declare const sap: any; + +interface MockUser { + UserName: string; + FirstName?: string; + LastName?: string; + Age?: number; + Friends?: string[]; + BestFriend?: string; + [key: string]: unknown; +} + +interface MockXhr { + method: string; + url: string; + requestBody?: string; + respond?: (status: number, headers: Record, body: string | null) => void; +} + +type MockResponse = [number, Record, string | null] | [number, Record]; + +// sinon is provided by the "sap/ui/thirdparty/sinon" module β€” see the require below +let sinon: any; +let sandbox: any; +let users: MockUser[]; // The array that holds the cached user data +let metadata: string; // The string that holds the cached mock service metadata +const namespace = "ui5/tutorial/odatav4"; +// Component for writing logs into the console +const logComponent = "ui5.tutorial.odatav4.mockserver"; +const rBaseUrl = /services.odata.org\/TripPinRESTierService/; + +/** + * Returns the base URL from a given URL. + * @param url - the complete URL + * @returns the base URL + */ +function getBaseUrl(url: string): string { + const matches = url.match(/http.+\(S\(.+\)\)\//); + + if (!Array.isArray(matches) || matches.length < 1) { + throw new Error("Could not find a base URL in " + url); + } + + return matches[0]; +} + +/** + * Looks for a user with a given user name and returns its index in the user array. + * @param userName - the user name to look for. + * @returns index of that user in the array, or -1 if the user was not found. + */ +function findUserIndex(userName: string): number { + for (let i = 0; i < users.length; i++) { + if (users[i].UserName === userName) { + return i; + } + } + return -1; +} + +/** + * Retrieves any user data from a given http request body. + * @param body - the http request body. + * @returns the parsed user data. + */ +function getUserDataFromRequestBody(body: string): MockUser { + const matches = body.match(/({.+})/); + + if (!Array.isArray(matches) || matches.length !== 2) { + throw new Error("Could not find any user data in " + body); + } + return JSON.parse(matches[1]) as MockUser; +} + +/** + * Retrieves a user name from a given request URL. + * @param url - the request URL. + * @returns the user name or undefined if no user was found. + */ +function getUserKeyFromUrl(url: string): string | undefined { + const matches = url.match(/People\('(.*)'\)/); + return matches ? matches[1] : undefined; +} + +/** + * Checks if a given UserName is unique or already used + * @param userName - the UserName to be checked + * @returns True if the UserName is unique (not used), false otherwise + */ +function isUnique(userName: string): boolean { + return findUserIndex(userName) < 0; +} + +/** + * Returns a proper HTTP response body for "duplicate key" errors + * @param key - the duplicate key + * @returns the proper response body + */ +function duplicateKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "409", + message: "There is already a user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function invalidKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "404", + message: "There is no user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function getSuccessResponse(responseBody: string): MockResponse { + return [ + 200, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Reads and caches the fake service metadata and data from their + * respective files. + * @returns a promise that is resolved when the data is loaded + */ +function readData(): Promise { + const metadataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/metadata.xml"); + const request = new XMLHttpRequest(); + + request.onload = function () { + // 404 is not an error for XMLHttpRequest so we need to handle it here + if (request.status === 404) { + const error = "resource " + resourcePath + " not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + metadata = this.responseText; + resolve(); + }; + request.onerror = function () { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }; + request.open("GET", resourcePath); + request.send(); + }); + + const mockDataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/mockdata/people.json"); + const mockDataModel = new JSONModel(resourcePath); + + mockDataModel.attachRequestCompleted(function (this: JSONModel, event: any) { + // 404 is not an error for JSONModel so we need to handle it here + if (event.getParameter("errorobject") + && event.getParameter("errorobject").statusCode === 404) { + const error = "resource '" + resourcePath + "' not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + users = (this.getData() as { value: MockUser[] }).value; + resolve(); + }); + + mockDataModel.attachRequestFailed(() => { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }); + }); + + return Promise.all([metadataPromise, mockDataPromise]); +} + +/** + * Reduces a given result set by applying the OData URL parameters 'skip' and 'top' to it. + * Does NOT change the given result set but returns a new array. + */ +function applySkipTop(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const reducedUsers = [...resultSet]; + const matches = xhr.url.match(/\$skip=(\d+)&\$top=(\d+)/); + + if (Array.isArray(matches) && matches.length >= 3) { + const skip = parseInt(matches[1], 10); + const top = parseInt(matches[2], 10); + return resultSet.slice(skip, skip + top); + } + + return reducedUsers; +} + +/** + * Sorts a given result set by applying the OData URL parameter 'orderby'. + * Does NOT change the given result set but returns a new array. + */ +function applySort(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const sortedUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$orderby=(\w*)(?:%20(\w*))?/); + + if (!Array.isArray(matches) || matches.length < 2) { + return sortedUsers; + } + const fieldName = matches[1]; + const direction = matches[2] || "asc"; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + sortedUsers.sort((a, b) => { + const nameA = (a.LastName || "").toUpperCase(); + const nameB = (b.LastName || "").toUpperCase(); + const asc = direction === "asc"; + + if (nameA < nameB) { + return asc ? -1 : 1; + } + if (nameA > nameB) { + return asc ? 1 : -1; + } + return 0; + }); + + return sortedUsers; +} + +/** + * Filters a given result set by applying the OData URL parameter 'filter'. + * Does NOT change the given result set but returns a new array. + */ +function applyFilter(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + let filteredUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$filter=.*\((.*),'(.*)'\)/); + + // If the request contains a filter command, apply the filter + if (Array.isArray(matches) && matches.length >= 3) { + const fieldName = matches[1]; + const query = matches[2]; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + filteredUsers = users.filter((user) => (user.LastName || "").indexOf(query) !== -1); + } + + return filteredUsers; +} + +/** + * Handles GET requests for metadata. + */ +function handleGetMetadataRequests(): MockResponse { + return [ + 200, + { + "Content-Type": "application/xml", + "odata-version": "4.0" + }, metadata + ]; +} + +/** + * Handles GET requests for a pure user count and returns a fitting response. + */ +function handleGetCountRequests(): MockResponse { + return getSuccessResponse(users.length.toString()); +} + +/** + * Handles GET requests for user data and returns a fitting response. + */ +function handleGetUserRequests(xhr: MockXhr, _bCount: boolean): MockResponse { + let count: number; + let expand: RegExpMatchArray | string[] | null; + let expand2: string; + let index: number; + let key: string | undefined; + let response: { "@odata.count"?: number; value: MockUser[] } | MockUser | null; + let responseBody: string; + let result: MockUser[]; + let select: RegExpMatchArray | string[] | null; + let select2: string; + let subSelects: string[][] = []; + let i: number; + + // Get expand parameter + expand = xhr.url.match(/\$expand=([^&]+)/); + + // Sort out expand parameter values + subSelects in brackets + if (expand) { + expand2 = expand[0]; + expand2 = expand2.substring(8); + + // Sort out subselects (e.g. BestFriend($select=Age,UserName),Friend) + const subSelectMatches = expand2.match(/\([^)]*\)/g) || []; + subSelects = subSelectMatches.map((s) => s.replace(/\(\$select=/, "").replace(/\)/, "").split(",")); + expand2 = expand2.replace(/\([^)]*\)/g, ""); + expand = expand2.split(","); + } + + // Get select parameter + select = xhr.url.match(/[^(]\$select=([\w|,]+)/); + + // Sort out select parameter values + if (Array.isArray(select)) { + select2 = select[0]; + select2 = select2.replace(/&/, "").replace(/\?/, "").substring(8); + select = select2.split(","); + } + + // Check if an individual user or a user range is requested + key = getUserKeyFromUrl(xhr.url); + if (key) { + index = findUserIndex(key); + + if (/People\(.+\)\/Friends/.test(xhr.url)) { + // ownRequest for friends + response = { value: createFriendsArray(users[index].Friends, select as string[]) }; + } else { + // specific user was requested + response = getUserObject(index, select as string[], expand as string[], subSelects); + } + + if (index > -1) { + responseBody = JSON.stringify(response); + return getSuccessResponse(responseBody); + } + responseBody = invalidKeyError(key); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // all users requested + result = applyFilter(xhr, users); + count = result.length; // the total no. of people found, after filtering + result = applySort(xhr, result); + result = applySkipTop(xhr, result); + + // generate sResponse + const finalResponse: { "@odata.count": number; value: MockUser[] } = { "@odata.count": count, value: [] }; + + result.forEach((user) => { + const userIndex = findUserIndex(user.UserName); + + finalResponse.value.push(getUserObject(userIndex, select as string[], expand as string[], subSelects) as MockUser); + }); + + responseBody = JSON.stringify(finalResponse); + + return getSuccessResponse(responseBody); +} + +/** + * Returns a specific user in the aUsers array. + */ +function getUserByIndex(index: number, properties: string[]): MockUser | null { + const helper: MockUser = { UserName: "" }; + const user = users[index]; + + if (user) { + properties.forEach((selectProperty) => { + helper[selectProperty] = user[selectProperty]; + }); + + return helper; + } + return null; +} + +/** + * Returns the user with iIndex in the aUsers array with all its information + */ +function getUserObject(index: number, select: string[], expand: string[] | null | undefined, subSelects: string[][]): MockUser | null { + let bestFriend: string | undefined; + let friendIndex: number; + let friends: string[] | undefined; + let object: MockUser | null; + let user: MockUser; + let i: number; + + object = getUserByIndex(index, select); + if (expand && object) { + user = users[index]; + for (i = 0; i < expand.length; i++) { + switch (expand[i]) { + case "Friends": + friends = user.Friends; + object.Friends = createFriendsArray(friends, subSelects[i]) as unknown as string[]; + break; + case "BestFriend": + bestFriend = user.BestFriend; + friendIndex = findUserIndex(bestFriend || ""); + object.BestFriend = getUserByIndex(friendIndex, subSelects[i]) as unknown as string; + break; + default: + break; + } + } + } + return object; +} + +/** + * creates array of friends for a given user + */ +function createFriendsArray(friends: string[] | undefined, subSelects: string[]): MockUser[] { + let array: (MockUser | null)[] = []; + + if (friends) { + friends.forEach((friend) => { + const friendIndex = findUserIndex(friend); + array.push(getUserByIndex(friendIndex, subSelects)); + }); + + array = array.filter((element) => element !== null); + } + + return array as MockUser[]; +} + +/** + * Handles PATCH requests for users and returns a fitting response. + */ +function handlePatchUserRequests(xhr: MockXhr): MockResponse { + // Get the key of the person to change + const key = getUserKeyFromUrl(xhr.url); + + // Get the list of changes + const changes = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if the UserName is changed to a duplicate. + // If the UserName is "changed" to its current value, that is not an error. + if (Object.prototype.hasOwnProperty.call(changes, "UserName") + && changes.UserName !== key + && !isUnique(changes.UserName)) { + // Error + const responseBody = duplicateKeyError(changes.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // No error: make the change(s) + const user = users[findUserIndex(key || "")]; + for (const fieldName in changes) { + if (Object.prototype.hasOwnProperty.call(changes, fieldName)) { + user[fieldName] = changes[fieldName]; + } + } + + // The response to PATCH requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles DELETE requests for users and returns a fitting response. + */ +function handleDeleteUserRequests(xhr: MockXhr): MockResponse { + const key = getUserKeyFromUrl(xhr.url); + users.splice(findUserIndex(key || ""), 1); + + // The response to DELETE requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles POST requests for users and returns a fitting response. + */ +function handlePostUserRequests(xhr: MockXhr): MockResponse { + const user = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if that user already exists + if (isUnique(user.UserName)) { + users.push(user); + + let responseBody = '{"@odata.context": "' + getBaseUrl(xhr.url) + + '$metadata#People/$entity",'; + responseBody += JSON.stringify(user).slice(1); + + // The response to POST requests is http 201 (Created) + return [ + 201, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; + } + // Error + const responseBody = duplicateKeyError(user.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; +} + +/** + * Handles POST requests for resetting the data and returns a fitting response. + */ +function handleResetDataRequest(): MockResponse { + void readData(); + + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Builds a response to direct (= non-batch) requests. + * Supports GET, PATCH, DELETE and POST requests. + */ +function handleDirectRequest(xhr: MockXhr): MockResponse | undefined { + let response2: MockResponse | undefined; + + switch (xhr.method) { + case "GET": + if (/\$metadata/.test(xhr.url)) { + response2 = handleGetMetadataRequests(); + } else if (/\/\$count/.test(xhr.url)) { + response2 = handleGetCountRequests(); + } else if (/People.*\?/.test(xhr.url)) { + response2 = handleGetUserRequests(xhr, /\$count=true/.test(xhr.url)); + } + break; + case "PATCH": + if (/People/.test(xhr.url)) { + response2 = handlePatchUserRequests(xhr); + } + break; + case "POST": + if (/People/.test(xhr.url)) { + response2 = handlePostUserRequests(xhr); + } else if (/ResetDataSource/.test(xhr.url)) { + response2 = handleResetDataRequest(); + } + break; + case "DELETE": + if (/People/.test(xhr.url)) { + response2 = handleDeleteUserRequests(xhr); + } + break; + case "HEAD": + response2 = [204, {}]; + break; + default: + break; + } + + return response2; +} + +/** + * Builds a response to batch requests. + * Unwraps batch request, gets a response for each individual part and + * constructs a fitting batch response. + */ +function handleBatchRequest(xhr: MockXhr): MockResponse { + let responseBody = ""; + const outerBoundary = (xhr.requestBody || "").match(/(.*)/)![1]; // First line of the body + let innerBoundary: string | undefined; + let partBoundary: string; + // The individual requests + const outerParts = (xhr.requestBody || "").split(outerBoundary).slice(1, -1); + let parts: string[]; + let header: string; + + const matches = outerParts[0].match(/multipart\/mixed;boundary=(.+)/); + // If this request has several change sets, then we need to handle the inner and outer + // boundaries (change sets have an additional boundary) + if (matches && matches.length > 0) { + innerBoundary = matches[1]; + parts = outerParts[0].split("--" + innerBoundary).slice(1, -1); + } else { + parts = outerParts; + } + + // If this request has several change sets, then the response must start with the outer + // boundary and content header + if (innerBoundary) { + partBoundary = "--" + innerBoundary; + responseBody += outerBoundary + "\r\n" + + "Content-Type: multipart/mixed; boundary=" + innerBoundary + "\r\n\r\n"; + } else { + partBoundary = outerBoundary; + } + + parts.forEach((part, index) => { + // Construct the batch response body out of the single batch request parts. + const matches0 = part.match(/(GET|DELETE|PATCH|POST) (\S+)(?:.|\r?\n)+\r?\n(.*)\r?\n$/)!; + const partResponse = handleDirectRequest({ + method: matches0[1], + url: getBaseUrl(xhr.url) + matches0[2], + requestBody: matches0[3] + })!; + + responseBody += partBoundary + "\r\n" + + "Content-Type: application/http\r\n"; + // If there are several change sets, we need to add a Content ID header + if (innerBoundary) { + responseBody += "Content-ID:" + index + ".0\r\n"; + } + responseBody += "\r\nHttp/1.1 " + partResponse[0] + "\r\n"; + // Add any headers from the request - unless this response is 204 (no content) + if (partResponse[1] && partResponse[0] !== 204) { + for (header in partResponse[1]) { + if (Object.prototype.hasOwnProperty.call(partResponse[1], header)) { + responseBody += header + ": " + partResponse[1][header] + "\r\n"; + } + } + } + responseBody += "\r\n"; + + if (partResponse[2]) { + responseBody += partResponse[2]; + } + responseBody += "\r\n"; + }); + + // Check if we need to add the inner boundary again at the end + if (innerBoundary) { + responseBody += "--" + innerBoundary + "--\r\n"; + } + // Add a final boundary to the batch response body + responseBody += outerBoundary + "--"; + + // Build the final batch response + return [ + 200, + { + "Content-Type": "multipart/mixed;boundary=" + outerBoundary.slice(2), + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Handles any type of intercepted request and sends a fake response. + * Logs the request and response to the console. + * Manages batch requests. + */ +function handleAllRequests(xhr: MockXhr): void { + let response2: MockResponse | undefined; + + // Log the request + Log.info( + "Mockserver: Received " + xhr.method + " request to URL " + xhr.url, + (xhr.requestBody ? "Request body is:\n" + xhr.requestBody : "No request body.") + + "\n", + logComponent + ); + + if (xhr.method === "POST" && /\$batch/.test(xhr.url)) { + response2 = handleBatchRequest(xhr); + } else { + response2 = handleDirectRequest(xhr); + } + + if (xhr.respond && response2) { + xhr.respond(response2[0], response2[1], response2[2] || null); + } + + // Log the response + if (response2) { + Log.info( + "Mockserver: Sent response with return code " + response2[0], + ("Response headers: " + JSON.stringify(response2[1]) + "\n\nResponse body:\n" + + (response2[2] || "")) + "\n", + logComponent + ); + } +} + +export default { + + /** + * Creates a Sinon fake service, intercepting all http requests to + * the URL defined in variable rBaseUrl above. + * @returns a promise that is resolved when the mock server is started + */ + init(): Promise { + // Load sinon lazily from the UI5 third-party shim + return new Promise((resolve, reject) => { + sap.ui.require(["sap/ui/thirdparty/sinon"], (lazySinon: any) => { + sinon = lazySinon; + sandbox = sinon.sandbox.create(); + + // Read the mock data + readData().then(() => { + // Initialize the sinon fake server + sandbox.useFakeServer(); + // Make sure that requests are responded to automatically. Otherwise we would need + // to do that manually. + sandbox.server.autoRespond = true; + + // Register the requests for which responses should be faked. + sandbox.server.respondWith(rBaseUrl, handleAllRequests); + + // Apply a filter to the fake XmlHttpRequest. + // Otherwise, ALL requests (e.g. for the component, views etc.) would be + // intercepted. + sinon.FakeXMLHttpRequest.useFilters = true; + sinon.FakeXMLHttpRequest.addFilter((_sMethod: string, url: string) => { + // If the filter returns true, the request will NOT be faked. + // We only want to fake requests that go to the intended service. + return !rBaseUrl.test(url); + }); + + // Set the logging level for console entries from the mock server + Log.setLevel(Log.Level.INFO, logComponent); + + Log.info("Running the app with mock data", logComponent); + resolve(undefined); + }, reject); + }); + }); + }, + + /** + * Stops the request interception and deletes the Sinon fake server. + */ + stop(): void { + if (sinon) { + sinon.FakeXMLHttpRequest.filters = []; + sinon.FakeXMLHttpRequest.useFilters = false; + } + if (sandbox) { + sandbox.restore(); + sandbox = null; + } + } +}; diff --git a/packages/odatav4/steps/08/webapp/manifest.json b/packages/odatav4/steps/08/webapp/manifest.json new file mode 100644 index 000000000..737f368da --- /dev/null +++ b/packages/odatav4/steps/08/webapp/manifest.json @@ -0,0 +1,67 @@ +{ + "_version": "2.8.0", + "sap.app": { + "id": "ui5.tutorial.odatav4", + "type": "application", + "i18n": { + "supportedLocales": [ + "" + ], + "fallbackLocale": "", + "bundleName": "ui5.tutorial.odatav4.i18n.i18n" + }, + "applicationVersion": { + "version": "1.0.0" + }, + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "dataSources": { + "default": { + "uri": "https://services.odata.org/TripPinRESTierService/(S(id))/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + } + }, + "sap.ui": { + "technology": "UI5" + }, + "sap.ui5": { + "rootView": { + "viewName": "ui5.tutorial.odatav4.view.App", + "type": "XML", + "id": "appView" + }, + "dependencies": { + "minUI5Version": "1.148", + "libs": { + "sap.m": {}, + "sap.ui.core": {} + } + }, + "handleValidation": true, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "ui5.tutorial.odatav4.i18n.i18n", + "supportedLocales": [ + "" + ], + "fallbackLocale": "" + } + }, + "": { + "dataSource": "default", + "preload": true, + "settings": { + "autoExpandSelect": true, + "earlyRequests": true, + "operationMode": "Server" + } + } + } + } +} diff --git a/packages/odatav4/steps/08/webapp/model/models.ts b/packages/odatav4/steps/08/webapp/model/models.ts new file mode 100644 index 000000000..5fdcff4a3 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/model/models.ts @@ -0,0 +1,10 @@ +// models.ts β€” central application models +import JSONModel from "sap/ui/model/json/JSONModel"; +import Device from "sap/ui/Device"; + +export function createDeviceModel(): JSONModel { + const model = new JSONModel(Device); + + model.setDefaultBindingMode("OneWay"); + return model; +} diff --git a/packages/odatav4/steps/08/webapp/test/integration/AllJourneys.js b/packages/odatav4/steps/08/webapp/test/integration/AllJourneys.js new file mode 100644 index 000000000..9706f08ad --- /dev/null +++ b/packages/odatav4/steps/08/webapp/test/integration/AllJourneys.js @@ -0,0 +1,13 @@ +sap.ui.define([ + "sap/ui/test/Opa5", + "ui5/tutorial/odatav4/test/integration/arrangements/Startup", + "ui5/tutorial/odatav4/test/integration/TutorialJourney" +], function (Opa5, Startup) { + "use strict"; + + Opa5.extendConfig({ + arrangements : new Startup(), + viewNamespace : "ui5.tutorial.odatav4.view.", + autoWait : true + }); +}); diff --git a/packages/odatav4/steps/08/webapp/test/integration/TutorialJourney.js b/packages/odatav4/steps/08/webapp/test/integration/TutorialJourney.js new file mode 100644 index 000000000..095f8b4b9 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/test/integration/TutorialJourney.js @@ -0,0 +1,122 @@ +sap.ui.define([ + "sap/ui/test/opaQunit", + "ui5/tutorial/odatav4/test/integration/pages/Tutorial" +], function (opaTest) { + "use strict"; + + var iGrowingBy = 10, // Must equal the 'growingThreshold' setting of the table + iTotalUsers = 20; // Must equal the total number of users + + QUnit.module("Posts"); + + opaTest("Should see the paginated table with all users", function (Given, _When, Then) { + // Arrangements + Given.iStartMyApp(); + // Assertions + Then.onTheTutorialPage.theTableShouldHavePagination() + .and.theTableShouldShowUsers(iGrowingBy) + .and.theTableShouldShowTotalUsers(iTotalUsers); + }); + + opaTest("Should be able to load more users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnMoreData(); + // Assertions + Then.onTheTutorialPage.theTableShouldShowUsers(iGrowingBy * 2); + }); + + opaTest("Should be able to sort users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnSort(); + // Assertions + Then.onTheTutorialPage.theTableShouldStartWith("Alfred"); + }); + + opaTest("Should be able to start adding users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnAdd() + .and.iEnterSomeData("a"); + When.onTheTutorialPage.iPressOnAdd() + .and.iEnterSomeData("b"); + // Assertions + Then.onTheTutorialPage.thePageFooterShouldBeVisible(true) + .and.theTableToolbarItemsShouldBeEnabled(false) + .and.theTableShouldShowTotalUsers(iTotalUsers + 2); + }); + + opaTest("Should be able to save the new users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnSave(); + // Assertions + Then.onTheTutorialPage.theTableShouldStartWith("b") + .and.theTableShouldShowTotalUsers(iTotalUsers + 2) + .and.theTableToolbarItemsShouldBeEnabled(true) + .and.thePageFooterShouldBeVisible(false); + }); + + opaTest("Should be able to delete/undelete the new users", function (_Given, When, Then) { + // delete and undelete + When.onTheTutorialPage.iSelectUser("b") + .and.iPressOnDelete(); + Then.onTheTutorialPage.theTableShouldStartWith("a") + .and.theTableShouldShowTotalUsers(iTotalUsers + 1); + When.onTheTutorialPage.iSelectUser("a") + .and.iPressOnDelete(); + Then.onTheTutorialPage.theTableShouldStartWith("Alfred") + .and.theTableShouldShowTotalUsers(iTotalUsers); + When.onTheTutorialPage.iPressOnCancel(); + Then.onTheTutorialPage.theMessageToastShouldShow("deletionRestoredMessage", "b") + .and.theMessageToastShouldShow("deletionRestoredMessage", "a") + .and.theTableShouldStartWith("b") + .and.theTableShouldShowTotalUsers(iTotalUsers + 2); + // delete and save + When.onTheTutorialPage.iSelectUser("a") + .and.iPressOnDelete() + .and.iSelectUser("b") + .and.iPressOnDelete() + .and.iPressOnSave(); + Then.onTheTutorialPage.theMessageToastShouldShow("deletionSuccessMessage", "a") + .and.theMessageToastShouldShow("deletionSuccessMessage", "b") + .and.theTableShouldStartWith("Alfred") + .and.theTableShouldShowTotalUsers(iTotalUsers); + }); + + opaTest("Should be able to search for users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iSearchFor("Mundy"); + // Assertions + Then.onTheTutorialPage.theTableShouldShowUsers(1); + }); + + opaTest("Should be able to reset the search", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iSearchFor(""); + // Assertions + Then.onTheTutorialPage.theTableShouldShowUsers(10); + }); + + opaTest("Should see an error when trying to change a user name to an existing one", + function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iChangeAUserKey("javieralfred", "willieashmore") + .and.iPressOnSave(); + // Assertions + Then.onTheTutorialPage.iShouldSeeAServiceError() + .and.theTableToolbarItemsShouldBeEnabled(false) + .and.thePageFooterShouldBeVisible(true); + } + ); + + opaTest("Should be able to close the error and cancel the change", + function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iCloseTheServiceError() + .and.iPressOnCancel(); + // Assertions + Then.onTheTutorialPage.theTableToolbarItemsShouldBeEnabled(true) + .and.thePageFooterShouldBeVisible(false); + // Cleanup + Then.iTeardownMyApp(); + } + ); +}); diff --git a/packages/odatav4/steps/08/webapp/test/integration/arrangements/Startup.js b/packages/odatav4/steps/08/webapp/test/integration/arrangements/Startup.js new file mode 100644 index 000000000..296ab6ec9 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/test/integration/arrangements/Startup.js @@ -0,0 +1,24 @@ +sap.ui.define([ + "sap/ui/test/Opa5", + "ui5/tutorial/odatav4/localService/mockserver" +], function (Opa5, mockserver) { + "use strict"; + + return Opa5.extend("ui5.tutorial.odatav4.test.integration.arrangements.Startup", { + + iStartMyApp : function () { + // start the mock server + this.iWaitForPromise(mockserver.init()); + + // start the app UI component + this.iStartMyUIComponent({ + componentConfig : { + name : "ui5.tutorial.odatav4", + async : true + }, + autoWait : true, + timeout : 45 // BCP: 2270085466 + }); + } + }); +}); diff --git a/packages/odatav4/steps/08/webapp/test/integration/opaTests.qunit.html b/packages/odatav4/steps/08/webapp/test/integration/opaTests.qunit.html new file mode 100644 index 000000000..30e944e01 --- /dev/null +++ b/packages/odatav4/steps/08/webapp/test/integration/opaTests.qunit.html @@ -0,0 +1,27 @@ + + + + Integration tests for OData V4 Tutorial + + + + + + + + + + + + +
+
+ + diff --git a/packages/odatav4/steps/08/webapp/test/integration/opaTests.qunit.js b/packages/odatav4/steps/08/webapp/test/integration/opaTests.qunit.js new file mode 100644 index 000000000..908edf09e --- /dev/null +++ b/packages/odatav4/steps/08/webapp/test/integration/opaTests.qunit.js @@ -0,0 +1,11 @@ +QUnit.config.autostart = false; + +sap.ui.require([ + "sap/ui/core/Core", + "ui5/tutorial/odatav4/test/integration/AllJourneys" +], function (Core) { + "use strict"; + Core.ready().then(function () { + QUnit.start(); + }); +}); diff --git a/packages/odatav4/steps/08/webapp/test/integration/pages/Tutorial.js b/packages/odatav4/steps/08/webapp/test/integration/pages/Tutorial.js new file mode 100644 index 000000000..29e33dacd --- /dev/null +++ b/packages/odatav4/steps/08/webapp/test/integration/pages/Tutorial.js @@ -0,0 +1,300 @@ +sap.ui.define([ + "sap/ui/test/Opa5", + "sap/ui/test/matchers/AggregationLengthEquals", + "sap/ui/test/matchers/PropertyStrictEquals", + "sap/ui/test/matchers/BindingPath", + "sap/ui/test/actions/Press", + "sap/ui/test/actions/EnterText" +], function (Opa5, AggregationLengthEquals, PropertyStrictEquals, BindingPath, Press, EnterText) { + "use strict"; + + var sViewName = "App", + sTableId = "peopleList"; + + function getListBinding(oTable) { + return oTable.getBinding("items"); + } + + function getFirstTableEntry(oTable) { + return getListBinding(oTable).getCurrentContexts()[0]; + } + + Opa5.createPageObjects({ + onTheTutorialPage : { + actions : { + iPressOnMoreData : function () { + // Press action hits the "more" trigger on a table + return this.waitFor({ + id : sTableId, + viewName : sViewName, + actions : new Press(), + errorMessage : "Table not found or it does not have a 'See More' trigger" + }); + }, + + iPressOnSort : function () { + return this.waitFor({ + id : "sortUsersButton", + viewName : sViewName, + actions : new Press(), + errorMessage : "Could not find the 'Sort' button" + }); + }, + + iPressOnAdd : function () { + return this.waitFor({ + id : "addUserButton", + viewName : sViewName, + actions : new Press(), + errorMessage : "Could not find the 'Add' button" + }); + }, + + iPressOnDelete : function () { + return this.waitFor({ + id : "deleteUserButton", + viewName : sViewName, + actions : new Press(), + errorMessage : "Could not find the 'Delete' button" + }); + }, + + iPressOnSave : function () { + return this.waitFor({ + id : "saveButton", + viewName : sViewName, + actions : new Press(), + errorMessage : "Could not find the 'Save' button" + }); + }, + + iPressOnCancel : function () { + return this.waitFor({ + id : "doneButton", + viewName : sViewName, + actions : new Press(), + errorMessage : "Could not find the 'Cancel' button" + }); + }, + + iEnterSomeData : function (sValue) { + return this.waitFor({ + controlType : "sap.m.Input", + viewName : sViewName, + matchers : [ + // Find the input fields for the new entry + function (oControl) { + return oControl.getBindingContext().getIndex() === 0; + }, + // Keep only empty input fields + function (oItem) { + return !oItem.getValue(); + } + ], + actions : new EnterText({ + text : sValue + }), + errorMessage : "Could not find Input controls to enter data" + }); + }, + + iSearchFor : function (sSearchString) { + return this.waitFor({ + id : "searchField", + viewName : sViewName, + actions : new EnterText({ + text : sSearchString + }), + errorMessage : "SearchField was not found" + }); + }, + + iSelectUser : function (sKey) { + return this.waitFor({ + controlType : "sap.m.ColumnListItem", + viewName : sViewName, + matchers : new BindingPath({ + path : "/People('" + sKey + "')" + }), + actions : function (oItem) { + oItem.setSelected(true); + }, + errorMessage : "Could not find a user with the key '" + sKey + "'" + }); + }, + + iChangeAUserKey : function (sOldKey, sNewKey) { + return this.waitFor({ + controlType : "sap.m.Input", + viewName : sViewName, + matchers : new PropertyStrictEquals({ + name : "value", + value : sOldKey + }), + actions : new EnterText({ + text : sNewKey + }), + errorMessage : "Could not find a user with the key '" + sOldKey + "'" + }); + }, + + iCloseTheServiceError : function () { + return this.waitFor({ + id : "serviceErrorMessageBox", + success : function () { + this.waitFor({ + controlType : "sap.m.Button", + searchOpenDialogs : true, + // The error MessageBox has only one button, which closes the box + actions : new Press(), + errorMessage : "Cannot find the 'Close' button" + }); + }, + errorMessage : "Could not see the service error dialog" + }); + } + }, + assertions : { + theTableShouldHavePagination : function () { + return this.waitFor({ + id : sTableId, + viewName : sViewName, + matchers : new PropertyStrictEquals({ + name : "growing", + value : true + }), + success : function () { + Opa5.assert.ok(true, "The table is paginated"); + }, + errorMessage : "Table not found or it is not paginated" + }); + }, + + theTableShouldShowUsers : function (iNumber) { + return this.waitFor({ + id : sTableId, + viewName : sViewName, + matchers : new AggregationLengthEquals({ + name : "items", + length : iNumber + }), + success : function () { + Opa5.assert.ok(true, "The table has " + + iNumber + " items"); + }, + errorMessage : "Table not found or it does not have " + + iNumber + " entries" + }); + }, + + theTableShouldShowTotalUsers : function (iNumber) { + return this.waitFor({ + id : sTableId, + viewName : sViewName, + matchers : function (oTable) { + var oListBinding = getListBinding(oTable); + + return oListBinding && oListBinding.getLength() === iNumber; + }, + success : function () { + Opa5.assert.ok(true, "The table shows a total of " + iNumber + + " users"); + }, + errorMessage : "Table not found or it does not show " + iNumber + + " total users" + }); + }, + + theTableShouldStartWith : function (sLastName) { + return this.waitFor({ + id : sTableId, + viewName : sViewName, + matchers : function (oTable) { + var oFirstItem = getFirstTableEntry(oTable); + + return oFirstItem && oFirstItem.getProperty("LastName") === sLastName; + }, + success : function () { + Opa5.assert.ok(true, "The table is sorted correctly"); + }, + errorMessage : "Table not found or it is not sorted correctl." + }); + }, + + thePageFooterShouldBeVisible : function (bVisible) { + var sDesiredState = bVisible ? "visible" : "invisible"; + + return this.waitFor({ + controlType : "sap.m.Toolbar", + viewName : sViewName, + visible : false, + matchers : new PropertyStrictEquals({ + name : "visible", + value : bVisible + }), + success : function () { + Opa5.assert.ok(true, "The toolbar is " + sDesiredState); + }, + errorMessage : "Toolbar not found or is not " + sDesiredState + }); + }, + + theTableToolbarItemsShouldBeEnabled : function (bEnabled) { + var sDesiredState = bEnabled ? "enabled" : "disabled"; + + return this.waitFor({ + id : /searchField$|refreshUsersButton$|sortUsersButton$/, + viewName : sViewName, + autoWait : false, // Needed because we want to find disabled controls, too + matchers : new PropertyStrictEquals({ + name : "enabled", + value : bEnabled + }), + check : function (aControls) { + // Validate that ALL controls have the right state + return aControls.length === 3; + }, + success : function () { + Opa5.assert.ok(true, "All controls in the table toolbar are " + + sDesiredState); + }, + errorMessage : "Not all controls in the table toolbar could be found or " + + "not all are " + sDesiredState + }); + }, + + theMessageToastShouldShow : function (sTextId, sArg0) { + return this.waitFor({ + autoWait : false, + id : sTableId, + viewName : sViewName, + check : function (oControl) { + // Locate the message toast using its CSS class name and content + var sText = oControl.getModel("i18n").getResourceBundle() + .getText(sTextId, [sArg0]), + sSelector = ".sapMMessageToast:contains('" + sText + "')"; + + return !!Opa5.getJQuery()(sSelector).length; + }, + success : function () { + Opa5.assert.ok(true, "Could see the MessageToast showing text with ID " + + sTextId); + }, + errorMessage : "Could not see a MessageToast showing text with ID " + + sTextId + }); + }, + + iShouldSeeAServiceError : function () { + return this.waitFor({ + id : "serviceErrorMessageBox", + success : function () { + Opa5.assert.ok(true, "Could see the service error dialog"); + }, + errorMessage : "Could not see the service error dialog" + }); + } + } + } + }); +}); diff --git a/packages/odatav4/steps/08/webapp/view/App.view.xml b/packages/odatav4/steps/08/webapp/view/App.view.xml new file mode 100644 index 000000000..5159a0bab --- /dev/null +++ b/packages/odatav4/steps/08/webapp/view/App.view.xml @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+
+
+
diff --git a/packages/odatav4/steps/09/README.md b/packages/odatav4/steps/09/README.md new file mode 100644 index 000000000..87bcf975e --- /dev/null +++ b/packages/odatav4/steps/09/README.md @@ -0,0 +1,362 @@ +# Step 9: List-Detail Scenario + +
+ +You can download the solution for this step here: [πŸ“₯ Download step 9](https://ui5.github.io/tutorials/odatav4/odatav4-step-09.zip). + +
+ +
+ +You can download the solution for this step here: [πŸ“₯ Download step 9](https://ui5.github.io/tutorials/odatav4/odatav4-step-09-js.zip). + +
+ +In this step we add a detail area with additional information. + +## Preview + +**A detail area containing information about the selected user is added** + +![A list of users with an added detail area](assets/Tut_OD4_Step_9_6e9025b.png "A detail area containing information about the selected user is added") + +## Coding + +You can view this step live: [πŸ”— Live Preview of Step 9](https://ui5.github.io/tutorials/odatav4/build/09/index-cdn.html). + +## `webapp/controller/App.controller.?s` + +```ts +... + onDelete() { + const oContext, + oPeopleList = this.byId("peopleList"), + oSelected = oPeopleList.getSelectedItem(), + sUserName; + + if (oSelected) { + oContext = oSelected.getBindingContext(); + sUserName = oContext.getProperty("UserName"); + oContext.delete().then(function () { + MessageToast.show(this._getText("deletionSuccessMessage", sUserName)); + }.bind(this), function (oError) { + if (oContext === oPeopleList.getSelectedItem().getBindingContext()) { + this._setDetailArea(oContext); + } + this._setUIChanges(); + if (oError.canceled) { + MessageToast.show(this._getText("deletionRestoredMessage", sUserName)); + return; + } + MessageBox.error(oError.message + ": " + sUserName); + }.bind(this)); + this._setDetailArea(); + this._setUIChanges(true); + } + }, +... + onMessageBindingChange(oEvent) { + ... + }, + + onSelectionChange(oEvent) { + this._setDetailArea(oEvent.getParameter("listItem").getBindingContext()); + }, + +... + /** + * Toggles the visibility of the detail area + * + * @param {object} [oUserContext] - the current user context + */ + _setDetailArea(oUserContext) { + const oDetailArea = this.byId("detailArea"), + oLayout = this.byId("defaultLayout"), + oSearchField = this.byId("searchField"); + + oDetailArea.setBindingContext(oUserContext || null); + // resize view + oDetailArea.setVisible(!!oUserContext); + oLayout.setSize(oUserContext ? "60%" : "100%"); + oLayout.setResizable(!!oUserContext); + oSearchField.setWidth(oUserContext ? "40%" : "20%"); + } +``` + +```js +... + onDelete : function () { + var oContext, + oPeopleList = this.byId("peopleList"), + oSelected = oPeopleList.getSelectedItem(), + sUserName; + + if (oSelected) { + oContext = oSelected.getBindingContext(); + sUserName = oContext.getProperty("UserName"); + oContext.delete().then(function () { + MessageToast.show(this._getText("deletionSuccessMessage", sUserName)); + }.bind(this), function (oError) { + if (oContext === oPeopleList.getSelectedItem().getBindingContext()) { + this._setDetailArea(oContext); + } + this._setUIChanges(); + if (oError.canceled) { + MessageToast.show(this._getText("deletionRestoredMessage", sUserName)); + return; + } + MessageBox.error(oError.message + ": " + sUserName); + }.bind(this)); + this._setDetailArea(); + this._setUIChanges(true); + } + }, +... + onMessageBindingChange : function (oEvent) { + ... + }, + + onSelectionChange : function (oEvent) { + this._setDetailArea(oEvent.getParameter("listItem").getBindingContext()); + }, + +... + /** + * Toggles the visibility of the detail area + * + * @param {object} [oUserContext] - the current user context + */ + _setDetailArea : function (oUserContext) { + var oDetailArea = this.byId("detailArea"), + oLayout = this.byId("defaultLayout"), + oSearchField = this.byId("searchField"); + + oDetailArea.setBindingContext(oUserContext || null); + // resize view + oDetailArea.setVisible(!!oUserContext); + oLayout.setSize(oUserContext ? "60%" : "100%"); + oLayout.setResizable(!!oUserContext); + oSearchField.setWidth(oUserContext ? "40%" : "20%"); + } +``` + +The `onSelectionChange` event handler retrieves the context of the selected list item and passes it to a new `_setDetailArea` function. Within `_setDetailArea`, the given context is passed as binding context for the semantic page detail area. + +Afterwards the detail area is made visible and is resized. + +The application also needs to close the detail area if its binding context is deleted. If the deleted context is restored after a failed DELETE request, or undeleted via `Context#resetChanges`, it could be shown in the detail area again, unless the user had selected another row in the meantime. Hence, we call `_setDetailArea` without a context once the context gets deleted, and with the restored context in the error handler of the `Context#delete` API. In `_setDetailArea` we resize the view based on the given context in an appropriate way. + +## webapp/view/App.view.xml + +```xml + + + + + ... + + + + + + + + + + ... + + ... +
+
+ + + + + + + </semantic:titleHeading> + <semantic:headerContent> + <FlexBox> + <VBox> + <ObjectAttribute text="{i18n>userNameLabelText}"/> + <ObjectAttribute text="{UserName}"/> + </VBox> + <VBox class="sapUiMediumMarginBegin"> + <ObjectAttribute text="{i18n>ageLabelText}"/> + <ObjectNumber number="{Age}" unit="Years"/> + </VBox> + </FlexBox> + </semantic:headerContent> + <semantic:content> + <VBox> + <FlexBox wrap="Wrap"> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>addressTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>addressLabelText}"> + <f:fields> + <Text text="{HomeAddress/Address}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>cityLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Name}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>regionLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Region}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>countryLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/CountryRegion}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>bestFriendTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>nameLabelText}"> + <f:fields> + <Text text="{BestFriend/FirstName} {BestFriend/LastName}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>ageLabelText}"> + <f:fields> + <Text text="{BestFriend/Age}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>userNameLabelText}"> + <f:fields> + <Text text="{BestFriend/UserName}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + </FlexBox> + </VBox> + </semantic:content> + </semantic:SemanticPage> + </l:SplitPane> + </l:PaneContainer> + </l:ResponsiveSplitter> + </content> + ... +</mvc:View> +``` + +Several new namespaces are added to the `appView`. After the `<content>` and before the `<Table>` tag the first part of the `SplitPane` is added. + +We add a detail area after the user table. In the `appView` we add a splitter layout around the existing table. The first `SplitPane` contains the table with all users, and the second one contains the new detail area. It consists of two forms, one for the address information, and the other one for the best friend of the currently selected user. We also add the `onSelectionChange` event handler to the user table. + +It is important that all bindings we introduced in the detail area are relative \(property\) bindings, so that we can reuse data of the list. This allows our application to share data like the user name or the age between the user selected in the table and the detail area. This helps to avoid redundant requests and to keep the data between the two areas in sync. Editing a property in the user table will thus automatically be reflected in the detail area as well. + +One of the most vital parts of the data reuse functionality is the usage of the `autoExpandSelect` binding parameter. It permits us to put a tailored `$select` clause in the `GET` request, so that only missing properties are requested for display in the detail area. + +## webapp/i18n/i18n.properties + +```ini +... +# Detail Area +#XTIT: Title for Address +addressTitleText=Address + +#XFLD: Label for Address +addressLabelText=Address + +#XFLD: Label for City +cityLabelText=City + +#XFLD: Label for Region +regionLabelText=Region + +#XFLD: Label for Country +countryLabelText=Country + +#XTIT: Title for Best Friend +bestFriendTitleText=Best Friend + +#XFLD: Label for Best Friend Name +nameLabelText=Name + +``` + +We add the missing texts to the properties file. + +**Related Information** + +[Data Reuse](../04_Essentials/data-reuse-648e360.md "The OData V4 model keeps data with respect to bindings, which allows different views on the same data, but also means that data is not automatically shared between bindings. There are mechanisms for sharing data to avoid redundant requests and to keep the same data in different controls in sync.") + +*** + +**Previous:** [Step 8: OData Operations](../08/README.md) diff --git a/packages/odatav4/steps/09/package.json b/packages/odatav4/steps/09/package.json new file mode 100644 index 000000000..261eebfc1 --- /dev/null +++ b/packages/odatav4/steps/09/package.json @@ -0,0 +1,19 @@ +{ + "name": "ui5.tutorial.odatav4.step09", + "version": "1.0.0", + "author": "SAP SE", + "description": "OpenUI5 TypeScript Tutorial β€” OData V4: Step 9 β€” List-Detail Scenario", + "private": true, + "scripts": { + "start": "ui5 serve -o index.html", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } +} diff --git a/packages/odatav4/steps/09/tsconfig.json b/packages/odatav4/steps/09/tsconfig.json new file mode 100644 index 000000000..6945c3011 --- /dev/null +++ b/packages/odatav4/steps/09/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2023", + "types": [ + "@openui5/types" + ], + "skipLibCheck": true, + "allowJs": true, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "paths": { + "ui5/tutorial/odatav4/*": [ + "./webapp/*" + ] + }, + "strict": false, + "strictNullChecks": false + }, + "include": [ + "./webapp/**/*" + ] +} diff --git a/packages/odatav4/steps/09/ui5.yaml b/packages/odatav4/steps/09/ui5.yaml new file mode 100644 index 000000000..4861ad50b --- /dev/null +++ b/packages/odatav4/steps/09/ui5.yaml @@ -0,0 +1,25 @@ +specVersion: "4.0" +metadata: + name: "ui5.tutorial.odatav4" +type: application +framework: + name: OpenUI5 + version: "1.148.1" + libraries: + - name: sap.m + - name: sap.f + - name: sap.ui.layout + - name: sap.ui.core + - name: themelib_sap_horizon +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression + - name: ui5-middleware-serveframework + afterMiddleware: compression + - name: ui5-middleware-livereload + afterMiddleware: compression diff --git a/packages/odatav4/steps/09/webapp/Component.ts b/packages/odatav4/steps/09/webapp/Component.ts new file mode 100644 index 000000000..170cbb94c --- /dev/null +++ b/packages/odatav4/steps/09/webapp/Component.ts @@ -0,0 +1,27 @@ +// Component.ts β€” UI5 OData V4 Tutorial +import UIComponent from "sap/ui/core/UIComponent"; +import { createDeviceModel } from "./model/models"; + +/** + * @namespace ui5.tutorial.odatav4 + */ +export default class Component extends UIComponent { + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], + manifest: "json" + }; + + /** + * The component is initialized by UI5 automatically during the startup of the app and calls + * the init method once. + * @public + * @override + */ + init(): void { + // call the base component's init function + super.init(); + + // set the device model + this.setModel(createDeviceModel(), "device"); + } +} diff --git a/packages/odatav4/steps/09/webapp/controller/App.controller.ts b/packages/odatav4/steps/09/webapp/controller/App.controller.ts new file mode 100644 index 000000000..664d4e8bf --- /dev/null +++ b/packages/odatav4/steps/09/webapp/controller/App.controller.ts @@ -0,0 +1,303 @@ +import Messaging from "sap/ui/core/Messaging"; +import Controller from "sap/ui/core/mvc/Controller"; +import MessageToast from "sap/m/MessageToast"; +import MessageBox from "sap/m/MessageBox"; +import Sorter from "sap/ui/model/Sorter"; +import Filter from "sap/ui/model/Filter"; +import FilterOperator from "sap/ui/model/FilterOperator"; +import FilterType from "sap/ui/model/FilterType"; +import JSONModel from "sap/ui/model/json/JSONModel"; +import ResourceModel from "sap/ui/model/resource/ResourceModel"; +import ResourceBundle from "sap/base/i18n/ResourceBundle"; +import Component from "sap/ui/core/Component"; +import List from "sap/m/List"; +import type ColumnListItem from "sap/m/ColumnListItem"; +import type SearchField from "sap/m/SearchField"; +import type ListBinding from "sap/ui/model/ListBinding"; +import type Event from "sap/ui/base/Event"; +import type Input from "sap/m/Input"; +import type Context from "sap/ui/model/odata/v4/Context"; +import type ODataModel from "sap/ui/model/odata/v4/ODataModel"; +import type ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding"; + +/** + * @namespace ui5.tutorial.odatav4.controller + */ +export default class App extends Controller { + + private _bTechnicalErrors = false; + + /** + * Hook for initializing the controller + */ + onInit(): void { + const messageModel = Messaging.getMessageModel(); + const messageModelBinding = messageModel.bindList("/", undefined, [], + new Filter("technical", FilterOperator.EQ, true)); + const viewModel = new JSONModel({ + busy: false, + hasUIChanges: false, + usernameEmpty: false, + order: 0 + }); + + this.getView().setModel(viewModel, "appView"); + this.getView().setModel(messageModel, "message"); + + messageModelBinding.attachChange(this.onMessageBindingChange, this); + this._bTechnicalErrors = false; + } + + /* =========================================================== */ + /* begin: event handlers */ + /* =========================================================== */ + + /** + * Create a new entry. + */ + onCreate(): void { + const list = this.byId("peopleList") as List; + const binding = list.getBinding("items") as ODataListBinding; + // Create a new entry through the table's list binding + const context = binding.create({ Age: "18" }); + + this._setUIChanges(true); + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", true); + + // Select and focus the table row that contains the newly created entry + list.getItems().some((item) => { + const columnItem = item as ColumnListItem; + if (columnItem.getBindingContext() === context) { + columnItem.focus(); + columnItem.setSelected(true); + return true; + } + return false; + }); + } + + /** + * Delete an entry. + */ + onDelete(): void { + const peopleList = this.byId("peopleList") as List; + const selected = peopleList.getSelectedItem() as ColumnListItem | null; + + if (selected) { + const context = selected.getBindingContext() as Context; + const userName = context.getProperty("UserName") as string; + void context.delete().then(() => { + MessageToast.show(this._getText("deletionSuccessMessage", [userName])); + }, (error2: Error & { canceled?: boolean }) => { + const currentSelected = peopleList.getSelectedItem() as ColumnListItem | null; + if (currentSelected && context === currentSelected.getBindingContext()) { + this._setDetailArea(context); + } + this._setUIChanges(); + if (error2.canceled) { + MessageToast.show(this._getText("deletionRestoredMessage", [userName])); + return; + } + MessageBox.error(error2.message + ": " + userName); + }); + this._setDetailArea(); + this._setUIChanges(); + } + } + + /** + * Lock UI when changing data in the input controls + */ + onInputChange(evt: Event): void { + if ((evt as unknown as { getParameter(n: string): unknown }).getParameter("escPressed")) { + this._setUIChanges(); + } else { + this._setUIChanges(true); + // Check if the username in the changed table row is empty and set the appView + // property accordingly + const ctx = (evt.getSource() as Input).getParent()?.getBindingContext(); + if (ctx && ctx.getProperty("UserName")) { + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", false); + } + } + } + + /** + * Refresh the data. + */ + onRefresh(): void { + const binding = (this.byId("peopleList") as List).getBinding("items") as ODataListBinding; + + if (binding.hasPendingChanges()) { + MessageBox.error(this._getText("refreshNotPossibleMessage")); + return; + } + binding.refresh(); + MessageToast.show(this._getText("refreshSuccessMessage")); + } + + /** + * Reset any unsaved changes. + */ + onResetChanges(): void { + ((this.byId("peopleList") as List).getBinding("items") as ODataListBinding).resetChanges(); + // If there were technical errors, cancelling changes resets them. + this._bTechnicalErrors = false; + this._setUIChanges(false); + } + + /** + * Reset the data source. + */ + onResetDataSource(): void { + const model = this.getView().getModel() as ODataModel; + const operation = model.bindContext("/ResetDataSource(...)"); + + void operation.invoke().then(() => { + model.refresh(); + MessageToast.show(this._getText("sourceResetSuccessMessage")); + }, (error2: Error) => { + MessageBox.error(error2.message); + }); + } + + /** + * Save changes to the source. + */ + onSave(): void { + const success = () => { + this._setBusy(false); + MessageToast.show(this._getText("changesSentMessage")); + this._setUIChanges(false); + }; + const error = (error2: Error) => { + this._setBusy(false); + this._setUIChanges(false); + MessageBox.error(error2.message); + }; + + this._setBusy(true); // Lock UI until submitBatch is resolved. + (this.getView().getModel() as ODataModel).submitBatch("peopleGroup").then(success, error); + // If there were technical errors, a new save resets them. + this._bTechnicalErrors = false; + } + + /** + * Search for the term in the search field. + */ + onSearch(): void { + const view = this.getView(); + const value = (view.byId("searchField") as SearchField).getValue(); + const filter = new Filter("LastName", FilterOperator.Contains, value); + + ((view.byId("peopleList") as List).getBinding("items") as ListBinding).filter(filter, FilterType.Application); + } + + /** + * Sort the table according to the last name. + * Cycles between the three sorting states "none", "ascending" and "descending" + */ + onSort(): void { + const view = this.getView(); + const states: (string | undefined)[] = [undefined, "asc", "desc"]; + const stateTextIds = ["sortNone", "sortAscending", "sortDescending"]; + let order = (view.getModel("appView") as JSONModel).getProperty("/order") as number; + + // Cycle between the states + order = (order + 1) % states.length; + const order2 = states[order]; + + (view.getModel("appView") as JSONModel).setProperty("/order", order); + ((view.byId("peopleList") as List).getBinding("items") as ListBinding) + .sort(order2 ? new Sorter("LastName", order2 === "desc") : []); + + const message = this._getText("sortMessage", [this._getText(stateTextIds[order])]); + MessageToast.show(message); + } + + onMessageBindingChange(event: Event): void { + const contexts = (event.getSource() as ListBinding).getContexts(); + let messageOpen = false; + + if (messageOpen || !contexts.length) { + return; + } + + // Extract and remove the technical messages + const messages = contexts.map((context: Context) => context.getObject()); + Messaging.removeMessages(messages); + + this._setUIChanges(true); + this._bTechnicalErrors = true; + MessageBox.error((messages[0] as { message: string }).message, { + id: "serviceErrorMessageBox", + onClose: () => { + messageOpen = false; + } + }); + + messageOpen = true; + } + + onSelectionChange(event: Event): void { + const listItem = (event as unknown as { getParameter(n: string): unknown }).getParameter("listItem") as ColumnListItem; + this._setDetailArea(listItem.getBindingContext() as Context); + } + + /* =========================================================== */ + /* end: event handlers */ + /* =========================================================== */ + + /** + * Convenience method for retrieving a translatable text. + */ + _getText(textId: string, args?: unknown[]): string { + const bundle = ((this.getOwnerComponent() as Component).getModel("i18n") as ResourceModel).getResourceBundle() as ResourceBundle; + return bundle.getText(textId, args as string[]); + } + + /** + * Set hasUIChanges flag in View Model + * @param bHasUIChanges - set or clear hasUIChanges; if undefined, the hasPendingChanges-function + * of the OdataV4 model determines the result + */ + _setUIChanges(hasUIChanges?: boolean): void { + if (this._bTechnicalErrors) { + // If there is currently a technical error, then force 'true'. + hasUIChanges = true; + } else if (hasUIChanges === undefined) { + hasUIChanges = (this.getView().getModel() as ODataModel).hasPendingChanges(); + } + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/hasUIChanges", hasUIChanges); + } + + /** + * Set busy flag in View Model + */ + _setBusy(isBusy: boolean): void { + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/busy", isBusy); + } + + /** + * Toggles the visibility of the detail area + * @param oUserContext - the current user context + */ + _setDetailArea(userContext?: Context): void { + const detailArea = this.byId("detailArea"); + const layout = this.byId("defaultLayout") as unknown as { setSize(s: string): void; setResizable(b: boolean): void }; + const searchField = this.byId("searchField") as SearchField; + + if (!detailArea) { + return; // do nothing during view destruction + } + + (detailArea as unknown as { setBindingContext(c: Context | null): void }).setBindingContext(userContext || null); + // resize view + (detailArea as unknown as { setVisible(b: boolean): void }).setVisible(!!userContext); + layout.setSize(userContext ? "60%" : "100%"); + layout.setResizable(!!userContext); + searchField.setWidth(userContext ? "40%" : "20%"); + } +} diff --git a/packages/odatav4/steps/09/webapp/i18n/i18n.properties b/packages/odatav4/steps/09/webapp/i18n/i18n.properties new file mode 100644 index 000000000..f44294148 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/i18n/i18n.properties @@ -0,0 +1,100 @@ +# App Descriptor +#XTIT: Application name +appTitle=OData V4 - Step 9: Add list-detail scenario + +#YDES: Application description +appDescription=OData V4 Tutorial + +#XTIT: Page Title +peoplePageTitle=My Users + +# Toolbar +#XBUT: Button text for save +saveButtonText=Save + +#XBUT: Button text for reset changes +resetChangesButtonText=Restart Tutorial + +#XBUT: Button text for cancel +cancelButtonText=Cancel + +#XTOL: Tooltip for sort +sortButtonText=Sort by Last Name + +#XBUT: Button text for add user +createButtonText=Add User + +#XBUT: Button text for delete user +deleteButtonText=Delete User + +#XTOL: Tooltip for refresh data +refreshButtonText=Refresh Data + +#XTXT: Placeholder text for search field +searchFieldPlaceholder=Type in a last name + +# Table Area +#XFLD: Label for User Name +userNameLabelText=User Name + +#XFLD: Label for First Name +firstNameLabelText=First Name + +#XFLD: Label for Last Name +lastNameLabelText=Last Name + +#XFLD: Label for Age +ageLabelText=Age + +# Messages +#XMSG: Message for user changes sent to the service +changesSentMessage=User data sent to the server + +#XMSG: Message for user deleted +deletionSuccessMessage=User {0} deleted + +#XMSG: Message for user restored (undeleted) +deletionRestoredMessage=User {0} restored + +#XMSG: Message for changes reverted +sourceResetSuccessMessage=All changes reverted back to start + +#XMSG: Message for refresh failed +refreshNotPossibleMessage=Before refreshing, please save or revert your changes + +#XMSG: Message for refresh succeeded +refreshSuccessMessage=Data refreshed + +#MSG: Message for sorting +sortMessage=Users sorted by {0} + +#MSG: Suffix for sorting by LastName, ascending +sortAscending=last name, ascending + +#MSG: Suffix for sorting by LastName, descending +sortDescending=last name, descending + +#MSG: Suffix for no sorting +sortNone=the sequence on the server + +# Detail Area +#XTIT: Title for Address +addressTitleText=Address + +#XFLD: Label for Address +addressLabelText=Address + +#XFLD: Label for City +cityLabelText=City + +#XFLD: Label for Region +regionLabelText=Region + +#XFLD: Label for Country +countryLabelText=Country + +#XTIT: Title for Best Friend +bestFriendTitleText=Best Friend + +#XFLD: Label for Best Friend Name +nameLabelText=Name diff --git a/packages/odatav4/steps/09/webapp/index-cdn.html b/packages/odatav4/steps/09/webapp/index-cdn.html new file mode 100644 index 000000000..692229401 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/index-cdn.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/09/webapp/index.html b/packages/odatav4/steps/09/webapp/index.html new file mode 100644 index 000000000..b1e54667d --- /dev/null +++ b/packages/odatav4/steps/09/webapp/index.html @@ -0,0 +1,22 @@ + + + + + + OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/09/webapp/initMockServer.ts b/packages/odatav4/steps/09/webapp/initMockServer.ts new file mode 100644 index 000000000..0150138fe --- /dev/null +++ b/packages/odatav4/steps/09/webapp/initMockServer.ts @@ -0,0 +1,11 @@ +// initMockServer.ts β€” bootstraps the mock server before the component starts +import MessageBox from "sap/m/MessageBox"; +import mockserver from "./localService/mockserver"; + +// initialize the mock server +mockserver.init().catch((oError: Error) => { + MessageBox.error(oError.message); +}).finally(() => { + // initialize the embedded component on the HTML page + void import("sap/ui/core/ComponentSupport"); +}); diff --git a/packages/odatav4/steps/09/webapp/localService/metadata.xml b/packages/odatav4/steps/09/webapp/localService/metadata.xml new file mode 100644 index 000000000..fe46ab7a5 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/localService/metadata.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Name + + + + + + + + + + + + + diff --git a/packages/odatav4/steps/09/webapp/localService/mockdata/people.json b/packages/odatav4/steps/09/webapp/localService/mockdata/people.json new file mode 100644 index 000000000..758030eaa --- /dev/null +++ b/packages/odatav4/steps/09/webapp/localService/mockdata/people.json @@ -0,0 +1,399 @@ +{ + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(id))/$metadata#People(Age,FirstName,LastName,UserName,Friends,BestFriend,HomeAddress)", + "value": [ + { + "Age": 23, + "FirstName": "Angel", + "LastName": "Huffman", + "UserName": "angelhuffman", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "clydeguess", + "HomeAddress": { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + }, + { + "Age": 44, + "FirstName": "Clyde", + "LastName": "Guess", + "UserName": "clydeguess", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "angelhuffman", + "HomeAddress": { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 19, + "FirstName": "Elaine", + "LastName": "Stewart", + "UserName": "elainestewart", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "genevievereeves", + "HomeAddress": { + "Address": "308 Negra Arroyo Ln.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 37, + "FirstName": "Genevieve", + "LastName": "Reeves", + "UserName": "genevievereeves", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "elainestewart", + "HomeAddress": { + "Address": "89 Jefferson Way Suite 2", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "WA" + } + } + }, + { + "Age": 25, + "FirstName": "Georgina", + "LastName": "Barlow", + "UserName": "georginabarlow", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "javieralfred", + "HomeAddress": { + "Address": "31 Spooner Street", + "City": { + "Name": "Quahog", + "CountryRegion": "United States", + "Region": "RI" + } + } + }, + { + "Age": 19, + "FirstName": "Javier", + "LastName": "Alfred", + "UserName": "javieralfred", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "georginabarlow", + "HomeAddress": { + "Address": "55 Grizzly Peak Rd.", + "City": { + "Name": "Butte", + "CountryRegion": "United States", + "Region": "MT" + } + } + }, + { + "Age": 26, + "FirstName": "Joni", + "LastName": "Rosales", + "UserName": "jonirosales", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "keithpinckney", + "HomeAddress": { + "Address": "742 Evergreen Terrace", + "City": { + "Name": "Springfield", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 41, + "FirstName": "Keith", + "LastName": "Pinckney", + "UserName": "keithpinckney", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "jonirosales", + "HomeAddress": { + "Address": "1600 Pennsylvania Avenue NW", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 30, + "FirstName": "Krista", + "LastName": "Kemp", + "UserName": "kristakemp", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "laurelosborn", + "HomeAddress": { + "Address": "Statue of Liberty", + "City": { + "Name": "New York", + "CountryRegion": "United States", + "Region": "NY" + } + } + }, + { + "Age": 29, + "FirstName": "Laurel", + "LastName": "Osborn", + "UserName": "laurelosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "kristakemp", + "HomeAddress": { + "Address": "4059 Mt Lee Dr. Hollywood", + "City": { + "Name": "Los Angelos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 53, + "FirstName": "Marshall", + "LastName": "Garay", + "UserName": "marshallgaray", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "ronaldmundy", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 66, + "FirstName": "Ronald", + "LastName": "Mundy", + "UserName": "ronaldmundy", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "marshallgaray", + "HomeAddress": { + "Address": "89 Chiaroscuro Rd.", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 51, + "FirstName": "Russell", + "LastName": "Whyte", + "UserName": "russellwhyte", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "ryantheriault", + "HomeAddress": { + "Address": "Tony Stark Mansion, Point Dume", + "City": { + "Name": "Malibu", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 57, + "FirstName": "Ryan", + "LastName": "Theriault", + "UserName": "ryantheriault", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "russellwhyte", + "HomeAddress": { + "Address": "2311 N. Los Robles Ave. Apt 4A", + "City": { + "Name": "Pasadena", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 34, + "FirstName": "Sallie", + "LastName": "Sampson", + "UserName": "salliesampson", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "sandyosborn", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 18, + "FirstName": "Sandy", + "LastName": "Osborn", + "UserName": "sandyosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "salliesampson", + "HomeAddress": { + "Address": "Grove Street, Ganton", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 24, + "FirstName": "Scott", + "LastName": "Ketchum", + "UserName": "scottketchum", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "ursulabright", + "HomeAddress": { + "Address": "Portola Drive, Rockford Hills", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 31, + "FirstName": "Ursula", + "LastName": "Bright", + "UserName": "ursulabright", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "scottketchum", + "HomeAddress": { + "Address": "First St. SE", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 40, + "FirstName": "Vincent", + "LastName": "Calabrese", + "UserName": "vincentcalabrese", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "willieashmore", + "HomeAddress": { + "Address": "4 Privet Drive", + "City": { + "Name": "Little Whinging", + "CountryRegion": "Great Britain", + "Region": "SRY" + } + } + }, + { + "Age": 45, + "FirstName": "Willie", + "LastName": "Ashmore", + "UserName": "willieashmore", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "vincentcalabrese", + "HomeAddress": { + "Address": "124 Conch St.", + "City": { + "Name": "Bikini Bottom", + "CountryRegion": "Pacific Ocean", + "Region": "N/D" + } + } + } + ] +} diff --git a/packages/odatav4/steps/09/webapp/localService/mockserver.ts b/packages/odatav4/steps/09/webapp/localService/mockserver.ts new file mode 100644 index 000000000..7ca0307f4 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/localService/mockserver.ts @@ -0,0 +1,782 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ +import JSONModel from "sap/ui/model/json/JSONModel"; +import Log from "sap/base/Log"; + +// Pull sinon from the UI5 third-party shim. The shim has no TS typings; +// we treat the imported value as a structural any so the mock implementation +// stays close to the original JavaScript. +// eslint-disable-next-line @typescript-eslint/no-require-imports +declare const sap: any; + +interface MockUser { + UserName: string; + FirstName?: string; + LastName?: string; + Age?: number; + Friends?: string[]; + BestFriend?: string; + [key: string]: unknown; +} + +interface MockXhr { + method: string; + url: string; + requestBody?: string; + respond?: (status: number, headers: Record, body: string | null) => void; +} + +type MockResponse = [number, Record, string | null] | [number, Record]; + +// sinon is provided by the "sap/ui/thirdparty/sinon" module β€” see the require below +let sinon: any; +let sandbox: any; +let users: MockUser[]; // The array that holds the cached user data +let metadata: string; // The string that holds the cached mock service metadata +const namespace = "ui5/tutorial/odatav4"; +// Component for writing logs into the console +const logComponent = "ui5.tutorial.odatav4.mockserver"; +const rBaseUrl = /services.odata.org\/TripPinRESTierService/; + +/** + * Returns the base URL from a given URL. + * @param url - the complete URL + * @returns the base URL + */ +function getBaseUrl(url: string): string { + const matches = url.match(/http.+\(S\(.+\)\)\//); + + if (!Array.isArray(matches) || matches.length < 1) { + throw new Error("Could not find a base URL in " + url); + } + + return matches[0]; +} + +/** + * Looks for a user with a given user name and returns its index in the user array. + * @param userName - the user name to look for. + * @returns index of that user in the array, or -1 if the user was not found. + */ +function findUserIndex(userName: string): number { + for (let i = 0; i < users.length; i++) { + if (users[i].UserName === userName) { + return i; + } + } + return -1; +} + +/** + * Retrieves any user data from a given http request body. + * @param body - the http request body. + * @returns the parsed user data. + */ +function getUserDataFromRequestBody(body: string): MockUser { + const matches = body.match(/({.+})/); + + if (!Array.isArray(matches) || matches.length !== 2) { + throw new Error("Could not find any user data in " + body); + } + return JSON.parse(matches[1]) as MockUser; +} + +/** + * Retrieves a user name from a given request URL. + * @param url - the request URL. + * @returns the user name or undefined if no user was found. + */ +function getUserKeyFromUrl(url: string): string | undefined { + const matches = url.match(/People\('(.*)'\)/); + return matches ? matches[1] : undefined; +} + +/** + * Checks if a given UserName is unique or already used + * @param userName - the UserName to be checked + * @returns True if the UserName is unique (not used), false otherwise + */ +function isUnique(userName: string): boolean { + return findUserIndex(userName) < 0; +} + +/** + * Returns a proper HTTP response body for "duplicate key" errors + * @param key - the duplicate key + * @returns the proper response body + */ +function duplicateKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "409", + message: "There is already a user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function invalidKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "404", + message: "There is no user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function getSuccessResponse(responseBody: string): MockResponse { + return [ + 200, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Reads and caches the fake service metadata and data from their + * respective files. + * @returns a promise that is resolved when the data is loaded + */ +function readData(): Promise { + const metadataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/metadata.xml"); + const request = new XMLHttpRequest(); + + request.onload = function () { + // 404 is not an error for XMLHttpRequest so we need to handle it here + if (request.status === 404) { + const error = "resource " + resourcePath + " not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + metadata = this.responseText; + resolve(); + }; + request.onerror = function () { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }; + request.open("GET", resourcePath); + request.send(); + }); + + const mockDataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/mockdata/people.json"); + const mockDataModel = new JSONModel(resourcePath); + + mockDataModel.attachRequestCompleted(function (this: JSONModel, event: any) { + // 404 is not an error for JSONModel so we need to handle it here + if (event.getParameter("errorobject") + && event.getParameter("errorobject").statusCode === 404) { + const error = "resource '" + resourcePath + "' not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + users = (this.getData() as { value: MockUser[] }).value; + resolve(); + }); + + mockDataModel.attachRequestFailed(() => { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }); + }); + + return Promise.all([metadataPromise, mockDataPromise]); +} + +/** + * Reduces a given result set by applying the OData URL parameters 'skip' and 'top' to it. + * Does NOT change the given result set but returns a new array. + */ +function applySkipTop(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const reducedUsers = [...resultSet]; + const matches = xhr.url.match(/\$skip=(\d+)&\$top=(\d+)/); + + if (Array.isArray(matches) && matches.length >= 3) { + const skip = parseInt(matches[1], 10); + const top = parseInt(matches[2], 10); + return resultSet.slice(skip, skip + top); + } + + return reducedUsers; +} + +/** + * Sorts a given result set by applying the OData URL parameter 'orderby'. + * Does NOT change the given result set but returns a new array. + */ +function applySort(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const sortedUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$orderby=(\w*)(?:%20(\w*))?/); + + if (!Array.isArray(matches) || matches.length < 2) { + return sortedUsers; + } + const fieldName = matches[1]; + const direction = matches[2] || "asc"; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + sortedUsers.sort((a, b) => { + const nameA = (a.LastName || "").toUpperCase(); + const nameB = (b.LastName || "").toUpperCase(); + const asc = direction === "asc"; + + if (nameA < nameB) { + return asc ? -1 : 1; + } + if (nameA > nameB) { + return asc ? 1 : -1; + } + return 0; + }); + + return sortedUsers; +} + +/** + * Filters a given result set by applying the OData URL parameter 'filter'. + * Does NOT change the given result set but returns a new array. + */ +function applyFilter(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + let filteredUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$filter=.*\((.*),'(.*)'\)/); + + // If the request contains a filter command, apply the filter + if (Array.isArray(matches) && matches.length >= 3) { + const fieldName = matches[1]; + const query = matches[2]; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + filteredUsers = users.filter((user) => (user.LastName || "").indexOf(query) !== -1); + } + + return filteredUsers; +} + +/** + * Handles GET requests for metadata. + */ +function handleGetMetadataRequests(): MockResponse { + return [ + 200, + { + "Content-Type": "application/xml", + "odata-version": "4.0" + }, metadata + ]; +} + +/** + * Handles GET requests for a pure user count and returns a fitting response. + */ +function handleGetCountRequests(): MockResponse { + return getSuccessResponse(users.length.toString()); +} + +/** + * Handles GET requests for user data and returns a fitting response. + */ +function handleGetUserRequests(xhr: MockXhr, _bCount: boolean): MockResponse { + let count: number; + let expand: RegExpMatchArray | string[] | null; + let expand2: string; + let index: number; + let key: string | undefined; + let response: { "@odata.count"?: number; value: MockUser[] } | MockUser | null; + let responseBody: string; + let result: MockUser[]; + let select: RegExpMatchArray | string[] | null; + let select2: string; + let subSelects: string[][] = []; + let i: number; + + // Get expand parameter + expand = xhr.url.match(/\$expand=([^&]+)/); + + // Sort out expand parameter values + subSelects in brackets + if (expand) { + expand2 = expand[0]; + expand2 = expand2.substring(8); + + // Sort out subselects (e.g. BestFriend($select=Age,UserName),Friend) + const subSelectMatches = expand2.match(/\([^)]*\)/g) || []; + subSelects = subSelectMatches.map((s) => s.replace(/\(\$select=/, "").replace(/\)/, "").split(",")); + expand2 = expand2.replace(/\([^)]*\)/g, ""); + expand = expand2.split(","); + } + + // Get select parameter + select = xhr.url.match(/[^(]\$select=([\w|,]+)/); + + // Sort out select parameter values + if (Array.isArray(select)) { + select2 = select[0]; + select2 = select2.replace(/&/, "").replace(/\?/, "").substring(8); + select = select2.split(","); + } + + // Check if an individual user or a user range is requested + key = getUserKeyFromUrl(xhr.url); + if (key) { + index = findUserIndex(key); + + if (/People\(.+\)\/Friends/.test(xhr.url)) { + // ownRequest for friends + response = { value: createFriendsArray(users[index].Friends, select as string[]) }; + } else { + // specific user was requested + response = getUserObject(index, select as string[], expand as string[], subSelects); + } + + if (index > -1) { + responseBody = JSON.stringify(response); + return getSuccessResponse(responseBody); + } + responseBody = invalidKeyError(key); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // all users requested + result = applyFilter(xhr, users); + count = result.length; // the total no. of people found, after filtering + result = applySort(xhr, result); + result = applySkipTop(xhr, result); + + // generate sResponse + const finalResponse: { "@odata.count": number; value: MockUser[] } = { "@odata.count": count, value: [] }; + + result.forEach((user) => { + const userIndex = findUserIndex(user.UserName); + + finalResponse.value.push(getUserObject(userIndex, select as string[], expand as string[], subSelects) as MockUser); + }); + + responseBody = JSON.stringify(finalResponse); + + return getSuccessResponse(responseBody); +} + +/** + * Returns a specific user in the aUsers array. + */ +function getUserByIndex(index: number, properties: string[]): MockUser | null { + const helper: MockUser = { UserName: "" }; + const user = users[index]; + + if (user) { + properties.forEach((selectProperty) => { + helper[selectProperty] = user[selectProperty]; + }); + + return helper; + } + return null; +} + +/** + * Returns the user with iIndex in the aUsers array with all its information + */ +function getUserObject(index: number, select: string[], expand: string[] | null | undefined, subSelects: string[][]): MockUser | null { + let bestFriend: string | undefined; + let friendIndex: number; + let friends: string[] | undefined; + let object: MockUser | null; + let user: MockUser; + let i: number; + + object = getUserByIndex(index, select); + if (expand && object) { + user = users[index]; + for (i = 0; i < expand.length; i++) { + switch (expand[i]) { + case "Friends": + friends = user.Friends; + object.Friends = createFriendsArray(friends, subSelects[i]) as unknown as string[]; + break; + case "BestFriend": + bestFriend = user.BestFriend; + friendIndex = findUserIndex(bestFriend || ""); + object.BestFriend = getUserByIndex(friendIndex, subSelects[i]) as unknown as string; + break; + default: + break; + } + } + } + return object; +} + +/** + * creates array of friends for a given user + */ +function createFriendsArray(friends: string[] | undefined, subSelects: string[]): MockUser[] { + let array: (MockUser | null)[] = []; + + if (friends) { + friends.forEach((friend) => { + const friendIndex = findUserIndex(friend); + array.push(getUserByIndex(friendIndex, subSelects)); + }); + + array = array.filter((element) => element !== null); + } + + return array as MockUser[]; +} + +/** + * Handles PATCH requests for users and returns a fitting response. + */ +function handlePatchUserRequests(xhr: MockXhr): MockResponse { + // Get the key of the person to change + const key = getUserKeyFromUrl(xhr.url); + + // Get the list of changes + const changes = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if the UserName is changed to a duplicate. + // If the UserName is "changed" to its current value, that is not an error. + if (Object.prototype.hasOwnProperty.call(changes, "UserName") + && changes.UserName !== key + && !isUnique(changes.UserName)) { + // Error + const responseBody = duplicateKeyError(changes.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // No error: make the change(s) + const user = users[findUserIndex(key || "")]; + for (const fieldName in changes) { + if (Object.prototype.hasOwnProperty.call(changes, fieldName)) { + user[fieldName] = changes[fieldName]; + } + } + + // The response to PATCH requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles DELETE requests for users and returns a fitting response. + */ +function handleDeleteUserRequests(xhr: MockXhr): MockResponse { + const key = getUserKeyFromUrl(xhr.url); + users.splice(findUserIndex(key || ""), 1); + + // The response to DELETE requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles POST requests for users and returns a fitting response. + */ +function handlePostUserRequests(xhr: MockXhr): MockResponse { + const user = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if that user already exists + if (isUnique(user.UserName)) { + users.push(user); + + let responseBody = '{"@odata.context": "' + getBaseUrl(xhr.url) + + '$metadata#People/$entity",'; + responseBody += JSON.stringify(user).slice(1); + + // The response to POST requests is http 201 (Created) + return [ + 201, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; + } + // Error + const responseBody = duplicateKeyError(user.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; +} + +/** + * Handles POST requests for resetting the data and returns a fitting response. + */ +function handleResetDataRequest(): MockResponse { + void readData(); + + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Builds a response to direct (= non-batch) requests. + * Supports GET, PATCH, DELETE and POST requests. + */ +function handleDirectRequest(xhr: MockXhr): MockResponse | undefined { + let response2: MockResponse | undefined; + + switch (xhr.method) { + case "GET": + if (/\$metadata/.test(xhr.url)) { + response2 = handleGetMetadataRequests(); + } else if (/\/\$count/.test(xhr.url)) { + response2 = handleGetCountRequests(); + } else if (/People.*\?/.test(xhr.url)) { + response2 = handleGetUserRequests(xhr, /\$count=true/.test(xhr.url)); + } + break; + case "PATCH": + if (/People/.test(xhr.url)) { + response2 = handlePatchUserRequests(xhr); + } + break; + case "POST": + if (/People/.test(xhr.url)) { + response2 = handlePostUserRequests(xhr); + } else if (/ResetDataSource/.test(xhr.url)) { + response2 = handleResetDataRequest(); + } + break; + case "DELETE": + if (/People/.test(xhr.url)) { + response2 = handleDeleteUserRequests(xhr); + } + break; + case "HEAD": + response2 = [204, {}]; + break; + default: + break; + } + + return response2; +} + +/** + * Builds a response to batch requests. + * Unwraps batch request, gets a response for each individual part and + * constructs a fitting batch response. + */ +function handleBatchRequest(xhr: MockXhr): MockResponse { + let responseBody = ""; + const outerBoundary = (xhr.requestBody || "").match(/(.*)/)![1]; // First line of the body + let innerBoundary: string | undefined; + let partBoundary: string; + // The individual requests + const outerParts = (xhr.requestBody || "").split(outerBoundary).slice(1, -1); + let parts: string[]; + let header: string; + + const matches = outerParts[0].match(/multipart\/mixed;boundary=(.+)/); + // If this request has several change sets, then we need to handle the inner and outer + // boundaries (change sets have an additional boundary) + if (matches && matches.length > 0) { + innerBoundary = matches[1]; + parts = outerParts[0].split("--" + innerBoundary).slice(1, -1); + } else { + parts = outerParts; + } + + // If this request has several change sets, then the response must start with the outer + // boundary and content header + if (innerBoundary) { + partBoundary = "--" + innerBoundary; + responseBody += outerBoundary + "\r\n" + + "Content-Type: multipart/mixed; boundary=" + innerBoundary + "\r\n\r\n"; + } else { + partBoundary = outerBoundary; + } + + parts.forEach((part, index) => { + // Construct the batch response body out of the single batch request parts. + const matches0 = part.match(/(GET|DELETE|PATCH|POST) (\S+)(?:.|\r?\n)+\r?\n(.*)\r?\n$/)!; + const partResponse = handleDirectRequest({ + method: matches0[1], + url: getBaseUrl(xhr.url) + matches0[2], + requestBody: matches0[3] + })!; + + responseBody += partBoundary + "\r\n" + + "Content-Type: application/http\r\n"; + // If there are several change sets, we need to add a Content ID header + if (innerBoundary) { + responseBody += "Content-ID:" + index + ".0\r\n"; + } + responseBody += "\r\nHttp/1.1 " + partResponse[0] + "\r\n"; + // Add any headers from the request - unless this response is 204 (no content) + if (partResponse[1] && partResponse[0] !== 204) { + for (header in partResponse[1]) { + if (Object.prototype.hasOwnProperty.call(partResponse[1], header)) { + responseBody += header + ": " + partResponse[1][header] + "\r\n"; + } + } + } + responseBody += "\r\n"; + + if (partResponse[2]) { + responseBody += partResponse[2]; + } + responseBody += "\r\n"; + }); + + // Check if we need to add the inner boundary again at the end + if (innerBoundary) { + responseBody += "--" + innerBoundary + "--\r\n"; + } + // Add a final boundary to the batch response body + responseBody += outerBoundary + "--"; + + // Build the final batch response + return [ + 200, + { + "Content-Type": "multipart/mixed;boundary=" + outerBoundary.slice(2), + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Handles any type of intercepted request and sends a fake response. + * Logs the request and response to the console. + * Manages batch requests. + */ +function handleAllRequests(xhr: MockXhr): void { + let response2: MockResponse | undefined; + + // Log the request + Log.info( + "Mockserver: Received " + xhr.method + " request to URL " + xhr.url, + (xhr.requestBody ? "Request body is:\n" + xhr.requestBody : "No request body.") + + "\n", + logComponent + ); + + if (xhr.method === "POST" && /\$batch/.test(xhr.url)) { + response2 = handleBatchRequest(xhr); + } else { + response2 = handleDirectRequest(xhr); + } + + if (xhr.respond && response2) { + xhr.respond(response2[0], response2[1], response2[2] || null); + } + + // Log the response + if (response2) { + Log.info( + "Mockserver: Sent response with return code " + response2[0], + ("Response headers: " + JSON.stringify(response2[1]) + "\n\nResponse body:\n" + + (response2[2] || "")) + "\n", + logComponent + ); + } +} + +export default { + + /** + * Creates a Sinon fake service, intercepting all http requests to + * the URL defined in variable rBaseUrl above. + * @returns a promise that is resolved when the mock server is started + */ + init(): Promise { + // Load sinon lazily from the UI5 third-party shim + return new Promise((resolve, reject) => { + sap.ui.require(["sap/ui/thirdparty/sinon"], (lazySinon: any) => { + sinon = lazySinon; + sandbox = sinon.sandbox.create(); + + // Read the mock data + readData().then(() => { + // Initialize the sinon fake server + sandbox.useFakeServer(); + // Make sure that requests are responded to automatically. Otherwise we would need + // to do that manually. + sandbox.server.autoRespond = true; + + // Register the requests for which responses should be faked. + sandbox.server.respondWith(rBaseUrl, handleAllRequests); + + // Apply a filter to the fake XmlHttpRequest. + // Otherwise, ALL requests (e.g. for the component, views etc.) would be + // intercepted. + sinon.FakeXMLHttpRequest.useFilters = true; + sinon.FakeXMLHttpRequest.addFilter((_sMethod: string, url: string) => { + // If the filter returns true, the request will NOT be faked. + // We only want to fake requests that go to the intended service. + return !rBaseUrl.test(url); + }); + + // Set the logging level for console entries from the mock server + Log.setLevel(Log.Level.INFO, logComponent); + + Log.info("Running the app with mock data", logComponent); + resolve(undefined); + }, reject); + }); + }); + }, + + /** + * Stops the request interception and deletes the Sinon fake server. + */ + stop(): void { + if (sinon) { + sinon.FakeXMLHttpRequest.filters = []; + sinon.FakeXMLHttpRequest.useFilters = false; + } + if (sandbox) { + sandbox.restore(); + sandbox = null; + } + } +}; diff --git a/packages/odatav4/steps/09/webapp/manifest.json b/packages/odatav4/steps/09/webapp/manifest.json new file mode 100644 index 000000000..5575dd133 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/manifest.json @@ -0,0 +1,69 @@ +{ + "_version": "2.8.0", + "sap.app": { + "id": "ui5.tutorial.odatav4", + "type": "application", + "i18n": { + "supportedLocales": [ + "" + ], + "fallbackLocale": "", + "bundleName": "ui5.tutorial.odatav4.i18n.i18n" + }, + "applicationVersion": { + "version": "1.0.0" + }, + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "dataSources": { + "default": { + "uri": "https://services.odata.org/TripPinRESTierService/(S(id))/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + } + }, + "sap.ui": { + "technology": "UI5" + }, + "sap.ui5": { + "rootView": { + "viewName": "ui5.tutorial.odatav4.view.App", + "type": "XML", + "id": "appView" + }, + "dependencies": { + "minUI5Version": "1.148", + "libs": { + "sap.f": {}, + "sap.m": {}, + "sap.ui.core": {}, + "sap.ui.layout": {} + } + }, + "handleValidation": true, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "ui5.tutorial.odatav4.i18n.i18n", + "supportedLocales": [ + "" + ], + "fallbackLocale": "" + } + }, + "": { + "dataSource": "default", + "preload": true, + "settings": { + "autoExpandSelect": true, + "earlyRequests": true, + "operationMode": "Server" + } + } + } + } +} diff --git a/packages/odatav4/steps/09/webapp/model/models.ts b/packages/odatav4/steps/09/webapp/model/models.ts new file mode 100644 index 000000000..5fdcff4a3 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/model/models.ts @@ -0,0 +1,10 @@ +// models.ts β€” central application models +import JSONModel from "sap/ui/model/json/JSONModel"; +import Device from "sap/ui/Device"; + +export function createDeviceModel(): JSONModel { + const model = new JSONModel(Device); + + model.setDefaultBindingMode("OneWay"); + return model; +} diff --git a/packages/odatav4/steps/09/webapp/view/App.view.xml b/packages/odatav4/steps/09/webapp/view/App.view.xml new file mode 100644 index 000000000..8098b3099 --- /dev/null +++ b/packages/odatav4/steps/09/webapp/view/App.view.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + </semantic:titleHeading> + <semantic:headerContent> + <FlexBox> + <VBox> + <ObjectAttribute text="{i18n>userNameLabelText}"/> + <ObjectAttribute text="{UserName}"/> + </VBox> + <VBox class="sapUiMediumMarginBegin"> + <ObjectAttribute text="{i18n>ageLabelText}"/> + <ObjectNumber number="{Age}" unit="Years"/> + </VBox> + </FlexBox> + </semantic:headerContent> + <semantic:content> + <VBox> + <FlexBox wrap="Wrap"> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>addressTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>addressLabelText}"> + <f:fields> + <Text text="{HomeAddress/Address}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>cityLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Name}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>regionLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Region}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>countryLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/CountryRegion}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>bestFriendTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>nameLabelText}"> + <f:fields> + <Text text="{BestFriend/FirstName} {BestFriend/LastName}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>ageLabelText}"> + <f:fields> + <Text text="{BestFriend/Age}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>userNameLabelText}"> + <f:fields> + <Text text="{BestFriend/UserName}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + </FlexBox> + </VBox> + </semantic:content> + </semantic:SemanticPage> + </l:SplitPane> + </l:PaneContainer> + </l:ResponsiveSplitter> + </content> + <footer> + <Toolbar visible="{appView>/hasUIChanges}"> + <ToolbarSpacer/> + <Button + id="saveButton" + type="Emphasized" + text="{i18n>saveButtonText}" + enabled="{= ${message>/}.length === 0 && ${appView>/usernameEmpty} === false }" + press=".onSave"/> + <Button + id="doneButton" + text="{i18n>cancelButtonText}" + press=".onResetChanges"/> + </Toolbar> + </footer> + </Page> + </pages> + </App> + </Shell> +</mvc:View> diff --git a/packages/odatav4/steps/10/README.md b/packages/odatav4/steps/10/README.md new file mode 100644 index 000000000..5c9061d30 --- /dev/null +++ b/packages/odatav4/steps/10/README.md @@ -0,0 +1,131 @@ +# Step 10: Enable Data Reuse + +<details class="ts-only" markdown="1"> + +You can download the solution for this step here: [πŸ“₯ Download step 10](https://ui5.github.io/tutorials/odatav4/odatav4-step-10.zip). + +</details> + +<details class="js-only" markdown="1"> + +You can download the solution for this step here: [πŸ“₯ Download step 10](https://ui5.github.io/tutorials/odatav4/odatav4-step-10-js.zip). + +</details> + +In this step we avoid unnecessary back-end requests by preventing the destruction of data shown in the detail area when sorting or filtering the list. + +## Preview + +**No visual change compared to the last step** + +![A list of users with an added detail area](assets/Tut_OD4_Step_9_6e9025b.png "No visual change compared to the last step") + +## Coding + +You can view this step live: [πŸ”— Live Preview of Step 10](https://ui5.github.io/tutorials/odatav4/build/10/index-cdn.html). + +## `webapp/controller/App.controller.?s` + +```ts +... + onMessageBindingChange(oEvent) { + ... + }, + + onSelectionChange(oEvent) { + this._setDetailArea(oEvent.getParameter("listItem").getBindingContext()); + }, +... + /** + * Toggles the visibility of the detail area + * + * @param {object} [oUserContext] - the current user context + */ + _setDetailArea(oUserContext) { + const oDetailArea = this.byId("detailArea"), + oLayout = this.byId("defaultLayout"), + oOldContext, + oSearchField = this.byId("searchField"); + + if (!oDetailArea) { + return; // do nothing when running within view destruction + } + + oOldContext = oDetailArea.getBindingContext(); + if (oOldContext) { + oOldContext.setKeepAlive(false); + } + if (oUserContext) { + oUserContext.setKeepAlive(true, + // hide details if kept entity was refreshed but does not exists any more + this._setDetailArea.bind(this)); + + } + oDetailArea.setBindingContext(oUserContext || null); + // resize view + oDetailArea.setVisible(!!oUserContext); + oLayout.setSize(oUserContext ? "60%" : "100%"); + oLayout.setResizable(!!oUserContext); + oSearchField.setWidth(oUserContext ? "40%" : "20%"); + } + ... +``` + +```js +... + onMessageBindingChange : function (oEvent) { + ... + }, + + onSelectionChange : function (oEvent) { + this._setDetailArea(oEvent.getParameter("listItem").getBindingContext()); + }, +... + /** + * Toggles the visibility of the detail area + * + * @param {object} [oUserContext] - the current user context + */ + _setDetailArea : function (oUserContext) { + var oDetailArea = this.byId("detailArea"), + oLayout = this.byId("defaultLayout"), + oOldContext, + oSearchField = this.byId("searchField"); + + if (!oDetailArea) { + return; // do nothing when running within view destruction + } + + oOldContext = oDetailArea.getBindingContext(); + if (oOldContext) { + oOldContext.setKeepAlive(false); + } + if (oUserContext) { + oUserContext.setKeepAlive(true, + // hide details if kept entity was refreshed but does not exists any more + this._setDetailArea.bind(this)); + + } + oDetailArea.setBindingContext(oUserContext || null); + // resize view + oDetailArea.setVisible(!!oUserContext); + oLayout.setSize(oUserContext ? "60%" : "100%"); + oLayout.setResizable(!!oUserContext); + oSearchField.setWidth(oUserContext ? "40%" : "20%"); + } + ... +``` + +We extend the logic of the `_setDetailArea` function. First, we check if there's an "old" binding context in the detail area. If so, the `keepAlive` for the old context is set to `false`. + +For the new context we set `keepAlive` to `true` and add `_setDetailArea` as an `onBeforeDestroy` function to it, which hides the detail area when the user linked to it is deleted in the back end and the list is refreshed. + +You can use the `Context#setKeepAlive` method to prevent the destruction of information shown in the detail area when the selected user is no longer part of the list from which the information was selected. This could otherwise happen if you filter or sort the list. + +**Related Information** + +[Extending the Lifetime of a Context that is not Used Exclusively by a Table Collection](../04_Essentials/data-reuse-648e360.md#loio648e360fa22d46248ca783dc6eb44531__section_ELC) + +*** + +**Next:** [Step 11: Add Table with :n Navigation to Detail Area](../11/README.md) diff --git a/packages/odatav4/steps/10/package.json b/packages/odatav4/steps/10/package.json new file mode 100644 index 000000000..a260f9df0 --- /dev/null +++ b/packages/odatav4/steps/10/package.json @@ -0,0 +1,19 @@ +{ + "name": "ui5.tutorial.odatav4.step10", + "version": "1.0.0", + "author": "SAP SE", + "description": "OpenUI5 TypeScript Tutorial β€” OData V4: Step 10 β€” Enable Data Reuse", + "private": true, + "scripts": { + "start": "ui5 serve -o index.html", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@openui5/types": "^1.148.1", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } +} diff --git a/packages/odatav4/steps/10/tsconfig.json b/packages/odatav4/steps/10/tsconfig.json new file mode 100644 index 000000000..6945c3011 --- /dev/null +++ b/packages/odatav4/steps/10/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "es2023", + "types": [ + "@openui5/types" + ], + "skipLibCheck": true, + "allowJs": true, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "paths": { + "ui5/tutorial/odatav4/*": [ + "./webapp/*" + ] + }, + "strict": false, + "strictNullChecks": false + }, + "include": [ + "./webapp/**/*" + ] +} diff --git a/packages/odatav4/steps/10/ui5.yaml b/packages/odatav4/steps/10/ui5.yaml new file mode 100644 index 000000000..4861ad50b --- /dev/null +++ b/packages/odatav4/steps/10/ui5.yaml @@ -0,0 +1,25 @@ +specVersion: "4.0" +metadata: + name: "ui5.tutorial.odatav4" +type: application +framework: + name: OpenUI5 + version: "1.148.1" + libraries: + - name: sap.m + - name: sap.f + - name: sap.ui.layout + - name: sap.ui.core + - name: themelib_sap_horizon +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression + - name: ui5-middleware-serveframework + afterMiddleware: compression + - name: ui5-middleware-livereload + afterMiddleware: compression diff --git a/packages/odatav4/steps/10/webapp/Component.ts b/packages/odatav4/steps/10/webapp/Component.ts new file mode 100644 index 000000000..170cbb94c --- /dev/null +++ b/packages/odatav4/steps/10/webapp/Component.ts @@ -0,0 +1,27 @@ +// Component.ts β€” UI5 OData V4 Tutorial +import UIComponent from "sap/ui/core/UIComponent"; +import { createDeviceModel } from "./model/models"; + +/** + * @namespace ui5.tutorial.odatav4 + */ +export default class Component extends UIComponent { + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], + manifest: "json" + }; + + /** + * The component is initialized by UI5 automatically during the startup of the app and calls + * the init method once. + * @public + * @override + */ + init(): void { + // call the base component's init function + super.init(); + + // set the device model + this.setModel(createDeviceModel(), "device"); + } +} diff --git a/packages/odatav4/steps/10/webapp/controller/App.controller.ts b/packages/odatav4/steps/10/webapp/controller/App.controller.ts new file mode 100644 index 000000000..7722959a3 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/controller/App.controller.ts @@ -0,0 +1,312 @@ +import Messaging from "sap/ui/core/Messaging"; +import Controller from "sap/ui/core/mvc/Controller"; +import MessageToast from "sap/m/MessageToast"; +import MessageBox from "sap/m/MessageBox"; +import Sorter from "sap/ui/model/Sorter"; +import Filter from "sap/ui/model/Filter"; +import FilterOperator from "sap/ui/model/FilterOperator"; +import FilterType from "sap/ui/model/FilterType"; +import JSONModel from "sap/ui/model/json/JSONModel"; +import ResourceModel from "sap/ui/model/resource/ResourceModel"; +import ResourceBundle from "sap/base/i18n/ResourceBundle"; +import Component from "sap/ui/core/Component"; +import List from "sap/m/List"; +import type ColumnListItem from "sap/m/ColumnListItem"; +import type SearchField from "sap/m/SearchField"; +import type ListBinding from "sap/ui/model/ListBinding"; +import type Event from "sap/ui/base/Event"; +import type Input from "sap/m/Input"; +import type Context from "sap/ui/model/odata/v4/Context"; +import type ODataModel from "sap/ui/model/odata/v4/ODataModel"; +import type ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding"; + +/** + * @namespace ui5.tutorial.odatav4.controller + */ +export default class App extends Controller { + + private _bTechnicalErrors = false; + + /** + * Hook for initializing the controller + */ + onInit(): void { + const messageModel = Messaging.getMessageModel(); + const messageModelBinding = messageModel.bindList("/", undefined, [], + new Filter("technical", FilterOperator.EQ, true)); + const viewModel = new JSONModel({ + busy: false, + hasUIChanges: false, + usernameEmpty: false, + order: 0 + }); + + this.getView().setModel(viewModel, "appView"); + this.getView().setModel(messageModel, "message"); + + messageModelBinding.attachChange(this.onMessageBindingChange, this); + this._bTechnicalErrors = false; + } + + /* =========================================================== */ + /* begin: event handlers */ + /* =========================================================== */ + + /** + * Create a new entry. + */ + onCreate(): void { + const list = this.byId("peopleList") as List; + const binding = list.getBinding("items") as ODataListBinding; + // Create a new entry through the table's list binding + const context = binding.create({ Age: "18" }); + + this._setUIChanges(true); + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", true); + + // Select and focus the table row that contains the newly created entry + list.getItems().some((item) => { + const columnItem = item as ColumnListItem; + if (columnItem.getBindingContext() === context) { + columnItem.focus(); + columnItem.setSelected(true); + return true; + } + return false; + }); + } + + /** + * Delete an entry. + */ + onDelete(): void { + const peopleList = this.byId("peopleList") as List; + const selected = peopleList.getSelectedItem() as ColumnListItem | null; + + if (selected) { + const context = selected.getBindingContext() as Context; + const userName = context.getProperty("UserName") as string; + void context.delete().then(() => { + MessageToast.show(this._getText("deletionSuccessMessage", [userName])); + }, (error2: Error & { canceled?: boolean }) => { + const currentSelected = peopleList.getSelectedItem() as ColumnListItem | null; + if (currentSelected && context === currentSelected.getBindingContext()) { + this._setDetailArea(context); + } + this._setUIChanges(); + if (error2.canceled) { + MessageToast.show(this._getText("deletionRestoredMessage", [userName])); + return; + } + MessageBox.error(error2.message + ": " + userName); + }); + this._setDetailArea(); + this._setUIChanges(); + } + } + + /** + * Lock UI when changing data in the input controls + */ + onInputChange(evt: Event): void { + if ((evt as unknown as { getParameter(n: string): unknown }).getParameter("escPressed")) { + this._setUIChanges(); + } else { + this._setUIChanges(true); + // Check if the username in the changed table row is empty and set the appView + // property accordingly + const ctx = (evt.getSource() as Input).getParent()?.getBindingContext(); + if (ctx && ctx.getProperty("UserName")) { + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", false); + } + } + } + + /** + * Refresh the data. + */ + onRefresh(): void { + const binding = (this.byId("peopleList") as List).getBinding("items") as ODataListBinding; + + if (binding.hasPendingChanges()) { + MessageBox.error(this._getText("refreshNotPossibleMessage")); + return; + } + binding.refresh(); + MessageToast.show(this._getText("refreshSuccessMessage")); + } + + /** + * Reset any unsaved changes. + */ + onResetChanges(): void { + ((this.byId("peopleList") as List).getBinding("items") as ODataListBinding).resetChanges(); + // If there were technical errors, cancelling changes resets them. + this._bTechnicalErrors = false; + this._setUIChanges(false); + } + + /** + * Reset the data source. + */ + onResetDataSource(): void { + const model = this.getView().getModel() as ODataModel; + const operation = model.bindContext("/ResetDataSource(...)"); + + void operation.invoke().then(() => { + model.refresh(); + MessageToast.show(this._getText("sourceResetSuccessMessage")); + }, (error2: Error) => { + MessageBox.error(error2.message); + }); + } + + /** + * Save changes to the source. + */ + onSave(): void { + const success = () => { + this._setBusy(false); + MessageToast.show(this._getText("changesSentMessage")); + this._setUIChanges(false); + }; + const error = (error2: Error) => { + this._setBusy(false); + this._setUIChanges(false); + MessageBox.error(error2.message); + }; + + this._setBusy(true); // Lock UI until submitBatch is resolved. + (this.getView().getModel() as ODataModel).submitBatch("peopleGroup").then(success, error); + // If there were technical errors, a new save resets them. + this._bTechnicalErrors = false; + } + + /** + * Search for the term in the search field. + */ + onSearch(): void { + const view = this.getView(); + const value = (view.byId("searchField") as SearchField).getValue(); + const filter = new Filter("LastName", FilterOperator.Contains, value); + + ((view.byId("peopleList") as List).getBinding("items") as ListBinding).filter(filter, FilterType.Application); + } + + /** + * Sort the table according to the last name. + * Cycles between the three sorting states "none", "ascending" and "descending" + */ + onSort(): void { + const view = this.getView(); + const states: (string | undefined)[] = [undefined, "asc", "desc"]; + const stateTextIds = ["sortNone", "sortAscending", "sortDescending"]; + let order = (view.getModel("appView") as JSONModel).getProperty("/order") as number; + + // Cycle between the states + order = (order + 1) % states.length; + const order2 = states[order]; + + (view.getModel("appView") as JSONModel).setProperty("/order", order); + ((view.byId("peopleList") as List).getBinding("items") as ListBinding) + .sort(order2 ? new Sorter("LastName", order2 === "desc") : []); + + const message = this._getText("sortMessage", [this._getText(stateTextIds[order])]); + MessageToast.show(message); + } + + onMessageBindingChange(event: Event): void { + const contexts = (event.getSource() as ListBinding).getContexts(); + let messageOpen = false; + + if (messageOpen || !contexts.length) { + return; + } + + // Extract and remove the technical messages + const messages = contexts.map((context: Context) => context.getObject()); + Messaging.removeMessages(messages); + + this._setUIChanges(true); + this._bTechnicalErrors = true; + MessageBox.error((messages[0] as { message: string }).message, { + id: "serviceErrorMessageBox", + onClose: () => { + messageOpen = false; + } + }); + + messageOpen = true; + } + + onSelectionChange(event: Event): void { + const listItem = (event as unknown as { getParameter(n: string): unknown }).getParameter("listItem") as ColumnListItem; + this._setDetailArea(listItem.getBindingContext() as Context); + } + + /* =========================================================== */ + /* end: event handlers */ + /* =========================================================== */ + + /** + * Convenience method for retrieving a translatable text. + */ + _getText(textId: string, args?: unknown[]): string { + const bundle = ((this.getOwnerComponent() as Component).getModel("i18n") as ResourceModel).getResourceBundle() as ResourceBundle; + return bundle.getText(textId, args as string[]); + } + + /** + * Set hasUIChanges flag in View Model + * @param bHasUIChanges - set or clear hasUIChanges; if undefined, the hasPendingChanges-function + * of the OdataV4 model determines the result + */ + _setUIChanges(hasUIChanges?: boolean): void { + if (this._bTechnicalErrors) { + // If there is currently a technical error, then force 'true'. + hasUIChanges = true; + } else if (hasUIChanges === undefined) { + hasUIChanges = (this.getView().getModel() as ODataModel).hasPendingChanges(); + } + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/hasUIChanges", hasUIChanges); + } + + /** + * Set busy flag in View Model + */ + _setBusy(isBusy: boolean): void { + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/busy", isBusy); + } + + /** + * Toggles the visibility of the detail area + * @param oUserContext - the current user context + */ + _setDetailArea(userContext?: Context): void { + const detailArea = this.byId("detailArea"); + const layout = this.byId("defaultLayout") as unknown as { setSize(s: string): void; setResizable(b: boolean): void }; + const searchField = this.byId("searchField") as SearchField; + + if (!detailArea) { + return; // do nothing during view destruction + } + + const oldContext = (detailArea as unknown as { getBindingContext(): Context | null }).getBindingContext(); + if (oldContext && !oldContext.isTransient()) { + oldContext.setKeepAlive(false); + } + if (userContext && !userContext.isTransient()) { + userContext.setKeepAlive(true, + // hide details if kept entity was refreshed but does not exist any more + this._setDetailArea.bind(this)); + } + (detailArea as unknown as { setBindingContext(c: Context | null): void }).setBindingContext(userContext || null); + // resize view + (detailArea as unknown as { setVisible(b: boolean): void }).setVisible(!!userContext); + layout.setSize(userContext ? "60%" : "100%"); + layout.setResizable(!!userContext); + searchField.setWidth(userContext ? "40%" : "20%"); + } +} diff --git a/packages/odatav4/steps/10/webapp/i18n/i18n.properties b/packages/odatav4/steps/10/webapp/i18n/i18n.properties new file mode 100644 index 000000000..e1b8c4a8c --- /dev/null +++ b/packages/odatav4/steps/10/webapp/i18n/i18n.properties @@ -0,0 +1,100 @@ +# App Descriptor +#XTIT: Application name +appTitle=OData V4 - Step 10: Context#setKeepAlive + +#YDES: Application description +appDescription=OData V4 Tutorial + +#XTIT: Page Title +peoplePageTitle=My Users + +# Toolbar +#XBUT: Button text for save +saveButtonText=Save + +#XBUT: Button text for reset changes +resetChangesButtonText=Restart Tutorial + +#XBUT: Button text for cancel +cancelButtonText=Cancel + +#XTOL: Tooltip for sort +sortButtonText=Sort by Last Name + +#XBUT: Button text for add user +createButtonText=Add User + +#XBUT: Button text for delete user +deleteButtonText=Delete User + +#XTOL: Tooltip for refresh data +refreshButtonText=Refresh Data + +#XTXT: Placeholder text for search field +searchFieldPlaceholder=Type in a last name + +# Table Area +#XFLD: Label for User Name +userNameLabelText=User Name + +#XFLD: Label for First Name +firstNameLabelText=First Name + +#XFLD: Label for Last Name +lastNameLabelText=Last Name + +#XFLD: Label for Age +ageLabelText=Age + +# Messages +#XMSG: Message for user changes sent to the service +changesSentMessage=User data sent to the server + +#XMSG: Message for user deleted +deletionSuccessMessage=User {0} deleted + +#XMSG: Message for user restored (undeleted) +deletionRestoredMessage=User {0} restored + +#XMSG: Message for changes reverted +sourceResetSuccessMessage=All changes reverted back to start + +#XMSG: Message for refresh failed +refreshNotPossibleMessage=Before refreshing, please save or revert your changes + +#XMSG: Message for refresh succeeded +refreshSuccessMessage=Data refreshed + +#MSG: Message for sorting +sortMessage=Users sorted by {0} + +#MSG: Suffix for sorting by LastName, ascending +sortAscending=last name, ascending + +#MSG: Suffix for sorting by LastName, descending +sortDescending=last name, descending + +#MSG: Suffix for no sorting +sortNone=the sequence on the server + +# Detail Area +#XTIT: Title for Address +addressTitleText=Address + +#XFLD: Label for Address +addressLabelText=Address + +#XFLD: Label for City +cityLabelText=City + +#XFLD: Label for Region +regionLabelText=Region + +#XFLD: Label for Country +countryLabelText=Country + +#XTIT: Title for Best Friend +bestFriendTitleText=Best Friend + +#XFLD: Label for Best Friend Name +nameLabelText=Name diff --git a/packages/odatav4/steps/10/webapp/index-cdn.html b/packages/odatav4/steps/10/webapp/index-cdn.html new file mode 100644 index 000000000..692229401 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/index-cdn.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/10/webapp/index.html b/packages/odatav4/steps/10/webapp/index.html new file mode 100644 index 000000000..b1e54667d --- /dev/null +++ b/packages/odatav4/steps/10/webapp/index.html @@ -0,0 +1,22 @@ + + + + + + OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/10/webapp/initMockServer.ts b/packages/odatav4/steps/10/webapp/initMockServer.ts new file mode 100644 index 000000000..0150138fe --- /dev/null +++ b/packages/odatav4/steps/10/webapp/initMockServer.ts @@ -0,0 +1,11 @@ +// initMockServer.ts β€” bootstraps the mock server before the component starts +import MessageBox from "sap/m/MessageBox"; +import mockserver from "./localService/mockserver"; + +// initialize the mock server +mockserver.init().catch((oError: Error) => { + MessageBox.error(oError.message); +}).finally(() => { + // initialize the embedded component on the HTML page + void import("sap/ui/core/ComponentSupport"); +}); diff --git a/packages/odatav4/steps/10/webapp/localService/metadata.xml b/packages/odatav4/steps/10/webapp/localService/metadata.xml new file mode 100644 index 000000000..fe46ab7a5 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/localService/metadata.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Name + + + + + + + + + + + + + diff --git a/packages/odatav4/steps/10/webapp/localService/mockdata/people.json b/packages/odatav4/steps/10/webapp/localService/mockdata/people.json new file mode 100644 index 000000000..758030eaa --- /dev/null +++ b/packages/odatav4/steps/10/webapp/localService/mockdata/people.json @@ -0,0 +1,399 @@ +{ + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(id))/$metadata#People(Age,FirstName,LastName,UserName,Friends,BestFriend,HomeAddress)", + "value": [ + { + "Age": 23, + "FirstName": "Angel", + "LastName": "Huffman", + "UserName": "angelhuffman", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "clydeguess", + "HomeAddress": { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + }, + { + "Age": 44, + "FirstName": "Clyde", + "LastName": "Guess", + "UserName": "clydeguess", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "angelhuffman", + "HomeAddress": { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 19, + "FirstName": "Elaine", + "LastName": "Stewart", + "UserName": "elainestewart", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "genevievereeves", + "HomeAddress": { + "Address": "308 Negra Arroyo Ln.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 37, + "FirstName": "Genevieve", + "LastName": "Reeves", + "UserName": "genevievereeves", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "elainestewart", + "HomeAddress": { + "Address": "89 Jefferson Way Suite 2", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "WA" + } + } + }, + { + "Age": 25, + "FirstName": "Georgina", + "LastName": "Barlow", + "UserName": "georginabarlow", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "javieralfred", + "HomeAddress": { + "Address": "31 Spooner Street", + "City": { + "Name": "Quahog", + "CountryRegion": "United States", + "Region": "RI" + } + } + }, + { + "Age": 19, + "FirstName": "Javier", + "LastName": "Alfred", + "UserName": "javieralfred", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "georginabarlow", + "HomeAddress": { + "Address": "55 Grizzly Peak Rd.", + "City": { + "Name": "Butte", + "CountryRegion": "United States", + "Region": "MT" + } + } + }, + { + "Age": 26, + "FirstName": "Joni", + "LastName": "Rosales", + "UserName": "jonirosales", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "keithpinckney", + "HomeAddress": { + "Address": "742 Evergreen Terrace", + "City": { + "Name": "Springfield", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 41, + "FirstName": "Keith", + "LastName": "Pinckney", + "UserName": "keithpinckney", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "jonirosales", + "HomeAddress": { + "Address": "1600 Pennsylvania Avenue NW", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 30, + "FirstName": "Krista", + "LastName": "Kemp", + "UserName": "kristakemp", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "laurelosborn", + "HomeAddress": { + "Address": "Statue of Liberty", + "City": { + "Name": "New York", + "CountryRegion": "United States", + "Region": "NY" + } + } + }, + { + "Age": 29, + "FirstName": "Laurel", + "LastName": "Osborn", + "UserName": "laurelosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "kristakemp", + "HomeAddress": { + "Address": "4059 Mt Lee Dr. Hollywood", + "City": { + "Name": "Los Angelos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 53, + "FirstName": "Marshall", + "LastName": "Garay", + "UserName": "marshallgaray", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "ronaldmundy", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 66, + "FirstName": "Ronald", + "LastName": "Mundy", + "UserName": "ronaldmundy", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "marshallgaray", + "HomeAddress": { + "Address": "89 Chiaroscuro Rd.", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 51, + "FirstName": "Russell", + "LastName": "Whyte", + "UserName": "russellwhyte", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "ryantheriault", + "HomeAddress": { + "Address": "Tony Stark Mansion, Point Dume", + "City": { + "Name": "Malibu", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 57, + "FirstName": "Ryan", + "LastName": "Theriault", + "UserName": "ryantheriault", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "russellwhyte", + "HomeAddress": { + "Address": "2311 N. Los Robles Ave. Apt 4A", + "City": { + "Name": "Pasadena", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 34, + "FirstName": "Sallie", + "LastName": "Sampson", + "UserName": "salliesampson", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "sandyosborn", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 18, + "FirstName": "Sandy", + "LastName": "Osborn", + "UserName": "sandyosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "salliesampson", + "HomeAddress": { + "Address": "Grove Street, Ganton", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 24, + "FirstName": "Scott", + "LastName": "Ketchum", + "UserName": "scottketchum", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "ursulabright", + "HomeAddress": { + "Address": "Portola Drive, Rockford Hills", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 31, + "FirstName": "Ursula", + "LastName": "Bright", + "UserName": "ursulabright", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "scottketchum", + "HomeAddress": { + "Address": "First St. SE", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 40, + "FirstName": "Vincent", + "LastName": "Calabrese", + "UserName": "vincentcalabrese", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "willieashmore", + "HomeAddress": { + "Address": "4 Privet Drive", + "City": { + "Name": "Little Whinging", + "CountryRegion": "Great Britain", + "Region": "SRY" + } + } + }, + { + "Age": 45, + "FirstName": "Willie", + "LastName": "Ashmore", + "UserName": "willieashmore", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "vincentcalabrese", + "HomeAddress": { + "Address": "124 Conch St.", + "City": { + "Name": "Bikini Bottom", + "CountryRegion": "Pacific Ocean", + "Region": "N/D" + } + } + } + ] +} diff --git a/packages/odatav4/steps/10/webapp/localService/mockserver.ts b/packages/odatav4/steps/10/webapp/localService/mockserver.ts new file mode 100644 index 000000000..7ca0307f4 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/localService/mockserver.ts @@ -0,0 +1,782 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ +import JSONModel from "sap/ui/model/json/JSONModel"; +import Log from "sap/base/Log"; + +// Pull sinon from the UI5 third-party shim. The shim has no TS typings; +// we treat the imported value as a structural any so the mock implementation +// stays close to the original JavaScript. +// eslint-disable-next-line @typescript-eslint/no-require-imports +declare const sap: any; + +interface MockUser { + UserName: string; + FirstName?: string; + LastName?: string; + Age?: number; + Friends?: string[]; + BestFriend?: string; + [key: string]: unknown; +} + +interface MockXhr { + method: string; + url: string; + requestBody?: string; + respond?: (status: number, headers: Record, body: string | null) => void; +} + +type MockResponse = [number, Record, string | null] | [number, Record]; + +// sinon is provided by the "sap/ui/thirdparty/sinon" module β€” see the require below +let sinon: any; +let sandbox: any; +let users: MockUser[]; // The array that holds the cached user data +let metadata: string; // The string that holds the cached mock service metadata +const namespace = "ui5/tutorial/odatav4"; +// Component for writing logs into the console +const logComponent = "ui5.tutorial.odatav4.mockserver"; +const rBaseUrl = /services.odata.org\/TripPinRESTierService/; + +/** + * Returns the base URL from a given URL. + * @param url - the complete URL + * @returns the base URL + */ +function getBaseUrl(url: string): string { + const matches = url.match(/http.+\(S\(.+\)\)\//); + + if (!Array.isArray(matches) || matches.length < 1) { + throw new Error("Could not find a base URL in " + url); + } + + return matches[0]; +} + +/** + * Looks for a user with a given user name and returns its index in the user array. + * @param userName - the user name to look for. + * @returns index of that user in the array, or -1 if the user was not found. + */ +function findUserIndex(userName: string): number { + for (let i = 0; i < users.length; i++) { + if (users[i].UserName === userName) { + return i; + } + } + return -1; +} + +/** + * Retrieves any user data from a given http request body. + * @param body - the http request body. + * @returns the parsed user data. + */ +function getUserDataFromRequestBody(body: string): MockUser { + const matches = body.match(/({.+})/); + + if (!Array.isArray(matches) || matches.length !== 2) { + throw new Error("Could not find any user data in " + body); + } + return JSON.parse(matches[1]) as MockUser; +} + +/** + * Retrieves a user name from a given request URL. + * @param url - the request URL. + * @returns the user name or undefined if no user was found. + */ +function getUserKeyFromUrl(url: string): string | undefined { + const matches = url.match(/People\('(.*)'\)/); + return matches ? matches[1] : undefined; +} + +/** + * Checks if a given UserName is unique or already used + * @param userName - the UserName to be checked + * @returns True if the UserName is unique (not used), false otherwise + */ +function isUnique(userName: string): boolean { + return findUserIndex(userName) < 0; +} + +/** + * Returns a proper HTTP response body for "duplicate key" errors + * @param key - the duplicate key + * @returns the proper response body + */ +function duplicateKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "409", + message: "There is already a user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function invalidKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "404", + message: "There is no user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function getSuccessResponse(responseBody: string): MockResponse { + return [ + 200, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Reads and caches the fake service metadata and data from their + * respective files. + * @returns a promise that is resolved when the data is loaded + */ +function readData(): Promise { + const metadataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/metadata.xml"); + const request = new XMLHttpRequest(); + + request.onload = function () { + // 404 is not an error for XMLHttpRequest so we need to handle it here + if (request.status === 404) { + const error = "resource " + resourcePath + " not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + metadata = this.responseText; + resolve(); + }; + request.onerror = function () { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }; + request.open("GET", resourcePath); + request.send(); + }); + + const mockDataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/mockdata/people.json"); + const mockDataModel = new JSONModel(resourcePath); + + mockDataModel.attachRequestCompleted(function (this: JSONModel, event: any) { + // 404 is not an error for JSONModel so we need to handle it here + if (event.getParameter("errorobject") + && event.getParameter("errorobject").statusCode === 404) { + const error = "resource '" + resourcePath + "' not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + users = (this.getData() as { value: MockUser[] }).value; + resolve(); + }); + + mockDataModel.attachRequestFailed(() => { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }); + }); + + return Promise.all([metadataPromise, mockDataPromise]); +} + +/** + * Reduces a given result set by applying the OData URL parameters 'skip' and 'top' to it. + * Does NOT change the given result set but returns a new array. + */ +function applySkipTop(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const reducedUsers = [...resultSet]; + const matches = xhr.url.match(/\$skip=(\d+)&\$top=(\d+)/); + + if (Array.isArray(matches) && matches.length >= 3) { + const skip = parseInt(matches[1], 10); + const top = parseInt(matches[2], 10); + return resultSet.slice(skip, skip + top); + } + + return reducedUsers; +} + +/** + * Sorts a given result set by applying the OData URL parameter 'orderby'. + * Does NOT change the given result set but returns a new array. + */ +function applySort(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const sortedUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$orderby=(\w*)(?:%20(\w*))?/); + + if (!Array.isArray(matches) || matches.length < 2) { + return sortedUsers; + } + const fieldName = matches[1]; + const direction = matches[2] || "asc"; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + sortedUsers.sort((a, b) => { + const nameA = (a.LastName || "").toUpperCase(); + const nameB = (b.LastName || "").toUpperCase(); + const asc = direction === "asc"; + + if (nameA < nameB) { + return asc ? -1 : 1; + } + if (nameA > nameB) { + return asc ? 1 : -1; + } + return 0; + }); + + return sortedUsers; +} + +/** + * Filters a given result set by applying the OData URL parameter 'filter'. + * Does NOT change the given result set but returns a new array. + */ +function applyFilter(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + let filteredUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$filter=.*\((.*),'(.*)'\)/); + + // If the request contains a filter command, apply the filter + if (Array.isArray(matches) && matches.length >= 3) { + const fieldName = matches[1]; + const query = matches[2]; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + filteredUsers = users.filter((user) => (user.LastName || "").indexOf(query) !== -1); + } + + return filteredUsers; +} + +/** + * Handles GET requests for metadata. + */ +function handleGetMetadataRequests(): MockResponse { + return [ + 200, + { + "Content-Type": "application/xml", + "odata-version": "4.0" + }, metadata + ]; +} + +/** + * Handles GET requests for a pure user count and returns a fitting response. + */ +function handleGetCountRequests(): MockResponse { + return getSuccessResponse(users.length.toString()); +} + +/** + * Handles GET requests for user data and returns a fitting response. + */ +function handleGetUserRequests(xhr: MockXhr, _bCount: boolean): MockResponse { + let count: number; + let expand: RegExpMatchArray | string[] | null; + let expand2: string; + let index: number; + let key: string | undefined; + let response: { "@odata.count"?: number; value: MockUser[] } | MockUser | null; + let responseBody: string; + let result: MockUser[]; + let select: RegExpMatchArray | string[] | null; + let select2: string; + let subSelects: string[][] = []; + let i: number; + + // Get expand parameter + expand = xhr.url.match(/\$expand=([^&]+)/); + + // Sort out expand parameter values + subSelects in brackets + if (expand) { + expand2 = expand[0]; + expand2 = expand2.substring(8); + + // Sort out subselects (e.g. BestFriend($select=Age,UserName),Friend) + const subSelectMatches = expand2.match(/\([^)]*\)/g) || []; + subSelects = subSelectMatches.map((s) => s.replace(/\(\$select=/, "").replace(/\)/, "").split(",")); + expand2 = expand2.replace(/\([^)]*\)/g, ""); + expand = expand2.split(","); + } + + // Get select parameter + select = xhr.url.match(/[^(]\$select=([\w|,]+)/); + + // Sort out select parameter values + if (Array.isArray(select)) { + select2 = select[0]; + select2 = select2.replace(/&/, "").replace(/\?/, "").substring(8); + select = select2.split(","); + } + + // Check if an individual user or a user range is requested + key = getUserKeyFromUrl(xhr.url); + if (key) { + index = findUserIndex(key); + + if (/People\(.+\)\/Friends/.test(xhr.url)) { + // ownRequest for friends + response = { value: createFriendsArray(users[index].Friends, select as string[]) }; + } else { + // specific user was requested + response = getUserObject(index, select as string[], expand as string[], subSelects); + } + + if (index > -1) { + responseBody = JSON.stringify(response); + return getSuccessResponse(responseBody); + } + responseBody = invalidKeyError(key); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // all users requested + result = applyFilter(xhr, users); + count = result.length; // the total no. of people found, after filtering + result = applySort(xhr, result); + result = applySkipTop(xhr, result); + + // generate sResponse + const finalResponse: { "@odata.count": number; value: MockUser[] } = { "@odata.count": count, value: [] }; + + result.forEach((user) => { + const userIndex = findUserIndex(user.UserName); + + finalResponse.value.push(getUserObject(userIndex, select as string[], expand as string[], subSelects) as MockUser); + }); + + responseBody = JSON.stringify(finalResponse); + + return getSuccessResponse(responseBody); +} + +/** + * Returns a specific user in the aUsers array. + */ +function getUserByIndex(index: number, properties: string[]): MockUser | null { + const helper: MockUser = { UserName: "" }; + const user = users[index]; + + if (user) { + properties.forEach((selectProperty) => { + helper[selectProperty] = user[selectProperty]; + }); + + return helper; + } + return null; +} + +/** + * Returns the user with iIndex in the aUsers array with all its information + */ +function getUserObject(index: number, select: string[], expand: string[] | null | undefined, subSelects: string[][]): MockUser | null { + let bestFriend: string | undefined; + let friendIndex: number; + let friends: string[] | undefined; + let object: MockUser | null; + let user: MockUser; + let i: number; + + object = getUserByIndex(index, select); + if (expand && object) { + user = users[index]; + for (i = 0; i < expand.length; i++) { + switch (expand[i]) { + case "Friends": + friends = user.Friends; + object.Friends = createFriendsArray(friends, subSelects[i]) as unknown as string[]; + break; + case "BestFriend": + bestFriend = user.BestFriend; + friendIndex = findUserIndex(bestFriend || ""); + object.BestFriend = getUserByIndex(friendIndex, subSelects[i]) as unknown as string; + break; + default: + break; + } + } + } + return object; +} + +/** + * creates array of friends for a given user + */ +function createFriendsArray(friends: string[] | undefined, subSelects: string[]): MockUser[] { + let array: (MockUser | null)[] = []; + + if (friends) { + friends.forEach((friend) => { + const friendIndex = findUserIndex(friend); + array.push(getUserByIndex(friendIndex, subSelects)); + }); + + array = array.filter((element) => element !== null); + } + + return array as MockUser[]; +} + +/** + * Handles PATCH requests for users and returns a fitting response. + */ +function handlePatchUserRequests(xhr: MockXhr): MockResponse { + // Get the key of the person to change + const key = getUserKeyFromUrl(xhr.url); + + // Get the list of changes + const changes = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if the UserName is changed to a duplicate. + // If the UserName is "changed" to its current value, that is not an error. + if (Object.prototype.hasOwnProperty.call(changes, "UserName") + && changes.UserName !== key + && !isUnique(changes.UserName)) { + // Error + const responseBody = duplicateKeyError(changes.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // No error: make the change(s) + const user = users[findUserIndex(key || "")]; + for (const fieldName in changes) { + if (Object.prototype.hasOwnProperty.call(changes, fieldName)) { + user[fieldName] = changes[fieldName]; + } + } + + // The response to PATCH requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles DELETE requests for users and returns a fitting response. + */ +function handleDeleteUserRequests(xhr: MockXhr): MockResponse { + const key = getUserKeyFromUrl(xhr.url); + users.splice(findUserIndex(key || ""), 1); + + // The response to DELETE requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles POST requests for users and returns a fitting response. + */ +function handlePostUserRequests(xhr: MockXhr): MockResponse { + const user = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if that user already exists + if (isUnique(user.UserName)) { + users.push(user); + + let responseBody = '{"@odata.context": "' + getBaseUrl(xhr.url) + + '$metadata#People/$entity",'; + responseBody += JSON.stringify(user).slice(1); + + // The response to POST requests is http 201 (Created) + return [ + 201, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; + } + // Error + const responseBody = duplicateKeyError(user.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; +} + +/** + * Handles POST requests for resetting the data and returns a fitting response. + */ +function handleResetDataRequest(): MockResponse { + void readData(); + + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Builds a response to direct (= non-batch) requests. + * Supports GET, PATCH, DELETE and POST requests. + */ +function handleDirectRequest(xhr: MockXhr): MockResponse | undefined { + let response2: MockResponse | undefined; + + switch (xhr.method) { + case "GET": + if (/\$metadata/.test(xhr.url)) { + response2 = handleGetMetadataRequests(); + } else if (/\/\$count/.test(xhr.url)) { + response2 = handleGetCountRequests(); + } else if (/People.*\?/.test(xhr.url)) { + response2 = handleGetUserRequests(xhr, /\$count=true/.test(xhr.url)); + } + break; + case "PATCH": + if (/People/.test(xhr.url)) { + response2 = handlePatchUserRequests(xhr); + } + break; + case "POST": + if (/People/.test(xhr.url)) { + response2 = handlePostUserRequests(xhr); + } else if (/ResetDataSource/.test(xhr.url)) { + response2 = handleResetDataRequest(); + } + break; + case "DELETE": + if (/People/.test(xhr.url)) { + response2 = handleDeleteUserRequests(xhr); + } + break; + case "HEAD": + response2 = [204, {}]; + break; + default: + break; + } + + return response2; +} + +/** + * Builds a response to batch requests. + * Unwraps batch request, gets a response for each individual part and + * constructs a fitting batch response. + */ +function handleBatchRequest(xhr: MockXhr): MockResponse { + let responseBody = ""; + const outerBoundary = (xhr.requestBody || "").match(/(.*)/)![1]; // First line of the body + let innerBoundary: string | undefined; + let partBoundary: string; + // The individual requests + const outerParts = (xhr.requestBody || "").split(outerBoundary).slice(1, -1); + let parts: string[]; + let header: string; + + const matches = outerParts[0].match(/multipart\/mixed;boundary=(.+)/); + // If this request has several change sets, then we need to handle the inner and outer + // boundaries (change sets have an additional boundary) + if (matches && matches.length > 0) { + innerBoundary = matches[1]; + parts = outerParts[0].split("--" + innerBoundary).slice(1, -1); + } else { + parts = outerParts; + } + + // If this request has several change sets, then the response must start with the outer + // boundary and content header + if (innerBoundary) { + partBoundary = "--" + innerBoundary; + responseBody += outerBoundary + "\r\n" + + "Content-Type: multipart/mixed; boundary=" + innerBoundary + "\r\n\r\n"; + } else { + partBoundary = outerBoundary; + } + + parts.forEach((part, index) => { + // Construct the batch response body out of the single batch request parts. + const matches0 = part.match(/(GET|DELETE|PATCH|POST) (\S+)(?:.|\r?\n)+\r?\n(.*)\r?\n$/)!; + const partResponse = handleDirectRequest({ + method: matches0[1], + url: getBaseUrl(xhr.url) + matches0[2], + requestBody: matches0[3] + })!; + + responseBody += partBoundary + "\r\n" + + "Content-Type: application/http\r\n"; + // If there are several change sets, we need to add a Content ID header + if (innerBoundary) { + responseBody += "Content-ID:" + index + ".0\r\n"; + } + responseBody += "\r\nHttp/1.1 " + partResponse[0] + "\r\n"; + // Add any headers from the request - unless this response is 204 (no content) + if (partResponse[1] && partResponse[0] !== 204) { + for (header in partResponse[1]) { + if (Object.prototype.hasOwnProperty.call(partResponse[1], header)) { + responseBody += header + ": " + partResponse[1][header] + "\r\n"; + } + } + } + responseBody += "\r\n"; + + if (partResponse[2]) { + responseBody += partResponse[2]; + } + responseBody += "\r\n"; + }); + + // Check if we need to add the inner boundary again at the end + if (innerBoundary) { + responseBody += "--" + innerBoundary + "--\r\n"; + } + // Add a final boundary to the batch response body + responseBody += outerBoundary + "--"; + + // Build the final batch response + return [ + 200, + { + "Content-Type": "multipart/mixed;boundary=" + outerBoundary.slice(2), + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Handles any type of intercepted request and sends a fake response. + * Logs the request and response to the console. + * Manages batch requests. + */ +function handleAllRequests(xhr: MockXhr): void { + let response2: MockResponse | undefined; + + // Log the request + Log.info( + "Mockserver: Received " + xhr.method + " request to URL " + xhr.url, + (xhr.requestBody ? "Request body is:\n" + xhr.requestBody : "No request body.") + + "\n", + logComponent + ); + + if (xhr.method === "POST" && /\$batch/.test(xhr.url)) { + response2 = handleBatchRequest(xhr); + } else { + response2 = handleDirectRequest(xhr); + } + + if (xhr.respond && response2) { + xhr.respond(response2[0], response2[1], response2[2] || null); + } + + // Log the response + if (response2) { + Log.info( + "Mockserver: Sent response with return code " + response2[0], + ("Response headers: " + JSON.stringify(response2[1]) + "\n\nResponse body:\n" + + (response2[2] || "")) + "\n", + logComponent + ); + } +} + +export default { + + /** + * Creates a Sinon fake service, intercepting all http requests to + * the URL defined in variable rBaseUrl above. + * @returns a promise that is resolved when the mock server is started + */ + init(): Promise { + // Load sinon lazily from the UI5 third-party shim + return new Promise((resolve, reject) => { + sap.ui.require(["sap/ui/thirdparty/sinon"], (lazySinon: any) => { + sinon = lazySinon; + sandbox = sinon.sandbox.create(); + + // Read the mock data + readData().then(() => { + // Initialize the sinon fake server + sandbox.useFakeServer(); + // Make sure that requests are responded to automatically. Otherwise we would need + // to do that manually. + sandbox.server.autoRespond = true; + + // Register the requests for which responses should be faked. + sandbox.server.respondWith(rBaseUrl, handleAllRequests); + + // Apply a filter to the fake XmlHttpRequest. + // Otherwise, ALL requests (e.g. for the component, views etc.) would be + // intercepted. + sinon.FakeXMLHttpRequest.useFilters = true; + sinon.FakeXMLHttpRequest.addFilter((_sMethod: string, url: string) => { + // If the filter returns true, the request will NOT be faked. + // We only want to fake requests that go to the intended service. + return !rBaseUrl.test(url); + }); + + // Set the logging level for console entries from the mock server + Log.setLevel(Log.Level.INFO, logComponent); + + Log.info("Running the app with mock data", logComponent); + resolve(undefined); + }, reject); + }); + }); + }, + + /** + * Stops the request interception and deletes the Sinon fake server. + */ + stop(): void { + if (sinon) { + sinon.FakeXMLHttpRequest.filters = []; + sinon.FakeXMLHttpRequest.useFilters = false; + } + if (sandbox) { + sandbox.restore(); + sandbox = null; + } + } +}; diff --git a/packages/odatav4/steps/10/webapp/manifest.json b/packages/odatav4/steps/10/webapp/manifest.json new file mode 100644 index 000000000..5575dd133 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/manifest.json @@ -0,0 +1,69 @@ +{ + "_version": "2.8.0", + "sap.app": { + "id": "ui5.tutorial.odatav4", + "type": "application", + "i18n": { + "supportedLocales": [ + "" + ], + "fallbackLocale": "", + "bundleName": "ui5.tutorial.odatav4.i18n.i18n" + }, + "applicationVersion": { + "version": "1.0.0" + }, + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "dataSources": { + "default": { + "uri": "https://services.odata.org/TripPinRESTierService/(S(id))/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + } + }, + "sap.ui": { + "technology": "UI5" + }, + "sap.ui5": { + "rootView": { + "viewName": "ui5.tutorial.odatav4.view.App", + "type": "XML", + "id": "appView" + }, + "dependencies": { + "minUI5Version": "1.148", + "libs": { + "sap.f": {}, + "sap.m": {}, + "sap.ui.core": {}, + "sap.ui.layout": {} + } + }, + "handleValidation": true, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "ui5.tutorial.odatav4.i18n.i18n", + "supportedLocales": [ + "" + ], + "fallbackLocale": "" + } + }, + "": { + "dataSource": "default", + "preload": true, + "settings": { + "autoExpandSelect": true, + "earlyRequests": true, + "operationMode": "Server" + } + } + } + } +} diff --git a/packages/odatav4/steps/10/webapp/model/models.ts b/packages/odatav4/steps/10/webapp/model/models.ts new file mode 100644 index 000000000..5fdcff4a3 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/model/models.ts @@ -0,0 +1,10 @@ +// models.ts β€” central application models +import JSONModel from "sap/ui/model/json/JSONModel"; +import Device from "sap/ui/Device"; + +export function createDeviceModel(): JSONModel { + const model = new JSONModel(Device); + + model.setDefaultBindingMode("OneWay"); + return model; +} diff --git a/packages/odatav4/steps/10/webapp/view/App.view.xml b/packages/odatav4/steps/10/webapp/view/App.view.xml new file mode 100644 index 000000000..8098b3099 --- /dev/null +++ b/packages/odatav4/steps/10/webapp/view/App.view.xml @@ -0,0 +1,270 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + </semantic:titleHeading> + <semantic:headerContent> + <FlexBox> + <VBox> + <ObjectAttribute text="{i18n>userNameLabelText}"/> + <ObjectAttribute text="{UserName}"/> + </VBox> + <VBox class="sapUiMediumMarginBegin"> + <ObjectAttribute text="{i18n>ageLabelText}"/> + <ObjectNumber number="{Age}" unit="Years"/> + </VBox> + </FlexBox> + </semantic:headerContent> + <semantic:content> + <VBox> + <FlexBox wrap="Wrap"> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>addressTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>addressLabelText}"> + <f:fields> + <Text text="{HomeAddress/Address}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>cityLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Name}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>regionLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Region}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>countryLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/CountryRegion}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>bestFriendTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>nameLabelText}"> + <f:fields> + <Text text="{BestFriend/FirstName} {BestFriend/LastName}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>ageLabelText}"> + <f:fields> + <Text text="{BestFriend/Age}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>userNameLabelText}"> + <f:fields> + <Text text="{BestFriend/UserName}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + </FlexBox> + </VBox> + </semantic:content> + </semantic:SemanticPage> + </l:SplitPane> + </l:PaneContainer> + </l:ResponsiveSplitter> + </content> + <footer> + <Toolbar visible="{appView>/hasUIChanges}"> + <ToolbarSpacer/> + <Button + id="saveButton" + type="Emphasized" + text="{i18n>saveButtonText}" + enabled="{= ${message>/}.length === 0 && ${appView>/usernameEmpty} === false }" + press=".onSave"/> + <Button + id="doneButton" + text="{i18n>cancelButtonText}" + press=".onResetChanges"/> + </Toolbar> + </footer> + </Page> + </pages> + </App> + </Shell> +</mvc:View> diff --git a/packages/odatav4/steps/11/README.md b/packages/odatav4/steps/11/README.md new file mode 100644 index 000000000..0363e192b --- /dev/null +++ b/packages/odatav4/steps/11/README.md @@ -0,0 +1,108 @@ +# Step 11: Add Table with :n Navigation to Detail Area + +<details class="ts-only" markdown="1"> + +You can download the solution for this step here: [πŸ“₯ Download step 11](https://ui5.github.io/tutorials/odatav4/odatav4-step-11.zip). + +</details> + +<details class="js-only" markdown="1"> + +You can download the solution for this step here: [πŸ“₯ Download step 11](https://ui5.github.io/tutorials/odatav4/odatav4-step-11-js.zip). + +</details> + +In this step we add a table with additional information to the detail area. + +## Preview + +**A table containing information about friends of the selected user is added** + +![](assets/Tut_OD4_Step_11_45abd62.png "A table containing information about friends of the selected user is added") + +## Coding + +You can view this step live: [πŸ”— Live Preview of Step 11](https://ui5.github.io/tutorials/odatav4/build/11/index-cdn.html). + +## webapp/view/App.view.xml + +```xml +<mvc:View +... + <VBox> + <FlexBox wrap="Wrap"> + ... + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>bestFriendTitleText}" /> + </f:title> + ... + <f:formContainers> + <f:FormContainer> + <f:formElements> + ... + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + </FlexBox> + <Table + id="friendsTable" + width="auto" + items="{path: 'Friends', + parameters: { + $$ownRequest: true + }}" + noDataText="No Data" + class="sapUiSmallMarginBottom"> + <headerToolbar> + <Toolbar> + <Title + text="Friends" + titleStyle="H3" + level="H3"/> + </Toolbar> + </headerToolbar> + <columns> + <Column> + <Text text="User Name"/> + </Column> + <Column> + <Text text="First Name"/> + </Column> + <Column> + <Text text="Last Name"/> + </Column> + <Column> + <Text text="Age"/> + </Column> + </columns> + <items> + <ColumnListItem> + <cells> + <Text text="{UserName}"/> + </cells> + <cells> + <Text text="{FirstName}"/> + </cells> + <cells> + <Text text="{LastName}"/> + </cells> + <cells> + <Text text="{Age}"/> + </cells> + </ColumnListItem> + </items> + </Table> + </VBox> +... +</mvc:View> +``` + +We extend the detail area of the `appView` by adding a table after the `FlexBox`. To this table we add a data binding for friends. It is important that we set the `$$ownRequest` binding parameter to `true`, so that the table containing all friends of the selected user makes its own OData requests separate from the request for best friend and best friend's address. + +*** + +**Next:** [Step 1: The Initial App](../01/README.md) + +**Previous:** [Step 10: Enable Data Reuse](../10/README.md) diff --git a/packages/odatav4/steps/11/package.json b/packages/odatav4/steps/11/package.json new file mode 100644 index 000000000..3cbe93d37 --- /dev/null +++ b/packages/odatav4/steps/11/package.json @@ -0,0 +1,20 @@ +{ + "name": "ui5.tutorial.odatav4.step11", + "version": "1.0.0", + "author": "SAP SE", + "description": "OpenUI5 TypeScript Tutorial β€” OData V4: Step 11 β€” Add Table with :n Navigation to Detail Area", + "private": true, + "scripts": { + "start": "ui5 serve -o index.html", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@openui5/types": "^1.148.1", + "@types/qunit": "2.5.4", + "@ui5/cli": "^4.0.53", + "typescript": "^6.0.3", + "ui5-middleware-livereload": "^3.3.1", + "ui5-middleware-serveframework": "^3.8.1", + "ui5-tooling-transpile": "^3.11.0" + } +} diff --git a/packages/odatav4/steps/11/tsconfig.json b/packages/odatav4/steps/11/tsconfig.json new file mode 100644 index 000000000..ae17cb7b2 --- /dev/null +++ b/packages/odatav4/steps/11/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "es2023", + "types": [ + "@openui5/types", + "@types/qunit" + ], + "skipLibCheck": true, + "allowJs": true, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "paths": { + "ui5/tutorial/odatav4/*": [ + "./webapp/*" + ] + }, + "strict": false, + "strictNullChecks": false + }, + "include": [ + "./webapp/**/*" + ] +} diff --git a/packages/odatav4/steps/11/ui5.yaml b/packages/odatav4/steps/11/ui5.yaml new file mode 100644 index 000000000..4861ad50b --- /dev/null +++ b/packages/odatav4/steps/11/ui5.yaml @@ -0,0 +1,25 @@ +specVersion: "4.0" +metadata: + name: "ui5.tutorial.odatav4" +type: application +framework: + name: OpenUI5 + version: "1.148.1" + libraries: + - name: sap.m + - name: sap.f + - name: sap.ui.layout + - name: sap.ui.core + - name: themelib_sap_horizon +builder: + customTasks: + - name: ui5-tooling-transpile-task + afterTask: replaceVersion +server: + customMiddleware: + - name: ui5-tooling-transpile-middleware + afterMiddleware: compression + - name: ui5-middleware-serveframework + afterMiddleware: compression + - name: ui5-middleware-livereload + afterMiddleware: compression diff --git a/packages/odatav4/steps/11/webapp/Component.ts b/packages/odatav4/steps/11/webapp/Component.ts new file mode 100644 index 000000000..170cbb94c --- /dev/null +++ b/packages/odatav4/steps/11/webapp/Component.ts @@ -0,0 +1,27 @@ +// Component.ts β€” UI5 OData V4 Tutorial +import UIComponent from "sap/ui/core/UIComponent"; +import { createDeviceModel } from "./model/models"; + +/** + * @namespace ui5.tutorial.odatav4 + */ +export default class Component extends UIComponent { + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], + manifest: "json" + }; + + /** + * The component is initialized by UI5 automatically during the startup of the app and calls + * the init method once. + * @public + * @override + */ + init(): void { + // call the base component's init function + super.init(); + + // set the device model + this.setModel(createDeviceModel(), "device"); + } +} diff --git a/packages/odatav4/steps/11/webapp/controller/App.controller.ts b/packages/odatav4/steps/11/webapp/controller/App.controller.ts new file mode 100644 index 000000000..7722959a3 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/controller/App.controller.ts @@ -0,0 +1,312 @@ +import Messaging from "sap/ui/core/Messaging"; +import Controller from "sap/ui/core/mvc/Controller"; +import MessageToast from "sap/m/MessageToast"; +import MessageBox from "sap/m/MessageBox"; +import Sorter from "sap/ui/model/Sorter"; +import Filter from "sap/ui/model/Filter"; +import FilterOperator from "sap/ui/model/FilterOperator"; +import FilterType from "sap/ui/model/FilterType"; +import JSONModel from "sap/ui/model/json/JSONModel"; +import ResourceModel from "sap/ui/model/resource/ResourceModel"; +import ResourceBundle from "sap/base/i18n/ResourceBundle"; +import Component from "sap/ui/core/Component"; +import List from "sap/m/List"; +import type ColumnListItem from "sap/m/ColumnListItem"; +import type SearchField from "sap/m/SearchField"; +import type ListBinding from "sap/ui/model/ListBinding"; +import type Event from "sap/ui/base/Event"; +import type Input from "sap/m/Input"; +import type Context from "sap/ui/model/odata/v4/Context"; +import type ODataModel from "sap/ui/model/odata/v4/ODataModel"; +import type ODataListBinding from "sap/ui/model/odata/v4/ODataListBinding"; + +/** + * @namespace ui5.tutorial.odatav4.controller + */ +export default class App extends Controller { + + private _bTechnicalErrors = false; + + /** + * Hook for initializing the controller + */ + onInit(): void { + const messageModel = Messaging.getMessageModel(); + const messageModelBinding = messageModel.bindList("/", undefined, [], + new Filter("technical", FilterOperator.EQ, true)); + const viewModel = new JSONModel({ + busy: false, + hasUIChanges: false, + usernameEmpty: false, + order: 0 + }); + + this.getView().setModel(viewModel, "appView"); + this.getView().setModel(messageModel, "message"); + + messageModelBinding.attachChange(this.onMessageBindingChange, this); + this._bTechnicalErrors = false; + } + + /* =========================================================== */ + /* begin: event handlers */ + /* =========================================================== */ + + /** + * Create a new entry. + */ + onCreate(): void { + const list = this.byId("peopleList") as List; + const binding = list.getBinding("items") as ODataListBinding; + // Create a new entry through the table's list binding + const context = binding.create({ Age: "18" }); + + this._setUIChanges(true); + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", true); + + // Select and focus the table row that contains the newly created entry + list.getItems().some((item) => { + const columnItem = item as ColumnListItem; + if (columnItem.getBindingContext() === context) { + columnItem.focus(); + columnItem.setSelected(true); + return true; + } + return false; + }); + } + + /** + * Delete an entry. + */ + onDelete(): void { + const peopleList = this.byId("peopleList") as List; + const selected = peopleList.getSelectedItem() as ColumnListItem | null; + + if (selected) { + const context = selected.getBindingContext() as Context; + const userName = context.getProperty("UserName") as string; + void context.delete().then(() => { + MessageToast.show(this._getText("deletionSuccessMessage", [userName])); + }, (error2: Error & { canceled?: boolean }) => { + const currentSelected = peopleList.getSelectedItem() as ColumnListItem | null; + if (currentSelected && context === currentSelected.getBindingContext()) { + this._setDetailArea(context); + } + this._setUIChanges(); + if (error2.canceled) { + MessageToast.show(this._getText("deletionRestoredMessage", [userName])); + return; + } + MessageBox.error(error2.message + ": " + userName); + }); + this._setDetailArea(); + this._setUIChanges(); + } + } + + /** + * Lock UI when changing data in the input controls + */ + onInputChange(evt: Event): void { + if ((evt as unknown as { getParameter(n: string): unknown }).getParameter("escPressed")) { + this._setUIChanges(); + } else { + this._setUIChanges(true); + // Check if the username in the changed table row is empty and set the appView + // property accordingly + const ctx = (evt.getSource() as Input).getParent()?.getBindingContext(); + if (ctx && ctx.getProperty("UserName")) { + (this.getView().getModel("appView") as JSONModel).setProperty("/usernameEmpty", false); + } + } + } + + /** + * Refresh the data. + */ + onRefresh(): void { + const binding = (this.byId("peopleList") as List).getBinding("items") as ODataListBinding; + + if (binding.hasPendingChanges()) { + MessageBox.error(this._getText("refreshNotPossibleMessage")); + return; + } + binding.refresh(); + MessageToast.show(this._getText("refreshSuccessMessage")); + } + + /** + * Reset any unsaved changes. + */ + onResetChanges(): void { + ((this.byId("peopleList") as List).getBinding("items") as ODataListBinding).resetChanges(); + // If there were technical errors, cancelling changes resets them. + this._bTechnicalErrors = false; + this._setUIChanges(false); + } + + /** + * Reset the data source. + */ + onResetDataSource(): void { + const model = this.getView().getModel() as ODataModel; + const operation = model.bindContext("/ResetDataSource(...)"); + + void operation.invoke().then(() => { + model.refresh(); + MessageToast.show(this._getText("sourceResetSuccessMessage")); + }, (error2: Error) => { + MessageBox.error(error2.message); + }); + } + + /** + * Save changes to the source. + */ + onSave(): void { + const success = () => { + this._setBusy(false); + MessageToast.show(this._getText("changesSentMessage")); + this._setUIChanges(false); + }; + const error = (error2: Error) => { + this._setBusy(false); + this._setUIChanges(false); + MessageBox.error(error2.message); + }; + + this._setBusy(true); // Lock UI until submitBatch is resolved. + (this.getView().getModel() as ODataModel).submitBatch("peopleGroup").then(success, error); + // If there were technical errors, a new save resets them. + this._bTechnicalErrors = false; + } + + /** + * Search for the term in the search field. + */ + onSearch(): void { + const view = this.getView(); + const value = (view.byId("searchField") as SearchField).getValue(); + const filter = new Filter("LastName", FilterOperator.Contains, value); + + ((view.byId("peopleList") as List).getBinding("items") as ListBinding).filter(filter, FilterType.Application); + } + + /** + * Sort the table according to the last name. + * Cycles between the three sorting states "none", "ascending" and "descending" + */ + onSort(): void { + const view = this.getView(); + const states: (string | undefined)[] = [undefined, "asc", "desc"]; + const stateTextIds = ["sortNone", "sortAscending", "sortDescending"]; + let order = (view.getModel("appView") as JSONModel).getProperty("/order") as number; + + // Cycle between the states + order = (order + 1) % states.length; + const order2 = states[order]; + + (view.getModel("appView") as JSONModel).setProperty("/order", order); + ((view.byId("peopleList") as List).getBinding("items") as ListBinding) + .sort(order2 ? new Sorter("LastName", order2 === "desc") : []); + + const message = this._getText("sortMessage", [this._getText(stateTextIds[order])]); + MessageToast.show(message); + } + + onMessageBindingChange(event: Event): void { + const contexts = (event.getSource() as ListBinding).getContexts(); + let messageOpen = false; + + if (messageOpen || !contexts.length) { + return; + } + + // Extract and remove the technical messages + const messages = contexts.map((context: Context) => context.getObject()); + Messaging.removeMessages(messages); + + this._setUIChanges(true); + this._bTechnicalErrors = true; + MessageBox.error((messages[0] as { message: string }).message, { + id: "serviceErrorMessageBox", + onClose: () => { + messageOpen = false; + } + }); + + messageOpen = true; + } + + onSelectionChange(event: Event): void { + const listItem = (event as unknown as { getParameter(n: string): unknown }).getParameter("listItem") as ColumnListItem; + this._setDetailArea(listItem.getBindingContext() as Context); + } + + /* =========================================================== */ + /* end: event handlers */ + /* =========================================================== */ + + /** + * Convenience method for retrieving a translatable text. + */ + _getText(textId: string, args?: unknown[]): string { + const bundle = ((this.getOwnerComponent() as Component).getModel("i18n") as ResourceModel).getResourceBundle() as ResourceBundle; + return bundle.getText(textId, args as string[]); + } + + /** + * Set hasUIChanges flag in View Model + * @param bHasUIChanges - set or clear hasUIChanges; if undefined, the hasPendingChanges-function + * of the OdataV4 model determines the result + */ + _setUIChanges(hasUIChanges?: boolean): void { + if (this._bTechnicalErrors) { + // If there is currently a technical error, then force 'true'. + hasUIChanges = true; + } else if (hasUIChanges === undefined) { + hasUIChanges = (this.getView().getModel() as ODataModel).hasPendingChanges(); + } + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/hasUIChanges", hasUIChanges); + } + + /** + * Set busy flag in View Model + */ + _setBusy(isBusy: boolean): void { + const model = this.getView().getModel("appView") as JSONModel; + model.setProperty("/busy", isBusy); + } + + /** + * Toggles the visibility of the detail area + * @param oUserContext - the current user context + */ + _setDetailArea(userContext?: Context): void { + const detailArea = this.byId("detailArea"); + const layout = this.byId("defaultLayout") as unknown as { setSize(s: string): void; setResizable(b: boolean): void }; + const searchField = this.byId("searchField") as SearchField; + + if (!detailArea) { + return; // do nothing during view destruction + } + + const oldContext = (detailArea as unknown as { getBindingContext(): Context | null }).getBindingContext(); + if (oldContext && !oldContext.isTransient()) { + oldContext.setKeepAlive(false); + } + if (userContext && !userContext.isTransient()) { + userContext.setKeepAlive(true, + // hide details if kept entity was refreshed but does not exist any more + this._setDetailArea.bind(this)); + } + (detailArea as unknown as { setBindingContext(c: Context | null): void }).setBindingContext(userContext || null); + // resize view + (detailArea as unknown as { setVisible(b: boolean): void }).setVisible(!!userContext); + layout.setSize(userContext ? "60%" : "100%"); + layout.setResizable(!!userContext); + searchField.setWidth(userContext ? "40%" : "20%"); + } +} diff --git a/packages/odatav4/steps/11/webapp/i18n/i18n.properties b/packages/odatav4/steps/11/webapp/i18n/i18n.properties new file mode 100644 index 000000000..a4fa06200 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/i18n/i18n.properties @@ -0,0 +1,100 @@ +# App Descriptor +#XTIT: Application name +appTitle=OData V4 - Step 11: Add table with :n navigation to detail area + +#YDES: Application description +appDescription=OData V4 Tutorial + +#XTIT: Page Title +peoplePageTitle=My Users + +# Toolbar +#XBUT: Button text for save +saveButtonText=Save + +#XBUT: Button text for reset changes +resetChangesButtonText=Restart Tutorial + +#XBUT: Button text for cancel +cancelButtonText=Cancel + +#XTOL: Tooltip for sort +sortButtonText=Sort by Last Name + +#XBUT: Button text for add user +createButtonText=Add User + +#XBUT: Button text for delete user +deleteButtonText=Delete User + +#XTOL: Tooltip for refresh data +refreshButtonText=Refresh Data + +#XTXT: Placeholder text for search field +searchFieldPlaceholder=Type in a last name + +# Table Area +#XFLD: Label for User Name +userNameLabelText=User Name + +#XFLD: Label for First Name +firstNameLabelText=First Name + +#XFLD: Label for Last Name +lastNameLabelText=Last Name + +#XFLD: Label for Age +ageLabelText=Age + +# Messages +#XMSG: Message for user changes sent to the service +changesSentMessage=User data sent to the server + +#XMSG: Message for user deleted +deletionSuccessMessage=User {0} deleted + +#XMSG: Message for user restored (undeleted) +deletionRestoredMessage=User {0} restored + +#XMSG: Message for changes reverted +sourceResetSuccessMessage=All changes reverted back to start + +#XMSG: Message for refresh failed +refreshNotPossibleMessage=Before refreshing, please save or revert your changes + +#XMSG: Message for refresh succeeded +refreshSuccessMessage=Data refreshed + +#MSG: Message for sorting +sortMessage=Users sorted by {0} + +#MSG: Suffix for sorting by LastName, ascending +sortAscending=last name, ascending + +#MSG: Suffix for sorting by LastName, descending +sortDescending=last name, descending + +#MSG: Suffix for no sorting +sortNone=the sequence on the server + +# Detail Area +#XTIT: Title for Address +addressTitleText=Address + +#XFLD: Label for Address +addressLabelText=Address + +#XFLD: Label for City +cityLabelText=City + +#XFLD: Label for Region +regionLabelText=Region + +#XFLD: Label for Country +countryLabelText=Country + +#XTIT: Title for Best Friend +bestFriendTitleText=Best Friend + +#XFLD: Label for Best Friend Name +nameLabelText=Name diff --git a/packages/odatav4/steps/11/webapp/index-cdn.html b/packages/odatav4/steps/11/webapp/index-cdn.html new file mode 100644 index 000000000..692229401 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/index-cdn.html @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/11/webapp/index.html b/packages/odatav4/steps/11/webapp/index.html new file mode 100644 index 000000000..b1e54667d --- /dev/null +++ b/packages/odatav4/steps/11/webapp/index.html @@ -0,0 +1,22 @@ + + + + + + OData V4 Tutorial + + + +
+ + diff --git a/packages/odatav4/steps/11/webapp/initMockServer.ts b/packages/odatav4/steps/11/webapp/initMockServer.ts new file mode 100644 index 000000000..0150138fe --- /dev/null +++ b/packages/odatav4/steps/11/webapp/initMockServer.ts @@ -0,0 +1,11 @@ +// initMockServer.ts β€” bootstraps the mock server before the component starts +import MessageBox from "sap/m/MessageBox"; +import mockserver from "./localService/mockserver"; + +// initialize the mock server +mockserver.init().catch((oError: Error) => { + MessageBox.error(oError.message); +}).finally(() => { + // initialize the embedded component on the HTML page + void import("sap/ui/core/ComponentSupport"); +}); diff --git a/packages/odatav4/steps/11/webapp/localService/metadata.xml b/packages/odatav4/steps/11/webapp/localService/metadata.xml new file mode 100644 index 000000000..fe46ab7a5 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/localService/metadata.xml @@ -0,0 +1,193 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Name + + + + + + + + + + + + + diff --git a/packages/odatav4/steps/11/webapp/localService/mockdata/people.json b/packages/odatav4/steps/11/webapp/localService/mockdata/people.json new file mode 100644 index 000000000..758030eaa --- /dev/null +++ b/packages/odatav4/steps/11/webapp/localService/mockdata/people.json @@ -0,0 +1,399 @@ +{ + "@odata.context": "https://services.odata.org/TripPinRESTierService/(S(id))/$metadata#People(Age,FirstName,LastName,UserName,Friends,BestFriend,HomeAddress)", + "value": [ + { + "Age": 23, + "FirstName": "Angel", + "LastName": "Huffman", + "UserName": "angelhuffman", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "clydeguess", + "HomeAddress": { + "Address": "187 Suffolk Ln.", + "City": { + "Name": "Boise", + "CountryRegion": "United States", + "Region": "ID" + } + } + }, + { + "Age": 44, + "FirstName": "Clyde", + "LastName": "Guess", + "UserName": "clydeguess", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "angelhuffman", + "HomeAddress": { + "Address": "2817 Milton Dr.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 19, + "FirstName": "Elaine", + "LastName": "Stewart", + "UserName": "elainestewart", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "genevievereeves", + "HomeAddress": { + "Address": "308 Negra Arroyo Ln.", + "City": { + "Name": "Albuquerque", + "CountryRegion": "United States", + "Region": "NM" + } + } + }, + { + "Age": 37, + "FirstName": "Genevieve", + "LastName": "Reeves", + "UserName": "genevievereeves", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "elainestewart", + "HomeAddress": { + "Address": "89 Jefferson Way Suite 2", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "WA" + } + } + }, + { + "Age": 25, + "FirstName": "Georgina", + "LastName": "Barlow", + "UserName": "georginabarlow", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "javieralfred", + "HomeAddress": { + "Address": "31 Spooner Street", + "City": { + "Name": "Quahog", + "CountryRegion": "United States", + "Region": "RI" + } + } + }, + { + "Age": 19, + "FirstName": "Javier", + "LastName": "Alfred", + "UserName": "javieralfred", + "Friends": [ + "marshallgaray", + "ursulabright" + ], + "BestFriend": "georginabarlow", + "HomeAddress": { + "Address": "55 Grizzly Peak Rd.", + "City": { + "Name": "Butte", + "CountryRegion": "United States", + "Region": "MT" + } + } + }, + { + "Age": 26, + "FirstName": "Joni", + "LastName": "Rosales", + "UserName": "jonirosales", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "keithpinckney", + "HomeAddress": { + "Address": "742 Evergreen Terrace", + "City": { + "Name": "Springfield", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 41, + "FirstName": "Keith", + "LastName": "Pinckney", + "UserName": "keithpinckney", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "jonirosales", + "HomeAddress": { + "Address": "1600 Pennsylvania Avenue NW", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 30, + "FirstName": "Krista", + "LastName": "Kemp", + "UserName": "kristakemp", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "laurelosborn", + "HomeAddress": { + "Address": "Statue of Liberty", + "City": { + "Name": "New York", + "CountryRegion": "United States", + "Region": "NY" + } + } + }, + { + "Age": 29, + "FirstName": "Laurel", + "LastName": "Osborn", + "UserName": "laurelosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "kristakemp", + "HomeAddress": { + "Address": "4059 Mt Lee Dr. Hollywood", + "City": { + "Name": "Los Angelos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 53, + "FirstName": "Marshall", + "LastName": "Garay", + "UserName": "marshallgaray", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "ronaldmundy", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 66, + "FirstName": "Ronald", + "LastName": "Mundy", + "UserName": "ronaldmundy", + "Friends": [ + "georginabarlow", + "ursulabright" + ], + "BestFriend": "marshallgaray", + "HomeAddress": { + "Address": "89 Chiaroscuro Rd.", + "City": { + "Name": "Portland", + "CountryRegion": "United States", + "Region": "OR" + } + } + }, + { + "Age": 51, + "FirstName": "Russell", + "LastName": "Whyte", + "UserName": "russellwhyte", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "ryantheriault", + "HomeAddress": { + "Address": "Tony Stark Mansion, Point Dume", + "City": { + "Name": "Malibu", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 57, + "FirstName": "Ryan", + "LastName": "Theriault", + "UserName": "ryantheriault", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "russellwhyte", + "HomeAddress": { + "Address": "2311 N. Los Robles Ave. Apt 4A", + "City": { + "Name": "Pasadena", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 34, + "FirstName": "Sallie", + "LastName": "Sampson", + "UserName": "salliesampson", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "sandyosborn", + "HomeAddress": { + "Address": "87 Polk St. Suite 5", + "City": { + "Name": "San Francisco", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 18, + "FirstName": "Sandy", + "LastName": "Osborn", + "UserName": "sandyosborn", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "salliesampson", + "HomeAddress": { + "Address": "Grove Street, Ganton", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 24, + "FirstName": "Scott", + "LastName": "Ketchum", + "UserName": "scottketchum", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "ursulabright", + "HomeAddress": { + "Address": "Portola Drive, Rockford Hills", + "City": { + "Name": "Los Santos", + "CountryRegion": "United States", + "Region": "CA" + } + } + }, + { + "Age": 31, + "FirstName": "Ursula", + "LastName": "Bright", + "UserName": "ursulabright", + "Friends": [ + "georginabarlow", + "marshallgaray" + ], + "BestFriend": "scottketchum", + "HomeAddress": { + "Address": "First St. SE", + "City": { + "Name": "Washington", + "CountryRegion": "United States", + "Region": "DC" + } + } + }, + { + "Age": 40, + "FirstName": "Vincent", + "LastName": "Calabrese", + "UserName": "vincentcalabrese", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "willieashmore", + "HomeAddress": { + "Address": "4 Privet Drive", + "City": { + "Name": "Little Whinging", + "CountryRegion": "Great Britain", + "Region": "SRY" + } + } + }, + { + "Age": 45, + "FirstName": "Willie", + "LastName": "Ashmore", + "UserName": "willieashmore", + "Friends": [ + "georginabarlow", + "marshallgaray", + "ursulabright" + ], + "BestFriend": "vincentcalabrese", + "HomeAddress": { + "Address": "124 Conch St.", + "City": { + "Name": "Bikini Bottom", + "CountryRegion": "Pacific Ocean", + "Region": "N/D" + } + } + } + ] +} diff --git a/packages/odatav4/steps/11/webapp/localService/mockserver.ts b/packages/odatav4/steps/11/webapp/localService/mockserver.ts new file mode 100644 index 000000000..7ca0307f4 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/localService/mockserver.ts @@ -0,0 +1,782 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any */ +import JSONModel from "sap/ui/model/json/JSONModel"; +import Log from "sap/base/Log"; + +// Pull sinon from the UI5 third-party shim. The shim has no TS typings; +// we treat the imported value as a structural any so the mock implementation +// stays close to the original JavaScript. +// eslint-disable-next-line @typescript-eslint/no-require-imports +declare const sap: any; + +interface MockUser { + UserName: string; + FirstName?: string; + LastName?: string; + Age?: number; + Friends?: string[]; + BestFriend?: string; + [key: string]: unknown; +} + +interface MockXhr { + method: string; + url: string; + requestBody?: string; + respond?: (status: number, headers: Record, body: string | null) => void; +} + +type MockResponse = [number, Record, string | null] | [number, Record]; + +// sinon is provided by the "sap/ui/thirdparty/sinon" module β€” see the require below +let sinon: any; +let sandbox: any; +let users: MockUser[]; // The array that holds the cached user data +let metadata: string; // The string that holds the cached mock service metadata +const namespace = "ui5/tutorial/odatav4"; +// Component for writing logs into the console +const logComponent = "ui5.tutorial.odatav4.mockserver"; +const rBaseUrl = /services.odata.org\/TripPinRESTierService/; + +/** + * Returns the base URL from a given URL. + * @param url - the complete URL + * @returns the base URL + */ +function getBaseUrl(url: string): string { + const matches = url.match(/http.+\(S\(.+\)\)\//); + + if (!Array.isArray(matches) || matches.length < 1) { + throw new Error("Could not find a base URL in " + url); + } + + return matches[0]; +} + +/** + * Looks for a user with a given user name and returns its index in the user array. + * @param userName - the user name to look for. + * @returns index of that user in the array, or -1 if the user was not found. + */ +function findUserIndex(userName: string): number { + for (let i = 0; i < users.length; i++) { + if (users[i].UserName === userName) { + return i; + } + } + return -1; +} + +/** + * Retrieves any user data from a given http request body. + * @param body - the http request body. + * @returns the parsed user data. + */ +function getUserDataFromRequestBody(body: string): MockUser { + const matches = body.match(/({.+})/); + + if (!Array.isArray(matches) || matches.length !== 2) { + throw new Error("Could not find any user data in " + body); + } + return JSON.parse(matches[1]) as MockUser; +} + +/** + * Retrieves a user name from a given request URL. + * @param url - the request URL. + * @returns the user name or undefined if no user was found. + */ +function getUserKeyFromUrl(url: string): string | undefined { + const matches = url.match(/People\('(.*)'\)/); + return matches ? matches[1] : undefined; +} + +/** + * Checks if a given UserName is unique or already used + * @param userName - the UserName to be checked + * @returns True if the UserName is unique (not used), false otherwise + */ +function isUnique(userName: string): boolean { + return findUserIndex(userName) < 0; +} + +/** + * Returns a proper HTTP response body for "duplicate key" errors + * @param key - the duplicate key + * @returns the proper response body + */ +function duplicateKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "409", + message: "There is already a user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function invalidKeyError(key: string): string { + return JSON.stringify({ + error: { + code: "404", + message: "There is no user with user name '" + key + "'.", + target: "UserName" + } + }); +} + +function getSuccessResponse(responseBody: string): MockResponse { + return [ + 200, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Reads and caches the fake service metadata and data from their + * respective files. + * @returns a promise that is resolved when the data is loaded + */ +function readData(): Promise { + const metadataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/metadata.xml"); + const request = new XMLHttpRequest(); + + request.onload = function () { + // 404 is not an error for XMLHttpRequest so we need to handle it here + if (request.status === 404) { + const error = "resource " + resourcePath + " not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + metadata = this.responseText; + resolve(); + }; + request.onerror = function () { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }; + request.open("GET", resourcePath); + request.send(); + }); + + const mockDataPromise = new Promise((resolve, reject) => { + const resourcePath = sap.ui.require.toUrl(namespace + "/localService/mockdata/people.json"); + const mockDataModel = new JSONModel(resourcePath); + + mockDataModel.attachRequestCompleted(function (this: JSONModel, event: any) { + // 404 is not an error for JSONModel so we need to handle it here + if (event.getParameter("errorobject") + && event.getParameter("errorobject").statusCode === 404) { + const error = "resource '" + resourcePath + "' not found"; + + Log.error(error, logComponent); + reject(new Error(error)); + } + users = (this.getData() as { value: MockUser[] }).value; + resolve(); + }); + + mockDataModel.attachRequestFailed(() => { + const error = "error loading resource '" + resourcePath + "'"; + + Log.error(error, logComponent); + reject(new Error(error)); + }); + }); + + return Promise.all([metadataPromise, mockDataPromise]); +} + +/** + * Reduces a given result set by applying the OData URL parameters 'skip' and 'top' to it. + * Does NOT change the given result set but returns a new array. + */ +function applySkipTop(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const reducedUsers = [...resultSet]; + const matches = xhr.url.match(/\$skip=(\d+)&\$top=(\d+)/); + + if (Array.isArray(matches) && matches.length >= 3) { + const skip = parseInt(matches[1], 10); + const top = parseInt(matches[2], 10); + return resultSet.slice(skip, skip + top); + } + + return reducedUsers; +} + +/** + * Sorts a given result set by applying the OData URL parameter 'orderby'. + * Does NOT change the given result set but returns a new array. + */ +function applySort(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + const sortedUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$orderby=(\w*)(?:%20(\w*))?/); + + if (!Array.isArray(matches) || matches.length < 2) { + return sortedUsers; + } + const fieldName = matches[1]; + const direction = matches[2] || "asc"; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + sortedUsers.sort((a, b) => { + const nameA = (a.LastName || "").toUpperCase(); + const nameB = (b.LastName || "").toUpperCase(); + const asc = direction === "asc"; + + if (nameA < nameB) { + return asc ? -1 : 1; + } + if (nameA > nameB) { + return asc ? 1 : -1; + } + return 0; + }); + + return sortedUsers; +} + +/** + * Filters a given result set by applying the OData URL parameter 'filter'. + * Does NOT change the given result set but returns a new array. + */ +function applyFilter(xhr: MockXhr, resultSet: MockUser[]): MockUser[] { + let filteredUsers = [...resultSet]; // work with a copy + const matches = xhr.url.match(/\$filter=.*\((.*),'(.*)'\)/); + + // If the request contains a filter command, apply the filter + if (Array.isArray(matches) && matches.length >= 3) { + const fieldName = matches[1]; + const query = matches[2]; + + if (fieldName !== "LastName") { + throw new Error("Filters on field " + fieldName + " are not supported."); + } + + filteredUsers = users.filter((user) => (user.LastName || "").indexOf(query) !== -1); + } + + return filteredUsers; +} + +/** + * Handles GET requests for metadata. + */ +function handleGetMetadataRequests(): MockResponse { + return [ + 200, + { + "Content-Type": "application/xml", + "odata-version": "4.0" + }, metadata + ]; +} + +/** + * Handles GET requests for a pure user count and returns a fitting response. + */ +function handleGetCountRequests(): MockResponse { + return getSuccessResponse(users.length.toString()); +} + +/** + * Handles GET requests for user data and returns a fitting response. + */ +function handleGetUserRequests(xhr: MockXhr, _bCount: boolean): MockResponse { + let count: number; + let expand: RegExpMatchArray | string[] | null; + let expand2: string; + let index: number; + let key: string | undefined; + let response: { "@odata.count"?: number; value: MockUser[] } | MockUser | null; + let responseBody: string; + let result: MockUser[]; + let select: RegExpMatchArray | string[] | null; + let select2: string; + let subSelects: string[][] = []; + let i: number; + + // Get expand parameter + expand = xhr.url.match(/\$expand=([^&]+)/); + + // Sort out expand parameter values + subSelects in brackets + if (expand) { + expand2 = expand[0]; + expand2 = expand2.substring(8); + + // Sort out subselects (e.g. BestFriend($select=Age,UserName),Friend) + const subSelectMatches = expand2.match(/\([^)]*\)/g) || []; + subSelects = subSelectMatches.map((s) => s.replace(/\(\$select=/, "").replace(/\)/, "").split(",")); + expand2 = expand2.replace(/\([^)]*\)/g, ""); + expand = expand2.split(","); + } + + // Get select parameter + select = xhr.url.match(/[^(]\$select=([\w|,]+)/); + + // Sort out select parameter values + if (Array.isArray(select)) { + select2 = select[0]; + select2 = select2.replace(/&/, "").replace(/\?/, "").substring(8); + select = select2.split(","); + } + + // Check if an individual user or a user range is requested + key = getUserKeyFromUrl(xhr.url); + if (key) { + index = findUserIndex(key); + + if (/People\(.+\)\/Friends/.test(xhr.url)) { + // ownRequest for friends + response = { value: createFriendsArray(users[index].Friends, select as string[]) }; + } else { + // specific user was requested + response = getUserObject(index, select as string[], expand as string[], subSelects); + } + + if (index > -1) { + responseBody = JSON.stringify(response); + return getSuccessResponse(responseBody); + } + responseBody = invalidKeyError(key); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // all users requested + result = applyFilter(xhr, users); + count = result.length; // the total no. of people found, after filtering + result = applySort(xhr, result); + result = applySkipTop(xhr, result); + + // generate sResponse + const finalResponse: { "@odata.count": number; value: MockUser[] } = { "@odata.count": count, value: [] }; + + result.forEach((user) => { + const userIndex = findUserIndex(user.UserName); + + finalResponse.value.push(getUserObject(userIndex, select as string[], expand as string[], subSelects) as MockUser); + }); + + responseBody = JSON.stringify(finalResponse); + + return getSuccessResponse(responseBody); +} + +/** + * Returns a specific user in the aUsers array. + */ +function getUserByIndex(index: number, properties: string[]): MockUser | null { + const helper: MockUser = { UserName: "" }; + const user = users[index]; + + if (user) { + properties.forEach((selectProperty) => { + helper[selectProperty] = user[selectProperty]; + }); + + return helper; + } + return null; +} + +/** + * Returns the user with iIndex in the aUsers array with all its information + */ +function getUserObject(index: number, select: string[], expand: string[] | null | undefined, subSelects: string[][]): MockUser | null { + let bestFriend: string | undefined; + let friendIndex: number; + let friends: string[] | undefined; + let object: MockUser | null; + let user: MockUser; + let i: number; + + object = getUserByIndex(index, select); + if (expand && object) { + user = users[index]; + for (i = 0; i < expand.length; i++) { + switch (expand[i]) { + case "Friends": + friends = user.Friends; + object.Friends = createFriendsArray(friends, subSelects[i]) as unknown as string[]; + break; + case "BestFriend": + bestFriend = user.BestFriend; + friendIndex = findUserIndex(bestFriend || ""); + object.BestFriend = getUserByIndex(friendIndex, subSelects[i]) as unknown as string; + break; + default: + break; + } + } + } + return object; +} + +/** + * creates array of friends for a given user + */ +function createFriendsArray(friends: string[] | undefined, subSelects: string[]): MockUser[] { + let array: (MockUser | null)[] = []; + + if (friends) { + friends.forEach((friend) => { + const friendIndex = findUserIndex(friend); + array.push(getUserByIndex(friendIndex, subSelects)); + }); + + array = array.filter((element) => element !== null); + } + + return array as MockUser[]; +} + +/** + * Handles PATCH requests for users and returns a fitting response. + */ +function handlePatchUserRequests(xhr: MockXhr): MockResponse { + // Get the key of the person to change + const key = getUserKeyFromUrl(xhr.url); + + // Get the list of changes + const changes = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if the UserName is changed to a duplicate. + // If the UserName is "changed" to its current value, that is not an error. + if (Object.prototype.hasOwnProperty.call(changes, "UserName") + && changes.UserName !== key + && !isUnique(changes.UserName)) { + // Error + const responseBody = duplicateKeyError(changes.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; + } + // No error: make the change(s) + const user = users[findUserIndex(key || "")]; + for (const fieldName in changes) { + if (Object.prototype.hasOwnProperty.call(changes, fieldName)) { + user[fieldName] = changes[fieldName]; + } + } + + // The response to PATCH requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles DELETE requests for users and returns a fitting response. + */ +function handleDeleteUserRequests(xhr: MockXhr): MockResponse { + const key = getUserKeyFromUrl(xhr.url); + users.splice(findUserIndex(key || ""), 1); + + // The response to DELETE requests is always http 204 (No Content) + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Handles POST requests for users and returns a fitting response. + */ +function handlePostUserRequests(xhr: MockXhr): MockResponse { + const user = getUserDataFromRequestBody(xhr.requestBody || ""); + + // Check if that user already exists + if (isUnique(user.UserName)) { + users.push(user); + + let responseBody = '{"@odata.context": "' + getBaseUrl(xhr.url) + + '$metadata#People/$entity",'; + responseBody += JSON.stringify(user).slice(1); + + // The response to POST requests is http 201 (Created) + return [ + 201, + { + "Content-Type": "application/json; odata.metadata=minimal", + "OData-Version": "4.0" + }, + responseBody + ]; + } + // Error + const responseBody = duplicateKeyError(user.UserName); + return [ + 400, + { + "Content-Type": "application/json; charset=utf-8" + }, + responseBody + ]; +} + +/** + * Handles POST requests for resetting the data and returns a fitting response. + */ +function handleResetDataRequest(): MockResponse { + void readData(); + + return [ + 204, + { + "OData-Version": "4.0" + }, + null + ]; +} + +/** + * Builds a response to direct (= non-batch) requests. + * Supports GET, PATCH, DELETE and POST requests. + */ +function handleDirectRequest(xhr: MockXhr): MockResponse | undefined { + let response2: MockResponse | undefined; + + switch (xhr.method) { + case "GET": + if (/\$metadata/.test(xhr.url)) { + response2 = handleGetMetadataRequests(); + } else if (/\/\$count/.test(xhr.url)) { + response2 = handleGetCountRequests(); + } else if (/People.*\?/.test(xhr.url)) { + response2 = handleGetUserRequests(xhr, /\$count=true/.test(xhr.url)); + } + break; + case "PATCH": + if (/People/.test(xhr.url)) { + response2 = handlePatchUserRequests(xhr); + } + break; + case "POST": + if (/People/.test(xhr.url)) { + response2 = handlePostUserRequests(xhr); + } else if (/ResetDataSource/.test(xhr.url)) { + response2 = handleResetDataRequest(); + } + break; + case "DELETE": + if (/People/.test(xhr.url)) { + response2 = handleDeleteUserRequests(xhr); + } + break; + case "HEAD": + response2 = [204, {}]; + break; + default: + break; + } + + return response2; +} + +/** + * Builds a response to batch requests. + * Unwraps batch request, gets a response for each individual part and + * constructs a fitting batch response. + */ +function handleBatchRequest(xhr: MockXhr): MockResponse { + let responseBody = ""; + const outerBoundary = (xhr.requestBody || "").match(/(.*)/)![1]; // First line of the body + let innerBoundary: string | undefined; + let partBoundary: string; + // The individual requests + const outerParts = (xhr.requestBody || "").split(outerBoundary).slice(1, -1); + let parts: string[]; + let header: string; + + const matches = outerParts[0].match(/multipart\/mixed;boundary=(.+)/); + // If this request has several change sets, then we need to handle the inner and outer + // boundaries (change sets have an additional boundary) + if (matches && matches.length > 0) { + innerBoundary = matches[1]; + parts = outerParts[0].split("--" + innerBoundary).slice(1, -1); + } else { + parts = outerParts; + } + + // If this request has several change sets, then the response must start with the outer + // boundary and content header + if (innerBoundary) { + partBoundary = "--" + innerBoundary; + responseBody += outerBoundary + "\r\n" + + "Content-Type: multipart/mixed; boundary=" + innerBoundary + "\r\n\r\n"; + } else { + partBoundary = outerBoundary; + } + + parts.forEach((part, index) => { + // Construct the batch response body out of the single batch request parts. + const matches0 = part.match(/(GET|DELETE|PATCH|POST) (\S+)(?:.|\r?\n)+\r?\n(.*)\r?\n$/)!; + const partResponse = handleDirectRequest({ + method: matches0[1], + url: getBaseUrl(xhr.url) + matches0[2], + requestBody: matches0[3] + })!; + + responseBody += partBoundary + "\r\n" + + "Content-Type: application/http\r\n"; + // If there are several change sets, we need to add a Content ID header + if (innerBoundary) { + responseBody += "Content-ID:" + index + ".0\r\n"; + } + responseBody += "\r\nHttp/1.1 " + partResponse[0] + "\r\n"; + // Add any headers from the request - unless this response is 204 (no content) + if (partResponse[1] && partResponse[0] !== 204) { + for (header in partResponse[1]) { + if (Object.prototype.hasOwnProperty.call(partResponse[1], header)) { + responseBody += header + ": " + partResponse[1][header] + "\r\n"; + } + } + } + responseBody += "\r\n"; + + if (partResponse[2]) { + responseBody += partResponse[2]; + } + responseBody += "\r\n"; + }); + + // Check if we need to add the inner boundary again at the end + if (innerBoundary) { + responseBody += "--" + innerBoundary + "--\r\n"; + } + // Add a final boundary to the batch response body + responseBody += outerBoundary + "--"; + + // Build the final batch response + return [ + 200, + { + "Content-Type": "multipart/mixed;boundary=" + outerBoundary.slice(2), + "OData-Version": "4.0" + }, + responseBody + ]; +} + +/** + * Handles any type of intercepted request and sends a fake response. + * Logs the request and response to the console. + * Manages batch requests. + */ +function handleAllRequests(xhr: MockXhr): void { + let response2: MockResponse | undefined; + + // Log the request + Log.info( + "Mockserver: Received " + xhr.method + " request to URL " + xhr.url, + (xhr.requestBody ? "Request body is:\n" + xhr.requestBody : "No request body.") + + "\n", + logComponent + ); + + if (xhr.method === "POST" && /\$batch/.test(xhr.url)) { + response2 = handleBatchRequest(xhr); + } else { + response2 = handleDirectRequest(xhr); + } + + if (xhr.respond && response2) { + xhr.respond(response2[0], response2[1], response2[2] || null); + } + + // Log the response + if (response2) { + Log.info( + "Mockserver: Sent response with return code " + response2[0], + ("Response headers: " + JSON.stringify(response2[1]) + "\n\nResponse body:\n" + + (response2[2] || "")) + "\n", + logComponent + ); + } +} + +export default { + + /** + * Creates a Sinon fake service, intercepting all http requests to + * the URL defined in variable rBaseUrl above. + * @returns a promise that is resolved when the mock server is started + */ + init(): Promise { + // Load sinon lazily from the UI5 third-party shim + return new Promise((resolve, reject) => { + sap.ui.require(["sap/ui/thirdparty/sinon"], (lazySinon: any) => { + sinon = lazySinon; + sandbox = sinon.sandbox.create(); + + // Read the mock data + readData().then(() => { + // Initialize the sinon fake server + sandbox.useFakeServer(); + // Make sure that requests are responded to automatically. Otherwise we would need + // to do that manually. + sandbox.server.autoRespond = true; + + // Register the requests for which responses should be faked. + sandbox.server.respondWith(rBaseUrl, handleAllRequests); + + // Apply a filter to the fake XmlHttpRequest. + // Otherwise, ALL requests (e.g. for the component, views etc.) would be + // intercepted. + sinon.FakeXMLHttpRequest.useFilters = true; + sinon.FakeXMLHttpRequest.addFilter((_sMethod: string, url: string) => { + // If the filter returns true, the request will NOT be faked. + // We only want to fake requests that go to the intended service. + return !rBaseUrl.test(url); + }); + + // Set the logging level for console entries from the mock server + Log.setLevel(Log.Level.INFO, logComponent); + + Log.info("Running the app with mock data", logComponent); + resolve(undefined); + }, reject); + }); + }); + }, + + /** + * Stops the request interception and deletes the Sinon fake server. + */ + stop(): void { + if (sinon) { + sinon.FakeXMLHttpRequest.filters = []; + sinon.FakeXMLHttpRequest.useFilters = false; + } + if (sandbox) { + sandbox.restore(); + sandbox = null; + } + } +}; diff --git a/packages/odatav4/steps/11/webapp/manifest.json b/packages/odatav4/steps/11/webapp/manifest.json new file mode 100644 index 000000000..5575dd133 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/manifest.json @@ -0,0 +1,69 @@ +{ + "_version": "2.8.0", + "sap.app": { + "id": "ui5.tutorial.odatav4", + "type": "application", + "i18n": { + "supportedLocales": [ + "" + ], + "fallbackLocale": "", + "bundleName": "ui5.tutorial.odatav4.i18n.i18n" + }, + "applicationVersion": { + "version": "1.0.0" + }, + "title": "{{appTitle}}", + "description": "{{appDescription}}", + "dataSources": { + "default": { + "uri": "https://services.odata.org/TripPinRESTierService/(S(id))/", + "type": "OData", + "settings": { + "odataVersion": "4.0" + } + } + } + }, + "sap.ui": { + "technology": "UI5" + }, + "sap.ui5": { + "rootView": { + "viewName": "ui5.tutorial.odatav4.view.App", + "type": "XML", + "id": "appView" + }, + "dependencies": { + "minUI5Version": "1.148", + "libs": { + "sap.f": {}, + "sap.m": {}, + "sap.ui.core": {}, + "sap.ui.layout": {} + } + }, + "handleValidation": true, + "models": { + "i18n": { + "type": "sap.ui.model.resource.ResourceModel", + "settings": { + "bundleName": "ui5.tutorial.odatav4.i18n.i18n", + "supportedLocales": [ + "" + ], + "fallbackLocale": "" + } + }, + "": { + "dataSource": "default", + "preload": true, + "settings": { + "autoExpandSelect": true, + "earlyRequests": true, + "operationMode": "Server" + } + } + } + } +} diff --git a/packages/odatav4/steps/11/webapp/model/models.ts b/packages/odatav4/steps/11/webapp/model/models.ts new file mode 100644 index 000000000..5fdcff4a3 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/model/models.ts @@ -0,0 +1,10 @@ +// models.ts β€” central application models +import JSONModel from "sap/ui/model/json/JSONModel"; +import Device from "sap/ui/Device"; + +export function createDeviceModel(): JSONModel { + const model = new JSONModel(Device); + + model.setDefaultBindingMode("OneWay"); + return model; +} diff --git a/packages/odatav4/steps/11/webapp/test/integration/AllJourneys.js b/packages/odatav4/steps/11/webapp/test/integration/AllJourneys.js new file mode 100644 index 000000000..9706f08ad --- /dev/null +++ b/packages/odatav4/steps/11/webapp/test/integration/AllJourneys.js @@ -0,0 +1,13 @@ +sap.ui.define([ + "sap/ui/test/Opa5", + "ui5/tutorial/odatav4/test/integration/arrangements/Startup", + "ui5/tutorial/odatav4/test/integration/TutorialJourney" +], function (Opa5, Startup) { + "use strict"; + + Opa5.extendConfig({ + arrangements : new Startup(), + viewNamespace : "ui5.tutorial.odatav4.view.", + autoWait : true + }); +}); diff --git a/packages/odatav4/steps/11/webapp/test/integration/TutorialJourney.js b/packages/odatav4/steps/11/webapp/test/integration/TutorialJourney.js new file mode 100644 index 000000000..2637d2c0b --- /dev/null +++ b/packages/odatav4/steps/11/webapp/test/integration/TutorialJourney.js @@ -0,0 +1,147 @@ +sap.ui.define([ + "sap/ui/test/opaQunit", + "ui5/tutorial/odatav4/test/integration/pages/Tutorial" +], function (opaTest) { + "use strict"; + + var growingBy = 10, // Must equal the 'growingThreshold' setting of the table + iTotalUsers = 20; // Must equal the total number of users + + QUnit.module("Posts"); + + opaTest("Should see the paginated table with all users", function (Given, _When, Then) { + // Arrangements + Given.iStartMyApp(); + // Assertions + Then.onTheTutorialPage.theTableShouldHavePagination() + .and.theTableShouldShowUsers(growingBy) + .and.theTableShouldShowTotalUsers(iTotalUsers); + }); + + opaTest("Should be able to load more users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnMoreData(); + // Assertions + Then.onTheTutorialPage.theTableShouldShowUsers(growingBy * 2); + }); + + opaTest("Should be able to sort users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnSort(); + // Assertions + Then.onTheTutorialPage.theTableShouldStartWith("Alfred"); + }); + + opaTest("Should be able to start adding users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnAdd() + .and.iEnterSomeData("a"); + When.onTheTutorialPage.iPressOnAdd() + .and.iEnterSomeData("b"); + // Assertions + Then.onTheTutorialPage.thePageFooterShouldBeVisible(true) + .and.theTableToolbarItemsShouldBeEnabled(false) + .and.theTableShouldShowTotalUsers(iTotalUsers + 2); + }); + + opaTest("Should be able to save the new users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iPressOnSave(); + // Assertions + Then.onTheTutorialPage.theTableShouldStartWith("b") + .and.theTableShouldShowTotalUsers(iTotalUsers + 2) + .and.theTableToolbarItemsShouldBeEnabled(true) + .and.thePageFooterShouldBeVisible(false); + }); + + opaTest("Should be able to delete/undelete the new users", function (_Given, When, Then) { + // delete and undelete + When.onTheTutorialPage.iSelectUser("b") + .and.iPressOnDelete(); + Then.onTheTutorialPage.theTableShouldStartWith("a") + .and.theTableShouldShowTotalUsers(iTotalUsers + 1); + When.onTheTutorialPage.iSelectUser("a") + .and.iPressOnDelete(); + Then.onTheTutorialPage.theTableShouldStartWith("Alfred") + .and.theTableShouldShowTotalUsers(iTotalUsers); + When.onTheTutorialPage.iPressOnCancel(); + Then.onTheTutorialPage.theMessageToastShouldShow("deletionRestoredMessage", "b") + .and.theMessageToastShouldShow("deletionRestoredMessage", "a") + .and.theTableShouldStartWith("b") + .and.theTableShouldShowTotalUsers(iTotalUsers + 2); + // delete and save + When.onTheTutorialPage.iSelectUser("a") + .and.iPressOnDelete() + .and.iSelectUser("b") + .and.iPressOnDelete() + .and.iPressOnSave(); + Then.onTheTutorialPage.theMessageToastShouldShow("deletionSuccessMessage", "a") + .and.theMessageToastShouldShow("deletionSuccessMessage", "b") + .and.theTableShouldStartWith("Alfred") + .and.theTableShouldShowTotalUsers(iTotalUsers); + }); + + opaTest("Should be able to search for users", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iSearchFor("Mundy"); + // Assertions + Then.onTheTutorialPage.theTableShouldShowUsers(1); + }); + + opaTest("Should be able to reset the search", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iSearchFor(""); + // Assertions + Then.onTheTutorialPage.theTableShouldShowUsers(10); + }); + + opaTest("Should see an error when trying to change a user name to an existing one", + function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iChangeAUserKey("javieralfred", "willieashmore") + .and.iPressOnSave(); + // Assertions + Then.onTheTutorialPage.iShouldSeeAServiceError() + .and.theTableToolbarItemsShouldBeEnabled(false) + .and.thePageFooterShouldBeVisible(true); + } + ); + + opaTest("Should be able to close the error and cancel the change", + function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iCloseTheServiceError() + .and.iPressOnCancel(); + // Assertions + Then.onTheTutorialPage.theTableToolbarItemsShouldBeEnabled(true) + .and.thePageFooterShouldBeVisible(false); + } + ); + + opaTest("Should be able to see the detail area", function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iSelectUser("javieralfred").and.iPressUser(); + //Assertions + Then.onTheTutorialPage.theDetailAreaShouldBeVisible(true); + }); + + opaTest("Should be able to toggle detail area when user is deleted/undeleted", + function (_Given, When, Then) { + //Actions + When.onTheTutorialPage.iSelectUser("javieralfred") + .and.iPressUser() + .and.iPressOnDelete(); + //Assertion + Then.onTheTutorialPage.theDetailAreaShouldBeVisible(false) + .and.theTableShouldShowUsers(growingBy - 1); + //Action + When.onTheTutorialPage.iPressOnCancel(); + //Assertion + Then.onTheTutorialPage.theDetailAreaShouldBeVisible(true) + .and.theMessageToastShouldShow("deletionRestoredMessage", "javieralfred") + .and.theTableShouldShowUsers(growingBy); + //Cleanup + Then.iTeardownMyApp(); + } + ); +}); diff --git a/packages/odatav4/steps/11/webapp/test/integration/arrangements/Startup.js b/packages/odatav4/steps/11/webapp/test/integration/arrangements/Startup.js new file mode 100644 index 000000000..296ab6ec9 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/test/integration/arrangements/Startup.js @@ -0,0 +1,24 @@ +sap.ui.define([ + "sap/ui/test/Opa5", + "ui5/tutorial/odatav4/localService/mockserver" +], function (Opa5, mockserver) { + "use strict"; + + return Opa5.extend("ui5.tutorial.odatav4.test.integration.arrangements.Startup", { + + iStartMyApp : function () { + // start the mock server + this.iWaitForPromise(mockserver.init()); + + // start the app UI component + this.iStartMyUIComponent({ + componentConfig : { + name : "ui5.tutorial.odatav4", + async : true + }, + autoWait : true, + timeout : 45 // BCP: 2270085466 + }); + } + }); +}); diff --git a/packages/odatav4/steps/11/webapp/test/integration/opaTests.qunit.html b/packages/odatav4/steps/11/webapp/test/integration/opaTests.qunit.html new file mode 100644 index 000000000..30e944e01 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/test/integration/opaTests.qunit.html @@ -0,0 +1,27 @@ + + + + Integration tests for OData V4 Tutorial + + + + + + + + + + + + +
+
+ + diff --git a/packages/odatav4/steps/11/webapp/test/integration/opaTests.qunit.js b/packages/odatav4/steps/11/webapp/test/integration/opaTests.qunit.js new file mode 100644 index 000000000..908edf09e --- /dev/null +++ b/packages/odatav4/steps/11/webapp/test/integration/opaTests.qunit.js @@ -0,0 +1,11 @@ +QUnit.config.autostart = false; + +sap.ui.require([ + "sap/ui/core/Core", + "ui5/tutorial/odatav4/test/integration/AllJourneys" +], function (Core) { + "use strict"; + Core.ready().then(function () { + QUnit.start(); + }); +}); diff --git a/packages/odatav4/steps/11/webapp/test/integration/pages/Tutorial.js b/packages/odatav4/steps/11/webapp/test/integration/pages/Tutorial.js new file mode 100644 index 000000000..fedf45bed --- /dev/null +++ b/packages/odatav4/steps/11/webapp/test/integration/pages/Tutorial.js @@ -0,0 +1,330 @@ +sap.ui.define([ + "sap/ui/test/Opa5", + "sap/ui/test/matchers/AggregationLengthEquals", + "sap/ui/test/matchers/PropertyStrictEquals", + "sap/ui/test/matchers/BindingPath", + "sap/ui/test/actions/Press", + "sap/ui/test/actions/EnterText" +], function (Opa5, AggregationLengthEquals, PropertyStrictEquals, BindingPath, Press, EnterText) { + "use strict"; + + var view = "App", + sTableId = "peopleList"; + + function getListBinding(oTable) { + return oTable.getBinding("items"); + } + + function getFirstTableEntry(oTable) { + return getListBinding(oTable).getCurrentContexts()[0]; + } + + Opa5.createPageObjects({ + onTheTutorialPage : { + actions : { + iPressOnMoreData : function () { + // Press action hits the "more" trigger on a table + return this.waitFor({ + id : sTableId, + viewName : view, + actions : new Press(), + errorMessage : "Table not found or it does not have a 'See More' trigger" + }); + }, + + iPressOnSort : function () { + return this.waitFor({ + id : "sortUsersButton", + viewName : view, + actions : new Press(), + errorMessage : "Could not find the 'Sort' button" + }); + }, + + iPressOnAdd : function () { + return this.waitFor({ + id : "addUserButton", + viewName : view, + actions : new Press(), + errorMessage : "Could not find the 'Add' button" + }); + }, + + iPressOnDelete : function () { + return this.waitFor({ + id : "deleteUserButton", + viewName : view, + actions : new Press(), + errorMessage : "Could not find the 'Delete' button" + }); + }, + + iPressOnSave : function () { + return this.waitFor({ + id : "saveButton", + viewName : view, + actions : new Press(), + errorMessage : "Could not find the 'Save' button" + }); + }, + + iPressOnCancel : function () { + return this.waitFor({ + id : "doneButton", + viewName : view, + actions : new Press(), + errorMessage : "Could not find the 'Cancel' button" + }); + }, + + iEnterSomeData : function (sValue) { + return this.waitFor({ + controlType : "sap.m.Input", + viewName : view, + matchers : [ + // Find the input fields for the new entry + function (oControl) { + return oControl.getBindingContext().getIndex() === 0; + }, + // Keep only empty input fields + function (oItem) { + return !oItem.getValue(); + } + ], + actions : new EnterText({ + text : sValue + }), + errorMessage : "Could not find Input controls to enter data" + }); + }, + + iSearchFor : function (sSearchString) { + return this.waitFor({ + id : "searchField", + viewName : view, + actions : new EnterText({ + text : sSearchString + }), + errorMessage : "SearchField was not found" + }); + }, + + iSelectUser : function (sKey) { + return this.waitFor({ + controlType : "sap.m.ColumnListItem", + viewName : view, + matchers : new BindingPath({ + path : "/People('" + sKey + "')" + }), + actions : function (oItem) { + oItem.setSelected(true); + }, + errorMessage : "Could not find a user with the key '" + sKey + "'" + }); + }, + + iChangeAUserKey : function (sOldKey, sNewKey) { + return this.waitFor({ + controlType : "sap.m.Input", + viewName : view, + matchers : new PropertyStrictEquals({ + name : "value", + value : sOldKey + }), + actions : new EnterText({ + text : sNewKey + }), + errorMessage : "Could not find a user with the key '" + sOldKey + "'" + }); + }, + + iCloseTheServiceError : function () { + return this.waitFor({ + id : "serviceErrorMessageBox", + success : function () { + this.waitFor({ + controlType : "sap.m.Button", + searchOpenDialogs : true, + // The error MessageBox has only one button, which closes the box + actions : new Press(), + errorMessage : "Cannot find the 'Close' button" + }); + }, + errorMessage : "Could not see the service error dialog" + }); + }, + + iPressUser : function () { + return this.waitFor({ + controlType : "sap.m.Table", + id : sTableId, + viewName : view, + actions : function (oTable) { + oTable.fireSelectionChange({listItem : oTable.getSelectedItem()}); + }, + errorMessage : "Could not press user" + }); + } + }, + assertions : { + theTableShouldHavePagination : function () { + return this.waitFor({ + id : sTableId, + viewName : view, + matchers : new PropertyStrictEquals({ + name : "growing", + value : true + }), + success : function () { + Opa5.assert.ok(true, "The table is paginated"); + }, + errorMessage : "Table not found or it is not paginated" + }); + }, + + theTableShouldShowUsers : function (iNumber) { + return this.waitFor({ + id : sTableId, + viewName : view, + matchers : new AggregationLengthEquals({ + name : "items", + length : iNumber + }), + success : function () { + Opa5.assert.ok(true, "The table has " + + iNumber + " items"); + }, + errorMessage : "Table not found or it does not have " + + iNumber + " entries" + }); + }, + + theTableShouldShowTotalUsers : function (iNumber) { + return this.waitFor({ + id : sTableId, + viewName : view, + matchers : function (oTable) { + var listBinding = getListBinding(oTable); + + return listBinding && listBinding.getLength() === iNumber; + }, + success : function () { + Opa5.assert.ok(true, "The table shows a total of " + iNumber + + " users"); + }, + errorMessage : "Table not found or it does not show " + iNumber + + " total users" + }); + }, + + theTableShouldStartWith : function (sLastName) { + return this.waitFor({ + id : sTableId, + viewName : view, + matchers : function (oTable) { + var oFirstItem = getFirstTableEntry(oTable); + + return oFirstItem && oFirstItem.getProperty("LastName") === sLastName; + }, + success : function () { + Opa5.assert.ok(true, "The table is sorted correctly"); + }, + errorMessage : "Table not found or it is not sorted correctl." + }); + }, + + thePageFooterShouldBeVisible : function (bVisible) { + var sDesiredState = bVisible ? "visible" : "invisible"; + + return this.waitFor({ + controlType : "sap.m.Toolbar", + viewName : view, + visible : false, + matchers : new PropertyStrictEquals({ + name : "visible", + value : bVisible + }), + success : function () { + Opa5.assert.ok(true, "The toolbar is " + sDesiredState); + }, + errorMessage : "Toolbar not found or is not " + sDesiredState + }); + }, + + theTableToolbarItemsShouldBeEnabled : function (bEnabled) { + var sDesiredState = bEnabled ? "enabled" : "disabled"; + + return this.waitFor({ + id : /searchField$|refreshUsersButton$|sortUsersButton$/, + viewName : view, + autoWait : false, // Needed because we want to find disabled controls, too + matchers : new PropertyStrictEquals({ + name : "enabled", + value : bEnabled + }), + check : function (aControls) { + // Validate that ALL controls have the right state + return aControls.length === 3; + }, + success : function () { + Opa5.assert.ok(true, "All controls in the table toolbar are " + + sDesiredState); + }, + errorMessage : "Not all controls in the table toolbar could be found or not" + + " all are " + sDesiredState + }); + }, + + theMessageToastShouldShow : function (sTextId, sArg0) { + return this.waitFor({ + autoWait : false, + id : sTableId, + viewName : view, + check : function (oControl) { + // Locate the message toast using its CSS class name and content + var sText = oControl.getModel("i18n").getResourceBundle() + .getText(sTextId, [sArg0]), + sSelector = ".sapMMessageToast:contains('" + sText + "')"; + + return !!Opa5.getJQuery()(sSelector).length; + }, + success : function () { + Opa5.assert.ok(true, "Could see the MessageToast showing text with ID " + + sTextId); + }, + errorMessage : "Could not see a MessageToast showing text with ID " + + sTextId + }); + }, + + iShouldSeeAServiceError : function () { + return this.waitFor({ + id : "serviceErrorMessageBox", + success : function () { + Opa5.assert.ok(true, "Could see the service error dialog"); + }, + errorMessage : "Could not see the service error dialog" + }); + }, + + theDetailAreaShouldBeVisible : function (bVisible) { + var sDesiredState = bVisible ? "visible" : "invisible"; + + return this.waitFor({ + controlType : "sap.f.semantic.SemanticPage", + viewName : view, + visible : false, + matchers : new PropertyStrictEquals({ + name : "visible", + value : bVisible + }), + success : function () { + Opa5.assert.ok(true, "The detail area is " + sDesiredState); + }, + errorMessage : "Detail area not found or is not " + sDesiredState + }); + } + } + } + }); +}); diff --git a/packages/odatav4/steps/11/webapp/view/App.view.xml b/packages/odatav4/steps/11/webapp/view/App.view.xml new file mode 100644 index 000000000..08e36fad0 --- /dev/null +++ b/packages/odatav4/steps/11/webapp/view/App.view.xml @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + + + </semantic:titleHeading> + <semantic:headerContent> + <FlexBox> + <VBox> + <ObjectAttribute text="{i18n>userNameLabelText}"/> + <ObjectAttribute text="{UserName}"/> + </VBox> + <VBox class="sapUiMediumMarginBegin"> + <ObjectAttribute text="{i18n>ageLabelText}"/> + <ObjectNumber number="{Age}" unit="Years"/> + </VBox> + </FlexBox> + </semantic:headerContent> + <semantic:content> + <VBox> + <FlexBox wrap="Wrap"> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>addressTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>addressLabelText}"> + <f:fields> + <Text text="{HomeAddress/Address}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>cityLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Name}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>regionLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/Region}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>countryLabelText}"> + <f:fields> + <Text text="{HomeAddress/City/CountryRegion}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + <f:Form editable="false"> + <f:title> + <core:Title text="{i18n>bestFriendTitleText}" /> + </f:title> + <f:layout> + <f:ResponsiveGridLayout + labelSpanXL="3" + labelSpanL="3" + labelSpanM="3" + labelSpanS="12" + adjustLabelSpan="false" + emptySpanXL="4" + emptySpanL="4" + emptySpanM="4" + emptySpanS="0" + columnsXL="1" + columnsL="1" + columnsM="1" + singleContainerFullSize="false" /> + </f:layout> + <f:formContainers> + <f:FormContainer> + <f:formElements> + <f:FormElement label="{i18n>nameLabelText}"> + <f:fields> + <Text text="{BestFriend/FirstName} {BestFriend/LastName}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>ageLabelText}"> + <f:fields> + <Text text="{BestFriend/Age}" /> + </f:fields> + </f:FormElement> + <f:FormElement label="{i18n>userNameLabelText}"> + <f:fields> + <Text text="{BestFriend/UserName}" /> + </f:fields> + </f:FormElement> + </f:formElements> + </f:FormContainer> + </f:formContainers> + </f:Form> + </FlexBox> + <Table + id="friendsTable" + width="auto" + items="{path: 'Friends', + parameters: { + $$ownRequest: true + }}" + noDataText="No Data" + class="sapUiSmallMarginBottom"> + <headerToolbar> + <Toolbar> + <Title + text="Friends" + titleStyle="H3" + level="H3"/> + </Toolbar> + </headerToolbar> + <columns> + <Column> + <Text text="User Name"/> + </Column> + <Column> + <Text text="First Name"/> + </Column> + <Column> + <Text text="Last Name"/> + </Column> + <Column> + <Text text="Age"/> + </Column> + </columns> + <items> + <ColumnListItem> + <cells> + <Text text="{UserName}"/> + </cells> + <cells> + <Text text="{FirstName}"/> + </cells> + <cells> + <Text text="{LastName}"/> + </cells> + <cells> + <Text text="{Age}"/> + </cells> + </ColumnListItem> + </items> + </Table> + </VBox> + </semantic:content> + </semantic:SemanticPage> + </l:SplitPane> + </l:PaneContainer> + </l:ResponsiveSplitter> + </content> + <footer> + <Toolbar visible="{appView>/hasUIChanges}"> + <ToolbarSpacer/> + <Button + id="saveButton" + type="Emphasized" + text="{i18n>saveButtonText}" + enabled="{= ${message>/}.length === 0 && ${appView>/usernameEmpty} === false }" + press=".onSave"/> + <Button + id="doneButton" + text="{i18n>cancelButtonText}" + press=".onResetChanges"/> + </Toolbar> + </footer> + </Page> + </pages> + </App> + </Shell> +</mvc:View> diff --git a/packages/quickstart/README.md b/packages/quickstart/README.md index 49823416b..4b4c339d6 100644 --- a/packages/quickstart/README.md +++ b/packages/quickstart/README.md @@ -10,7 +10,7 @@ We first introduce you to the basic development paradigms like *Model-View-Contr ![Preview of the OpenUI5 application that is going to be built in this tutorial. Contains a Hello World upper part with buttons and a text input. The lower part shows list of invoices with details, grouped by vendor names.](steps/03/assets/loio79e1157d948c488c9717ef840fa9b396_LowRes.png). -> πŸ’‘ **Tip:** <br> +> :tip: > You don't have to do all tutorial steps sequentially, you can also jump directly to any step you want. Just download the code from the previous step and make sure that the application runs as intended. > > You can view the samples for all steps here in this repository. @@ -26,4 +26,4 @@ The tutorial consists of the following steps. To start, just open the first link ## License -Copyright (c) 2025 SAP SE or an SAP affiliate company. All rights reserved. This project is licensed under the Apache Software License, version 2.0 except as noted otherwise in the [LICENSE](../../LICENSE) file. +Copyright (c) 2026 SAP SE or an SAP affiliate company. All rights reserved. This project is licensed under the Apache Software License, version 2.0 except as noted otherwise in the [LICENSE](../../LICENSE) file. diff --git a/packages/quickstart/steps/01/Component.ts b/packages/quickstart/steps/01/Component.ts index 4491b5ce6..64d8f01bb 100644 --- a/packages/quickstart/steps/01/Component.ts +++ b/packages/quickstart/steps/01/Component.ts @@ -1,10 +1,11 @@ import UIComponent from "sap/ui/core/UIComponent"; /** - * @namespace sap.m.tutorial.quickstart.01 + * @namespace ui5.tutorial.quickstart */ export default class Component extends UIComponent { - static readonly metadata = { + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], manifest: "json" }; } diff --git a/packages/quickstart/steps/01/README.md b/packages/quickstart/steps/01/README.md index 53f9b750a..87a3402a1 100644 --- a/packages/quickstart/steps/01/README.md +++ b/packages/quickstart/steps/01/README.md @@ -50,21 +50,21 @@ In our webapp folder, we create a new HTML file named `index.html` and copy the ```html <!DOCTYPE html> <html> - <head> - <meta charset="utf-8"> - <title>Quickstart Tutorial - - - + + + Quickstart Tutorial + + + ``` @@ -93,17 +93,17 @@ new Button({ ``` ```js sap.ui.define([ - "sap/m/Button", - "sap/m/MessageToast" + "sap/m/Button", + "sap/m/MessageToast" ], (Button, MessageToast) => { - "use strict"; - - new Button({ - text: "Ready...", - press() { - MessageToast.show("Hello World!"); - } - }).placeAt("content"); + "use strict"; + + new Button({ + text: "Ready...", + press() { + MessageToast.show("Hello World!"); + } + }).placeAt("content"); }); ``` @@ -115,14 +115,14 @@ Create a new file named `manifest.json` in the webapp folder; it's also known as ```json { - "_version": "1.60.0", - "sap.app": { - "id": "ui5.quickstart" - } + "_version": "2.8.0", + "sap.app": { + "id": "ui5.tutorial.quickstart" + } } ``` -> πŸ“ **Note:**
+> :note: > In this tutorial step, we focus on adding the absolute minimum configuration to the app descriptor file. In certain development environments you might encounter validation errors due to missing settings. However, for the purposes of this tutorial you can safely ignore these errors. In [Step 10: Descriptor for Applications](../10/README.md) we'll examine the purpose of the file in detail and configure some further options. *** @@ -141,12 +141,12 @@ Enter the following content: ```json { - "name": "ui5.quickstart", - "version": "1.0.0", - "description": "The UI5 quickstart tutorial", - "scripts": { - "start": "ui5 serve -o index.html" - } + "name": "ui5.tutorial.quickstart", + "version": "1.0.0", + "description": "The UI5 quickstart tutorial", + "scripts": { + "start": "ui5 serve -o index.html" + } } ``` @@ -175,23 +175,22 @@ We specify the compiler options as follow: ```json { "compilerOptions": { - "target": "es2023", - "types": [ - "node", - "@types/openui5" - ], - "skipLibCheck": true, - "allowJs": true, - "strictPropertyInitialization": false, - "rootDir": "./webapp", - "paths": { - "ui5/quickstart/*": [ - "./webapp/*" - ] - } + "target": "es2023", + "types": [ + "@openui5/types" + ], + "skipLibCheck": true, + "allowJs": true, + "strictPropertyInitialization": false, + "rootDir": "./webapp", + "paths": { + "ui5/tutorial/quickstart/*": [ + "./webapp/*" + ] + } }, "include": [ - "./webapp/**/*" + "./webapp/**/*" ] } ``` @@ -202,7 +201,7 @@ Let's go through the compiler options specified in the file: - `"target": "es2023"`: The `target` parameter sets the JavaScript language level that the TypeScript code should be compiled down to. We set it to `es2023`, which means the generated JavaScript code is compatible with ECMAScript 2023. -- `"types": [ "node", "@types/openui5"]`: The `types` parameter defines the types used for TypeScript code. We configure this parameter to use the built-in Node.js types and the OpenUI5 types delivered by the `@types/openui5` package. +- `"types": ["@openui5/types"]`: The `types` parameter defines the types used for TypeScript code. We configure this parameter to use the OpenUI5 types delivered by the `@openui5/types` package. - `"skipLibCheck": true`: When the `skipLibCheck` parameter is set to `true`, it tells the compiler to skip type checking of declaration files (`.d.ts` files) that are part of external libraries. This can improve compilation speed. @@ -212,7 +211,7 @@ Let's go through the compiler options specified in the file: - `"rootDir": "./webapp"`: The `rootDir` parameter specifies the root directory of the TypeScript source files. The compiler considers this directory as the starting point for resolving file paths. We set it to our `webapp` folder. -- `"paths": { "ui5/quickstart/*": ["./webapp/**/*"] }`: The `path` paramter specifies path mappings for module resolution. It allows you to define custom module paths that map to specific directories or files. In this case, it maps the module path `ui5/quickstart/*`. +- `"paths": { "ui5/tutorial/quickstart/*": ["./webapp/**/*"] }`: The `path` paramter specifies path mappings for module resolution. It allows you to define custom module paths that map to specific directories or files. In this case, it maps the module path `ui5/tutorial/quickstart/*`. - `"include": [ "./webapp/**/*" ]`: Specifies an array of filenames or patterns to include in TypeScript compilation. @@ -292,7 +291,7 @@ Next, we have to configure the tooling extension we installed from npm to our U - All our custom middleware extensions will be called after the `compression` middleware. -> πŸ“Œ **Important:**
+> :info: > Middleware configurations are applied in the order in which they are defined.
@@ -330,7 +329,7 @@ Now you can benefit from live reload on changes and built framework resources at

-> πŸ“ **Note:**
+> :note: > During its initial run, the `ui5-middleware-serveframework` middleware will build the framework, which can take a while. In all following steps, the build will not happen again and the framework is served from the built resources.   diff --git a/packages/quickstart/steps/01/package.json b/packages/quickstart/steps/01/package.json index 6594f755e..1cf664758 100644 --- a/packages/quickstart/steps/01/package.json +++ b/packages/quickstart/steps/01/package.json @@ -1,5 +1,5 @@ { - "name": "ui5.quickstart.step01", + "name": "ui5.tutorial.quickstart.step01", "private": true, "version": "1.0.0", "author": "SAP SE", @@ -9,7 +9,7 @@ "typecheck": "tsc --noEmit" }, "devDependencies": { - "@types/openui5": "^1.147.0", + "@openui5/types": "^1.148.1", "@ui5/cli": "^4.0.53", "typescript": "^6.0.3", "ui5-middleware-livereload": "^3.3.1", diff --git a/packages/quickstart/steps/01/tsconfig.json b/packages/quickstart/steps/01/tsconfig.json index ddc228fd3..4e1358963 100644 --- a/packages/quickstart/steps/01/tsconfig.json +++ b/packages/quickstart/steps/01/tsconfig.json @@ -2,18 +2,19 @@ "compilerOptions": { "target": "es2023", "types": [ - "node", - "@types/openui5" + "@openui5/types" ], "skipLibCheck": true, "allowJs": true, "strictPropertyInitialization": false, "rootDir": "./webapp", "paths": { - "ui5/quickstart/*": [ + "ui5/tutorial/quickstart/*": [ "./webapp/*" ] - } + }, + "strict": false, + "strictNullChecks": false }, "include": [ "./webapp/**/*" diff --git a/packages/quickstart/steps/01/ui5.yaml b/packages/quickstart/steps/01/ui5.yaml index 851e823d5..cd3e3d27d 100644 --- a/packages/quickstart/steps/01/ui5.yaml +++ b/packages/quickstart/steps/01/ui5.yaml @@ -1,10 +1,10 @@ specVersion: "4.0" metadata: - name: quickstart-tutorial + name: ui5.tutorial.quickstart type: application framework: name: OpenUI5 - version: "1.147.1" + version: "1.148.1" libraries: - name: sap.m - name: sap.tnt diff --git a/packages/quickstart/steps/01/webapp/index-cdn.html b/packages/quickstart/steps/01/webapp/index-cdn.html index f6d5be806..5d024cd33 100644 --- a/packages/quickstart/steps/01/webapp/index-cdn.html +++ b/packages/quickstart/steps/01/webapp/index-cdn.html @@ -6,11 +6,12 @@ diff --git a/packages/quickstart/steps/01/webapp/index.html b/packages/quickstart/steps/01/webapp/index.html index b5e19abc9..35ab09b77 100644 --- a/packages/quickstart/steps/01/webapp/index.html +++ b/packages/quickstart/steps/01/webapp/index.html @@ -6,13 +6,14 @@ - \ No newline at end of file + diff --git a/packages/quickstart/steps/01/webapp/manifest.json b/packages/quickstart/steps/01/webapp/manifest.json index bc55cb8e8..619e46b0e 100644 --- a/packages/quickstart/steps/01/webapp/manifest.json +++ b/packages/quickstart/steps/01/webapp/manifest.json @@ -1,7 +1,7 @@ { - "_version": "1.60.0", + "_version": "2.8.0", "sap.app": { - "id": "ui5.quickstart", + "id": "ui5.tutorial.quickstart", "type": "application", "title": "OpenUI5 Quickstart", "applicationVersion": { diff --git a/packages/quickstart/steps/02/Component.ts b/packages/quickstart/steps/02/Component.ts index ee77bacf3..64d8f01bb 100644 --- a/packages/quickstart/steps/02/Component.ts +++ b/packages/quickstart/steps/02/Component.ts @@ -1,10 +1,11 @@ import UIComponent from "sap/ui/core/UIComponent"; /** - * @namespace sap.m.tutorial.quickstart.02 + * @namespace ui5.tutorial.quickstart */ export default class Component extends UIComponent { - static readonly metadata = { + public static metadata = { + interfaces: ["sap.ui.core.IAsyncContentCreation"], manifest: "json" }; } diff --git a/packages/quickstart/steps/02/README.md b/packages/quickstart/steps/02/README.md index 6b5e1b952..d6f5dbfb6 100644 --- a/packages/quickstart/steps/02/README.md +++ b/packages/quickstart/steps/02/README.md @@ -35,7 +35,7 @@ You can download the solution for this step here: [πŸ“₯ Download step 2](https:/ ### webapp/index.?s -Now we replace most of the code in this file: We remove the inline button from the previous step, and introduce a proper XML view to separate the presentation from the controller logic. We prefix the view name `ui5.quickstart.App` with our newly defined namespace. The view is loaded asynchronously. +Now we replace most of the code in this file: We remove the inline button from the previous step, and introduce a proper XML view to separate the presentation from the controller logic. We prefix the view name `ui5.tutorial.quickstart.App` with our newly defined namespace. The view is loaded asynchronously. Similar to the step before, the view is placed in the element with the `content` ID after it has finished loading. @@ -43,7 +43,7 @@ Similar to the step before, the view is placed in the element with the `content` import XMLView from "sap/ui/core/mvc/XMLView"; XMLView.create({ - viewName: "ui5.quickstart.App" + viewName: "ui5.tutorial.quickstart.App" }).then((oView) => oView.placeAt("content")); ``` ```js @@ -53,7 +53,7 @@ sap.ui.define([ "use strict"; XMLView.create({ - viewName: "ui5.quickstart.App" + viewName: "ui5.tutorial.quickstart.App" }).then((oView) => oView.placeAt("content")); }); ``` @@ -68,19 +68,19 @@ We outsource the controller logic to an app controller. The `.onPress` event now ```xml - - -