Примечание
Для доступа к этой странице требуется авторизация. Вы можете попробовать войти или изменить каталоги.
Для доступа к этой странице требуется авторизация. Вы можете попробовать изменить каталоги.
При создании решений SharePoint разработчики часто используют подключаемый модуль FullCalendar для jQuery, чтобы отображать данные в представлении календаря. FullCalendar — отличная альтернатива стандартному представлению календаря SharePoint, так как с помощью этого модуля можно отображать данные из нескольких списков календарей, из других списков и даже из расположений за пределами SharePoint. В этой статье показано, как перенести модификацию SharePoint с использованием FullCalendar, созданную в веб-части редактора скриптов, на платформу SharePoint Framework.
Список задач, отображаемый в виде календаря, созданного с помощью веб-части редактора скриптов
Чтобы проиллюстрировать перенос модификации SharePoint с использованием FullCalendar на платформу SharePoint Framework, мы будем использовать приведенное ниже решение, которое показывает представление календаря с задачами, полученными из списка 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, можно с минимальными усилиями создавать функциональные решения, предоставляющие пользователям такие возможности, как пометка событий разными цветами и упорядочивание событий путем перетаскивания.
Перенос решения для просмотра календаря задач из веб-части редактора скриптов на платформу SharePoint Framework
Преобразование модификации на основе веб-части редактора скриптов для SharePoint Framework обеспечивает ряд преимуществ, таких как повышенное удобство настройки и централизованное управление решением. Ниже представлено пошаговое описание переноса решения на платформу SharePoint Framework.
Для начала необходимо перенести решение на платформу SharePoint Framework, внеся как можно меньше изменений в первоначальный код. Затем следует преобразовать код решения в TypeScript, чтобы воспользоваться функциями обеспечения безопасности типов во время разработки, и заменить часть кода на API SharePoint Framework, чтобы в полной мере воспользоваться возможностями платформы и еще больше упростить решение.
Примечание.
Исходный код проекта на разных этапах миграции представлен в статье Руководство. Перенос решения с jQuery и FullCalendar, созданного с помощью веб-части редактора скриптов, на платформу SharePoint Framework.
Создание проекта SharePoint Framework
Для начала создайте папку проекта:
md fullcalendar-taskscalendar
Перейдите в папку проекта:
cd fullcalendar-taskscalendar
В папке проекта запустите генератор Yeoman для SharePoint Framework, чтобы выполнить скаффолдинг нового проекта на платформе SharePoint Framework:
yo @microsoft/sharepoint
При появлении запроса введите следующие значения (выберите вариант по умолчанию для всех запросов, не перечисленных ниже).
- Как называется решение?: fullcalendar-taskscalendar
- Какие базовые пакеты нужно выбрать как целевые для ваших компонентов?: Только SharePoint Online (последняя версия)
- Какой тип клиентского компонента нужно создать?: WebPart
- Как называется веб-часть?: Календарь задач
- Опишите веб-часть.: Показывает задачи в представлении календаря
- Какую платформу нужно использовать?: Не использовать платформу веб-решений на базе JavaScript
Откройте папку проекта в редакторе кода. В этом руководстве используется Visual Studio Code.
Загрузка библиотек JavaScript
Как и в исходном решении, созданном с помощью веб-части редактора скриптов, сначала необходимо загрузить библиотеки JavaScript, необходимые решению. Как правило, в SharePoint Framework этот процесс состоит из двух этапов: указания URL-адреса библиотеки, которую нужно загрузить, и обращения к библиотеке в коде.
Укажите 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" }, // .. }
Откройте файл ./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.
В папке ./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
используется на классических страницах, но недоступна на современных, поэтому не следует рассчитывать на нее. Рекомендуем использовать настраиваемое свойство, которым можно управлять самостоятельно.
Чтобы сослаться на этот файл в веб-части, откройте в редакторе кода файл ./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'); } // ... }
Проверьте работу веб-части, выполнив в командной строке следующую команду:
gulp serve --nobrowser
Так как веб-часть загружает свои данные из SharePoint, ее необходимо тестировать с помощью размещенной рабочей области SharePoint Framework.
Перейдите в https://{your-tenant-name}.sharepoint.com/_layouts/workbench.aspx и добавьте веб-часть на холст. Теперь задачи должны отображаться в представлении календаря с помощью подключаемого модуля FullCalendar для jQuery.
Добавление возможности настраивать веб-часть с помощью ее свойств
На предыдущих этапах мы перенесли решение для просмотра календаря задач из веб-части редактора скриптов на платформу SharePoint Framework. Решение уже работает надлежащим образом, но не использует преимущества платформы SharePoint Framework. Имя списка, из которого загружаются задачи, включено в код, представляющий собой обычный JavaScript, который хуже поддается рефакторингу, чем TypeScript.
В приведенных ниже разделах описано, как расширить имеющееся решение, чтобы пользователи могли указывать имя списка, из которого будут загружаться данные. Позже мы преобразуем код в TypeScript, чтобы воспользоваться функциями обеспечения безопасности типов.
Определение свойства веб-части для хранения имени списка
Определите свойство веб-части для хранения имени списка, из которого будут загружаться задачи. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.manifest.json, замените имя заданного по умолчанию свойства
description
наlistName
и очистите его значение.Обновите интерфейс свойств веб-части, чтобы применить изменения манифеста. В редакторе кода откройте файл ./src/webparts/tasksCalendar/ITasksCalendarWebPartProps.ts и замените его содержимое на следующий код:
export interface ITasksCalendarWebPartProps { listName: string; }
Обновите метки отображения для свойства
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; }
Откройте файл ./src/webparts/tasksCalendar/loc/en-us.js и замените его содержимое на следующее:
define([], function() { return { "PropertyPaneDescription": "Tasks calendar settings", "BasicGroupName": "Data", "ListNameFieldLabel": "List name" } });
Обновите веб-часть, чтобы использовалось новое свойство. В редакторе кода откройте файл ./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 в основной файл веб-части.
В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts.
Найдите следующий код, который вы ранее добавили в файл:
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, нет необходимости изменять способ импорта.
Последняя часть включает копирование списка цветов для пометки различных событий.
Скопируйте функции
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, чтобы запретить внедрение скриптов.
- Теперь обычные функции JavaScript преобразуются в методы TypeScript путем замены ключевого слова
Напоследок измените метод
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(); } // ... }
Так как мы переместили содержимое файла script.js в основной файл веб-части, файл script.js больше не требуется. Его можно удалить из проекта.
Чтобы проверить работу веб-части, выполните в командной строке следующую команду:
gulp serve --nobrowser
Перейдите к размещенной рабочей области и добавьте веб-часть на холст. Откройте область свойств веб-части, укажите имя списка задач и нажмите кнопку Apply (Применить), чтобы подтвердить изменения. Задачи должны появиться в представлении календаря в веб-части.
Преобразование обычного кода JavaScript в TypeScript
Использование TypeScript вместо обычного JavaScript обеспечивает ряд преимуществ. Обслуживание и рефакторинг кода выполнять удобнее, если используется TypeScript. Кроме того, в таком коде можно раньше обнаруживать ошибки. Ниже описано, как преобразовать первоначальный код JavaScript в TypeScript.
Добавление объявлений типов для используемых библиотек
Для правильной работы TypeScript требуются объявления типов для разных библиотек, используемых в проекте. Объявления типов часто распространяются в виде пакетов npm в @types пространстве имен.
Чтобы установить объявления типов для jQuery, выполните в командной строке следующую команду:
npm install @types/jquery@1 --save-dev
Объявления типов для Moment.js доступны в пакете Moment.js. Мы загружаем библиотеку Moment.js с URL-адреса, но для использования ее определений типов все равно необходимо установить пакет Moment.js в проекте.
Установите пакет Moment.js, выполнив в командной строке следующую команду:
npm install moment --save
Примечание.
Обратите внимание, что мы не устанавливаем объявление типа для библиотеки FullCalendar. Так как в решении, которое упоминалось в начале статьи, используется старая версия FullCalendar, не содержащая допустимых объявлений типов, мы будем использовать типы TypeScript в нашем решении выборочно.
В идеале рекомендуется обновить проект до новой версии библиотеки FullCalendar. Такое обновление не рассматривается в этой статье, так как включает многочисленные изменения API по сравнению с версией, на которой основано наше начальное решение.
Обновление ссылок на пакеты
Чтобы использовать типы из установленных объявлений, необходимо изменить ссылки на библиотеки.
В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts
Найдите следующий код, который вы ранее добавили в файл:
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.
Определите интерфейс для задачи, получаемой из списка SharePoint. В редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts и непосредственно над классом веб-части добавьте следующий фрагмент кода:
interface ITask { ID: number; Title: string; StartDate: string; DueDate: string; AssignedTo: [{ Title: string }]; }
В классе веб-части замените коды методов
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-запросов.
Чтобы убедиться, что все работает должным образом, выполните в командной строке следующую команду:
gulp serve --nobrowser
Перейдите к размещенной рабочей области и добавьте веб-часть на холст. Визуально ничего не изменилось, но новая кодовая база использует 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 => {
// ...
});
Чтобы заменить исходные вызовы AJAX jQuery на API SPHttpClient платформы SharePoint Framework, в редакторе кода откройте файл ./src/webparts/tasksCalendar/TasksCalendarWebPart.ts. В список существующих операторов
import
добавьте следующее:import { SPHttpClient, SPHttpClientResponse } from '@microsoft/sp-http';
В классе
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.Чтобы убедиться, что все работает должным образом, выполните в командной строке следующую команду:
gulp serve --nobrowser
Перейдите к размещенной рабочей области и добавьте веб-часть на холст. Внешне ничего не изменилось, но в новом коде используется SPHttpClient на платформе SharePoint Framework, что упрощает код и поддержку решения.