воскресенье, 16 марта 2014 г.

Отображение подчиненных списков в SharePoint

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

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

С технической точки зрения, в этой статье я продемонстрирую использование OOTB веб-частей и их связывание друг с другом и с внешними параметрами. Также в процессе создания решения я буду использовать CSR и даже кастомизировать JSGrid.

Варианты решения

Как известно, в InfoPath специально для отображения подчиненных списков уже есть готовый инструмент, и вроде бы чего тут вообще изобретать велосипед!? Однако, с InfoPath обычно возникает слишком много проблем (подробнее можно почитать в моей статье про формы списков в SharePoint 2013), да и вообще InfoPath уже официально "заканчивается", если вдруг кто не в курсе. Но если не InfoPath, то что?

На самом деле задача решается достаточно легко средствами SharePoint, без всяких InfoPath и даже без кода. И вариантов решения даже не один, а довольно много. Например, в некоторых случаях можно использовать Document Set'ы, а Стас Выщепан придумал, что можно банально сделать библиотеку с оформленными по шаблону Excel-документами.

Что до меня, я обычно использую вот какой способ:

  1. Создается отдельный page layout.
  2. Создается отдельный список "позиций", с lookup-полем, ссылающимся на библиотеку документов настроенную на упомянутый выше page layout.
  3. На page layout добавляются поля заказа, а также вебчасть CQWP, ссылающаяся на список позиций, с фильтром по вышеупомянутому lookup-полю.
Даже в изначальном виде этот способ отлично работает, хорошо интегрирован в SharePoint, визуален, пригоден для печати (ну, возможно придется добавить пару стилей CSS для @media print).

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

По шагам

Как известно, в SharePoint'е можно застопориться на день-другой на любой даже самой простейшей ерунде, поэтому на мой взгляд, описание реализации по шагам всегда должно присутствовать в статьях про SharePoint :)

Поехали:

  1. Site Settings -> Master pages and page layouts -> Ribbon -> New document -> Page layout
  2. Создаем тип содержимого. Используем ссылку "Create new content type"; выбираем Parent Content Type = Article Page (из группы Page Layout Content Types), задаем имя, OK.
  3. Далее нужно скрыть ненужные поля и добавить те поля, которые вам нужны. Будьте аккуратны с такими полями как например поля типов Publishing HTML и Publishing Image. Они обязательно должны добавляться именно на этом шаге, т.е. через Content Type, а не непосредственно в библиотеку документов, иначе возможны проблемы.
  4. Как только тип содержимого создан, возвращаемся к созданию Page layout, обновляем список типов содержимого, выбираем только что созданный, выбираем название для Page layout, OK -> page layout создан!
  5. Создаем отдельный publishing сайт
  6. Конфигурируем этот сайт на использование вновь созданного page layout'а (Site settings -> Page layouts and site templates -> Page layouts -> Pages in this site can only use the following layouts -> выбираем только наш layout):
    Также выставляем page layout по умолчанию в то же значение, и сохраняем настройки.
  7. Теперь создаем список товаров и список позиций на этом самом отдельном сайте. Надеюсь понятно, почему списка 2:
    • список товаров включает в себя все возможные товары
    • список позиций содержит ссылки на те товары, которые были выбраны пользователем в заказ. Т.е. по сути дела список позиций это агрегированная таблица, реализующая связь много-ко-многим, и включающая в минимальном варианте 2 колонки: товар и заказ. Чаще всего там также будут дополнительные данные: например, из таблицы товаров может быть подтянута картинка товара, а также не помешает поле с количеством товаров для заказа. Также логично для списка позиций выбрать настройку "отображать только созданные мной записи", и добавить группировку по заказу. Наконец, не забудьте пожалуйста поле Title скрыть (через тип содержимого), и плюс сделать его не обязательным (в настройках поля).
  8. Списки созданы - давайте займемся донастройкой page layout'а. Открываем SharePoint Designer, переходим на нашу коллекцию сайтов, Files->_catalogs->masterpage-> ищем вновь созданный page layout, и выбираем Edit file in Advanced Mode через контекстное меню.
  9. Добавляем обычные поля из типа содержимого. Это кстати можно сделать через SharePoint Designer UI: на Ribbon, есть вкладка Insert, и там эти поля можно найти в раскрывающейся кнопке "SharePoint" - правда иногда приходится немного подождать, т.к. список полей загружается какое-то время, и доступен не сразу.
  10. Далее добавляем собственно самую интересную часть: список позиций. Тут есть два варианта реализации:
    • Наиболее простой способ это сделать - это использовать CQWP (Content by Query WebPart), как я упоминал выше. Эта веб-часть не имеет возможности редактирования, но зато списки товаров и позиций могут располагаться на любом сайте в текущей сайт-коллекции, что очень удобно.
    • Чтобы сделать более user-friendly интерфейс и обеспечить редактирование "на лету", можно использовать XLV (XsltListViewWebPart). В этом случае списки товаров и позиций должны располагаться на том же сайте, где расположена библиотека документов, настроенная на использование нашего page layout'а. Этот вариант технически сложнее (но и интереснее).

