When reading the CRM newsgroups you often find questions like "How can I make JavaScript functions available in all forms" or "Is it possible to change the color of a row in a view". There are two types of answers:
If you install an HTTP module, it is invoked on each request, so after all of the server side processing is done, the response stream is passed to all HTTP modules. After all of them have finished, the final result is passed to the client. Each HTTP module can change the content of the stream, so let's start to build a module allowing us to include additional JavaScript include files and links to cascading style sheets.
Our only subscription is BeginRequest (our OnBeginRequest event handler). Once applied, the OnBeginRequest method is called for every single file requested by a client, containing images, JavaScript files, style sheets, HTML controls and of course ASP.NET pages. We all know that CRM forms are ASP.NET web pages and they all have an extension of ".aspx". Only these must be evaluated and therefore we only apply a filter for .aspx pages.
This configuration file must be placed in /MyConfig/config and must be named config.xml. However you can change that in the Config class:
- Not in any supported way.
- Use an HTTP module.
What is an HTTP module?
An HTTP module is an extension for IIS and it's general purpose is to hook into the stream between IIS and the client to log or modify data. A request to a file on the web server is first served by IIS. It loads the file and if it contains server code (ASP, ASP.NET), the code is executed. Once that's done, the document is sent to the client.If you install an HTTP module, it is invoked on each request, so after all of the server side processing is done, the response stream is passed to all HTTP modules. After all of them have finished, the final result is passed to the client. Each HTTP module can change the content of the stream, so let's start to build a module allowing us to include additional JavaScript include files and links to cascading style sheets.
Implementing the IHttpModule interface
Creating an HTTP module is very easy. All you have to do is
to implement the System.Web.IHttpModule interface. It only has two methods,
Init and Dispose. Unless you have something to dispose, you only need to care
about the Init method. It is called once and serves as the general
initialization routine. You use the context parameter to add event handlers and
they are invoked whenever a request is made. That's said an HTTP module must be
fast, otherwise it may slow down your server dramatically.
Here's the implementation I'm using is this sample:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <summary> | |
/// The HttpModule implementation. | |
/// </summary> | |
public class HttpModule : IHttpModule { | |
public HttpModule() { | |
} | |
/// <summary> | |
/// The Init method is called from IIS. It is used to add handlers for the events we're interested in. | |
/// </summary> | |
/// <param name="context"></param> | |
public void Init(HttpApplication context) { | |
context.BeginRequest += new EventHandler(OnBeginRequest); | |
} | |
/// <summary> | |
/// A resource is requested. | |
/// </summary> | |
/// <param name="sender"></param> | |
/// <param name="e"></param> | |
private void OnBeginRequest(object sender, EventArgs e) { | |
HttpContext context = ((HttpApplication) sender).Context; | |
string queryString = context.Request.QueryString.ToString(); | |
if (queryString.Length == 0) { | |
Log.Write(context.Request.Path); | |
} | |
else { | |
Log.Write(context.Request.Path + "?" + queryString); | |
} | |
if (context.Request.Path.ToLower().EndsWith(".aspx")) { | |
//We only parse the content of files with the aspx extension. This prevents us | |
//from checking image files, style sheets or SOAP documents. | |
context.Response.Filter = new CrmFilter(context.Response.Filter, context.Request); | |
} | |
} | |
public void Dispose() { | |
//we don't have disposable objects but need to implement this method (part | |
//of the IHttpModule interface) | |
} | |
} |
Our
only subscription is BeginRequest (our OnBeginRequest event handler). Once
applied, the OnBeginRequest method is called for every single file requested by
a client, containing images, JavaScript files, style sheets, HTML controls and
of course ASP.NET pages. We all know that CRM forms are ASP.NET web pages and
they all have an extension of ".aspx". Only these must be evaluated
and therefore we only apply a filter for .aspx pages.
What is a filter?
A
filter (the context.Response.Filter) is a Stream. It is not a standard stream,
like a FileStream or a MemoryStream. It is a class deriving from the abstract
Stream class. If a filter is applied, the stream operations are passed to the
filter instead of using the response stream directly.
Here's
the interesting part of the CrmFilter class:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class CrmFilter : Stream { | |
const string ReqSpace = @"\s+"; | |
const string Argument = @"(\w+)"; | |
const string Quote = "\\\""; | |
//RegEx pattern to search the form object type | |
const string Pattern = "<input" + ReqSpace + "type=" + Quote + "hidden" + Quote + ReqSpace + "id=" + Quote + "crmFormSubmitObjectTypeName" + Quote + ReqSpace + "value=" + Quote + Argument + Quote + ">"; | |
StringBuilder _buffer = new StringBuilder(); | |
Stream _stream; | |
bool _isHtmlFile; | |
HttpRequest _request; | |
/// <summary> | |
/// Initializes a new filter. | |
/// </summary> | |
/// <param name="stream">The response stream containing the original CRM page.</param> | |
/// <param name="request">The ASP.NET request object.</param> | |
public CrmFilter(Stream stream, HttpRequest request) { | |
_stream = stream; | |
_request = request; | |
} | |
/// <summary> | |
/// The main purpose of this Write implementation is to inject code into the response stream. | |
/// To know what to inject, we have to parse the HTML document. | |
/// </summary> | |
/// <param name="buffer">A chunk of data.</param> | |
/// <param name="offset">The offset of the data inside the CRM page.</param> | |
/// <param name="count">The number of bytes.</param> | |
public override void Write(byte[] buffer, int offset, int count) { | |
//If we already know that the content passed to the client is not a HTML document, | |
//we directly write the buffer to the client. | |
if ((offset > 0) && !_isHtmlFile) { | |
_stream.Write(buffer, offset, count); | |
return; | |
} | |
//To analyze the data, we have to copy it to a buffer first. | |
_buffer.Append(UTF8Encoding.UTF8.GetString(buffer, offset, count)); | |
//Now convert it to an HTML string. | |
string html = _buffer.ToString(); | |
//If this is the first data sent to the client, we're going to check if it is an | |
//HTML document. This simply is done by looking for the <html> tag. Though not | |
//totally correct, it has worked so far. | |
if (offset == 0) { | |
//Check for the <html> tag | |
Match match = new Regex("<html>", RegexOptions.IgnoreCase).Match(html); | |
//If the <html> tag was found, we will buffer the entire stream until we also | |
//found the </html> tag. | |
_isHtmlFile = match.Success; | |
} | |
//Again, if this is not an HTML document, send the buffer to the client. | |
if (!_isHtmlFile) { | |
_stream.Write(buffer, offset, count); | |
return; | |
} | |
//As an HTML document usually ends with the </html> tag, we assume that the entire | |
//document is loaded when we find it. | |
Match htmlMatch = new Regex("</html>", RegexOptions.IgnoreCase).Match(html); | |
//If we found the </html> tag | |
if (htmlMatch.Success) { | |
//Find the </head> element | |
Match headMatch = new Regex( "</head>", RegexOptions.IgnoreCase).Match(html); | |
if (headMatch.Success) { | |
//Find the entity type name. This will be set if it is a CRM form, but not in views. | |
Match entityMatch = new Regex(Pattern, RegexOptions.IgnoreCase).Match(html); | |
string entityTypeName = null; | |
if (entityMatch.Success && (entityMatch.Groups != null) && (entityMatch.Groups.Count > 0)) { | |
entityTypeName = entityMatch.Groups[0].ToString(); | |
for(int i = 1; i < entityMatch.Groups.Count; i++) { | |
string text = entityMatch.Groups[i].ToString(); | |
if (text.Length < entityTypeName.Length) { | |
entityTypeName = text; | |
} | |
} | |
} | |
if (entityTypeName != null) { | |
Log.Write("Found entity " + entityTypeName); | |
} | |
//Get the HTML to inject. | |
string includesHtml = Config.GetHead(entityTypeName); | |
if (includesHtml != null) { | |
//If there is something to inject, place it right before the </head> tag. | |
_buffer.Insert(headMatch.Index, includesHtml); | |
} | |
} | |
//Convert the html text to an UTF8-encoded byte array and send it to the client. | |
byte[] data = UTF8Encoding.UTF8.GetBytes(_buffer.ToString()); | |
//That's it. The html code now contains additional javascript include files or style sheets. | |
_stream.Write(data, 0, data.Length); | |
} | |
} | |
} |
What to include?
The easiest implementation would be to include a single link to a JavaScript file in all ASP.NET pages. However I thought it would be better to differentiate a bit and added a very simple configuration file:
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?xml version="1.0" encoding="utf-8" ?> | |
<Config xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"> | |
<All> | |
<Include href="/MyConfig/config/css/styles.css" type="css" /> | |
</All> | |
<AllEntities> | |
<Include href="/MyConfig/config/scripts/global.js" type="js" /> | |
</AllEntities> | |
<Entities> | |
<Entity name="account"> | |
<Includes> | |
<Include href="/MyConfig/config/scripts/account.js" type="js" / --> | |
</Includes> | |
</Entity> | |
</Entities> | |
</Config> |
string configFilePath = webSitePath + "MyConfig\\config\\config.xml";
The above configuration file defines the following:
- Include files listed in the All section are added to all HTML files. In the sample configuration above, the following line is injected into the head of the html document: <link rel="stylesheet" type="text/css" href="/MyConfig/config/css/styles.css">.
- Include files listed in the AllEntities section are added to all CRM forms. That's the reason why I'm looking for the entity type name when parsing the HTML content. In the sample configuration above, the following line is injected into the head of the html document:<script language="javascript" src="/MyConfig/config/scripts/global.js" />
- Finally, include files listed in the Entities section are added only if the HTML document is a CRM form of the specified type. In the sample configuration above, the following line is injected: <script language="javascript" src="/MyConfig/config/scripts/account.js" />.
You
can add as many entries as you like. The xml structure simply is a serialized
Config class and the "Current" accessor deserializes it. Feel free to
change it to your needs. Also note that I have included some very basic
logging. By default it logs to C:\CRMModule\log.txt and only when running the
debug version. You can change this in the Log class.
How to install it
You have to place the assembly into two locations on your CRM server:
- /bin
- /mscrmservices/bin
If you have modified the web to contain additional bin directories, you may have to copy them in there too. As an alternative you can add the assembly to the GAC. For this reason I added a strong name to the assembly, as unsigned assemblies are not allowed in the GAC.
Finally, open the web.config in the CRM root and make the highlighted changes:
<system.web>
...
<httpModules>
<add name="MyConfig.Crm.HttpModule" type="MyConfig.Crm.HttpModule, MyConfig.Crm.HttpModule, Version=1.0.0.0, Culture=neutral, PublicKeyToken=1ed2e80199a69f48"/>
</httpModules>
</system.web>
...
<httpModules>
<add name="MyConfig.Crm.HttpModule" type="MyConfig.Crm.HttpModule, MyConfig.Crm.HttpModule, Version=1.0.0.0, Culture=neutral, PublicKeyToken=1ed2e80199a69f48"/>
</httpModules>
</system.web>
Note
that there may be two system.web sections, one for the main CRM application and
one for the reporting services, which is identified by the following xml
element:
<location
path="Reports">
Be
sure to add the module to the main CRM configuration and do not put it into the
reports section.
And finally: How to test it
Create
a new JavaScript file and add the following function:
function MyConfig_Test(message)
{
alert(message);
}
alert(message);
}
Save
it at /MyConfig/config/scripts/global.js.
You
can of course use any name for your function, but to not conflict with
functions in other include files, I'm using a unique prefix. I leave it up to
you to follow that or not.
Use
the following configuration file (/MyConfig/config/config.xml):
<?xml version="1.0" encoding="utf-8"
?>
<Config xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<All/>
<AllEntities>
<Include href="/MyConfig/config/scripts/global.js" type="js" />
</AllEntities>
<Entities/>
</Config>
<Config xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<All/>
<AllEntities>
<Include href="/MyConfig/config/scripts/global.js" type="js" />
</AllEntities>
<Entities/>
</Config>
Open
a CRM form and add the following to your OnLoad event:
MyConfig_Test("It works!");
If it doesn't work, try
resetting IIS. If it indeed does work and you come across some cool functions,
I would love to see them to build some kind of function repository that
everyone can download (of course the poster will get full credits for it).