Store Sitecore XM Cloud Forms data in Umbraco with the UI Builder

Sitecore XM Cloud Forms has been available for a few months now and offers a simple SaaS solution for setting up forms. Despite its versatility, this service does not store submitted data. Therefore, in this blog, we explore a creative solution: developing a Web API in Umbraco, which saves the collected data in a custom table. This uses the Umbraco UI Builder for a streamlined management experience.

This blog continues my previous research into the synergy between Sitecore and Umbraco. I have previously investigated how these two platforms can work together, both in sharing content and in using a shared identity provider. You can read these earlier insights and my findings below:

XM Cloud Forms setup

We start with the setup of XM Cloud Forms. First, a new webhook is created. In this, we already provide the URL of the Umbraco Web API that we will create later.

After the webhook, we create a form. We choose the "Your feedback" template so we immediately have a complete form.

In the settings of this form, we choose our "Feedback webhook".

Now that all the settings are correct, we can try out the form via the "Preview" function.

When we click on "Submit", we see which form data is sent via the webhook. We are not actually sending this data yet because we still have to build the Web API, but now we know what is being sent. That's what we need for our next step.

Umbraco Web API + UI Builder

We start with the FeedbackController. Since this inherits from the UmbracoApiController, it automatically gets the following routing: /umbraco/api/feedback/insertfeedback. The FeedbackRequest model is how we receive the data from XM Cloud Forms. We get this in the InsertFeedback method and then convert it to a FeedbackDto model that is used to store the data in the database.

public class FeedbackController : UmbracoApiController
{
    private readonly IScopeProvider _scopeProvider;
    public FeedbackController(IScopeProvider scopeProvider)
    {
        _scopeProvider = scopeProvider;
    }

    [HttpGet]
    public IEnumerable<FeedbackDto> GetFeedback()
    {
        using var scope = _scopeProvider.CreateScope();
        var queryResults = scope.Database.Fetch<FeedbackDto>("SELECT * FROM Feedback");
        scope.Complete();
        return queryResults;
    }

    [HttpPost]
    public void InsertFeedback([FromBody] FeedbackRequest feedbackRequest)
    {
        var feedbackDto = new FeedbackDto
        {
            Name = feedbackRequest.FullName,
            Email = feedbackRequest.Email,
            Type = feedbackRequest.FeedbackType,
            Message = feedbackRequest.Feedback
        };
        using var scope = _scopeProvider.CreateScope();
        scope.Database.Insert<FeedbackDto>(feedbackDto);
        scope.Complete();
    }
}

public class FeedbackRequest
{
    [JsonProperty("Full name")]
    public string FullName { get; set; }

    public string Email { get; set; }
    public string FeedbackType { get; set; }
    public string Feedback { get; set; }
}

[TableName("Feedback")]
[PrimaryKey("Id", AutoIncrement = true)]
[ExplicitColumns]
public class FeedbackDto
{
    [PrimaryKeyColumn(AutoIncrement = true, IdentitySeed = 1)]
    [Column("Id")]
    public int Id { get; set; }

    [Column("Name")]
    public required string Name { get; set; }

    [Column("Email")]
    public required string Email { get; set; }

    [Column("Type")]
    public required string Type { get; set; }

    [Column("Message")]
    [SpecialDbType(SpecialDbTypes.NVARCHARMAX)]
    public string Message { get; set; }
}

The FeedbackController will only work when there is actually a table in which the data is stored. This is made possible by the following migration. In RunFeedbackMigration, the migration is executed. The AddFeedbackTable ensures that the table is added. A separate FeedbackSchema has been chosen deliberately because this is how it is documented. More information about migrations can be found here: https://docs.umbraco.com/umbraco-cms/extending/database.

public class RunFeedbackMigration : INotificationHandler<UmbracoApplicationStartingNotification>
{
    private readonly IMigrationPlanExecutor _migrationPlanExecutor;
    private readonly ICoreScopeProvider _coreScopeProvider;
    private readonly IKeyValueService _keyValueService;
    private readonly IRuntimeState _runtimeState;

    public RunFeedbackMigration(
        ICoreScopeProvider coreScopeProvider,
        IMigrationPlanExecutor migrationPlanExecutor,
        IKeyValueService keyValueService,
        IRuntimeState runtimeState)
    {
        _migrationPlanExecutor = migrationPlanExecutor;
        _coreScopeProvider = coreScopeProvider;
        _keyValueService = keyValueService;
        _runtimeState = runtimeState;
    }

