609 lines
14 KiB
Markdown
609 lines
14 KiB
Markdown
# 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
|