Komponenta pro editaci kolekcí pro ASP.NET

2. díl - Komponenta pro editaci kolekcí pro ASP.NET

Tomáš Herceg       01.01.2010       C#, VB.NET, ASP.NET WebForms, Komponenty       12196 zobrazení

V tomto dílu seriálu o vývoji komponent v ASP.NET si ukážeme, jak do komponent deklarovat pokročilejší vlastnosti, jako třeba kolekce nebo šablony. Představíme si také, jak dělat podporu pro design režim a seznámíme se s mnoha dalšími věcmi.

V minulém dílu tohoto seriálu jsme si předvedli, jak udělat CAPTCHA komponentu se vším všudy. Představili jsme si, jak se v ASP.NET používají handlery, nemálo jsme povídali o tom, jak udělat sdílenou kolekci a jak na ní synchronizovat vlákna, abychom si nezadělali na problémy a ukázali jsme si i řadu dalších věcí.

V tomto dílu si ukážeme, jak psát komponenty využívající šablony. Jakožto ukázka nám bude sloužit komponenta, kterou jsem napsal nedávno a která slouží pro vytváření a manipulaci s kolekcemi. Tato komponenta nic neukládá do databáze, je to jen jakýsi kontejner, kterému předložíme kolekci objektů a šablonu editoru pro jednu položku. Tento kontejner zobrazí všechny položky pod sebou v tabulce, vedle každé umístí tlačítko Odstranit a pod všechny položky umístí tlačítko Přidat, které přidá do kolekce nový řádek. Uživatel si tak může vytvořit libovolný počet položek a různě si s nimi manipulovat. Při troše snahy by se dalo udělat i přeskupování záznamů, ale to si již každý zvládne dodělat sám.

Vzhled komponenty

Komponenta nebude pro jednoduchost spolupracovat s datovými zdroji – původně jsem tuto možnost implementovat chtěl, ale pak jsem si uvědomil, že by s tím bylo mnoho problémů, komponenta například může editovat záznamy svázané pomocí cizího klíče s položkou, která nebyla ještě do databáze vložena atd. Bylo by obtížné definovat provázání více datových zdrojů, takže o logiku úpravy záznamů se musíme postarat sami v codebehindu. Špinavou práci jako přidávání a odebírání položek a generování příslušných formulářových polí udělá komponenta za nás. To, jak bude vypadat jedna konkrétní položka, si samozřejmě můžeme nadefinovat sami, o naplnění komponenty daty se budeme starat sami. Využijeme také možnosti DataBindingu.

Jak bude komponenta fungovat?

Komponenta se bude renderovat jako tabulka se dvěma sloupci – v prvním bude vždy editor položky, v druhém budou tlačítka Odstranit. Poslední řádek bude mít buňky sloučené a bude obsahovat poslední tlačítko.

Komponenta bude mít vlastnost Items, což bude kolekce objektů. Vlastnosti těchto objektů budeme bindovat přímo do zadané šablony ItemTemplate. Binding bude probíhat pomocí klasických <%# Bind(“Vlastnost”) %> binding expressions.

Protože objekty, které budeme chtít editovat v kolekci, budou pravděpodobně potřebovat při svém vytvoření nastavit výchozí hodnoty některých vlastností, komponenta bude obsahovat ještě vlastnost FieldDefaults, ve které budou právě názvy a výchozí hodnoty vlastností.

Uvnitř bude naše komponenta obsahovat Repeater, s jehož položkami budeme manipulovat a pomocí nějž vygenerujeme celý obsah.

Základní kostra komponenty

Kód budu tentokrát uvádět pouze v jazyce C#, přeložení do VB.NET není těžké.

Naši komponentu odvodíme z CompositeControl. Od této třídy je vhodné začít implementovat komponentu, pokud tvoříme nějakou obálku nad jednou nebo více komponentami, což je přesně náš případ. Přestože vlastně jen vylepšujeme Repeater, není dobrý nápad dědit přímo od Repeateru, customizujeme si ho poměrně dost a nechceme, aby nám uživatel (programátor, který komponentu používá) svévolně měnil třeba HeaderTemplate.

Třída CompositeControl má jednu důležitou metodu, kterou jest nutno přepsat, a tou je CreateChildControls. V ní máme za úkol vytvořit všechny komponenty, které potřebujeme, nastavit je a vložit ve správném pořadí do kolekce Controls. Tím se objeví na stránce a budou fungovat.

Zde je tedy naše základní třída, z níž budeme vycházet:

 using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Web;
using System.Web.UI;
using System.Web.UI.WebControls;

namespace MyComponents
{

/// <summary>
/// Komponenta editoru kolekcí
/// </summary>
public class CollectionEditor : CompositeControl
{


/// <summary>
/// Vytvoří komponenty do kolekce Controls
/// </summary>
protected override void CreateChildControls()
{



}
}
}

Komponentu hned vyzkoušíme na stránce, zaregistrovali jsme ji pomocí direktivy Register.

 <%@ Page Language="C#" %>
<%@ Register Namespace="MyComponents" TagPrefix="my" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">

</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<asp:ScriptManager ID="ScriptManager1" runat="server"></asp:ScriptManager>

<my:CollectionEditor ID="CollectionEditor1" runat="server" />

</form>
</body>
</html>

Vlastnosti komponenty

Naše komponenta bude mít následující vlastnosti:

Items IList<object> Kolekce zobrazených položek
ItemDataType string Název datového typu položek
FieldDefaults ParameterCollection Kolekce výchozích hodnot pro nové položky
ItemTemplate ITemplate Šablona editoru jedné položky
RemoveButtonText string Text tlačítka pro odebrání
AddButtonText string Text tlačítka pro přidání

 

Vlastnosti AddButtonText a RemoveButtonText

Nejprve přidáme do naší třídy dvě poslední – ty jsou jednoduché:

image
         /// <summary>
/// Text tlačítka pro přidání položky
/// </summary>
[
Category("Appearance"), DefaultValue("Přidat položku"), Localizable(true)]
public string AddButtonText
{
get { return (string)ViewState["AddButtonText"] ?? "Přidat položku"; }
set { ViewState["AddButtonText"] = value; }
}

/// <summary>
/// Text tlačítka pro odebrání položky
/// </summary>
[
Category("Appearance"), DefaultValue("Odstranit"), Localizable(true)]
public string RemoveButtonText
{
get { return (string)ViewState["RemoveButtonText"] ?? "Odstranit"; }
set { ViewState["RemoveButtonText"] = value; }
}

Vlastnosti si své hodnoty ukládají do ViewState. Operátor a ?? b je ekvivalentní zápisu if (a == null) return b; else return a;, takže getter se vždy podívá do ViewState na danou položku (konvence je dát položce ve ViewState stejný název jako název vlastnosti) a pokud ji nenajde, vrátí alternativní výchozí hodnotu. V setteru hodnotu, kterou do vlastnosti přiřazujeme, jednoduše uložíme do ViewState.

Naše vlastnosti mají tři atributy – Localizable říká, že tato vlastnost je určena k lokalizaci, DefaultValue a Category slouží pro okno vlastností, kde se podle hodnoty Category komponenty řadí do skupin, DefaultValue pak určuje výchozí hodnotu vlastnosti. Pokud má vlastnost jinou hodnotu než je její DefaultValue, tak se v okně vlastností zobrazí tučně. Na obrázku vidíme, že jsou vlastnosti opravdu řazené do kategorií (naše dvě jsou v kategorii Appearance).

Nyní přidáme vlastnost ItemTemplate – šablonu pro editor jedné položky. Všechny šablony jsou typu ITemplate, což je rozhraní definující jednu metodu InstantiateIn. Ta dostane komponentu a do její kolekce Controls vygeneruje své komponenty. Tuto metodu si můžeme zavolat víckrát a pokaždé jí předat jinou komponentu (kontejner), takže se nám komponenty uvedené v šabloně na stránce mohou objevit víckrát.

         /// <summary>
/// Šablona jedné položky
/// </summary>
[
Browsable(false), PersistenceMode(PersistenceMode.InnerProperty), TemplateContainer(typeof(RepeaterItem), BindingDirection.TwoWay)]
public ITemplate ItemTemplate { get; set; }

Takto bude vypadat naše vlastnost. Můžeme použít automatickou property, stačí nám implicitní getter a setter. Šabloyn do ViewState neukládáme, protože je téměř vždy přiřazujeme deklarativně a programově je měníme málokdy (a pokud ano, tak prostě do té vlastnosti přiřadíme v každém požadavku, protože její změny nepřežijí PostBack). Šablona navíc nemusí být serializovatelná, takže by do ViewState ani uložit nešla.

Naše vlastnost má 3 atributy – Browsable říká, že se tato vlastnost nemá ukazovat v okně vlastností (protože by tam stejně nešla rozumně vyplnit), PersistenceMode nám říká, že se tato vlastnost nebude v ASPX stránce zadávat jako atribut, ale jako vnitřní element komponenty my:CollectionEditor. Poslední atribut je kvůli tomu, že uvnitř šablony budeme mít databinding – řekneme jí tedy datový typ kontejneru, v němž bude hostovaná (v našem případě RepeaterItem) a specifikujeme jí, že má podporovat obousměrný data-binding.

Aby vše fungovalo tak, jak má, je ještě potřeba přidat naší třídě CollectionEditor atribut ParseChildren, který zařídí, že vnitřek komponenty nebude parser chápat jako komponenty, které má CollectionEditoru dát do kolekce Controls, ale že uvnitř elementu budou jen specifikace vlastností.

 [ParseChildren(true)] 

Nyní můžeme šablonu nastavit tímto způsobem:

     <my:CollectionEditor ID="CollectionEditor1" runat="server" AddButtonText="Přidat">
<ItemTemplate>
<asp:TextBox ID="TextBox1" runat="server" Text='<%# Bind("Title") %>' />
<asp:CheckBox ID="CheckBox1" runat="server" Checked='<%# Bind("Active") %>' />
</ItemTemplate>
</my:CollectionEditor>

Všimněme si, že vlastnost AddButtonText nastavujeme jako atribut (je to standardní vlastnost), zatímco vlastnost ItemTemplate nastavujeme pomocí vnitřního elementu. To je právě atributem PersistenceMode s nastavením InnerProperty.

Pokud jste někdy dumali nad tím, na co se vlastně přeloží výše uvedený kus kódu, zde je malá část kódu vykuchaná z assembly pomocí Reflectoru. První metoda má za úkol vygenerovat komponentu CollectionEditor1 a volá se v době, kdy se vytváří strom komponent ve stránce. Vidíme, že se vytvoří instance třídy CollectionEditor a do její ItemTemplate se přiřadí instance CompiledBindableTemplateBuilder, což je třída implementující rozhraní ITemplate. Jako parametry do konstruktoru se dají metody pro vytvoření komponent a pro vytažení hodnot z komponent (přes binding). Naše šablona totiž používá datadinbing (to jsme řekli pomocí TemplateContainer atributu), implementuje tudíž i rozhraní IBindableTemplate, které má oproti ITemplate navíc i metodu ExtractValues. Ta právě vytáhne hodnoty vlastností, které obsahují vazbu Bind.

         [DebuggerNonUserCode]
private CollectionEditor __BuildControlCollectionEditor1()
{
CollectionEditor editor = new CollectionEditor();
this.CollectionEditor1 = editor;
editor.ApplyStyleSheetSkin(
this);
editor.ItemTemplate =
new CompiledBindableTemplateBuilder(
new BuildTemplateMethod(this.__BuildControl__control4),
new ExtractTemplateValuesMethod(this.__ExtractValues__control4)
);
editor.ID =
"CollectionEditor1";
editor.AddButtonText =
"Přidat";
this.__BuildControl__control7(editor.FieldDefaults);
return editor;
}

[DebuggerNonUserCode]
private void __BuildControl__control4(Control __ctrl)
{
IParserAccessor accessor = __ctrl;
accessor.AddParsedSubObject(
new LiteralControl("\r\n "));
TextBox box = this.__BuildControl__control5();
accessor.AddParsedSubObject(box);
accessor.AddParsedSubObject(
new LiteralControl("\r\n "));
CheckBox box2 = this.__BuildControl__control6();
accessor.AddParsedSubObject(box2);
accessor.AddParsedSubObject(
new LiteralControl("\r\n "));
}

[DebuggerNonUserCode]
private TextBox __BuildControl__control5()
{
TextBox box = new TextBox();
box.TemplateControl =
this;
box.ApplyStyleSheetSkin(
this);
box.ID =
"TextBox1";
box.DataBinding +=
new EventHandler(this.__DataBinding__control5);
return box;
}

[DebuggerNonUserCode]
private CheckBox __BuildControl__control6()
{
CheckBox box = new CheckBox();
box.TemplateControl =
this;
box.ApplyStyleSheetSkin(
this);
box.ID =
"CheckBox1";
box.DataBinding +=
new EventHandler(this.__DataBinding__control6);
return box;
}
public void __DataBinding__control5(object sender, EventArgs e)
{
TextBox box = (TextBox)sender;
RepeaterItem bindingContainer = (RepeaterItem)box.BindingContainer;
if (this.Page.GetDataItem() != null)
{
box.Text =
Convert.ToString(base.Eval("Text"), CultureInfo.CurrentCulture);
}
}

[DebuggerNonUserCode]
public IOrderedDictionary __ExtractValues__control4(Control __container)
{
TextBox box = (TextBox)__container.FindControl("TextBox1");
CheckBox box2 = (CheckBox)__container.FindControl("CheckBox1");
OrderedDictionary dictionary =
new OrderedDictionary();
if (box != null)
{
dictionary[
"Text"] = box.Text;
}
if (box2 != null)
{
dictionary[
"Checked"] = box2.Checked;
}
return dictionary;
}

Všechny metody jsem sem nedával, na ukázce mimo první metody další tři, které generují samotnou šablonu – první z nich vypíše konec řádku a proloží je výsledkem volání následujících dvou – jedna generuje TextBox, druhá CheckBox. Aby fungoval databinding dovnitř, vidíme, že komponenta má na událost DataBinding navěšenou další metodu, která se postará o naplnění této komponenty.

Předposlední metoda je pak ukázka databindingu dovnitř – je to obsluha události DataBinding komponenty TextBox. Poslední metoda pak implementuje metodu ExtractValues rozhraní IBindableTemplate, vidíme, že si najde TextBox1 a CheckBox1 a vrátí slovník názvů a hodnot.

Myslím si, že každý, kdo s ASP.NET dělá, by měl mít minimálně představu, jak samotná technologie funguje uvnitř. Není nutné znát každý detail a mít nazpaměť nastudované referenční zdrojáky, ale základní principy to znát opravdu chce. Auto sice taky můžete řídit aniž byste věděli, co je to spalovací motor a jak přesně funguje. Ale pokud někdo nechápe, proč a kdy má mačkat spojku anebo proč má do kopce přeřadit na nižší stupeň, nejezdí zrovna dobře a kdyby měl motor jeho auta nožičky, tak by ho určitě pořádně nakopal. To jsme ale trochu odbočili.

Tím jsme tedy nadefinovali šablonu jedno položky, která se skládá s TextBoxu a CheckBoxu. Máme dva bindingy s názvy Active a Title, takže očekáváme, že položky, které budeme editorem editovat, budou mít tyto dvě vlastnosti (a možná nějaké další).

Vlastnost FieldDefaults

Zbývá nám ještě pár dalších vlastností, takže hurá na ně. Vlastnost FieldDefaults bude obsahovat názvy vlastností a jejich výchozí hodnoty. To se využije jednak v případě, kdy přidáme novou položku do editoru, protože podle výchozích hodnot můžeme některá pole předvyplnit, ale třeba i v situaci, kdy víme, že všechny položky náleží objednávce s ID 5. V takovém případě můžeme říct, že editované objekty automaticky dostanou do vlastnosti OrderId hodnotu 5.

FieldDefaults má být typu ParameterCollection. Možná tuto třídu neznáte, ale skoro bych si vsadil, že ano. Pokud jste pracovali s libovolnou komponentou, jejíž název končil DataSource, tak kolekce parametrů SelectParameters atd. byly typu ParameterCollection. Výhodou této kolekce je fakt, že výchozí hodnoty vlastností můžeme tahat třeba z QueryStringu nebo z jiné komponenty (můžeme použít ControlParameter) atd.

Jak tedy bude tato vlastnost vypadat? Přidáme jí tuto deklaraci:

         private ParameterCollection fieldDefaults = new ParameterCollection();
/// <summary>
/// Kolekce výchozích hodnot komponent
/// </summary>
[
Category("Data"), DefaultValue(null), PersistenceMode(PersistenceMode.InnerProperty),
Editor("System.Web.UI.Design.WebControls.ParameterCollectionEditor", typeof(System.Drawing.Design.UITypeEditor)), MergableProperty(false)]
public ParameterCollection FieldDefaults
{
get { return fieldDefaults; }
}

První tři atributy známe, zajímavý atribut je Editor, který definuje třídu, která se má použít pro editaci této kolekce. Zatím design-time podporu u naší komponenty nemáme, ale časem ji uděláme.

Ve stránce dovnitř naší komponenty přidejte deklaraci výchozích hodnot vlastností pro naše objekty. Hodnotu vlastnosti OrderId nastavíme podle parametru id v URL. Dále nastavíme výchozí hodnotu vlastnosti Active na true, což zapříčiní, že u nových položek bude checkbox zaškrtnutý.

         <FieldDefaults>
<asp:QueryStringParameter Name="OrderId" QueryStringField="id" Type="Int32" />
<asp:Parameter Name="Active" DefaultValue="true" Type="Boolean" />
</FieldDefaults>

Vlastnost ItemDataType

Tato vlastnost bude velmi jednoduchá, jako řetězec se zadá datový typ jedné položky. Ten použijeme v případě, že přidáváme novou položku do kolekce, a také v případě, kdy si uživatel vyžádá kolekci objektů, které v komponentě jsou (tedy zavolá getter vlastnosti Items). Naše komponenta projde položky vnitřního repeateru a podle nich a podle výchozích hodnot postaví objekty.

         /// <summary>