Список позиций через Content Query Web Part

Последовательность действий в этом случае следующая:
  1. На любой webpart page временно добавляем CQWP. Например можно добавить на форму создания элемента любого списка
  2. Настраиваем CQWP на список позиций.
  3. Добавляем фильтр по полю из списка позиций, которое указывает на заказ (например, у меня это поле называется Order). Обычно я делаю так, что lookup-поле "Order" ссылается на Title страницы, но в настройках этого поля отмечаю также колонку ID (при этом автоматически добавляется Dependent lookup поле "Order:ID"):
    И тогда фильтр нужно применять уже именно на поле "Order:ID". В качестве значения фильтра необходимо указать строку "[PageFieldValue:ID]".
  4. При необходимости изменения способа отображения списка, добавляем стиль в ItemStyle.xsl, и соответственным образом меняем настройки CQWP.
    Подсматриваем markup получившейся CQWP из SharePoint Designer, и copy+paste этот маркап в page layout. Обычно нужно скопировать тег WpNs0:ContentByQueryWebPart и регистрацию соответствующего префикса, т.е. код <%@ Register TagPrefix="WpNs0" ... %>.
  5. Для упрощения редактирования, просто добавьте ссылку (target="_blank") на список перед этой вебчастью, обернув ее в EditModePanel. Практика показывает, что клиенты в большинстве случаев вполне спокойно относятся к такому варианту редактирования, особенно если функционал редактирования доступен ограниченному числу лиц.

Список позиций через Xslt List View Web Part

