Nationbuilder Visual Edit

This guide explains how to add NationBuilder visual editing to an existing theme. Administrators can edit content and manage subpage modules directly on the page.

#Overview

Visual editing adds controls to existing HTML using JavaScript. Changes are saved through a proxy service that calls the NationBuilder API.

The integration uses three files:

The scripts work together or independently. For example, a blog post can use Visual Edit without Visual Builder.

To configure them, add data attributes to existing template HTML. Attributes on the outer wrapper identify the page being updated; attributes on individual fields identify the content to edit.

You will need developer access to the Nation to create API tokens, plus a configured API proxy deployed to Heroku.

Heroku Deployment

Before adding the scripts to the theme:

  1. Set up developer API access for the Nation.
  2. Deploy the API proxy app to Heroku.
  3. Enable the routes required by the scripts, using the same admin route token for all routes.
  4. Use the proxy URL and shared admin route token in the script configuration below.

TODO: improvements are being made to the Heroku deployment and a walkthrough will be added once ready.

Existing Module HTML Pre-Check

Review the existing module templates before adding data attributes.

  1. Keep empty fields available for editing. Remove conditions such as {% if headline.size > 0 %} where they prevent an administrator from accessing an editable field. Administrators need to see blank fields to set an initial value.

  2. Make layout choices explicit. Use a configuration tag or a separate module type to control layout. Avoid changing the layout solely because a field is empty, such as hiding the entire image section when no image is attached. If a section needs to be optional, expose that choice in Settings.

  3. Use <img> elements for editable images. The image picker does not support CSS background images. Update modules that use style="background-image:…" before adding image editing. Using <img> also allows the theme to use srcset.

  4. Use one source for each editable value. For example, if a hero eyebrow can come from either an excerpt or a tag, choose one source so the editor has an unambiguous field to update.

  5. Keep tag configuration simple. Settings supports a single tag selected from a dropdown, multiple tags selected with checkboxes, and a text value with a fixed tag prefix. More complex formats, such as source:type:value, may need to be simplified in the module's Liquid before they can be exposed in Settings.

  6. Check draft visibility. If using the optional publish controls, keep module pages published in NationBuilder and use the draft tag to mark unfinished modules. Update the parent Liquid to hide draft-tagged modules from visitors while still rendering them for administrators. See Publish State for the wrapper attributes and controls.

Module Page Tags Overview

Two page tags support module ordering and draft visibility:

These tags work alongside the theme's existing module-type and configuration tags, such as module:hero or image-left.

Visual Edit

The visual edit script owns content editing, image uploads, settings, publishing, save, and cancel.

Setup

Add the script to layout.html, inside the administrator check:

{% if request.is_admin? %}
  <script src="{{ theme['_z_script_visual_edit.js'] }}"
          apiURL="https://nation-api-endpoint.herokuapp.com"
          apiToken="admin-route-token"></script>
{% endif %}

Replace apiURL with the Heroku proxy URL and apiToken with the shared admin route token. Only include the script for administrators, and ensure _z_visual_edit.scss is included in the theme's compiled styles.

Visual Edit Data Attributes

Configure an editable wrapper first, then declare the fields and settings inside it. The examples use page for a full page and child for a subpage module; use the Liquid object for the page that owns the content.

Field Updating

Add these attributes to the module's outer element or page content wrapper:

Attribute Purpose
data-edit="true" Activates editing on this wrapper (a coloured border and edit button will appear on hover).
data-page-id="{{ page.id }}" Required page ID. The page id is used for V2 API operations.
data-resource-id="{{ page.basic.id }}" Required resource ID. This is the id used by the V1 API. Use page.blog_post.id for a blog post.
data-page-type="basic" Selects the API update route. Set explicitly to basic or blog_post.
data-blog-id="{{ page.parent.blog.id }}" Required for blog posts only: the parent blog's page ID.
data-admin-edit-url="{{ page.admin_edit_url | escape }}" Optional. Adds a control-panel link to Settings, opening in a new tab.

Inside the wrapper, add data-edit-field="FIELD_NAME" to each editable field. Use the API field name, such as headline, rather than a display label.

data-edit-control chooses the editor:

Value Editor
text Plain inline text. This is the default when the attribute is omitted.
rich_text Rich-text editor with formatting controls.
image_picker Image picker for an <img> element.

Basic pages support headline, excerpt, content, page settings, and image attachments.

