logo
Published on

Connecting ACEs to Real Data with Graph API

An ACE that shows static or hardcoded data is a demo. An ACE that surfaces real pending approvals, live task counts, or up-to-date meeting information β€” connected to Graph β€” is a production tool that earns a permanent place on the dashboard.
The challenge is that ACEs have a subtly different async lifecycle compared to web parts. onInit must complete synchronously before the card renders, but Graph calls are async. This article shows you the correct pattern for wiring Graph to an ACE, with a practical pending approvals example.


πŸ—οΈ Scaffold the ACE Project

Run the Yeoman generator and select the options exactly as shown below. The Adaptive Card Extension component type is only available from SPFx 1.16+, so make sure you are on Node 18 and the latest generator.

yo @microsoft/sharepoint

Answer the prompts as follows:

PromptValue
What is your solution name?spfx-ace-approvals
Which type of client-side component to create?Adaptive Card Extension
What is your Adaptive Card Extension name?ApprovalsCard
What is your Adaptive Card Extension description?Pending approvals from Graph API
Which template do you want to use?Generic Card Template

Why Generic Card Template? The Primary Text and Image Card templates pre-wire specific card view layouts that can be harder to customise. Generic gives you a clean CardView and QuickView scaffold you control from scratch.

After scaffolding completes you will have this structure:

spfx-ace-approvals/
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ package-solution.json       ← add webApiPermissionRequests here
β”‚   └── serve.json                  ← configure pageUrl for hosted workbench
β”œβ”€β”€ src/
β”‚   └── adaptiveCardExtensions/
β”‚       └── approvalsCard/
β”‚           β”œβ”€β”€ ApprovalsCardAdaptiveCardExtension.ts   ← main ACE class
β”‚           β”œβ”€β”€ ApprovalsCardAdaptiveCardExtension.manifest.json
β”‚           β”œβ”€β”€ cardView/
β”‚           β”‚   └── CardView.ts     ← card view template
β”‚           └── quickView/
β”‚               └── QuickView.ts    ← quick view template
└── package.json

πŸ“¦ Install Required Packages

npm install @pnp/sp @pnp/graph --save

βš™οΈ Configure serve.json for the Hosted Workbench

Open config/serve.json and set pageUrl to a modern SharePoint page in your tenant. The local workbench does not support ACEs β€” you must use the hosted workbench.

{
  "$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.config-2.0.schema.json",
  "port": 4321,
  "https": true,
  "serveConfigurations": {
    "default": {
      "pageUrl": "https://<tenant>.sharepoint.com/sites/<site>/_layouts/workbench.aspx",
      "adaptiveCardExtensionId": "<your-ace-id-from-manifest>",
      "adaptiveCardExtensionProperties": {
        "dataSource": "graph"
      }
    }
  }
}

Replace <tenant>, <site>, and <your-ace-id-from-manifest> with your values. The adaptiveCardExtensionId value is the id field in ApprovalsCardAdaptiveCardExtension.manifest.json.


πŸ” Add Graph Permissions to package-solution.json

For the Planner/Tasks Graph endpoint, open config/package-solution.json and add:

{
  "$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
  "solution": {
    "name": "spfx-ace-approvals-client-side-solution",
    "id": "<solution-id>",
    "version": "1.0.0.0",
    "includeClientSideAssets": true,
    "skipFeatureDeployment": true,
    "isDomainIsolated": false,
    "developer": {
      "name": "",
      "websiteUrl": "",
      "privacyUrl": "",
      "termsOfUseUrl": "",
      "mpnId": "Undefined-1.18.2"
    },
    "webApiPermissionRequests": [
      { "resource": "Microsoft Graph", "scope": "Tasks.Read" },
      { "resource": "Microsoft Graph", "scope": "Tasks.ReadWrite" }
    ]
  },
  "paths": {
    "zippedPackage": "solution/spfx-ace-approvals.sppkg"
  }
}

For a SharePoint list approach, no additional Graph scopes are needed beyond the SPFx context.


πŸ—ΊοΈ The ACE Async Pattern

In a React web part, you initiate data fetching in useEffect after the first render β€” the component mounts with a loading state, then updates when data arrives.

In an ACE, the pattern is:

  1. Set a loading state in onInit synchronously
  2. Fire the async Graph call without awaiting it in onInit
  3. When the call resolves, call this.setState() β€” the ACE re-renders the card and quick view automatically
public async onInit(): Promise<void> {
  // Set loading state synchronously β€” card renders immediately with a spinner
  this.state = {
    approvals: [],
    loading: true,
    error: null
  };

  // Register views
  this.quickViewNavigator.register(QUICK_VIEW_REGISTRY_ID, () => new QuickView());

  // Fire async Graph call β€” do NOT await here
  this._loadApprovals();

  return Promise.resolve(); // onInit completes immediately
}

