diff --git a/docs/App Extensions/app-extension-examples/_order.yaml b/docs/App Extensions/app-extension-examples/_order.yaml index e566674b..9bf03483 100644 --- a/docs/App Extensions/app-extension-examples/_order.yaml +++ b/docs/App Extensions/app-extension-examples/_order.yaml @@ -1,2 +1,9 @@ - index +- date-picker-with-blackout-dates +- high-energy-hazard-selector +- illness-symptoms-selector +- inventory-scanner-with-barcode +- offline-capabilities - rich-text-editor +- share-pdf +- visualize-data-with-chart-js 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..7ff5d7a9 --- /dev/null +++ b/docs/App Extensions/app-extension-examples/date-picker-with-blackout-dates.md @@ -0,0 +1,229 @@ +--- +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's **Form 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 ─────────────────────────────────────────────────────────── + +// Form ID of the blackout dates app +const BLACKOUT_FORM_ID = 'YOUR-BLACKOUT-DATES-FORM-ID-HERE'; + +// Field key of the start date field in the blackout app +const START_DATE_KEY = 'YOUR-START-DATE-FIELD-KEY'; + +// Field key of the end date field in the blackout app +const END_DATE_KEY = 'YOUR-END-DATE-FIELD-KEY'; + +// 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: (function (d) { + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return year + '-' + month + '-' + day; + })(new Date()) + }, + 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 + +- Dates are handled as local calendar days (`YYYY-MM-DD`) end to end, so ranges and the selected date are not shifted by time zone. +- The bridge ` + + + + + + + + + + + + +
+
+

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 **data names** of the barcode, item name, and quantity fields (shown in the App Designer's field settings) +5. The form ID and field names are saved in `localStorage` and persist across sessions. The API token is kept in `sessionStorage` only, so you will be asked to enter it again in a new session. + +## 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. +- **Lookup method:** `GET /api/v2/records.json` has no free-text search parameter, so the extension finds the record with a Query API SQL lookup (`WHERE = ''`) and then loads it with the Records API. The scanned value is escaped and the data name is validated before being used in the query. +- **Matching is exact.** The scanned barcode must equal the stored barcode value. +- **Network requests:** The extension calls `api.fulcrumapp.com` directly from the browser or webview, so it needs a connection. Test it on each platform you plan to use (iOS, Android, and web) before rolling it out. +- **Token security:** Any API token used in a client-side extension can be read by scripts running in the same page, so treat it as exposed to anyone who can use or inspect the extension. + - Create a dedicated token for this extension and give it to a user whose role can only read and update the inventory app. Never use an owner-level token. + - Prefer short-lived or revocable tokens, and revoke the token when a device is lost or a shift ends. + - Do not use this pattern on shared devices unless each person enters their own token. + - The token is stored in `sessionStorage` rather than `localStorage`, so it is not persisted, but it is still visible to scripts in the page. 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..14cd9863 --- /dev/null +++ b/docs/App Extensions/app-extension-examples/visualize-data-with-chart-js.md @@ -0,0 +1,273 @@ +--- +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 in an 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. The example loads Chart.js from a CDN, so the device must be online; see [Offline use](#offline-use) to bundle it for offline work. + +## 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 Fulcrum.load(). + 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 Fulcrum.finish({ 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 uses the Fulcrum App Extension bridge (`Fulcrum.load()` and `Fulcrum.finish()`) to receive the data from the Data Event, 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. + +> The bridge ` +``` + +## 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 (used instead of `QUERY()` because the Query API has no attachments table) | +| `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 +<% +/** + * 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({ + report: { + record_id: RECORDID(), + template_id: templateId, + }, + }), + api: true, + cache: false, // always generate a fresh report + headers: { 'Content-Type': 'application/json' }, + }; + const response = APIREQUEST(requestOptions); + const report = JSON.parse(response.body).report; + + if (!report || report.state !== 'completed') { + throw new Error('Report was not completed (state: ' + (report && report.state) + ')'); + } + + // Append the token so the client-side fetch is authenticated. + // $params.token is the token the report was requested with (?token=...). + const separator = report.url.includes('?') ? '&' : '?'; + return `${report.url}${separator}token=${encodeURIComponent($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). +// The Query API has no attachments table, so API() is used here instead of QUERY(). +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](https://docs.fulcrumapp.com/reference/reports-create). The response contains a `report` object with a `state` and a `url`; the example checks that `state` is `completed` before using `url`. `$params.token` is appended so the client-side `fetch()` call can download the PDF without a separate auth header. + +> **Note:** `$params.token` is only populated when the report is requested with a `token` query-string parameter (for example `...?token=YOUR-TOKEN`). If the report is requested with an `X-ApiToken` header instead, `$params.token` is empty. That token is passed to the browser, so only use this pattern when the report is delivered to the user who owns the token. + +PDF attachment bytes are fetched entirely server-side using `GETBLOB()` 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 (matched by content type or a `.pdf` file name). Add further checks on `attachment.name` if you only want specific files. +- This report template is best run as a standalone "merge" report, not embedded in a normal record view. In the Report Builder, set **Output** to **HTML** so the browser runs the merge script (the Output selector appears after you [enable the `reportsEnabled` flag](../../Utilities/utilities-examples/enable-feature-flag-via-console.md)). +- 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..074eff26 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/photo-metadata-in-reports.md @@ -0,0 +1,119 @@ +--- +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}'`); + + // Provide safe defaults in case no metadata is found + let latitude = null; + let longitude = null; + let altitude = null; + let direction = null; + let formattedDate = null; + + if (imageData && Array.isArray(imageData.rows) && imageData.rows.length > 0) { + const meta = imageData.rows[0]; + + latitude = meta.latitude; + longitude = meta.longitude; + altitude = meta.altitude; + direction = meta.direction; + + // Format the capture timestamp to YYYY-MM-DD + if (meta.updated_at) { + const capturedAt = new Date(meta.updated_at); + 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. 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..c574f5aa --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/puppeteer-stall-for-async-rendering.md @@ -0,0 +1,110 @@ +--- +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.md) example, which requires async rendering to complete before Puppeteer captures the page. 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..ef00b1d5 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/query-repeatables-photos-and-google-street-view.md @@ -0,0 +1,217 @@ +--- +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 _parent_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. + 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-introduction/functions.md b/docs/REPORT BUILDER/reports-introduction/functions.md index 7c8d9067..29df1754 100644 --- a/docs/REPORT BUILDER/reports-introduction/functions.md +++ b/docs/REPORT BUILDER/reports-introduction/functions.md @@ -33,6 +33,50 @@ API("/choice_lists", { qs: { per_page: 1 } }); --- +## APIREQUEST + +Make a synchronous HTTP request. Unlike `API()`, which only makes `GET` requests to the Fulcrum API, `APIREQUEST()` accepts a full request object, so you can use other methods (for example `POST`) and send a body and headers. + +### Parameters + +`options` Object (**required**) - Request options + +- `url` String (**required**) - Request URL +- `method` String - HTTP method, such as `GET` or `POST` +- `headers` Object - Request headers +- `body` String - Request body +- `qs` Object - Query string parameters +- `json` Boolean - Send and expect JSON. The response `body` is then parsed into an object. Otherwise `body` is returned as a string. +- `api` Boolean - Set to `true` to send the report requester's Fulcrum API token with the request. Use this for calls to the Fulcrum API. +- `cache` Boolean - Responses to `api: true` requests may be cached for the duration of a report run. Set to `false` to always make a fresh request. + +### Returns + +Object - `{ statusCode, body, headers }` + +### Examples + +```js +// Generate a report for the current record using the Reports API +const response = APIREQUEST({ + url: "https://api.fulcrumapp.com/api/v2/reports.json", + method: "POST", + body: JSON.stringify({ + report: { + record_id: RECORDID(), + template_id: "YOUR-TEMPLATE-ID", + }, + }), + api: true, + cache: false, + headers: { "Content-Type": "application/json" }, +}); + +const reportUrl = JSON.parse(response.body).report.url; +``` + +--- + ## AUDIOURL Generate a public audio URL @@ -55,6 +99,26 @@ AUDIOURL($my_audio_field[0].audio_id, { version: "original" }); --- +## BUFFER2BASE64 + +Encode a binary buffer, such as the result of `GETBLOB()`, as a Base64 string. This is useful for embedding a file's contents directly in the report HTML. + +### Parameters + +`arrayBuffer` ArrayBuffer (**required**) - The binary data to encode + +### Returns + +String + +### Examples + +```js +BUFFER2BASE64(GETBLOB("https://learn.fulcrumapp.com/img/branding/fulcrum-icon.png")); +``` + +--- + ## FORMATDATE Format date diff --git a/docs/Utilities/_order.yaml b/docs/Utilities/_order.yaml index 5dcd70b7..8775dbfe 100644 --- a/docs/Utilities/_order.yaml +++ b/docs/Utilities/_order.yaml @@ -1 +1,2 @@ - utilities-introduction +- utilities-examples diff --git a/docs/Utilities/utilities-examples/_order.yaml b/docs/Utilities/utilities-examples/_order.yaml new file mode 100644 index 00000000..9366b6c8 --- /dev/null +++ b/docs/Utilities/utilities-examples/_order.yaml @@ -0,0 +1,3 @@ +- app-designer-field-mapping-csv +- download-choice-list-as-csv +- enable-feature-flag-via-console diff --git a/docs/Utilities/utilities-examples/app-designer-field-mapping-csv.md b/docs/Utilities/utilities-examples/app-designer-field-mapping-csv.md new file mode 100644 index 00000000..d1128472 --- /dev/null +++ b/docs/Utilities/utilities-examples/app-designer-field-mapping-csv.md @@ -0,0 +1,96 @@ +--- +title: Export a Field Mapping CSV from the App Designer +excerpt: Run one of these browser console scripts inside the Fulcrum App Designer to instantly generate a CSV mapping every field's data_name, key, label, and type — useful for building Report Builder templates, setting up the Query API, and onboarding new team members to an app's schema. +--- + +When building Report Builder templates or writing Query API SQL, knowing each field's `data_name` and `key` is essential. The App Designer displays this information in its field inspector panel, but copying it field by field is tedious. These scripts read the inspector DOM directly and produce a downloadable CSV in one click. + +Run any of these snippets in the browser's developer console while the App Designer is open. The CSV is printed to the console; copy it from there or adapt the script to trigger a file download. + +## Version 1 — data_name and key only + +The most common lookup: map every field's `data_name` to its `key`. + +```javascript +let csv = "data_name,key\n"; + +$$('p.form-label.pull-left').forEach(p => { + // Skip container types that don't have a data_name/key of their own + if ( + p.innerHTML.includes('type: Section') || + p.innerHTML.includes('type: Repeatable') || + p.innerHTML.includes('type: Label') + ) return; + + const html = p.innerHTML; + const dataName = html.match(/data_name:\s*([\w-]+)/)?.[1] ?? ""; + const key = html.match(/key:\s*([\w-]+)/)?.[1] ?? ""; + + csv += `${dataName},${key}\n`; +}); + +console.log(csv); +``` + +## Version 2 — label and field type + +Useful for documentation and onboarding: produces a human-readable list of every field label alongside its Fulcrum field type. + +```javascript +let csv = "label,field_type\n"; + +$$('p.form-label.pull-left').forEach(p => { + if ( + p.innerHTML.includes('type: Section') || + p.innerHTML.includes('type: Repeatable') || + p.innerHTML.includes('type: Label') + ) return; + + const label = p.textContent.split(" _")[0]; + const fieldType = p.innerHTML.match(/type:\s*([\w-]+)/)?.[1] ?? ""; + + csv += `${label}: ${fieldType}\n`; +}); + +console.log(csv); +``` + +## Version 3 — Full mapping: label, data_name, key, and field type + +The most complete version. Useful as a reference sheet for any developer working on the app. + +```javascript +let csv = "label,dataname,key,fieldtype\n"; + +$$('p.form-label.pull-left').forEach(p => { + if ( + p.innerHTML.includes('type: Section') || + p.innerHTML.includes('type: Repeatable') || + p.innerHTML.includes('type: Label') + ) return; + + const label = p.textContent.split(" _")[0]; + const fieldType = p.innerHTML.match(/type:\s*([\w-]+)/)?.[1] ?? ""; + const dataName = p.innerHTML.match(/data_name:\s*([\w-]+)/)?.[1] ?? ""; + const key = p.innerHTML.match(/key:\s*([\w-]+)/)?.[1] ?? ""; + + csv += `${label},${dataName},${key},${fieldType}\n`; +}); + +console.log(csv); +``` + +## How to Use + +1. Open the Fulcrum App Designer for the app you want to inspect. +2. Open the browser developer console (F12 → Console tab, or right-click → Inspect → Console). +3. Paste the script and press Enter. +4. The CSV text is printed to the console. Select and copy it, then paste into a spreadsheet or text editor. + +## Key Notes + +**`$$()` is a console shorthand for `document.querySelectorAll()`.** It works in Chrome, Firefox, and Edge developer consoles but is not available in regular JavaScript — don't use it in production code. + +**Sections, Repeatables, and Labels are excluded.** These container/display elements appear in the App Designer DOM but don't have their own `data_name` or `key`, so they're filtered out. If you need to include them, remove or adjust the `if` guard at the top of each `forEach`. + +**The script reads the current scroll state.** The App Designer may use virtual rendering — if a long form isn't fully scrolled, not all fields may be present in the DOM. Scroll to the bottom of the form before running the script to ensure all fields are captured. diff --git a/docs/Utilities/utilities-examples/download-choice-list-as-csv.md b/docs/Utilities/utilities-examples/download-choice-list-as-csv.md new file mode 100644 index 00000000..a28d62fe --- /dev/null +++ b/docs/Utilities/utilities-examples/download-choice-list-as-csv.md @@ -0,0 +1,57 @@ +--- +title: Download a Choice List as CSV +excerpt: Run this browser console script while viewing a Fulcrum choice list to extract all choice labels and values into a CSV and download it — useful for auditing choice lists, sharing them with stakeholders, or importing them into another tool. +--- + +When viewing a choice list in the Fulcrum web application, this console script reads the choice labels and values from the page DOM and downloads them as a CSV file — no API call required. + +## Script + +Open the choice list you want to export in the Fulcrum web application, then open the browser developer console (F12 → Console tab) and run: + +```javascript +var labels = document.getElementsByClassName('pull-left choice-label'); +var values = document.getElementsByClassName('pull-left choice-value'); + +var rows = []; +for (var i = 0; i < labels.length; i++) { + rows.push('"' + labels[i].value + '","' + values[i].value + '"'); +} + +var csvContent = 'label,value\r\n' + rows.join('\r\n') + '\r\n'; + +// Download via a Blob URL (browsers block navigating to data: URLs) +var blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8' }); +var link = document.createElement('a'); +link.href = URL.createObjectURL(blob); +link.download = 'choice-list.csv'; +document.body.appendChild(link); +link.click(); +document.body.removeChild(link); +``` + +## How to Use + +1. Navigate to **Settings → Choice Lists** in the Fulcrum web app and open the choice list you want to export. +2. Open the browser developer console (F12 → Console tab, or right-click → Inspect → Console). +3. Paste the script and press Enter. +4. Your browser downloads `choice-list.csv`. Open it in a spreadsheet or text editor. + +If the download is blocked, allow downloads for `web.fulcrumapp.com` and run the script again. + +## Output Format + +The CSV has two columns — `label` (the display text users see) and `value` (the stored value written to records): + +``` +label,value +Approved,approved +Pending Review,pending_review +Rejected,rejected +``` + +## Notes + +**`element.value`** reads the current input value from the choice label and choice value text fields rendered in the choice list editor. If a choice list is very long and the UI has virtualized (not rendered all rows), scroll to the bottom of the list before running the script to ensure all choices are present in the DOM. + +**This only works on the choice list editor page** in `web.fulcrumapp.com`. It reads live DOM elements and does not call any API. diff --git a/docs/Utilities/utilities-examples/enable-feature-flag-via-console.md b/docs/Utilities/utilities-examples/enable-feature-flag-via-console.md new file mode 100644 index 00000000..5f10aaf5 --- /dev/null +++ b/docs/Utilities/utilities-examples/enable-feature-flag-via-console.md @@ -0,0 +1,23 @@ +--- +title: Enable a Feature Flag via the Browser Console +excerpt: Use this browser console one-liner to reveal the advanced Report Builder options, which are gated behind a localStorage feature flag. +--- + +Some Report Builder options are hidden unless a feature flag is set in your browser. This console snippet sets the relevant `localStorage` key to unlock them. + +## Enable the Advanced Report Builder options + +Setting the `reportsEnabled` flag reveals an **Advanced** section in the Report Builder sidebar, plus **Output** (PDF or HTML) and **Format** (Default or Raw) selectors. + +Open the Fulcrum web app in your browser, open the developer console (F12 → Console), and run: + +```javascript +window.localStorage.setItem('reportsEnabled', '1'); +``` + +Then refresh the page and open the Report Builder. + +## Notes + +- The flag is stored per browser. You need to set it again in other browsers or after clearing site data. +- To turn it off, run `window.localStorage.removeItem('reportsEnabled');` and refresh. diff --git a/docs/_order.yaml b/docs/_order.yaml index a3cb9219..f30ca383 100644 --- a/docs/_order.yaml +++ b/docs/_order.yaml @@ -4,3 +4,5 @@ - REPORT BUILDER - App Extensions - Utilities +- Query API +- Integrations diff --git a/docs/integrations/_order.yaml b/docs/integrations/_order.yaml new file mode 100644 index 00000000..6992656b --- /dev/null +++ b/docs/integrations/_order.yaml @@ -0,0 +1 @@ +- integration-examples diff --git a/docs/integrations/integration-examples/_order.yaml b/docs/integrations/integration-examples/_order.yaml new file mode 100644 index 00000000..1bdc7171 --- /dev/null +++ b/docs/integrations/integration-examples/_order.yaml @@ -0,0 +1 @@ +- import-esri-feature-service-to-fulcrum diff --git a/docs/integrations/integration-examples/import-esri-feature-service-to-fulcrum.md b/docs/integrations/integration-examples/import-esri-feature-service-to-fulcrum.md new file mode 100644 index 00000000..a2a9f0d2 --- /dev/null +++ b/docs/integrations/integration-examples/import-esri-feature-service-to-fulcrum.md @@ -0,0 +1,135 @@ +--- +title: Import Data from an Esri Feature Service into Fulcrum +excerpt: Use Python to query an Esri ArcGIS feature service layer, download all features as JSON, write them to a CSV file, and then import the CSV into Fulcrum — useful for seeding a Fulcrum app with existing GIS data or running periodic syncs from an enterprise GIS. +--- + +Esri's ArcGIS REST API exposes feature service layers via a `/query` endpoint that returns features as JSON. This script queries all features from a layer, extracts the attribute fields, and writes them to a CSV that can be imported into Fulcrum using the standard CSV importer. + +## Prerequisites + +```bash +pip install requests +``` + +## Script + +```python +import requests +import csv +import json + +# ── Configuration ───────────────────────────────────────────────────────────── +# The base URL of the feature service layer. Format: +# https:///arcgis/rest/services///FeatureServer/ +FEATURE_SERVICE_URL = 'https://services.arcgis.com/YOUR-ORG-ID/arcgis/rest/services/YOUR_SERVICE/FeatureServer/0' +OUTPUT_CSV = 'esri_features.csv' + +# Optional: include geometry as latitude/longitude columns +INCLUDE_GEOMETRY = True + + +def fetch_all_features(service_url): + """ + Fetches all features from an ArcGIS feature service layer using + offset-based pagination (required for large layers). + """ + all_features = [] + offset = 0 + page_size = 1000 # ArcGIS default max per request + + while True: + params = { + 'where': '1=1', # Return all features + 'outFields': '*', # Return all attribute fields + 'returnGeometry': INCLUDE_GEOMETRY, + 'outSR': '4326', # WGS 84 lat/lon + 'f': 'json', + 'resultOffset': offset, + 'resultRecordCount': page_size + } + + response = requests.get(f"{service_url}/query", params=params, timeout=30) + response.raise_for_status() + data = response.json() + + features = data.get('features', []) + all_features.extend(features) + print(f" Fetched {len(all_features)} features so far...") + + # ArcGIS signals "no more pages" via exceededTransferLimit or empty page + if not data.get('exceededTransferLimit', False) or len(features) < page_size: + break + + offset += page_size + + return all_features + + +def features_to_csv(features, output_path): + """Writes feature attributes (and optional lat/lon) to a CSV file.""" + if not features: + print("No features to write.") + return + + # Collect all attribute field names across features + fieldnames = set() + for f in features: + fieldnames.update(f.get('attributes', {}).keys()) + fieldnames = sorted(fieldnames) + + if INCLUDE_GEOMETRY: + fieldnames = ['latitude', 'longitude'] + fieldnames + + with open(output_path, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.DictWriter(csvfile, fieldnames=fieldnames, extrasaction='ignore') + writer.writeheader() + + for feature in features: + row = dict(feature.get('attributes', {})) + + if INCLUDE_GEOMETRY and feature.get('geometry'): + geom = feature['geometry'] + # Point geometry returns x (longitude) and y (latitude) + row['longitude'] = geom.get('x') + row['latitude'] = geom.get('y') + + writer.writerow(row) + + print(f"✅ Wrote {len(features)} rows to {output_path}") + + +# ── Run ─────────────────────────────────────────────────────────────────── +print(f"Querying {FEATURE_SERVICE_URL}...") +features = fetch_all_features(FEATURE_SERVICE_URL) +print(f"Total features: {len(features)}") +features_to_csv(features, OUTPUT_CSV) +print(f"\nCSV saved to: {OUTPUT_CSV}") +print("Next step: Import this CSV into Fulcrum via Settings → Imports.") +``` + +## Finding Your Feature Service URL + +The feature service URL follows this pattern: + +``` +https://services.arcgis.com/{org-id}/arcgis/rest/services/{ServiceName}/FeatureServer/{layerIndex} +``` + +You can find it in ArcGIS Online by opening the feature layer's item page and clicking **View** → **View in Map Viewer** or by navigating to the service's REST endpoint directly and browsing the layer list. + +## Importing the CSV into Fulcrum + +1. In the Fulcrum web app, go to the app you want to import into. +2. Click the **Import** button and select **CSV**. +3. Upload `esri_features.csv`. +4. Map the CSV columns to your Fulcrum fields. If you included `latitude` and `longitude`, map them to the **Latitude** and **Longitude** system fields to place records on the map. + +## Notes + +**Pagination:** ArcGIS feature services cap responses at 1,000–2,000 features by default depending on the server configuration. The script uses offset pagination to retrieve all records in multiple requests. If the service sets `maxRecordCount` lower than 1,000, reduce `page_size` accordingly. + +**Authentication:** Some feature services require authentication. Pass a token via the `token` query parameter: add `'token': 'YOUR-ARCGIS-TOKEN'` to `params`. Generate a token from your ArcGIS portal or use OAuth2. + +**Feature type:** This script handles Point geometry (the most common type for Fulcrum imports). For Line or Polygon features, the geometry extraction logic would need to be adapted to serialize ring coordinates differently. + +**Date fields:** ArcGIS stores dates as Unix timestamps in milliseconds. Convert them to ISO 8601 strings before importing if your Fulcrum app uses a Date field: `datetime.utcfromtimestamp(ts / 1000).isoformat()`. diff --git a/reference/EXPORTS/_order.yaml b/reference/EXPORTS/_order.yaml new file mode 100644 index 00000000..99ab3418 --- /dev/null +++ b/reference/EXPORTS/_order.yaml @@ -0,0 +1,4 @@ +- exports-list +- exports-create +- exports-get +- exports-delete diff --git a/reference/EXPORTS/exports-create.md b/reference/EXPORTS/exports-create.md new file mode 100644 index 00000000..8cd408e0 --- /dev/null +++ b/reference/EXPORTS/exports-create.md @@ -0,0 +1,112 @@ +--- +title: Create an Export +excerpt: >- + Trigger a data export for an app or a SQL query +api: + file: rest-api.json + operationId: exports-create +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: index +next: + description: '' +--- +Exports are processed asynchronously. The request returns immediately with the new export. Poll [Get an Export](https://docs.fulcrumapp.com/reference/exports-get) until `state` is `completed` (or `failed`), then download the file from the `file` URL. Exports expire 7 days after they are created. + +This is useful for automated reporting pipelines, scheduled data extracts, and integrations that need fresh Fulcrum data on a regular basis. + +## Request body + +```json +{ + "export": { + "queries": [ + { "form_id": "YOUR-FORM-ID" } + ], + "format": "csv", + "options": { + "use_labels_as_headers": false + } + } +} +``` + +Each entry in `queries` is either `{ "form_id": "..." }` to export an app, or `{ "name": "...", "sql": "..." }` to export the results of a [Query API](https://docs.fulcrumapp.com/reference/query-intro) SQL statement. At least one query is required. + +## Supported formats + +| Value | Description | +|---|---| +| `csv` | Comma-separated values | +| `xlsx` | Excel workbook | +| `shp` | Shapefile | +| `kml` | KML | +| `geojson` | GeoJSON | +| `json` | JSON | +| `sqlite` | SQLite database | +| `spatialite` | SpatiaLite database | +| `geopackage` | GeoPackage | +| `gdb` | File geodatabase | +| `postgres` | PostgreSQL SQL script | +| `none` | Media only. Requires at least one `include_*` media option. | + +## Example with curl + +```bash +curl -X POST https://api.fulcrumapp.com/api/v2/exports \ + -H "Content-Type: application/json" \ + -H "X-ApiToken: YOUR-API-TOKEN" \ + -d '{ + "export": { + "queries": [ + { "form_id": "YOUR-FORM-ID" } + ], + "format": "csv" + } + }' +``` + +## Example with Python + +```python +import requests +import time + +API_TOKEN = 'YOUR-API-TOKEN' +HEADERS = {'Content-Type': 'application/json', 'X-ApiToken': API_TOKEN} + +# 1. Trigger the export +response = requests.post( + 'https://api.fulcrumapp.com/api/v2/exports', + headers=HEADERS, + json={ + 'export': { + 'queries': [{'form_id': 'YOUR-FORM-ID'}], + 'format': 'csv' + } + } +) +response.raise_for_status() +export = response.json()['export'] +export_id = export['id'] +print(f'Export triggered: {export_id}') + +# 2. Poll until the export finishes +while export['state'] not in ('completed', 'failed'): + time.sleep(5) + export = requests.get( + f'https://api.fulcrumapp.com/api/v2/exports/{export_id}', + headers=HEADERS + ).json()['export'] + print(f'State: {export["state"]}') + +if export['state'] == 'failed': + raise SystemExit(f'Export failed: {export.get("error")}') + +# 3. Download the file +download_url = export['file'] +print(f'Download URL: {download_url}') +``` diff --git a/reference/EXPORTS/exports-delete.md b/reference/EXPORTS/exports-delete.md new file mode 100644 index 00000000..c3b768ad --- /dev/null +++ b/reference/EXPORTS/exports-delete.md @@ -0,0 +1,17 @@ +--- +title: Cancel an Export +excerpt: >- + Cancel a running export +api: + file: rest-api.json + operationId: exports-delete +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: index +next: + description: '' +--- +Cancels a running export. The export is marked as `failed` and the updated export is returned. diff --git a/reference/EXPORTS/exports-get.md b/reference/EXPORTS/exports-get.md new file mode 100644 index 00000000..1c439c3e --- /dev/null +++ b/reference/EXPORTS/exports-get.md @@ -0,0 +1,17 @@ +--- +title: Get an Export +excerpt: >- + Retrieve an export and, once completed, its download URL +api: + file: rest-api.json + operationId: exports-get +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: index +next: + description: '' +--- +Poll this endpoint after [creating an export](https://docs.fulcrumapp.com/reference/exports-create) until `state` is `completed` or `failed`. When the export is completed, the `file` attribute contains a download URL. Exports expire 7 days after they are created, so download the file promptly. diff --git a/reference/EXPORTS/exports-list.md b/reference/EXPORTS/exports-list.md new file mode 100644 index 00000000..700ebfc7 --- /dev/null +++ b/reference/EXPORTS/exports-list.md @@ -0,0 +1,17 @@ +--- +title: List Exports +excerpt: >- + List the data exports you have created +api: + file: rest-api.json + operationId: exports-list +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: index +next: + description: '' +--- +Returns the exports you have created that have not yet expired, with pagination. Use `per_page` (maximum 20) and `page` to page through the results. diff --git a/reference/_order.yaml b/reference/_order.yaml index 4d25042f..dfb20c14 100644 --- a/reference/_order.yaml +++ b/reference/_order.yaml @@ -24,3 +24,4 @@ - WORKFLOWS - BATCH OPERATIONS - REPORT TEMPLATES +- EXPORTS diff --git a/reference/rest-api.json b/reference/rest-api.json index 0a670091..acf34357 100644 --- a/reference/rest-api.json +++ b/reference/rest-api.json @@ -4526,6 +4526,279 @@ } } }, + "ExportQuery": { + "type": "object", + "description": "Either `form_id` (export an app) or both `name` and `sql` (export the results of a SQL query).", + "properties": { + "form_id": { + "type": "string", + "description": "The ID of the app (form) to export. The app must exist and you must have access to it.", + "format": "uuid" + }, + "name": { + "type": "string", + "description": "A name for the query. Required when `sql` is provided." + }, + "sql": { + "type": "string", + "description": "A SQL query to export the results of. When provided, `name` is required." + } + } + }, + "ExportOptions": { + "type": "object", + "properties": { + "media_format": { + "type": "string", + "description": "How media columns are written in the export.", + "enum": [ + "url", + "id", + "name" + ] + }, + "record_link_format": { + "type": "string", + "description": "How record link columns are written in the export.", + "enum": [ + "title", + "id" + ] + }, + "media_file_name_format": { + "type": "string", + "description": "The file name pattern for exported media.", + "example": "{title}" + }, + "include_photos": { + "type": "boolean", + "description": "Include photos" + }, + "include_videos": { + "type": "boolean", + "description": "Include videos" + }, + "include_audio": { + "type": "boolean", + "description": "Include audio" + }, + "include_signatures": { + "type": "boolean", + "description": "Include signatures" + }, + "include_attachments": { + "type": "boolean", + "description": "Include attachments" + }, + "include_sketches": { + "type": "boolean", + "description": "Include sketches" + }, + "include_reports": { + "type": "boolean", + "description": "Include PDF reports. When true and `report_template_id` is omitted, the app's default report template is used." + }, + "report_template_id": { + "type": "string", + "description": "The report template to use when `include_reports` is true", + "format": "uuid" + }, + "use_labels_as_headers": { + "type": "boolean", + "description": "Use field labels instead of data names as column headers (`csv` and `xlsx` only)" + } + } + }, + "ExportRequest": { + "type": "object", + "required": [ + "export" + ], + "properties": { + "export": { + "type": "object", + "required": [ + "queries", + "format" + ], + "properties": { + "queries": { + "type": "array", + "minItems": 1, + "description": "What to export. Must contain at least one query.", + "items": { + "$ref": "#/components/schemas/ExportQuery" + } + }, + "format": { + "type": "string", + "description": "The export format. Use `none` for a media-only export, which requires at least one `include_*` media option.", + "enum": [ + "csv", + "xlsx", + "shp", + "kml", + "geojson", + "json", + "sqlite", + "spatialite", + "geopackage", + "gdb", + "postgres", + "none" + ], + "example": "csv" + }, + "options": { + "$ref": "#/components/schemas/ExportOptions" + } + } + } + } + }, + "Export": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for the export", + "format": "uuid" + }, + "state": { + "type": "string", + "description": "The processing state of the export.", + "enum": [ + "pending", + "queued", + "running", + "completed", + "failed" + ], + "example": "running" + }, + "format": { + "type": "string", + "description": "The export format.", + "example": "csv" + }, + "queries": { + "type": "array", + "description": "The queries the export was created with.", + "items": { + "$ref": "#/components/schemas/ExportQuery" + } + }, + "options": { + "$ref": "#/components/schemas/ExportOptions" + }, + "file": { + "type": [ + "string", + "null" + ], + "description": "A download URL for the exported file. `null` until the export is completed. Exports expire 7 days after they are created, so download the file promptly." + }, + "file_size": { + "type": [ + "integer", + "null" + ], + "description": "The size of the exported file in bytes" + }, + "error": { + "type": [ + "string", + "null" + ], + "description": "The error, if the export failed" + }, + "message": { + "type": [ + "string", + "null" + ], + "description": "A status message for the export" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the export completed" + }, + "failed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time", + "description": "When the export failed" + }, + "created_at": { + "type": "string", + "description": "When the export was created", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "description": "When the export was last updated", + "format": "date-time" + }, + "created_by": { + "type": "string", + "description": "The name of the user who created the export" + }, + "created_by_id": { + "type": "string", + "description": "The ID of the user who created the export", + "format": "uuid" + }, + "updated_by": { + "type": "string", + "description": "The name of the user who last updated the export" + }, + "updated_by_id": { + "type": "string", + "description": "The ID of the user who last updated the export", + "format": "uuid" + } + } + }, + "ExportResponse": { + "type": "object", + "required": [ + "export" + ], + "properties": { + "export": { + "$ref": "#/components/schemas/Export" + } + } + }, + "ExportListResponse": { + "type": "object", + "properties": { + "exports": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Export" + } + }, + "current_page": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + }, + "total_count": { + "type": "integer" + }, + "per_page": { + "type": "integer" + } + } + }, "UnauthorizedResponse": { "type": "object", "description": "Unauthorized error response", @@ -14282,6 +14555,249 @@ }, "deprecated": false } + }, + "/v2/exports": { + "get": { + "summary": "List exports", + "description": "List the data exports you have created. Exports expire 7 days after they are created, and expired exports are not returned.", + "operationId": "exports-list", + "parameters": [ + { + "name": "newest_first", + "in": "query", + "description": "If present, exports are sorted newest first.", + "schema": { + "type": "boolean" + } + }, + { + "name": "page", + "in": "query", + "description": "The page number requested.", + "schema": { + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "description": "The number of items to return per page (maximum 20).", + "schema": { + "type": "integer", + "format": "int32", + "default": 20, + "maximum": 20 + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportListResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + } + }, + "deprecated": false + }, + "post": { + "summary": "Create an export", + "description": "Trigger a data export. The export is processed asynchronously: the response returns immediately, so poll Get an export until `state` is `completed` (or `failed`), then download the file from the `file` URL.", + "operationId": "exports-create", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportRequest" + }, + "examples": { + "form": { + "summary": "Export an app as CSV", + "value": { + "export": { + "queries": [ + { + "form_id": "YOUR-FORM-ID" + } + ], + "format": "csv", + "options": { + "use_labels_as_headers": false + } + } + } + }, + "sql": { + "summary": "Export the results of a SQL query as CSV", + "value": { + "export": { + "queries": [ + { + "name": "Open work orders", + "sql": "SELECT * FROM \"Work Orders\" WHERE status = 'open'" + } + ], + "format": "csv" + } + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Export created and queued for processing", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportResponse" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + } + }, + "deprecated": false + } + }, + "/v2/exports/{export_id}": { + "get": { + "summary": "Get an export", + "description": "Retrieve an export. When `state` is `completed`, the `file` attribute contains a download URL for the exported file.", + "operationId": "exports-get", + "parameters": [ + { + "name": "export_id", + "in": "path", + "description": "The unique identifier of the export.", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundResponse" + } + } + } + } + }, + "deprecated": false + }, + "delete": { + "summary": "Cancel an export", + "description": "Cancel a running export. The export is marked as `failed` and returned in the response.", + "operationId": "exports-delete", + "parameters": [ + { + "name": "export_id", + "in": "path", + "description": "The unique identifier of the export.", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "responses": { + "200": { + "description": "Export cancelled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExportResponse" + } + } + } + }, + "401": { + "description": "Unauthorized", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnauthorizedResponse" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NotFoundResponse" + } + } + } + } + }, + "deprecated": false + } } }, "x-readme": {