Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 08:55:27 +08:00
commit 3c265b6541
12 changed files with 6207 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
{
"name": "sap-sac-custom-widget",
"description": "Custom Widget development for SAC. Covers Web Components, JSON metadata, lifecycle functions, data binding, styling panels, third-party libraries, and Widget Add-Ons.",
"version": "1.1.0",
"author": {
"name": "Zhongwei Li",
"email": "zhongweili@tubi.tv"
},
"skills": [
"./"
]
}

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
# sap-sac-custom-widget
Custom Widget development for SAC. Covers Web Components, JSON metadata, lifecycle functions, data binding, styling panels, third-party libraries, and Widget Add-Ons.

361
SKILL.md Normal file
View File

@@ -0,0 +1,361 @@
---
name: sap-sac-custom-widget
description: |
SAP Analytics Cloud (SAC) Custom Widget development skill. Use when building custom visualizations, interactive components, extending SAC with Web Components, or creating Widget Add-Ons to customize built-in widgets. Covers JSON metadata configuration, JavaScript Web Components, lifecycle functions, data binding with feeds, styling panels, builder panels, property/event/method definitions, custom types, script API data types, third-party library integration, hosting options, security, performance optimization, and debugging. Includes Widget Add-On feature (QRC Q4 2023+) for extending built-in widgets without creating from scratch. Provides templates for basic widgets, data-bound charts, styling panels, and KPI cards. Supports Optimized Story Experience and Analytics Designer. Prevents common errors: missing lifecycle functions, incorrect JSON schema, integrity warnings, CORS failures, property type mismatches, data binding issues, and performance anti-patterns.
Keywords: sap analytics cloud, sac custom widget, custom widget development, web component sac, json metadata widget, widget lifecycle functions, onCustomWidgetBeforeUpdate, onCustomWidgetAfterUpdate, onCustomWidgetResize, onCustomWidgetDestroy, sac data binding, widget data binding, dataBindings feeds, getDataBinding, getResultSet, styling panel widget, builder panel widget, widget properties events methods, propertiesChanged event, dispatchEvent custom widget, sac echarts integration, sac d3js integration, third party library sac, widget hosting sac, sac hosted widget, integrity hash widget, sha256 integrity, widget security cors, sac widget debugging, custom visualization sac, sac analytics designer widget, optimized story experience widget, sac widget api, widget add-on, sac script api widget, custom types enumeration, MemberInfo ResultMemberInfo Selection, widget installation admin, sac performance optimization, shadow dom web component, sac tooltip customization, plot area addon
license: GPL-3.0
metadata:
version: 1.2.0
last_verified: 2025-11-26
sac_version: "2025.21"
token_savings: ~75%
errors_prevented: 25+
official_docs:
- [https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html](https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html)
- [https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
samples_repo: [https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets](https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets)
allowed-tools:
- Read
- Write
- Edit
- Bash
- WebFetch
---
# SAP Analytics Cloud Custom Widget Development
## Overview
This skill enables development of custom widgets for SAP Analytics Cloud (SAC). Custom widgets are Web Components that extend SAC stories and applications with custom visualizations, interactive elements, and specialized functionality.
**Use this skill when**:
- Building custom visualizations not available in standard SAC
- Integrating third-party charting libraries (ECharts, D3.js, Chart.js)
- Creating interactive input components for SAC applications
- Implementing specialized data displays or KPI widgets
- Extending Analytics Designer applications with custom functionality
- Troubleshooting custom widget loading or data binding issues
**Requirements**:
- SAC tenant with Optimized Story Experience or Analytics Designer
- JavaScript/Web Components knowledge
- External hosting (GitHub Pages, AWS S3, Azure) OR SAC-hosted resources (QRC Q2 2023+)
---
## Quick Start
### Minimal Custom Widget Structure
A custom widget requires two files:
**1. widget.json** (Metadata)
```json
{
"id": "com.company.mywidget",
"version": "1.0.0",
"name": "My Custom Widget",
"description": "A simple custom widget",
"vendor": "Company Name",
"license": "MIT",
"icon": "",
"webcomponents": [
{
"kind": "main",
"tag": "my-custom-widget",
"url": "[https://your-host.com/widget.js",](https://your-host.com/widget.js",)
"integrity": "",
"ignoreIntegrity": true
}
],
"properties": {
"title": {
"type": "string",
"default": "My Widget"
}
},
"methods": {},
"events": {}
}
```
**2. widget.js** (Web Component)
```javascript
(function() {
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
width: 100%;
height: 100%;
}
.container {
padding: 16px;
font-family: Arial, sans-serif;
}
</style>
<div class="container">
<h3 id="title">My Widget</h3>
<div id="content"></div>
</div>
`;
class MyCustomWidget extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this._props = {};
}
connectedCallback() {
// Called when element is added to DOM
}
onCustomWidgetBeforeUpdate(changedProperties) {
// Called BEFORE properties are updated
this._props = { ...this._props, ...changedProperties };
}
onCustomWidgetAfterUpdate(changedProperties) {
// Called AFTER properties are updated - render here
if (changedProperties.title !== undefined) {
this._shadowRoot.getElementById("title").textContent = changedProperties.title;
}
}
onCustomWidgetResize() {
// Called when widget is resized
}
onCustomWidgetDestroy() {
// Cleanup when widget is removed
}
// Property getter/setter (required for SAC framework)
get title() {
return this._props.title;
}
set title(value) {
this._props.title = value;
this.dispatchEvent(new CustomEvent("propertiesChanged", {
detail: { properties: { title: value } }
}));
}
}
customElements.define("my-custom-widget", MyCustomWidget);
})();
```
**⚠️ Production Note**: The `ignoreIntegrity: true` setting above is **development only**. For production deployments, generate a SHA256 integrity hash and set `ignoreIntegrity: false`.
---
## Community Sample Widgets
SAP provides 15+ ready-to-use custom widget samples:
**Repository**: [SAP-samples/SAC_Custom_Widgets](https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets)
| Category | Widgets |
|----------|---------|
| **Charts** | Funnel, Pareto, Sankey, Sunburst, Tree, Line, UI5 Gantt |
| **KPI/Gauge** | KPI Ring, Gauge Grade, Half Donut, Nested Pie, Custom Pie |
| **Utilities** | File Upload, Word Cloud, Bar Gradient, Widget Add-on Sample |
**Requirements**: Optimized View Mode (OVM) enabled, data binding support
**Note**: Check third-party library licenses before production use.
---
## Key Concepts
### Lifecycle Functions
Essential functions called by SAC framework:
- `onCustomWidgetBeforeUpdate(changedProperties)` - Pre-update hook
- `onCustomWidgetAfterUpdate(changedProperties)` - Post-update (render here)
- `onCustomWidgetResize()` - Handle resize events
- `onCustomWidgetDestroy()` - Cleanup resources
### Data Binding
Configure in widget.json to receive SAC model data:
```json
{
"dataBindings": {
"myDataBinding": {
"feeds": [
{
"id": "dimensions",
"description": "Dimensions",
"type": "dimension"
},
{
"id": "measures",
"description": "Measures",
"type": "mainStructureMember"
}
]
}
}
}
```
Access data in JavaScript:
```javascript
// Get data binding
const dataBinding = this.dataBindings.getDataBinding("myDataBinding");
// Access result set
const data = this.myDataBinding.data;
const metadata = this.myDataBinding.metadata;
// Iterate over rows
this.myDataBinding.data.forEach(row => {
const dimensionValue = row.dimensions_0.label;
const measureValue = row.measures_0.raw;
});
```
### Hosting Options
**1. SAC-Hosted (Recommended, QRC Q2 2023+)**
- Upload files directly to SAC > Files > Public Files
- Use relative paths: `"/path/to/widget.js"`
- Set `"integrity": ""` and `"ignoreIntegrity": true`
**2. GitHub Pages**
- Create repository with widget files
- Enable GitHub Pages in Settings
- Use URL: `[https://username.github.io/repo/widget.js`](https://username.github.io/repo/widget.js`)
**3. External Web Server**
- AWS S3, Azure Blob, or any HTTPS server
- Must include CORS headers: `Access-Control-Allow-Origin: *`
### Security: Integrity Hash
For production, generate SHA256 hash:
```bash
# Generate hash
openssl dgst -sha256 -binary widget.js | openssl base64 -A
# Update JSON
"integrity": "sha256-abc123...",
"ignoreIntegrity": false
```
---
## Common Errors & Solutions
| Error | Cause | Solution |
|-------|-------|----------|
| "The system couldn't load the custom widget" | Incorrect URL or hosting issue | Verify URL is accessible, check CORS |
| "Integrity check failed" | Hash mismatch | Regenerate hash after JS changes |
| Widget not appearing | Missing connectedCallback render | Call render in onCustomWidgetAfterUpdate |
| Properties not updating | Missing propertiesChanged dispatch | Use dispatchEvent with propertiesChanged |
| Data not displaying | Data binding misconfigured | Verify feeds in JSON match usage |
---
## Debugging
### Browser DevTools
1. Open Chrome DevTools (F12)
2. Sources tab: Find widget.js, set breakpoints
3. Console tab: View console.log output
4. Network tab: Check if files load (200 status)
### Debug Pattern
```javascript
onCustomWidgetAfterUpdate(changedProperties) {
console.log("Widget updated:", changedProperties);
console.log("Current props:", this._props);
console.log("Data binding:", this.myDataBinding?.data);
this._render();
}
```
---
## Widget Add-Ons (QRC Q4 2023+)
Widget Add-Ons extend built-in SAC widgets without building from scratch.
**Use Cases**:
- Customize chart tooltips
- Add visual elements to plot areas
- Override built-in styling
**Supported Charts**: Bar/Column, Stacked Bar/Column, Line, Stacked Area, Numeric Point
**Key Differences**:
- Only `main` and `builder` components (no `styling`)
- Must specify extension target (`tooltip`, `plotArea`, `numericPoint`)
- SAC provides chart context data via methods
See **`references/widget-addon-guide.md`** for complete implementation.
---
## Bundled Resources
For detailed templates and examples, see:
1. **`references/json-schema-reference.md`** - Complete JSON schema documentation
2. **`references/widget-templates.md`** - Ready-to-use widget templates (6 templates)
3. **`references/echarts-integration.md`** - ECharts library integration guide
4. **`references/widget-addon-guide.md`** - Widget Add-On development (QRC Q4 2023+)
5. **`references/best-practices-guide.md`** - Performance, security, and development guidelines
6. **`references/advanced-topics.md`** - Custom types, script API types, installation
7. **`references/integration-and-migration.md`** - Script integration, content transport
8. **`references/script-api-reference.md`** - DataSource, Selection, MemberInfo APIs
---
## Official Documentation Links
**Primary References** (for skill updates):
- [Custom Widget Developer Guide](https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html?version=2025.21&locale=en-US)
- [Developer Guide PDF](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [Widget API PDF (2025)](https://help.sap.com/doc/7e0efa0e68dc45958e568699f8226ad7/cloud/en-US/SAC_Widget_API_en.pdf)
**Sample Widgets**:
- [SAP Samples Repository](https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets)
- [SAP Custom Widget GitHub](https://github.com/SAP-Custom-Widget)
---
## Version History
**v1.2.0** (2025-11-26)
- Updated SAC version reference to 2025.21
- Optimized SKILL.md length from 563 to ~200 lines
- Added Table of Contents to all 8 reference files
- Improved progressive disclosure architecture
**v1.1.0** (2025-11-22)
- Added Widget Add-On feature documentation (QRC Q4 2023+)
- Added best practices guide (performance, security, development)
- Added advanced topics (custom types, script API types, installation)
- Enhanced description with additional keywords
- Increased error prevention coverage to 25+
**v1.0.0** (2025-11-22)
- Initial release
- Complete JSON metadata reference
- Lifecycle functions documentation
- Data binding guide
- Styling panel implementation
- Hosting options (SAC-hosted, GitHub, external)
- Security (integrity hash, CORS)
- Common errors and debugging
---
**Last Verified**: 2025-11-26 | **SAC Version**: 2025.21 | **Skill Version**: 1.2.0

77
plugin.lock.json Normal file
View File

@@ -0,0 +1,77 @@
{
"$schema": "internal://schemas/plugin.lock.v1.json",
"pluginId": "gh:secondsky/sap-skills:skills/sap-sac-custom-widget",
"normalized": {
"repo": null,
"ref": "refs/tags/v20251128.0",
"commit": "c54c50fb069fb0a9473dad80fae33c8806fe4e48",
"treeHash": "e1e034c03fb26d421392a129f47b20f4b0aff00d33818933faa6fe247c5897a6",
"generatedAt": "2025-11-28T10:28:14.277165Z",
"toolVersion": "publish_plugins.py@0.2.0"
},
"origin": {
"remote": "git@github.com:zhongweili/42plugin-data.git",
"branch": "master",
"commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390",
"repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data"
},
"manifest": {
"name": "sap-sac-custom-widget",
"description": "Custom Widget development for SAC. Covers Web Components, JSON metadata, lifecycle functions, data binding, styling panels, third-party libraries, and Widget Add-Ons.",
"version": "1.1.0"
},
"content": {
"files": [
{
"path": "README.md",
"sha256": "e05125e5b1d7ba6048a627bec983c98e6e6243651c747afe203e1c7832dcbba6"
},
{
"path": "SKILL.md",
"sha256": "d526545dce743d3882dc3895944e39d4184c4caf4ea1957ab22045026c414ea2"
},
{
"path": "references/advanced-topics.md",
"sha256": "3beb5ef8ed75f80d8cee95ae3fedd2b5836bdfaf7c66ca23a91092da242f7080"
},
{
"path": "references/integration-and-migration.md",
"sha256": "e28856f5b7f3887b73622fcdd8fa750c6e7c50a0e445ed6aab7db89bf1f8004b"
},
{
"path": "references/widget-addon-guide.md",
"sha256": "cc7dabebafbdde90a806b913291cdc5944f74b3b2c91fdf262264f2d76a1a8f6"
},
{
"path": "references/json-schema-reference.md",
"sha256": "faf3665badb730817f4016a5b4e14fa1d84c12fb5943ceb0af808903b2f19182"
},
{
"path": "references/widget-templates.md",
"sha256": "96005ab5e6077201e738ffc85bf7f493b3c2c0f7b6343bf02e1d4f29c5ff33c6"
},
{
"path": "references/script-api-reference.md",
"sha256": "e4c9fab96222e9d74bac262dd55472d2407af1d599544cee4a79c5c534fac651"
},
{
"path": "references/best-practices-guide.md",
"sha256": "0f438dd9280519ac82c178fbd6e67dca5418bf78142f1e951c5f994f527c0b71"
},
{
"path": "references/echarts-integration.md",
"sha256": "99832e64b599e6e066c01d6d568a87dd8d3144acb1a2dd4bbde4a9045ee74a94"
},
{
"path": ".claude-plugin/plugin.json",
"sha256": "da927b6bbc0faf3f78bcebedefed2602222703fd3e9c94d5f8b27dfa904a08a4"
}
],
"dirSha256": "e1e034c03fb26d421392a129f47b20f4b0aff00d33818933faa6fe247c5897a6"
},
"security": {
"scannedAt": null,
"scannerVersion": null,
"flags": []
}
}

View File

@@ -0,0 +1,658 @@
# SAP SAC Custom Widget Advanced Topics
Advanced features including custom types, script data types, and administration.
**Source**: [SAP Custom Widget Developer Guide](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
---
## Table of Contents
1. [Custom Types](#custom-types)
2. [Script API Data Types](#script-api-data-types)
3. [Widget Installation](#widget-installation)
4. [Third-Party Library Integration](#third-party-library-integration)
5. [Advanced Data Binding](#advanced-data-binding)
6. [Multi-Language Support](#multi-language-support)
---
## Custom Types
Custom types enable complex data structures in widget properties and script interactions.
### Custom Data Structures
Define reusable object types in JSON:
```json
{
"id": "com.company.advancedwidget",
"version": "1.0.0",
"name": "Advanced Widget",
"types": {
"ChartConfig": {
"description": "Chart configuration object",
"properties": {
"chartType": {
"type": "string",
"default": "bar",
"description": "Type of chart"
},
"showLegend": {
"type": "boolean",
"default": true,
"description": "Show chart legend"
},
"colors": {
"type": "string[]",
"default": [],
"description": "Color palette"
}
}
},
"DataPoint": {
"description": "Single data point",
"properties": {
"label": {
"type": "string",
"default": "",
"description": "Data point label"
},
"value": {
"type": "number",
"default": 0,
"description": "Data point value"
},
"color": {
"type": "string",
"default": "#336699",
"description": "Data point color"
}
}
}
},
"properties": {
"config": {
"type": "ChartConfig",
"default": {
"chartType": "bar",
"showLegend": true,
"colors": ["#5470c6", "#91cc75", "#fac858"]
},
"description": "Chart configuration"
},
"dataPoints": {
"type": "DataPoint[]",
"default": [],
"description": "Array of data points"
}
}
}
```
### Custom Enumerations
Define allowed values:
```json
{
"types": {
"ChartTypeEnum": {
"description": "Allowed chart types",
"values": [
{
"id": "bar",
"description": "Bar Chart"
},
{
"id": "line",
"description": "Line Chart"
},
{
"id": "pie",
"description": "Pie Chart"
},
{
"id": "area",
"description": "Area Chart"
}
]
},
"AlignmentEnum": {
"description": "Text alignment options",
"values": [
{ "id": "left", "description": "Left aligned" },
{ "id": "center", "description": "Center aligned" },
{ "id": "right", "description": "Right aligned" }
]
}
},
"properties": {
"chartType": {
"type": "ChartTypeEnum",
"default": "bar",
"description": "Type of chart to display"
},
"titleAlignment": {
"type": "AlignmentEnum",
"default": "center",
"description": "Title text alignment"
}
}
}
```
### Using Custom Types in Web Component
```javascript
class AdvancedWidget extends HTMLElement {
constructor() {
super();
this._props = {
config: {
chartType: "bar",
showLegend: true,
colors: ["#5470c6", "#91cc75", "#fac858"]
},
dataPoints: []
};
}
// Getter returns the full object
get config() {
return this._props.config;
}
// Setter accepts object and validates
set config(value) {
if (typeof value !== "object") {
console.warn("config must be an object");
return;
}
this._props.config = {
...this._props.config,
...value
};
this._render();
}
get dataPoints() {
return this._props.dataPoints;
}
set dataPoints(value) {
if (!Array.isArray(value)) {
console.warn("dataPoints must be an array");
return;
}
this._props.dataPoints = value;
this._render();
}
}
```
### Type Name Qualification
Internally, custom type names are qualified with widget ID to avoid conflicts:
- Defined as: `ChartConfig`
- Internal name: `com.company.advancedwidget.ChartConfig`
---
## Script API Data Types
Types available for properties and method parameters.
### Selection Type
Represents a data selection in SAC:
```json
{
"properties": {
"currentSelection": {
"type": "Selection",
"default": {},
"description": "Current data selection"
}
},
"methods": {
"setSelection": {
"description": "Set data selection",
"parameters": [
{
"name": "selection",
"type": "Selection",
"description": "Selection to apply"
}
],
"body": "this._setSelection(selection);"
}
}
}
```
**Usage in Scripts**:
```javascript
// In SAC script
var selection = {
"Account": "Revenue",
"Year": "2024"
};
Widget_1.setSelection(selection);
```
### MemberInfo Type
Information about a dimension member:
```javascript
// MemberInfo object structure
{
id: "MEMBER_ID", // Technical ID
description: "Member Name", // Display name
dimensionId: "DIM_ID", // Parent dimension
modelId: "MODEL_ID", // Data model
displayId: "DISPLAY_ID" // Display ID
}
```
**Using in Widget**:
```javascript
class MyWidget extends HTMLElement {
setMemberInfo(memberInfo) {
this._currentMember = memberInfo;
this._shadowRoot.getElementById("memberLabel").textContent =
memberInfo.description || memberInfo.id;
}
}
```
### ResultMemberInfo Type
Extended member information from result set:
```javascript
// ResultMemberInfo structure
{
id: "MEMBER_ID",
description: "Member Name",
parentId: "PARENT_ID", // For hierarchies
properties: {
"Property1": "Value1"
}
}
```
### DataSource Methods
Access data source information:
```javascript
// In SAC script with data binding
var ds = Widget_1.getDataSource();
// Get members
var members = ds.getMembers("Account", { limit: 100 });
// Get result member
var selection = { "Account": "Revenue" };
var memberInfo = ds.getResultMember("Account", selection);
// Get data cell value
var value = ds.getData(selection);
```
### Color Type
SAC Color type for color properties:
```json
{
"properties": {
"primaryColor": {
"type": "Color",
"default": "#336699",
"description": "Primary widget color"
}
}
}
```
---
## Widget Installation
### Administrator Steps
1. **Access Custom Widgets**:
- Main Menu > **Analytic Applications**
- Select **Custom Widgets** tab
2. **Upload Widget**:
- Click **+** (Add) button
- Select JSON file from local system
- Widget appears in list after upload
3. **Manage Widgets**:
- View installed widgets in list
- Delete widgets no longer needed
- Update by re-uploading JSON
### Requirements
- **Role**: Administrator or custom widget manager
- **Files**: JSON metadata file (resource files hosted externally)
- **Hosting**: Resource files accessible via HTTPS
### SAC-Hosted Widgets (QRC Q2 2023+)
Upload resource files directly to SAC:
1. **Prepare Files**:
- Pack JSON and JS files into ZIP
- Or upload individually to SAC Files
2. **Configure JSON for SAC Hosting**:
```json
{
"webcomponents": [
{
"kind": "main",
"tag": "my-widget",
"url": "/my-widget.js",
"integrity": "",
"ignoreIntegrity": true
}
]
}
```
Note: URL starts with `/` for SAC-hosted files
3. **Upload to SAC**:
- Go to Files > Public Files
- Create folder for widget
- Upload JS files
- Upload JSON to Custom Widgets
### Using Widgets in Stories
1. Open story in Edit mode
2. Open widget panel (Insert > Widget)
3. Find custom widget in Custom section
4. Drag onto canvas
5. Configure via Builder/Styling panels
---
## Third-Party Library Integration
### Supported Libraries
Common libraries used with SAC widgets:
| Library | Use Case | CDN |
|---------|----------|-----|
| ECharts | Charts | `[https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js`](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js`) |
| D3.js | Data viz | `[https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js`](https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js`) |
| Chart.js | Charts | `[https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js`](https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.js`) |
| Leaflet | Maps | `[https://unpkg.com/leaflet@1.9/dist/leaflet.js`](https://unpkg.com/leaflet@1.9/dist/leaflet.js`) |
| Moment.js | Dates | `[https://cdn.jsdelivr.net/npm/moment@2/moment.min.js`](https://cdn.jsdelivr.net/npm/moment@2/moment.min.js`) |
### Integration Pattern
```javascript
(function() {
// Library URLs
const LIBS = {
echarts: "[https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js")
};
// Track loading state
const libState = {
echarts: { loaded: false, loading: false, callbacks: [] }
};
// Load library once
function loadLibrary(name) {
return new Promise((resolve, reject) => {
const state = libState[name];
// Already loaded
if (state.loaded) {
resolve(window[name === "echarts" ? "echarts" : name]);
return;
}
// Loading - queue callback
if (state.loading) {
state.callbacks.push({ resolve, reject });
return;
}
// Start loading
state.loading = true;
const script = document.createElement("script");
script.src = LIBS[name];
script.onload = () => {
state.loaded = true;
state.loading = false;
const lib = window[name === "echarts" ? "echarts" : name];
resolve(lib);
state.callbacks.forEach(cb => cb.resolve(lib));
state.callbacks = [];
};
script.onerror = (err) => {
state.loading = false;
reject(err);
state.callbacks.forEach(cb => cb.reject(err));
state.callbacks = [];
};
document.head.appendChild(script);
});
}
class ChartWidget extends HTMLElement {
async connectedCallback() {
try {
const echarts = await loadLibrary("echarts");
this._initChart(echarts);
} catch (error) {
this._showError("Failed to load chart library");
}
}
_initChart(echarts) {
const container = this._shadowRoot.getElementById("chart");
this._chart = echarts.init(container);
this._render();
}
}
customElements.define("chart-widget", ChartWidget);
})();
```
### License Considerations
**Important**: Review third-party library licenses before deployment.
- MIT/Apache: Generally safe for commercial use
- GPL: May have copyleft requirements
- Commercial: May require license purchase
Check license compatibility with SAC deployment.
---
## Advanced Data Binding
### Multiple Data Bindings
```json
{
"dataBindings": {
"primaryData": {
"feeds": [
{ "id": "xAxis", "description": "X-Axis", "type": "dimension" },
{ "id": "yAxis", "description": "Y-Axis", "type": "mainStructureMember" }
]
},
"secondaryData": {
"feeds": [
{ "id": "categories", "description": "Categories", "type": "dimension" },
{ "id": "values", "description": "Values", "type": "mainStructureMember" }
]
}
}
}
```
**Note**: Currently only the first dataBinding is used. Multiple bindings are defined but only one is active.
### Accessing Metadata
```javascript
_processData() {
const data = this.primaryData;
if (!data || !data.data) return;
// Access metadata
const metadata = data.metadata;
// Dimension info
if (metadata.dimensions) {
Object.entries(metadata.dimensions).forEach(([key, dim]) => {
console.log(`Dimension: ${dim.description}`);
});
}
// Measure info
if (metadata.mainStructureMembers) {
Object.entries(metadata.mainStructureMembers).forEach(([key, measure]) => {
console.log(`Measure: ${measure.description}, Unit: ${measure.unitOfMeasure}`);
});
}
}
```
### DataBinding Object Methods
```javascript
// Get DataBinding object
const binding = this.dataBindings.getDataBinding("primaryData");
// Available methods (async)
const resultSet = await binding.getResultSet();
const members = await binding.getMembers("DimensionName");
```
---
## Multi-Language Support
### Externalize Strings
```json
{
"properties": {
"titleKey": {
"type": "string",
"default": "WIDGET_TITLE",
"description": "Translation key for title"
}
}
}
```
### Translation Pattern
```javascript
class MyWidget extends HTMLElement {
constructor() {
super();
this._translations = {
en: {
WIDGET_TITLE: "My Widget",
NO_DATA: "No data available",
LOADING: "Loading..."
},
de: {
WIDGET_TITLE: "Mein Widget",
NO_DATA: "Keine Daten verfügbar",
LOADING: "Laden..."
}
};
this._locale = "en";
}
_t(key) {
const translations = this._translations[this._locale] || this._translations.en;
return translations[key] || key;
}
_render() {
this._shadowRoot.getElementById("title").textContent = this._t(this._props.titleKey);
}
// Set locale from SAC context
setLocale(locale) {
this._locale = locale.substring(0, 2); // "en-US" -> "en"
this._render();
}
}
```
---
## Debugging Advanced Widgets
### Console Inspection
```javascript
// Expose widget for debugging
connectedCallback() {
// Make accessible in console
window.__myWidget = this;
// Log initialization
console.log("[MyWidget] Initialized", {
props: this._props,
dataBinding: this.primaryData
});
}
```
### Performance Profiling
```javascript
_render() {
const start = performance.now();
// Rendering logic
this._doRender();
const duration = performance.now() - start;
if (duration > 16) { // > 1 frame
console.warn(`[MyWidget] Slow render: ${duration.toFixed(2)}ms`);
}
}
```
---
## Resources
- [SAP Custom Widget Developer Guide (PDF)](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [Analytics Designer API Reference](https://help.sap.com/doc/958d4c11261f42e992e8d01a4c0dde25/release/en-US/index.html)
- [Hosting in SAC](https://community.sap.com/t5/technology-blogs-by-sap/hosting-and-uploading-custom-widgets-resource-files-into-sap-analytics/ba-p/13563064)
---
**Last Updated**: 2025-11-22

View File

@@ -0,0 +1,608 @@
# SAP SAC Custom Widget Best Practices
Comprehensive guide for performance, security, and development best practices.
**Sources**:
- [SAP Custom Widget Developer Guide](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [Performance Optimization](https://community.sap.com/t5/technology-blog-posts-by-members/performance-optimization-techniques-for-sap-analytics-cloud-application/ba-p/13516595)
- [Optimizing SAC](https://community.sap.com/t5/technology-blog-posts-by-sap/optimizing-sap-analytics-cloud-best-practices-and-performance/ba-p/14229397)
---
## Table of Contents
1. [Performance Best Practices](#performance-best-practices)
2. [Security Best Practices](#security-best-practices)
3. [Development Best Practices](#development-best-practices)
4. [Testing Guidelines](#testing-guidelines)
5. [Deployment Checklist](#deployment-checklist)
---
## Performance Best Practices
### Widget Initialization
**DO**:
```javascript
// Lazy initialization - only load when visible
connectedCallback() {
if (this._initialized) return;
this._initialized = true;
this._init();
}
// Defer non-critical setup
_init() {
// Critical setup first
this._setupDOM();
// Defer expensive operations
requestAnimationFrame(() => {
this._loadResources();
});
}
```
**DON'T**:
```javascript
// Heavy processing in constructor
constructor() {
super();
this._processLargeDataset(); // Blocks main thread
this._loadExternalLibraries(); // Network call in constructor
}
```
### Data Handling
**Use getResultSet() Instead of getMembers()**:
```javascript
// RECOMMENDED - No backend roundtrip
async _getData() {
const resultSet = await this.dataBinding.getResultSet();
return resultSet;
}
// AVOID - Causes extra backend roundtrip
async _getData() {
const members = await this.dataBinding.getMembers("Dimension");
return members;
}
```
**Limit Data Points**:
```javascript
_renderChart(data) {
const MAX_POINTS = 100;
// Warn if data is truncated
if (data.length > MAX_POINTS) {
console.warn(`Data truncated from ${data.length} to ${MAX_POINTS} points`);
}
const limitedData = data.slice(0, MAX_POINTS);
this._chart.setOption({ series: [{ data: limitedData }] });
}
```
### Rendering Optimization
**Debounce Updates**:
```javascript
onCustomWidgetAfterUpdate(changedProperties) {
// Debounce rapid property changes
if (this._updateTimer) {
clearTimeout(this._updateTimer);
}
this._updateTimer = setTimeout(() => {
this._render();
}, 50);
}
```
**Batch DOM Updates**:
```javascript
// GOOD - Single DOM update
_render() {
const html = this._data.map(item => `<div>${item.label}: ${item.value}</div>`).join("");
this._container.innerHTML = html;
}
// BAD - Multiple DOM updates
_render() {
this._container.innerHTML = "";
this._data.forEach(item => {
const div = document.createElement("div");
div.textContent = `${item.label}: ${item.value}`;
this._container.appendChild(div); // Triggers reflow each time
});
}
```
**Use requestAnimationFrame for Visual Updates**:
```javascript
_scheduleRender() {
if (this._renderScheduled) return;
this._renderScheduled = true;
requestAnimationFrame(() => {
this._renderScheduled = false;
this._render();
});
}
```
### Resize Handling
```javascript
onCustomWidgetResize() {
// Debounce resize events
if (this._resizeTimer) {
clearTimeout(this._resizeTimer);
}
this._resizeTimer = setTimeout(() => {
if (this._chart) {
this._chart.resize();
}
}, 100);
}
```
### Memory Management
```javascript
onCustomWidgetDestroy() {
// Clear timers
if (this._updateTimer) clearTimeout(this._updateTimer);
if (this._resizeTimer) clearTimeout(this._resizeTimer);
// Dispose chart libraries
if (this._chart) {
this._chart.dispose();
this._chart = null;
}
// Remove event listeners
if (this._boundHandlers) {
window.removeEventListener("resize", this._boundHandlers.resize);
}
// Clear data references
this._data = null;
this._props = null;
}
```
### Third-Party Library Loading
**Lazy Load Libraries**:
```javascript
async _loadEcharts() {
if (window.echarts) return window.echarts;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "[https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";)
script.onload = () => resolve(window.echarts);
script.onerror = () => reject(new Error("Failed to load ECharts"));
document.head.appendChild(script);
});
}
// Use async initialization
async connectedCallback() {
try {
const echarts = await this._loadEcharts();
this._initChart(echarts);
} catch (error) {
this._showError("Failed to load chart library");
}
}
```
---
## Security Best Practices
### Input Validation
**Sanitize User Input**:
```javascript
_setTitle(value) {
// Sanitize to prevent XSS
const sanitized = this._sanitizeHTML(value);
this._shadowRoot.getElementById("title").textContent = sanitized;
}
_sanitizeHTML(str) {
const temp = document.createElement("div");
temp.textContent = str;
return temp.innerHTML;
}
```
**Validate Property Types**:
```javascript
set value(val) {
// Type validation
if (typeof val !== "number" || isNaN(val)) {
console.warn("Invalid value type, expected number");
return;
}
// Range validation
if (val < 0 || val > 100) {
console.warn("Value out of range (0-100)");
val = Math.max(0, Math.min(100, val));
}
this._props.value = val;
this._render();
}
```
### Content Security
**Avoid innerHTML with User Data**:
```javascript
// DANGEROUS - XSS vulnerability
_render() {
this._container.innerHTML = `<div>${this._userInput}</div>`;
}
// SAFE - Use textContent or sanitize
_render() {
const div = document.createElement("div");
div.textContent = this._userInput;
this._container.innerHTML = "";
this._container.appendChild(div);
}
```
**Use Shadow DOM Encapsulation**:
```javascript
constructor() {
super();
// Shadow DOM isolates styles and scripts
this._shadowRoot = this.attachShadow({ mode: "open" });
}
```
### Integrity Hash (Production)
**Generate SHA256 Hash**:
```bash
# Generate integrity hash for JavaScript file
openssl dgst -sha256 -binary widget.js | openssl base64 -A
# Example output: K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=
```
**Configure in JSON**:
```json
{
"webcomponents": [
{
"kind": "main",
"tag": "my-widget",
"url": "[https://host.com/widget.js",](https://host.com/widget.js",)
"integrity": "sha256-K7gNU3sdo+OL0wNhqoVWhr3g6s1xYv72ol/pe/Unols=",
"ignoreIntegrity": false
}
]
}
```
**Warning**: `ignoreIntegrity: true` triggers security warnings for admins. Only use in development.
### CORS Configuration
**Server Headers Required**:
```
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, OPTIONS
Access-Control-Allow-Headers: Content-Type
```
**Express.js Example**:
```javascript
const express = require("express");
const cors = require("cors");
const app = express();
app.use(cors({
origin: "*",
methods: ["GET", "OPTIONS"]
}));
app.use(express.static("public"));
app.listen(3000);
```
### External API Calls
**Use HTTPS Only**:
```javascript
async _fetchExternalData() {
// Always use HTTPS
const url = "[https://api.example.com/data";](https://api.example.com/data";)
try {
const response = await fetch(url, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("API call failed:", error);
throw error;
}
}
```
---
## Development Best Practices
### Code Organization
**Modular Structure**:
```javascript
(function() {
// Constants
const CONFIG = {
MAX_ITEMS: 100,
DEBOUNCE_MS: 50,
DEFAULT_COLOR: "#336699"
};
// Template
const template = document.createElement("template");
template.innerHTML = `<style>...</style><div>...</div>`;
// Helper functions
function formatNumber(n) { ... }
function sanitize(str) { ... }
// Main class
class MyWidget extends HTMLElement {
// Properties first
static get observedAttributes() { return ["title", "value"]; }
// Constructor
constructor() { ... }
// Lifecycle methods (in order)
connectedCallback() { ... }
onCustomWidgetBeforeUpdate() { ... }
onCustomWidgetAfterUpdate() { ... }
onCustomWidgetResize() { ... }
onCustomWidgetDestroy() { ... }
// Public methods
refresh() { ... }
setValue(v) { ... }
// Private methods (underscore prefix)
_render() { ... }
_handleClick() { ... }
// Getters/setters last
get title() { ... }
set title(v) { ... }
}
customElements.define("my-widget", MyWidget);
})();
```
### Error Handling
```javascript
class MyWidget extends HTMLElement {
_render() {
try {
// Rendering logic
this._doRender();
} catch (error) {
console.error("Widget render failed:", error);
this._showErrorState(error.message);
}
}
_showErrorState(message) {
this._shadowRoot.innerHTML = `
<div class="error-container">
<span class="error-icon">⚠️</span>
<span class="error-message">Widget Error: ${this._sanitize(message)}</span>
</div>
`;
}
_sanitize(str) {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
}
```
### Logging for Debugging
```javascript
class MyWidget extends HTMLElement {
_log(level, message, data) {
if (!this._props.debug) return;
const prefix = `[MyWidget]`;
switch (level) {
case "info":
console.log(prefix, message, data);
break;
case "warn":
console.warn(prefix, message, data);
break;
case "error":
console.error(prefix, message, data);
break;
}
}
onCustomWidgetAfterUpdate(changedProperties) {
this._log("info", "Properties updated", changedProperties);
this._render();
}
}
```
### Documentation
**JSDoc Comments**:
```javascript
/**
* Custom KPI Widget for SAP Analytics Cloud
* @class
* @extends HTMLElement
*
* @property {string} title - Widget title
* @property {number} value - KPI value (0-100)
* @property {string} color - Primary color (hex)
*
* @fires onClick - When widget is clicked
*
* @example
* // In SAC script
* KPIWidget_1.title = "Revenue";
* KPIWidget_1.value = 85;
*/
class KPIWidget extends HTMLElement { ... }
```
---
## Testing Guidelines
### Local Development Server
```javascript
// server.js
const express = require("express");
const cors = require("cors");
const path = require("path");
const app = express();
app.use(cors());
app.use(express.static(path.join(__dirname, "dist")));
app.listen(3000, () => {
console.log("Widget dev server: [http://localhost:3000](http://localhost:3000)");
});
```
### Test Scenarios
**1. Property Updates**:
- Change each property via script
- Verify visual updates
- Check console for errors
**2. Data Binding**:
- Add/remove data binding
- Empty data handling
- Large dataset handling
**3. Resize**:
- Resize container
- Switch responsive layouts
- Check chart redraws
**4. Lifecycle**:
- Navigate away and back
- Remove and re-add widget
- Verify cleanup in destroy
**5. Error Cases**:
- Invalid property values
- Network failures
- Missing data
### Browser DevTools
1. **Console**: Watch for errors and logs
2. **Network**: Verify file loading
3. **Elements**: Inspect Shadow DOM
4. **Performance**: Profile render times
5. **Memory**: Check for leaks
---
## Deployment Checklist
### Pre-Deployment
- [ ] Remove `console.log` statements (or guard with debug flag)
- [ ] Set `ignoreIntegrity: false`
- [ ] Generate and set integrity hash
- [ ] Minify JavaScript files
- [ ] Test with production data
- [ ] Verify HTTPS hosting
- [ ] Check CORS headers
### JSON Configuration
- [ ] Unique ID (reverse domain notation)
- [ ] Correct version number
- [ ] All URLs are absolute HTTPS
- [ ] Integrity hashes set
- [ ] Properties have descriptions
- [ ] Methods documented
### Documentation
- [ ] README with usage instructions
- [ ] Property descriptions
- [ ] Event documentation
- [ ] Known limitations
- [ ] Version changelog
### Hosting
- [ ] Files accessible via HTTPS
- [ ] CORS configured correctly
- [ ] CDN or reliable hosting
- [ ] Backup of all files
---
## Anti-Patterns to Avoid
| Anti-Pattern | Problem | Solution |
|--------------|---------|----------|
| Heavy constructor | Blocks initial render | Defer to connectedCallback |
| Sync external loads | Freezes UI | Use async/await |
| innerHTML with user data | XSS vulnerability | Use textContent or sanitize |
| No error handling | Silent failures | try/catch with error display |
| Memory leaks | Performance degradation | Clean up in destroy |
| Unbounded data | UI freeze | Limit and paginate |
| Frequent DOM updates | Janky UI | Batch updates |
| `ignoreIntegrity: true` in prod | Security warning | Generate proper hash |
---
## Resources
- [SAP Custom Widget Developer Guide](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [SAC Performance Optimization](https://community.sap.com/t5/technology-blog-posts-by-sap/optimizing-sap-analytics-cloud-best-practices-and-performance/ba-p/14229397)
- [Local Development Server](https://community.sap.com/t5/technology-blog-posts-by-sap/streamline-sac-custom-widget-development-with-local-server/ba-p/14160499)
---
**Last Updated**: 2025-11-22

View File

@@ -0,0 +1,927 @@
# ECharts Integration for SAP SAC Custom Widgets
Guide for integrating Apache ECharts library with SAP Analytics Cloud custom widgets.
**Source**: [SAP Hands-on Guide](https://community.sap.com/t5/technology-blog-posts-by-sap/sap-analytics-cloud-custom-widget-hands-on-guide/ba-p/13573631)
---
## Table of Contents
1. [Overview](#overview)
2. [Basic ECharts Widget](#basic-echarts-widget)
3. [Data-Bound ECharts Widget](#data-bound-echarts-widget)
4. [Common Chart Types](#common-chart-types)
5. [Styling Panel for ECharts](#styling-panel-for-echarts)
6. [Performance Considerations](#performance-considerations)
---
## Overview
Apache ECharts is a powerful charting library that can be integrated into SAC custom widgets to create advanced visualizations not available in standard SAC charts.
**ECharts CDN**: `[https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js`](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js`)
**Key Benefits**:
- 20+ chart types (sankey, treemap, sunburst, radar, etc.)
- Rich animation and interaction
- Excellent performance with large datasets
- Extensive customization options
---
## Basic ECharts Widget
### echarts-widget.json
```json
{
"id": "com.company.echartswidget",
"version": "1.0.0",
"name": "ECharts Widget",
"description": "Custom chart using Apache ECharts",
"vendor": "Company Name",
"license": "MIT",
"icon": "",
"webcomponents": [
{
"kind": "main",
"tag": "echarts-widget",
"url": "/echarts-widget.js",
"integrity": "",
"ignoreIntegrity": true
}
],
"properties": {
"title": {
"type": "string",
"default": "ECharts Demo"
},
"chartType": {
"type": "string",
"default": "bar"
},
"colorScheme": {
"type": "string",
"default": "#5470c6,#91cc75,#fac858,#ee6666,#73c0de"
}
},
"methods": {
"refresh": {
"description": "Refresh the chart",
"body": "this._refresh();"
}
},
"events": {
"onChartClick": {
"description": "Fired when chart element is clicked"
}
}
}
```
### echarts-widget.js
```javascript
(function() {
// Load ECharts library
const ECHARTS_CDN = "[https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";)
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
width: 100%;
height: 100%;
}
.chart-container {
width: 100%;
height: 100%;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6a6d70;
font-family: "72", Arial, sans-serif;
}
</style>
<div class="chart-container" id="chart">
<div class="loading">Loading ECharts...</div>
</div>
`;
class EchartsWidget extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this._props = {
title: "ECharts Demo",
chartType: "bar",
colorScheme: "#5470c6,#91cc75,#fac858,#ee6666,#73c0de"
};
this._chart = null;
this._echartsLoaded = false;
}
connectedCallback() {
this._loadEcharts();
}
_loadEcharts() {
if (window.echarts) {
this._echartsLoaded = true;
this._initChart();
return;
}
const script = document.createElement("script");
script.src = ECHARTS_CDN;
script.onload = () => {
this._echartsLoaded = true;
this._initChart();
};
script.onerror = () => {
this._shadowRoot.getElementById("chart").innerHTML =
'<div class="loading">Failed to load ECharts library</div>';
};
document.head.appendChild(script);
}
_initChart() {
const container = this._shadowRoot.getElementById("chart");
container.innerHTML = "";
this._chart = echarts.init(container);
// Handle click events
this._chart.on("click", (params) => {
this.dispatchEvent(new CustomEvent("onChartClick", {
detail: {
name: params.name,
value: params.value,
seriesName: params.seriesName
}
}));
});
this._render();
}
onCustomWidgetBeforeUpdate(changedProperties) {
this._props = { ...this._props, ...changedProperties };
}
onCustomWidgetAfterUpdate(changedProperties) {
this._render();
}
onCustomWidgetResize() {
if (this._chart) {
this._chart.resize();
}
}
onCustomWidgetDestroy() {
if (this._chart) {
this._chart.dispose();
this._chart = null;
}
}
_render() {
if (!this._chart || !this._echartsLoaded) return;
const colors = this._props.colorScheme.split(",").map(c => c.trim());
// Demo data - replace with data binding data
const option = this._getChartOption(colors);
this._chart.setOption(option, true);
}
_getChartOption(colors) {
const chartType = this._props.chartType;
const baseOption = {
title: {
text: this._props.title,
left: "center",
textStyle: {
fontFamily: '"72", Arial, sans-serif',
fontSize: 16,
fontWeight: 600
}
},
color: colors,
tooltip: {
trigger: chartType === "pie" ? "item" : "axis"
},
grid: {
left: "3%",
right: "4%",
bottom: "3%",
containLabel: true
}
};
// Demo data
const categories = ["Jan", "Feb", "Mar", "Apr", "May", "Jun"];
const values = [150, 230, 224, 218, 135, 147];
switch (chartType) {
case "bar":
return {
...baseOption,
xAxis: { type: "category", data: categories },
yAxis: { type: "value" },
series: [{ type: "bar", data: values }]
};
case "line":
return {
...baseOption,
xAxis: { type: "category", data: categories },
yAxis: { type: "value" },
series: [{ type: "line", data: values, smooth: true }]
};
case "pie":
return {
...baseOption,
series: [{
type: "pie",
radius: "60%",
data: categories.map((name, i) => ({ name, value: values[i] }))
}]
};
default:
return {
...baseOption,
xAxis: { type: "category", data: categories },
yAxis: { type: "value" },
series: [{ type: "bar", data: values }]
};
}
}
_refresh() {
this._render();
}
// Property getters/setters
get title() { return this._props.title; }
set title(v) { this._props.title = v; }
get chartType() { return this._props.chartType; }
set chartType(v) { this._props.chartType = v; }
get colorScheme() { return this._props.colorScheme; }
set colorScheme(v) { this._props.colorScheme = v; }
}
customElements.define("echarts-widget", EchartsWidget);
})();
```
---
## Data-Bound ECharts Widget
Integrate with SAC data models via data binding.
### echarts-databound.json
```json
{
"id": "com.company.echartsdatabound",
"version": "1.0.0",
"name": "ECharts Data-Bound",
"description": "ECharts with SAC data binding",
"vendor": "Company Name",
"license": "MIT",
"icon": "",
"webcomponents": [
{
"kind": "main",
"tag": "echarts-databound",
"url": "/echarts-databound.js",
"integrity": "",
"ignoreIntegrity": true
}
],
"properties": {
"title": { "type": "string", "default": "Data Chart" },
"chartType": { "type": "string", "default": "bar" },
"showLegend": { "type": "boolean", "default": true }
},
"methods": {},
"events": {
"onDataPointClick": {
"description": "Fired when data point is clicked"
}
},
"dataBindings": {
"chartData": {
"feeds": [
{ "id": "categories", "description": "Categories", "type": "dimension" },
{ "id": "values", "description": "Values", "type": "mainStructureMember" }
]
}
}
}
```
### echarts-databound.js
```javascript
(function() {
const ECHARTS_CDN = "[https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js";)
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
width: 100%;
height: 100%;
}
.chart-container {
width: 100%;
height: 100%;
}
.no-data {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6a6d70;
font-family: "72", Arial, sans-serif;
flex-direction: column;
gap: 8px;
}
.no-data-icon {
font-size: 32px;
opacity: 0.5;
}
</style>
<div class="chart-container" id="chart">
<div class="no-data">
<div class="no-data-icon">📊</div>
<div>Add data binding to display chart</div>
</div>
</div>
`;
class EchartsDatabound extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this._props = {
title: "Data Chart",
chartType: "bar",
showLegend: true
};
this._chart = null;
this._echartsLoaded = false;
}
connectedCallback() {
this._loadEcharts();
}
_loadEcharts() {
if (window.echarts) {
this._echartsLoaded = true;
this._initChart();
return;
}
const script = document.createElement("script");
script.src = ECHARTS_CDN;
script.onload = () => {
this._echartsLoaded = true;
this._initChart();
};
document.head.appendChild(script);
}
_initChart() {
const container = this._shadowRoot.getElementById("chart");
// Check for data
if (!this._hasData()) {
return;
}
container.innerHTML = "";
this._chart = echarts.init(container);
this._chart.on("click", (params) => {
this.dispatchEvent(new CustomEvent("onDataPointClick", {
detail: {
category: params.name,
value: params.value,
dataIndex: params.dataIndex
}
}));
});
this._render();
}
_hasData() {
return this.chartData &&
this.chartData.data &&
this.chartData.data.length > 0;
}
onCustomWidgetBeforeUpdate(changedProperties) {
this._props = { ...this._props, ...changedProperties };
}
onCustomWidgetAfterUpdate(changedProperties) {
// Re-init if we now have data
if (!this._chart && this._hasData() && this._echartsLoaded) {
this._initChart();
}
this._render();
}
onCustomWidgetResize() {
if (this._chart) {
this._chart.resize();
}
}
onCustomWidgetDestroy() {
if (this._chart) {
this._chart.dispose();
this._chart = null;
}
}
_render() {
if (!this._chart || !this._echartsLoaded) return;
if (!this._hasData()) {
this._chart.clear();
return;
}
const { categories, values } = this._parseDataBinding();
const option = this._buildChartOption(categories, values);
this._chart.setOption(option, true);
}
_parseDataBinding() {
const data = this.chartData.data;
const categories = [];
const values = [];
data.forEach(row => {
// Get category (first dimension)
if (row.categories_0) {
categories.push(row.categories_0.label || row.categories_0.id);
}
// Get value (first measure)
if (row.values_0) {
values.push(row.values_0.raw || 0);
}
});
return { categories, values };
}
_buildChartOption(categories, values) {
const chartType = this._props.chartType;
const option = {
title: {
text: this._props.title,
left: "center",
textStyle: {
fontFamily: '"72", Arial, sans-serif',
fontSize: 16
}
},
tooltip: {
trigger: chartType === "pie" ? "item" : "axis",
formatter: chartType === "pie" ? "{b}: {c} ({d}%)" : undefined
},
legend: {
show: this._props.showLegend,
bottom: 0
},
color: ["#5470c6", "#91cc75", "#fac858", "#ee6666", "#73c0de", "#3ba272"]
};
switch (chartType) {
case "bar":
return {
...option,
xAxis: {
type: "category",
data: categories,
axisLabel: { rotate: categories.length > 6 ? 45 : 0 }
},
yAxis: { type: "value" },
grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },
series: [{
type: "bar",
data: values,
itemStyle: { borderRadius: [4, 4, 0, 0] }
}]
};
case "line":
return {
...option,
xAxis: {
type: "category",
data: categories,
boundaryGap: false
},
yAxis: { type: "value" },
grid: { left: "3%", right: "4%", bottom: "15%", containLabel: true },
series: [{
type: "line",
data: values,
smooth: true,
areaStyle: { opacity: 0.3 }
}]
};
case "pie":
return {
...option,
series: [{
type: "pie",
radius: ["40%", "70%"],
center: ["50%", "55%"],
data: categories.map((name, i) => ({
name,
value: values[i]
})),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: "rgba(0, 0, 0, 0.5)"
}
},
label: {
formatter: "{b}: {d}%"
}
}]
};
case "horizontal-bar":
return {
...option,
xAxis: { type: "value" },
yAxis: {
type: "category",
data: categories
},
grid: { left: "3%", right: "4%", bottom: "3%", containLabel: true },
series: [{
type: "bar",
data: values,
itemStyle: { borderRadius: [0, 4, 4, 0] }
}]
};
default:
return option;
}
}
// Property getters/setters
get title() { return this._props.title; }
set title(v) { this._props.title = v; }
get chartType() { return this._props.chartType; }
set chartType(v) { this._props.chartType = v; }
get showLegend() { return this._props.showLegend; }
set showLegend(v) { this._props.showLegend = v; }
}
customElements.define("echarts-databound", EchartsDatabound);
})();
```
---
## Common Chart Types
### Sankey Diagram
```javascript
_buildSankeyOption(data) {
return {
series: [{
type: "sankey",
layout: "none",
emphasis: { focus: "adjacency" },
data: data.nodes, // [{name: "A"}, {name: "B"}]
links: data.links // [{source: "A", target: "B", value: 100}]
}]
};
}
```
### Treemap
```javascript
_buildTreemapOption(data) {
return {
series: [{
type: "treemap",
data: data, // [{name: "A", value: 100, children: [...]}]
levels: [
{ itemStyle: { borderWidth: 3 } },
{ itemStyle: { borderWidth: 1 } }
]
}]
};
}
```
### Radar Chart
```javascript
_buildRadarOption(categories, values) {
return {
radar: {
indicator: categories.map(name => ({ name, max: Math.max(...values) * 1.2 }))
},
series: [{
type: "radar",
data: [{ value: values, name: "Values" }]
}]
};
}
```
### Gauge Chart
```javascript
_buildGaugeOption(value, target) {
return {
series: [{
type: "gauge",
progress: { show: true, width: 18 },
axisLine: { lineStyle: { width: 18 } },
axisTick: { show: false },
splitLine: { length: 15, lineStyle: { width: 2 } },
axisLabel: { distance: 25 },
detail: {
valueAnimation: true,
formatter: "{value}%",
fontSize: 24
},
data: [{ value: value, name: "Progress" }]
}]
};
}
```
---
## Styling Panel for ECharts
### echarts-styling.js
```javascript
(function() {
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
font-family: "72", Arial, sans-serif;
font-size: 12px;
}
.panel { padding: 12px; }
.field { margin-bottom: 16px; }
label {
display: block;
margin-bottom: 4px;
font-weight: 500;
color: #32363a;
}
select, input[type="text"] {
width: 100%;
padding: 8px;
border: 1px solid #89919a;
border-radius: 4px;
font-size: 13px;
}
.checkbox-field {
display: flex;
align-items: center;
gap: 8px;
}
.color-row {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.color-input {
width: 32px;
height: 32px;
padding: 2px;
border: 1px solid #89919a;
border-radius: 4px;
cursor: pointer;
}
</style>
<div class="panel">
<div class="field">
<label>Title</label>
<input type="text" id="titleInput" />
</div>
<div class="field">
<label>Chart Type</label>
<select id="chartTypeSelect">
<option value="bar">Bar Chart</option>
<option value="line">Line Chart</option>
<option value="pie">Pie Chart</option>
<option value="horizontal-bar">Horizontal Bar</option>
</select>
</div>
<div class="field checkbox-field">
<input type="checkbox" id="legendCheckbox" />
<label for="legendCheckbox">Show Legend</label>
</div>
<div class="field">
<label>Colors</label>
<div class="color-row">
<input type="color" class="color-input" id="color1" value="#5470c6" />
<input type="color" class="color-input" id="color2" value="#91cc75" />
<input type="color" class="color-input" id="color3" value="#fac858" />
<input type="color" class="color-input" id="color4" value="#ee6666" />
<input type="color" class="color-input" id="color5" value="#73c0de" />
</div>
</div>
</div>
`;
class EchartsStyling extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this._props = {};
// Title
this._shadowRoot.getElementById("titleInput").addEventListener("change", (e) => {
this._fire({ title: e.target.value });
});
// Chart type
this._shadowRoot.getElementById("chartTypeSelect").addEventListener("change", (e) => {
this._fire({ chartType: e.target.value });
});
// Legend
this._shadowRoot.getElementById("legendCheckbox").addEventListener("change", (e) => {
this._fire({ showLegend: e.target.checked });
});
// Colors
for (let i = 1; i <= 5; i++) {
this._shadowRoot.getElementById(`color${i}`).addEventListener("input", () => {
this._updateColors();
});
}
}
_fire(properties) {
this.dispatchEvent(new CustomEvent("propertiesChanged", {
detail: { properties }
}));
}
_updateColors() {
const colors = [];
for (let i = 1; i <= 5; i++) {
colors.push(this._shadowRoot.getElementById(`color${i}`).value);
}
this._fire({ colorScheme: colors.join(",") });
}
onCustomWidgetBeforeUpdate(changedProperties) {
this._props = { ...this._props, ...changedProperties };
}
onCustomWidgetAfterUpdate(changedProperties) {
if (changedProperties.title !== undefined) {
this._shadowRoot.getElementById("titleInput").value = changedProperties.title;
}
if (changedProperties.chartType !== undefined) {
this._shadowRoot.getElementById("chartTypeSelect").value = changedProperties.chartType;
}
if (changedProperties.showLegend !== undefined) {
this._shadowRoot.getElementById("legendCheckbox").checked = changedProperties.showLegend;
}
if (changedProperties.colorScheme !== undefined) {
const colors = changedProperties.colorScheme.split(",");
colors.forEach((color, i) => {
const input = this._shadowRoot.getElementById(`color${i + 1}`);
if (input) input.value = color.trim();
});
}
}
}
customElements.define("echarts-styling", EchartsStyling);
})();
```
---
## Performance Considerations
### 1. Lazy Load ECharts
Only load when widget is used:
```javascript
_loadEcharts() {
return new Promise((resolve, reject) => {
if (window.echarts) {
resolve(window.echarts);
return;
}
const script = document.createElement("script");
script.src = ECHARTS_CDN;
script.onload = () => resolve(window.echarts);
script.onerror = reject;
document.head.appendChild(script);
});
}
```
### 2. Debounce Resize
```javascript
onCustomWidgetResize() {
if (this._resizeTimer) {
clearTimeout(this._resizeTimer);
}
this._resizeTimer = setTimeout(() => {
if (this._chart) {
this._chart.resize();
}
}, 100);
}
```
### 3. Use notMerge for Large Updates
```javascript
this._chart.setOption(option, { notMerge: true });
```
### 4. Limit Data Points
```javascript
_parseDataBinding() {
const data = this.chartData.data;
const MAX_POINTS = 100;
// Limit data for performance
const limitedData = data.slice(0, MAX_POINTS);
// ... parse data
}
```
### 5. Dispose on Destroy
```javascript
onCustomWidgetDestroy() {
if (this._chart) {
this._chart.dispose();
this._chart = null;
}
}
```
---
## ECharts Resources
- **ECharts Documentation**: [https://echarts.apache.org/en/index.html](https://echarts.apache.org/en/index.html)
- **ECharts Examples**: [https://echarts.apache.org/examples/en/index.html](https://echarts.apache.org/examples/en/index.html)
- **ECharts Option Reference**: [https://echarts.apache.org/en/option.html](https://echarts.apache.org/en/option.html)
- **ECharts CDN**: [https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js](https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js)
---
**Last Updated**: 2025-11-22

View File

@@ -0,0 +1,397 @@
# SAP SAC Custom Widget Integration and Migration
Coverage of script integration, content transport, story compatibility, and planning features.
**Sources**:
- [SAP Community - Content Transport](https://community.sap.com/t5/technology-blog-posts-by-members/sac-content-transport-migration-using-ctms/ba-p/13742318)
- [Optimized Story Experience](https://community.sap.com/t5/technology-blog-posts-by-sap/the-new-optimized-story-experience-the-unification-of-story-and-analytic/ba-p/13558887)
- [Analytics Designer API Reference](https://help.sap.com/doc/958d4c11261f42e992e8d01a4c0dde25/release/en-US/index.html)
---
## Table of Contents
1. [Script Integration](#script-integration)
2. [Content Transport and Migration](#content-transport-and-migration)
3. [Story Compatibility](#story-compatibility)
4. [Planning Integration](#planning-integration)
5. [API Methods Reference](#api-methods-reference)
---
## Script Integration
### Global Script Objects
Custom widgets can interact with SAC's global script objects.
**Script Object Structure**:
- Script objects act as containers for reusable functions
- Functions not tied to events, invoked directly
- Accessible from all scripts in the story
**Invoking Script Functions**:
```javascript
// In SAC script
ScriptObjectName.ScriptFunctionName();
// Example
Utils.formatCurrency(1000, "USD"); // Returns "$1,000.00"
```
### Script Variables
**Global Variables**:
- Defined at story level
- Accessible from all script blocks
- Can receive values from URL parameters
**Using with Custom Widgets**:
```javascript
// In SAC script
var myValue = GlobalVariable_1;
CustomWidget_1.setValue(myValue);
// Widget method receives value
class MyWidget extends HTMLElement {
setValue(val) {
this._props.value = val;
this._render();
}
}
```
### Script Object Integration Pattern
```javascript
// Custom widget firing events for script handling
class MyWidget extends HTMLElement {
_handleUserAction(data) {
// Fire event that SAC script can handle
this.dispatchEvent(new CustomEvent("onUserAction", {
detail: { actionData: data }
}));
}
}
// In SAC script (event handler)
CustomWidget_1.onUserAction = function() {
var eventData = CustomWidget_1.getEventInfo();
// Process event, call other script objects
DataProcessor.handleAction(eventData.actionData);
};
```
---
## Content Transport and Migration
### Transport Methods
**1. Content Network (Same Region)**
- Source and destination on same region
- Same or +1 quarterly version
- Access: Main Menu > Transport > Export/Import > Content Network Storage
**2. Import/Export (Any Region)**
- No region restriction
- Version constraints apply
- More flexible but manual
### Custom Widget Transport
**Supported Scenarios**:
- Cloud Foundry to Cloud Foundry tenants
- Same hosting configuration required
**Not Supported**:
- Cloud Foundry to Neo platform
- Different hosting configurations may cause issues
### Common Transport Issue
**Error**: "The system couldn't load the custom widget"
**Causes**:
- Widget JSON transported but resource files not accessible
- Different hosting URLs between source/target
- Integrity hash mismatch after transport
**Solution**:
```json
// Ensure resource URLs are accessible from target tenant
{
"webcomponents": [
{
"kind": "main",
"tag": "my-widget",
"url": "[https://globally-accessible-host.com/widget.js",](https://globally-accessible-host.com/widget.js",)
"integrity": "sha256-...",
"ignoreIntegrity": false
}
]
}
```
### Transport Best Practices
1. **Use globally accessible hosting** (GitHub Pages, CDN, SAC-hosted)
2. **Verify URLs before transport** - Ensure target can reach resource files
3. **Re-upload JSON** if hosting changes - Update URLs post-transport
4. **Test in target** before production use
### CTMS Integration
Cloud Transport Management Service (CTMS) provides automated transport:
1. Integrate CTMS with SAC
2. Define transport routes
3. Upload packages via SAC interface
4. CTMS handles deployment to destination
**Limitation**: CTMS is basic - no destination location selection like native Content Network.
---
## Story Compatibility
### Story Types
| Type | Custom Widgets | Scripting | CSS/Themes |
|------|----------------|-----------|------------|
| Classic Story | Limited | No | Limited |
| Optimized Story (Classic Responsive) | Yes | Limited | Limited |
| Optimized Story (Advanced Responsive) | Full | Full | Full |
### Optimized Story Experience (QRC Q2 2023+)
**Advanced Responsive Layout** features:
- Full custom widget support
- Complete scripting capabilities
- CSS and theme customization
- Device preview
- Data binding
### Classic Story Conversion
**Conversion Status Types**:
1. **Ready to convert** - No issues, direct conversion
2. **Feature limitation** - Some features not supported in optimized
3. **Blocked** - Issues must be resolved first
**Conversion Notes**:
- Conversion is permanent
- Save as copy recommended
- Converted stories use Classic Responsive Layout initially
### Custom Widget Compatibility
**In Optimized Stories**:
```json
{
"id": "com.company.widget",
"dataBindings": {
"myData": {
"feeds": [...]
}
}
}
```
- Full data binding support
- Script integration
- Builder/Styling panels
**In Classic Stories**:
- Limited support
- No data binding
- Basic property configuration only
---
## Planning Integration
### ⚠️ Important Limitations
Before implementing planning widgets, review these constraints:
1. **Builder Panel + Data Binding Conflict**: Cannot combine custom Builder Panel with data binding in the same widget
2. **Hierarchies Not Supported**: Data binding works with flat data only; select "flat" representation in SAC
See details in [Data Binding Limitations](#data-binding-limitations) below.
### Custom Widgets for Planning
Custom widgets can support SAP Analytics Cloud Planning scenarios:
**Use Cases**:
- Custom input controls
- Specialized data entry forms
- Planning workflow visualization
- Custom approval interfaces
### Data Binding Limitations
**Known Limitations**:
1. **Builder Panel + Data Binding Conflict**:
- Cannot combine custom Builder Panel with data binding
- Builder Panel overrides data binding functionality
- Choose one approach per widget
2. **Hierarchies Not Supported**:
- Data binding works with flat data only
- Select "flat" representation in SAC properties
- Hierarchical dimensions require alternative approach
### Planning API Integration
**Available through Script**:
```javascript
// In SAC script
var ds = Table_1.getDataSource();
// Planning operations (via DataSource)
ds.setUserInput(selection, value); // Write data
ds.submitData(); // Commit changes
ds.revertData(); // Rollback
```
**Custom Widget Access**:
```javascript
// Widget receives DataSource via method
class PlanningWidget extends HTMLElement {
async setDataSource(dataSource) {
this._ds = dataSource;
// Can now call dataSource methods
}
}
```
### Input Control Pattern
```javascript
class CustomInputWidget extends HTMLElement {
constructor() {
super();
this._setupInputHandlers();
}
_setupInputHandlers() {
this._shadowRoot.getElementById("input").addEventListener("change", (e) => {
// Fire event with new value
this.dispatchEvent(new CustomEvent("onValueChange", {
detail: { newValue: e.target.value }
}));
});
}
}
// SAC script handles the planning write-back
CustomInputWidget_1.onValueChange = function() {
var info = CustomInputWidget_1.getEventInfo();
var selection = { "Account": "Forecast", "Time": "2024.Q1" };
Table_1.getDataSource().setUserInput(selection, info.newValue);
};
```
---
## API Methods Reference
### DataSource Methods (via Script)
| Method | Description | Parameters |
|--------|-------------|------------|
| `getData(selection)` | Get data cell value | Selection object |
| `getResultSet()` | Get current result set | None |
| `getMembers(dimension)` | Get dimension members | Dimension name |
| `getResultMember(dim, selection)` | Get member info | Dimension, Selection |
| `getDimensionFilters(dimension)` | Get filter values | Dimension name |
| `setDimensionFilter(dim, member)` | Set filter | Dimension, MemberInfo |
| `removeDimensionFilter(dimension)` | Clear filter | Dimension name |
### Variable Methods
| Method | Description | Parameters |
|--------|-------------|------------|
| `setVariableValue(name, value)` | Set variable | Variable name, value |
| `getVariableValues()` | Get all variables | None |
**Performance Tip**: Group `setVariableValue()` calls together for automatic request merging.
### Custom Widget Data Binding Methods
```javascript
// Access data binding
const binding = this.dataBindings.getDataBinding("myBinding");
// Get result set (async)
const resultSet = await binding.getResultSet();
// Direct property access
const data = this.myBinding.data;
const metadata = this.myBinding.metadata;
```
### Event Info Pattern
```javascript
// In custom widget
this.dispatchEvent(new CustomEvent("onSelect", {
detail: {
selectedId: "item-123",
selectedValue: 100
}
}));
// In SAC script
Widget_1.onSelect = function() {
var info = Widget_1.getEventInfo();
// info.selectedId = "item-123"
// info.selectedValue = 100
};
```
---
## Migration Checklist
### Before Transport
- [ ] Verify resource file hosting is globally accessible
- [ ] Update URLs if changing hosting strategy
- [ ] Regenerate integrity hashes if files changed
- [ ] Test widget in source tenant
- [ ] Document any script dependencies
### After Transport
- [ ] Verify widget loads in target tenant
- [ ] Test all functionality
- [ ] Check script object references still work
- [ ] Verify data binding if applicable
- [ ] Test in view mode (not just edit mode)
### Troubleshooting
| Symptom | Likely Cause | Solution |
|---------|--------------|----------|
| Widget won't load | URL not accessible | Verify hosting, CORS |
| Integrity warning | Hash mismatch | Regenerate hash |
| Script errors | Missing script objects | Recreate in target |
| No data | Data binding lost | Reconfigure binding |
| Styling broken | CSS not loaded | Check styling panel config |
---
## Resources
- [Analytics Designer API Reference](https://help.sap.com/doc/958d4c11261f42e992e8d01a4c0dde25/release/en-US/index.html)
- [Custom Widget Developer Guide](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [SAC Content Transport](https://community.sap.com/t5/technology-blog-posts-by-members/sac-content-transport-migration-using-ctms/ba-p/13742318)
- [Optimized Story Experience](https://community.sap.com/t5/technology-blog-posts-by-sap/the-new-optimized-story-experience-the-unification-of-story-and-analytic/ba-p/13558887)
---
**Last Updated**: 2025-11-22

View File

@@ -0,0 +1,579 @@
# SAP SAC Custom Widget JSON Schema Reference
Complete reference for the JSON metadata file that defines custom widgets.
**Source**: [SAP Custom Widget Developer Guide](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf) - Section 6.1
---
## Table of Contents
1. [Complete Schema Example](#complete-schema-example)
2. [Root Object](#root-object)
3. [Webcomponents Array](#webcomponents-array)
4. [Properties Object](#properties-object)
5. [Methods Object](#methods-object)
6. [Events Object](#events-object)
7. [DataBindings Object](#databindings-object)
8. [Custom Types](#custom-types)
---
## Complete Schema Example
```json
{
"id": "com.company.advancedwidget",
"version": "1.0.0",
"name": "Advanced Custom Widget",
"description": "A feature-rich custom widget with data binding",
"vendor": "Company Name",
"license": "MIT",
"icon": "[https://example.com/icon.png",](https://example.com/icon.png",)
"newInstancePrefix": "advWidget",
"webcomponents": [
{
"kind": "main",
"tag": "advanced-widget",
"url": "[https://host.com/widget.js",](https://host.com/widget.js",)
"integrity": "sha256-abc123...",
"ignoreIntegrity": false
},
{
"kind": "styling",
"tag": "advanced-widget-styling",
"url": "[https://host.com/styling.js",](https://host.com/styling.js",)
"integrity": "sha256-def456...",
"ignoreIntegrity": false
},
{
"kind": "builder",
"tag": "advanced-widget-builder",
"url": "[https://host.com/builder.js",](https://host.com/builder.js",)
"integrity": "sha256-ghi789...",
"ignoreIntegrity": false
}
],
"properties": {
"title": {
"type": "string",
"default": "Widget Title",
"description": "The widget title"
},
"value": {
"type": "number",
"default": 0,
"description": "Numeric value"
},
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable/disable widget"
},
"color": {
"type": "Color",
"default": "#336699",
"description": "Primary color"
},
"items": {
"type": "string[]",
"default": [],
"description": "List of items"
},
"config": {
"type": "Object<string>",
"default": {},
"description": "Configuration object"
}
},
"methods": {
"refresh": {
"description": "Refresh widget data",
"body": "this._refresh();"
},
"setValue": {
"description": "Set the widget value",
"parameters": [
{
"name": "newValue",
"type": "number",
"description": "The new value"
}
],
"body": "this._setValue(newValue);"
},
"getValue": {
"description": "Get the current value",
"returnType": "number",
"body": "return this._getValue();"
}
},
"events": {
"onSelect": {
"description": "Fired when an item is selected"
},
"onChange": {
"description": "Fired when value changes"
},
"onLoad": {
"description": "Fired when widget loads"
}
},
"dataBindings": {
"myData": {
"feeds": [
{
"id": "dimensions",
"description": "Dimensions",
"type": "dimension"
},
{
"id": "measures",
"description": "Measures",
"type": "mainStructureMember"
}
]
}
}
}
```
---
## Root Object
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `id` | string | **Yes** | Unique identifier using reverse domain notation (e.g., "com.company.widgetname") |
| `version` | string | **Yes** | Semantic version (e.g., "1.0.0", "2.1.3") |
| `name` | string | **Yes** | Display name shown in SAC widget panel |
| `description` | string | No | Description shown in widget panel |
| `vendor` | string | No | Developer or company name |
| `license` | string | No | License type (MIT, Apache-2.0, proprietary) |
| `icon` | string | No | URL to widget icon (recommended: 32x32 PNG) |
| `newInstancePrefix` | string | No | Prefix for auto-generated script variable names |
| `webcomponents` | array | **Yes** | Array of web component definitions |
| `properties` | object | No | Widget properties accessible via script |
| `methods` | object | No | Methods callable from script |
| `events` | object | No | Events the widget can fire |
| `dataBindings` | object | No | Data binding configuration |
### ID Best Practices
```
com.company.widgetname # Standard format
com.github.username.widget # GitHub-hosted
sap.sample.widget # SAP samples only
```
---
## Webcomponents Array
Each widget can have up to three web components:
### Main Component (Required)
```json
{
"kind": "main",
"tag": "my-widget",
"url": "[https://host.com/widget.js",](https://host.com/widget.js",)
"integrity": "sha256-abc123...",
"ignoreIntegrity": false
}
```
### Styling Panel (Optional)
```json
{
"kind": "styling",
"tag": "my-widget-styling",
"url": "[https://host.com/styling.js",](https://host.com/styling.js",)
"integrity": "sha256-def456...",
"ignoreIntegrity": false
}
```
### Builder Panel (Optional)
```json
{
"kind": "builder",
"tag": "my-widget-builder",
"url": "[https://host.com/builder.js",](https://host.com/builder.js",)
"integrity": "sha256-ghi789...",
"ignoreIntegrity": false
}
```
### Webcomponent Properties
| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `kind` | string | **Yes** | "main", "styling", or "builder" |
| `tag` | string | **Yes** | Custom element tag name (lowercase, hyphenated, must contain hyphen) |
| `url` | string | **Yes** | URL to JavaScript file (HTTPS required for external hosting) |
| `integrity` | string | No | SHA256 hash for subresource integrity |
| `ignoreIntegrity` | boolean | No | Skip integrity check (development only, default: false) |
### Tag Naming Rules
- Must be lowercase
- Must contain at least one hyphen (-)
- Cannot start with a hyphen
- Cannot use reserved names (like HTML elements)
**Valid**: `my-widget`, `company-chart-v2`, `data-grid-component`
**Invalid**: `MyWidget`, `widget`, `my_widget`
---
## Properties Object
### Simple Types
```json
{
"stringProp": {
"type": "string",
"default": "default value",
"description": "A string property"
},
"numberProp": {
"type": "number",
"default": 3.14,
"description": "A floating-point number"
},
"integerProp": {
"type": "integer",
"default": 42,
"description": "An integer"
},
"booleanProp": {
"type": "boolean",
"default": true,
"description": "A boolean flag"
}
}
```
### Array Types
```json
{
"stringArray": {
"type": "string[]",
"default": ["item1", "item2"],
"description": "Array of strings"
},
"numberArray": {
"type": "number[]",
"default": [1, 2, 3],
"description": "Array of numbers"
}
}
```
### Object Types
```json
{
"objectProp": {
"type": "Object<string>",
"default": {},
"description": "Object with string values"
},
"numberObject": {
"type": "Object<number>",
"default": { "a": 1, "b": 2 },
"description": "Object with number values"
}
}
```
### Script API Types
```json
{
"colorProp": {
"type": "Color",
"default": "#336699",
"description": "Color value"
},
"selectionProp": {
"type": "Selection",
"default": {},
"description": "Selection object"
}
}
```
**Note**: For detailed information on `Color` and `Selection` types, including their JavaScript usage patterns and structure, see [Script API Data Types](advanced-topics.md#script-api-data-types) in Advanced Topics.
### Property Configuration
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `type` | string | **Yes** | Data type |
| `default` | varies | **Yes** | Default value matching type |
| `description` | string | No | Description for documentation |
---
## Methods Object
Methods allow scripts to call functions on the widget.
### Method Without Parameters
```json
{
"refresh": {
"description": "Refresh the widget",
"body": "this._refresh();"
}
}
```
> **⚠️ Important**: The `body` must call an internal method (prefixed with `_`) to avoid infinite recursion. When SAC invokes `Widget.refresh()`, it executes the body code. If the body calls `this.refresh()`, it would recursively call itself. Always use `this._refresh()` pattern.
### Method With Parameters
```json
{
"setTitle": {
"description": "Set the widget title",
"parameters": [
{
"name": "newTitle",
"type": "string",
"description": "The new title text"
}
],
"body": "this._setTitle(newTitle);"
}
}
```
### Method With Return Value
```json
{
"getTotal": {
"description": "Get the total value",
"returnType": "number",
"body": "return this._getTotal();"
}
}
```
### Method With Multiple Parameters
```json
{
"configure": {
"description": "Configure the widget",
"parameters": [
{ "name": "width", "type": "integer", "description": "Width in pixels" },
{ "name": "height", "type": "integer", "description": "Height in pixels" },
{ "name": "title", "type": "string", "description": "Title text" }
],
"body": "this._configure(width, height, title);"
}
}
```
### Method Configuration
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `description` | string | No | Method description |
| `parameters` | array | No | Array of parameter definitions |
| `returnType` | string | No | Return type (if method returns value) |
| `body` | string | **Yes** | JavaScript code to execute |
---
## Events Object
Events allow the widget to notify scripts of user interactions or state changes.
### Basic Event
```json
{
"onSelect": {
"description": "Fired when an item is selected"
},
"onClick": {
"description": "Fired when widget is clicked"
},
"onDataChange": {
"description": "Fired when data changes"
}
}
```
### Firing Events in Web Component
```javascript
// Simple event
this.dispatchEvent(new Event("onSelect"));
// Event with data (accessible via getEventInfo in script)
this.dispatchEvent(new CustomEvent("onSelect", {
detail: {
selectedItem: "item1",
selectedIndex: 0
}
}));
```
### Script Event Handler
In Analytics Designer script:
```javascript
// Event handler
Widget_1.onSelect = function() {
console.log("Item selected");
// Access event data if provided
var eventInfo = Widget_1.getEventInfo();
};
```
---
## DataBindings Object
Enable widgets to receive data from SAC models.
### Basic Data Binding
```json
{
"dataBindings": {
"myBinding": {
"feeds": [
{
"id": "dimensions",
"description": "Dimensions",
"type": "dimension"
},
{
"id": "measures",
"description": "Measures",
"type": "mainStructureMember"
}
]
}
}
}
```
### Feed Types
| Type | Description | Use Case |
|------|-------------|----------|
| `dimension` | Dimension members | Categories, labels, hierarchies |
| `mainStructureMember` | Measures/KPIs | Numeric values, calculations |
### Feed Configuration
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | **Yes** | Unique identifier for the feed |
| `description` | string | **Yes** | Display name in Builder Panel |
| `type` | string | **Yes** | "dimension" or "mainStructureMember" |
### Multiple Feeds Example
```json
{
"dataBindings": {
"chartData": {
"feeds": [
{ "id": "category", "description": "Category", "type": "dimension" },
{ "id": "series", "description": "Series", "type": "dimension" },
{ "id": "value", "description": "Value", "type": "mainStructureMember" },
{ "id": "target", "description": "Target", "type": "mainStructureMember" }
]
}
}
}
```
### Accessing Data in JavaScript
```javascript
// Access via property
const data = this.chartData.data;
const metadata = this.chartData.metadata;
// Iterate rows
this.chartData.data.forEach(row => {
const category = row.category_0.label;
const value = row.value_0.raw;
console.log(`${category}: ${value}`);
});
// Via getDataBinding method
const binding = this.dataBindings.getDataBinding("chartData");
```
---
## Custom Types
Define reusable complex types for properties.
### Defining Custom Type
```json
{
"types": {
"ChartConfig": {
"properties": {
"chartType": { "type": "string", "default": "bar" },
"showLegend": { "type": "boolean", "default": true },
"colors": { "type": "string[]", "default": [] }
}
}
},
"properties": {
"chartConfiguration": {
"type": "ChartConfig",
"default": {
"chartType": "bar",
"showLegend": true,
"colors": ["#336699", "#669933"]
}
}
}
}
```
---
## Validation Checklist
Before deploying, verify your JSON:
- [ ] `id` follows reverse domain notation
- [ ] `version` is semantic version format
- [ ] `name` is concise and descriptive
- [ ] All `webcomponents` have valid `tag` names (lowercase, hyphenated)
- [ ] All URLs are HTTPS (for external hosting)
- [ ] All `properties` have `type` and `default`
- [ ] All `methods` have `body`
- [ ] `integrity` is set for production (or explicitly `ignoreIntegrity: true` for dev)
- [ ] `dataBindings` feeds have unique `id` values
---
**Source Documentation**:
- [SAP Custom Widget Developer Guide (PDF)](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [SAP Help Portal - Custom Widgets](https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html)
**Last Updated**: 2025-11-22

View File

@@ -0,0 +1,831 @@
# SAP SAC Script API Reference for Custom Widgets
Comprehensive reference for Analytics Designer and Optimized Story Experience Script APIs
relevant to custom widget development.
**Sources**:
- [Analytics Designer API Reference Guide](https://help.sap.com/doc/958d4c11261f42e992e8d01a4c0dde25/release/en-US/index.html)
- [Optimized Story Experience API Reference Guide](https://help.sap.com/doc/1639cb9ccaa54b2592224df577abe822/release/en-US/index.html)
- [Custom Widget Developer Guide (PDF)](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
> **Note**: These documentation links point to the latest release version. Version-specific
> documentation may be available under versioned pages in the SAP Help Portal.
---
## Table of Contents
1. [DataSource Object](#datasource-object)
2. [Selection Type](#selection-type)
3. [MemberInfo Object](#memberinfo-object)
4. [ResultMemberInfo Object](#resultmemberinfo-object)
5. [ResultSet APIs](#resultset-apis)
6. [DataBinding Object](#databinding-object)
7. [Filter APIs](#filter-apis)
8. [Planning APIs](#planning-apis)
9. [Variable APIs](#variable-apis)
10. [Event Handling](#event-handling)
---
## DataSource Object
The DataSource object provides access to data model information and operations.
### Getting DataSource
```javascript
// In SAC Script
var ds = Table_1.getDataSource();
var ds = Chart_1.getDataSource();
// In custom widget (via script method)
Widget_1.getDataSource(); // If widget has data binding
```
### DataSource Methods
#### getResultSet()
Returns result data based on optional parameters.
```javascript
// Signature
getResultSet(options?: Object): Array<Object>
// Options object can include:
// - selection: Object - data selection context
// - offset: number - starting row index
// - limit: number - maximum rows to return
// Examples
var allData = ds.getResultSet();
var filteredData = ds.getResultSet({ selection: { "Year": "2024" } });
var pagedData = ds.getResultSet({ offset: 0, limit: 100 }); // First 100 rows
```
**Return Value**: Array of result objects containing:
- Dimension member info (id, description, parentId)
- Measure values (raw, formatted, unit)
#### getResultMember()
Returns member information for a specific selection.
```javascript
// Signature
getResultMember(
dimensionId: string,
selection: Object
): Object | undefined
// Example
var memberInfo = ds.getResultMember("Account", { "Account": "Revenue" });
console.log(memberInfo.description); // "Revenue"
console.log(memberInfo.id); // "REVENUE"
```
#### getMembers()
Retrieves dimension members.
```javascript
// Signature
getMembers(
dimensionId: string,
maxNumber?: number
): Array<Object>
// Examples
var allMembers = ds.getMembers("Account");
var limitedMembers = ds.getMembers("Account", 100); // Max 100 members
```
**Note**: Using getMembers() causes a backend roundtrip. For performance, prefer
getResultSet() when possible as it doesn't require additional network calls.
#### getMember()
Returns information for a specific member.
```javascript
// Signature
getMember(
dimensionId: string,
memberId: string
): Object
// Example
var member = ds.getMember("Account", "REVENUE");
```
#### getData()
Gets the raw data value for a selection.
```javascript
// Signature
getData(selection?: Object): number | null
// Examples
var currentValue = ds.getData(); // Uses current selection context
var specificValue = ds.getData({
"Account": "Revenue",
"Year": "2024"
});
```
#### getDimensions()
Returns available dimensions in the data source.
```javascript
// Signature
getDimensions(): Array<Object>
// Example
var dimensions = ds.getDimensions();
dimensions.forEach(dim => {
console.log(dim.id, dim.description);
});
```
#### getMeasures() / getMainStructureMembers()
Returns available measures.
```javascript
// Signature
getMeasures(): Array<Object>
// Example
var measures = ds.getMeasures();
measures.forEach(m => {
console.log(m.id, m.description, m.unitOfMeasure);
});
```
---
## Selection Type
Selection objects define data context for operations.
### Selection Structure
```javascript
// Simple selection
var selection = {
"Account": "Revenue",
"Year": "2024"
};
// Selection with multiple values
var multiSelection = {
"Account": ["Revenue", "Cost"],
"Year": "2024"
};
// Selection with hierarchy
var hierarchySelection = {
"Account": {
id: "Revenue",
hierarchyId: "H1"
}
};
```
### Using Selection in JSON
```json
{
"properties": {
"currentSelection": {
"type": "Selection",
"default": {},
"description": "Current data selection context"
}
},
"methods": {
"setSelection": {
"description": "Apply a data selection",
"parameters": [
{
"name": "selection",
"type": "Selection",
"description": "Selection to apply"
}
],
"body": "this._setSelection(selection);"
},
"getSelection": {
"description": "Get current selection",
"returnType": "Selection",
"body": "return this._getSelection();"
}
}
}
```
### Selection in Web Component
```javascript
class DataWidget extends HTMLElement {
constructor() {
super();
this._selection = {};
}
// Method called from SAC script
setSelection(selection) {
this._selection = selection;
this._render();
}
getSelection() {
return this._selection;
}
_render() {
// Use selection to filter/highlight data
console.log("Current selection:", this._selection);
}
}
```
---
## MemberInfo Object
Represents dimension member information.
### Structure
```javascript
{
id: string, // Technical member ID (e.g., "REVENUE")
description: string, // Display name (e.g., "Revenue")
displayId: string, // Display ID
dimensionId: string, // Parent dimension ID
modelId: string, // Data model ID
parentId?: string, // Parent member ID (hierarchies)
hierarchyId?: string, // Hierarchy ID (if applicable)
level?: integer, // Hierarchy level
isNode?: boolean, // Is hierarchy node (has children)
properties?: object // Additional attributes
}
```
### Performance Optimization
When using setDimensionFilter(), passing a MemberInfo object instead of just
a member ID string prevents a backend roundtrip:
```javascript
// Slower - causes roundtrip to fetch description
ds.setDimensionFilter("Account", "REVENUE");
// Faster - no roundtrip, MemberInfo already has description
ds.setDimensionFilter("Account", {
id: "REVENUE",
description: "Revenue"
});
```
### Accessing Member Properties
```javascript
// Get member with attributes
var member = ds.getMember("Product", "P001");
// Access properties (if dimension has attributes)
if (member.properties) {
console.log("Category:", member.properties.Category);
console.log("Brand:", member.properties.Brand);
}
```
---
## ResultMemberInfo Object
Extended member information from result sets.
### Structure
```javascript
{
id: string, // Member ID
description: string, // Display name
parentId?: string, // Parent ID for hierarchies
formattedValue?: string, // Formatted display value
unitOfMeasure?: string, // Unit (for measures)
raw?: number, // Raw numeric value (for measures)
properties?: {
// Dimension attributes
[attributeName: string]: string
}
}
```
### Accessing in Result Set
```javascript
var resultSet = ds.getResultSet();
resultSet.forEach(row => {
// Access dimension member info
var accountMember = row["Account"];
console.log("Account ID:", accountMember.id);
console.log("Account Name:", accountMember.description);
// Access measure value
var revenue = row["Revenue"];
console.log("Value:", revenue.raw);
console.log("Formatted:", revenue.formattedValue);
console.log("Unit:", revenue.unitOfMeasure);
});
```
### Note on Visibility
Only visible properties in the widget configuration are included in the
ResultMemberInfo object. Hidden dimensions/measures won't appear.
---
## ResultSet APIs
### getResultSet() Deep Dive
```javascript
// Full signature
getResultSet(
selection?: Selection | Selection[] | SelectionContext,
offset?: integer,
limit?: integer
): ResultSet[]
```
#### Pagination
```javascript
// Get first page (100 rows)
var page1 = ds.getResultSet(null, 0, 100);
// Get second page
var page2 = ds.getResultSet(null, 100, 100);
// Count total rows
var total = ds.getResultSetCount();
```
#### Filtering
```javascript
// Single filter
var filtered = ds.getResultSet({ "Year": "2024" });
// Multiple filters
var filtered = ds.getResultSet({
"Year": "2024",
"Region": "EMEA"
});
// Array of selections (OR logic)
var filtered = ds.getResultSet([
{ "Year": "2024" },
{ "Year": "2023" }
]);
```
### Processing Result Sets
```javascript
function processResults(resultSet) {
var chartData = [];
resultSet.forEach(row => {
// Dynamically access columns based on data binding
var dimensions = Object.keys(row).filter(k => row[k].id !== undefined);
var measures = Object.keys(row).filter(k => row[k].raw !== undefined);
chartData.push({
category: row[dimensions[0]]?.description || "Unknown",
value: row[measures[0]]?.raw || 0
});
});
return chartData;
}
```
---
## DataBinding Object
Custom widget data binding API.
### Getting DataBinding
```javascript
// In custom widget web component
const binding = this.dataBindings.getDataBinding("myDataBinding");
```
### DataBinding Methods
#### getResultSet()
```javascript
// Async - returns Promise
const resultSet = await binding.getResultSet();
```
#### getMembers()
```javascript
// Get dimension members
const members = await binding.getMembers("DimensionId");
```
#### addDimensionToFeed()
```javascript
// Programmatically add dimension to feed
await binding.addDimensionToFeed("dimensions", "Account");
```
#### addMeasureToFeed()
```javascript
// Add measure to feed
await binding.addMeasureToFeed("measures", "Revenue");
```
#### removeDimensionFromFeed()
```javascript
await binding.removeDimensionFromFeed("dimensions", "Account");
```
### Accessing Bound Data Directly
```javascript
// Direct property access (name from JSON dataBindings)
const data = this.myDataBinding;
// Structure
{
data: ResultSet[], // Array of result rows
metadata: {
dimensions: {}, // Dimension info
mainStructureMembers: {} // Measure info
}
}
```
### Data Binding Example
```javascript
class DataBoundWidget extends HTMLElement {
onCustomWidgetAfterUpdate(changedProperties) {
this._processData();
}
_processData() {
// Access data binding by name defined in JSON
const binding = this.chartData;
if (!binding || !binding.data) {
this._showNoData();
return;
}
// Process rows
const chartData = binding.data.map(row => {
// Access dimension (first feed)
const categoryKey = Object.keys(row).find(k =>
binding.metadata.dimensions[k]);
const valueKey = Object.keys(row).find(k =>
binding.metadata.mainStructureMembers[k]);
return {
label: row[categoryKey]?.description || "",
value: row[valueKey]?.raw || 0
};
});
this._renderChart(chartData);
}
}
```
---
## Filter APIs
### setDimensionFilter()
```javascript
// Set single filter
ds.setDimensionFilter("Year", "2024");
// Set filter with MemberInfo (avoids roundtrip)
ds.setDimensionFilter("Year", {
id: "2024",
description: "Year 2024"
});
// Multiple values
ds.setDimensionFilter("Year", ["2023", "2024"]);
// Clear filter
ds.removeDimensionFilter("Year");
```
### setVariableValue()
```javascript
// Set planning variable
ds.setVariableValue("VAR_YEAR", "2024");
// Multiple values
ds.setVariableValue("VAR_REGION", ["EMEA", "AMER"]);
```
### Filter Synchronization
```javascript
// Apply filters and refresh data
ds.setDimensionFilter("Year", "2024");
ds.setDimensionFilter("Region", "EMEA");
// Refresh to apply (may be automatic depending on widget)
ds.refresh();
```
---
## Planning APIs
For widgets supporting SAP Analytics Cloud Planning.
> **⚠️ Important**: Planning APIs are synchronous and return boolean success values.
> Always check the return value to handle errors appropriately.
### Write-back Methods
```javascript
// Set user input (planning)
ds.setUserInput(selection, value);
// Example
ds.setUserInput({
"Account": "Forecast",
"Year": "2025",
"Month": "Jan"
}, 100000);
// Submit changes (synchronous, returns boolean)
var success = ds.submitData();
if (!success) {
console.error("Submit failed");
}
// Revert changes using Planning Version
var planningVersion = ds.getPlanningVersion();
planningVersion.revert();
```
### Planning Workflow
```javascript
class PlanningWidget extends HTMLElement {
saveData(entries) {
const ds = this._dataSource;
// Apply all inputs
for (const entry of entries) {
ds.setUserInput(entry.selection, entry.value);
}
// Submit to backend (synchronous)
var success = ds.submitData();
if (success) {
this._showSuccess("Data saved");
} else {
// Rollback on error using Planning Version
var planningVersion = ds.getPlanningVersion();
if (planningVersion) {
planningVersion.revert();
}
this._showError("Save failed");
}
}
}
```
### Data Locking
```javascript
// Get data locking interface
var dataLocking = ds.getDataLocking();
// Check lock status
var isLocked = dataLocking.isLocked();
// Set lock state (returns boolean)
var lockSuccess = dataLocking.setState(true); // Lock
if (!lockSuccess) {
console.error("Failed to acquire lock");
}
// Release lock
var unlockSuccess = dataLocking.setState(false); // Unlock
if (!unlockSuccess) {
console.error("Failed to release lock");
}
```
---
## Variable APIs
### Input Variable Methods
```javascript
// Get variable value
var year = ds.getVariableValue("VAR_YEAR");
// Set variable value
ds.setVariableValue("VAR_YEAR", "2024");
// Get available values
var values = ds.getVariableValues("VAR_YEAR");
```
### Variable Information
```javascript
// Get variable details
var varInfo = ds.getVariable("VAR_YEAR");
console.log(varInfo.description); // "Fiscal Year"
console.log(varInfo.mandatory); // true/false
console.log(varInfo.multiValue); // true/false
```
---
## Event Handling
### Widget Events in JSON
```json
{
"events": {
"onSelect": {
"description": "Fired when item is selected"
},
"onDataChange": {
"description": "Fired when data changes"
},
"onError": {
"description": "Fired on error"
}
}
}
```
### Firing Events
```javascript
// Simple event
this.dispatchEvent(new Event("onSelect"));
// Event with data
this.dispatchEvent(new CustomEvent("onSelect", {
detail: {
selection: this._currentSelection,
value: this._selectedValue
}
}));
// Error event
this.dispatchEvent(new CustomEvent("onError", {
detail: {
message: "Failed to load data",
code: "DATA_ERROR"
}
}));
```
### Handling in SAC Script
```javascript
// Event handler in SAC script
Widget_1.onSelect = function(event) {
var info = Widget_1.getEventInfo();
console.log("Selected:", info.selection);
console.log("Value:", info.value);
// Update other widgets
Table_1.getDataSource().setDimensionFilter("Account", info.selection.Account);
};
Widget_1.onError = function(event) {
var info = Widget_1.getEventInfo();
Application.showMessage(ApplicationMessageType.Error, info.message);
};
```
### Data-Driven Events
```javascript
class InteractiveWidget extends HTMLElement {
_handleClick(item) {
// Build selection from clicked item
const selection = {
[this._dimensionId]: item.id
};
// Store for getEventInfo
this._lastEvent = {
selection: selection,
label: item.label,
value: item.value
};
// Fire event
this.dispatchEvent(new Event("onSelect"));
}
// Called by SAC script via getEventInfo()
getEventInfo() {
return this._lastEvent || {};
}
}
```
---
## Common Patterns
### Async Data Loading
```javascript
class AsyncWidget extends HTMLElement {
async connectedCallback() {
this._showLoading();
try {
const binding = this.dataBindings.getDataBinding("myData");
const resultSet = await binding.getResultSet();
this._renderData(resultSet);
} catch (error) {
this._showError(error.message);
}
}
_showLoading() {
this._shadowRoot.innerHTML = '<div class="loading">Loading...</div>';
}
}
```
### Selection Synchronization
```javascript
class SyncWidget extends HTMLElement {
// Apply external selection
setSelection(selection) {
this._selection = selection;
this._highlightSelection();
}
// Notify of internal selection
_onItemClick(item) {
this._selection = { [this._dimId]: item.id };
this.dispatchEvent(new CustomEvent("propertiesChanged", {
detail: { properties: { currentSelection: this._selection } }
}));
this.dispatchEvent(new Event("onSelect"));
}
}
```
---
## Resources
- [Analytics Designer API Reference Guide](https://help.sap.com/doc/958d4c11261f42e992e8d01a4c0dde25/release/en-US/index.html)
- [Optimized Story Experience API Reference Guide](https://help.sap.com/doc/1639cb9ccaa54b2592224df577abe822/release/en-US/index.html)
- [Custom Widget Developer Guide (PDF)](https://help.sap.com/doc/c813a28922b54e50bd2a307b099787dc/release/en-US/CustomWidgetDevGuide_en.pdf)
- [Use Result Set APIs](https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/00f68c2e08b941f081002fd3691d86a7/834786949212459caabe3a3d13f0aaa9.html)
- [SAP Help Portal - Custom Widgets](https://help.sap.com/docs/SAP_ANALYTICS_CLOUD/0ac8c6754ff84605a4372468d002f2bf/75311f67527c41638ceb89af9cd8af3e.html)
---
**Last Updated**: 2025-11-22

View File

@@ -0,0 +1,415 @@
# SAP SAC Widget Add-On Development Guide
Widget Add-Ons extend built-in SAC widgets without building from scratch.
**Available Since**: QRC Q4 2023
**Source**: [Announcing Widget Add-On](https://community.sap.com/t5/technology-blog-posts-by-sap/announcing-the-new-sap-analytics-cloud-feature-widget-add-on/ba-p/13575788)
---
## Table of Contents
1. [Overview](#overview)
2. [Widget Add-On vs Custom Widget](#widget-add-on-vs-custom-widget)
3. [Supported Chart Types](#supported-chart-types)
4. [JSON Structure](#json-structure)
5. [Implementation Examples](#implementation-examples)
6. [Using Widget Add-Ons](#using-widget-add-ons)
---
## Overview
Widget Add-Ons allow customization of SAC's built-in widgets:
- Add visual elements to charts
- Modify tooltip contents
- Override existing styling
- Extend plot area rendering
**Key Benefit**: Leverage SAC's native visualizations with custom enhancements without building widgets from scratch.
---
## Widget Add-On vs Custom Widget
| Aspect | Custom Widget | Widget Add-On |
|--------|---------------|---------------|
| Purpose | Create entirely new widgets | Extend built-in widgets |
| Web Components | Creates new widget | Adds/replaces parts of existing |
| Component Types | main, styling, builder | main, builder only |
| Use Case | Custom chart types, input controls | Tooltip customization, plot styling |
| Complexity | Higher | Lower |
---
## Supported Chart Types
### Tooltip Customization
- All chart types **except** numeric point
### Plot Area (General)
- Bar/Column charts
- Stacked Bar/Column charts
- Stacked Area charts
- Line charts
### Plot Area (Numeric Point)
- Numeric Point only
---
## JSON Structure
### widget-addon.json
```json
{
"id": "com.company.mywidgetaddon",
"version": "1.0.0",
"name": "My Widget Add-On",
"description": "Customizes chart tooltips",
"vendor": "Company Name",
"license": "MIT",
"icon": "",
"webcomponents": [
{
"kind": "main",
"tag": "my-addon-main",
"url": "[https://host.com/addon-main.js",](https://host.com/addon-main.js",)
"integrity": "",
"ignoreIntegrity": true
},
{
"kind": "builder",
"tag": "my-addon-builder",
"url": "[https://host.com/addon-builder.js",](https://host.com/addon-builder.js",)
"integrity": "",
"ignoreIntegrity": true
}
],
"properties": {
"customColor": {
"type": "string",
"default": "#336699"
},
"showCustomLabel": {
"type": "boolean",
"default": true
}
},
"extension": {
"target": "tooltip"
}
}
```
### Extension Targets
| Target | Description |
|--------|-------------|
| `tooltip` | Customize tooltip content and styling |
| `plotArea` | Add visual elements to plot area |
| `numericPoint` | Customize numeric point display |
---
## Implementation Examples
### Tooltip Add-On
```javascript
(function() {
const template = document.createElement("template");
template.innerHTML = `
<style>
:host {
display: block;
}
.custom-tooltip {
padding: 8px 12px;
background: #1a1a2e;
border-radius: 4px;
color: #ffffff;
font-family: "72", Arial, sans-serif;
font-size: 12px;
}
.tooltip-title {
font-weight: 600;
margin-bottom: 4px;
}
.tooltip-value {
font-size: 16px;
color: #4cc9f0;
}
.tooltip-change {
font-size: 11px;
margin-top: 4px;
}
.tooltip-change.positive { color: #4ade80; }
.tooltip-change.negative { color: #f87171; }
</style>
<div class="custom-tooltip" id="tooltip">
<div class="tooltip-title" id="title"></div>
<div class="tooltip-value" id="value"></div>
<div class="tooltip-change" id="change"></div>
</div>
`;
class TooltipAddon extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this._props = {
customColor: "#336699",
showCustomLabel: true
};
}
connectedCallback() {
this._render();
}
onCustomWidgetBeforeUpdate(changedProperties) {
this._props = { ...this._props, ...changedProperties };
}
onCustomWidgetAfterUpdate(changedProperties) {
this._render();
}
// Called by SAC with tooltip data
setTooltipData(data) {
this._tooltipData = data;
this._render();
}
_render() {
if (!this._tooltipData) return;
const data = this._tooltipData;
const titleEl = this._shadowRoot.getElementById("title");
const valueEl = this._shadowRoot.getElementById("value");
const changeEl = this._shadowRoot.getElementById("change");
titleEl.textContent = data.dimensionLabel || "Value";
valueEl.textContent = this._formatValue(data.measureValue);
valueEl.style.color = this._props.customColor;
if (data.previousValue && this._props.showCustomLabel) {
const change = ((data.measureValue - data.previousValue) / data.previousValue) * 100;
changeEl.textContent = `${change >= 0 ? "+" : ""}${change.toFixed(1)}% vs previous`;
changeEl.className = `tooltip-change ${change >= 0 ? "positive" : "negative"}`;
changeEl.style.display = "block";
} else {
changeEl.style.display = "none";
}
}
_formatValue(value) {
if (typeof value !== "number") return value;
if (value >= 1000000) return (value / 1000000).toFixed(1) + "M";
if (value >= 1000) return (value / 1000).toFixed(1) + "K";
return value.toLocaleString();
}
get customColor() { return this._props.customColor; }
set customColor(v) { this._props.customColor = v; }
get showCustomLabel() { return this._props.showCustomLabel; }
set showCustomLabel(v) { this._props.showCustomLabel = v; }
}
customElements.define("tooltip-addon", TooltipAddon);
})();
```
### Plot Area Add-On
```javascript
(function() {
class PlotAreaAddon extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._props = {
showTargetLine: true,
targetValue: 0,
targetColor: "#ff6b6b"
};
}
connectedCallback() {
this._render();
}
onCustomWidgetBeforeUpdate(changedProperties) {
this._props = { ...this._props, ...changedProperties };
}
onCustomWidgetAfterUpdate(changedProperties) {
this._render();
}
// Called by SAC with chart context
setChartContext(context) {
this._chartContext = context;
this._render();
}
_render() {
if (!this._chartContext || !this._props.showTargetLine) {
this._shadowRoot.innerHTML = "";
return;
}
const { width, height, yScale } = this._chartContext;
// Defensive check: Validate yScale is a function before using
if (!yScale || typeof yScale !== "function") {
console.warn("[PlotAreaAddon] Chart context missing valid yScale function");
this._shadowRoot.innerHTML = "";
return;
}
const y = yScale(this._props.targetValue);
this._shadowRoot.innerHTML = `
<svg width="${width}" height="${height}" style="position:absolute;top:0;left:0;pointer-events:none;">
<line
x1="0" y1="${y}"
x2="${width}" y2="${y}"
stroke="${this._props.targetColor}"
stroke-width="2"
stroke-dasharray="5,5"
/>
<text
x="${width - 5}" y="${y - 5}"
text-anchor="end"
fill="${this._props.targetColor}"
font-size="11"
font-family="72, Arial, sans-serif"
>
Target: ${this._props.targetValue}
</text>
</svg>
`;
}
get showTargetLine() { return this._props.showTargetLine; }
set showTargetLine(v) { this._props.showTargetLine = v; }
get targetValue() { return this._props.targetValue; }
set targetValue(v) { this._props.targetValue = v; }
get targetColor() { return this._props.targetColor; }
set targetColor(v) { this._props.targetColor = v; }
}
customElements.define("plotarea-addon", PlotAreaAddon);
})();
```
---
## Using Widget Add-Ons
### Enabling Add-Ons in Stories
1. Open story in Edit mode
2. Select a supported chart widget
3. Open Builder panel
4. Scroll to **Custom Add-Ons** section
5. Toggle **Enable Custom Add-Ons** to ON
6. Select your installed add-on
### Installation
Same process as custom widgets:
1. Navigate to **Analytic Applications** > **Custom Widgets**
2. Click **+** to add new
3. Upload the add-on JSON file
4. Widget Add-Ons appear in the add-ons dropdown for supported widgets
---
## Builder Panel for Add-Ons
```javascript
(function() {
const template = document.createElement("template");
template.innerHTML = `
<style>
:host { display: block; font-family: "72", Arial, sans-serif; font-size: 12px; }
.panel { padding: 12px; }
.field { margin-bottom: 12px; }
label { display: block; margin-bottom: 4px; font-weight: 500; }
input[type="color"], input[type="number"] {
width: 100%; padding: 6px; border: 1px solid #89919a; border-radius: 4px;
}
.checkbox-field { display: flex; align-items: center; gap: 8px; }
</style>
<div class="panel">
<div class="field">
<label>Custom Color</label>
<input type="color" id="colorInput" />
</div>
<div class="field checkbox-field">
<input type="checkbox" id="labelCheckbox" />
<label for="labelCheckbox">Show Custom Label</label>
</div>
</div>
`;
class AddonBuilder extends HTMLElement {
constructor() {
super();
this._shadowRoot = this.attachShadow({ mode: "open" });
this._shadowRoot.appendChild(template.content.cloneNode(true));
this._shadowRoot.getElementById("colorInput").addEventListener("input", (e) => {
this._fire({ customColor: e.target.value });
});
this._shadowRoot.getElementById("labelCheckbox").addEventListener("change", (e) => {
this._fire({ showCustomLabel: e.target.checked });
});
}
_fire(properties) {
this.dispatchEvent(new CustomEvent("propertiesChanged", { detail: { properties } }));
}
onCustomWidgetBeforeUpdate(changedProperties) {}
onCustomWidgetAfterUpdate(changedProperties) {
if (changedProperties.customColor !== undefined) {
this._shadowRoot.getElementById("colorInput").value = changedProperties.customColor;
}
if (changedProperties.showCustomLabel !== undefined) {
this._shadowRoot.getElementById("labelCheckbox").checked = changedProperties.showCustomLabel;
}
}
}
customElements.define("addon-builder", AddonBuilder);
})();
```
---
## Key Differences from Custom Widgets
1. **No Styling Panel**: Add-ons only support main + builder components
2. **Extension Target**: Must specify what part of the widget to extend
3. **Context Data**: SAC provides chart context (scales, dimensions, data) via methods
4. **Limited Scope**: Can only modify supported parts of supported chart types
---
## Resources
- [Widget Add-On Announcement](https://community.sap.com/t5/technology-blog-posts-by-sap/announcing-the-new-sap-analytics-cloud-feature-widget-add-on/ba-p/13575788)
- [Widget Add-On Samples](https://community.sap.com/t5/technology-blog-posts-by-sap/sap-analytics-cloud-custom-widget-amp-widget-add-ons-samples-preview/ba-p/13585313)
- [SAP Samples Repository](https://github.com/SAP-samples/analytics-cloud-datasphere-community-content/tree/main/SAC_Custom_Widgets)
---
**Last Updated**: 2025-11-22

File diff suppressed because it is too large Load Diff