Mtools Module for PDF Reports

Overview

The ReportHelpers class (accessed as M in reports) provides methods for generating professional PDF reports from Fulcrum data. It handles HTML grid generation, photo galleries, table building, and field value formatting.

When to Use

Use ReportHelpers when you need to:

  • Generate PDF reports from Fulcrum records
  • Display fields in organized grids and tables
  • Create photo galleries with captions
  • Handle repeatable sections
  • Format field values (checkboxes, choice fields, addresses)
  • Build consistent, professional-looking reports

What You Get

  • Grid layouts - Automatic 12-column responsive grids
  • Photo galleries - Customizable column layouts with captions
  • Table generation - Open tables for tabular data
  • Value formatting - Automatic checkbox/choice field conversion
  • Section builders - Pre-configured section templates
  • CSS styling - Professional Merjent report styles

Important Things to Remember

Before building your PDF report, keep these critical points in mind:

✅ Always Use Region Markers

//#region !START MERJENT FULCRUM TOOLS!
// ... paste merjent-tools-report.mmin.js content here ...
//#endregion !END MERJENT FULCRUM TOOLS!

These markers let Python update tools replace MTools while preserving your custom report code.

✅ M is Pre-Instantiated

// The dist file already contains:
// const M = new ReportHelpers();

// ✓ CORRECT - Just use M directly after the region markers
<%- M.buildDefaultSection(secDn, mFlds, secOpts) %>

// ✗ WRONG - Don't re-instantiate
const M = new ReportHelpers(CONFIG, data);

✅ Grid Spans Must Add Up to 12

// ✓ CORRECT - Total = 12
mFlds.m('field1').span = 4;  // 4
mFlds.m('field2').span = 4;  // 4
mFlds.m('field3').span = 4;  // 4

// ✗ WRONG - Total = 15 (will wrap incorrectly)
mFlds.m('field1').span = 5;
mFlds.m('field2').span = 5;
mFlds.m('field3').span = 5;

✅ Use FIELDNAMES() for Dynamic Lists

// ✓ GOOD - Automatically adapts to form changes
const dns = FIELDNAMES('section', {sections: false});
const mFlds = M.mkMflds(dns);

// ✗ AVOID - Breaks when fields are added/removed
const dns = ['field1', 'field2', 'field3'];

✅ Use fldVal() for Complex Field Types

// ✓ CORRECT - Handles choice fields, addresses, etc.
<%=M.fldVal('choice_field')%>
<%=M.fldVal('address_field')%>

// ✗ WRONG - Shows [object Object]
<%=VALUE('choice_field')%>
<%=VALUE('address_field')%>

✅ Test with Edge Cases

Always preview your report with:

  • Records with no data (blank fields)
  • Records with all fields filled
  • Records with many repeatable items (10+)
  • Records with no photos
  • Records with long text fields

✅ Group Related Code

Keep section setup code together:

<%
  const secDn = 'site_information';
  const dns = FIELDNAMES(secDn, {sections: false});
  const mFlds = M.mkMflds(dns);
  const secOpts = new SecOpt(null, LABEL(secDn), null);
%>
<%- M.buildDefaultSection(secDn, mFlds, secOpts) %>

Installation

Copy merjent-tools-report.mmin.js into your Fulcrum PDF report template:

//#region !START MERJENT FULCRUM TOOLS!
// ... paste merjent-tools-report.mmin.js content here ...
//#endregion !END MERJENT FULCRUM TOOLS!

// M is already instantiated in the dist file as: const M = new ReportHelpers();
// Your custom code starts here

Important: The region markers allow Python update tools to replace MTools code while preserving your custom report logic.

Note: Report templates (HTML blocks) used by ReportHelpers come from the merjent-fulcrum-core sibling repository. These are bundled into the dist file during build.


Basic Concepts

Report Environment

Fulcrum PDF reports use:

  • EJS templates - <%= %> for output, <% %> for logic
  • Predefined functions - VALUE(), LABEL(), FIELDNAMES(), etc.
  • Data object - Contains all record data
  • CONFIG object - Report configuration

Grid System

ReportHelpers uses a 12-column grid by default:

  • Each row spans 12 columns total
  • Fields can span 1-12 columns
  • Responsive layout automatically wraps