Для реализации этого варианта потребуется немного больше знаний и немного больше ручных действий:
  1. Во-первых, перейдите в SharePoint Designer, на отдельный сайт который вы создали, и найдите файл Lists/ваш_список/AllItems.aspx. Перейдите к редактированию этого файла и выкопируйте оттуда тег XsltListViewWebPart, а также соответствующий ему серверный Register-тег для TagPrefix="WebPartPages". Скопированную разметку добавьте на page layout.
  2. Если сейчас вы посмотрите, как выглядит страница созданная на основе нашего page layout'а, там вы увидите представление списка. Наша задача сделать так, чтобы в режиме редактирования XLV автоматически переходил в режим Quick Edit, а в режиме просмотра выглядел примерно как CQWP. Это делается довольно просто.
  3. Сначала добавьте код для режима редактирования (нужный код я банально вытащил из onclick атрибута стандартной ссылки "edit this list"):
    <PublishingWebControls:EditModePanel runat="server">
        <script type="text/javascript">
            _spBodyOnLoadFunctions.push(function()
            {
                EnsureScriptParams('inplview', 'InitGridFromView', '{YOUR-GUID-HERE}');
            });
        </script>
    </PublishingWebControls:EditModePanel>
    
    Этот код необходимо поместить сразу после XsltListViewWebPart. GUID берется из View Name: 
  4. Теперь нужно добавить CSR-преобразование, которое бы изменяло внешний вид списка. В простейшем случае это преобразование будет выглядеть следующим образом:
    <PublishingWebControls:EditModePanel runat="server" PageDisplayMode="Display" SupressTag="True">
        <script type="text/javascript">
           SP.SOD.executeFunc('clienttemplates.js', 'SPClientTemplates.TemplateManager', function() {
                SPClientTemplates.TemplateManager.RegisterTemplateOverrides({
                    Templates: {
                        View: function (ctx) {
                            return String.format('<div class="cbq-layout-main"><ul class="dfwp-list dfwp-column">{0}</ul></div>', ctx.RenderBody(ctx)); 
                        },
                        Item: function (ctx) {
                            return String.format('<li class="dfwp-item"><div class="item"><div class="link-item">{0}</div></div></li>', ctx.CurrentItem["Item"][0].lookupValue);
                        }
                    }
                })
           })
        </script>
    </PublishingWebControls:EditModePanel>
    
    
  5. Теперь необходимо добавить фильтр по полю Order. Для этого, нужно сделать следующее:
    • сначала нужно добавить в page layout контрол NumberField, ссылающийся на ID текущей страницы, который не будет отображаться, но значение из которого мы будем через ParameterBinding передавать в XLV:
      <div style="display:none;">
         <SharePointWebControls:NumberField ID="PageID" ControlMode="Display" FieldName="1d22ea11-1e32-424e-89ab-9fedbadb6ce1" runat="server"/>
      </div>
      
    • Теперь добавляем ParameterBinding (рядом с уже существующими биндингами внутри тега XsltListViewWebPart):
        <ParameterBinding Name="pageId" Location="Control(PageId,ItemFieldValue)" />
      
    • И наконец, собственно настраиваем фильтр, добавляя в тег Query (внутри View) следующий код:
      <Where><Eq><FieldRef Name="Order" LookupId="TRUE" /><Value Type="Integer">{pageId}</Value></Eq></Where>
      
После выполнения этих действий, в режиме редактирования моя тестовая страница выглядела вот как (из общих полей я добавлял только поле Comments):

Вроде бы уже здорово, вау, но... на самом деле ничего не работает :)

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

Кроме такой вот большой проблемы, есть также и несколько мелких недочетов:

  • при попытке сортировки грида выводятся все записи списка, фильтрация по ID страницы слетает
  • встроенный в грид функционал по добавлению колонок, а также по их фильтрации - явно лишний.
Поскольку XLV в режиме редактирования выводится с помощью JSGrid, для исправления этих проблем понадобятся знания по контролу JSGrid. К сожалению, документации по JSGrid в интернете практически нет, а API там очень большой и разобраться самостоятельно - сложно. Но если все-таки это сделать, все наши проблемы решаются буквально парой десятков строк javascript-кода (этот фрагмент необходимо разместить после XLV):

<PublishingWebControls:EditModePanel runat="server">
    <script type="text/javascript">
        _spBodyOnLoadFunctions.push(function()
        {
            var viewId = '{8571F3CD-C286-4D4D-89AD-D4B2507A9448}';
            var orderColumnName = 'Order';

            g_SPGridInitInfo[viewId].jsInitObj.canUserAddColumn = false;
            g_SPGridInitInfo[viewId].jsInitObj.showAddColumn = false;
            EnsureScriptParams('inplview', 'InitGridFromView', viewId);
            
            SP.SOD.executeFunc('spgantt.js', 'SP.GanttControl', function() {
                var jsGridContainer = $get("spgridcontainer_" + g_SPGridInitInfo[viewId].jsInitObj.qualifier);
                jsGridContainer.jsgrid.HideColumn(orderColumnName);
                var columns = jsGridContainer.jsgrid.GetColumns();
                for (var i in columns)
                {
                    columns[i].isSortable = false;
                    columns[i].isAutoFilterable = false;
                }
                jsGridContainer.jsgrid.UpdateColumns(new SP.JsGrid.ColumnInfoCollection(columns));
                jsGridContainer.jsgrid.AttachEvent(SP.JsGrid.EventType.OnEntryRecordPropertyChanged, function(args) {
                    if (args.fieldKey != orderColumnName)
                    {
                        var update = SP.JsGrid.CreateUnvalidatedPropertyUpdate(args.recordKey,orderColumnName,currentPageId,false);
                        setTimeout(function() {jsGridContainer.jsgrid.UpdateProperties([update], SP.JsGrid.UserAction.UserEdit);}, 100);
                    }
                });
            });
        });
    </script>
