Enhancing Dataverse Views with Custom Icons, Tooltips & Visualizations (Dynamics 365)


While working with Model-driven apps, sometimes a visual indicator can make a view much easier to scan.

For example, instead of displaying simple text like Priority – Hot, Warm and Cold.

we can display an icon based on the value along with the field value. And we can also display a tooltip when the user hovers over the icon.

Microsoft Power Apps supports adding custom icons and tooltips to columns in views using image web resources and JavaScript. The JavaScript function determines which image and tooltip should be displayed for each row.

How it works

The JavaScript function receives the row data and the user’s LCID:

function displayPriorityIcon(rowData, userLCID) {
    var data = JSON.parse(rowData);
    var priority = parseInt(data.prioritycode_Value, 10);

    switch (priority) {
        case 1:
            return ["custom_PriorityHigh", "High Priority"];
        case 2:
            return ["custom_PriorityMedium", "Medium Priority"];
        case 3:
            return ["custom_PriorityLow", "Low Priority"];
        default:
            return ["", ""];
    }
}

The function returns an array containing: [imageName, tooltip]

For example: return [“custom_PriorityHigh”, “High Priority”];

The image names refer to image web resources that we add to the solution.

The userLCID parameter can also be used when tooltip text needs to be localized for different users.

Microsoft recommends using 16 × 16 pixel images for these icons. Also, when working with Choice columns, it is better to use the underlying integer value rather than the label, since labels can be localized.

Configure the view

The JavaScript web resource and function are then specified for the column in the view.

Open the view for customization, select Edit Properties for the column

Specify the Web resource and Function Name there.

Once published, the appropriate icon and tooltip are displayed based on the row data.

This can be particularly useful for columns such as:

  • Priority
  • Risk
  • SLA status
  • Customer health
  • Approval status
  • Integration status

The icon provides the visual signal, while the tooltip provides additional context.

A note about Column Visualizations

There is also a newer option available in Power Apps: Custom Column Visualizations for grids and views.

These can render column data as a Heat Map, Line Chart, Radial Dial, or Star Rating, allowing users to compare values visually within a grid.

I covered this feature in more detail in my earlier post: Column Visualizations for Grids / Views (Preview) – Dataverse / Dynamics 365

So, if we simply need a small dynamic icon with a tooltip, custom icons are a useful option. If we need a richer visualization of the underlying data, Column Visualizations may be worth considering.

Custom icons are configured per view, whereas the newer Column Visualizations are configured on the column and automatically appear wherever that column is used in grids/views.

Custom icons are configured as part of the view definition, whereas Column Visualizations are configured at the column level.

Custom icons and tooltips are supported for read-only grids. They aren’t supported for editable grids.

Hope it helps ..

Advertisements

Drawer Lookup Control in Dynamics 365 (A better lookup experience on mobile)


I recently came across a small but useful addition to the Dynamics 365 / Power Apps lookup experience: the Drawer Lookup Control.

With the standard lookup, selecting a value typically opens the familiar lookup experience.

With Drawer Lookup, the records are displayed in a bottom-sheet style drawer, making it easier to search and select a record on a phone or tablet.

This is how it would look on Web –

How to Configure It

The configuration is quite simple. Open the form in Power Apps, select the lookup field, and go to Components.

Select:

  • + Component → Drawer Lookup Control

We can then configure the lookup click-through behavior:

  • Enable lookup clickthrough
  • Disable lookup clickthrough

The control can also be enabled for the Web, Mobile, and Tablet experiences.

This is especially useful when we have forms that are frequently used from mobile devices, and users need to select records from lookup lists like leads, contacts, accounts, work orders etc.

The Disable lookup clickthrough option can also help prevent users from accidentally navigating away from the current form when interacting with a lookup value.

It is a relatively small UI enhancement, but for mobile-heavy Dynamics 365 implementations, these small improvements can make the experience noticeably cleaner. If we have forms that are heavily used on phones or tablets, Drawer Lookup Control is definitely worth trying.

References

Hope it helps..

