SPFX - Infinite Scrolling

Como construir con Fluent UI un ejemplo de un Grid con scroll infinito o Infinite scrolling.

SPFX - Infinite Scrolling

En este artículo, aprenderemos a realizar consultas a SharePoint Online utilizando el api renderListDataAsStream y en conjunto del componente DetailList de la librería de componentes de Fluent UI realizaremos un ejemplo de un Grid con scroll infinito o Infinite scrolling, el cual realizará la consulta a una lista de SharePoint que supera el umbral de 5,000 elementos.

Este articulo está dirigido a usuarios avanzados. Por lo tanto, para este ejemplo, se debe estar familiarizado con los siguientes temas:

  • SharePoint Framework (SPFx)
  • React
  • API de SharePoint
  • Bibliotecas PnP

Para más información favor de como comenzar a desarrollar componentes SPFx favor consultar la siguiente liga:

Build your first SharePoint client-side web part

Este es el objetivo final de nuestro articulo:

Como podemos ver, cuando alcanzamos los últimos elementos de nuestro listado, nuestro componente automáticamente realiza la petición para obtener los siguientes elementos, vamos a comenzar a generar este elemento.

Datos de Prueba

Si es posible realizar una consulta a una lista de SharePoint con más de 5,000 elementos.

Para este ejemplo contamos con una lista en SharePoint Online con el nombre de BigList, la cual cuenta con 25,000 elementos.

Para motivos de este ejemplo la lista solamente cuenta con 3 Columnas:

  • Titulo
  • Categoría
  • Fecha

El objetivo de nuestro componente será realizar la consulta de esta lista de manera exitosa, sin errores por superar el umbra de 5,000 elementos.

Creando la solución

Vamos a comenzar creando nuestro proyecto con la siguiente configuración

  • Nombre de la solución: DemoInfiniteScroll

Componente Web

Dentro de nuestra solución vamos a crear una clase de apoyo, SPPagedResponse para organizar los resultados obtenidos por nuestra consulta a SharePoint:

export class SPPagedResponse {
    public items: any[];
    public nextPageToken: string;

    constructor(items:any[], nextPageToken:string) {
      this.items = items;
      this.nextPageToken =nextPageToken;
    }
  }

La estructura final de nuestro proyecto debería ser similar a la siguiente:

Dentro del archivo InifiniteGrid.tsx vamos a definir el estado del componente:

export interface InifiniteGridState {
  listData:any[];
  nextPageToken: string;
}

En el constructor de la clase principal vamos a agregar lo siguiente:

  private _columns: IColumn[];

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

    this._columns = [
      { key: 'Title', name: 'Title', fieldName: 'Title', minWidth: 200, isResizable: true },
      { key: 'Categoria', name: 'Categoria', fieldName: 'Category', minWidth: 200, isResizable: true },
      { key: 'Fecha', name: 'Fecha', fieldName: 'Date', minWidth: 200, isResizable: true }
    ];

    this.state = {
      listData:[],
      nextPageToken:''
    };
  }

Obtener la información de SharePoint

Para realizar la consulta vamos a crear una función llamada getSharePointData la cual se encargará de obtener la información de SharePoint:

  private getSharePointData(nextPageToken?:string){
    return new Promise<SPPagedResponse>((resolve, reject): void => {
    sp.web.lists.getByTitle("BigList").renderListDataAsStream({
      ViewXml: `<View>
        <ViewFields>
          <FieldRef Name="Title"/>
          <FieldRef Name="Categoria"/>
          <FieldRef Name="Fecha"/>
        </ViewFields>
        <RowLimit Paged="TRUE">200</RowLimit>
        </View>`,
      Paging: nextPageToken
    }).then(pagedResponse => {
        //Obtenemos los elementos de la consulta
        let resultItems = pagedResponse.Row.map((item)=>{
          let newItem: any = {
            Title: item["Title"],
            Category:item["Categoria"],
            Date:item["Fecha"]
          };
          return newItem;
        });

        //Obtenemos el token de referencia a la siguiente página
        nextPageToken = pagedResponse.NextHref && pagedResponse.NextHref.length ? pagedResponse.NextHref.split('?')[1] : null;

        //En caso de que exista una siguiente página se agrega un elemento nulo, para indicar al grid que se debe cargar más elementos
        if(nextPageToken != null && nextPageToken != "" ){
          //Se agrega un elemento nulo, para indicarle al grid que debe cargar mas elementos
          resultItems.push(null);
        }

        let result = new SPPagedResponse(resultItems,nextPageToken);
        resolve(result);
      });
    });
  }

