Forms manager prefill paths (row → form)

The problem this doc solves

The framework supports three distinct patterns for “user taps something, a form opens with values pre-filled from the underlying data row.” They publish different keys on the row, target different iOS resolvers, and are easy to mix up. This doc lays them side by side so plugin authors can pick the right one, and so future iOS work knows where each path lands.

It also documents an open gap in pattern C (content_type: 'form') — the prefill values aren’t currently resolved on the iOS side. Tracked at the bottom.


Vocabulary

  • Row — the dict a content class adds to its get_data_for_app() return array. One row per list item / card / cell
  • Form payload — the cached form definition in $local_app_form_array, shared across all rows that reference it
  • Form index — 1-based position in $local_app_form_array, obtained via apply_filters( 'mam_gf_get_form_from_cache', 0, $form_id )
  • Prefill values — the key-value pairs the form should be populated with when it opens for this specific row. Keyed by populate_with_key slugs

The form payload is shared. Prefill is per-row. Every pattern below is a different mechanism for the per-row part.


Pattern A — Tabbar button (open_form_array)

The tabbar button across the bottom opens a sheet containing one or more forms. Each form gets its own prefill dict. Used by mam-geodirectory for Edit Staff and Edit Events.

Row / button keys

$tabbar_button['icon']   = 'white_person';
$tabbar_button['action'] = 'open_form_array';
$tabbar_button['source'] = [
    [
        'custom_form'        => $form_index,        // 1-based index into $local_app_form_array
        'custom_form_values' => $form_values,       // [slug => value] keyed by populate_with_key
        'custom_form_icon'   => 'white_person',
        'custom_form_name'   => $staff->display_name,
        'no_persist'         => 'yes',
        // optional:
        'custom_form_excluded_fields' => [ '<field_id>', ... ],
    ],
    // more form entries...
];

iOS resolver site

tab_bar_manager button handler, open_form_array action branch. Walks source and presents each as a card → opens form on tap, passing custom_form_values as the populate dict.

Known-working reference

mam-suite/mam-geodirectory/includes/mam_gd_tab_bar_buttons.php lines 361-449 (Edit Staff), 720-900 (Edit Events).


Pattern B — custom_form_array on a row

Each row publishes one or more form entries inline. The mobile list renders each as an icon/button. Used by mam-qrlife for the Edit button on profile cards.

Row keys

$row['custom_form_array'][] = [
    'custom_form'        => $form_index,
    'custom_form_values' => $populate_values,     // [slug => value] keyed by populate_with_key
    'custom_form_icon'   => 'black_edit.png',
    'custom_form_name'   => 'Edit',
    'no_persist'         => 'yes',
];
$row['edit_form_id']             = $form_index;   // optional, for "row has primary edit form"
$row['layout_button_3_key']      = $form_index;   // optional, layout-button binding
$row['layout_button_3_key_type'] = 'form';

iOS resolver site

Detail-view “custom forms” rendering. Iterates custom_form_array; for each entry, opens the form referenced by custom_form and passes custom_form_values as the populate dict.

Known-working reference

mam-use-case/active/mam-qrlife/includes/content-class-profiles.php lines 510-525.


Pattern C — content_type: 'form' row tap

The whole row is tap-to-open-form. No button, no icon — the row itself is the affordance. Used by mam-aquaman-boat-booking for the Anchor Times list.

Row keys

$row['content_type']             = 'form';
$row['form_id']                  = (string) $form_index;
$row['layout_button_3_key']      = (string) $form_index;
$row['layout_button_3_key_type'] = 'form';

// Prefill values published at top level (current convention, see §gap below)
$row['anchor_time']  = '09:00';
$row['capt_one']     = '425';
$row['capt_two']     = '423';
$row['booking_date'] = '2026-05-29';

iOS resolver path

The row tap flows through three hops:

  1. ListingSelectionRouter.m openDetailListingWIthID:…fromVC: — looks up the tapped row by ID; when content_type == "form" it calls [VCLauncher launchVCWithType:@"form" … selectedItem:item_info …], passing the row dict as the selectedItem argument.
  2. VCLauncher.m launchVCWithType:, the [vc_type isEqualToString:@"form"] branch (around line 169) — builds a vc_form_manager, sets wf_form from options[@"wf_form_id"], and now also sets controller.selectedItem = selectedItem so the form-manager’s existing populate fallback can read row keys directly. (Previously only selectedDirectory was set, leaving the selectedItem property nil.)
  3. vc_form_manager.m picks up selectedItem[key] for each field with populate_with_key.
// VCLauncher.m, form branch
vc_form_manager *controller = [[vc_form_manager alloc] init];
controller.button_id      = currentItem;
controller.back_to_vc_id  = @"main_menu";
controller.form_source    = formSource;
controller.selectedDirectory = selectedItem;
controller.selectedItem   = selectedItem;   // ← the fix
controller.subFormParentId = [NSString stringWithFormat:@"%ld", (long)currentItem];
controller.fromLeftMenu   = isFromLeftMenu;
NSInteger wf_form_id = [options[@"wf_form_id"] integerValue];
if (wf_form_id > 0)
    controller.wf_form = [general_functions getFormToOpen:wf_form_id];

