В марте я писал про то, как разрабатывать приложения для SharePoint c помощью TypeScript. С тех пор прошло почти полгода, появились новые версии компилятора TypeScript (не совместимые со старыми) и улучшились описания типов для SharePoint (http://sptypescript.codeplex.com). Настало время обновить пример.
Пример приложения
Приложение позволяет фиксировать часы на рабочем месте.
Приложение ведет список всех временных интервалов, зафиксированных нажатием кнопок check-in\check-out. Пользователю отображается сумма всех его часов.
Также есть app part с тем же функционалом, но доступный для размещения на любой странице сайта.
Скачать можно по ссылке - TimeTrackerApp v0.9
Подготовка
Для начала необходимо:
- Установить средства разработки приложений для SharePoint 2013.
- Установить Web Essentials 2012.
- Установить TypeScript.
- Если у вас еще нет Office365, то можете создать 90-дневный trial для отладки и тестирования.
- Создать проект SharePoint Hosted приложения.
Для того чтобы при сборке проекта выполнялась компиляция TypeScript необходимо добавить в .csproj файл следующие элементы:
<PropertyGroup>
<TypeScriptTarget>ES3</TypeScriptTarget>
<TypeScriptIncludeComments>true</TypeScriptIncludeComments>
<TypeScriptSourceMap>true</TypeScriptSourceMap>
</PropertyGroup>
<Import Project="$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)\TypeScript\Microsoft.TypeScript.targets" />
Библиотеки и определения
Прошлый раз я использовал библиотеки jQuery и knockoutjs с плагинами. В этот раз решил обойтись стандартными средствами SharePoint и небольшим хелпером из проекта sptypescript.
Чтобы все заработало необходимо добавить NuGet пакет sharepoint.TypeScript.DefinitelyTyped (http://www.nuget.org/packages/sharepoint.TypeScript.DefinitelyTyped/). Далее необходимо скопировать файл typescripttemplates.ts в проект, при необходимости поправить ссылку на sharepoint.d.ts.
Представление списка
Прошлый раз интерфейс был сделан на knockoutjs с асинхронной загрузкой данных. Это вызывало заметный лаг между отображением страницы и появлением интерфейса. Такое поведение очень сильно не нравилось и хотелось сделать лучше.
В новой версии интерфейс сделан на основе представления списка. Представление генерирует данные прямо в разметку страницы, и не требуется асинхронная загрузка. Кроме того, представление формирует разметку до рисования страницы. Для пользователя все выглядит, как-будто рисование происходит на сервере.
Для создания представления я воспользовался инструментами visual studio, но запрос пришлось вручную написать (незначимые детали убрал):
<View BaseViewID="2" Hidden="TRUE" >
<ViewFields>
<FieldRef Name="DurationInHours" />
<FieldRef Name="ID" />
</ViewFields>
<Query>
<Where>
<Eq>
<FieldRef Name="Author"/>
<Value Type="Integer">
<UserID/>
</Value>
</Eq>
</Where>
</Query>
<JSLink>~site/scripts/typescripttemplates.js|~site/scripts/view.js</JSLink>
</View>
Это представление получает все элементы автора.
Для того, чтобы поместить представление на страницу необходимо написать следующий код в Elements.xml
<Elements xmlns="http://schemas.microsoft.com/sharepoint/">
<Module Name="Pages">
<File Path="Pages\Default.aspx" Url="Pages/Default.aspx" ReplaceContent="TRUE" >
<View BaseViewID="2" List="Log" WebPartZoneID="full" />
</File>
</Module>
</Elements>
Достаточно чтобы на странице Default.aspx была размещена зона веб-частей с идентификатором full.
Client-side rendering код представления:
CSR
.override(10000, 2)
.onPreRender(/* omitted for clarity */)
.header(' ')
.body(renderTemplate)
.footer(' ')
.onPostRender(initializeModel)
.onPostRender(suppressDefault)
.register();
Хелпер в файле typescripttemplates.ts реализует fluent-итерфейс для шаблонов клиентского рендеринга.
Как оказалось нельзя просто так взять и переопределить шаблон View у представления, потому что он вызывается два раза. Если хочется сформировать полностью свою разметку, то надо переопределить body, header, footer, а еще написать код, который скрывает элемент для переключения страниц.
Еще один недостаток переопределения представления списка – при клике на представление появляется ribbon.
От обоих недостатков избавляет функция suppressDefault:
function suppressDefault(ctx: SPClientTemplates.RenderContext_InView) {
var wpzoneCell = $get('MSOZoneCell_WebPart' + ctx.wpq);
wpzoneCell.onkeyup = wpzoneCell.onmouseup = function () { };
var footer = $get('scriptPaging' + ctx.wpq);
footer.style.display = 'none';
}
Формирование разметки
Для удобства работы я сделал расширение контекста рендеринга:
interface TimeTrackerView extends SPClientTemplates.RenderContext_InView {
totalHours: number;
openLogItem: number;
buttonText: string;
spanId: string;
buttonId: string;
}
Функция в onPreRender получает необходимые данные:
(ctx: TimeTrackerView) => {
var rows = ctx.ListData.Row;
ctx.totalHours = 0;
for (var i = 0; i < rows.length; i++) {
if (rows[i]['DurationInHours.']) {
ctx.totalHours += parseFloat(rows[i]['DurationInHours.']);
} else {
ctx.openLogItem = rows[i]['ID'];
}
}
ctx.buttonText = checkInOut(Boolean(ctx.openLogItem));
ctx.spanId = ctx.wpq + '_totalHours';
ctx.buttonId = ctx.wpq + '_button';
}
Переменная ctx.wpq имеет уникальное значение для каждого представления на странице и отлично подходит для формирования id для элементов.
Обращение к полю 'DurationInHours.' (с точкой в конце) это не опечатка. SharePoint генерирует два поля – одно без точки, форматированное с учетом локали сервера (для вывода), а второе с точкой, не форматированное. Такое происходит для многих полей, надо смотреть в отладчике что отдаёт SharePoint.
Сама функция создания разметки выглядит так:
function renderTemplate(ctx: TimeTrackerView): string {
var result: string[] = [];
result.push('<div>');
result.push(String.format('<p>Total hours submitted <span id="{0}" >{1}</span></p>', ctx.spanId, ctx.totalHours.toPrecision(2)));
result.push(String.format('<button id="{0}" disabled="disabled">{1}</button>', ctx.buttonId, ctx.buttonText));
result.push('</div>');
return result.join('');
}
Передача данных через контекст рендеринга – рекомендуемый способ при создании сложных шаблонов.
Простая функция выбора текста кнопки:
function checkInOut(checkedIn: boolean): string {
return checkedIn ? 'Check-Out' : 'Check-In';
}
Логика интерфейса
При использовании client-side rendering только в onPostRender появляется возможность получить dom элементы, но это происходит до события загрузки страницы и до загрузки асинхронных скриптов, поэтому очень важно пользоваться механизмами Script On Demand, о которых я писал недавно.
SP.SOD.executeFunc('sp.js', 'SP.CleintContext', () => {
var button = $get(ctx.buttonId);
var span = $get(ctx.spanId);
var totalHours = ctx.totalHours;
var context = SP.ClientContext.get_current();
var web = context.get_web();
var list = web.get_lists().getById(ctx.listName);
var currentItem: SP.ListItem;
if (ctx.openLogItem) {
currentItem = list.getItemById(ctx.openLogItem);
context.load(currentItem);
executeQuery(() => { button.disabled = false; });
} else {
button.disabled = false;
}
//...
})
Этот код инициализирует переменные для работы интерфейса, если есть открытый интервал, то он загружается с сервера и только после этого кнопка становится активной.
Функция executeQuery:
function executeQuery(callback: () => void) {
context.executeQueryAsync(
() => {
callback();
},
(sender, args) => {
alert(args.get_message());
SP.Utilities.Utility.logCustomAppError(context,
args.get_message() + '\n' + args.get_stackTrace());
context.executeQueryAsync();
});
}
В случае ошибки сообщение попадает в ULS и лог ошибок приложения.
Обработчик события нажатия кнопки (используя mQuery):
SP.SOD.executeFunc('mQuery.js', 'm$', () => {
m$(button).click(e => {
button.disabled = true;
if (currentItem) {
checkOut(updateView);
} else {
checkIn(updateView);
}
e.preventDefault();
});
});
Функция updateView, обновляет представление:
function updateView() {
button.disabled = false;
button.innerHTML = checkInOut(Boolean(currentItem));
span.innerHTML = totalHours.toPrecision(2);
};
Функции checkIn и checkOut, которые реализуют логику создания нового элемента в списке и “закрытие” существующего, мало изменились с прошлого раза:
function checkOut(complete: () => void) {
var startedDate = <Date>currentItem.get_item('StartDate');
var dateCompleted = new Date();
var hours = (dateCompleted.getTime() - startedDate.getTime()) / (1000 * 60 * 60);
currentItem.set_item('DateCompleted', dateCompleted);
currentItem.set_item('DurationInHours', hours);
currentItem.update();
SPAnimationUtility.BasicAnimator.FadeOut(span);
executeQuery(() => {
currentItem = null;
totalHours += hours;
SPAnimationUtility.BasicAnimator.FadeIn(span);
complete();
});
}
function checkIn(complete: () => void) {
var item = list.addItem(new SP.ListItemCreationInformation());
item.set_item('StartDate', new Date());
item.update();
executeQuery(() => {
currentItem = item;
complete();
});
}
Добавление app part
После добавления app part в проект появляется еще одна aspx страница.
На ней необходимо добавить разметку в body:
<form runat="server">
<WebPartPages:SPWebPartManager runat="server"/>
<WebPartPages:WebPartZone runat="server" FrameType="None" ID="full">
</WebPartPages:WebPartZone>
</form>
Чтобы заработали все скрипты необходимо в head добавить элемент:
<SharePoint:ScriptLink runat="server"/>
После этого в elements.xml надо добавить строчки, как и для главной страницы:
<File Path="Pages\CheckInOutWebPart.aspx" Url="Pages/CheckInOutWebPart.aspx" ReplaceContent="TRUE" > <View BaseViewID="2" List="Log" WebPartZoneID="full" /> </File>
Заключение
Обязательно скачайте код по ссылке - TimeTrackerApp v0.9, пишите на TypeScript, используйте наши дефинишены (https://sptypescript.codeplex.com/).
