3/10/2020

Using PnP Field Controls in PnP ListView to Render Taxonomy, Pictures and Other Types of SharePoint Columns

This blog post is based on SPFx web part sample that shows how to use PnP Field Controls to render different types of SharePoint columns in ListView control.

Objective

Let's say we have a SharePoint list with such columns as Lookup, Choice, Hyperlink or Picture, etc. And we want to display items from this list in our SPFx web part.
In this blog post I will use the list as shown below:

We want to display this list in our web part similarly to the OOTB rendering.

Getting the Items

First thing to do - we need to request the list items from SharePoint.
There are several way how you can do that:
  • Using SharePoint REST API
  • Using Microsoft Graph
  • Using PnPJS library
I will use PnPJS. And to be more precise: getItemsByCAMLQuery method of IList object. This method allows to correctly get labels for Managed Metadata columns (selected terms). And this is required for the selected list. So, let's install PnPJS module to our project:
npm i --save @pnp/sp
Now let's import all needed instances in our web part's ts file:
import { sp } from '@pnp/sp';
import '@pnp/sp/webs';
import '@pnp/sp/lists';
import '@pnp/sp/items';
And let's request the items in web part's onInit method:
private _items: any[];
protected async onInit(): Promise<void> {
  await super.onInit();

  sp.setup({
    spfxContext: this.context
  });

  const countries = await sp.web.lists.getByTitle('Country').items.get(); // getting Country items to get lookup values

  this._items = await sp.web.lists.getByTitle('Journeys').getItemsByCAMLQuery({
    ViewXml: ''
  });

  // getting lookup values by ids
  this._items.forEach(item => {
    const visitedCountriesIds: number[] = item['VisitedCountriesId'] as number[];
    item['VisitedCountries'] = countries.filter(c => visitedCountriesIds.indexOf(c['ID']) !== -1).map(c => c['Title']);
  });

}
This request will return items in the format shown below:
[
  {
    "Title": "Teen Vacation",
    "JourneyDate": "2000-07-10T00:00:00",
    "VisitedCountriesId": [
      5
    ],
    "Experience": "Good",
    "Picture": {
      "Description": "Hungray",
      "Url": "https://www.coe.int/documents/14577325/16404142/HUN_publication_report/6bac7580-94d7-1cf7-ba59-d3c74228fbad?t=1569512959000"
    },
    "JourneyType": {
      "Label": "Leisure",
      "TermGuid": "2262759a-0902-4ec6-87e0-910abb935f59",
      "WssId": 1
    },
    "VisitedCountries": [
      "Hungary"
    ]
  },
  {
    "Title": "Europe Journey",
    "JourneyDate": "2013-12-05T00:00:00",
    "VisitedCountriesId": [
      6,
      3
    ],
    "Experience": "Good",
    "Picture": {
      "Description": "greece",
      "Url": "https://static.coindesk.com/wp-content/uploads/2018/10/greece-1594689_1920-710x458.jpg?format=webp"
    },
    "JourneyType": {
      "Label": "Leisure",
      "TermGuid": "2262759a-0902-4ec6-87e0-910abb935f59",
      "WssId": 1
    },
    "VisitedCountries": [
      "Greece",
      "France"
    ]
  }
]
As you can see, such columns as VisitedCountriesId, Picture and others are complex object with multiple properties. And we are additionally processing VisitedCountriesIds to add VisitedCountries property to contain array of lookup values.

PnP ListView

PnP ListView component allows you to render collection of items. It is based on Office UI Fabric React DetailsList and allow to render items similarly to Office 365 experience.
You have to provide a list of view fields as IViewField[] that describes what columns to render and list of items as any[]. You can read documentation on these parameters here.
The problem with this controls is that it flattens the passed items. And for our example, for VisitedCountries it will create a separate property for each array member:
{
  "VisitedCountries.0": "Greece",
  "VisitedCountries.1": "France"
}
Such a format doesn't allow to correctly specify IViewField instance to render array.
Good thing is - we can define render function for each column and provide custom rendering for a cell there.

Field Controls

PnP Field Controls were initially created to be used in SPFx Field Customizers to provide rendering of the fields similar to out of the box experience.
But technically we can use them in web parts as well. And, as a result, we can use them to provide custom rendering for ListView cells.

Combining Everything

So, let's use Field Controls in our ListView.
And first thing - add all needed imports:
import { ListView, IViewField } from '@pnp/spfx-controls-react/lib/ListView';
import { FieldTextRenderer } from '@pnp/spfx-controls-react/lib/FieldTextRenderer';
import { FieldDateRenderer } from '@pnp/spfx-controls-react/lib/FieldDateRenderer';
import { FieldLookupRenderer } from '@pnp/spfx-controls-react/lib/FieldLookupRenderer';
import { FieldUrlRenderer } from '@pnp/spfx-controls-react/lib/FieldUrlRenderer';
import { FieldTaxonomyRenderer } from '@pnp/spfx-controls-react/lib/FieldTaxonomyRenderer';
import { ISPFieldLookupValue } from '@pnp/spfx-controls-react/lib/Common';
For Title and Experience (Choice) we will use simple FieldTextRenderer:
private _renderTitle(item?: any): any {
  return <FieldTextRenderer
    text={item.Title}
  />;
}

