This post was written for Xperience by Kentico v29.0.0. Please consult the documentation for any feature or API changes when using a different version. Be sure to check the System Requirements in the product documentation.
If you are interested in following along with some runnable code, or do some content modeling experimentation, go review the open source Xperience by Kentico Labs: Custom Data Types project.
Xperience by Kentico comes with a powerful 💪🏾 suite of content modeling features, like taxonomies, reusable field schemas, and modeling assets as content items.
One key content modeling feature, linked content items, builds on modeling each piece of content as a separate content item and defining relationships between multiple items.
A library of content in Xperience by Kentico (both in channels and the Content hub) represents a graph of these content relationships, which can be traversed in websites, emails, and for headless content delivery.
Owned content
Sometimes, we want to model content in a highly structured way 🏗️, but we don't need (or want) it to participate in this graph of content relationships.
To help explain this use-case, imagine we want to model various businesses as a content type. We can use the Dancing Goat Cafe content type as a starting point.
If you're new to Xperience by Kentico and want to learn how to get started, watch our Getting Started Technical Spotlight video.
This content type includes the cafe name and photo along with its address. The address is modeled as individual fields. This is much better 👍🏼 than using 1 rich text field for an address because it improves content governance for marketers. Each address field can have its own validation, developers can access the fields individually to apply a specific design to each part of the address, and the address content can be presented using semantic HTML and schema microdata.
This content is owned by the Cafe content type because we cannot access this address content without going through the cafe it belongs to.
Multiple addresses
What happens if a cafe represents a business that has multiple addresses 🤔? We have a few options, which might or might not be a good fit, depending on our goals.
Multiple sets of fields
We could create multiple sets of address fields in the Cafe content type - CafeCity1
, CafeCity2
, ect...
This would let each cafe own its addresses (they are fields of the content type), but we have to know in advance the maximum of addresses a cafe/business can have. If decide 3 sets of fields is enough, any cafe with only 1 or 2 addresses is going to have an entire set of fields empty, which feels wrong 😕. If a marketer wants to rearrange the order of addresses - because the order impacts the presentation - they have to play the copy/paste equivalent of musical chairs, which is a pretty poor user experience 😖.
Re-modeling
Another option is to re-model - represent the business as a new "Business" content type with a "Content items" type field so it can have 1 or more related addresses (or cafes). We then create a new separate Address/Cafe content item for every address and link them to the parent business. This scales well and has a good content authoring experience 😊.
If this fits how our marketing team (and organization) thinks about this content, then it seems like we found our solution 👏🏽!
However we now have a new awkward problem - the Content hub is full of addresses. Let's consider the following questions.
- Does it make sense to model an address as its own content item 🤷🏻♀️? Maybe... but, maybe not.
- Do we prefer to think of the address as being "related to" the business or owned by it?
- Does it make sense for an address to stand on its own?
- In our Xperience by Kentico solution, will that address relate to anything else?
- Does it just become an annoyance to see addresses in the Content hub every time we go searching for content?
It quickly becomes apparent how this easy solution might not align with the experience we want marketers (and developers) to have 😒.
Embedded structured content
What we're really looking for is something that meets the following requirements:
- Structured - We need the address fields to be structured so that we can present them according to our needs.
- Owned - We want the address to be part of the business because it doesn't make sense for an address to be its own content item - it's not truly "reusable".
- Repeatable - We need to have a variable number of addresses but no leftover fields - all the benefits of a separate, related content item without it actually being separate.
Thanks to Xperience by Kentico's underlying content modeling technologies, we can achieve these requirements with a bit of custom development effort 😲.
Custom data type
Xperience by Kentico allows us to create a custom data type, which is partially documented, but a step-by-step use-case example isn't there yet, so let's dig into the idea here 👍🏾.
Server-side setup
We need a C# class to represent our custom Address data type, so let's create that.
namespace DancingGoat;
public class AddressDataType
{
public const string FIELD_TYPE_LIST = "addresslist";
public Guid ID { get; set; } = Guid.NewGuid();
public string Street { get; set; } = "";
public string City { get; set; } = "";
public string StateProvince { get; set; } = "";
public string PostalCode { get; set; } = "";
public string Country { get; set; } = "";
public string Phone { get; set; } = "";
}
Now, we'll register a data type in a custom module with this C# class.
using DancingGoat;
[assembly: RegisterModule(typeof(CustomDataTypeModule))]
namespace DancingGoat;
public class CustomDataTypeModule : Module
{
public CustomDataTypeModule() : base(nameof(CustomDataTypeModule)) { }
protected override void OnPreInit(ModulePreInitParameters parameters)
{
DataTypeManager.RegisterDataTypes(
new DataType<IEnumerable<AddressDataType>>(
sqlType: "nvarchar(max)",
fieldType: AddressDataType.FIELD_TYPE_LIST,
schemaType: "xs:string",
conversionFunc: JsonDataTypeConverter.ConvertToModels,
dbConversionFunc: JsonDataTypeConverter.ConvertToString,
textSerializer: new DefaultDataTypeTextSerializer(
AddressDataType.FIELD_TYPE_LIST))
{
TypeAlias = "string",
TypeGroup = "String",
SqlValueFormat = DataTypeManager.UNICODE,
DbType = SqlDbType.NVarChar,
DefaultValueCode = "[]",
DefaultValue = [],
HasConfigurableDefaultValue = false,
});
}
}
This data type will be represented as nvarchar(max)
in the database because we'll serialize the structured content to JSON when saved. We use utilities provided by Xperience for the conversionFunc
, dbConversionFunc
, and textSerializer
values.
We also need to provide a code generator for this data type so that when we re-generate the Cafe content type C# class, we have a strongly typed C# property that represents our list of addresses 🤩. We can do this directly below our DataTypeManager.RegisterDataTypes()
call.
DataTypeCodeGenerationManager.RegisterDataTypeCodeGenerator(
AddressDataType.FIELD_TYPE_LIST,
() => new DataTypeCodeGenerator(
field => "IEnumerable<AddressDataType>",
field => nameof(ValidationHelper.GetString),
field => "[]",
field => ["System.Collections.Generic", "DancingGoat"]));
Xperience now knows about this new data type that we can use for fields, but it doesn't know how values of that data type can be edited. For that, we need to create a custom Form Component which will create the editing experience we want marketers to have.
public class AddressListFormComponentAttribute
: FormComponentAttribute { }
[ComponentAttribute(typeof(AddressListFormComponentAttribute))]
public class AddressListFormComponent : FormComponent<
AddressListFormComponentProperties,
AddressListFormComponentClientProperties,
IEnumerable<AddressDataType>>
{
public const string IDENTIFIER =
"DancingGoat.FormComponent.AddressListDataType";
public override string ClientComponentName =>
"@acme/web-admin/AddressDataTypeList";
}
public class AddressListFormComponentProperties
: FormComponentProperties { }
public class AddressListFormComponentClientProperties :
FormComponentClientProperties<IEnumerable<AddressDataType>>
{
public AddressDataType NewAddress { get; } = new AddressDataType();
}
For the above component, we're relying on the scaffolded Admin UI customization boilerplate and the custom UI Form Component documentation.
The AddressListFormComponentAttribute
allows us to assign this UI Form Component to Page Builder properties and the AddressListFormComponent
does the actual work of connecting the back-end data type management and the front-end React form.
Again, check out the Xperience by Kentico Labs: Custom Data Types project on GitHub for more details.
We'll also want to register this Form Component at the top of the file where we define it ✔️.
[assembly: RegisterFormComponent(
AddressListFormComponent.IDENTIFIER,
typeof(AddressListFormComponent),
"Address List")]
Client-side setup
The ClientComponentName
property of the AddressListFormComponent
C# class is a reference to the React component we'll create for this Form Component under the following path in the "Admin" project.
~\Client\src\components\AddressListDataTypeFormComponent.tsx
Notice how the C# ClientComponentName
property ends in AddressListDataType
but the React filename exported React component below are named AddressListDataTypeFormComponent
- the FormComponent
part of the name is automatically added on the C# side, so we shouldn't add it ourselves 😉.
Most of this code is basic React book keeping and HTML for the address fields. The focus isn't to create the best React component, but to give you something that works, as a starting point.
import { FormComponentProps } from "@kentico/xperience-admin-base";
import {
Input,
Button,
ButtonColor,
} from "@kentico/xperience-admin-components";
import React, { ChangeEvent, useState } from "react";
import {
AddressDataType,
newAddress,
AddressDataTypeFields,
} from "./AddressDataType";
interface AddressDataTypeListFormComponentProps extends FormComponentProps {
newAddress: AddressDataType;
value: AddressDataType[];
}
export const AddressListDataTypeFormComponent = (
props: AddressDataTypeListFormComponentProps
) => {
const [addresses, setAddresses] = useState(
props.value ?? [{ ...props.newAddress }]
);
const handleFieldChange = (
index: number,
event: ChangeEvent<HTMLInputElement>
) => {
if (props.onChange) {
const field = event.target.name.replace(
`${index}-`,
""
) as keyof AddressDataType;
const updatedAddress = {
...addresses[index],
[field]: event.target.value,
};
const updatedAddresses = addresses.map((a, i) =>
i === index ? updatedAddress : a
);
setAddresses(updatedAddresses);
props.onChange(updatedAddresses);
}
};
const handleDeleteAddress = (index: number) => {
if (props.onChange) {
const updatedAddresses = addresses.filter((a, i) => i !== index);
setAddresses(updatedAddresses);
props.onChange(updatedAddresses);
}
};
const handleAddressAdd = () => {
if (props.onChange) {
const updatedAddresses = [...addresses, { ...newAddress() }];
setAddresses(updatedAddresses);
props.onChange(updatedAddresses);
}
};
return (
<div>
<label style={{ color: "var(--color-text-default-on-light)" }}>
{props.label}
</label>
{addresses.map((address, index) => (
<div
key={address.id}
style={{
marginTop: index ? "2rem" : "0",
color: "var(--color-text-default-on-light)",
}}
>
<div
style={{
marginTop: ".5rem",
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
}}
>
<label>Address {index + 1}</label>
<Button
onClick={() => handleDeleteAddress(index)}
label="Remove"
color={ButtonColor.Quinary}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}
>
{AddressDataTypeFields.map((f) => (
<div style={{ marginTop: ".5rem" }} key={f.value}>
<Input
label={f.text}
name={`${index}-${f.value}`}
value={address[f.value]}
onChange={(e) => handleFieldChange(index, e)}
disabled={props.disabled}
/>
</div>
))}
</div>
</div>
))}
<div style={{ marginTop: ".5rem" }}>
<Button
onClick={handleAddressAdd}
label="Add Address"
color={ButtonColor.Primary}
/>
</div>
</div>
);
};
We store our AddressDataType
TypeScript definition in an AddressDataType.ts
file, in case we want to reuse it in additional components.
export type AddressDataType = {
id: string;
street: string;
city: string;
stateProvince: string;
postalCode: string;
country: string;
phone: string;
};
export function newAddress(): AddressDataType {
return {
id: crypto.randomUUID(),
street: "",
city: "",
stateProvince: "",
postalCode: "",
country: "",
phone: "",
};
}
export type AddressDataTypeField = Exclude<keyof AddressDataType, "id">;
export const AddressDataTypeFields: {
value: AddressDataTypeField;
text: string;
}[] = [
{
value: "street",
text: "Street",
},
{
value: "city",
text: "City",
},
{
value: "stateProvince",
text: "State or Province",
},
{
value: "postalCode",
text: "Postal Code",
},
{
value: "country",
text: "Country",
},
{
value: "phone",
text: "Phone",
},
];
Finally, add an export of the component in the Admin project's entry.tsx
file to make it available to Xperience.
export * from "./components/AddressListDataTypeFormComponent";
Custom resource names
Xperience stores the names and labels for most of the administration UI in .resx
files for future localization support.
To make sure our new custom data type has a friendly name in the content type form UI we need create a DancingGoatAdminResources.cs
class to represent our .resx
resource file.
using CMS.Base;
using CMS.Localization;
using DancingGoat.Admin;
[assembly: RegisterLocalizationResource(
typeof(DancingGoatAdminResources),
SystemContext.SYSTEM_CULTURE_NAME)]
namespace DancingGoat.Admin;
internal class DancingGoatAdminResources { }
Then, we create the .resx
file with the same name as the C# file (minus the extension). The .resx
file has an entry for our custom data type following the pattern base.datatypes.<customtype>
.
<root>
<!-- ... -->
<data name="base.datatypes.addresslist" xml:space="preserve">
<value>Address List</value>
</data>
</root>
The full contents of the .resx
file are available on GitHub
Our Admin project .csproj
file needs a block of MSBuild to tell the application to look for (and embed) .resx
files.
<ItemGroup>
<EmbeddedResource Update="**\*.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>$([System.String]::Copy('%(FileName)')).Designer.cs</LastGenOutput>
<ExcludeFromManifest>true</ExcludeFromManifest>
</EmbeddedResource>
</ItemGroup>
With this complete, we're all ready to go!
Modeling with an AddressList data type
Let's log back into the Xperience administration UI and open the Cafe content type definition and add a new "Address List" data type field to the content type.
This field uses the "Address List" form component for content authoring.
If we then navigate to the Content hub and edit a Cafe content item, we'll see the Address List fields at the bottom of the content form and we can create as many of these addresses as we want. They won't show up in the Content hub because they're "owned" by the Cafe. Yet, they're still well structured and easy to manage 🙌🏼.
When we enter some data in the fields and save the content item, we can query the database with some SQL and see the serialized JSON 🧐!
Generated content type C# classes
Because we registered a code-generator for our custom data type with Xperience's DataTypeCodeGenerationManager
, when we re-generate our C# reusable content type models we'll see our AddressDataType
C# type in our CafeAddresses
Cafe content type field.
namespace DancingGoat.Models
{
/// <summary>
/// Represents a content item of type <see cref="Cafe"/>.
/// </summary>
[RegisterContentTypeMapping(CONTENT_TYPE_NAME)]
public partial class Cafe : IContentItemFieldsSource
{
/// <summary>
/// Code name of the content type.
/// </summary>
public const string CONTENT_TYPE_NAME = "DancingGoat.Cafe";
/// <summary>
/// Represents system properties for a content item.
/// </summary>
[SystemField]
public ContentItemFields SystemFields { get; set; }
// .... other fields excluded
/// <summary>
/// CafeAddresses.
/// </summary>
public IEnumerable<AddressDataType> CafeAddresses { get; set; }
}
}
When we execute content item queries for Cafe
content items using the IContentQueryExecutor
, our resulting data set will include all of the address JSON data from the database, deserialized into C# AddressDataType
objects auto-magically 🧙!
Wrap up
Using custom data types might be a great tool in your toolbelt, but not something you'd use every day. It's great that "it just works" with the rest of Xperience by Kentico's content management technology and platform architecture, but it does take a little bit of setup.
If you do find that you're often reaching for custom data types to support your content strategy and you'd like it to require less custom development, let us know by giving us feedback on our roadmap.