Kais DevBlog

More or less structred thoughts

Reusable TailwindCSS Components in Structr
Posted in Structr by Kai on Mar 16, 2021

This post serves as kind of a "Part 2" of the previous post. Using TailwindCSS is nice but creating reusable components (regardless of the framework) seems to be a common problem when learning how to develop with structr. In this article I'll explain my (currently) favorite method of creating and working with components.

Since explanations are often only half as good without examples, I'll also add an example project which can be imported and explored locally. It contains a simple dataset and schema and a couple of pages to illustrate how I would build and use components in structr. Because I currently really like to work with TailwindCSS the example will be using it as well as a UI Kit for it. This will be LoFiUI because it can be redistributed (even though I like TailwindUI better).


I think it all boils down to knowing the builtin functions in structr well enough to be able to get creative in combining them. Creating shared components with a lot of DOM elements is a valid approach and offers a lot of flexibility in terms of access control and branching. A more flat approach (using templates) offers less flexibility but makes it a lot easier to get an overview of the component/application as a whole.

One of the strengths of structr is that almost all of the time different approaches can be mixed. So you could always cut up the main template of the component to add in functionality via DOM nodes underneath.

The example application sticks to a mostly flat approach to reduce the number of elements so it is easier to understand. Only the self-contained pagination example uses a mixed approach with very shallow depth.

Functions and Concepts

To get a good overview on what features we can use to create components in structr, we need to discuss a few core concepts and functions. Actually we do not need to know all that much, so take the time to read this before diving into the example 😉

Before we start: This is not a basic tutorial about structr - there is a whole video series on their youtube channel where you can learn a lot.

1. Repeaters

Repeaters are basically subtrees in the page tree which are repeated for every element of a given collection which is bound to the repeater. Those collections can have different sources: REST, functions, Flows, etc. Every time the tree is repeated the next element from the collection is made available as a constant with a user-defined name (usually called data key).

This concept will reoccur all over the place and is important to understand. Make sure to read the documentation about the concept and how to apply it.

2. include()

The include() function can be used multiple ways. Either simply as include('named object'). This searches for a (globally unique) named DOM element and renders it at the position the call was made. This allows singular renderings of self-contained components.

The more interesting use is include('named node', collection, 'dataKey'). This function call does the same as the above, but uses the named element as a repeater with the given dataKey.

This function is used to render a global template anywhere in the application. The named element can exist anywhere in the page tree or as a shared component. Typically it is advisable to keep those named elements in a certain place (most often the shared components) in order to quickly access them.

The include() function is very handy to have in your toolbox - make sure to have a look at the documentation.

3. include_child()

The include_child() function is a specialization of the include() function. It allows to do the same things but has three differences:

  1. the call can only happen in a Template element
  2. the named node must be a direct child of the Template element
  3. the named element does not have to have a globally unique name. It must only be unique for direct children of the Template element

One important thing to keep in mind when using include_child(): Do not use render(children) in the same template. include_child() renders the specified child selectively at exactly the position where the function is called whereas render(children) renders all children in the order the occur at exactly the position where it is called.

The include_child() function is very useful. In the example application we will use it to have page-dependent CSS and JavaScript includes. Make sure to have a look at the documentation.

4. to_graph_object()

A Repeater expects its input to be a collection of GraphObjects (meaning that the object is an actual element retrieved from the graph). This makes it hard to use repeaters with manually created objects (or string arrays). Structr has a builtin function `to_graph_object()` which makes almost everything compatible with repeaters.

The to_graph_object() function is very handy in situations where we want to use our own custom objects but any function expects GraphObject type. Make sure to read the documentation. Take a special look at the behavior when dealing with a collection of strings.

5. Show and Hide Conditions

Every element in the page tree can have a show/hide condition. The condition is always interpreted as a scripting expression - so rather than guarding our include() calls with our condition, we can simply suppress the output of the whole element. But we are free to do both and mix and match what makes sense when.

6. Know your tools - be creative!

It might seem obvious, but I'll still say it: If we have a single object and we want to use it as a collection for a repeater, we can simply wrap it in a collection:

The following does not work:

StructrScript

${include('USER_INFO', me, 'user')}

JavaScript

${{
    $.include('USER_INFO', $.me, 'user');
}}

