среда, 15 июня 2011 г.

Как правильно изменять внешний вид List Form в SharePoint

Всё в SharePoint'е можно делать разными способами. Но в последнее время, очень хочется делать правильно...

На этот раз, мне потребовалось внести изменения на формы стандартного SharePoint'овского списка. Таких форм существует три вида, и вы все их прекрасно знаете:
  1. DisplayForm - форма отображения элемента списка
  2. EditForm - форма изменения элемента списка
  3. NewForm - форма создания нового элемента списка
Из-за большого количества полей в одном из наших списков, мне захотелось разнести эти поля по вкладкам. Причем, захотелось также следующее:
  1. Чтобы сохранилась возможность добавлять новые поля в список
  2. Чтобы можно было перемещать поля из одной вкладки в другую, и менять порядок их следования
  3. Чтобы поля рендерились стандартными средствами 
Для того, чтобы определить принадлежность полей к вкладкам, я раскопал способ добавления пользовательских данных в SPField, и создал симпатичный jquery-интерфейс для того, чтобы этим мог заниматься пользователь. Но дальше дело, неожиданно, застопорилось: оказалось, что народ повсеместно пользуется для подобных целей грязными js-хаками, а такой способ мне категорически не подошел!

И вот, после долгих исследований и поиска в интернетах, мне-таки удалось понять, как же это делается правильно, и воплотить в жизнь вот такую вот красивую форму:



Как видите, часть полей (ФИО и фотография) здесь рендерятся по-особому. Остальные поля рендерятся обычным образом, но разбиваются на вкладки (пользователь может менять порядок полей и принадлежность полей разным вкладкам).

В общем, функционал на мой взгляд, весьма интересный, и в этом посте я расскажу подход к подобным изменениям.

Как делают все

Оказалось, что когда дело доходит до форм списков, большинство SharePoint-разработчиков использует крайне сомнительные подходы:
  1. Создание новой формы в виде aspx-страницы, которая кладется в _layouts. Форма полностью "кастомная", и если в список добавить поле, это поле придется руками добавлять и на форму...
  2. Javascript и jquery-хаки. К примеру, такие известные проекты, как Virto Ajax List Form Extender и SPServices - на самом деле парзят содержимое страницы с формой уже после её загрузки, и затем вносят свои изменения.
Ни один из этих подходов мне не понравился.

Создание целиком новой формы вообще пошло из SharePoint Designer, то есть это кустарное решение, решение для сисадминов.

Что же касается jquery, то это типичный клиентский подход, изобилующий собственными минусами:
  • В таких решениях, чаще всего даже невооруженным взглядом видно, что сначала форма загружается в обычном виде, а через мгновение - меняется (например, добавляются вкладки, и т.д.). Чем больше полей, тем лучше это видно.
  • jQuery использует механизм, который в профессиональной среде называется скринскрэпингом (screen scraping, data scraping), а даже само слово это - дурно пахнет! По сути, скрипт парзит HTML-код или же ищет элементы DOM-модели по некоторым признакам - в любом случае, признаки эти официально нигде не заявлены, и никто не гарантирует, что после очередного обновления SharePoint, ваш код останется работоспособным.
RenderingTemplate

Через некоторое время поисков, я понял, что если мне что-то и поможет, то только механизм RenderingTemplate. Этот механизм позволяет задавать т.н. шаблоны отображения форм списков для типов содержимого (класс SPContentType, свойства EditFormTemplateName, NewFormTemplateName и DisplayFormTemplateName).

RenderingTemplate представляет собой обычный ASP.Net контрол, реализующий интерфейс ITemplate, и позволяющий задать шаблон для отображения формы списка. Куча RenderingTemplate'ов лежит в файлике 14\TEMPLATE\CONTROLTEMPLATES\DefaultTemplates.ascx. Среди них, присутствует и шаблон под именем ListForm, который представляет собой стандартный шаблон для отображения всех форм списка.

Центральным элементом шаблона ListForm является контрол ListFieldIterator. Собственно, он и отображает все поля. И что интересно, от него можно отнаследоваться, а затем переопределить метод Render, чтобы изменить отображение так, как нам вздумается.