    public void Handle(UmbracoApplicationStartingNotification notification)
    {
        if (_runtimeState.Level < RuntimeLevel.Run)
        {
            return;
        }

        var migrationPlan = new MigrationPlan("Feedback");

        migrationPlan.From(string.Empty)
            .To<AddFeedbackTable>("feedback-db");

        var upgrader = new Upgrader(migrationPlan);
        upgrader.Execute(
            _migrationPlanExecutor,
            _coreScopeProvider,
            _keyValueService);
    }
}

public class FeedbackComposer : IComposer
{
    public void Compose(IUmbracoBuilder builder)
    {
        builder.AddNotificationHandler<UmbracoApplicationStartingNotification, RunFeedbackMigration>();
    }
}

public class AddFeedbackTable : MigrationBase
{
    public AddFeedbackTable(IMigrationContext context) : base(context)
    {
    }
    protected override void Migrate()
    {
        Logger.LogDebug("Running migration {MigrationStep}", "AddFeedbackTable");

        if (TableExists("Feedback") == false)
        {
            Create.Table<FeedbackSchema>().Do();
        }
        else
        {
            Logger.LogDebug("The database table {DbTable} already exists, skipping", "Feedback");
        }
    }

    [TableName("Feedback")]
    [PrimaryKey("Id", AutoIncrement = true)]
    [ExplicitColumns]
    public class FeedbackSchema
    {
        [PrimaryKeyColumn(AutoIncrement = true, IdentitySeed = 1)]
        [Column("Id")]
        public int Id { get; set; }

        [Column("Name")]
        public required string Name { get; set; }

        [Column("Email")]
        public required string Email { get; set; }

        [Column("Type")]
        public required string Type { get; set; }

        [Column("Message")]
        [SpecialDbType(SpecialDbTypes.NVARCHARMAX)]
        public string Message { get; set; }
    }
}

The final piece of the code is configuring the Umbraco UI Builder. After the installation, you can add the following code. This ensures that a new Repositories section is added with an overview and detail page. My Umbraco UI Builder code is based on this example: https://docs.umbraco.com/umbraco-ui-builder/how-to-guides/creating-your-first-integration.

.AddUIBuilder(cfg => {
    cfg.AddSectionAfter("media", "Repositories", sectionConfig => sectionConfig
        .Tree(treeConfig => treeConfig
            .AddCollection<FeedbackDto>(x => x.Id, "Feedback", "Feedback", "A feedback entity", "icon-forms-stackoverflow", "icon-forms-stackoverflow", collectionConfig => collectionConfig
                .SetNameProperty(p => p.Name)
                .ListView(listViewConfig => listViewConfig
                    .AddField(p => p.Email)
                )
                .Editor(editorConfig => editorConfig
                    .AddTab("General", tabConfig => tabConfig
                        .AddFieldset("General", fieldsetConfig => fieldsetConfig
                            .AddField(p => p.Type)
                            .AddField(p => p.Email).SetValidationRegex("[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+.[a-zA-Z0-9-.]+")
                            .AddField(p => p.Message).SetDataType("Textarea")
                        )
                    )
                )
            )
        )
    );
})

You can view all the necessary adjustments in this Gist. After pushing this code to my Umbraco Cloud environment, we can start testing everything. I used the same environment for this blog as for my Umbraco Content Delivery API blog.

Submitting the form

We can now go back to the submit step. Because we now have a Web API with a request model that matches this form data, we can now send the data. After we have done that, we return to our form and see the success message.

To test that it can also go wrong, I removed the "Feedback type" field in XM Cloud Forms. If we try to send the data now, we get an error message. That's because this field is mandatory in Umbraco.

Now that we have successfully submitted the form, we can check in Umbraco to see if we can see the data there. Thanks to our code, a Repositories section has indeed been added. Here we can see the feedback overview where we already see the name and email.

When we click on the name, the detail view is shown. Here we can see exactly all the data we sent with the form. Because we use the Umbraco UI Builder, we can also edit or delete this data.

Short demo video

In this short video, I submit the XM Cloud form several times.
Then I show how this looks in Umbraco: https://youtu.be/SCShGrHJ78M.

Conclusion

Sitecore XM Cloud Forms can easily send form data to external systems via webhooks. This can be anything such as a CRM, CDP, or a separate database. I wanted to show that you can also use Umbraco for this and that with the Umbraco UI Builder it is a small effort to manage this data. I have not taken GDPR or security into account in this blog as it would become too complex. It is mainly intended to show what is possible.