Making a Field Read-Only in Business Process Flow (BPF) – Dataverse / Dynamics 365


A common requirement with Business Process Flows is this: the user must capture all the required details on the form before they are allowed to move to the next stage of the BPF.

The BPF can make individual fields required, but it has no idea about all the other fields sitting on the main form. So how do we make the BPF block the Next Stage button until everything is filled in?

One way is to use JavaScript: register a handler using addOnPreStageChange and call preventDefault() on the event arguments to stop the stage change whenever our conditions are not met. We have covered this approach in an earlier post here.

However, there is one more method to achieve this, where we don’t have to intercept the stage change ourselves and let the BPF do the blocking for us.

In this post we will see how we did it using a Boolean field, kept it read-only in the BPF, and set it through JavaScript.

Scenario

Let us look at it by taking an example of the Opportunity BPF. At the Qualify stage the user has to capture a set of details on the main form (contact, account, budget, and so on) before moving on to Develop.

We want:

  • The user cannot move to the next stage until all the details are captured.
  • The user should not be able to simply tick a box and bypass the check.

The Approach

We created a Boolean (Two-Option) field, Qualification Details Captured, with a default value of No, and added it to the Qualify stage of the BPF as a required step.

  • While the value is No, the BPF blocks the stage change and shows “Required fields must be filled in.”
  • Once all the required data is filled in on the form, we can write JavaScript, as per our specific requirement, to set the field to Yes.
  • With the value at Yes, the user can click Next Stage.

This works because, as we saw in an earlier post, a required Boolean field in a BPF treats only Yes as a valid value, and No is treated as not filled in. Here we are using that behavior to our advantage.

Reference: Boolean Fields in Business Process Flows: Required Field Behavior Explained

Why Make the Field Read-Only?

If the field is editable, the user can just change No to Yes in the BPF flyout and move ahead without filling in anything. That defeats the whole purpose.

The field should be driven by the data, not by the user. So we made it read-only in the BPF, and only the script is allowed to flip the value.

How to Make a Field Read-Only in BPF

The easiest way is through configuration, without any script.

When we create a BPF, Dataverse creates a table for that BPF (named after the BPF). The fields we add to the stages are rendered from the form of this BPF table, so we can control them there like we do for any other column.

  1. Go to Tables, select All (or show all tables), and search for the table with the same name as your BPF.
  2. Open its Forms and select the main form.
  • Open the Tree view, expand the stage section, and select the field.
  • Under Display options, check Read-only.
  • Save and publish.

Now in the BPF flyout the field is greyed out, and the user cannot change it.

Setting the Value Using JavaScript

Now the field needs to be set to Yes at the right time. For this, we can write JavaScript on the main form as per our specific requirement, which checks that all the required details are captured and then sets the field to Yes.

Until that happens, the field stays at No, and the user will not be able to move to the next stage by clicking Next Stage.

Result

  • Fields are incomplete: flag is No, read-only, and Next Stage is blocked.
  • User fills in all the required details: our JavaScript sets the flag to Yes, and the user can move to the next stage.
  • The user cannot bypass the check by changing the flag manually.

Key Takeaway

To make a field read-only in a BPF, open the form of the BPF table and check Read-only on the field. Combined with a required Boolean field and a little JavaScript, it gives us a simple way to make the BPF wait until all the required data on the form is captured.

Hope it helps..

Advertisements

Power Platform Environment Not Showing Up in Maker Portal & Power Platform Admin Center (Dataverse / Dynamics 365)


Recently, we ran into an interesting issue while working with a Dynamics 365 / Dataverse environment. Even though the user was assigned the System Administrator security role, the environment did not appear in either the Power Platform Admin Center or the Maker Portal. The surprising part was that nothing was actually wrong with the permissions. The environment eventually appeared automatically after about 4 to 6 hours, which suggests there was a synchronization delay. Another interesting aspect was that, out of 3 environments assigned to the user, only 1 had this problem.

