While working with different REST APIs I often have to render data that is a complex object that can be treated as a list of key-value pairs. Some of these JSON objects need to have some special representation on UI, but for most of them (especially, closer to the deadline of the project) it’s often sufficient to dump them into some generic component that renders these objects in uniformed way with some minimal definition from the code side.

Consider the following JSON that partially represents networking device:

{

"id": "00e93c16-467a-11e8-842f-0ed5f89f718b",

"facts": {

"hw_model": "NX-OSv",

"hw_version": "0.0",

"mgmt_ifname": "mgmt0",

"mgmt_ipaddr": "172.20.194.22",

"mgmt_macaddr": "52:54:00:23:42:DC",

"os_arch": "x86_64",

"os_family": "NXOS",

"os_version": "7.0(3)I7(1)",

"os_version_info": {

"build": "(3)I7(1)",

"major": "7",

"minor": "0"

},

"serial_number": "5254002342DC",

"vendor": "Cisco"

}

}

Or another payload, that represents a connection to TACACS+

{

"provider_name": "test1",

"hostname_fqdn_ip": [

"10.1.3.126",

"10.1.3.122"

],

"port": 49,

"shared_key": "testing123",

"auth_mode": "ASCII",

"active": true,

"vendor": "TACACS+",

"check_login": {

"username": "tacacs-admin",

"password": "admin"

}

}

Curious designer can find an exciting challenge in representing this data on UI, but I decided to use plain old table for such objects. This table will have 2 columns, 1st being name of the property (not just the key as is, but a user-friendly spelling of it), 2nd — formatted value. In Semantic UI it’s called “Definition Table” and in this article I’ll build a Reagent version of it from scratch. Code with the sample app for this article is available at https://github.com/kishanov/reagent-definition-table and live demo can be found at https://kishanov.github.io/reagent-definition-table/

Designing Component API

Eventually definition table component should produce the following markup for the given object:

<table class="ui compact celled definition table">

<tbody>

<tr>

<td>Provider Name</td>

<td>test1</td>

</tr>

<tr>

<td>Port</td>

<td>49</td>

</tr>

<tr>

<td>Auth Mode</td>

<td>ASCII</td>

</tr>

</tbody>

...

</table>

To achieve that, component function should accept the following arguments

Object to be rendered

Some definition of rows that should be rendered. It is possible to just inspect object itself and deduce rows that should be rendered, but I prefer to have a manual control over order of rows, which rows to render, how to render keys and values, so I’d rather have a special argument that describes all these rules

Options argument that will allow to tweak table rendering (for example, list of CSS classes that should be applied to <table> tag)

In Reagent terms component signature can look like this:

(defn definition-table [obj rows-def & [options]])

obj and options arguments are trivial, but rows-def is a bit trickier. In order to describe what will be shown in a row, rows-def should define the following info for each row:

How to get specific value from the obj . Due to passed object can have nested field it can be defined via vector that will be used by get-in function to access property value (for example, [:facts :hw_model] for the nested field or [:provider_name] for the top-level field

. Due to passed object can have nested field it can be defined via vector that will be used by function to access property value (for example, for the nested field or for the top-level field Human-readable label for the 1st column. Again, it is possible to do string manipulation with object key, like splitting by _ , then capitalizing each token, but I prefer manual control cause these transformations typically fail with abbreviations (and there is no guarantee that API will return meaningful key name anyway), so we can just pass string that will be rendered in the 1st cell of the row

, then capitalizing each token, but I prefer manual control cause these transformations typically fail with abbreviations (and there is no guarantee that API will return meaningful key name anyway), so we can just pass string that will be rendered in the 1st cell of the row A function that will be used to format the value. This one is important because rendering values as strings only works well for trivial field, but in general we should be able to pass another reagent that should be used to rendered the value of the field (for example, in TACACS+ example, auth_mode is likely modeled as enum on the back-end and will have only few values that can be rendered as a label #([:div.ui.label %]) )

The simplest data structure to cover these 3 parameters defining each row via rows-def can be a list, in which each entry has 3 elements:

(list [[:provider_name] "Name" identity]

[[:port] "Port" identity]

[[:auth_mode] "Auth Mode" (fn [v] [:div.ui.label v])]

[[:active] "Active?" (fn [v] (if v "yes" "no"))])

This data structure and easy to parse (via vector destructuring) but in general I prefer to have maps for definitions, cause if at some point the interface should be extended it’s easier to have map with additional key for each definition, rather than growing a vector and trying to remember which position means what. The same definition with maps will look like this (notice that these are namespace-qualified keywords, so from other namespaces the consumer will need to do something like (:require [def-table.core :as dt]) and then use these keywords as ::dt/path-to-value ):

(list {::path-to-value [:provider_name]

::label "Name"

::formatter identity}

{::path-to-value [:port]

::label "Port"

::formatter identity}

{::path-to-value [:auth_mode]

::label "Auth Mode"

::formatter (fn [v] [:div.ui.label v])}

{::path-to-value [:active]

::label "Active?"

::formatter (fn [v] (if v "yes" "no"))})

For the purpose of this article I’ll use 1st version of rows-def data structure.

Naive Implementation

The most basic implementation is straightforward (and quite fragile to incorrect/missing arguments), but we’ll add guardrails later:

(defn definition-table [obj rows-def & [options]]

[:table.ui.definition.table

(into [:tbody]

(map (fn [[path-to-value label formatter]]

[:tr

[:td label]

[:td (formatter (get-in obj path-to-value))]])

rows-def))])

For our sample TACACS+ provider payload, the usage will look like this (some fields intentionally omitted at this point):

[dt/definition-table

provider-payload

(list [[:provider_name] "Name" identity]

[[:vendor] "Vendor" identity]

[[:port] "Port" identity]

[[:auth_mode] "Auth Mode" (fn [v] [:div.ui.label v])]

[[:active] "Active?" (fn [v] (if v " yes " " no "))])]

And with Semantic UI enabled it will render the following table

Formatters can be extracted into reusable components also. For example, if we’ll know that there will be a lot of values in a payload represented as a list of strings, it’s easy to extract the following as-list formatter:

(defn as-list [coll]

(->> coll

(map (fn [e] [:div.item e]))

(into [:div.ui.items])))

And then use it in rows-def like this:

[[:hostname_fqdn_ip] "Hostname FQDNs" as-list]

Due to formatter function expects single argument we can actually parametrize formatters by using partial

(defn as-list [item-formatter coll]

(->> coll

(map (fn [e] [:div.item (item-formatter e)]))

(into [:div.ui.items])))





(defn as-code [value]

[:pre

[:code value]])

With new signature, as-list becomes more universal tool to render lists:

[[:hostname_fqdn_ip] "Hostname FQDNs" (partial as-list as-code)] [[:hostname_fqdn_ip] "Hostname FQDNs" (partial identity as-code)]

Same is true for other formatters. It’s trivial to create a label formatter from the anonymous function that was used to render :auth_mode and reuse it in other places:

(defn as-label [css-classes value]

[:div.ui.label

{:class css-classes}

value])

Now both :auth_mode and :vendor can be rendered as labels:

[[:vendor] "Vendor" (partial as-label "big default")]

[[:auth_mode] "Auth Mode" (partial as-label "teal")]

Options

The pattern of providing customization through optional options argument is widely used in different UI components. CloureScript is very forgiving when it comes to dealing with incomplete dicts and will safely return nil when option is not provided. The simplest way to customize our table is to define optional CSS classes that will be applied to different elements of the rendered markup:

(defn definition-table [obj rows-def & [options]]

[:table.ui.definition.table

{:class (:table-css-class options)}

(into [:tbody]

(map (fn [[path-to-value label formatter]]

[:tr

[:td

{:class (:left-column-css-class options)}

[:div

{:class (when (:ribbon-label? options)

"ui ribbon label")}

label]]

[:td

{:class (:right-column-css-class options)}

(formatter (get-in obj path-to-value))]])

rows-def))])

Now if we’ll pass a map with some of these options to renderer TACACS+ provider payload the resulting table will look quite differently:

{:ribbon-label? true

:left-column-css-class "four wide"

:right-column-css-class "twelve wide"}

Validating Arguments

As I’ve mentioned before, rows-def argument has to follow the pre-defined data structure, otherwise exceptions will happen. In order to provide good feedback for the users of the component, rows-def format should be both documented and validated. I prefer to use :pre contract of defn macro together with clojure.spec definition for the arguments. The rules are easy to code:

rows-def must be a collection of vectors, each vector should have 3 elements

must be a collection of vectors, each vector should have 3 elements 1st element of each vector should be a valid path for get-in function, i.e. it should be a vector of either keywords (or, if we want to allow navigation in nested vectors and non-keywordized keys it should be either non-negative integer or string)

function, i.e. it should be a vector of either keywords (or, if we want to allow navigation in nested vectors and non-keywordized keys it should be either non-negative integer or string) 2nd element should be a string (technically, in given implementation it can be a valid Reagent component or even nil , but I want to enforce strings at this point)

, but I want to enforce strings at this point) 3rd element should be a function with single argument. This one is the hardest to spec properly cause ideally spec should also validate that this function will return a valid Reagent component, but for the purpose of this example it would be an overkill

In clojure.spec’s terms these rules will look like this:

(s/def ::path-to-value (s/coll-of keyword :kind vector))

(s/def ::label string?)

(s/def ::formatter fn?)

(s/def ::row-def (s/tuple ::path-to-value ::label ::formatter))

(s/def ::rows-def (s/coll-of ::row-def :min-count 1 :distinct true))

And we can leverage :pre form in component definition to check rows-def usage:

(defn definition-table [obj rows-def & [options]]

{:pre [(if (s/valid? ::rows-def rows-def)

true

(do

(js/console.error (s/explain-str ::rows-def rows-def))

false))]}

And in case of incorrect usage of definition-table all validation errors will be printed in console:

[dt/definition-table

provider-payload

(list [[:provider_name] "Name"]

[[:port] nil identity])]

With these basic guardrails the component becomes less vulnerable to incorrect usage/invalid arguments.

Using Definition Table in other components

Lets imagine that for our Networking Device payload we want to group related properties and render using sections:

In order to do it with Definition Table we’ll need to manually define sections and use this component 3 times, with different properties. If we plan to reuse this rendering pattern a lot in the application, then this pattern can be extracted into component on its own and use definition-table in its implementation:

(defn expanded-definition-steps [obj steps-def & [options]]

(let [sections (filter first steps-def)

headers (map (fn [[title]]

[:div.ui.row

[:div.column

[:h4.ui.dividing.header title]]])

sections)

contents (->> (range (count sections))

(map (fn [i]

[:div.ui.centered.row

[:div.twelve.wide.column.center.aligned

(let [[_ rows-def def-table-options] (get (vec sections) i)]

[definition-table

obj

rows-def

def-table-options])]])))]



(->> (interleave headers contents)

(into [:div.ui.grid]))))

We can create multiple representations for these sections views, for example using “Vertical Step” element from Semantic UI:

And the code to render it would be very similar to expanded-definition-steps

(defn compact-definition-steps [obj secitons & [options]]

(let [active-step (reagent/atom 0)]

(fn [obj sections]

[:div.ui.grid

[:div.two.column.row

[:div.column

{:class (or (:left-column-width options) "six wide")}

(->> sections

(map-indexed vector)

(map (fn [[i [title]]]

[:a.step

{:class (when (= @active-step i) "active")

:on-click (fn [e]

(.preventDefault e)

(reset! active-step i))}

[:div.content

[:div.title title]]]))

(into [:div.ui.vertical.steps]))]



[:div.column

{:class (:right-column-width options)}

(let [[_ rows-def def-table-options] (get (vec sections) @active-step)]

^{:key @active-step}

[definition-table

obj

rows-def

def-table-options])]]])))

As a final touch we can wrap both these components into a component that allows to select which representation should be used:

(defn definition-sections [obj sections-def & [options]]

(let [view-type (reagent/atom ::expanded)]

(fn [obj sections-def]

[:div.ui.grid

[:div.one.column.row

[:div.column

[:div.ui.compact.tiny.menu

[:a.item

{:class (when (= @view-type ::expanded) "active")

:on-click #(reset! view-type ::expanded)}

"Expanded View"]

[:a.item

{:class (when (= @view-type ::compact) "active")

:on-click #(reset! view-type ::compact)}

"Compact View"]]]]

[:div.one.column.row

[:div.column

^{:key @view-type}

[(case @view-type

::expanded expanded-definition-steps

::compact compact-definition-steps)

obj (filter first sections-def) options]]]])))

This component will allow user to choose between “Compact View” and “Extended View”, while properties will be rendered the same way using definition-table . From the component’s client view the usage will be the following:

[dt/definition-sections

device-payload

(list ["HW Info"

(list [[:facts :vendor] "Vendor" identity]

[[:facts :serial_number] "S/N" identity]

[[:facts :hw_model] "Model" identity]

[[:facts :hw_version] "Version" identity])]



["Connectivity"

(list [[:facts :mgmt_ifname] "Interface Name" identity]

[[:facts :mgmt_ipaddr] "IP Addrress" identity]

[[:facts :mgmt_macaddr] "Mac Address" identity])]



["OS Info"

(list [[:facts :os_arch] "Architecture" identity]

[[:facts :os_family] "Family" identity]

[[:facts :os_version] "Version" identity])])]

And the rendered result will look like this: