Web Controls como si estuviese en segundo.

 


A veces, en nuestros desarrollos ASP.NET nos encontramos con partes comunes en un proyecto que, como todos sabemos, encapsulamos en UserControls rapidamente y sin complicaciones. Pero cuando queremos traspasar las fronteras del proyecto y/o hacer algo más grande, reutilizable o novedoso, la cosa se complica ya que nos habremos metido en el apasionante y complicado mundo de los controles  Web. En este primer post intentaré plasmar lo que he aprendido en este campo ya que lo que he encontrado por ahí no me llega a convencer.



Como punto de partida decir que si queremos ser con «Web Control Developer» necesitamos, evidentemente, conocimientos del viejo HTML, manejo en el arcano mundo JavaScript y como no, conocimientos de la plataforma .NET.


 



  1. Punto de partida

La ayuda de las msdn ofrece una guía más o menos completa sobre este tema, llamada Developing Custom ASP.NET Server Controls aunque un poco desorganizada desde mi punto de vista. Tambien ayuda la plantilla de Visual Studio llamada ASP.NET Server Control, aunque menos.



 


Como el movimiento se demuestra andando, vamos a ver como realizar un control más o menos complejo, alejado del típico hola mundo que muestran todas las guias. 


2. Definición del control 


En vez de definir un control de cero, vamos a intentar  portar algo ya hecho, algo vistoso, bonito y que no necesite mucha explicación y es aquí cuando entra en escena DHTML Suite. Esta Suite ofrece una colección de controles importantes, todos ellos realizados con JavaScript, que bajo la excusa de AJAX ofrece un autentico FrameWork para realizar cosas vistosas. Una demo de lo que es capaz de hacer la tenéis aquí.


 De toda la lista de controles el que vamos a «encapsular» en forma de control ASP.NET es el InfoPane, un control que muestra una columna a la izquierda del navegador donde mostrar categorías de enlaces o información y la posibilidad de plegar y desplegar dichas categorías.




 


Si queréis examinarlo deberéis de bajar la suite desde este enlace y descomprimirlo. El ejemplo se encuentra en la ruta dhtml-siutedemosdemo-info-pane1.html. Si abrís esa página el navegador os avisara que ha bloqueado el contenido, por medidas de seguridad. Es necesario permitirle que abra completamente la página para ver el panel (el código javascript lo podéis auditar y no es malicioso, para los más escépticos).


 Vamos a ver que código tiene la página de ejemplo abriéndola con nuestro editor preferido, lo importante es esto:


 


<script type=»text/javascript» src=»../js/ajax.js»></script>
<script type=»text/javascript» src=»../js/dhtml-suite-for-applications.js»></script>

</head>

<body>            
    
<div class=»DHTMLSuite_xpPane»>
        
<div id=»pane1″>
                  
<div>ContenidoPane1</div>    
            
</div>
            
<div id=»pane2″>
                  
<div>ContenidoPane2</div>          
            
</div>
            
<div id=»pane3″>
                  
<div>ContenidoPane3</div>          
            
</div>
      
</div>
      
<div id=»otherContent»>
            …….
      
</div>
<script type=»text/javascript»>
var infoPane = new DHTMLSuite.infoPanel();
DHTMLSuite.commonObj.setCssCacheStatus(true);
infoPane.addPane(‘pane1’,‘Panel 1’,500,‘cookie_pane1’);
infoPane.addPane(‘pane2’,‘Panel 2’,500,‘cookie_pane2’);
infoPane.addPane(‘pane3’,‘File and folder tasks’,500,‘cookie_pane3’);
InfoPane.init();
</
script>
….. 

 


 En primer lugar tenemos la carga de los js con el código de DHTML. En otra parte de la página tenemos un DIV con class=»DHTMLSuite_xpPane» y dentro una serie de DIVS con id concreto. Por ultimo otro javascript con la inicialización del control.



