When we use Power Apps Grid customizer control, there could be cases when the rendering depends on the other cells. We cannot control when the cell rendering happens, and the Power Apps Grid doesn’t always re-render the cells when the data wasn’t changed (or doesn’t provide the updated data). In this blog I’m talking about how to work around this issue, and create your own events for cell renderer.
The use case
This blog applies to all dependency cases, but it’s easier to explain looking to a specific use-case. Do you remember the example from the docs, which shows the CreditLimit in red or blue, depending on the amount value?
Now let’s create a similar control, where the colors depend on another cell. Let’s consider a table “Consumption“. The “Amount” column should be shown in different colors, depending on a column “Plan” (of type Choice/OptionSet). An example:
- For “Plan A”, it should turn orange, when the “Amount” is over 10.000. (otherwise green)
- For “Plan B” it should turn red when the Amount is over 100.000 (it’s a more expensive plan)
- For “Plan C” it should turn “darkred” when the Amount is over 500.000
Or if you prefer the definition as code:
const rules = {
//Plan A
"341560000" : (value: number) => value > 10000 ? "orange" : "green",
//Plan B
"341560001" : (value: number) => value > 100000 ? "red" : "green",
//Plan C
"341560001" : (value: number) => value > 500000 ? "darkred" : "green
}
The problem
The code is pretty simple, and similar with the example from the docs (which can be found here). I just have to check the plan code first. Here is my code:
{
["Integer"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
const {columnIndex, colDefs, rowData } = rendererParams;
const columnName = colDefs[columnIndex].name;
if(columnName==="diana_amount") {
const plan = (rowData as any)["diana_plan"] as string;
//console.log(plan);
const value = props.value as number;
const color = (rules as any)[plan](value);
return <Label style={{color}}>{props.formattedValue}</Label>
}
}
}
And it seems to work, until we edit the “Plan” value. The problem: after changing the plan, then “Amount” will be re-rendered, but the data we get as a parameter contains (sometimes) the old value for the “plan”. And there is no other event to force the re-rendering. Have a look:
I’m not sure if it’s a bug, or not. It’s an edge case, since we need to refresh another cell, not the one being edited.
The solution
We could try to force a refresh, by changing the control in edit mode, and then back (stopEditing). That could force a refresh, but it would cause a flickering on the screen, and that’s not nice.
I went with another approach: trigger an own event. And here is how I’ve implemented it.
The complete code can be found in my github repository:
https://github.com/brasov2de/GridCustomizerControl/tree/main/CellDependency
EventManager
First I’ve created an EventManager, a class which can trigger events and let the cells to attach to them. It’s easy to create; based on the CustomEvent API
export class EventManager{
private target : EventTarget;
private eventName: string;
constructor(eventName: string){
//didn't wanted to attach to a real DOM element, so I've created an own target
this.target = new EventTarget();
this.eventName = eventName;
}
public publish(rowId: string, value: any | null ){
//trigger an event, passing the rowId and value to the listeners
this.target.dispatchEvent(new CustomEvent(this.eventName, {detail: {rowId, value } }));
}
public subscribe(callback: any){
this.target.addEventListener(this.eventName, callback);
}
public unsubscribe(callback : any){
this.target.removeEventListener(this.eventName, callback);
}
}
I think is self explaining. The publish method will be called when the “plan” was changed. The subscribe and unsubscribe is supposed to be called inside the react component rendering the coloured labels.
This eventManager class instance is created inside my cellRenderer closure:
export const generateCellRendererOverrides = () => {
const eventManager = new EventManager("OnPlanChange");
return {
["Integer"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
//code for "Amount" renderer goes here
},
["OptionSet"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
//code to check if the "plan" value was changed
//return null, so let the grid create the control for you
return null;
}
}
}
React component for rendering the dependent cells – event listener
const Amount: React.FC<IAmountProps> =
({ value, plan, rowId , formattedValue, eventManager}: IAmountProps) => {
//keep the plan (dependency) in an internal state
const [currentPlan, setCurrentPlan] = React.useState(plan);
//this part takes care to detect when the cell was unloaded by the grid
const mounted = React.useRef(false);
React.useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
//this callback is responsible to change the "currentPlan" state;
//that way React rerenders the label
const onChanged = (evt: any) => {
const detail = evt.detail;
if(!mounted.current) return;
if(detail.rowId === rowId){ //ignore the events for the other cells
setCurrentPlan(detail.value);
}
};
// the magic happens here:
//when the component is created, subscribes to the eventManager
//the return at the end of effect takes care to unsubscribe this component from the eventManager
React.useEffect( () => {
if(!mounted.current){
return;
}
eventManager.subscribe(onChanged);
return () => { eventManager.unsubscribe(onChanged);}
}, [rowId]);
const colorFn = currentPlan ? rules.get(currentPlan) : undefined;
return <Label style={{color: colorFn ? colorFn(value) : "black"}}>{formattedValue}</Label>
};
export default Amount;
To say it in a few words: we keep the dependency in an internal state, and use the “React.useEffect” to attach when the component is created/unloaded. There we subscribe to the eventManager, and get notified when there was a change. The callback attached will set the state “currentPlan”, and React will re-render the cell. All cells from all rows are listening to the event, so we filter only the events for the current rowId .
The cell renderer looks now like this:
export const generateCellRendererOverrides = () => {
const eventManager = new EventManager("OnPlanChange");
return {
["Integer"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
const {columnIndex, colDefs, rowData } = rendererParams;
const columnName = colDefs[columnIndex].name;
if(columnName==="diana_amount") {
const plan = (rowData as any)["diana_plan"] as string;
return <Amount
value={props.value as number | null}
plan={plan}
eventManager={eventManager}
rowId={rowData?.[RECID] as string}
formattedValue={props.formattedValue}/>;
}
},
["OptionSet"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
//code goes here
return null;
}
}
}
Triggering the event
- In case you have implemented your own cell renderer, you know when the value was changed, so you could trigger there the eventManager.publish() method
- You don’t need to implement your own cell renderer for the cell you depend on
The case 1 is not very common, and means only calling the publish method, so I won’t go with that one. I’ll go with the case 2. The problem here is to detect that the value was changed. So I’ve implemented an own cache, where I track the last “plan” value per row (using a Map). Where a change is detected, we just call the eventManager.publish.
We just return null at the end of the render function. That way the the Power Apps Grid will use the standard controls.
export const generateCellRendererOverrides = () => {
const eventManager = new EventManager("OnPlanChange");
//we create a cache containing the combination: rowId->planValue
const planCache = new Map<string, string | null >();
return {
["Integer"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
//we saw that above
},
["OptionSet"]: (props: CellRendererProps, rendererParams: GetRendererParams) => {
const {columnIndex, colDefs, rowData } = rendererParams;
const columnName = colDefs[columnIndex].name;
if(columnName!=="diana_plan") return;
const rowId = rowData?.[RECID];
if(rowId===undefined) return;
const oldValue = planCache.get(rowId);
if(oldValue!=null && oldValue !== props.value){
//when there is a change, we trigger the event
eventManager.publish(rowId, props.value);
}
planCache.set(rowId, props.value as string ?? null);
//return null will use the standard component for Choices/OptionSet
return null;
}
}
}
That’s it. Now the color of the amount is changed right away.
Another use-case
Remember my older blog about calculated cells using Power Apps Grid customizer control: https://dianabirkelbach.wordpress.com/2022/09/19/implement-calculated-columns-with-power-apps-grid-control/ (s. screenshot below).
There I had a similar problem: the calculated “Next Appointment” was calculated based on “Scheduled Appointment” but it got refreshed only after I’ve clicked on another row. The fix works similar: creating own events, so I can force the refreshing right away. I’ve implemented this one too; you can find the code in my github repository: https://github.com/brasov2de/GridCustomizerControl/tree/main/CalculatedColumn_ForceRefresh
And here is the result:
Hope it helps!
Photo by The Lazy Artist Gallery: https://www.pexels.com/photo/woman-holding-remote-of-drone-1170064/
About the Author
Hi! My name is Diana Birkelbach and I’m a developer, working at ORBIS SE . Together with the team we develop generic components and accelerators for Dynamics 365 and Power Platform.
One of the biggest accomplishments are the Microsoft Most Valuable Professional Award (MVP) in Business Applications for 2021.
You can also find me in the Power Platform Community Forum, where I’m part of the Power Platform Community Super User Program (Appstronaut) .
References
Birkelback, D., (2023), ‘Power Apps Grid: Cell Renderer Dependency – Trigger Your Own Events’, available at: https://dianabirkelbach.wordpress.com/2023/10/26/power-apps-grid-cell-renderer-dependency-trigger-your-own-events/, [accessed 26th March 2024].