Como podemos ver la función es un poco compleja, vamos a revisarla un poco más a detalle:

sp.web.lists.getByTitle("BigList").renderListDataAsStream({
      ViewXml: `<View>
        <ViewFields>
          <FieldRef Name="Title"/>
          <FieldRef Name="Categoria"/>
          <FieldRef Name="Fecha"/>
        </ViewFields>
        <RowLimit Paged="TRUE">200</RowLimit>
        </View>`,
      Paging: nextPageToken

De manera inicial vemos la llamada al api renderListDataAsStream, la cual recibe dos parámetros:

  • ViewXML: Se envia el query CAML de la consulta a realizar en la lista, en este ejemplo solamente se definen las columnas con el numero de elementos, pero se puede modificar agregando el nodo <Query> para incluir filtros a las columnas.
  • Paging: Se envia la cadena de caracteres con el token de la página que se desea obtener, este parametro lo obtenemos de los resultados de la primer consulta.

Existen más parametros disponibles pero para este ejemplo solo nos enfocaremos en los mencionados previamente.

Más adelante vemos que se obtiene y procesa el resultado de la consulta:

        //Obtenemos los elementos de la consulta
        let resultItems = pagedResponse.Row.map((item)=>{
          let newItem: any = {
            Title: item["Title"],
            Category:item["Categoria"],
            Date:item["Fecha"]
          };
          return newItem;
        });

Y en la siguiente sección agregamos un poco de lógica para poder determinar si es necesario realizar una consulta adicional, para obtener el siguiente listado de información.

//Obtenemos el token de referencia a la siguiente página
nextPageToken = pagedResponse.NextHref && pagedResponse.NextHref.length ? pagedResponse.NextHref.split('?')[1] : null;

//En caso de que exista una siguiente página se agrega un elemento nulo, para indicar al grid que se debe cargar más elementos
if(nextPageToken != null && nextPageToken != "" ){
	//Se agrega un elemento nulo, para indicarle al grid que debe cargar mas 	elementos
	resultItems.push(null);
}

En el caso del ultimó apartado el elemento null es la manera en la que el componente DetailList de Fluent UI identifica que es necesario realizar una petición adicional para obtener el siguiente bloque de elementos, esta sección podria cambiar dependiendo del componente que se utilice.

Una vez que tenemos nuestra función la manadamos llamar en el evento componentDidMount y actualizamos el estado de nuestro componente:

public componentDidMount() {
    //Realizamos la consulta inicial
    this.getSharePointData('').then((pagedResult) => {
      this.setState({
        listData: pagedResult.items,
        nextPageToken: pagedResult.nextPageToken
      });
    });
  }

Componente Principal

En el archivo InifiniteGrid.module.scss

Agregamos las siguientes clases:

.inifiniteGrid {
  .container {
    max-width: 700px;
    margin: 0px auto;
  }
  .gridContainer {
    max-height: 300px;
    overflow: auto;
  }

Y finalmente para el visualizar la información de nuestro consulta actualizamos el metodo render de la siguiente manera:

public render(): React.ReactElement<IInifiniteGridProps> {
    return (
      <div className={ styles.inifiniteGrid }>
        <div className={styles.gridContainer} data-is-scrollable="true">
          <DetailsList
            items={this.state.listData}
            columns={this._columns}
            setKey="set"
            layoutMode={DetailsListLayoutMode.fixedColumns}
            constrainMode={ConstrainMode.unconstrained}
            onShouldVirtualize = { () => true}
            onRenderMissingItem={ (index, rowData) => {
              this.LoadMoreItems(this.state.nextPageToken);
              return null;
          } }
          />
        </div>
      </div>
    );
  }

Como podemos ver en la propiedad  onRenderMissingItem se manda llamar una función LoadMoreItems la cual definimos de la siguiente manera:

  public LoadMoreItems = (nextPageData) => {
    let currentListData = this.state.listData;
    if(nextPageData != null && nextPageData != "" ){
      this.getSharePointData(nextPageData).then((pagedResult) => {

        //Quitamos el elemento nulo, para evitar que el grid siga cargando el mismo set de elementos
        currentListData = currentListData.filter((el) => { return el != null; });
        currentListData = [...currentListData, ... pagedResult.items];
        
        //Actualizamos el estado con los nuevos elementos.
        this.setState({
          listData:currentListData,
          nextPageToken:pagedResult.nextPageToken
        });

      });
    }
  }

La función LoadMoreItems, tiene un poco más de logica para el manejo de los elementos que va relacionada al componente DetailList, esta tendriamos que ajustarla dependiendo del componente que se desee usar.

Una vez terminado todo lo mencionado, nuestro archivo InifiniteGrid.tsx deberia quedar de la siguiente manera:

import * as React from 'react';
import styles from './InifiniteGrid.module.scss';
import { sp } from "@pnp/sp";
import "@pnp/sp/webs";
import "@pnp/sp/lists/web";
import "@pnp/sp/items";
import { IInifiniteGridProps } from './IInifiniteGridProps';
import { ConstrainMode, DetailsList, DetailsListLayoutMode, IColumn, Spinner } from 'office-ui-fabric-react';
import { SPPagedResponse } from './PagedResponse';

export interface InifiniteGridState {
  listData:any[];
  nextPageToken: string;
}

export default class InifiniteGrid extends React.Component<IInifiniteGridProps, InifiniteGridState> {
  private _columns: IColumn[];

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

    this._columns = [
      { key: 'Title', name: 'Title', fieldName: 'Title', minWidth: 200, isResizable: true },
      { key: 'Categoria', name: 'Categoria', fieldName: 'Category', minWidth: 200, isResizable: true },
      { key: 'Fecha', name: 'Fecha', fieldName: 'Date', minWidth: 200, isResizable: true }
    ];

    this.state = {
      listData:[],
      nextPageToken:''
    };
  }

  public componentDidMount() {
    //Realizamos la consulta inicial
    this.getSharePointData('').then((pagedResult) => {
      this.setState({
        listData: pagedResult.items,
        nextPageToken: pagedResult.nextPageToken
      });
    });
  }

  //Realizamos la consulta a SharePoint
  private getSharePointData(nextPageToken?:string){
    return new Promise<SPPagedResponse>((resolve, reject): void => {
    sp.web.lists.getByTitle("BigList").renderListDataAsStream({

      ViewXml: `<View>
        <ViewFields>
          <FieldRef Name="Title"/>
          <FieldRef Name="Categoria"/>
          <FieldRef Name="Fecha"/>
        </ViewFields>
        <RowLimit Paged="TRUE">200</RowLimit>
        </View>`,
      Paging: nextPageToken
    }).then(pagedResponse => {
        //Obtenemos los elementos de la consulta
        let resultItems = pagedResponse.Row.map((item)=>{
          let newItem: any = {
            Title: item["Title"],
            Category:item["Categoria"],
            Date:item["Fecha"]
          };
          return newItem;
        });

        //Obtenemos el token de referencia a la siguiente página
        nextPageToken = pagedResponse.NextHref && pagedResponse.NextHref.length ? pagedResponse.NextHref.split('?')[1] : null;

        //En caso de que exista una siguiente página se agrega un elemento nulo, para indicar al grid que se debe cargar más elementos
        if(nextPageToken != null && nextPageToken != "" ){
          //Se agrega un elemento nulo, para indicarle al grid que debe cargar mas elementos
          resultItems.push(null);
        }

        let result = new SPPagedResponse(resultItems,nextPageToken);
        resolve(result);
      });
    });
  }

  public LoadMoreItems = (nextPageData) => {
    let currentListData = this.state.listData;
    if(nextPageData != null && nextPageData != "" ){
      this.getSharePointData(nextPageData).then((pagedResult) => {

        //Quitamos el elemento nulo, para evitar que el grid siga cargando el mismo set de elementos
        currentListData = currentListData.filter((el) => { return el != null; });
        currentListData = [...currentListData, ... pagedResult.items];
        
        //Actualizamos el estado con los nuevos elementos.
        this.setState({
          listData:currentListData,
          nextPageToken:pagedResult.nextPageToken
        });

      });
    }
  }

  public render(): React.ReactElement<IInifiniteGridProps> {
    return (
      <div className={ styles.inifiniteGrid }>
        <div className={styles.gridContainer} data-is-scrollable="true">
          <DetailsList
            items={this.state.listData}
            columns={this._columns}
            setKey="set"
            layoutMode={DetailsListLayoutMode.fixedColumns}
            constrainMode={ConstrainMode.unconstrained}
            onShouldVirtualize = { () => true}
            onRenderMissingItem={ (index, rowData) => {
              this.LoadMoreItems(this.state.nextPageToken);
              return null;
          } }
          />
        </div>
      </div>
    );
  }
}

Finalmente validamos nuestro componente con lo cual nos debe dar la siguiente funcionalidad:

El código fuente es disponible en el siguiente repositorio:

GitHub - inavant/SPFX-Infinite-Scrolling
Contribute to inavant/SPFX-Infinite-Scrolling development by creating an account on GitHub.

Sé productivo. Sé extraordinario. Sé INAVANT.