В прошлый раз я рассказывал как сделать простые соединенные веб-части. Сегодня буду рассказывать как улучшить решение и сделать его более полезным в реальной жизни.

План действий

  1. Фильтрация в SPGridView
  2. Оптимизация передачи данных между веб-частями
  3. Добавление параметров для веб-частей
  4. Асинхронная загрузка дерева
  5. Bin deployment


Фильтрация в SPGridView

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

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

Подробно описано в статье, здесь приведу вкратце код для достижения цели.

Создание дочерних элементов веб-части:

this.ds = new ObjectDataSource(typeof(GridTableConsumer).AssemblyQualifiedName, "SelectData")
    {
        ID = "gridDS",
        EnableCaching = false
    };
this.ds.ObjectCreating += (sender, e) => e.ObjectInstance = this;
this.Controls.Add(this.ds);

this.grid = new SPGridView
    {
        ID = "grid",
        AutoGenerateColumns = false,
        DataSourceID = this.ds.ID,
        AllowFiltering = true,
        FilteredDataSourcePropertyName = "FilterExpression",
        FilteredDataSourcePropertyFormat = "{1} = '{0}'",
    };
this.Controls.Add(this.grid);

Для тех кто не знаком с классом ObjectDataSource рекомендую изучить его возможности. Для создания веб-частей очень хорошо подходит.

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

Далее метод SelectData

 

Чтобы работали фильтры SPGridView лучше всего возвращать объект DataTable.

IWebPartTable provider;
PropertyDescriptorCollection schema;
ICollection data;

public DataTable SelectData()
{
    EnsureDataAndSchema();

    DataTable result = null;
    if (this.data != null && this.schema != null)
    {
        result = this.data.ToDataTable(this.schema);
    }
    return result;
}

private void EnsureDataAndSchema()
{
    if (this.data == null)
    {
        this.provider.GetTableData((d, s) => 
        {
            this.data = d;
            this.schema = s;
        });
    }
}

В данном случае используется простые extension-метод GetTableData и ToDataTable. Их код можно посмотреть в исходниках.

Так как автоматически колонки для SPGridView не создаются, то необходимо это делать в коде, причем на наиболее позднем этапе жизненного цикла.

protected override void OnPreRender(EventArgs e)
{
    base.OnPreRender(e);

    EnsureDataAndSchema();
    GenerateGridColumns(this.grid, this.schema);
    this.grid.DataBind();
}

private static void GenerateGridColumns(SPGridView grid, PropertyDescriptorCollection properties)
{
    grid.Columns.Clear();
    if (properties != null)
    {
        var fields = properties.OfType<PropertyDescriptor>()
                               .Select(p => new SPBoundField
                               {
                                   DataField = p.Name,
                                   HeaderText = p.DisplayName,
                                   SortExpression = p.Name
                               })
                               .ToList();
        fields.ForEach(grid.Columns.Add);
        grid.FilterDataFields = string.Join(",", fields.Select(f => f.DataField).ToArray());
    }
}

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

Ну собственно этого достаточно чтобы работала фильтрация в SPGridView. Все это выглядит как стандартное представление для списка.

image

Оптимизация

Теперь получается что веб-часть дерева организаций создает из профилей DataTable, передает его веб-части представления в виде таблицы и там еще раз создается DataTable. Можно оптимизировать и создавать таблицу один раз.

Мы можем передавать с помощью интерфейса IWebPartTable сам список сотрудников в выбранном подразделении, а в Schema передавать PropertyDescriptorCollection, которые позволят вытаскивать данные из профилей пользователей.

Для класса UserProfile таких дескрипторов нет, но никто не мешает их написать

public class UserProfilePropertyDescriptor: PropertyDescriptor
{
    public UserProfilePropertyDescriptor(ProfileSubtypeProperty propery)
        :base(propery.Name, new Attribute[] { new DisplayNameAttribute(propery.DisplayName)})
    {
    }

    public override bool CanResetValue(object component)
    {
        return false;
    }

    public override Type ComponentType
    {
        get { return typeof(UserProfile); }
    }

    public override object GetValue(object component)
    {
        return Convert.ToString((component as UserProfile)[this.Name].Value);
    }

    public override bool IsReadOnly
    {
        get { return false; }
    }

