Compartir a través de


Migrar la solución de jQuery y FullCalendar compilada con el elemento web Editor de scripts a SharePoint Framework

Al crear soluciones de SharePoint, los desarrolladores de SharePoint suelen usar el complemento de JQuery FullCalendar para mostrar los datos en la vista de calendario. FullCalendar es una buena alternativa a la vista de calendario de SharePoint estándar, ya que permite representar como calendario datos procedentes de múltiples listas de calendario, datos de listas que no son de calendario o incluso datos externos a SharePoint. En este artículo se muestra cómo podría migrar una personalización de SharePoint con FullCalendar compilada con el elemento Editor de scripts a SharePoint Framework.

Lista de tareas mostrada como un calendario compilado con el elemento web Editor de scripts

Para mostrar el proceso de migración de una personalización de SharePoint con FullCalendar a SharePoint Framework, use la siguiente solución que muestra una vista de calendario de tareas recuperadas de una lista de SharePoint.

Vista de calendario de tareas mostrada en una página de SharePoint

La solución se crea con el elemento web Editor de scripts de SharePoint estándar. La personalización usa el código siguiente:

<script src="//code.jquery.com/jquery-1.11.1.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.6/moment.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.4.0/fullcalendar.min.js"></script>
<link type="text/css" rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.4.0/fullcalendar.min.css" />
<div id="calendar"></div>

