- 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:
| Prompt | Value |
|---|---|
| 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
CardViewandQuickViewscaffold 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:
- Set a loading state in
onInitsynchronously - Fire the async Graph call without awaiting it in
onInit - 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 servecompiles 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 fromhttps://localhost:4321.
When the browser opens:
- Click + Add a card on the Viva Connections dashboard (or use the hosted workbench
+button) - Find ApprovalsCard in the list
- 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-certonce 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:
| Command | What it does |
|---|---|
heft build | Compiles TypeScript, runs linting, validates manifests. Use during development to catch errors early. |
heft bundle --ship | Bundles 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 --ship | Packages 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
- Open your tenant App Catalog (
https://<tenant>.sharepoint.com/sites/appcatalog) - Upload
spfx-ace-approvals.sppkgto the Apps for SharePoint library - When prompted β Make this solution available to all sites in the organization β tick if you want tenant-wide deployment
- Go to Site Settings β Site App Permissions in the SharePoint Admin Center and approve the
Tasks.ReadandTasks.ReadWriteGraph 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
β Summary
- Use
yo @microsoft/sharepointβ Adaptive Card Extension β Generic Card Template for a clean ACE scaffold. - Configure
serve.jsonwith a realpageUrlβ ACEs cannot be previewed in the local workbench. - Add
webApiPermissionRequeststopackage-solution.jsonbefore packaging β Graph scopes must be declared at solution level. - Fire async Graph calls from
onInitwithoutawaitβ letsetState()trigger re-render when data arrives. - Set a loading state synchronously in
onInitso the card renders immediately. - Use
heft servefor development,heft bundle --ship+heft package-solution --shipfor production. - Graph permission requests require Admin approval in the SharePoint Admin Center API access page after deployment.
Happy coding!!!
Author
Ravichandran@Hi_Ravichandran