En dicha inicialización es importante recalcar que la nueva instancia detectará el DIV con class=»DHTMLSuite_xpPane» sin necesidad de pasarle un ID en el constructor. También hay que fijarse en que por cada DIV «hijo» hay que hacer un addPane para añadirlo a la colección de paneles hijos que se mostrará en el control, que a partir de ahora llamaremos InfoPane. ¿Como encapsulamos todo esto dentro de un control (web) ASP.NET?


 Mi experiencia personal me dice que en este tipo de controles es muy necesario tener claro de antemano como vamos a querer el control y que funcionalidad le vamos a dar, ya que puede ser un poco tedioso el desarrollo y, sobretodo, el cambio. No basta con tener claro como se realizan controles, aunque ayuda bastante. De ahí la motivación a la hora de escribir este texto. Pensemos en el diseño del control, antes incluso de saber como se hacen.


 Diseño de InfoPane


Siguiendo la filosofía ASP.NET es evidente que la idea es tener un control que se use o que se escriba con un código ASPX más o menos así:


<cc1:InfoPaneContainer ID=»InfoPaneContainer1″ runat=»server» CssUrl=»/Css/info-pane.css»>
   
<cc1:InfoPaneElement runat=»server» ID=»infoPaneElement1″>
   …contenido dentro del subpanel 1….
   
</cc1:InfoPaneElement>
   
<cc1:InfoPaneElement runat=»server» ID=»infoPaneElement2″>
   …contenido dentro del subpanel 2….
   
</cc1:InfoPaneElement>
</cc1:InfoPaneContainer>



 Sin necesidad de escribir tedioso JAVASCRIPT o HTML adicional. También nos podría ocurrir que en cualquier parte del código trasero necesitásemos algo así:


InfoPaneElement e=new InfoPaneElement();


InfoPaneContainer1.AddPanel(e);


Es decir, necesitamos que admita instancias DECLARATIVAS en ASPX o imperativas dentro del código C#. Para empezar ya sirve la idea y nos podemos poner manos a la obra aunque evidentemente siempre podremos añadir más funcionalidad como decirle el tamaño de los InfoPaneElements, visibilidad de los mismos, o lo que se nos ocurra.


Resumiendo, tenemos que programar, no uno, sino dos controles, uno llamado InfoPaneElement y otro llamado InfoPaneContainer.


 


Programando el control


No voy a explicar como funcionan en profundidad los controles ni su arquitectura ya que existe bastante literatura al respecto. Lo único que hay que tener claro es como funciona el ciclo Render en cada post-Back y el VIEWSTATE. Básicamente, en el evento Render de un control podemos escribir código HTML que se enviara directamente al output y se ejecuta cada vez que hay un PostBack. El ViewState es bastante conocido y no necesita explicación. Al lio, ejecutemos el Visual Studio (en mi caso VS2008 beta 2).


 Lo primero que necesitamos es una solución con dos proyectos, uno de librería de controles y otro ASP.NET de los de toda la vida para probar el control mientras lo desarrollamos. La aplicación de prueba necesitará una referencia al proyecto libreriaControles, ahora sería un buen momento para hacerlo.




 En LibreríaControles añadiremos dos ficheros cs, uno para cada tipo de control que vamos a programar, yo los llamaré, en una muestra de originalidad, InfoPaneElement e InfoPaneContainer. Para simplificarnos la tarea, podremos hacer un copia-pega del código que viene en el fichero-control de ejemplo o usar la plantilla correspondiente. Ahora toca programár


Codificación de InfoPaneElement


Empezaremos por el contenedor. Abrimos el fichero InfoPaneContainer.cs y, después de heredar de WebControl tendremos que modificar el método RenderContents para escribir en el documento html las etiquetas de inicio y cierre del control, además del código Javascript que lo inicializa, aunque si consultamos un poco los métodos disponibles, mejor realizar estas tareas en los métodos RenderBeginTag y RenderEndTag, dejando el RenderContents para el contenido en sí del control, algo así:


 


protected override void RenderContents(HtmlTextWriter output)
{
    RenderElements(output)
;    
}