/// Datový typ vnitřních položek
/// </summary>
[
Category("Behavior"), DefaultValue(null), Localizable(false)]
public string ItemDataType
{
get { return (string)ViewState["ItemDataType"]; }
set { ViewState["ItemDataType"] = value; }
}

Vlastnost Items

A konečně nám zbývá poslední vlastnost – ta nejdůležitější. Je jí naše kolekce položek. Ta bude mít již trochu složitější getter a setter.

Pokud do vlastnosti Items někdo přiřadí kolekci objektů, smažeme všechny položky, které máme, a nahradíme je těmi, co jsme dostali. Zkrátka hodnotu přiřadíme jako DataSource pro vnitřní Repeater a zavoláme DataBind, čímž se položky Repeateru přepíšou těmi novými.

Pokud bude někdo chtít k vlastnosti Items přistupovat, budeme muset projít položky Repeateru a pomocí metody ExtractValues naší ItemTemplate vytáhnout hodnoty vlastností z komponent, zkombinovat je s výchozími hodnotami pro vlastnosti, pro každou položku vytvořit objekt typu ItemDataType a tyto vlastnosti mu nastavit. Celou tuto kolekci pak vrátíme.

Protože po nás někdo může chtít kolekci Items víckrát a ukáže se, že se bude hodit i nám pro přidávání a mazání položek (Repeater si totiž do své kolekce položek nenechá jen tak sahat, jeho kolekce Items je jen pro čtení), uděláme na ní tzv. lazy inicializaci – vytvoříme ji až při prvním čtení vlastnosti Items a uložíme si ji do privátní proměnné, abychom ji příště jen použili.

Navíc by se nám pro tuto vlastnost hodila i podpora jednosměrného data-bindingu, abychom do vlastnosti Items mohli hodnotu nacpat pomocí <%# Eval(“něco”) %>, podobně, jako to umí třeba ListView. Dělat binding zpět by bylo sice hezké, ale nejsem si jistý, jestli to umí některý z data sources využít.

         private List<object> items = null;
/// <summary>
/// Editované položky v komponentě
/// </summary>
[
Browsable(false), Bindable(true, BindingDirection.OneWay)]
public IList<object> Items
{
get
{
if (items == null)
RestoreItems();
return items;
}
set
{
items =
value.ToList();
}
}

Metodu RestoreItems napíšeme za chvíli, teď konečně naimplementujeme metodu CreateChildControls, kde vytvoříme náš Repeater, o němž zde již drahnou dobu hovoříme.

Vytvoření šablony položku Repeateru

Repeater, který se chystáme vytvořit, bude mít jako svou ItemTemplate něco takového:

     <tr>
<td><%-- Editor --%></td>
<td>
<asp:LinkButton runat="server" Text="Odstranit" />
</td>
</tr>

My pochopitelně tuto šablonu musíme reprezentovat pomocí kódu. Místo <%--Editor--%> umístíme instanci naší ItemTemplate a na událost Click tlačítka navěsíme naši vlastní obsluhu události.

Přidejte tedy do naší třídy CollectionEditor novou třídu CollectionEditorRepeaterItemTemplate.

         /// <summary>
/// Šablona pro jeden řádek naší tabulky
/// </summary>
internal class CollectionEditorRepeaterItemTemplate : ITemplate
{
private CollectionEditor editor;

/// <summary>
/// Inicializuje instanci třídy
/// </summary>
public CollectionEditorRepeaterItemTemplate(CollectionEditor editor)
{
this.editor = editor;
}

/// <summary>
/// Vygeneruje konponenty
/// </summary>
public void InstantiateIn(Control container)
{
container.Controls.Add(
new LiteralControl("<tr><td class=\"editor\">"));

// vygenerovat instanci editoru
editor.ItemTemplate.InstantiateIn(container);

container.Controls.Add(
new LiteralControl("</td><td class=\"remove-button\">"));

// vygenerovat tlačítko pro odebrání
var btn = new LinkButton() { Text = editor.RemoveButtonText, CausesValidation = false, ID = "__RemoveButton" };
btn.Click +=
new EventHandler(editor.RemoveItem);
container.Controls.Add(btn);

container.Controls.Add(
new LiteralControl("</td></tr>"));
}
}

Na tomto místě bych upozornil, že zde jsou jisté rezervy v implementaci – rozhodně nejsou ideální ty natvrdo nadefinované CSS třídy editor a remove-button. Komponenta by se dala rozhodně vylepšit tak, že bychom do CollectionEditoru přidali styly pro první a druhý sloupec, které bychom na jednotlivé buňky aplikovali. Proč jsem to přes styly nedělal má dva důvody – zaprvé se mi implementace stylů v ASP.NET moc nelíbí (dělám vše výhradně přes CSS) a zadruhé by byl článek o dost delší, protože správně naimplementovat styly v komponentě není úplně triviální. Tomu se budeme věnovat třeba někdy příště.

Všimněme si, že komponenty obalíme několika instancemi LiteralControl – to je prostě jen text, který se vyrenderuje do stránky. Tlačítku LinkButton nastavuji text z rodičovské komponenty, kterou šablona dostane v konstruktoru, a nastavuji mu CausesValidation na false, aby se pro odebrání položek nepožadovalo vyplnit nejdřív celý formulář.

Vytvoření Repeateru

Nyní se můžeme vrhnout na vygenerování celého Repeateru. Abychom při přidávání nebo odebírání položek uživatele nerozčilovali neustálými PostBacky, dáme celý Repeater dovnitř komponenty UpdatePanel, takže pro změny v rámci naší komponenty se nebude odesílat celá stránka, ale použije se AJAX.

Takto tedy bude vypadat metoda CreateChildControls v naší hlavní třídě.

         /// <summary>