    public override Type PropertyType
    {
        get { return typeof(string); }
    }

    public override void ResetValue(object component)
    {
        throw new NotImplementedException();
    }

    public override void SetValue(object component, object value)
    {
        (component as UserProfile)[this.Name].Value = value;
    }

    public override bool ShouldSerializeValue(object component)
    {
        return false;
    }
}

Теперь нужно выкинуть код формирования таблицы, заменив его гораздо более простым

public void GetTableData(TableCallback callback)
{
    if (callback != null)
    {
        EnsureChildControls();
        if (this.tree.SelectedNode != null)
        {
            long recordId = 0;

            if (long.TryParse(this.tree.SelectedValue, out recordId))
            {
                var profiles = this.OrganizationProfileManager
                                   .GetOrganizationProfile(recordId)
                                   .GetImmediateMembers();
                callback(profiles);
            }
        }
    }
}

И в качестве Schema возвращать набор свойств профиля

public PropertyDescriptorCollection Schema
{
    get
    {
        if (this.schema == null)
        {
            var upm = new UserProfileManager(SPServiceContext.Current);
            //Filter section headers from property list
            var props = from prop in upm.DefaultProfileSubtypeProperties
                        where !prop.IsSection
                        orderby prop.DisplayOrder
                        select new UserProfilePropertyDescriptor(prop);
            this.schema = new PropertyDescriptorCollection(props.ToArray());
        }
        return this.schema;
    }
}

Параметры веб-частей

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

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

[WebBrowsable(false)]
[Personalizable(PersonalizationScope.Shared)]
public string ProfileProperties { get; set; }

public HashSet<string> ProfilePropertyNames
{
    get
    {
        return new HashSet<string>((ProfileProperties ?? "").Split(new[] { PropertyNamesDelimeter }, StringSplitOptions.None));
    }
    set
    {
        ProfileProperties = string.Join(PropertyNamesDelimeter, value.ToArray());
    }
}

Атрибут WebBrowsable с параметром false говорит что не надо генерировать интерфейс для ввода параметра, а Personalizable говорит что значение этого свойствах надо хранить в свойствах веб-части.

Чтобы редактировать параметр нужен Custom Tool Part, сделать его довольно легко. Надо переопределить всего два метода, один для создания дочерних контролов, как у обычной веб-части, а второй для сохранения настроек.

public class OrganizationTreeToolPart : ToolPart
{
    ListBox list;
    public OrganizationTreeToolPart()
    {
        this.Title = "Profile properties";
    }

    protected OrganizationTree WebPart
    {
        get
        {
            return this.ParentToolPane.SelectedWebPart as OrganizationTree;
        }
    }

    protected override void CreateChildControls()
    {
        this.list = new ListBox()
        {
            SelectionMode = ListSelectionMode.Multiple,
            Height = Unit.Pixel(200)
        };
        var names = this.WebPart.ProfilePropertyNames;
        var upm = new UserProfileManager(SPServiceContext.Current);
        var items = from p in upm.DefaultProfileSubtypeProperties
                    where !p.IsSection
                    orderby p.DisplayOrder
                    select new ListItem(
                        string.Format("{0} ({1})", p.DisplayName, p.Name),
                        p.Name)
                        {
                            Selected = names.Contains(p.Name)
                        };
        this.list.Items.AddRange(items.ToArray());
        this.Controls.Add(this.list);
    }

    public override void ApplyChanges()
    {
        var set = new HashSet<string>(this.list
                    .GetSelectedIndices()
                    .Select(i => this.list.Items[i].Value));
        this.WebPart.ProfilePropertyNames = set;
    }
}

В этом Tool Part создается список с возможностью множественного выбора, который заполняется свойствами профилей пользователей.

Чтобы отображать этот Custom Tool Part необходимо веб-часть унаследовать от Microsoft.SharePoint.WebParts.WebPart и переопределить метод GetToolParts

public override ToolPart[] GetToolParts()
{
    var tps = base.GetToolParts().ToList();
    tps.Add(new OrganizationTreeToolPart());
    return tps.ToArray();
}

Далее чтобы использовать это свойство достаточно слегка изменить возвращаемую схему

