diff --git a/docs/App Extensions/app-extension-examples/add-text-caption-to-photo.md b/docs/App Extensions/app-extension-examples/add-text-caption-to-photo.md new file mode 100644 index 00000000..ac4d5240 --- /dev/null +++ b/docs/App Extensions/app-extension-examples/add-text-caption-to-photo.md @@ -0,0 +1,145 @@ +--- +title: Add a Text Caption to a Photo +excerpt: An App Extension that lets field users upload a photo, type a caption, and render the captioned image on an HTML5 canvas — useful for annotating site photos, adding measurement labels, or branding field images before submission. +--- + +This App Extension uses the browser's HTML5 Canvas API to composite a text caption over a user-selected photo entirely in the browser, with no server round-trip. The result can be screenshotted or extended to upload the annotated image back to Fulcrum. + +## App Extension Code + +```html + + + + + + Photo Caption Tool + + + + +
+ + +
+ + +
+ +
+ + + + + + +``` + +## How It Works + +`FileReader.readAsDataURL()` reads the selected image file into a base64 data URL, which is then assigned to an `Image` object's `src`. Once the image fires its `onload` event, the canvas is resized to match the image dimensions and `drawImage()` renders it. + +When the user clicks "Add Caption", the canvas is redrawn (to reset any previous caption), and the caption text is drawn at the horizontal center, positioned near the bottom of the image. The text is rendered twice: first with a thick black stroke for contrast, then with a white fill for legibility. + +## Customization + +**Caption position:** Change the `y` calculation to place the caption at the top (`y = fontSize * 1.5`) or at a fixed pixel offset from any edge. + +**Font and color:** Swap `ctx.font` and `ctx.fillStyle` for different typefaces, weights, or colors. Use `ctx.fillStyle = 'rgba(0,0,0,0.5)'` to draw a semi-transparent background bar behind the text. + +**Download the captioned image:** Add a download button that calls `canvas.toDataURL('image/jpeg')` and triggers a link click: + +```javascript +document.getElementById('downloadBtn').addEventListener('click', () => { + const link = document.createElement('a'); + link.download = 'captioned-photo.jpg'; + link.href = canvas.toDataURL('image/jpeg', 0.92); + link.click(); +}); +``` + +**Multiple captions / drag and drop:** Track caption objects in an array and redraw them all on each render pass to support repositionable annotations. diff --git a/docs/App Extensions/app-extension-examples/date-picker-with-blackout-dates.md b/docs/App Extensions/app-extension-examples/date-picker-with-blackout-dates.md new file mode 100644 index 00000000..c4db1762 --- /dev/null +++ b/docs/App Extensions/app-extension-examples/date-picker-with-blackout-dates.md @@ -0,0 +1,218 @@ +--- +title: Date picker with blackout dates +excerpt: >- + This App Extension opens a popup calendar that prevents users from selecting + blacked-out date ranges. Blackout dates are loaded dynamically from a + separate Fulcrum app using LOADRECORDS, so administrators can manage blocked + dates without touching the data event code. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Date picker with blackout dates + +This example uses an App Extension to display a Flatpickr-powered calendar popup that restricts users from selecting certain dates. Blackout date ranges are stored in a separate Fulcrum app and loaded at runtime via `LOADRECORDS`, so administrators can add or remove blocked dates without updating the Data Event. + +When the user clicks a button on the form, `OPENEXTENSION` launches the calendar. The selected date is returned to the form and written to a date field. + +## How it works + +1. A separate **blackout dates app** stores date range records (start date / end date). +2. On `load-record`, the Data Event fetches those ranges via `LOADRECORDS` and stores them in a variable. +3. When the user clicks the calendar button, `OPENEXTENSION` opens `calendar_picker.html` — a self-contained HTML page that uses [Flatpickr](https://flatpickr.js.org/) to render the calendar. +4. The blackout ranges are passed to the HTML page via the `data` option. Flatpickr disables those date ranges in the calendar. +5. When the user picks a date, the HTML page sends it back via `Fulcrum.finish()`, and the Data Event writes it to an appointment date field. + +## Setup + +1. Create a **blackout dates app** with two Date fields: + - `start` — the first day of the blocked range + - `end` — the last day of the blocked range (inclusive) + Note the **App ID** and the field keys for `start` and `end`. +2. Upload `calendar_picker.html` (below) as a **Reference File** in your Fulcrum org, or attach it directly to your app. +3. In your data collection app, add: + - A **Date** field for the appointment result (e.g. data name: `appointment_date`) + - A **Button** field to trigger the calendar (e.g. data name: `open_calendar`) +4. Add the Data Event code below to the app and update the configuration constants. + +> **Note:** The Flatpickr calendar requires an internet connection when using the CDN version below. For fully offline use, replace the CDN links with a locally hosted or embedded copy of Flatpickr. + +## Data Event Code + +```js +// ─── Configuration ─────────────────────────────────────────────────────────── + +// App ID of the blackout dates app +const BLACKOUT_FORM_ID = 'YOUR-BLACKOUT-DATES-APP-ID-HERE'; + +// Field key of the start date field in the blackout app +const START_DATE_KEY = 'start'; + +// Field key of the end date field in the blackout app +const END_DATE_KEY = 'end'; + +// Data name of the date field to write the selected appointment date to +const APPOINTMENT_FIELD = 'appointment_date'; + +// Data name of the button field that opens the calendar +const CALENDAR_BUTTON = 'open_calendar'; + +// ─── Load blackout ranges on record open ───────────────────────────────────── + +let blackoutRanges = []; + +ON('load-record', () => { + LOADRECORDS({ form_id: BLACKOUT_FORM_ID }, (err, result) => { + if (err) { + console.log('Error loading blackout dates:', INSPECT(err)); + return; + } + + // Build an array of { start, end } objects from the loaded records + blackoutRanges = (result.records || []).map(rec => ({ + start: rec.form_values[START_DATE_KEY] || '', + end: rec.form_values[END_DATE_KEY] || '' + })).filter(range => range.start && range.end); + }); +}); + +// ─── Open the calendar extension ───────────────────────────────────────────── + +ON('click', CALENDAR_BUTTON, () => { + if (!blackoutRanges.length) { + // Allow the calendar to open even if no blackout dates loaded yet + console.log('No blackout ranges loaded; opening calendar without restrictions.'); + } + + OPENEXTENSION({ + url: 'attachment://calendar_picker.html', + title: 'Select Appointment Date', + width: 400, + height: 500, + data: { + blackoutRanges: blackoutRanges, + today: new Date().toISOString().split('T')[0] + }, + onMessage: ({ data }) => { + if (data.selectedDate) { + SETVALUE(APPOINTMENT_FIELD, data.selectedDate); + } + } + }); +}); +``` + +## HTML Extension File (`calendar_picker.html`) + +Save the content below as `calendar_picker.html` and attach it to your Fulcrum app as a Reference File. This file is loaded inside the `OPENEXTENSION` popup. + +```html + + + + + + Appointment Calendar + + + + + + + + + + + +

