суббота, 20 сентября 2014 г.

3 простых способа подружить KnockoutJs и SharePoint

Попал на старый проект, SharePoint 2010. Бывает, что тут сделаешь! Спасаюсь от скуки только благодаря KnockoutJs :) Всвязи с чем - этот пост.

KnockoutJs как MVVM фреймворк на самом деле ничем не уступает тому же Angular'у. Единственная вещь, которую нужно очень хорошо изучить и вызубрить в Knockout - это когда скобки () нужны, а когда нет, и как этот ko observable wrapping вообще работает. Преодолев этот порог, больше с Knockout проблем не возникает. Angular конечно помощнее, более фундаментальный, но и всякой магии в нем намного больше, траблшутить крайне сложно, и модель освоения Angular действительно вот такая (проверено на собственном опыте):



Так что, в своих SharePoint-проектах я преимущественно использую именно Knockout. И в этом посте я опишу три простых техники использования Knockout совместно с SharePoint.

ICallbackEventHandler


ICallbackEventHandler нужен, если вы используете серверный код. Будь то веб-часть, User Control или Applications Page. Особенно актуально для SP2010 и SP2007, где разработка основывается по большей части на серверном коде. Поскольку сейчас делаю SP2010-проект, использую как раз подход с ICallbackEventHandler, и поэтому начинаю список именно с него!

Итак, для начала, нужно зарегистрировать колбэк и поместить код его вызова на страницу (в ascx или aspx markup). Пример реализации:

aspx/ascx:

<script type="text/javascript">
  function PerformServerCallback(resultCallback)
  {
    theForm.action = theForm.action.replace(/#[^#]*$/, '');
    var eventArgument = ko.toJSON(pageModel);
    // Literal will be replaced with WebForm_DoCallback call
    <asp:Literal runat="server" ID="CallbackReferenceLiteral" />
  }
</script>

Обратите внимание на строку, которая фиксит theForm.action. Эту строку на MSDN вы не найдете :) Проблема в том, что если в адресной строке есть "#", WebForm_DoCallback не сработает, поэтому приходится фиксить вот таким вот чудным образом.

aspx.cs/ascx.cs:

protected void Page_Load(object sender, EventArgs args)
{
    CallbackReferenceLiteral.Text = Page.ClientScript.GetCallbackEventReference(this, "eventArgument", "resultCallback", "");
}

Обратите внимание, что строка "eventArgument" должна совпадать с названием переменной eventArgument на клиенте, и то же самое для resultCallback.

Теперь мы можем вызывать коллбэк из JS путем вызова PerformServerCallback, передавать туда любые данные, и также любые данные в ответ и обрабатывать их в resultCallback.

Я обычно использую на сервере такую же по структуре ViewModel страницы/контрола, как на клиенте. Поэтому для взаимодействия между клиентом и сервером в простых случаях можно банально гонять всю ViewModel:
  1. Сериализуем view model на клиенте с помощью ko.toJSON
  2. Вызываем web callback
  3. В RaiseCallbackEvent на сервере: десериализуем с помощью JavaScriptSerializer.Deserialize
  4. Продолжаем в RaiseCallbackEvent: Синхронизируем серверную модель с клиентской. Например, если есть изменения в данных, надо их сохранить в списки/БД/... Информацию об ошибках или успешном завершении БД-операций помещаем опять же в серверную ViewModel.
  5. В GetCallbackResult: сериализуем серверную ViewModel и возвращаем сериализованный json.
  6. Получаем серверную модель на клиенте, синхронизируем ее с клиентской
Как видите, придется написать процедуры для синхронизации клиентской и серверной моделей друг с другом. На клиенте с этим может сильно помочь Knockout Mapping plugin, на сервере скорее всего придется писать вручную.

Заготовка серверного кода выглядит примерно так:

// Don't forget implementing the ICallbackEventHandler interface!
public partial class MyControl : UserControl, ICallbackEventHandler
{
    protected MyViewModel ViewModel = new MyViewModel();

    // ...

    public void RaiseCallbackEvent(string eventArgument)
    {
        var viewModelFromClient = JavaScriptSerializer.Deserialize<MyViewModel>(eventArgument);

        // ... synchronise ViewModel with viewModelFromClient
        // ... and perform appropriate actions, e.g. save changes to DB or Lists
        // ... then update ViewModel to correspond the state of the page
        // ... e.g. put error messages there, etc.
    }

