Jan 03, 2019

SPFX ListView Command Set and Panel

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

element that will be the container for our React control.

Let's implement it !

As a sample, let's assume the following requirements:

  • The solution is for SharePoint Modern UI only
  • We need a custom button in the command bar
  • The button only appears when a single item is selected
  • When the button is clicked, a panel pops up
  • The panel contains a simple form allowing to update the Title field of the item

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 prerequisites

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

npm install react react-dom @types/react @types/react-dom @pnp/common @pnp/logging @pnp/odata @pnp/sp --save

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:

"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 !

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.

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>
        );
    }
}

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 :

// ...
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();
  }

// ...
}

We create the DOM element in the extension initialization step and keep a reference to it.

// 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);
  }
}

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.

// 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');
    }
  }
//...
}

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 :

"items": {
       "CMD_PANEL": {
              "title": { "default": "Open editor" },
              "iconImageUrl": "icons/request.png",
              "type": "command"
        }
}

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

cmd-panel-servejson

cmd-panel-manifest

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)

gulp serve

cmd-loaddebugscript

You are asked if you want to continue debugging script, click "Load debug scripts"

cmd-buttonvisible

Select an item and see the Open editor button appearing in the command bar, click this button to see the following panel showing up

cmd-panelvisible

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.

It works !

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:

  • We don't affect anything in the existing DOM
  • We rely on the only thing that will probably remain forever (except if HTML basics changes completely, which is unlikely...)

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,

Yannick

Other posts