The problem

After receiving System Administrator access, we expected the environment to appear immediately in:

• Power Platform Admin Center (PPAC)
• Power Apps Maker Portal

However, the environment simply wasn’t listed, even though we could access the Dynamics 365 application directly using its URL.

Open the Maker experience from Advanced Settings

If you already have the Dynamics 365 / CRM URL for the environment, you can continue working without waiting for the synchronization.

Navigate to:

Advanced Settings → Settings → Customize the System

Then select the Try New Experience option.

Selecting Try New Experience opens the modern Maker experience for that environment even when it isn’t listed in the environment selector.

Open the environment directly in PPAC or Maker Portal using the Environment ID

We can also open the environment directly in Power Platform Admin Center or Maket Portal by using the Environment ID.

Step 1: Get the Environment ID

If you have a Maker Portal URL like:

https://make.powerapps.com/environments/50d07577-70d1-4846-b1d1-af1b52c7685f/

 Copy the Environment ID:

50d07577-70d1-4846-b1d1-af1b52c7685f

Step 2: Use the Environment ID in PPAC

Replace the Environment ID in the PPAC URL:

https://admin.powerplatform.microsoft.com/manage/environments/environment/50d07577-70d1-4846-b1d1-af1b52c7685f/hub?geo=Oce

The Environment ID can also be found in the environment details page. We can use the Environment ID and the corresponding Maker and PPAC URL to open the particular environment.

Reference –

Troubleshoot missing environments

Also check –

https://www.michaelroth42.com/post/2022-08-26-power-platform-security-levels

Hope it helps..

Advertisements

SQL4CDS UTC Mode: Converting Advanced Find Date Filters to UTC Boundaries– A Quick Reference (Dataverse / Dynamics 365)


While validating record counts for a Work Orders view in Dynamics 365 Field Service, we ran into the same underlying issue covered in an earlier post – Why SQL4CDS Record Counts May Not Match Advanced Find for Date Filters.

This time, instead of just explaining the root cause again, we put together a quick reference table for converting an Advanced Find date range into the correct SQL4CDS UTC boundary, for any user time zone.

The Scenario

Advanced Find query on Work Orders, filtered on Created On:

  • On or After 01/01/2026
  • On or Before 05/01/2026

The user running this is in Auckland, New Zealand. (User’s Time Zone is Auckland)

We were running SQL4CDS in UTC mode. A query that simply matches the literal date strings against createdon will not reliably reproduce the Advanced Find count, because Advanced Find evaluates the date range in the user’s local time zone, while createdon is stored in UTC. The two only line up once the date range is converted to explicit UTC boundaries.

The Reliable Formula

StartBoundaryUTC = StartDate 00:00:00 (user’s local time) → converted to UTC

EndBoundaryUTC   = (EndDate + 1 day) 00:00:00 (user’s local time) → converted to UTC

WHERE createdon >= StartBoundaryUTC AND createdon < EndBoundaryUTC

Two points worth calling out:

  • The upper boundary always uses End Date + 1 day, with a strict <, not <=. This avoids any ambiguity around milliseconds and reliably captures the entire end date.
  • For time zones ahead of UTC (New Zealand, India), convert by subtracting the offset. For time zones behind UTC (Hawaii, US), convert by adding the offset.

Quick Reference Table

Boundary used below: 1/01/2026 to 5/01/2026 (end boundary = 6/01/2026 local, converted to UTC).