    public string GetCallbackResult()
    {
        return JavaScriptSerializer.Serialize(ViewModel);
    }

}

Как видите, серверного кода - действительно получается очень мало, и он простой. ViewModel - голый POCO класс, без кода, только свойства. Всю динамику интерфейса берет на себя клиентский код KnockoutJs и HTML markup с Knockout-биндингами (data-bind). Решение получается очень элегантное и эффективное.

Конечно, если данных много, всю модель таскать на каждый чих не стоит, и лучше использовать отдельный объект "CallbackArguments", который каждый раз заполнять только актуальной информацией.

JavaScript Object Model


Использование SharePoint JS Object Model (JSOM) предоставляет замечательную возможность делать полностью клиентские решения на основе динамических интерфейсов на KnockoutJs и данных из SharePoint-списков.

Чаще всего в случае JSOM, создается отдельная Site Page, пишется KnockoutJs html markup, и соответствующий js код. Для загрузки и сохранения данных используется JSOM.

В этом способе очень рекомендую использовать TypeScript Definitions for SharePoint 2013: они намного упростят работу с SharePoint'овским JSOM. Если делаете запросы к спискам, также не забывайте про CamlJs (кстати, готовлю новый релиз CamlJs, с классными плюшками!).

Пример: добавление значений в справочник

Представьте, что у вас есть форма добавления контакта, и справочник "Компании". Вводим имя, фамилию, телефон, ... и компания (поле типа Lookup). И вот хотелось бы, чтобы новую компанию можно было добавлять быстро и безболезненно, без перепрыгивания в список "Компании" и обратно. Причем очевидно, что у компании может быть много дополнительных атрибутов - сайт, директор, адрес и т.п., а следовательно Choice field здесь явно не подходит.



Для того, чтобы достичь цели, можно использовать CSR кастомизацию поля совместно с KnockoutJs.

Подробно о CSR для форм списка расписано в моей статье SharePoint 2013 Client Side Rendering: List Forms на CodeProject.

Вот что у меня получилось в итоге:


Полный код:

<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/knockout/3.1.0/knockout-min.js"></script>
<script type="text/javascript">
var defaultLookupFieldTemplate = SPClientTemplates._defaultTemplates.Fields.default.all.all.Lookup.NewForm;

SPClientTemplates.TemplateManager.RegisterTemplateOverrides({
  OnPostRender: function(ctx) {
    if (ctx.ListSchema.Field[0].Name != 'Company')
      return;
    var pageModel = {
      editMode: ko.observable(false),
      newCompanyTitle: ko.observable(''),
      addNewCompany: function() {
           this.editMode(true);
      },
      saveChanges: function() {
        var clientContext = new SP.ClientContext.get_current();
        var list = clientContext.get_web().get_lists().getByTitle('Company');
        var itemCreateInfo = new SP.ListItemCreationInformation();
        var li = list.addItem(itemCreateInfo);
        li.set_item('Title', this.newCompanyTitle());
        li.update();
        clientContext.load(li);
        clientContext.executeQueryAsync(function()
        {
            var select = document.querySelector('#myCompanyLookup select');
            var option = document.createElement('option');
            option.value = li.get_id();
            option.innerHTML = li.get_item("Title");
            select.appendChild(option);

            pageModel.editMode(false);
        },
        function(sender, args)
        {
          SP.UI.Notify.addNotification('Error adding country: ' + args.get_message, false);
          this.editMode(false);
        });

      }
    };
    ko.applyBindings(pageModel);
  },
  Templates: {
    Fields: {
      'Company': {
        NewForm: function(ctx) {
           var html = '<div id="myCompanyLookup" data-bind="visible: !editMode()">' + defaultLookupFieldTemplate(ctx);
           html+= '<a href="javascript:void()" data-bind="click: addNewCompany">Add</span>';
           html+= '</div>';
           html+= '<div data-bind="visible: editMode()">';
           html+= '<input type="text" data-bind="value: newCompanyTitle" />';
           html+= '<input type="button" data-bind="click: saveChanges, clickBubble: false" value="Save" />';
           html+= '</div>';
           return html;
        }
      }
    }
  }

});
</script>

