Tuesday, October 29, 2013

EPiServer 7: A custom property combined with attributes and a custom EditorDescriptor - UPDATED

In this post I will demonstrate the possibility to create an EPiServer 7 custom property and combine it with property attributes for edit mode configuration (which is utilized by a custom EditorDescriptor).

This entire post has been revised and updated to work with EPiServer 7.5.

What I have created is a custom property to let the editor create key-value items that can be used to render, for instance, a drop-down list.

Overview

In order to achieve this we have to create the files marked in blue in the image below. And I will go trough each and every one of them to try and explain how to create a custom property in EPiServer 7.5.



StartPage.cs


   1:  [ContentType(GUID = "333e2a8b-46d4-4ed9-8f05-ac4a3c09058e", GroupName = SystemTabNames.Content)]
   2:  public class StartPage : PageData
   3:  {
   4:      [Display(GroupName = SystemTabNames.Content, Order = 100)]
   5:      [BackingType(typeof(PropertyKeyValueItems))]
   6:      [KeyValueItems("Name", "E-mail", "Add", "X", "", "", @"^[a-zA-Z0-9_.-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", "The entered value is not valid. Must be a valid e-mail.")]
   7:      public virtual IEnumerable<KeyValueItem> KeyValueItems { get; set; }
   8:  }

Three things are of importance here.
1. The BackingType attribute specified on line 5 which is specifying what property to use when retrieving and storing data.
2. The KeyValueItems attribute on line 6 which is the attribute that lets us add specific parameters for this particular property
3. The IEnumerable on line 7 which match the type used in the backing type property


PropertyKeyValueItems.cs


   1:  [PropertyDefinitionTypePlugIn(Description = "A property for list of key-value-items.", DisplayName = "Key-Value Items")]
   2:  public class PropertyKeyValueItems : PropertyLongString
   3:  {
   4:      public override Type PropertyValueType
   5:      {
   6:          get { return typeof(IEnumerable<KeyValueItem>); }
   7:      }
   8:   
   9:      public override object Value
  10:      {
  11:          get
  12:          {
  13:              var value = base.Value as string;
  14:              if (value == null) { return null; }
  15:              JavaScriptSerializer serializer = new JavaScriptSerializer();
  16:              return serializer.Deserialize(value, typeof(IEnumerable<KeyValueItem>));
  17:          }
  18:          set
  19:          {
  20:              if (value is IEnumerable<KeyValueItem>)
  21:              {
  22:                  JavaScriptSerializer serializer = new JavaScriptSerializer();
  23:                  base.Value = serializer.Serialize(value);
  24:              }
  25:              else { base.Value = value; }
  26:          }
  27:      }
  28:  }
  29:   
  30:  public class KeyValueItem
  31:  {
  32:      public string Key { get; set; }
  33:      public string Value { get; set; }
  34:  }

I have edited the file and in the above code only display the essential (the source code for all files are found at the botton of this post).

The are two classes here: 1. PropertyKeyValueItems, 2. KeyValueItem.

PropertyKeyValueItems is used to retrieve and store the value from editor input. Since the property is of type 'IEnumerable' as specified in property 'PropertyValueType' but it does in fact inherit 'PropertyLongString' we need to serialize and deserialize the data and this is done in property 'Value'. The 'KeyValueItem' class is simply used to help with the serialization and deserialization.

KeyValueItemsAttribute.cs


   1:  [AttributeUsage(AttributeTargets.Property)]
   2:  public class KeyValueItemsAttribute : Attribute
   3:  {
   4:      public string KeyLabel { get; set; }
   5:      public string ValueLabel { get; set; }
   6:      public string AddButtonLabel { get; set; }
   7:      public string RemoveButtonLabel { get; set; }
   8:      public string KeyValidationExpression { get; set; }
   9:      public string ValueValidationExpression { get; set; }
  10:      public string KeyValidationMessage { get; set; }
  11:      public string ValueValidationMessage { get; set; }
  12:   
  13:      public KeyValueItemsAttribute(string keyLabel, 
  14:                                      string valueLabel, 
  15:                                      string addButtonLabel, 
  16:                                      string removeButtonLabel, 
  17:                                      string keyValidationExpression,
  18:                                      string keyValidationMessage,
  19:                                      string valueValidationExpression,
  20:                                      string valueValidationMessage)
  21:      {
  22:          KeyLabel = keyLabel;
  23:          ValueLabel = valueLabel;
  24:          AddButtonLabel = addButtonLabel;
  25:          RemoveButtonLabel = removeButtonLabel;
  26:          KeyValidationExpression = keyValidationExpression;
  27:          KeyValidationMessage = keyValidationMessage;
  28:          ValueValidationExpression = valueValidationExpression;
  29:          ValueValidationMessage = valueValidationMessage;
  30:      }
  31:  }

