Поделиться через


Перенос решения с jQuery и FullCalendar, созданного с помощью веб-части редактора скриптов, на платформу SharePoint Framework

При создании решений SharePoint разработчики часто используют подключаемый модуль FullCalendar для jQuery, чтобы отображать данные в представлении календаря. FullCalendar — отличная альтернатива стандартному представлению календаря SharePoint, так как с помощью этого модуля можно отображать данные из нескольких списков календарей, из других списков и даже из расположений за пределами SharePoint. В этой статье показано, как перенести модификацию SharePoint с использованием FullCalendar, созданную в веб-части редактора скриптов, на платформу SharePoint Framework.

Список задач, отображаемый в виде календаря, созданного с помощью веб-части редактора скриптов

Чтобы проиллюстрировать перенос модификации SharePoint с использованием FullCalendar на платформу SharePoint Framework, мы будем использовать приведенное ниже решение, которое показывает представление календаря с задачами, полученными из списка SharePoint.

Представление календаря с задачами, отображаемое на странице SharePoint

Решение создается с помощью стандартной веб-части редактора сценариев SharePoint. Ниже приведен код, используемый настройкой.

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

Примечание.

Это решение основано на работе Марка Рэкли (Mark Rackley), специалиста MVP по серверам и службам Office, а также директора по стратегическим вопросам в компании PAIT Group. Дополнительные сведения об исходном решении см. в статье Создание пользовательских календарей в SharePoint с помощью FullCalendar.io.

Для начала модификация загружает свои библиотеки, jQuery, Moment.js и FullCalendar, в первые несколько элементов <script> и <link>.

Затем задается тег <div> для добавления создаваемого представления календаря.

После этого определяются две функции: displayTasks() (отображает задачи в представлении календаря) и updateTask() (вызывается при перетаскивании задачи для переноса ее на другую дату и обновляет даты в соответствующем элементе списка). В каждой функции определяется собственный запрос REST, который затем используется для связи с REST API списков SharePoint, чтобы получать или обновлять элементы списков.

Используя подключаемый модуль FullCalendar для jQuery, можно с минимальными усилиями создавать функциональные решения, предоставляющие пользователям такие возможности, как пометка событий разными цветами и упорядочивание событий путем перетаскивания.

Перетаскивание событий в модуле FullCalendar для перепланировки задач

Перенос решения для просмотра календаря задач из веб-части редактора скриптов на платформу SharePoint Framework

Преобразование модификации на основе веб-части редактора скриптов для SharePoint Framework обеспечивает ряд преимуществ, таких как повышенное удобство настройки и централизованное управление решением. Ниже представлено пошаговое описание переноса решения на платформу SharePoint Framework.

Для начала необходимо перенести решение на платформу SharePoint Framework, внеся как можно меньше изменений в первоначальный код. Затем следует преобразовать код решения в TypeScript, чтобы воспользоваться функциями обеспечения безопасности типов во время разработки, и заменить часть кода на API SharePoint Framework, чтобы в полной мере воспользоваться возможностями платформы и еще больше упростить решение.

Примечание.

Исходный код проекта на разных этапах миграции представлен в статье Руководство. Перенос решения с jQuery и FullCalendar, созданного с помощью веб-части редактора скриптов, на платформу SharePoint Framework.

Создание проекта SharePoint Framework

  1. Для начала создайте папку проекта:

    md fullcalendar-taskscalendar
    
  2. Перейдите в папку проекта:

    cd fullcalendar-taskscalendar
    
  3. В папке проекта запустите генератор Yeoman для SharePoint Framework, чтобы выполнить скаффолдинг нового проекта на платформе SharePoint Framework:

    yo @microsoft/sharepoint
    
  4. При появлении запроса введите следующие значения (выберите вариант по умолчанию для всех запросов, не перечисленных ниже).

    • Как называется решение?: fullcalendar-taskscalendar
    • Какие базовые пакеты нужно выбрать как целевые для ваших компонентов?: Только SharePoint Online (последняя версия)
    • Какой тип клиентского компонента нужно создать?: WebPart
    • Как называется веб-часть?: Календарь задач
    • Опишите веб-часть.: Показывает задачи в представлении календаря
    • Какую платформу нужно использовать?: Не использовать платформу веб-решений на базе JavaScript
  5. Откройте папку проекта в редакторе кода. В этом руководстве используется Visual Studio Code.