FormContext

Когда мы переопределяем метод Render в новосозданном потомке ListFieldIterator, сразу же возникает вопрос: а как рендерить поля? Откуда взять сведения о том, какие у нас есть поля, каковы их значения, и т.д. Оказалось, всё очень просто, и решается с помощью коллекции SPContext.Current.FormContext.FieldControlCollection. Эта коллекция хранит объекты типа BaseFieldControl, каждый из которых содержит, среди прочего, свойства Field и ItemFieldValue, которые позволяют получить любые сведения о поле, и значении этого поля.

Также, каждый из этих контролов имеет метод RenderControl, благодаря чему их можно спокойно отображать штатными средствами, ровно также, как это сделал бы SharePoint.

Ниже представлен шаблонный пример класса-потомка ListFieldIterator:
    public class CustomFieldIterator : ListFieldIterator
    {
        protected override void Render(HtmlTextWriter output)
        {
            foreach (BaseFieldControl fieldControl in SPContext.Current.FormContext.FieldControlCollection)
            {
                if (fieldControl.Field == null)
                    continue;

                // небольшой хак, если его не использовать,
                // IsFieldExcluded будет всегда возвращать true,
                // т.к. считает, что контрол уже есть, и еще раз
                // добавлять его не требуется.
                fieldControl.Visible = false;

                if (!this.IsFieldExcluded(fieldControl.Field))
                {
                    output.AddAttribute("class", "my-field");
                    output.AddAttribute("id", "field" + fieldControl.FieldName);
                    output.RenderBeginTag(HtmlTextWriterTag.Div);
                    output.Indent++;

                    // Восстанавливаем видимость и рендерим контрол
                    fieldControl.Visible = true;
                    fieldControl.RenderControl(output);

                    output.Indent--;
                    output.RenderEndTag();
                }

            }

        }
    }

В этом фрагменте, правда, отсутствует прорисовка заголовков и описаний полей, но пусть это будет домашним заданием :)

Кстати, возможностей данный подход открывает великое множество, если подумать...

Проект-пример

Проект, демонстрирующий описанный выше способ, доступен для скачивания.


P.S. Мои исследования во многом основывались на статье Виталия Баума Расширяем возможности ListFieldIterator, спасибо ему большое за эту статью!

Собственно, там описан практически такой же результат, который получил и я, только Виталий объединял поля не во вкладки, а в секции:

Кроме того, сведения о секциях хранились у Виталия в схеме типа содержимого, а не присоединенными к полям, что накладывало некоторые ограничения и неудобства.

Но вообще, статья обрывочная, вот честно. Фрагменты кода в надерганы очень куцо, мне по ним было крайне сложно сориентироваться, почти до всего пришлось доходить самому.

В целом, надеюсь, мой пост прольет еще больше света на это очень правильное направление.

