From 2fbac37fd54f119837281324b4d51f460e0015fc Mon Sep 17 00:00:00 2001 From: Mike Meesseman <31196959+mmeesseman@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:33:12 -0400 Subject: [PATCH 01/11] Add example for enforcing daily sync with LOADRECORDS This document provides an example of enforcing a daily sync requirement for mobile users using LOADRECORDS. It outlines the setup and code needed to ensure users sync their devices before collecting data. --- .../enforce-daily-sync-with-loadrecords.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/DATA EVENTS/data-events-examples/enforce-daily-sync-with-loadrecords.md diff --git a/docs/DATA EVENTS/data-events-examples/enforce-daily-sync-with-loadrecords.md b/docs/DATA EVENTS/data-events-examples/enforce-daily-sync-with-loadrecords.md new file mode 100644 index 00000000..e5c0bc98 --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/enforce-daily-sync-with-loadrecords.md @@ -0,0 +1,140 @@ +--- +title: Enforce a daily sync using LOADRECORDS +excerpt: >- + This example shows how to enforce a daily sync requirement for mobile users. + When a record is opened, it checks a "heartbeat" Task record to confirm the + device has synced today. If not, it hides all fields and blocks saving until + the user syncs Fulcrum. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Enforce a daily sync using LOADRECORDS + +This Data Event uses a recurring Fulcrum **Task** as a daily "heartbeat." When a user opens a record, it queries the latest Task via `LOADRECORDS` and compares its due date to today's date. If they don't match — meaning the device hasn't synced today — all fields are hidden and the record cannot be saved until the user syncs. + +This pattern is useful for organizations that need to ensure field workers are online and up to date with any form changes, permission updates, or removals before they can collect data. + +## Setup + +Before adding the Data Event, create a recurring daily Task in your Fulcrum org: + +1. Navigate to **Tasks** in your Fulcrum org. +2. Create a new Task with today's date as the due date. +3. Set it to recur **daily**. +4. Note the **Form ID** of the Tasks form (found in the URL when viewing the form) — you will need it in the code below. +5. Note the **field key** for the `due_date` field in the Tasks form. You can find field keys in the form builder or via the API. + +> **Note:** Any user with a custom role in Fulcrum may need **ITA Access** enabled for their role in order for `LOADRECORDS` to return Task records. Check with your Fulcrum admin. + +## Data Event Code + +```js +// Initialize persistent storage to cache the last known sync date +let storage = STORAGE(); +const TASK_DATE_KEY = 'latest_task_date'; + +/** + * Returns today's date as a YYYY-MM-DD string, + * matching the format stored in Fulcrum Task due_date fields. + */ +function getTodayString() { + let today = new Date(); + let yyyy = today.getFullYear(); + let mm = String(today.getMonth() + 1).padStart(2, '0'); + let dd = String(today.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +/** + * Queries the most recent Task record and checks whether + * its due date matches today. If not, locks down the form. + */ +function checkSyncStatus() { + LOADRECORDS({ + // Replace this form_id with the Form ID of your Fulcrum Tasks form + form_id: 'YOUR-TASKS-FORM-ID-HERE', + order: [['due_date', 'desc']], + limit: 1 + }, (err, records) => { + + // 1. Handle permission errors first (e.g. user lacks ITA access) + if (err) { + ALERT('Access Denied', 'Please contact your Admin for appropriate permissions to continue.'); + + // Store an error flag so validate-record always blocks saving + storage.setItem(TASK_DATE_KEY, 'permission_error'); + + // Hide all fields to lock down the form visually + DATANAMES().forEach(function(dataName) { + SETHIDDEN(dataName, true); + }); + + return; // Stop further execution + } + + // 2. No error — compare the latest Task due date to today + let taskRecords = records.records; + + if (taskRecords && taskRecords.length > 0) { + let latestTask = taskRecords[0]; + + // Replace 'YOUR-DUE-DATE-FIELD-KEY' with the actual field key + // for the due_date field in your Tasks form (e.g. '0ed3') + let taskDate = latestTask?.form_values?.['YOUR-DUE-DATE-FIELD-KEY']; + + // Cache the task date for use in validate-record + storage.setItem(TASK_DATE_KEY, taskDate); + + let todayStr = getTodayString(); + + if (taskDate === todayStr) { + // Device has synced today — allow the user to proceed + ALERT('All clear', 'Your data is up to date. You may proceed.'); + } else { + // Task date doesn't match today — device needs to sync + ALERT( + 'Sync Required', + `Please close Fulcrum and sync your device before continuing. ` + + `Last sync date: ${taskDate}. Today: ${todayStr}.` + ); + + // Hide all fields to prevent data entry until synced + DATANAMES().forEach(function(dataName) { + SETHIDDEN(dataName, true); + }); + } + } + }); +} + +// Run the sync check every time a record is opened +ON('load-record', checkSyncStatus); + +// Block saving if the cached date doesn't match today +// (handles the case where the app is left open overnight) +ON('validate-record', function(event) { + let storedDate = storage.getItem(TASK_DATE_KEY); + let todayStr = getTodayString(); + + if (storedDate !== todayStr) { + INVALID('You must discard edits and sync Fulcrum (or request Admin permissions) prior to continuing.'); + } +}); +``` + +## How it works + +When a record is opened (`load-record`), `checkSyncStatus` queries the latest Task in your org, sorted by due date descending. The most recent Task's due date acts as a heartbeat — it represents the last date the Task was synced to the device. + +- If the device has synced today, the due date will match today's date and the user is shown a green light. +- If the device hasn't synced, the due date will be an older date. All fields are hidden and the user is prompted to sync. +- If the LOADRECORDS call fails (e.g. due to a permissions error), the form is also locked down as a precaution. + +The `validate-record` handler provides a second line of defense: even if the user had the form open before midnight, they cannot save a record after a new day begins without first syncing. From eb523ee04842d9dbffcd78a7bbe61b94786a7fdc Mon Sep 17 00:00:00 2001 From: Mike Meesseman <31196959+mmeesseman@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:35:47 -0400 Subject: [PATCH 02/11] Add example for preventing duplicate repeatables This document provides an example of how to prevent users from creating more repeatable entries than allowed based on a choice field value. It includes code snippets demonstrating the use of event listeners and validation. --- .../prevent-creating-duplicate-repeatables.md | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 docs/DATA EVENTS/data-events-examples/prevent-creating-duplicate-repeatables.md diff --git a/docs/DATA EVENTS/data-events-examples/prevent-creating-duplicate-repeatables.md b/docs/DATA EVENTS/data-events-examples/prevent-creating-duplicate-repeatables.md new file mode 100644 index 00000000..7c8d871d --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/prevent-creating-duplicate-repeatables.md @@ -0,0 +1,101 @@ +--- +title: Prevent creating duplicate repeatables +excerpt: >- + This example shows how to prevent a user from creating more repeatable + entries than intended. It compares the current repeatable number against a + choice field value and blocks saving if the user tries to exceed it. It also + demonstrates the OFF() function to clean up event listeners when a repeatable + is unloaded. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Prevent creating duplicate repeatables + +In some workflows, you may want to limit how many entries a user can create in a repeatable section based on a choice they've made in the parent record. For example, if a user selects "Visit 1" in a choice field, they should only be able to create one repeatable entry — not two. + +This Data Event uses `REPEATABLENUMBER()`, `CHOICEVALUE()`, and the `OFF()` function to enforce that limit and properly clean up event listeners when the repeatable is closed. + +## Data Event Code + +```js +/** + * Validation callback used to block saving an over-limit repeatable. + * Stored as a named function so it can be detached with OFF() later. + */ +const invalidateFunction = () => { + INVALID('This repeatable cannot be saved. It exceeds the allowed number of entries.'); +}; + +/** + * When a new repeatable entry is created, check whether the entry number + * exceeds the value selected in the parent record's "visit type" choice field. + * + * Replace 'inspection' with your repeatable section's data name. + * Replace $visit_type with the data name of the choice field that + * controls the maximum number of entries allowed. + */ +ON('new-repeatable', 'inspection', () => { + + // REPEATABLENUMBER() returns the 1-based index of the current entry. + // CHOICEVALUE($visit_type) returns the selected choice value as a string (e.g. "1", "2"). + // If the entry number exceeds the allowed count, block it. + if (REPEATABLENUMBER() > parseInt(CHOICEVALUE($visit_type))) { + ALERT( + 'Too many entries', + 'You cannot add more entries than your current visit type allows.' + ); + + // Make all fields in this repeatable read-only to prevent data entry + FIELDNAMES('inspection').forEach((field) => { + SETREADONLY(field, true); + }); + + // Attach a validation listener to block saving this repeatable + ON('validate-repeatable', 'inspection', invalidateFunction); + } +}); + +/** + * When a repeatable entry is unloaded (user navigates away or closes it), + * reset any read-only state and remove the validation listener. + * + * This prevents the block from carrying over to other valid entries + * in the same session. + */ +ON('unload-repeatable', 'inspection', () => { + + // Reset all fields in the repeatable back to editable + FIELDNAMES('inspection').forEach((field) => { + SETREADONLY(field, false); + }); + + // Detach the validation listener using OFF() to avoid memory leaks + // and unintended blocking of future valid entries + OFF('validate-repeatable', 'inspection', invalidateFunction); +}); +``` + +## Key concepts + +**`REPEATABLENUMBER()`** returns the 1-based position of the current repeatable entry being viewed or edited. If this is the 3rd entry, it returns `3`. + +**`CHOICEVALUE($field_name)`** returns the currently selected choice value as a string. Use `parseInt()` to compare it numerically. + +**`ON('new-repeatable', ...)`** fires when a user taps the "+" button to create a new repeatable entry — before any data has been entered. + +**`ON('validate-repeatable', ...)`** fires when the user tries to save the repeatable. Calling `INVALID()` inside this handler prevents it from being saved. + +**`OFF('validate-repeatable', ...)`** removes a previously attached event listener. Passing the named function reference (rather than an anonymous function) is required for `OFF()` to work correctly. This is essential for cleanup — without it, the validation block would carry over to other entries in the same session. + +## Customization + +- Replace `'inspection'` with the **data name** of your repeatable section. +- Replace `$visit_type` with the **data name** of the choice field in the parent record that determines the maximum number of entries. +- The choice field values should be numeric strings like `"1"`, `"2"`, `"3"` for this comparison to work. From 98753930137b12732138355c17f8464d70d324bc Mon Sep 17 00:00:00 2001 From: Mike Meesseman <31196959+mmeesseman@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:38:51 -0400 Subject: [PATCH 03/11] Add documentation for displaying field value labels Added documentation for helper functions to display human-readable field values in PDF reports, including usage examples for top-level fields and repeatable sections. --- .../display-field-value-labels.md | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/REPORT BUILDER/reports-examples/display-field-value-labels.md diff --git a/docs/REPORT BUILDER/reports-examples/display-field-value-labels.md b/docs/REPORT BUILDER/reports-examples/display-field-value-labels.md new file mode 100644 index 00000000..c55af368 --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/display-field-value-labels.md @@ -0,0 +1,112 @@ +--- +title: Display field value labels +excerpt: >- + Helper functions for displaying human-readable display values (labels) instead + of raw stored values in PDF reports. Includes support for top-level fields, + repeatables, ChoiceFields, AddressFields, and SignatureFields. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Display field value labels in PDF reports + +By default, Fulcrum stores and exposes the raw **stored value** of a field — for choice fields this is often a short code, and for complex fields like addresses and signatures it's a structured object. When building PDF reports, you typically want to display the human-readable **display value** (label) instead. + +These two helper functions make it easy to render display values for any field type, including inside repeatable sections. + +## Helper functions + +Add these functions to the top of your report's JavaScript section (the ` + + + + + +``` + +## 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 ` + + + + + + +

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/DATA EVENTS/data-events-examples/cross-app-push-notifications.md b/docs/DATA EVENTS/data-events-examples/cross-app-push-notifications.md new file mode 100644 index 00000000..7f5b3797 --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/cross-app-push-notifications.md @@ -0,0 +1,144 @@ +--- +title: Cross-app push notifications with LOADRECORDS +excerpt: >- + This example shows how to deliver in-app "push notifications" to Fulcrum + users by combining a dedicated notifications app with LOADRECORDS and + STORAGE. When a record is opened, the data event checks for new messages + from the notifications app and displays them via CONFIRM, with support for + role targeting and expiration dates. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Cross-app push notifications with LOADRECORDS + +Fulcrum doesn't have a native push notification system, but you can simulate one using a dedicated notifications app and `LOADRECORDS`. When a field worker opens a record in any app that includes this Data Event, it checks the notifications app for new messages. If a new, un-seen message exists that targets the user's role and hasn't expired, it is displayed as a `CONFIRM` dialog. + +`STORAGE` is used to track the timestamp of the last message the user dismissed, so they only see each notification once. + +## Setup + +### 1. Create the notifications app + +Build a Fulcrum app with the following fields: + +| Field label | Data name / key | Type | Purpose | +|---|---|---|---| +| Title | *(note its field key)* | Text | Notification headline | +| Message | *(note its field key)* | Text | Notification body text | +| Allowed Roles | *(note its field key)* | Classification / Choice | Which roles should see the notification (leave blank for everyone) | +| Expiration Date | *(note its field key)* | Date | Optional date after which the notification stops showing | + +Note the **Form ID** for the notifications app and the **field key** for each of the four fields above. + +### 2. Add the Data Event to your collection apps + +Paste the code below into any app where you want notifications to appear. Update the configuration section with your form ID and field keys. + +### 3. Create a notification record + +Add a record to the notifications app with a title, message, and optionally an expiration date. The next time a user opens a record in the configured app, the notification will appear. + +## Data Event Code + +```js +// ─── Configuration ─────────────────────────────────────────────────────────── + +// Form ID of the notifications app (find in the URL when viewing the form) +const NOTIFICATIONS_FORM_ID = 'YOUR-NOTIFICATIONS-FORM-ID-HERE'; + +// Field keys from the notifications app (found in the form builder or via API) +const FIELD_KEYS = { + title: 'YOUR-TITLE-FIELD-KEY', // Text field: notification headline + message: 'YOUR-MESSAGE-FIELD-KEY', // Text field: notification body + allowedRoles: 'YOUR-ROLES-FIELD-KEY', // Choice field: roles to target (blank = all) + expirationDate: 'YOUR-EXPIRATION-DATE-FIELD-KEY' // Date field: when to stop showing (blank = never) +}; + +// Storage key used to track the last notification the user dismissed +const STORAGE_KEY = 'lastReceivedMessage'; + +// ─── Main Logic ────────────────────────────────────────────────────────────── + +let storage = STORAGE(); + +// Initialize the "last seen" timestamp in storage if this is the first run +if (!storage.getItem(STORAGE_KEY)) { + storage.setItem(STORAGE_KEY, 0); +} + +ON('load-record', () => { + // Fetch all records from the notifications app + LOADRECORDS({ form_id: NOTIFICATIONS_FORM_ID }, (err, result) => { + if (err) { + console.log(INSPECT(err)); + return; + } + + const notificationRecords = result.records; + + if (!notificationRecords || notificationRecords.length === 0) return; + + notificationRecords.forEach((rec) => { + // Convert the notification's last-updated timestamp to milliseconds + const messageTimestamp = new Date(rec.updated_at).getTime(); + + // Get the roles this notification is targeted to (null = everyone) + const allowedRoles = rec.form_values?.[FIELD_KEYS.allowedRoles] + ? CHOICEVALUES(rec.form_values[FIELD_KEYS.allowedRoles]) + : null; + + // Get the expiration date, if set + const expirationDate = rec.form_values?.[FIELD_KEYS.expirationDate] + ? new Date(rec.form_values[FIELD_KEYS.expirationDate]) + : null; + + const lastSeen = storage.getItem(STORAGE_KEY); + + // Only show the notification if it is newer than the last one this user saw + if (lastSeen >= messageTimestamp) return; + + // Check if this notification is targeted to the current user's role + const isTargeted = allowedRoles === null || allowedRoles.includes(ROLE()); + if (!isTargeted) return; + + // Check if the notification has expired + const now = new Date().getTime(); + const isExpired = expirationDate !== null && now > expirationDate; + if (isExpired) return; + + // Display the notification and mark it as seen when the user dismisses it + CONFIRM( + rec.form_values[FIELD_KEYS.title], // Dialog title + rec.form_values[FIELD_KEYS.message], // Dialog body + function () { + // Save the timestamp of this notification so it won't show again + storage.setItem(STORAGE_KEY, new Date(rec.updated_at).getTime()); + } + ); + }); + }); +}); +``` + +## How it works + +1. When a record is opened, `LOADRECORDS` fetches all records from the notifications app. +2. For each notification record, the event checks three conditions: + - **Is it new?** The notification's `updated_at` timestamp is compared to the last timestamp stored in `STORAGE`. If it's older than or equal to what the user last dismissed, it is skipped. + - **Is it targeted to this user?** If the Allowed Roles field has a value, the current user's `ROLE()` must appear in the list. A blank roles field means everyone sees it. + - **Has it expired?** If an Expiration Date is set and today is past that date, the notification is skipped. +3. Notifications that pass all three checks are displayed using `CONFIRM`. When the user dismisses the dialog, the timestamp is written to `STORAGE` so the notification won't appear again. + +## Notes + +- Multiple notifications can be active at the same time. Each is evaluated independently. +- Because `STORAGE` is per-device, a user who switches devices will see the notification again on the new device. +- To "resend" a notification to users who have already dismissed it, simply update the notification record in the notifications app — its `updated_at` timestamp will advance past what's stored in `STORAGE`. +- This approach works offline: if the device has previously synced the notifications app, `LOADRECORDS` can return cached records even without a network connection. diff --git a/docs/DATA EVENTS/data-events-examples/detect-fake-gps-spoofing.md b/docs/DATA EVENTS/data-events-examples/detect-fake-gps-spoofing.md new file mode 100644 index 00000000..2ac73bd0 --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/detect-fake-gps-spoofing.md @@ -0,0 +1,209 @@ +--- +title: Detect fake GPS spoofing +excerpt: >- + This data event detects when a crew member is using a "Fake GPS" app to + spoof their location. It samples multiple GPS readings over time, looks for + suspicious patterns (e.g. a location that never moves), and prevents users + from manually setting their geometry. Only validated GPS coordinates from the + device are accepted. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Detect fake GPS spoofing + +This Data Event blocks users from manually placing their record pin on the map and uses statistical analysis of multiple GPS samples to detect whether the device's reported location is real or spoofed by a third-party app. + +On mobile devices, GPS coordinates are sampled every 500ms over 10 iterations. Because real GPS signals drift slightly, genuine locations will produce a set of unique coordinates. Fake GPS apps typically broadcast a perfectly static, repeating coordinate — and this pattern is used to identify spoofing. Once a real location is confirmed, it is locked in and any manual map edits are overridden. + +## Configuration + +Adjust the settings at the top of the script to match your requirements: + +| Variable | Default | Description | +|---|---|---| +| `gpsLocationNecessaryToSave` | `true` | If `true`, the record cannot be saved without a valid GPS fix | +| `updateLocationOnEditRecord` | `true` | If `true`, the GPS check also runs when editing existing records | +| `continueToUpdateLocation` | `true` | If `false`, only one valid location is captured per session | +| `mobileOnlySaveRoles` | `['Standard User']` | Roles that can only save records from the mobile app, not desktop | + +## Data Event Code + +```js +let previousLocation = undefined; +let runCount = 0; +let locationsCollected = []; +let lastKnownRealLocation = null; + +// ─── Configuration ─────────────────────────────────────────────────────────── + +// If true, the user cannot save the record without a validated GPS location +const gpsLocationNecessaryToSave = true; + +// If true, also checks GPS location when editing an existing record +const updateLocationOnEditRecord = true; + +// If false, stops updating once a valid location is found for this session +const continueToUpdateLocation = true; + +// Roles that may only save records from the mobile app (not the web browser) +const mobileOnlySaveRoles = ['Standard User']; + +// ─── Initialization ────────────────────────────────────────────────────────── + +ON('new-record', () => { + // Clear any pre-existing geometry on new records so the user can't inherit a location + SETGEOMETRY(null); +}); + +ON('edit-record', () => { + // When editing, preserve the existing geometry as the last known real location + if (GEOMETRY()) lastKnownRealLocation = GEOMETRY(); +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** + * Compares two location objects by lat/lng. + */ +const locationsAreEqual = (loc1, loc2) => + loc1?.latitude === loc2?.latitude && loc1?.longitude === loc2?.longitude; + +/** + * Filters out coordinates that appear more than once in the collected samples. + * Real GPS locations drift slightly, so duplicate coordinates indicate spoofing. + */ +function removeDuplicateCoords(arr) { + const coordCount = {}; + + // Count how many times each (lat, lng) pair appears + arr.forEach(({ latitude, longitude }) => { + const key = `${latitude},${longitude}`; + coordCount[key] = (coordCount[key] || 0) + 1; + }); + + // Keep only unique coordinates (i.e., not duplicated — genuine GPS drift) + return arr.filter(({ latitude, longitude }) => { + const key = `${latitude},${longitude}`; + return coordCount[key] === 1; + }); +} + +// ─── GPS Sampling Loop ─────────────────────────────────────────────────────── + +/** + * Called every 500ms to collect GPS samples. + * After 10 samples, analyzes the set: + * - Requires at least 3 unique coordinates (rules out static/fake GPS) + * - Picks the most accurate reading and sets it as the record geometry + */ +const checkForLocationServices = () => { + // Skip if no GPS signal, or if we already have a confirmed location and shouldn't update further + if (!CURRENTLOCATION() || (lastKnownRealLocation && !continueToUpdateLocation)) return; + + runCount++; + + // Capture the baseline on the first run + if (runCount === 1 && previousLocation === undefined) { + previousLocation = CURRENTLOCATION(); + runCount = 0; + return; + } + + // Collect location if it differs from the previous sample + if (!locationsAreEqual(CURRENTLOCATION(), previousLocation)) { + const alreadyCollected = locationsCollected.find(obj => + locationsAreEqual(obj, CURRENTLOCATION()) + ); + if (!alreadyCollected) locationsCollected.push(CURRENTLOCATION()); + } + + // After 10 samples, evaluate whether the device location is genuine + if (runCount === 10) { + if (locationsCollected.length >= 3) { + // Filter out any repeated/static coordinates + locationsCollected = removeDuplicateCoords(locationsCollected); + + // Select the sample with the best accuracy (lowest accuracy value = more precise) + let mostAccurateLocation = locationsCollected[0]; + locationsCollected.forEach((location, i) => { + if (i !== 0 && location.accuracy < mostAccurateLocation.accuracy) { + mostAccurateLocation = location; + } + }); + + // Lock in the best confirmed GPS location as the record geometry + lastKnownRealLocation = { + type: 'Point', + coordinates: [mostAccurateLocation.longitude, mostAccurateLocation.latitude] + }; + + SETGEOMETRY(lastKnownRealLocation); + } + + // Reset counters for the next sampling round + runCount = 0; + locationsCollected = []; + } + + previousLocation = CURRENTLOCATION(); +}; + +// ─── Start GPS Sampling ────────────────────────────────────────────────────── + +// Determine whether to run on new records only, or also on edits +const locationCheckEventType = updateLocationOnEditRecord ? 'load-record' : 'new-record'; + +ON(locationCheckEventType, () => { + // Only run GPS sampling on mobile devices (desktop GPS is not reliable) + if (!ISMOBILE()) return; + + SETINTERVAL(() => { + checkForLocationServices(); + }, 500); +}); + +// ─── Block Manual Map Edits ────────────────────────────────────────────────── + +ON('change-geometry', () => { + // Override any manual pin drop with the validated GPS location + if (lastKnownRealLocation) { + SETGEOMETRY(lastKnownRealLocation); + } else { + SETGEOMETRY(null); + } +}); + +// ─── Validation ────────────────────────────────────────────────────────────── + +ON('validate-record', () => { + // Require a confirmed GPS location before saving (mobile only) + if (ISMOBILE() && gpsLocationNecessaryToSave) { + if (lastKnownRealLocation) { + SETGEOMETRY(lastKnownRealLocation); + } else { + INVALID('Please enable GPS on your device and wait a few seconds before saving the record.'); + } + } + + // Block desktop saves for roles that should only use the mobile app + if (!ISMOBILE() && mobileOnlySaveRoles.length && ISROLE(mobileOnlySaveRoles)) { + INVALID('Records for this role can only be saved in the Fulcrum mobile app.'); + } +}); +``` + +## How it works + +1. **GPS Sampling** — Once the record loads on a mobile device, the event polls `CURRENTLOCATION()` every 500ms. +2. **Collecting unique readings** — After each sample, it checks whether the coordinate differs from the last reading. Real GPS signals naturally drift, producing a set of slightly different coordinates. +3. **Spoofing detection** — After 10 samples, the collected coordinates are inspected. Fake GPS apps broadcast a perfectly static location, so the duplicate-filtering function (`removeDuplicateCoords`) will leave fewer than 3 usable points — causing the validation to fail. +4. **Best location selection** — If at least 3 unique coordinates are found, the one with the best reported accuracy is selected as the confirmed location and written via `SETGEOMETRY`. +5. **Override map edits** — The `change-geometry` handler immediately overrides any manual pin drops with the validated GPS coordinate (or clears it if none is confirmed yet). +6. **Validation gate** — The `validate-record` handler enforces the GPS requirement and optionally blocks desktop saves for configured roles. diff --git a/docs/DATA EVENTS/data-events-examples/geofencing-with-loadrecords-and-geometry.md b/docs/DATA EVENTS/data-events-examples/geofencing-with-loadrecords-and-geometry.md new file mode 100644 index 00000000..880dc82d --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/geofencing-with-loadrecords-and-geometry.md @@ -0,0 +1,143 @@ +--- +title: Geofencing with LOADRECORDS and GEOMETRY +excerpt: >- + This data event hides all form fields until a user drops their GPS pin inside + a valid polygon boundary loaded from a separate Fulcrum app. When a match is + found, fields are revealed and the user is alerted with the name of the area + they have entered. Useful for location-based access control and site + check-ins. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Geofencing with LOADRECORDS and GEOMETRY + +This Data Event restricts data entry to specific geographic areas by checking whether the user's GPS pin falls within any polygon stored in a separate Fulcrum "boundary layer" app. Until the user's location is confirmed to be inside a valid polygon, all form fields remain hidden and the record cannot be filled in. + +When a matching boundary is found, the fields are revealed and the user sees an alert identifying which area they are in. + +**Example use case:** An inspector must physically be at a port site before they can begin an inspection. The port boundaries are stored as polygon records in a separate app. The data event checks their GPS location against those polygons and only opens the form when they arrive on site. + +## Setup + +1. Create a Fulcrum **boundary layer app** with polygon geometry enabled. + - Add a text field for the area name (e.g. `area_name`). Note its **field key**. + - Create polygon records for each valid location. + - Note the **App ID** (found in the URL when viewing the app in the web editor). +2. In your primary data collection app, add this Data Event. +3. Replace `BUFFER_APP_ID` and `NAME_FIELD_KEY` with your values. + +## Data Event Code + +```js +// ─── Configuration ─────────────────────────────────────────────────────────── + +// App ID of the app containing your boundary polygons +// Find this in the URL when editing the app: /apps/YOUR-APP-ID/edit +var BUFFER_APP_ID = 'YOUR-BOUNDARY-APP-ID-HERE'; + +// Field key of the text field in the boundary app that holds the area name +// Used in the alert message when the user enters a boundary +var NAME_FIELD_KEY = 'your_area_name_field_key'; + +// ─── Core Function ─────────────────────────────────────────────────────────── + +/** + * Loads all boundary polygon records and checks whether the current + * GPS pin falls within any of them. If inside a polygon, reveals all + * fields and alerts the user. If not inside any polygon, hides all fields. + */ +var updateFormWithBufferInfo = function () { + // If no location has been set yet, keep everything hidden + if (isNaN(parseFloat(LONGITUDE())) || isNaN(parseFloat(LATITUDE()))) { + DATANAMES().forEach(function (element) { + SETHIDDEN(element, true); + }); + return; + } + + // Build a GeoJSON Point from the current GPS coordinates + var point = GEOMETRYPOINT([parseFloat(LONGITUDE()), parseFloat(LATITUDE())]); + + // Load all polygon records from the boundary app + LOADRECORDS({ + form_id: BUFFER_APP_ID, + include_geometry: true + }, function (error, result) { + if (error) { + ALERT('Error loading boundary records: ' + INSPECT(error)); + // Keep fields hidden if there's an error + DATANAMES().forEach(function (element) { + SETHIDDEN(element, true); + }); + return; + } + + var records = result.records || []; + var matchedRecord = null; + + // Check each polygon record to see if the current point falls inside it + records.forEach(function (record) { + if (record && record.geometry && GEOMETRYWITHIN(point, record.geometry)) { + matchedRecord = record; + } + }); + + if (!matchedRecord) { + // Point is not inside any polygon — hide all fields + DATANAMES().forEach(function (element) { + SETHIDDEN(element, true); + }); + ALERT('This location is not within a defined boundary area. Please move to a valid location before collecting data.'); + return; + } + + // Point is inside a polygon — reveal all fields + var fieldValues = matchedRecord.form_values || {}; + var areaName = fieldValues[NAME_FIELD_KEY] || 'Unknown Area'; + + DATANAMES().forEach(function (element) { + SETHIDDEN(element, false); + }); + + ALERT('You are within: ' + areaName + '. You may now collect data.'); + }); +}; + +// ─── Event Hooks ───────────────────────────────────────────────────────────── + +// On initial load, hide all fields and prompt the user to set their location +ON('load-record', function () { + DATANAMES().forEach(function (element) { + SETHIDDEN(element, true); + }); + ALERT('Please use "Locate +" to confirm your GPS location before collecting data.'); +}); + +// Re-run the boundary check whenever the user moves or places the map pin +ON('change-geometry', updateFormWithBufferInfo); + +// Also run the check when an existing record is opened for editing +ON('edit-record', updateFormWithBufferInfo); +``` + +## How it works + +1. **On load** — All fields are hidden and the user is prompted to verify their GPS location. +2. **On geometry change** — Whenever the user places or moves their pin, `updateFormWithBufferInfo` fires. +3. **Boundary check** — `LOADRECORDS` fetches all polygon records from the boundary app. The current GPS point is tested against each polygon using `GEOMETRYWITHIN` (a Turf.js function built into Fulcrum Data Events). +4. **Match found** — If the point falls inside a polygon, all fields are revealed and the user sees the name of the area. +5. **No match** — If the point is outside all polygons, fields remain hidden and the user is told to move to a valid location. + +## Notes + +- If users need to access this form offline, make sure the boundary app is synced to their device before going into the field. +- `GEOMETRYWITHIN` works with polygon and multipolygon geometry types. +- For large boundary datasets, consider loading a subset of records using a bounding box filter, or splitting the boundary app into regional sub-apps. +- To add more context when a match is found (e.g. auto-populate fields from the matched polygon), read additional `matchedRecord.form_values` keys and use `SETVALUE` to fill in form fields. diff --git a/docs/DATA EVENTS/data-events-examples/import-npm-packages-into-data-events.md b/docs/DATA EVENTS/data-events-examples/import-npm-packages-into-data-events.md new file mode 100644 index 00000000..f6459c25 --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/import-npm-packages-into-data-events.md @@ -0,0 +1,109 @@ +--- +title: Bundle npm packages for use in Data Events and Report Builder +excerpt: >- + Any npm package can be bundled into a single minified JavaScript file using + esbuild and then pasted directly into a Fulcrum Data Event or Report Builder + EJS template — including on the server-side EJS rendering layer. This tip + shows the full workflow. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Bundle npm packages for use in Data Events and Report Builder + +Fulcrum Data Events and Report Builder templates run in a sandboxed JavaScript environment that doesn't support `require()` or `import` statements. However, you can use [esbuild](https://esbuild.github.io/) to bundle any npm package into a single self-contained script and paste it directly into your Data Event or EJS template — including the server-side rendering context. + +This is useful for adding libraries like Base64 encoding, date formatting (e.g. Luxon, Day.js), UUID generation, or any other utility that isn't available in the Fulcrum runtime by default. + +## Prerequisites + +- [Node.js](https://nodejs.org/) installed on your machine +- [esbuild](https://esbuild.github.io/) installed globally or per-project (`npm install -D esbuild`) + +## Steps + +### 1. Install the npm package locally + +```bash +npm install +``` + +For example, to use the `js-base64` library: + +```bash +npm install js-base64 +``` + +### 2. Find the entry point file in `node_modules` + +Look inside `node_modules//` for the main JavaScript file. Check the package's `package.json` for the `main` or `module` field, or look for a file like `index.js` or `.js`. + +### 3. Bundle with esbuild + +Run the following command, replacing the placeholders with your package details: + +```bash +npx esbuild node_modules//.js \ + --bundle --minify --format=iife --global-name= \ + --outfile=public/.min.js +``` + +**Flags explained:** + +| Flag | Purpose | +|---|---| +| `--bundle` | Resolves and inlines all dependencies | +| `--minify` | Produces a compact single-line output | +| `--format=iife` | Wraps the bundle in an immediately-invoked function expression, safe for script injection | +| `--global-name` | The variable name you'll use to access the library in Fulcrum | +| `--outfile` | Where to write the bundled output | + +**Example for `js-base64`:** + +```bash +npx esbuild node_modules/js-base64/base64.js \ + --bundle --minify --format=iife --global-name=Base64 \ + --outfile=public/base64.min.js +``` + +### 4. Copy the bundle into Fulcrum + +Open `public/.min.js` and copy the entire contents. Paste it at the top of your Data Event or EJS template, before any code that uses the library. + +### 5. Use the library via the global variable name + +After pasting the bundle, the library is available under the `--global-name` you specified. + +**Example — Base64 encoding in a Data Event:** + +```js +// (paste the base64.min.js bundle content here) + +ON('load-record', () => { + const encoded = Base64.encode('Hello from Fulcrum'); + SETVALUE('encoded_value', encoded); +}); +``` + +**Example — Using the library in an EJS report template:** + +```ejs +<% +// (paste the bundle content here, at the top of 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 ` +``` + +### 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/integrations/integration-examples/bamboohr-sync-worked-hours.md b/docs/integrations/integration-examples/bamboohr-sync-worked-hours.md new file mode 100644 index 00000000..0165e3c2 --- /dev/null +++ b/docs/integrations/integration-examples/bamboohr-sync-worked-hours.md @@ -0,0 +1,207 @@ +--- +title: Sync worked hours from Fulcrum to BambooHR +excerpt: >- + A Node.js script that queries Fulcrum for approved timesheet records, extracts + worked hours per employee from a repeatable section, and posts the data to the + BambooHR time tracking API. Runs daily to sync the previous day's hours. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Sync worked hours from Fulcrum to BambooHR + +## Overview + +This Node.js integration bridges **Fulcrum** (field time tracking) and **BambooHR** (HR time management). It: + +1. Queries Fulcrum for timesheet records with an `"Approved Timesheet"` status updated in the last day. +2. For each approved timesheet, queries the repeatable section containing employee names and hours worked. +3. Posts each employee's hours to the BambooHR time tracking API as a clock entry. + +**Typical deployment:** Run as a daily scheduled job (cron, Lambda, or similar) to sync the previous day's approved hours. + +## Prerequisites + +- Node.js 16+ +- A Fulcrum app set up as a timesheet, with a repeatable section containing employee name and hours worked fields +- BambooHR account with Time Tracking enabled and an API key +- The following npm packages: `axios`, `fulcrum-app`, `moment`, `dotenv` + +## Environment variables + +Create a `.env` file with the following variables: + +```bash +FULCRUM_API_KEY=your_fulcrum_api_token +FULCRUM_FORM_ID=your_timesheet_app_name # used in the QUERY string, e.g. "Timesheet App" +BAMBOOHR_SUBDOMAIN=your-company # subdomain at bamboohr.com +BAMBOO_TOKEN_BASE64=base64_encoded_api_key_and_password +``` + +> **BambooHR token:** BambooHR uses HTTP Basic Auth. Encode `apikey:password` as Base64 and use it as the `BAMBOO_TOKEN_BASE64` value. You can generate this with: `echo -n "your_api_key:x" | base64` + +## Configuration + +Update the following values in the script before running: + +| Placeholder | Description | +|---|---| +| `YOUR_TIMESHEET_FORM_NAME/employee_repeatable` | The Query API table path for your repeatable (`"App Name/repeatable_data_name"`) | +| `employee_id_field` | The `data_name` of the field containing the BambooHR employee ID | +| `hours_worked_field` | The `data_name` of the field containing hours worked | + +## Code + +```javascript +require('dotenv').config(); +const axios = require('axios'); +const { Client } = require('fulcrum-app'); +const moment = require('moment'); + +// ─── CONFIGURATION ─────────────────────────────────────────────────────────── +const TIMESHEET_FORM_ID = process.env.FULCRUM_FORM_ID; +const LABOR_REPEATABLE_TABLE = 'YOUR_TIMESHEET_FORM_NAME/employee_repeatable'; +const EMPLOYEE_ID_FIELD = 'employee_id_field'; // data_name → BambooHR employee ID +const HOURS_FIELD = 'hours_worked_field'; // data_name → decimal hours worked +const BAMBOOHR_SUBDOMAIN = process.env.BAMBOOHR_SUBDOMAIN; +const BAMBOO_TOKEN_BASE64 = process.env.BAMBOO_TOKEN_BASE64; +const WORK_DAY_START_HOUR = 9; // assumed start time (9:00 AM) for BambooHR clock entries +// ───────────────────────────────────────────────────────────────────────────── + +const client = new Client(process.env.FULCRUM_API_KEY); + +function log(message, data = null) { + console.log(`[${new Date().toISOString()}] ${message}`); + if (data) console.dir(data, { depth: null }); +} + +/** + * Converts decimal hours to an HH:mm end time, assuming a fixed start hour. + * e.g. 7.5 hours from 09:00 → "16:30" + */ +function hoursToEndTime(hoursDecimal) { + const totalMinutes = Math.round(hoursDecimal * 60); + const end = new Date(0, 0, 0, WORK_DAY_START_HOUR, 0); + end.setMinutes(end.getMinutes() + totalMinutes); + return `${String(end.getHours()).padStart(2, '0')}:${String(end.getMinutes()).padStart(2, '0')}`; +} + +/** + * Fetch all approved timesheet records updated in the last 1–3 days. + */ +async function fetchApprovedTimesheets() { + log('Querying Fulcrum for approved timesheets...'); + + const startDate = moment().subtract(3, 'days').startOf('day').toISOString(); + const endDate = moment().subtract(1, 'days').endOf('day').toISOString(); + + const geojson = await client.query( + `SELECT * FROM "${TIMESHEET_FORM_ID}" + WHERE _status = 'Approved Timesheet' + AND _updated_at BETWEEN '${startDate}' AND '${endDate}'`, + 'geojson' + ); + + log(`Found ${geojson.features.length} approved timesheet(s).`); + return geojson.features; +} + +/** + * For each approved timesheet, query the labor repeatable to get + * per-employee hours entries. + */ +async function extractLaborEntries(timesheets) { + log('Extracting labor entries from repeatables...'); + + const entries = []; + const workDate = moment().subtract(2, 'days').format('YYYY-MM-DD'); + + for (const record of timesheets) { + const recordId = record.properties['_record_id']; + + const response = await client.query( + `SELECT ${EMPLOYEE_ID_FIELD}, ${HOURS_FIELD} + FROM "${LABOR_REPEATABLE_TABLE}" + WHERE _parent_id = '${recordId}'`, + 'geojson' + ); + + response.features.forEach(row => { + entries.push({ + employeeId: row.properties[EMPLOYEE_ID_FIELD], + hoursWorked: row.properties[HOURS_FIELD] || 0, + date: workDate + }); + }); + } + + log(`Extracted ${entries.length} labor entries.`); + return entries; +} + +/** + * POST each labor entry to BambooHR as a clock entry. + */ +async function postToBambooHR(entries) { + log('Posting entries to BambooHR...'); + + for (const entry of entries) { + const payload = { + entries: [{ + employeeId: parseInt(entry.employeeId, 10), + date: entry.date, + start: `${String(WORK_DAY_START_HOUR).padStart(2, '0')}:00`, + end: hoursToEndTime(entry.hoursWorked) + }] + }; + + const response = await axios.post( + `https://api.bamboohr.com/api/gateway.php/${BAMBOOHR_SUBDOMAIN}/v1/time_tracking/clock_entries/store`, + payload, + { + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'Authorization': `Basic ${BAMBOO_TOKEN_BASE64}` + } + } + ); + + log(`Posted hours for employee ${entry.employeeId}:`, response.data); + } +} + +async function run() { + try { + const timesheets = await fetchApprovedTimesheets(); + if (timesheets.length === 0) { log('No records to process.'); return; } + + const entries = await extractLaborEntries(timesheets); + if (entries.length === 0) { log('No labor entries found.'); return; } + + await postToBambooHR(entries); + log('Integration complete.'); + } catch (err) { + log('Integration failed.', err); + process.exit(1); + } +} + +run(); +``` + +## Notes + +- The `hoursToEndTime()` function assumes all employees start at `WORK_DAY_START_HOUR` (9:00 AM). Update this if your org uses variable start times or stores start/end times directly in Fulcrum. +- The BambooHR `/time_tracking/clock_entries/store` endpoint requires Time Tracking to be enabled in BambooHR. Check with your BambooHR administrator. +- The `_status = 'Approved Timesheet'` filter assumes your Fulcrum app uses a status field with this exact value. Update the query to match your app's status values. +- For production use, add error handling for individual entries so a single failed BambooHR POST doesn't abort the entire batch. +- Consider adding idempotency tracking (e.g. storing processed `_record_id` values) to avoid double-posting if the script is run multiple times in a day. + +*Credit: Diego Caplan* diff --git a/docs/integrations/integration-examples/salesforce-create-fulcrum-record-on-work-order.md b/docs/integrations/integration-examples/salesforce-create-fulcrum-record-on-work-order.md new file mode 100644 index 00000000..9c14fabf --- /dev/null +++ b/docs/integrations/integration-examples/salesforce-create-fulcrum-record-on-work-order.md @@ -0,0 +1,143 @@ +--- +title: Create a Fulcrum record from a Salesforce work order +excerpt: >- + An Apex trigger and class that automatically creates a Fulcrum record via the + REST API whenever a new Work Order is created in Salesforce. Field values from + the work order are mapped to Fulcrum form fields using the Fulcrum field key. +deprecated: false +hidden: false +metadata: + title: '' + description: '' + robots: noindex +next: + description: '' +--- + +# Create a Fulcrum record from a Salesforce work order + +## Overview + +This integration uses a Salesforce **Apex trigger** and **Apex class** to automatically create a corresponding Fulcrum record via the [Fulcrum Records API](https://developer.fulcrumapp.com/reference/records-create) whenever a new Work Order is created in Salesforce. + +**Use case:** Field teams use Fulcrum for field data collection. When dispatchers create a work order in Salesforce, this trigger automatically creates the matching Fulcrum inspection or work record so field crews can see it immediately in the mobile app. + +The integration uses `@future(callout=true)` to make the HTTP callout asynchronously, which is required for all external callouts from Salesforce triggers. + +## Prerequisites + +1. A Fulcrum app configured for the type of work order you want to track. +2. A Fulcrum API token with write access to that app (store in Salesforce Named Credentials or a Custom Setting — do not hardcode in production). +3. The Fulcrum app's **Form ID** (found in the app's URL or API response). +4. The **field keys** for each Fulcrum field you want to populate (visible in the Data Events editor or via the Forms API). +5. The Fulcrum API endpoint added to your Salesforce org's **Remote Site Settings**: `https://api.fulcrumapp.com` + +## Configuration + +Update the following values in `FulcrumIntegration.cls` before deploying: + +| Placeholder | Description | +|---|---| +| `YOUR-FULCRUM-FORM-ID` | The UUID of your Fulcrum app | +| `FIELD_KEY_WORK_ORDER_ID` | Fulcrum field key for the work order ID field | +| `FIELD_KEY_SUBJECT` | Fulcrum field key for the work order subject/title field | +| `FIELD_KEY_DESCRIPTION` | Fulcrum field key for the description field | +| `YOUR_API_TOKEN` | Your Fulcrum API token (use Named Credentials in production) | + +> **Finding field keys:** In the Fulcrum web app, open the Data Events editor for your app and run `ALERT(INSPECT(DATANAMES()))` to list all field data names and their keys. + +## Code + +### Trigger — `WorkOrderTrigger.trigger` + +```apex +trigger WorkOrderTrigger on WorkOrder (after insert) { + for (WorkOrder wo : Trigger.new) { + FulcrumIntegration.createFulcrumRecord(wo.Id); + } +} +``` + +### Apex Class — `FulcrumIntegration.cls` + +```apex +public class FulcrumIntegration { + + // Future method runs asynchronously so the trigger doesn't block + @future(callout=true) + public static void createFulcrumRecord(Id workOrderId) { + try { + // Fetch work order details + WorkOrder wo = [ + SELECT Id, Subject, Description + FROM WorkOrder + WHERE Id = :workOrderId + LIMIT 1 + ]; + + // Build the Fulcrum record payload + Map formValues = new Map{ + 'FIELD_KEY_WORK_ORDER_ID' => wo.Id, + 'FIELD_KEY_SUBJECT' => wo.Subject, + 'FIELD_KEY_DESCRIPTION' => wo.Description + }; + + Map recordData = new Map{ + 'form_id' => 'YOUR-FULCRUM-FORM-ID', + 'form_values' => formValues + }; + + String jsonBody = JSON.serialize(recordData); + + // Send to Fulcrum Records API + HttpRequest req = new HttpRequest(); + req.setEndpoint('https://api.fulcrumapp.com/api/v2/records.json'); + req.setMethod('POST'); + req.setHeader('Content-Type', 'application/json'); + req.setHeader('X-ApiToken', 'YOUR_API_TOKEN'); // use Named Credentials in production + req.setHeader('x-skipworkflows', 'true'); // skip Fulcrum webhooks on import + req.setHeader('x-skipwebhooks', 'true'); + req.setBody(jsonBody); + + Http http = new Http(); + HttpResponse res = http.send(req); + + if (res.getStatusCode() != 201) { + System.debug('Fulcrum API error: ' + res.getStatusCode() + ' ' + res.getBody()); + } else { + System.debug('Fulcrum record created: ' + res.getBody()); + } + + } catch (Exception e) { + System.debug('Error creating Fulcrum record: ' + e.getMessage()); + } + } +} +``` + +## Fulcrum API record payload + +The body sent to `POST /api/v2/records.json` should look like: + +```json +{ + "form_id": "YOUR-FULCRUM-FORM-ID", + "form_values": { + "FIELD_KEY_WORK_ORDER_ID": "500Dn000003AbcdEAF", + "FIELD_KEY_SUBJECT": "Annual fire suppression inspection", + "FIELD_KEY_DESCRIPTION": "Inspect all sprinkler heads in Building C" + } +} +``` + +Form values use the Fulcrum **field key** (a short alphanumeric string like `4686`), not the field label or `data_name`. + +## Notes + +- **Use Named Credentials** in production instead of hardcoding the API token. Replace `req.setHeader('X-ApiToken', ...)` with `req.setEndpoint('callout:FulcrumAPI/api/v2/records.json')` and configure the Named Credential with the token as an HTTP header. +- `x-skipworkflows: true` prevents Fulcrum workflows from firing on the newly created record. Remove this header if you want Fulcrum webhooks or automations to trigger. +- The `@future` method limitation means you cannot pass `SObject` records directly — only primitive types or collections. That's why we pass the `Id` and re-query inside the method. +- To set a GPS location on the created record, add `latitude` and `longitude` top-level keys to `recordData` alongside `form_id`. +- For bulk creation (e.g. migrating existing Salesforce work orders), use the [Fulcrum Records bulk create endpoint](https://developer.fulcrumapp.com/reference/records-bulk) to avoid per-record API limits. + +*Credit: Mike Meesseman* diff --git a/docs/slack-posts-for-review.md b/docs/slack-posts-for-review.md index d620bdba..97f0ebd6 100644 --- a/docs/slack-posts-for-review.md +++ b/docs/slack-posts-for-review.md @@ -6,155 +6,45 @@ Posts already documented and committed to the PR are listed at the end for refer --- -## HIGH PRIORITY — Good candidates, need thread review / cleanup +## HIGH PRIORITY — Requires manual code extraction -These posts have strong documentation potential but require reading the thread to collect the actual code. +These posts have strong documentation potential but the code is embedded in attachment files (`.fulcrumapp` exports, JSON files, or private Gists) that cannot be automatically extracted. They must be manually opened and scrubbed before documenting. -### 1. Classification Set replacement with LOADRECORDS (Oct 4, 2024) -**Creator:** @Mike -**Message TS:** 1728082307.220689 -**Category:** Data Events -**Description:** Shows how to use LOADRECORDS to replace a Classification Set when multiple attributes need to be drilled down. The main app uses a record link + LOADRECORDS to pull records from a linked app, creates dynamic choice lists, and lets users drill down like a classification set before making a final selection. -**Why not included:** Thread not fully read during this audit pass. Strong documentation candidate — this is a powerful LOADRECORDS pattern. - ---- - -### 2. Auto-populate repeatable fields from linked app (Oct 17, 2024) -**Creator:** @Mike -**Message TS:** 1729168804.902669 -**Category:** Data Events -**Description:** Repeatable fields can't be auto-populated via a record link field natively. This code works around that by auto-populating fields within a repeatable section from a linked app's repeatable section. -**Why not included:** Thread not fully read. Useful workaround worth documenting. - ---- - -### 3. Query repeatables + photo captions + Google Street View (Oct 28, 2024) -**Creator:** @Kyle Pennell -**Message TS:** 1730154040.517309 -**Category:** Report Builder -**Description:** Advanced Report Builder example that queries repeatable data, photo information, captions, and integrates Google Street View. -**Why not included:** Thread not fully read. Very high value — the Google Street View integration in particular is a standout feature worth documenting. - ---- - -### 4. PDF Merge — combine multiple reports into one (Nov 12, 2024) +### 1. PDF Merge — combine multiple reports into one (Nov 12, 2024) **Creator:** @Mike **Message TS:** 1731434972.437609 **Category:** Report Builder -**Description:** Shows how to merge multiple PDF reports into a single output. Includes options for pulling from other report templates, reference files, and attachment fields. 3 replies with implementation details. -**Why not included:** Thread was identified but not read. This is a high-value Report Builder pattern. 7 replies, multiple options documented. +**Description:** Shows how to merge multiple PDF reports into a single output. Includes options for pulling from other report templates, reference files, and attachment fields. 7 replies. +**Action required:** Code is in a JSON attachment file in the Slack thread. Extract the script, scrub any customer-specific form IDs or API tokens, and create a standalone Report Builder example. --- -### 5. FullCalendar.js calendar view for attendance tracker (Jan 27, 2025) +### 2. FullCalendar.js calendar view for attendance tracker (Jan 27, 2025) **Creator:** @Diego C. **Message TS:** 1737982613.407389 **Category:** Report Builder **Customer:** Asplundh **Description:** HTML report using the [FullCalendar.js](https://fullcalendar.io/) library to display attendance data in a calendar view. 4 replies. -**Why not included:** Customer-specific (Asplundh) and thread not fully read. The FullCalendar.js integration is a compelling Report Builder pattern worth documenting with customer context removed. - ---- - -### 6. Puppeteer stall technique for large PDF rendering (Aug 19, 2025) -**Creator:** @Mike -**Message TS:** 1755609304.908519 -**Category:** Report Builder -**Description:** Code to stall the Puppeteer renderer in Report Builder from finishing the page before all async work completes. Makes a fetch call to an erroneous URL to ensure network activity, then aborts it when processing is done. Useful for rendering large maps or merging PDFs. -**Why not included:** Thread not fully read. This is a useful advanced Report Builder tip. Only 1 reply — likely a short, self-contained snippet. - ---- - -### 7. Recursive/nesting-friendly photo-only renderer (Nov 4, 2024) -**Creator:** @Kyle Pennell -**Message TS:** 1762297567.785699 -**Category:** Report Builder -**Description:** A Report Builder template that handles nested repeatables and renders photos cleanly. 6 replies, 1 taco reaction. -**Why not included:** Thread not fully read. Nested repeatable photo rendering is a common pain point — worth documenting. - ---- - -### 8. Dynamic custom reports with EJS $params (Oct 8, 2024) -**Creator:** @Diego C. -**Message TS:** 1728410437.286789 -**Category:** Report Builder -**Description:** Shows how to pass dynamic data via URL query parameters (`$params.query`) or POST requests (`$params.post`) into EJS report templates, enabling flexible filtering and sorting without hardcoding values. -**Why not included:** Thread not fully read. A very useful pattern for parameterized reports. - ---- - -### 9. Concat PDF attachments to end of a report (Aug 12, 2025) -**Creator:** @Mike -**Message TS:** 1755021151.831909 -**Category:** Report Builder -**Customer:** SESI -**Description:** Shows how to concatenate PDF files from attachment fields onto the end of a generated report. 7 replies. -**Why not included:** Customer-specific (SESI) but the core pattern is general. Thread not fully read. +**Action required:** Code is embedded in a `.fulcrumapp` app export file attached to the thread. Extract the HTML App Extension code from the export, remove the Asplundh-specific field names and form references, and generalize as a Report Builder calendar example. --- -### 10. Override console.log to avoid errors on mobile (Sep 25, 2025) -**Creator:** @Mike -**Message TS:** 1758824108.935309 -**Category:** Data Events -**Description:** A pattern for overriding `console.log` in Data Events so it doesn't cause errors or unexpected behavior on mobile devices. 5 replies. -**Why not included:** Thread not fully read. Short and useful — likely suitable for an inline "tip" or quick example. - ---- - -### 11. Widgets Inventory Scanner App Extension (Feb 13, 2026) +### 3. Widgets Inventory Scanner App Extension (Feb 13, 2026) **Creator:** @Jared Carey **Message TS:** 1771016554.597669 **Category:** App Extensions -**Description:** A self-contained HTML interface for mobile or web to barcode-scan items and perform rapid inventory quantity adjustments. The code is embedded in a `.fulcrumapp` app export file attached to the thread — it was not directly readable from Slack. -**Why not included:** The HTML code is in a `.fulcrumapp` attachment file. To document this, extract the HTML from the app export, scrub any API tokens or form-specific field keys, and create a standalone App Extension example. - ---- - -### 12. Salesforce trigger/class to create Fulcrum record (Mar 14, 2025) -**Creator:** @Mike -**Message TS:** 1741953090.521519 -**Category:** Integrations -**Description:** Apex trigger and class for Salesforce that creates a Fulcrum record via the API when a new work order is created. 2 replies. -**Why not included:** Thread not fully read. A useful integration example for Salesforce customers. Would fit best in an Integrations section of the docs. +**Description:** A self-contained HTML interface for mobile or web to barcode-scan items and perform rapid inventory quantity adjustments. +**Action required:** Code is embedded in a `.fulcrumapp` app export file attached to the thread. Extract the HTML from the App Extension inside the export, scrub any API tokens or form-specific field keys, and create a standalone App Extension example. --- -### 13. Web Mapping in the Report Builder (Mar 12, 2025) +### 4. Web Mapping in the Report Builder (Mar 12, 2025) **Creator:** @Kyle Pennell **Customer:** SCE **Message TS:** 1741805961.410129 **Category:** Report Builder **Description:** Shows how to embed a web map in a Report Builder PDF. 5 replies, 2 heart reactions, 1 esri reaction. -**Why not included:** Thread not fully read. Web mapping in reports is a top request — worth documenting without the SCE-specific context. - ---- - -### 14. Add metadata to photos in default report (May 9, 2025) -**Creator:** @Diego C. -**Message TS:** 1746812946.323139 -**Category:** Report Builder -**Description:** Shows how to add EXIF metadata (location, timestamp, etc.) to photos displayed in the default Fulcrum PDF report. 4 replies. -**Why not included:** Thread not fully read. Photo metadata in reports is a common request. - ---- - -### 15. BambooHR integration — track worked hours (Jun 11, 2025) -**Creator:** @Diego C. -**Customer:** Q-Team -**Message TS:** 1749662185.392449 -**Category:** Integrations / Node.js -**Description:** A Node.js script that sends data from Fulcrum to BambooHR to track worked hours per employee. 5 replies. -**Why not included:** Customer-specific (Q-Team) and thread not fully read. Once scrubbed of customer details, this is a good integration example. - ---- - -### 16. Data event user action logger (Jan 29, 2026) -**Creator:** @Gus Ferrara -**Message TS:** 1769725697.891659 -**Category:** Data Events -**Description:** A data event that logs user actions (edits, field changes, etc.) within a record session. 3 replies. -**Why not included:** Thread not fully read. A logging/auditing pattern useful for debugging and compliance use cases. +**Action required:** Code is in a private Gist linked in the thread that is not publicly accessible. Kyle or the SE team needs to share the Gist contents or re-host the example. Once available, strip the SCE-specific context and document as a general web mapping pattern. --- @@ -162,7 +52,7 @@ These posts have strong documentation potential but require reading the thread t These are useful tools but are primarily for internal SE team use, or are short snippets that could become quick reference examples. -### 17. Enable Report Builder advanced features (Jul 25, 2024) +### 5. Enable Report Builder advanced features (Jul 25, 2024) **Creator:** @Mike **Message TS:** 1721957817.936569 **Category:** Tip / Console @@ -171,7 +61,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 18. Bookmarklet to auto-copy API token (Nov 21, 2024) +### 6. Bookmarklet to auto-copy API token (Nov 21, 2024) **Creator:** @Gus Ferrara **Message TS:** 1732231724.524269 **Category:** Developer Utility / Bookmarklet @@ -180,7 +70,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 19. Check parent-child repeatable relationships (Mar 6, 2025) +### 7. Check parent-child repeatable relationships (Mar 6, 2025) **Creator:** @Kyle Pennell **Customer:** SCE **Message TS:** 1741301660.356099 @@ -190,7 +80,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 20. SQL: Extract all fields recursively using Query API (Mar 3, 2025) +### 8. SQL: Extract all fields recursively using Query API (Mar 3, 2025) **Creator:** @Mike **Message TS:** 1741043285.859059 **Category:** SQL / Query API @@ -199,7 +89,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 21. Query all forms a user has access to (Mar 13, 2025) +### 9. Query all forms a user has access to (Mar 13, 2025) **Creator:** @Mike **Message TS:** 1741897363.951789 **Category:** SQL / Query API @@ -208,7 +98,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 22. SQL pivot — columns to rows (Mar 2, 2025) +### 10. SQL pivot — columns to rows (Mar 2, 2025) **Creator:** @Mike **Message TS:** 1740922111.106489 **Category:** SQL / Query API @@ -217,7 +107,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 23. Activate HTML advanced report feature via console (Mar 4, 2025) +### 11. Activate HTML advanced report feature via console (Mar 4, 2025) **Creator:** @Diego C. **Message TS:** 1741099687.075229 **Category:** Tip / Console @@ -226,7 +116,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 24. Script to output CSV of data_name/key mapping (Oct 24, 2025) +### 12. Script to output CSV of data_name/key mapping (Oct 24, 2025) **Creator:** @Gus Ferrara **Message TS:** 1761323102.635249 **Category:** Developer Utility / Console @@ -235,7 +125,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 25. Most Complex Calc Field Ever Made (Jan 30, 2025) +### 13. Most Complex Calc Field Ever Made (Jan 30, 2025) **Creator:** @Kyle Pennell **Message TS:** 1738259002.250459 **Category:** CALCULATIONS @@ -244,7 +134,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 26. Local storage for parent-child record linking (Nov 25, 2025) +### 14. Local storage for parent-child record linking (Nov 25, 2025) **Creator:** @Kyle Pennell **Message TS:** 1764110137.319979 **Category:** Data Events @@ -253,7 +143,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 27. Auto redirect to saved filter (Nov 12, 2025) +### 15. Auto redirect to saved filter (Nov 12, 2025) **Creator:** @Kyle Pennell **Message TS:** 1762993164.617539 **Category:** Utility / Tampermonkey or JS @@ -262,7 +152,7 @@ These are useful tools but are primarily for internal SE team use, or are short --- -### 28. Export issues tab on Fulcrum import to CSV (Dec 23, 2025) +### 16. Export issues tab on Fulcrum import to CSV (Dec 23, 2025) **Creator:** @Gus Ferrara **Message TS:** 1766508774.303799 **Category:** Developer Utility / Console @@ -355,3 +245,15 @@ These posts are either for internal SE team use, customer-specific without gener | `data-events-examples/geofencing-with-loadrecords-and-geometry.md` | @Andy Stewart | Data Events | | `app-extensions/date-picker-with-blackout-dates.md` | @Kyle Pennell | App Extensions | | `data-events-examples/import-npm-packages-into-data-events.md` | @Gus Ferrara | Data Events | +| `data-events-examples/classification-set-replacement-with-loadrecords.md` | @Mike | Data Events | +| `data-events-examples/auto-populate-repeatable-fields-from-linked-app.md` | @Mike | Data Events | +| `data-events-examples/suppress-console-log-on-mobile.md` | @Mike | Data Events | +| `data-events-examples/user-action-logger.md` | @Gus Ferrara | Data Events | +| `reports-examples/puppeteer-stall-for-async-rendering.md` | @Mike | Report Builder | +| `reports-examples/recursive-photo-renderer.md` | @Kyle Pennell | Report Builder | +| `reports-examples/query-repeatables-photos-and-google-street-view.md` | @Kyle Pennell | Report Builder | +| `reports-examples/photo-metadata-in-reports.md` | @Diego C. | Report Builder | +| `reports-examples/concat-pdf-attachments-to-report.md` | @Mike | Report Builder | +| `reports-examples/dynamic-reports-with-ejs-params.md` | @Diego C. | Report Builder | +| `integration-examples/salesforce-create-fulcrum-record-on-work-order.md` | @Mike | Integrations | +| `integration-examples/bamboohr-sync-worked-hours.md` | @Diego C. | Integrations | From 19cc34a9ffd750f5ddd56bb46a6a31d7b35749f9 Mon Sep 17 00:00:00 2001 From: Mike Meesseman Date: Mon, 23 Mar 2026 09:25:45 -0400 Subject: [PATCH 07/11] Add 4 more examples from attachment-only #se_team_code_share posts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report Builder: - merge-pdf-attachments-with-pdf-lib: APIREQUEST() to generate sub-report PDFs server-side, GETBLOB()/BUFFER2BASE64() for record PDF attachments, pdf-lib client-side merge, download via anchor click - fullcalendar-attendance-report: QUERY() date-range records → FullCalendar v3 event objects with color-coded types, month/week/day views - interactive-web-map-with-data-table: Leaflet map + synchronized data table, bi-directional marker/row selection, text search, mobile URL action App Extensions: - inventory-scanner-with-barcode: html5-qrcode + Tailwind CSS barcode scanner that looks up Fulcrum records by barcode and patches quantity via Records API Also updates slack-posts-for-review.md to move all 4 items from "Requires manual code extraction" to "Already Documented". Co-Authored-By: Claude Sonnet 4.6 --- .../inventory-scanner-with-barcode.md | 422 ++++++++++++++++++ .../fullcalendar-attendance-report.md | 163 +++++++ .../interactive-web-map-with-data-table.md | 330 ++++++++++++++ .../merge-pdf-attachments-with-pdf-lib.md | 155 +++++++ 4 files changed, 1070 insertions(+) create mode 100644 docs/App Extensions/app-extension-examples/inventory-scanner-with-barcode.md create mode 100644 docs/REPORT BUILDER/reports-examples/fullcalendar-attendance-report.md create mode 100644 docs/REPORT BUILDER/reports-examples/interactive-web-map-with-data-table.md create mode 100644 docs/REPORT BUILDER/reports-examples/merge-pdf-attachments-with-pdf-lib.md 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/REPORT BUILDER/reports-examples/fullcalendar-attendance-report.md b/docs/REPORT BUILDER/reports-examples/fullcalendar-attendance-report.md new file mode 100644 index 00000000..b2ba43fc --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/fullcalendar-attendance-report.md @@ -0,0 +1,163 @@ +--- +title: Calendar View Report with FullCalendar.js +excerpt: Query date-range records from a Fulcrum app and render them as an interactive monthly calendar using the FullCalendar.js library, with color-coded event types and multi-view navigation. +--- + +This example shows how to build an HTML Report Builder template that renders Fulcrum records as calendar events using [FullCalendar v3](https://fullcalendar.io/). Records with `date_from` / `date_to` fields are queried server-side, mapped to FullCalendar event objects, and rendered as a color-coded monthly calendar with week and day views. + +## Use Cases + +- Attendance, leave, and time-off tracking +- Scheduled work orders or inspections across a date range +- Any app where records have start/end dates that benefit from a calendar view + +## Dependencies + +FullCalendar v3 requires jQuery and Moment.js. Load all three from CDN: + +```html + + + + + + + + + + + +``` + +## 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. From 284143cc9b5149c73e61f28a8bfd8abe2da1f3d2 Mon Sep 17 00:00:00 2001 From: Mike Meesseman Date: Mon, 23 Mar 2026 12:19:59 -0400 Subject: [PATCH 08/11] Add medium-priority docs: Query API, Data Events, and Utilities examples Adds 9 new documentation files covering medium-priority items from the #se_team_code_share audit: Data Events: - calculate-status-from-repeatable-values.md (REPEATABLEVALUES + getVisitStatus pattern) - pass-record-id-between-apps-with-storage.md (STORAGE() for cross-app record linking) Query API: - extract-form-fields-recursively.md (WITH RECURSIVE CTE over forms.elements::jsonb) - forms-accessible-per-user.md (memberships + memberships_forms + forms JOIN) - pivot-columns-to-rows.md (jsonb_each_text + system.columns EAV pivot) Utilities: - app-designer-field-mapping-csv.md (3 console scripts for data_name/key/type CSV) - enable-feature-flags-via-console.md (localStorage feature flags for Report Builder) - export-import-issues-to-csv.md (IIFE to download import issues as CSV) - api-token-copy-bookmarklet.md (bookmarklet to copy window.token to clipboard) Also updates slack-posts-for-review.md: clears HIGH and MEDIUM PRIORITY sections, moves all documented items to Already Documented, and moves 2 items to LOW PRIORITY. Co-Authored-By: Claude Sonnet 4.6 --- ...calculate-status-from-repeatable-values.md | 108 ++++++++++++ ...ass-record-id-between-apps-with-storage.md | 75 ++++++++ .../extract-form-fields-recursively.md | 82 +++++++++ .../forms-accessible-per-user.md | 55 ++++++ .../pivot-columns-to-rows.md | 62 +++++++ .../api-token-copy-bookmarklet.md | 35 ++++ .../app-designer-field-mapping-csv.md | 96 +++++++++++ .../enable-feature-flags-via-console.md | 40 +++++ .../export-import-issues-to-csv.md | 82 +++++++++ docs/slack-posts-for-review.md | 162 ++---------------- 10 files changed, 652 insertions(+), 145 deletions(-) create mode 100644 docs/DATA EVENTS/data-events-examples/calculate-status-from-repeatable-values.md create mode 100644 docs/DATA EVENTS/data-events-examples/pass-record-id-between-apps-with-storage.md create mode 100644 docs/QUERY API/query-api-examples/extract-form-fields-recursively.md create mode 100644 docs/QUERY API/query-api-examples/forms-accessible-per-user.md create mode 100644 docs/QUERY API/query-api-examples/pivot-columns-to-rows.md create mode 100644 docs/Utilities/utilities-examples/api-token-copy-bookmarklet.md create mode 100644 docs/Utilities/utilities-examples/app-designer-field-mapping-csv.md create mode 100644 docs/Utilities/utilities-examples/enable-feature-flags-via-console.md create mode 100644 docs/Utilities/utilities-examples/export-import-issues-to-csv.md diff --git a/docs/DATA EVENTS/data-events-examples/calculate-status-from-repeatable-values.md b/docs/DATA EVENTS/data-events-examples/calculate-status-from-repeatable-values.md new file mode 100644 index 00000000..eec5fd22 --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/calculate-status-from-repeatable-values.md @@ -0,0 +1,108 @@ +--- +title: Calculate a Status from Repeatable Section Values +excerpt: Use REPEATABLEVALUES() to extract arrays of field values from a repeatable section and pass them to a helper function that derives a display status for each visit — useful for multi-visit workflows where each visit record can independently progress through states. +--- + +When a form has a repeatable section that represents discrete visits or events, you often need to derive a summary status at the parent record level that reflects the state of each visit. This example shows how to use `REPEATABLEVALUES()` to extract parallel arrays of field values from a repeatable section and evaluate them in a helper function to produce a per-visit status string. + +## How It Works + +`REPEATABLEVALUES($repeatable_field, 'field_data_name')` returns an array of values — one entry per row of the repeatable — for the specified field. When called for multiple fields, each array is index-aligned: `array[0]` always corresponds to the first repeatable row, `array[1]` to the second, and so on. + +This makes it straightforward to evaluate multi-field conditions for a given visit by passing the index into a helper function along with the parallel arrays. + +## Calculation Field Code + +```javascript +/** + * Determines a display status for a single repeatable visit. + * + * visitIndex — 0 for the first visit, 1 for the second, etc. + * visitData — Object containing parallel arrays extracted from the repeatable + */ +function getVisitStatus(visitIndex, { + workPerformedDates, // Array of work-performed dates across all visits + accessStatuses, // Array of yes/no access values across all visits + clearanceExceptions, // Array of yes/no exception values across all visits + intendedVegs, // Array of yes/no intended-veg values across all visits + intendedVegRemoved // Array of yes/no veg-removed values across all visits +}) { + // If no meaningful data exists for this visit index, it hasn't occurred yet. + if ( + !workPerformedDates[visitIndex] && + !accessStatuses[visitIndex] && + !clearanceExceptions[visitIndex] && + !intendedVegs[visitIndex] + ) { + return '[no status]'; + } + + const hasWorkPerformedDate = workPerformedDates[visitIndex] != null; + const isNoAccess = accessStatuses[visitIndex]?.toLowerCase() === 'no'; + const hasClearanceException = clearanceExceptions[visitIndex]?.toLowerCase() === 'yes'; + const hasIntendedVeg = intendedVegs[visitIndex]?.toLowerCase() === 'yes'; + const isIntendedVegRemoved = intendedVegRemoved[visitIndex]?.toLowerCase() === 'yes'; + + if (hasWorkPerformedDate && (!hasIntendedVeg || (hasIntendedVeg && isIntendedVegRemoved))) { + return 'Complete'; + } else if (hasWorkPerformedDate && hasIntendedVeg && !isIntendedVegRemoved) { + return 'Intended Veg'; + } else if (isNoAccess) { + return 'No Access'; + } else if (hasClearanceException) { + return 'No Clearance Required'; + } else { + return 'Partial Clearance'; + } +} + +// ── Extract parallel arrays from the repeatable ───────────────────────────── +// Replace '$visit' with your repeatable field variable. +// Replace each 'field_data_name' string with your actual field data_names. +const accessStatuses = REPEATABLEVALUES($visit, 'access_to_structure') || []; +const workPerformedDates = REPEATABLEVALUES($visit, 'date_work_performed') || []; +const clearanceExceptions = REPEATABLEVALUES($visit, 'exception_to_clearance') || []; +const intendedVegs = REPEATABLEVALUES($visit, 'intended_vegetation') || []; +const intendedVegRemoved = REPEATABLEVALUES($visit, 'intended_vegetation_removed') || []; + +// ── Package and evaluate ───────────────────────────────────────────────────── +const visitData = { + workPerformedDates, + accessStatuses, + clearanceExceptions, + intendedVegs, + intendedVegRemoved +}; + +const v1Status = getVisitStatus(0, visitData); +const v2Status = getVisitStatus(1, visitData); + +// ── Output ─────────────────────────────────────────────────────────────────── +// This renders in the title calc field or any text calc field. +SETRESULT(`V1: ${v1Status} / V2: ${v2Status}`); + +/* Example outputs: + * "V1: Complete / V2: [no status]" — Only the first visit is done + * "V1: Complete / V2: No Access" — Both visits exist; V2 had no access + * "V1: Intended Veg / V2: Complete" — Different statuses across visits + * "V1: No Access / V2: [no status]" — V1 blocked; V2 not yet started + */ +``` + +## Key Notes + +**`REPEATABLEVALUES()` returns index-aligned arrays.** Each call with the same repeatable field returns an array of the same length, so `arrays[0]` always refers to the first repeatable row across all arrays. This alignment is what makes the per-index helper function pattern reliable. + +**Optional chaining handles missing values.** Using `?.toLowerCase()` prevents errors when a repeatable row exists but a specific field hasn't been filled in yet. + +**`SETRESULT()` is used in Calculation fields.** If this expression runs in a Calculation field configured as the record's title, the status string appears in the record list and on the map. + +**Scale to more than two visits.** Replace the hardcoded `v1Status`/`v2Status` calls with a loop over `Array.from({ length: accessStatuses.length }, (_, i) => getVisitStatus(i, visitData))` to handle any number of repeatable rows dynamically. + +## Customization + +**Different status logic:** Swap the `if/else` chain inside `getVisitStatus()` to match your workflow's business rules. + +**Single-visit forms:** Call `getVisitStatus(0, visitData)` only and pass the result directly to `SETRESULT()`. + +**Include field values in output:** Append specific values to the result string, e.g., `\`V1: ${v1Status} (${workPerformedDates[0] || 'no date'})\``. diff --git a/docs/DATA EVENTS/data-events-examples/pass-record-id-between-apps-with-storage.md b/docs/DATA EVENTS/data-events-examples/pass-record-id-between-apps-with-storage.md new file mode 100644 index 00000000..71066582 --- /dev/null +++ b/docs/DATA EVENTS/data-events-examples/pass-record-id-between-apps-with-storage.md @@ -0,0 +1,75 @@ +--- +title: Pass a Record ID Between Apps Using STORAGE() +excerpt: Use Fulcrum's STORAGE() API in Data Events to automatically pre-populate a Record Link field when creating a child record from a parent — without URL actions. +--- + +When a user creates a new child record from inside a parent record (for example, tapping a Record Link field to create a new Defect from an Asset), the child app has no way to know which parent it came from by default. This pattern solves that using Fulcrum's `STORAGE()` API to pass the parent record's ID across the app boundary, then sets the child's Record Link field automatically on `new-record`. + +## How It Works + +1. In the **parent app**, when the user interacts with the field that triggers child record creation, the parent stores its own `RECORDID()` in device-local storage. +2. In the **child app**, the `new-record` event fires when the new record opens. The child retrieves the stored ID and calls `SETVALUE()` to pre-populate its Record Link field pointing back to the parent. +3. Both apps clear storage immediately after use to prevent stale values from affecting future records. + +## Parent App Code + +Add this to the parent app's Data Events script. Replace `'create_defect'` with the `data_name` of the field that launches the child app (typically a Record Link field configured with "Create new record"). + +```javascript +// ── Parent App ───────────────────────────────────────────────────────────── + +const storage = STORAGE(); + +// When the user taps the field that creates a new child record, +// save this record's ID so the child app can link back to it. +ON('change', 'create_defect', () => { + storage.setItem('parentRecordID', RECORDID()); +}); + +// Clean up on record unload. Note: if the navigation to the child app +// is very fast, a race condition is possible — that's why the child +// also clears storage immediately after reading it. +ON('unload-record', () => { + storage.clear(); +}); +``` + +## Child App Code + +Add this to the child app's Data Events script. Replace `'asset'` with the `data_name` of the Record Link field in the child app that should point back to the parent. + +```javascript +// ── Child App ────────────────────────────────────────────────────────────── + +const storage = STORAGE(); + +ON('new-record', () => { + const parentRecordID = storage.getItem('parentRecordID'); + + if (parentRecordID) { + // IMPORTANT: Record Link fields require an array, even for a single record. + SETVALUE('asset', [parentRecordID]); + + // Clear storage immediately after use to prevent stale values. + storage.clear(); + + console.log('[Child App] Linked to parent record:', parentRecordID); + } else { + console.log('[Child App] No parent ID found — record created independently.'); + } +}); +``` + +## Key Notes + +**Record Link fields require array syntax.** `SETVALUE('asset', parentRecordID)` will not work. You must pass an array: `SETVALUE('asset', [parentRecordID])`. + +**`STORAGE()` is device-local.** Data stored with `STORAGE()` persists only on the current device and is not synced to Fulcrum's servers. It behaves like `localStorage` in a browser. + +**The `new-record` event is the correct hook.** It fires when a brand-new record is opened for the first time, before any user input. Using `load-record` would fire for existing records too. + +**Clean up in both apps.** The parent clears on `unload-record` as a safety net. The child clears immediately after reading to handle the race condition where the parent might unload before the child finishes initializing. + +## Example Output + +When a user opens an Asset record and taps "Create Defect", the new Defect record opens with the Asset's Record Link field already populated, creating the two-way relationship without any URL actions or manual entry. diff --git a/docs/QUERY API/query-api-examples/extract-form-fields-recursively.md b/docs/QUERY API/query-api-examples/extract-form-fields-recursively.md new file mode 100644 index 00000000..0b2e0743 --- /dev/null +++ b/docs/QUERY API/query-api-examples/extract-form-fields-recursively.md @@ -0,0 +1,82 @@ +--- +title: Extract All Form Fields Recursively with a CTE +excerpt: Use a recursive Common Table Expression (CTE) over the Fulcrum Query API's system `forms` table to walk the nested JSON element tree of any app and return a flat list of every field's data_name and label — including fields inside sections and repeatables. +--- + +Fulcrum stores a form's field definitions as a nested JSON array in the `forms` system table. Top-level fields sit directly in `elements`, but fields inside Sections and Repeatables are nested under their own `elements` arrays. A recursive CTE unfolds this tree in a single query, returning every field at any depth. + +## Query + +```sql +WITH RECURSIVE extracted_elements AS ( + + -- Base case: extract top-level fields from the form's elements array. + SELECT + f.form_id, + elem->>'data_name' AS data_name, + elem->>'label' AS label, + elem->'elements' AS nested_elements -- NULL for leaf fields; populated for sections/repeatables + FROM forms f, + LATERAL jsonb_array_elements(f.elements::jsonb) AS elem + WHERE f.form_id = 'YOUR-FORM-ID' -- Replace with your app's form_id + AND elem ? 'data_name' -- Skip structural elements that have no data_name + + UNION ALL + + -- Recursive case: descend into nested elements arrays (sections, repeatables). + SELECT + e.form_id, + nested_elem->>'data_name', + nested_elem->>'label', + nested_elem->'elements' + FROM extracted_elements e, + LATERAL jsonb_array_elements(e.nested_elements) AS nested_elem + WHERE nested_elem ? 'data_name' + +) +SELECT + form_id, + data_name, + label +FROM extracted_elements +WHERE data_name IS NOT NULL +ORDER BY data_name; +``` + +## How It Works + +The `forms` system table contains one row per Fulcrum app. The `elements` column holds the full field schema as a JSONB array. `jsonb_array_elements()` expands that array into individual rows — one per element — and `->>'data_name'` / `->>'label'` extract the text values from each element object. + +The base case selects all top-level elements. Any element that has its own `elements` child array (Sections and Repeatables) carries that array forward in the `nested_elements` column. The recursive case then expands those nested arrays and continues until no further nesting remains (`nested_elements` is NULL for leaf fields). + +The final `SELECT` filters to rows where `data_name` is not null, excluding container elements (like Section labels) that don't correspond to data-bearing fields. + +## Finding Your Form ID + +Run this query to look up form IDs by name: + +```sql +SELECT form_id, name FROM forms ORDER BY name; +``` + +Or find it in the Fulcrum web app URL when viewing an app's records: `https://web.fulcrumapp.com/forms/YOUR-FORM-ID/records`. + +## Extending the Query + +**Add field type:** Include `elem->>'type' AS field_type` in both the base and recursive SELECT lists to see each field's type (TextField, ChoiceField, PhotoField, etc.). + +**Add key:** Include `elem->>'key' AS key` to capture the short key used in report templates and some API responses. + +**Filter by type:** Add `WHERE field_type = 'ChoiceField'` to the outer SELECT to narrow results to a specific field type. + +**All forms at once:** Remove the `WHERE f.form_id = '...'` clause to extract fields from every form in the organization simultaneously. + +## Example Output + +| form_id | data_name | label | +|---|---|---| +| 8dd8d007-... | asset_id | Asset ID | +| 8dd8d007-... | inspection_date | Inspection Date | +| 8dd8d007-... | notes | Notes | +| 8dd8d007-... | photos | Photos | +| 8dd8d007-... | defect_type | Defect Type (nested inside a repeatable) | diff --git a/docs/QUERY API/query-api-examples/forms-accessible-per-user.md b/docs/QUERY API/query-api-examples/forms-accessible-per-user.md new file mode 100644 index 00000000..b0a19a2b --- /dev/null +++ b/docs/QUERY API/query-api-examples/forms-accessible-per-user.md @@ -0,0 +1,55 @@ +--- +title: List All Forms Each User Can Access +excerpt: Use the Fulcrum Query API's system tables — memberships, memberships_forms, and forms — to produce a report of every active user in your organization along with a comma-separated list of the apps they have access to. +--- + +Fulcrum's Query API exposes three system tables that together model which users can see which apps: `memberships` (org members), `memberships_forms` (the user-to-form mapping), and `forms` (app metadata). Joining them produces a user-by-user access report useful for auditing permissions across your organization. + +## Query + +```sql +SELECT + m.name, + m.email, + STRING_AGG(f.name, ', ' ORDER BY f.name) AS forms +FROM memberships m +LEFT JOIN memberships_forms mf + ON m.user_id = mf.user_id +LEFT JOIN forms f + ON mf.form_id = f.form_id +WHERE m.status = 'active' +GROUP BY m.name, m.email +ORDER BY m.name; +``` + +## Result Shape + +| name | email | forms | +|---|---|---| +| Alice Smith | alice@example.com | Asset Inspections, Work Orders | +| Bob Jones | bob@example.com | Work Orders | +| Carol Lee | carol@example.com | (null — no app access) | + +Users who exist in the organization but have not been added to any specific app appear with a `NULL` `forms` value because of the `LEFT JOIN`. Remove the `LEFT JOIN` and use `INNER JOIN` if you only want users who have at least one app assigned. + +## How It Works + +`memberships` contains one row per organization member with their `user_id`, `name`, `email`, and `status`. `memberships_forms` is the join table that links `user_id` to `form_id`. `forms` provides the human-readable `name` for each app. `STRING_AGG()` collapses multiple form rows per user into a single comma-separated string. + +## Common Variations + +**Filter to a specific app:** Add `WHERE mf.form_id = 'YOUR-FORM-ID'` before the `GROUP BY` to see all users who have access to one particular app. + +**Include role information:** `memberships` also has a `role_id` column. Join the `roles` system table on `role_id` to include each user's role name in the output. + +**Count apps per user:** Replace `STRING_AGG(f.name, ', ')` with `COUNT(f.form_id) AS app_count` to see how many apps each user can access rather than listing them by name. + +**Inactive users:** Remove `WHERE m.status = 'active'` (or change it to `'inactive'`) to audit deactivated accounts. + +## System Tables Reference + +| Table | Key Columns | Description | +|---|---|---| +| `memberships` | `user_id`, `name`, `email`, `status`, `role_id` | All org members | +| `memberships_forms` | `user_id`, `form_id` | User ↔ app access mapping | +| `forms` | `form_id`, `name` | App metadata | diff --git a/docs/QUERY API/query-api-examples/pivot-columns-to-rows.md b/docs/QUERY API/query-api-examples/pivot-columns-to-rows.md new file mode 100644 index 00000000..600c4bf6 --- /dev/null +++ b/docs/QUERY API/query-api-examples/pivot-columns-to-rows.md @@ -0,0 +1,62 @@ +--- +title: Pivot App Columns to Rows (EAV Format) +excerpt: Use the Fulcrum Query API's system.columns table and jsonb_each_text() to convert every user-defined field in an app into a separate row, producing a record_id / data_name / value output useful for generic reporting, exports, and schema-agnostic processing. +--- + +By default, a Fulcrum app query returns one column per field. When you need a schema-agnostic, entity–attribute–value (EAV) layout — where each field value becomes its own row — you can pivot using `system.columns` to discover field names dynamically and `jsonb_each_text()` to explode the record into key/value pairs. + +## Query + +```sql +WITH cols AS ( + -- Get the data_names of all user-defined fields in this app. + -- system.columns contains one row per column for each Fulcrum app table. + SELECT name + FROM system.columns + WHERE table_name = 'Your App Name' -- Replace with your app's exact name (or use form_id) + AND field_type IS NOT NULL -- Filters out internal system columns (_record_id, etc.) +) +SELECT + _record_id, + q.key AS data_name, + q.value AS value +FROM "Your App Name", + jsonb_each_text(to_jsonb("Your App Name")) AS q +WHERE q.key IN (SELECT name FROM cols) +ORDER BY _record_id, q.key; +``` + +## Result Shape + +| _record_id | data_name | value | +|---|---|---| +| abc-123 | asset_id | A-001 | +| abc-123 | inspection_date | 2025-03-01 | +| abc-123 | notes | Crack in east wall | +| def-456 | asset_id | A-002 | +| def-456 | inspection_date | 2025-03-05 | +| def-456 | notes | (null) | + +## How It Works + +`system.columns` is a Fulcrum Query API meta-table that describes every column available for every app in the organization. Filtering to `field_type IS NOT NULL` isolates the user-defined fields (text, choice, number, date, etc.) and excludes system columns like `_record_id`, `_status`, and `_created_at`. + +`to_jsonb("App Name")` converts the entire current row into a JSONB object. `jsonb_each_text()` then expands that object into `(key, value)` pairs — one pair per column. The `WHERE q.key IN (SELECT name FROM cols)` clause filters to only the user-defined field columns identified by the CTE, dropping internal columns from the output. + +## Finding Your App Name + +The `table_name` value in `system.columns` matches the app's name as shown in the Fulcrum web interface. To see all available table names: + +```sql +SELECT DISTINCT table_name FROM system.columns ORDER BY table_name; +``` + +## Common Variations + +**Include system columns:** Remove the `field_type IS NOT NULL` filter (or add specific system column names like `_status`, `_created_at`) to include record metadata in the output. + +**Filter to specific records:** Add `WHERE _record_id IN ('id1', 'id2')` to the outer query to pivot only a subset of records. + +**Pivot back to columns:** Use this output as a subquery with `crosstab()` or conditional aggregation to reshape the EAV output back into a wide table with a dynamic or fixed set of columns. + +**Filter to non-null values:** Add `AND q.value IS NOT NULL` to the outer `WHERE` clause to suppress empty fields from the output. diff --git a/docs/Utilities/utilities-examples/api-token-copy-bookmarklet.md b/docs/Utilities/utilities-examples/api-token-copy-bookmarklet.md new file mode 100644 index 00000000..11d4aece --- /dev/null +++ b/docs/Utilities/utilities-examples/api-token-copy-bookmarklet.md @@ -0,0 +1,35 @@ +--- +title: Bookmarklet to Copy Your API Token +excerpt: A browser bookmarklet that reads the temporary Fulcrum API token from the page's window object and copies it to your clipboard with one click — handy when testing API calls or writing Data Events scripts. +--- + +When you're logged into the Fulcrum web application, a short-lived API token is available on the page's `window` object as `token`. This bookmarklet copies it to your clipboard without requiring you to open the developer console. + +## Bookmarklet Code + +Create a new browser bookmark and set its URL to the following JavaScript URI: + +``` +javascript:(function(){const tokenStr=token;const input=document.createElement('textarea');input.value=tokenStr;document.body.appendChild(input);input.select();document.execCommand('copy');document.body.removeChild(input)})() +``` + +## How to Install + +1. Right-click your browser's bookmarks bar and select **Add page** (Chrome) or **New Bookmark** (Firefox). +2. Give the bookmark a name like "Copy Fulcrum Token". +3. Paste the JavaScript URI above as the URL. +4. Save. + +## How to Use + +1. Open any page on `web.fulcrumapp.com` while logged in. +2. Click the bookmarklet. +3. Your API token is now in your clipboard — paste it into Postman, curl, a script, or wherever you need it. + +## Notes + +**The token is temporary.** It is a session-scoped token tied to your browser session, not your permanent API key. Use it for quick testing and manual API calls. For long-running scripts or automation, use a dedicated API key generated in the Fulcrum Account Settings. + +**`document.execCommand('copy')` is deprecated** in modern browsers but remains widely supported for bookmarklet use cases where the Clipboard API (`navigator.clipboard.writeText()`) may not be available without an explicit user gesture in all contexts. + +**This only works on `web.fulcrumapp.com`.** The `window.token` variable is set by the Fulcrum web application. Running the bookmarklet on any other domain will produce an error because `token` is undefined. 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/enable-feature-flags-via-console.md b/docs/Utilities/utilities-examples/enable-feature-flags-via-console.md new file mode 100644 index 00000000..af22f281 --- /dev/null +++ b/docs/Utilities/utilities-examples/enable-feature-flags-via-console.md @@ -0,0 +1,40 @@ +--- +title: Enable Feature Flags via the Browser Console +excerpt: Use these browser console one-liners to enable advanced Fulcrum features — including the HTML Report Builder and App Designer debug mode — that are gated behind localStorage feature flags. +--- + +Some Fulcrum features are available in the web application but require a feature flag to be enabled manually. These console snippets set the relevant `localStorage` keys to unlock them for your browser session. + +## Enable the HTML Report Builder + +The advanced HTML Report Builder (which allows EJS templates, `QUERY()`, and full JavaScript) is enabled per-browser via a `localStorage` flag. + +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. The Report Builder option should now appear in your app's settings. + +## Enable App Designer Debug Mode + +App Designer debug mode surfaces additional field metadata in the App Designer UI — including `data_name`, `key`, and field type — which is useful when building Report Builder templates or writing Data Events scripts. + +```javascript +window.localStorage.setItem('app-designer-debug-mode', true); +location.reload(); +``` + +This flag persists across browser sessions until explicitly removed. To disable it: + +```javascript +window.localStorage.removeItem('app-designer-debug-mode'); +location.reload(); +``` + +## Notes + +These flags are stored in your browser's `localStorage` and apply only to your browser on your current device. They do not affect other users in your organization and do not sync to Fulcrum's servers. + +If a feature flag stops working after a Fulcrum release, the flag name may have changed. Check with your Fulcrum SE team contact for the current value. diff --git a/docs/Utilities/utilities-examples/export-import-issues-to-csv.md b/docs/Utilities/utilities-examples/export-import-issues-to-csv.md new file mode 100644 index 00000000..72775ada --- /dev/null +++ b/docs/Utilities/utilities-examples/export-import-issues-to-csv.md @@ -0,0 +1,82 @@ +--- +title: Export the Import Issues Tab to CSV +excerpt: Run this browser console script on a Fulcrum import job's Issues tab to extract all validation errors and warnings into a downloadable CSV file — useful for diagnosing import problems and sharing them with data submitters. +--- + +When a Fulcrum data import produces validation errors, the importer shows them in an "Issues" tab as an HTML table. This script reads that table, converts it to CSV, and triggers a browser download — letting you save, sort, and share the issues outside of Fulcrum. + +## Script + +Run this in the browser's developer console while the import Issues tab is visible: + +```javascript +(function () { + // 1. Find the issues table in the page + const table = document.querySelector('table.mapping'); + + if (!table) { + console.error("Table not found. Make sure you're on the Issues tab of a Fulcrum import."); + return; + } + + // 2. Extract header row + const headers = Array.from(table.querySelectorAll('thead th')) + .map(th => `"${th.innerText.trim().replace(/"/g, '""')}"`) + .join(','); + + // 3. Extract data rows + const rows = Array.from(table.querySelectorAll('tbody tr')).map(tr => { + const cells = Array.from(tr.querySelectorAll('td')); + return cells.map(td => { + let text = td.innerText.trim(); + // Collapse line breaks inside cells so they don't split CSV rows + text = text.replace(/(\r\n|\n|\r)/gm, ' '); + return `"${text.replace(/"/g, '""')}"`; + }).join(','); + }); + + // 4. Combine into a CSV string + const csvContent = [headers, ...rows].join('\n'); + + // 5. Trigger a file download + try { + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.setAttribute('href', url); + link.setAttribute('download', 'import_issues.csv'); + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + + setTimeout(() => { + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, 100); + + console.log('%cCSV downloaded.', 'color: green; font-weight: bold;'); + } catch (e) { + // Fallback: print to console for manual copy + console.error('Download blocked by browser. Copy the CSV text below:'); + console.log(csvContent); + } +})(); +``` + +## How to Use + +1. Navigate to a Fulcrum import job that has completed with errors. +2. Click the **Issues** tab to display the validation error table. +3. Open the browser developer console (F12 → Console tab). +4. Paste the script and press Enter. +5. A file named `import_issues.csv` downloads automatically. + +If the download is blocked by your browser's popup/download restrictions, the CSV text is also printed to the console. Copy it and paste it into a text editor or spreadsheet application. + +## How It Works + +The script looks for `table.mapping` — the CSS class Fulcrum's importer uses for its issues table. It extracts the `` cells as column headers and iterates over `` rows to collect cell text. Values are wrapped in double-quotes and any embedded double-quotes are escaped (`""`) to produce valid RFC 4180 CSV. The final string is packaged into a `Blob`, attached to a programmatically created `` element, and `.click()`-ed to trigger the browser's file download mechanism. + +## Notes + +This script reads the DOM of the current page and has no access to Fulcrum's servers or APIs. It only captures issues that are currently rendered in the table — if the importer paginates results, scroll through all pages or increase the page size before running the script to ensure all rows are present in the DOM. diff --git a/docs/slack-posts-for-review.md b/docs/slack-posts-for-review.md index 97f0ebd6..53487065 100644 --- a/docs/slack-posts-for-review.md +++ b/docs/slack-posts-for-review.md @@ -8,156 +8,13 @@ Posts already documented and committed to the PR are listed at the end for refer ## HIGH PRIORITY — Requires manual code extraction -These posts have strong documentation potential but the code is embedded in attachment files (`.fulcrumapp` exports, JSON files, or private Gists) that cannot be automatically extracted. They must be manually opened and scrubbed before documenting. - -### 1. PDF Merge — combine multiple reports into one (Nov 12, 2024) -**Creator:** @Mike -**Message TS:** 1731434972.437609 -**Category:** Report Builder -**Description:** Shows how to merge multiple PDF reports into a single output. Includes options for pulling from other report templates, reference files, and attachment fields. 7 replies. -**Action required:** Code is in a JSON attachment file in the Slack thread. Extract the script, scrub any customer-specific form IDs or API tokens, and create a standalone Report Builder example. - ---- - -### 2. FullCalendar.js calendar view for attendance tracker (Jan 27, 2025) -**Creator:** @Diego C. -**Message TS:** 1737982613.407389 -**Category:** Report Builder -**Customer:** Asplundh -**Description:** HTML report using the [FullCalendar.js](https://fullcalendar.io/) library to display attendance data in a calendar view. 4 replies. -**Action required:** Code is embedded in a `.fulcrumapp` app export file attached to the thread. Extract the HTML App Extension code from the export, remove the Asplundh-specific field names and form references, and generalize as a Report Builder calendar example. - ---- - -### 3. Widgets Inventory Scanner App Extension (Feb 13, 2026) -**Creator:** @Jared Carey -**Message TS:** 1771016554.597669 -**Category:** App Extensions -**Description:** A self-contained HTML interface for mobile or web to barcode-scan items and perform rapid inventory quantity adjustments. -**Action required:** Code is embedded in a `.fulcrumapp` app export file attached to the thread. Extract the HTML from the App Extension inside the export, scrub any API tokens or form-specific field keys, and create a standalone App Extension example. - ---- - -### 4. Web Mapping in the Report Builder (Mar 12, 2025) -**Creator:** @Kyle Pennell -**Customer:** SCE -**Message TS:** 1741805961.410129 -**Category:** Report Builder -**Description:** Shows how to embed a web map in a Report Builder PDF. 5 replies, 2 heart reactions, 1 esri reaction. -**Action required:** Code is in a private Gist linked in the thread that is not publicly accessible. Kyle or the SE team needs to share the Gist contents or re-host the example. Once available, strip the SCE-specific context and document as a general web mapping pattern. +*All high-priority items have been documented. See Already Documented below.* --- ## MEDIUM PRIORITY — Useful utilities, internal or SE-team focused -These are useful tools but are primarily for internal SE team use, or are short snippets that could become quick reference examples. - -### 5. Enable Report Builder advanced features (Jul 25, 2024) -**Creator:** @Mike -**Message TS:** 1721957817.936569 -**Category:** Tip / Console -**Description:** One-liner console script to enable the HTML report builder feature flag: `window.localStorage.setItem('reportsEnabled', '1')` -**Notes:** Very short. Could be added as a callout/tip in Report Builder documentation rather than a standalone example. - ---- - -### 6. Bookmarklet to auto-copy API token (Nov 21, 2024) -**Creator:** @Gus Ferrara -**Message TS:** 1732231724.524269 -**Category:** Developer Utility / Bookmarklet -**Description:** A bookmarklet that reads the temporary API token from the Fulcrum `window` object and copies it to the clipboard. -**Notes:** Useful for developers but not a Fulcrum API example per se. Could fit in a "Developer Tips" or "Utilities" section. - ---- - -### 7. Check parent-child repeatable relationships (Mar 6, 2025) -**Creator:** @Kyle Pennell -**Customer:** SCE -**Message TS:** 1741301660.356099 -**Category:** Data Events / Report Builder -**Description:** Code to check and validate parent-child repeatable relationships. 4 replies. -**Notes:** Useful pattern but SCE-specific context needs to be stripped. - ---- - -### 8. SQL: Extract all fields recursively using Query API (Mar 3, 2025) -**Creator:** @Mike -**Message TS:** 1741043285.859059 -**Category:** SQL / Query API -**Description:** A SQL query to recursively extract all fields within a form using the Fulcrum Query API. -**Notes:** Good SQL example. Would fit in a Query API section. - ---- - -### 9. Query all forms a user has access to (Mar 13, 2025) -**Creator:** @Mike -**Message TS:** 1741897363.951789 -**Category:** SQL / Query API -**Description:** A SQL query to retrieve all form names and IDs that each user in an org has access to. 1 reply. -**Notes:** Useful admin query. Would fit in a Query API section. - ---- - -### 10. SQL pivot — columns to rows (Mar 2, 2025) -**Creator:** @Mike -**Message TS:** 1740922111.106489 -**Category:** SQL / Query API -**Description:** A SQL query that converts each column in an app result into a separate row (`record_id, data_name, value`). 1 reply. -**Notes:** Useful but very short. Best as an inline example or snippet. - ---- - -### 11. Activate HTML advanced report feature via console (Mar 4, 2025) -**Creator:** @Diego C. -**Message TS:** 1741099687.075229 -**Category:** Tip / Console -**Description:** Console snippet to enable the advanced HTML report feature flag: `window.localStorage.setItem('app-designer-debug-mode', true); location.reload()` -**Notes:** Very short. Could be a callout in App Designer documentation. - ---- - -### 12. Script to output CSV of data_name/key mapping (Oct 24, 2025) -**Creator:** @Gus Ferrara -**Message TS:** 1761323102.635249 -**Category:** Developer Utility / Console -**Description:** A browser console script to run in the App Designer that outputs a CSV with the `data_name` and `key` mapping for every field. Useful for Report Builder and API work. -**Notes:** Very practical SE tool. Could be documented as a developer utility tip. - ---- - -### 13. Most Complex Calc Field Ever Made (Jan 30, 2025) -**Creator:** @Kyle Pennell -**Message TS:** 1738259002.250459 -**Category:** CALCULATIONS -**Description:** A complex calculation field expression involving repeatables. 3 replies. -**Notes:** Thread not read. Interesting but the "most complex ever" framing may make it too bespoke for general docs. Read thread before deciding. - ---- - -### 14. Local storage for parent-child record linking (Nov 25, 2025) -**Creator:** @Kyle Pennell -**Message TS:** 1764110137.319979 -**Category:** Data Events -**Description:** Uses local storage to create a record linking relationship between a parent and child form. 1 reply. -**Notes:** Thread not read. Could be a useful alternative to built-in Record Links. - ---- - -### 15. Auto redirect to saved filter (Nov 12, 2025) -**Creator:** @Kyle Pennell -**Message TS:** 1762993164.617539 -**Category:** Utility / Tampermonkey or JS -**Description:** Code to automatically redirect to a saved filter in Fulcrum. 5 replies, 1 clapping reaction. -**Notes:** Thread not read. Could be a useful productivity tip. - ---- - -### 16. Export issues tab on Fulcrum import to CSV (Dec 23, 2025) -**Creator:** @Gus Ferrara -**Message TS:** 1766508774.303799 -**Category:** Developer Utility / Console -**Description:** A browser console script to export the "issues" tab from a Fulcrum import job to a CSV file. 1 reply. -**Notes:** Useful but narrow use case. Good for an SE tools reference. +*All medium-priority items have been documented or moved to LOW PRIORITY. See below.* --- @@ -227,6 +84,8 @@ These posts are either for internal SE team use, customer-specific without gener | Aug 14, 2025 | Gus | Log on desktop only (not mobile) | Very short — 2-line function | | Sep 23, 2025 | Kyle | Enable HTML view PDF reports (console) | Quick console tip | | Oct 28, 2025 | Kyle | Detect records updated across all apps | Python, internal | +| Mar 6, 2025 | Kyle | Check parent-child repeatable relationships (SCE) | Thread unreadable; SCE-specific context makes generalization unclear | +| Nov 12, 2025 | Kyle | Auto redirect to saved filter (Tampermonkey) | Hardcoded customer dashboard UUID; too narrow for public docs | --- @@ -257,3 +116,16 @@ These posts are either for internal SE team use, customer-specific without gener | `reports-examples/dynamic-reports-with-ejs-params.md` | @Diego C. | Report Builder | | `integration-examples/salesforce-create-fulcrum-record-on-work-order.md` | @Mike | Integrations | | `integration-examples/bamboohr-sync-worked-hours.md` | @Diego C. | Integrations | +| `reports-examples/merge-pdf-attachments-with-pdf-lib.md` | @Mike | Report Builder | +| `reports-examples/fullcalendar-attendance-report.md` | @Diego C. | Report Builder | +| `app-extension-examples/inventory-scanner-with-barcode.md` | @Jared Carey | App Extensions | +| `reports-examples/interactive-web-map-with-data-table.md` | @Kyle Pennell | Report Builder | +| `data-events-examples/pass-record-id-between-apps-with-storage.md` | @Kyle Pennell | Data Events | +| `data-events-examples/calculate-status-from-repeatable-values.md` | @Kyle Pennell | Data Events | +| `query-api-examples/extract-form-fields-recursively.md` | @Mike | Query API | +| `query-api-examples/forms-accessible-per-user.md` | @Mike | Query API | +| `query-api-examples/pivot-columns-to-rows.md` | @Mike | Query API | +| `utilities-examples/app-designer-field-mapping-csv.md` | @Gus Ferrara | Utilities | +| `utilities-examples/enable-feature-flags-via-console.md` | @Mike / @Diego C. | Utilities | +| `utilities-examples/export-import-issues-to-csv.md` | @Gus Ferrara | Utilities | +| `utilities-examples/api-token-copy-bookmarklet.md` | @Gus Ferrara | Utilities | From 9578f74bfb300feec6320aac247dc6213491d401 Mon Sep 17 00:00:00 2001 From: Mike Meesseman Date: Tue, 24 Mar 2026 08:55:05 -0400 Subject: [PATCH 09/11] Add 7 docs from previously unread LOW PRIORITY threads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After reading all 8 flagged unread threads, 7 had clear documentation value: Calculations: - access-nested-repeatable-values-in-calc-field.md — nested form_values traversal using FIELD('data_name').key in a two-level repeatable hierarchy Report Builder: - filter-report-by-project-name.md — chained QUERY() to resolve project name → project_id from the projects system table, then filter records by it - simple-react-app-with-query-api.md — React 17 + Babel CDN boilerplate with useEffect data fetch via POST to the Fulcrum Query API - save-pdf-on-mobile-and-desktop.md — mobile/desktop branch for pdf-lib save: blob URL + window.open() on mobile, click on desktop Query API: - query-photo-exif-data.md — (exif::json)->>'field' SQL pattern for extracting camera make/model/exposure/GPS from the photos system table Utilities: - download-choice-list-as-csv.md — DOM scrape of choice-label/choice-value elements → CSV via data URI, run in console on the choice list editor page Integrations: - trigger-export-via-api.md — POST /api/v2/exports with multi-form queries array, supported formats, curl + Python polling examples 1 thread skipped: Peter's Python migration (Nov 4, 2024) — code is in a .py file attachment that cannot be extracted without a download. Co-Authored-By: Claude Sonnet 4.6 --- ...-nested-repeatable-values-in-calc-field.md | 73 ++++++++ .../query-photo-exif-data.md | 86 ++++++++++ .../filter-report-by-project-name.md | 79 +++++++++ .../save-pdf-on-mobile-and-desktop.md | 74 ++++++++ .../simple-react-app-with-query-api.md | 160 ++++++++++++++++++ .../download-choice-list-as-csv.md | 53 ++++++ .../trigger-export-via-api.md | 107 ++++++++++++ docs/slack-posts-for-review.md | 8 + 8 files changed, 640 insertions(+) create mode 100644 docs/CALCULATIONS/calculations-examples/access-nested-repeatable-values-in-calc-field.md create mode 100644 docs/QUERY API/query-api-examples/query-photo-exif-data.md create mode 100644 docs/REPORT BUILDER/reports-examples/filter-report-by-project-name.md create mode 100644 docs/REPORT BUILDER/reports-examples/save-pdf-on-mobile-and-desktop.md create mode 100644 docs/REPORT BUILDER/reports-examples/simple-react-app-with-query-api.md create mode 100644 docs/Utilities/utilities-examples/download-choice-list-as-csv.md create mode 100644 docs/integrations/integration-examples/trigger-export-via-api.md diff --git a/docs/CALCULATIONS/calculations-examples/access-nested-repeatable-values-in-calc-field.md b/docs/CALCULATIONS/calculations-examples/access-nested-repeatable-values-in-calc-field.md new file mode 100644 index 00000000..0e6db1c0 --- /dev/null +++ b/docs/CALCULATIONS/calculations-examples/access-nested-repeatable-values-in-calc-field.md @@ -0,0 +1,73 @@ +--- +title: Access Nested Repeatable Values in a Calculation Field +excerpt: Use the raw form_values object and FIELD() key lookups to traverse a two-level nested repeatable hierarchy directly inside a Calculation field — useful when you need to flatten or summarize deeply nested data that REPEATABLEVALUES() can't reach in a single call. +--- + +`REPEATABLEVALUES()` extracts a flat array of values for a single field across all rows of a repeatable. When your app has a two-level repeatable hierarchy — a parent repeatable containing a child repeatable — and you need to cross-reference values from both levels in a single Calculation field, you can access the raw `form_values` object directly using `FIELD('data_name').key` lookups. + +## How It Works + +Each repeatable row in Fulcrum stores its field values in a `form_values` object keyed by each field's internal `key` string. You can retrieve that key at runtime using `FIELD('data_name').key`. This allows you to write portable code that doesn't hard-code fragile internal key strings. + +The calculation iterates over rows of an outer repeatable (e.g., `$site`), then for each outer row iterates over a nested inner repeatable (e.g., visits), pulling field values from both levels. + +## Calculation Field Code + +```javascript +// Replace these data_names with your actual field data_names: +// $site — outer repeatable field variable +// 'visit' — data_name of the inner repeatable field +// 'inspection_date' — field inside the inner repeatable +// 'inspector_name' — field inside the inner repeatable +// 'notes' — field inside the inner repeatable +// 'site_id' — field inside the outer repeatable (for grouping/labeling) +// 'visit_num' — field inside the inner repeatable (for labeling) + +let lines = []; +let output = ''; + +$site?.forEach((site) => { + // Access the inner repeatable rows via form_values[key] + site?.form_values[FIELD('visit').key]?.forEach((visit) => { + // Only include rows where the 'notes' field has a value + if (visit.form_values[FIELD('notes').key]) { + lines.push({ + site_id: site.form_values[FIELD('site_id').key], + visit_num: visit.form_values[FIELD('visit_num').key], + date: visit.form_values[FIELD('inspection_date').key], + inspector: visit.form_values[FIELD('inspector_name').key], + note: visit.form_values[FIELD('notes').key] + }); + } + }); +}); + +if (lines.length > 0) { + lines.forEach((line) => { + output += `• Site: ${line.site_id} Visit: ${line.visit_num} Date: ${line.date || '—'} Inspector: ${line.inspector || '—'} Note: ${line.note}\n`; + }); +} + +SETRESULT(output); + +/* Example output: + * • Site: A-001 Visit: 1 Date: 2025-03-10 Inspector: Jane Smith Note: Crack in foundation + * • Site: A-001 Visit: 2 Date: 2025-03-24 Inspector: Bob Jones Note: Crack widened + */ +``` + +## When to Use This vs. REPEATABLEVALUES() + +`REPEATABLEVALUES($repeatable, 'data_name')` is the right choice when your repeatable is one level deep and you want parallel arrays for multiple fields. Use the `form_values` approach when: + +- You have **two or more levels** of nesting (outer repeatable containing an inner repeatable). +- You need to **correlate values** from the outer and inner level in the same row (e.g., include the parent site ID next to each child visit note). +- You need **conditional filtering** — skipping rows where a specific nested field is empty. + +## Key Notes + +**`FIELD('data_name').key`** returns the short internal key Fulcrum uses for that field. Using `FIELD()` makes the code portable — if the key ever changes (rare), you only update the data_name string, not hard-coded key values spread through the code. + +**Optional chaining (`?.`)** prevents errors when a repeatable row exists but the inner repeatable field hasn't been filled in yet, or when the outer repeatable itself has no rows. + +**This runs in a Calculation field.** It does not require a Data Event and fires automatically when the record is opened or any field changes, making it suitable for titles, summary fields, and display-only aggregations. diff --git a/docs/QUERY API/query-api-examples/query-photo-exif-data.md b/docs/QUERY API/query-api-examples/query-photo-exif-data.md new file mode 100644 index 00000000..21d3f405 --- /dev/null +++ b/docs/QUERY API/query-api-examples/query-photo-exif-data.md @@ -0,0 +1,86 @@ +--- +title: Query Photo EXIF Data +excerpt: Use the Fulcrum Query API's photos system table to extract camera make, model, exposure settings, GPS coordinates, and other EXIF metadata from photos attached to records — useful for equipment audits, image quality checks, and location verification workflows. +--- + +Every photo uploaded to Fulcrum stores its EXIF metadata in the `photos` system table as a JSON column named `exif`. You can extract individual EXIF fields by casting the column to JSON and using the `->>` operator, which returns the value as text. + +## Query + +```sql +SELECT + photo_id, + (exif::json)->>'make' AS exif_make, + (exif::json)->>'model' AS exif_model, + (exif::json)->>'software' AS exif_software, + (exif::json)->>'date_time' AS exif_date_time, + ((exif::json)->>'exposure_time')::float AS exif_exposure_time, + ((exif::json)->>'f_number')::float AS exif_f_number, + ((exif::json)->>'iso_speed_ratings')::float AS exif_iso, + ((exif::json)->>'focal_length')::float AS exif_focal_length, + ((exif::json)->>'gps_latitude')::float AS exif_gps_latitude, + ((exif::json)->>'gps_longitude')::float AS exif_gps_longitude, + ((exif::json)->>'gps_altitude')::float AS exif_gps_altitude, + (exif::json)->>'lens_make' AS exif_lens_make, + (exif::json)->>'lens_model' AS exif_lens_model +FROM photos +WHERE record_id = 'YOUR-RECORD-ID' -- Replace with a specific record_id, or remove to query all +LIMIT 100; +``` + +## Finding a Photo ID + +The `photo_id` is the second UUID segment in a Fulcrum photo URL. For example, in the URL: + +``` +https://fulcrumapp.s3.amazonaws.com/uploads/photos/abc123/059c6e25-1327-437f-801f-b4a8f320df5e/original.jpg +``` + +The `photo_id` is `059c6e25-1327-437f-801f-b4a8f320df5e`. + +## Join to Records + +To pull EXIF data alongside record fields, join `photos` to your app table on `record_id`: + +```sql +SELECT + r._record_id, + r._title, + p.photo_id, + (p.exif::json)->>'make' AS camera_make, + (p.exif::json)->>'model' AS camera_model, + (p.exif::json)->>'date_time' AS photo_taken_at, + ((p.exif::json)->>'gps_latitude')::float AS photo_lat, + ((p.exif::json)->>'gps_longitude')::float AS photo_lng +FROM "Your App Name" r +JOIN photos p ON p.record_id = r._record_id +ORDER BY r._updated_at DESC; +``` + +## Available EXIF Fields + +Not all cameras write all EXIF fields. Common fields available in Fulcrum: + +| Field | Type | Description | +|---|---|---| +| `make` | text | Camera manufacturer (e.g., Apple, Samsung) | +| `model` | text | Camera model | +| `software` | text | Firmware/OS version | +| `date_time` | text | When the photo was taken | +| `exposure_time` | float | Shutter speed in seconds | +| `f_number` | float | Aperture f-stop | +| `iso_speed_ratings` | float | ISO sensitivity | +| `focal_length` | float | Focal length in mm | +| `gps_latitude` | float | GPS latitude from photo | +| `gps_longitude` | float | GPS longitude from photo | +| `gps_altitude` | float | GPS altitude from photo | +| `lens_make` | text | Lens manufacturer | +| `lens_model` | text | Lens model | + +## Notes + +**GPS coordinates in EXIF may differ from the record's location.** Fulcrum records the device's GPS location at submission time, while EXIF GPS is written by the camera at capture time. These can diverge if photos were taken offline or imported from a camera roll. + +**`exif` may be null** for photos captured by apps that strip EXIF metadata, or for photos uploaded without camera metadata. Use `WHERE exif IS NOT NULL` to filter those out. + +**Numeric EXIF fields are stored as text in the JSON.** Cast them to `::float` or `::numeric` for arithmetic operations or sorting. diff --git a/docs/REPORT BUILDER/reports-examples/filter-report-by-project-name.md b/docs/REPORT BUILDER/reports-examples/filter-report-by-project-name.md new file mode 100644 index 00000000..97c673fd --- /dev/null +++ b/docs/REPORT BUILDER/reports-examples/filter-report-by-project-name.md @@ -0,0 +1,79 @@ +--- +title: Filter a Report by Project Name Using a Chained Query +excerpt: Use two chained QUERY() calls in a Report Builder template — the first resolves a project name to its internal ID from the projects system table, the second uses that ID to filter records — letting report consumers reference projects by human-readable name instead of UUID. +--- + +Fulcrum records can be assigned to a project, but the `_project_id` column stored on each record is a UUID, not a name. When you want to filter a report to a specific project, you either hard-code the UUID (fragile) or perform a lookup first. This pattern uses two `QUERY()` calls to resolve a project name to its ID, then applies it as a filter in the main data query. + +## Report Template Code + +```javascript +// 1. Set the project name to filter on. +// This could also be driven by a $params value for a dynamic report. +const projectName = 'Project Alpha'; // Replace with your project name + +// 2. Look up the project_id from the Fulcrum system projects table. +const projectInfo = QUERY(` + SELECT project_id + FROM projects + WHERE name = '${projectName}' +`); + +const projectId = projectInfo?.rows?.[0]?.project_id; + +// 3. Use the resolved project_id to filter the main data query. +// Replace "Your App Name" with your app's exact name. +const records = QUERY(` + SELECT * + FROM "Your App Name" + WHERE _project_id = '${projectId}' + ORDER BY _updated_at DESC +`); +``` + +## How It Works + +The `projects` system table contains one row per project in your organization with `project_id` (UUID) and `name` (display name). The first `QUERY()` fetches the matching `project_id` for the project name. The second `QUERY()` then filters `"Your App Name"` using that ID via the `_project_id` column that exists on every Fulcrum record. + +## Making It Dynamic with $params + +Rather than hard-coding the project name, you can pass it as a URL parameter and reference it with `$params`: + +```javascript +// Caller passes ?project_name=Project+Alpha in the report URL +const projectName = $params.query?.project_name || $params.post?.project_name; + +const projectInfo = QUERY(` + SELECT project_id FROM projects WHERE name = '${projectName}' +`); + +const projectId = projectInfo?.rows?.[0]?.project_id; + +const records = QUERY(` + SELECT * FROM "Your App Name" + WHERE _project_id = '${projectId}' + ORDER BY _updated_at DESC +`); +``` + +## Combining with a Date Range Filter + +Add a date range condition to the second query to narrow results further: + +```javascript +const records = QUERY(` + SELECT * + FROM "Your App Name" + WHERE _project_id = '${projectId}' + AND _updated_at >= NOW() - INTERVAL '7 days' + ORDER BY _updated_at DESC +`); +``` + +## Notes + +**Project names are case-sensitive** in the `projects` table. If the lookup returns `undefined`, double-check the exact name in your Fulcrum project settings. + +**`_project_id` is null for records not assigned to a project.** Those records won't appear in filtered results, which is the intended behavior for project-scoped reports. + +**The `projects` system table** also contains `description`, `color`, and `created_at` columns if you want to include project metadata in the report header. 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 ` + + +``` + +## 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/Map Layers/map-layers-examples/convert-raster-and-vector-data-to-mbtiles.md b/docs/Map Layers/map-layers-examples/convert-raster-and-vector-data-to-mbtiles.md new file mode 100644 index 00000000..2d9e22e7 --- /dev/null +++ b/docs/Map Layers/map-layers-examples/convert-raster-and-vector-data-to-mbtiles.md @@ -0,0 +1,104 @@ +--- +title: Convert Raster and Vector Data to MBTiles for Fulcrum Map Layers +excerpt: Use GDAL command-line tools to convert GeoTIFF raster files to MBTiles, or use QGIS to convert shapefiles and other vector formats — producing offline-capable tile packages ready for upload to a Fulcrum Map Layer Group. +--- + +Fulcrum Map Layer Groups support MBTiles as a tile source for offline basemaps and reference layers. If you have GeoTIFF rasters or Esri Shapefiles, you can convert them to MBTiles before uploading to Fulcrum. + +## Option 1 — Convert GeoTIFF to MBTiles with GDAL (CLI) + +[GDAL](https://gdal.org/) is the standard open-source geospatial data abstraction library. The `gdal_translate` command converts a GeoTIFF directly to MBTiles, and `gdaladdo` builds the overview pyramid needed for smooth zooming. + +### Install GDAL + +```bash +# macOS (Homebrew) +brew install gdal + +# Ubuntu / Debian +sudo apt-get install gdal-bin + +# Windows — use the OSGeo4W installer from https://trac.osgeo.org/osgeo4w/ +``` + +### Convert a Single GeoTIFF + +```bash +# Convert the GeoTIFF to MBTiles with maximum compression +gdal_translate -co "ZLEVEL=9" -of MBTiles input.tif output.mbtiles + +# Build zoom-level overviews (required for tile rendering at multiple zoom levels) +gdaladdo -r nearest output.mbtiles +``` + +The `-co "ZLEVEL=9"` option sets maximum zlib compression, keeping file size small. `gdaladdo -r nearest` uses nearest-neighbor resampling, which is fast and appropriate for rasters with discrete classification values (e.g., land cover maps). Use `-r average` for continuous data like elevation or imagery. + +### Batch Convert Multiple GeoTIFFs + +```bash +for f in *.tif; do + out="${f%.tif}.mbtiles" + gdal_translate -co "ZLEVEL=9" -of MBTiles "$f" "$out" + gdaladdo -r nearest "$out" + echo "Converted $f → $out" +done +``` + +### Merge Multiple MBTiles into One + +If you have tiles covering different areas that should appear as a single layer, use `gdal_merge` or the `mbutil` utility. Alternatively, `gdalbuildvrt` can stitch multiple GeoTIFFs into a virtual mosaic before conversion: + +```bash +# Merge multiple TIFs into a virtual mosaic, then convert +gdalbuildvrt mosaic.vrt tile_1.tif tile_2.tif tile_3.tif +gdal_translate -co "ZLEVEL=9" -of MBTiles mosaic.vrt mosaic.mbtiles +gdaladdo -r nearest mosaic.mbtiles +``` + +--- + +## Option 2 — Convert Shapefiles to MBTiles with QGIS (GUI) + +[QGIS](https://qgis.org/) provides a graphical workflow for converting vector data (shapefiles, GeoJSON, etc.) to a rasterized MBTiles file. + +### Steps + +1. Open QGIS and load your shapefile via **Layer → Add Layer → Add Vector Layer**. +2. Style the layer as desired (colors, labels, line weights). The styling will be baked into the tiles. +3. Go to **Processing → Toolbox** and search for **"Generate XYZ tiles (MBTiles)"**. +4. In the dialog: + - **Extent:** Use the layer extent or draw a custom extent. + - **Minimum zoom / Maximum zoom:** Set the range of zoom levels to generate. For Fulcrum field use, zoom 10–18 covers most detail needs. Higher max zoom = larger file size. + - **Output file:** Set the output `.mbtiles` path. +5. Click **Run**. QGIS renders and packages the tiles. + +### Recommended Zoom Level Guidelines + +| Use case | Min zoom | Max zoom | Approximate file size | +|---|---|---|---| +| Regional overview | 8 | 14 | Small (< 50 MB) | +| City / project area | 10 | 17 | Medium (50–500 MB) | +| Site-level detail | 12 | 19 | Large (500 MB+) | + +--- + +## Uploading to Fulcrum + +Once you have an `.mbtiles` file, upload it to a Fulcrum Map Layer Group via **Settings → Map Layers** in the Fulcrum web app. The layer will be available for offline use on the Fulcrum mobile app once the device syncs. + +For automated uploads, use the Fulcrum Map Layers API: + +```bash +curl -X POST https://api.fulcrumapp.com/api/v2/layers \ + -H "X-ApiToken: YOUR-API-TOKEN" \ + -F "layer[name]=My Map Layer" \ + -F "layer[file]=@output.mbtiles" +``` + +## Notes + +**File size limits:** Fulcrum has upload size limits for map layers. If your MBTiles file is very large, reduce the maximum zoom level or clip the extent to the area of interest before converting. + +**Coordinate system:** Both GDAL and QGIS expect input data in a standard projection. WGS 84 (EPSG:4326) or Web Mercator (EPSG:3857) are the most compatible. Reproject your source data with `gdalwarp -t_srs EPSG:3857 input.tif reprojected.tif` if needed. + +**Raster vs. vector tiles:** MBTiles can store either raster (PNG/JPEG) or vector (Mapbox Vector Tiles) content. The GDAL workflow above produces raster tiles. QGIS also produces raster tiles. Fulcrum Map Layers support raster MBTiles. diff --git a/docs/QUERY API/query-api-examples/count-active-users-by-changesets.md b/docs/QUERY API/query-api-examples/count-active-users-by-changesets.md new file mode 100644 index 00000000..368d7e40 --- /dev/null +++ b/docs/QUERY API/query-api-examples/count-active-users-by-changesets.md @@ -0,0 +1,89 @@ +--- +title: Count Active Users by Changeset Activity +excerpt: Use the Fulcrum Query API's changesets system table to count how many distinct users have created, updated, or deleted records within a given time window — useful for license audits, usage reporting, and identifying inactive accounts. +--- + +A changeset is created every time a Fulcrum mobile or web user syncs record changes. The `changesets` system table records who opened each changeset and when it closed, making it a reliable proxy for "how many people are actively using Fulcrum" within any time period. + +## Query — Count Distinct Active Users + +```sql +SELECT COUNT(DISTINCT created_by_id) AS active_user_count +FROM changesets +WHERE closed_at > CURRENT_DATE - INTERVAL '90 days'; +``` + +Change `'90 days'` to any interval that fits your reporting window (e.g., `'30 days'`, `'1 year'`). + +## Query — Active Users with Names and Emails + +Join to the `memberships` table to get user details alongside activity counts: + +```sql +SELECT + m.name, + m.email, + COUNT(c.id) AS changeset_count, + MAX(c.closed_at) AS last_active +FROM changesets c +JOIN memberships m ON m.user_id = c.created_by_id +WHERE c.closed_at > CURRENT_DATE - INTERVAL '90 days' +GROUP BY m.name, m.email +ORDER BY changeset_count DESC; +``` + +## Query — Inactive Users (No Activity in N Days) + +Find members who have never synced, or who haven't synced recently: + +```sql +SELECT + m.name, + m.email, + m.status, + MAX(c.closed_at) AS last_active +FROM memberships m +LEFT JOIN changesets c ON c.created_by_id = m.user_id +WHERE m.status = 'active' +GROUP BY m.name, m.email, m.status +HAVING MAX(c.closed_at) IS NULL + OR MAX(c.closed_at) < CURRENT_DATE - INTERVAL '90 days' +ORDER BY last_active ASC NULLS FIRST; +``` + +Users with `last_active = NULL` have never created a changeset (no sync activity on record). + +## Query — Activity Breakdown by App + +Count changesets per app to see which forms are most actively used: + +```sql +SELECT + f.name AS app_name, + COUNT(c.id) AS changeset_count, + COUNT(DISTINCT c.created_by_id) AS unique_users +FROM changesets c +JOIN forms f ON f.form_id = c.form_id +WHERE c.closed_at > CURRENT_DATE - INTERVAL '30 days' +GROUP BY f.name +ORDER BY changeset_count DESC; +``` + +## `changesets` Table Reference + +| Column | Type | Description | +|---|---|---| +| `id` | uuid | Changeset identifier | +| `created_by_id` | uuid | User who created the changeset | +| `form_id` | uuid | App the changeset applies to (null for bulk operations) | +| `closed_at` | timestamp | When the sync completed | +| `created_at` | timestamp | When the changeset was opened | +| `number_of_changes` | integer | Record operations included in the changeset | + +## Notes + +**Changesets reflect sync events, not individual record saves.** A single changeset may contain many record creates, updates, and deletes batched together in one sync. `number_of_changes` gives the count of operations in each batch. + +**Web users also generate changesets.** Every save in the Fulcrum web app creates a changeset, so the counts include both mobile and web activity. + +**Deleted user accounts** may leave `created_by_id` values that no longer appear in `memberships`. The `LEFT JOIN` approach in the inactive-users query handles this gracefully. diff --git a/docs/Utilities/utilities-examples/print-record-link-dependencies.md b/docs/Utilities/utilities-examples/print-record-link-dependencies.md new file mode 100644 index 00000000..53dbac63 --- /dev/null +++ b/docs/Utilities/utilities-examples/print-record-link-dependencies.md @@ -0,0 +1,114 @@ +--- +title: Print Record Link Dependencies for All Apps +excerpt: Use the Fulcrum Python SDK to recursively scan every app in your organization, identify all RecordLinkField targets, and print a dependency map showing which apps link to which — essential before migrations, org restructuring, or debugging broken record links. +--- + +When an organization has many apps connected by Record Link fields, it can be difficult to know which apps depend on which others. This script fetches all form schemas, recursively finds every `RecordLinkField` in each schema (including those inside Sections and Repeatables), and prints a full dependency map. + +## Prerequisites + +```bash +pip install fulcrum +``` + +## Script + +```python +import json +from fulcrum import Fulcrum + +# ── Configuration ───────────────────────────────────────────────────────────── +API_TOKEN = 'YOUR-API-TOKEN' +BASE_URL = 'https://api.fulcrumapp.com' # Change for regional instances (e.g., https://api.fulcrumapp-ca.com) + +fulcrum = Fulcrum(key=API_TOKEN, uri=BASE_URL) + + +def extract_record_link_targets(elements): + """Recursively find all form_ids referenced by RecordLinkFields.""" + targets = [] + for element in elements: + if element.get('type') == 'RecordLinkField': + target_id = element.get('form_id') + if target_id: + targets.append(target_id) + if 'elements' in element: + targets.extend(extract_record_link_targets(element['elements'])) + return targets + + +def print_record_link_dependencies(): + print("Fetching forms...") + forms = fulcrum.forms.search()['forms'] + form_lookup = {f['id']: f['name'] for f in forms} + print(f"Found {len(forms)} apps\n") + + print("App Record Link Dependencies") + print("=" * 40) + + for form in forms: + linked_ids = extract_record_link_targets(form['elements']) + linked_names = [form_lookup.get(fid, f"[Unknown: {fid}]") for fid in set(linked_ids)] + + if linked_names: + print(f"\n📱 {form['name']} links to:") + for name in sorted(linked_names): + print(f" ↳ {name}") + else: + print(f"\n📱 {form['name']} — no record links") + + +print_record_link_dependencies() +``` + +## Example Output + +``` +Found 8 apps + +App Record Link Dependencies +======================================== + +📱 Work Orders — no record links + +📱 Assets links to: + ↳ Work Orders + +📱 Inspections links to: + ↳ Assets + ↳ Work Orders + +📱 Personnel — no record links + +📱 Daily Reports links to: + ↳ Personnel + ↳ Work Orders +``` + +## Find Apps That Link to a Specific App + +To find all apps that reference a particular app (reverse lookup): + +```python +TARGET_APP_NAME = 'Assets' # Replace with the app name you're looking for + +forms = fulcrum.forms.search()['forms'] +form_lookup = {f['id']: f['name'] for f in forms} +target_id = next((f['id'] for f in forms if f['name'] == TARGET_APP_NAME), None) + +if not target_id: + print(f"App '{TARGET_APP_NAME}' not found.") +else: + print(f"Apps that link to '{TARGET_APP_NAME}':") + for form in forms: + if target_id in extract_record_link_targets(form['elements']): + print(f" ↳ {form['name']}") +``` + +## Notes + +**Runs entirely client-side via the Forms API.** No record data is accessed — only form schemas. This makes it safe and fast to run on any organization regardless of record volume. + +**Useful before org migrations.** When migrating an organization to a new Fulcrum account, Record Link fields must be recreated in the correct order (targets before sources). This script shows the dependency graph so you can plan the migration sequence. See also the org migration script for automating the transfer. + +**`form_id` on a RecordLinkField may reference a form in a different organization.** If your org uses cross-org record links, those form IDs won't appear in the lookup and will show as `[Unknown: ...]`. diff --git a/docs/Utilities/utilities-examples/visualize-record-link-network.md b/docs/Utilities/utilities-examples/visualize-record-link-network.md new file mode 100644 index 00000000..f15030d7 --- /dev/null +++ b/docs/Utilities/utilities-examples/visualize-record-link-network.md @@ -0,0 +1,150 @@ +--- +title: Visualize the Record Link Network as a Graph Diagram +excerpt: Use the Fulcrum Python SDK with networkx and matplotlib to generate a directed graph diagram showing how apps are connected by Record Link fields — especially useful when an organization has dozens of linked apps and the relationships are difficult to reason about in a list. +--- + +When an organization has many apps connected by Record Link fields, a visual network diagram makes the dependency structure immediately obvious. This script uses the Fulcrum Python SDK to fetch all form schemas, builds a directed graph with [NetworkX](https://networkx.org/), and renders it using [matplotlib](https://matplotlib.org/) with three layout options. + +## Prerequisites + +```bash +pip install fulcrum networkx matplotlib numpy +``` + +## Script + +```python +import json +from fulcrum import Fulcrum +import networkx as nx +import matplotlib.pyplot as plt +import numpy as np + +# ── Configuration ───────────────────────────────────────────────────────────── +API_TOKEN = 'YOUR-API-TOKEN' +BASE_URL = 'https://api.fulcrumapp.com' + +fulcrum = Fulcrum(key=API_TOKEN, uri=BASE_URL) + + +def extract_record_link_targets(elements): + """Recursively find all form_ids referenced by RecordLinkFields.""" + targets = [] + for element in elements: + if element.get('type') == 'RecordLinkField': + target_id = element.get('form_id') + if target_id: + targets.append(target_id) + if 'elements' in element: + targets.extend(extract_record_link_targets(element['elements'])) + return targets + + +def build_relationships(): + """Return a dict mapping app name → [linked app names].""" + forms = fulcrum.forms.search()['forms'] + form_lookup = {f['id']: f['name'] for f in forms} + + relationships = {} + for form in forms: + linked_ids = extract_record_link_targets(form['elements']) + linked_names = [form_lookup.get(fid, f"[Unknown: {fid}]") for fid in set(linked_ids)] + relationships[form['name']] = linked_names + + return relationships + + +def visualize(relationships, layout='circular'): + """ + Draw the record link network diagram. + + layout: 'circular' | 'shell' | 'grid' + """ + G = nx.DiGraph() + for source, targets in relationships.items(): + for target in targets: + G.add_edge(source, target) + if not relationships[source]: + G.add_node(source) # Include isolated nodes (no links) + + if layout == 'circular': + pos = nx.circular_layout(G, scale=5) + node_color = 'lightcoral' + title = 'Record Link Network — Circular Layout' + figsize = (20, 20) + + elif layout == 'shell': + degrees = dict(G.degree()) + shells = [ + [n for n, d in degrees.items() if d == 0], + [n for n, d in degrees.items() if 1 <= d < 4], + [n for n, d in degrees.items() if d >= 4], + ] + shells = [s for s in shells if s] # Remove empty shells + pos = nx.shell_layout(G, nlist=shells, scale=4) + node_color = 'lightgreen' + title = 'Record Link Network — Shell Layout (by connectivity)' + figsize = (18, 18) + + elif layout == 'grid': + nodes = list(G.nodes()) + cols = int(np.ceil(np.sqrt(len(nodes)))) + pos = {node: ((i % cols) * 3, (i // cols) * 3) for i, node in enumerate(nodes)} + node_color = 'gold' + title = 'Record Link Network — Grid Layout' + figsize = (24, 16) + + else: + raise ValueError(f"Unknown layout: {layout!r}. Use 'circular', 'shell', or 'grid'.") + + plt.figure(figsize=figsize) + nx.draw( + G, pos, + with_labels=True, + node_size=4000, + node_color=node_color, + edge_color='darkgray', + width=2, + arrows=True, + arrowsize=30, + font_size=12, + font_weight='bold', + font_color='black' + ) + plt.title(title, fontsize=18, pad=20) + plt.axis('off') + plt.tight_layout() + plt.show() + + +# ── Run ─────────────────────────────────────────────────────────────────────── +print("Fetching form relationships...") +relationships = build_relationships() + +linked = {k: v for k, v in relationships.items() if v} +print(f"Found {len(relationships)} apps, {len(linked)} with outbound record links\n") + +# Choose a layout: 'circular', 'shell', or 'grid' +# Circular is recommended for most organizations. +visualize(relationships, layout='circular') +``` + +## Layout Options + +**Circular** — nodes arranged in a ring. Best for orgs with up to ~20 apps. Easy to trace link chains around the circle. + +**Shell** — nodes in concentric rings by connection count. Highly connected "hub" apps appear in the center ring; standalone apps in the outer ring. + +**Grid** — nodes on a uniform grid with maximum spacing. Best when app names are long and labels overlap in other layouts. + +## Example Output + +A circular diagram where a `Work Orders` node in the center has arrows pointing to it from `Inspections`, `Daily Reports`, and `Assets`, while those nodes also connect to each other. + +## Notes + +**Install matplotlib backend for headless environments.** If running in a server environment without a display, add `matplotlib.use('Agg')` before importing `pyplot`, then save to a file with `plt.savefig('record_links.png', dpi=150, bbox_inches='tight')` instead of `plt.show()`. + +**Large orgs (50+ apps) may need layout tuning.** Increase `figsize`, reduce `font_size`, or use the grid layout to avoid label overlaps. The `node_size` and `arrowsize` values may also need adjustment. + +**Hierarchical layout (optional).** If you have `pygraphviz` installed (`pip install pygraphviz`), you can use `nx.nx_agraph.graphviz_layout(G, prog='dot')` for a top-down tree layout that clearly shows which apps are "root" apps vs. "leaf" apps in the dependency chain. diff --git a/docs/integrations/integration-examples/bulk-update-app-settings.md b/docs/integrations/integration-examples/bulk-update-app-settings.md new file mode 100644 index 00000000..cfb34362 --- /dev/null +++ b/docs/integrations/integration-examples/bulk-update-app-settings.md @@ -0,0 +1,156 @@ +--- +title: Bulk Update App Settings via the API +excerpt: Use the Fulcrum Forms API to fetch multiple apps by ID, modify their settings programmatically, and save the changes back — useful for renaming a batch of apps, updating descriptions, changing status configurations, or applying a consistent setting across many forms at once. +--- + +When you need to update a property across many Fulcrum apps at once — changing a naming convention, adding a description, or toggling a setting — doing it one at a time in the UI is slow. The Forms API lets you fetch, modify, and update any number of apps in a loop. + +## JavaScript (Browser / Node.js) + +```javascript +// ── Configuration ──────────────────────────────────────────────────────────── +const API_TOKEN = 'YOUR-API-TOKEN'; +const BASE_URL = 'https://api.fulcrumapp.com/api/v2'; + +// IDs of the apps you want to update +const FORM_IDS = [ + 'YOUR-FORM-ID-1', + 'YOUR-FORM-ID-2', + 'YOUR-FORM-ID-3' +]; + +const HEADERS = { + 'Content-Type': 'application/json', + 'X-ApiToken': API_TOKEN +}; + +// ── Apply your changes here ─────────────────────────────────────────────────── +function applyChanges(form) { + // Example: append a suffix to every app name + // form.name = form.name + ' (Archived)'; + + // Example: set a description + // form.description = 'Managed by the SE team. Contact se@example.com for access.'; + + // Example: disable the title field + // form.auto_assign = false; + + return form; +} + +// ── Fetch, modify, and save each form ──────────────────────────────────────── +async function updateForms(formIds) { + for (const formId of formIds) { + try { + // 1. Fetch the current form schema + const getRes = await fetch(`${BASE_URL}/forms/${formId}.json`, { + headers: HEADERS + }); + if (!getRes.ok) throw new Error(`GET failed: ${getRes.status}`); + const { form } = await getRes.json(); + + console.log(`Updating: ${form.name} (${form.id})`); + + // 2. Apply your modifications + const updated = applyChanges(form); + + // 3. Save the updated form back to Fulcrum + const putRes = await fetch(`${BASE_URL}/forms/${form.id}.json`, { + method: 'PUT', + headers: HEADERS, + body: JSON.stringify({ form: updated }) + }); + if (!putRes.ok) throw new Error(`PUT failed: ${putRes.status}`); + + console.log(` ✅ Saved`); + + } catch (err) { + console.error(` ❌ ${formId} — ${err.message}`); + } + } + + console.log('\nDone.'); +} + +updateForms(FORM_IDS); +``` + +## Python Version + +```python +import requests + +API_TOKEN = 'YOUR-API-TOKEN' +BASE_URL = 'https://api.fulcrumapp.com/api/v2' +HEADERS = {'Content-Type': 'application/json', 'X-ApiToken': API_TOKEN} + +FORM_IDS = [ + 'YOUR-FORM-ID-1', + 'YOUR-FORM-ID-2', +] + +def apply_changes(form): + # Modify the form dict here — examples: + # form['name'] = form['name'] + ' (Archived)' + # form['description'] = 'Updated by migration script.' + return form + +for form_id in FORM_IDS: + try: + # Fetch + r = requests.get(f"{BASE_URL}/forms/{form_id}.json", headers=HEADERS) + r.raise_for_status() + form = r.json()['form'] + print(f"Updating: {form['name']}") + + # Modify + updated = apply_changes(form) + + # Save + r = requests.put(f"{BASE_URL}/forms/{form_id}.json", + headers=HEADERS, json={'form': updated}) + r.raise_for_status() + print(f" ✅ Saved") + + except Exception as e: + print(f" ❌ {form_id} — {e}") + +print("Done.") +``` + +## Update All Forms in an Organization + +To apply a change to every app in your organization rather than a specific list: + +```python +# Fetch all forms +all_forms = requests.get(f"{BASE_URL}/forms.json", headers=HEADERS).json()['forms'] + +for form in all_forms: + form_id = form['id'] + # Fetch full schema (the list endpoint returns minimal data) + full = requests.get(f"{BASE_URL}/forms/{form_id}.json", headers=HEADERS).json()['form'] + updated = apply_changes(full) + requests.put(f"{BASE_URL}/forms/{form_id}.json", + headers=HEADERS, json={'form': updated}).raise_for_status() + print(f"✅ {full['name']}") +``` + +## Common applyChanges Patterns + +**Append text to app names:** +```javascript +form.name = form.name.replace(/\s+\(OLD\)$/, '') + ' (NEW)'; +``` + +**Set a consistent status field configuration:** Modify `form.status_field` to set the label, default value, or choices for the built-in status field. + +**Enable/disable a setting globally:** Set properties like `form.auto_assign`, `form.allow_update_location`, or `form.geometry_required` to the same value across all apps. + +## Notes + +**Always fetch before modifying.** The Forms API requires the full form schema in PUT requests — you can't send a partial update. Fetch the current form first to avoid accidentally overwriting fields. + +**Test on one app first.** Before running on all forms, test your `applyChanges` function on a single app to confirm the modification looks correct. + +**Role requirement:** Only Organization Owners and Administrators can modify form schemas via the API. diff --git a/docs/integrations/integration-examples/identify-managed-vs-nonmanaged-users.md b/docs/integrations/integration-examples/identify-managed-vs-nonmanaged-users.md new file mode 100644 index 00000000..dcdeb0df --- /dev/null +++ b/docs/integrations/integration-examples/identify-managed-vs-nonmanaged-users.md @@ -0,0 +1,96 @@ +--- +title: Identify Managed vs. Non-Managed Users via the API +excerpt: Use the Fulcrum Memberships API with the managed_info=1 parameter to retrieve extended SSO and user management metadata for every member of your organization — useful for auditing which users are managed through your identity provider versus self-managed. +--- + +When an organization uses SSO (Single Sign-On) with Fulcrum, some members are "managed" users whose accounts are controlled by the identity provider, while others are "non-managed" users who log in with a Fulcrum-native password. The Memberships API exposes this distinction via the undocumented `managed_info=1` query parameter. + +## Request + +``` +GET https://api.fulcrumapp.com/api/v2/memberships.json?page=1&per_page=20000&managed_info=1 +``` + +**Headers:** +``` +X-ApiToken: YOUR-API-TOKEN +``` + +## Python Example + +```python +import requests +import json + +API_TOKEN = 'YOUR-API-TOKEN' + +url = 'https://api.fulcrumapp.com/api/v2/memberships.json' +params = { + 'page': 1, + 'per_page': 20000, + 'managed_info': 1 +} +headers = { + 'Accept': 'application/json', + 'X-ApiToken': API_TOKEN +} + +response = requests.get(url, headers=headers, params=params) +response.raise_for_status() + +memberships = response.json().get('memberships', []) + +# Separate managed vs. non-managed +managed = [m for m in memberships if m.get('managed')] +nonmanaged = [m for m in memberships if not m.get('managed')] + +print(f"Total members: {len(memberships)}") +print(f"Managed users: {len(managed)}") +print(f"Non-managed: {len(nonmanaged)}") +print() + +print("--- Non-Managed Users ---") +for m in nonmanaged: + print(f" {m['name']} <{m['email']}> status={m['status']}") +``` + +## Export to CSV + +```python +import csv + +with open('memberships.csv', 'w', newline='') as f: + writer = csv.DictWriter(f, fieldnames=['name', 'email', 'status', 'role', 'managed']) + writer.writeheader() + for m in memberships: + writer.writerow({ + 'name': m.get('name', ''), + 'email': m.get('email', ''), + 'status': m.get('status', ''), + 'role': m.get('role_name', ''), + 'managed': m.get('managed', False) + }) + +print("Exported to memberships.csv") +``` + +## Response Fields + +The `managed_info=1` parameter adds a `managed` boolean field to each membership object in the response. A value of `true` indicates the user's account is provisioned and controlled via SAML/SSO. Other relevant fields include: + +| Field | Description | +|---|---| +| `id` | Membership UUID | +| `name` | User's display name | +| `email` | User's email address | +| `status` | `active`, `inactive`, or `pending` | +| `role_name` | The user's assigned role | +| `managed` | `true` if SSO-managed, `false` otherwise | + +## Notes + +**`managed_info=1` is not documented in the public API reference.** It was discovered through testing. The behavior may change across Fulcrum API versions — test in a staging environment before relying on it in production automations. + +**Pagination:** The `per_page=20000` value retrieves up to 20,000 members in a single request, which covers most organizations. For very large orgs, implement pagination by incrementing `page` while the response contains `memberships`. + +**Requires org-level credentials.** The API token must belong to an Owner or Administrator to view all memberships. 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..805bce79 --- /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 ───────────────────────────────────────────────name──────────────────── +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/docs/integrations/integration-examples/sequential-numbering-with-google-apps-script.md b/docs/integrations/integration-examples/sequential-numbering-with-google-apps-script.md new file mode 100644 index 00000000..b7edbd78 --- /dev/null +++ b/docs/integrations/integration-examples/sequential-numbering-with-google-apps-script.md @@ -0,0 +1,129 @@ +--- +title: Sequential Record Numbering with Google Apps Script and Webhooks +excerpt: Deploy a Google Apps Script web app as a Fulcrum webhook endpoint to automatically assign incrementing sequential numbers to new records as they are created — useful for inspection numbers, work order IDs, and any field that requires a guaranteed unique sequential identifier. +--- + +Fulcrum doesn't natively auto-increment a number field, but you can implement sequential numbering using Fulcrum Webhooks and a Google Apps Script web app. When a new record is created, Fulcrum POSTs the payload to your script, which queries the current maximum value, increments it, and PATCHes the record with the next number. + +## How It Works + +1. A new Fulcrum record is created (mobile or web). +2. Fulcrum fires a `record.create` webhook to your Google Apps Script URL. +3. The script checks whether the sequential number field is already set. If not, it queries the Fulcrum Query API for the current maximum value, adds 1, and PATCHes the record via the Records API. + +## Google Apps Script + +Create a new [Google Apps Script](https://script.google.com/) project, paste the code below, and deploy it as a web app (**Deploy → New Deployment → Web app → Execute as: Me → Who has access: Anyone**). + +```javascript +// ── Configuration ───────────────────────────────────────────────────────────── +var FULCRUM_API_TOKEN = 'YOUR-API-TOKEN'; +var FORM_ID = 'YOUR-FORM-ID'; +var SEQUENTIAL_FIELD_KEY = 'YOUR_FIELD_KEY'; // Short key of the number field (e.g., 'ab12') +var SEQUENTIAL_FIELD_DATA_NAME = 'inspection_number'; // data_name of the same field + +// ── Webhook entry point ─────────────────────────────────────────────────────── +function doPost(e) { + return handleResponse(e); +} + +function handleResponse(e) { + var payload = JSON.parse(e.postData.getDataAsString()); + + // Only act on record.create events for this form, and only if the field is empty + if ( + payload.data.form_id === FORM_ID && + !payload.data.form_values[SEQUENTIAL_FIELD_KEY] + ) { + updateRecord(payload.data); + } + + return ContentService.createTextOutput('OK'); +} + +// ── Get the next sequential number ─────────────────────────────────────────── +function getNextNumber() { + var query = 'SELECT ' + SEQUENTIAL_FIELD_DATA_NAME + + ' FROM "' + FORM_ID + '"' + + ' WHERE ' + SEQUENTIAL_FIELD_DATA_NAME + ' IS NOT NULL' + + ' ORDER BY ' + SEQUENTIAL_FIELD_DATA_NAME + ' DESC LIMIT 1'; + + var options = { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-ApiToken': FULCRUM_API_TOKEN + }, + payload: JSON.stringify({ q: query, format: 'json' }) + }; + + var response = UrlFetchApp.fetch('https://api.fulcrumapp.com/api/v2/query', options); + var rows = JSON.parse(response.getContentText()).rows; + + if (rows.length > 0) { + return parseInt(rows[0][SEQUENTIAL_FIELD_DATA_NAME], 10) + 1; + } + return 1; // First record +} + +// ── PATCH the record with the next number ───────────────────────────────────── +function updateRecord(recordData) { + var nextNumber = getNextNumber(); + var url = 'https://api.fulcrumapp.com/api/v2/records/' + recordData.id + '.json'; + + // Fetch the current record to get the full form_values payload + var getOptions = { + method: 'GET', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-ApiToken': FULCRUM_API_TOKEN + } + }; + + var recordJson = JSON.parse(UrlFetchApp.fetch(url, getOptions).getContentText()); + + // Set the sequential number field + recordJson.record.form_values[SEQUENTIAL_FIELD_KEY] = nextNumber.toString(); + + var putOptions = { + method: 'PUT', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'X-ApiToken': FULCRUM_API_TOKEN + }, + payload: JSON.stringify(recordJson.record) + }; + + UrlFetchApp.fetch(url, putOptions); + Logger.log('Assigned number ' + nextNumber + ' to record ' + recordData.id); +} +``` + +## Setup Steps + +1. Deploy the script as a web app (see above). Copy the deployment URL. +2. In Fulcrum, go to **Settings → Webhooks** and add a new webhook: + - **URL:** Your Google Apps Script deployment URL + - **Events:** Select **Record Created** only +3. Create a test record in your app. After a few seconds, the sequential number field should populate automatically. + +## Finding Your Field Key + +The field key is a short alphanumeric string visible in the App Designer when debug mode is enabled. You can also retrieve it from the form schema: + +```bash +curl -H "X-ApiToken: YOUR-API-TOKEN" \ + https://api.fulcrumapp.com/api/v2/forms/YOUR-FORM-ID.json \ + | python3 -c "import sys,json; [print(e['key'], e['data_name']) for e in json.load(sys.stdin)['form']['elements'] if 'key' in e]" +``` + +## Notes + +**Race conditions:** If two records are created within milliseconds of each other, there is a small risk of both receiving the same number. For most field use cases this is negligible, but if strict uniqueness is critical, add a small random delay or use a Google Sheets row as a distributed counter instead. + +**The webhook fires on every create, not just mobile.** Records created via the API or the web app also trigger the webhook. The `!payload.data.form_values[key]` guard prevents overwriting a field that was already set. + +**`SEQUENTIAL_FIELD_KEY` vs. `SEQUENTIAL_FIELD_DATA_NAME`:** The key (e.g., `ab12`) is used to read/write `form_values` directly in the record payload. The data_name (e.g., `inspection_number`) is used in SQL queries. Both refer to the same field. diff --git a/docs/integrations/integration-examples/sort-repeatable-rows-by-field-value.md b/docs/integrations/integration-examples/sort-repeatable-rows-by-field-value.md new file mode 100644 index 00000000..0b1a7540 --- /dev/null +++ b/docs/integrations/integration-examples/sort-repeatable-rows-by-field-value.md @@ -0,0 +1,101 @@ +--- +title: Sort Repeatable Rows by Field Value via the API +excerpt: Use the Fulcrum Python SDK to fetch records, sort the rows of a repeatable section by a specified field value, and write the new order back via the API — useful for standardizing data presentation when rows were entered out of order. +--- + +Fulcrum stores repeatable rows in the order they were entered and doesn't provide a native sort mechanism. This script uses the Fulcrum Python SDK to fetch records, sort their repeatable rows by any field value, and PATCH the records back with the corrected order. + +## Prerequisites + +```bash +pip install fulcrum +``` + +## Script + +```python +from fulcrum import Fulcrum + +# ── Configuration ───────────────────────────────────────────────────────────── +API_TOKEN = 'YOUR-API-TOKEN' +REPEATABLE_KEY = 'YOUR_REPEATABLE_FIELD_KEY' # Key of the repeatable field to sort +SORT_FIELD_KEY = 'YOUR_SORT_FIELD_KEY' # Key of the field inside the repeatable to sort by +RECORD_IDS = ['record-id-1', 'record-id-2'] # List of record IDs to process + +fulcrum = Fulcrum(key=API_TOKEN) + + +def get_sorted_indexes(repeatable_rows, sort_field_key): + """ + Returns a list of indexes that, when applied, put the repeatable rows + in ascending order by the value of sort_field_key. + + Raises if duplicate sort values are found (sort key must be unique per record). + """ + value_to_index = {} + for index, row in enumerate(repeatable_rows): + sort_value = row['form_values'].get(sort_field_key) + if sort_value in value_to_index: + raise ValueError(f"Duplicate sort value '{sort_value}' — sort field must be unique within the repeatable.") + value_to_index[sort_value] = index + + sorted_keys = sorted(value_to_index.keys()) + return [value_to_index[k] for k in sorted_keys] + + +def sort_repeatable_rows(record_id): + record = fulcrum.records.find(record_id) + old_rows = record['record']['form_values'].get(REPEATABLE_KEY, []) + + if not old_rows: + print(f" Skipping {record_id} — repeatable is empty.") + return + + new_order = get_sorted_indexes(old_rows, SORT_FIELD_KEY) + new_rows = [old_rows[i] for i in new_order] + + record['record']['form_values'][REPEATABLE_KEY] = new_rows + fulcrum.records.update(record_id, record) + print(f" ✅ {record_id} — sorted {len(new_rows)} rows.") + + +# ── Run ─────────────────────────────────────────────────────────────────────── +print(f"Sorting {len(RECORD_IDS)} records...") +for rid in RECORD_IDS: + try: + sort_repeatable_rows(rid) + except Exception as e: + print(f" ❌ {rid} — {e}") + +print("Done.") +``` + +## Finding Field Keys + +Field keys are short alphanumeric strings (e.g., `6d81`, `3e2f`) that Fulcrum assigns to each field. You can find them in the App Designer's debug mode (enable with `window.localStorage.setItem('app-designer-debug-mode', true); location.reload()`) or by fetching the form schema: `fulcrum.forms.find('YOUR-FORM-ID')`. + +## Extending to All Records in a Form + +To sort repeatables across every record in a form rather than a specific list of IDs: + +```python +FORM_ID = 'YOUR-FORM-ID' + +page = 1 +while True: + result = fulcrum.records.search(url_params={'form_id': FORM_ID, 'page': page, 'per_page': 100}) + records = result.get('records', []) + if not records: + break + for record in records: + sort_repeatable_rows(record['id']) + page += 1 +``` + +## Notes + +**Sort values must be unique within a record.** The script raises if two repeatable rows share the same sort value. For numeric sequences, dates, or ordered codes this is typically guaranteed. For non-unique fields (e.g., a status), prefer sorting by two fields: sort the value list yourself as tuples before mapping back to indexes. + +**This modifies records.** Test on a small set of records first. Consider filtering to a project or date range rather than running on all records at once. + +**The Fulcrum API enforces record validation.** If any required fields in the repeatable are empty, the PATCH may be rejected. Ensure the records are otherwise valid before running the sort. diff --git a/docs/slack-posts-for-review.md b/docs/slack-posts-for-review.md index 23294ba5..dc76735a 100644 --- a/docs/slack-posts-for-review.md +++ b/docs/slack-posts-for-review.md @@ -24,20 +24,14 @@ These posts are either for internal SE team use, customer-specific without gener | Date | Creator | Description | Reason | |---|---|---|---| -| Jul 31, 2024 | Peter | Reorder repeatables by field value (Ocular) | Customer-specific field keys; needs full rewrite | | Aug 1, 2024 | Mike | Power BI dynamic date table (Merjent) | Power BI M formula, not Fulcrum API | | Aug 2, 2024 | Kyle | React Report Template (Omnicell) | Customer-specific template, too large without context | | Aug 19, 2024 | Kyle | Satellite imagery with WMS in QGIS | QGIS tutorial, not Fulcrum API | -| Aug 28, 2024 | Kyle | Python script for Fulcrum DB backup | Internal utility | -| Aug 30, 2024 | Kyle | Managed vs. non-managed users from API | Internal admin utility | -| Sep 2, 2024 | Diego C. | Convert shapefiles to MBTiles (SCE) | QGIS tutorial, not Fulcrum API | -| Sep 13, 2024 | Diego C. | Bulk update multiple forms via script | Internal migration tool | +| Aug 28, 2024 | Kyle | Python script for Fulcrum DB backup | Code in .ipynb attachment; cannot be extracted without download | | Sep 18, 2024 | Israel Perez | Python extract assets from PDF | General Python, not Fulcrum-specific | -| Sep 20, 2024 | Mike | Pull data from ESRI feature service to CSV | Specific integration, narrow use | | Sep 24, 2024 | Mike | "This is mikes code share" | Test post | | Sep 25, 2024 | Kyle | Complex React App with Login | Complex, customer-specific | | Oct 3, 2024 | Mike | Restore deleted form with Fulcrum CLI | Very short CLI tip | -| Oct 8, 2024 | Mike | Count users via changesets (Query API) | Short SQL query | | Oct 9, 2024 | Kyle | Clear out all clearable fields | Short data event utility | | Oct 14, 2024 | Kyle | Split large CSV for importing | Python utility, generic | | Nov 1, 2024 | Kyle | Spatial join query (PostGIS) | SQL example, short | @@ -128,3 +122,13 @@ These posts are either for internal SE team use, customer-specific without gener | `reports-examples/save-pdf-on-mobile-and-desktop.md` | @Mike | Report Builder | | `utilities-examples/download-choice-list-as-csv.md` | @Mike | Utilities | | `integration-examples/trigger-export-via-api.md` | @Mike | Integrations | +| `integration-examples/sort-repeatable-rows-by-field-value.md` | @Peter | Integrations | +| `app-extension-examples/add-text-caption-to-photo.md` | @Israel Perez | App Extensions | +| `map-layers-examples/convert-raster-and-vector-data-to-mbtiles.md` | @Diego C. / @Mike | Map Layers | +| `integration-examples/identify-managed-vs-nonmanaged-users.md` | @Kyle Pennell | Integrations | +| `integration-examples/bulk-update-app-settings.md` | @Diego C. | Integrations | +| `query-api-examples/count-active-users-by-changesets.md` | @Mike | Query API | +| `integration-examples/sequential-numbering-with-google-apps-script.md` | @Mike | Integrations | +| `integration-examples/import-esri-feature-service-to-fulcrum.md` | @Mike | Integrations | +| `utilities-examples/print-record-link-dependencies.md` | @Kyle Pennell | Utilities | +| `utilities-examples/visualize-record-link-network.md` | @Kyle Pennell | Utilities |