В марте я писал про то, как разрабатывать приложения для 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/).