// This runs after onInit β€” calls setState when data arrives
private async _loadApprovals(): Promise<void> {
  try {
    const data = await this._fetchPendingApprovals();
    this.setState({ approvals: data, loading: false, error: null });
  } catch (err) {
    this.setState({ approvals: [], loading: false, error: 'Failed to load approvals.' });
  }
}

This is the critical pattern difference from web parts. If you await the Graph call inside onInit, the card does not render until the call completes β€” on a slow connection, the card is blank for seconds. Fire and forget in onInit, call setState when data arrives.


πŸ”§ Graph Service β€” Pending Approvals via Microsoft Graph

Microsoft Approvals in Teams stores approval requests as Tasks in the Microsoft Graph Tasks API. Alternatively, Power Automate flows can write approval data to a SharePoint list or Teams adaptive card β€” making it accessible via Graph or PnPjs.

This example fetches tasks assigned to the current user from the Graph Tasks/Planner endpoint:

src/adaptiveCardExtensions/approvalsCard/services/ApprovalsService.ts

import { MSGraphClientV3 } from '@microsoft/sp-http';

export interface IApprovalItem {
  id: string;
  title: string;
  requestedBy: string;
  daysAgo: number;
  priority: string;
  url: string;
}

export class ApprovalsService {
  private readonly _client: MSGraphClientV3;

  constructor(client: MSGraphClientV3) {
    this._client = client;
  }

  // Fetch tasks from Planner assigned to current user
  public async getPendingApprovals(): Promise<IApprovalItem[]> {
    const response = await this._client
      .api('/me/planner/tasks')
      .filter(`percentComplete eq 0`)
      .select('id,title,createdDateTime,priority,assignments,planId')
      .top(10)
      .get();

    const tasks = response.value as any[];

    return tasks.map(task => ({
      id: task.id,
      title: task.title,
      requestedBy: 'See task details',
      daysAgo: Math.floor(
        (Date.now() - new Date(task.createdDateTime).getTime()) / (1000 * 60 * 60 * 24)
      ),
      priority: this._mapPriority(task.priority),
      url: `https://tasks.office.com/Home/Task/${task.id}`
    }));
  }

  // Alternatively: fetch from a SharePoint list updated by Power Automate
  public async getApprovalsFromList(
    sp: any,
    listName: string
  ): Promise<IApprovalItem[]> {
    const items = await sp.web.lists
      .getByTitle(listName)
      .items
      .filter(`ApproverEmail eq '${this._currentUserEmail}'`)
      .filter(`and Status eq 'Pending'`)
      .select('Id', 'Title', 'RequesterName', 'Created', 'Priority')
      .orderBy('Created', true)
      .top(10)();

    return items.map((item: any) => ({
      id: item.Id.toString(),
      title: item.Title,
      requestedBy: item.RequesterName ?? 'Unknown',
      daysAgo: Math.floor(
        (Date.now() - new Date(item.Created).getTime()) / (1000 * 60 * 60 * 24)
      ),
      priority: item.Priority ?? 'Normal',
      url: `${window.location.origin}${item['odata.editLink'] ?? ''}`
    }));
  }

  private _mapPriority(num: number): string {
    if (num <= 3) return 'Urgent';
    if (num <= 5) return 'Important';
    return 'Normal';
  }

  private get _currentUserEmail(): string {
    return (window as any)._spPageContextInfo?.userEmail ?? '';
  }
}

βš™οΈ ACE Class β€” Full Implementation

src/adaptiveCardExtensions/approvalsCard/ApprovalsCardAdaptiveCardExtension.ts

import {
  BaseAdaptiveCardExtension,
  IPropertyPaneConfiguration
} from '@microsoft/sp-adaptive-card-extension-base';
import { MSGraphClientV3 } from '@microsoft/sp-http';
import { spfi, SPFx, SPFI } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
import { ApprovalsService, IApprovalItem } from './services/ApprovalsService';
import { CardView, CARD_VIEW_REGISTRY_ID } from './cardView/CardView';
import { QuickView, QUICK_VIEW_REGISTRY_ID } from './quickView/QuickView';

export interface IApprovalsCardState {
  approvals: IApprovalItem[];
  loading: boolean;
  error: string | null;
}

export interface IApprovalsCardProps {
  dataSource: 'graph' | 'list';
  listName: string;
}