Column Visualizations for Grids / Views (Preview) –Dataverse / Dynamics 365


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

  1. Sign in to Power Apps (make.powerapps.com) → Solutions → open the table.
  2. Open the column to edit (or create a new one).
  3. Expand Advanced options → “Visualization”.
  4. Select one of: Heat Map, Line Chart, Radial Dial, Star Rating.
  5. 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.

Get all the details here: Column visualizations for grids in Power Apps

Hope it helps..

Advertisements

Business Process Flow Error: “Participating entity record of stage: X is not valid” (0x80040216) (Dynamics 365 / Dataverse)


Scenario

Working on a custom multi-entity Business Process Flow (Opportunity → Quote) recently, we ran into this error while trying to reactivate the process:

The BPF (custom_salesprocess) had already traversed several stages — Generate Opportunity → Qualify → Generate Quote → Initiate Deposit/Bank Guarantee → Sales Completion — before landing on PM Completion, which sits in the “Close” category.

Reactivating the process at that point is where it failed.

In a multi-entity BPF, each stage is bound to a specific entity via processstage.primaryentitytypecode, and the BPF instance record carries a lookup column per non-primary entity used in the flow (e.g. bpf_opportunityid, bpf_quoteid). When a stage tries to activate, Dataverse resolves the “participating entity” for that stage — and if the corresponding lookup on the instance is null, or points to a record that no longer exists (or the user can’t read), we get exactly this error.

Diagnosis

First, we check for the processtageid we got in the error – 0642302a-cc72-4d31-8e08-66d311f6f7b1

SELECT
    processid, 
    stagename,
    processstageid,
    stagecategoryname
FROM processstage
WHERE processid = '0d680f7c-6c3a-ef11-a316-00224896a8d5';

Then we confirm what entity each stage actually maps to using the processid:

PM Completion resolves to Quote (1084) — not Opportunity — which meant the BPF instance needed a valid bpf_quoteid to activate it.

Checked the instance directly:

SELECT * FROM custom_salesprocess WHERE businessprocessflowinstanceid = '682f0586-3660-f111-a826-00224892607c' or bpf_opportunityid = '9e5bdc52-62d4-4773-8d30-fba2089ceba2'

bpf_quoteid and bpf_quoteidname both came back NULL — despite an active Quote already existing against the Opportunity. Something along the way (likely the Quote being created outside the BPF’s native “generate related record” flow) never linked it back.

Fix

A quote existed, so it was a matter of linking it. The preferred route is the related-entity picker on the BPF stage bar in the Opportunity form (safest, goes through supported platform logic). Where that’s not viable, updating the lookup directly works:

UPDATE custom_salesprocess SET    bpf_quoteid = '18cc66e2-9280-f111-ab0f-7c1e528a7a2a' WHERE  businessprocessflowinstanceid = '682f0586-3660-f111-a826-00224892607c';

After the update, the process reactivated cleanly.

We got the options to Abandon and Finish the flow.

Rules of thumb

  • Multi-entity BPF errors on activation → check the stage’s primaryentitytypecode first. If it’s not the primary entity you’re working from, the instance needs a valid lookup to that other entity.
  • The instance table always carries one lookup column per entity in the flow (bpf_<entity>id) — go straight to that table and check for nulls before looking anywhere else.
  • A null lookup with a record that already exists usually means the record was created outside the BPF’s intended path (manually, via integration, or via a process that bypasses the stage’s native record-generation step) and never got linked back.
  • Before manually setting the lookup, confirm the target record’s statecode/statuscode — a Won/Closed or inactive record may satisfy the lookup but still fail stage entry criteria if a business rule checks for an active state.
  • Prefer the BPF’s own related-entity picker on the form over a direct table update where available — it’s the supported path and won’t skip any platform-side validation.

Key takeaway

“Participating entity record of stage: X is not valid (0x80040216)” on a multi-entity BPF almost always means the stage’s bound entity has no valid linked record on the BPF instance. Trace the stage to its entity via primaryentitytypecode, check the corresponding bpf_<entity>id lookup on the instance, and link a valid, active record.

Reference

Business process flows overview – Microsoft Learn

