Here's a scenario almost every Dynamics 365 CE team runs into eventually. Sales wants the Opportunity Business Process Flow to stop a rep from advancing past the Qualify stage until they've actually entered an estimated revenue and a close date. Sounds like a two-minute fix. Then you go looking for the setting that does it, and there isn't one — the built-in "required fields" on a stage only check that a field has some value, not that it makes sense. This post walks through the actual fix: one JavaScript event, a couple of lines of validation, and a clear error message the rep can act on.
- The event is addOnPreStageChange, on
formContext.data.process - It fires before the stage actually changes — and can cancel the change
- Cancel it with
executionContext.getEventArgs().preventDefault() - It fires whether the user clicks "Next Stage" or your own code calls
moveNext()
Why the built-in stage requirements aren't enough
Each stage in a Business Process Flow (covered in more depth in the Business Process Flows chapter of the Customization course) can mark specific fields as required before the flow lets a user move on. That's genuinely useful, and it's the first thing to reach for. But "required" only means "not empty." It can't check that an estimated revenue is actually greater than zero, that a close date isn't set in the past, or that two fields make sense together — and that's exactly the kind of check a sales manager tends to ask for once they start reviewing real pipeline data.
A Business Rule can't help here either — rules react to a field changing, not to someone clicking "Next Stage." To hook into that specific moment, you need the client API's process object, and a small piece of JavaScript.
The event: addOnPreStageChange
The Business Process Flow control on a form exposes its own mini API, reached through formContext.data.process (the formContext object itself is covered in the Form Context chapter). Two methods matter for this post:
| Method | What it does |
|---|---|
formContext.data.process.getActiveStage() | Returns the stage currently active on the form |
formContext.data.process.addOnPreStageChange(handler) | Registers a function to run right before the active stage changes — and lets that function cancel the change |
The handler you register receives an execution context, exactly like an OnChange or OnSave handler (covered in Common Form Events). Calling executionContext.getEventArgs().preventDefault() inside it stops the stage from changing — the rep stays right where they are, and nothing about the record is saved or lost.
addOnPreStageChange handler runs, checks the Estimated Revenue field, finds it empty, calls preventDefault(), and posts a clear message. The flow stays on Qualify — nothing else happens.
Building it, step by step
- Open (or create) the JavaScript web resource already attached to the Opportunity form.
- Write a validation function for the Qualify stage — plain field checks, nothing exotic.
- Write a small "router" function that checks which stage is active and calls the right validator.
- Register the router against
addOnPreStageChange, inside your form's OnLoad handler. - Publish the web resource and the form.
The code
Here's a complete, working example for the Qualify stage described above:
function onLoad(executionContext) {
var formContext = executionContext.getFormContext();
formContext.data.process.addOnPreStageChange(onPreStageChange);
}
function onPreStageChange(executionContext) {
var formContext = executionContext.getFormContext();
var eventArgs = executionContext.getEventArgs();
var activeStage = formContext.data.process.getActiveStage();
var stageName = activeStage.getName();
if (stageName === "Qualify" && !validateQualifyStage(formContext)) {
eventArgs.preventDefault();
}
}
function validateQualifyStage(formContext) {
var revenue = formContext.getAttribute("estimatedvalue").getValue();
var closeDate = formContext.getAttribute("estimatedclosedate").getValue();
if (!revenue || revenue <= 0) {
formContext.ui.setFormNotification(
"Enter an estimated revenue greater than zero before moving to the next stage.",
"ERROR",
"qualifyValidation"
);
return false;
}
if (!closeDate) {
formContext.ui.setFormNotification(
"Enter an estimated close date before moving to the next stage.",
"ERROR",
"qualifyValidation"
);
return false;
}
formContext.ui.clearFormNotification("qualifyValidation");
return true;
}
Read it in order: onLoad registers the handler once, when the form opens. onPreStageChange runs every time any stage is about to change, checks which stage is currently active, and only bothers validating when it's the one you care about. validateQualifyStage does the actual checking, and returns a plain true/false so the router stays easy to read. Notice the last line clears the notification on success — skip that, and a rep who fixes the problem and clicks Next again will still see yesterday's error message sitting on the form.
Leveling up: one script, every stage
The moment a second stage needs its own validation, hardcoding if (stageName === "Qualify") chains gets messy fast. A small lookup table keeps it manageable no matter how many stages the flow has:
var stageValidators = {
"Qualify": validateQualifyStage,
"Develop": validateDevelopStage,
"Propose": validateProposeStage
};
function onPreStageChange(executionContext) {
var formContext = executionContext.getFormContext();
var eventArgs = executionContext.getEventArgs();
var stageName = formContext.data.process.getActiveStage().getName();
var validate = stageValidators[stageName];
if (validate && !validate(formContext)) {
eventArgs.preventDefault();
}
}
Each stage gets its own small, focused validation function (following the same shape as validateQualifyStage above), and onPreStageChange itself never has to change again when a new stage is added — just add one more entry to the lookup table.
retrieveMultiple call rather than a plain field read. Both fit the same pattern: a function that returns true or false, and lets the router handle the rest.
Two things worth knowing before you ship this
- It fires for movement in both directions.
addOnPreStageChangeruns whether the user is moving forward or clicking back to a previous stage. If you only want to block forward movement, keep track of the currently active stage's position yourself (its index informContext.data.process.getActivePath()) and compare it before deciding whether to enforce the check. - It doesn't block Save. This event only guards the "Next Stage" action. A rep who never touches the BPF control and just clicks Save can still save an Opportunity sitting on an "invalid" Qualify stage. If that matters for your scenario, pair this with an OnSave handler that runs the same validation.
formContext.data.process.addOnPreStageChange is the one event built specifically for this job — it fires before a stage change commits, and eventArgs.preventDefault() cancels it cleanly. Keep each stage's validation in its own small function, route by the active stage's name, and always clear your notification on success so the rep isn't left staring at a stale error.