Select Appointment Date

+
+ + + + + +``` + +## Notes + +- The blackout date ranges are fetched each time the record is opened. If the blackout app is updated while a user has the form open, they will need to close and reopen the record to get the latest dates. +- Flatpickr's `disable` option accepts an array of `{ from, to }` objects. Dates within those ranges are greyed out and unselectable in the calendar UI. +- The `minDate: data.today` setting prevents users from selecting any date in the past. +- The `Fulcrum.finish({ selectedDate })` call sends the result back to the Data Event's `onMessage` handler and closes the extension popup. diff --git a/docs/App Extensions/app-extension-examples/inventory-scanner-with-barcode.md b/docs/App Extensions/app-extension-examples/inventory-scanner-with-barcode.md new file mode 100644 index 00000000..e6da4b83 --- /dev/null +++ b/docs/App Extensions/app-extension-examples/inventory-scanner-with-barcode.md @@ -0,0 +1,422 @@ +--- +title: Inventory Scanner with Barcode Lookup +excerpt: A self-contained HTML App Extension that uses the device camera or keyboard input to scan barcodes, looks up the matching inventory item via the Fulcrum Records API, and lets users make rapid quantity adjustments — all without leaving the Fulcrum mobile or web interface. +--- + +This App Extension provides a purpose-built inventory management UI embedded directly inside a Fulcrum app. Users scan a barcode (via camera or physical scanner), the extension finds the matching record in Fulcrum, displays the item name and current quantity, and lets users increment or decrement the stock count with a single tap. Changes are saved back to the record via the Fulcrum REST API. + +## Overview + +The extension is a single HTML file added to a Fulcrum App Extension field. It uses: + +- **[html5-qrcode](https://github.com/mebjas/html5-qrcode)** — camera-based QR/barcode scanning via the device camera +- **Tailwind CSS** — utility-first styling optimized for mobile +- **Fulcrum Records API** — `GET /api/v2/records.json` to look up by barcode, `PATCH /api/v2/records/{id}.json` to update quantity +- **Settings panel** — auth token, form ID, and field key mapping entered once per device and stored in `localStorage` + +## Prerequisites + +The Fulcrum app must have at minimum: +- A `BarcodeField` to hold the item's barcode (e.g. `part_number_barcode`) +- A `TextField` for item description (e.g. `item_description`) +- A numeric `TextField` for quantity on hand (e.g. `quantity_on_hand`) +- A numeric `TextField` for minimum quantity threshold (e.g. `quantity_to_maintain`) + +## App Extension HTML + +```html + + + + + + Inventory Scanner + + + + + + + + + + + + + + + +
+
+

Inventory Tool

+

Direct Connection

