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,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