private _renderExperience(item?: any): any {
  const experience = item['Expirience'];

  return <FieldTextRenderer
    text={experience}
  />;
}
For date - FieldDateRenderer:
private _renderDate(item?: any): any {
  const date = new Date(item['JourneyDate']);
  return <FieldDateRenderer
    text={date.toLocaleDateString()} />;
}
For the Picture (Hyperlink or Picture) we need to get 2 properties of the flattened item: Picture.Url and Picture.Description to use FieldUrlRenderer
private _renderPicture(item?: any): any {

  return <FieldUrlRenderer
    url={item['Picture.Url']}
    isImageUrl={true}
    className={styles.image}
    text={item['Picture.Description'] || ''} />;
}
Similarly, for JorneyType (Managed Metadata) we need JourneyType.Label and JourneyType.TermGuid to use FieldTaxonomyRenderer:
private _renderJourneyType(item?: any) {

  return <FieldTaxonomyRenderer
    terms={[{
      Label: item['JourneyType.Label'],
      TermID: item['JourneyType.TermGuid']
    }]} />;
  }
The most interesting part is VisitedCoutries. As this is a lookup column we want to displayed referenced item when user clicks on the lookup value. We can display this item either in a separate tab or in a popup. I will show the second option. So, first let's use FieldLookupRenderer to render the values and handle onClick:
private _renderCountries(item?: any, index?: number): any {
  //
  // ListView item contains "flattened" information
  // So, we're getting original item first
  //

  const originalItem = this.props.items[index!];
  const visitedCountriesIds = originalItem['VisitedCountriesId'];
  const visitedCountries = originalItem['VisitedCountries'];

  const lookups: ISPFieldLookupValue[] = visitedCountries.map((vc, idx) => {
    return {
      lookupId: visitedCountriesIds[idx].toString(),
      lookupValue: vc
    };
  });

    return <FieldLookupRenderer
      lookups={lookups}
      onClick={args => {
        this.setState({
          selectedLookupId: args.lookup.lookupId
        });
      }} />;
  }
And if selectedLookupId is set, we'll use one more PnP Reusable Control IFrameDialog to render list item display form as a dialog:
import { IFrameDialog } from '@pnp/spfx-controls-react/lib/IFrameDialog';
import { DialogType } from 'office-ui-fabric-react/lib/Dialog';
//...
public render(): React.ReactElement<IPnPListViewProps> {
//...
{this.state.selectedLookupId &&
  <IFrameDialog
    hidden={false}
    url={`${this.props.context.pageContext.web.absoluteUrl}${this._lookupFieldDispWebRelativeUrl.replace('{0}', this.state.selectedLookupId.toString())}`}
    iframeOnLoad={iframe => {
      const iframeWindow: Window = iframe.contentWindow;
      const iframeDocument: Document = iframeWindow.document;

      const s4Workspace: HTMLDivElement = iframeDocument.getElementById('s4-workspace') as HTMLDivElement;
      s4Workspace.style.height = iframe.style.height;

      s4Workspace.scrollIntoView();
    }}
    onDismiss={() => {
      this.setState({
        selectedLookupId: undefined
      });
    }}
    modalProps={{
      isBlocking: true
    }}
    dialogContentProps={{
      type: DialogType.close,
      showCloseButton: true
    }}
    width={'570px'}
    height={'250px'} />}
//...
}
The constructed url parameter will look like that: https://tenant.sharepoint.com/sites/your-site/Lists/Journeys/DispForm.aspx?ID=item-id&IsDlg=1
The final code of the component:
export default class PnPListView extends React.Component<IPnPListViewProps, IPnPListViewState> {

  private readonly _countriesLookupFieldId = '5e037cea-aaef-40dd-895f-90442114016f';
  private readonly _lookupFieldDispWebRelativeUrl = '/Lists/Country/DispForm.aspx?ID={0}&RootFolder=*&IsDlg=1';

  private readonly _fields: IViewField[] = [{
    name: 'Title',
    displayName: 'Title',
    minWidth: 150,
    maxWidth: 250,
    render: this._renderTitle
  }, {
    name: 'JourneyDate',
    displayName: 'Journey Date',
    render: this._renderDate
  }, {
    name: 'VisitedCountries',
    displayName: 'Visited Countries',
    minWidth: 100,
    render: (item, index) => { return this._renderCountries(item, index); }
  }, {
    name: 'Experience',
    displayName: 'Experience',
    minWidth: 100,
    render: this._renderExperience
  }, {
    name: 'Picture',
    displayName: 'Picture',
    minWidth: 150,
    render: this._renderPicture
  }, {
    name: 'JourneyType',
    displayName: 'Journey Type',
    minWidth: 100,
    render: this._renderJourneyType
  }];

