App-Idea 6: JSON to CSV Converter (Part 2)

We will continue to enhance the JSON to CSV Converter, which allows users to enter text in a simplified JSON format and converts it into a comma-separated value (CSV) format. This app-idea has more complex requirements, so we will cover this application in 3 parts:

  • In part 1, we focused on the basic conversion functionality that allowed users to type in JSON text and convert it to CSV format.
  • In part 2 (this article), we will add functionality to copy the converted text to the clipboard, and load the JSON input from a local file.
  • In part 3, we will add functionality to save the converted text as a local CSV file.

In this article, we will learn how to call JavaScript code from our C# view model class to access the browser’s Clipboard integration. Then, we will use the InputFile component to find a local JSON file and load it into the SourceText property.

We will continue to use the JSON-CSV converter page and classes from Part 1 (please read that article first). You can find the source for the Blazor.AppIdeas.Converters on GitHub. And the running sample of the Blazor app online.

We will start by loading Blazor.AppIdeas.Converters solution from Part 1 into Visual Studio.

Copy To Clipboard

Copy to clipboard is useful functionality in many web applications. in our scenarios, we will add a copy function that takes the ConvertedText and copies it to the Clipboard.

However, in Blazor we cannot directly call the .NET Clipboard methods because we are running in the context of a browser host. So we need to use some JavaScript to communicate with the browser’s clipboard integration. Blazor allows us to call JavaScript from our C# code using its JavaScript interop layer.

1. Create the ./wwwroot/js folder in the Blazor.AppIdeas.Converters project.

2. Let’s create the Clipboard.js file in that new folder.

window.clipboardCopy = {
    copyText: function (text) {
        navigator.clipboard.writeText(text).then(function () {
            alert("Copied to clipboard!");
        })
            .catch(function (error) {
                alert(error);
            });
    },

    copyElement: function (codeElement) {
        navigator.clipboard.writeText(codeElement.textContent).then(function () {
            alert("Copied to clipboard!");
        })
            .catch(function (error) {
                alert(error);
            });
    }
};

We defined two functions: copyText and copyElement in this JavaScript, though we will only use copyText for this article. Both methods make use of the cross-browser Clipboard API. The Clipboard API provides the ability to respond to clipboard commands (cut, copy, and paste) as well as to asynchronously read from and write to the system clipboard. It supports text as well as object representations of clipboard items. For our example, we will only use the write methods.

  • copyText takes a string as input and calls the browser’s clipboard.writeText method with the text we wish to copy, which writes the specified text to the system clipboard. Finally, it shows an alert stating whether the copy succeeded or failed.
  • copyElement takes an element parameter. It then retrieves that element’s textual representation, using the element’s textContent attribute. And, it uses that in the clipboard.writeText method call. This method is helpful when trying to copy some HTML content and markup, like a table.