Загрузка библиотек JavaScript

Как и в исходном решении, созданном с помощью веб-части редактора скриптов, сначала необходимо загрузить библиотеки JavaScript, необходимые решению. Как правило, в SharePoint Framework этот процесс состоит из двух этапов: указания URL-адреса библиотеки, которую нужно загрузить, и обращения к библиотеке в коде.

  1. Укажите URL-адреса библиотек, которые нужно загрузить.

    Откройте в редакторе кода файл ./config/config.json и замените код в разделе externals на следующий:

    {
      // ..
      "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. Откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts и после последнего оператора import добавьте следующий код:

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

Определение div-контейнера

Как и в исходном решении, далее необходимо определить, где будет отображаться календарь.

В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts и замените код метода render() на следующий:

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

Инициализация модуля FullCalendar и загрузка данных

Последний этап — добавление кода, инициализирующего подключаемый модуль FullCalendar для jQuery и загружающего данные из SharePoint.

  1. В папке ./src/webparts/tasksCalendar создайте файл с именем script.js и вставьте следующий код:

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

Он практически идентичен первоначальному коду модификации на основе веб-части редактора скриптов. Единственное отличие заключается в том, что первоначальный код получал URL-адрес текущего сайта из глобальной переменной _spPageContextInfo, которую задает SharePoint, а на платформе SharePoint Framework используется пользовательская переменная, которую необходимо задать в веб-части.

Клиентские веб-части SharePoint Framework можно использовать как на классических, так и на современных страницах. Переменная _spPageContextInfo используется на классических страницах, но недоступна на современных, поэтому не следует рассчитывать на нее. Рекомендуем использовать настраиваемое свойство, которым можно управлять самостоятельно.

  1. Чтобы сослаться на этот файл в веб-части, откройте в редакторе кода файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts и замените код метода render() на следующий:

    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. Проверьте работу веб-части, выполнив в командной строке следующую команду:

    gulp serve --nobrowser
    

    Так как веб-часть загружает свои данные из SharePoint, ее необходимо тестировать с помощью размещенной рабочей области SharePoint Framework.

  3. Перейдите в https://{your-tenant-name}.sharepoint.com/_layouts/workbench.aspx и добавьте веб-часть на холст. Теперь задачи должны отображаться в представлении календаря с помощью подключаемого модуля FullCalendar для jQuery.

    Задачи, отображаемые в представлении календаря в клиентской веб-части SharePoint Framework

Добавление возможности настраивать веб-часть с помощью ее свойств

На предыдущих этапах мы перенесли решение для просмотра календаря задач из веб-части редактора скриптов на платформу SharePoint Framework. Решение уже работает надлежащим образом, но не использует преимущества платформы SharePoint Framework. Имя списка, из которого загружаются задачи, включено в код, представляющий собой обычный JavaScript, который хуже поддается рефакторингу, чем TypeScript.

В приведенных ниже разделах описано, как расширить имеющееся решение, чтобы пользователи могли указывать имя списка, из которого будут загружаться данные. Позже мы преобразуем код в TypeScript, чтобы воспользоваться функциями обеспечения безопасности типов.

Определение свойства веб-части для хранения имени списка

  1. Определите свойство веб-части для хранения имени списка, из которого будут загружаться задачи. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.manifest.json, замените имя заданного по умолчанию свойства description на listName и очистите его значение.

  2. Обновите интерфейс свойств веб-части, чтобы применить изменения манифеста. В редакторе кода откройте файл ./src/webparts/tasksCalendar/ITasksCalendarWebPartProps.ts и замените его содержимое на следующий код:

    export interface ITasksCalendarWebPartProps {
      listName: string;
    }
    
  3. Обновите метки отображения для свойства listName.

    Откройте файл ./src/webparts/tasksCalendar/loc/mystrings.d.ts и замените его содержимое на следующее:

    declare interface ITasksCalendarStrings {
      PropertyPaneDescription: string;
      BasicGroupName: string;
      ListNameFieldLabel: string;
    }
    
    declare module 'tasksCalendarStrings' {
      const strings: ITasksCalendarStrings;
      export = strings;
    }
    
  4. Откройте файл ./src/webparts/tasksCalendar/loc/en-us.js и замените его содержимое на следующее:

    define([], function() {
      return {
        "PropertyPaneDescription": "Tasks calendar settings",
        "BasicGroupName": "Data",
        "ListNameFieldLabel": "List name"
      }
    });
    
  5. Обновите веб-часть, чтобы использовалось новое свойство. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts и замените код метода getPropertyPaneConfiguration на следующий:

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

Чтобы веб-часть не перезагружалась, когда пользователь вводит имя списка, она также должна использовать нереактивную область свойств. Для этого добавьте метод disableReactivePropertyChanges() и задайте для него возвращаемое значение true.

Использование заданного имени списка для загрузки данных

Изначально имя списка, из которого загружаются данные, внедрялось в запросы REST. Теперь, когда пользователи могут настраивать это имя, указанное значение следует внедрять в запросы REST перед их выполнением. Это проще всего сделать, переместив содержимое файла script.js в основной файл веб-части.

  1. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts.

  2. Найдите следующий код, который вы ранее добавили в файл:

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

    Измените его на следующий:

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

    В коде, который мы будем использовать позже, есть ссылка на библиотеку Moment.js, поэтому коду TypeScript должно быть известно ее имя, иначе сборка проекта завершится ошибкой. То же относится и к jQuery.

    Так как FullCalendar представляет собой подключаемый модуль для jQuery, присоединяющийся к объекту jQuery, нет необходимости изменять способ импорта.

    Последняя часть включает копирование списка цветов для пометки различных событий.

  3. Скопируйте функции displayTasks() и updateTask() из файла script.js и вставьте их в класс 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();
          });
      // ...
    }
    

    По сравнению с предыдущим случаем в код вносится меньше изменений.

    • Теперь обычные функции JavaScript преобразуются в методы TypeScript путем замены ключевого слова function на модификатор private. Это необходимо, чтобы можно было добавить их в класс TaskCalendarWebPart.
    • Так как теперь оба метода находятся в том же файле, что и веб-часть, вы можете не определять глобальную переменную для хранения URL-адреса текущего сайта, а обращаться к нему непосредственно из контекста веб-части с помощью свойства this.context.pageContext.web.absoluteUrl.
    • Во всех REST-запросах фиксированное имя списка заменяется на значение свойства listName, в котором хранится имя списка, заданное пользователем. Прежде чем использовать это значение, необходимо применить к нему функцию escape() из lodash, чтобы запретить внедрение скриптов.
  4. Напоследок измените метод render(), чтобы он вызывал новый метод displayTasks():

    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. Так как мы переместили содержимое файла script.js в основной файл веб-части, файл script.js больше не требуется. Его можно удалить из проекта.

  6. Чтобы проверить работу веб-части, выполните в командной строке следующую команду:

    gulp serve --nobrowser
    
  7. Перейдите к размещенной рабочей области и добавьте веб-часть на холст. Откройте область свойств веб-части, укажите имя списка задач и нажмите кнопку Apply (Применить), чтобы подтвердить изменения. Задачи должны появиться в представлении календаря в веб-части.

    Задачи, загруженные из указанного списка и отображаемые в клиентской веб-части SharePoint Framework