/// Vytvoří komponenty do kolekce Controls
/// </summary>
protected override void CreateChildControls()
{
// vytvořit UpdatePanel
updatePanel =
new UpdatePanel() { ID = "UpdatePanel1", ChildrenAsTriggers = true };
Controls.Add(updatePanel);

updatePanel.ContentTemplateContainer.Controls.Add(
new LiteralControl("<table>"));

// vytvořit repeater, pokud neexistuje
repeater =
new Repeater();
repeater.ID =
"Repeater1";
repeater.ItemTemplate =
new CollectionEditorRepeaterItemTemplate(this);
updatePanel.ContentTemplateContainer.Controls.Add(repeater);

updatePanel.ContentTemplateContainer.Controls.Add(
new LiteralControl("<tr><td colspan=\"2\" class=\"add-button\">"));

// vytvořit tlačítko pro přidávání záznamů
var btn = new LinkButton() { Text = AddButtonText, CausesValidation = false, ID = "AddButton" };
btn.Click +=
new EventHandler(AddItem);
updatePanel.ContentTemplateContainer.Controls.Add(btn);

updatePanel.ContentTemplateContainer.Controls.Add(
new LiteralControl("</td></tr></table>"));
}

Aby to fungovalo, je ještě nutné přidat mimo metodu deklaraci těchto privátních položek třídy:

         private UpdatePanel updatePanel;
private Repeater repeater;

Manipulace s položkami

Nyní nám zbývá naimplementovat tři metody – RestoreItems, která naplní kolekci položek, a dále AddItem, která přidá položku na konec, a konečně metodu RemoveItem, která odebere položku.

Odebírání položky

Metoda RemoveItem je přímým handlerem události kliknutí na tlačítko pro odebrání. Abychom mohli z kolekce items položku smazat, musíme vědět, na kterém je indexu. To musíme zjistit z tlačítka, které dostaneme v události v parametru sender. Jelikož její nejbližší vyšší naming container (část stromu komponent, v jehož rámci musí být ID komponent unikátní) jsou jednotlivé kontejnery šablon uvnitř Repeateru, tedy instance třídy RepeaterItem, přetypujeme si sender na typ Control, odtud si z vlastnosti NamingContainer zjistíme, do které RepeaterItem že patříme, a instance RepeaterItem již má vlastnost, která nám řekne index položky v Repeateru, který je stejný, jak index položky v naší kolekci items (protože kolekce items stavíme podle položek Repeateru).

         /// <summary>
/// Odstraní položku z kolekce
/// </summary>
private void RemoveItem(object sender, EventArgs e)
{
// zjistit index položky
var repeaterItem = (RepeaterItem)((Control)sender).NamingContainer;
int index = repeaterItem.ItemIndex;

// odstranit položku
Items.RemoveAt(index);
}

Abychom měli jistotu, že pracujeme s inicializovanou kolekcí items, používáme vlastnost Items. V případě, že by si o položky nikdo v aktuálním HTTP požadavku neřekl, tak se celá kolekce natáhne až těsně před voláním RemoveAt. Položky musíme mazat z kolekce, přímo z Repeateru to dělat nemůžeme.

Přidání položky

Předtím, než přidáme novou položku do kolekce, ji musíme vytvořit, a to s ohledem na nastavené výchozí hodnoty. Napíšeme si nejprve metodu InitializeItem, která nám vytvoří nový objekt požadovaného typu, a metodu SetItemProperties, která nastaví objektu vlastnosti a hodnoty, které dostane v parametru jako kolekci IOrderedDictionary. Tak je totiž dostaneme z kolekce parametrů i z metody ExtractValues naší šablony.

         /// <summary>
/// Vrátí novou instanci datové položky
/// </summary>
protected virtual object InitializeItem()
{
Type type;
object newItem;

try
{
// získat datový typ
type =
Type.GetType(ItemDataType, true);

// vytvořit instanci třídy
newItem =
Activator.CreateInstance(type);
}
catch (Exception ex)
{
throw new ArgumentException("ItemDataType", ex);
}

// nastavit jí výchozí hodnoty
SetItemProperties(newItem, FieldDefaults.GetValues(
HttpContext.Current, this));

return newItem;
}

Protože datový typ položky známe až za běhu, musíme novou instanci dotyčné třídy vytvořit pomocí Activator.CreateInstance. Na prvním řádku si zjistíme dle názvu typu jeho reprezentaci pomocí .NET třídy System.Type (druhý parametr říká, že pokud se typ nenajde, máme vyhodit výjimku). 

Z celé této akce chytáme výjimky a pokud nastane, oznámíme ven, že chyba je v parametru ItemDataType naší komponenty. Nezapomeneme jako druhý parametr předat původní chycenou výjimku, protože dobré pravidlo je, že pokud už naše obsluha výjimky vyvolává nějakou výjimku, měla by ji zpřesnit a doplnit, rozhodně by neměla zamlčet původní příčinu problému.

Jakmile máme prázdnou instanci vytvořenou, pomocí FieldDefaults.GetValues si vytáhneme kolekci názvů parametrů a hodnot z naší kolekce parametrů FieldDefaults. Na novou položku a tuto kolekci pak poštveme metodu SetItemProperties, která naší instanci nastaví výchozí hodnoty vlastností. Nakonec tuto inicializovanou instanci vrátíme.

Nejčastěji asi budou nastávat dvě chyby – první bude špatně zadaný datový typ třídy a druhá bude problém, že daná třída nemá bezparametrický konstruktor, s nímž počítáme.

Druhý problém by se dal řešit tak, že naší komponentě přidáme událost OnInitializeItem, ve které do předaných EventArgsů vytvoří instanci třídy za nás. Pokud obsluha této události nebude definována, použila by se výchozí logika a vlastnost ItemDataType. To si ale opět můžete dodělat sami, abychom článek zbytečně nenatahovali. Metodu jsem nadeklaroval jako protected virtual, aby se dala kdyžtak přepsat v poděděné třídě.

Nastavení vlastností objektu

Nyní napíšeme metodu SetItemProperties, která pomocí Reflection nastaví objektu hodnoty vlastností. Nemůžeme to pochopitelně udělat přímo, protože datový typ známe až za běhu.

         /// <summary>
/// Nastaví vlastnosti z kolekce danému objektu
/// </summary>
protected void SetItemProperties(object item, IOrderedDictionary properties)
{
// projít všechny vlastnosti
foreach (DictionaryEntry property in properties)
{
// vytáhnout Reflection objekt popisující vlastnost
var propertyInfo = item.GetType().GetProperty((string)property.Key);
if (propertyInfo == null)
throw new MemberAccessException(string.Format("The object of type '{0}' does not have a property '{1}'.", item.GetType(), property.Key));

// zkonvertovat hodnotu na datový typ vlastnosti
object value = Convert.ChangeType(property.Value, propertyInfo.PropertyType);

// přiřadit hodnotu do vlastnosti
propertyInfo.SetValue(item, value,
null);
}
}