3. We need to include this JavaScript file in our index.html file, so that it is accessible in our Blazor code. We do that by adding a script link (line #26):

<!DOCTYPE html>
<html>

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
    <title>Blazor AppIdeas - Converters</title>
    <base href="/" />
    <link href="css/bootstrap/bootstrap.min.css" rel="stylesheet" />
    <link href="css/app.css" rel="stylesheet" />
    <link href="Blazor.AppIdeas.Converters.styles.css" rel="stylesheet" />
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
</head>

<body>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss">🗙</a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
    <script>navigator.serviceWorker.register('service-worker.js');</script>
    <script src="js/Clipboard.js"></script>
</body>

</html>

4. We make several changes to the JsonCsvConverterViewModel to provide a Copy operation (in the ViewModels folder).

using Blazor.AppIdeas.Converters.Models;
using Blazor.AppIdeas.Converters.Services;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.ViewModels
{
    public class JsonCsvConverterViewModel
    {
        private readonly IJSRuntime _jsRuntime;
        private readonly IBrowserFileAdapter _browserFileAdapter;

        public JsonCsvConverterViewModel(
            IJSRuntime jsRuntime, IBrowserFileAdapter fileAdapter)
        {
            _jsRuntime = jsRuntime ?? 
                throw new ArgumentNullException(nameof(jsRuntime));

            _browserFileAdapter = fileAdapter ?? 
                throw new ArgumentNullException(nameof(fileAdapter));
        }

        public string SourceText { get; set; }

        public string ConvertedText { get; set; }

        public bool IsConvertedTextEmpty => string.IsNullOrEmpty(ConvertedText);

        public string ErrorMessage { get; private set; }

        public bool HasError => !string.IsNullOrEmpty(ErrorMessage);

        public void ConvertToCsv()
        {
            try
            {
                ErrorMessage = null;

                var converter = new TextConverter(SourceText);
                ConvertedText = converter.JsonToCsv();
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot parse source text. {ex.Message}";
                ConvertedText = null;
            }
        }

        public void ClearAll()
        {
            SourceText = string.Empty;
            ConvertedText = string.Empty;
            ErrorMessage = null;
        }

        public async Task Copy() => await _jsRuntime.InvokeVoidAsync(
                                        "clipboardCopy.copyText",
                                        ConvertedText);
}

  • First, we define two new external dependencies (lines #12-13): IJSRuntime and IBrowserFileAdapter (we will discuss this interface in the next section). The IJSRuntime interface is an abstraction that Blazor provides to call into JavaScript code from .NET. This interface is already registered in the app’s dependency injection container, so we can just inject it into our view model constructor.
  • Then, we define a constructor that accepts those two dependencies (lines #15-23). We use constructor injection to pass these objects in. And we save these objects in our member variables to use later.
  • Finally, we implement the Copy method (lines #58-60) that the JsonCsvConverter page will bind to. We call the IJSRuntime.InvokeVoidAsync method to call a JavaScript method with no return parameter.
    • InvokeVoidAsync‘s first parameter is a string that represents the function name to call. “clipboardCopy.copyText” is the fully qualified name for the JavaScript method we wrote earlier.
    • The second parameter (the ConvertedText) is the only parameter passed into the copyText Javascript function.
  • That’s all we need to call the JavaScript function and copy our results.

5. Update the JsonCsvConverter page to bind the ‘Copy’ button onclick event to the JsonCsvConverterViewModel.Copy method.

        <button id="clipboard-copy" class="btn btn-outline-secondary"
                style="width: 46.5%; margin-left: -2px"
                disabled="@vm.IsConvertedTextEmpty" @onclick="vm.Copy">
            <span class="oi oi-clipboard" aria-hidden="true" />
            Copy
        </button>

Those are all of the changes that we need to support to copy our conversion results to the system clipboard.

Load JSON File

Typing long strings of JSON by hand can be boring and error-prone, so we want to allow users to load a JSON file into our SourceText property in order to use it for conversion. We will do this by using the Blazor InputFile component to display the system ‘File Open’ dialog. Then, when the user selects a file, we will load that local file as text into the view model’s SourceText.

If we review the InputFile and the event arguments it uses (InputFileChangeEventArgs), we will quickly see that we need to work with an IBrowserFile object that represents the file for us to read. After thinking through the design, we will want to abstract the system interface using the Adapter pattern, so that we insulate ourselves a bit from changes to IBrowserFile and make it easier to test our application by mocking the adapter.

1. We will create the IBrowseFileAdapter interface in the Blazor.AppIdeas.Converters project and Services folder. For this article the adapter interface has a single ReadTextAsync method.

using Microsoft.AspNetCore.Components.Forms;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.Services
{
    public interface IBrowserFileAdapter
    {
        Task<string> ReadTextAsync(IBrowserFile file);
    }
}

2. Create the BrowserFileAdapter class also in the Blazor.AppIdeas.Converters project and Services folder.

using Microsoft.AspNetCore.Components.Forms;
using System;
using System.IO;
using System.Text;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.Services
{
    public class BrowserFileAdapter : IBrowserFileAdapter
    {
        public async Task<string> ReadTextAsync(IBrowserFile browserFile)
        {
            _ = browserFile ?? throw new ArgumentNullException(nameof(browserFile));

            using var file = browserFile.OpenReadStream();
            using var reader = new StreamReader(file, Encoding.UTF8);

            return await reader.ReadToEndAsync().ConfigureAwait(false);
        }
    }
}
  • First, we ensure the specified browserFile is not null (line #13).
  • Then, we open a readable stream from the IBrowserFile.
  • Followed by creating a StreamReader, which is able to read through a text file with UTF-8 encoding. If the file does not conform to this format, then the read operation will fail.
  • Then, we read the whole file as a string and return it from this method.
  • Finally, we surface all exceptions from this class and allow the view model to handle them.

3. Update the JsonCsvConverterViewModel class (in the ViewModels folder) to expose the OpenInputFile operation. The OpenInputFile method is similar to other view model methods and provides the target for our page’s event binding.

using Blazor.AppIdeas.Converters.Models;
using Blazor.AppIdeas.Converters.Services;
using Microsoft.AspNetCore.Components.Forms;
using Microsoft.JSInterop;
using System;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters.ViewModels
{
    public class JsonCsvConverterViewModel
    {
        private readonly IJSRuntime _jsRuntime;
        private readonly IBrowserFileAdapter _browserFileAdapter;

        public JsonCsvConverterViewModel(
            IJSRuntime jsRuntime, IBrowserFileAdapter fileAdapter)
        {
            _jsRuntime = jsRuntime ?? 
                throw new ArgumentNullException(nameof(jsRuntime));

            _browserFileAdapter = fileAdapter ?? 
                throw new ArgumentNullException(nameof(fileAdapter));
        }

        public string SourceText { get; set; }

        public string ConvertedText { get; set; }

        public bool IsConvertedTextEmpty => string.IsNullOrEmpty(ConvertedText);

        public string ErrorMessage { get; private set; }

        public bool HasError => !string.IsNullOrEmpty(ErrorMessage);

        public void ConvertToCsv()
        {
            try
            {
                ErrorMessage = null;

                var converter = new TextConverter(SourceText);
                ConvertedText = converter.JsonToCsv();
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot parse source text. {ex.Message}";
                ConvertedText = null;
            }
        }

        public void ClearAll()
        {
            SourceText = string.Empty;
            ConvertedText = string.Empty;
            ErrorMessage = null;
        }

        public async Task Copy() => await _jsRuntime.InvokeVoidAsync(
                                        "clipboardCopy.copyText",
                                        ConvertedText);

        public async Task OpenInputFile(InputFileChangeEventArgs e)
        {
            try
            {
                ErrorMessage = null;

                _ = e ?? throw new ArgumentNullException(nameof(e));
                if (e.FileCount > 1)
                {
                    ErrorMessage = "Application does not support multiple file selection.";
                }
                else
                {
                    SourceText = await _browserFileAdapter.ReadTextAsync(e.File)
                                                          .ConfigureAwait(false);
                }
            }
            catch (Exception ex)
            {
                ErrorMessage = $"Cannot read file {e?.File.Name}. {ex.Message}";
            }
        }
    }
}

The OpenInputFile method (lines #62-83) ensures that there is only a single file selected by the user, handles any errors that occur along they way, and calls the IBrowserFileAdapter.ReadTextAsync method (described above) to load the data from the selected file.

4. Update the JsonCsvConverter page (in the Pages folder) to use the InputFile component.

@page "/json-csv-convert"
@inject JsonCsvConverterViewModel vm

<div class="col-12 mt-3 pb-2 mb-3 container">
    <h3 class="mt-3">JSON-CSV Converter</h3>
    <hr />
    <EditForm class="form-row" Model=@vm>
        <div class="form-group col-lg-5 col-md-12">
            <InputTextArea id="convert-from-value" class="form-control"
                           placeholder="Type JSON or CSV..." rows="8"
                           @bind-Value=vm.SourceText />
            <div class="button-wrap">
                <label class="btn btn-secondary" for="convert-open-file">
                    <span class="oi oi-cloud-upload" aria-hidden="true" /> Load File
                </label>
                <InputFile id="convert-open-file" style="width: 100%"
                           OnChange="vm.OpenInputFile"/>
            </div>
        </div>
        <div class="col-lg-2 col-md-12">
            <button id="btn-convert-csv"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-6 mb-lg-1 mb-3"
                    @onclick="vm.ConvertToCsv">
                To CSV
            </button>
            <button id="btn-clear"
                    class="btn btn-primary col-lg-10 offset-lg-1 col-5 mb-lg-1 mb-3"
                    @onclick="vm.ClearAll">
                Clear
            </button>
        </div>
        <div class="form-group col-lg-5 col-12">
            <InputTextArea id="converted-value" class="form-control" rows="8"
                           readonly @bind-Value=vm.ConvertedText />
            <div>
                <button id="download-file" class="btn btn-outline-secondary text-nowrap"
                        style="width: 52%" disabled="@vm.IsConvertedTextEmpty">
                    <span class="oi oi-cloud-download" aria-hidden="true" />
                    Download File
                </button>
                <button id="clipboard-copy" class="btn btn-outline-secondary"
                        style="width: 46.5%; margin-left: -2px"
                        disabled="@vm.IsConvertedTextEmpty" @onclick="vm.Copy">
                    <span class="oi oi-clipboard" aria-hidden="true" />
                    Copy
                </button>
            </div>
        </div>
        @if (vm.HasError)
        {
         <div class="alert alert-danger col-12 ml-1">
             <strong>Error:</strong> @vm.ErrorMessage
         </div>
        }
    </EditForm>
</div>

We changed the original button on this page to actually use the InputFile component instead (lines #12-18). To help customize this component, we wrap the elements in a div with the button-wrap BootStrap class. Then, the label that gets styled as a custom button. And, we bind the component’s OnChange event to the OpenInputFile method above.

5. Add the IBrowserFileAdapter service to the app’s DI container (line #29) with the following changes to the Program.cs file, so that it can be injected into our view model constructor.

using Blazor.AppIdeas.Converters.Services;
using Blazor.AppIdeas.Converters.ViewModels;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Threading.Tasks;

namespace Blazor.AppIdeas.Converters
{
    [ExcludeFromCodeCoverage]
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            builder.RootComponents.Add<App>("#app");

            var httpClient = new HttpClient
            {
                BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
            };
            builder.Services.AddScoped(sp => httpClient);

            // add services and view models to DI container.
            builder.Services.AddSingleton<ICurrencyServiceClient>(
                sp => new CurrencyServiceClient(httpClient));
            builder.Services.AddSingleton<IBrowserFileAdapter, BrowserFileAdapter>();

            builder.Services.AddTransient<RomanDecimalConverter>();
            builder.Services.AddTransient<NumberConverterViewModel>();
            builder.Services.AddTransient<DollarCentsConverterViewModel>();
            builder.Services.AddTransient<CurrencyConverterViewModel>();
            builder.Services.AddTransient<JsonCsvConverterViewModel>();

            await builder.Build().RunAsync();
        }
    }
}

6. We want to customize the look of the InputFile element, so let’s make some styling changes to the app.css file (in the ./wwwroot/css folder).

@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');

html, body {
    font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

a, .btn-link {
    color: #0366d6;
}

.btn-primary {
    color: #fff;
    background-color: #1b6ec2;
    border-color: #1861ac;
}

.content {
    padding-top: 1.1rem;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid red;
}

.validation-message {
    color: red;
}

#blazor-error-ui {
    background: lightyellow;
    bottom: 0;
    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
    display: none;
    left: 0;
    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
    position: fixed;
    width: 100%;
    z-index: 1000;
}

    #blazor-error-ui .dismiss {
        cursor: pointer;
        position: absolute;
        right: 0.75rem;
        top: 0.5rem;
    }

.container {
    border-style: solid;
    border-radius: 15px;
    border-width: 1px;
    border-color: darkgreen;
}

hr {
    border-top: 1px solid darkgreen;
}

select {
    border: 1px solid lightgray;
    border-radius: 5px;
    height: 36px;
}


/*----------------------- Input File styles --------------------------*/

input[type="file"] {
    position: absolute;
    z-index: -1;
    top: 4px;
    left: 4px;
    font-size: 17px;
    color: #b8b8b8;
}

.button-wrap {
    position: relative;
}

.button {
    display: inline-block;
    padding: 8px 12px;
    cursor: pointer;
    border-radius: 5px;
    background-color: gray;
    font-size: 16px;
    font-weight: bold;
    color: #fff;
}

We define these custom styles (lines #70-94) for the InputFile element to make it a flat button with an icon and text to match the other buttons in our app.

Finally, with all of these code changes, we can build and run our application again (Ctrl + F5). We can test drive our file load functionality by clicking the ‘Load File’ button.

Fig 1 – JSON-CSV Converter

Then, selecting a JSON file on your local drive to open (create a simple test JSON file, if you don’t have any handy).

Fig 2 – File Open Dialog

Next, click the ‘To CSV’ button to perform the conversion.

Fig 3 – Loaded and Converted JSON

Finally, click the ‘Copy’ button to copy the converted text to the system clipboard and show all of the features added in this article.

Fig 4 – Copy Converted Text

In this article, we learned:

  • To use IJSRuntime to invoke JavaScript functions from within our .NET code.
  • How to inject the IJSRuntime service into our view model constructor.
  • About the Clipboard API supported by most major browser versions.
  • To use the InputFile component to load the system’s ‘File Open’ dialog.
  • To use the IBrowserFile service to open and read local system files.
  • How to customize the look of the <input type="file> element.

One thought on “App-Idea 6: JSON to CSV Converter (Part 2)

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s