Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support using predefined controller extensions #120

Merged
merged 1 commit into from
Nov 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
52 changes: 44 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ There are 2 main features of the plugin, and you can use both or one without the
1. Converting ES modules (import/export) into sap.ui.define or sap.ui.require.
2. Converting ES classes into Control.extend(..) syntax.

**NOTE:** The class transform will be split into its own plugin in the future.
**NOTE:** The class transform might be split into its own plugin in the future.

This only transforms the UI5 relevant things. It does not transform everything to ES5 (for example it does not transform const/let to var). This makes it easier to use `@babel/preset-env` to transform things correctly.

Expand Down Expand Up @@ -417,7 +417,7 @@ const AController = SAPController.extend("my.app.AController", {

Alternatively, you can use decorators to override the namespace or name used. The same properties as JSDoc will work, but instead of a space, pass the string literal to the decorator function.

NOTE that using a variable is currently not supported, but will be.
NOTE that using a variable is currently not supported.

```js
@alias('my.app.AController')
Expand Down Expand Up @@ -469,7 +469,9 @@ class Controller extends SAPController {

#### Instance Class Props

Instance props either get added to the constructor or the onInit function (for controller), or get added to the extend object. However in v7, there will be a breaking change to always put instance props in either the constructor or the onInit, so if you want a prop in the extend object, it's best to use a static prop.
Instance props either get added to the constructor or to the `onInit` function (for controllers).

Before version 7.x, they could also get added directly to the `SomeClass.extend(..)` config object, but not anymore now. So if you still want a prop in the extend object, it's best to use a static prop. However, there are some exception where it is known that UI5 expects certain properties in the extend object, like `renderer`, `metadata` and `overrides` and some configurable cases related to controller extensions (see below).

Refer to the next section to see the logic for determining if `constructor` or `onInit` is used as the init function for class properties.

Expand All @@ -479,8 +481,8 @@ In the bind method (either constructor or onInit), the properties get added afte

```js
class Controller extends SAPController {
A = 1; // added to extend object in v6
B = Imported.B; // added to extend object in v6
A = 1; // added to constructor or onInit (to extend object in v6 and lower)
B = Imported.B; // added to constructor or onInit (to extend object in v6 and lower)
C = () => true; // added to constructor or onInit
D = this.B.C; // added to constructor or onInit
E = func(this); // added to constructor or onInit
Expand Down Expand Up @@ -561,7 +563,7 @@ class MyController extends Controller {
}
```

### Handling metadata and renderer
#### Handling metadata and renderer

Because ES classes are not plain objects, you can't have an object property like 'metadata'.

Expand All @@ -587,8 +589,10 @@ const MyControl = SAPClass.extend('MyControl', {
});
```

It additionally supports the usage of the new `overrides` class property required for a `ControllerExtension`.
(For backward compatibility, you can use `overridesToOverride: true`)
#### Properties related to Controller Extensions

The new (as of UI5 1.112) `overrides` class property required for implementing a `ControllerExtension` will also be added to the extend object.
(For backward compatibility with older UI5 runtimes, you can use `overridesToOverride: true`.)

```js
class MyExtension extends ControllerExtension {
Expand All @@ -610,6 +614,38 @@ const MyExtension = ControllerExtension.extend("MyExtension", {
return MyExtension;
```

When a controller implemented by you *uses* pre-defined controller extensions, in JavaScript the respective extension *class* needs to be assigned to the extend object; the UI5 runtime will instatiate the extension and this *instance* will then be available as `this.extensionName`.

To support the same in TypeScript, while in the JavaScript code a controller *class* must be assigned in the extend object, the TypeScript compiler needs to see that the class property contains an extension *instance*. To support this, the plugin applies special logic which transforms the code accordingly. This logic is triggered with any comment containing the string `@transformControllerExtension` or the `@transformControllerExtension` decorator directly preceding the class property. And the class property must only be typed, but not assigned an instance. (The instance is created by the UI5 framework.)

Example:
```js
class MyController extends Controller {

// @transformControllerExtension
routing: Routing; // use the "Routing" extension provided by "sap/fe/core/controllerextensions/Routing"

someMethod() {
this.routing.doSomething();
}
}
```

is converted to

```js
const MyController = Controller.extend("MyController", {
routing: Routing, // note that this is now the Routing CLASS being assigned as value within the extend object, while above it was the Routing TYPE defining the type of the member property

someMethod: function() {
this.routing.doSomething();
}
});
return MyController;
```

#### Static Properties

Since class properties are an early ES proposal, TypeScript's compiler (like babel's class properties transform) moves static properties outside the class definition, and moves instance properties inside the constructor (even if TypeScript is configured to output ESNext).

To support this, the plugin will also search for static properties outside the class definition. It does not currently search in the constructor (but will in the future) so be sure to define renderer and metadata as static props if Typescript is used.
Expand Down
46 changes: 46 additions & 0 deletions packages/plugin/__test__/__snapshots__/test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,20 @@ exports[`decorators mixin_mixed.js 1`] = `
});"
`;

exports[`decorators ts-class-controller-extension-usage-decorator.ts 1`] = `
"sap.ui.define(["sap/ui/core/mvc/Controller", "sap/fe/core/controllerextensions/Routing"], function (Controller, Routing) {
"use strict";

/**
* @namespace test.controller
*/
const MyExtendedController = Controller.extend("test.controller.MyExtendedController", {
routing: Routing
});
return MyExtendedController;
});"
`;

exports[`empty empty_js.js 1`] = `""`;

exports[`empty empty_ts.ts 1`] = `""`;
Expand Down Expand Up @@ -1648,6 +1662,38 @@ sap.ui.define(["sap/Class"], function (SAPClass) {
});"
`;

exports[`typescript ts-class-controller-extension-usage.ts 1`] = `
"sap.ui.define(["sap/ui/core/mvc/Controller", "sap/fe/core/controllerextensions/Routing", "sap/fe/core/controllerextensions/OtherExtension", "sap/fe/core/controllerextensions/ThirdExtension", "sap/fe/core/controllerextensions/DoubleExportExtension", "sap/fe/core/controllerextensions/ManyExtensions"], function (Controller, Routing, sap_fe_core_controllerextensions_OtherExtension, ThirdExtension, sap_fe_core_controllerextensions_DoubleExportExtension, extensionCollection) {
"use strict";

const OtherExtension = sap_fe_core_controllerextensions_OtherExtension["OtherExtension"];
const SomethingElse = sap_fe_core_controllerextensions_DoubleExportExtension["SomethingElse"];
const AlmostRemovedExtension = sap_fe_core_controllerextensions_DoubleExportExtension["AlmostRemovedExtension"];
/**
* @namespace test.controller
*/
const MyExtendedController = Controller.extend("test.controller.MyExtendedController", {
// @transformControllerExtension
fifth: extensionCollection.group.OneOfManyExtensions,
// @transformControllerExtension
fourth: AlmostRemovedExtension,
// @transformControllerExtension
third: ThirdExtension,
// @transformControllerExtension
other: OtherExtension,
/**
* @transformControllerExtension
*/
routing: Routing,
constructor: function _constructor() {
Controller.prototype.constructor.call(this);
this.realPropertyExtension = SomethingElse.Something;
}
});
return MyExtendedController;
});"
`;

exports[`typescript ts-class-param-props.ts 1`] = `
"sap.ui.define(["sap/Class"], function (SAPClass) {
"use strict";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import Controller from "sap/ui/core/mvc/Controller";
import Routing from "sap/fe/core/controllerextensions/Routing";