Чтобы начать использовать этот код:

  1. создайте на сайте список "Company"
  2. в списке "Контакты" создайте колонку-Lookup "Company", ссылающуюся на одноименный список из п.1
  3. перейдите на новую форму списка "Контакты", в режим редактирования страницы, и добавьте Script Editor сразу после формы
  4. В Script Editor вставьте код
  5. Сохраните страницу - всё, должно работать

DataFormWebPart


В SharePoint 2010 я любил генерить Knockout-модель с помощью DataFormWebPart. Этот способ будет прекрасно работать и сейчас, даже в O365. Основное преимущество по сравнению с предыдущим, чисто клиентским методом - в том, что модель рендерится сразу на стороне сервера, вместо того, чтобы подтягиваться уже после загрузки страницы. Т.е. получается немного побыстрее. С учетом того, что SharePoint итак долгий, даже немного побыстрее - это весьма желательно! :) Здорово также, что DataFormWebPart может объединять несколько списков сразу (LinkedDataSource), и даже использовать внешние источники данных. И между прочим, даже без Design View в SharePoint Designer 2013, генерить DFWP в SPD все еще можно без особых проблем.
  1. Insert->Data View->Empty data view
  2. Insert -> Data Source -> ваш список
  3. Выбираем колонки на панели "Data Source Details"
  4. Insert Selected Fields as -> Multi item view
  5. Меняем сгенерировавшийся XSLT, чтобы генерить JS вместо HTML
XSLT будет выглядеть примерно так:
<xsl:stylesheet xmlns:x="http://www.w3.org/2001/XMLSchema" xmlns:d="http://schemas.microsoft.com/sharepoint/dsp" version="1.0" exclude-result-prefixes="xsl msxsl ddwrt" xmlns:ddwrt="http://schemas.microsoft.com/WebParts/v2/DataView/runtime" xmlns:asp="http://schemas.microsoft.com/ASPNET/20" xmlns:__designer="http://schemas.microsoft.com/WebParts/v2/DataView/designer" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:msxsl="urn:schemas-microsoft-com:xslt" xmlns:SharePoint="Microsoft.SharePoint.WebControls" xmlns:ddwrt2="urn:frontpage:internal">
 <xsl:output method="html" indent="no"/>
 <xsl:decimal-format NaN=""/>
 <xsl:template match="/">
  <script type="text/javascript">
    (function() {
      function Contact(lastName, firstName, cellPhone, email)
      {
          this.isNew = lastName ? false : true;

          this.lastName = ko.observable(lastName || '');
          this.firstName = ko.observable(firstName || '');
          this.cellPhone = ko.observable(cellPhone || '');
          this.email = ko.observable(email || '');

          this.editMode = ko.observable(this.isNew);

          this.save = function() {
              // ... use JSOM for saving changes
              this.editMode(false);
          }
          this.toEditMode = function() {
              this.editMode(true);
          }
      }
   
      function MyPageModel()
      {
          this.contacts = ko.observableArray([<xsl:for-each select="/dsQueryResponse/Rows/Row">
             new Contact('<xsl:value-of select="@Title" />',
                '<xsl:value-of select="@FirstName" />',
                '<xsl:value-of select="@CellPhone" />',
                '<xsl:value-of select="@Email" />')<xsl:if test="position()!=last()">,</xsl:if>
          </xsl:for-each>]);
          this.addNew = function() { this.contacts.push(new Contact()); }
      }
      
      var pageModel = ko.observable(new MyPageModel());
      ko.applyBindings(pageModel);
})();
   
  </script>
 </xsl:template>
</xsl:stylesheet>

Внимание! Основной код лучше писать в js-файле, а в XSLT генерировать только модель. Это намного практичнее, и можно использовать TS. Я запихнул весь код в XSLT только в целях демонстрации, Дальше останется только добавить ссылку на KnockoutJs и соответствующий HTML markup с KO биндингами.

Например:

<div data-bind="foreach: contacts">
 <div data-bind="visible: editMode()" class="form-inline">
  <input class="form-control" data-bind="value: firstName" placeholder="First name" />
  <input class="form-control" data-bind="value: lastName" placeholder="Last name" />
  <input class="form-control" data-bind="value: cellPhone" placeholder="Cell phone" />
  <input class="form-control" data-bind="value: email" placeholder="Email" />
  <div class="btn btn-default" data-bind="click: toggleEditMode">Save</div>
 </div>
 <div data-bind="visible: !editMode()" class="form-inline">
  <span data-bind="text: firstName"></span>
  <span data-bind="text: lastName"></span>
  <span data-bind="text: cellPhone"></span>
  <span data-bind="text: email"></span>
  <div class="btn btn-default" data-bind="click: toggleEditMode">Edit</div>
 </div>
</div>
<div class="btn btn-success" data-bind="click: addNew">Add new contact</div>

Как видите, здесь я еще использую bootstrap. Отличная вещь, чтобы ваши интерфейсы не выглядели доисторическим г****м :) Также для оформления очень рекомендую Font Awesome.

И да, не забудьте добавить соответствующие ссылки в <head>!

<link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.2.0/css/bootstrap.min.css" type="text/css" />
<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/knockout/3.1.0/knockout-min.js"></script>

И вуаля, получается что-то типа такого:



Для сохранения значений после изменений в модели используйте JSOM.

Заключение


К сожалению, далеко не все современные веб-фреймворки и библиотеки хорошо работают в SharePoint. Некоторые могут сломать какую-нибудь функциональность SharePoint, другие сами не работают из-за тех или иных ограничений, третьи работают, но криво.

Мой опыт показывает, что с KnockoutJs никаких проблем или конфликтов не возникает, а преимущества от его использования при создании удобных динамичных интерфейсов - сложно переоценить. К тому же, соединить Knockout с SharePoint, как видите, довольно легко,

В общем, если еще не используете, рекомендую!

6 комментариев:

  1. С knockout знаком очень плотно, делал на нем несколько реализаций и могу смело сказать, что в разрезе SharePoint данный MVVM фреймворк очень прост и практичен и главное Легкий. Очень помогает EventDelegation для Knockout и Dump ( Точного названия не помню), все можно найти на ГитХабе и еще много много всяких дополнений для него.

    ОтветитьУдалить
  2. Добрый день
    как обычно твои посты очень полезны.
    хотел задать вопрос, при реализации JavaScript Object Model у меня возникла проблема.
    когда я прикручиваю скрипт к одному полю, то все работает, если же к двум, то второе поле отказывается работать.
    можешь подсказать как решить проблему?

    ОтветитьУдалить
    Ответы
    1. Владимир, привет.
      Не понял вопрос, абсолютно.
      Какие поля - в представлении списка? в форме списка? где-то еще?
      Какой скрипт, что конкретно он делает?
      Как именно прикручиваешь?
      Что означает "второе поле отказывается работать"? Что конкретно не работает? Поле не отображается, не редактируется, не сохраняется, кастомизации не применяются, что именно?

      Удалить
  3. Здравствуйте! Насколько я понял CSR работает только для webpart? если я делаю кастомных формы через VS, подключая свою страницу через настройки списка
    Form Type="NewForm" Url="NewForm.aspx" SetupPath="features\$SharePoint.Feature.DeploymentPath$\Forms\NewForm.aspx" WebPartZoneID="Main" />
    как лучше сбайндить knockout модель на поля? В данным случае имеется ввиду простановка data-bind для элементов формы.
    Сами поля формируются стандартным для шарика способом:


    ОтветитьУдалить
    Ответы
    1. Если создаешь форму самостоятельно, то соответственно сам формируешь HTML, и проблемы с тем чтобы добавить туда data-bind быть не должно.
      Как я упоминал в статье, в этом случае контролы скорее всего придется делать свои собственные, а сохранение - через JSOM.

      Если все же хочется использовать стандартные контролы полей, то самый правильный способ - наверное CSR. Для этого нужно просто задеплоить CSR-скрипт на страницу (любым способом, можно script-тег добавить или через ScriptLink или через JSLink-свойство). Пример CSR-скрипта есть в статье.

      Удалить
  4. >>Сохраните страницу - всё, должно работать

    Андрейс спасибо большое! это бомба!)

    ОтветитьУдалить

Внимание! Реклама и прочий спам будут беспощадно удаляться.