Pro každou vlastnost, kterou dostaneme, si vytáhneme popisnou třídu PropertyInfo, která obsahuje informace o vlastnosti a metody, které umí manipulovat s její hodnotou. Pomocí třídy Convert si pak hodnotu převedeme na cílový typ vlastnosti. A na závěr do vlastnosti přiřadíme pomocí volání SetValue.

A samotné přidání položky? Velmi jednoduché:

         /// <summary>
/// Přidá položku do kolekce
/// </summary>
private void AddItem(object sender, EventArgs e)
{
// vytvořit a nainicializovat novou položku
object newItem = InitializeItem();


// přidat ji do kolekce
Items.Add(newItem);
}

Obnovení kolekce Items

Téměř poslední krok, který nám zbývá, je metoda RestoreItems, která nám obnoví kolekci items. Je nutné si uvědomit, že při skončení HTTP požadavku se všechny objekty, které se používaly, zruší. Kolekce items tedy nepřežije PostBack. Naproti tomu položky Repeateru ano, protože Repeater si pamatuje jejich počet a hodnoty, které uživatel do položek vyplnil, případně vlastnosti, které se nastavily kódem, se při PostBacku obnoví z formulářových POST dat a z ViewState. Podle těchto hodnot již jsme schopni obnovit celou naši kolekci.

Metoda RestoreItems bude tedy fungovat takto:

         /// <summary>
/// Obnoví kolekci položek podle hodnot v Repeateru
/// </summary>
private void RestoreItems()
{
// ujistit se, že se již metoda CreateChildControls spustila, a že Repeater již máme vytvořen
EnsureChildControls();

// projít položky Repeateru
items =
new List<object>();
foreach (RepeaterItem item in repeater.Items)
{
// inicializovat položku
object newItem = InitializeItem();

// nastavit jí hodnoty vlastností podle bindingu z šablony
SetItemProperties(newItem, (ItemTemplate
as IBindableTemplate).ExtractValues(item));

// přidat položku do kolekce
items.Add(newItem);
}
}

Nejprve zavoláme metodu EnsureChildControls, což je metoda pocházející z třídy CompositeControl, z níž dědíme. Tím se zajistí, že pokud metoda CreateChildControls ještě nebyla zavolána, zavolá se. Metoda CreateChildControls je další ukázka lazy inicializace – volá se až v okamžiku, kdy je opravdu potřeba. Pozor, nemůžeme ale CreateChildControls zavolat natvrdo, protože by se nám komponenty do kolekce přidaly dvakrát, což rozhodně nechceme.

Jakmile máme jistotu, že Repeater už je vytvořen a nastaven, můžeme pokračovat dál. Jen tak pro informaci, pokud je aktuální požadavek PostBack, Repeater si již v okamžiku přidání do kolekce Controls nadřazeného prvku sáhnul do ViewState a formulářových dat a obnovil všechny své hodnoty vlastností.

Projdeme tedy položky Repeateru, pro každou z nich si necháme postavit novou instanci naší třídy s výchozími vlastnostmi, a pak si vytáhneme z šablony (musíme ji přetypovat na IBindableTemplate) pomocí metody ExtractValues položky (jako parametr jí předáme naming container, ve kterém má položky hledat, což je v tomto případě aktuálně zpracovávaná RepeaterItem). Jakmile položku máme, přidáme ji do kolekce items.

Všimněte si, že na rozdíl od předchozích metod, kde jsme používali kolekci Items (vlastnost), ji zde použít nesmíme! Metodu RestoreItems voláme z getteru vlastnosti Items, pokud bychom tedy sáhli na Items, zacyklili bychom se.

Provázání Repeateru a kolekce položek

Z funkčního hlediska nám zbývá poslední krok – provázat kolekci items a položky Repeateru. Tohle je potřeba udělat pokud možno co nejpozději, rozhodně až poté, co byla kolekce upravena obsluhami tlačítek pro přidávání a odebírání položek. Asi nejvhodnější místo bude událost PreRender.

Přidejte tedy do naší třídy tento override a budeme mít z funkční stránky hotovo:

         /// <summary>
/// Naplní Repeater položkami
/// </summary>
protected override void OnPreRender(EventArgs e)
{
base.OnPreRender(e);

// naplnit Repeater
repeater.DataSource = Items;
repeater.DataBind();
}

Podpora designeru

Přestože designer příliš často nepoužívám, je vhodné každou komponentu, kterou píšeme a u které to má alespoň nějaký smysl, rozšířit o podporu v režimu návrhu. Třídě komponenty přidejte tyto dva atributy:

 ToolboxData("<{0}:CollectionEditor runat=\"server\" ItemDataType=\"\" />"), Designer(typeof(CollectionEditorDesigner)) 

První atribut slouží pro podporu ToolBoxu – obsahuje kód, který se automaticky vygeneruje, pokud přetáhnete komponentu ze soupravy nástrojů do okna kódu. Druhý atribut nám říká datový typ třídy, která slouží pro definici chování designeru komponenty.

Do referencí projektu přidejte assembly System.Design, která obsahuje potřebné třídy. Dále do projektu přidáme designerovou třídu CollectionEditorDesigner, jejíž základní kostra je zde. Třída musí dědit z ControlDesigner, případně ze specifičtější třídy CompositeControlDesigner.

 using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Web.UI.Design;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.Design.WebControls;

namespace MyComponents
{
/// <summary>
/// Designer pro komponentu CollectionEditor
/// </summary>
public class CollectionEditorDesigner : CompositeControlDesigner
{
/// <summary>
/// Vrátí instanci editované komponenty
/// </summary>
private CollectionEditor CollectionEditor
{
get { return (CollectionEditor)Component; }
}

}

}

Podpora editace šablony

První věc, kterou bychom chtěli udělat, je umožnit editaci šablony ItemTemplate v designeru. To uděláme tak, že v metodě Initialize nastavíme flag, který řekne, že tato komponenta má šablony, a dále overridneme vlastnost TemplateGroups, která vrátí kolekci skupin šablon. Naše kolekce bude obsahovat jen jednu skupinu Templates a tato skupina bude obsahovat jednu šablonu ItemTemplate.