/**
* @namespace test.controller
*/
export default class MyExtendedController extends Controller {

@transformControllerExtension
routing: Routing;

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import Controller from "sap/ui/core/mvc/Controller";
import Routing from "sap/fe/core/controllerextensions/Routing";
import { OtherExtension } from "sap/fe/core/controllerextensions/OtherExtension";
import * as ThirdExtension from "sap/fe/core/controllerextensions/ThirdExtension";
import { SomethingElse, AlmostRemovedExtension } from "sap/fe/core/controllerextensions/DoubleExportExtension";
import * as extensionCollection from "sap/fe/core/controllerextensions/ManyExtensions";

/**
* @namespace test.controller
*/
export default class MyExtendedController extends Controller {

/**
* @transformControllerExtension
*/
routing: Routing;

// @transformControllerExtension
other: OtherExtension;

// @transformControllerExtension
third: ThirdExtension;

// @transformControllerExtension
fourth: AlmostRemovedExtension;

// @transformControllerExtension
fifth: extensionCollection.group.OneOfManyExtensions;

realPropertyExtension;

constructor() {
super();

this.realPropertyExtension = SomethingElse.Something;
}
}
2 changes: 1 addition & 1 deletion packages/plugin/__test__/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function processDirectory(dir) {
presets,
sourceRoot: __dirname,
comments:
filePath.includes("comments") || filename.includes("copyright"),
filePath.includes("comments") || filename.includes("copyright") || filename.includes("controller-extension-usage"),
babelrc: false,
}).code;

Expand Down
89 changes: 87 additions & 2 deletions packages/plugin/src/classes/helpers/classes.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as ast from "../../utils/ast";

import { getJsDocClassInfo, getTags } from "./jsdoc";
import { getDecoratorClassInfo } from "./decorators";
import { getImportDeclaration } from "./imports";

