I'm trying to create a more friendly page URL in Kentico with the two letter culture code (en) instead of the full culture code (en-US). I modified the page URL pattern as seen in the image below. Is there a better way of doing this, should I create a custom macro expression?
The other thing I wanted to achieve was having the default culture not in the URL. Maybe I could also use the custom macro expression for it.
I have solved the problem with a custom macro expression. This is used in the URL pattern in combination with the DocumentCulture. The macro expression takes the culture alias name which is in case of the default culture empty and else the two letter code, and returns this with a '/'.
The url pattern for a page:
{%Util.GetUrlCultureCode(DocumentCulture)%}{%NodeAliasPath%}
The macro expression I wrote for fixing this problem is as below.
public class CustomCultureMacroExpression : MacroMethodContainer
{
[MacroMethod(typeof(List<string>), "Returns the url culture code of current document", 1)]
[MacroMethodParam(0, "Culture", typeof(string), "Current culture.")]
public static object GetUrlCultureCode(EvaluationContext context, params object[] parameters)
{
// Branches according to the number of the method's parameters
switch (parameters.Length)
{
case 1:
return GetCultureAliasOfCulture(parameters[0].ToString());
case 2:
// Weird bug causing macro expression in url pattern to have two parameters where the first parameter is null.
return GetCultureAliasOfCulture(parameters[1].ToString());
default:
// No other overloads are supported
return string.Empty;
}
}
private static string GetCultureAliasOfCulture(string cultureCode)
{
var culture = CultureInfoProvider.GetCultureInfo(cultureCode);
var culturealias = culture?.CultureAlias ?? string.Empty;
return string.IsNullOrEmpty(culturealias) ? string.Empty : $"/{culturealias}";
}
}
You may try to update culture codes in Localization -> Cultures, however I'm not sure if it won't have any side effect
If you use the Dynamic Routing module, you can hook into the GetCulture and GetPage global events to do what you are looking for.
For the GetCulture, you would check if the URL has a 2 letter culture code, if it does then use that as the culture.
You can also adjust the GetPage if needed, although the URL slugs generated should work properly, unless you want a fall back that on the GetPage.After, if there is no found page, you can try removing any culture from the URL and looking up the page by that.
I recommended using the NodeAliasPath for the remaining part of the UrlPattern for ease of use.
Related
The asp-items Razor "TagHelper" will add an <option> to a <select> for each value in the SelectList. I want to modify each of those children.
Specifically I want to disable some of them (i.e. add disabled="disabled").
Even more specifically I want to dynamically disable some of them; I'm using angular so I could ng-disabled="{dynamic_boolean_which_determines_disabled}". This means the option could be disabled at first, but after user makes a change, the option could be disabled (without page reload). Angular should take care of this; I think Angular and TagHelpers should work together in theory...
I expected:
I could somehow access an IEnumerable of the children <option> tags that would be created (i.e. one for each item in the SelectList), iterate the children tags, and SetAttribute("disabled") or SetAttribute("ng-disabled")...
I tried:
Creating my own TagHelper which targets the select[asp-items], and tries to GetChildContentAsync() and/or SetContent to reach an IEnumerable <option> tags and iterate them and process each, but I think this will only let me modify the entire InnerHtml as a string; feels hacky to do a String.replace, but I could do it if that's my only option? i.e. ChildrenContent.Replace("<option", "<option disabled=\"...\"")
Creating my own TagHelper which targets the option elements that are children of the select[asp-items], so I can individually process each. This works, but not on the dynamically-added <option> created by asp-items, it only works on "literal" <option> tags that I actually put into my cshtml markup.
I think this'll work but not ideal:
As I said above, I think I can get the result of TagHelper's dynamic asp-items <option></option> <option></option>, as a string, and do a string replace, but I prefer not to work with strings directly...
I suspect (I haven't tried it) that I could just do the work of asp-items myself; i.e. custom-items. But then I'm recreating the wheel by re-doing the work which asp-items could've done for me?
So I hadn't yet read the "AutoLinkHttpTagHelper" in the example which uses string replacement (specifically RegEx replace) to replace every occurrence of a URL, with an <a> pointed at that URL. The cases are slightly different*, but...
Anyway, here's my solution once I learned to stop worrying and love the string modification:
[HtmlTargetElement("select", Attributes = "asp-items")]
public class AspItemsNgDisabledTagHelper : SelectTagHelper
{
//Need it to process *after* the SelectTagHelper
public override int Order { get; } = int.MaxValue;
//https://learn.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/authoring#ProcessAsync
public AspItemsNgDisabledTagHelper(IHtmlGenerator gen) : base(gen) {}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
//Notice I'm getting the PostContent;
//SelectTagHelper leaves its literal content (i.e. in your CSHTML, if there is any) alone ; that's Content
//it only **appends** new options specified; that's PostContent
//Makes sense, but I still wasn't expecting it
var generated_options = output.PostContent.GetContent();
//Note you do NOT need to extend SelectTagHelper as I've done here
//I only did it to take advantage of the asp-for property, to get its Name, so I could pass that to the angular function
var select_for = this.For.Name;
//The heart of the processing is a Regex.Replace, just like
//their example https://learn.microsoft.com/en-us/aspnet/core/mvc/views/tag-helpers/authoring#inspecting-and-retrieving-child-content
var ng_disabled_generated_options = Regex.Replace(
generated_options,
"<option value=\"(\\w+)\">",
$"<option value=\"$1\" ng-disabled=\"is_disabled('{select_for}', '$1')\">");
//Finally, you Set your modified Content
output.PostContent.SetHtmlContent(ng_disabled_generated_options);
}
}
Few learning opportunities:
Was thinking I'd find AspForTagHelper and AspItemsTagHelper, (angular background suggested that the corresponding attributes; asp-for and asp-items, would be separate "directives" aka TagHelper).
In fact, TagHelper "matching" focuses on the element name (unlike angular which can match element name... attribute... class... CSS selector)
Therefore I found what I was looking for in SelectTagHelper, which has For and Items as properties. Makes sense.
As I said above, I extend SelectTagHelper, but that's not necessary to answer my original question. It's only necessary if you want access to the this.For.Name as I've done, but there may even be a way around that (i.e. re-bind its own For property here?)
I got on a distraction thinking I would need to override the SelectTagHelper's behavior to achieve my goals; i.e. Object-Oriented Thinking. In fact, even if I did extend SelectTagHelper, that doesn't stop a separate instance of the base SelectTagHelper from matching and processing the element. In other words, element processing happens in a pipeline.
This explains why extending and calling base.Process(), will result in Select doing its job twice; once when your instance matches, and again when the base instance matched.
(I suppose could've prevented SelectTagHelper from matching by creating a new element name like <asp-items-select>? But, not necessary... I just avoid calling base.Process(). So unless that's a bad practice...)
*Different in this way:
They want to create a tag where none exists, whereas I want to add an attribute a tag which is already there; i.e. the <option>
Though the <option> "tag" is generated by the SelectTagHelper in its PostContent (was expecting to find it in Content), and I don't think tags-generated-in-strings-by-content-mods can be matched with their corresponding TagHelper -- so maybe we really are the same in that we're just dealing with plain old strings
Their "data" aka "model" is implied in the text itself; they find a URL and that URL string becomes a unit of meaning they use. In my case, there is an explicit class for Modeling; the SelectList (<select>) which consists of some SelectListItem (<option>) -- but that class doesn't help me either.
That class only gives me attributes like public bool Disabled (remember, this isn't sufficient for me because the value of disabled could change to true or false within browser; i.e. client-side only), and public SelectListGroup Group -- certainly nothing as nonstandard as ng-disabled, nor a "catch-all" property like Attributes which could let me put arbitrary attributes (ng-disabled or anything else) in there.
The current node is coming up null. I can't figure out how to make MvcSiteMapProvider resolve the node under this circumstance.
Here's the node it needs to match
<mvcSiteMapNode title="Policy" route="Details" typeName="Biz.ABC.ShampooMax.Policy" preservedRouteParameters="id" />
Here's the route:
routes.MapRoute(
"Details",
"Details/{id}",
new { Controller = "Object", Action = "Details" }
).RouteHandler = new ObjectRouteHandler();
The link that gets clicked:
http://localhost:36695/MyGreatWebSite/Details/121534455762071
It's hitting the route ok. Just the MvcSiteMapProvider.SiteMaps.Current.CurrentNode is null.
A null result for CurrentNode indicates the incoming request doesn't match any node in the SiteMap In your case there are 4 different problems that may be contributing to this:
The URL you are inputting http://localhost:36695/MyGreatWebSite/Details/121534455762071 does not (necessarily) match the URL pattern you specified in the route, "Details/{id}". It may if your site is hosted as an IIS application under IISROOT/MyGreatWebSite/.
Your mvcSiteMapNode doesn't specify a controller or action to match. route only acts like an additional piece of criteria (meaning only the named route will be considered in the match), but all of the parameters also need to be provided in order for there to be a match with the route.
You are passing in a custom RouteHandler, which could alter how the route matches the URL. Without seeing the code from your ObjectRouteHandler, it is impossible to tell if or how that will affect how the route matches the URL.
You have a custom attribute, typeName in your mvcSiteMapNode configuration. Unless you have specified to ignore this attribute, it will also be required in the URL to match, i.e. http://localhost:36695/MyGreatWebSite/Details/121534455762071?typeName=Biz.ABC.ShampooMax.Policy.
I recommend against using a custom RouteHandler for the purpose of matching URLs. The effect of doing so makes your incoming routes (URLs into MVC) act differently than your outgoing routes (URLs generated to link to other pages). Since MvcSiteMapProvider uses both parts of the route, it will cause URL generation problems if you only change the incoming routes without also changing the outgoing routes to match. Instead, I recommend you subclass RouteBase, where you can control both sides of the route. See this answer for an example of a custom RouteBase subclass.
However, do note that conventional routing is pretty powerful out of the box and you probably don't need to subclass RouteBase for this simple scenario.
Solution
I arrived at the answer by adding the mvc sitemap provider project into my own solution and stepping through the mvc sitemap provider code to see why my node wasn't being matched. A few things had to be changed. I fixed it by doing the following:
Mvc.sitemap
<mvcSiteMapNode title="Policy" controller="Object" action="Details" typeName="Biz.ABC.ShampootMax.Policy" preservedRouteParameters="id" roles="*"/>
RouteConfig.cs
routes.MapRoute(
name: "Details",
url: "details/{id}",
defaults: new { controller = "Object", action = "Details", typeName = "*" }
).RouteHandler = new ObjectRouteHandler();
Now at first it didn't want to work like this, but I modified the provider like so:
RouteValueDictionary.cs (added wildcard to match value)
protected virtual bool MatchesValue(string key, object value)
{
return this[key].ToString().Equals(value.ToString(), StringComparison.OrdinalIgnoreCase) || value.ToString() == "*";
}
SiteMapNode.cs (changed requestContext.RouteData.Values)
/// <summary>
/// Sets the preserved route parameters of the current request to the routeValues collection.
/// </summary>
/// <remarks>
/// This method relies on the fact that the route value collection is request cached. The
/// values written are for the current request only, after which they will be discarded.
/// </remarks>
protected virtual void PreserveRouteParameters()
{
if (this.PreservedRouteParameters.Count > 0)
{
var requestContext = this.mvcContextFactory.CreateRequestContext();
var routeDataValues = requestContext.HttpContext.Request.RequestContext.RouteData.Values;// requestContext.RouteData.Values;
I think the second modification wasn't strictly necessary because my request context wasn't cached; it would have worked if it was. I didn't know how to get it cached.
It's the first modification to make route values honor a wildcard (*) that made it work. It seems like a hack and maybe there's a built in way.
Note
Ignoring the typeName attribute with:
web.config
<add key="MvcSiteMapProvider_AttributesToIgnore" value="typeName" />
makes another node break:
Mvc.sitemap
<mvcSiteMapNode title="Policies" url="~/Home/Products/HarvestMAX/Policy/List" productType="HarvestMax" type="P" typeName="AACOBusinessModel.AACO.HarvestMax.Policy" roles="*">
so that's why I didn't do that.
I'm getting this error message, and any of the advice that I've seen does not appear to be applicable; i.e. all views, controllers and models are in the correct folders.
More detail:
I have a master view, which shows a graphical flowchart-like interface for interacting with the application. The user selects the "Open Study" symbol, and I redirect to another view which allows the user to select a Study to work with.
The OpenStudyController code retrieves the selected study and then redirects back to the master view:
public ActionResult SelectStudy( Guid? id )
{
// code elided for clarity
return RedirectToAction( "ActivateStudy", "Home" );
}
HomeController has a method called ActivateStudy(...), which does get invoked with the appropriate environment:
public ActionResult ActivateStudy()
{
// code elided for clarity
return View();
}
As I said, all views, controllers and models are in the correct folders.
When the "return View()" code in ActivateStudy() is executed, the error message occurs:
Server Error in '/' Application.
The view 'ActivateStudy' or its master was not found or no view engine supports the searched locations.The following locations were searched:
~/Views/Home/ActivateStudy.aspx
~/Views/Home/ActivateStudy.ascx
~/Views/Shared/ActivateStudy.aspx
~/Views/Shared/ActivateStudy.ascx
~/Views/Home/ActivateStudy.cshtml
~/Views/Home/ActivateStudy.vbhtml
~/Views/Shared/ActivateStudy.cshtml
~/Views/Shared/ActivateStudy.vbhtml
What am I missing? Some additional parameter in RedirectToAction(...)? Some new entry in RouteConfig?
If you have a _ViewStart.cshtml and it contains something like the following you do not need to specify the same layout in each view.
#{
Layout = "~/Views/Shared/_Layout.cshtml";
}
I believe your problem might be related to a missing slash. In your comment you said that your view contained the following line:
Layout = "~Views/Shared/_Layout.cshtml"
I think that needs to have a slash after the tilde.
Layout = "~/Views/Shared/_Layout.cshtml"
But, as I said, you should be able to remove that line entirely.
Well, I don't know if this is the "right" way to do it, but, in my HomeController.ActivateStudy() method, I return the Home "index" view:
public ActionResult ActivateStudy()
{
// code elided for clarity
return View( "Index" );
}
And this works.
It's at times like these where you realize you really don't know very much. Back to the books and code editor. Learn by doing.
I'm using attribute routing with asp mvc 5.2.
I want the user to be able to call the urls with a language token like "de" or "en".
mydomain/en/foo
For this I created a routeattribute with a constraint like descriped in this blog post
This works well when the locale part is at the end like:
[LocaleRoute("home/index/{locale}/","de)]
then i could call home/index or home/index/de
But when I move this to the beginning i can't omit the locale any more.
[LocaleRoute("{locale}/home/index","de)]
There i can only call de/home/index but home/index returns a 404 Notfound. Also the constraint isn't called. It looks like the route isn't just found.
Any hints on what i'm doing wrong and what i have to do?
You probably need to specify that the locale is an optional argument :
[LocaleRoute("{locale?}/home/index","de")]
On a side note, are you using different controllers for de & en ? If you use a single controller you don't need LocaleRoute and can use the default attributes (http://blogs.msdn.com/b/webdev/archive/2013/10/17/attribute-routing-in-asp-net-mvc-5.aspx) :
[RouteArea("{locale?}/home/")]
public class HomeController : Controller
{
...
[Route("index")]
public ActionResult Index (string culture)
{
...
}
}
EDIT
You can also register your action with the following additional route as long as you're careful with providing a default value for culture or treating null.
[Route("/home/index")]
I am working with a custom list I've built in SharePoint. I have a feature which has an event handler that kicks off when the feature is activated. That event handler calls (I can debug and watch it execute) the following function:
I'm intending that the function behaves as described here.
private void OrderFields(SPWeb web)
{
// This works fine: I get the expected SPContentType object
// There is only 1 SPContentType in ContentTypes
SPContentType contentType = web.Lists[TASK_LIST_NAME].ContentTypes[0];
contentType.FieldLinks.Reorder(new string[4]
{
"Field1",
"Field2",
"Field3",
"Field4"
});
contentType.Update();
}
The function that calls OrderFields calls web.Update(); as well.
Unfortunately, the reorder call does not reorder my fields on my form. Am I missing something? Can anyone suggest any tricks I might try?
The problem was with my field names.
I had two lookups that I'd programmatically created earlier in the execution of the event handler. These lookups' internal names vary significantly from the "Field1" and "Field2" names I'd given them in the Name and StaticName parameters of my CAML definition.
Instead, the internal name seems to be composed of the Display Name, spaces replaced by "0020", and truncated to a length of 32 characters.