How Do You Build an Impact-Aware PCF Approval Control with React & Fluent UI

How Do You Build an Impact-Aware PCF Approval Control with React & Fluent UI

Why We Evolved from JavaScript to PCF 

In Part 1, we explained the business need for impact preview. In Part 2, we built server-side logic for conditional approval. Both the articles are provided towards the end of this blog.

Now we tackle the user experience

The Problem with Simple JavaScript Dialogs

  • 40% decline rate (planners lacked confidence without impact preview) 
  • No tier date visibility 
  • No task-level impact breakdown 
  • Binary approve/decline only (no “Review Later” option) 

What Planners Needed

  • “Which tier dates will shift?” 
  • “How many tasks are affected?” 
  • “Can I override calculated dates if system is wrong?” 
  • “What’s the delta (days advanced/delayed)?” 

Solution: Power Apps Component Framework (PCF) control with React, Fluent UI, and async custom actions. 

Result: 95% approval rate (planners confident with full context).

📸 Visual Preview: Staged Date Approval PCF Control 

Below is the production UI showing the approval dialog with tier date impact grid and task breakdown.

PCF Approval Dialog 

Key UI Elements Shown
1. Date Change Summary (top): Old CRD/MCSD → New CRD/MCSD with visual indicators 
2. Tier Date Impact Table (middle): Original vs. Proposed dates for Tier 0/1/2, editable with validation 
3. Resource Task Impact Grid (bottom): All affected RWOSTs with current/proposed start/end dates, delays highlighted in yellow 
4. Action Buttons (footer): Accept Changes (green) | Review Later | Decline Changes 
5. Loading State (not shown): Spinner during async custom action calculation 

PCF Architecture Overview

User Opens Demand Record 
    ↓ 
Form OnLoad → PCF Control Initializes 
    ↓ 
PCF Detects: contoso_new_crd OR contoso_new_mcsd populated 
    ↓ 
Renders React Dialog Component 
    ↓ 
useEffect Hook Fires 
    ↓ 
Calls Custom Action: "CalculateTierDatesAndDurations" 
    ↓ 
Custom Action → Plugin → Business Logic → Calculated Tier Dates 
    ↓ 
PCF Receives Response (1-2 seconds) 
    ↓ 
setState: proposedDates, rwostRows 
    ↓ 
Dialog Renders: Date Summary, Tier Table, Task Grid 
    ↓ 
User Reviews, Optionally Edits Proposed Dates 
    ↓ 
User Clicks "Accept Changes" 
    ↓ 
PCF Calls Custom Action: "ReviseSchedule" 
    ↓ 
Plugin Cascades: Demand → Work Order → Tier Dates → Tasks 
    ↓ 
Audit Log Updated → Dialog Closes → Form Refreshes

PCF Control Structure 

1. Control Manifest (ControlManifest.Input.xml) 

<?xml version="1.0" encoding="utf-8" ?> 
<manifest> 
  <control namespace="Contoso" constructor="CRDMCSDApprovalControl" version="1.0.0"> 
    <property name="demandId" display-name-key="Demand ID" of-type="SingleLine.Text" usage="bound" required="true" /> 
    <property name="oldCRD" display-name-key="Current CRD" of-type="DateAndTime.DateOnly" usage="bound" required="false" /> 
    <property name="newCRD" display-name-key="Staged CRD" of-type="DateAndTime.DateOnly" usage="bound" required="false" /> 
    <property name="oldMCSD" display-name-key="Current MCSD" of-type="DateAndTime.DateOnly" usage="bound" required="false" /> 
    <property name="newMCSD" display-name-key="Staged MCSD" of-type="DateAndTime.DateOnly" usage="bound" required="false" /> 
    <property name="approvalRequired" display-name-key="Approval Required" of-type="TwoOptions" usage="bound" required="false" /> 
 
    <resources> 
      <code path="index.ts" order="1" /> 
      <css path="css/CRDMCSDApprovalControl.css" order="1" /> 
    </resources> 
  </control> 
