Theme:

Using Umbraco UI Builder to Edit Data Stored Locally in JSON Files

The Umbraco UI Builder Add-in is a great tool to give your Umbraco users the ability to view and edit data which isn’t stored in an Umbraco node while using the familiar back-office environment with just a small amount of code and configuration. The documentation provides a how-to guide to connect to SQL Server database tables, but what if your data is stored in a text file? Here's a tutorial on how to do just that.

The Umbraco UI Builder Add-in (formerly known as “Konstrukt”) is a great tool to give your Umbraco users the ability to view and edit data which isn’t stored in an Umbraco node while using the familiar back-office environment. Umbraco itself provides all the extension points for seasoned developers to create user interfaces to external data sources, but building it all from scratch can take some work. The UI Builder provides a quick and easy way to create such interfaces with just a small amount of code and configuration.  The documentation provides a how-to guide to connect to SQL Server database tables, but what if your data is stored in a text file?

For a client project, I had a need to provide an editing interface for some locally-stored JSON files. The JSON data was used for some on-page rendering, but the data was provided via an external API import operation on a Hangfire schedule. The issue was that some of the imported data had to be manually updated by website content editors, thus the need for an editing interface.

I can also imagine scenarios where you have some custom configuration files which you’d like to be editable via the back-office, for non-developers.

Fortunately, connecting to and editing the JSON wasn’t difficult at all once I understood how the code structure worked.

For this tutorial, I’ve created a demo using my own website as an example.

Installation

The first step, of course, is to install Umbraco UI Builder (and set up licensing, as needed). This is done via NuGet. Install the “Umbraco.UIBuilder” package into your website project. If you have your .cs source code files in a separate project, install “Umbraco.UIBuilder.Core” into that project, so you have the correct references available.

Create a Custom Repository

For each separate JSON file you want to be able to edit, you will need to have a defined data model and a Repository class, which provides logic for UI builder to link your file and model, as well as understand how to locate individual entries. This is a C# file, so place it in whichever project you have your compiling code in.

Your repository uses the base class Repository<TEntity, TId>. “TEntity” is the model for the data, and “TId” is the type of the uniquely identifying property for each record in your model (int, string, etc). The requirement for a unique identifier is important. If your data model already contains some sort of Id field – one which is guaranteed to be unique to each record – and that you won’t need to have editable in your UI, then you can use that. If your model doesn’t have a unique property you can use, you will need to add one, which does alter your stored data file. If this is a problem in your case, you won’t be able to use UI Builder for editing the file and will need to consider an alternative method. In my demo, I am going to provide a UI for editing a fictionalized JSON file. Here is the structure of the file:


[
    {
        "id": 1,
        "name": "Red",
        "value": -100,
        "color": "#ff0000"
    },
    {
        "id": 2,
        "name": "Yellow",
        "value": -50,
        "color": "#eeff05"
    },
    {
        "id": 3,
        "name": "Green",
        "value": 50,
        "color": "#00ff00"
    },
    {
        "id": 4,
        "name": "Blue",
        "value": 100,
        "color": "#0000ff"
    }
]

Demo.json - the data file

You might need to create a simple C# class model to represent your data items if one isn’t already present in your project. Here is my demo model:



namespace HeatherFloyd.Web
{
	using Newtonsoft.Json;

	public class DemoItem
	{
		[JsonProperty("id")]
		public long Id { get; set; } 

		[JsonProperty("name")]
		public string Name { get; set; }

		[JsonProperty("value")]
		public long Value { get; set; } 

		[JsonProperty("color")]
		public string Color { get; set; }
	}
}

DemoItem data model

“Id” is of type long, so this is my repository setup:

public class DemoJsonRepository : Repository<DemoItem, long>{}