|--- 4 cols ---|--- 4 cols ---|--- 4 cols ---|  (3 fields per row)
|------ 6 cols ------|------ 6 cols ------|      (2 fields per row)
|------------ 12 cols ----------------------|    (1 field per row)

Field Collections

The MfldColl class manages collections of fields for grids:

// Create field collection
const fields = M.mkMflds(['field1', 'field2', 'field3']);

// Modify individual fields
fields.m('field1').span = 6;     // Make field1 span 6 columns
fields.m('field2').opt = '-hn';  // Hide if null

Instantiation

The dist file already instantiates M for you:

// This is already in the dist file - you don't need to add it
const M = new ReportHelpers();

Just paste the dist file content inside the region markers and start using M directly.


Building Sections

Default Grid Section

The most common pattern - displays fields in a responsive grid:

<!-- SECTION: Site Information -->
<%
  const secDn = 'site_information';
  const dns = FIELDNAMES(secDn, {sections: false});
  const mFlds = M.mkMflds(dns);
  const secOpts = new SecOpt(null, LABEL(secDn), null);
%>
<%- M.buildDefaultSection(secDn, mFlds, secOpts) %>

What this does:

  1. Gets all field data names in the section
  2. Creates a field collection
  3. Builds a grid with section header
  4. Auto-formats values (checkboxes for yes/no, etc.)

Result:

<div class="mjnt-sectionHeader spacer-top">Site Information</div>
<div class="grid-lo default">
  <div class="rows">
    <div style="grid-column: span 4;">
      <div>Project Name</div>
      <div>West Ridge Survey</div>
    </div>
    <div style="grid-column: span 4;">
      <div>Date</div>
      <div>2025-01-15</div>
    </div>
    <!-- ... more fields -->
  </div>
</div>

Customizing Fields in Grid

Change Column Span

const dns = FIELDNAMES('site_info', {sections: false});
const mFlds = M.mkMflds(dns);

// Make project_name span full width
mFlds.m('project_name').span = 12;

// Make date and time each span half width
mFlds.m('date').span = 6;
mFlds.m('time').span = 6;

Hide Fields When Null

mFlds.m('optional_field').opt = '-hn';  // Hide if null

Choice Fields as Checkboxes

// By default, choice fields show as: "Option1, Option2"
// To show as checkboxes:
mFlds.m('habitat_types').opt = '-cb';   // Show all options
mFlds.m('threats').opt = '-hcb';        // Hide unchecked options

Remove Fields from Grid

const dns = FIELDNAMES('site_info', {sections: false});

// Remove fields you don't want
const filtered = M.arrRem(dns, ['internal_notes', 'gps_accuracy']);

const mFlds = M.mkMflds(filtered);

Yes/No Table Section

For sections with many yes/no fields, display as a table:

<!-- SECTION: Equipment Checklist -->
<%
  const secDn = 'equipment_checklist';
  const dns = FIELDNAMES(secDn, {sections: false});
  const mFlds = M.mkMflds(dns);
  const secOpts = new SecOpt(null, LABEL(secDn), null);
%>
<%- M.buildYNSection(secDn, mFlds, secOpts) %>

Result:

Equipment Checklist
□ GPS Device
☑ Camera
☑ Field Notebook
□ First Aid Kit

Repeatable Sections

Repeatable Default Grid

For repeatable sections with few items:

<!-- REPEATABLE: Species Observations -->
<%
  const secDn = 'species_observations';
  const dns = FIELDNAMES(secDn);
  const mFlds = M.mkMflds(dns);
  const secOpts = new SecOpt(null, LABEL(secDn), null);
%>
<div class="mjnt-sectionHeader spacer-top"><%= LABEL(secDn) %></div>
<%- M.buildRepeatableSubsec(secDn, mFlds, secOpts) %>

Result: Each repeatable item displayed as a subsection with grid layout.


Repeatable Table

For repeatable sections with many items, use table format:

<!-- REPEATABLE TABLE: Survey Points -->
<div class="clump">
<%
  const secDn = 'survey_points';
  const dns = FIELDNAMES(secDn);
  const repFormId = '7d00...';  // Child form ID
  const rows = M.repRequestRecords([RECORDID()], FORM().id, repFormId, null, M.addCH(dns));
  const divRows = M.rows2Divs(rows, dns);