</manifest> 

2. PCF Entry Point (index.ts) 

import { IInputs, IOutputs } from "./generated/ManifestTypes"; 
import * as React from "react"; 
import * as ReactDOM from "react-dom"; 
import { ApprovalDialog, IApprovalDialogProps } from "./components/ApprovalDialog"; 
 
export class CRDMCSDApprovalControl 
    implements ComponentFramework.StandardControl<IInputs, IOutputs> 
{ 
    private _container: HTMLDivElement; 
    private _context: ComponentFramework.Context<IInputs>; 
    private _notifyOutputChanged: () => void; 
 
    public init( 
        context: ComponentFramework.Context<IInputs>, 
        notifyOutputChanged: () => void, 
        state: ComponentFramework.Dictionary, 
        container: HTMLDivElement 
    ): void { 
        this._context = context; 
        this._container = container; 
        this._notifyOutputChanged = notifyOutputChanged; 
    } 
 
    public updateView(context: ComponentFramework.Context<IInputs>): void { 
        this._context = context; 
 
        // Check if approval is required 
        const approvalRequired = context.parameters.approvalRequired.raw; 
        const newCRD = context.parameters.newCRD.raw; 
        const newMCSD = context.parameters.newMCSD.raw; 
 
        if (approvalRequired && (newCRD || newMCSD)) { 
            this.renderApprovalDialog(); 
        } else { 
            // Clear dialog if no approval needed 
            ReactDOM.unmountComponentAtNode(this._container); 
        } 
    } 
 
    private renderApprovalDialog(): void { 
        const props: IApprovalDialogProps = { 
            demandId: this._context.parameters.demandId.raw || "", 
            oldCRD: this._context.parameters.oldCRD.raw, 
            newCRD: this._context.parameters.newCRD.raw, 
            oldMCSD: this._context.parameters.oldMCSD.raw, 
            newMCSD: this._context.parameters.newMCSD.raw, 
            onAccept: this.handleAccept.bind(this), 
            onDecline: this.handleDecline.bind(this), 
            onReviewLater: this.handleReviewLater.bind(this), 
            xrmContext: this._context.webAPI, 
        }; 
 
        ReactDOM.render( 
            React.createElement(ApprovalDialog, props), 
            this._container 
        ); 
    } 
 
    private handleAccept(): void { 
        // Trigger form save to commit approved changes 
        this._context.parameters.demandId.refresh(); 
        this._notifyOutputChanged(); 
    } 
 
    private handleDecline(): void { 
        // Trigger form save to clear staged fields 
        this._context.parameters.demandId.refresh(); 
        this._notifyOutputChanged(); 
    } 
 
    private handleReviewLater(): void { 
        // Simply close dialog without making changes 
        ReactDOM.unmountComponentAtNode(this._container); 
    } 
 
    public getOutputs(): IOutputs { 
        return {}; 
    } 
 
    public destroy(): void { 
        ReactDOM.unmountComponentAtNode(this._container); 
    } 
} 

React Approval Dialog Component 

3. ApprovalDialog.tsx (Main Component) 

import * as React from "react"; 
import { useState, useEffect } from "react"; 
import { Dialog, DialogType, DialogFooter } from "@fluentui/react/lib/Dialog"; 
import { PrimaryButton, DefaultButton } from "@fluentui/react/lib/Button"; 
import { Spinner, SpinnerSize } from "@fluentui/react/lib/Spinner"; 
import { Stack, Text, Icon } from "@fluentui/react"; 
import { DateChangeSummary } from "./DateChangeSummary"; 
import { TierDateTable } from "./TierDateTable"; 
import { TaskImpactGrid } from "./TaskImpactGrid"; 
 