As the documentation shows, there are several items which need to be overridden with custom logic. First, the constructor should handle injections of any external services needed to access or interact with the data. I like to have error logging available, so I’ll add that to my constructor here. Also, since I need to get the physical file path, I will inject IWebHostEnvironment and set a variable with the physical file path.



		#region CTOR/DI

		private readonly ILogger<DemoJsonRepository> _Logger;
		private readonly IWebHostEnvironment _WebHostEnvironment;

		private readonly string _FilePhysicalPath = "";

		public DemoJsonRepository(
			RepositoryContext context
			, ILogger<DemoJsonRepository> logger
			, IWebHostEnvironment webHostEnvironment
		) : base(context)
		{
			_Logger = logger;

			//Get file location
			var webRootPath = webHostEnvironment.WebRootPath; //physical location of "wwwroot" folder
			_FilePhysicalPath =  Path.Combine(webRootPath , "App_Data","Demo.json");

		}

		#endregion


Constructor and Dependency Injection

The first thing we need to consider is how we read and write the persisted file. Each action in the UI (displaying the list of items, displaying a specific item, adding or deleting or saving an item) initializes the Repository, so we need to persist any changes immediately, since the data will need to be re-loaded on each subsequent action.

I like to create a set of private methods, since it makes clear what their purpose is, and we can use them in the required override methods as needed.

LoadData and SaveData will interact with a private property which holds the whole collection. They can use whatever method you prefer to read/write the file. You might already have a service in your code base designed to handle these operations, in which case, inject it and use its methods. Otherwise, add some logic to access your specific file here.

In my example, I will use Newtonsoft.Json to deserialize the JSON, and simple File.IO operations to handle reading/writing. You will likely want to add in various error handling, etc. for a production system, but I wanted to keep the example as straightforward as possible.


		#region Data Read/Write Methods

		private List<DemoItem> allDataItems = new List<DemoItem>();
		private List<DemoItem> AllData
		{
			get
			{
				if (allDataItems.Count == 0)
				{
					LoadData();
				}

				return allDataItems;
			}
		}

		private void LoadData()
		{
			var json = System.IO.File.ReadAllText(_FilePhysicalPath);
			var data= JsonConvert.DeserializeObject<List<DemoItem>>(json);
			if (data != null)
			{
				allDataItems = data;
			}
			else
			{
				allDataItems= new List<DemoItem>();
			}
		}

		private void SaveData()
		{
			var json = JsonConvert.SerializeObject(allDataItems);
			System.IO.File.WriteAllText(_FilePhysicalPath, json);
		}

		#endregion



Data Read/Write Methods

Next, consider which operations you want to permit editors to do: editing existing records, adding new records, deleting records. You will need to write code to handle each of these. You can explicitly prohibit specific actions in the configuration of UI Builder (example in the next section).

The three editing operations:

  1. UpdateObjectInList: Locate a specific entity in the list and replace it with an updated version of the entity.
  2. AddObjectToList: Assign an Id value to a new entity and add it.
  3. DeleteObjectFromList: Locate a specific entity in the list and remove it.

	#region Entity Editing Methods
		private void UpdateObjectInList(DemoItem UpdatedEntity)
		{
			// Find the index of the object to be replaced
			int index = AllData.FindIndex(x => x.Id == UpdatedEntity.Id);

			if (index != -1)
			{
				// Replace the object at the found index with the updated object
				AllData[index] = UpdatedEntity;
				SaveData();
			}
			else
			{
				// If the object is not found, you might want to add it to the list
				AddObjectToList(UpdatedEntity);
			}
			
		}

		private void DeleteObjectFromList(DemoItem RemovedEntity)
		{
			// Find the index of the object to be removed
			int index = AllData.FindIndex(x => x.Id == RemovedEntity.Id);

			if (index != -1)
			{
				// Remove the object at the found index
				AllData.RemoveAt(index);
				SaveData();
			}
		}

		private void AddObjectToList(DemoItem NewEntity)
		{
			var newId = AllData.Max(x => x.Id) + 1;
			NewEntity.Id = newId;

			// Add the new object to the list
			AllData.Add(NewEntity);
			SaveData();

		}

		#endregion