Time ZoneOffsetDST Active?Start CalculationEnd CalculationSQL4CDS Boundary
UTC+0:00No DST2026-01-01T00:00 − 0:002026-01-06T00:00 − 0:00>= ‘2026-01-01T00:00:00Z’ AND < ‘2026-01-06T00:00:00Z’
India (IST)+5:30No DST2026-01-01T00:00 − 5:302026-01-06T00:00 − 5:30>= ‘2025-12-31T18:30:00Z’ AND < ‘2026-01-05T18:30:00Z’
New Zealand (NZDT – summer)+13:00Yes (active in Jan)2026-01-01T00:00 − 13:002026-01-06T00:00 − 13:00>= ‘2025-12-31T11:00:00Z’ AND < ‘2026-01-05T11:00:00Z’
New Zealand (NZST – winter)+12:00Yes (inactive in Jan)2026-01-01T00:00 − 12:002026-01-06T00:00 − 12:00>= ‘2025-12-31T12:00:00Z’ AND < ‘2026-01-05T12:00:00Z’
Hawaii (HST)−10:00No DST2026-01-01T00:00 + 10:002026-01-06T00:00 + 10:00>= ‘2026-01-01T10:00:00Z’ AND < ‘2026-01-06T10:00:00Z’

Since January falls in NZ summer, the Auckland user’s boundary above uses NZDT (+13:00):

SELECT count(1)
FROM msdyn_workorder
WHERE createdon >= '2025-12-31T11:00:00Z'
AND createdon < '2026-01-05T11:00:00Z'

This reproduces the Advanced Find count for the same range.

NZ DST Transition Windows

New Zealand does not stay on a single offset year-round, so the correct value depends on the date range being queried, not the date the query is run.

PeriodOffset
Late Sep – early Apr (NZDT)UTC+13
Early Apr – late Sep (NZST)UTC+12

Confirm the exact transition dates for the specific year, as they shift slightly.

Rules of Thumb

RuleReason
End boundary = End Date + 1 day, use < not <=Captures the full end date without truncating time
Time zones ahead of UTC: subtract the offsetUTC = Local − Offset
Time zones behind UTC: add the offsetUTC = Local + Offset
Check DST for the query dates, not today’s dateThe same time zone can have two different offsets depending on the time of year

Key Takeaway

When running SQL4CDS in UTC mode, the reliable and repeatable approach is to convert the Advanced Find date range into explicit UTC boundaries using the local-time offset (accounting for DST where applicable), and query using >= / < against those boundaries.

Reference

For more background on how SQL4CDS interprets date and time values in UTC vs Local mode, see Mark Carrington’s article: Date/Time handling in SQL 4 CDS

Hope it helps..

Advertisements

Hide Expired Sessions in Event Registration Form using JavaScript (Dynamics 365 Customer Insights – Journeys)


One of our recent requirements was to ensure that users could no longer register for event sessions once the session had already ended. The goal was to improve the user experience by hiding expired sessions from the Event Registration form, rather than displaying sessions that were no longer available. Once a session’s end date and time had passed, it should be hidden from the UI so that new registrations could not be made through the form.

We used the standard ‘Default registration form with Sessions’ provided by Microsoft and added a small JavaScript customization. The script executes after the form loads by subscribing to the d365mkt-afterformload event. It reads the rendered session date and end time, creates a JavaScript Date object, compares it with the current browser time (all users are in the New Zealand time zone), and hides any expired sessions. If every session has expired, the entire Sessions section is also hidden.

We can see the following sessions configured for the event.

Below we can see the Event Registration form showing all the sessions before it is rendered for the end users.

And after our JavaScript that is registered on d365mkt-afterformload runs, it hides all the expired sessions except the active one.

