Build your first custom content class (list view)

In MAM Suite, every button a customer can drop onto a screen in the no-code builder is backed by a content class. There are 21 of them shipped in mam-main, covering Login, Map, Web URL, Phone Call, Favorites, and so on. Adding a new content class is how you give customers a brand-new building block to assemble screens with.

This tutorial walks you through building one from scratch: a content class that feeds a curated list of your own data into the app’s existing list view. By the end you’ll have a working content type that shows up in the App Setup admin UI and renders a scrollable list of items on the device.

Be clear about what you are — and aren’t — building. You are not inventing a new kind of native screen. You are producing data shaped for a view the shipped apps already know how to draw. The single most important design decision is which existing view you target; everything else is shaping your data to fit it.

You cannot add a new vc_type. The mobile clients (iOS and Android) render a fixed set of view typeslist, offDirectory, calendar, favorites, map, html, form, and the rest of the shipped catalog. That set lives in the compiled apps, not on the server. A content class’s vc_type field selects one of those views; it does not define one. Register a vc_type the apps have never heard of and the button renders nothing — there is no error, just a blank. So the rule for every custom content class is: pick an existing vc_type and shape your data for it. This tutorial picks list.


What you’ll have at the end

  • A new file in mam-main/includes/content-classes/ that registers and defines one content type whose vc_type is list.
  • That type registered in the global $local_app_content array, so it appears in the Content Type dropdown in App Setup.
  • A class implementing the method set a list-backed content class needs.
  • An admin who can add your button to a tab or the left menu, and a device (or the Previewer) that renders your curated items as a scrollable list.

We’ll build an “Announcements” list as our worked example — a short, curated set of items, each with a headline and an image, rendered through the standard list view. It’s deliberately simple (the items are hardcoded) so the focus stays on the mechanics of the content-class contract and, crucially, on the exact item shape the list view consumes.


Prerequisites

  • A local MAM Suite development environment with mam-main active.
  • Comfort with WordPress plugin PHP (procedural include order, globals).
  • A code editor with access to mam-main/includes/content-classes/.
  • Read access to the directory’s README.md and at least one existing list-backed class. The two cleanest in-tree examples of a vc_type list class are local-app-favorites-class.php and content-class-settings-menu.php (the Settings Menu / left-menu list) — study the latter’s get_data_for_app() for the exact item shape.

This is a developer task. There is no admin-only path — adding a content type means writing PHP in mam-main.

Heads-up on the interface. There is no formal PHP interface declaration for content classes yet; the contract is duck-typed (a refactor target noted in the directory README). The directory README documents an idealized six-method set that leans on app_settings_categories() / app_settings(). Simpler classes implement a subset: __construct, update_cursor, get_blank_content_html, get_content_type_form, and get_data_for_app. That subset is what our list type needs, so it’s the set we implement here. (The two pure-list exemplars, Favorites and Settings Menu, go even lighter — they omit update_cursor entirely, since duck typing only requires the methods actually called. We keep it for parallelism with the simple button classes.) The settings methods are optional and only needed if your type exposes configurable settings tabs — the Login button is the example to study for that.


Steps

1. Create the class file

Create a new file in the content-classes directory. Follow the existing naming convention:

mam-main/includes/content-classes/local-app-announcements-class.php

Start it with the PHP open tag and the registration block (covered in the next step), then the class definition.

2. Register the type in $local_app_content

At the very top of the file — before the class definition, so it runs at include time — register your type in the global $local_app_content array. This is the single line that makes your type appear in the admin Content Type dropdown:

<?php
$local_app_content['Announcements'] = array(
    'content_type' => 'Announcements',
    'class'        => 'local_app_announcements',
    'vc_type'      => 'list',
);

Three keys, each load-bearing:

  • Array key (Announcements) — the internal type identifier stored in each button’s saved configuration. It is frozen once customers have saved buttons of this type (see Gotchas).
  • content_type — the user-visible label in the admin UI. This is what an admin sees in the Content Type dropdown.
  • class — the PHP class name to instantiate for this type. Match it exactly.
  • vc_typethe client view your data renders in. This is not decorative and it is not legacy. When mam-main builds a button, it copies this value straight onto the button payload (main-json-manager.php: 'vc_type' => $local_app_content[ $value['type'] ]['vc_type']), and the JSON builder then branches on it'html' merges your return into the button itself, while 'list' and 'offDirectory' treat your return as a data array of rows and hand it to the corresponding native view. Set it to list and the apps draw a scrollable list. Set it to a string the apps don’t ship a view for and the button renders nothing.

