diff --git a/packages/ui5-types-typedjsonmodel/.eslintrc.js b/packages/ui5-types-typedjsonmodel/.eslintrc.js new file mode 100644 index 000000000..24126b462 --- /dev/null +++ b/packages/ui5-types-typedjsonmodel/.eslintrc.js @@ -0,0 +1,18 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +module.exports = { + root: true, + env: { + browser: true, + es6: true, + node: true, + }, + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:@typescript-eslint/recommended-requiring-type-checking"], + ignorePatterns: [".eslintignore.js"], + parser: "@typescript-eslint/parser", + parserOptions: { + project: ["./tsconfig.json"], + tsconfigRootDir: __dirname, + sourceType: "module", + }, + plugins: ["@typescript-eslint"], +}; diff --git a/packages/ui5-types-typedjsonmodel/CHANGELOG.md b/packages/ui5-types-typedjsonmodel/CHANGELOG.md new file mode 100644 index 000000000..38d0106d0 --- /dev/null +++ b/packages/ui5-types-typedjsonmodel/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +# 0.2.0 (2023-03-31) + + +### Features + +* experimental TypeSafeJSONModel ([0f18623](https://github.com/ui5-community/ui5-ecosystem-showcase/commit/0f18623222430f56c7ee340cd010b80a0b816c2f)) diff --git a/packages/ui5-types-typedjsonmodel/Readme.md b/packages/ui5-types-typedjsonmodel/Readme.md new file mode 100644 index 000000000..b0ab0ffd8 --- /dev/null +++ b/packages/ui5-types-typedjsonmodel/Readme.md @@ -0,0 +1,127 @@ +# Typed JSONModel + +This utility is a TypeScript library designed to enhance the development experience for UI5 projects that utilize JSONModel. UI5 is an open-source JavaScript UI library maintained by SAP. JSONModel is a core concept in UI5, providing a way to bind data to UI controls. + +The Typed JSONModel utility adds type safety and improved development capabilities when working with JSONModel in your OpenUI5 projects. By leveraging TypeScript's static typing features, this utility helps catch type-related errors early and provides enhanced code completion and documentation in your code editor. + +## Features + +- 🔒 **Type Safety**: The utility enables you to define TypeScript types for your JSONModel data, ensuring that the data structure conforms to the specified types at compile-time. +- 💡 **Code Completion**: With the type definitions in place, your code editor can provide accurate autocompletion suggestions and documentation for the JSONModel data. +- 🚀 **Improved Development Experience**: By eliminating potential runtime type errors, you can write more reliable and maintainable code, resulting in a smoother development experience. +- 🔌 **Easy Integration**: The utility can be easily integrated into your existing OpenUI5 projects by following the installation and usage instructions provided below. + +Feel free to leverage these features to enhance your UI5 project development! + +## Installation + +1. If you are not already using TypeScript in your UI5 project, it is recommended to check out [UI5 & TypeScript](https://sap.github.io/ui5-typescript/) first. + +1. Install the utility as a development dependency in your project: + +```bash +npm install ui5-types-typedjsonmodel --save-dev +``` + +1. In order to prevent runtime overhead, or bundling headaches the Typed JSONModel utility is only used in TypeScript by appling it's type over a ordinary JSONModel. To do this we need to write the following utility function: + +```typescript +// webapp/model/model.ts +import type { TypedJSONModel, TypedJSONModelData } from "ui5-types-typedjsonmodel/lib/TypedJSONModel"; + +export function createTypedJSONModel(oData: T, bObserve?: boolean | undefined) { + return new JSONModel(oData, bObserve) as TypedJSONModel; +} +``` + +## Usage + +1. Import the `createTypedJSONModel` method + +```typescript +// Test.controller.ts +import { createTypedJSONModel } from "../model/models"; + +``` + +1. Create a new model inside your controllers class + +```typescript +// Test.controller.ts +export default class Test extends Controller { + myModel = createTypedJSONModel({ + foo: { + bar: 'bar', + baz: 3, + }, + messages: [ + { + content: 'Hello World', + } + ] + }); +} +``` + +1. Bind the model to the view + +```typescript +// Test.controller.ts +export default class Test extends Controller { + // ... + + onInit() { + this.getView()?.setModel(this.myModel); + } +} +``` + +1. Read/Write to the model + +```typescript +// TypeSafe Reads +const foo = this.myModel.getProperty('/foo'); // foo has the type { bar: string, baz: number } +const bar = this.myModel.getProperty('/foo/bar'); // bar has the type string +const messages = this.myModel.getProperty('/messages'); // messages has the type { world: boolean }[] +const firstMessage = this.myModel.getProperty('/messages/0'); // firstMessage has the type { content: string } +const firstMessageContent = this.myModel.getProperty('/messages/0/content'); // firstMessageContent has the type string + +// TypeSafe Writes +this.myModel.setProperty('/foo', { bar: 'any string', baz: 10 }); // OK +this.myModel.setProperty('/foo', { bar: 'any string', baz: '3' }); // Not OK, baz is a string not a number +this.myModel.setProperty('/foo', { bar: 'test' }); // Not OK, baz is missing +this.myModel.setProperty('/messages/1', { content: 'New Message' }); // OK +this.myModel.setProperty('/foo/baz', 123); // OK +this.myModel.setProperty('/foo/bazz', 123); // Not Ok, property bazz does not exist on type foo +this.myModel.setProperty('foo/baz', 123); // Not OK, missing leading slash in path +this.myModel.setProperty('/foo/test', 123); // Not OK, property test does not exist on type foo +``` + +Note that you can also explicitly specify the type of the model content if you don't want to infer it from the default values: + +```typescript +// Test.controller.ts + +type MyModelContent = { + items: { + status: "success" | "failure"; + message: string; + }[]; +} + +export default class Test extends Controller { + myModel = createTypedJSONModel({ + items: [], + }); + + // ... +} +``` + +## Limitations + +- To prevent infinitely large types, the depth of recursive objects is limited to 10 by default +- Access to array items via an index in the property string aren't checked even if the array is defined as a touple +- Autocomplete for array indicies might not work in all cases +- Recursive types as model definitions may not work in all cases +- TypeScripts `readonly` attribute may not work as expected in all cases diff --git a/packages/ui5-types-typedjsonmodel/lib/TypedJSONModel.ts b/packages/ui5-types-typedjsonmodel/lib/TypedJSONModel.ts new file mode 100644 index 000000000..8128c5c09 --- /dev/null +++ b/packages/ui5-types-typedjsonmodel/lib/TypedJSONModel.ts @@ -0,0 +1,138 @@ +// eslint-disable-file + +/** + * This file contains the type safe facade "TypedJSONModel" for "JSONModel". + * + * Since TypedJSONModel is only a type it has no runtime differences or overhead compared to the JSONModel. + * + * The JSONModel class is used to store data which has to be synchronized with the view. JSONModel mainly uses the + * getProperty() and setProperty() methods to access the stored values. To specify which property should be + * returned the JSONModel uses the names of the object keys and '/' as a separator to access nested properties. + * E.g. if we have the object { foo: 'FOO', bar: { baz: 'BAZ' }} we could use '/foo' to access + * the string "FOO", '/bar' to access the object { baz: 1 } or '/bar/baz' to access the string + * "BAZ". + * + * The purpose of TypedJSONModel is to add typechecking at compile time. This means that the returned + * properties have the correct type, instead of the any type that the JSONModel returns by + * default. Additionally, accessing non-existent properties will cause type errors at compile time. + */ + +import JSONModel from "sap/ui/model/json/JSONModel"; + +import type Context from "sap/ui/model/Context"; + +//#region Configuration +/** + * You can safely overwrite this value to configure the TypedJSONModel + */ +interface TypedJSONModelConfig { + /** + * The maximum recursion depth used during path discovery. + * + * Only values between 0 and 20 are allowed. You can safely overwrite this value. + */ + maxDepth: 10; +} +//#endregion + +//#region Helper types +/** + * Alias for ease of use + */ +type RecursionMaxDepth = TypedJSONModelConfig["maxDepth"]; + +/** + * Array used to limit recursive calls + * + * This array works as a counter since for e.g. depth = 5 the value of RecursionLimiterValues[5] is 4 + */ +type RecursionLimiterValues = [-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]; + +/** + * Exclude the symbol type from a given type union + */ +type NoSymbols = T extends symbol ? never : T; + +//#endregion + +//#region JSON Types + +/** + * JSON safe value + */ +type JSONScalar = string | boolean | number | Date | null | undefined; + +/** + * JSON safe array + */ +type JSONArray = (JSONScalar | JSONObject)[]; + +/** + * JSON safe object + */ +type JSONObject = { + [x: string]: JSONScalar | JSONObject | JSONArray; +}; + +/** + * Type to represent JSONModels content + * + * We disallow scalar values as roots since UI5 doesn't allow it + */ +type JSON = JSONArray | JSONObject; + +//#endregion + +//#region Generate string literal union of all valid paths for a given object + +type PathType = + // We use this intermediary helper for convenience + PathTypeHelper, RecursionMaxDepth>; + +type PathTypeHelper, DEPTH extends RecursionLimiterValues[number]> = { + done: never; + recur: D extends JSONObject // Objects + ? K extends string | number + ? `/${K}${"" | PathTypeHelper, RecursionLimiterValues[DEPTH]>}` // Recursive call + : never // shouldn't happen + : D extends JSONArray // Arrays + ? K extends number + ? `/${K}${"" | PathTypeHelper, RecursionLimiterValues[DEPTH]>}` // Recursive call + : never // shouldn't happen + : never; // JSON Scalar +}[DEPTH extends -1 ? "done" : "recur"]; + +//#endregion + +//#region Lookup property type of given object for a given string literal + +// Value Type - lookup type of data specified by the path +type ValueType> = + // We use this intermediary helper for convenience + ValueTypeHelper; + +type ValueTypeHelper = D extends JSONObject | JSONArray + ? P extends `/${infer KP extends NoSymbols}` // Extract string + ? D[KP] // Return type if we have reached a property + : P extends `/${infer KN extends NoSymbols}/${infer REST extends string}` // Nested object + ? ValueTypeHelper // Recursive call (we need to add a / since the template "REST" doesn't include it above) + : never + : never; +//#endregion + +//#region Typesafe JSON Model + +/** + * Type safe facade over UI5's JSONModel + * + * @see JSONModel + */ +export interface TypedJSONModel extends JSONModel { + constructor: (oData: D, bObserve?: boolean | undefined) => this; + getProperty:

>(sPath: P, oContext?: Context | undefined) => ValueType; + setProperty:

, V extends ValueType>(sPath: P, oValue: V, oContext?: Context | undefined, bAsyncUpdate?: boolean | undefined) => boolean; + setData: (oData: D, bMerge?: boolean | undefined) => void; +} + +export type TypedJSONModelData = JSON; +//#endregion diff --git a/packages/ui5-types-typedjsonmodel/package.json b/packages/ui5-types-typedjsonmodel/package.json new file mode 100644 index 000000000..52a70de3d --- /dev/null +++ b/packages/ui5-types-typedjsonmodel/package.json @@ -0,0 +1,21 @@ +{ + "name": "ui5-types-typedjsonmodel", + "version": "0.2.0", + "description": "UI5 TypedJSONModel", + "private": true, + "author": "Marc Popescu-Pfeiffer", + "license": "MIT", + "types": "index.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/ui5-community/ui5-ecosystem-showcase.git", + "directory": "packages/ui5-types-typedjsonmodel" + }, + "scripts": { + "lint": "eslint ." + }, + "devDependencies": { + "@types/openui5": "1.112.0", + "typescript": "^5.0.3" + } +} diff --git a/packages/ui5-types-typedjsonmodel/tsconfig.json b/packages/ui5-types-typedjsonmodel/tsconfig.json new file mode 100644 index 000000000..8fab946cc --- /dev/null +++ b/packages/ui5-types-typedjsonmodel/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "node", + "skipLibCheck": true, + "allowJs": true, + "strict": true, + "strictPropertyInitialization": false, + "rootDir": "./", + "baseUrl": "./" + }, + "include": ["./lib/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4ba356b64..526d911e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,12 @@ lockfileVersion: '6.1' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + chromedriver: '>=113' + settings: autoInstallPeers: true excludeLinksFromLockfile: false @@ -494,6 +501,15 @@ importers: specifier: ^5.3.1 version: 5.3.1 + packages/ui5-types-typedjsonmodel: + devDependencies: + '@types/openui5': + specifier: 1.112.0 + version: 1.112.0 + typescript: + specifier: ^5.0.3 + version: 5.1.6 + showcases/ui5-app: dependencies: '@js-temporal/polyfill': @@ -814,6 +830,9 @@ importers: ui5-tooling-transpile: specifier: workspace:^ version: link:../../packages/ui5-tooling-transpile + ui5-types-typedjsonmodel: + specifier: workspace:^ + version: link:../../packages/ui5-types-typedjsonmodel showcases/ui5-tslib: devDependencies: @@ -4122,6 +4141,13 @@ packages: '@types/qunit': 2.19.6 dev: true + /@types/openui5@1.115.1: + resolution: {integrity: sha512-mLJP5R4HhqVpIlA7AOxIjCsXSP+Mk9jTZX4O/LgNJ2btUJUEpY6ITHHyb9BmGUtRuHKsHIpeIQY0ODkNROPIjg==} + dependencies: + '@types/jquery': 3.5.16 + '@types/qunit': 2.19.6 + dev: true + /@types/phoenix@1.6.0: resolution: {integrity: sha512-qwfpsHmFuhAS/dVd4uBIraMxRd56vwBUYQGZ6GpXnFuM2XMRFJbIyruFKKlW2daQliuYZwe0qfn/UjFCDKic5g==} dev: false diff --git a/showcases/ui5-tsapp-simple/package.json b/showcases/ui5-tsapp-simple/package.json index 8aa80802f..d91f65b84 100644 --- a/showcases/ui5-tsapp-simple/package.json +++ b/showcases/ui5-tsapp-simple/package.json @@ -30,6 +30,7 @@ "typescript": "^5.1.6", "ui5-middleware-livereload": "workspace:^", "ui5-tooling-modules": "workspace:^", - "ui5-tooling-transpile": "workspace:^" + "ui5-tooling-transpile": "workspace:^", + "ui5-types-typedjsonmodel": "workspace:^" } } diff --git a/showcases/ui5-tsapp-simple/webapp/controller/Main.controller.ts b/showcases/ui5-tsapp-simple/webapp/controller/Main.controller.ts index 657191ec7..0c8057fa8 100644 --- a/showcases/ui5-tsapp-simple/webapp/controller/Main.controller.ts +++ b/showcases/ui5-tsapp-simple/webapp/controller/Main.controller.ts @@ -1,11 +1,45 @@ import Controller from "sap/ui/core/mvc/Controller"; -import MessageToast from "sap/m/MessageToast"; +import { createTypedJSONModel } from "../model/models"; /** * @namespace ui5.ecosystem.demo.simpletsapp.controller */ export default class Main extends Controller { - public onBoo(): void { - MessageToast.show(`👻`); + model = createTypedJSONModel({ + notes: [ + { + id: 0, + text: "Hello World", + createdAt: new Date(), + }, + ], + currentNoteId: 0, + + newNoteInput: "", + }); + + onInit() { + this.getView()?.setModel(this.model); + } + + onDelete(id: number) { + console.log(id); + let notes = this.model.getProperty("/notes"); + notes = notes.filter((note) => note.id !== id); + this.model.setProperty("/notes", notes); + } + + onCreate() { + const notes = this.model.getProperty("/notes"); + const newNoteId = this.model.getProperty("/currentNoteId") + 1; + notes.push({ + id: newNoteId, + text: this.model.getProperty("/newNoteInput"), + createdAt: new Date(), + }); + + this.model.setProperty("/notes", notes); + this.model.setProperty("/currentNoteId", newNoteId); + this.model.setProperty("/newNoteInput", ""); } } diff --git a/showcases/ui5-tsapp-simple/webapp/i18n/i18n.properties b/showcases/ui5-tsapp-simple/webapp/i18n/i18n.properties index fa02233c4..8605424ac 100644 --- a/showcases/ui5-tsapp-simple/webapp/i18n/i18n.properties +++ b/showcases/ui5-tsapp-simple/webapp/i18n/i18n.properties @@ -1,2 +1,6 @@ appTitle=Simple TypeScript UI5 Application appDescription=A really simple TypeScript UI5 Application + +AddNote=Add Note +DeleteNote=Delete Note +MyNotes=My Notes diff --git a/showcases/ui5-tsapp-simple/webapp/i18n/i18n_en.properties b/showcases/ui5-tsapp-simple/webapp/i18n/i18n_en.properties index fa02233c4..8605424ac 100644 --- a/showcases/ui5-tsapp-simple/webapp/i18n/i18n_en.properties +++ b/showcases/ui5-tsapp-simple/webapp/i18n/i18n_en.properties @@ -1,2 +1,6 @@ appTitle=Simple TypeScript UI5 Application appDescription=A really simple TypeScript UI5 Application + +AddNote=Add Note +DeleteNote=Delete Note +MyNotes=My Notes diff --git a/showcases/ui5-tsapp-simple/webapp/model/models.ts b/showcases/ui5-tsapp-simple/webapp/model/models.ts index 4a8608ff8..4ef0a6e18 100644 --- a/showcases/ui5-tsapp-simple/webapp/model/models.ts +++ b/showcases/ui5-tsapp-simple/webapp/model/models.ts @@ -1,8 +1,13 @@ import JSONModel from "sap/ui/model/json/JSONModel"; import Device from "sap/ui/Device"; +import type { TypedJSONModel, TypedJSONModelData } from "ui5-types-typedjsonmodel/lib/TypedJSONModel"; export function createDeviceModel(): JSONModel { const model = new JSONModel(Device); model.setDefaultBindingMode("OneWay"); return model; } + +export function createTypedJSONModel(oData: T, bObserve?: boolean | undefined) { + return new JSONModel(oData, bObserve) as TypedJSONModel; +} diff --git a/showcases/ui5-tsapp-simple/webapp/view/Main.view.xml b/showcases/ui5-tsapp-simple/webapp/view/Main.view.xml index ede22f202..4b3240671 100644 --- a/showcases/ui5-tsapp-simple/webapp/view/Main.view.xml +++ b/showcases/ui5-tsapp-simple/webapp/view/Main.view.xml @@ -1,11 +1,26 @@ - + - -