В марте я писал про то, как разрабатывать приложения для SharePoint c помощью TypeScript. С тех пор прошло почти полгода, появились новые версии компилятора TypeScript (не совместимые со старыми) и улучшились описания типов для SharePoint (http://sptypescript.codeplex.com). Настало время обновить пример.

Пример приложения

Приложение позволяет фиксировать часы на рабочем месте.

image

Приложение ведет список всех временных интервалов, зафиксированных нажатием кнопок check-in\check-out. Пользователю отображается сумма всех его часов.

Также есть app part с тем же функционалом, но доступный для размещения на любой странице сайта.

Скачать можно по ссылке - TimeTrackerApp v0.9

Подготовка

Для начала необходимо:

Для того чтобы при сборке проекта выполнялась компиляция  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/).

Теги : mQuery, sp2013, код, javascript, typescript, SharePoint