<script>
  var PATH_TO_DISPFORM = _spPageContextInfo.webAbsoluteUrl + "/Lists/Tasks/DispForm.aspx";
  var TASK_LIST = "Tasks";
  var COLORS = ['#466365', '#B49A67', '#93B7BE', '#E07A5F', '#849483', '#084C61', '#DB3A34'];

  displayTasks();

  function displayTasks() {
    $('#calendar').fullCalendar('destroy');
    $('#calendar').fullCalendar({
      weekends: false,
      header: {
        left: 'prev,next today',
        center: 'title',
        right: 'month,basicWeek,basicDay'
      },
      displayEventTime: false,
      // open up the display form when a user clicks on an event
      eventClick: function (calEvent, jsEvent, view) {
        window.location = PATH_TO_DISPFORM + "?ID=" + calEvent.id;
      },
      editable: true,
      timezone: "UTC",
      droppable: true, // this allows things to be dropped onto the calendar
      // update the end date when a user drags and drops an event
      eventDrop: function (event, delta, revertFunc) {
        updateTask(event.id, event.start, event.end);
      },
      // put the events on the calendar
      events: function (start, end, timezone, callback) {
        var startDate = start.format('YYYY-MM-DD');
        var endDate = end.format('YYYY-MM-DD');

        var restQuery = "/_api/Web/Lists/GetByTitle('" + TASK_LIST + "')/items?$select=ID,Title,\
Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
$filter=((DueDate ge '" + startDate + "' and DueDate le '" + endDate + "')or(StartDate ge '" + startDate + "' and StartDate le '" + endDate + "'))";

        $.ajax({
          url: _spPageContextInfo.webAbsoluteUrl + restQuery,
          type: "GET",
          dataType: "json",
          headers: {
            Accept: "application/json;odata=nometadata"
          }
        })
          .done(function (data, textStatus, jqXHR) {
            var personColors = {};
            var colorNo = 0;

            var events = data.value.map(function (task) {
              var assignedTo = task.AssignedTo.map(function (person) {
                return person.Title;
              }).join(', ');

              var color = personColors[assignedTo];
              if (!color) {
                color = COLORS[colorNo++];
                personColors[assignedTo] = color;
              }
              if (colorNo >= COLORS.length) {
                colorNo = 0;
              }

              return {
                title: task.Title + " - " + assignedTo,
                id: task.ID,
                color: color, // specify the background color and border color can also create a class and use className parameter.
                start: moment.utc(task.StartDate).add("1", "days"),
                end: moment.utc(task.DueDate).add("1", "days") // add one day to end date so that calendar properly shows event ending on that day
              };
            });

            callback(events);
          });
      }
    });
  }

  function updateTask(id, startDate, dueDate) {
    // subtract the previously added day to the date to store correct date
    var sDate = moment.utc(startDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
      startDate.format("hh:mm") + ":00Z";
    if (!dueDate) {
      dueDate = startDate;
    }
    var dDate = moment.utc(dueDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
      dueDate.format("hh:mm") + ":00Z";

    $.ajax({
      url: _spPageContextInfo.webAbsoluteUrl + '/_api/contextinfo',
      type: 'POST',
      headers: {
        'Accept': 'application/json;odata=nometadata'
      }
    })
      .then(function (data, textStatus, jqXHR) {
        return $.ajax({
          url: _spPageContextInfo.webAbsoluteUrl +
          "/_api/Web/Lists/getByTitle('" + TASK_LIST + "')/Items(" + id + ")",
          type: 'POST',
          data: JSON.stringify({
            StartDate: sDate,
            DueDate: dDate,
          }),
          headers: {
            Accept: "application/json;odata=nometadata",
            "Content-Type": "application/json;odata=nometadata",
            "X-RequestDigest": data.FormDigestValue,
            "IF-MATCH": "*",
            "X-Http-Method": "PATCH"
          }
        });
      })
      .done(function (data, textStatus, jqXHR) {
        alert("Update Successful");
      })
      .fail(function (jqXHR, textStatus, errorThrown) {
        alert("Update Failed");
      })
      .always(function () {
        displayTasks();
      });
  }
</script>

Nota

Esta solución se basa en el trabajo de Mark Rackley, MVP de Office Servers and Services y Chief Strategy Officer de PAIT Group. Para más información sobre la solución original, vea Usar FullCalendar.io para crear calendarios personalizados en SharePoint.

En primer lugar, la personalización carga las bibliotecas que usa: jQuery, Moment.js y FullCalendar en los primeros elementos <script> y <link>.

Luego, define el <div> en el que se inserta la vista de calendario generada.

Después, define dos funciones: displayTasks(), que se usa para mostrar las tareas en la vista de calendario, y updateTask(), que se activa después de arrastrar y soltar una tarea a una fecha diferente, y que actualiza las fechas en el elemento de lista subyacente. Cada función define su propia consulta de REST, que luego se usa para comunicarse con la API de REST de la lista de SharePoint para recuperar o actualizar los elementos de la lista.

Con el complemento FullCalendar de jQuery, los usuarios obtienen, sin apenas esfuerzo, soluciones completas capaces de cosas tales como el uso de distintos colores para marcar distintos eventos o el uso de arrastrar y soltar para reorganizar eventos.

Arrastrar eventos en FullCalendar para volver a programar tareas subyacentes

Migrar la solución de calendario de tareas del elemento web Editor de scripts a SharePoint Framework

Transformar una personalización basada en un elemento web Editor de scripts en SharePoint Framework ofrece una serie de ventajas, como una configuración más fácil de usar y la administración centralizada de la solución. A continuación, se incluye una descripción paso a paso de cómo migrar la solución a SharePoint Framework.

En primer lugar, migre la solución a SharePoint Framework con el menor número de cambios que pueda realizar en el código original. Luego, debe transformar el código de la solución en TypeScript para beneficiarse de las características de seguridad de tipo en tiempo de desarrollo y reemplazar parte del código por la API de SharePoint Framework para beneficiarse por completo de sus capacidades y simplificar aún más la solución.

Nota

El código fuente del proyecto en las distintas fases de migración está disponible en Tutorial: Migrar la solución de jQuery y FullCalendar compilada con el elemento web del Editor de scripts a SharePoint Framework.

Crear un proyecto de SharePoint Framework

  1. Empiece por crear una carpeta para el proyecto:

    md fullcalendar-taskscalendar
    
  2. Vaya a la carpeta del proyecto:

    cd fullcalendar-taskscalendar
    
  3. En la carpeta del proyecto, ejecute el generador de Yeoman de SharePoint Framework para aplicar scaffolding a un nuevo proyecto de SharePoint Framework:

    yo @microsoft/sharepoint
    
  4. En el momento en que se le solicite, introduzca los siguientes valores (seleccione la opción predeterminada para todas las solicitudes que se omitan a continuación):

    • ¿Cómo se llama su solución?: fullcalendar-taskscalendar
    • ¿Qué paquetes de línea base quiere usar como destino para el componente o los componentes?: solo SharePoint Online (versión más reciente)
    • ¿Cuál es el tipo de componente del lado cliente que se va a crear?: Elemento web
    • ¿Cómo se llama su elemento web?: Calendario de tareas
    • ¿Cuál es la descripción del elemento web?: Muestra tareas en la vista Calendario
    • ¿Qué marco desearía usar?: sin marco de JavaScript
  5. Abra la carpeta del proyecto en su editor de código. En este tutorial, usará Visual Studio Code.

Cargar bibliotecas de JavaScript

De forma similar a la solución original compilada con el elemento web Editor de scripts, primero debe cargar las bibliotecas de JavaScript que necesita la solución. En SharePoint Framework esto normalmente consta de dos pasos: especificar la dirección URL de la biblioteca que se va a cargar y hacer referencia a la biblioteca en el código.

  1. Especifique las direcciones URL de las bibliotecas que se van a cargar.

    En el editor de código, abra el archivo ./config/config.json y cambie la sección externals a:

    {
      // ..
      "externals": {
        "jquery": {
          "path": "https://code.jquery.com/jquery-1.11.1.min.js",
          "globalName": "jQuery"
        },
        "fullcalendar": {
          "path": "https://cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.4.0/fullcalendar.min.js",
          "globalName": "jQuery",
          "globalDependencies": [
            "jquery"
          ]
        },
        "moment": "https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.6/moment.min.js"
      },
      // ..
    }
    
  2. Abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts y, tras la última instrucción import, agregue el siguiente código:

    import 'jquery';
    import 'moment';
    import 'fullcalendar';
    

Definir div de contenedor

Al igual que en la solución original, el paso siguiente consiste en definir la ubicación en donde debe representarse el calendario.

En el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts y cambie el método render() a:

export default class ItRequestsWebPart extends BaseClientSideWebPart<IItRequestsWebPartProps> {
  public render(): void {
    this.domElement.innerHTML = `
      <div class="${styles.tasksCalendar}">
        <link type="text/css" rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.4.0/fullcalendar.min.css" />
        <div id="calendar"></div>
      </div>`;
  }
  // ...
}

Inicializar FullCalendar y cargar los datos

El último paso consiste en incluir el código que inicializa el complemento FullCalendar de jQuery y carga los datos de SharePoint.

  1. En la carpeta ./src/webparts/tasksCalendar, cree un archivo denominado script.js y pegue el código siguiente:

    var moment = require('moment');
    
    var PATH_TO_DISPFORM = window.webAbsoluteUrl + "/Lists/Tasks/DispForm.aspx";
    var TASK_LIST = "Tasks";
    var COLORS = ['#466365', '#B49A67', '#93B7BE', '#E07A5F', '#849483', '#084C61', '#DB3A34'];
    
    displayTasks();
    
    function displayTasks() {
      $('#calendar').fullCalendar('destroy');
      $('#calendar').fullCalendar({
        weekends: false,
        header: {
          left: 'prev,next today',
          center: 'title',
          right: 'month,basicWeek,basicDay'
        },
        displayEventTime: false,
        // open up the display form when a user clicks on an event
        eventClick: function (calEvent, jsEvent, view) {
          window.location = PATH_TO_DISPFORM + "?ID=" + calEvent.id;
        },
        editable: true,
        timezone: "UTC",
        droppable: true, // this allows things to be dropped onto the calendar
        // update the end date when a user drags and drops an event
        eventDrop: function (event, delta, revertFunc) {
          updateTask(event.id, event.start, event.end);
        },
        // put the events on the calendar
        events: function (start, end, timezone, callback) {
          var startDate = start.format('YYYY-MM-DD');
          var endDate = end.format('YYYY-MM-DD');
    
          var restQuery = "/_api/Web/Lists/GetByTitle('" + TASK_LIST + "')/items?$select=ID,Title,\
              Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
              $filter=((DueDate ge '" + startDate + "' and DueDate le '" + endDate + "')or(StartDate ge '" + startDate + "' and StartDate le '" + endDate + "'))";
    
          $.ajax({
            url: window.webAbsoluteUrl + restQuery,
            type: "GET",
            dataType: "json",
            headers: {
              Accept: "application/json;odata=nometadata"
            }
          })
            .done(function (data, textStatus, jqXHR) {
              var personColors = {};
              var colorNo = 0;
    
              var events = data.value.map(function (task) {
                var assignedTo = task.AssignedTo.map(function (person) {
                  return person.Title;
                }).join(', ');
    
                var color = personColors[assignedTo];
                if (!color) {
                  color = COLORS[colorNo++];
                  personColors[assignedTo] = color;
                }
                if (colorNo >= COLORS.length) {
                  colorNo = 0;
                }
    
                return {
                  title: task.Title + " - " + assignedTo,
                  id: task.ID,
                  color: color, // specify the background color and border color can also create a class and use className parameter.
                  start: moment.utc(task.StartDate).add("1", "days"),
                  end: moment.utc(task.DueDate).add("1", "days") // add one day to end date so that calendar properly shows event ending on that day
                };
              });
    
              callback(events);
            });
        }
      });
    }
    
    function updateTask(id, startDate, dueDate) {
      // subtract the previously added day to the date to store correct date
      var sDate = moment.utc(startDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
        startDate.format("hh:mm") + ":00Z";
      if (!dueDate) {
        dueDate = startDate;
      }
      var dDate = moment.utc(dueDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
        dueDate.format("hh:mm") + ":00Z";
    
      $.ajax({
        url: window.webAbsoluteUrl + '/_api/contextinfo',
        type: 'POST',
        headers: {
          'Accept': 'application/json;odata=nometadata'
        }
      })
        .then(function (data, textStatus, jqXHR) {
          return $.ajax({
            url: window.webAbsoluteUrl +
            "/_api/Web/Lists/getByTitle('" + TASK_LIST + "')/Items(" + id + ")",
            type: 'POST',
            data: JSON.stringify({
              StartDate: sDate,
              DueDate: dDate,
            }),
            headers: {
              Accept: "application/json;odata=nometadata",
              "Content-Type": "application/json;odata=nometadata",
              "X-RequestDigest": data.FormDigestValue,
              "IF-MATCH": "*",
              "X-Http-Method": "PATCH"
            }
          });
        })
        .done(function (data, textStatus, jqXHR) {
          alert("Update Successful");
        })
        .fail(function (jqXHR, textStatus, errorThrown) {
          alert("Update Failed");
        })
        .always(function () {
          displayTasks();
        });
    }
    

Este código es casi idéntico al código original de la personalización del elemento web Script Editor. La única diferencia es que, donde el código original recuperaba la dirección URL de la web actual de la variable global _spPageContextInfo establecida por SharePoint, el código de SharePoint Framework usa una variable personalizada que habrá que establecer en el elemento web.

Los elementos web del lado cliente de SharePoint Framework se pueden usar tanto en las páginas clásicas como en las modernas. Si bien la variable _spPageContextInfo está presente en las páginas clásicas, no está disponible en las páginas modernas, por lo que no puede depender de ella y, en su lugar, necesita una propiedad personalizada que usted pueda controlar.

  1. Para hacer referencia a este archivo en el elemento web, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts en el editor de código y cambie el método render() a:

    export default class ItRequestsWebPart extends BaseClientSideWebPart<IItRequestsWebPartProps> {
      public render(): void {
        this.domElement.innerHTML = `
          <div class="${styles.tasksCalendar}">
            <link type="text/css" rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.4.0/fullcalendar.min.css" />
            <div id="calendar"></div>
          </div>`;
    
        (window as any).webAbsoluteUrl = this.context.pageContext.web.absoluteUrl;
        require('./script');
      }
      // ...
    }
    
  2. Compruebe que el elemento web funciona como debería. Para ello, ejecute lo siguiente en la línea de comandos:

    gulp serve --nobrowser
    

    Dado que el elemento web carga sus datos de SharePoint, tiene que probarlo con SharePoint Framework Workbench hospedado.

  3. Desplácese hasta https://{nombre-de-su-espacio-empresarial}.sharepoint.com/_layouts/workbench.aspx y agregue el elemento web al lienzo. Ahora deben aparecer las tareas en una vista de calendario mediante el complemento FullCalendar de jQuery.

    Tareas mostradas en una vista de calendario de un elemento web del lado cliente de SharePoint Framework

Agregar compatibilidad para configurar el elemento web mediante las propiedades de elemento web

En los pasos anteriores, migró las soluciones de calendario de tareas del elemento web Editor de scripts a SharePoint Framework. Aunque la solución ya funciona correctamente, no usa ninguno de los beneficios de SharePoint Framework. El nombre de la lista desde la que se cargan las tareas se incluye en el código que es JavaScript sin formato, por lo que es más difícil de refactorizar que TypeScript.

En los pasos siguientes se muestra cómo ampliar la solución existente para permitir a los usuarios especificar el nombre de la lista desde la que se cargarán los datos. Luego, debe transformar el código en TypeScript para beneficiarse de sus características de seguridad de tipo.

Definir la propiedad del elemento web para almacenar el nombre de la lista

  1. Defina una propiedad del elemento web para almacenar el nombre de la lista desde la que deben cargarse las tareas. En el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.manifest.json, cambie el nombre de la propiedad description predeterminada a listName y borre el valor.

  2. Actualice la interfaz de propiedades de elemento web para que refleje los cambios realizados en el manifiesto. En el editor de código, abra el archivo ./src/webparts/tasksCalendar/ITasksCalendarWebPartProps.ts y cambie el contenido por lo siguiente:

    export interface ITasksCalendarWebPartProps {
      listName: string;
    }
    
  3. Actualice las etiquetas de visualización de la propiedad listName.

    Abra el archivo ./src/webparts/tasksCalendar/loc/mystrings.d.ts y cambie el contenido a:

    declare interface ITasksCalendarStrings {
      PropertyPaneDescription: string;
      BasicGroupName: string;
      ListNameFieldLabel: string;
    }
    
    declare module 'tasksCalendarStrings' {
      const strings: ITasksCalendarStrings;
      export = strings;
    }
    
  4. Abra el archivo ./src/webparts/tasksCalendar/loc/en-us.js y cambie el contenido a:

    define([], function() {
      return {
        "PropertyPaneDescription": "Tasks calendar settings",
        "BasicGroupName": "Data",
        "ListNameFieldLabel": "List name"
      }
    });
    
  5. Actualice el elemento web para que use la propiedad recién definida. En el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts y cambie el método getPropertyPaneConfiguration por lo siguiente:

    export default class TasksCalendarWebPart extends BaseClientSideWebPart<ITasksCalendarWebPartProps> {
      // ...
      protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
        return {
          pages: [
            {
              header: {
                description: strings.PropertyPaneDescription
              },
              groups: [
                {
                  groupName: strings.BasicGroupName,
                  groupFields: [
                    PropertyPaneTextField('listName', {
                      label: strings.ListNameFieldLabel
                    })
                  ]
                }
              ]
            }
          ]
        };
      }
    
      protected get disableReactivePropertyChanges(): boolean {
        return true;
      }
    }
    

Para impedir que el elemento web vuelva a cargarse cuando los usuarios escriben el nombre de la lista, también ha configurado el elemento web para que use el panel de propiedades no reactivo mediante la adición del método disableReactivePropertyChanges() y el establecimiento de su valor devuelto en true.

Usar el nombre configurado de la lista desde la que se cargan los datos

Inicialmente, el nombre de la lista desde la que deben cargarse los datos se insertaba en las consultas REST. Ahora que los usuarios pueden configurar este nombre, hay que inyectar el valor configurado en las consultas REST antes de ejecutarlas. La forma más fácil de hacerlo consiste en mover el contenido del archivo script.js al archivo de elemento web principal.

  1. En el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts.

  2. Busque el siguiente código, que agregó previamente al archivo:

    import 'jquery';
    import 'moment';
    import 'fullcalendar';
    

    Actualice el código al código siguiente:

    var $: any = require('jquery');
    var moment: any = require('moment');
    
    import 'fullcalendar';
    
    var COLORS = ['#466365', '#B49A67', '#93B7BE', '#E07A5F', '#849483', '#084C61', '#DB3A34'];
    

    Dado que se hace referencia a Moment.js en el código que va a usar más adelante, su nombre debe ser conocido para TypeScript; de lo contrario, el proyecto no se compilará. Lo mismo se aplica a jQuery.

    Dado que FullCalendar es un complemento de jQuery que se asocia al objeto jQuery, no necesita cambiar la manera en la que se importa.

    La última parte incluye copiar la lista de colores que se usan para marcar los distintos eventos.

  3. Copie las funciones displayTasks() y updateTask() del archivo script.js y péguelas como se indica a continuación en la clase TasksCalendarWebPart:

    export default class TasksCalendarWebPart extends BaseClientSideWebPart<ITasksCalendarWebPartProps> {
      // ...
    
      private displayTasks() {
        $('#calendar').fullCalendar('destroy');
        $('#calendar').fullCalendar({
          weekends: false,
          header: {
            left: 'prev,next today',
            center: 'title',
            right: 'month,basicWeek,basicDay'
          },
          displayEventTime: false,
          // open up the display form when a user clicks on an event
          eventClick: (calEvent, jsEvent, view) => {
            (window as any).location = this.context.pageContext.web.absoluteUrl +
              "/Lists/" + escape(this.properties.listName) + "/DispForm.aspx?ID=" + calEvent.id;
          },
          editable: true,
          timezone: "UTC",
          droppable: true, // this allows things to be dropped onto the calendar
          // update the end date when a user drags and drops an event
          eventDrop: (event, delta, revertFunc) => {
            this.updateTask(event.id, event.start, event.end);
          },
          // put the events on the calendar
          events: (start, end, timezone, callback) => {
            var startDate = start.format('YYYY-MM-DD');
            var endDate = end.format('YYYY-MM-DD');
    
            var restQuery = "/_api/Web/Lists/GetByTitle('" + escape(this.properties.listName) + "')/items?$select=ID,Title,\
    Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
    $filter=((DueDate ge '" + startDate + "' and DueDate le '" + endDate + "')or(StartDate ge '" + startDate + "' and StartDate le '" + endDate + "'))";
    
            $.ajax({
              url: this.context.pageContext.web.absoluteUrl + restQuery,
              type: "GET",
              dataType: "json",
              headers: {
                Accept: "application/json;odata=nometadata"
              }
            })
              .done((data, textStatus, jqXHR) => {
                var personColors = {};
                var colorNo = 0;
    
                var events = data.value.map((task) => {
                  var assignedTo = task.AssignedTo.map((person) => {
                    return person.Title;
                  }).join(', ');
    
                  var color = personColors[assignedTo];
                  if (!color) {
                    color = COLORS[colorNo++];
                    personColors[assignedTo] = color;
                  }
                  if (colorNo >= COLORS.length) {
                    colorNo = 0;
                  }
    
                  return {
                    title: task.Title + " - " + assignedTo,
                    id: task.ID,
                    color: color, // specify the background color and border color can also create a class and use className parameter.
                    start: moment.utc(task.StartDate).add("1", "days"),
                    end: moment.utc(task.DueDate).add("1", "days") // add one day to end date so that calendar properly shows event ending on that day
                  };
                });
    
                callback(events);
              });
          }
        });
      }
    
      private updateTask(id, startDate, dueDate) {
        // subtract the previously added day to the date to store correct date
        var sDate = moment.utc(startDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
          startDate.format("hh:mm") + ":00Z";
        if (!dueDate) {
          dueDate = startDate;
        }
        var dDate = moment.utc(dueDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
          dueDate.format("hh:mm") + ":00Z";
    
        $.ajax({
          url: this.context.pageContext.web.absoluteUrl + '/_api/contextinfo',
          type: 'POST',
          headers: {
            'Accept': 'application/json;odata=nometadata'
          }
        })
          .then((data, textStatus, jqXHR) => {
            return $.ajax({
              url: this.context.pageContext.web.absoluteUrl +
              "/_api/Web/Lists/getByTitle('" + escape(this.properties.listName) + "')/Items(" + id + ")",
              type: 'POST',
              data: JSON.stringify({
                StartDate: sDate,
                DueDate: dDate,
              }),
              headers: {
                Accept: "application/json;odata=nometadata",
                "Content-Type": "application/json;odata=nometadata",
                "X-RequestDigest": data.FormDigestValue,
                "IF-MATCH": "*",
                "X-Http-Method": "PATCH"
              }
            });
          })
          .done((data, textStatus, jqXHR) => {
            alert("Update Successful");
          })
          .fail((jqXHR, textStatus, errorThrown) => {
            alert("Update Failed");
          })
          .always(() => {
            this.displayTasks();
          });
      // ...
    }
    

    Hay algunos cambios en el código con respecto a la situación anterior.

    • Las funciones de JavaScript sin formato se cambian ahora a métodos de TypeScript al reemplazar la palabra clave function por el modificador private. Esto es necesario para que se puedan agregar a la clase TaskCalendarWebPart.
    • Dado que ambos métodos están ahora en el mismo archivo que el elemento web, en lugar de definir una variable global que contenga la dirección URL del sitio actual, puede obtener acceso directamente desde el contexto del elemento web mediante la propiedad this.context.pageContext.web.absoluteUrl.
    • En todas las consultas REST, el nombre de la lista fija se reemplaza por el valor de la propiedad listName, que contiene el nombre de la lista tal y como lo ha configurado el usuario. Antes de usar el valor, se aplican caracteres de escape con la función escape() de lodash para impedir la inyección de script.
  4. Como último paso, cambie el método render() para que llame al método displayTasks() recién agregado:

    export default class TasksCalendarWebPart extends BaseClientSideWebPart<ITasksCalendarWebPartProps> {
      public render(): void {
        this.domElement.innerHTML = `
          <div class="${styles.tasksCalendar}">
            <link type="text/css" rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/fullcalendar/3.4.0/fullcalendar.min.css" />
            <div id="calendar"></div>
          </div>`;
    
        this.displayTasks();
      }
      // ...
    }
    
  5. Como ha movido el contenido del archivo script.js al archivo de elemento web principal, script.js ya no es necesario y puede eliminarlo del proyecto.

  6. Para comprobar que el elemento web funciona del modo previsto, ejecute lo siguiente en la línea de comandos:

    gulp serve --nobrowser
    
  7. Vaya al área de trabajo hospedada y agregue el elemento web al lienzo. Abra el panel de propiedades del elemento web, especifique el nombre de lista con las tareas y seleccione el botón Aplicar para confirmar los cambios. Ahora deberían aparecer las tareas en una vista de calendario en el elemento web.

    Tareas cargadas de la lista configurada y mostradas en un elemento web del lado cliente de SharePoint Framework

Transformar el código JavaScript sin formato a TypeScript

El uso de TypeScript sobre JavaScript sin formato ofrece varias ventajas. TypeScript no solo es más fácil de mantener y refactorizar, sino que permite también detectar los errores antes. En los pasos siguientes se describe cómo se transformaría el código JavaScript original en TypeScript.

Agregar declaraciones de tipo para las bibliotecas que se usan

Para que funcione correctamente, TypeScript requiere declaraciones de tipo para las distintas bibliotecas que se usan en el proyecto. Las declaraciones de tipo suelen distribuirse como paquetes npm en el espacio de nombres @types.

  1. Ejecute lo siguiente en la línea de comandos para instalar las declaraciones de tipo de jQuery:

    npm install @types/jquery@1 --save-dev
    

    Las declaraciones de tipo de Moment.js se distribuyen con el paquete de Moment.js. Aunque cargue Moment.js desde una dirección URL, para usar sus tipos, tiene que instalar el paquete de Moment.js en el proyecto.

  2. Ejecute lo siguiente en la línea de comandos para instalar el paquete de Moment.js:

    npm install moment --save
    

Nota

Observará que no estamos instalando una declaración de tipo para la biblioteca FullCalendar. La solución que inició este artículo es usar una versión anterior de FullCalendar que no tenía declaraciones de tipo válidas en ese momento, por lo que vamos a utilizar los tipos de TypeScript únicamente de forma selectiva en nuestra solución.

Lo ideal sería que se planteara actualizar el proyecto a una nueva versión de la biblioteca FullCalendar. Esto va más allá del ámbito de este artículo, ya que hay numerosos cambios en la API respecto a la versión en la que se basa nuestra solución de inicio.

Actualizar las referencias del paquete

Para usar tipos de las declaraciones de tipo instaladas, debe cambiar la forma de hacer referencia a las bibliotecas.

  1. En el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts

  2. Busque el siguiente código, que agregó previamente al archivo:

    var $: any = require('jquery');
    var moment: any = require('moment');
    
    import 'fullcalendar';
    

    Actualice el código al código siguiente:

    import * as $ from 'jquery';
    import 'fullcalendar';
    import * as moment from 'moment';
    

Actualizar los archivos de elemento web principales a TypeScript

Ahora que tiene las declaraciones de tipo para todas las bibliotecas instaladas en el proyecto, puede empezar a transformar el código JavaScript sin formato a TypeScript.

  1. Defina una interfaz para una tarea que recupere de la lista de SharePoint. En el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts e, inmediatamente antes de la clase del elemento web, agregue el fragmento de código siguiente:

    interface ITask {
      ID: number;
      Title: string;
      StartDate: string;
      DueDate: string;
      AssignedTo: [{ Title: string }];
    }
    
  2. En la clase de elemento web, cambie los métodos displayTasks() y updateTask() a:

    private displayTasks() {
      ($('#calendar') as any).fullCalendar('destroy');
      ($('#calendar') as any).fullCalendar({
        weekends: false,
        header: {
          left: 'prev,next today',
          center: 'title',
          right: 'month,basicWeek,basicDay'
        },
        displayEventTime: false,
        // open up the display form when a user clicks on an event
        eventClick: (calEvent: any, jsEvent: MouseEvent, view: any): void => {
          (window as any).location = this.context.pageContext.web.absoluteUrl +
            "/Lists/" + escape(this.properties.listName) + "/DispForm.aspx?ID=" + calEvent.id;
        },
        editable: true,
        timezone: "UTC",
        droppable: true, // this allows things to be dropped onto the calendar
        // update the end date when a user drags and drops an event
        eventDrop: (event: any, delta: moment.Duration, revertFunc: Function): void => {
          this.updateTask(<number>event.id, <moment.Moment>event.start, <moment.Moment>event.end);
        },
        // put the events on the calendar
        events: (start: moment.Moment, end: moment.Moment, timezone: string, callback: Function): void => {
          var startDate = start.format('YYYY-MM-DD');
          var endDate = end.format('YYYY-MM-DD');
    
          var restQuery = "/_api/Web/Lists/GetByTitle('" + escape(this.properties.listName) + "')/items?$select=ID,Title,\
      Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
      $filter=((DueDate ge '" + startDate + "' and DueDate le '" + endDate + "')or(StartDate ge '" + startDate + "' and StartDate le '" + endDate + "'))";
    
          $.ajax({
            url: this.context.pageContext.web.absoluteUrl + restQuery,
            type: "GET",
            dataType: "json",
            headers: {
              Accept: "application/json;odata=nometadata"
            }
          })
            .done((data: { value: ITask[] }, textStatus: string, jqXHR: JQueryXHR) => {
              let personColors: { [person: string]: string; } = {};
              var colorNo = 0;
    
              var events = data.value.map((task: ITask): any => {
                var assignedTo = task.AssignedTo.map((person: { Title: string }): string => {
                  return person.Title;
                }).join(', ');
    
                var color = personColors[assignedTo];
                if (!color) {
                  color = COLORS[colorNo++];
                  personColors[assignedTo] = color;
                }
                if (colorNo >= COLORS.length) {
                  colorNo = 0;
                }
    
                return {
                  title: task.Title + " - " + assignedTo,
                  id: task.ID,
                  color: color, // specify the background color and border color can also create a class and use className parameter.
                  start: moment.utc(task.StartDate).add("1", "days"),
                  end: moment.utc(task.DueDate).add("1", "days") // add one day to end date so that calendar properly shows event ending on that day
                };
              });
    
              callback(events);
            });
        }
      });
    }
    
    private updateTask(id: number, startDate: moment.Moment, dueDate: moment.Moment) {
      // subtract the previously added day to the date to store correct date
      var sDate = moment.utc(startDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
        startDate.format("hh:mm") + ":00Z";
      if (!dueDate) {
        dueDate = startDate;
      }
      var dDate = moment.utc(dueDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
        dueDate.format("hh:mm") + ":00Z";
    
      $.ajax({
        url: this.context.pageContext.web.absoluteUrl + '/_api/contextinfo',
        type: 'POST',
        headers: {
          'Accept': 'application/json;odata=nometadata'
        }
      })
        .then((data: { FormDigestValue: string }, textStatus: string, jqXHR: JQueryXHR): JQueryXHR => {
          return $.ajax({
            url: this.context.pageContext.web.absoluteUrl +
              "/_api/Web/Lists/getByTitle('" + escape(this.properties.listName) + "')/Items(" + id + ")",
            type: 'POST',
            data: JSON.stringify({
              StartDate: sDate,
              DueDate: dDate,
            }),
            headers: {
              Accept: "application/json;odata=nometadata",
              "Content-Type": "application/json;odata=nometadata",
              "X-RequestDigest": data.FormDigestValue,
              "IF-MATCH": "*",
              "X-Http-Method": "PATCH"
            }
          });
        })
        .done((data: {}, textStatus: string, jqXHR: JQueryXHR): void => {
          alert("Update Successful");
        })
        .fail((jqXHR: JQueryXHR, textStatus: string, errorThrown: string) => {
          alert("Update Failed");
        })
        .always((): void => {
          this.displayTasks();
        });
      // ...
    }
    

El primer cambio al transformar JavaScript sin formato en TypeScript son los tipos explícitos. Aunque no son necesarios, permiten dejar claro qué tipo de datos se esperan. TypeScript detecta inmediatamente cualquier desviación del contrato especificado, lo que le ayuda a encontrar algunos problemas durante el proceso de desarrollo. Esto resulta útil cuando se trabaja con respuestas de AJAX y sus datos.

Otro cambio, que puede que ya haya notado, es la interpolación de cadenas de TypeScript. El uso de la interpolación de cadenas simplifica la composición de cadenas dinámicas y aumenta la legibilidad del código.

Compare JavaScript sin formato:

var restQuery = "/_api/Web/Lists/GetByTitle('" + TASK_LIST + "')/items?$select=ID,Title,\
Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
$filter=((DueDate ge '" + startDate + "' and DueDate le '" + endDate + "')or(StartDate ge '" + startDate + "' and StartDate le '" + endDate + "'))";

con:

const restQuery: string = `/_api/Web/Lists/GetByTitle('${escape(this.properties.listName)}')/items?$select=ID,Title,\
Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
$filter=((DueDate ge '${startDate}' and DueDate le '${endDate}')or(StartDate ge '${startDate}' and StartDate le '${endDate}'))`;

La ventaja adicional de usar la interpolación de cadenas de TypeScript es que no hay que usar comillas de escape, lo que además simplifica la redacción de consultas REST.

  1. Para confirmar que todo funciona del modo previsto, ejecute lo siguiente en la línea de comandos:

    gulp serve --nobrowser
    
  2. Vaya al área de trabajo hospedada y agregue el elemento web al lienzo. Aunque visualmente no ha cambiado nada, la nueva base de código usa TypeScript y sus declaraciones de tipo para ayudarle a mantener la solución.

Reemplazar llamadas AJAX de jQuery por la API de SharePoint Framework

La solución usa actualmente llamadas AJAX de jQuery para comunicarse con la API de REST de SharePoint. En el caso de las solicitudes GET habituales, la API de AJAX de jQuery es tan práctica como usar la API SPHttpClient de SharePoint Framework. La diferencia más evidente se observa al realizar solicitudes POST como la solicitud para actualizar el evento:

$.ajax({
  url: this.context.pageContext.web.absoluteUrl + '/_api/contextinfo',
  type: 'POST',
  headers: { 'Accept': 'application/json;odata=nometadata'}
})
  .then((data: { FormDigestValue: string }, textStatus: string, jqXHR: JQueryXHR): JQueryXHR => {
    return $.ajax({
      url: `${this.context.pageContext.web.absoluteUrl}\
/_api/Web/Lists/getByTitle('${escape(this.properties.listName)}')/Items(${id})`,
      type: 'POST',
      data: JSON.stringify({
        StartDate: sDate,
        DueDate: dDate,
      }),
      headers: {
        Accept: "application/json;odata=nometadata",
        "Content-Type": "application/json;odata=nometadata",
        "X-RequestDigest": data.FormDigestValue,
        "IF-MATCH": "*",
        "X-Http-Method": "PATCH"
      }
    });
  })
  .done((data: {}, textStatus: string, jqXHR: JQueryXHR): void => {
    alert("Update Successful");
  });
  // ...

Puesto que quiere actualizar un elemento de lista, tiene que proporcionar un token de resumen de la solicitud válido a SharePoint. Aunque el resumen está disponible en las páginas clásicas, solo es válido durante tres minutos. Por lo tanto, lo más seguro es recuperar siempre un token válido antes de hacer una operación de actualización. Cuando obtenga el resumen de solicitud, tiene que agregarlo a los encabezados de solicitud de la solicitud de actualización. Si no lo hace, la solicitud no se realizará.

La API SPHttpClient de SharePoint Framework simplifica la comunicación con SharePoint, ya que detecta si la solicitud es una solicitud POST y necesita un resumen de solicitud válido. Si es así, la API SPHttpClient recupera el resumen automáticamente desde SharePoint y lo agrega a la solicitud. En cambio, la misma solicitud emitida con la API SPHttpClient tendría este aspecto:

this.context.spHttpClient.post(`${this.context.pageContext.web.absoluteUrl}\
/_api/Web/Lists/getByTitle('${escape(this.properties.listName)}')/Items(${id})`, SPHttpClient.configurations.v1, {
  body: JSON.stringify({
    StartDate: sDate,
    DueDate: dDate,
  }),
  headers: {
    Accept: "application/json;odata=nometadata",
    "Content-Type": "application/json;odata=nometadata",
    "IF-MATCH": "*",
    "X-Http-Method": "PATCH"
  }
})
.then((response: SPHttpClientResponse): void => {
  // ...
});
  1. Para reemplazar las llamadas AJAX de jQuery originales por la API SPHttpClient de SharePoint Framework, en el editor de código, abra el archivo ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts. Agregue lo siguiente a la lista de instrucciones de import existentes:

    import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
    
  2. En la clase TasksCalendarWebPart, reemplace los métodos displayTasks() y updateTask() con el código siguiente:

    private displayTasks() {
      ($('#calendar') as any).fullCalendar('destroy');
      ($('#calendar') as any).fullCalendar({
        weekends: false,
        header: {
          left: 'prev,next today',
          center: 'title',
          right: 'month,basicWeek,basicDay'
        },
        displayEventTime: false,
        // open up the display form when a user clicks on an event
        eventClick: (calEvent: any, jsEvent: MouseEvent, view: any): void => {
          (window as any).location = this.context.pageContext.web.absoluteUrl +
            "/Lists/" + escape(this.properties.listName) + "/DispForm.aspx?ID=" + calEvent.id;
        },
        editable: true,
        timezone: "UTC",
        droppable: true, // this allows things to be dropped onto the calendar
        // update the end date when a user drags and drops an event
        eventDrop: (event: any, delta: moment.Duration, revertFunc: Function): void => {
          this.updateTask(<number>event.id, <moment.Moment>event.start, <moment.Moment>event.end);
        },
        // put the events on the calendar
        events: (start: moment.Moment, end: moment.Moment, timezone: string, callback: Function): void => {
          var startDate = start.format('YYYY-MM-DD');
          var endDate = end.format('YYYY-MM-DD');
    
          var restQuery = "/_api/Web/Lists/GetByTitle('" + escape(this.properties.listName) + "')/items?$select=ID,Title,\
      Status,StartDate,DueDate,AssignedTo/Title&$expand=AssignedTo&\
      $filter=((DueDate ge '" + startDate + "' and DueDate le '" + endDate + "')or(StartDate ge '" + startDate + "' and StartDate le '" + endDate + "'))";
    
          this.context.spHttpClient.get(this.context.pageContext.web.absoluteUrl + restQuery, SPHttpClient.configurations.v1, {
            headers: {
              'Accept': "application/json;odata.metadata=none"
            }
          })
            .then((response: SPHttpClientResponse): Promise<{ value: ITask[] }> => {
              return response.json();
            })
            .then((data: { value: ITask[] }): void => {
              let personColors: { [person: string]: string; } = {};
              var colorNo = 0;
    
              var events = data.value.map((task: ITask): any => {
                var assignedTo = task.AssignedTo.map((person: { Title: string }): string => {
                  return person.Title;
                }).join(', ');
    
                var color = personColors[assignedTo];
                if (!color) {
                  color = COLORS[colorNo++];
                  personColors[assignedTo] = color;
                }
                if (colorNo >= COLORS.length) {
                  colorNo = 0;
                }
    
                return {
                  title: task.Title + " - " + assignedTo,
                  id: task.ID,
                  color: color, // specify the background color and border color can also create a class and use className parameter.
                  start: moment.utc(task.StartDate).add("1", "days"),
                  end: moment.utc(task.DueDate).add("1", "days") // add one day to end date so that calendar properly shows event ending on that day
                };
              });
    
              callback(events);
            });
        }
      });
    }
    
    private updateTask(id: number, startDate: moment.Moment, dueDate: moment.Moment) {
      // subtract the previously added day to the date to store correct date
      var sDate = moment.utc(startDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
        startDate.format("hh:mm") + ":00Z";
      if (!dueDate) {
        dueDate = startDate;
      }
      var dDate = moment.utc(dueDate).add("-1", "days").format('YYYY-MM-DD') + "T" +
        dueDate.format("hh:mm") + ":00Z";
    
      this.context.spHttpClient.post(`${this.context.pageContext.web.absoluteUrl}\
    /_api/Web/Lists/getByTitle('${escape(this.properties.listName)}')/Items(${id})`, SPHttpClient.configurations.v1, {
        body: JSON.stringify({
          StartDate: sDate,
          DueDate: dDate,
        }),
        headers: {
          Accept: "application/json;odata=nometadata",
          "Content-Type": "application/json;odata=nometadata",
          "IF-MATCH": "*",
          "X-Http-Method": "PATCH"
        }
      })
        .then((response: SPHttpClientResponse): void => {
          if (response.ok) {
            alert("Update Successful");
          } else {
            alert("Update Failed");
          }
    
          this.displayTasks();
        });
    }
    

    Importante

    Si suprime metadatos en las respuestas de la API de REST de SharePoint cuando se usa SPHttpClient de SharePoint Framework tiene que asegurarse de que usa application/json;odata.metadata=none y no application/json;odata=nometadata como valor del encabezado Aceptar. SPHttpClient usa OData 4.0 y requiere el primer valor. Si, en su lugar, usa el segundo, la solicitud generará un error con una respuesta 406 No aceptable.

  3. Para confirmar que todo funciona del modo previsto, ejecute lo siguiente en la línea de comandos:

    gulp serve --nobrowser
    
  4. Vaya al área de trabajo hospedada y agregue el elemento web al lienzo. Aunque todavía no hay ningún cambio visual, el nuevo código usa SPHttpClient de SharePoint Framework, que simplifica el código y mantiene la solución.