diff --git a/resources/integration_cards_guidelines.md b/resources/integration_cards_guidelines.md index d1c6c99d..4dd1d524 100644 --- a/resources/integration_cards_guidelines.md +++ b/resources/integration_cards_guidelines.md @@ -39,6 +39,9 @@ ### 1.3 Analytical Cards - **ALWAYS** follow [6. Analytical Cards Coding Guidelines](#6-analytical-cards-coding-guidelines) when developing Analytical cards. +### 1.4 Configuration Editor +- **ALWAYS** follow [5. Configuration Editor](#5-configuration-editor) guidelines when creating or modifying Configuration Editors for Integration Cards. + ## 2. Validation - **ALWAYS** ensure that `manifest.json` file is valid JSON. - **ALWAYS** ensure that in `manifest.json` file the property `sap.app/type` is set to `"card"`. @@ -50,13 +53,268 @@ - The Card Explorer provides detailed documentation for the Integration Cards schema, including descriptions of every property, guidance for integrating cards into hosting environments, configuration editor documentation with examples, and broader best practices. It is available at: https://ui5.sap.com/test-resources/sap/ui/integration/demokit/cardExplorer/webapp/index.html ## 4. Preview Instructions -- **ALWAYS** check the card folder for an existing preview file and any accompanying instructions or scripts, and reuse them if available. +- If preview of the card must be shown, **ALWAYS** check the card folder for an existing preview file and any accompanying instructions or scripts, and reuse them if available. * for example, in NodeJS-based projects, search the `package.json` file for `start` or similar script. If such is available, use it * also search in the `README.md` file. - If preview instructions are not available, you have to create an HTML page that contains a `ui-integration` card element which references the card manifest. Then serve the HTML page using `http` server. ## 5. Configuration Editor -- When a Configuration Editor is available, make as many Integration Card fields editable as possible. +Configuration Editor allows different personas to customize Integration Cards without modifying the manifest file directly. +The following roles/personas are supported: +- Administrator +- Page/Content Administrator +- Translator + +The Configuration Editor is implemented through two key components: + +1. **Definition file**: Create a `dt/Configuration.js` file that exports a Designtime definition object +2. **Manifest reference**: Reference this definition in the `manifest.json` under the `sap.card/configuration/editor` property + +The `dt/Configuration.js` file defines the Configuration Editor's structure by specifying: +- Form layout and field definitions +- Input controls and visualizations +- Validation rules and field relationships +- Grouping and organization of configuration options + +When creating or modifying Integration Cards, follow these guidelines for Configuration Editors: +- Assume the role of Administrator persona when designing the Configuration Editor. +- **ALWAYS** ensure that the Configuration Editor reflects the current structure and fields of the `manifest.json`. +- **ALWAYS** make the existing fields in the `manifest.json` configurable via the editor. For example manifest parameters, title, subtitle, icon of the header, etc. +- **NEVER** add fields to the editor that do not exist in the `manifest.json`. +- **ALWAYS** remove fields from the editor when removing them from the `manifest.json`. +- **ALWAYS** add fields in the Configuration Editor when adding them to the `manifest.json`. + +### 5.1 Example: +`manifest.json` file: +```json +{ + "sap.app": { + "id": "test.editor", + "type": "card", + "title": "Test Card", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5" + }, + "sap.card": { + "type": "List", + "configuration": { + "editor": "./dt/Configuration", + "parameters": { + "cardTitle": { + "value": "Customers" + }, + "icon": { + "value": "sap-icon://account" + }, + "maxItems": { + "value": 3 + }, + "showDescription": { + "value": true + }, + "dateContext": { + "value": "2020-09-02" + }, + "Customers": { + "value": ["ALFKI"] + }, + "northwindDestination": { + "value": "northwind" + } + }, + "destinations": { + "northwind": { + "name": "Northwind_V4", + "defaultUrl": "https://services.odata.org/V4/Northwind/Northwind.svc" + } + } + }, + "data": { + "request": { + "url": "{{destinations.northwind}}/Customers", + "parameters": { + "$select": "CustomerID,CompanyName,ContactName", + "$top": "{parameters>/maxItems/value}" + } + } + }, + "header": { + "title": "{parameters>/cardTitle/value}", + "subtitle": "As of {parameters>/dateContext/value}", + "icon": { + "src": "{parameters>/icon/value}", + "shape": "Circle" + } + }, + "content": { + "data": { + "path": "/value" + }, + "item": { + "title": "{CompanyName}", + "description": "{= ${parameters>/showDescription/value} ? ${ContactName} : '' }" + }, + "maxItems": "{parameters>/maxItems/value}" + } + } +} +``` + +`dt/Configuration.js` file: +```javascript +sap.ui.define(["sap/ui/integration/Designtime"], function (Designtime) { + "use strict"; + + return function () { + return new Designtime({ + form: { + items: { + + /* ======================= + General + ======================= */ + generalGroup: { + type: "group", + label: "General" + }, + + cardTitle: { + manifestpath: "/sap.card/configuration/parameters/cardTitle/value", + type: "string", + label: "Card Title", + translatable: true, + required: true, + allowDynamicValues: true + }, + + icon: { + manifestpath: "/sap.card/header/icon/src", + type: "string", + label: "Icon", + visualization: { + type: "IconSelect", + settings: { + value: "{currentSettings>value}", + editable: "{currentSettings>editable}" + } + } + }, + + iconShape: { + manifestpath: "/sap.card/header/icon/shape", + type: "string", + label: "Icon Shape", + visualization: { + type: "ShapeSelect", + settings: { + value: "{currentSettings>value}", + editable: "{currentSettings>editable}" + } + }, + cols: 1 + }, + + iconBackground: { + manifestpath: "/sap.card/header/icon/backgroundColor", + type: "string", + label: "Icon Background", + visualization: { + type: "ColorSelect", + settings: { + enumValue: "{currentSettings>value}", + editable: "{currentSettings>editable}" + } + }, + cols: 1 + }, + + /* ======================= + Data & Behavior + ======================= */ + dataGroup: { + type: "group", + label: "Data & Behavior" + }, + + maxItems: { + manifestpath: "/sap.card/configuration/parameters/maxItems/value", + type: "integer", + label: "Maximum Items", + visualization: { + type: "Slider", + settings: { + value: "{currentSettings>value}", + min: 1, + max: 10, + width: "100%", + enabled: "{currentSettings>editable}" + } + } + }, + + showDescription: { + manifestpath: "/sap.card/configuration/parameters/showDescription/value", + type: "boolean", + label: "Show Contact Name", + visualization: { + type: "Switch", + settings: { + state: "{currentSettings>value}", + customTextOn: "Show", + customTextOff: "Hide", + enabled: "{currentSettings>editable}" + } + } + }, + + dateContext: { + manifestpath: "/sap.card/configuration/parameters/dateContext/value", + type: "date", + label: "Date Context" + }, + + /* ======================= + Filtering + ======================= */ + filterGroup: { + type: "group", + label: "Customer Filter" + }, + + CustomerID: { + manifestpath: "/sap.card/configuration/parameters/CustomerID/value", + type: "string", + label: "Customer ID", + values: { + data: { + request: { + url: "{{destinations.northwind}}/Customers", + parameters: { + "$select": "CustomerID,CompanyName" + } + }, + path: "/value" + }, + item: { + key: "{CustomerID}", + text: "{CompanyName}" + } + } + } + } + }, + preview: { + modes: "None" + } + }); + }; +}); + +``` ## 6. Analytical Cards Coding Guidelines - **ALWAYS** set `sap.card/content/chartType` property. diff --git a/resources/template-card/card/manifest.json b/resources/template-card/card/manifest.json index 23fcba5c..d36f61ab 100644 --- a/resources/template-card/card/manifest.json +++ b/resources/template-card/card/manifest.json @@ -19,6 +19,16 @@ "sap.card": { "type": "<%= cardType %>", "configuration": { + <% if (destinations && destinations.length > 0) { %> + "destinations": { + <% destinations.forEach(function(destination, index) { %> + "<%= destination.name %>": { + "name": "<%= destination.name %>", + "defaultUrl": "<%= destination.defaultUrl %>" + }<%= index < destinations.length - 1 ? ',' : '' %> + <% }); %> + }, + <% } %> "editor": "./dt/Configuration" }, <% if (cardType === "Analytical") { %> diff --git a/resources/template-card/test/App.js b/resources/template-card/test/App.js index 7dee18be..14024789 100644 --- a/resources/template-card/test/App.js +++ b/resources/template-card/test/App.js @@ -7,13 +7,20 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => { const card = document.getElementById("card"); const applyChangesBtn = document.getElementById("applyChangesBtn"); const resetBtn = document.getElementById("resetBtn"); + const destinations = { + <%_ if (destinations && destinations.length > 0) { -%> + <%_ destinations.forEach(function(destination, index) { -%> + "<%= destination.name %>": "<%= destination.defaultUrl %>"<%= index < destinations.length - 1 ? ',' : '' %> + <%_ }); -%> + <%_ } -%> + }; const host = new Host({ - resolveDestination: function(sDestinationName) { - if (sDestinationName === "Northwind") { - return "https://services.odata.org/V4/Northwind/Northwind.svc/"; + resolveDestination: function(destinationName) { + if (destinations[destinationName]) { + return destinations[destinationName]; } - throw new Error("Destination " + sDestinationName + " not found!"); + return Promise.reject("Destination " + destinationName + " not found!"); }, actions: [ { @@ -27,6 +34,15 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => { ] }); + // Called by the Configuration Editor to show a list of available destinations + host.getDestinations = function() { + return Promise.resolve(Object.entries(destinations).map(([name, url]) => { + return { + name, + }; + })); + }; + card.host = host.getId(); card.manifest = "../card/manifest.json"; diff --git a/resources/template-card/test/index.css b/resources/template-card/test/index.css index b0477e4f..4239c912 100644 --- a/resources/template-card/test/index.css +++ b/resources/template-card/test/index.css @@ -17,6 +17,10 @@ body { flex-direction: column; } +#editorSection { + --sapUiIntegrationEditorFormHeight: auto; +} + .button-container { display: flex; justify-content: flex-end; diff --git a/resources/template-card/test/index.html b/resources/template-card/test/index.html index d31f0270..68305f66 100644 --- a/resources/template-card/test/index.html +++ b/resources/template-card/test/index.html @@ -23,9 +23,7 @@

