Adding Interactive Content to FSM Mobile

Use imported JavaScript to add interactive controls to custom content in FSM Mobile. For customization responsibilities and shared limits, see FSM Mobile Scripting and Customization.

For file registration, see Changing Registered Imports. For configured HTML and styling, see Configuring HTML and Styles in FSM Mobile.

In this topic:

FSM Mobile renders and replaces content within a single page. Monitor your own placeholders with MutationObserver, initialize content already present, and release attached work when it is removed. Imported page code is separate from pure expression-callable functions. Do not run DOM changes or asynchronous work through an expression.

The following examples use a small shared helper followed by a button script and an optional compass script. These files are complete examples. Their customMobile namespace and lifecycle methods are defined here by the customization. These methods are not supplied FSM callbacks.

Shared Content Lifecycle

Store the following helper as custom-content.js. A mount function returns its owned element and a cleanup function. The helper handles initial content, later insertion, replacement, duplicate observer notifications, and the browser's pagehide and pageshow events. The helper monitors the document but changes only matching custom placeholders.

            (() => {
  "use strict";
  // This namespace belongs to these examples, not to FSM.
  window.customMobile?.dispose();
  if (!window.MutationObserver || !document.body) return;
  const registrations = new Set();
  let paused = false;

  function observe(selector, mount) {
    const active = new Map();
    function clear() {
      for (const item of active.values()) item.dispose();
      active.clear();
    }
    function scan() {
      for (const [host, item] of active) {
        if (!host.isConnected || !host.matches(selector) || item.element.parentNode !== host) {
          item.dispose();
          active.delete(host);
        }
      }
      for (const host of document.querySelectorAll(selector)) {
        if (!active.has(host)) active.set(host, mount(host));
      }
    }
    const observer = new MutationObserver(scan);
    const registration = {
      pause() { observer.disconnect(); clear(); },
      resume() {
        observer.observe(document.body, {
          childList: true, subtree: true,
          attributes: true, attributeFilter: ["class"]
        });
        scan();
      }
    };
    registrations.add(registration);
    if (!paused) registration.resume();
    return () => {
      registration.pause();
      registrations.delete(registration);
    };
  }

  const hide = () => {
    paused = true;
    for (const item of registrations) item.pause();
  };
  const show = () => {
    if (!paused) return;
    paused = false;
    for (const item of registrations) item.resume();
  };
  function dispose() {
    hide();
    registrations.clear();
    window.removeEventListener("pagehide", hide);
    window.removeEventListener("pageshow", show);
  }
  window.addEventListener("pagehide", hide);
  window.addEventListener("pageshow", show);
  window.customMobile = { observe, dispose };
})(); 

          

The observe() function also returns a function that stops that registration. The customMobile.dispose() function disconnects every registration and removes its page listeners. Use these cleanup functions when explicitly unloading the custom code. For the browser observation mechanism, refer to MutationObserver.

Button Example

Store button.js at the path shown in the following example and register the helper first. Merge this fragment into the existing configuration. It adds an owned placeholder to the Details resource, with the current record's internal ID supplied as data:

            {
  "routes/mobile": {
    "import": {
      "customContent_v1": "/SuiteScripts/FSMCustom/custom-content.js",
      "customButton_v1": "/SuiteScripts/FSMCustom/button.js"
    },
    "details": {
      "article": {
        "map": {
          "customButton": "formulatext:'<div class=\"custom-button\" data-custom-task=\"'||{internalid}||'\"></div>'"
        }
      },
      "element": {
        "customButton": { "type": "html", "readonly": true }
      }
    }
  }
} 

          

If the intended resource is not already selected, see Selecting Resources. No resource-rule replacement is needed for this fragment itself.

The complete button.js file is:

            (() => {
  "use strict";
  const custom = window.customMobile;
  if (!custom) return;
  custom.stopButton?.();
  custom.stopButton = custom.observe(".custom-button", (host) => {
    const element = document.createElement("div");
    element.className = "custom-button-content";
    const button = document.createElement("button");
    button.type = "button";
    button.textContent = "Show task reference";
    const output = document.createElement("p");
    output.setAttribute("role", "status");
    const click = () => {
      const id = host.dataset.customTask;
      output.textContent = id ? `Task reference: ${id}` : "Task reference unavailable.";
    };
    button.addEventListener("click", click);
    element.append(button, output);
    host.append(element);
    return {
      element,
      dispose() {
        button.removeEventListener("click", click);
        element.remove();
      }
    };
  });
})(); 

          

The button displays only the value explicitly passed through the custom attribute. Its output is temporary custom UI state. It does not read FSM state, write a record, or call an FSM endpoint. Removed controls have their listeners detached, and a later replacement placeholder gets one fresh control.

Compass Example

This example combines an observed placeholder, a Web Component, a shadow root, and device location. It calculates an initial bearing to the task coordinates. It uses a north-up dial when a recent absolute device heading is unavailable. Relative orientation alone is insufficient to identify north.