Note: vc_directory_listing.m performForm: contains a [vc_type isEqualToString:@"form"] branch too, but no caller invokes performForm: with that vc_type — the row-tap path bypasses it entirely via ListingSelectionRouterVCLauncher.

Side-by-side

Pattern A (tabbar) Pattern B (custom_form_array) Pattern C (content_type: 'form')
Tap target Tabbar button Row’s edit icon Whole row
Form index lives at tabbar.source[N].custom_form row.custom_form_array[N].custom_form row.form_id
Prefill values live at tabbar.source[N].custom_form_values row.custom_form_array[N].custom_form_values Top-level row keys
iOS resolver Tabbar handler (populateWithKeyValues) Detail-view custom forms (populateWithKeyValues) vc_form_manager.selectedItem fallback
Status ✅ Working in geodirectory ✅ Working in qrlife ✅ Working in aquaman-boat-booking (as of 2026-05-20)

Resolved: the content_type: 'form' prefill path

Root cause

vc_form_manager.m:1378-1385 already had the right resolver for Pattern C, sitting unused:

}else if(field[@"populate_with_key"]) {
    NSString *key = field[@"populate_with_key"];
    if (populateWithKeyValues && populateWithKeyValues[key]) {
        storedValue = populateWithKeyValues[key];        // Pattern A/B path
    } else if (!populateWithKeyValues && selectedItem[key]) {
        storedValue = selectedItem[key];                 // ← Pattern C path
    }
}

The form-manager exposes @property (copy, nonatomic) NSDictionary *selectedItem; in vc_form_manager.h:92. When opened via Pattern A/B, populateWithKeyValues is set and the first branch runs. When opened via Pattern C, populateWithKeyValues is nil and the selectedItem[key] fallback was designed to handle it — but VCLauncher.m‘s form branch was only assigning the row to selectedDirectory, leaving selectedItem nil. The fallback was never reachable.

Fix #1 — VCLauncher.m launchVCWithType: form branch

Added one line after the existing controller.selectedDirectory = selectedItem; assignment:

controller.selectedItem = selectedItem;

That’s the entire structural change. No PHP changes needed — content classes were already publishing the top-level row keys correctly. The resolver picks them up via the selectedItem[key] branch.

Fix #2 — DropdownFieldView.m initial label display (latent)

Investigation surfaced a secondary bug: DropdownFieldView.m:63-65 set the closed-dropdown text directly from storedValue. For values that are post IDs ('425'), users saw the raw ID until tapping the dropdown — at which point the picker’s onSelect callback rewrites the text to the label.

The bug was masked in Patterns A/B because geodirectory’s storedValues happen to be label-like strings ('Manager', 'Staff'). It became visible in Pattern C with post-ID values.

Fix: walk _choices looking for a value match and use the matching choice’s text as the display value. Falls back to the raw storedValue when no choice matches (preserves the existing behavior for free-text use cases or label-equals-value scenarios).

NSString *displayText = storedValue ?: @"";
if (storedValue.length > 0) {
    for (NSDictionary *choice in _choices) {
        if ([choice[@"value"] isEqual:storedValue]) {
            displayText = choice[@"text"] ?: storedValue;
            break;
        }
    }
}
self.textFieldView.text = displayText;

This improves all three patterns wherever a dropdown’s value and label differ.

Acceptance test (verified)

Open the aquaman-boat-booking Anchor Times list, tap May 29, 2026:

  • ✅ Anchor Time field shows “9:00 AM” pre-selected
  • ✅ Basin Cruiser shows “Captain Phillips” pre-selected
  • ✅ Group Tubing shows “Captain Ron” pre-selected
  • ✅ Ski Boat One shows “Captain Phillips”, Ski Boat Two shows “Captain Ron”
  • booking_date hidden field carries 2026-05-29
  • ✅ Submitting the form lands at mam_for_gravity_forms_form_result_form_<ID> with the slug-keyed values

Why this matters for future Pattern C plugins

Plugins using content_type: 'form' row taps now have a working prefill path with zero ceremony. The contract is:

  1. Publish populate values as top-level keys on the row, matching the slugs declared in mam_gf_get_form_settings (or populate_with_key on the field definitions)
  2. Set content_type = 'form' and form_id = <cache_index>
  3. The form opens with each field whose populate_with_key matches a row key pre-filled to that row’s value

No custom_form_array wrapping needed for tap-the-row use cases — that pattern stays reserved for the edit-icon / tabbar flows.


See also


Metadata

Field Value
Article type Reference / Gap Analysis
Plugin slug mam-main
Applies to plugin version 2.1.11+
Category Forms Manager
Audience PHP developer + iOS developer
Last verified 2026-05-20
Related End-to-end cookbook, Hook reference
Contents

    Need Support?

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