Advertisements

Comparing Web Resources (JavaScript) Across Multiple Dataverse Environments Using a Console App


We recently refactored a large JavaScript solution — replacing a number of deprecated methods with their supported equivalents, along with some performance improvements — the kind of change that touches a lot of files without changing what any of them are actually supposed to do. That solution needed to go out to three Production environments, and before rolling it out, we wanted to be sure of one thing: were all three Prod environments currently running the same JavaScript to begin with? If one of them had quietly drifted from the others over time — a direct hotfix, a missed deployment — we wanted to know that before deploying, not after.

That’s what led us to write this utility. And while our own case was three Prod environments, there’s nothing about it that ties it specifically to Dev/UAT/Prod — that’s just the most common shape for this kind of check. The source is really just “the environment whose solution I want to treat as the baseline,” and the target(s) are “however many environments I want to check that baseline against.” You could just as easily point the source at UAT and compare it against multiple Staging / DM / UAT instances, or point it at Prod and verify a DR/failover environment matches — any number of targets can be listed, and each is compared against the source independently, so you always know exactly which environment(s) are out of step rather than just “something, somewhere, differs.”

To keep this post simple, we’ll walk through a smaller example here — a Dev environment with two solutions that between them contain a handful of JS web resources — and compare it against UAT and Prod to confirm all three environments.

The approach

Rather than matching components by name across environments, we used the solutioncomponent entity to pull the exact web resource GUIDs belonging to our solution from the source environment (Dev). This matters because a component’s GUID stays the same wherever it travels via solution import — so the same GUID can be looked up directly in every other environment, with no name-matching guesswork involved.

For each web resource, in each environment:

  • Retrieve the content field (base64-encoded) and decode it.
  • Compute a SHA-256 hash of the decoded bytes.

If two environments produce the same hash for the same GUID, the files are guaranteed byte-identical. If the hashes differ, something changed — even a single character is enough to produce a completely different hash.

Sample Code –

private const int COMPONENTTYPE_WEBRESOURCE = 61;
private const int WEBRESOURCETYPE_JSCRIPT = 3;

private static List<Guid> GetJavaScriptWebResourceIds(IOrganizationService svc, List<string> solutionUniqueNames)
{
    var solutionQuery = new QueryExpression("solution")
    {
        ColumnSet = new ColumnSet("solutionid")
    };
    solutionQuery.Criteria.AddCondition("uniquename", ConditionOperator.In,
        solutionUniqueNames.Cast<object>().ToArray());
    var solutionIds = svc.RetrieveMultiple(solutionQuery).Entities
        .Select(s => s.Id).Cast<object>().ToArray();

    var compQuery = new QueryExpression("solutioncomponent")
    {
        ColumnSet = new ColumnSet("objectid")
    };
    compQuery.Criteria.AddCondition("solutionid", ConditionOperator.In, solutionIds);
    compQuery.Criteria.AddCondition("componenttype", ConditionOperator.Equal, COMPONENTTYPE_WEBRESOURCE);

    var allWebResourceIds = svc.RetrieveMultiple(compQuery).Entities
        .Select(e => (Guid)e["objectid"])
        .Distinct()
        .ToArray();

    // The Web Resource component type covers every kind of web resource in the solution -
    // JS, HTML, CSS, PNG, SVG, and so on - not JavaScript specifically. We only want the
    // actual scripts, so we filter again on the webresource entity's own type field.
    var wrQuery = new QueryExpression("webresource")
    {
        ColumnSet = new ColumnSet("name")
    };
    wrQuery.Criteria.AddCondition("webresourceid", ConditionOperator.In, allWebResourceIds.Cast<object>().ToArray());
    wrQuery.Criteria.AddCondition("webresourcetype", ConditionOperator.Equal, WEBRESOURCETYPE_JSCRIPT);

    return svc.RetrieveMultiple(wrQuery).Entities
        .Select(e => e.Id)
        .ToList();
}