public PropertyDescriptorCollection Schema
{
    get
    {
        if (this.schema == null)
        {
            var names = this.ProfilePropertyNames; //(1)
            var upm = new UserProfileManager(SPServiceContext.Current);
            //Filter section headers from property list
            var props = from prop in upm.DefaultProfileSubtypeProperties
                        where !prop.IsSection
                        where names.Contains(prop.Name) //(2)
                        orderby prop.DisplayOrder
                        select new UserProfilePropertyDescriptor(prop);
            this.schema = new PropertyDescriptorCollection(props.ToArray());
        }
        return this.schema;
    }
}

Изменения выделены комментариями.

Теперь пользователь сможет самостоятельно выбирать какие свойства профиля отправлять веб-части получателю.

image

Асинхронная загрузка дерева организаций по требованию

В первоначальном варианте дерево строилось при загрузке веб-части, но необходимости в этом нет. Достаточно построить дерево, из части узлов, а остальное загружать по требованию.

И снова нам на помощь приходит ASP.NET. Стандартный контрол TreeView умеет загружать узлы по требованию с помощь AJAX. Нам для этого надо сделать совсем мало:

  1. Установить свойство TreeView.PopulateNodesFromClient = true
  2. Указывать TreeNode .PopulateOnDemand = true, если требуется загрузка узлов по требованию
  3. Обрабатывать событие TreeView.TreeNodePopulate, в обработчике которого и заниматься загрузкой узлов.

Код для загрузки по требованию

protected override void CreateChildControls()
{
    this.tree = new TreeView();
    this.tree.EnableClientScript = true;
    this.tree.PopulateNodesFromClient = true;
    this.tree.TreeNodePopulate += new TreeNodeEventHandler(tree_TreeNodePopulate);
    this.tree.Nodes.Add(ToTreeNode(this.OrganizationProfileManager.RootOrganization));
    Controls.Add(this.tree);
}


void tree_TreeNodePopulate(object sender, TreeNodeEventArgs e)
{
    var recordId = int.Parse(e.Node.Value);
    var profile = this.OrganizationProfileManager.GetOrganizationProfile(recordId);
    foreach (var child in profile.GetChildren())
    {
        e.Node.ChildNodes.Add(ToTreeNode(child));
    }

}

private static TreeNode ToTreeNode(OrganizationProfile child)
{
    return new TreeNode(child.DisplayName, child.RecordId.ToString())
           {
               PopulateOnDemand = child.HasChildren,
           };
}

Bin deployment

Сборки решений уровня фермы в SharePoint можно развертывать как в GAC, так и в каталог Bin веб-приложений SharePoint. Второй вариант подходит только для веб-частей, но в данном случае больше и не нужно.

Чтобы развернуть сборку в Bin надо поменять одну настройку в свойствах проекта

image

Но после этого решение откажется работать. Для сборок, развертываемых в Bin, необходимо вручную прописывать требуемый уровень разрешений для работы. Это можно сделать руками, но можно воспользоваться расширением SPSF.

В результате в solution manifest (package) должен появиться раздел

<CodeAccessSecurity>
  <PolicyItem>
    <PermissionSet class="NamedPermissionSet" version="1">
      <IPermission class="Microsoft.SharePoint.Security.SharePointPermission, Microsoft.SharePoint.Security, Version=14.0.0.0, Culture=neutral, PublicKeyToken=71e9bce111e9429c" version="1" ObjectModel="True" />
      <IPermission class="SecurityPermission" version="1" Flags="Execution" />
      <IPermission class="AspNetHostingPermission" version="1" Level="Minimal" />
    </PermissionSet>
    <Assemblies>
      <Assembly
        Name="$SharePoint.Project.AssemblyName$"
        Version="$SharePoint.Project.AssemblyVersion$"
        PublicKeyBlob="$SharePoint.Project.AssemblyPublicKeyBlob$" />
    </Assemblies>
  </PolicyItem>
</CodeAccessSecurity>

Кстати SPSF генерирует переносы строк, но с ними не работает.

Заключение

Если вы дочитали до этого момента и ничего не поняли, то рекомендую более внимательно изучать ASP.NET Controls и .NET FW в частности компонентную модель, вопросы работы с данными и безопасностью, а также внимательно изучить ссылки, которые я привел в первом посте.

Исходный код можно найти на CodePlex:  http://spsamples.codeplex.com/


Теги : ASP.NET, web parts, SharePoint