Editing Methods

Notice that we explicitly call “SaveData()” in each one.

With our primary methods defined, we can now update the override methods. 

Here is the full Repository:



namespace HeatherFloyd.Web
{
	using System;
	using System.Collections.Generic;
	using System.IO;
	using System.Linq;
	using System.Linq.Expressions;

	using Microsoft.Extensions.Logging;
	using Microsoft.AspNetCore.Hosting;

	using Umbraco.Cms.Core.Models;
	using Umbraco.UIBuilder;
	using Umbraco.UIBuilder.Persistence;

	using Newtonsoft.Json;

	public class DemoJsonRepository : Repository<DemoItem, long>
	{

		#region CTOR/DI

		private readonly ILogger<DemoJsonRepository> _Logger;
		private readonly IWebHostEnvironment _WebHostEnvironment;

		private readonly string _FilePhysicalPath = "";

		public DemoJsonRepository(
			RepositoryContext context
			, ILogger<DemoJsonRepository> logger
			, IWebHostEnvironment webHostEnvironment
		) : base(context)
		{
			_Logger = logger;

			//Get file location
			var webRootPath = webHostEnvironment.WebRootPath; //physical location of "wwwroot" folder
			_FilePhysicalPath = Path.Combine(webRootPath, "App_Data", "Demo.json");

		}

		#endregion


		#region Data Read/Write Methods

		private List<DemoItem> _allDataItems = new List<DemoItem>();
		private List<DemoItem> AllData
		{
			get
			{
				if (_allDataItems.Count == 0)
				{
					LoadData();
				}

				return _allDataItems;
			}
		}

		private void LoadData()
		{
			var json = System.IO.File.ReadAllText(_FilePhysicalPath);
			var data = JsonConvert.DeserializeObject<List<DemoItem>>(json);
			if (data != null)
			{
				_allDataItems = data;
			}
			else
			{
				_allDataItems = new List<DemoItem>();
			}
		}

		private void SaveData()
		{
			var json = JsonConvert.SerializeObject(_allDataItems);
			System.IO.File.WriteAllText(_FilePhysicalPath, json);
		}

		#endregion

		#region Entity Editing Methods
		private void UpdateObjectInList(DemoItem UpdatedEntity)
		{
			// Find the index of the object to be replaced
			int index = AllData.FindIndex(x => x.Id == UpdatedEntity.Id);

			if (index != -1)
			{
				// Replace the object at the found index with the updated object
				AllData[index] = UpdatedEntity;
				SaveData();
			}
			else
			{
				// If the object is not found, you might want to add it to the list
				AddObjectToList(UpdatedEntity);
			}

		}

		private void DeleteObjectFromList(DemoItem RemovedEntity)
		{
			// Find the index of the object to be removed
			int index = AllData.FindIndex(x => x.Id == RemovedEntity.Id);

			if (index != -1)
			{
				// Remove the object at the found index
				AllData.RemoveAt(index);
				SaveData();
			}
		}

		private void AddObjectToList(DemoItem NewEntity)
		{
			var newId = AllData.Max(x => x.Id) + 1;
			NewEntity.Id = newId;

			// Add the new object to the list
			AllData.Add(NewEntity);
			SaveData();

		}

		#endregion

		#region Overrides of Repository<DemoItem, long>

		protected override long GetIdImpl(DemoItem entity)
		{
			return entity.Id;
		}

		protected override DemoItem GetImpl(long id)
		{
			var matches = AllData.Where(x => x.Id == id).ToList();
			if (matches.Any())
			{
				return matches.First();
			}
			else
			{
				//Id cannot be found, it might have been deleted or never existed.
				//Be aware that this can cause an un-friendly error. 
				return new DemoItem();
			}
		}

		protected override DemoItem SaveImpl(DemoItem entity)
		{
			if (entity.Id == 0)
			{
				AddObjectToList(entity);
			}
			else
			{
				UpdateObjectInList(entity);
			}

			return entity;
		}