  constructor(props: IPnPListViewProps) {
    super(props);

    this.state = {};
  }


  public render(): React.ReactElement<IPnPListViewProps> {
    return (
      <div className={styles.pnPListView}>
        <ListView items={this.props.items} viewFields={this._fields} />
        {this.state.selectedLookupId &&
          <IFrameDialog
            hidden={false}
            url={`${this.props.context.pageContext.web.absoluteUrl}${this._lookupFieldDispWebRelativeUrl.replace('{0}', this.state.selectedLookupId.toString())}`}
            iframeOnLoad={iframe => {
              const iframeWindow: Window = iframe.contentWindow;
              const iframeDocument: Document = iframeWindow.document;

              const s4Workspace: HTMLDivElement = iframeDocument.getElementById('s4-workspace') as HTMLDivElement;
              s4Workspace.style.height = iframe.style.height;

              s4Workspace.scrollIntoView();
            }}
            onDismiss={() => {
              this.setState({
                selectedLookupId: undefined
              });
            }}
            modalProps={{
              isBlocking: true
            }}
            dialogContentProps={{
              type: DialogType.close,
              showCloseButton: true
            }}
            width={'570px'}
            height={'250px'} />}
      </div>
    );
  }

  /**
   * Title column renderer
   * @param item ListView item
   */
  private _renderTitle(item?: any): any {
    return <FieldTextRenderer
      text={item.Title}
    />;
  }

  /**
   * Date column renderer
   * @param item ListView item
   */
  private _renderDate(item?: any): any {
    const date = new Date(item['JourneyDate']);
    return <FieldDateRenderer
      text={date.toLocaleDateString()} />;
  }

  /**
   * Countries (Multi Lookup) column renderer
   * @param item ListView item
   * @param index item index
   */
  private _renderCountries(item?: any, index?: number): any {
    //
    // ListView item contains "flattened" information
    // So, we're getting original item first
    //

    const originalItem = this.props.items[index!];
    const visitedCountriesIds = originalItem['VisitedCountriesId'];
    const visitedCountries = originalItem['VisitedCountries'];

    const lookups: ISPFieldLookupValue[] = visitedCountries.map((vc, idx) => {
      return {
        lookupId: visitedCountriesIds[idx].toString(),
        lookupValue: vc
      };
    });

    return <FieldLookupRenderer
      lookups={lookups}
      onClick={args => {
        this.setState({
          selectedLookupId: args.lookup.lookupId
        });
      }} />;
  }

  /**
   * Experience (Choice) column renderer
   * @param item ListView item
   */
  private _renderExperience(item?: any): any {
    const experience: string = item['Expirience'];

    return <FieldTextRenderer
      text={experience}
    />;
  }

  /**
   * Picture (Hyperlink or Picture) column renderer
   * @param item ListView item
   */
  private _renderPicture(item?: any): any {

    return <FieldUrlRenderer
      url={item['Picture.Url']}
      isImageUrl={true}
      className={styles.image}
      text={item['Picture.Description'] || ''} />;
  }

  /**
   * JourneyType (Managed Metadata) column renderer
   * @param item ListView item
   */
  private _renderJourneyType(item?: any) {

    return <FieldTaxonomyRenderer
      terms={[{
        Label: item['JourneyType.Label'],
        TermID: item['JourneyType.TermGuid']
      }]} />;
  }
}
And this is how our list will look:

And lookup popup:

Conclusion

As you can see it's pretty easy to combine different PnP Reusable Controls to work together. Using the same technique you can render other SharePoint column types like People or Group, multiline text, and others.
As mentioned in the beginning of the post, you can find all the code here


That's all for today!
Have fun!

4 comments:

  1. This a great Source for me! thanks a bunch.
    Wonder if you can help me do something you do not show here. Say I need to show your "Journey type" with the entire cell (the height of the picture) background be light green when the value is "Leisure" and light red when it is "Business".
    How would you write the _renderJourneyType function?
    I tried cssProps={{ background: 'green' }} but it adds background only to the text and it is ugly.
    Is there a property I can use on the IViewField with a function?

    ReplyDelete
    Replies
    1. Yep. It's a known behavior both in Office UI Fabric React DetailsList and in actual SharePoint list as well.
      To apply background to the whole cell you will need to use ref and get access to the parent DOM element:
      private _renderJourneyType(item?: any) {

      return {
      ReactDOM.findDOMNode(r).parentElement.style.background = 'green';
      }} />;
      }

      This part is what you're interested in:
      ref={r => {
      ReactDOM.findDOMNode(r).parentElement.style.background = 'green';
      }}

      Delete
    2. Thanks, That worked perfect.
      One more if I may.
      How do i catch a double click or enter key on a row?

      Delete
    3. Good question! I don't think it's possible.
      You should add an enhancement request to the GitHub repo!

      Delete