public override void RenderBeginTag(HtmlTextWriter writer)
{
    writer.Write(
«<div class=»DHTMLSuite_xpPane»>»);    
}

public override void RenderEndTag(HtmlTextWriter writer)
{
    writer.Write(
«</div>»);
    
writer.Write(RenderInitScript());
}

El método que se hace referencia, renderElements, se encarga de renderizar los «subcontroles» contenidos en el InfoPaneContainer. A su vez, RenderInitScript se encarga de renderizar el código Javascript necesario para que funcione el control, tal como vimos anteriormente. La pregunta del millón es ¿Cómo sabes que subcontroles tiene el control? Y lo más importante, ¿Cómo hacer que el control PUEDA tener subcontroles anidados?


 


Para responder a las preguntas es necesario saber que ASP.NET, para interpretar el código aspx, dispone de un parseador como cualquier otro compilador o interprete. Por tanto, si nuestro control va a contener subcontroles propios, hay que indicarle que parsee nuestros controles hijos o, en nuestro caso, que parsee los InfoPaneElement ¿Cómo?, con un atributo (como no) llamado ParseChildren. Se usa así:


namespace LibreriaControles
{
    [ToolboxData(
«<{0}:InfoPaneContainer runat=server></{0}:InfoPaneContainer>»)]
    [ParseChildren(
true«InfoPaneElements»)]
    
//[ParseChildren(false)]  
    
public class InfoPaneContainer : WebControl
    {
        
private ArrayList _infoPaneElements;
        
[Category(«Appearance»)]
        [DefaultValue(
«»)]
        [Localizable(
false)]
        
public ArrayList InfoPaneElements
        {
            
get
            
{
                
if (_infoPaneElements == null) _infoPaneElements = new ArrayList();
                return 
_infoPaneElements
            
}
        }

Al atributo ParseChildren le pasamos el atributo true para activarlo y luego una propiedad ICollection que es donde dejará el motor ASP.NET las instancias de nuestros controles hijos. Yo he escogido ArrayList como el tipo de la lista, pero lo conveniente sería tener una lista tipada para evitar problemas.


Por tanto en la lista InfoPaneElements tendremos todas las instancias de los controles hijos. Veamos como usarla. Antes nos hemos quedado en el método RenderElements, que es un método propio, veamos la implementación:


        private void RenderElements(HtmlTextWriter output)
        {
            
foreach (InfoPaneElement e in this.InfoPaneElements)
            {
                e.RenderControl(output)
;
            
}
        }


Recorremos la lista de métodos hijos e invocamos su render, para que ellos mismos se auto dibujen. En principio, la inercia y los ejemplos nos llevan a realizar el render del objeto hijo en este método, pero es conveniente que cada control se renderize a si mismo.


Tambien nos quedaba pendiente el método RenderInitScript() el cual nos generará el javascript de incialización:


        private string RenderInitScript()
        {
            StringBuilder output 
= new StringBuilder();
            
output.Append(«<script type=»text/javascript»>»);
            
output.Append(«var infoPane = new DHTMLSuite.infoPanel();»);
            
//output.Append(«DHTMLSuite.commonObj.setCssCacheStatus(true);»);
            
foreach (InfoPaneElement e in this.InfoPaneElements)
            {
                output.AppendFormat(
«infoPane.addPane(‘{0}’,'{1}’,500,’cookie_{0}’);»,e.ID, e.Text);
            
}
            output.Append(
«infoPane.init();»);
            
output.Append(«</script>»);
            return 
output.ToString();
        
}

Como vemos, la filosofia es la misma, recorremos la colección de controles, y para cada control, hacemos lo que haya que hacer. En este caso esto no se ha delegado al control ya que esta inicalización necesitamos hacerla de todos a la vez para mantenerlo todo junto y organizado en el código javascript resultante.


Otra cosa que le podemos agregar al control son propiedades que se inicialicen declarativa o imperativamente.


<cc1:InfoPaneContainer ID=»InfoPaneContainer1″ runat=»server» CssUrl=»~/Css/info-pane.css»>

En este caso vamos a agregarle esta propiedad llamada CssUrl. Dos partes, la propiedad con sus atributos para hacerla «bonita» en el visual Studio y el método que la va a renderizar.


[Category(«Appearance»)]
[DefaultValue(
«»)]
[Localizable(
false)]        
public Uri CssUrl
{
    
get
      
{
          
if (ViewState[«CssUrl»] != null)
              
return (Uri)ViewState[«CssUrl»];
            else
              return null;
      
}
      
set
      
{
          ViewState[
«CssUrl»= value;
      
}
}
private string RenderCssLink()
{
    
if (CssUrl == null)
          
throw new InvalidOperationException(«Falta la url de las css»);
      else
            return 
String.Format(«<link rel=»stylesheet» href=»{0}» media=»screen» type=»text/css»/>{1}» , CssUrl, Environment.NewLine);
}

Lo más importante para destacar aquí es que si no está establecida la Cssurl levantaremos una excepción, no dejaremos que este error traspase la frontera y se convierta en un error javascript. Quizás InvalidOperationException no sea el tipo adecuado, pero para nuestro control, sobra. Tambien como usaremos el ViewState para guardar el estado de dicha propiedad. Si no lo hiciesemos, los cambios que hiciesemos por código no se verian reflejados en cada PostBack.


 


¿Cuándo se debe renderizar esta propiedad? En el RenderContet no porque debe ir en la cabecera, en el beginTag tampoco. Su sitio es el OnPreRender


 protected override void OnPreRender(EventArgs e)
{
    
this.Page.Header.Controls.Add(new LiteralControl(RenderScript()));
      this
.Page.Header.Controls.Add(new LiteralControl(RenderCssLink()));
}

RenderScript es el método que hará los «includes» del código fuente de Dhtml Suite


        private string RenderScript()
        {
            
//string scriptUrlInfoPane = Page.ClientScript.GetWebResourceUrl(typeof(LibreriaControles.InfoPaneContainer), «LibreriaControles.dhtmlSuite-infoPanel.js»);
            //string scriptUrlCommon = Page.ClientScript.GetWebResourceUrl(typeof(LibreriaControles.InfoPaneContainer), «LibreriaControles.dhtmlSuite-infoCommon.js»);
            //string scriptUrlDynamicContent = Page.ClientScript.GetWebResourceUrl(typeof(LibreriaControles.InfoPaneContainer), «LibreriaControles.dhtmlSuite-dynamicContent.js»);

            
string scriptUrlInfoPane «/Js/dhtmlSuite-infoPanel.js»;
            string 
scriptUrlCommon «/Js/dhtmlSuite-common.js»;
            string 
scriptUrlDynamicContent «/Js/dhtmlSuite-dynamicContent.js»;


            
StringBuilder sb = new StringBuilder();
            
sb.AppendFormat(«<script type=»text/javascript» src=»{0}»></script>{1}», scriptUrlCommon, Environment.NewLine);
            
sb.AppendFormat(«<script type=»text/javascript» src=»{0}»></script>{1}», scriptUrlDynamicContent, Environment.NewLine);
            
sb.AppendFormat(«<script type=»text/javascript» src=»{0}»></script>{1}», scriptUrlInfoPane, Environment.NewLine);           
            
            return 
sb.ToString();
        
}

Os dejo comentado, para los curiosos, la forma que habría que  usar para extraer el código Javascript del ensamblado. Y esta comentado precisamente porque con estas librerias de DhtmlSuite, no funciona pero esto ya es otro tema.


 


Ahora toca codificar el InfoPaneElement.


 


InfoPaneElement


 


Este es sencillo:


namespace LibreriaControles
{
    [ToolboxData(
«<{0}:InfoPaneElement runat=server></{0}:InfoPaneElement>»)]
    [Serializable]
    [ParseChildren(
false)]
    
public class InfoPaneElement :WebControl
    {
        
string _text «»;
        public string 
Text
        {
            
get
            
{
                
return _text;
            
}   
            
set
            
{
                _text 
= value;
            
}
        }
        
protected override void RenderContents(HtmlTextWriter writer)
        {
            
foreach (Control wb in this.Controls)
            {
                wb.RenderControl(writer)
;
            
}
            
        }
 
        
public override void RenderBeginTag(HtmlTextWriter writer)
        {
            writer.Write(
«<div id=»{0}»><div>»this.ID);
        
}

        
public override void RenderEndTag(HtmlTextWriter writer)
        {
            writer.Write(
«</div></div>»);
        
}
    }
}

Destacar el ParseChild(false), necesario si queremos que nuestro control pueda contener otros controles como labels, calendars y demas. Recordemos que parseChild solo se inicializa con true si queremos parsear nuestros propios controles. La propiedad Text no es el texto del control, es el título del control y se usará en el método RenderInitScript de InfoPaneContainer.


 


Prueba de los controles


 


Para probar los controles en el proyecto Web, página Default.aspx los usamos tal que así:


 


<%@ Page Language=»C#» AutoEventWireup=»true» CodeBehind=»WebForm1.aspx.cs» Inherits=»TestControls.WebForm1″ %>

<%@ Register Assembly=»DhtmlSuiteControls» Namespace=»LibreriaControles» TagPrefix=»cc1″ %>

<!DOCTYPE html PUBLIC «-//W3C//DTD XHTML 1.0 Transitional//EN» «http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd»>

<html xmlns=»http://www.w3.org/1999/xhtml» >
<head runat=»server»>
    
<title>Untitled Page</title>
</head>
<body style=»margin:0px»>
    
<form id=»form1″ runat=»server»>
    
<div>
        
<cc1:InfoPaneContainer ID=»InfoPaneContainer1″ runat=»server» CssUrl=»~/Css/info-pane.css»>
            
<cc1:InfoPaneElement ID=»pane1″ runat=»server» Text=»Aaaaaa»>
                
<div>
                    
<a href=»fgdfg»>dfgdfgdfgdfg</a>
                    salida sin control
                    
<asp:Label ID=»milabel» runat=»server»> contenido label</asp:Label>
                    
<asp:Button ID=»miboton» runat=»server» />
                </
div>
            
</cc1:InfoPaneElement>
            
<cc1:InfoPaneElement ID=»InfoPanecontainer2″ runat=»server» Text=»Titulo B»>
                Texto del InfoPaneelement
            
</cc1:InfoPaneElement>
        
</cc1:InfoPaneContainer>
    
</div>
    
</form>
</body>

Y la salida correspondiente:



 


 


Cosas para mejorar y conclusión


 


Una de las cosas que más complicado se puede llegar a hacer es el control de los script. En este ejemplo esta MUY mal gestionado y lo ideal sería usar ScriptManagers para controlar su cacheo y administración en general (recordar que para que funcione es necesario copiar los archivos que incluimos en el proceso RenderScript). Eso lo dejaremos para otra entrada. También existe un tema bastante interesante y es que podemos crear un gestor de propiedades invocable desde el panel de propiedades de visual Studio ( estilo Items de un ListBox). En este ejemplo vendría bien para gestionar los InfoPaneElements que contiene un InfoPaneContainer.


En conclusión, hemos visto que hacer un control re-usable en varios proyectos no es muy difícil (sobre todo si nos dan el javascript como en este ejemplo ;)) y sin embargo, la encapsulación y control que te da sobre la aplicación Web es muchísimo más potente que los controles de usuario Web. Si lo quereis probar, adjunto un zip con todo el proyecto para Visual Studio 2008 RTM


P.D: Agradecimientos a Jesus Jimenez Antelo de Ilitia. Realmente todo el trabajo es suyo 😉


P.D. v2: Aprovecho mi primera entrada para presentarme a Geeks.ms y dar las gracias Rodrigo Corral por proporcionarme este blog. Espero que os haya resultado interesante. Los comentarios, correciones y criticas son bienvenidos.