This is a class used to dress up a specific property. In this case we say that the value the key is 'Name' and the value is 'E-mail'. We send all of these calues up to the front layer with the help of the EditorDescriptor coming next.

KeyValueItemsEditorDescriptor.cs


   1:  [EditorDescriptorRegistration(TargetType = typeof(IEnumerable<KeyValueItem>))]
   2:  public class KeyValueItemsEditorDescriptor : EditorDescriptor
   3:  {
   4:      public KeyValueItemsEditorDescriptor()
   5:      {
   6:          ClientEditingClass = "episerver75.editors.KeyValueItems";
   7:      }
   8:   
   9:      protected override void SetEditorConfiguration(ExtendedMetadata metadata)
  10:      {
  11:          var keyValueItemsAttribute = metadata.Attributes.FirstOrDefault(a => typeof(KeyValueItemsAttribute) == a.GetType()) as KeyValueItemsAttribute;
  12:          if (keyValueItemsAttribute != null)
  13:          {
  14:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.KeyLabel))
  15:                  EditorConfiguration["keyLabel"] = keyValueItemsAttribute.KeyLabel;
  16:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.ValueLabel))
  17:                  EditorConfiguration["valueLabel"] = keyValueItemsAttribute.ValueLabel;
  18:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.AddButtonLabel))
  19:                  EditorConfiguration["addButtonLabel"] = keyValueItemsAttribute.AddButtonLabel;
  20:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.RemoveButtonLabel))
  21:                  EditorConfiguration["removeButtonLabel"] = keyValueItemsAttribute.RemoveButtonLabel;
  22:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.KeyValidationExpression))
  23:                  EditorConfiguration["keyValidationExpression"] = keyValueItemsAttribute.KeyValidationExpression;
  24:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.KeyValidationMessage))
  25:                  EditorConfiguration["keyValidationMessage"] = keyValueItemsAttribute.KeyValidationMessage;
  26:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.ValueValidationExpression))
  27:                  EditorConfiguration["valueValidationExpression"] = keyValueItemsAttribute.ValueValidationExpression;
  28:              if (!String.IsNullOrEmpty(keyValueItemsAttribute.ValueValidationMessage))
  29:                  EditorConfiguration["valueValidationMessage"] = keyValueItemsAttribute.ValueValidationMessage;
  30:          }
  31:          base.SetEditorConfiguration(metadata);
  32:      }
  33:  }

This is the piece of code that binds everything together (back-end with front-end). First we need to specify that whenever editing a property of the type 'IEnumerable' then this is the code to use (line 1). In the constructor we specify which front-end code to use (line 6, more on this path late i 'module.config'). Third we override the 'SetEditorConfiguration' method and simply reads the 'KeyValueItemsAttribute' and its properties and add them to the EditorConfiguration property we get when inheriting the 'EditorDescriptor' class.

module.config


   1:  <?xml version="1.0" encoding="utf-8"?>
   2:  <module>
   3:      <clientResources>
   4:          <add name="epi-cms.widgets.base" path="Styles/Styles.css" resourceType="Style"/>
   5:      </clientResources>
   6:      <dojo>
   7:          <paths>
   8:              <add name="episerver75" path="Scripts" />
   9:          </paths>
  10:      </dojo>
  11:  </module>

This config file specifies the base path for the client resources used by our custom front-end code (on line 8). This is what makes the 'ClientEditingClass = "episerver75.editors.KeyValueItems";' work in the EditorDescriptor class described above.

Style.css

Any styles needed for the front-end custom property.