These calls will include the USER_INFO element, but the element will not be able to access the user key.

The me keyword returns a GraphObject, but for a repeater we need a collection of GraphObjects. Because it is already a GraphObject we do not need to use to_graph_object(), but we still need to wrap it in a collection.

StructrScript

In StructrScript we can used the merge() function to create a collection to make the example work.

${include('USER_INFO', merge(me), 'user')}

JavaScript

In JavaScript things are even easier, we only need to use the native array syntax [] to make it work.

${{
    $.include('USER_INFO', [$.me], 'user');
}}

Bringing the pieces together - how to apply the concepts

Now that the theory is out of the way we can look at the examples in the application. The application comes with a small schema and example data set. The schema only has three custom data types Actor, Movie and Genre.

The dataset is included as a file /data/top-rated-movies-01.json and can be imported using the global method importData (via the dashboard for example).

1. Repeaters

In the example application I'm purposely using different ways of creating repeaters. The most basic on is the repeater configuration in the element properties. The following screenshots show one example of this basic repeater configuration. The ACTOR_LINK element is repeated for every Actor node found in the database (sorted by name).

2. include()

One prominent use of the include() function is the global MOVIE_TILE component. The component itself is pretty simple. It prints some basic HTML some information for each Movie node it is repeated for. The component assumes/depends on the dataKey being movie.

The component is included from different pages with the repeater syntax. Only the collection it is repeated for is slightly different on each page.

include('MOVIE_TILE', current.movies, 'movie')

3. include_child()

IMHO one of the best uses for include_child() is page-dependent CSS and JavaScript. This allows us to keep pages very light and only include JavaScript libraries (including CSS) on the pages where they are needed. If we - for example - need a chart drawing library on one page, we do not want it to be included on every other page. The below screenshot shows the main page template. It is the core component of the application. It assumes that on every page it has three children: page-css, page-content and page-js. If any of those components is missing it will simply not be displayed (as it is the case on every page in the example application because I did not include the use-case of page-dependent libraries).

4. to_graph_object()

The self-contained pagination uses the to_graph_object() function to repeat custom JavaScript objects. Those objects are then used in a very simple template to render the individual pagination elements.

The PAGINATION_ITEM component, where those elements are printed, is almost trivial. Only for the sake of the screenshot did I create it as a template element. This is where a single a DOM node would serve us much better. For example the href attribute would not be rendered by default it it was empty - in the template I had to recreate that behavior.

I won't go into more detail regarding the pagination component. It was a fun little excercise to create it and I think it is also a nice excercise for the eager reader to try and understand it. You can do it! One little hint: The pagination itself and the configuration of the pagination are separate so we can first configure it, then use the configuration to retrieve database results, print them and then print the pagination.

When I was almost done with this article, I decided to add another page, creatively named actors_with_pagination to show a second use of the pagination component. More input for the interested reader to understand how the pagination component works. This page is not linked in the menu - it can be accessed directly or via the Preview function in the administration UI.

5. Show and Hide Conditions

In the above example for include() I purposely used a small script to determine if the MOVIE_TILE component was rendered. Lets take a look at it again. We conditionally include MOVIE_TILE if the current object is not empty (i.e. if we have a current object - in this case a Genre - selected).

This can be useful, but Structr also offers a concept call show/hide condtions. These conditions allow us to control under which conditions an element is rendered. This is used on the actors page for the element ACTOR_DETAILS. The component is included via the include() function, but is still not rendered if not current element (in this case an Actor) is available.

Example Application

I firmly believe that tinkering around with stuff is a very good way to learn something new. So feel free to clone the GitHub repository for the example application and run a deployment import in your local instance to get a better understanding of how I'm applying the concepts. If you are not familiar with the deployment process, take a look at the documentation here and here.

Perhaps you feel that a different combination of techniques is better - I won't say that this is the best approach but I'd say that it's not the worst 😅

Important notes:

  • This has been created with the most recent 4.0-SNAPSHOT version of structr and uses pretty recent functionality. Be sure to import it with at least this version, otherwise it will not work.
  • The deployment process will overwrite your current application (if present), so be sure to do this after creating a backup or in a dedicated instance. Creating a throwaway instance for this is easiest via Docker - we have an excellent example Docker setup in this repository