Modernizing Audit Summary History using Generative Pages (Preview)!
In Customer Insight, if you enable audit capability for the Journey table, the only way to view the audit history is through a backend operation or by using the "/tools/audit/audit_details.aspx" page. This page was introduced so long ago, and it is tough to navigate, as we need to filter and check the changes one by one for the selected record that we want to see. Fortunately, we have an interesting feature called Generative Pages! Essentially, we can create a custom page (based on a React component) using natural language and embed it directly into the App/Site Map. In this blog post, I will not show how to create it step by step (because I refined it multiple times till I think sufficient to show to you all). Instead, I just show the potential of it and what the state of the current implementation is based on my experience. Without further ado, let's go!
Prerequisite and how to create the Generative Page(s)!
First, you need to ensure that you have an environment in the US! To check this, go to admin.powerplatform.com > Environments > based on the list, you can see which environment you can use for this testing:

Select the environment in the US Region
Next, in that Environment > Settings > Features > ensure "Enable new AI-powered Copilot features for people who make apps" is set to On:

Ensure the setting is turned on
Last, when you open the Model Driven App > you can see the following component:

Click the "Describe a page"
When you click the "Describe a page", you basically can start entering the prompt to generate the page that you need!

Start building with your prompt!
Audit Pages
Here is the requirement that I want for this page:
- The system needs to fetch all the Entities/Tables that have IsAuditEnabled (for this ***RetrieveAllEntitiesRequest***via WebApi).
- Show the list as a ComboBox and on the selection of the Entity/Table, allow the user to search based on the ID/Primary Field Name.
- On the click of Search, fetch the selected table based on ID/Primary Field Name and show it as a Table. The data needs to be selectable using a Radio button.
- On selection of the record (Radio button), the system needs to fetch the Audit table and show it as another table below the existing table.
- Don't hardcode the label name of the records. Always fetch based on the localized table.
- Because RetrieveAllEntitiesRequest is quite expensive and takes so long, we need to cache it in sessionStorage (expires every 6 hours to reduce the retrieval process).
Based on the above specifications, here is the code:
import React, { useEffect, useState } from "react";
import {
Text,
Spinner,
Combobox,
Option,
Input,
Button,
Table,
TableHeader,
TableBody,
TableRow,
TableCell,
Radio,
} from "@fluentui/react-components";
import type {
GeneratedComponentProps,
ReadableTableRow,
QueryTableOptions,
audit,
audit_action,
audit_operation,
} from "./RuntimeTypes";
/**
* Main generated component.
*/
const GeneratedComponent = ({ dataApi }: GeneratedComponentProps) => {
// Entity selection and search states
const [rows, setRows] = useState<EntityMetadata[]>([]);
const [loading, setLoading] = useState(true);
const [selectedEntity, setSelectedEntity] = useState<EntityMetadata | undefined>(undefined);
const [searchBy, setSearchBy] = useState<string>("");
const [searchResults, setSearchResults] = useState<any[]>([]);
const [selectedRecord, setSelectedRecord] = useState<any | undefined>(undefined);
const [searchLoading, setSearchLoading] = useState(false);
const [hasSearched, setHasSearched] = useState(false);
// Audit records state
const [auditRecords, setAuditRecords] = useState<ReadableTableRow<audit>[]>([]);
const [auditLoading, setAuditLoading] = useState(false);
const [auditError, setAuditError] = useState<string | null>(null);
// Fetch entity metadata
useEffect(() => {
let isMounted = true;
async function fetchRows() {
setLoading(true);
const stored = sessionStorage.getItem('entityMetadata');
if (stored) {
const parsed = JSON.parse(stored);
if (Date.now() - parsed.timestamp < 6 * 60 * 60 * 1000) {
if (isMounted) setRows(parsed.data);
if (isMounted) setLoading(false);
return;
}
}
try {
const execute_RetrieveAllEntities_Request = {
EntityFilters: 3,
RetrieveAsIfPublished: true,
getMetadata: function () {
return {
boundParameter: null,
parameterTypes: {
EntityFilters: { typeName: "Microsoft.Dynamics.CRM.EntityFilters", structuralProperty: 3, enumProperties: [{ name: "Entity,Attributes", value: 3 }] },
RetrieveAsIfPublished: { typeName: "Edm.Boolean", structuralProperty: 1 }
},
operationType: 1, operationName: "RetrieveAllEntities"
};
}
};
const result = (await (await (dataApi.webApi || Xrm.WebApi).execute(execute_RetrieveAllEntities_Request)).json()).EntityMetadata
.filter((e: any) => e.IsAuditEnabled.Value || e.LogicalName === 'audit').map((e: any) => ({
logicalName: e.LogicalName,
displayName: e.DisplayName.UserLocalizedLabel.Label,
primaryFieldAttribute: e.PrimaryNameAttribute,
attributes: e.Attributes.map((attr: any) => ({
logicalName: attr.LogicalName,
displayName: attr.DisplayName?.UserLocalizedLabel?.Label || attr.LogicalName
}))
}));
if (isMounted) setRows(result);
sessionStorage.setItem('entityMetadata', JSON.stringify({ data: result, timestamp: Date.now() }));
} finally {
if (isMounted) setLoading(false);
}
}
fetchRows();
return () => { isMounted = false; };
}, [dataApi]);
const selectedItem = rows.find(item => item.logicalName === selectedEntity?.logicalName);
const auditEntity = rows.find(r => r.logicalName === 'audit');
const getDisplayName = (logicalName: string) =>
selectedEntity?.attributes.find(attr => attr.logicalName === logicalName)?.displayName || logicalName;
const getAuditDisplayName = (logicalName: string) =>
auditEntity?.attributes.find(attr => attr.logicalName === logicalName)?.displayName || logicalName;
// Search main records
const handleSearch = async () => {
if (!selectedEntity || !searchBy) return;
setSearchLoading(true);
setSearchResults([]);
setHasSearched(false);
setSelectedRecord(undefined);
setAuditRecords([]);
setAuditError(null);
const isGuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(searchBy);
const filterField = isGuid ? selectedEntity.logicalName + 'id' : selectedEntity.primaryFieldAttribute;
const filterText = isGuid ? `${filterField} eq '${searchBy}'` : `contains(${filterField},'${searchBy}')`;
try {
const query = `?$select=${selectedEntity.primaryFieldAttribute},createdon,_createdby_value,_modifiedby_value,modifiedon&$expand=createdby($select=fullname),modifiedby($select=fullname)&$filter=${filterText}`;
const response = await (dataApi.webApi || Xrm.WebApi).retrieveMultipleRecords(selectedEntity.logicalName, query);
setSearchResults(response.entities);
} catch (error) {
console.error("Search failed:", error);
} finally {
setSearchLoading(false);
setHasSearched(true);
}
};
// When selectedRecord changes, fetch audit records
useEffect(() => {
const fetchAuditRecords = async () => {
if (!selectedRecord || !selectedEntity) {
setAuditRecords([]);
setAuditError(null);
return;
}
setAuditLoading(true);
setAuditRecords([]);
setAuditError(null);
try {
// Query audit table for records related to selected record
// Filter: _objectid_value eq /<entity>(<id>)
const objectIdValue = `/${selectedEntity.logicalName}(${selectedRecord[selectedEntity.logicalName + "id"]})`;
const queryOptions: QueryTableOptions<audit> = {
select: [
"action",
"operation",
"additionalinfo",
"changedata",
"timetoliveinseconds",
"transactionid",
"useradditionalinfo",
"createdon",
"_userid_value",
"_callinguserid_value",
],
filter: `_objectid_value eq '${objectIdValue}'`,
orderBy: "createdon desc",
pageSize: 20,
};
const result = await dataApi.queryTable("audit", queryOptions);
setAuditRecords(result.rows);
} catch (err) {
setAuditError("Failed to load audit records.");
} finally {
setAuditLoading(false);
}
};
fetchAuditRecords();
}, [selectedRecord, selectedEntity, dataApi]);
const getFormattedValue = (row: any, field: string) =>
row[`${field}@OData.Community.Display.V1.FormattedValue`] || row[field];
// Helper to get user display name from _userid_value or _callinguserid_value
const getUserDisplayName = (row: ReadableTableRow<audit>) => {
// Try to get formatted value for _userid_value or _callinguserid_value
return (
row["_userid_value@OData.Community.Display.V1.FormattedValue"] ||
row["_callinguserid_value@OData.Community.Display.V1.FormattedValue"] ||
row["_userid_value"] ||
row["_callinguserid_value"] ||
"N/A"
);
};
// Helper to render additional info with changedAttributes mapping
function renderAdditionalInfo(additionalinfo: string | undefined): React.ReactNode {
if (!additionalinfo) return "—";
let parsed: any;
try {
parsed = JSON.parse(additionalinfo);
} catch {
// Not JSON, show as is
return additionalinfo;
}
if (parsed && Array.isArray(parsed.changedAttributes)) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
{parsed.changedAttributes.map((attr: any, idx: number) => (
<Text key={idx} size={300} block>
Display Name: {getDisplayName(attr.logicalName)}. Old Value: {attr.oldValue}. New Value: {attr.newValue}
</Text>
))}
</div>
);
}
// If not expected format, show as string
return additionalinfo;
}
return (
<div style={{
flexGrow: 1,
alignSelf: "stretch",
width: "100%",
height: "100%",
padding: 24,
boxSizing: "border-box",
overflow: "hidden",
display: "flex",
flexDirection: "column"
}}>
{loading ? (
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Spinner size="medium" label="Loading data..." />
</div>
) : (
<>
{/* Content */}
<section style={{ display: "flex", flexDirection: "row", gap: "20px", alignItems: "flex-end" }}>
<div>
<Text size={400} block style={{ marginBottom: 8 }}>
Select a Table:
</Text>
<Combobox
aria-label="Select Table"
placeholder="Choose an item..."
value={selectedItem?.displayName ?? ""}
onOptionSelect={(_, data) => {
setSelectedEntity(rows.find(item => item.logicalName === data.optionValue));
setSearchResults([]);
setSelectedRecord(undefined);
setAuditRecords([]);
setAuditError(null);
}}
style={{ maxWidth: "400px", width: "100%" }}
>
{rows.map(item => (
<Option key={item.displayName} value={item.logicalName}>
{item.displayName}
</Option>
))}
</Combobox>
</div>
<div>
<Text size={400} block style={{ marginBottom: 4 }}>
Search
</Text>
<Input
aria-label="Search by ID or Name"
placeholder="Search by ID or Name"
value={searchBy}
onChange={(_, data) => setSearchBy(data.value)}
style={{ minWidth: 180, maxWidth: 260 }}
/>
</div>
<Button onClick={handleSearch} disabled={searchLoading || !selectedEntity || !searchBy}>
{searchLoading ? "Searching..." : "Search"}
</Button>
</section>
{hasSearched && searchResults.length === 0 && (
<Text>No data found..</Text>
)}
{searchResults.length > 0 && (
<div style={{ overflowY: "auto", marginTop: 24, marginBottom: 24 }}>
<Table>
<TableHeader>
<TableRow>
<TableCell>Select</TableCell>
<TableCell>{getDisplayName(selectedEntity.primaryFieldAttribute)}</TableCell>
<TableCell>{getDisplayName('createdon')}</TableCell>
<TableCell>{getDisplayName('createdby') || 'Created By'}</TableCell>
<TableCell>{getDisplayName('modifiedon')}</TableCell>
<TableCell>{getDisplayName('modifiedby') || 'Modified By'}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{searchResults.map((record) => {
const recordId = record[selectedEntity?.logicalName + "id"] || record.id;
const crmUrl = `/main.aspx?pagetype=entityrecord&etn=${selectedEntity.logicalName}&id=${recordId}`;
return (
<TableRow key={recordId}>
<TableCell>
<Radio
name="selectRecord"
value={recordId}
checked={selectedRecord && (selectedRecord[selectedEntity?.logicalName + "id"] === recordId)}
onChange={() => setSelectedRecord(record)}
aria-label={`Select record ${record[selectedEntity?.primaryFieldAttribute]}`}
/>
</TableCell>
<TableCell>
<a href={crmUrl} target="_blank" style={{ color: 'inherit', textDecoration: 'none' }}>
{record[selectedEntity?.primaryFieldAttribute]}
</a>
</TableCell>
<TableCell>{record.createdon ? new Date(record.createdon).toLocaleString() : "N/A"}</TableCell>
<TableCell>{record.createdby?.fullname || "N/A"}</TableCell>
<TableCell>{record.modifiedon ? new Date(record.modifiedon).toLocaleString() : "N/A"}</TableCell>
<TableCell>{record.modifiedby?.fullname || "N/A"}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
{/* Audit Table */}
{selectedRecord && (
<section style={{ marginTop: 16, padding: "16px 0", borderTop: "1px solid #eee" }}>
<Text size={500} weight="semibold" block style={{ marginBottom: 12 }}>
Audit Records
</Text>
{auditLoading ? (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: 80 }}>
<Spinner size="small" label="Loading audit records..." />
</div>
) : auditError ? (
<Text intent="error">{auditError}</Text>
) : auditRecords.length === 0 ? (
<Text>No audit records found for this record.</Text>
) : (
<div style={{ maxHeight: "300px", overflow: "auto", borderRadius: "6px", boxSizing: "border-box", border: "1px solid #eee", background: "#fafafa" }}>
<Table>
<TableHeader>
<TableRow>
<TableCell>{getAuditDisplayName('action')}</TableCell>
<TableCell>{getAuditDisplayName('operation')}</TableCell>
<TableCell>{getAuditDisplayName('userid') || 'User'}</TableCell>
<TableCell>{getAuditDisplayName('createdon')}</TableCell>
<TableCell>{getAuditDisplayName('changedata') || 'Additional Info'}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{auditRecords.map((auditRow) => (
<TableRow key={auditRow.auditid}>
<TableCell>
{getFormattedValue(auditRow, "action")}
</TableCell>
<TableCell>
{getFormattedValue(auditRow, "operation")}
</TableCell>
<TableCell>
{getUserDisplayName(auditRow)}
</TableCell>
<TableCell>
{auditRow.createdon ? new Date(auditRow.createdon as string).toLocaleString() : "N/A"}
</TableCell>
<TableCell>
{renderAdditionalInfo(auditRow.changedata)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</section>
)}
</>
)}
</div>
);
};
// Types for entity metadata
type EntityMetadata = {
logicalName: string;
displayName: string;
primaryFieldAttribute: string;
attributes: attributeMetadata[];
};
type attributeMetadata = {
logicalName: string;
displayName: string;
};
export default GeneratedComponent;
When in Preview mode, I can see that dataApi has webApi property, which same as Xrm.WebApi. But, when we publish and test it directly in the App, the dataApi.webApi is empty. Yet, the Xrm.WebApi exists, and for that reason, the implementation changed to (dataApi.webApi || Xrm.WebApi).
So, what are the tips that I can share with all of you (generally when working with LLM):
- Break down the task into smaller tasks (as small as possible).
- Test the changes.
- Get your hands dirty when your Copilot can't help you (or maybe because of your bad 'prompting' skills)! When developing this, I checked the existence of dataApi.webApi || Xrm.WebApi by myself. Then, I wrote the logic to RetrieveAllEntitiesRequest before I passed the other implementation to my assistant (Copilot).
- Backup the stable point!
Export-Import Capabilities?
I tried to export the solution after I was done and checked it. Seems we still need to wait on how this component can become "Solution Aware" as to the customization.xml, there is no hint yet on how the system packs the component. I tried to deploy it to another environment, and the App has an error (especially because my target environment does not fulfill the prerequisite for Generative Pages to run). Meaning, we can only use it for a non-production environment for now.
Demo
https://www.youtube.com/watch?v=\_fNtuMq9b90
Happy CRM-ing 🚀!
Leave a comment
Your comment is sent privately to the author and isn't published on the site.