export interface IApprovalDialogProps { 
    demandId: string; 
    oldCRD: string | null; 
    newCRD: string | null; 
    oldMCSD: string | null; 
    newMCSD: string | null; 
    onAccept: () => void; 
    onDecline: () => void; 
    onReviewLater: () => void; 
    xrmContext: ComponentFramework.WebApi; 
} 
 
export interface IProposedDates { 
    moveInDate: string; 
    tier0StartDate: string; 
    tier1StartDate: string; 
    tier2StartDate: string; 
} 
 
export interface IRWOSTRow { 
    id: string; 
    resourceName: string; 
    task: string; 
    role: string; 
    currentStartDate: string; 
    proposedStartDate: string; 
    currentEndDate: string; 
    proposedEndDate: string; 
    isDelay: boolean; 
    hasValidationError: boolean; 
} 
 
export function ApprovalDialog(props: IApprovalDialogProps): JSX.Element { 
    const [isCalculating, setIsCalculating] = useState<boolean>(true); 
    const [proposedDates, setProposedDates] = useState<IProposedDates | null>(null); 
    const [rwostRows, setRwostRows] = useState<IRWOSTRow[]>([]); 
    const [hasErrors, setHasErrors] = useState<boolean>(false); 
    const [calculationError, setCalculationError] = useState<string | null>(null); 
    const [isSaving, setIsSaving] = useState<boolean>(false); 
 
    // Calculate proposed dates on mount 
    useEffect(() => { 
        calculateProposedDates(); 
    }, []); 
 
    const calculateProposedDates = async (): Promise<void> => { 
        setIsCalculating(true); 
        setCalculationError(null); 
 
        try { 
            // Call custom action to calculate tier dates 
            const request = { 
                demandId: props.demandId, 
                newCRD: props.newCRD, 
                newMCSD: props.newMCSD, 
            }; 
 
            const response = await props.xrmContext.execute({ 
                name: "contoso_CalculateTierDatesAndDurations", 
                parameters: request, 
            } as any); 
 
            // Check if response is OK (custom actions can return non-200 status) 
            if (!response.ok) { 
                const errorText = await response.text(); 
                throw new Error(`Custom action failed (${response.status}): ${errorText}`); 
            } 
 
            const contentType = response.headers.get("content-type"); 
            if (!contentType || !contentType.includes("application/json")) { 
                // Dataverse sometimes returns HTML error pages on validation failures 
                const errorHtml = await response.text(); 
                throw new Error("Server returned non-JSON response. Check Plugin Trace Log for details."); 
            } 
 
            const result = await response.json(); 
 
            // Validate response structure 
            if (!result.moveInDate || !result.tier0StartDate) { 
                throw new Error("Invalid response from server: Missing required date fields."); 
            } 
 
            setProposedDates({ 
                moveInDate: result.moveInDate, 
                tier0StartDate: result.tier0StartDate, 
                tier1StartDate: result.tier1StartDate, 
                tier2StartDate: result.tier2StartDate, 
            }); 
 
            setRwostRows(result.affectedTasks || []); 
        } catch (error: any) { 
            console.error("Calculation failed:", error); 
 
            // Extract user-friendly error message 
            let userMessage = "Unable to calculate tier dates. Please enter dates manually."; 
 
            if (error.message) { 
                // Check for known error patterns 
                if (error.message.includes("work order")) { 
                    userMessage = "No work order found for this demand. Please create a work order first."; 
                } else if (error.message.includes("tier duration")) { 
                    userMessage = "Tier durations not configured on incident type. Please contact system administrator."; 
                } else if (error.message.includes("Plugin Trace Log")) { 
                    userMessage = "Server error occurred. Check Plugin Trace Log for details."; 
                } else { 
                    userMessage = error.message; 
                } 
            } 
 
            setCalculationError(userMessage); 
        } finally { 
            setIsCalculating(false); 
        } 
    }; 
 
    const handleAcceptChanges = async (): Promise<void> => { 
        // Validate tier date sequence 
        if (proposedDates && !validateTierDateSequence(proposedDates)) { 
            alert("Invalid tier date sequence. Tier 0 must come before Tier 1, which must come before Tier 2."); 
            return; 
        } 
 
        setIsSaving(true); 
 
        try { 
            // Call custom action to revise schedule 
            const request = { 
                demandId: props.demandId, 
                mode: 1, // Accept mode 
                shippingDate: calculateShippingDate(props.newCRD, props.newMCSD), 
                moveInDate: proposedDates?.moveInDate || null, 
                tier0Date: proposedDates?.tier0StartDate || null, 
                tier1Date: proposedDates?.tier1StartDate || null, 
                tier2Date: proposedDates?.tier2StartDate || null, 
                rwosts: rwostRows 
                    .filter((r) => !r.isDelay) 
                    .map((r) => ({ 
                        rwostId: r.id, 
                        plannedStartDate: r.proposedStartDate, 
                        plannedEndDate: r.proposedEndDate, 
                    })), 
            }; 
 
            await props.xrmContext.execute({ 
                name: "contoso_ReviseSchedule", 
                parameters: request, 
            } as any); 
 
            props.onAccept(); 
        } catch (error: any) { 
            console.error("Failed to accept changes:", error); 
            alert("Failed to accept changes: " + error.message); 
        } finally { 
            setIsSaving(false); 
        } 
    }; 
 
    const handleDeclineChanges = async (): Promise<void> => { 
        setIsSaving(true); 
 
        try { 
            // Call custom action to decline (mode = 2) 
            const request = { 
                demandId: props.demandId, 
                mode: 2, // Decline mode 
            }; 
 
            await props.xrmContext.execute({ 
                name: "contoso_ReviseSchedule", 
                parameters: request, 
            } as any); 
 
            props.onDecline(); 
        } catch (error: any) { 
            console.error("Failed to decline changes:", error); 
            alert("Failed to decline changes: " + error.message); 
        } finally { 
            setIsSaving(false); 
        } 
    }; 
 
    const validateTierDateSequence = (dates: IProposedDates): boolean => { 
        const tier0 = new Date(dates.tier0StartDate); 
        const tier1 = new Date(dates.tier1StartDate); 
        const tier2 = new Date(dates.tier2StartDate); 
 
        if (tier1 < tier0) return false; 
        if (tier2 < tier1) return false; 
 
        return true; 
    }; 
 
    const calculateShippingDate = (crd: string | null, mcsd: string | null): string | null => { 
        // Priority: MCSD > CRD 
        return mcsd || crd; 
    }; 
 
    const dialogContentProps = { 
        type: DialogType.normal, 
        title: "Accept CRD/MCSD Changes to Schedule", 
        showCloseButton: true, 
    }; 
 
    const modalProps = { 
        isBlocking: true, 
        styles: { 
            main: { 
                maxWidth: "90vw", 
                minWidth: "1125px", 
                maxHeight: "90vh", 
            }, 
        }, 
    }; 
 
    const hasValidationErrors = rwostRows.some((r) => r.hasValidationError); 
 
    return ( 
        <Dialog 
            hidden={false} 
            onDismiss={props.onReviewLater} 
            dialogContentProps={dialogContentProps} 
            modalProps={modalProps} 
        > 
            <Stack tokens={{ childrenGap: 12 }}> 
                {/* Loading State */} 
                {isCalculating && ( 
                    <div style={{ padding: "10px 14px", backgroundColor: "#fff4ce", borderRadius: "4px" }}> 
                        <Stack horizontal verticalAlign="center" tokens={{ childrenGap: 8 }}> 
                            <Spinner size={SpinnerSize.small} /> 
                            <Text>Calculating proposed dates...</Text> 
                        </Stack> 
                    </div> 
                )} 
 
                {/* Calculation Error */} 
                {calculationError && ( 
                    <div style={{ padding: "10px 14px", backgroundColor: "#fde7e9", borderRadius: "4px" }}> 
                        <Stack horizontal verticalAlign="center" tokens={{ childrenGap: 8 }}> 
                            <Icon iconName="ErrorBadge" styles={{ root: { color: "#d13438" } }} /> 
                            <Text>{calculationError}</Text> 
                        </Stack> 
                    </div> 
                )} 
 
                {/* Date Change Summary */} 
                {!isCalculating && ( 
                    <DateChangeSummary 
                        oldCRD={props.oldCRD} 
                        newCRD={props.newCRD} 
                        oldMCSD={props.oldMCSD} 
                        newMCSD={props.newMCSD} 
                    /> 
                )} 
 
                {/* Tier Date Impact Table */} 
                {!isCalculating && proposedDates && ( 
                    <TierDateTable 
                        proposedDates={proposedDates} 
                        onChange={setProposedDates} 
                        onValidationError={setHasErrors} 
                    /> 
                )} 
 
                {/* Resource Task Impact Grid */} 
                {!isCalculating && rwostRows.length > 0 && ( 
                    <TaskImpactGrid tasks={rwostRows} onChange={setRwostRows} /> 
                )} 
            </Stack> 
 
            {/* Dialog Footer */} 
            {!isCalculating && ( 
                <DialogFooter> 
                    <PrimaryButton 
                        text="Accept Changes" 
                        onClick={handleAcceptChanges} 
                        disabled={hasErrors || isSaving || hasValidationErrors} 
                        styles={{ 
                            root: { backgroundColor: "#107c10" }, 
                            rootHovered: { backgroundColor: "#0e6b0e" }, 
                        }} 
                        iconProps={{ iconName: "CheckMark" }} 
                    /> 
                    <DefaultButton 
                        text="Review Later" 
                        onClick={props.onReviewLater} 
                        disabled={isSaving} 
                        iconProps={{ iconName: "Clock" }} 
                    /> 
                    <DefaultButton 
                        text="Decline Changes" 
                        onClick={handleDeclineChanges} 
                        disabled={isSaving} 
                        iconProps={{ iconName: "Cancel" }} 
                    /> 
                </DialogFooter> 
            )} 
 
            {/* Saving Overlay */} 
            {isSaving && ( 
                <div style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", backgroundColor: "rgba(255,255,255,0.8)", display: "flex", alignItems: "center", justifyContent: "center" }}> 
                    <Spinner size={SpinnerSize.large} label="Saving schedule changes..." /> 
                </div> 
            )} 
        </Dialog> 
    ); 
} 