Preview

Integration Card

+ id="card">
; + export const inputSchema = { basePath: z.string() .describe("Absolute base path for the creation."), @@ -20,4 +29,7 @@ export const inputSchema = { cardType: z.enum(supportedCardTypes) .describe("Type of the Integration Card to create.") .default("List"), + destinations: z.array(destinationSchema) + .describe("List of destinations to be included in the card configuration.") + .optional(), }; diff --git a/src/tools/create_ui5_app/create_ui5_app.ts b/src/tools/create_ui5_app/create_ui5_app.ts index 10fd8c54..5ce1d85a 100644 --- a/src/tools/create_ui5_app/create_ui5_app.ts +++ b/src/tools/create_ui5_app/create_ui5_app.ts @@ -11,7 +11,8 @@ import {CreateUi5AppParams, CreateUi5AppResult} from "./schema.js"; import {getLogger} from "@ui5/logger"; import {isUi5Framework, Ui5Framework} from "../../utils/ui5Framework.js"; import {dirExists, InvalidInputError, PKG_VERSION} from "../../utils.js"; -import isValidUrl from "./isValidUrl.js"; +import getAllowedDomains from "../../utils/getAllowedDomains.js"; +import isValidUrl from "../../utils/isValidUrl.js"; const log = getLogger("tools:create_ui5_app:create_ui5_app"); @@ -299,36 +300,3 @@ The minimum version for ${framework} is ${minFwkVersionToUse}.` }, }; } - -function getAllowedDomains() { - if ("UI5_MCP_SERVER_ALLOWED_DOMAINS" in process.env || "UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS" in process.env) { - const inputDomainList = process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS ?? - process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS; - if (!inputDomainList?.trim()) { - // Empty list allows all domains - log.verbose("Empty value for UI5_MCP_SERVER_ALLOWED_DOMAINS, allowing all domains"); - return []; - } - // Use the environment variable if set - const domainList = inputDomainList.split(",").map((d) => d.trim()); - // Validate domains to catch user errors - for (const domain of domainList) { - try { - // Note that the dot prefix (which we use for wildcards) is valid in a domain - new URL(`https://${domain}`); - } catch (err) { - throw new InvalidInputError( - `Invalid domain '${domain}' in UI5_MCP_SERVER_ALLOWED_DOMAINS: ` + - (err instanceof Error ? err.message : String(err)) - ); - } - } - log.verbose(`${domainList.length} allowed OData V4 domains configured: ${domainList.join(", ")}`); - return domainList; - } - return [ - // Default allowed domains for OData V4 services - "localhost", - "services.odata.org", - ]; -} diff --git a/src/utils/getAllowedDomains.ts b/src/utils/getAllowedDomains.ts new file mode 100644 index 00000000..c883f393 --- /dev/null +++ b/src/utils/getAllowedDomains.ts @@ -0,0 +1,37 @@ +import {getLogger} from "@ui5/logger"; +import {InvalidInputError} from "../utils.js"; + +const log = getLogger("utils:getAllowedDomains"); + +export default function getAllowedDomains() { + if ("UI5_MCP_SERVER_ALLOWED_DOMAINS" in process.env || "UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS" in process.env) { + const inputDomainList = process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS ?? + process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS; + if (!inputDomainList?.trim()) { + // Empty list allows all domains + log.verbose("Empty value for UI5_MCP_SERVER_ALLOWED_DOMAINS, allowing all domains"); + return []; + } + // Use the environment variable if set + const domainList = inputDomainList.split(",").map((d) => d.trim()); + // Validate domains to catch user errors + for (const domain of domainList) { + try { + // Note that the dot prefix (which we use for wildcards) is valid in a domain + new URL(`https://${domain}`); + } catch (err) { + throw new InvalidInputError( + `Invalid domain '${domain}' in UI5_MCP_SERVER_ALLOWED_DOMAINS: ` + + (err instanceof Error ? err.message : String(err)) + ); + } + } + log.verbose(`${domainList.length} allowed domains configured: ${domainList.join(", ")}`); + return domainList; + } + return [ + // Default allowed domains for services + "localhost", + "services.odata.org", + ]; +} diff --git a/src/tools/create_ui5_app/isValidUrl.ts b/src/utils/isValidUrl.ts similarity index 98% rename from src/tools/create_ui5_app/isValidUrl.ts rename to src/utils/isValidUrl.ts index 931cde42..cfb3bed4 100644 --- a/src/tools/create_ui5_app/isValidUrl.ts +++ b/src/utils/isValidUrl.ts @@ -1,4 +1,4 @@ -import {InvalidInputError} from "../../utils.js"; +import {InvalidInputError} from "../utils.js"; /** * Validates a URL against a set of specified rules, including protocol and domain allow lists. diff --git a/test/expected/create_integration_card/common/test/App.js b/test/expected/create_integration_card/common/test/App.js index 7dee18be..847e2e5a 100644 --- a/test/expected/create_integration_card/common/test/App.js +++ b/test/expected/create_integration_card/common/test/App.js @@ -7,13 +7,15 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => { const card = document.getElementById("card"); const applyChangesBtn = document.getElementById("applyChangesBtn"); const resetBtn = document.getElementById("resetBtn"); + const destinations = { + }; const host = new Host({ - resolveDestination: function(sDestinationName) { - if (sDestinationName === "Northwind") { - return "https://services.odata.org/V4/Northwind/Northwind.svc/"; + resolveDestination: function(destinationName) { + if (destinations[destinationName]) { + return destinations[destinationName]; } - throw new Error("Destination " + sDestinationName + " not found!"); + return Promise.reject("Destination " + destinationName + " not found!"); }, actions: [ { @@ -27,6 +29,15 @@ sap.ui.define(["sap/ui/integration/Host"], async (Host) => { ] }); + // Called by the Configuration Editor to show a list of available destinations + host.getDestinations = function() { + return Promise.resolve(Object.entries(destinations).map(([name, url]) => { + return { + name, + }; + })); + }; + card.host = host.getId(); card.manifest = "../card/manifest.json"; diff --git a/test/expected/create_integration_card/common/test/index.css b/test/expected/create_integration_card/common/test/index.css index b0477e4f..4239c912 100644 --- a/test/expected/create_integration_card/common/test/index.css +++ b/test/expected/create_integration_card/common/test/index.css @@ -17,6 +17,10 @@ body { flex-direction: column; } +#editorSection { + --sapUiIntegrationEditorFormHeight: auto; +} + .button-container { display: flex; justify-content: flex-end; diff --git a/test/expected/create_integration_card/common/test/index.html b/test/expected/create_integration_card/common/test/index.html index d31f0270..68305f66 100644 --- a/test/expected/create_integration_card/common/test/index.html +++ b/test/expected/create_integration_card/common/test/index.html @@ -23,9 +23,7 @@

Preview

Integration Card

+ id="card">
title}", + "subtitle": "{i18n>subtitle}" + }, + "content": { + "data": { + "path": "/items" + }, + "item": { + "title": "{title}", + "description": "{description}" + } + } + } +} \ No newline at end of file diff --git a/test/expected/create_integration_card/destinations/package.json b/test/expected/create_integration_card/destinations/package.json new file mode 100644 index 00000000..91b07f69 --- /dev/null +++ b/test/expected/create_integration_card/destinations/package.json @@ -0,0 +1,12 @@ +{ + "name": "template-card", + "version": "1.0.0", + "description": "A basic template card project", + "scripts": { + "start": "http-server ./ -c-1 -o test/index.html", + "test": "echo \"No tests specified\" && exit 0" + }, + "devDependencies": { + "http-server": "^14.1.1" + } +} \ No newline at end of file diff --git a/test/expected/create_integration_card/destinations/test/App.js b/test/expected/create_integration_card/destinations/test/App.js new file mode 100644 index 00000000..541c37e9 --- /dev/null +++ b/test/expected/create_integration_card/destinations/test/App.js @@ -0,0 +1,63 @@ +sap.ui.define(["sap/ui/integration/Host"], async (Host) => { + "use strict"; + await customElements.whenDefined("ui-integration-card"); + await customElements.whenDefined("ui-integration-card-editor"); + + const editor = document.getElementById("editor"); + const card = document.getElementById("card"); + const applyChangesBtn = document.getElementById("applyChangesBtn"); + const resetBtn = document.getElementById("resetBtn"); + const destinations = { + "northwind": "https://services.odata.org/V4/Northwind/Northwind.svc/", + "myapi": "http://localhost:8080/v1/" + }; + const host = new Host({ + resolveDestination: function(destinationName) { + if (destinations[destinationName]) { + return destinations[destinationName]; + } + + return Promise.reject("Destination " + destinationName + " not found!"); + }, + actions: [ + { + type: "Custom", + text: "Configure", + icon: "sap-icon://action-settings", + action: () => { + document.getElementById("editorSection").classList.remove("hidden"); + } + } + ] + }); + + // Called by the Configuration Editor to show a list of available destinations + host.getDestinations = function() { + return Promise.resolve(Object.entries(destinations).map(([name, url]) => { + return { + name, + }; + })); + }; + + card.host = host.getId(); + card.manifest = "../card/manifest.json"; + + editor.host = host.getId(); + editor.card = "card"; + + let initialSettings; + + // UI5 CustomElementBase doesn't expose the "ready" event, so use timeout + setTimeout(() => { + initialSettings = editor.getCurrentSettings(); + }, 3000); + + applyChangesBtn.addEventListener("click", () => { + card.manifestChanges = [editor.getCurrentSettings()]; + }); + + resetBtn.addEventListener("click", () => { + card.manifestChanges = [initialSettings]; + }); +}); \ No newline at end of file diff --git a/test/expected/create_integration_card/destinations/test/index.css b/test/expected/create_integration_card/destinations/test/index.css new file mode 100644 index 00000000..4239c912 --- /dev/null +++ b/test/expected/create_integration_card/destinations/test/index.css @@ -0,0 +1,61 @@ +body { + margin: 2rem; +} + +.container { + display: flex; + gap: 2rem; + align-items: flex-start; + flex-wrap: wrap; +} + +.card-section { + flex: 1; + min-width: 300px; + max-width: 600px; + display: block; + flex-direction: column; +} + +#editorSection { + --sapUiIntegrationEditorFormHeight: auto; +} + +.button-container { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +.config-button { + padding: 0.25rem 0.5rem; + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s ease; +} + +.config-button.primary { + background-color: #0070f3; + color: white; +} + +.config-button.primary:hover { + background-color: #0051cc; +} + +.config-button.secondary { + background-color: #f5f5f5; + color: #333; + border: 1px solid #ddd; +} + +.config-button.secondary:hover { + background-color: #e5e5e5; +} + +.hidden { + display: none; +} \ No newline at end of file diff --git a/test/expected/create_integration_card/destinations/test/index.html b/test/expected/create_integration_card/destinations/test/index.html new file mode 100644 index 00000000..68305f66 --- /dev/null +++ b/test/expected/create_integration_card/destinations/test/index.html @@ -0,0 +1,43 @@ + + + + + + + Integration Card Preview + + + + + + +

Preview

+
+
+

Integration Card

+ + +
+ +
+ + + \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/card/dt/Configuration.js b/test/expected/create_integration_card/single_destination/card/dt/Configuration.js new file mode 100644 index 00000000..bc386ab0 --- /dev/null +++ b/test/expected/create_integration_card/single_destination/card/dt/Configuration.js @@ -0,0 +1,43 @@ +sap.ui.define([ + "sap/ui/integration/Designtime", +], +function ( + Designtime +) { + "use strict"; + + return function () { + return new Designtime({ + form: { + items: { + groupheader1: { + label: "General Settings", + type: "group", + }, + separator1: { + type: "separator", + }, + title: { + manifestpath: "/sap.card/header/title", + type: "string", + translatable: true, + label: "Card Title", + cols: 1, + allowDynamicValues: true, + }, + subtitle: { + manifestpath: "/sap.card/header/subtitle", + type: "string", + translatable: true, + label: "Card Subtitle", + cols: 1, + allowDynamicValues: true, + }, + }, + }, + preview: { + modes: "None", + }, + }); + }; +}); \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/card/i18n/i18n.properties b/test/expected/create_integration_card/single_destination/card/i18n/i18n.properties new file mode 100644 index 00000000..467b579c --- /dev/null +++ b/test/expected/create_integration_card/single_destination/card/i18n/i18n.properties @@ -0,0 +1,2 @@ +title=Sample Integration Card +subtitle=Subtitle \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/card/manifest.json b/test/expected/create_integration_card/single_destination/card/manifest.json new file mode 100644 index 00000000..adaf6789 --- /dev/null +++ b/test/expected/create_integration_card/single_destination/card/manifest.json @@ -0,0 +1,58 @@ +{ + "_version": "1.78.0", + "sap.app": { + "id": "sample.integration.card", + "type": "card", + "title": "Sample Integration Card", + "applicationVersion": { + "version": "1.0.0" + } + }, + "sap.ui": { + "technology": "UI5", + "deviceTypes": { + "desktop": true, + "tablet": true, + "phone": true + } + }, + "sap.card": { + "type": "List", + "configuration": { + "destinations": { + "northwind": { + "name": "northwind", + "defaultUrl": "https://services.odata.org/V4/Northwind/Northwind.svc/" + } + }, + "editor": "./dt/Configuration" + }, + "data": { + "json": { + "items": [ + { + "title": "Item 1", + "description": "Description for Item 1" + }, + { + "title": "Item 2", + "description": "Description for Item 2" + } + ] + } + }, + "header": { + "title": "{i18n>title}", + "subtitle": "{i18n>subtitle}" + }, + "content": { + "data": { + "path": "/items" + }, + "item": { + "title": "{title}", + "description": "{description}" + } + } + } +} \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/package.json b/test/expected/create_integration_card/single_destination/package.json new file mode 100644 index 00000000..91b07f69 --- /dev/null +++ b/test/expected/create_integration_card/single_destination/package.json @@ -0,0 +1,12 @@ +{ + "name": "template-card", + "version": "1.0.0", + "description": "A basic template card project", + "scripts": { + "start": "http-server ./ -c-1 -o test/index.html", + "test": "echo \"No tests specified\" && exit 0" + }, + "devDependencies": { + "http-server": "^14.1.1" + } +} \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/test/App.js b/test/expected/create_integration_card/single_destination/test/App.js new file mode 100644 index 00000000..bffe1c60 --- /dev/null +++ b/test/expected/create_integration_card/single_destination/test/App.js @@ -0,0 +1,62 @@ +sap.ui.define(["sap/ui/integration/Host"], async (Host) => { + "use strict"; + await customElements.whenDefined("ui-integration-card"); + await customElements.whenDefined("ui-integration-card-editor"); + + const editor = document.getElementById("editor"); + const card = document.getElementById("card"); + const applyChangesBtn = document.getElementById("applyChangesBtn"); + const resetBtn = document.getElementById("resetBtn"); + const destinations = { + "northwind": "https://services.odata.org/V4/Northwind/Northwind.svc/" + }; + const host = new Host({ + resolveDestination: function(destinationName) { + if (destinations[destinationName]) { + return destinations[destinationName]; + } + + return Promise.reject("Destination " + destinationName + " not found!"); + }, + actions: [ + { + type: "Custom", + text: "Configure", + icon: "sap-icon://action-settings", + action: () => { + document.getElementById("editorSection").classList.remove("hidden"); + } + } + ] + }); + + // Called by the Configuration Editor to show a list of available destinations + host.getDestinations = function() { + return Promise.resolve(Object.entries(destinations).map(([name, url]) => { + return { + name, + }; + })); + }; + + card.host = host.getId(); + card.manifest = "../card/manifest.json"; + + editor.host = host.getId(); + editor.card = "card"; + + let initialSettings; + + // UI5 CustomElementBase doesn't expose the "ready" event, so use timeout + setTimeout(() => { + initialSettings = editor.getCurrentSettings(); + }, 3000); + + applyChangesBtn.addEventListener("click", () => { + card.manifestChanges = [editor.getCurrentSettings()]; + }); + + resetBtn.addEventListener("click", () => { + card.manifestChanges = [initialSettings]; + }); +}); \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/test/index.css b/test/expected/create_integration_card/single_destination/test/index.css new file mode 100644 index 00000000..4239c912 --- /dev/null +++ b/test/expected/create_integration_card/single_destination/test/index.css @@ -0,0 +1,61 @@ +body { + margin: 2rem; +} + +.container { + display: flex; + gap: 2rem; + align-items: flex-start; + flex-wrap: wrap; +} + +.card-section { + flex: 1; + min-width: 300px; + max-width: 600px; + display: block; + flex-direction: column; +} + +#editorSection { + --sapUiIntegrationEditorFormHeight: auto; +} + +.button-container { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1rem; +} + +.config-button { + padding: 0.25rem 0.5rem; + border: none; + border-radius: 0.25rem; + cursor: pointer; + font-size: 14px; + transition: background-color 0.2s ease; +} + +.config-button.primary { + background-color: #0070f3; + color: white; +} + +.config-button.primary:hover { + background-color: #0051cc; +} + +.config-button.secondary { + background-color: #f5f5f5; + color: #333; + border: 1px solid #ddd; +} + +.config-button.secondary:hover { + background-color: #e5e5e5; +} + +.hidden { + display: none; +} \ No newline at end of file diff --git a/test/expected/create_integration_card/single_destination/test/index.html b/test/expected/create_integration_card/single_destination/test/index.html new file mode 100644 index 00000000..68305f66 --- /dev/null +++ b/test/expected/create_integration_card/single_destination/test/index.html @@ -0,0 +1,43 @@ + + + + + + + Integration Card Preview + + + + + + +

Preview

+
+
+

Integration Card

+ + +
+ +
+ + + \ No newline at end of file diff --git a/test/lib/tools/create_integration_card/create_integration_card.integration.ts b/test/lib/tools/create_integration_card/create_integration_card.integration.ts index 95f0f2a7..7b22ff6e 100644 --- a/test/lib/tools/create_integration_card/create_integration_card.integration.ts +++ b/test/lib/tools/create_integration_card/create_integration_card.integration.ts @@ -107,3 +107,75 @@ supportedCardTypes.forEach((cardType) => { await checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, folderPath); }); }); + +test.serial("Generate card template with single destination", async (t) => { + const folderPath = path.join( + __dirname, "..", "..", "..", "tmp", "create_integration_card_single_destination" + ); + await rm(folderPath, {recursive: true, force: true}); + + const destinations = [ + { + name: "northwind", + defaultUrl: "https://services.odata.org/V4/Northwind/Northwind.svc/", + }, + ]; + + const result = await t.context.createIntegrationCard({ + folderPath, + cardType: "List", + manifestVersion: "1.78.0", + destinations, + }); + + // Normalize paths for snapshot consistency across OSes + const normalizedResult = result.map((filePath) => filePath.replaceAll(path.sep, "/")).sort(); + t.snapshot( + normalizedResult, + "Result of createIntegrationCard with single destination should match expected structure" + ); + + const expectedPath = path.join(expectedBasePath, "single_destination"); + const expectedFiles = await findFiles(expectedPath); + + // Check for all directories and files + await directoryDeepEqual(t, folderPath, expectedPath); + + // Check for all file contents + await checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, folderPath); +}); + +test.serial("Generate card template with multiple destinations", async (t) => { + const folderPath = path.join(__dirname, "..", "..", "..", "tmp", "create_integration_card_destinations"); + await rm(folderPath, {recursive: true, force: true}); + const destinations = [ + { + name: "northwind", + defaultUrl: "https://services.odata.org/V4/Northwind/Northwind.svc/", + }, + { + name: "myapi", + defaultUrl: "http://localhost:8080/v1/", + }, + ]; + + const result = await t.context.createIntegrationCard({ + folderPath, + cardType: "List", + manifestVersion: "1.78.0", + destinations, + }); + + // Normalize paths for snapshot consistency across OSes + const normalizedResult = result.map((filePath) => filePath.replaceAll(path.sep, "/")).sort(); + t.snapshot(normalizedResult, "Result of createIntegrationCard with destinations should match expected structure"); + + const expectedPath = path.join(expectedBasePath, "destinations"); + const expectedFiles = await findFiles(expectedPath); + + // Check for all directories and files + await directoryDeepEqual(t, folderPath, expectedPath); + + // Check for all file contents + await checkFileContentsIgnoreLineFeeds(t, expectedFiles, expectedPath, folderPath); +}); diff --git a/test/lib/tools/create_integration_card/create_integration_card.ts b/test/lib/tools/create_integration_card/create_integration_card.ts index cdd2602e..17c36f7f 100644 --- a/test/lib/tools/create_integration_card/create_integration_card.ts +++ b/test/lib/tools/create_integration_card/create_integration_card.ts @@ -3,6 +3,8 @@ import esmock from "esmock"; import sinonGlobal from "sinon"; import {InvalidInputError} from "../../../../src/utils.js"; import path from "node:path"; +import getAllowedDomains from "../../../../src/utils/getAllowedDomains.js"; +import realIsValidUrl from "../../../../src/utils/isValidUrl.js"; // Define test context type const test = anyTest as TestFn<{ @@ -23,6 +25,7 @@ const test = anyTest as TestFn<{ isLevelEnabled: sinonGlobal.SinonStub; }; globbyStub: sinonGlobal.SinonStub; + isValidUrlStub: sinonGlobal.SinonStub; createIntegrationCard: typeof import( "../../../../src/tools/create_integration_card/create_integration_card.js" ).createIntegrationCard; @@ -66,6 +69,9 @@ test.beforeEach(async (t) => { ]; t.context.globbyStub = t.context.sinon.stub().resolves(t.context.staticFiles); + // Create a stub that wraps the real isValidUrl function + t.context.isValidUrlStub = t.context.sinon.stub().callsFake(realIsValidUrl); + const {createIntegrationCard} = await esmock( "../../../../src/tools/create_integration_card/create_integration_card.js", { "@ui5/logger": { @@ -86,6 +92,12 @@ test.beforeEach(async (t) => { "../../../../src/utils.js": { dirExists: t.context.dirExistsStub, }, + "../../../../src/utils/isValidUrl.js": { + default: t.context.isValidUrlStub, + }, + "../../../../src/utils/getAllowedDomains.js": { + default: getAllowedDomains, + }, } ); @@ -110,6 +122,7 @@ test("createIntegrationCard executes successfully", async (t) => { folderPath, cardType: "List", manifestVersion: "1.78.0", + destinations: undefined, }); t.is(mkdirStub.callCount, 4); @@ -134,6 +147,7 @@ test("createIntegrationCard executes successfully", async (t) => { t.deepEqual(templateVars, { cardType: "List", manifestVersion: "1.78.0", + destinations: undefined, }); }); @@ -222,3 +236,66 @@ test("Error processing template file", async (t) => { instanceOf: Error, }); }); + +test.serial("Destinations: Throws error when there is not allowed domain", async (t) => { + const {createIntegrationCard, mkdirStub} = t.context; + const folderPath = "/some/folder/path/card"; + const destinations = [ + { + name: "invalidDomain", + defaultUrl: "https://invalid-domain.com/api/v1/", + }, + ]; + const allowedDomains = ["allowed-domain.com"]; + + process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "allowed-domain.com"; + + await t.throwsAsync(async () => { + await createIntegrationCard({ + folderPath, + cardType: "List", + manifestVersion: "1.78.0", + destinations, + }); + }, { + message: `Domain "invalid-domain.com" is not allowed. Allowed domains are: ${allowedDomains.join(", ")}. See ` + + `https://github.com/UI5/mcp-server#configuration for information on how to configure the allow list.`, + instanceOf: InvalidInputError, + }); + + t.true(mkdirStub.notCalled); + t.true(t.context.isValidUrlStub.calledOnce, "isValidUrl should be called once"); + t.deepEqual( + t.context.isValidUrlStub.firstCall.args, + [destinations[0].defaultUrl, allowedDomains], + "isValidUrl should be called with the destination URL and allowed domains" + ); +}); + +test.serial("Destinations: Successfully generates card template with allowed destination domain", async (t) => { + const {createIntegrationCard} = t.context; + const folderPath = "/some/folder/path/card"; + const destinations = [ + { + name: "validDomain", + defaultUrl: "https://allowed-domain.com/api/v1/", + }, + ]; + process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "allowed-domain.com"; + + await t.notThrowsAsync(async () => { + await createIntegrationCard({ + folderPath, + cardType: "List", + manifestVersion: "1.78.0", + destinations, + }); + }); + + t.true(t.context.isValidUrlStub.calledOnce, "isValidUrl should be called once"); + t.deepEqual( + t.context.isValidUrlStub.firstCall.args, + [destinations[0].defaultUrl, ["allowed-domain.com"]], + "isValidUrl should be called with the destination URL and allowed domains" + ); +}); diff --git a/test/lib/tools/create_integration_card/index.ts b/test/lib/tools/create_integration_card/index.ts index 91b85f04..5e3c72fb 100644 --- a/test/lib/tools/create_integration_card/index.ts +++ b/test/lib/tools/create_integration_card/index.ts @@ -78,6 +78,7 @@ test("create_integration_card tool returns success message on success", async (t folderPath: "/projects/mycards/mycard".replace(/\//g, path.sep), cardType: "List", manifestVersion: "1.78.0", + destinations: undefined, }]); const message = `Successfully created Integration Card ${params.cardFolderName} at ${params.basePath}\n` + diff --git a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md index 51aeba33..76c9b1fa 100644 --- a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md +++ b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.md @@ -87,3 +87,31 @@ Generated by [AVA](https://avajs.dev). 'test/index.css', 'test/index.html', ] + +## Generate card template with single destination + +> Result of createIntegrationCard with single destination should match expected structure + + [ + 'card/dt/Configuration.js', + 'card/i18n/i18n.properties', + 'card/manifest.json', + 'package.json', + 'test/App.js', + 'test/index.css', + 'test/index.html', + ] + +## Generate card template with multiple destinations + +> Result of createIntegrationCard with destinations should match expected structure + + [ + 'card/dt/Configuration.js', + 'card/i18n/i18n.properties', + 'card/manifest.json', + 'package.json', + 'test/App.js', + 'test/index.css', + 'test/index.html', + ] diff --git a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap index 1ad47a46..d6b08914 100644 Binary files a/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap and b/test/lib/tools/create_integration_card/snapshots/create_integration_card.integration.ts.snap differ diff --git a/test/lib/utils/getAllowedDomains.ts b/test/lib/utils/getAllowedDomains.ts new file mode 100644 index 00000000..53bfc795 --- /dev/null +++ b/test/lib/utils/getAllowedDomains.ts @@ -0,0 +1,101 @@ +import anyTest, {TestFn} from "ava"; +import sinonGlobal from "sinon"; +import esmock from "esmock"; +import {InvalidInputError} from "../../../src/utils.js"; + +const test = anyTest as TestFn<{ + sinon: sinonGlobal.SinonSandbox; + getAllowedDomains: typeof import("../../../src/utils/getAllowedDomains.js").default; + loggerMock: { + verbose: sinonGlobal.SinonStub; + }; + originalEnv: NodeJS.ProcessEnv; +}>; + +test.beforeEach(async (t) => { + t.context.sinon = sinonGlobal.createSandbox(); + t.context.originalEnv = {...process.env}; + + const loggerMock = { + verbose: t.context.sinon.stub(), + }; + t.context.loggerMock = loggerMock; + + const getAllowedDomainsModule = await esmock("../../../src/utils/getAllowedDomains.js", { + "@ui5/logger": { + getLogger: t.context.sinon.stub().returns(loggerMock), + }, + }); + + t.context.getAllowedDomains = getAllowedDomainsModule.default; +}); + +test.afterEach.always((t) => { + process.env = t.context.originalEnv; + t.context.sinon.restore(); +}); + +test.serial("returns default domains when no environment is set", (t) => { + const {getAllowedDomains, loggerMock} = t.context; + delete process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS; + delete process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS; + + const domains = getAllowedDomains(); + + t.deepEqual(domains, ["localhost", "services.odata.org"]); + t.false(loggerMock.verbose.called); +}); + +test.serial("returns trimmed domains from UI5_MCP_SERVER_ALLOWED_DOMAINS", (t) => { + const {getAllowedDomains, loggerMock} = t.context; + process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = " example.com , .api.example.com ,localhost "; + + const domains = getAllowedDomains(); + + t.deepEqual(domains, ["example.com", ".api.example.com", "localhost"]); + t.true(loggerMock.verbose.calledWith( + "3 allowed domains configured: example.com, .api.example.com, localhost" + )); +}); + +test.serial("prefers UI5_MCP_SERVER_ALLOWED_DOMAINS over UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS", (t) => { + const {getAllowedDomains, loggerMock} = t.context; + process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "primary.example.com"; + process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = "secondary.example.com"; + + const domains = getAllowedDomains(); + + t.deepEqual(domains, ["primary.example.com"]); + t.true(loggerMock.verbose.calledWith("1 allowed domains configured: primary.example.com")); +}); + +test.serial("returns empty list and logs when env value is blank", (t) => { + const {getAllowedDomains, loggerMock} = t.context; + process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = " \t "; + + const domains = getAllowedDomains(); + + t.deepEqual(domains, []); + t.true(loggerMock.verbose.calledWith( + "Empty value for UI5_MCP_SERVER_ALLOWED_DOMAINS, allowing all domains" + )); +}); + +test.serial("throws InvalidInputError for malformed domain entries", (t) => { + const {getAllowedDomains} = t.context; + process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS = "invalid domain"; + + const error = t.throws(() => getAllowedDomains(), {instanceOf: InvalidInputError}); + t.regex(error?.message ?? "", /Invalid domain 'invalid domain' in UI5_MCP_SERVER_ALLOWED_DOMAINS/); +}); + +test.serial("falls back to UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS when primary is unset", (t) => { + const {getAllowedDomains, loggerMock} = t.context; + delete process.env.UI5_MCP_SERVER_ALLOWED_DOMAINS; + process.env.UI5_MCP_SERVER_ALLOWED_ODATA_DOMAINS = "odata.example.com"; + + const domains = getAllowedDomains(); + + t.deepEqual(domains, ["odata.example.com"]); + t.true(loggerMock.verbose.calledWith("1 allowed domains configured: odata.example.com")); +}); diff --git a/test/lib/tools/create_ui5_app/isValidUrl.ts b/test/lib/utils/isValidUrl.ts similarity index 97% rename from test/lib/tools/create_ui5_app/isValidUrl.ts rename to test/lib/utils/isValidUrl.ts index c649f467..ec7c5187 100644 --- a/test/lib/tools/create_ui5_app/isValidUrl.ts +++ b/test/lib/utils/isValidUrl.ts @@ -1,6 +1,6 @@ import test from "ava"; -import isValidUrl from "../../../../src/tools/create_ui5_app/isValidUrl.js"; -import {InvalidInputError} from "../../../../src/utils.js"; +import isValidUrl from "../../../src/utils/isValidUrl.js"; +import {InvalidInputError} from "../../../src/utils.js"; test("should return false for non-string or empty input", (t) => { t.false(isValidUrl(""), "Empty string should be invalid");