JavaScript

 document.addEventListener("d365mkt-afterformload", function () {

    document.querySelectorAll(".eventSession").forEach(function (session) {
        debugger;
        const values = Array.from(
            session.querySelectorAll(".msdynmkt_personalization")
        ).map(x => x.textContent.trim());

        // [0] Session Title
        // [1] Session Date (M/D/YYYY)
        // [2] Start Time
        // [3] End Time
        // [4] Location/Room (optional)

        if (values.length < 4) {
            return;
        }

        const dateText = values[1];
        const endTimeText = values[3];

        const dateParts = dateText.split('/');

        if (dateParts.length !== 3) {
            return;
        }

        const month = parseInt(dateParts[0], 10) - 1;
        const day = parseInt(dateParts[1], 10);
        const year = parseInt(dateParts[2], 10);

        const timeMatch = endTimeText.match(/(\d+):(\d+)\s*(AM|PM)/i);

        if (!timeMatch) {
            return;
        }

        let hours = parseInt(timeMatch[1], 10);
        const minutes = parseInt(timeMatch[2], 10);
        const meridian = timeMatch[3].toUpperCase();

        if (meridian === "PM" && hours !== 12) {
            hours += 12;
        }

        if (meridian === "AM" && hours === 12) {
            hours = 0;
        }

        const sessionEndDateTime = new Date(
            year,
            month,
            day,
            hours,
            minutes,
            0
        );

        if (sessionEndDateTime <= new Date()) {
            session.style.display = "none";
        }
    });

    // Hide the entire Sessions block if all sessions are hidden
    const visibleSessions = Array.from(
        document.querySelectorAll(".eventSession")
    ).filter(s => s.style.display !== "none");

    if (visibleSessions.length === 0) {

        const sessionBlock =
            document.querySelector('[data-editorblocktype="Sessions"]') ||
            document.querySelector("fieldset.eventSessions")?.closest("div");

        if (sessionBlock) {
            sessionBlock.style.display = "none";
        }
    }
});

Things to Note

Reference

Set up sessions in Dynamics 365 Customer Insights.

Extend Customer Insights – Journeys marketing forms using code.

Hope it helps..

Advertisements

When Entity.Id Is Guid.Empty with QueryExpression.Distinct = True (Dataverse)


While optimising the performance of a Dataverse plugin, we noticed a QueryExpression using ColumnSet(true). The business logic only required a few attributes, so replacing ColumnSet(true) with a minimal ColumnSet looked like an easy performance improvement. The query also used Distinct = true, which we left unchanged because it had always been there and everything was working correctly.

The Original Query

query.ColumnSet = new ColumnSet(true);
query.Distinct = true;

We changed the query to retrieve only the attributes required by the business logic:

query.ColumnSet = new ColumnSet(
    "msdyn_systemstatus",
    "msdyn_datewindowstart",
    "custom_cancelledreason");
query.Distinct = true;

The optimisation looked perfectly valid. The query returned the expected records, but part of the plugin logic suddenly stopped working.

The Unexpected Bug

The plugin compared Work Orders using Entity.Id to determine whether a record already existed in a collection. During debugging, we discovered that every retrieved entity had Guid.Empty as its Id, causing the comparison logic to fail and duplicate records to be added.

Finding the Root Cause

To isolate the problem, we reproduced the behaviour with a simple Lead query.

QueryExpression query = new QueryExpression("lead");
query.ColumnSet = new ColumnSet("lastname");
query.Distinct = true;

Once again, the record was returned successfully, but Entity.Id was Guid.Empty.

While reading the Microsoft documentation for QueryExpression.Distinct, we found the following remark:

“When the Distinct property is true, the results returned don’t include primary key values for each record because they represent an aggregation of all the distinct values.”

The Fix

Including the primary key in the ColumnSet resolved the issue.

query.ColumnSet = new ColumnSet("leadid", "lastname");
query.Distinct = true;

After this change, both Entity.Id and the leadid attribute were populated correctly.

One More Observation

While investigating the issue, we realised something else. The original query used ColumnSet(true) together with Distinct = true. Since ColumnSet(true) retrieves every readable attribute, including the primary key, every record is already unique because the primary key itself is unique. In that particular QueryExpression there were no LinkEntity joins or other scenarios that could naturally produce duplicate rows. That meant Distinct = true was not really providing any value. In fact, once we reviewed the query, removing Distinct = true was a cleaner solution than simply adding the primary key back into the ColumnSet.
This serves as a useful reminder that performance optimisation is not just about reducing the columns retrieved. It is also a good opportunity to question whether every part of the original query is still necessary.

Lessons Learned

