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 types —list,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’svc_typefield selects one of those views; it does not define one. Register avc_typethe 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 existingvc_typeand shape your data for it. This tutorial pickslist.
What you’ll have at the end
- A new file in
mam-main/includes/content-classes/that registers and defines one content type whosevc_typeislist. - That type registered in the global
$local_app_contentarray, 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-mainactive. - 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.mdand at least one existing list-backed class. The two cleanest in-tree examples of avc_typelistclass arelocal-app-favorites-class.phpandcontent-class-settings-menu.php(the Settings Menu / left-menu list) — study the latter’sget_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
interfacedeclaration 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 onapp_settings_categories()/app_settings(). Simpler classes implement a subset:__construct,update_cursor,get_blank_content_html,get_content_type_form, andget_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 omitupdate_cursorentirely, 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 internaltypeidentifier 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_type— the 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 tolistand the apps draw a scrollable list. Set it to a string the apps don’t ship a view for and the button renders nothing.
listunlocks list-manager behavior in the admin, too. The builder treats the view-controller setlist,offDirectory,calendar,favoritesas 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_contentis populated at file-include time and is a global. Keep this registration block outside theclass_existsguard 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
stylewidth and thedata-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.1001is 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 attitleand the row shows the headline. Richer rows chainlayout_field_2,layout_field_3,layout_field_4at other keys (ABI Now maps them topost_title,date_label,venue_name,street).imageurl+showImage/isSquareImagesupply and enable the row thumbnail.idcast to a string honors the frozen App JSON type contract — list-item ids are JSON strings; emitting an integer id can crash the client.subcategoriesis how the list view buckets rows; alistbutton getsuse_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
- Confirm there are no PHP errors on load (the
require_onceline and class both parse). - In Mobile App Manager → App Setup, add a button to a tab or the left menu and open the Content Type dropdown — your
content_typelabel (Announcements) should be listed. Because itsvc_typeislist, App Setup shows the list-manager controls (“Total Listings” etc.), not a single-button form. - Select it, type a section label, and save. Reopen the button —
get_content_type_form()should pre-fill the label you saved. - Open the Previewer (or inspect the
local_app_get_phone_datapayload) and confirm your button’sdataarray contains your rows, each withlayout_id1001, atitle, animageurl, and a stringid— 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
$announcementsfor aWP_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.phpand 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()andapp_settings(). Studylocal-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, whosemam_get_phone_data_before_sendfilter 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.mdincontent-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 fromlist,offDirectory,calendar,favorites,map,html,form, and the rest of the registered catalog. An unrecognizedvc_typerenders nothing, silently. - List rows are the frozen App JSON contract.
idmust be a JSON string, andsubcategoriesmust stay a JSON array. After anyunset()on a subcategories array, reindex witharray_values()— gaps in the keys makejson_encodeemit an object, which the app decodes as an NSDictionary and mis-iterates (mam-main’smam_clean_subcategoriesexists 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_announcementsafter 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_typeis user-visible. Changing it changes what admins see in the dropdown, mid-stream.
Related
- 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_typemust 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.phpandlocal-app-favorites-class.php(vc_typelist),mam-abi-now/includes/content-manager.php(real-world list rows,layout_id1001),local-app-login-class.php(with settings).
Verification
This article was last verified against:
mam-main/includes/content-classes/README.mdmam-main/includes/content-classes/content-class-manager.phpmam-main/includes/content-classes/content-class-settings-menu.php(vc_typelist, list-row shape)mam-main/includes/content-classes/local-app-favorites-class.php(vc_typelist, 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_typebranching,dataassignment,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_ddinsertion-order emit,left_menu_only)mam-use-case/active/mam-abi-now/includes/content-manager.php(real-world list-row emission,layout/layout_id1001)
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.