		protected override void DeleteImpl(long id)
		{
			var found = AllData.FirstOrDefault(x => x.Id == id);
			if (found != null)
			{
				DeleteObjectFromList(found);
			}
		}

		protected override IEnumerable<DemoItem> GetAllImpl(Expression<Func<DemoItem, bool>> whereClause, Expression<Func<DemoItem, object>> orderBy, SortDirection orderByDirection)
		{
			// Convert the list to IQueryable
			var query = AllData.AsQueryable();

			// Apply the where clause if provided
			if (whereClause != null)
			{
				query = query.Where(whereClause);
			}

			// Apply the order by clause if provided
			if (orderBy != null)
			{
				query = orderByDirection == SortDirection.Ascending
					? query.OrderBy(orderBy)
					: query.OrderByDescending(orderBy);
			}

			return query.ToList();
		}

		protected override PagedResult<DemoItem> GetPagedImpl(int pageNumber, int pageSize, Expression<Func<DemoItem, bool>> whereClause, Expression<Func<DemoItem, object>> orderBy,
			SortDirection orderByDirection)
		{

			//Get the full list, queried & sorted
			var query = GetAllImpl(whereClause, orderBy, orderByDirection).ToList();

			// Calculate the total count of items after filtering
			var totalItemCount = query.Count();

			// Apply pagination
			var pagedItems = query
				.Skip((pageNumber - 1) * pageSize)
				.Take(pageSize)
				.ToList();

			// Return the results in a PagedResult object
			return new PagedResult<DemoItem>(totalItemCount, pageNumber, pageSize)
			{
				Items = pagedItems
			};

		}

		protected override long GetCountImpl(Expression<Func<DemoItem, bool>> whereClause)
		{
			return AllData.Count;
		}

		#endregion
	}


}

DemoJsonRepository.cs

So, that takes care of our data repository.

Configure UI Builder Back-office Areas

You will also need to configure the actual UI. The complete documentation explains all the options for this. For our purposes, I will add a custom section with a tree.

There is a fluent syntax to configure the various areas of the interface and connect them to the proper data sets. When I first worked with it, I found the configuration got rather sprawling, and also a bit confusing if you wanted to add in multiple separate folders/tree items. I decided to separate the configuration out into its own static function, and flip the arrangement a bit, where the lowest-level items (collections) are defined first, then can be added into folders, trees, sections, etc.



namespace HeatherFloyd.Web
{
	using System;
	using Umbraco.UIBuilder.Configuration.Builders;

	public static class UiBuilderConfig
	{
		public static void ConfigureUiBuilder(UIBuilderConfigBuilder Cfg)
		{
			// Data Section Contents

			//Collection: Demo Data
			Action<TreeCollectionConfigBuilder<DemoItem>> collectionDemo = collectionDemo =>
				collectionDemo
					.SetAlias("DemoData")
					.SetRepositoryType<DemoJsonRepository>()
					//.DisableCreate()
					//.DisableDelete()
					.SetNameProperty(p => p.Name)
					.ListView(listViewConfig => listViewConfig
						.AddField(p => p.Value).SetHeading("Numeric Value")
						.AddField(p => p.Color).SetHeading("HEX Color")
						.AddField(p => p.Id).SetHeading("ID")
					)
					.Editor(editorConfig => editorConfig
						.AddTab("Demo Data", tabConfig => tabConfig
							.AddFieldset("Info", fieldsetConfig => fieldsetConfig
								.AddField(p => p.Id).SetLabel("ID").MakeReadOnly()
								.AddField(p => p.Value).SetLabel("Numeric Value").MakeRequired()
								.AddField(p => p.Color).SetLabel("Color").SetDescription("This is a HEX code beginning with #")
							)
						)
					);

			//Collection: Other Data
			//Action<TreeCollectionConfigBuilder<OtherData>> collectionOther = collectionOther =>
			//	collectionOther
			//		.SetAlias("OtherData")
			//		.SetRepositoryType<OtherDataRepository>()
			//		.DisableCreate()
			//		.DisableDelete()
			//		.SetNameProperty(p => p.FundName)
			//		...
			//		);

			//Folder: "JSON"
			var folderJsonName = "JSON";
			Action<TreeFolderConfigBuilder> folderJsonConfig = folderJsonConfig =>
				folderJsonConfig
					.AddCollection<DemoItem>(x => x.Id,
						nameSingular: "Demo Data Item",
						namePlural: "Demo Data Items",
						description: "Total nonsense Data",
						iconSingular: "icon-chart-curve",
						iconPlural: "icon-chart",
						collectionDemo);

					//.AddCollection<DemoItem>(x => x.Id,
					//	nameSingular: "Other Data Item",
					//	namePlural: "Other Data Items",
					//	description:"More total nonsense Data",
					//	iconSingular:"icon-chart-curve", 
					//	iconPlural:"icon-chart",
					//	collectionOther);



			//Section: Data
			Action<SectionConfigBuilder> sectionData = sectionData =>
				sectionData
					.SetAlias("ExtraData")
					.Tree(treeConfig =>
					{
						treeConfig.AddFolder(folderJsonName, folderJsonConfig);
					});

			//Add Section(s)
			Cfg.AddSectionAfter("media", "Data", sectionData);

		}


	}
}