Critical: WebAPI Exception Handling 

Dataverse custom actions can fail in unexpected ways:

  1. Non-JSON responses: When plugin throws exception, Dataverse may return HTML error page instead of JSON 
  1. Non-200 status codes: Custom action validation failures return 400/500 but response body format varies 
  1. Empty responses: If plugin doesn’t set output parameters, response.json() may throw 

Best Practices
✅ Always check response.ok before calling response.json() 
✅ Validate content-type header to detect HTML error pages 
✅ Validate response structure (check for required fields) 
✅ Provide user-friendly error messages (not raw plugin exceptions) 
✅ Log full error to console for debugging (keep technical details out of UI)

User Experience: If custom action fails, dialog still renders with empty proposed dates, allowing planner to manually enter dates and proceed. This prevents a server error from blocking the entire approval workflow. 

Custom Actions: Server-Side Calculation & Revision 

4. Custom Action: CalculateTierDatesAndDurations 

Purpose: Calculate proposed tier dates and affected tasks based on new shipping date. 

Input Parameters

  • demandId (String) 
  • newCRD (DateTime) 
  • newMCSD (DateTime) 

Output Parameters

  • moveInDate (String – formatted MM/dd/yyyy) 
  • tier0StartDate (String) 
  • tier1StartDate (String) 
  • tier2StartDate (String) 
  • affectedTasks (String – JSON array of IRWOSTRow[]) 