%>
  <div class="mjnt-sectionHeader spacer-top"><%=LABEL(secDn)%></div>

  <!-- Adjust column count in style -->
  <div class="grid-lo open-table" style="grid-template-columns:repeat(5, 1fr)">
    <!-- Table Headers -->
    <div class="header">
      <%- M.makeTableHeaders(dns) %>
    </div>
    <!-- Table Rows -->
    <div class="rows">
      <%- divRows %>
    </div>
  </div>
</div>

Adjusting columns:

/* 3 columns */
style="grid-template-columns:repeat(3, 1fr)"

/* 5 columns with custom widths */
style="grid-template-columns: 2fr 1fr 1fr 1fr 3fr"

Photo Sections

Simple Photo Field

<!-- PHOTOS: Site Photos -->
<%
  const dn = 'site_photos';
  const photos = VALUE(dn);
  const secOpts = new SecOpt(null, LABEL(dn), null);
%>
<%- M.buildPhotoSection(photos, secOpts) %>

Customizing columns:

// 1 column (default)
M.buildPhotoSection(photos, secOpts);

// 2 columns
M.buildPhotoSection(photos, secOpts, 2);

// 3 columns
M.buildPhotoSection(photos, secOpts, 3);

Photos in Repeatable

<!-- PHOTOS: Observation Photos (in repeatable) -->
<%
  const repDn = 'species_observations';
  const dn = 'observation_photos';
  const secOpts = new SecOpt(null, LABEL(dn), null);
%>
<%- M.buildRepeatablePhotos(repDn, dn, secOpts) %>

This automatically groups photos by repeatable item.


Text Sections

For long-text fields (notes, descriptions):

<!-- SECTION: Field Notes -->
<div class="clump">
  <div class="mjnt-sectionHeader spacer-top"><%=LABEL('field_notes')%></div>
  <div style="white-space:pre-line;text-align:left;min-height:150px">
    <%=M.fldVal('field_notes')%>
  </div>
</div>

Formatting options:

  • white-space:pre-line - Preserves line breaks
  • text-align:left - Left-align text
  • min-height - Ensure minimum vertical space

Bullet Lists

Convert paragraph text to HTML bullet lists:

<!-- SECTION: Recommendations -->
<%
  const recommendations = VALUE('recommendations');
  const bulletHTML = M.buildBullets(recommendations);
%>
<div class="clump">
  <div class="mjnt-sectionHeader spacer-top">Recommendations</div>
  <div class="bullet-list">
    <%- bulletHTML %>
  </div>
</div>

Input:

Replace damaged fencing in northwest corner
Schedule follow-up survey in spring
Update habitat maps with new boundaries

Output:

<ul>
  <li>Replace damaged fencing in northwest corner</li>
  <li>Schedule follow-up survey in spring</li>
  <li>Update habitat maps with new boundaries</li>
</ul>

Value Formatting

fldVal() Method

The fldVal() method (also available as M.fVal()) automatically formats field values for display:

M.fldVal('field_name');
M.fldVal('field_name', 'default_value');

Automatic formatting:

Field Type Input Output
Null/Undefined null "---"
Yes/No "yes" (checkbox)
Yes/No "no" (empty box)
Choice {choice_values: ['A', 'B']} "A, B"
Address {thoroughfare: '123 Main', locality: 'City'} "123 Main, City, ST 12345"
Date "2025-01-15" "2025-01-15"
Array ['a', 'b', 'c'] "a, b, c"

Customizing Yes/No Display

As Checkboxes (Default)

M.fldVal('completed');  // ☑ or □

As Text

const value = VALUE('completed');
// Display "Yes" or "No" instead of checkbox
<%%= value === 'yes' ? 'Yes' : 'No' %>

Customizing Choice Fields

As Comma-Separated (Default)

M.fldVal('habitat_types');  // "Wetland, Forest, Grassland"

As Checkboxes

mFlds.m('habitat_types').opt = '-cb';    // All options with checkboxes
mFlds.m('habitat_types').opt = '-hcb';   // Only checked options

Section Options (SecOpt)

The SecOpt class configures section appearance:

new SecOpt(hidden, title, note);

Parameters

  • hidden (boolean) - Hide section if true
  • title (string) - Section header text
  • note (string) - Optional note below header

Examples

// Basic section
new SecOpt(null, 'Site Information', null);