Example module attributes:

<div data-edit="true"
     data-page-type="basic"
     data-resource-id="{{ page.basic.id }}"
     data-page-id="{{ page.id }}">
  <h2 data-edit-field="headline">{{ page.headline }}</h2>
  <div class="content" data-edit-field="content" data-edit-control="rich_text">
    {{ page.basic.content }}
  </div>
</div>

Images: use data-edit-control="image_picker" on an editable image field. Existing images need data-attachment-id so they can be replaced or removed. Image pickers can also set data-attachment-prefix and data-image-aspect-ratio.

The wrapper may provide data-page-slug for attachment operations. Otherwise, the slug is resolved from an explicit slug setting or an API response. The wrapper attributes data-page-slug, data-page-name, and data-page-title also serve as legacy Basic Page setting fallbacks; explicit setting declarations take precedence.

Blog posts: supply the parent blog ID and the configured text or rich-text field names. For example:

<div data-edit="true"
     data-page-type="blog_post"
     data-resource-id="{{ page.blog_post.id }}"
     data-page-id="{{ page.id }}"
     data-blog-id="{{ page.parent.blog.id }}">
  <h2 data-edit-field="headline">{{ page.headline }}</h2>
  <div class="content" data-edit-field="content_after_flip" data-edit-control="rich_text">
    {{ page.blog_post.content_full }}
  </div>
</div>

Settings Dialog

Use Settings for Basic Page values that are not edited directly on the page, such as the slug, page name, title, or tags controlling a module's layout. The Settings button appears after the administrator clicks Edit.

Declare each setting as an empty element inside the editable wrapper, normally a hidden <div>.

Attribute Purpose
data-edit-field Required API field name, for example slug, name, title, excerpt or tags.
data-edit-control Required control: settings.text, settings.select, or settings.multiselect. settings is an alias for settings.text.
data-edit-value Current value, calculated in Liquid. Defaults to blank.
data-edit-label User-facing label. Defaults to the field name.
data-edit-refresh="true|false" Controls whether changing this setting requires saving the entire edit session and refreshing the page. Defaults to false for slug, name, and title, and true for custom tag settings.

When a setting marked for refresh changes, the dialog's Save and refresh action saves the entire edit session and reloads the page so Liquid can render the new configuration. Set data-edit-refresh explicitly to override the defaults. Settings that do not require a refresh are staged with OK, then persisted with the main Save action.

Text settings

Use settings.text for slug, name, or title:

<div hidden data-edit-field="slug" data-edit-control="settings.text"
     data-edit-value="{{ page.slug | escape }}"
     data-edit-label="Page Slug"></div>

Single-choice tag settings

Use settings.select with data-edit-field="tags". List the tags this setting controls in data-edit-options, separated by commas. Optional data-edit-option-labels supplies display labels in the same order.

<div hidden data-edit-field="tags" data-edit-control="settings.select"
     data-edit-value="image-left" data-edit-label="Image Position"
     data-edit-options="image-left,image-right"
     data-edit-option-labels="Left,Right"
     data-edit-empty-label="None"></div>

data-edit-empty-label adds a blank choice that removes the setting's tag. If the current value is blank and no empty label is configured, the first option is staged as the default and saved when the administrator confirms Save and refresh.

Multiple-choice tag settings

Use settings.multiselect to show checkboxes. Supply the currently selected tags as a comma-separated value:

<div hidden data-edit-field="tags" data-edit-control="settings.multiselect"
     data-edit-value="border-top,border-left" data-edit-label="Borders"
     data-edit-options="border-top,border-right,border-bottom,border-left"
     data-edit-option-labels="Top,Right,Bottom,Left"></div>

Text values stored in tags

Use settings.text with data-edit-field="tags" and data-edit-tag-prefix for a unique prefix:value tag:

<div hidden data-edit-field="tags" data-edit-control="settings.text"
     data-edit-value="selected-tag:People" data-edit-label="Selected Tag"
     data-edit-tag-prefix="selected-tag:"></div>

data-edit-value contains the complete current tag. The dialog shows only the value after the prefix, such as People, and replaces the previous tag when the value changes.

The tag examples use fixed current values for clarity. In the theme, calculate these values from the page's tags in Liquid and escape them when inserting them into HTML attributes.

Tag settings submit only the additions and removals they own. Do not overlap options or tag prefixes between settings on the same wrapper.

Publish State

