Happy New Year!
Dynamics CRM has fantastic localisation support and multiple language user interfaces is no exception to that rule. Installable language packs provide translations for out-of-the box labels, whilst customizers are able to translate their own labels for the following elements:
- Field Labels
- Option Set Values
- Entity Display Names
- User Interface Messages
- View Names
- Ribbon Button Labels
- Descriptions
More information about this can be found at Customize Labels to Support Multiple Languages, but the purpose of this post is to provide a suggested solution to localising the only item that is missing from this list: Lookup Record Display Names
If you had two sales teams that spoke only English or German and they wanted to share the same products, there currently is no way of showing the product name field in the sales persons own language without duplicating product records.
My suggested solution is to write a plugin to intercept the results returned from the database when queried and insert the correct language into the name field.
The example I show below works for Products and Opportunity Products, but it could equally be applied to other entities as well.
Key design objectives were:
- Optimise for performance by minimising database queries and caching where the same data where possible.
- Allow translation of the Product Name attribute of the Product Entity.
- Show the Product Name on the Opportunity Product form in the user's language.
- Show the Product Name in Lookup Views in the user's language
- Show the Product Name title on the Product form in the user's language.
- Support translated Product Name fields in reports
- Allow easy translation of products into multiple languages an export/import process.
Solution Summary
The technique adds an attribute for each name translation on the product form, and then intercepts Retrieve and Retrieve-Multiple pipeline steps in order to substitute the name field for the correct translation.
1) An attribute for each name translation is added to the Product entity
2) When the product is updated, each translated is packed into the 'name' field via the 'Create' and 'Update' pipeline steps. The name attribute is increased in length to hold 4000 characters to accommodate all the translated names. This is done so that all translations are available when querying the name attribute without going back to the database.
new_name_en: "Red Shoes"
new_name_de: "Rote Schuhe"
name: "Red Shoes,Rote Schuhe"
Each time the name attribute is retrieved, we can always re-write it to include the language that we need without going back to the database to get the new_name_en or new_name_de field values.
Note: A limitation of the primary name field is that it has a maximum length of 4,000 characters. The product name is included in a SQL index and so cannot be more than 450 characters (900 bytes). Due to the nature of this technique, we must be able to fit all our translations into these characters, so if we have 5 languages, then each name cannot be more than 90 characters long – since the default Name length is 100 characters, this doesn't seem that unreasonable – especially if you have less than 5 languages. If you are using this technique on a custom entity attribute, then you can use the full 4000 characters.
3) When the Products are queried (via Lookup dialogs or Advanced Find) the results are changed so that the name field only contains the translation that matches the language of the user.
4) When the Opportunity Product is Retrieved, the name field is re-written to only contains the translation that matches the language of the user.
5) The user's selected language is looked up in the UserSetting entity via the UILanguageId attribute. This contains an LCID that is used to match against the correct translated label.
Solution Steps
First we need to create a project to hold the plugin:
1) Create a Plugin Solution using the CRM Developer Toolkit found in the CRM2011 SDK.
2) Connect to your CRM server and select a solution to add your plugin to.
3) Select the Plugin and Workflow project in turn and open the properties window. Select 'Signing' and check 'Sign the assembly' before selecting '<New…>'. You will be prompted to give your key a name and password. I usually use the name of the project as a name for the key.
4) Open the CRM Explorer window and double click 'Product' to open the Product entity configuration page.
5) Select the 'name' attribute, and change its 'Maximum Length' to 450 characters, and the required level to 'No Constraint'
6) We need to create some attributes to hold the translated names of the products. I am only creating English and German, but you can create however many you need.
Display Name
|
Schema Name
|
Type
|
Name (English)
|
new_name_en
|
Single Line of Text (100)
|
Name (German)
|
new_name_de
|
Single Line of Text (100)
|
7) Add the two fields to the Product Form as well, and un-check 'Visible by default' on the 'Name' field.
8) Publish the customisations.
9) In your Visual Studio Project, add a new Class named 'MultiLanguageProductPlugin'
10) Paste the following code into your class.
IMPORTANT: Change the namespace to match your project namespace
// Scott Durow
// 1/2/2013 7:55:23 PM
// Multi Language Support for Lookups
namespace Develop1.Plugins
{
using System;
using System.ServiceModel;
using Microsoft.Xrm.Sdk;
using System.Text;
using Microsoft.Xrm.Sdk.Query;
///
/// Plugin to support Multi Language Product Names
///
public class MultiLanguageProductPlugin: Plugin
{
private readonly string preImageAlias = "PreImage";
private readonly string[] languages = new string[] { "en", "de" }; // Languages Supported
private readonly int[] locales = new int[] { 1033, 1031 }; // LCIDs of each language in the languages array
///
/// Initializes a new instance of the class.
///
public MultiLanguageProductPlugin()
: base(typeof(MultiLanguageProductPlugin))
{
// Registrations for Packing each translation field into the name field
base.RegisteredEvents.Add(new Tuple>(20, "Create", "product",
new Action(PackNameTranslations)));
base.RegisteredEvents.Add(new Tuple>(20, "Update", "product",
new Action(PackNameTranslations)));
// Registrations for unpacking the name field on Retrieve of Products
base.RegisteredEvents.Add(new Tuple>(40, "Retrieve", "product",
new Action(UnpackNameOnRetrieve)));
base.RegisteredEvents.Add(new Tuple>(40, "RetrieveMultiple", "product",
new Action(UnpackNameOnRetrieveMultiple)));
// Registrations for unpacking the name field on related Opportunity Products
// NOTE: You could add registratons for Quotes, Orders, Price Lists here...
base.RegisteredEvents.Add(new Tuple>(40, "Retrieve", "opportunityproduct",
new Action(UnpackNameOnRetrieveRelated)));
base.RegisteredEvents.Add(new Tuple>(40, "RetrieveMultiple", "opportunityproduct",
new Action(UnpackNameOnRetrieveMultipleRelated)));
}
///
/// Pack the translations into the name field when a Product is Created or Updated
/// Each translated name is packed into a comma separated string
/// This field is unpacked when the product entity is retrieved or related records are retrieved
///
protected void PackNameTranslations(LocalPluginContext localContext)
{
IPluginExecutionContext context = localContext.PluginExecutionContext;
// Pack the translated labels into the name field en,de
Entity target = (Entity)localContext.PluginExecutionContext.InputParameters["Target"];
Entity preImageEntity = (context.PreEntityImages != null && context.PreEntityImages.Contains(this.preImageAlias)) ? context.PreEntityImages[this.preImageAlias] : null;
string[] names = new string[languages.Length];
for (int i = 0; i < languages.Length; i++)
{
names[i] = GetAttributeValue("new_name_" + languages[i], preImageEntity, target);
}
// Store the packed value in the target entity
target["name"] = string.Join(",", names);
}
///
/// Unpack the name field when a Product is Retreived
///
protected void UnpackNameOnRetrieve(LocalPluginContext localContext)
{
IPluginExecutionContext context = localContext.PluginExecutionContext;
Entity target = (Entity)context.OutputParameters["BusinessEntity"];
// Re-write the name field in the retrieved entity
target["name"] = UnpackName(localContext, target.GetAttributeValue("name"));
}
///
/// Unpack the name field when Products are retrieved via Lookup Search or Advanced Find
///
protected void UnpackNameOnRetrieveMultiple(LocalPluginContext localContext)
{
IPluginExecutionContext context = localContext.PluginExecutionContext;
EntityCollection collection = (EntityCollection) localContext.PluginExecutionContext.OutputParameters["BusinessEntityCollection"];
foreach (Entity e in collection.Entities)
{
if (e.Attributes.ContainsKey("name"))
{
e["name"] = UnpackName(localContext, e.GetAttributeValue("name"));
}
}
}
///
/// Unpack the product lookup name when an Opportunity Producs is Retrieved
///
protected void UnpackNameOnRetrieveMultipleRelated(LocalPluginContext localContext)
{
IPluginExecutionContext context = localContext.PluginExecutionContext;
EntityCollection collection = (EntityCollection)localContext.PluginExecutionContext.OutputParameters["BusinessEntityCollection"];
foreach (Entity e in collection.Entities)
{
if (e.Attributes.ContainsKey("productid"))
{
((EntityReference)e["productid"]).Name = UnpackName(localContext, e.GetAttributeValue("productid").Name);
}
}
}
///
/// Unpack the product lookup name when Opportunity Products are retrieved via lookup searches or advanced find
///
protected void UnpackNameOnRetrieveRelated(LocalPluginContext localContext)
{
IPluginExecutionContext context = localContext.PluginExecutionContext;
Entity target = (Entity)context.OutputParameters["BusinessEntity"];
if (target.Attributes.ContainsKey("productid"))
{
((EntityReference)target["productid"]).Name = UnpackName(localContext, target.GetAttributeValue("productid").Name);
}
}
///
/// Unpack the product name field
///
protected string UnpackName(LocalPluginContext localContext, string name)
{
// Get the language of the user
int userLanguageId = 0;
if (localContext.PluginExecutionContext.SharedVariables.ContainsKey("UserLocaleId"))
{
// Get the user language from the pipeline cache
userLanguageId = (int)localContext.PluginExecutionContext.SharedVariables["UserLocaleId"];
}
else
{
// The user language isn't cached in the pipline, so get it here
Entity userSettings = localContext.OrganizationService.Retrieve(
"usersettings",
localContext.PluginExecutionContext.InitiatingUserId,
new ColumnSet("uilanguageid"));
userLanguageId = userSettings.GetAttributeValue("uilanguageid");
localContext.PluginExecutionContext.SharedVariables["uilanguageid"] = userLanguageId;
}
// Split the name
string[] labels = name.Split(',');
// Which language is set for the user?
int labelIndex = Array.IndexOf(locales, userLanguageId);
// Return the correct translation
return labels[labelIndex];
}
///
/// Get a value from the target if present, otherwise from the preImage
///
private T GetAttributeValue(string attributeName, Entity preImage, Entity targetImage)
{
if (targetImage.Contains(attributeName))
{
return targetImage.GetAttributeValue(attributeName);
}
else if (preImage != null)
return preImage.GetAttributeValue(attributeName);
else
return default(T);
}
}
}
11) Locate the RegisterFile.crmregister file in the CRM Solution Project, and paste the following inside the PluginTypes section:
IMPORTANT: Change the Name and TypeName to match the namespace of your project.
<Plugin Description="Multi Language Support for Products"
FriendlyName="MultiLanguageProductPlugin"
Name="Develop1.Plugins.MultiLanguageProductPlugin"
Id="00000000-0000-0000-0000-000000000000"
TypeName="Develop1.Plugins.MultiLanguageProductPlugin">
<Steps>
<clear />
<!-- Pack the translations into the name field when a Product is Created -->
<Step CustomConfiguration=""
Name="ManageNameFieldPlugin"
Description="Pack the translations into the name field when a Product is Created"
Id="00000000-0000-0000-0000-000000000000"
MessageName="Create"
Mode="Synchronous"
PrimaryEntityName="product"
Rank="1"
SecureConfiguration=""
Stage="PreInsideTransaction"
SupportedDeployment="ServerOnly">
<Images />
</Step>
<!-- Pack the translations into the name field when a Product is Updated -->
<Step CustomConfiguration=""
Name="ManageNameFieldPlugin"
Description="Pack the translations into the name field when a Product is Updated"
Id="00000000-0000-0000-0000-000000000000"
MessageName="Update"
Mode="Synchronous"
PrimaryEntityName="product"
Rank="1"
SecureConfiguration=""
Stage="PreInsideTransaction"
SupportedDeployment="ServerOnly">
<Images>
<!-- We need the translated labels even if it isn't updated in this update -->
<Image Attributes="new_name_en,new_name_de"
EntityAlias="PreImage"
Id="00000000-0000-0000-0000-000000000000"
MessagePropertyName="Target"
ImageType="PreImage" />
</Images>
</Step>
<!-- Unpack the Product name field when Retrieved-->
<Step CustomConfiguration=""
Name="PostProductRetrieve"
Description="Unpack the Product name field when Retrieved"
Id="00000000-0000-0000-0000-000000000000"
MessageName="Retrieve"
Mode="Synchronous"
PrimaryEntityName="product"
Rank="1"
SecureConfiguration=""
Stage="PostOutsideTransaction"
SupportedDeployment="ServerOnly">
<Images />
</Step>
<!-- Unpack the Product name field when Retreived in Lookup/advanced find-->
<Step CustomConfiguration=""
Name="PostProductRetrieveMultiple"
Description="Unpack the Product name field when Retreived in Lookup/advanced find"
Id="00000000-0000-0000-0000-000000000000"
MessageName="RetrieveMultiple"
Mode="Synchronous"
PrimaryEntityName="product"
Rank="1"
SecureConfiguration=""
Stage="PostOutsideTransaction"
SupportedDeployment="ServerOnly">
<Images />
</Step>
<!-- Unpack the Product name in the Opportunity Product productid lookup when Retreived-->
<Step CustomConfiguration=""
Name="PostOpportunityProductRetrieve"
Description=" Unpack the Product name in the Opportunity Product productid lookup when Retreived"
Id="00000000-0000-0000-0000-000000000000"
MessageName="Retrieve"
Mode="Synchronous"
PrimaryEntityName="opportunityproduct"
Rank="1"
SecureConfiguration=""
Stage="PostOutsideTransaction"
SupportedDeployment="ServerOnly">
<Images />
</Step>
<!-- Unpack the Product name in the Opportunity Product productid lookup when Retreived in Lookup/advanced find-->
<Step CustomConfiguration=""
Name="PostProductOpportunityRetrieveMultiple"
Description="Post-Operation of Product Opportunity Retrieve"
Id="00000000-0000-0000-0000-000000000000"
MessageName="RetrieveMultiple"
Mode="Synchronous"
PrimaryEntityName="opportunityproduct"
Rank="1"
SecureConfiguration=""
Stage="PostOutsideTransaction"
SupportedDeployment="ServerOnly">
<Images />
</Step>
</Steps>
</Plugin>
12) Build and deploy your project.
You should now be able to create products, providing both a German and English name, and see the correct translations depending on your language selection.
You can extend this solution to include quotes, orders, price lists so that the lookups to products on those entities will also show the correct translated name.
In the same way that you can export translations from a solution to be translated, you can export the products to excel and mark them as being available for re-import. This file can be passed to a translator, updated and then re-imported.
If you need multi-language support for product names in Report, you can simply ensure that all the translated name fields (new_name_en, new_name_de) are included in the query and use an Expression such as :
=IIF(Parameters!CRM_UILanguageId.Value=1031,Fields!new_name_de.Value,Fields!new_name_en.Value)
This is a proposed solution to allowing lookup names to be translated into multiple languages. I welcome any feedback/suggestions/alternatives.
Download the full solution from the MSDN Code Gallery.
Until next time!
@ScottDurow