// Section with note
new SecOpt(null, 'Weather Conditions', 'Recorded at start of survey');

// Conditionally hide section
const hasPhotos = VALUE('site_photos').length > 0;
new SecOpt(!hasPhotos, 'Site Photos', null);  // Hidden if no photos

Converting to HTML

const secOpts = new SecOpt(null, 'Section Title', 'Optional note');

// Generate section header HTML
<%- secOpts.toHtml() %>

Report Template Structure

Complete Report Example

//#region !START MERJENT FULCRUM TOOLS!
// ... merjent-tools-report.mmin.js ...
//#endregion !END MERJENT FULCRUM TOOLS!

// M is already instantiated in the dist file
const mjntLogo = 'https://example.com/logo.png';

%>

<!--***************** BODY *****************-->
<div class="generic-theme2">

  <!--***************** TITLE BLOCK *****************-->
  <div id="titlebox">
    <div class="titleleft">
      <div><%= form.name %></div>
      <div><%= VALUE('project_name') %></div>
      <div><%= VALUE('report_id') %></div>
    </div>
    <div class="clear"></div>
    <div class="titleright">
      <div><img src="<%= mjntLogo %>"/></div>
    </div>
  </div>

  <!--***************** SITE INFORMATION *****************-->
  <%
    const siteDns = FIELDNAMES('site_information', {sections: false});
    const siteFlds = M.mkMflds(siteDns);
    const siteOpts = new SecOpt(null, 'Site Information', null);
  %>
  <%- M.buildDefaultSection('site_information', siteFlds, siteOpts) %>

  <!--***************** OBSERVATIONS *****************-->
  <%
    const obsDn = 'species_observations';
    const obsDns = FIELDNAMES(obsDn);
    const obsFlds = M.mkMflds(obsDns);
    const obsOpts = new SecOpt(null, LABEL(obsDn), null);
  %>
  <div class="mjnt-sectionHeader spacer-top"><%= LABEL(obsDn) %></div>
  <%- M.buildRepeatableSubsec(obsDn, obsFlds, obsOpts) %>

  <!--***************** PHOTOS *****************-->
  <%
    const photos = VALUE('site_photos');
    const photoOpts = new SecOpt(null, 'Site Photos', null);
  %>
  <%- M.buildPhotoSection(photos, photoOpts, 2) %>

  <!--***************** NOTES *****************-->
  <div class="clump">
    <div class="mjnt-sectionHeader spacer-top">Field Notes</div>
    <div style="white-space:pre-line;text-align:left">
      <%=M.fldVal('field_notes')%>
    </div>
  </div>

</div>

CSS Styling

MJNT_Report.css Classes

The report stylesheet provides these classes:

Layout Classes

.generic-theme2          /* Main report container */
.mjnt-sectionHeader      /* Section headers */
.mjnt-subsectionHeader   /* Subsection headers */
.spacer-top              /* Top margin spacing */
.clump                   /* Group related elements */
.clear                   /* Clear floats */

Grid Classes

.grid-lo.default         /* Default 12-column grid */
.grid-lo.open-table      /* Table layout */
.grid-lo.yn-table        /* Yes/No table */

Usage Examples

<!-- Add spacing above section -->
<div class="mjnt-sectionHeader spacer-top">Section Title</div>

<!-- Group elements together -->
<div class="clump">
  <div class="mjnt-sectionHeader">Title</div>
  <p>Content</p>
</div>

Advanced Techniques

Conditional Sections

<!-- Only show if field has value -->
<% if (VALUE('follow_up_required') === 'yes') { %>
  <%
    const followUpDns = FIELDNAMES('follow_up_details', {sections: false});
    const followUpFlds = M.mkMflds(followUpDns);
    const followUpOpts = new SecOpt(null, 'Follow-Up Details', null);
  %>
  <%- M.buildDefaultSection('follow_up_details', followUpFlds, followUpOpts) %>
<% } %>

Custom Grid Layouts

<!-- Mixed column spans -->
<%
  const dns = ['project_name', 'date', 'time', 'surveyor', 'weather'];
  const mFlds = M.mkMflds(dns);

  // Full width
  mFlds.m('project_name').span = 12;

  // Half width
  mFlds.m('date').span = 6;
  mFlds.m('time').span = 6;

  // Third width
  mFlds.m('surveyor').span = 4;
  mFlds.m('weather').span = 8;

  const secOpts = new SecOpt(null, 'Survey Details', null);