Module drafts use a draft tag on a published Basic Page. NationBuilder does not render genuinely unpublished child pages, so the parent Liquid must hide draft-tagged modules from non-administrators while continuing to render them for administrators.

Add data-publish-status="draft|published" to the editable Basic Page wrapper, calculating its value from the current tags:

Both states add a Published toggle to Settings. Use this toggle to publish or unpublish a module. It stages the change until OK, then persists it with the main Save action; the toggle itself does not require a refresh. The hover Publish button saves immediately. Both controls update data-publish-status only after the API succeeds.

Wrappers without this attribute, or with a value other than draft or published, get no publish controls. The wrapper must also have data-page-type="basic" and both IDs.

For example, after Liquid has inspected child.tags and set publish_status:

<div data-edit="true" data-page-type="basic"
     data-resource-id="{{ child.basic.id }}"
     data-page-id="{{ child.id }}"
     data-publish-status="{{ publish_status }}">
  <!-- Module fields go here. -->
</div>

_z_visual_edit.scss supplies the Draft label and reserves space for it in the shared Visual Edit / Visual Builder controls row.

Visual Builder

Visual Builder lets administrators add, reorder, and delete Basic Page subpage modules. It can manage the main subpage module area or run inside a module, such as a concertina with its own child items.

Builder controls are hidden while a module is being edited with Visual Edit. Related V1 updates run in order and use best-effort rollback after partial failures.

Setup

Load the script once per page, for administrators only. For example, place it at the bottom of the builder template, such as z_content.html, after defining modules_config_json as shown below. If several modules contain builders, load the script from a shared template rather than once per module.

{% if request.is_admin? %}
  <script src="{{ theme['_z_script_visual_builder.js'] }}"
          apiURL="https://nation-api-endpoint.herokuapp.com"
          apiToken="admin-route-token"
          modulesConfig='{{ modules_config_json | escape }}'></script>
{% endif %}

Use the same proxy URL and admin route token as Visual Edit. Visual Builder also requires _z_visual_edit.scss in the theme's compiled styles.

Module Configuration

Visual Builder needs two kinds of configuration:

  1. Definitions of the new modules administrators can create.
  2. Data attributes identifying the existing builder container and modules.

Define the available module types as a JSON array. Each definition appears in the Add dialog:

Property Purpose
name Required display name. Also used when naming a new subpage.
type Required page type. Only basic is currently supported.
description Optional description in the Add dialog.
tags Optional array of tags to apply when creating the module. Use the tags expected by the theme's Liquid.
image Optional preview image URL.
categories Optional array of categories for filtering the Add dialog.

For example, define this Liquid capture before the script include:

{% capture modules_config_json %}
[
  {
    "name": "Basic Text",
    "description": "Basic text with a heading and content.",
    "type": "basic",
    "tags": ["draft", "page_no_robots"],
    "image": "https://placehold.co/400",
    "categories": ["Basic", "Text"]
  },
  {
    "name": "Hero",
    "description": "A hero module with an editable image.",
    "type": "basic",
    "tags": ["module:hero", "draft", "page_no_robots"],
    "image": "https://placehold.co/400",
    "categories": ["Hero", "Header"]
  },
  {
    "name": "Banner",
    "description": "A banner module with an editable image.",
    "type": "basic",
    "tags": ["layout:banner", "draft", "page_no_robots"],
    "image": "https://placehold.co/400",
    "categories": ["Banner", "Image"]
  },
  {
    "name": "Concertina",
    "description": "An expandable list of child items.",
    "type": "basic",
    "tags": ["module_concertina", "draft", "page_no_robots"],
    "image": "https://placehold.co/400",
    "categories": ["Concertina"]
  }
]
{% endcapture %}

These module tags are examples; match them to the existing theme. Include draft when new modules should use the draft visibility system described above. Each module also uses an order:N tag, which the builder manages when inserting or reordering modules.

Visual Builder creates modules using a name without a separate headline, because NationBuilder V1 normalises a Basic Page's headline to its name during creation.

Visual Builder Data Attributes

Parent container

Keep a builder container in the HTML even when it has no modules, so the script can show the first Add control.