/**
* Converts an ES6 class to a UI5 extend.
Expand All @@ -19,12 +20,14 @@ export function convertClassToUI5Extend(
node,
classInfo,
extraStaticProps,
importDeclarationPaths,
opts
) {
if (!(t.isClassDeclaration(node) || t.isClassExpression(node))) {
return node;
}

const CONTROLLER_EXTENSION_TAG = "transformControllerExtension";
const staticMembers = [];

const classNameIdentifier = node.id;
Expand Down Expand Up @@ -65,7 +68,8 @@ export function convertClassToUI5Extend(
}
}

for (const member of node.body.body) {
for (const memberPath of path.get("body.body")) {
const member = memberPath.node;
const memberName = member.key.name;

if (t.isClassMethod(member)) {
Expand Down Expand Up @@ -153,7 +157,60 @@ export function convertClassToUI5Extend(
}
}
} else if (t.isClassProperty(member)) {
if (!member.value) continue; // un-initialized static class prop (typescript)
// For class properties annotated to represent controller extensions, replace the pure declaration with an assignment (that's what the runtime expects)
// and keep them at the initialization object as properties (don't move into constructor).
if (
member.leadingComments?.some((comment) => {
return comment.value.includes("@" + CONTROLLER_EXTENSION_TAG);
}) ||
member.decorators?.some((decorator) => {
return decorator.expression?.name === CONTROLLER_EXTENSION_TAG;
})
) {
const typeAnnotation = member.typeAnnotation?.typeAnnotation;
// double-check that it is a valid node for a controller extension
if (
t.isTSTypeReference(typeAnnotation) ||
t.isTSQualifiedName(typeAnnotation)
) {
const typeName = getTypeName(typeAnnotation);

// 1. transform the property from being typed as instance and un-initialized to a property where the controller extension *class* is assigned as value
const valueIdentifier = t.identifier(typeName);
member.value = valueIdentifier;
member.typeAnnotation = null;
extendProps.unshift(buildObjectProperty(member)); // add it to the properties of the extend() config object

// 2. add a binding reference to the value, so in case the TS transpiler runs later it recognizes that the import is still needed
const typeNameFirstPart = typeName.split(".")[0]; // e.g. when "myExtension: someBundle.MyExtension"
if (memberPath.scope.hasBinding(typeNameFirstPart)) {
const binding = path.scope.getBinding(typeNameFirstPart);
binding.referencePaths.push(memberPath.get("value"));
}

// 3. restore the import in case it was run already and removed the import
const neededImportDeclaration = getImportDeclaration(
memberPath.hub.file.opts.filename,
typeName
);
if (
!importDeclarationPaths.some(
(path) => path.node === neededImportDeclaration
)
) {
// TODO: import might be there but with the specifier removed; we can clone, but then other specifiers are duplicate
// if import is no longer there, re-add it
importDeclarationPaths[
importDeclarationPaths.length - 1
].insertAfter(neededImportDeclaration);
}

// 4. prevent the member from also being added to the constructor (member does have a value now and initializer would be added below)
continue;
}
}

if (!member.value) continue; // remove all other un-initialized static class props (typescript)

// Special handling for TypeScript limitation where metadata, renderer and overrides must be properties.
if (["metadata", "renderer", "overrides"].includes(memberName)) {
Expand Down Expand Up @@ -338,6 +395,34 @@ function getFileBaseNamespace(path, pluginOpts) {
}
}

const getQualifiedName = (node) => {
let { left, right } = node;

// if left is TSQualifiedName, recursive call to get full namespace
if (t.isTSQualifiedName(left)) {
left = getQualifiedName(left);
} else {
// if left is an Identifier
left = left.name;
}

return `${left}.${right.name}`;
};

export const getTypeName = (typeAnnotation) => {
if (t.isTSTypeReference(typeAnnotation)) {
// for TSTypeReference, typeName can be an Identifier or a TSQualifiedName
return (
typeAnnotation.typeName.name || getQualifiedName(typeAnnotation.typeName)
);
}
if (t.isTSQualifiedName(typeAnnotation)) {
// for TSQualifiedName
return getQualifiedName(typeAnnotation);
}
return null;
};

const buildObjectProperty = (member) => {
const newObjectProperty = t.objectProperty(
member.key,
Expand Down
39 changes: 39 additions & 0 deletions packages/plugin/src/classes/helpers/imports.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { types as t } from "@babel/core";

let importDeclarations = {};

export const saveImports = (file) => {
// save all import declarations before "unneeded" ones are removed by the TypeScript plugin
importDeclarations[file.opts.filename] = file.ast.program.body.filter(
t.isImportDeclaration
); // right now even the removed import still exists later and can be re-added. Otherwise do: .map(decl => t.cloneNode(decl));
};

// can be called from visitor to access previously present declarations
export function getImportDeclaration(filename, typeName) {
const typeNameParts = typeName.split(".");

// find the declaration importing the typeName among the collected import declarations in this file
const filteredDeclarations = importDeclarations[filename].filter(
(importDeclaration) => {
// each import declaration can import several entities, so let's check all of them
for (let specifier of importDeclaration.specifiers) {
if (
(t.isImportDefaultSpecifier(specifier) ||
t.isImportNamespaceSpecifier(specifier)) &&
specifier.local.name === typeNameParts[0]
) {
// if the import is default, then the typeName should only have one part (the import name)
return true;
} else if (
t.isImportSpecifier(specifier) &&
specifier.imported.name === typeNameParts[typeNameParts.length - 1]
) {
// If the import is named, then the last part of the typeName should match the imported name
return true;
}
}
}
);
return filteredDeclarations[0]; // should be exactly one
}
6 changes: 6 additions & 0 deletions packages/plugin/src/classes/pre.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { saveImports } from "./helpers/imports";

export const ClassPre = (file) => {
// save all import declarations before "unneeded" ones are removed by the TypeScript plugin
saveImports(file);
};