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.

Content type fields definition for a Cafe content type

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:

  1. Structured - We need the address fields to be structured so that we can present them according to our needs.
  2. 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".
  3. 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.

Cafe content type field definition using an Address List custom data type for one field

This field uses the "Address List" form component for content authoring.

Configuration panel of a custom Address List data type field in a cafe content type definition

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 🙌🏼.

A content type form with data entered into a set of field for an Address List custom data type

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 🧐!

Results of a SQL query showing the embedded structured JSON of a content item's custom Address List data type field

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.