</PublishingWebControls:EditModePanel>

Я не стану здесь пускаться в долгие объяснения о том, как устроен JSGrid - это, пожалуй, материал для целой отдельной серии статей.

Отмечу лишь, что для того, чтобы все заработало, вам необходимо будет добавить в представление списка колонку "Order". Как можно заметить выше, приведенный код ее скрывает от пользователя, и заполняет значением currentPageId каждый раз, когда добавляется новая строка. Переменную currentPageId я получил, используя следующий фрагмент кода, добавленный перед тегом со скриптом:

<script type="text/javascript">
    var currentPageId = <SharePointWebControls:NumberField ControlMode="Display" FieldName="1d22ea11-1e32-424e-89ab-9fedbadb6ce1" runat="server"/>;
</script>

Результат:


Полный код фрагмента, который получился у меня для XLV, я выложил на pastebin: http://pastebin.com/3TJCcc8f

Заключение

SharePoint предлагает огромное количество "строительных блоков" и вариантов их интеграции друг с другом. На основе этих средств в последнее время мне удается решать большинство задач по SharePoint всего за несколько часов.

Однако, необходимо понимать, что даже несмотря все это, множество ограничений и баговподводных камней приводят к тому, что очень сложно рассчитать заранее трудозатраты на создание подобных решений и гарантировать их полную работоспособность. Нередко получается, что 90% решения готово за час, а оставшиеся 10% делаются 2-3 дня. В таких случаях важно не буксовать в одном месте, а придумывать и пробовать разные альтернативные варианты - в шарепойнте их всегда очень много.

В общем, удачи вам в ваших решениях, и не стесняйтесь оставлять комментарии, если у вас есть какие-то вопросы или замечания!

