VBS: Opportunities Layout — Custom Sales Stage Popup
DISCLAIMER: The views expressed in this story are my own and do not necessarily reflect the views of Oracle.
VBS: Opportunities Layout — Custom Sales Stage Popup
DISCLAIMER: The views expressed in this story are my own and do not necessarily reflect the views of Oracle.
Overview
This article describes how to create and enable a custom sales stage popup on the opportunities details page in Visual Builder Studio. The setup includes the following:
- Fragment: Create sales stage popup
- Opportunities Details Page: Add the fragment
- Opportunities Header Layout: Add an event to open the fragment when sales stage badge is clicked

Setup
Fragment (Sales Stage Popup)
- Create a fragment called “sales-stage-popup”.
- The sales stage popup will show all sales stages and status for each stage (CURRENT, PENDING, COMPLETED).
- Select current sales stage by clicking on sales stage.
- NOTE: The input parameters for the fragment which need to be passed from from the container (opportunities layout) include: optyNumber, currentStgId, and salesStagesList.
- In the fragment, copy all the code shown below including: Fragment JSON, HTML, JS, and Action Chains (JS).
JSON
{
"description": "",
"eventListeners": {
"divClick": {
"chains": [
{
"chain": "updateSalesStageActionChain",
"parameters": {
"stgId": "[[ $current.data.StgId ]]"
}
}
]
},
"divClick2": {
"chains": [
{
"chain": "updateSalesStageActionChain",
"parameters": {
"stgId": "[[ $current.data.StgId ]]"
}
}
]
},
"divClick3": {
"chains": [
{
"chain": "updateSalesStageActionChain",
"parameters": {
"stgId": "[[ $current.data.StgId ]]"
}
}
]
},
"vbEnter": {
"chains": [
{
"chain": "updateSalesStageActionChain",
"parameters": {
"stgId": "{{ $variables.currentStgId }}"
}
}
]
}
},
"events": {
"updateSalesStage": {
"propagationBehavior": "container",
"description": "Emits to container",
"payloadType": {
"OptyNumber": "string",
"StgId": "number"
}
}
},
"imports": {
"components": {
"oj-popup": {
"path": "ojs/ojpopup"
}
}
},
"types": {
"salesStageType": {
"Name": "string",
"StgId": "number",
"StgOrder": "number"
}
},
"variables": {
"currentSalesStage": {
"type": "salesStageType",
"required": false,
"input": "none"
},
"currentStgId": {
"type": "number",
"required": true,
"input": "fromCaller"
},
"dataReady": {
"type": "boolean",
"defaultValue": false
},
"optyNumber": {
"type": "string",
"required": true,
"input": "fromCaller"
},
"salesStagesList": {
"type": "salesStageType[]",
"required": true,
"input": "fromCaller"
},
"salesStageStatusList": {
"required": false,
"type": "any[]",
"input": "none"
},
"salesStageStatusListADP": {
"type": "vb/ArrayDataProvider2",
"defaultValue": {
"data": "{{ $variables.salesStageStatusList }}",
"itemType": "any"
}
}
}
}
HTML
<style>
.sales-stage {
width: 170px;
height: 150px;
font-size: 14px;
text-align: center;
color: #FFF;
margin: 4px;
padding: 4px 4px 4px 16px;
float: left;
position: relative;
background-color: var(--oj-core-neutral-1);
cursor: pointer;
}
.sales-stage:after,
.sales-stage:before {
content: " ";
position: absolute;
top: 0;
right: -16px;
width: 0;
height: 0;
border-top: 76px solid transparent;
border-bottom: 82px solid transparent;
border-left: 17px solid var(--oj-core-neutral-1);
z-index: 2;
}
.sales-stage:before {
right: auto;
left: 0;
border-left: 16px solid #fff;
z-index: 0;
}
.sales-stage:first-child:before,
.sales-stage:last-child:after {
border: none;
}
.sales-stage:first-child {
border-top-left-radius: 24px;
border-bottom-left-radius: 24px;
}
.sales-stage:last-child {
border-top-right-radius: 24px;
border-bottom-right-radius: 24px;
}
.sales-stage.current {
color: #fff;
background-color: var(--oj-core-info-1);
}
.sales-stage.current:after {
border-left: 17px solid var(--oj-core-info-1);
}
.sales-stage.completed {
color: #fff;
background-color: var(--oj-core-success-1);
}
.sales-stage.completed:after {
border-left: 17px solid var(--oj-core-success-1);
}
</style>
<oj-popup id="popup-sales-stage">
<div class="oj-flex oj-sm-align-items-center oj-sm-margin-6x">
<oj-bind-if test="[[ $variables.dataReady ]]">
<oj-bind-for-each data="[[ $variables.salesStageStatusListADP ]]">
<template>
<oj-bind-if test='[[ $current.data.Status === "COMPLETED" ]]'>
<div class="sales-stage completed" on-click="[[$listeners.divClick]]">
<div
class="oj-flex oj-sm-align-items-center oj-sm-justify-content-center oj-sm-padding-2x" style="width: 100%; height: 100%;">
<img :src="[[ $extension.path + $fragment.functions.getSalesStageImageUrl($current.data.StgOrder) ]]" width="100" height="100" style="border-radius: 50%;">
<span class="oj-sm-margin-1x-top"><oj-bind-text value="[[ $current.data.Name ]]"></oj-bind-text></span>
</div>
</div>
</oj-bind-if>
<oj-bind-if test='[[ $current.data.Status === "CURRENT" ]]'>
<div class="sales-stage current" on-click="[[$listeners.divClick2]]">
<div
class="oj-flex oj-sm-align-items-center oj-sm-justify-content-center oj-sm-padding-2x" style="width: 100%; height: 100%;">
<img :src="[[ $extension.path + $fragment.functions.getSalesStageImageUrl($current.data.StgOrder) ]]" width="100" height="100" style="border-radius: 50%;">
<span class="oj-sm-margin-1x-top"><oj-bind-text value="[[ $current.data.Name ]]"></oj-bind-text></span>
</div>
</div>
</oj-bind-if>
<oj-bind-if test='[[ $current.data.Status === "PENDING" ]]'>
<div class="sales-stage" on-click="[[$listeners.divClick3]]">
<div
class="oj-flex oj-sm-align-items-center oj-sm-justify-content-center oj-sm-padding-2x" style="width: 100%; height: 100%;">
<img :src="[[ $extension.path + $fragment.functions.getSalesStageImageUrl($current.data.StgOrder) ]]" width="100" height="100" style="border-radius: 50%;">
<span class="oj-sm-margin-1x-top"><oj-bind-text value="[[ $current.data.Name ]]"></oj-bind-text></span>
</div>
</div>
</oj-bind-if>
</template>
</oj-bind-for-each>
</oj-bind-if>
</div>
</oj-popup>
JS
define([], () => {
'use strict';
class FragmentModule {
}
FragmentModule.prototype.processCurrentSalesStage = function (salesStagesList, stgId) {
let salesStagesListCopy = JSON.parse(JSON.stringify(salesStagesList));
let currentSalesStage;
for (let salesStage of salesStagesListCopy) {
if (salesStage.StgId === stgId) {
currentSalesStage = salesStage;
break;
}
}
return currentSalesStage;
};
FragmentModule.prototype.updateSalesStagesStatusList = function (salesStagesList, currentSalesStage) {
let salesStagesListCopy = JSON.parse(JSON.stringify(salesStagesList));
let currentSalesStageCopy = JSON.parse(JSON.stringify(currentSalesStage));
for (let salesStage of salesStagesListCopy) {
if (salesStage.StgOrder < currentSalesStageCopy.StgOrder) {
salesStage.Status = "COMPLETED";
} else if (salesStage.StgOrder > currentSalesStageCopy.StgOrder) {
salesStage.Status = "PENDING";
} else if (salesStage.StgOrder === currentSalesStageCopy.StgOrder) {
salesStage.Status = "CURRENT";
}
}
return salesStagesListCopy;
};
// NOTE: Add sales stages images in resources directory. You can use a different image for each sales stage based on StgOrder value.
FragmentModule.prototype.getSalesStageImageUrl = function (stgOrder) {
return "resources/images/o_tag.png";
};
return FragmentModule;
});
Action Chain (updateSalesStageActionChain)
define([
'vb/action/actionChain',
'vb/action/actions',
'vb/action/actionUtils',
], (
ActionChain,
Actions,
ActionUtils
) => {
'use strict';
class updateSalesStageActionChain extends ActionChain {
/**
* @param {Object} context
* @param {Object} params
* @param {number} params.stgId
*/
async run(context, { stgId }) {
const { $fragment, $extension, $global, $functions, $variables } = context;
$variables.dataReady = false;
const processCurrentSalesStage = await $functions.processCurrentSalesStage($variables.salesStagesList, stgId);
$fragment.variables.currentSalesStage = processCurrentSalesStage;
await Actions.fireEvent(context, {
event: 'updateSalesStage',
payload: {
OptyNumber: $variables.optyNumber,
StgId: stgId,
},
});
const callFunctionResult = await $fragment.functions.updateSalesStagesStatusList($fragment.variables.salesStagesList, $fragment.variables.currentSalesStage);
$fragment.variables.salesStageStatusList = callFunctionResult;
await Actions.fireDataProviderEvent(context, {
refresh: null,
target: $fragment.variables.salesStageStatusListADP,
});
$fragment.variables.dataReady = true;
}
}
return updateSalesStageActionChain;
});
Opportunities Details Page
- Go to App UIs > Sales > opportunities > opportunities-detail
- Duplicate the default layout and a section (e.g. salesStagePopupTemplate) which will contain the fragment.
- Add the fragment (sales-stage-popup) to the template as shown below.
<template id="salesStagePopupTemplate">
<oj-vb-fragment bridge="[[vbBridge]]" name="sales-stage-popup">
<oj-vb-fragment-param name="optyNumber" value="[[ $base.variables.puid ]]"></oj-vb-fragment-param>
<oj-vb-fragment-param name="currentStgId" value="[[ $variables.currentStgId ]]"></oj-vb-fragment-param>
<oj-vb-fragment-param name="salesStagesList" value="[[ $variables.salesStagesList ]]"></oj-vb-fragment-param>
</oj-vb-fragment>
</template>
- Note the input parameters that need to be passed to the fragment include: optyNumber (puid), currentStgId (number), and salesStagesList (array of salesStageType). For example:
optyNumber = "10001";
currentStgId = 300000000323740;
salesStagesList = [
{
"Name": "01 - Qualification",
"StgId": 300000000323741,
"StgOrder": 1
},
{
"Name": "02 - Discovery",
"StgId": 300000000323740,
"StgOrder": 2
},
{
"Name": "03 - Building Vision",
"StgId": 300000000323742,
"StgOrder": 3
},
{
"Name": "04 - Presentation",
"StgId": 300000000323739,
"StgOrder": 4
},
{
"Name": "05 - Agreement",
"StgId": 300000000323736,
"StgOrder": 5
},
{
"Name": "06 - Negotiation",
"StgId": 300000000323738,
"StgOrder": 6
},
{
"Name": "07 - Closed",
"StgId": 300000000323737,
"StgOrder": 7
}
]
Opportunities Header Layout
- Go to Layout > Sales > Opportunities > Header Layout
- Duplicate the default layout and make sure the SalesStage field is set as the badgeFieldName.
- Add the opportunitiesOnLoadHeaderEvent which triggers an action chain (opportunitiesOnLoadHeaderEventListener) as shown below. This action chain will add a click handler on the SalesStage field to open the sales stage popup. Make sure the ID of popup (e.g. popup-sales-stage) is the same as the ID used in the fragment.
Action Chain (opportunitiesOnLoadHeaderEventListener)
define([
'vb/action/actionChain',
'vb/action/actions',
'vb/action/actionUtils',
], (
ActionChain,
Actions,
ActionUtils
) => {
'use strict';
class opportunitiesOnLoadHeaderEventListener extends ActionChain {
/**
* @param {Object} context
*/
async run(context) {
const { $layout, $base, $extension, $responsive, $user } = context;
const elements = document.getElementsByClassName("cx-badge-item");
if (elements && elements.length > 0) {
let element = elements.item(0);
element.addEventListener('click', function() {
const elementPopup = document.getElementById("popup-sales-stage");
elementPopup.open();
});
}
}
}
return opportunitiesOnLoadHeaderEventListener;
});
Test
- Run the application and you should see the following:

메타데이터
- post_id
- 8c98bc2e564f
- slug
- vbs-opportunities-layout-custom-sales-stage-popup-8c98bc2e564f
- url
- https://medium.com/oracle-jet/vbs-opportunities-layout-custom-sales-stage-popup-8c98bc2e564f
- canonical_url
- https://medium.com/oracle-jet/vbs-opportunities-layout-custom-sales-stage-popup-8c98bc2e564f
- author_url
- https://medium.com/@mzebib
- status
- ok
- fetched_at
- 2026-06-11 15:16:29