Plugin Implementation (Simplified): 

public class CalculateTierDatesAndDurationsAction : IPlugin 
{ 
    public void Execute(IServiceProvider serviceProvider) 
    { 
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext)); 
        var service = serviceFactory.CreateOrganizationService(context.UserId); 
 
        // Get input parameters 
        string demandId = (string)context.InputParameters["demandId"]; 
        DateTime? newCRD = context.InputParameters.Contains("newCRD") 
            ? (DateTime?)context.InputParameters["newCRD"] 
            : null; 
        DateTime? newMCSD = context.InputParameters.Contains("newMCSD") 
            ? (DateTime?)context.InputParameters["newMCSD"] 
            : null; 
 
        // Calculate shipping date (MCSD > CRD) 
        DateTime shippingDate = newMCSD ?? newCRD ?? DateTime.UtcNow; 
 
        // Get work order and calculate move-in date 
        Entity workOrder = GetWorkOrderForDemand(demandId, service); 
        int shipDays = workOrder.GetAttributeValue<int>("contoso_estimated_ship_days"); 
        DateTime moveInDate = shippingDate.AddDays(shipDays); 
 
        // Get tier durations from incident type 
        int tier0Duration = 7; 
        int tier1Duration = 14; 
        int tier2Duration = 7; 
 
        // Calculate tier dates (backward from move-in) 
        DateTime tier2Start = moveInDate; 
        DateTime tier1Start = tier2Start.AddDays(-tier2Duration); 
        DateTime tier0Start = tier1Start.AddDays(-tier1Duration); 
 
        // Query affected RWOSTs 
        var affectedTasks = GetAffectedRWOSTs(workOrder.Id, service); 
 
        // Set output parameters 
        context.OutputParameters["moveInDate"] = FormatDate(moveInDate); 
        context.OutputParameters["tier0StartDate"] = FormatDate(tier0Start); 
        context.OutputParameters["tier1StartDate"] = FormatDate(tier1Start); 
        context.OutputParameters["tier2StartDate"] = FormatDate(tier2Start); 
        context.OutputParameters["affectedTasks"] = SerializeToJson(affectedTasks); 
    } 
 
    private string FormatDate(DateTime date) 
    { 
        return date.ToString("MM/dd/yyyy"); 
    } 
} 