23 комментария:

  1. Андрей , спасибо за статью. Скажите, а какой подход если используется sp2010?

    ОтветитьУдалить
    Ответы
    1. Денис, CQWP в SP2010 абсолютно такой же, так что первый вариант без изменений.

      Удалить
  2. как все в шарике то не просто :)

    У нас подход куда лаконичней.

    примерная концепция:
    1. есть мастер список - Заказы, и связанный по лукапу список Позиций. При этом в обоих случаях форма может быть кастомизирована в InfoPath или по другому, но лишь бы это была веб-часть.
    2. мы странице просмотра "Заказа" настриваем через web (обычная страница веб-частей), и добавляем на нее связанный список "Позиций", Табы, JS
    2.1 JS - это некое шаманство замещающее NewItem2(url) и и добавляющее в урл ParentID=ИД Заявки. таким образом при создании новой позиции будет открыта страница создания Новой позиции с переданной ей по урл нужными параметрами.
    3. На странице создания "Позиция заказа", добавляем некий функ, допустим через JS который берет из URL ParentID и устанавливает его в лукап поле формы позиции.

    собственно все. и ни каких бубнов в SPD. по этой концепции мы уже не одно решение сделали.

    Пример как вглядит можно посмотреть здесь http://www.sharepointworkshop.ru/solutions/storagesystem

    ОтветитьУдалить
    Ответы
    1. Александр, дак это то же самое. Понятное дело что вместо page layout можно использовать list form, и тогда "шаманство" с SPD не потребуется. Просто дело в том, что обычно такие вещи как форма заказа и подобные ей, клиенты хотят печатать, и поэтому page layout тут подходит лучше.

      А в остальном подход полностью идентичен, и на самом деле я делал также раньше, пока не раскопал как сделать через XLV (что намного изящнее, на мой взгляд). В моем случае тот самый "JS" просто делает немного другие вещи, а конкретно - кастомизирует JSGrid.

      Удалить
    2. Просто от одной мысли про PageLayout уже хреново. чета на ...лись мы ней вдоволь. с тех пор у нас четкий стереотип, что лучший тип страниц в шарике это страницы веб-частей.
      Но а если читывать требование печати, то да вариант с PageLayout круче.
      Хотя, я бы все же, для этой задачи выбрал бы InfoPath, а когда ему будет аналог мы на него еще посмотрим :) или если совсем не требуется автоматизации тупо ввод, вариант с шаблоном екселя очень даже привлекательный.

      Удалить
    3. Александр, честно говоря не знаю ни одной проблемы из-за Page Layouts. У нас тут вот, редкий проект делается без Page Layout, постоянно их используют - по поводу и без. И ни разу не возникало проблем.

      Удалить
  3. Спасибо, Андрей!
    Как всегда, что-нибудь новое, интересное :)
    В своём опыте использую такой же метод, как описал Александр. Только без Табов и для передачи параметра в ссылке использую XSLT.
    Единственное, что меня не устраивает, это медленность загрузки страницы при размещении на ней несколько веб-частей со ссылкой на разные списки. Не подскажешь методы борьбы с этим? Шаманить с JS, чтобы загружать веб-части в разное время? Или подгружать в разных Табах?

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

      Удалить
    2. Евгений, у меня лично несколько веб-частей, будь то XLV или CQWP или DFWP, размещенных на одной странице и отображающих разные списки, никогда не вызывали никаких проблем. Рекомендую попробовать тот же вариант на других списках, на другом environment и т.п. Я подозреваю, что-то есть необычное в твоих веб-частях, или же есть какой-то общий код на той странице, который вызывает такие тормоза.

      Удалить
    3. Решил всё-таки разобраться с самой медленной страницей. Проблема оказалась с веб-частью DataFormWebPart, которая выводит информацию из связанного источника данных.
      Кроме неё на странице ещё 6 веб-частей (и XLV и DFWP). Так вот без этой веб-части страница грузится примерно 3 секунды, с ней - 25-30.
      Думаю проблема в том, что в одном из списков в связанном источнике чуть более 5000 элементов. Хотя на страницу выводятся единицы.
      Что делать и как оптимизировать пока не придумал.

      Удалить
  4. Андрей, подскажите, а этот код должен работать, если разместить его на обычной странице веб-частей?

    Добавил XsltListViewWebPart, как у Вас описано.
    Добавил после него скрипт, который переводит в режим редактирования - работает.
    Попробовал вместо него добавить скрипт CSR для изменения представления, получаю "TypeError: Unable to get property '0' of undefined or null reference". Удалил.
    Добавил следующий блок, из пункта 5. В итоге: возможность добавления столбцов и сортировки пропала, как и должно, а вот столбец не скрывается, хотя в режиме дебага смотрю свойства этого столбца, стоит "isVisible=false".
    Так же, при добавлении строк, этот столбец не заполняется, точнее, видно что-то заполняется на 1 секунду и тут же пустеет, в итоге, при сохранении, значения нет.

    С чем может быть связанно такое поведение, не подскажете?

    ОтветитьУдалить
    Ответы
    1. С CSR разобрался. остальные вопросы актуальны :(

      Удалить
    2. Евгений, сори за поздний ответ. Вопрос видел, но сразу не было времени, а потом забыл :(

      Проверь плиз что orderColumnName и currentPageId выставлены правильно. currentPageId должен быть числом (=listitem.ID), а не строкой. Также посмотри код на pastebin, туда я просто выдрал из SPD полностью работающий код, так что если даже где-то в статье что-то теоретически может быть не так, то там точно правильно. Еще можно попробовать увеличить setTimeout со 100 до 500 например.

      Удалить
    3. Андрей, главное, что ответили :)

      Со скрытием поля разобрался. Оказалось, что оно скрывалось, а затем, во время выполнения jsgrid.UpdateColumns, опять отображалось. Перенёс jsgrid.HideColumn в конец функции и всё стало ОК.

      Осталась проблема с авто-заполнением. currentPageId я пока не использую. Для опыта вручную проставил ID элемента из списка подстановки, который существует. Во время добавления записи, указанный элемент появляется в нужном поле (отключил временно его сокрытие, чтобы проследить) и тут же исчезает. Такое ощущение, что какой-то другой скрипт его очищает.
      При увеличении таймаута до 500, получаю ошибку скрипта "Unable to get property 'fieldRawDataMap' of undefined or null reference" во время добавления новой записи.

      Удалить
    4. Так. Следующий этап :) Извиняюсь за флуд.

      Действительно, заполняемое поле очищает какой-то скрипт ПРИ СОХРАНЕНИИ ЗАПИСИ.
      У меня в списке было 2 поля: Title и поле подстановки, которое должно заполняться автоматом.
      Я заполняю Title, жму Enter, запускается скрипт сохранения записи и тут же очищает только что заполненное автоматом поле.
      Если добавить ещё одно поле, то при переходе на него, автоматическое поле заполняется корректно и при сохранении не очищается.

      Получается, что нужно куда-то ещё добавить таймер :)

      Удалить
    5. Интересно.

      Я постараюсь проверить еще раз свою версию в ближайшие дни. У меня все 100% работало, но вдруг...

      А ты потом обычное представление списка открывал, поле точно остается незаполненным? :)

      Удалить
    6. Да, конечно проверял, всё заполнено :)

      Удалить
    7. Возможно дело в том, что у тебя только 1 поле.
      "заполняю Title, жму Enter"...
      Я проверял немного по-другому: заполняю все поля (а в моем случае их было 2 и все required) и уже потом жму Enter.

      Событие SP.JsGrid.EventType.OnEntryRecordPropertyChanged срабатывает когда что-то изменяется в самой нижней строке, т.н. Entry Record Row, которая собственно и нужна для создания новых записей.

      Удалить
    8. Всё, я кажется разобрался в чем там дело.

      Использование setTimeout неверно. Оказывается лучше использовать банальный "lock".

      http://pastebin.com/kfgEtecf

      Удалить
  5. Андрей, а не удалось ли решить проблему: "при попытке сортировки грида выводятся все записи списка, фильтрация по ID страницы слетает".
    Дело в том, что у меня на странице несколько таких гридов и хотелось бы сделать так, чтобы пользователь мог включать/выключать их быстрое редактирование. При этих манипуляциях, к сожалению, так же слетает фильтрация по ID :(

    ОтветитьУдалить
  6. Здравствуйте, столкнулся с проблемой в 3 пункте, не могли бы более подробно описать его. Что надо делать с полями Publishing HTML и Publishing Image. При попытке присваивания странице этого шаблона я получаю страницу с ошибкой в стиле чтото пошло не так и "Unknown server tag 'SharePointWebControls:NumberField". Я не знаю как добавить поля Publishing HTML и Publishing Image через Content Type и думаю проблема в этом.
    Извините за возможно глупый вопрос, но моих знаний этого продукта пока не хватает чтобы понять что я сделал не так.

    ОтветитьУдалить
  7. Так же при создании строчки заполняю Lookup-поле, столкнулся со следующей проблемой:

    Если заполнять только title (то есть вводим title и нажимем enter, не заполняя другие поля), то Lookup-поле не заполняется, точнее оно заполняется но как-то не так (если нажать на него мышкой он пишет object). Если перезагрузить страницу, то запись будет сохранена а вот Lookup-поле будет пустое. Но если заполнить еще хотя бы одно поле, то все будет нормально.
    Если заполнить вторую строчку, то Lookup-поле в ней заполнится корректно, а потом сразу же оно заполнится в предыдущей записи (магия :))).
    Все последующие заполнения строк будут нормальными, проблема только с первой.
    Проблему удалось решить заменой SP.JsGrid.CreateUnvalidatedPropertyUpdate на SP.JsGrid.CreateValidatedPropertyUpdate.

    Как-то так, не знаю может кому пригодиться.

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

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