KeyValueItems.js


   1:  define(
   2:  "episerver75/editors/KeyValueItems",
   3:  [
   4:  "dojo/_base/array",
   5:  "dojo/_base/declare",
   6:  "dojo/_base/lang",
   7:  "dojo/_base/json",
   8:  "dojo/query",
   9:  "dojo/dom-construct",
  10:  "dojo/on",
  11:  "dijit/focus",
  12:  "dijit/_TemplatedMixin",
  13:  "dijit/_Widget",
  14:  "dijit/form/ValidationTextBox",
  15:  "dijit/form/Button",
  16:  "epi/shell/widget/_ValueRequiredMixin"
  17:  ],
  18:  function (array, declare, lang, json, query, domConstruct, on, focus, templatedMixin, widget, textbox, button, valueRequiredMixin) {
  19:  return declare([widget, templatedMixin, valueRequiredMixin], {
  20:      templateString: "<div data-dojo-attach-point=\"stateNode, tooltipNode\" class=\"dijit dijitReset dijitInline\"> \
  21:                          <div data-dojo-attach-point=\"keyValueItemsNode\" class=\"dijit dijitReset\"></div> \
  22:                          <div class=\"dijit dijitReset\"> \
  23:                              <button data-dojo-attach-event=\"onclick:addKeyValueItem\" type=\"button\" class=\"\">${addButtonLabel}</button> \
  24:                          </div> \
  25:                      </div>",
  26:      baseClass: "keyValueItems",
  27:      keyLabel: "Key",
  28:      valueLabel: "Value",
  29:      addButtonLabel: "Add",
  30:      removeButtonLabel: "X",
  31:      keyValidationExpression: "",
  32:      keyValidationMessage: "",
  33:      valueValidationExpression: "",
  34:      valueValidationMessage: "",
  35:      valueIsCsv: true,
  36:      valueIsInclusive: true,
  37:      value: null,
  38:      widgetsInTemplate: true,
  39:      constructor: function() {
  40:          this._keyValueItems = [];
  41:      },
  42:      postMixInProperties: function() {
  43:          this.inherited(arguments);
  44:          if (this.params.keyLabel)
  45:              this.keyLabel = this.params.keyLabel;
  46:          if (this.params.valueLabel)
  47:              this.valueLabel = this.params.valueLabel;
  48:          if (this.params.addButtonLabel)
  49:              this.addButtonLabel = this.params.addButtonLabel;
  50:          if (this.params.removeButtonLabel)
  51:              this.removeButtonLabel = this.params.removeButtonLabel;
  52:          if (this.params.keyValidationExpression)
  53:              this.keyValidationExpression = this.params.keyValidationExpression;
  54:          if (this.params.keyValidationMessage)
  55:              this.keyValidationMessage = this.params.keyValidationMessage;
  56:          if (this.params.valueValidationExpression)
  57:              this.valueValidationExpression = this.params.valueValidationExpression;
  58:          if (this.params.valueValidationMessage)
  59:              this.valueValidationMessage = this.params.valueValidationMessage;
  60:      },
  61:      destroy: function() {
  62:          var _a;
  63:          while (_a = this._keyValueItems.pop()) {
  64:              _a.div.destroyRecursive();
  65:          }
  66:          this.inherited(arguments);
  67:      },
  68:      focus: function() {
  69:          try {
  70:              if (this._keyValueItems.length > 0) {
  71:                  focus.focus(this._keyValueItems[0].div.keyValueItemsNode);
  72:              }
  73:          } catch (e) {
  74:          }
  75:      },
  76:      onChange: function() {},
  77:      onBlur: function() {},
  78:      onFocus: function() {},
  79:      isValid: function() {
  80:          var isValid = true;
  81:          array.forEach(this._keyValueItems, function(entry) {
  82:              var keyTextbox = entry.keyTextbox,
  83:                  valueTextbox = entry.valueTextbox;
  84:   
  85:              isValid = isValid && keyTextbox.isValid() && valueTextbox.isValid();
  86:          });
  87:          return isValid;
  88:      },
  89:      _calculateValue: function () {
  90:          var value = [];
  91:          array.forEach(this._keyValueItems, function(entry) {
  92:              var keyTextbox = entry.keyTextbox,
  93:                      valueTextbox = entry.valueTextbox;
  94:   
  95:              if (keyTextbox.value && valueTextbox.value && keyTextbox.isValid() && valueTextbox.isValid()) {
  96:                  var keyValuePair = new Object();
  97:                  keyValuePair.key = keyTextbox.value;
  98:                  keyValuePair.value = valueTextbox.value;
  99:                  value.push(keyValuePair);
 100:              }
 101:          });
 102:   
 103:          this._set("value", value);
 104:      },
 105:      _setValueAttr: function (value) {
 106:          this._set("value", value);
 107:   
 108:          array.forEach(value, this._addKeyValueTextboxesForItem, this);
 109:      },
 110:      _onBlur: function () {
 111:          this.inherited(arguments);
 112:          this.onBlur();
 113:      },
 114:      addKeyValueItem: function () {
 115:          this._addKeyValueTextboxesForItem({ "Key": "", "Value": "" });
 116:      },
 117:      _addKeyValueTextboxesForItem: function (keyValueItem) {
 118:          var div = domConstruct.create("div", null, this.keyValueItemsNode);
 119:          div.setAttribute("class", "keyValueItemContainer");
 120:   
 121:          var keyTextbox = this._getTextbox(keyValueItem.key, "keyTextbox", this.keyValidationMessage, this.keyValidationExpression);
 122:          var valueTextbox = this._getTextbox(keyValueItem.value, "valueTextbox", this.valueValidationMessage, this.valueValidationExpression);
 123:   
 124:          keyTextbox.placeAt(div);
 125:          valueTextbox.placeAt(div);
 126:   
 127:          var btn = new button({
 128:              label: this.removeButtonLabel,
 129:              main: this,
 130:              container: div
 131:          });
 132:          btn.on("click", function () {
 133:              this.main._removeKeyValueItem(this.container);
 134:              domConstruct.destroy(this.container);
 135:              this.main._calculateValue();
 136:              this.main.onChange(this.main.value);
 137:   
 138:          });
 139:          btn.placeAt(div);
 140:   
 141:          this._pushKeyValueItem(div, keyTextbox, valueTextbox);
 142:      },
 143:      _removeKeyValueItem: function (div) {
 144:          var newKeyValueItems = [];
 145:   
 146:          array.forEach(this._keyValueItems, function (entry) {
 147:              if (entry.div != div) {
 148:                  newKeyValueItems.push(entry);
 149:              }
 150:          });
 151:   
 152:          this._keyValueItems = newKeyValueItems;
 153:      },
 154:      _pushKeyValueItem: function(div, keyTextbox, valueTextbox) {
 155:          var o = new Object();
 156:          o.div = div;
 157:          o.keyTextbox = keyTextbox;
 158:          o.valueTextbox = valueTextbox;
 159:   
 160:          this._keyValueItems.push(o);
 161:      },
 162:      _getTextbox: function (value, cssClass, message, expression) {
 163:          var tb = new textbox({
 164:              value: value,
 165:              invalidMessage: message,
 166:              regExp: expression
 167:          });
 168:          tb.setAttribute("class", cssClass);
 169:   
 170:          tb.on("change", lang.hitch(this, function () {
 171:              this._calculateValue();
 172:              this.onChange(this.value);
 173:          }));
 174:          tb.on("focus", lang.hitch(this, function () {
 175:              this._set("focused", true);
 176:              this.onFocus();
 177:          }));
 178:          tb.on("blur", lang.hitch(this, function () {
 179:              this._set("focused", false);
 180:              this._onBlur();
 181:          }));
 182:   
 183:          return tb;
 184:      }
 185:  });
 186:  });