+
+ +
+ + +
+
+
+ + + + + + + +
+
+
+ + + + + + + +``` + +## Setup + +1. Create a Fulcrum app with the fields described in Prerequisites. +2. Add an **App Extension** field to the app. +3. Paste the HTML above as the extension source. +4. Open the extension on mobile or web, tap **Settings**, and enter: + - Your Fulcrum API token + - The app's Form ID (from the URL or `GET /api/v2/forms.json`) + - The field keys for barcode, item name, and quantity (find these in the App Designer or via the field's `key` property in the API) +5. Settings are saved in `localStorage` and persist across sessions. + +## Notes + +- **Physical barcode scanners** work automatically via the text input field — most keyboard-mode scanners send an Enter key after the barcode, which triggers the lookup. +- **Camera scanning** uses the device's rear camera via the html5-qrcode library and supports 1D barcodes (Code 128, EAN, UPC, etc.) as well as QR codes. +- **CORS:** The Fulcrum API allows cross-origin requests from App Extensions. If hosting this HTML outside of Fulcrum, ensure your hosting environment has HTTPS. +- The Settings panel stores the API token in `localStorage`. For shared devices, consider clearing settings when the session ends or using a shorter-lived token. diff --git a/docs/App Extensions/app-extension-examples/visualize-data-with-chart-js.md b/docs/App Extensions/app-extension-examples/visualize-data-with-chart-js.md new file mode 100644 index 00000000..56f54113 --- /dev/null +++ b/docs/App Extensions/app-extension-examples/visualize-data-with-chart-js.md @@ -0,0 +1,248 @@ +--- +title: Visualize field data with Chart.js +excerpt: >- + This example shows how to use a Fulcrum App Extension to display an + interactive chart from field data. It uses Chart.js embedded in an offline + HTML attachment, opened via OPENEXTENSION, and sends a result back to the + Fulcrum record using onMessage. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Visualize field data with Chart.js + +This example demonstrates how to build an App Extension that renders an interactive bar chart using [Chart.js](https://www.chartjs.org/). The chart is driven by data collected in the Fulcrum form — the user taps a button, the extension opens a chart view, and can optionally write a result back to the record. + +This is useful for giving field workers a real-time visual summary of their collected data without needing an internet connection. + +## How it works + +1. A button field (`view_chart`) triggers the `ON('click', ...)` handler. +2. `OPENEXTENSION()` opens an HTML attachment (`survey_chart.html`) with form field values passed as `data`. +3. The HTML file renders a bar chart using Chart.js. +4. When the user taps a "Done" button in the chart view, `onMessage` receives the result and writes it back to a field in the record. + +## Data Event Code + +```js +/** + * When the user taps the "View Chart" button, open the chart extension. + * + * Replace 'view_chart' with the data name of your button field. + * Replace the field references ($field_name) with the data names + * of the fields you want to visualize. + */ +ON('click', 'view_chart', () => { + + OPENEXTENSION({ + // The HTML file must be attached to the form as an attachment file. + // See the setup section below for instructions. + url: 'attachment://survey_chart.html', + + // Title shown in the extension header + title: 'Survey Data Chart', + + // Pass field values to the HTML extension as a data object. + // These are available in the HTML via the postMessage event. + data: { + surveyDate: $survey_date, + locationName: $location_name, + sampleCount: $sample_count, + temperatureC: $temperature_c, + dissolvedOxygen: $dissolved_oxygen_mgl, + depthM: $depth_m, + velocityMs: $velocity_ms + }, + + // Receive a result back from the HTML extension. + // The HTML calls window.parent.postMessage({ result: '...' }, '*') + onMessage: ({ data }) => { + // Write the result to a field in the record + // Replace 'review_status' with your target field's data name + SETVALUE('review_status', data.result); + ALERT('Chart result saved', `Status set to: ${data.result}`); + } + }); + +}); +``` + +## HTML Extension File (`survey_chart.html`) + +The HTML file uses Chart.js loaded from a CDN to render a bar chart. It listens for the `message` event to receive data from Fulcrum, builds the chart, and provides a button to send a result back. + +> **Note:** Because this file uses a CDN-hosted Chart.js, the device must be online when opening the extension. For fully offline use, see the [offline bundling note](#offline-use) below. + +```html + + + + + + Survey Data Chart + + + + +

Survey Data Chart

+

+ +
+ + + + + + + + + +``` + +## Setup + +1. Save the HTML above as `survey_chart.html`. +2. In the Fulcrum Form Builder, open your form and go to **Media** → **Attachments**. +3. Upload `survey_chart.html` as a form attachment. +4. The file will be available at `attachment://survey_chart.html` in your Data Event. +5. Add a **Button** field to your form and note its data name (e.g. `view_chart`). +6. Add the Data Event code above, updating the field names to match your form. + +## Offline use + +The example above loads Chart.js from a CDN and requires an internet connection. To make the extension work fully offline, download the Chart.js bundle and inline it in the HTML file: + +1. Download the minified Chart.js file from [cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js](https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js). +2. In the HTML file, replace the ` +``` + +## Code + +Add the Puppeteer idle-blocker before `main()`, then include the PDF attachment section inside your `main()` function: + +```html + + +
+ + <%- /* RENDER(...) or other EJS output */ %> + + +
+ + +``` + +## How it works + +| Step | Where it runs | What it does | +|---|---|---| +| `API('/attachments?...')` | Server (EJS) | Fetches attachment metadata for the record | +| `GETBLOB(url)` | Server (EJS) | Downloads the raw binary content of each PDF | +| `BUFFER2BASE64(blob)` | Server (EJS) | Encodes the binary as a Base64 string embedded in the HTML | +| `base64ToUint8Array()` | Browser (JS) | Decodes the Base64 string back to binary | +| `pdfjsLib.getDocument()` | Browser (JS) | Parses the binary PDF | +| `page.render()` | Browser (JS) | Draws each page to a `` element | +| `document.body.appendChild(canvas)` | Browser (JS) | Appends each page to the document for Puppeteer to capture | +| `window.__idleBlocker.abort()` | Browser (JS) | Signals Puppeteer that rendering is complete | + +## Notes + +- `API()` and `GETBLOB()` are EJS-only server-side functions — they cannot be called from client-side ` + + + + + + +``` + +## EJS Report Template + +```html +<% +/** + * Calendar View Report + * + * Queries records from a Fulcrum app and displays them as calendar events. + * Each record must have at minimum: + * - a date_from field (event start) + * - a type or category field (used for color coding) + * - a label field (shown as the event title) + * + * Replace "YOUR_APP_TABLE_NAME" with your app's data_name or form_id. + * Adjust the SELECT columns and field references to match your schema. + */ +const rows = QUERY(` + SELECT + label_field, + type_field, + comment_field, + date_from, + date_to + FROM "YOUR_APP_TABLE_NAME" +`).rows; +%> + + + + + + +
+ + + + +``` + +## How It Works + +The `QUERY()` call runs server-side during EJS rendering and returns an array of row objects. That array is serialized with `JSON.stringify()` and injected directly into the ` +``` + +## EJS Report Template + +```html +<% +/** + * Interactive Web Map + Data Table + * + * Queries records from a Fulcrum app and renders them as an interactive + * Leaflet map with a synchronized, searchable data table. + * + * Replace "YOUR_APP_TABLE_NAME" with your app's data_name or form_id. + * Adjust SELECT columns to match your schema. The _latitude and _longitude + * system columns are always available for any Fulcrum app with GPS enabled. + */ +const rows = QUERY(` + SELECT + _record_id, + _title, + _latitude, + _longitude, + _status, + _created_at, + field_a, + field_b + FROM "YOUR_APP_TABLE_NAME" + WHERE _latitude IS NOT NULL + ORDER BY _created_at DESC +`).rows; +%> + + + + + + + Record Map + + + + + + +
+ + +
+ +
+
+
+ + + + + + + + +
RecordStatus
+
+
+ + + + +``` + +## How It Works + +`QUERY()` fetches records server-side using the Fulcrum SQL API. The `_latitude`, `_longitude`, `_title`, `_status`, and `_record_id` columns are available on every Fulcrum app with location enabled. The result is injected as `ALL_RECORDS` via `JSON.stringify()`. + +On the client, Leaflet renders a marker for each record that has coordinates. Clicking a marker highlights the corresponding table row and scrolls it into view. Clicking a table row pans and zooms the map to that marker. + +The text search runs `JSON.stringify(record).toLowerCase().includes(q)` — a simple but effective approach that matches against any field value in the record without needing to specify field names. + +The **Open in App** link uses the `fulcrumapp://records/{id}` URL scheme, which opens the record directly in the Fulcrum mobile app on iOS and Android. + +## Customization + +**Custom marker colors:** Replace `L.marker()` with `L.circleMarker()` and vary the `color` option based on `record._status` or another field. + +**Clustering:** Add [Leaflet.markercluster](https://github.com/Leaflet/Leaflet.markercluster) for better performance with large datasets. + +**Filter by status:** Add dropdown filters above the table and call `renderAll(filtered)` on change. + +**Esri basemap:** Replace the OpenStreetMap tile URL with an Esri tile layer: +```javascript +L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri' +}).addTo(map); +``` diff --git a/docs/REPORT BUILDER/reports-examples/merge-pdf-attachments-with-pdf-lib.md b/docs/REPORT BUILDER/reports-examples/merge-pdf-attachments-with-pdf-lib.md new file mode 100644 index 00000000..d595f338 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/merge-pdf-attachments-with-pdf-lib.md @@ -0,0 +1,155 @@ +--- +title: Merge PDF Report Templates and Attachments with pdf-lib +excerpt: Use APIREQUEST() to generate multiple report PDFs server-side, fetch PDF attachments with GETBLOB(), then merge everything into a single downloadable PDF using pdf-lib on the client. +--- + +This example shows how to build a Report Builder template that programmatically merges multiple Fulcrum report templates and PDF file attachments into a single combined PDF. The merged PDF is triggered as a browser download when the report opens. + +## Overview + +The pattern works in two phases inside one advanced report template: + +**Server-side (EJS):** Call `APIREQUEST()` to generate each desired sub-report for the current record and capture the resulting PDF URLs. Fetch any PDF attachments on the record using `API()` + `GETBLOB()` + `BUFFER2BASE64()`. + +**Client-side (JavaScript):** Load `pdf-lib`, await all the PDF byte arrays, copy every page into a new combined document, and trigger a browser download. + +## Dependencies + +```html + +``` + +## EJS Report Template + +```html +<% +/** + * PDF Merge Report Template + * + * Fetches one or more report PDFs by template ID, plus any PDF attachments + * on the current record, and merges them client-side with pdf-lib. + * + * Replace the template IDs below with the UUIDs of the Report Builder + * templates you want to include. Remove or add fetchReport() calls as needed. + * The allAttachments block fetches every PDF file attached to the record — + * remove it if you only want report templates. + */ + +// Helper: generate a report PDF for the current record from a template ID. +// APIREQUEST is executed server-side and returns the signed report URL. +function fetchReport(templateId) { + const requestOptions = { + url: 'https://api.fulcrumapp.com/api/v2/reports.json', + method: 'POST', + body: JSON.stringify({ + record_id: RECORDID(), + template_id: templateId, + }), + api: true, + headers: { 'Content-Type': 'application/json' }, + }; + const response = APIREQUEST(requestOptions); + const reportUrl = JSON.parse(response.body).report_url; + // Append the report viewer token so the client-side fetch is authenticated + return `${reportUrl}?token=${$params.token}`; +} + +// Generate each sub-report server-side and capture the authenticated URLs +const page1Url = fetchReport('YOUR-FIRST-TEMPLATE-ID'); +const page2Url = fetchReport('YOUR-SECOND-TEMPLATE-ID'); +// Add more fetchReport() calls here for additional templates + +// Fetch PDF attachments on this record (remove block if not needed) +const allAttachments = API(`/attachments?record_id=${RECORDID()}&owner_type=record`); +%> + + +
+

+
Processing + . + . + . +
+

+
+ + + + + +``` + +## How It Works + +`fetchReport(templateId)` is an EJS server-side function that calls `APIREQUEST()` to POST to the Fulcrum Reports API. Fulcrum generates the sub-report PDF and returns a signed `report_url`. The `$params.token` is appended so the client-side `fetch()` call can download the PDF without a separate auth header. + +PDF attachment bytes are fetched entirely server-side using `GETBLOB()` (which follows authenticated Fulcrum download URLs) wrapped in `BUFFER2BASE64()` to embed the binary data as a Base64 string directly into the HTML. This avoids CORS issues on the client. + +On the client, `pdf-lib` loads each PDF, copies all of its pages into a new merged document, and saves the result as a data URI. A programmatic anchor click triggers the download. + +## Usage Notes + +- Replace `YOUR-FIRST-TEMPLATE-ID` and `YOUR-SECOND-TEMPLATE-ID` with the UUIDs of the Report Builder templates you want to include. Find template IDs in the Report Builder URL or via the Fulcrum API at `GET /api/v2/report_templates.json`. +- The `allAttachments` block merges every PDF attached to the record. Filter by `attachment.content_type` or attachment filename if you only want specific files. +- This report template is best run as a standalone "merge" report, not embedded in a normal record view. Set `"output": "html"` in the report config and keep `"status": "inactive"` until ready to deploy. +- Because of the async download pattern, use this template alongside the [Puppeteer stall technique](./puppeteer-stall-for-async-rendering.md) if Puppeteer is involved in your report workflow. diff --git a/docs/REPORT BUILDER/reports-examples/photo-metadata-in-reports.md b/docs/REPORT BUILDER/reports-examples/photo-metadata-in-reports.md new file mode 100644 index 00000000..0a1ab23a --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/photo-metadata-in-reports.md @@ -0,0 +1,108 @@ +--- +title: Display photo EXIF metadata in reports +excerpt: >- + Use the QUERY function to fetch GPS coordinates, altitude, direction, and + capture timestamp for each photo in a report, and display that metadata + alongside the photo image. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Display photo EXIF metadata in reports + +## Overview + +Fulcrum stores EXIF metadata for every photo captured on mobile — including GPS coordinates, altitude, compass direction, and timestamp. This data lives in the `photos` system table and can be queried in a Report Builder template using `QUERY()`. + +This snippet replaces the standard `` block in the default report's photo section with a two-column layout that shows the photo on the left and its metadata on the right. + +## Where to add this + +This snippet is designed to replace the photo rendering block inside the default Fulcrum report template. Find the section that iterates over `value.items` inside a `isPhotoElement` block and replace the inner `` element with the code below. + +The default report structure looks like: + +```ejs +<% } else if (element.isPhotoElement) { %> + ... + <% value.items.forEach((item, index) => { %> +
+ + + +
+ <% }); %> +``` + +## Code + +Replace the `` element inside the photo column loop with the following: + +```ejs +<% + // Fetch EXIF metadata for this specific photo from the photos system table + const imageData = QUERY(`SELECT * FROM photos WHERE photo_id = '${item.mediaID}'`); + const meta = imageData.rows[0]; + const latitude = meta.latitude; + const longitude = meta.longitude; + const altitude = meta.altitude; + const direction = meta.direction; + + // Format the capture timestamp to YYYY-MM-DD + const capturedAt = new Date(meta.updated_at); + const formattedDate = capturedAt.toISOString().split('T')[0]; +%> + +
+ + + +
+

Latitude: <%= latitude %>

+

Longitude: <%= longitude %>

+

Altitude: <%= altitude %> m

+

Direction: <%= direction %>°

+

Date:
<%= formattedDate %>

+
+ +
+``` + +## Available photo metadata fields + +The `photos` system table contains the following columns you can query: + +| Column | Description | +|---|---| +| `photo_id` | UUID matching `item.mediaID` | +| `latitude` | GPS latitude at capture | +| `longitude` | GPS longitude at capture | +| `altitude` | Altitude in meters | +| `direction` | Compass bearing (degrees) | +| `accuracy` | GPS accuracy in meters | +| `updated_at` | Capture timestamp (ISO 8601) | +| `created_by` | Username of the field user | +| `record_id` | The parent record ID | + +## Notes + +- `QUERY()` is executed server-side during report generation, not in the browser. Each call adds a small amount of rendering time — for reports with many photos, consider batching the query to fetch all photo IDs at once and building a lookup map. +- If a photo was uploaded from a device without GPS (or with location disabled), `latitude` and `longitude` will be `null`. Add a null check before displaying. +- `direction` is the compass bearing the device camera was facing at the moment of capture, not the direction of travel. It may be `null` if the device does not have a compass. +- This snippet works inside both the default report and custom HTML report templates. + +*Credit: Diego Caplan, Gus Ferrara, Diego Osorio* diff --git a/docs/REPORT BUILDER/reports-examples/puppeteer-stall-for-async-rendering.md b/docs/REPORT BUILDER/reports-examples/puppeteer-stall-for-async-rendering.md new file mode 100644 index 00000000..db5ec89e --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/puppeteer-stall-for-async-rendering.md @@ -0,0 +1,112 @@ +--- +title: Stall Puppeteer for async rendering +excerpt: >- + Report Builder uses Puppeteer to render PDFs. By default it captures the page + as soon as network activity goes idle, which cuts off async operations like + PDF merging or large map rendering before they complete. This snippet keeps + the renderer waiting until your async work is done. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Stall Puppeteer for async rendering + +## Overview + +Fulcrum's Report Builder renders PDFs using [Puppeteer](https://pptr.dev/), which captures the page once it detects that the network has gone idle. This works well for simple reports, but fails when your report performs async work **after** initial page load — such as: + +- Rendering large maps or satellite imagery +- Fetching and appending PDF attachments from record fields +- Running multiple API calls in sequence + +When Puppeteer fires too early, the PDF is cut off or blank in the async sections. + +**The fix:** Issue a long-running network request at startup to keep Puppeteer in a "network active" state, then abort that request when your async work finishes. Puppeteer detects the abort as network idle and captures the page at the right moment. + +## Code + +### 1 — Place this ` +``` + +### 2 — Place these two lines at the very end of your `main()` async function + +```javascript +async function main() { + // ... your async report logic here ... + + // Wait for the next two animation frames to ensure all canvas/DOM updates + // have been committed before releasing Puppeteer. + await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))); + + // Release the idle blocker — Puppeteer will now detect network idle and + // capture the page. + if (window.__idleBlocker) { + window.__idleBlocker.abort(); + } +} +``` + +## Full example structure + +```html + + + + +
+ + <%- /* your EJS here */ %> + + +
+ + +``` + +## How it works + +1. `fetch('https://httpbin.org/delay/60', ...)` makes a request to an endpoint that intentionally delays 60 seconds. Puppeteer sees ongoing network activity and waits. +2. Your `main()` function runs all async operations. +3. `requestAnimationFrame` (called twice) ensures any final DOM mutations from the last async operation have been painted. +4. `window.__idleBlocker.abort()` cancels the long-running fetch. The browser fires an `AbortError` which is caught and ignored. +5. Puppeteer now sees the network go idle and captures the complete page. + +## Notes + +- This technique does not affect normal report rendering speed — if your async work finishes in 2 seconds, Puppeteer captures the page in 2 seconds. +- The `https://httpbin.org/delay/60` endpoint is a reliable public utility. If your org restricts outbound network access from the report renderer, substitute any URL that takes a long time to respond (or times out gracefully). +- This pattern pairs well with the [concat PDF attachments to a report](./concat-pdf-attachments-to-report) example, which requires async rendering to complete before Puppeteer captures the page. + +*Credit: Mike Meesseman* diff --git a/docs/REPORT BUILDER/reports-examples/query-repeatables-photos-and-google-street-view.md b/docs/REPORT BUILDER/reports-examples/query-repeatables-photos-and-google-street-view.md new file mode 100644 index 00000000..670160c5 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/query-repeatables-photos-and-google-street-view.md @@ -0,0 +1,218 @@ +--- +title: Query repeatables, photos, and Google Street View +excerpt: >- + Use the QUERY function in a Report Builder template to pull repeatable records + and their photo metadata, display each photo alongside a static map pin, and + link to Google Street View at the photo's GPS coordinates. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Query repeatables, photos, and Google Street View + +## Overview + +This Report Builder template demonstrates three powerful techniques in a single pattern: + +1. **Querying a repeatable child table** — Using `QUERY()` to fetch repeatable records for the current report record directly, with control over ordering. +2. **Rendering photos with captions and GPS metadata** — Each photo is displayed alongside its caption and coordinates pulled from the query result. +3. **Static map + Google Street View link** — For each photo that has GPS coordinates, a `STATICMAP()` pin is rendered beside the photo, with a clickable link to open Google Street View at that location. + +## Configuration + +Replace `YOUR_APP_NAME/repeatable_data_name` in the query with the actual path to your repeatable. The format is `"App Name/repeatable_field_data_name"`. + +Also update the `data_name` references in the query columns to match your app's field names: +- `photo_number` — a numeric or text field used for ordering/labeling photos +- `observation_photo` — the photo field `data_name` +- `observations_details` — a text field for the photo caption + +## Template + +```ejs +
+

Photo Observations

+ + <% + // Query the repeatable table for this record, ordered by photo number + const observations = QUERY(` + SELECT * + FROM "YOUR_APP_NAME/photo_observations" + WHERE _record_id = '${record.id}' + ORDER BY photo_number ASC + `); + + if (observations.rows && observations.rows.length > 0) { + %> +
+ <% observations.rows.forEach((obs) => { + const photoNumber = obs.photo_number; + const photoId = obs.observation_photo ? obs.observation_photo[0] : null; + const caption = obs.observations_details; + const hasLocation = obs._latitude != null && obs._longitude != null; + + if (!photoId) return; // skip rows with no photo + + // Build a map marker for STATICMAP if we have coordinates + const marker = hasLocation + ? `size:mid|color:0xe00606|label:${photoNumber}|${obs._latitude},${obs._longitude}` + : ''; + + const mapOptions = hasLocation ? { + size: '600x600', + zoom: 18, + scale: 2, + markers: [marker] + } : {}; + + // Build the Google Street View URL + const streetViewLink = hasLocation + ? `https://www.google.com/maps/@?api=1&map_action=pano&viewpoint=${obs._latitude},${obs._longitude}&heading=0&pitch=0&fov=80` + : ''; + %> +
+
+ + +
+
Photo #<%= photoNumber %>
+ Photo <%= photoNumber %> +
+ <% if (caption) { %> +
<%= caption %>
+ <% } %> + <% if (hasLocation) { %> +
+ Location: + <%= obs._latitude.toFixed(6) %>, <%= obs._longitude.toFixed(6) %> +
+ <% } %> +
+
+ + +
+ <% if (hasLocation) { %> + Map for Photo <%= photoNumber %> + + <% } else { %> +

No location data available for this photo.

+ <% } %> +
+ +
+
+ <% }); %> +
+ + <% } else { %> +

No photo observations found for this record.

+ <% } %> +
+``` + +## CSS + +```css +.photo-section { + margin-top: 2em; +} + +.photo-observation { + margin-bottom: 2em; + page-break-inside: avoid; + border: 1px solid #ddd; + padding: 1em; + border-radius: 4px; +} + +.photo-map-container { + display: flex; + gap: 1em; + flex-wrap: wrap; +} + +.photo-container { + flex: 1; + min-width: 300px; + position: relative; +} + +.photo-number { + position: absolute; + top: 10px; + left: 10px; + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-weight: bold; +} + +.photo { + width: 100%; + height: 340px; + object-fit: cover; +} + +.photo-info { + margin-top: 1em; +} + +.photo-caption { + font-style: italic; + margin-bottom: 0.5em; +} + +.photo-location { + font-size: 0.9em; + color: #666; +} + +.map-container { + flex: 1; + min-width: 300px; +} + +.map { + width: 100%; + height: 340px; +} + +.street-view-link { + margin-top: 0.5em; + text-align: right; +} + +.street-view-link a { + color: #0066cc; + text-decoration: none; +} + +.no-location { + padding: 1em; + background: #f5f5f5; + text-align: center; + color: #666; +} +``` + +## Notes + +- `QUERY()` in a report context runs against the Fulcrum Query API and accepts standard SQL. The table name format for a repeatable is `"App Name/repeatable_data_name"` — both names are case-sensitive. +- `obs.observation_photo` returns an array of photo IDs. `[0]` takes the first photo from each repeatable row. To display multiple photos per row, iterate over the array. +- `STATICMAP()` and `SET_MAP_OPTIONS()` / `SET_MAP_CLASS()` are Report Builder built-ins that generate Google Static Maps API URLs using your org's API key. +- The Google Street View link opens in a new tab. In a printed PDF it will appear as a hyperlink but won't be clickable — consider including the coordinates as plain text for printed output. +- `_latitude` and `_longitude` on repeatable rows refer to the GPS coordinates captured when the repeatable item was created on mobile. + +*Credit: Kyle Pennell* diff --git a/docs/REPORT BUILDER/reports-examples/recursive-photo-renderer.md b/docs/REPORT BUILDER/reports-examples/recursive-photo-renderer.md new file mode 100644 index 00000000..e8084717 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/recursive-photo-renderer.md @@ -0,0 +1,152 @@ +--- +title: Recursive photo renderer for nested sections +excerpt: >- + The RENDER function only traverses one level of nesting at a time. If your + photos live inside sections that are themselves inside other sections or + repeatables, they'll be silently skipped. This template uses recursive + renderSection() and renderRepeatableItems() calls to traverse any depth of + nesting and render every photo, sketch, and signature in the form. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Recursive photo renderer for nested sections + +## Overview + +Fulcrum's `RENDER` function iterates over every field element in a form, but it only goes **one level deep** by default. If your form has photos nested inside sections that are themselves inside other sections (or inside repeatables), those photos will be silently skipped unless you explicitly tell `RENDER` to recurse into each container. + +**The problem:** A form structure like this: + +``` +- Defect (Section) + - Before Photos (Section) + - Photo 1 (PhotoField) + - Photo 2 (PhotoField) + - After Photos (Section) + - Photo 3 (PhotoField) +``` + +Without recursion, `RENDER` finds the `Defect` section, doesn't match `isPhotoElement` or `isRepeatableElement`, and skips it — never looking inside for photos. + +**The fix:** Add an `isSectionElement` handler that calls `renderSection()`, which tells `RENDER` to recurse one level deeper with the same rule set. Combined with `renderRepeatableItems()` for repeatables, the template becomes fully recursive. + +## Template + +```ejs +
+ <% RENDER(record, null, ({element, value, renderSection, renderRepeatableItems, renderItem}) => { %> + + <%# ── Photos ──────────────────────────────────────────────────────────── %> + <% if (element.isPhotoElement) { %> + <% if (value && !value.isEmpty) { %> +
+ <% value.items.forEach(item => { %> + + <% }); %> +
+ <% } %> + + <%# ── Sketches ─────────────────────────────────────────────────────────── %> + <% } else if (element.isSketchElement) { %> + <% if (value && !value.isEmpty) { %> +
+ <% value.items.forEach(item => { %> + + <% }); %> +
+ <% } %> + + <%# ── Signatures ────────────────────────────────────────────────────────── %> + <% } else if (element.isSignatureElement) { %> + <% if (value && !value.isEmpty) { %> +
+ +
+ <% } %> + + <%# ── Sections: recurse one level deeper ──────────────────────────────── %> + <% } else if (element.isSectionElement) { %> + <% renderSection() %> + + <%# ── Repeatables: recurse into each child item ────────────────────────── %> + <% } else if (element.isRepeatableElement) { %> +
+ <% renderRepeatableItems(({renderItem}) => { %> +
+ <% renderItem() %> +
+ <% }); %> +
+ <% } %> + + <% }) %> +
+``` + +## How the recursion works + +| Handler | What it does | +|---|---| +| `isPhotoElement` | Renders all photos in a photo field | +| `isSketchElement` | Renders all sketches in a sketch field | +| `isSignatureElement` | Renders the signature image | +| `isSectionElement` → `renderSection()` | Tells RENDER to apply the same rule set to all elements *inside* this section | +| `isRepeatableElement` → `renderRepeatableItems()` | Iterates each child record in the repeatable, applying the same rule set to each | + +The key insight is `renderSection()` — calling it is the command that makes `RENDER` go one level deeper with the **same callback**. Without it, sections are a dead end. + +## Suggested CSS + +```css +.root { + font-family: sans-serif; + padding: 1em; +} + +.photo-group, +.sketch-group, +.signature-group { + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 1em; +} + +.photo-group img, +.sketch-group img { + width: 45%; + height: auto; + page-break-inside: avoid; +} + +.signature-group img.signature { + max-width: 300px; +} + +.repeatable-group { + margin-left: 1em; + border-left: 3px solid #ddd; + padding-left: 1em; + margin-bottom: 1em; +} + +.repeatable-item { + margin-bottom: 1em; + page-break-inside: avoid; +} +``` + +## Notes + +- This template renders **only media fields** (photos, sketches, signatures). To include other field values alongside photos, add additional `else if` branches for `element.isTextField`, `element.isChoiceElement`, etc. +- `IMAGE_SIZE()` returns the CSS class (`'small'`, `'medium'`, or `'large'`) configured in the report's settings. You can also pass a fixed class name to `img` directly if you prefer. +- Sections that are hidden in the record (via `SETHIDDEN`) will still appear in the report since visibility rules are not applied by `RENDER` by default. Add a `ISVISIBLE(element, record, allValues)` check if you need to respect visibility. + +*Credit: Kyle Pennell* diff --git a/docs/REPORT BUILDER/reports-examples/save-pdf-on-mobile-and-desktop.md b/docs/REPORT BUILDER/reports-examples/save-pdf-on-mobile-and-desktop.md new file mode 100644 index 00000000..82d5e331 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/save-pdf-on-mobile-and-desktop.md @@ -0,0 +1,74 @@ +--- +title: Save a Generated PDF on Mobile and Desktop +excerpt: Use a mobile user-agent check to branch between two PDF delivery strategies — opening the PDF in a new tab on mobile (where programmatic downloads are blocked) and triggering a file download on desktop — ensuring a consistent save experience across all devices. +--- + +When generating a PDF client-side in a Fulcrum Report Builder template (using pdf-lib or a similar library), the standard `` click trick works on desktop browsers but silently fails on most mobile browsers, which block programmatic downloads. The fix is to detect the device type and open the PDF in a new browser tab on mobile instead. + +## Code + +This snippet assumes `pdfDoc` is a [pdf-lib](https://pdf-lib.js.org/) `PDFDocument` instance that has already been built. Replace `pdfDoc.save()` / `pdfDoc.saveAsBase64()` with the equivalent method from your PDF library if you're using something else. + +```javascript +async function savePdf(pdfDoc, filename) { + const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); + + if (isMobile) { + // Mobile browsers block programmatic clicks. + // Instead, open the PDF as a blob URL in a new tab so the user + // can use the browser's native "Save" / "Share" options. + const pdfBytes = await pdfDoc.save(); // Returns Uint8Array + const blob = new Blob([pdfBytes], { type: 'application/pdf' }); + const blobUrl = URL.createObjectURL(blob); + + const newTab = window.open(blobUrl, '_blank'); + if (!newTab) { + alert('Pop-up blocked. Please allow pop-ups for this site to save the PDF.'); + } + } else { + // Desktop: trigger a standard file download via a temporary element. + const pdfDataUri = await pdfDoc.saveAsBase64({ dataUri: true }); + + const link = document.createElement('a'); + link.href = pdfDataUri; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } +} +``` + +## Usage in a Report Template + +Call `savePdf()` after the PDF has been fully assembled, typically in a button's click handler or at the end of an async generation function. The `filename` argument can include EJS expressions resolved before the template is served: + +```javascript +// EJS resolves record.displayValue before the browser receives the page. +// The resulting filename might be: "Inspection 2025-03-24.pdf" +const filename = '<%= record.displayValue.replaceAll("\r\n", " ") %>' + '.pdf'; + +document.getElementById('save-btn').addEventListener('click', async () => { + document.getElementById('processing').style.display = 'block'; + document.getElementById('save-btn').disabled = true; + + // ... build pdfDoc here ... + + await savePdf(pdfDoc, filename); + + document.getElementById('processing').style.display = 'none'; + document.getElementById('finish').style.display = 'block'; +}); +``` + +## Why This Is Needed + +iOS Safari and most Android browsers treat `` as a navigation event rather than a download trigger. Calling `link.click()` programmatically has no effect, or navigates away from the report page. Opening a `blob:` URL in a new tab works because the browser's built-in PDF viewer provides its own save/share UI. + +## Notes + +**Revoke the blob URL** after the tab opens to avoid memory leaks in long-lived sessions: `setTimeout(() => URL.revokeObjectURL(blobUrl), 10000)`. + +**Pop-up blockers** may prevent `window.open()` on mobile if the call isn't triggered directly by a user gesture (e.g., it's inside a `setTimeout` or a long async chain). Keep the `window.open()` call as close to the user event handler as possible. + +**`saveAsBase64({ dataUri: true })`** returns a string like `data:application/pdf;base64,...`. The base64 data URI approach is convenient on desktop but creates a very large string for big PDFs. For large PDFs, prefer `save()` + `Blob` + `URL.createObjectURL()` on desktop as well. diff --git a/docs/REPORT BUILDER/reports-examples/simple-react-app-with-query-api.md b/docs/REPORT BUILDER/reports-examples/simple-react-app-with-query-api.md new file mode 100644 index 00000000..57a70c02 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/simple-react-app-with-query-api.md @@ -0,0 +1,160 @@ +--- +title: Build a React Report Using CDN Libraries +excerpt: Use React, React DOM, and Babel loaded from CDNs to build a fully interactive Report Builder template with React components and hooks — no build tooling required. This pattern is ideal for data tables, dashboards, and any report that benefits from React's component model. +--- + +The Fulcrum Report Builder renders HTML templates that can include any JavaScript loaded from CDNs. React v17 with Babel's standalone JSX transformer runs entirely client-side — no `npm install` or build step required — making it a practical way to write component-based report templates. + +## Report Template + +```html + + + + + Record Viewer + + + + + + + + + + + + + + +
+ + + + + +``` + +## Using QUERY() Instead of fetch() + +In a Fulcrum Report Builder template, you have access to the `QUERY()` function which runs server-side before the HTML is rendered. This is more secure than exposing an API token in the browser, and it's faster because the data is embedded directly in the HTML: + +```html + +``` + +## Key Notes + +**`type="text/babel"`** tells the Babel standalone library to transpile the JSX in that script block before execution. Standard `