Since the October 2022 refresh, Xperience by Kentico has supported the use of multiple visibility conditions in the properties of page builder and form builder components. This means that the visibility of one property can be dependent on multiple other properties at the same time.
Let's create a widget that demonstrates this functionality. A widget for embedding videos in the page could be a good opportunity for this-- Since certain features may vary depending on the video service provider, we can use visibility conditions to make use of the available features for each service, depending on the selection.
For the purposes of this example, the widget will be added into the Dancing Goat sample site, and will use DancingGoat namespaces. It will also include code snippets both for older, backwards-compatible C# form components, and newer React-based form components.
Setting the goal
The goal of our widget will be to display a video embed in a page. Most video content shared on the internet is hosted on video sharing platforms, so we should include some of these services as options, along with the ability to embed a publicly accessible video file via the HTML <video>
tag.
However, using multiple video sharing platforms introduces some complication-- not all platforms have the same features. For instance, Youtube and Vimeo allow for a video to be started midway, at a certain point in time, while Dailymotion does not. Conversely, Vimeo and Dailymotion can be sized dynamically (percentage-based widths in CSS), while Youtube did not play well with this in my testing.
Based on these available features, we can use visibility conditions to show or hide different configuration options.
Defining the properties
Basic properties
In the Dancing Goat project, go to ~/Components/Widgets
and add a new folder called VideoEmbedWidget
. Within this folder, define a properties class that inherits from IWidgetProperties
.
namespace DancingGoat.Widgets;
public class VideoEmbedWidgetProperties : IWidgetProperties
{
}
Add the necessary using
directives to the file.
using Kentico.Forms.Web.Mvc;
using Kentico.PageBuilder.Web.Mvc;
using Kentico.Xperience.Admin.Base.FormAnnotations;
using DancingGoat.FormComponents;
Next, define some constants within the class to hold the code names of the services we will use. Let's go with the examples cited above.
public const string YOUTUBE = "youtube";
public const string VIMEO = "vimeo";
public const string DAILYMOTION = "dailymotion";
public const string FILE = "file";
Now let's consider what kinds of properties we'll need.
Since the video can come from several places, and we'll need to react differently based on where, let's add property that signifies which service a video is from, and another to hold the url of the video itself.
public string Service { get; set; }
public string Url { get; set; }
Next, we can signify whether the video should be sized dynamically, using a boolean property, and also add properties for the dimensions when its size is explicitly specified.
public bool DynamicSize { get; set; }
public int Width { get; set; }
public int Height { get; set; }
Finally, let's add properties to specify whether the video should be played from the beginning, or from a timestamp, and what that timestamp should be.
public bool PlayFromBeginning { get; set; }
public int StartingTime { get; set; }
Editing controls
Now we can assign editing controls to our properties.
Since the Service property should be picking from a finite list of options, let's make it use radio buttons, and have it default to the Youtube option, since this is the most popular video sharing service. We can assign a plain textbox to the Url property.
[RadioGroupComponent(Label = "Video service", Inline = true, Order = 1, Options = YOUTUBE + ";YouTube\r\n" + VIMEO + ";Vimeo\r\n" + DAILYMOTION + ";Dailymotion\r\n" + FILE + ";File URL\r\n")]
public string Service { get; set; } = YOUTUBE;
[TextInputComponent(Label = "Url", Order = 2)]
public string Url { get; set; }
We can use checkbox components for the boolean properties DynamicSize
and PlayFromBeginning
, and number or integer components for Width
, Height
, and StartingTime
.
Set the default starting time to 0, since we don't know how long the provided videos will be, and choose default dimensions that seem appropriate. Below are the default dimensions Youtube seems to use when generating embeds for videos with the standard aspect ratio.
[CheckBoxComponent(Label = "Size dynamically", Order = 3)]
public bool DynamicSize { get; set; } = true;
[NumberInputComponent(Label = "Width (px)", Order = 4)]
public int Width { get; set; } = 560;
[NumberInputComponent(Label = "Height (px)", Order = 5)]
public int Height { get; set; } = 315;
[CheckBoxComponent(Label = "Play from beginning", Order = 6)]
public bool PlayFromBeginning { get; set; } = true;
[NumberInputComponent(Label = "Starting time (seconds)", Order = 7)]
public int StartingTime { get; set; } = 0;
Standard visibility conditions
Now we can add visibility conditions to these properties.
In my testing, Youtube embeds seem to get a bit wonky when trying to size them dynamically (via percentage-based CSS). There may be some way around this with CSS wizardry, but that's not my strong suit, so let's hide the DynamicSize
property when Youtube is the selected service.
[CheckBoxComponent(Label = "Size dynamically", Order = 3)]
[VisibleIfNotEqualTo(nameof(Service), YOUTUBE)]
public bool DynamicSize { get; set; } = true;
Similarly, Dailymotion does not allow embeds to start at a specific timestamp, so we can hide the PlayFromBeginning
checkbox when it is the selected service.
[CheckBoxComponent(Label = "Play from beginning", Order = 6)]
[VisibleIfNotEqualTo(nameof(Service), DAILYMOTION)]
public bool PlayFromBeginning { get; set; } = true;
Next, let's determine the visibility of the StartingTime
numeric input. We want this to hidden when PlayFromBeginning
is true, as well as when Dailymotion is selected, regardless of the value of PlayFromBeginning
. Thankfully, these conditions can be applied by stacking multiple visibility conditions. The StartingTime
property will only be visible when both conditions are met.
[NumberInputComponent(Label = "Starting time (seconds)", Order = 7)]
[VisibleIfFalse(nameof(PlayFromBeginning))]
[VisibleIfNotEqualTo(nameof(Service), DAILYMOTION)]
public int StartingTime { get; set; } = 0;
Setting up the widget display
Now let's add a viewmodel and view for our widget.
Viewmodel
Create a class called VideoEmbedWidgetViewModel
in the DancingGoat.Widgets
namespace.
namespace DancingGoat.Widgets;
public class VideoEmbedWidgetViewModel
{
//...
}
Add a string property called Markup
, which we can use to store the Html that will be rendered for the widget. Due to the variety of video platforms, and potential configuration within each one, a lot of conditional logic will go into assembling the markup for the embed, so we should evaluate this logic in the view component rather than the view.
public string Markup { get; set; }
View
Now let's add the view. Create a new view file in the same folder called _VideoEmbedWidget.cshtml
.
This aligns with the conventions set by the other widgets in the Dancing Goat project, such as HeroImageWidget
and ProductCardWidget
, though other projects may have their own conventions. The system automatically checks the ~/Views/Shared/Widgets
for a view named _{your widget identifier}.cshtml
when no view is specified in the widget registration or the view component's result.
For the purposes of this example, we'll keep it simple, and simply render the markup passed in the viewmodel.
@model DancingGoat.Widgets.VideoEmbedWidgetViewModel
@Html.Raw(Model.Markup)
Creating the ViewComponent
Now we can create a ViewComponent
class that holds most of the widget's logic. Add a new file to your VideoEmbedWidget
folder called VideoEmbedWidgetViewComponent.cs
.
Give this class the following using directives.
using Kentico.PageBuilder.Web.Mvc;
using Microsoft.AspNetCore.Mvc;
using System;
using System.Web;
using DancingGoat.Widgets;
using Microsoft.Extensions.Localization;
Use the DancingGoat.Widgets
namespace, and make the class inherit from ViewComponent
.
namespace DancingGoat.Widgets;
public class VideoEmbedWidgetViewComponent : ViewComponent
{
//...
}
Add a constant for the widget's identifier. This is the value that is used to connect the widget configurations saved for each instance of your widget in the database with the code that is used to render it.
public const string IDENTIFIER = "DancingGoat.VideoEmbedWidget";
Then add the RegisterWidget
assembly attribute to this class, passing each of the following parameters.
- The identifier constant.
- The type of the widget's view component.
- A localization macro for the display name of the widget, which is rendered in the page builder.
- The type of the widget's properties.
- A localization macro for the description of the widget, which is rendered in the page builder
- The CSS class of the icon which should visually represent the widget in the listing in page builder. Let's use the right-facing triangle icon, which resembles a "play" button.
[assembly: RegisterWidget(
identifier: VideoEmbedWidgetViewComponent.IDENTIFIER,
viewComponentType: typeof(VideoEmbedWidgetViewComponent),
name: "Video embed",
propertiesType: typeof(VideoEmbedWidgetProperties),
Description = "Embeds a video in the page.",
IconClass = "icon-triangle-right")]
The path to the widget's view can also be included in the widget registration, but in keeping with this project's conventions, we will forgo this option and handle it elsewhere.
Add a private property for an IStringLocalizer
, utilizing the SharedResources
type which is included in the Dancing Goat by default, and populate it through dependency injection in the constructor.
private readonly IStringLocalizer<SharedResources> localizer;
public VideoEmbedWidgetViewComponent(IStringLocalizer<SharedResources> localizer)
{
this.localizer = localizer;
}
This will open up the widget to the possibility of localization for its error messages in the future.
With this setup taken care of, we can look into the meat of the component.
The primary method of a view component is Invoke, of the type IViewComponentResult
. For a parameter, it takes a ComponentViewModel
with a generic type parameter to hold the type of its properties. This can be set to the IWidgetProperties
implementation defined earlier, VideoEmbedWidgetProperties
.
public IViewComponentResult Invoke(ComponentViewModel<VideoEmbedWidgetProperties> widgetProperties)
{
//...
}
In keeping with the conventions of the other Dancing Goat widgets, this method should return a view, the path to which is passed directly to the View()
method rather than through the widget registration attribute.
We'll pass an instance of the VideoEmbedWidgetViewModel
class that we created earlier as the model for this view. This view model has a string property called Markup
, which holds the HTML markup of the video embed. We should add a method to supply this markup, which uses the supplied VideoEmbedWidgetProperties
to generate the proper markup.
Let's call it GetEmbedMarkup
, and add a call in the Invoke method.
public IViewComponentResult Invoke(ComponentViewModel<VideoEmbedWidgetProperties> widgetProperties)
{
string markup = GetEmbedMarkup(widgetProperties.Properties);
return View("/Components/Widgets/VideoEmbedWidget/_VideoEmbedWidget.cshtml", new VideoEmbedWidgetViewModel { Markup = markup });
}
Now, with this example of how it should work, let's implement this method. It needs to take a VideoEmbedWidgetProperties
object as a parameter.
private string GetEmbedMarkup(VideoEmbedWidgetProperties widgetProperties)
{
//...
}
Since all of this functionality hinges on embedding a video, let's first make sure the video is not null, and return a message if it is missing. Since this text will be returned as markup, it will be rendered to the page builder interface, where the editor can read it and react accordingly.
if(widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
//...
}
return localizer["Please make sure the URL property is filled in."];
Now we can check which video service is selected, and create HTML markup accordingly. Let's use a switch expression to return a different method for each video service. We can use calls to methods that don't exist yet, and then go through an implement them one-at-a-time.
Check the value of widgetProperties.Service
against the various constants defined in the properties, and return a message if there are no matches.
return widgetProperties.Service switch
{
VideoEmbedWidgetProperties.YOUTUBE => GetYoutubeMarkup(widgetProperties),
VideoEmbedWidgetProperties.VIMEO => GetVimeoMarkup(widgetProperties),
VideoEmbedWidgetProperties.DAILYMOTION => GetDailyMotionMarkup(widgetProperties),
VideoEmbedWidgetProperties.FILE => GetFileMarkup(widgetProperties),
_ => localizer["Specified video service not found."],
};
The resulting GetEmbedMarkup
file should look like this.
private string GetEmbedMarkup(VideoEmbedWidgetProperties widgetProperties)
{
if(widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
return widgetProperties.Service switch
{
VideoEmbedWidgetProperties.YOUTUBE => GetYoutubeMarkup(widgetProperties),
VideoEmbedWidgetProperties.VIMEO => GetVimeoMarkup(widgetProperties),
VideoEmbedWidgetProperties.DAILYMOTION => GetDailyMotionMarkup(widgetProperties),
VideoEmbedWidgetProperties.FILE => GetFileMarkup(widgetProperties),
_ => localizer["Specified video service not found."],
};
}
return localizer["Please make sure the URL property is filled in."];
}
Let's start out with the Youtube-specific method first. Create a string method called GetYoutubeMarkup
, which takes a VideoEmbedWidgetProperties
object as a parameter.
private string GetYoutubeMarkup(VideoEmbedWidgetProperties widgetProperties)
{
//...
}
Start out by validating the parameters, like in the GetEmbedMarkup
method. The code should not reach this point if the properties or URL is null, but it's a good practice to validate nullable parameters anyway, in case there are future changes to how the method is called.
if (widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
//...
}
return localizer["Please make sure the URL property is filled in."];
Let's add a call to a method that extracts the Youtube ID of the video from the URL, which we will implement next.
string videoId = GetYoutubeId(widgetProperties.Url);
Then, validate this video ID and assemble the markup if it is not empty.
Based on whether or not the video is set to play from the beginning, we can make a query string using the start
parameter.
Then, we can drop all of our pieces into an iframe
, which is configured to mirror the markup provided by the embed sharing option on Youtube itself.
if (!string.IsNullOrEmpty(videoId))
{
string query = widgetProperties.PlayFromBeginning ? string.Empty : $"?start={widgetProperties.StartingTime}";
return $"<iframe width=\"{widgetProperties.Width}\" height=\"{widgetProperties.Height}\" src=\"https://www.youtube.com/embed/{videoId}{query}\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share\" allowfullscreen></iframe>";
}
return localizer["Unable to parse Youtube video ID from the provided Url."];
The ending result will look like this:
private string GetYoutubeMarkup(VideoEmbedWidgetProperties widgetProperties)
{
if (widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
string videoId = GetYoutubeId(widgetProperties.Url);
if (!string.IsNullOrEmpty(videoId))
{
string query = widgetProperties.PlayFromBeginning ? string.Empty : $"?start={widgetProperties.StartingTime}";
return $"<iframe width=\"{widgetProperties.Width}\" height=\"{widgetProperties.Height}\" src=\"https://www.youtube.com/embed/{videoId}{query}\" title=\"YouTube video player\" frameborder=\"0\" allow=\"accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share\" allowfullscreen></iframe>";
}
return localizer["Unable to parse Youtube video ID from the provided Url."];
}
return localizer["Please make sure the URL property is filled in."];
}
Moving on to the GetYoutubeId
method mentioned above, it should be of the type string
, and take a string parameter for the URL.
private string GetYoutubeId(string url)
{
//...
}
Here, we have to find the video ID based on the provided Youtube URL. However, we may encounter multiple formats of Youtube URL, as we don't know where the editor using the widget is going to be copy/pasting from. For example, all of the following URLs have the same video ID, dQw4w9WgXcQ
.
- https://www.youtube.com/watch?v=dQw4w9WgXcQ
- http://www.youtube.com/watch?feature=player_embedded&v=dQw4w9WgXcQ
- https://youtu.be/dQw4w9WgXcQ
- http://m.youtube.com/v/dQw4w9WgXcQ
- https://youtube.com/v/dQw4w9WgXcQ?feature=youtube_gdata_player
It seems that if the video URL is passed through the query string, it will reliably be sent as the v parameter. Otherwise, it will be the final part of the path, prior to a possible query string.
To account for this, let's make two more methods-- one to pull the video ID from the query parameter, and another to get it from the end of the path. We can call the first, then if it returns no result, call the second.
if (!string.IsNullOrEmpty(url))
{
string queryId = GetIdFromQuery(url, "v");
return string.IsNullOrEmpty(queryId) ? GetFinalPathComponent(url) : queryId;
}
return string.Empty;
GetIdFromQuery
should take two string parameters-- one for the Url, and one for the name of the query parameter.
private string GetIdFromQuery(string url, string paramName)
{
//...
}
Validate the parameters, and construct a Uri from the URL if they are not empty. This object has a property called Query
, which can be parsed by the HttpUtility
class if it is not null.
if (!string.IsNullOrEmpty(url) && !string.IsNullOrEmpty(paramName))
{
Uri uri = new Uri(url);
if (!string.IsNullOrEmpty(uri.Query))
{
var query = HttpUtility.ParseQueryString(uri.Query);
//...
}
}
return string.Empty;
Finally, we can validate this parsed collection of values and extract the necessary parameter.
if (query != null)
{
return query.Get(paramName);
}
Altogether, the method should look like this.
private string GetIdFromQuery(string url, string paramName)
{
if (!string.IsNullOrEmpty(url) && !string.IsNullOrEmpty(paramName))
{
Uri uri = new Uri(url);
if (!string.IsNullOrEmpty(uri.Query))
{
var query = HttpUtility.ParseQueryString(uri.Query);
if (query != null)
{
return query.Get(paramName);
}
}
}
return string.Empty;
}
The other method for finding the ID from the path only needs one parameter, the URL.
private string GetFinalPathComponent(string url)
{
//...
}
After validating the URL, use the string.Split()
method to isolate everything that comes before the query string, if there is one.
if(!string.IsNullOrEmpty(url))
{
string baseUrl = url.Split('?')[0];
//...
}
return string.Empty;
Then, you can split the string again, this time on the /
character. If the resulting array has more than 3 elements, it means that the URL contains more than just the protocol and domain, so you can return the final element of this array.
var urlComponents = baseUrl.Split('/');
if(urlComponents.Length > 3)
{
return urlComponents[urlComponents.Length - 1];
}
The resulting method should look like this, once all is finished.
private string GetFinalPathComponent(string url)
{
if(!string.IsNullOrEmpty(url))
{
string baseUrl = url.Split('?')[0];
var urlComponents = baseUrl.Split('/');
if(urlComponents.Length > 3)
{
return urlComponents[urlComponents.Length - 1];
}
}
return string.Empty;
}
That should wrap up the Youtube method, so now we can move on to the next.
GetVimeoMarkup
will be similar overall, but with the addition of dynamic sizing, and no need to look for the video ID in the query string.
Start out by validating the properties and the URL, before using the GetFinalPathComponent
method to retrieve the Vimeo ID. Validate the retrieved ID as well.
private string GetVimeoMarkup(VideoEmbedWidgetProperties widgetProperties)
{
if (widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
var videoId = GetFinalPathComponent(widgetProperties.Url);
if (!string.IsNullOrEmpty(videoId))
{
//...
}
return localizer["Unable to parse Vimeo video ID from the provided Url."];
}
return localizer["Please make sure the URL property is filled in."];
}
Vimeo's embeds use an anchor rather than a query string parameter to specify what time the video should start at, but the process of getting the anchor should be nearly identical to that of the query string in the Youtube example.
string anchor = widgetProperties.PlayFromBeginning
? string.Empty
: $"#t={widgetProperties.StartingTime}s";
Next, we want to render different markup depending on whether the video should be sized dynamically, or with explicit width and height.
The share embed functionality on Vimeo nests the iframe
within a div with certain styles, and provides a script for adapting to the size responsively. The markup for the static sized version looks somewhat similar to Youtube.
if(widgetProperties.DynamicSize)
{
return $"<div style=\"padding: 56.25% 0 0 0;position:relative;\"><iframe src=\"https://player.vimeo.com/video/{videoId}{anchor}\" style=\"position:absolute;top:0;left:0;width:100%;height:100%;\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></div><script src=\"https://player.vimeo.com/api/player.js\"></script>";
}
else
{
return $"<iframe src=\"https://player.vimeo.com/video/{videoId}{anchor}\" width=\"{widgetProperties.Width}\" height=\"{widgetProperties.Height}\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen ></iframe >";
}
The completed GetVimeoMarkup
method should look like this.
private string GetVimeoMarkup(VideoEmbedWidgetProperties widgetProperties)
{
if (widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
var videoId = GetFinalPathComponent(widgetProperties.Url);
if (!string.IsNullOrEmpty(videoId))
{
string anchor = widgetProperties.PlayFromBeginning ? string.Empty : $"#t={widgetProperties.StartingTime}s";
if(widgetProperties.DynamicSize)
{
return $"<div style=\"padding: 56.25% 0 0 0;position:relative;\"><iframe src=\"https://player.vimeo.com/video/{videoId}{anchor}\" style=\"position:absolute;top:0;left:0;width:100%;height:100%;\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen></iframe></div><script src=\"https://player.vimeo.com/api/player.js\"></script>";
}
else
{
return $"<iframe src=\"https://player.vimeo.com/video/{videoId}{anchor}\" width=\"{widgetProperties.Width}\" height=\"{widgetProperties.Height}\" frameborder=\"0\" allow=\"autoplay; fullscreen; picture-in-picture\" allowfullscreen ></iframe >";
}
}
return localizer["Unable to parse Vimeo video ID from the provided Url."];
}
return localizer["Please make sure the URL property is filled in."];
}
Continuing down the switch statement in GetEmbedMarkup
, we have GetDailyMotionMarkup
next, which is nearly identical to the Vimeo method, except that it has no functionality for starting the video partway through or special script. At this point, I think you should be able to make sense of it all.
private string GetDailyMotionMarkup(VideoEmbedWidgetProperties widgetProperties)
{
if (widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
var videoId = GetFinalPathComponent(widgetProperties.Url);
if (!string.IsNullOrEmpty(videoId))
{
if (widgetProperties.DynamicSize)
{
return $"<div style=\"position:relative;padding-bottom:56.25%;height:0;overflow:hidden;\"> <iframe style=\"width:100%;height:100%;position:absolute;left:0px;top:0px;overflow:hidden\" frameborder=\"0\" type=\"text/html\" src=\"https://www.dailymotion.com/embed/video/{videoId}\" width=\"100%\" height=\"100%\" allowfullscreen title=\"Dailymotion Video Player\" allow=\"autoplay\"></iframe></div>";
}
else
{
return $"<iframe src=\"https://www.dailymotion.com/embed/video/{videoId}\" width=\"{widgetProperties.Width}\" height=\"{widgetProperties.Height}\" frameborder=\"0\" type=\"text/html\" allowfullscreen title=\"Dailymotion Video Player\"></iframe>";
}
}
return localizer["Unable to parse Dailymotion video ID from the provided Url."];
}
return localizer["Please make sure the URL property is filled in."];
}
The last of the Markup methods is GetFileMarkup
. It will start out similarly to the others.
private string GetFileMarkup(VideoEmbedWidgetProperties widgetProperties)
{
if (widgetProperties != null && !string.IsNullOrEmpty(widgetProperties.Url))
{
//...
}
return localizer["Please make sure the URL property is filled in."];
}
However, this final method introduces a new requirement- The <video>
tag in Html utilizes an attribute called type which is typically set to values such as video/mp4
or video/ogg
. In order to populate this attribute, we'll need to find the file extension of the provided video.
Let's add a call to a new method, GetFileExtension
, and validate its result.
string extension = GetFileExtension(widgetProperties.Url);
if (!string.IsNullOrEmpty(extension))
{
//...
}
return localizer["Unable to parse file extension from the provided Url."];
The video tag supports starting times set through an anchor tag on the URL, similar to Vimeo.
string anchor = widgetProperties.PlayFromBeginning
? string.Empty
: $"#t={widgetProperties.StartingTime}";
Lastly, we can return a different video tag that uses either the style
or width
/height
attributes to set its size, depending on whether the properties indicate that it should be sized dynamically.
if (widgetProperties.DynamicSize)
{
return $"<video style=\"width:100%;\" controls><source src=\"{widgetProperties.Url}{anchor}\" type=\"video/{extension}\"></video>";
}
else
{
return $"<video width=\"{widgetProperties.Width}\" height=\"{widgetProperties.Height}\" controls><source src=\"{widgetProperties.Url}{anchor}\" type=\"video/{extension}\"></video>";
}
Now all that's left is to implement the GetFileExtension method. This method should take a URL like https://www.mywebsite.com/files/videofile.mp4 and isolate the .mp4
at the end.
Take the URL as a parameter, and set the return type to string
.
private string GetFileExtension(string url)
{
//...
}
After validating the URL, use the GetFinalPathComponent
method from earlier to get what comes after the last slash in the path.
if (!string.IsNullOrEmpty(url))
{
string finalComponent = GetFinalPathComponent(url);
//...
}
return string.Empty;
Split this string on the .
character, and if the resulting array has more than one element, return the final one as the file extension.
string[] parts = finalComponent.Split('.');
if (parts.Length > 1)
{
return parts[parts.Length - 1];
}
The resulting method should look like this.
private string GetFileExtension(string url)
{
if (!string.IsNullOrEmpty(url))
{
string finalComponent = GetFinalPathComponent(url);
string[] parts = finalComponent.Split('.');
if (parts.Length > 1)
{
return parts[parts.Length - 1];
}
}
return string.Empty;
}
More advanced visibility scenarios
You may have noticed that the properties that specify explicit dimensions for the video are still always visible. This is because the logic that should determine whether they are displayed is a bit more complicated.
They should always be displayed when the selected service is Youtube, but only when the checkbox for dynamic size is not enabled for any other service. Logically, it should look something like this:
(Service is Youtube) OR (DynamicSize is disabled)
or alternatively,
NOT((Service is not youtube) AND (Dynamic size is enabled))
With stacked visibility conditions, the field will only display when both of them are true. And since there's no way to negate the entire combination, we have to find an alternate way to evaluate a more complex boolean condition.
A custom visibility condition also does not quite cut it for this scenario. While they allow for more complex logic, they can only access the value of the property to which they are applied, and a single other property.
A complicating factor to adding this logic is that visibility conditions must depend on properties that are rendered in the properties form. We can't rely on the value of a get
accessor for a property that does not have an editing component, or which is currently hidden.
However, if we can get a field that technically renders without actually displaying anything, we can use its get
accessor however we please, without worrying about its value becoming inaccessible due to a visibility condition change.
So in order to accomplish this end, let's create an invisible form component.
Invisible component in C#
If you're using C# components in your widget, follow along with this section. If not, move on to Invisible component in React.
Under the ~/Components
folder in the solution, add a folder called FormComponents
, then a folder called InvisibleComponent
within it. This will be the directory for our invisible component.
Create a new class called InvisibleProperties
in the DancingGoat.FormComponents
namespace, extending FormComponentProperties<bool>
. You'll need using directives for CMS.DataEngine
and Kentico.Forms.Web.Mvc
.
using CMS.DataEngine;
using Kentico.Forms.Web.Mvc;
namespace DancingGoat.FormComponents;
public class InvisibleProperties : FormComponentProperties<bool>
{
//...
}
Passing a bool to the generic type of FormComponentProperties
allows this editing component to apply to be used on boolean properties.
Next, call the constructor of the base class, specifying the Boolean
data type for the field.
public InvisibleProperties() : base(FieldDataType.Boolean)
{
}
Override the DefaultValue
and Label
properties, adjusting the latter so that its get accessor always returns an empty string.
public override bool DefaultValue { get; set; }
public override string Label { get => string.Empty; set => base.Label = value; }
The finished properties class should look like this.
using CMS.DataEngine;
using Kentico.Forms.Web.Mvc;
namespace DancingGoat.FormComponents;
public class InvisibleProperties : FormComponentProperties<bool>
{
public InvisibleProperties() : base(FieldDataType.Boolean)
{
}
public override bool DefaultValue { get; set; }
public override string Label { get => string.Empty; set => base.Label = value; }
}
Next, add a completely blank file called _Invisible.cshtml
to the same folder.
Lastly, add a final file called InvisibleFormComponent.cs
. Give it the following using directives.
using Kentico.Forms.Web.Mvc;
using DancingGoat.FormComponents;
Use the namespace DancingGoat.FormComponents
and have the class inherit from FormComponent<InvisibleProperties,bool>
. This ensures that the component is applicable to a boolean property, and utilizes the previously defined properties.
namespace DancingGoat.FormComponents;
public class InvisibleFormComponent : FormComponent<InvisibleProperties, bool>
{
//...
}
Add constants for the identifier and name of the component.
public const string IDENTIFIER = "Custom.InvisibleComponent";
public const string NAME = "InvisibleComponent";
Using these new constants we can add the RegisterFormComponent
assembly attribute to register the editing component. Place the attribute above the namespace declaration.
[assembly: RegisterFormComponent(
identifier: InvisibleFormComponent.IDENTIFIER,
componentType: typeof(InvisibleFormComponent),
name: InvisibleFormComponent.NAME,
ViewName = "/Components/FormComponents/InvisibleComponent/_Invisible.cshtml",
IsAvailableInFormBuilderEditor = false)]
Getting back to the class, we can add a property to hold the value, and use it to override the required abstract methods GetValue
and SetValue
. This allows the component to react to changes in the value of its property, enabling the visibility conditions to work properly.
public bool Value { get; set; }
public override bool GetValue()
{
return Value;
}
public override void SetValue(bool value)
{
Value = value;
}
The finished component class should look like this.
namespace DancingGoat.FormComponents;
public class InvisibleFormComponent : FormComponent<InvisibleProperties, bool>
{
public const string IDENTIFIER = "Custom.InvisibleComponent";
public const string NAME = "InvisibleComponent";
public bool Value { get; set; }
public override bool GetValue()
{
return Value;
}
public override void SetValue(bool value)
{
Value = value;
}
}
Invisible component in React
If you're using React components in your widget, follow along with this section.
Installing and setting up the boilerplate
To start out in React, download the admin customization boilerplate as described in the documentation with the name DancingGoat.WebAdmin
.
Don't forget to add a reference from your Dancing Goat solution to this new admin project.
dotnet add reference <the relative path from your main project's root to your custom admin csproj>
Then, set the CMSAdminClientModuleSettings
mode for the boilerplate in the Dancing Goat site's appsettings.json
file.
"CMSAdminClientModuleSettings": {
"dancinggoat-web-admin": {
"Mode": ""
}
}
Optionally, open the boilerplate project, and delete the ~/UIPages
folder, as well as the ~/Client/src/custom-layout/CustomLayoutTemplate.tsx
file. Then, open ~/Client/src/entry.tsx
and delete the following line:
export * from './custom-layout/CustomLayoutTemplate';
This will get rid of sample customizations for the UI which are not relevant to this article.
To complete the setup, follow the steps outlined in the documentation to rename the organization from acme
to dancinggoat
.
Creating the form component
Next, create a folder called invisible-form-component
in the ~/Client
directory, and add a file called InvisibleFormComponent.tsx
This will be our front-end file for the invisible form component, used by the administration UI. Because the whole point of our form component is to display nothing, it will be even simpler than the example provided by the documentation.
Unlike the documentation's example, we don't need to import react or the default form component properties. We aren't actually using the properties, or any react functionality. Simply export InvisibleFormComponent
to return nothing.
export const InvisibleFormComponent = () => {
return;
};
Now, switch back to the entry.tsx
file and export everything from this file.
export * from './invisible-form-component/InvisibleFormComponent';
Next, we can create the C# files that this control needs.
Add a folder called FormComponents
to the root of the project, and a folder called InvisibleComponent
inside of it.
Add a new C# file called InvisibleClientProperties.cs
. This class represents the properties passed to the administration application when it renders the react component.
Add a using directive for Kentico.Xperience.Admin.Base.Forms
; and set the namespace to DancingGoat.FormComponents
.
using Kentico.Xperience.Admin.Base.Forms;
namespace DancingGoat.FormComponents
Make the class inherit from FormComponentClientProperties<bool>
. This uses the bool type so that we can assign the component to a boolean property, which will work most easily with visibility conditions.
Since the component doesn't display anything, we can leave the class empty, with a final result like this.
using Kentico.Xperience.Admin.Base.Forms;
namespace DancingGoat.FormComponents;
public class InvisibleClientProperties : FormComponentClientProperties<bool>
{
}
Next, add a similar class called InvisibleProperties.cs
, which represents the configuration of the component. Again, since this component renders nothing, the class can be empty.
using Kentico.Xperience.Admin.Base.Forms;
namespace DancingGoat.FormComponents;
public class InvisibleProperties : FormComponentProperties
{
}
Next, let's add an attribute class, which will allow us to use an attribute to assign the component to our widget property. This class doesn't need to contain anything-- it will only be used to map any widget properties that use it to the proper form component class.
Use the same namespace as the previous two files, and have it inherit from the FormComponentAttribute
class.
using Kentico.Xperience.Admin.Base.FormAnnotations;
namespace DancingGoat.FormComponents;
public class InvisibleComponentAttribute : FormComponentAttribute
{
}
Finally, we can tie all of these together with the component class. Create a new file, InvisibleFormComponent.cs
.
Add using directives for DancingGoat.FormComponents
and Kentico.Xperience.Admin.Base.Forms
, and place the class in the DancingGoat.FormComponents
namespace.
using DancingGoat.FormComponents;
using Kentico.Xperience.Admin.Base.Forms;
namespace DancingGoat.FormComponents;
// ...
Make the InvisibleFormComponent class inherit from FormComponent<InvisibleProperties,InvisibleClientProperties,bool>
. This connects the class with the properties and client properties, and specifies that it should be used on a boolean property.
Define IDENTIFIER
and NAME
constants for the class, and point the ClientComponentName
property to the front-end we defined previously. (Note that the app will automatically add FormComponent
to the end of the name passed here.)
public const string IDENTIFIER = "Custom.InvisibleComponent";
public const string NAME = "InvisibleComponent";
public override string ClientComponentName => "@dancinggoat/web-admin/Invisible";
Next, use the ComponentAttributeAttribute
to map InvisibleComponentAttribute
to this class.
[ComponentAttribute(typeof(InvisibleComponentAttribute))]
Finally, use the RegisterFormComponent
assembly attribute to register the form component.
[assembly: RegisterFormComponent(
identifier: InvisibleFormComponent.IDENTIFIER,
componentType: typeof(InvisibleFormComponent),
name: InvisibleFormComponent.NAME)]
Altogether, the class should look like this:
using DancingGoat.FormComponents;
using Kentico.Xperience.Admin.Base.Forms;
[assembly: RegisterFormComponent(
identifier: InvisibleFormComponent.IDENTIFIER,
componentType: typeof(InvisibleFormComponent),
name: InvisibleFormComponent.NAME)]
namespace DancingGoat.FormComponents;
[ComponentAttribute(typeof(InvisibleComponentAttribute))]
public class InvisibleFormComponent : FormComponent<InvisibleProperties, InvisibleClientProperties, bool>
{
public const string IDENTIFIER = "Custom.InvisibleComponent";
public const string NAME = "InvisibleComponent";
public override string ClientComponentName => "@dancinggoat/web-admin/Invisible";
}
Building the project
Now build the C# portion of the project through visual studio, and build the ~/Client
app through the command line with the command npm run build
. Thanks to the boilerplate's use of Babel, this will automatically transpile our typescript files to work in browsers.
Depending on whether your future changes involve client files, C# files, or both, you will need to determine which of these build options to use.
Then, go back to the Dancing Goat Xperience project and clean
and build
the solution.
Using the editing component on properties
With this form component in place, we can use it in the properties of our widget. Return to the VideoEmbedWidgetProperties.cs
file.
Let's create a new public bool property called ShowDimensions
. Since we're going to be putting custom functionality in the accessors, it will need an associated private variable. Create a private bool
variable called _showDimensions
.
private bool _showDimensions;
public bool ShowDimensions { get; set; }
Now we can update the get
accessor to return true when the dimensions should be displayed.
As discussed earlier, the dimensions should display when the service is youtube, or when DynamicSize
is disabled, so the the following boolean expression Service == YOUTUBE || !DynamicSize;
should be sufficient for this case, though the logic here can be as complex as necessary.
Add the boolean expression to the getter through an expression body definition.
get => Service == YOUTUBE || !DynamicSize;
Now that we've set the get
accessor in this way, the compiler will expect the same to be done for the set accessor. We can simply set the private variable to the provided value, even though this value will never be used.
set => _showDimensions = value;
Now we can assign our invisible component to the property. If you made the React component, use the InvisibleComponentAttribute
that we created earlier. If you made the C# component instead, use the typical EditingComponentAttribute
.
In either case, Set the order to 0 so that it comes before the fields that depend on it.
[InvisibleComponent(Order = 0)]
Now that this special property is in place, we can add visibility conditions depending on it to the Width
and Height
properties.
[NumberInputComponent(Label = "Width (px)", Order = 4)]
[VisibleIfTrue(nameof(ShowDimensions))]
public int Width { get; set; } = 560;
[NumberInputComponent(Label = "Height (px)", Order = 5)]
[VisibleIfTrue(nameof(ShowDimensions))]
public int Height { get; set; } = 315;
If you run the site now, you may notice a problem-- toggling the checkbox for the DynamicSize
property in the UI does not seem to make a difference. Currently, the dialog is not listening to changes to this control, because no other properties reference DynamicSize
through a normal visibility condition.
Let's use the invisible form component on another property, and make it depend on the DynamicSize
property, to ensure that the value of ShowDimensions
is re-evaluated when it changes.
We can call it DummyProperty
, and set its order to a very high number so that it logically comes after any properties it depends on.
[InvisibleComponent(Order = 999)]
[VisibleIfFalse(nameof(DynamicSize))]
public bool DummyProperty { get; set; }
Now, ShowDimensions
should be evaluated, and thus show and hide the Width
and Height
properties, whenever the DynamicSize
checkbox value changes.
In the end, with added summary comments, the properties class should look like this.
using Kentico.Forms.Web.Mvc;
using Kentico.PageBuilder.Web.Mvc;
using Kentico.Xperience.Admin.Base.FormAnnotations;
using DancingGoat.FormComponents;
namespace DancingGoat.Widgets;
public class VideoEmbedWidgetProperties : IWidgetProperties
{
public const string YOUTUBE = "youtube";
public const string VIMEO = "vimeo";
public const string DAILYMOTION = "dailymotion";
public const string FILE = "file";
private bool _showDimensions;
/// <summary>
/// Holds a complex boolean expression used in determining other fields' visibility.
/// </summary>
[InvisibleComponent(Order = 0)]
public bool ShowDimensions
{
get => Service == YOUTUBE || !DynamicSize;
set => _showDimensions = value;
}
/// <summary>
/// Defines the video platform from which the embedded video originates.
/// </summary>
[RadioGroupComponent(Label = "Video service", Inline = true, Order = 1, Options = YOUTUBE + ";YouTube\r\n" + VIMEO + ";Vimeo\r\n" + DAILYMOTION + ";Dailymotion\r\n" + FILE + ";File URL\r\n")]
public string Service { get; set; } = YOUTUBE;
/// <summary>
/// Defines the URL of the embedded video.
/// </summary>
[TextInputComponent(Label = "Url", Order = 2)]
public string Url { get; set; }
/// <summary>
/// Determines whether the video should be sized dynamically or with explicit dimensions.
/// </summary>
[CheckBoxComponent(Label = "Size dynamically", Order = 3)]
[VisibleIfNotEqualTo(nameof(Service), YOUTUBE)]
public bool DynamicSize { get; set; } = true;
/// <summary>
/// Determines the width of the embed.
/// </summary>
[NumberInputComponent(Label = "Width (px)", Order = 4)]
[VisibleIfTrue(nameof(ShowDimensions))]
public int Width { get; set; } = 560;
/// <summary>
/// Determines the height of the embed.
/// </summary>
[NumberInputComponent(Label = "Height (px)", Order = 5)]
[VisibleIfTrue(nameof(ShowDimensions))]
public int Height { get; set; } = 315;
/// <summary>
/// Defines the time to start the player at.
/// </summary>
[CheckBoxComponent(Label = "Play from beginning", Order = 6)]
[VisibleIfNotEqualTo(nameof(Service), DAILYMOTION)]
public bool PlayFromBeginning { get; set; } = true;
/// <summary>
/// Determines whether the video will start at the beginning, or at a specified timestamp.
/// </summary>
[NumberInputComponent(Label = "Starting time (seconds)", Order = 7)]
[VisibleIfFalse(nameof(PlayFromBeginning))]
[VisibleIfNotEqualTo(nameof(Service), DAILYMOTION)]
public int StartingTime { get; set; } = 0;
/// <summary>
/// Makes sure all necessary properties used in ShowDimensions are listened to.
/// </summary>
[InvisibleComponent(Order = 999)]
[VisibleIfFalse(nameof(DynamicSize))]
public bool DummyProperty { get; set; }
}
Conclusion
Now you should have a functioning video embed widget. Feel free to modify the code and add more styles and features. You can find a repository on GitHub with the code of the widget and component. The main branch uses react components for the widget properties, while the CSharpComponents
branch uses C# components.