21 комментарий:

  1. ооо! Наконец то нашел!! )))
    Уже было пошел по пути кастомных аспх страниц, но тут ваше описание, то что нужно.
    Спасибо большое, просто огромное, что не поленились все это описать

    ОтветитьУдалить
  2. Андрей,если не сложно. могли бы вы кратно рассказать как сделали вкладки? в методе Render? Или в уже в ascx файле? Спасибо )

    ОтветитьУдалить
  3. Привет, Роман!

    Спасибо за отзыв. Очень рад, что кому-то помогает моя писанина :)

    Всё делал в Render, на основе jquery tabs. Рендерил там div'ы, им добавлял нужные классы и id, а в конце - вот такой js:

    output.AddAttribute(HtmlTextWriterAttribute.Type, "text/javascript");
    output.RenderBeginTag(HtmlTextWriterTag.Script);
    output.Write("$('#my-field-tabs').tabs();");
    output.RenderEndTag();

    В общем всё очень просто, по примерам. А для того, чтобы определить, к какой вкладке какие поля относятся, использовал SPField metadata.

    Кстати, еще один, более расширенный пример кастомизаций можно посмотреть в посте HTML5 в формах списков SharePoint.

    ОтветитьУдалить
    Ответы
    1. Если не сложно можно выложить код данного шаблона, у меня проблемы с формой редактирования, а именно с наличием полей для выбора пользователя и даты
      При генерации разметки элементы управлениям выходят за границы таба

      Удалить
    2. Александр, код выкладывать нежелательно, т.к. он был написан в рабочее время и принадлежит не мне... Что-то похожее на вашу проблему припоминаю, посмотрите css в FireBug или в IE Developer Tools, там вроде просто нужно будет css подправить. Но что конкретно - не помню, к сожалению :(

      Удалить
  4. Еще один благодарственный коммент :) После недели борьбы с кастомизацией форм, прочтя этот пост повернул свою голову к тепмлейтам как наиболее гармоничному решению. Спасибо!

    ОтветитьУдалить
  5. Спасибо за познавательную статью!
    Хочу задать вопрос: можно ли при помощи RenderingTemplate реализовать зависимости между полями формы? То есть скрывать/показывать/делать обязательными поля в зависимости от значений других полей?

    ОтветитьУдалить
  6. Конечно да, у итератора есть свойство ListItem
    Для валидации нужно переопределить метод OnLoad
    и там взывать функции валидации
    Установить ошибку можно так:
    fieldControl.ErrorMessage = "errorMessage";
    fieldControl.IsValid = false;

    ОтветитьУдалить
  7. Не могу понять. А зачем наследоваться от ListFieldIterator? Если можно просто вместо ListFieldIterator разместить свой кастомный контрол (не наследник от ListFieldIterator)

    ОтветитьУдалить
  8. SPContext.Current.FormContext.FieldControlCollection - эта коллекция меня спасла! Спасибо!)

    ОтветитьУдалить
  9. Этот комментарий был удален автором.

    ОтветитьУдалить
  10. Как настраивать внешний вид\разметку формы я вроде разобрался, а вот с отображением полей мучаюсь. Допустим у меня есть выбор, который содержит 2 варианта. В зависимости от выбора 1 поле должно появиться, а другое скрыться, и наоборот. Можно ли каким-нибудь образом в рамках этого проекта реализовать такую динамическую смену? И если да то в какую сторону копать?
    Сверху вроде на подобный вопрос ответили, но сумбурно и не понятно...

    ОтветитьУдалить
  11. Кто справился с домашнем заданием по прорисовке, может скинуть рабочий пример? Хотя бы с двумя вкладками и полями :) И я не совсем понял, нужно еще хранить где какое поле отображать в каком-нибудь xml файле для каждого экземпляра?

    ОтветитьУдалить
  12. Тогда, если можно, еще вопрос, прошу хотя бы направить меня в нужном направлении. В проекте нужно создать сначала свой List Definitions и уже "к нему" привязывать свои шаблоны форм, или можно сделать проект только шаблона форм, и как-то подставлять его в любой список, вместо стандартный форм?

    ОтветитьУдалить
  13. Красивое решение. А как привязывать этот темплейт к конкретному листу? Он примерится сразу ко всем режимам CRU?

    ОтветитьУдалить
  14. Здравствуйте Андрей,
    Мне нужно реализовать сложную форму, подскажите пожалуйста как получить сообщение об ошибке для текущего проверяемого поля.
    В частности, для обязательных полей в случае пустого поля выдается сообщение - "Необходимо задать значение для этого обязательного поля."

    Хочу сделать правильно, и использовать в форме штатный функционал проверки полей и дополнить его своими проверками. Не могу найти где живет эти проветки.

    Пробовал (на основе Вашего примера)
    1. fieldControl.Field.ValidationMessage - содержит пустую строку
    2. fieldControl.Field.GetValidatedString(fieldControl.Value) - этот вариант тоже ничего не дал.
    3. Попытки использовать класс SPFieldValidationException пока успеха не принесли, возможно чтото делаю не так.

    Подскажите в какую сторону копать?

    ОтветитьУдалить
    Ответы
    1. Добрый день, Виталий

      Штатные проверки должны итак работать, если делаете так как описано в этом посте.

      Самый простой способ сделать дополнительные проверки - это использовать формулы (SPField.ValidationFormula). Также можно использовать аналогичную формулу у SPList, если нужно проверить зависимости между полями. ValidationMessage нужно устанавливать в сообщение которое будет отображено, если валидация не прошла. Это все нужно делать не из ListFieldIterator, а где-нибудь при активации фичи, т.е. это просто настройка полей.

      Эти проверки формулами можно задать в том числе через веб-интерфейс. Синтаксис там Excel-евский, аналогично Calculated Columns. Формула должна возвращать Boolean-значение.

      Если функционала формул не хватает, дополнительные проверки можно реализовать например ASP.Net-овскими валидаторами.

      Удалить
    2. Здравствуйте Андрей,
      спасибо за ответ,
      опишу ситуацию более широко -

      Один из самых простых функционалов которые мне нужно реализовать в проекте это древовидный список.
      Соответственно Лист будет содержать 2 поля ID и ParentID.
      Дерево будет большим и ветвистым.
      Чтобы пользователь при вводе, мог легко выбрать родителя мне нужно будет
      (для этого 1го поля)
      организовать 2-3(как пойдет) Дроп-Довн списка,
      чтобы последовательно сужать количество потенциальных Парентов.

      Результатом этих танцев с бубном должен стать ID записи котора будет родителем вводимой записи.

      Я решил сделать ParentID обычным числовым полем,
      и переопределить Render для этого поля в форме,
      так чтобы оно отображалось не как поле ввода а как выпадающий список с таким-же именем,
      тогда форма будет сохраняться и проверяться стандартным функционалом без дополнительного вмешательства.

      Рендер переопределил,
      имя подменил
      все сохраняется и должным образом
      и реагирует на неправильный ввод.

      Для окончательного построения формы у меня получился примерно следующий код -

      protected override void Render(HtmlTextWriter output)
      {
      // Цикл по фиелдам
      foreach (BaseFieldControl fieldControl in SPContext.Current.FormContext.FieldControlCollection)
      {
      if (fieldControl.Field.InternalName.Equals("ParentID"))
      {
      // Здесь готовлю HTML отображение

      String ret = < tr --- /tr > ;

      // в котором под полем ввода нужно вставить проверку на факт возникновения ошибки
      // и вывести эту ошибку

      if(fieldControl.Field.???)
      ret += "<5pan class=\"ms-formvalidation\">"
      + "<5pan role=\"alert\" >СЮДА НУЖНО ВСТАВИТЬ ТЕКСТ ОШИБКИ
      ";

      // !!! Здесь самый затык и случился.
      В котором, собственно вопрос и заключается.

      Не нашел где в дереве классов где лежит текст ошибки или есть функционал чтобы ее вытащить.

      Теоретически -
      если использовать базовый функционал шарепоинта по обработке сохранения форм,
      то должна работать и локализация стандартных сообщений об ошибках
      (еще одна заманчивая перспектива чтобы сделать все правильно).

      Пробовал -
      fieldControl.Field.ValidationMessage - сообщения не содержат
      fieldControl.Field.GetValidatedString(fieldControl.Value) - тоже пустое

      Прямо таки тоска и мировая скорбъ.
      Подозреваю что для решения будет не достаточно просто вытащить переменную и вставить в нужное место.

      Хотел узнать Ваше мнение.

      Удалить
  15. Здравствуйте!
    Прошу прощение за, возможно, нелепый вопрос, но хотя бы направьте меня на правильный путь. Как всё это применить к конкретному списку?
    Заранее спасибо!

    ОтветитьУдалить
  16. Здравствуйте!
    Интересная и познавательная статья. Но до меня никак не может дойти - где осуществлять разметку пользовательской формы... в xml шаблоне? В статье Виталия видно, что разметка сделана xml шаблоном, но конкретно куда ее прикрепить в проекте непонятно. Этот же вопрос задавал Voldemar Ing.

    ОтветитьУдалить
  17. Я конечно понимаю, что тут освещаются серьезные подходы к реализации нестандартных форм, но... простите, нехватает опыта применить это на деле. Подскажите пожалуйста пошагово, куда и чего вставлять.
    Андрей, прошу, свяжитесь со мной. У вас отниму 10 минут, Вы мне сэкономите недели!
    Skype: shpalich2

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

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

Примечание. Отправлять комментарии могут только участники этого блога.