Ok. This is where everything is happening on the fron-end. The concept is the get the value and then render the controls needed as well as setting a new value based on the rendered controls if this makes sense. And, I see now that I forgot to use the 'Key' and 'Value' labels but it works fine without them. ;-)

Some of the important methods here are:

postMixInProperties

This is where we read the parameters we specified in 'KeyValueItemsEditorDescriptor'.

isValid

This is where we makes sure if the user input is valid or not.

_calculateValue

This is used to calculate the new value based on user input.

_setValueAttr

This is in this cased used to get the value stored in the database and render the controls.

_addKeyValueTextboxesForItem

This is the method to render the controls base on a single KeyValueItem value.

Output




Index.cshtml


   1:  @Html.DropDownListFor(m => m.KeyValueItems, new SelectList(Model.KeyValueItems, "Value", "Key"))

This is an example of how to render the property as a drop-down list.

That's it

Yes, a lot of code and examples but I hope you get the idea. :-)

Source code.

41 comments:

  1. Would you be able to share some of the code for the keyvalueitem list, especially the Dojo part? There isn't that many code samples out there, so that would be great!

    ReplyDelete
    Replies
    1. I uploaded the source code for this little example and hope it is something you find useful! :-)

      Delete
  2. Hi Peter, could you explain, what is the "KeyValueItemsParameters" type, I couldn't find description for this type in your example? Thanks

    ReplyDelete
    Replies
    1. Well, it's not really a type but simply a means to get parameters from backend to frontend. In this example we pass on values specified on the KeyValueItemsAttribute, such as button texts and validation expressions, and these are passed on up to the dojo editor as a Json object via the EditorDescriptor.

      Delete
    2. Hi Peter, thanks for this great example, it really helps me to understand custom properties and custom editors. I have a similar question to valik. When I try to implement this, I get the error "The type or namespace name 'KeyValueItemsParameters' could not be found (are you missing a using directive or an assembly reference?)" in reference to KeyValueItemsAttribute.cs (line 55). Your code uses EPi7.Business.Classes, and I assume that you have a class 'KeyValueItemsParameters' in there? If I was a little smarter I could probably figure out what should go in there, but so far I haven't been able to get it working. Could you tell me what that class needs to look like?

      Delete
    3. Hi Rudi,
      I will look into this and update the source accordingly. It is a simple class to hold the parameters sent to the front layer via json serialization, if I remember correctly. I have struggled with understanding dojo and find it rather difficult but will try to post a better example of what I have learned so far. It's just a question of finding the time. :-)

      Delete
  3. Hi Peter, Thanks for sharing your code, its a very neat way of doing custom property. I am trying to implement it with Episerver 7.5 (dojo version 1.8). The editor loads first time when there are no items are present, but once added one or more, it stops working i.e. the page doesnt allow you to go into the property editor mode. It seems that the _setValueAttr method gets called befor the div tag with id '_content' exists.

    ReplyDelete
    Replies
    1. Hi Punjabisoul, I was seeing the same issue you were. Were you able to come up with a solution?

      Delete
    2. I noticed this too when trying to implement it as a test in my EPiServer 7.5 project. There must be some changes made between 7 and 7.5 concerning this. I will try to figure out what the issue is and update the information. Or if I ever find the time post a better example if creating a custom property. :-)

      Delete
    3. Hi Peter! Thanks for replying and updating the code to work with 7.5, works very well :)

      Delete
  4. Great post and resource! Thank you for sharing it!

    Just one small thing: seems like SaveData function in PropertyKeyValueItems class, is essential to make it work (at least in EPi7.1). Otherwise I end up with a EPi/dojo error: Unable to cast object of type 'System.Collections.Generic.List`1[KeyValueItem]' to type 'System.String'.

    I guess I'm the only one that tries it out without downloading the source first ;)

    ReplyDelete
  5. I am getting this issue when I copy pasted the code in version 7.0/7.1. This is the error I receive on clicking on edit window
    Can you please mention, what specific changes I need to do to work with version 7.0 and 7.1

    TypeError: ctor is not a constructor
    _229()widgets.js (line 2)
    .cache["dojo/_base/lang"]/</_388/<()dojo.js (line 15)
    _c6()dojo.js (line 15)
    _36()dojo.js (line 15)
    _16()dojo.js (line 15)
    req()dojo.js (line 15)
    .cache["epi/shell/widget/WidgetFactory"]/</<.defaultWidgetInstantiator()widgets.js (line 2)
    .cache["dojo/_base/lang"]/</_388/<()dojo.js (line 15)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createInternal()widgets.js (line 2)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createWidgets/_215<()widgets.js (line 2)
    .cache["dojo/_base/lang"]/</_388/<()dojo.js (line 15)
    map()dojo.js (line 15)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createWidgets()widgets.js (line 2)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createInternal/<()widgets.js (line 2)
    .cache["dojo/_base/lang"]/</_388/<()dojo.js (line 15)
    _174()dojo.js (line 15)
    .cache["dojo/_base/Deferred"]/</dojo.Deferred/this.then()dojo.js (line 15)
    .cache["dojo/_base/Deferred"]/</dojo.when()dojo.js (line 15)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createInternal()widgets.js (line 2)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createWidgets/_215<()widgets.js (line 2)
    .cache["dojo/_base/lang"]/</_388/<()dojo.js (line 15)
    map()dojo.js (line 15)
    .cache["epi/shell/widget/WidgetFactory"]/</<._createWidgets()widgets.js (line 2)
    .cache["epi/shell/widget/WidgetFactory"]/</<.createWidgets/</<()widgets.js (line 2)
    .cache["dojo/_base/lang"]/</_388/<()dojo.js (line 15)
    _c6()dojo.js (line 15)
    _36()dojo.js (line 15)
    _7a()dojo.js (line 15)
    _32/_ee()dojo.js (line 15)
    req.injectUrl/_109()dojo.js (line 15)


    ...s);if(this.anchor){if(!this.getItem(this.anchor.id)){this.anchor=null;}}var t=[]...

    ReplyDelete
  6. Thanks Peter! This was exactly what I was looking for. You really describe how to combine both backend and frontend to build a complex property. Dojo/Dijit is still a bit scary for most developers and the documentation is non-existent.
    I wonder how the search works though. I guess it shouldn't be a problem since the property inherits from PropertyLongString but have you tested it?
    Thanks again for a great post!!

    ReplyDelete
  7. Hello Peter,

    I am running EpiServer 7.18 and on creating a new keyValue it does not pick up the change (for publishing) unless I click another keyvalue textfield.

    No errors are presented in the logs.

    Could you help me in pointing me into the right direction as to why that is the case

    ReplyDelete
    Replies
    1. Im getting the same issue. Is there a way to trigger the CMS to show the Publish button?

      Delete
    2. I got the same issue, changes to the first KeyValue-pair I added displays the Publish button. If I add new pairs I have to edit my first pair to make the Publish button appear. Have anyone got this to work yet?

      Delete
  8. The code just works fine for me to enter the values for the key-value pair. But after save or publish the values are not maintained and it starts from the first, asking to "Add" new fields. It would be great if I could figure out this :(

    ReplyDelete
  9. Same us above. Althought I get an exception in Episerver edit mode: "Unable to cast object of type 'System.Collections.Generic.List`1[EpiTutorial.Models.Properties.KeyValueItem]' to type 'System.String'."

    Does anyone have a solution to this?

    ReplyDelete
  10. Thank you for an additional great post. Exactly where else could anybody get that kind of facts in this kind of a ideal way of writing? I have a presentation next week, and I’m around the appear for this kind of data.

    click here

    Selenium Training in Bangalore|
    Selenium Training in Chennai

    ReplyDelete
  11. I enjoy what you guys are usually up too. This sort of clever work and coverage! Keep up the wonderful works guysl.Good going.
    oneplus service center chennai
    oneplus service center in chennai
    oneplus service centre chennai

    ReplyDelete
  12. Thanks for sharing the knowledgeable stuff to enlighten us no words for this amazing blog.. learnt so many things I recommend everyone to learn something from this blogger and blog.. I am sharing it with others also
    IT Software Training in Chennai | Python Training in Chennai | Dot Net Training in Chennai

    ReplyDelete
  13. Thank you so much for posting this hub, Nice work on the Dot net Training..
    Regards,

    https://www.softlogicsys.in/datascience-training-in-chennai/
    https://www.softlogicsys.in/machine-learning-training-in-chennai/
    https://www.softlogicsys.in/linux-training-in-chennai/
    https://www.softlogicsys.in/dba-administration-training-in-chennai/

    ReplyDelete
  14. Book Tenride call taxi in Chennai at most affordable taxi fare for Local or Outstation rides. Get multiple car options with our Chennai cab service
    chennai to kochi cab
    bangalore to kochi cab
    kochi to bangalore cab
    chennai to hyderabad cab
    hyderabad to chennai cab

    ReplyDelete
  15. This comment has been removed by the author.

    ReplyDelete
  16. ecommerce website design company
    ecommerce development solutions

    ReplyDelete
  17. It is a very simple and straight-forward article. There is no beating around the bush, the writer tells you exactly what you need to know.bus rental Dubai airport transfer

    ReplyDelete
  18. You'll instantly receive the license key on the next webpage. Copy it down. The process has bit changed, the activation code will now Kaspersky Crack

    ReplyDelete