list unlocks list-manager behavior in the admin, too. The builder treats the view-controller set list, offDirectory, calendar, favorites as layout-manager classes (button-manager-class.php), which is what gives your button the “Total Listings” and per-list layout controls in App Setup rather than a single-button form.

$local_app_content is populated at file-include time and is a global. Keep this registration block outside the class_exists guard from Step 4 — it must run on every include, not just the first, or your type silently never registers. Anything that reads it must run after the content-class manager has loaded. You don’t manage that ordering yourself — the manager does (next step).

3. Wire the file into the manager

The registry is loaded by content-class-manager.php, which require_onces each class file explicitly — one line per file. Add your file to that chain:

// in content-class-manager.php
require_once __DIR__ . '/local-app-announcements-class.php';

Without this line your file never loads, the registration never runs, and your type never appears. The order within the chain only affects where your type appears in the dropdown — local_app_content_type_dd() iterates $local_app_content and emits options in registration (include) order, with no sorting.

4. Define the class with a class-exists guard

Wrap the class in a class_exists guard, matching the house pattern. The guard prevents fatal redeclaration errors if the file is included more than once:

if ( ! class_exists( 'local_app_announcements' ) ) {

    /**
     * Content class representing a curated list of announcements rendered via the app's list view.
     *
     * Part of the MAM Suite platform.
     * Layer: Core
     *
     * @package MAM_Suite
     */
    class local_app_announcements {

        // methods go here (Steps 5-9)
    }
}

5. __construct()

Most content classes have an empty constructor. Wire hooks here only if your type needs them (the richer classes do). For a self-contained list, leave it empty:

/** Constructor. */
public function __construct() {
}

6. update_cursor()

A no-op on the simple classes. Implement it so the duck-typed contract is satisfied:

/** Updates the cursor state. @return void */
public function update_cursor() {

    // No action.
}

7. get_blank_content_html()

Returns the HTML rendered when an admin adds a new, empty button of this type. It is the input the admin first types into. A pure-list class with nothing to configure can simply return 'None' (that’s what Favorites and Settings Menu do). Our Announcements list takes one optional value — a section label used to group the list’s items — so we render a real input and demonstrate the round-trip. Note the conventions baked into the markup:

  • class="local-app-content-source" — the admin JS keys off this class to read the value.
  • data-type — must match your array key from Step 2 (Announcements).
  • data-id="BLANK_ID" — the literal placeholder the admin UI swaps for a real ID on save.
/** Returns empty HTML for a blank button slot. @return string */
public function get_blank_content_html() {

    return '<input placeholder="Section label, e.g. News" type="text" style="width:20em" class="local-app-content-source" data-type="Announcements" data-id="BLANK_ID" value="">';
}

8. get_content_type_form( $id, $content_source )

The same input, but for an existing button that already has a value. It receives the button’s real $id and its stored $content_source and echoes them back into the field so the form is pre-filled when an admin edits the button:

/**
 * Renders the content source form.
 *
 * @param mixed $id             The content element ID.
 * @param mixed $content_source The selected content source value.
 * @return string
 */
public function get_content_type_form( $id, $content_source ) {

    return '<input placeholder="Section label, e.g. News" type="text" class="local-app-content-source" data-type="Announcements" data-id="' . $id . '" value="' . $content_source . '">';
}

Steps 7 and 8 are near-identical apart from the style width and the data-id. That’s intentional — keep them in sync, since they render the same field in two states.

9. get_data_for_app( $source, $unused = false, $title = '' ) — shape the list rows

This is the method that matters, and where a list class differs sharply from a single-button class. For a list type, mam-main does not treat your return as the button itself — it assigns your return to the button’s data key and hands that array of rows to the native list view (main-json-manager.php: $this_button['data'] = $content_obj->get_data_for_app( $value['source'], false, $title );, then, because vc_type is list, $this_button['use_subcategories'] = 'yes').

So you must return an array of item rows, and each row must carry the fields the list view reads. There is no new rendering to invent — the list view already knows how to draw a row; your job is to populate the keys it looks for. The canonical shape comes straight from the Settings Menu list class (content-class-settings-menu.php) and is confirmed by the real-world ABI Now content manager:

/**
 * Returns the list rows for the app payload.
 *
 * @param mixed  $source The stored content source (our section label).
 * @param mixed  $unused Positional slot mam-main passes (false for list types).
 * @param string $title  The button's title, passed by mam-main.
 * @return array         An array of list-item rows.
 */
public function get_data_for_app( $source, $unused = false, $title = '' ) {

    // Where your real data comes from — a query, an API, an option.
    // Hardcoded here so the shape stays the focus.
    $announcements = array(
        array( 'headline' => 'We are open late all week', 'image' => 'https://example.com/img/late.jpg' ),
        array( 'headline' => 'New parking entrance is now open', 'image' => 'https://example.com/img/parking.jpg' ),
    );

    // The section label the admin typed (Step 7/8), falling back to the button title.
    $section = ( is_string( $source ) && $source !== '' ) ? $source : ( $title !== '' ? $title : 'Announcements' );

    $list = array();
    $i    = 1;

    foreach ( $announcements as $item ) {

        $list[] = array(
            // App JSON contract: ids are STRINGS, not ints. Cast every id.
            'id'             => (string) $i,

            // The row's data. layout_field_N below names which of these keys
            // feeds each text line the layout draws.
            'title'          => $item['headline'],
            'imageurl'       => $item['image'],

            // Which client layout draws the row. '1001' is the standard
            // image-plus-text list row used across the suite.
            'layout'         => '1001',
            'layout_id'      => '1001',

            // Line 1 of the row renders the item's 'title' key.
            'layout_field_1' => 'title',

            'layout_height'  => '80',
            'showImage'      => 'yes',
            'isSquareImage'  => 'yes',
            'nocat'          => 'yes',

            // The list view groups rows by subcategory. Keep this a real
            // (reindexed) array — see the Gotcha below.
            'subcategories'  => array( $section ),
        );

        $i++;
    }

    return $list;
}

What each block is doing, and why it’s not optional:

  • layout / layout_id = '1001' selects the client-side row layout. 1001 is the standard image-plus-text list row used throughout the suite (the left-menu list, the ABI Now venue and calendar lists all use it). It is a layout the app ships; you are choosing one, not defining one.
  • layout_field_1 = 'title' tells that layout which key on the row to draw on its first text line. Point it at title and the row shows the headline. Richer rows chain layout_field_2, layout_field_3, layout_field_4 at other keys (ABI Now maps them to post_title, date_label, venue_name, street).
  • imageurl + showImage/isSquareImage supply and enable the row thumbnail.
  • id cast to a string honors the frozen App JSON type contract — list-item ids are JSON strings; emitting an integer id can crash the client.
  • subcategories is how the list view buckets rows; a list button gets use_subcategories = 'yes' set automatically.

A class that returns an empty array here still appears in the admin UI, but the list renders as empty (mam-main substitutes a nodata marker). A class that returns a scalar instead of an array of rows renders nothing useful — the list view has no rows to draw.

10. Verify it end to end

  1. Confirm there are no PHP errors on load (the require_once line and class both parse).
  2. In Mobile App Manager → App Setup, add a button to a tab or the left menu and open the Content Type dropdown — your content_type label (Announcements) should be listed. Because its vc_type is list, App Setup shows the list-manager controls (“Total Listings” etc.), not a single-button form.
  3. Select it, type a section label, and save. Reopen the button — get_content_type_form() should pre-fill the label you saved.
  4. Open the Previewer (or inspect the local_app_get_phone_data payload) and confirm your button’s data array contains your rows, each with layout_id 1001, a title, an imageurl, and a string id — and that the list renders your two announcements as a scrollable list.

If the button appears but the list is empty, check get_data_for_app() first — returning an empty array, a scalar, or rows missing layout_id/layout_field_1 are the classic causes. If the button doesn’t appear in the dropdown at all, work back through the wiring: confirm the require_once line in content-class-manager.php points at your exact filename, and that the $local_app_content registration sits outside the class_exists guard. If the button appears but renders as a single blank tile instead of a list, re-check that vc_type is exactly 'list' — a typo’d or unknown vc_type has no client view behind it. (One catch worth knowing: entries flagged left_menu_only are hidden from the home tab bar’s dropdown but offered for the left menu — you didn’t set that flag, so your button shows in both.)


What’s next

  • Drive the list from real data. Swap the hardcoded $announcements for a WP_Query, a custom-post-type query, or a cached third-party fetch — the row shape stays identical. Keep it fast; get_data_for_app() runs on every app data fetch.
  • Target a different existing view. The same technique applies to the other layout-manager vc_types — offDirectory, calendar, favorites. Each consumes the same row primitives (layout_id, layout_field_N, imageurl, subcategories) with its own extras. local-app-favorites-class.php and the ABI Now content manager (mam-abi-now/includes/content-manager.php) are the worked examples. Remember the constraint holds for all of them: you are feeding a shipped view, never inventing one.
  • Add configurable settings to your type. If your type needs settings tabs (toggles, selects, colors) in its per-button settings UI, implement app_settings_categories() and app_settings(). Study local-app-login-class.php — it’s the richest worked example of the settings schema (category, title, variable, type, id, environment).
  • Understand the data pipeline. Your get_data_for_app() return joins a much larger payload assembled by the phone-data hub, whose mam_get_phone_data_before_send filter fans out to many subscribers across the suite. See the planned Explanation: How the mobile JSON payload is assembled article.
  • Read the contract reference. The directory README.md in content-classes/ is the canonical (if idealized) description of the interface and lists every shipped class.

Gotchas

  • You can’t invent a vc_type. The client view set is compiled into the shipped apps. get_data_for_app() can only feed a view that already exists — pick from list, offDirectory, calendar, favorites, map, html, form, and the rest of the registered catalog. An unrecognized vc_type renders nothing, silently.
  • List rows are the frozen App JSON contract. id must be a JSON string, and subcategories must stay a JSON array. After any unset() on a subcategories array, reindex with array_values() — gaps in the keys make json_encode emit an object, which the app decodes as an NSDictionary and mis-iterates (mam-main’s mam_clean_subcategories exists precisely to prevent this). Match the types the shipped rows already use rather than inventing your own.
  • Class names are frozen for any content class customers may reference in saved button arrays. Renaming local_app_announcements after customers have created buttons of this type would orphan every one of them. Rename internals only alongside a migration that rewrites the serialized button arrays.
  • The array key is also frozen once buttons of this type exist — it is stored as each button’s type.
  • No interface enforcement. A missing method won’t error at load time; it fails only when something calls it. Implement the methods the pipeline will call even when some are no-ops.
  • content_type is user-visible. Changing it changes what admins see in the dropdown, mid-stream.

  • Reference: content-classes directory — the registry README and full class list.
  • Explanation: the content-class interface (six methods) — why the contract is shaped the way it is, and why the emitted type/vc_type must be one the client already recognizes.
  • Hook: mam_get_phone_data_before_send — how button data joins the mobile payload.
  • Explanation: How the mobile JSON payload is assembled (planned).
  • Study files: content-class-settings-menu.php and local-app-favorites-class.php (vc_type list), mam-abi-now/includes/content-manager.php (real-world list rows, layout_id 1001), local-app-login-class.php (with settings).

Verification

This article was last verified against:

  • mam-main/includes/content-classes/README.md
  • mam-main/includes/content-classes/content-class-manager.php
  • mam-main/includes/content-classes/content-class-settings-menu.php (vc_type list, list-row shape)
  • mam-main/includes/content-classes/local-app-favorites-class.php (vc_type list, minimal method set)
  • mam-main/includes/content-classes/local-app-web-url-class.php (single-button method set)
  • mam-main/includes/content-classes/local-app-login-class.php (settings methods)
  • mam-main/includes/app-connect/main-json-manager.php (vc_type branching, data assignment, use_subcategories, mam_clean_subcategories)
  • mam-main/includes/app-settings/button-manager-class.php (layout_manager_classes = list/offDirectory/calendar/favorites)
  • mam-main/includes/app-settings/local-app-app-setup-class.php (local_app_content_type_dd insertion-order emit, left_menu_only)
  • mam-use-case/active/mam-abi-now/includes/content-manager.php (real-world list-row emission, layout/layout_id 1001)

Re-verify whenever the content-class interface gains a formal interface declaration, the registration array keys change, the set of client vc_type views changes, the list-row field contract (layout_id, layout_field_N, subcategories) changes, or the include mechanism in content-class-manager.php changes.

Was this article helpful?
Contents

    Need Support?

    Can't find the answer you're looking for? Don't worry we're here to help!