• Replacing ColumnSet(true) with a minimal ColumnSet is a good optimisation.
• If a query uses Distinct = true and the code relies on Entity.Id, include the primary key in the ColumnSet.
• Review whether Distinct = true is actually required. In many QueryExpression scenarios, especially those without joins, it may be redundant.
• Small performance improvements can sometimes expose subtle behaviours that are easy to overlook.

Hope it helps..

Advertisements

Custom Form Submission Validation in Dynamics 365 Customer Insights – Journeys: Inside the Validation Pipeline and the “ms_captcha_solution” Error


While implementing custom form submission validation (server side validation) for Dynamics 365 Customer Insights – Journeys Real-Time Marketing forms, we came across the following error,

“Required params cannot be null or empty –
ms_captcha_solution
ms_captcha_type
ms_captcha_flow_id”

after enabling the data-validate-submission attribute on the form as shown below.


Setting this attribute to true caused the platform to invoke the msdynmkt_validateformsubmission Custom API, which ultimately resulted in the error.

After reviewing Microsoft’s documentation, plugin trace logs, and decompiling the Microsoft assemblies, we were able to understand exactly how the validation pipeline works.

When does this error occur?

• data-validate-submission=”true” is enabled.
• Microsoft CAPTCHA isn’t configured.
• No custom validation plugin overwrites the default validation response.

Understanding the Validation Pipeline

Customer Insights invokes the following Custom API:

msdynmkt_validateformsubmission

The msdynmkt_validateformsubmission Custom API is implemented by Microsoft’s Microsoft.Dynamics.Cxp.Forms.Plugins.Plugins.ValidateFormSubmissionPlugin, which performs the default CAPTCHA validation and initializes the msdynmkt_validationresponse.

We can then register our own plugin steps on the same message.

Microsoft recommends registering custom validation plugins with an Execution Order of 20, allowing them to execute after the out-of-the-box Microsoft.Dynamics.Cxp.FormsReCaptcha.Plugins.ReCaptchaValidationPlugin (Execution Order 10) and overwrite the validation response if required.

Check the flow below to get more details –

Why does the error occur?

The VerifyCaptchaChallenge service expects the following fields:

ms_captcha_solution
ms_captcha_type
ms_captcha_flow_id

If any are missing, it returns IsValid = false.

if (solution == null ||
    string.IsNullOrEmpty(captchaType) ||
    string.IsNullOrEmpty(flowId))
{
    return new VerifyCaptchaResponse
    {
        IsValid = false
    };
}

How our custom validation plugin resolves the issue

Our custom Honeypot custom plugin performs its own validation and overwrites the validation response.

SetValidationResponse(context, true, null);

// or

SetValidationResponse(context, false, “Form validation failed.”);

What about the ReCaptchaValidationPlugin?

The ReCaptchaValidationPlugin checks for g-recaptcha-response. If it isn’t present, it simply returns.

if (field == null)
{
    tracing.Trace("g-recaptcha-response field was not present in form submission");
    return;
}

Plugin Trace Logs

Our trace logs confirmed:

1. ValidateFormSubmissionPlugin executes.
2. Our Honeypot plugin overwrites the validation response.
3. ReCaptchaValidationPlugin executes afterwards and exits because g-recaptcha-response isn’t present without throwing any exception.

Conclusion

Although the error appears to be a configuration issue, it’s actually the expected behaviour of the default validation pipeline. The default implementation expects Microsoft’s CAPTCHA fields. If we’re implementing our own custom plugin for form submission validation, it should overwrite the validation response after performing its own server-side validation.

Get more details –

https://learn.microsoft.com/en-us/dynamics365/customer-insights/journeys/real-time-marketing-form-customize-submission-validation

https://www.ameyholden.com/articles/recaptcha-v3-cloudflare-turnstile-for-customer-insights-journeys-forms

Check the previous posts –

Honey Pot Validation (Server-side)

Hope it helps..

Advertisements