Преобразование обычного кода JavaScript в TypeScript

Использование TypeScript вместо обычного JavaScript обеспечивает ряд преимуществ. Обслуживание и рефакторинг кода выполнять удобнее, если используется TypeScript. Кроме того, в таком коде можно раньше обнаруживать ошибки. Ниже описано, как преобразовать первоначальный код JavaScript в TypeScript.

Добавление объявлений типов для используемых библиотек

Для правильной работы TypeScript требуются объявления типов для разных библиотек, используемых в проекте. Объявления типов часто распространяются в виде пакетов npm в @types пространстве имен.

  1. Чтобы установить объявления типов для jQuery, выполните в командной строке следующую команду:

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

    Объявления типов для Moment.js доступны в пакете Moment.js. Мы загружаем библиотеку Moment.js с URL-адреса, но для использования ее определений типов все равно необходимо установить пакет Moment.js в проекте.

  2. Установите пакет Moment.js, выполнив в командной строке следующую команду:

    npm install moment --save
    

Примечание.

Обратите внимание, что мы не устанавливаем объявление типа для библиотеки FullCalendar. Так как в решении, которое упоминалось в начале статьи, используется старая версия FullCalendar, не содержащая допустимых объявлений типов, мы будем использовать типы TypeScript в нашем решении выборочно.