export default class ApprovalsCardAdaptiveCardExtension
  extends BaseAdaptiveCardExtension<IApprovalsCardProps, IApprovalsCardState> {

  private _approvalsService: ApprovalsService;
  private _sp: SPFI;

  public async onInit(): Promise<void> {
    // Initialise clients
    const graphClient: MSGraphClientV3 =
      await this.context.msGraphClientFactory.getClient('3');
    this._sp = spfi().using(SPFx(this.context));
    this._approvalsService = new ApprovalsService(graphClient);

    // Set loading state synchronously
    this.state = { approvals: [], loading: true, error: null };

    // Register views
    this.cardNavigator.register(CARD_VIEW_REGISTRY_ID, () => new CardView());
    this.quickViewNavigator.register(QUICK_VIEW_REGISTRY_ID, () => new QuickView());

    // Fire async load β€” do NOT await
    this._loadApprovals();

    return Promise.resolve();
  }

  private async _loadApprovals(): Promise<void> {
    try {
      let approvals: IApprovalItem[];

      if (this.properties.dataSource === 'list') {
        approvals = await this._approvalsService
          .getApprovalsFromList(this._sp, this.properties.listName ?? 'Approvals');
      } else {
        approvals = await this._approvalsService.getPendingApprovals();
      }

      this.setState({ approvals, loading: false, error: null });
    } catch (err) {
      this.setState({ approvals: [], loading: false, error: 'Failed to load.' });
    }
  }

  public renderCard(): string | undefined {
    return CARD_VIEW_REGISTRY_ID;
  }

  // Refresh data when the user returns to the card after acting in quick view
  public async onBeforeAction(): Promise<void> {
    await this._loadApprovals();
  }
}

πŸ” Refreshing State After an Action

When the user approves or rejects an item in the quick view, the card view should update immediately. Call _loadApprovals() after the action completes, then close the quick view:

// In QuickView.ts onAction handler
public async onAction(action: IActionArguments): Promise<void> {
  if (action.type === 'Submit' && action.data.action === 'approve') {
    await this._approvalsService.approve(action.data.itemId);

    // Refresh state β€” card view re-renders with updated count
    await this.adaptiveCardExtensionContext._loadApprovals();
    this.quickViewNavigator.close();
  }
}

πŸ§ͺ Test with heft serve

Run the local dev server and open the hosted workbench URL you configured in serve.json:

heft serve

heft serve compiles TypeScript, starts the HTTPS dev server on port 4321, and watches for file changes. It does not deploy to SharePoint β€” it serves assets locally and the hosted workbench loads them from https://localhost:4321.

When the browser opens:

  1. Click + Add a card on the Viva Connections dashboard (or use the hosted workbench + button)
  2. Find ApprovalsCard in the list
  3. The card renders immediately with a loading state, then updates once Graph data arrives

If you see a browser certificate warning on first run, navigate to https://localhost:4321 directly and trust the dev certificate.

Tip: Run heft trust-dev-cert once per machine to permanently trust the SPFx dev certificate and avoid the warning on every serve.

heft trust-dev-cert

πŸš€ Build and Package for Deployment

Once the ACE is working correctly in the hosted workbench, build and package for production:

# 1. Type-check and compile TypeScript
heft build

# 2. Bundle and minify assets for production (--ship flag enables production mode)
heft bundle --ship

# 3. Create the .sppkg package from the bundled assets
heft package-solution --ship

What each command does:

CommandWhat it does
heft buildCompiles TypeScript, runs linting, validates manifests. Use during development to catch errors early.
heft bundle --shipBundles all JS/CSS with webpack in production mode β€” tree-shaking, minification, and CDN path resolution applied. The --ship flag is required; without it, assets point to localhost.
heft package-solution --shipPackages the bundled assets into a .sppkg file at sharepoint/solution/spfx-ace-approvals.sppkg. The --ship flag must match what you used in bundle.

The generated .sppkg file is at:

sharepoint/solution/spfx-ace-approvals.sppkg

πŸͺ Deploy to App Catalog

  1. Open your tenant App Catalog (https://<tenant>.sharepoint.com/sites/appcatalog)
  2. Upload spfx-ace-approvals.sppkg to the Apps for SharePoint library
  3. When prompted β€” Make this solution available to all sites in the organization β€” tick if you want tenant-wide deployment
  4. Go to Site Settings β†’ Site App Permissions in the SharePoint Admin Center and approve the Tasks.Read and Tasks.ReadWrite Graph permission requests

Graph permission requests must be approved by a SharePoint Administrator or Global Administrator from the SharePoint Admin Center under Advanced β†’ API access.

Once approved, add the ACE to any Viva Connections dashboard via Dashboard β†’ Edit β†’ Add a card.


πŸ“‚ GitHub Source

View full SPFx project on GitHub:SPFx ACE β€” pending approvals card pulling live data from Power Automate via Graph

GitHub

βœ… Summary

  • Use yo @microsoft/sharepoint β†’ Adaptive Card Extension β†’ Generic Card Template for a clean ACE scaffold.
  • Configure serve.json with a real pageUrl β€” ACEs cannot be previewed in the local workbench.
  • Add webApiPermissionRequests to package-solution.json before packaging β€” Graph scopes must be declared at solution level.
  • Fire async Graph calls from onInit without await β€” let setState() trigger re-render when data arrives.
  • Set a loading state synchronously in onInit so the card renders immediately.
  • Use heft serve for development, heft bundle --ship + heft package-solution --ship for production.
  • Graph permission requests require Admin approval in the SharePoint Admin Center API access page after deployment.

Happy coding!!!

Ad image