private static string GetContentHash(IOrganizationService svc, Guid webResourceId)
{
    var e = svc.Retrieve("webresource", webResourceId, new ColumnSet("content"));
    var bytes = Convert.FromBase64String((string)e["content"]);

    using (var sha = SHA256.Create())
    {
        var hash = sha.ComputeHash(bytes);
        return BitConverter.ToString(hash).Replace("-", "");
    }
}

Running this against Dev + UAT + Prod, for every JavaScript web resource GUID found, gives a simple report per environment: MATCH, MISMATCH, or MISSING.

Going a step further — showing what actually changed

A MISMATCH on its own only tells you that something differs, not what. So we extended it to also diff the two files line by line whenever the hashes didn’t match, using a classic LCS (Longest Common Subsequence) based diff — the same underlying idea git diff uses.

private static List<string> DiffTextLines(string baseline, string other, int maxDiffs = 6)
{
    var a = baseline.Replace("\r\n", "\n").Split('\n');
    var b = other.Replace("\r\n", "\n").Split('\n');

    int n = a.Length, m = b.Length;
    var dp = new int[n + 1, m + 1];
    for (int i = n - 1; i >= 0; i--)
        for (int j = m - 1; j >= 0; j--)
            dp[i, j] = a[i] == b[j] ? dp[i + 1, j + 1] + 1 : Math.Max(dp[i + 1, j], dp[i, j + 1]);

    var diffs = new List<string>();
    int x = 0, y = 0;
    while (x < n && y < m && diffs.Count < maxDiffs)
    {
        if (a[x] == b[y]) { x++; y++; continue; }
        if (dp[x + 1, y] >= dp[x, y + 1]) { diffs.Add($"line {x + 1} removed: \"{a[x]}\""); x++; }
        else { diffs.Add($"line {y + 1} added: \"{b[y]}\""); y++; }
    }
    return diffs;
}

This walks a dynamic-programming grid to find the longest sequence of lines common to both files. Everything outside that common sequence is, by definition, either a removed line or an added one — which is exactly what shows up in the report instead of a plain “files differ”.

Every result also gets written out to a CSV, one row per web resource, with a hash column per environment, a Status column, and a DiffSummary column showing the actual line-level differences for anything that doesn’t match.

How it works, end to end

  1. Connects to the source environment (Dev) and reads solutioncomponent for the solution’s unique name, filtered to web resources only.
  2. For each web resource GUID found, fetches that same GUID from every target environment listed (UAT, Prod, or as many as you configure).
  3. Hashes the content from each environment and compares every target’s hash against the source’s hash — independently, so you know exactly which target(s) differ, not just that “something” differs.
  4. For anything that doesn’t match, runs the LCS diff and records the specific lines that changed.
  5. Writes a CSV report and saves every environment’s actual file content to disk, so any mismatch can be opened directly in a diff tool if the summary line isn’t enough.

Using it yourself

The console app is config-driven — no code changes needed to point it at your own solution and environments. config.json looks like this:

{
  "SolutionNames": ["YourSolutionUniqueName1", "YourSolutionUniqueName2"],
  "Source": {
    "Label": "Dev",
    "ConnectionString": "AuthType=ClientSecret;Url=https://yourorg-dev.crm.dynamics.com;ClientId=..;ClientSecret=..;"
  },
  "Targets": [
    { "Label": "UAT",  "ConnectionString": "AuthType=ClientSecret;Url=https://yourorg-uat.crm.dynamics.com;ClientId=..;ClientSecret=..;" },
    { "Label": "Prod", "ConnectionString": "AuthType=ClientSecret;Url=https://yourorg-prod.crm.dynamics.com;ClientId=..;ClientSecret=..;" }
  ],
  "OutputFolder": "output"
}
  • SolutionNames — the unique name(s) of the solution(s) holding your web resources. You can list more than one, as in the example above, if your JS is spread across a couple of solutions.
  • Source — the environment you trust as the baseline (usually Dev).
  • Targets — as many environments as you want to check, each compared independently against Source.

Once config.json is filled in, just run:

jscompare-fx.exe config.json

and the report and diffed files show up under the configured output folder.

https://github.com/nishantranacrm/JsCompareFx

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