Celou hierarchii objektů je třeba nainicializovat jen jednou a to při prvním použití vlastnosti TemplateGroups. Tento kód tedy vložte dovnitř designerové třídy:

         private TemplateGroupCollection templateGroupCollection;
/// <summary>
/// Vrací popis šablon designeru
/// </summary>
public override TemplateGroupCollection TemplateGroups
{
get
{
if (templateGroupCollection == null)
{
// vytvořit popis šablon komponenty
templateGroupCollection =
new TemplateGroupCollection();
var templateGroup = new TemplateGroup("Templates");
templateGroup.AddTemplateDefinition(
new TemplateDefinition(this, "Item", Component, "ItemTemplate", false));
templateGroupCollection.Add(templateGroup);
}
return templateGroupCollection;
}
}

/// <summary>
/// Inicializuje komponentu
/// </summary>
public override void Initialize(System.ComponentModel.IComponent component)
{
base.Initialize(component);

// říct designeru, že má zobrazit editovátko na šablony
base.SetViewFlags(ViewFlags.TemplateEditing, true);
}

Teď by se ještě hodilo zobrazit ukázkové 3 položky v případě, že uživatel šablonu zeditovala vrátil se do standardního režimu, aby mohl vidět, jak to bude vypadat pod sebou. Overridneme tedy metodu GetDesignTimeHtml, kde si nachystáme hierarchii komponent a na konci ji vyrenderujeme jako HTML. Pokud je šablona ItemTemplate nastavena, použijeme ji, pokud není (má hodnotu null), místo ní jen vypíšeme text.

         /// <summary>
/// Vrátí HTML kód, který se zobrazí v designeru
/// </summary>
public override string GetDesignTimeHtml()
{
// nachystat hlavičku
Panel container = new Panel();
container.Controls.Add(
new LiteralControl("<table>"));

// nachystat 3 položky
for (int i = 0; i < 3; i++)
{
container.Controls.Add(
new LiteralControl("<tr><td class=\"editor\">"));

// vyrenderovat šablonu
if (CollectionEditor.ItemTemplate != null)
CollectionEditor.ItemTemplate.InstantiateIn(container);
else
container.Controls.Add(
new LiteralControl("ItemTemplate"));

container.Controls.Add(
new LiteralControl("</td><td class=\"remove-button\">"));

// nachystat tlačítko pro odebrání
container.Controls.Add(
new LinkButton() { Text = CollectionEditor.RemoveButtonText });

container.Controls.Add(
new LiteralControl("</td></tr>"));
}

// nachystat spodní řádek
container.Controls.Add(
new LiteralControl("<tr><td colspan=\"2\" class=\"add-button\">"));
container.Controls.Add(
new LinkButton() { Text = CollectionEditor.AddButtonText });
container.Controls.Add(
new LiteralControl("</td></tr></table>"));

// převést sadu komponent na HTML
using (var textWriter = new System.IO.StringWriter())
using (var htmlWriter = new HtmlTextWriter(textWriter))
{
container.RenderControl(htmlWriter);
return textWriter.ToString();
}
}

Tím by byla podpora design-time editace komponenty hotová. Všimněte si, že pokud si v okně vlastností (v design režimu) najdete vlastnost FieldDefaults, objeví se vedle ní tlačítko se třemi tečkami, které když rozkliknete, objeví se okno s možností editací parametrů. To je způsobeno atributem Editor, o němž jsme si již řekli ve chvíli, kdy jsme tuto vlastnost deklarovali.

Testujeme komponentu

Pro otestování si do projektu přidáme testovací třídu, která bude mít 3 vlastnosti – Title, OrderId a Active. Přepíšeme jí také metodu ToString, což není nutné, ale pro testování se to hodí, když si budeme chtít nějak hezky vypsat kolekci Items naší komponenty.

 using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;

/// <summary>
/// Položka objednávky
/// </summary>
public class OrderItem
{
/// <summary>
/// Název položky
/// </summary>
public string Title { get; set; }

/// <summary>
/// ID objednávky
/// </summary>
public int OrderId { get; set; }

/// <summary>
/// Aktivní
/// </summary>
public bool Active { get; set; }


/// <summary>
/// Vypíše stringovou reprezentaci položky
/// </summary>
public override string ToString()
{
return string.Format("Title: {0}, OrderId: {1}, Active: {2}", Title, OrderId, Active);
}
}

Naše testovací stránka bude vypadat takto:

 <%@ Page Language="C#" %>
<%@ Register Namespace="MyComponents" TagPrefix="my" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<script runat="server">

protected void Button1_Click(object sender, EventArgs e)
{
Label1.Text = string.Join("<br />", CollectionEditor1.Items.Select(i => i.ToString()).ToArray());
}
</script>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
</head>
<body>
<form id="form1" runat="server">
<asp:ScriptManager ID="ScriptManager1" runat="server"></asp:ScriptManager>

<my:CollectionEditor ID="CollectionEditor1" runat="server" AddButtonText="Přidat" ItemDataType="OrderItem">
<FieldDefaults>
<asp:QueryStringParameter Name="OrderId" QueryStringField="id" Type="Int32" />
<asp:Parameter Name="Active" DefaultValue="true" Type="Boolean" />
</FieldDefaults>
<ItemTemplate>
<asp:TextBox ID="TextBox1" runat="server" Text='<%# Bind("Title") %>' />
<asp:CheckBox ID="CheckBox1" runat="server" Checked='<%# Bind("Active") %>' />
</ItemTemplate>
</my:CollectionEditor>

<asp:Button ID="Button1" runat="server" Text="Zkontrolovat obsah kolekce Items" OnClick="Button1_Click"></asp:Button><br />
<asp:Label ID="Label1" runat="server" Text=""></asp:Label>

</form>
</body>
</html>

Pokud se klikne na tlačítko, vytáhne se z komponenty hodnota vlastnosti Items, na všechny položky zavolám ToString a udělám z toho pole. Toto pole pak pomocí funkce String.Join spojím značkami <br />, čímž vygeneruji HTML s textovou reprezentací celé kolekce.

