In this blog post, I want to demonstrate how to create a simple Bill of Materials (BOM) relationship visualization using SAP Cloud Application Programming Model (CAP) and consume it in a SAP Fiori Elements application. We will display the BOM structure as a Network Graph, making it easy for end users to navigate parent/child relationships among materials.
1. Reason and Basic Setup
When handling production or manufacturing processes, a Bill of Materials (BOM) is one of the fundamental structures that define how materials are composed or assembled. By visualizing the BOM in a graph, it is much easier to understand complex hierarchies.
For this example, I am using an SAP CAP project that manages Materials and their BOM structures, along with a pseudo-entity GraphNetwork that dynamically assembles the data for a SAP sap.suite.ui.commons.networkgraph control to consume.
You can see more examples of the NetworkGraph in the UI5 Documentation.
The example is a very simplified example with a self-created schema in CAP.
Theoretically, this should of course also be possible in ABAP using the SAP tables as a basis.
In CAP, this is of course also possible if CAP has access to the standard SAP tables.
The aim here is not to show a productive application but only an MVP to show that this is possible.
Below is our simplified “Material BOM” setup. Each Material can have multiple child Materials (via MaterialBOM entries), and each relationship can have additional attributes like quantity, unit of measure, or relationship type (e.g., “Component,” “Raw,” or “Packaging”).
We will walk through the overall CAP schema, a custom read API to generate the network data, and finally a Fiori Elements application that displays the BOM as a network graph.
You find the complete project in my GitHub Repository: https://github.com/marianfoo/cap-materialbom-graph
2. Create CAP Schema
Below is the full schema definition in schema.cds. It includes the entities: Material, MaterialBOM, RelationshipType, and the pseudo-entity GraphNetwork used to deliver JSON node/line structures. I’ve added additional comments to help clarify each part:
namespace de.marianzeis.materialbomgraph;
using { cuid, managed } from ‘@sap/cds/common’;
/**
* The ‘Material’ entity represents a product or raw material that can be part of a BOM.
* It includes references to child and parent BOM compositions, as well as an embedded
* GraphNetwork composition for the visualization.
*/
@odata.draft.enabled
@assert.unique: { materialId: [materialId] }
entity Material : cuid, managed {
materialId: String(20) @mandatory; // An internal ID (e.g., “KIT-001”)
materialDescription: String(100); // A text field describing the material
materialType: String(15); // Defines if it’s “KIT”, “COMPONENT”, etc.
// Composition to build or retrieve the GraphNetwork for this Material
graph : Composition of one GraphNetwork on graph.root = $self;
// BOM references:
// The parentMaterial or childMaterial in ‘MaterialBOM’ associations
// form the link in the hierarchy.
bomParent: Composition of many MaterialBOM on bomParent.parentMaterial = $self;
bomChild: Composition of many MaterialBOM on bomChild.childMaterial = $self;
}
/**
* ‘MaterialBOM’ is a bridging entity to define parent-child relationships.
* It stores the quantity, unit of measure, and an associated relationship type.
*/
entity MaterialBOM : cuid, managed {
parentMaterial: Association to Material; // e.g., higher-level component
childMaterial: Association to Material; // e.g., sub-component, raw, or packaging
quantity: Decimal(13,3); // e.g., “2.5 childMaterial units”
uom: String(10); // e.g., “KG”, “L”, “PC”
relationshipType: Association to one RelationshipType;
}
/**
* ‘RelationshipType’ might define how the child is used: ‘Component’, ‘Packaging’, ‘Raw’, etc.
*/
@assert.unique: { code: [code] }
@odata.draft.enabled
entity RelationshipType : cuid, managed {
code: String(10) @mandatory;
name: String(50);
}
/**
* The ‘GraphNetwork’ entity returns “nodes” and “lines” as JSON strings
* so we can display a network diagram in the front-end (sap.suite.ui.commons.networkgraph).
* ‘root’ links back to the parent Material for clarity. ‘groups’ is optional
* for advanced grouping or clustering of nodes.
*/
entity GraphNetwork {
key ID: UUID;
root: Association to Material;
nodes: LargeString;
lines: LargeString;
groups: LargeString;
}
3. Create CAP GraphNetwork READ API
Next, we need to define a service that exposes our entities (Material, MaterialBOM, RelationshipType, and GraphNetwork). In our service.cds, we create a service named MaterialGraph:
using {de.marianzeis.materialbomgraph as MaterialSchema} from ‘../db/schema.cds’;
service MaterialGraph {
entity Material as projection on MaterialSchema.Material;
entity MaterialBOM as projection on MaterialSchema.MaterialBOM;
entity RelationshipType as projection on MaterialSchema.RelationshipType;
entity GraphNetwork as
projection on MaterialSchema.GraphNetwork {
*,
root : redirected to Material
};
}
This makes all four entities available as OData V4 endpoints. The GraphNetwork entity is “pseudo” in that it doesn’t hold data in the database itself; it is populated at runtime in the service implementation.
We don´t use “@CDS.persistence.skip”, as this would lead to errors on the Fiori elements object page, when going into edit mode.
The main logic for generating the network data (JSON nodes and lines) is found in service.js.
I return JSON because the structure here is more flexible for the graph in the frontend. This then requires a little extra code in the frontend with a little more flexibility.
It performs a recursive traversal of BOM structures, starting from a given “root” material. We use sets and maps to avoid loops and to accumulate node/edge data. Below is the expanded implementation with extra comments:
const cds = require(‘@sap/cds’);
module.exports = cds.service.impl(function () {
const { Material, MaterialBOM, RelationshipType } = this.entities;
/**
* Overwrite READ for the pseudo-entity “GraphNetwork”.
* We’ll accept a Material’s ID as the “root” in the request path, for example:
* GET /GraphNetwork(ID=<material-uuid>, IsActiveEntity=true)
*/
this.on(‘READ’, ‘GraphNetwork’, async (req) => {
// 1) Extract the root Material’s ID from the request parameters.
const rootMaterialID = req.params[0]?.ID;
if (!rootMaterialID) {
// If no root ID is found, return an empty array,
// meaning we have no graph to build.
return [];
}
// 2) We’ll perform a Depth-First Search (DFS) or Breadth-First Search (BFS)
// to gather child materials and build a graph of nodes and edges.
// visited: keeps track of visited nodes to prevent infinite loops.
// nodesMap: a Map storing node definitions for each material.
// linesArr: an array of edges connecting parent -> child materials.
const visited = new Set();
const nodesMap = new Map();
const linesArr = [];
// ‘traverseBOM’ recursively explores child materials.
// ‘path’ helps ensure unique node keys when the same child is reached via different paths.
async function traverseBOM(parentID, path = ”) {
// Each visitedKey is a combination of the current parentID + path.
const visitedKey = `${parentID}_${path}`;
if (visited.has(visitedKey)) return;
visited.add(visitedKey);
// Fetch the parent material record, containing IDs, descriptions, etc.
const parentMat = await SELECT.one.from(Material).where({ ID: parentID });
if (!parentMat) return;
// Create a unique node key. For example, “KIT-001_” or “RAW-001_KIT-001_”…
const nodeKey = `${parentMat.materialId}_${path}`;
// Insert/update node details in the nodesMap.
// This node object is read by the sap.suite.ui.commons.networkgraph component.
nodesMap.set(nodeKey, {
key: nodeKey,
title: parentMat.materialDescription,
icon: parentMat.materialType, // e.g., “KIT”, “COMPONENT”, “RAW_MATERIAL”
status: parentMat.materialType, // used for color-coding
attributes: [
{ label: ‘Material ID’, value: parentMat.materialId },
{ label: ‘Material Type’, value: parentMat.materialType }
],
customData: {
materialId: parentMat.materialId,
materialType: parentMat.materialType,
material_ID: parentMat.ID
}
});
// Look up child BOMs for this parent (meaning: which materials does the parent reference?)
const childBOMs = await SELECT.from(MaterialBOM).where({ parentMaterial_ID: parentID });
for (const row of childBOMs) {
// For each child entry, fetch the child’s record from ‘Material’
const childMat = await SELECT.one.from(Material).where({ ID: row.childMaterial_ID });
if (childMat) {
// Build the node key for the child. We append the parent’s materialId to ‘path’
// to ensure uniqueness if a child is reached multiple times from different branches.
const childNodeKey = `${childMat.materialId}_${path}${parentMat.materialId}_`;
// Retrieve the relationship type, e.g., “Component”, “Filling Material”, etc.
const relationshipType = await SELECT.one.from(RelationshipType).where({ ID: row.relationshipType_ID });
// Create a “line” (edge) from parent -> child in the graph.
// ‘title’ is displayed near the line, and ‘description’ can be additional info (like quantity).
linesArr.push({
from: nodeKey,
to: childNodeKey,
description: `Quantity: ${row.quantity} ${row.uom}`,
title: relationshipType.name
});
// Recursively traverse the child’s BOM.
await traverseBOM(childMat.ID, `${path}${parentMat.materialId}_`);
}
}
}
// Start traversal from the root material the user requested.
await traverseBOM(rootMaterialID);
// 3) Return exactly one record for GraphNetwork with the computed nodes and lines.
// ‘groups’ can be used if we need to group or cluster certain materials.
return [
{
ID: ‘00000000-0000-0000-0000-000000000000’, // a fixed placeholder ID
root_ID: rootMaterialID, // which Material is “root” for this graph
nodes: JSON.stringify(Array.from(nodesMap.values())), // stringified array of node objects
lines: JSON.stringify(linesArr) // stringified array of edges
}
];
});
});
With this, whenever we do a GET request such as:
GET /GraphNetwork(ID=<UUID>,IsActiveEntity=true)
… the CAP service will generate a list of “nodes” and “lines” in JSON for our network graph, enabling the front-end to easily bind it to the sap.suite.ui.commons.networkgraph control.
4. Consume in Fiori Elements App
To visualize the data, I have created an SAP Fiori Elements application using the Application Generator (“Fiori: Open Application Generator” in VS Code or SAP Business Application Studio). My app is TypeScript-based and includes a controller extension with a custom section to embed the Network Graph.
The key point is that from the Object Page, I fetch the GraphNetwork by calling materialID + ‘/graph’ via OData. For example:
http://localhost:4004/odata/v4/material-graph/Material(
ID=88888888-8888-8888-8888-888888888888,
IsActiveEntity=true
)/graph
This is a sample response of a a Material with two child materials:
{
“@odata.context”: “$metadata#GraphNetwork/$entity”,
“ID”: “00000000-0000-0000-0000-000000000000”,
“root_ID”: “88888888-8888-8888-8888-888888888888”,
“nodes”: “[{“key”:”BLK-001_”,”title”:”Pre-Mixed Filter Solution”,”icon”:”BULK”,”status”:”BULK”,”attributes”:[{“label”:”Material ID”,”value”:”BLK-001″},{“label”:”Material Type”,”value”:”BULK”}],”customData”:{“materialId”:”BLK-001″,”materialType”:”BULK”,”material_ID”:”88888888-8888-8888-8888-888888888888″}},{“key”:”ESS-001_BLK-001_”,”title”:”Mint Fresh Essence”,”icon”:”ESSENCE”,”status”:”ESSENCE”,”attributes”:[{“label”:”Material ID”,”value”:”ESS-001″},{“label”:”Material Type”,”value”:”ESSENCE”}],”customData”:{“materialId”:”ESS-001″,”materialType”:”ESSENCE”,”material_ID”:”55555555-5555-5555-5555-555555555555″}},{“key”:”WAT-001_BLK-001_”,”title”:”Purified Water Base”,”icon”:”WATER”,”status”:”WATER”,”attributes”:[{“label”:”Material ID”,”value”:”WAT-001″},{“label”:”Material Type”,”value”:”WATER”}],”customData”:{“materialId”:”WAT-001″,”materialType”:”WATER”,”material_ID”:”66666666-6666-6666-6666-666666666666″}}]”,
“lines”: “[{“from”:”BLK-001_”,”to”:”ESS-001_BLK-001_”,”description”:”Quantity: 0.05 L”,”title”:”Filling Material”},{“from”:”BLK-001_”,”to”:”WAT-001_BLK-001_”,”description”:”Quantity: 0.95 L”,”title”:”Filling Material”}]”,
“HasActiveEntity”: false,
“HasDraftEntity”: false,
“IsActiveEntity”: true
}
Because the GraphNetwork is associated to the Material entity, this navigation property automatically invokes the on(‘READ’,’GraphNetwork’) logic we defined, giving us back our “nodes” and “lines” in JSON.
Below is the relevant section of my manifest.json that sets up the controller extension and configures a custom section for displaying the graph. I have omitted other parts for brevity:
{
“sap.ui5”: {
“extends”: {
“extensions”: {
“sap.ui.controllerExtensions”: {
“sap.fe.templates.ObjectPage.ObjectPageController”: {
“controllerNames”: [
“de.marianzeis.material.ext.controller.ObjectPage”
]
}
}
}
},
“routing”: {
“targets”: {
“MaterialObjectPage”: {
“options”: {
“settings”: {
“content”: {
“body”: {
“sections”: {
“myCustomSection”: {
“template”: “de.marianzeis.material.custom.fragment.graph”,
“title”: “Graph”,
“position”: {
“placement”: “After”,
“anchor”: “MaterialBOM”
}
}
}
}
}
}
}
}
}
}
}
}
This creates a new custom section called “Graph” right after the standard BOM section in the Object Page. Inside that custom section, we will load our Network Graph from the graph.fragment.xml file.
5. Explain the Fragment and Controller Extension
In graph.fragment.xml, we define the sap.suite.ui.commons.networkgraph.Graph control, binding its nodes, lines, and optional groups to a graphModel JSON model. We also set up statuses for different material types, which color-code the nodes in the diagram. Below is an extended example with inline comments:
<core:FragmentDefinition xmlns:m=”sap.m”
xmlns:core=”sap.ui.core”
xmlns=”sap.suite.ui.commons.networkgraph”
xmlns:layout=”sap.suite.ui.commons.networkgraph.layout”>
<!–
The <Graph> control is the container for our network visualization.
It binds to “graphModel” (nodes, lines, groups) that we populate in the controller.
–>
<Graph
id=”graph”
busy=”{graphSettings>/busy}”
nodes=”{graphModel>/nodes}”
lines=”{graphModel>/lines}”
groups=”{graphModel>/groups}”
orientation=”LeftRight”
height=”800px”
enableWheelZoom=”true”>
<!–
Layout algorithm influences how nodes are placed.
‘LayeredLayout’ with “LinearSegments” tries to lay them out in hierarchical layers.
–>
<layoutAlgorithm>
<layout:LayeredLayout
mergeEdges=”true”
nodeSpacing=”75″
nodePlacement=”LinearSegments” />
</layoutAlgorithm>
<!–
‘statuses’ define color-coding, keyed by the “status” property
each node will have (e.g., “KIT”, “COMPONENT”, “RAW_MATERIAL”).
–>
<statuses>
<Status key=”PACKAGING” backgroundColor=”#bac8d3″/>
<Status key=”KIT” backgroundColor=”#d5e8d4″/>
<Status key=”COMPONENT” backgroundColor=”#5c9cd2″/>
<Status key=”RAW_MATERIAL” backgroundColor=”#f9dad5″/>
<Status key=”ESSENCE” backgroundColor=”#b1ddf0″/>
<Status key=”WATER” backgroundColor=”#00b7c7″/>
<Status key=”BULK” backgroundColor=”#fad7ac”/>
<Status key=”SALT_FILL” backgroundColor=”#e3c800″/>
<Status key=”SALT_ASSEMBLY” backgroundColor=”#DAA520″/>
<Status key=”LIQUID_FILL” backgroundColor=”#dae8fc”/>
</statuses>
<!–
‘nodes’ define the actual boxes or shapes for each material in the graph.
We read “key”, “title”, “icon”, “status” from graphModel, along with “attributes”.
–>
<nodes>
<Node
shape=”Box”
key=”{graphModel>key}”
title=”{graphModel>title}”
icon=”{graphModel>icon}”
status=”{graphModel>status}”
showActionLinksButton=”true”
attributes=”{ path:’graphModel>attributes’ }”>
<!–
Each node can have multiple attributes, displayed on the UI as label:value.
–>
<attributes>
<ElementAttribute
label=”{graphModel>label}”
value=”{graphModel>value}” />
</attributes>
<!–
customData allows us to store the actual Material ID
so we can navigate to the correct ObjectPage if the user clicks “Go to Material”.
–>
<customData>
<core:CustomData key=”materialId” value=”{graphModel>customData/materialId}” />
<core:CustomData key=”materialType” value=”{graphModel>customData/materialType}” />
<core:CustomData key=”material_ID” value=”{graphModel>customData/material_ID}” />
</customData>
<!–
actionButtons can be placed on each node for custom interactions.
Here, we provide a button to jump to the Material’s details.
–>
<actionButtons>
<ActionButton
icon=”sap-icon://chain-link”
title=”Go to Material”
press=”.extension.de.marianzeis.material.controller.ObjectPage.onGoToMaterial”/>
</actionButtons>
</Node>
</nodes>
<!–
‘lines’ define connections (edges) between the nodes.
‘from’ and ‘to’ indicate node keys, while ‘title’ and ‘description’
can carry text like “Component”, “Quantity: 0.5 KG”, etc.
–>
<lines>
<Line
from=”{graphModel>from}”
to=”{graphModel>to}”
description=”{graphModel>description}”
title=”{graphModel>title}” />
</lines>
<!–
Optionally group sets of nodes visually. Not used in this basic example,
but we can define group keys in the same JSON if needed.
–>
<groups>
<Group
key=”{graphModel>key}”
title=”{graphModel>title}” />
</groups>
</Graph>
</core:FragmentDefinition>
In the controller extension ObjectPage.controller.ts, we handle loading of the graph data after the user navigates to the Object Page, and also implement onGoToMaterial so the user can click a node’s action button to navigate to that material’s object page.
import ControllerExtension from ‘sap/ui/core/mvc/ControllerExtension’;
import ExtensionAPI from ‘sap/fe/templates/ObjectPage/ExtensionAPI’;
import JSONModel from ‘sap/ui/model/json/JSONModel’;
import { ActionButton$PressEvent } from ‘sap/suite/ui/commons/networkgraph/ActionButton’;
// …
export default class ObjectPage extends ControllerExtension<ExtensionAPI> {
static overrides = {
/**
* Called when the user navigates to the ObjectPage. We then load the graph
* for the currently bound Material context.
*/
routing: {
onAfterBinding: async function (bindingContext) {
await this.loadGraph(bindingContext);
}
},
/**
* If using draft mode, after saving the object we can reload the graph to
* reflect changes to BOM relationships or material type, etc.
*/
editFlow: {
onAfterSave: async function (mParameters) {
await this.loadGraph(mParameters.context);
}
}
};
/**
* Action button event from each node: “Go to Material”.
* We retrieve the customData to find the correct Material ID,
* then navigate to that object’s detail page.
*/
onGoToMaterial(this: ObjectPage, event: ActionButton$PressEvent): void {
// Identify the node that triggered the button press
const graph = this.getView().byId(
‘de.marianzeis.material::MaterialObjectPage–fe::CustomSubSection::myCustomSection–graph’
);
const nodes = graph.getNodes();
const buttonNodeId = event.getParameter(‘id’);
// Attempt to match node ID with the trailing numeric part
const graphNumber = buttonNodeId.split(‘-‘).pop();
const node = nodes.find((node) => node.getId().endsWith(`-${graphNumber}`));
if (node) {
// Extract customData to find the underlying Material’s technical ID
const customData = node.getCustomData();
const material_ID = customData
.find((d) => d.getKey() === ‘material_ID’)
.getValue();
// Build the binding path to that Material
const model = this.base.getView().getModel();
const bindingPath = `/Material(ID=${material_ID},IsActiveEntity=true)`;
const context = model.bindContext(bindingPath).getBoundContext();
// Now navigate to the Material object page
this.base.routing.navigate(context, { preserveHistory: true });
}
}
/**
* Loads the network graph data for the Material in the current context.
* This requests ‘sPath + “/graph”‘ from the OData model, parses the returned
* JSON strings for nodes & lines, and sets the ‘graphModel’.
*/
private async loadGraph(context) {
const view = this.getView();
// Initialize a small “graphSettings” model to handle busy indicators, etc.
if (!view.getModel(‘graphSettings’)) {
const graphSettings = new JSONModel({ busy: false, busyIndicatorDelay: 0 });
view.setModel(graphSettings, ‘graphSettings’);
}
view.getModel(‘graphSettings’).setProperty(‘/busy’, true);
// Build path to the ‘graph’ navigation property
let sPath = context.getPath();
// always load the data from the active entity
sPath = sPath.replace(/IsActiveEntity=false/, ‘IsActiveEntity=true’);
const oModel = view.getModel();
try {
// We bind to sPath + “/graph”. Because of our CAP service,
// this will return an entity with ‘nodes’ and ‘lines’ as JSON strings.
const oData = await oModel.bindContext(sPath + ‘/graph’);
await oData.requestObject(); // triggers the read
const data = oData.getBoundContext().getObject();
// Parse out nodes, lines, groups from the returned pseudo-entity
const nodes = data.nodes ? JSON.parse(data.nodes) : [];
const lines = data.lines ? JSON.parse(data.lines) : [];
const groups = data.groups ? JSON.parse(data.groups) : [];
const oJSONModel = new JSONModel({ nodes, lines, groups });
// Increase size limit if the BOM is large
oJSONModel.setSizeLimit(1000000);
view.setModel(oJSONModel, ‘graphModel’);
} catch (error) {
console.error(‘Error loading graph data:’, error);
} finally {
// Turn off the busy indicator
view.getModel(‘graphSettings’).setProperty(‘/busy’, false);
}
}
}
As you can see, we fetch the network data (nodes/lines) by calling the “/graph” navigation property, then set it as a graphModel on the view. The Graph control in graph.fragment.xml binds to that model and automatically draws the diagram.
6. Show Screenshots of the Graph
Below are some sample screenshots. For example, if we open KIT-001 (Premium Water Filter Kit), the BOM includes packaging, components, raw materials, etc. The different colors correspond to distinct materialType categories, and the lines show the quantity plus the relationshipType (e.g., “Component,” “Filling Material,” “Packaging”).
Furthermore, more information can be added to each connection. Here we have added the relationship type and the weight.
As you can see, each node is clickable with an “Action Button” to navigate to the specific Material’s object page. This makes BOM exploration more intuitive and user-friendly.
Conclusion
We have successfully built a custom BOM Network Graph with CAP, generating the relationships dynamically from the MaterialBOM data and delivering it to a Fiori Elements Object Page. The sap.suite.ui.commons.networkgraph control offers multiple layout algorithms, status colors, custom attributes, and navigation actions, resulting in an interactive hierarchy view for end users.
Feel free to adapt these examples for your own projects, add grouping logic, or expand the relationships into multi-level BOMs. The approach remains the same: store the BOM, recursively gather the structure, and map it into the Network Graph “nodes” and “lines” format.
Further References
SAP CAP DocumentationSAPUI5 / Fiori Elements Documentationsap.suite.ui.commons.networkgraph API
Thank you for reading, and happy coding!
In this blog post, I want to demonstrate how to create a simple Bill of Materials (BOM) relationship visualization using SAP Cloud Application Programming Model (CAP) and consume it in a SAP Fiori Elements application. We will display the BOM structure as a Network Graph, making it easy for end users to navigate parent/child relationships among materials.1. Reason and Basic SetupWhen handling production or manufacturing processes, a Bill of Materials (BOM) is one of the fundamental structures that define how materials are composed or assembled. By visualizing the BOM in a graph, it is much easier to understand complex hierarchies.For this example, I am using an SAP CAP project that manages Materials and their BOM structures, along with a pseudo-entity GraphNetwork that dynamically assembles the data for a SAP sap.suite.ui.commons.networkgraph control to consume.You can see more examples of the NetworkGraph in the UI5 Documentation.The example is a very simplified example with a self-created schema in CAP.Theoretically, this should of course also be possible in ABAP using the SAP tables as a basis.In CAP, this is of course also possible if CAP has access to the standard SAP tables.The aim here is not to show a productive application but only an MVP to show that this is possible.Below is our simplified “Material BOM” setup. Each Material can have multiple child Materials (via MaterialBOM entries), and each relationship can have additional attributes like quantity, unit of measure, or relationship type (e.g., “Component,” “Raw,” or “Packaging”).We will walk through the overall CAP schema, a custom read API to generate the network data, and finally a Fiori Elements application that displays the BOM as a network graph.You find the complete project in my GitHub Repository: https://github.com/marianfoo/cap-materialbom-graph 2. Create CAP SchemaBelow is the full schema definition in schema.cds. It includes the entities: Material, MaterialBOM, RelationshipType, and the pseudo-entity GraphNetwork used to deliver JSON node/line structures. I’ve added additional comments to help clarify each part: namespace de.marianzeis.materialbomgraph;
using { cuid, managed } from ‘@sap/cds/common’;
/**
* The ‘Material’ entity represents a product or raw material that can be part of a BOM.
* It includes references to child and parent BOM compositions, as well as an embedded
* GraphNetwork composition for the visualization.
*/
@odata.draft.enabled
@assert.unique: { materialId: [materialId] }
entity Material : cuid, managed {
materialId: String(20) @mandatory; // An internal ID (e.g., “KIT-001”)
materialDescription: String(100); // A text field describing the material
materialType: String(15); // Defines if it’s “KIT”, “COMPONENT”, etc.
// Composition to build or retrieve the GraphNetwork for this Material
graph : Composition of one GraphNetwork on graph.root = $self;
// BOM references:
// The parentMaterial or childMaterial in ‘MaterialBOM’ associations
// form the link in the hierarchy.
bomParent: Composition of many MaterialBOM on bomParent.parentMaterial = $self;
bomChild: Composition of many MaterialBOM on bomChild.childMaterial = $self;
}
/**
* ‘MaterialBOM’ is a bridging entity to define parent-child relationships.
* It stores the quantity, unit of measure, and an associated relationship type.
*/
entity MaterialBOM : cuid, managed {
parentMaterial: Association to Material; // e.g., higher-level component
childMaterial: Association to Material; // e.g., sub-component, raw, or packaging
quantity: Decimal(13,3); // e.g., “2.5 childMaterial units”
uom: String(10); // e.g., “KG”, “L”, “PC”
relationshipType: Association to one RelationshipType;
}
/**
* ‘RelationshipType’ might define how the child is used: ‘Component’, ‘Packaging’, ‘Raw’, etc.
*/
@assert.unique: { code: [code] }
@odata.draft.enabled
entity RelationshipType : cuid, managed {
code: String(10) @mandatory;
name: String(50);
}
/**
* The ‘GraphNetwork’ entity returns “nodes” and “lines” as JSON strings
* so we can display a network diagram in the front-end (sap.suite.ui.commons.networkgraph).
* ‘root’ links back to the parent Material for clarity. ‘groups’ is optional
* for advanced grouping or clustering of nodes.
*/
entity GraphNetwork {
key ID: UUID;
root: Association to Material;
nodes: LargeString;
lines: LargeString;
groups: LargeString;
} 3. Create CAP GraphNetwork READ APINext, we need to define a service that exposes our entities (Material, MaterialBOM, RelationshipType, and GraphNetwork). In our service.cds, we create a service named MaterialGraph: using {de.marianzeis.materialbomgraph as MaterialSchema} from ‘../db/schema.cds’;
service MaterialGraph {
entity Material as projection on MaterialSchema.Material;
entity MaterialBOM as projection on MaterialSchema.MaterialBOM;
entity RelationshipType as projection on MaterialSchema.RelationshipType;
entity GraphNetwork as
projection on MaterialSchema.GraphNetwork {
*,
root : redirected to Material
};
} This makes all four entities available as OData V4 endpoints. The GraphNetwork entity is “pseudo” in that it doesn’t hold data in the database itself; it is populated at runtime in the service implementation.We don´t use “@CDS.persistence.skip”, as this would lead to errors on the Fiori elements object page, when going into edit mode.The main logic for generating the network data (JSON nodes and lines) is found in service.js.I return JSON because the structure here is more flexible for the graph in the frontend. This then requires a little extra code in the frontend with a little more flexibility.It performs a recursive traversal of BOM structures, starting from a given “root” material. We use sets and maps to avoid loops and to accumulate node/edge data. Below is the expanded implementation with extra comments: const cds = require(‘@sap/cds’);
module.exports = cds.service.impl(function () {
const { Material, MaterialBOM, RelationshipType } = this.entities;
/**
* Overwrite READ for the pseudo-entity “GraphNetwork”.
* We’ll accept a Material’s ID as the “root” in the request path, for example:
* GET /GraphNetwork(ID=<material-uuid>, IsActiveEntity=true)
*/
this.on(‘READ’, ‘GraphNetwork’, async (req) => {
// 1) Extract the root Material’s ID from the request parameters.
const rootMaterialID = req.params[0]?.ID;
if (!rootMaterialID) {
// If no root ID is found, return an empty array,
// meaning we have no graph to build.
return [];
}
// 2) We’ll perform a Depth-First Search (DFS) or Breadth-First Search (BFS)
// to gather child materials and build a graph of nodes and edges.
// visited: keeps track of visited nodes to prevent infinite loops.
// nodesMap: a Map storing node definitions for each material.
// linesArr: an array of edges connecting parent -> child materials.
const visited = new Set();
const nodesMap = new Map();
const linesArr = [];
// ‘traverseBOM’ recursively explores child materials.
// ‘path’ helps ensure unique node keys when the same child is reached via different paths.
async function traverseBOM(parentID, path = ”) {
// Each visitedKey is a combination of the current parentID + path.
const visitedKey = `${parentID}_${path}`;
if (visited.has(visitedKey)) return;
visited.add(visitedKey);
// Fetch the parent material record, containing IDs, descriptions, etc.
const parentMat = await SELECT.one.from(Material).where({ ID: parentID });
if (!parentMat) return;
// Create a unique node key. For example, “KIT-001_” or “RAW-001_KIT-001_”…
const nodeKey = `${parentMat.materialId}_${path}`;
// Insert/update node details in the nodesMap.
// This node object is read by the sap.suite.ui.commons.networkgraph component.
nodesMap.set(nodeKey, {
key: nodeKey,
title: parentMat.materialDescription,
icon: parentMat.materialType, // e.g., “KIT”, “COMPONENT”, “RAW_MATERIAL”
status: parentMat.materialType, // used for color-coding
attributes: [
{ label: ‘Material ID’, value: parentMat.materialId },
{ label: ‘Material Type’, value: parentMat.materialType }
],
customData: {
materialId: parentMat.materialId,
materialType: parentMat.materialType,
material_ID: parentMat.ID
}
});
// Look up child BOMs for this parent (meaning: which materials does the parent reference?)
const childBOMs = await SELECT.from(MaterialBOM).where({ parentMaterial_ID: parentID });
for (const row of childBOMs) {
// For each child entry, fetch the child’s record from ‘Material’
const childMat = await SELECT.one.from(Material).where({ ID: row.childMaterial_ID });
if (childMat) {
// Build the node key for the child. We append the parent’s materialId to ‘path’
// to ensure uniqueness if a child is reached multiple times from different branches.
const childNodeKey = `${childMat.materialId}_${path}${parentMat.materialId}_`;
// Retrieve the relationship type, e.g., “Component”, “Filling Material”, etc.
const relationshipType = await SELECT.one.from(RelationshipType).where({ ID: row.relationshipType_ID });
// Create a “line” (edge) from parent -> child in the graph.
// ‘title’ is displayed near the line, and ‘description’ can be additional info (like quantity).
linesArr.push({
from: nodeKey,
to: childNodeKey,
description: `Quantity: ${row.quantity} ${row.uom}`,
title: relationshipType.name
});
// Recursively traverse the child’s BOM.
await traverseBOM(childMat.ID, `${path}${parentMat.materialId}_`);
}
}
}
// Start traversal from the root material the user requested.
await traverseBOM(rootMaterialID);
// 3) Return exactly one record for GraphNetwork with the computed nodes and lines.
// ‘groups’ can be used if we need to group or cluster certain materials.
return [
{
ID: ‘00000000-0000-0000-0000-000000000000’, // a fixed placeholder ID
root_ID: rootMaterialID, // which Material is “root” for this graph
nodes: JSON.stringify(Array.from(nodesMap.values())), // stringified array of node objects
lines: JSON.stringify(linesArr) // stringified array of edges
}
];
});
}); With this, whenever we do a GET request such as: GET /GraphNetwork(ID=<UUID>,IsActiveEntity=true) … the CAP service will generate a list of “nodes” and “lines” in JSON for our network graph, enabling the front-end to easily bind it to the sap.suite.ui.commons.networkgraph control.4. Consume in Fiori Elements AppTo visualize the data, I have created an SAP Fiori Elements application using the Application Generator (“Fiori: Open Application Generator” in VS Code or SAP Business Application Studio). My app is TypeScript-based and includes a controller extension with a custom section to embed the Network Graph.The key point is that from the Object Page, I fetch the GraphNetwork by calling materialID + ‘/graph’ via OData. For example: http://localhost:4004/odata/v4/material-graph/Material(
ID=88888888-8888-8888-8888-888888888888,
IsActiveEntity=true
)/graph This is a sample response of a a Material with two child materials: {
“@odata.context”: “$metadata#GraphNetwork/$entity”,
“ID”: “00000000-0000-0000-0000-000000000000”,
“root_ID”: “88888888-8888-8888-8888-888888888888”,
“nodes”: “[{“key”:”BLK-001_”,”title”:”Pre-Mixed Filter Solution”,”icon”:”BULK”,”status”:”BULK”,”attributes”:[{“label”:”Material ID”,”value”:”BLK-001″},{“label”:”Material Type”,”value”:”BULK”}],”customData”:{“materialId”:”BLK-001″,”materialType”:”BULK”,”material_ID”:”88888888-8888-8888-8888-888888888888″}},{“key”:”ESS-001_BLK-001_”,”title”:”Mint Fresh Essence”,”icon”:”ESSENCE”,”status”:”ESSENCE”,”attributes”:[{“label”:”Material ID”,”value”:”ESS-001″},{“label”:”Material Type”,”value”:”ESSENCE”}],”customData”:{“materialId”:”ESS-001″,”materialType”:”ESSENCE”,”material_ID”:”55555555-5555-5555-5555-555555555555″}},{“key”:”WAT-001_BLK-001_”,”title”:”Purified Water Base”,”icon”:”WATER”,”status”:”WATER”,”attributes”:[{“label”:”Material ID”,”value”:”WAT-001″},{“label”:”Material Type”,”value”:”WATER”}],”customData”:{“materialId”:”WAT-001″,”materialType”:”WATER”,”material_ID”:”66666666-6666-6666-6666-666666666666″}}]”,
“lines”: “[{“from”:”BLK-001_”,”to”:”ESS-001_BLK-001_”,”description”:”Quantity: 0.05 L”,”title”:”Filling Material”},{“from”:”BLK-001_”,”to”:”WAT-001_BLK-001_”,”description”:”Quantity: 0.95 L”,”title”:”Filling Material”}]”,
“HasActiveEntity”: false,
“HasDraftEntity”: false,
“IsActiveEntity”: true
} Because the GraphNetwork is associated to the Material entity, this navigation property automatically invokes the on(‘READ’,’GraphNetwork’) logic we defined, giving us back our “nodes” and “lines” in JSON.Below is the relevant section of my manifest.json that sets up the controller extension and configures a custom section for displaying the graph. I have omitted other parts for brevity: {
“sap.ui5”: {
“extends”: {
“extensions”: {
“sap.ui.controllerExtensions”: {
“sap.fe.templates.ObjectPage.ObjectPageController”: {
“controllerNames”: [
“de.marianzeis.material.ext.controller.ObjectPage”
]
}
}
}
},
“routing”: {
“targets”: {
“MaterialObjectPage”: {
“options”: {
“settings”: {
“content”: {
“body”: {
“sections”: {
“myCustomSection”: {
“template”: “de.marianzeis.material.custom.fragment.graph”,
“title”: “Graph”,
“position”: {
“placement”: “After”,
“anchor”: “MaterialBOM”
}
}
}
}
}
}
}
}
}
}
}
} This creates a new custom section called “Graph” right after the standard BOM section in the Object Page. Inside that custom section, we will load our Network Graph from the graph.fragment.xml file.5. Explain the Fragment and Controller ExtensionIn graph.fragment.xml, we define the sap.suite.ui.commons.networkgraph.Graph control, binding its nodes, lines, and optional groups to a graphModel JSON model. We also set up statuses for different material types, which color-code the nodes in the diagram. Below is an extended example with inline comments: <core:FragmentDefinition xmlns:m=”sap.m”
xmlns:core=”sap.ui.core”
xmlns=”sap.suite.ui.commons.networkgraph”
xmlns:layout=”sap.suite.ui.commons.networkgraph.layout”>
<!–
The <Graph> control is the container for our network visualization.
It binds to “graphModel” (nodes, lines, groups) that we populate in the controller.
–>
<Graph
id=”graph”
busy=”{graphSettings>/busy}”
nodes=”{graphModel>/nodes}”
lines=”{graphModel>/lines}”
groups=”{graphModel>/groups}”
orientation=”LeftRight”
height=”800px”
enableWheelZoom=”true”>
<!–
Layout algorithm influences how nodes are placed.
‘LayeredLayout’ with “LinearSegments” tries to lay them out in hierarchical layers.
–>
<layoutAlgorithm>
<layout:LayeredLayout
mergeEdges=”true”
nodeSpacing=”75″
nodePlacement=”LinearSegments” />
</layoutAlgorithm>
<!–
‘statuses’ define color-coding, keyed by the “status” property
each node will have (e.g., “KIT”, “COMPONENT”, “RAW_MATERIAL”).
–>
<statuses>
<Status key=”PACKAGING” backgroundColor=”#bac8d3″/>
<Status key=”KIT” backgroundColor=”#d5e8d4″/>
<Status key=”COMPONENT” backgroundColor=”#5c9cd2″/>
<Status key=”RAW_MATERIAL” backgroundColor=”#f9dad5″/>
<Status key=”ESSENCE” backgroundColor=”#b1ddf0″/>
<Status key=”WATER” backgroundColor=”#00b7c7″/>
<Status key=”BULK” backgroundColor=”#fad7ac”/>
<Status key=”SALT_FILL” backgroundColor=”#e3c800″/>
<Status key=”SALT_ASSEMBLY” backgroundColor=”#DAA520″/>
<Status key=”LIQUID_FILL” backgroundColor=”#dae8fc”/>
</statuses>
<!–
‘nodes’ define the actual boxes or shapes for each material in the graph.
We read “key”, “title”, “icon”, “status” from graphModel, along with “attributes”.
–>
<nodes>
<Node
shape=”Box”
key=”{graphModel>key}”
title=”{graphModel>title}”
icon=”{graphModel>icon}”
status=”{graphModel>status}”
showActionLinksButton=”true”
attributes=”{ path:’graphModel>attributes’ }”>
<!–
Each node can have multiple attributes, displayed on the UI as label:value.
–>
<attributes>
<ElementAttribute
label=”{graphModel>label}”
value=”{graphModel>value}” />
</attributes>
<!–
customData allows us to store the actual Material ID
so we can navigate to the correct ObjectPage if the user clicks “Go to Material”.
–>
<customData>
<core:CustomData key=”materialId” value=”{graphModel>customData/materialId}” />
<core:CustomData key=”materialType” value=”{graphModel>customData/materialType}” />
<core:CustomData key=”material_ID” value=”{graphModel>customData/material_ID}” />
</customData>
<!–
actionButtons can be placed on each node for custom interactions.
Here, we provide a button to jump to the Material’s details.
–>
<actionButtons>
<ActionButton
icon=”sap-icon://chain-link”
title=”Go to Material”
press=”.extension.de.marianzeis.material.controller.ObjectPage.onGoToMaterial”/>
</actionButtons>
</Node>
</nodes>
<!–
‘lines’ define connections (edges) between the nodes.
‘from’ and ‘to’ indicate node keys, while ‘title’ and ‘description’
can carry text like “Component”, “Quantity: 0.5 KG”, etc.
–>
<lines>
<Line
from=”{graphModel>from}”
to=”{graphModel>to}”
description=”{graphModel>description}”
title=”{graphModel>title}” />
</lines>
<!–
Optionally group sets of nodes visually. Not used in this basic example,
but we can define group keys in the same JSON if needed.
–>
<groups>
<Group
key=”{graphModel>key}”
title=”{graphModel>title}” />
</groups>
</Graph>
</core:FragmentDefinition> In the controller extension ObjectPage.controller.ts, we handle loading of the graph data after the user navigates to the Object Page, and also implement onGoToMaterial so the user can click a node’s action button to navigate to that material’s object page. import ControllerExtension from ‘sap/ui/core/mvc/ControllerExtension’;
import ExtensionAPI from ‘sap/fe/templates/ObjectPage/ExtensionAPI’;
import JSONModel from ‘sap/ui/model/json/JSONModel’;
import { ActionButton$PressEvent } from ‘sap/suite/ui/commons/networkgraph/ActionButton’;
// …
export default class ObjectPage extends ControllerExtension<ExtensionAPI> {
static overrides = {
/**
* Called when the user navigates to the ObjectPage. We then load the graph
* for the currently bound Material context.
*/
routing: {
onAfterBinding: async function (bindingContext) {
await this.loadGraph(bindingContext);
}
},
/**
* If using draft mode, after saving the object we can reload the graph to
* reflect changes to BOM relationships or material type, etc.
*/
editFlow: {
onAfterSave: async function (mParameters) {
await this.loadGraph(mParameters.context);
}
}
};
/**
* Action button event from each node: “Go to Material”.
* We retrieve the customData to find the correct Material ID,
* then navigate to that object’s detail page.
*/
onGoToMaterial(this: ObjectPage, event: ActionButton$PressEvent): void {
// Identify the node that triggered the button press
const graph = this.getView().byId(
‘de.marianzeis.material::MaterialObjectPage–fe::CustomSubSection::myCustomSection–graph’
);
const nodes = graph.getNodes();
const buttonNodeId = event.getParameter(‘id’);
// Attempt to match node ID with the trailing numeric part
const graphNumber = buttonNodeId.split(‘-‘).pop();
const node = nodes.find((node) => node.getId().endsWith(`-${graphNumber}`));
if (node) {
// Extract customData to find the underlying Material’s technical ID
const customData = node.getCustomData();
const material_ID = customData
.find((d) => d.getKey() === ‘material_ID’)
.getValue();
// Build the binding path to that Material
const model = this.base.getView().getModel();
const bindingPath = `/Material(ID=${material_ID},IsActiveEntity=true)`;
const context = model.bindContext(bindingPath).getBoundContext();
// Now navigate to the Material object page
this.base.routing.navigate(context, { preserveHistory: true });
}
}
/**
* Loads the network graph data for the Material in the current context.
* This requests ‘sPath + “/graph”‘ from the OData model, parses the returned
* JSON strings for nodes & lines, and sets the ‘graphModel’.
*/
private async loadGraph(context) {
const view = this.getView();
// Initialize a small “graphSettings” model to handle busy indicators, etc.
if (!view.getModel(‘graphSettings’)) {
const graphSettings = new JSONModel({ busy: false, busyIndicatorDelay: 0 });
view.setModel(graphSettings, ‘graphSettings’);
}
view.getModel(‘graphSettings’).setProperty(‘/busy’, true);
// Build path to the ‘graph’ navigation property
let sPath = context.getPath();
// always load the data from the active entity
sPath = sPath.replace(/IsActiveEntity=false/, ‘IsActiveEntity=true’);
const oModel = view.getModel();
try {
// We bind to sPath + “/graph”. Because of our CAP service,
// this will return an entity with ‘nodes’ and ‘lines’ as JSON strings.
const oData = await oModel.bindContext(sPath + ‘/graph’);
await oData.requestObject(); // triggers the read
const data = oData.getBoundContext().getObject();
// Parse out nodes, lines, groups from the returned pseudo-entity
const nodes = data.nodes ? JSON.parse(data.nodes) : [];
const lines = data.lines ? JSON.parse(data.lines) : [];
const groups = data.groups ? JSON.parse(data.groups) : [];
const oJSONModel = new JSONModel({ nodes, lines, groups });
// Increase size limit if the BOM is large
oJSONModel.setSizeLimit(1000000);
view.setModel(oJSONModel, ‘graphModel’);
} catch (error) {
console.error(‘Error loading graph data:’, error);
} finally {
// Turn off the busy indicator
view.getModel(‘graphSettings’).setProperty(‘/busy’, false);
}
}
} As you can see, we fetch the network data (nodes/lines) by calling the “/graph” navigation property, then set it as a graphModel on the view. The Graph control in graph.fragment.xml binds to that model and automatically draws the diagram.6. Show Screenshots of the GraphBelow are some sample screenshots. For example, if we open KIT-001 (Premium Water Filter Kit), the BOM includes packaging, components, raw materials, etc. The different colors correspond to distinct materialType categories, and the lines show the quantity plus the relationshipType (e.g., “Component,” “Filling Material,” “Packaging”).Furthermore, more information can be added to each connection. Here we have added the relationship type and the weight.As you can see, each node is clickable with an “Action Button” to navigate to the specific Material’s object page. This makes BOM exploration more intuitive and user-friendly.ConclusionWe have successfully built a custom BOM Network Graph with CAP, generating the relationships dynamically from the MaterialBOM data and delivering it to a Fiori Elements Object Page. The sap.suite.ui.commons.networkgraph control offers multiple layout algorithms, status colors, custom attributes, and navigation actions, resulting in an interactive hierarchy view for end users.Feel free to adapt these examples for your own projects, add grouping logic, or expand the relationships into multi-level BOMs. The approach remains the same: store the BOM, recursively gather the structure, and map it into the Network Graph “nodes” and “lines” format.Further ReferencesSAP CAP DocumentationSAPUI5 / Fiori Elements Documentationsap.suite.ui.commons.networkgraph APIThank you for reading, and happy coding! Read More Technology Blogs by Members articles
#SAP
#SAPTechnologyblog