Attribute Purpose
data-builder="true" Activates Visual Builder on this container.
data-page-id="{{ page.id }}" V2 ID of the parent page. New modules become subpages of this page.
data-page-name="{{ page.name | escape }}" Parent page name, used when naming new subpages.
data-module-config Optional JSON array replacing modulesConfig for this builder. See Using the builder script in a module at the end of this section.
<div data-builder="true"
     data-page-id="{{ page.id }}"
     data-page-name="{{ page.name | escape }}">
  <!-- Render this builder's subpage modules here. -->
</div>

Individual modules

Add these attributes to each module wrapper inside its builder:

Attribute Purpose
data-module="true" Identifies a module that can be moved or deleted.
data-resource-id="{{ child.basic.id }}" V1 Basic Page resource ID used to update the module.
data-page-id="{{ child.id }}" V2 page ID of the module.
data-module-order="N" Numeric value from the module's order:N tag.

Each existing module must have one order:N tag. Render modules in their intended order and supply the corresponding number in data-module-order.

To make the same module editable, also add data-edit="true", data-page-type="basic", and the field attributes described earlier. For example, this is the first module after Liquid has resolved its order tag to 1:

<div data-module="true"
     data-module-order="1"
     data-edit="true"
     data-page-type="basic"
     data-resource-id="{{ child.basic.id }}"
     data-page-id="{{ child.id }}">
  <h2 data-edit-field="headline">{{ child.headline }}</h2>
  <div class="content" data-edit-field="content" data-edit-control="rich_text">
    {{ child.basic.content }}
  </div>
</div>

In the module loop, calculate data-module-order from each child's tag rather than hardcoding it. Add data-publish-status as described above if the module also needs publish controls.

Rendering modules in order

This example scans the parent's children for each order:N tag, then renders the matching module. It assumes the children are Basic Page modules with consecutive order tags starting at order:1. It also applies the optional draft visibility rules.

Use module_page consistently inside this loop: its IDs and fields belong to the module, while page identifies the builder's parent. Replace the simple heading and content markup with the theme's existing module rendering as needed, retaining the wrapper attributes.

<div data-builder="true"
     data-page-id="{{ page.id }}"
     data-page-name="{{ page.name | escape }}">
  {% if page.children_count > 0 %}
    {% for order in (1..page.children_count) %}
      {% capture order_tag %}order:{{ order }}{% endcapture %}
      {% for module_page in page.children %}
        {% assign has_order_tag = false %}
        {% assign publish_status = 'published' %}
        {% for tag in module_page.tags %}
          {% if tag.name == order_tag %}
            {% assign has_order_tag = true %}
          {% endif %}
          {% if tag.name == 'draft' %}
            {% assign publish_status = 'draft' %}
          {% endif %}
        {% endfor %}
        {% unless has_order_tag %}
          {% continue %}
        {% endunless %}
        {% if request.is_admin? or publish_status == 'published' %}
          <div data-module="true" data-module-order="{{ order }}"
               data-edit="true" data-page-type="basic"
               data-resource-id="{{ module_page.basic.id }}"
               data-page-id="{{ module_page.id }}"
               data-publish-status="{{ publish_status }}">
            <h2 data-edit-field="headline">{{ module_page.headline }}</h2>
            <div data-edit-field="content" data-edit-control="rich_text">
              {{ module_page.basic.content }}
            </div>
          </div>
        {% endif %}
      {% endfor %}
    {% endfor %}
  {% endif %}
</div>

The container remains in the HTML when there are no children, so administrators can add the first module. A child without a matching order tag will not render in this example; set up the existing modules' order tags before enabling the builder.

Using the builder script in a module

For nested content, add a builder container inside the module. For example, a concertina can manage its own child items while the outer builder manages full page modules.

Set data-module-config on the nested container to replace the script's modulesConfig for that builder. In this example, module_page is the concertina's page, and the Add dialog offers a Concertina Item:

<div data-builder="true"
     data-page-id="{{ module_page.id }}"
     data-page-name="{{ module_page.name | escape }}"
     data-module-config='[{"name":"Concertina Item","type":"basic","tags":["page_no_robots"]}]'>
  <!-- Render the concertina's own child items here. -->
</div>

Use the Liquid variable that represents the module in your template so new items become children of the correct page. Each child item needs its own module attributes and an order tag within the nested builder. Adapt the ordered-rendering example to loop over the module's children, using a separate variable such as item_page for each item.

Match the definition's tags to the concertina's Liquid. Add draft if its child items also use the optional publish system, and apply the same visitor visibility rules. For JSON generated in Liquid, escape the value as in the script configuration example. The script still loads only once per page.