5. Custom Action: ReviseSchedule 

Purpose: Apply approved changes to demand, work order, tier dates, and tasks. 

Input Parameters

  • demandId (String) 
  • mode (Integer – 1=Accept, 2=Decline) 
  • shippingDate (String) 
  • moveInDate (String) 
  • tier0Date (String) 
  • tier1Date (String) 
  • tier2Date (String) 
  • rwosts (String – JSON array of RWOST updates) 

Plugin Implementation (Simplified):

public class ReviseScheduleAction : IPlugin 
{ 
    private const int ACCEPT_MODE = 1; 
    private const int DECLINE_MODE = 2; 
 
    public void Execute(IServiceProvider serviceProvider) 
    { 
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext)); 
        var service = serviceFactory.CreateOrganizationService(context.UserId); 
 
        string demandId = (string)context.InputParameters["demandId"]; 
        int mode = (int)context.InputParameters["mode"]; 
 
        if (mode == ACCEPT_MODE) 
        { 
            HandleAccept(demandId, context, service); 
        } 
        else if (mode == DECLINE_MODE) 
        { 
            HandleDecline(demandId, service); 
        } 
    } 
 
    private void HandleAccept(string demandId, IPluginExecutionContext context, IOrganizationService service) 
    { 
        Guid demandGuid = new Guid(demandId); 
 
        // Update demand: Copy new_crd → crd, new_mcsd → mcsd 
        Entity demandUpdate = new Entity("contoso_demand", demandGuid); 
        demandUpdate["contoso_crd"] = ParseDate(context.InputParameters["shippingDate"]); 
        demandUpdate["contoso_new_crd"] = null; // Clear staging 
        demandUpdate["contoso_new_mcsd"] = null; // Clear staging 
        demandUpdate["contoso_approval_required"] = false; 
        demandUpdate["contoso_approval_flag"] = true; 
        service.Update(demandUpdate); 
 
        // Update work order tier dates 
        Entity workOrder = GetWorkOrderForDemand(demandGuid, service); 
        Entity workOrderUpdate = new Entity("msdyn_workorder", workOrder.Id); 
        workOrderUpdate["contoso_move_in_date"] = ParseDate(context.InputParameters["moveInDate"]); 
        workOrderUpdate["contoso_tier0_planned_start_date"] = ParseDate(context.InputParameters["tier0Date"]); 
        workOrderUpdate["contoso_tier1_planned_start_date"] = ParseDate(context.InputParameters["tier1Date"]); 
        workOrderUpdate["contoso_tier2_planned_start_date"] = ParseDate(context.InputParameters["tier2Date"]); 
        service.Update(workOrderUpdate); 
 
        // Update RWOSTs 
        string rwostsJson = (string)context.InputParameters["rwosts"]; 
        var rwosts = DeserializeRWOSTs(rwostsJson); 
        foreach (var rwost in rwosts) 
        { 
            Entity rwostUpdate = new Entity("msdyn_resourcerequirement", new Guid(rwost.rwostId)); 
            rwostUpdate["msdyn_fromdate"] = ParseDate(rwost.plannedStartDate); 
            rwostUpdate["msdyn_todate"] = ParseDate(rwost.plannedEndDate); 
            service.Update(rwostUpdate); 
        } 
    } 
 
    private void HandleDecline(string demandId, IOrganizationService service) 
    { 
        Guid demandGuid = new Guid(demandId); 
 
        Entity demandUpdate = new Entity("contoso_demand", demandGuid); 
        demandUpdate["contoso_new_crd"] = null; // Clear staging 
        demandUpdate["contoso_new_mcsd"] = null; // Clear staging 
        demandUpdate["contoso_approval_required"] = false; 
        demandUpdate["contoso_approval_flag"] = false; 
        service.Update(demandUpdate); 
    } 
} 

Key Takeaways 

  1. PCF enables rich UX: React + Fluent UI provides production-grade approval dialog 
  1. Async calculation critical: Custom action calculates tier dates server-side (business logic centralization) 
  1. State management: React hooks (useState, useEffect) manage loading, validation, errors 
  1. Graceful degradation: If calculation fails, allow manual date entry 
  1. Reusability: Same PCF control embedded in Demand form, Gantt view, Summary grid 
  1. Production metrics: 95% approval rate vs. 60% with simple dialog (impact preview matters!) 

    Complete 3-Part Series 

    Part 1: Strategy & Business Architecture – Why this pattern, when to use, ROI 
    Part 2: Server-Side Plugins & Transaction Pipelines – C# decision logic, cascading 
    Part 3: Client-Side PCF & Custom Actions (You Are Here) 

    After going through the blog series, if you are left with queries, reach out to our experts.

    Sandip Paul

    Sandip Paul

    Sandip Paul is a Technical Architect at Netwoven based in the bay area. He has over 13 years of experience in software development and consulting working with both large and small customers. He is experienced in all the three Microsoft clouds: Office 365, Dynamics 365 and Azure. Sandip has worked with Netwoven for over 10 years building scalable systems using Microsoft technologies. He specializes in design and implementation of SharePoint, .NET, and Frontend technologies. Sandip holds a Bachelor of Technology degree in Computer Science from West Bengal University of Technology, Kolkata.

    Leave a comment

    Your email address will not be published. Required fields are marked *