%>
<%- M.buildDefaultSection('survey_details', mFlds, secOpts) %>

Photo Grid with Custom Captions

<!-- Photos with custom captions -->
<%
  const photos = VALUE('site_photos');
  const photoGrid = photos.map((photo, index) => {
    return `
      <div class="photo-item">
        <img src="${photo.url}" alt="Photo ${index + 1}"/>
        <div class="caption">
          Photo ${index + 1}: ${photo.caption || 'No caption'}
          <br>Location: ${photo.latitude}, ${photo.longitude}
        </div>
      </div>
    `;
  }).join('');
%>
<div class="mjnt-sectionHeader spacer-top">Site Photos</div>
<div class="photo-grid">
  <%- photoGrid %>
</div>

Summary Statistics

<!-- Calculate and display statistics -->
<%
  const observations = VALUE('species_observations');
  const totalCount = observations.reduce((sum, obs) => {
    return sum + (obs.form_values.count || 0);
  }, 0);

  const speciesList = observations.map(obs => obs.form_values.species_name);

  // Utils methods are available statically in report templates
  // You can also use M.arrUnique() if preferred (they're equivalent)
  const uniqueSpecies = Utils.arrUnique(speciesList).length;
%>
<div class="clump">
  <div class="mjnt-sectionHeader spacer-top">Survey Summary</div>
  <div class="grid-lo default">
    <div class="rows">
      <div style="grid-column: span 6;">
        <div>Total Observations</div>
        <div><%= observations.length %></div>
      </div>
      <div style="grid-column: span 6;">
        <div>Total Individual Count</div>
        <div><%= totalCount %></div>
      </div>
      <div style="grid-column: span 12;">
        <div>Unique Species Observed</div>
        <div><%= uniqueSpecies %></div>
      </div>
    </div>
  </div>
</div>

Best Practices

1. Use FIELDNAMES() for Flexibility

// GOOD: Dynamic field list
const dns = FIELDNAMES('section_name', {sections: false});

// AVOID: Hardcoded field list
const dns = ['field1', 'field2', 'field3'];

This way, adding/removing fields in Fulcrum automatically updates the report.

2. Group Related Code

<!-- SECTION: Site Information -->
<%
  // All section setup together
  const secDn = 'site_information';
  const dns = FIELDNAMES(secDn, {sections: false});
  const mFlds = M.mkMflds(dns);
  const secOpts = new SecOpt(null, LABEL(secDn), null);
%>
<%- M.buildDefaultSection(secDn, mFlds, secOpts) %>

3. Use Comments Liberally

<!--***************** OBSERVATIONS TABLE *****************-->
<!--
  This section displays all species observations in a table format.
  Columns: Species Name, Count, Behavior, Location
-->

4. Test with Edge Cases

Test your report with:

  • Records with no data
  • Records with all fields filled
  • Records with many repeatable items
  • Records with no photos
  • Records with long text fields

5. Preview Before Deploying

Always generate a preview PDF before deploying to production:

  1. Use test record with representative data
  2. Check layout at different page breaks
  3. Verify photos display correctly
  4. Test with different field values

Troubleshooting

Issue: Grid layout looks wrong

Cause: Column spans don't add up to 12

Solution:

// Check your spans
mFlds.m('field1').span = 4;
mFlds.m('field2').span = 4;
mFlds.m('field3').span = 4;
// Total = 12 ✓

// WRONG
mFlds.m('field1').span = 5;
mFlds.m('field2').span = 5;
mFlds.m('field3').span = 5;
// Total = 15 ✗ (will wrap incorrectly)

Issue: Photos not displaying

Cause: Photo URLs not loading or incorrect photo object

Solution:

// Check if photos exist
const photos = VALUE('site_photos');
if (!photos || photos.length === 0) {
  // No photos
}

// Check photo URL
console.log(photos[0].url);

Issue: Values showing as "[object Object]"

Cause: Not using fldVal() for complex field types

Solution:

// WRONG
<%=VALUE('address')%>  // Shows [object Object]

// CORRECT
<%=M.fldVal('address')%>  // Shows formatted address

Next Steps


Additional Resources