В идеале рекомендуется обновить проект до новой версии библиотеки FullCalendar. Такое обновление не рассматривается в этой статье, так как включает многочисленные изменения API по сравнению с версией, на которой основано наше начальное решение.

Обновление ссылок на пакеты

Чтобы использовать типы из установленных объявлений, необходимо изменить ссылки на библиотеки.

  1. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts

  2. Найдите следующий код, который вы ранее добавили в файл:

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

    Измените его на следующий:

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

Преобразование основных файлов веб-части в TypeScript

Теперь, когда у нас есть объявления типов для всех библиотек, установленных в проекте, можно приступить к преобразованию обычного кода JavaScript в TypeScript.

  1. Определите интерфейс для задачи, получаемой из списка SharePoint. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts и непосредственно над классом веб-части добавьте следующий фрагмент кода:

    interface ITask {
      ID: number;
      Title: string;
      StartDate: string;
      DueDate: string;
      AssignedTo: [{ Title: string }];
    }
    
  2. В классе веб-части замените коды методов displayTasks() и updateTask() на следующий код:

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

Первое изменение при преобразовании обычного кода JavaScript в TypeScript — это явные типы. Использовать их не обязательно, но они четко дают понять, данные какого типа ожидаются. TypeScript мгновенно определяет любое отклонение от заданного соглашения, помогая вам находить возможные проблемы в процессе разработки. Это полезно при работе с откликами AJAX и их данными.

Еще одно изменение, которое вы уже могли заметить, — строковая интерполяция TypeScript. Она упрощает динамическое составление строк и делает код более удобочитаемым.

Обычный код JavaScript:

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 + "'))";

Сравните его со следующим кодом:

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

Еще одно преимущество строковой интерполяции TypeScript — отсутствие необходимости отменять кавычки, что также упрощает создание REST-запросов.

  1. Чтобы убедиться, что все работает должным образом, выполните в командной строке следующую команду:

    gulp serve --nobrowser
    
  2. Перейдите к размещенной рабочей области и добавьте веб-часть на холст. Визуально ничего не изменилось, но новая кодовая база использует TypeScript и соответствующие объявления типов, что упрощает обслуживание решения.

Замена вызовов AJAX jQuery на API SharePoint Framework

В настоящий момент в решении используются вызовы AJAX jQuery для связи с REST API SharePoint. Для обычных GET-запросов API AJAX jQuery так же удобен, как и API SPHttpClient платформы SharePoint Framework. Разница заметна при выполнении POST-запросов, например запроса на обновление события:

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

Так как нам требуется обновить элемент списка, необходимо предоставить среде SharePoint действительный токен дайджеста запроса. Хотя дайджест доступен на классических страницах, он действителен только в течение 3 минут. Поэтому всегда безопаснее получить действительный токен самостоятельно перед выполнением операции обновления Получив дайджест запроса, следует добавить его к заголовкам запроса на обновление. В противном случае запрос не будет выполнен.

API SPHttpClient платформы SharePoint Framework упрощает связь с SharePoint, автоматически определяя POST-запросы, которым требуется действительный токен дайджеста. Для таких запросов API SPHttpClient автоматически получает токен из SharePoint и добавляет его к запросу. Для сравнения, аналогичный запрос, отправленный с использованием API SPHttpClient, будет выглядеть так:

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. Чтобы заменить исходные вызовы AJAX jQuery на API SPHttpClient платформы SharePoint Framework, в редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts. В список существующих операторов import добавьте следующее:

    import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
    
  2. В классе TasksCalendarWebPart замените коды методов displayTasks() и updateTask() на следующий код:

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

    Важно!

    Если в откликах от REST API SharePoint запрещены метаданные, то при использовании класса SPHttpClient платформы SharePoint Framework необходимо убедиться, что для заголовка Accept используется значение application/json;odata.metadata=none, а не application/json;odata=nometadata. SPHttpClient использует OData 4.0, поэтому необходимо задать первое значение. Если использовать другое, при выполнении запроса будет возвращена ошибка 406 Not Acceptable.

  3. Чтобы убедиться, что все работает должным образом, выполните в командной строке следующую команду:

    gulp serve --nobrowser
    
  4. Перейдите к размещенной рабочей области и добавьте веб-часть на холст. Внешне ничего не изменилось, но в новом коде используется SPHttpClient на платформе SharePoint Framework, что упрощает код и поддержку решения.