OK, so the OOB SharePoint integration experience for Dataverse (Model Drive Apps) is really great for some things, like just giving you a simple view of the top few documents in the library folder so you can click to open, but what happens when your document integration needs are a bit more demanding? I found a post in the ProDev forum with a dev facing just this problem (specifically, they needed to display custom metadata columns for each document), and I decided to give it a go. The results, I think, are pretty close to seamless - you almost can't tell that the one on the right is a custom control and not a native experience:
Historically, the OOB documents grid in Dataverse was pretty much your only reasonable option for displaying the integrated documents because SharePoint put a strict allowfrom constraint in all their pages, allowing only sharepoint.com and teams.com -- For those of you who aren't devs and don't speak html, that means that SharePoint was forbidding any website from being able to host SharePoint.com pages inside an iframe 👎. This was done for security reasons; to make sure no bogus website could pretend to be your sharepoint site, but unfortunately, they didn't add dynamics.com to the allowed list, so it was impossible to iframe a SharePoint page into dataverse for YEARS. That all changed this summer when SharePoint finally added dynamics.com to the allowed pages, which means we can now replace the OOB experience with an actual native SharePoint one 🎉
Not interested in learning about the code, and just want to jump straight to the solution zip? Jump to my GitHub repo here where you can download the solution zip (install instructions in the README).
To implement this, I built a virtual component PCF using the React framework. I did this because I knew that I needed to at least give users a choice between document locations (as the OOB experience does) and a fluent Dropdown in a Stack would be an easy way to deliver that with my IFrame and still have everything look seamless. The PCF itself is very simple, with minimal work in the index, and just 104 lines in one .tsx
Let's walk through each of the files in the PCF and how they are implemented:
The Manifest is super-simple: it just has one dataset named and no parameters at all. This does make the sizing a bit static (as we'll see later) but I was just trying to put out a proof that this could be done. Maybe in the future I'll add more configurability to the manifest 🤷
The Index is almost as simple as the manifest; the only function that we need to edit is updateview and all we need to add here is a declaration of props and the element creation:
public updateView(context: ComponentFramework.Context<IInputs>): React.ReactElement {
let locations: LocationOption[] = [];
for (let index in context.parameters.DocumentLocations.records) {
locations.push({
dropdownOption: {
key: context.parameters.DocumentLocations.records[index].getRecordId(),
text: context.parameters.DocumentLocations.records[index].getFormattedValue("name")
},
id: context.parameters.DocumentLocations.records[index].getRecordId(),
type: context.parameters.DocumentLocations.getTargetEntityType()
});
}
const props: IFrameProps = { dropdownOptions: locations, context: context };
return React.createElement(
iframe, props
);
}
What are we doing there? We're stripping down the dataset information down to only the bits that we actually need; a list of "dropdown options" which will become our picklist, and an Id/Type combination for each that will let us run up the chain of entities to build a URL for the SharePoint folder.
OK, now we're getting to the good stuff. We're going to build a react component now that lets a user pick between the available document locations and then renders an IFrame that shows that actual SharePoint page. We start with declaring our props Interfaces:
export interface IFrameProps {
dropdownOptions: LocationOption[],
context: ComponentFramework.Context<IInputs>
}
export interface LocationOption {
dropdownOption: IDropdownOption,
id: string
type: string
}
Then we build a couple basic objects for styling the Stack that will hold our dropdown and iframe:
const stackTokens: IStackTokens = {
childrenGap: 5
}
const stackStyles: IStackStyles = {
root: {
width: "100%",
},
};
const stackItemStyles: IStackItemStyles = {
root: {
display: 'flex',
width: "100%",
height: "600px"
},
};
Now we declare our React Functional Component and a few basic state parameters. These state parameters will let us control the Options in the dropdown, which option has been selected, and the url associated with the selected option:
export const iframe: React.FC<IFrameProps> = ((props: IFrameProps) => {
const [options, setOptions] = React.useState<IDropdownOption[]>([]);
const [selectedItem, setSelectedItem] = React.useState<IDropdownOption>();
const [selectedUrl, setSelectedUrl] = React.useState<string>();
let map : [{ key: string, url: string }] | undefined;
we'll need an onChange event to set the selected item and selected url whenever the user changes the dropdown value:
const onChange = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption<any> | undefined, index?: number | undefined) => {
setSelectedItem(option);
setSelectedUrl(map?.find(x => x.key === option?.key)?.url);
};
and now the good stuff--we need to retrieve the document location and as many parent locations as it takes to get up to the root sharepointsite record. Along the way, we'll be building a url from the back to the front, and once we get to the top, we'll set our options, the selecteditem, and the selectedurl:
const buildurl = (location: LocationOption, id: string, type: string, url: string) => {
if(type.toLocaleLowerCase() === "sharepointdocumentlocation"){
props.context.webAPI.retrieveRecord(type, id, "").then(
(success) => {
if(success._parentsiteorlocation_value !== success.sitecollectionid){
buildurl(location, success._parentsiteorlocation_value, "sharepointdocumentlocation", success.relativeurl + "/" + url);
}
else{
buildurl(location, success._parentsiteorlocation_value, "sharepointsite", success.relativeurl + "/" + url);
}
},
(error) => {
console.log(error);
}
)
} else { //sharepointsite
props.context.webAPI.retrieveRecord(type, id, "").then(
(success) => {
if(typeof(map) === "undefined"){
map =[{ key: location.id, url: success.absoluteurl + "/" + url }];
}else{
if(!map.includes({ key: location.id, url: success.absoluteurl + "/" + url }))
map.push({ key: location.id, url: success.absoluteurl + "/" + url });
}
let opts = options;
if(!opts.includes(props.dropdownOptions.find(x => x.id === location.id)!.dropdownOption))
opts.push(props.dropdownOptions.find(x => x.id === location.id)!.dropdownOption);
setOptions(opts);
setSelectedItem(options[0]);
setSelectedUrl(map?.find(x => x.key === options[0].key)?.url);
},
(error) => {
console.log(error);
}
)
}
};
Now we kick off the url building as long as we have at least one option in the dropdown:
if(props.dropdownOptions.length > 0){
props.dropdownOptions.forEach(opt => {
buildurl(opt, opt.id, opt.type, "");
});
}
and we return our Stack with the Dropdown and iframe inside:
return(
<Stack tokens={stackTokens} styles={stackStyles}>
<Stack.Item align="baseline" >
<Dropdown
label="Document Location"
selectedKey={selectedItem ? selectedItem.key : (options.length > 0 ? options[0].key : "")}
onChange={onChange}
options={options}
/>
</Stack.Item>
<Stack.Item align="auto" styles={stackItemStyles}>
<iframe src={selectedUrl} title="SharePoint Documents" width={"100%"}/>
</Stack.Item>
</Stack>
)
All of that put together into one code block:
import { Dropdown, IDropdownOption, IStackItemStyles, IStackStyles, IStackTokens, Stack } from "@fluentui/react";
import * as React from "react";
import { IInputs } from "./generated/ManifestTypes";
export interface IFrameProps {
dropdownOptions: LocationOption[],
context: ComponentFramework.Context<IInputs>
}
export interface LocationOption {
dropdownOption: IDropdownOption,
id: string
type: string
}
const stackTokens: IStackTokens = {
childrenGap: 5
}
const stackStyles: IStackStyles = {
root: {
width: "100%",
},
};
const stackItemStyles: IStackItemStyles = {
root: {
display: 'flex',
width: "100%",
height: "600px"
},
};
export const iframe: React.FC<IFrameProps> = ((props: IFrameProps) => {
const [options, setOptions] = React.useState<IDropdownOption[]>([]);
const [selectedItem, setSelectedItem] = React.useState<IDropdownOption>();
const [selectedUrl, setSelectedUrl] = React.useState<string>();
let map : [{ key: string, url: string }] | undefined;
const buildurl = (location: LocationOption, id: string, type: string, url: string) => {
if(type.toLocaleLowerCase() === "sharepointdocumentlocation"){
props.context.webAPI.retrieveRecord(type, id, "").then(
(success) => {
if(success._parentsiteorlocation_value !== success.sitecollectionid){
buildurl(location, success._parentsiteorlocation_value, "sharepointdocumentlocation", success.relativeurl + "/" + url);
}
else{
buildurl(location, success._parentsiteorlocation_value, "sharepointsite", success.relativeurl + "/" + url);
}
},
(error) => {
console.log(error);
}
)
} else { //sharepointsite
props.context.webAPI.retrieveRecord(type, id, "").then(
(success) => {
if(typeof(map) === "undefined"){
map =[{ key: location.id, url: success.absoluteurl + "/" + url }];
}else{
if(!map.includes({ key: location.id, url: success.absoluteurl + "/" + url }))
map.push({ key: location.id, url: success.absoluteurl + "/" + url });
}
let opts = options;
if(!opts.includes(props.dropdownOptions.find(x => x.id === location.id)!.dropdownOption))
opts.push(props.dropdownOptions.find(x => x.id === location.id)!.dropdownOption);
setOptions(opts);
setSelectedItem(options[0]);
setSelectedUrl(map?.find(x => x.key === options[0].key)?.url);
},
(error) => {
console.log(error);
}
)
}
};
const onChange = (event: React.FormEvent<HTMLDivElement>, option?: IDropdownOption<any> | undefined, index?: number | undefined) => {
setSelectedItem(option);
setSelectedUrl(map?.find(x => x.key === option?.key)?.url);
};
if(props.dropdownOptions.length > 0){
props.dropdownOptions.forEach(opt => {
buildurl(opt, opt.id, opt.type, "");
});
}
return(
<Stack tokens={stackTokens} styles={stackStyles}>
<Stack.Item align="baseline" >
<Dropdown
label="Document Location"
selectedKey={selectedItem ? selectedItem.key : (options.length > 0 ? options[0].key : "")}
onChange={onChange}
options={options}
/>
</Stack.Item>
<Stack.Item align="auto" styles={stackItemStyles}>
<iframe src={selectedUrl} title="SharePoint Documents" width={"100%"}/>
</Stack.Item>
</Stack>
)
})
😅Phew! That was a lot of code, I know, but now we get to the payout. Take a look at the screenshots below. on the left, we have the Document Library in SharePoint, on the right, we have that exact same SharePoint page cleanly embedded in Dataverse. We get all the same buttons, the same navigation, the same columns... everything! So now when we update SharePoint, we will get the exact same updates back in Dataverse immediately!
I know a lot of users that love this experience and are really excited for the change. If you find this how-to helpful, feel free to upvote it or come and star my GitHub repo (code contributions welcome if you have ideas on how to improve this sample!).