Pokud jste ještě nikdy neviděli konstrukci Select(i => i.ToString()), tak vězte, že se jedná o použití technologie LINQ a funguje to tak, že kolekce Items se projde a pro každou položku (objekt typu OrderItem) se vezme výraz i.ToString(), kde i je právě zpracovávaný objekt. Z těchto výrazů se udělá nová kolekce, na které se zavolá ToArray, čímž se z ní udělá pole. A toto pole, které na i-té pozici obsahuje výsledek volání ToString na i-tý objekt z kolekce Items, se zavolá String.Join, které vezme řetězce z pole, mezi ně naskládá <br /> a vrátí jeden dlouhý string, který přiřadíme do komponenty Label1.

Po kliknutí na tlačítko se tedy zobrazí přesně to, co je v kolekci. Pokud si zobrazíte stránku a do URL přidáte parametr ?id=5 (bez něj to spadne, protože QueryStringParameter nenajde na příslušném parametru číslo a bude se snažit vrátit null, což se nebude líbit naší komponentě). Jak je vidět na obrázku, naklikáme-li kolekci takto, hodnoty položek odpovídají tomu, co v kolekci je. 

Výsledek testování

Závěrem

Tato komponenta nám může velmi značně usnadnit situaci, pokud má uživatel zadávat podřízené záznamy (například položky objednávky) k záznamu, který ještě ani nemusí být v databázi (insertuje se vše najednou po odeslání formuláře). Uživatel si pomocí naší komponenty může naklikat kolekci a až v okamžiku, kdy se rozhodne, že se mu líbí, změny uloží do databáze najednou. Samotná komponenta nic nedělá s databází.

Komponenta má samozřejmě jisté rezervy, dalo by se najít mnoho vylepšení, cílem tohoto článku však je spíš demonstrovat nejběžnější praktiky vývoje komponent než vytvořit 100% kompletní dokonalou superkomponentu™. Dala by se udělat možnost pro umístění tlačítka Přidat nahoru místo dolů, případně nahoru i dolů, dal by se nastavovat minimální a maximální počet položek, dala by se udělat změna pořadí položek atd.

Asi by bylo možné i komponentu naučit spolupracovat s datovými zdroji, ale v tuto chvíli mě nenapadá, jak řešit například editaci kolekce, která ještě nemá podřízení záznamy v databázi. Pokud by kolekce byla třeba ve FormView, jak ji šikovně provázat s událostmi OnInserted a OnUpdated, kde už je známé ID záznamu, pod který položky kolekce budou patřit.

Ukázali jsme si tedy pokročilejší deklarace vlastností a základní atributy, které se při vývoji ASP.NET komponent používají. Vysvětlili jsme si, jak pracovat se šablonami a nahlédli jsme pod pokličku, abychom viděli jak fungují. A v neposlední řadě jsme si ukázali něco málo z toho, jak se dá dělat základní podpora pro design režim. V příštích dílech tohoto seriálu, budou-li nějaké, se podíváme třeba na i v tomto článku zmiňované možnosti skinování, spolupráci s datovými zdroji a další zajímavé věci.

 

hodnocení článku

0       Hodnotit mohou jen registrované uživatelé.

 

Všechny díly tohoto seriálu

 

Mohlo by vás také zajímat

LINQ a Entity Framework - díl 3.: LINQ - Rozhraní Ienumerable, iqueryable a yield return

Genericita, rozhraní a dědičnost

Jazyk C# je multiparadigmatický, což v praxi znamená, že v něm můžeme dělat hodně věcí. Jak ale do sebe jednotlivá paradigma zapadají? Co se hezky doplňuje a co není vzájemně kompatibilní? V tomto článku chci popsat, jak se chová IEquatable vzhledem k dědičnosti typu T.

Visual Studio 2017, .NET Core a nový formát projektů

 

 

Nový příspěvek

 

jQuery

Věděl by někdo, jak na takto dynamicky vytvořený input zavolám jQuery funkci? Konkrétně datepicker(). Id se dá zjistit, ale při jaké události to provést?

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Diskuse: Komponenta pro editaci kolekcí pro ASP.NET

Předem bych chtěl poděkovat za skvělý článek,musim říct,že mne posunul zas o krůček dál..díky :)

Doufám,že seriál bude pokračovat a dovolil bych si navrhnout možnost dalšího tématu a to: Hlasovací komponentu.Myslím,že dnes nechybí v podstatě na žádném webu a tak se hodí všem kdo s ASP.net dělá nebo začíná.

ještě jednou díky..

nahlásit spamnahlásit spam 0 odpovědětodpovědět

Hlasovací komponenta je jeden UpdatePanel s vnitřním obsahem, to by po přečtení tohoto seriálu měl zvládnout každý sám.

nahlásit spamnahlásit spam 0 odpovědětodpovědět

No právě,že je tam to slovíčko "měl",ale jak říkám,byl to jen návrh :) Každopádně super článek

nahlásit spamnahlásit spam 0 odpovědětodpovědět
                       
Nadpis:
Antispam: Komu se občas házejí perly?
Příspěvek bude publikován pod identitou   anonym.

Nyní zakládáte pod článkem nové diskusní vlákno.
Pokud chcete reagovat na jiný příspěvek, klikněte na tlačítko "Odpovědět" u některého diskusního příspěvku.

Nyní odpovídáte na příspěvek pod článkem. Nebo chcete raději založit nové vlákno?

 

  • Administrátoři si vyhrazují právo komentáře upravovat či mazat bez udání důvodu.
    Mazány budou zejména komentáře obsahující vulgarity nebo porušující pravidla publikování.
  • Pokud nejste zaregistrováni, Vaše IP adresa bude zveřejněna. Pokud s tímto nesouhlasíte, příspěvek neodesílejte.

přihlásit pomocí externího účtu

přihlásit pomocí jména a hesla

Uživatel:
Heslo:

zapomenuté heslo

 

založit nový uživatelský účet

zaregistrujte se

 
zavřít

Nahlásit spam

Opravdu chcete tento příspěvek nahlásit pro porušování pravidel fóra?

Nahlásit Zrušit

Chyba

zavřít

feedback