Hi SharePoint fellows,
In this post, I would like to tackle a user experience I wanted to implement in one of my projects in the SharePoint modern experience: A contextual command opening a form contained in a panel.
Let's use SPFx extensions
A contextual command means I would need to implement a ListView Command Set extension. Using this kind of solution, I am able to add a contextual button in the command bar and in the contextual item menu. For those of you familiar with the classic experience development, it is the successor of Custom Actions located in the ribbon and the ECB. From the official documentation, we can find a very well explained tutorial explaining how to implement this custom command.
I don't want a dialog !
When the need comes for a custom UI in the context of a ListView command set, the offered possibility is the use of the Dialog API. This API wraps prepared Office UI Fabric dialogs, either standard prompt or alert or with custom dialog content. It is quite easy to use once you know how to do it ;) Check out the official tutorial to learn more.
In most cases, the dialog approach is a good fit, when UI is quite small and the user action flow is straight-forward, it's probably not worthy to do anything else. In my case, however, I needed a larger, more complex and dynamic UI. I had not a good feeling about using a dialog for that. Moreover, A nice-to-have I had in mind was that the user could be able to still see the content of the list while working in the custom form, the dialog wouldn't fit this one.
What about a panel instead ?
Using the Office UI Fabric React library, we can easily use a Panel and implement our own content in it. The only thing we need is to call the render() method from the ReactDOM. It actually turns out to be a bit tricky, because this render() method requires to pass in a container DOM element. In ListView Command Set SPFx extensions, unlike the Application Customizers, we don't have direct access to the any DOM element through standardized available placeholders. That said, we are developing in JavaScript, that means we can still manipulate everything in the DOM !
DOM Injection is officially discouraged !
Those who know me will be aware that I'm pretty in line with Microsoft's recommendation about NOT doing DOM injection in SharePoint UI development.
It actually means you should avoid messing around with the standard SharePoint UI.
The SharePoint developers struggle to deliver the best user experience with many advanced features, and there are new stuff almost every week!
The DOM of the SharePoint pages is continuously evolving. you CAN'T and SHOULDN'T rely on the existence or the format or content of any particular HTML element because you have no assurance it will be there and remain the same forever.
Moreover, altering these elements might end up in breaking a standard SharePoint UI feature.
With this recommendation taken into account, we can, however, be pretty sure that one special HTML element will always be there; the element.
We won't alter or remove any child element from it but instead adding one simple As a sample, let's assume the following requirements: We will create such a solution in the form of a ListView Command Set solution.
I won't go too much into the basic details here, ff you need more details, you can check the following tutorial. The main thing we will require is to install the React dependencies in our solution in order to be able to use the Office UI Fabric React component.
I also install the PnP JS library dependencies for the sake of simplicity When your solution is scaffolded, type the following command in your console For some reasons I didn't dig into, the latest versions of React libraries caused the solution not being able to build (something related to React prop types, ...).
If you run into the same problem, here are the React version that worked I just grab from another extension solution that I had: First, let's create a React component that will render the Panel and its content.
In my solution, I have a component source file called CustomPanel.tsx within the src/components folder. This component renders a simple form contained in a Panel component.
When the Save button is clicked, A REST call (using PnP JS) will update the title of the current item. In our Extension main class, let's do some changes. First we need to create the container for our SPFx component : We create the DOM element in the extension initialization step and keep a reference to it. We implement the methods that will help display and close the Panel.
Notice that we grab the List ID our component needs from the current page context. And we finally edit the extension life cycle handlers to suit our needs.
The command has a token CMD_PANEL.
The button shows up only if a single list item is selected, and when clicked, will open up the panel passing it in the arguments that will help us working on the selected item.
Since we used a custom token for our command, we will need to update the manifest accordingly.
Edit the content of the YourCommandSet.manifest.json and change the value of the items property to : In order to try our extension without actually deploying it, we will update the serve.json file located in the /config folder.
This will allow us to directly open a browser to a page we will configure and append the proper query string to it to go into debug mode. Edit the content of the serveConfigurations default entry and only change the pageUrl value to the URL of a list view page (e.g. https://contoso.sharepoint.com/sites/comsite/Lists/Test/AllItems.aspx). The rest should be just fine. If, however, you run into trouble using it, make sure the specified GUID matches the one in your manifest file With all that in place, we should be good to go, let's give it a try, (make sure your last active browser window was one in your logged in session) You are asked if you want to continue debugging script, click "Load debug scripts" Select an item and see the Open editor button appearing in the command bar, click this button to see the following panel showing up And there is it, we can now open up a panel with our custom content within the context of an SPFx ListView Command Set extension. As a conclusion, I'd like to emphasize that according to the requirements, I feel a panel might be much more intuitive and less intrusive than a dialog.
My personal opinion is also that it is, somehow, more in line with most of the standard UI, and I like when my customization seem to be just a part of the original product, because I've seen too often many different customization from different teams that ends up in making the page looking like a patch work....
I personally hate it ! But I digress. In this solution however, the Panel takes up the whole viewport height which is slightly different from the standard UI panels. The way to do it here is through DOM injection, I am not a fan of using this approach, at least in the first place, Microsoft gives us great tooling to build stuff, but it is supposed to be used in a proper way...
Think of it as, you will probably be able to stick a screw in a wall with a hammer, but most likely, you will damage the wall, the screw, and the whole thing will look horrible.
You'd rather use an adapted screwdriver instead ! In our solution, I think we're good because: However, I think the best solution would be to have from Microsoft an official API similar to Dialog for using Panels as well, this would prevent us from doing any kind of DOM injection and maybe avoid to inspire bad ideas to people Hope all this was interesting. Tell me what you think! Cheers, YannickLet's implement it !
The prerequisites
npm install react react-dom @types/react @types/react-dom @pnp/common @pnp/logging @pnp/odata @pnp/sp --save
"dependencies": {
.....
"react": "16.3.2",
"react-dom": "16.3.2",
"@types/react": "16.4.2",
"@types/react-dom": "16.0.5"
},
Okay, ready to code !
import * as React from 'react';
import { TextField, DefaultButton, PrimaryButton, DialogFooter, autobind, Panel, Spinner, SpinnerType } from "office-ui-fabric-react";
import { sp } from "@pnp/sp";
export interface ICustomPanelState {
saving: boolean;
}
export interface ICustomPanelProps {
onClose: () => void;
isOpen: boolean;
currentTitle: string;
itemId: number;
listId: string;
}
export default class CustomPanel extends React.Component<ICustomPanelProps, ICustomPanelState> {
private editedTitle: string = null;
constructor(props: ICustomPanelProps) {
super(props);
this.state = {
saving: false
};
}
@autobind
private _onTitleChanged(title: string) {
this.editedTitle = title;
}
@autobind
private _onCancel() {
this.props.onClose();
}
@autobind
private _onSave() {
this.setState({ saving: true });
sp.web.lists.getById(this.props.listId).items.getById(this.props.itemId).update({
'Title': this.editedTitle
}).then(() => {
this.setState({ saving: false });
this.props.onClose();
});
}
public render(): React.ReactElement<ICustomPanelProps> {
let { isOpen, currentTitle } = this.props;
return (
<Panel isOpen={isOpen}>
<h2>This is a custom panel with your own content</h2>
<TextField value={currentTitle} onChanged={this._onTitleChanged} label="Item title" placeholder="Choose the new title" />
{this.state.saving && <Spinner type={SpinnerType.large} label="Saving..." />}
<DialogFooter>
<DefaultButton text="Cancel" onClick={this._onCancel} />
<PrimaryButton text="Save" onClick={this._onSave} />
</DialogFooter>
</Panel>
);
}
}
// ...
import CustomPanel, { ICustomPanelProps } from "./components/CustomPanel";
import { sp } from "@pnp/sp";
import { autobind, assign } from '@uifabric/utilities';
// ...
const LOG_SOURCE: string = 'CustomFormPanelCommandSet';
export default class CustomFormPanelCommandSet extends BaseListViewCommandSet<ICustomFormPanelCommandSetProperties> {
private panelPlaceHolder: HTMLDivElement = null;
@override
public onInit(): Promise<void> {
Log.info(LOG_SOURCE, 'Initialized CommandSetWithPanelCommandSet');
// Setup the PnP JS with SPFx context
sp.setup({
spfxContext: this.context
});
// Create the container for our React component
this.panelPlaceHolder = document.body.appendChild(document.createElement("div"));
return Promise.resolve();
}
// ...
}
// Other code here...
import * as React from 'react';
import * as ReactDom from 'react-dom';
// Other code here...
export default class CustomFormPanelCommandSet extends BaseListViewCommandSet<ICustomFormPanelCommandSetProperties> {
// Other code here...
private _showPanel(itemId: number, currentTitle: string) {
this._renderPanelComponent({
isOpen: true,
currentTitle,
itemId,
listId: this.context.pageContext.list.id.toString(),
onClose: this._dismissPanel
});
}
@autobind
private _dismissPanel() {
this._renderPanelComponent({ isOpen: false });
}
private _renderPanelComponent(props: any) {
const element: React.ReactElement<ICustomPanelProps> = React.createElement(CustomPanel, assign({
onClose: null,
currentTitle: null,
itemId: null,
isOpen: false,
listId: null
}, props));
ReactDom.render(element, this.panelPlaceHolder);
}
}
// imports ...
export default class CustomFormPanelCommandSet extends BaseListViewCommandSet<ICustomFormPanelCommandSetProperties> {
//...
@override
public onListViewUpdated(event: IListViewCommandSetListViewUpdatedParameters): void {
const openEditorCommand: Command = this.tryGetCommand('CMD_PANEL');
openEditorCommand.visible = event.selectedRows.length === 1;
}
@override
public onExecute(event: IListViewCommandSetExecuteEventParameters): void {
switch (event.itemId) {
case 'CMD_PANEL':
let selectedItem = event.selectedRows[0];
const listItemId = selectedItem.getValueByName('ID') as number;
const title = selectedItem.getValueByName("Title");
this._showPanel(listItemId, title);
break;
default:
throw new Error('Unknown command');
}
}
//...
}
"items": {
"CMD_PANEL": {
"title": { "default": "Open editor" },
"iconImageUrl": "icons/request.png",
"type": "command"
}
}
gulp serve
It works !
Other posts