UiBuilderConfig.cs

This can be referenced very simply, keeping the Startup/Program files tidy:

V10-V12 - Startup.cs:


public void ConfigureServices(IServiceCollection services)
{
    services.AddUmbraco(_env, _config)
        .AddBackOffice()
        .AddWebsite()
        .AddUIBuilder(cfg => UiBuilderConfig.ConfigureUiBuilder(cfg))
        .AddComposers()
        .Build();
}

V13+ - Program.cs:


builder.CreateUmbracoBuilder()
    .AddBackOffice()
    .AddWebsite()    
    .AddDeliveryApi()   
    .AddComposers()   
    .AddUIBuilder(cfg => UiBuilderConfig.ConfigureUiBuilder(cfg))   
    .Build();

Build and run your project. When you log in to the back-office you will likely not yet see your new section. Go to the Users section and add the section to whichever groups should have permission to view it. If you then do a hard-refresh of your browser, you should see it appear.

Clicking the “…” under Actions will give you the option to Delete that item, if you have allowed Deletions.

Clicking the bold-faced Name of the item will open a form interface to edit the item:

Troubleshooting

If you are getting an error message when your custom section loads, it can be tricky to figure out exactly what the issue is, due to the way that UI Builder is operating. Something which might not be obvious is that after your Repository constructor code runs, then UI Builder calls “GetPagedImpl” from your repository, so setting a breakpoint in that method in your code can help when doing step-through debugging.

Additionally, when you click on an individual item to edit it, the constructor runs again, then “GetImpl”, passing in the ID of the chosen item.

Some Considerations

For a quick and simple way to interact with a JSON file, this will work nicely, however, there are some things you might be concerned about…

Carefully consider issues of concurrency when doing file-based operations. Unlike a database where individual rows can be managed independently of one another, a text file which is being read-from and written-to in its entirety is a bit trickier. If you expect multiple users editing the file simultaneously, you might want to consider how best to do your LoadData and SaveData operations – perhaps even checking if a record which is set to be updated has changed since it was last loaded, etc.

If your ID property is a simple incremental number, consider how new ID numbers are assigned. Unlike SQL server with “Auto-increment”, your text file isn’t keeping track of “already used” IDs, which were perhaps deleted from the file. If ID-integrity is important, you might need to implement a more robust way of managing their generation than what I have in this example. (This is less important if the ID is only used inside UI Builder.)

Additionally, performance could become an issue with very large text files, so keep that in mind if you are considering giving editors access to a text file like this.

But if your requirements are met, Umbraco's UI Builder can be a great way to allow your users to edit JSON data stored in text files.