Power Apps now lets us render a column’s data as a small graphic in the grid – a gauge, a sparkline, a colored bar, or stars – instead of plain text. We tried it out on a couple of our own columns, and here’s a quick step-by-step on how to set it up, along with the data format each visualization expects.
This is a preview feature, so it’s best tried out in Dev/Sandbox for now rather than rolled out to production grids.
Pick a Visualization for the Column
Sign in to Power Apps (make.powerapps.com) → Solutions → open the table.
Open the column to edit (or create a new one).
Expand Advanced options → “Visualization”.
Select one of: Heat Map, Line Chart, Radial Dial, Star Rating.
Save. Every grid or view showing that column now shows the graphic automatically.
That’s it – it’s a one-time setting on the column, not something you configure per view.
Know What Data Each One Expects
This is the part that actually matters – each visualization is strict about the shape of the data behind it.
Radial Dial: expects a single value, 0–100. Example: 60 → ring 60% full.
Heat Map: expects a single value, 0–100 by default. Example: 90 → bar colored red (high).
Star Rating: expects a value from 0 to the star count, 5 by default. Example: 3 → three stars filled.
Line Chart: expects comma-separated numbers, 100 characters maximum length. Example: 10,20,30,45 → a trend line.
For .e.g the data for these columns –
Below is how they are rendered in a view.
And this is as a subgrid.
Key Takeaway
Set the visualization once on the column, keep the underlying data clean (0–100 for Radial Dial/Heat Map, comma-separated numbers for Line Chart, 0 to 5 for Star Rating), and every grid using that column just works. Most issues we ran into came down to one thing – the value wasn’t stored the way the visualization expected.
In one of our recent projects, we implemented Cloudflare Turnstile (Invisible) for a Dynamics 365 Customer Insights – Journeys Real-Time Marketing form. In this post, we’ll look at how to configure Cloudflare Turnstile, integrate it with the marketing form using JavaScript, and validate submissions on the server using the msdynmkt_validateformsubmission plugin.
Cloudflare Turnstile provides an invisible verification mechanism that evaluates the request in the background and only requires additional verification when necessary.
For marketing forms, this means: • Better user experience • Reduced spam and bot submissions • Server-side verification
Solution Overview
The implementation consists of two major components.
The first component is a JavaScript that runs inside the Marketing form. It loads the Cloudflare Turnstile library, renders an invisible widget, requests a token when the user submits the form, writes that token into a hidden form field, and then allows the submission to continue.
The second component is a Dataverse plugin registered on the msdynmkt_validateformsubmission message. The plugin retrieves the Turnstile token, reads the Cloudflare secret key from a Dataverse Environment Variable, validates the token against the Cloudflare SiteVerify API, and finally tells Customer Insights whether the submission should be accepted.
Solution Flow
Visitor │ ▼ Customer Insights – Journeys Form │ ▼ JavaScript │ ├── Loads Cloudflare Turnstile ├── Executes Invisible Challenge └── Stores Token in Hidden Field │ ▼ Form Submission │ ▼ msdynmkt_validateformsubmission Plugin │ ├── Reads Token ├── Retrieves Secret Key from Environment Variable ├── Calls Cloudflare SiteVerify API └── Returns Validation Result │ ▼ Lead / Contact Created
Step 1 – Create a Cloudflare Account
Create a free Cloudflare account and navigate to Application Security > Turnstile. Turnstile widgets are managed from this area.
Step 2 – Create an Invisible Turnstile Widget
Create a new widget, select Invisible mode, and configure all hostnames that will serve the Customer Insights form.
e.g. hostname – assets-oce.mkt.dynamics.com
Cloudflare generates a Site Key and Secret Key. The Site Key is used by JavaScript while the Secret Key is kept securely on the server and used by the plugin.
Step 3 – Create a Dataverse Environment Variable
Create an environment variable to hold the Secret Key that will be used by the plugin for server-side validation of the token.
Step 4 – Configure the Marketing Form
Add an unmappedhidden Short Text field named cf_token to the form. This field temporarily stores the token generated by Cloudflare.
Enable data-validate-submission=”true” and add the JavaScript to the form between the closing form and body tag : </form> <Script>JavaScript </Script></body> (added in the next section)
JavaScript Implementation
The JavaScript is responsible for integrating the Customer Insights form with Cloudflare Turnstile.
At a high level, it performs the following tasks:
• Defines configuration such as the Site Key and token field name. • Dynamically loads the Cloudflare Turnstile JavaScript library. • Renders an invisible Turnstile widget inside the marketing form. • Waits until the user clicks Submit. • Executes Turnstile to obtain a fresh token. • Stores the token in the hidden cf_token field. • Resubmits the form after the token has been written.
The implementation also caches tokens for a short period and automatically refreshes expired tokens, ensuring that only valid tokens are submitted.
(function () {
'use strict';
// ---------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------
var TURNSTILE_SITE_KEY = 'SITEKEY';
var TURNSTILE_SCRIPT_URL = 'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit';
var FORM_SELECTOR = 'form.marketingForm';
var SUBMIT_EVENT = 'd365mkt-formsubmit';
var AFTER_LOAD_EVENT = 'd365mkt-afterformload';
// Refresh tokens after four minutes.
// Cloudflare tokens expire after approximately five minutes.
var TOKEN_STALE_MS = 4 * 60 * 1000;
// Hidden unmapped field added through the Customer Insights – Journeys
// form designer. The generated Turnstile token is copied into this field
// before the form is submitted.
var TOKEN_FIELD_NAME = 'cf_token';
var scriptPromise = null;
// Stores the Turnstile widget state for each form instance without
// preventing the form from being garbage collected.
var widgetState = new WeakMap();
function loadTurnstileScript() {
if (scriptPromise) return scriptPromise;
scriptPromise = new Promise(function (resolve, reject) {
if (window.turnstile) {
resolve();
return;
}
var script = document.createElement('script');
script.src = TURNSTILE_SCRIPT_URL;
script.async = true;
script.defer = true;
script.onload = function () {
resolve();
};
script.onerror = function () {
scriptPromise = null;
reject(new Error('[CIJ Turnstile] Failed to load Turnstile script.'));
};
document.head.appendChild(script);
});
return scriptPromise;
}
function renderWidget(formEl) {
if (widgetState.has(formEl)) {
return widgetState.get(formEl);
}
// The widget container must exist inside the form so that Cloudflare's
// internally managed hidden field is created within the form element.
var container = document.createElement('div');
container.style.display = 'none';
formEl.appendChild(container);
var entry = {
widgetId: null,
token: null,
tokenAt: 0,
resolve: null,
reject: null
};
entry.widgetId = window.turnstile.render(container, {
sitekey: TURNSTILE_SITE_KEY,
// Execute Turnstile only when the user submits the form.
execution: 'execute',
// Keep the widget completely invisible.
appearance: 'execute',
callback: function (token) {
entry.token = token;
entry.tokenAt = Date.now();
if (entry.resolve) {
entry.resolve(token);
entry.resolve = null;
entry.reject = null;
}
},
'error-callback': function (code) {
console.error('[CIJ Turnstile] Widget error:', code);
if (entry.reject) {
entry.reject(new Error('Turnstile error: ' + code));
entry.resolve = null;
entry.reject = null;
}
},
'expired-callback': function () {
entry.token = null;
entry.tokenAt = 0;
}
});
widgetState.set(formEl, entry);
return entry;
}
function getToken(formEl) {
return loadTurnstileScript().then(function () {
var entry = renderWidget(formEl);
var isFresh =
entry.token &&
entry.tokenAt &&
(Date.now() - entry.tokenAt) < TOKEN_STALE_MS;
if (isFresh) {
return Promise.resolve(entry.token);
}
return new Promise(function (resolve, reject) {
entry.resolve = resolve;
entry.reject = reject;
window.turnstile.reset(entry.widgetId);
window.turnstile.execute(entry.widgetId);
});
});
}
function getFormFromEvent(evt) {
var target = evt && evt.target;
if (!target) {
return null;
}
if (target.tagName === 'FORM') {
return target;
}
if (target.querySelector) {
var nested = target.querySelector('form');
if (nested) {
return nested;
}
}
return target.closest ? target.closest('form') : null;
}
function setTokenField(formEl, token) {
var input = formEl.querySelector('[name="' + TOKEN_FIELD_NAME + '"]');
if (!input) {
console.error(
'[CIJ Turnstile] Could not find field "' +
TOKEN_FIELD_NAME +
'". Add an unmapped field with this name in the Customer Insights – Journeys form designer.'
);
return false;
}
input.value = token;
return true;
}
function getSubmitButton(formEl) {
return formEl.querySelector('button[type="submit"], input[type="submit"]');
}
function showWaiting(formEl) {
var btn = getSubmitButton(formEl);
if (btn) {
btn.disabled = true;
btn.style.opacity = '0.6';
btn.style.cursor = 'wait';
}
}
function hideWaiting(formEl) {
var btn = getSubmitButton(formEl);
if (btn) {
btn.disabled = false;
btn.style.opacity = '';
btn.style.cursor = '';
}
}
function onFormSubmit(evt) {
var formEl = getFormFromEvent(evt);
if (!formEl || formEl._cijResubmitting) {
return;
}
// Allow the re-submitted request to continue once a valid token
// has already been written to the hidden field.
if (formEl._cijTurnstileReady) {
formEl._cijTurnstileReady = false;
return;
}
evt.preventDefault();
if (evt.stopImmediatePropagation) {
evt.stopImmediatePropagation();
}
showWaiting(formEl);
getToken(formEl)
.then(function (token) {
setTokenField(formEl, token);
formEl._cijTurnstileReady = true;
formEl._cijResubmitting = true;
// Re-submit the form after a valid Turnstile token has been obtained.
if (formEl.requestSubmit) {
formEl.requestSubmit();
} else {
formEl.submit();
}
setTimeout(function () {
formEl._cijResubmitting = false;
}, 0);
})
.catch(function (err) {
// Do not allow the form to continue without a valid token.
// The server-side plugin will also reject missing or invalid tokens.
console.error('[CIJ Turnstile] Could not obtain token. Submission blocked.', err);
hideWaiting(formEl);
});
}
function wireForm(formEl) {
if (formEl._cijTurnstileWired) {
return;
}
formEl._cijTurnstileWired = true;
loadTurnstileScript().then(function () {
renderWidget(formEl);
});
}
function findAndWireForms(root) {
(root || document)
.querySelectorAll(FORM_SELECTOR)
.forEach(wireForm);
}
document.addEventListener(AFTER_LOAD_EVENT, function (evt) {
var formEl = getFormFromEvent(evt);
if (formEl) {
wireForm(formEl);
} else {
findAndWireForms(document);
}
});
document.addEventListener(SUBMIT_EVENT, onFormSubmit);
// Handle forms that were rendered before this script was loaded.
if (document.readyState !== 'loading') {
findAndWireForms(document);
} else {
document.addEventListener('DOMContentLoaded', function () {
findAndWireForms(document);
});
}
})();
Plugin Implementation
The plugin performs the server-side verification. The Execute method first retrieves the form submission payload supplied by Customer Insights. From that payload, it reads the cf_token field. The plugin then retrieves the Cloudflare Secret Key from the Dataverse Environment Variable. If the Environment Variable has not been configured, the plugin throws an exception because verification cannot continue.
Once both values are available, the plugin calls the Cloudflare SiteVerify endpoint using HttpClient. Cloudflare returns a JSON payload indicating whether the token is valid. Only successful responses allow the submission to continue. Finally, the plugin creates a ValidateFormSubmissionResponse object and returns it to Customer Insights. The cf_token field is included in ValidationOnlyFields, so it is ignored during entity creation.
using Microsoft.Xrm.Sdk;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
namespace SamplePlugin
{
/// <summary>
/// Validates Customer Insights - Journeys form submissions by verifying
/// the Cloudflare Turnstile token before allowing record creation.
/// Registered on:
/// Message: msdynmkt_validateformsubmission
/// Stage: PostOperation
/// Mode: Synchronous
/// </summary>
public class ValidateTurnstilePlugin : IPlugin
{
private const string TurnstileVerifyUrl =
"https://challenges.cloudflare.com/turnstile/v0/siteverify";
private const string TokenFieldName = "tokenfieldname";
private const string FailureMessage =
"Captcha validation failed. Please refresh the page and try again.";
private const string SecretKeyEnvironmentVariableSchemaName =
"environmentvariablename";
public void Execute(IServiceProvider serviceProvider)
{
var tracing =
(ITracingService)serviceProvider.GetService(typeof(ITracingService));
var context =
(IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
if (!context.InputParameters.Contains("msdynmkt_formsubmissionrequest"))
{
tracing.Trace(
"[Turnstile] Input parameter 'msdynmkt_formsubmissionrequest' not found.");
return;
}
var serviceFactory =
(IOrganizationServiceFactory)serviceProvider.GetService(
typeof(IOrganizationServiceFactory));
var service = serviceFactory.CreateOrganizationService(null);
string secretKey = GetEnvironmentVariableValue(
service,
tracing,
SecretKeyEnvironmentVariableSchemaName);
if (string.IsNullOrWhiteSpace(secretKey))
{
throw new InvalidPluginExecutionException(
$"Environment Variable '{SecretKeyEnvironmentVariableSchemaName}' does not contain a value.");
}
var request = Deserialize<FormSubmissionRequest>(
(string)context.InputParameters["msdynmkt_formsubmissionrequest"]);
var fields = request?.Fields ?? new List<FormField>();
string token = fields
.FirstOrDefault(f => f.Key == TokenFieldName)?
.Value;
if (string.IsNullOrWhiteSpace(token))
{
tracing.Trace(
$"[Turnstile] Token field '{TokenFieldName}' is empty.");
SetValidationResponse(
context,
false,
FailureMessage);
return;
}
bool isValid = VerifyTurnstile(
secretKey,
token,
tracing);
tracing.Trace(
$"[Turnstile] Validation Result = {isValid}");
SetValidationResponse(
context,
isValid,
isValid ? null : FailureMessage);
}
private bool VerifyTurnstile(
string secretKey,
string token,
ITracingService tracing)
{
var formContent =
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "secret", secretKey },
{ "response", token }
});
using (var httpClient = new HttpClient())
{
string body;
try
{
var response =
httpClient.PostAsync(
TurnstileVerifyUrl,
formContent).Result;
body =
response.Content.ReadAsStringAsync().Result;
if (!response.IsSuccessStatusCode)
{
tracing.Trace(
$"[Turnstile] siteverify returned HTTP {(int)response.StatusCode}. " +
$"Body: {(string.IsNullOrWhiteSpace(body) ? "<empty>" : body)}");
return false;
}
}
catch (Exception ex)
{
tracing.Trace(
"[Turnstile] Exception calling siteverify: " +
ex);
return false;
}
var verifyResponse =
Deserialize<TurnstileVerifyResponse>(body);
tracing.Trace(
$"[Turnstile] success={verifyResponse.Success}, " +
$"hostname={verifyResponse.Hostname}, " +
$"errors={(verifyResponse.ErrorCodes == null ? "none" : string.Join(", ", verifyResponse.ErrorCodes))}");
return verifyResponse.Success;
}
}
private void SetValidationResponse(
IPluginExecutionContext context,
bool isValid,
string error)
{
var response = new ValidateFormSubmissionResponse
{
IsValid = isValid,
ValidationOnlyFields = new List<string>
{
TokenFieldName
},
Error = error
};
context.OutputParameters["msdynmkt_validationresponse"] =
Serialize(response);
}
private string GetEnvironmentVariableValue(
IOrganizationService service,
ITracingService tracing,
string schemaName)
{
tracing.Trace(
"[Turnstile] Retrieving Environment Variable: {0}",
schemaName);
var request = new OrganizationRequest(
"RetrieveEnvironmentVariableValue")
{
Parameters =
{
["DefinitionSchemaName"] = schemaName
}
};
var response = service.Execute(request);
if (response.Results.Count == 0)
{
tracing.Trace(
"[Turnstile] Environment Variable '{0}' not found.",
schemaName);
return null;
}
var value = response.Results
.Values
.FirstOrDefault()
?.ToString();
tracing.Trace(
"[Turnstile] Environment Variable '{0}' retrieved successfully.",
schemaName);
return value;
}
private static T Deserialize<T>(string json)
{
using (var stream =
new MemoryStream(Encoding.UTF8.GetBytes(json)))
{
return (T)new DataContractJsonSerializer(typeof(T))
.ReadObject(stream);
}
}
private static string Serialize<T>(T value)
{
using (var stream = new MemoryStream())
{
new DataContractJsonSerializer(typeof(T))
.WriteObject(stream, value);
return Encoding.UTF8.GetString(stream.ToArray());
}
}
#region Models
[DataContract]
private class FormField
{
[DataMember(Name = "Key")]
public string Key { get; set; }
[DataMember(Name = "Value")]
public string Value { get; set; }
}
[DataContract]
private class FormSubmissionRequest
{
[DataMember(Name = "Fields")]
public List<FormField> Fields { get; set; }
}
[DataContract]
private class ValidateFormSubmissionResponse
{
[DataMember(Name = "IsValid")]
public bool IsValid { get; set; }
[DataMember(Name = "ValidationOnlyFields")]
public List<string> ValidationOnlyFields { get; set; }
[DataMember(Name = "Error")]
public string Error { get; set; }
}
[DataContract]
private class TurnstileVerifyResponse
{
[DataMember(Name = "success")]
public bool Success { get; set; }
[DataMember(Name = "hostname")]
public string Hostname { get; set; }
[DataMember(Name = "error-codes")]
public string[] ErrorCodes { get; set; }
[DataMember(Name = "action")]
public string Action { get; set; }
[DataMember(Name = "challenge_ts")]
public string ChallengeTimestamp { get; set; }
}
#endregion
}
}
Register a synchronous plugin step on the msdynmkt_validateformsubmission message.
On failure – we get the message specified in the plugin
On the successful submission, we can see the marketing form submitted successfully and the details in our plugin trace log.
While validating some Dynamics 365 Field Service data recently, we came across an interesting scenario where SQL4CDS and Advanced Find returned different record counts even though the date filters appeared to be identical.
At first glance it was surprising to see different record counts being returned despite using what appeared to be the same date range. After investigating further, we found that the difference was related to time zone handling and the behavior of User Local date fields.
In this post, we’ll walk through the issue, explain why it happens, and show how to get matching results between Advanced Find and SQL4CDS.
The Scenario
We had a user in Auckland, New Zealand running the following Advanced Find query against Work Orders.
Date Window Start
On or After 01/01/2026
On or Before 02/01/2026
Advanced Find returned:
5,755 records
The generated FetchXML looked like this:
To validate the result, we ran the following query in SQL4CDS:
SELECT COUNT(*) FROM msdyn_workorder WHERE msdyn_datewindowstart >= ‘2026-01-01 00:00:00’ AND msdyn_datewindowstart <= ‘2026-01-02 00:00:00’;
The results were unexpected.
Query Method
Time Zone Used
Result
Advanced Find (Auckland User)
Auckland (NZDT)
5,755
SQL4CDS
UTC Mode
3,027
SQL4CDS
Local Mode (India)
3,026
At this point, it was clear that Advanced Find and SQL4CDS were evaluating different date boundaries, even though the filters appeared very similar. The next step was to understand why.
Understanding the Date Window Start Field
The key detail was the configuration of the Date Window Start field.
The Date Window Start field is configured as a Date Only field with User Local behavior.
Although users only see a date value, Dataverse stores an underlying UTC datetime value and performs time zone conversion based on the user’s personal settings.
To better understand what was happening, we queried some of the underlying values directly.
SELECT msdyn_workorderid, msdyn_datewindowstart FROM msdyn_workorder WHERE msdyn_datewindowstart >= ‘2026-01-01 00:00:00’ AND msdyn_datewindowstart <= ‘2026-01-02 00:00:00’;
When running SQL4CDS in UTC mode, many records had values such as:
2026-01-01 11:00:00
This initially looked unusual because users only see a date value in the application.
However, the explanation becomes clear when we consider the Auckland user’s time zone.
In January, Auckland operates on New Zealand Daylight Time (NZDT), which is UTC+13.
For a User Local Date Only field, Dataverse converts the user’s local date into UTC before storing it.
Date Seen by Auckland User
Stored UTC Value
01-Jan-2026
31-Dec-2025 11:00 UTC
02-Jan-2026
01-Jan-2026 11:00 UTC
03-Jan-2026
02-Jan-2026 11:00 UTC
This explains why so many records appear with a value of 11:00 UTC when viewed in SQL4CDS running in UTC mode.
Why Advanced Find Returned More Records
When the Auckland user enters:
01/01/2026 to 02/01/2026
Advanced Find interprets those dates using the user’s personal time zone.
The actual UTC boundaries become:
>= 2025-12-31 11:00:00 UTC < 2026-01-02 11:00:00 UTC
This represents two complete calendar days for the Auckland user.
Our original SQL4CDS query was searching a different range entirely:
>= 2026-01-01 00:00:00 UTC <= 2026-01-02 00:00:00 UTC
Although the dates appear similar, the actual UTC boundaries are very different.
Finding the Correct SQL4CDS Query in UTC Mode
To reproduce the Advanced Find results, we converted the Auckland user’s date range into UTC and updated the SQL4CDS query accordingly.
SELECT COUNT(*) FROM msdyn_workorder WHERE msdyn_datewindowstart >= ‘2025-12-31 11:00:00’ AND msdyn_datewindowstart < ‘2026-01-02 11:00:00’;
This returned:
5,755 records
which matched Advanced Find exactly.
What If SQL4CDS Is Running in Local Mode?
The example above used SQL4CDS running in UTC mode. However, SQL4CDS can also be configured to use Local Time mode.
In our scenario, SQL4CDS was running on a machine configured for India Standard Time (IST), which is UTC+5:30.
To match the Advanced Find results in Local Mode, we need to convert the Auckland UTC boundaries into the local time zone used by SQL4CDS.
Earlier we determined that the Auckland user’s date range:
01-Jan-2026 to 02-Jan-2026
corresponds to the following UTC boundaries:
31-Dec-2025 11:00 UTC to 02-Jan-2026 11:00 UTC
When SQL4CDS is running in Local Mode on an India machine, those UTC values need to be converted to IST.
UTC Boundary
IST Boundary
31-Dec-2025 11:00 UTC
31-Dec-2025 16:30 IST
02-Jan-2026 11:00 UTC
02-Jan-2026 16:30 IST
The equivalent SQL4CDS query becomes:
SELECT COUNT(*) FROM msdyn_workorder WHERE msdyn_datewindowstart >= ‘2025-12-31 16:30:00’ AND msdyn_datewindowstart < ‘2026-01-02 16:30:00’;
This query also returned:
5,755 records
matching Advanced Find exactly.
The results can now be summarized as follows:
Validation Method
Query Boundary
Result
Advanced Find (Auckland User)
User Time Zone
5,755
SQL4CDS UTC Mode
31-Dec-2025 11:00 UTC → 02-Jan-2026 11:00 UTC
5,755
SQL4CDS Local Mode (India)
31-Dec-2025 16:30 IST → 02-Jan-2026 16:30 IST
5,755
References
For a deeper understanding of how SQL4CDS handles date and time values, I highly recommend Mark Carrington’s article:
This article explains how SQL4CDS interprets date and time values in both UTC and Local Time modes and was a useful reference while investigating this scenario.
Key Takeaways
The investigation highlighted that there may be three different time zones involved when validating results:
The Dataverse user’s personal time zone used by Advanced Find.
The SQL4CDS Local Time setting.
UTC when SQL4CDS is configured to use UTC mode.
Even when the same date values are entered, the actual UTC range being queried may be different.
For the most reliable comparison:
Identify the time zone of the user who ran Advanced Find.
Convert the date boundaries to UTC.
Run SQL4CDS in UTC mode.
Use explicit UTC values in your query.
We also recommend using an exclusive upper boundary:
WHERE Field >= StartBoundaryUTC AND Field < EndBoundaryUTC
instead of:
WHERE Field <= EndOfDay
This avoids potential issues with milliseconds and provides more predictable results.
SQL4CDS can match Advanced Find in either UTC Mode or Local Mode. The important requirement is that the date boundaries represent the same moment in time. We generally prefer UTC Mode because the query behaves consistently regardless of the machine or user executing it.
We recently worked on a requirement where we had to sync Business Process Flow (BPF) data between two different Dataverse environments for the Case (Incident) table. At first glance, the requirement looked straightforward — pick the active BPF instance and replicate it. However, while analyzing the source environment, we encountered an interesting and unexpected scenario.
For certain Case records, we found that there were multiple active Business Process Flow instances (of the same BPF) present for the same record.
Below is an example where we observed two active BPF instances for the same Case record:
Below is our Case record with Research as the active stage.
As we know, Dataverse is designed to maintain only one active BPF instance per record. So naturally, this raised a question — which one should we consider during synchronization?
On further analysis, we observed a consistent pattern. One BPF instance was typically created at the time when the Case record itself was created. The second instance — usually the one with the most recent Modified On value — corresponded to the latest process applied.
Based on this observation, we decided to use the following approach during synchronization:
For records with multiple active BPF instances, we pick the instance with the most recent Modified On value and ignore the older ones. This ensures that we are syncing the most relevant and currently active business process state.
Naturally, we wanted to understand how such a scenario could even exist, so we tried to replicate it. We attempted to create another BPF instance programmatically using the SDK. The code executed successfully and even returned a GUID; however, interestingly, the GUID was always the same as the already existing active instance.
This behavior clearly indicates that the platform prevents creating duplicate active instances through standard SDK operations.
After further experimentation, we discovered that the only way we were able to create multiple active BPF instances was by directly updating the Incident lookup (incidentid) on an existing BPF instance record. By reassigning the BPF instance from one Case record to another, we effectively bypassed the normal BPF lifecycle validations. This resulted in multiple active BPF instances being associated with the same Case record.
During data migration or synchronization scenarios, it is important to handle such anomalies carefully. In our case, choosing the BPF instance with the latest Modified On value ensured that we always picked the most relevant process state.
When working with forms in Dynamics 365 / Power Apps model-driven apps, we often customize field labels based on context, using the setLabel method. At times, we would also like to change the tool tip to go with the changed label of the field. The tooltip is defined as a Description of the field.
Below is the Topic (subject) field of the lead.
However, we cannot set the tool tip (description) of the field dynamically in the form using the Client API. So, what do we do when the meaning of a field changes depending on another value on the form? That’s where addNotification comes in as a handy workaround.
Let us take a simple example to see how we can use it. On the Lead form, the Topic(subject) field means different things depending on the Lead Source. So here we will be changing the label of the Topic (subject) field, along with setting a different notification message.
For e.g., if Lead Source – Advertisement, we are changing the label to Campaign Name. We can also notice the bulb icon next to the field.
Clicking on it, we can see our message –
Similarly, on changing the Lead Source to Web, we are changing the label to Landing Page, and clicking on the icon, we can see a different message.
Sample Code –
function updateSubjectField(executionContext) {
var formContext = executionContext.getFormContext();
var leadSourceAttr = formContext.getAttribute("leadsourcecode");
var subjectControl = formContext.getControl("subject");
subjectControl.clearNotification("subjectTooltip");
var leadSource = leadSourceAttr ? leadSourceAttr.getValue() : null;
if (leadSource === 1) {
// 1 = Advertisement
subjectControl.setLabel("Campaign Name");
subjectControl.addNotification({
messages: ["Enter the name of the ad campaign"],
notificationLevel: "RECOMMENDATION",
uniqueId: "subjectTooltip"
});
}
else if (leadSource === 2) {
// 2 = Referral
subjectControl.setLabel("Referrer Notes");
subjectControl.addNotification({
messages: ["Mention details about the referrer"],
notificationLevel: "RECOMMENDATION",
uniqueId: "subjectTooltip"
});
}
else if (leadSource === 8) {
// 3 = Web
subjectControl.setLabel("Landing Page");
subjectControl.addNotification({
messages: ["Provide the landing page URL"],
notificationLevel: "RECOMMENDATION",
uniqueId: "subjectTooltip"
});
}
else {
// Default
subjectControl.setLabel("Subject");
}
}
While addNotification isn’t a perfect replacement for a native tooltip, it’s a practical workaround when we need dynamic, context-aware user guidance.
In Dynamics 365 / Dataverse, sometimes we want to show or hide a ribbon button based on a form field value. But when the button is on a subgrid, it does not refresh automatically when a field changes on the form. We can handle this requirement using gridContext.refreshRibbon(). It is a small but very useful method that helps to refresh the subgrid ribbon without saving or reloading the form.
Here we are taking a simple scenario to understand the usage.
We will only show the New Case button on the Case Subgrid if the Preferred Method of Contact = Any else we will hide it.
Below is our JavaScript function to check the field value and return true or false. This function will be used as CustomRule for our Add New button command’s EnableRule on the subgrid. This function checks if the Preferred Method of Contact is ‘Any’. If yes, it returns true. Otherwise, it returns false.
We are passing CRM Parameter = PrimaryControl here.
Depending on where the button lives (Form ribbon or Subgrid ribbon), the correct context is passed.
On a form: PrimaryControl is the formContext.
On a Subgrid: PrimaryControl gives the context of the parent form hosting the subgrid.
Below we have customized the Add New Subgrid button for Case and added a new Enable Rule for its command.
Now on the form load, the Add New button on the subgrid will be hidden on the form load event.
But when we change the value for the Preferred Method of Contact we will not see any effect on the Add New button. For it to work we need to use the refershRibbon method of the grid’s context as shown below.
We added it on the onChange event for the Preferred Method of Contact field so that when a user changes it, the subgrid ribbon refreshes.
Now, when a user changes the Preferred Method of Contact, the subgrid ribbon will refresh and check again if the button should be visible.
As a result, now the Add New button appears on the Case subgrid when the Preferred Method of Contact is ‘Any’.
The ribbon refreshes immediately when the field changes to Email or any other value except Any.No need to save or reload the form.
JavaScript –
function showAddNewButtonOnCaseSubgrid(primaryControl) {
var formContext = primaryControl;
var preferredMethod = formContext.getAttribute("preferredcontactmethodcode");
var preferredMethodValue = preferredMethod.getValue();
// Check if Preferred Method is 'Any'
if (preferredMethodValue === 1) {
return true;
}
else {
return false;
}
}
function refreshCaseSubgridRibbon(executionContext) {
var formContext = executionContext.getFormContext();
var gridContext = formContext.getControl("Subgrid_Cases");
if (gridContext) {
gridContext.refreshRibbon();
}
}