The example requires HTTPS, geolocation permission, and browser support for Custom Elements, Shadow DOM, and MutationObserver. Optional device orientation support varies. Permission requests occur from Start compass, including the orientation permission API where implemented. For more information, refer to watchPosition and orientation permission.

This fragment replaces the CRM Task event body. Its numeric coordinate fields are custevent_nx_latitude and custevent_nx_longitude. Project Tasks need their own mapping under event.article.projecttask.map. Configuring the CRM Task branch does not change Project Tasks.

            {
  "routes/mobile": {
    "import": {
      "customContent_v1": "/SuiteScripts/FSMCustom/custom-content.js",
      "customCompass_v1": "/SuiteScripts/FSMCustom/compass.js"
    },
    "event": {
      "article": {
        "task": {
          "map": {
            "body": "formulatext:'<div class=\"custom-compass\" data-custom-latitude=\"'||{custevent_nx_latitude}||'\" data-custom-longitude=\"'||{custevent_nx_longitude}||'\"></div>'"
          }
        }
      }
    }
  }
} 

          

Append the marker to an existing body formula when other event details must remain. The shared helper needs only one registration even when both examples are used.

Store the following complete file as compass.js:

            (() => {
  "use strict";
  if (!window.customMobile || !window.customElements || !HTMLElement.prototype.attachShadow) return;
  const tag = "custom-coordinate-compass-v1";
  const valid = (lat, lon) => Number.isFinite(lat) && Number.isFinite(lon) &&
    Math.abs(lat) <= 90 && Math.abs(lon) <= 180;
  const number = (text) => text === null || text.trim() === "" ? NaN : Number(text);
  const normalize = (angle) => (angle % 360 + 360) % 360;

  function bearing(lat1, lon1, lat2, lon2) {
    const radians = Math.PI / 180;
    const a = lat1 * radians, b = lat2 * radians;
    const d = (lon2 - lon1) * radians;
    const x = Math.sin(d) * Math.cos(b);
    const y = Math.cos(a) * Math.sin(b) - Math.sin(a) * Math.cos(b) * Math.cos(d);
    return Math.hypot(x, y) < 1e-12 ? null : normalize(Math.atan2(x, y) / radians);
  }

  class CustomCompass extends HTMLElement {
    static get observedAttributes() { return ["latitude", "longitude"]; }
    constructor() {
      super();
      this.attachShadow({ mode: "open" }).innerHTML = `
        <style>
          :host { display:block; max-width:250px; margin:0 auto; color:inherit; }
          button { font:inherit; margin:0.25rem; padding:0.5rem; }
          .dial { width:150px; height:150px; border:1px solid currentColor;
            border-radius:50%; position:relative; margin:0.5rem auto; }
          .needle { position:absolute; width:4px; height:65px; bottom:50%;
            left:calc(50% - 2px); background:#b42318; transform-origin:center bottom; }
          .north { position:absolute; top:2px; left:calc(50% - 0.4em); }
        </style>
        <button type="button" class="start">Start compass</button>
        <button type="button" class="stop" disabled>Stop</button>
        <div class="dial" aria-hidden="true">
          <span class="north">N</span><span class="needle" hidden></span>
        </div>
        <p role="status">Press Start compass to use this device's location.</p>`;
      const root = this.shadowRoot;
      this.startButton = root.querySelector(".start");
      this.stopButton = root.querySelector(".stop");
      this.needle = root.querySelector(".needle");
      this.north = root.querySelector(".north");
      this.status = root.querySelector("[role=status]");
      this.watch = null;
      this.timer = null;
      this.generation = 0;
      this.active = false;
      this.startButton.addEventListener("click", () => this.start());
      this.stopButton.addEventListener("click", () => this.stop("Stopped."));
      this.orientation = (event) => {
        const level = Number.isFinite(event.beta) && Number.isFinite(event.gamma) &&
          Math.abs(event.beta) <= 15 && Math.abs(event.gamma) <= 15;
        this.heading = event.absolute === true && Number.isFinite(event.alpha) && level &&
          window.screen.orientation?.angle === 0
          ? { value: normalize(360 - event.alpha), time: Date.now() } : null;
        this.render();
      };
      this.visibility = () => { if (document.hidden) this.stop("Stopped while page hidden."); };
    }
    connectedCallback() { document.addEventListener("visibilitychange", this.visibility); }
    disconnectedCallback() {
      this.stop("Stopped.");
      document.removeEventListener("visibilitychange", this.visibility);
    }
    attributeChangedCallback(name, oldValue, newValue) {
      if (oldValue !== newValue && this.status) this.stop("Destination changed. Press Start compass.");
    }
    target() {
      return [number(this.getAttribute("latitude")), number(this.getAttribute("longitude"))];
    }
    stop(message) {
      this.active = false;
      this.generation++;
      if (this.watch !== null) navigator.geolocation.clearWatch(this.watch);
      if (this.timer !== null) clearInterval(this.timer);
      this.watch = this.timer = null;
      this.position = this.heading = null;
      window.removeEventListener("deviceorientationabsolute", this.orientation);
      window.removeEventListener("deviceorientation", this.orientation);
      this.startButton.disabled = false;
      this.stopButton.disabled = true;
      this.needle.hidden = true;
      this.north.textContent = "N";
      if (message) this.status.textContent = message;
    }
    async start() {
      this.stop();
      if (!valid(...this.target())) { this.stop("Destination coordinates unavailable."); return; }
      if (!window.isSecureContext || !navigator.geolocation) {
        this.stop("Location needs HTTPS and a browser with geolocation."); return;
      }
      this.active = true;
      const run = this.generation;
      this.startButton.disabled = true;
      this.stopButton.disabled = false;
      this.status.textContent = "Waiting for location. Allow access if prompted.";
      // Invoke the permission API directly from the button's user gesture.
      let permission = Promise.resolve(false);
      const Orientation = window.DeviceOrientationEvent;
      try {
        if (Orientation) permission = typeof Orientation.requestPermission === "function"
          ? Orientation.requestPermission(true).then((value) => value === "granted", () => false)
          : Promise.resolve(true);
        this.watch = navigator.geolocation.watchPosition((position) => {
          if (!this.active || run !== this.generation || !this.isConnected) return;
          const { latitude, longitude } = position.coords;
          this.position = valid(latitude, longitude)
            ? { latitude, longitude, time: Date.now() } : null;
          this.render();
        }, (error) => {
          if (this.active && run === this.generation)
            this.stop(`Location unavailable (code ${error.code}). Press Start compass to retry.`);
        }, { enableHighAccuracy: true, maximumAge: 0, timeout: 15000 });
        this.timer = setInterval(() => this.render(), 1000);
      } catch {
        this.stop("Location could not start."); return;
      }
      if (await permission && this.active && run === this.generation && this.isConnected) {
        window.addEventListener("deviceorientationabsolute", this.orientation);
        window.addEventListener("deviceorientation", this.orientation);
      }
    }
    render() {
      if (!this.active) return;
      const position = this.position;
      if (!position || Date.now() - position.time > 30000) {
        this.needle.hidden = true;
        this.status.textContent = "Waiting for a recent valid location."; return;
      }
      const angle = bearing(position.latitude, position.longitude, ...this.target());
      if (angle === null) {
        this.needle.hidden = true;
        this.status.textContent = "No unique bearing at this position."; return;
      }
      const heading = this.heading && Date.now() - this.heading.time < 3000 ? this.heading.value : null;
      const relative = heading !== null;
      this.north.textContent = relative ? "" : "N";
      this.needle.hidden = false;
      this.needle.style.transform = `rotate(${normalize(angle - (heading ?? 0))}deg)`;
      const message = `Bearing ${angle.toFixed(0)} deg from north. ` +
        (relative ? "Arrow relative to phone top." : "North-up dial; heading unavailable or phone not flat/portrait.");
      if (this.status.textContent !== message) this.status.textContent = message;
    }
  }
  if (!customElements.get(tag)) customElements.define(tag, CustomCompass);

  window.customMobile.stopCompass?.();
  window.customMobile.stopCompass = window.customMobile.observe(".custom-compass", (host) => {
    const element = document.createElement(tag);
    function update() {
      for (const name of ["latitude", "longitude"]) {
        const value = host.getAttribute(`data-custom-${name}`) ?? "";
        if (element.getAttribute(name) !== value) element.setAttribute(name, value);
      }
    }
    update();
    host.append(element);
    const observer = new MutationObserver(update);
    observer.observe(host, { attributes: true,
      attributeFilter: ["data-custom-latitude", "data-custom-longitude"] });
    return { element, dispose() { observer.disconnect(); element.remove(); } };
  });
})(); 

          

The component rejects missing, nonnumeric, or out-of-range coordinates while accepting zero. It stops the location watch, timer, and orientation listeners when stopped, removed, hidden, or given a changed destination. A pending permission result cannot restart a stopped instance. Its attribute observer is disconnected when the placeholder is removed.

For the optional phone-relative arrow, hold the device flat in portrait orientation. The example accepts absolute orientation only and uses the horizontal-device heading relationship described by Device Orientation and Motion. It falls back to north-up when the screen angle, tilt, or heading freshness does not meet its conditions. Sensor accuracy, calibration, and browser policy affect the result. This widget is illustrative and does not provide turn-by-turn navigation.

The widget's location watch belongs to the customization. It does not use or update FSM's Employee tracking, arrival and departure timestamps, or task state. For those product capabilities, see FSM Maps and Location Tracking.

Custom element connection and disconnection callbacks are browser lifecycle hooks. These callbacks do not indicate that an FSM record saved or a resource completed. For more information, refer to Using custom elements. Reload the page after changing a registered element's implementation. Do not try to redefine its existing tag in the same document.

Additional Information

Related Topics

General Notices