Description

A common design pattern is to allow users to add images and text to a grid, preview the images in the grid, and edit the selected row of the grid in place using form fields. Consider the following example in which an inspector uploads one or more photos of a violation, describes the violation, and adds the entry to an Added Photos grid:



The inspector may add as many rows of data as they want, and then by selecting a row in the grid they can modify the Photo and/or Description. Pressing the "Save Changes" button will update the existing row with the new data.



Note that the grid contains a Photo column with a thumbnail of the image. Because this is a smaller version of the image, its resolution can be significantly reduced in order to reduce the memory usage of the form as well as the size of the submitted session file. The original, full-resolution image is stored within the Photo field (which is an Image Annotation field) regardless of whether it is selected and visible.


Implementation

Overview

In this example, the field ID of the Photo Image Annotation field is "Photo," the Description field is "PhotoDescription," the Add Photo button is "AddPhoto," and the grid for Added Photos is "PhotoGrid." The Photo field has its property set to Allow Multiple Images, and the PhotoGrid field has three columns - the first is an Image type and the third is a non-visible column titled "Guid."


Behind the scenes, the Photo field will contain all images for all entries and will use its internal filter to only display the photo for the currently-selected grid row. Image Annotation fields are able to filter their contents using the _form.imageAnnotationTagFilter method. Each grid entry (photo and description) is assigned a Guid as a unique identifier for that record, which is also equal to the AttachmentID that is automatically assigned to each photo added to the Photo Image Annotation field. This Guid is saved in the hidden grid column and used to trigger which photos should be displayed in the Photo field. Finally, the same button is used for Add Photo and Save Changes, only its caption changes depending on whether an item is selected in PhotoGrid.


As previously mentioned, the original full-resolution images are stored in the Photo Image Annotation field. The first column of the grid contains a thumbnail of the image, which is a reduced resolution version for informational purposes.


Define Constants

First define some constants in client script.


const IMAGEANNOTATION_DUMMYTAG = "DUMMYVALUE"// used as placeholder tag filter for image annotations
const PHOTOSBUTTONCAPTION_ADD = "Add Photo";
const PHOTOSBUTTONCAPTION_EDIT = "Save Changes";


As the comment suggests, the dummy tag is used as a placeholder for the image annotation when no entry is selected. It assigns a dummy value as the filter to the Photo field, effectively causing it to display nothing. The two captions describe the text to be displayed on the add/edit button and are also used to determine whether an edit is actively occurring.


In the form's EH_AfterOpen method, issue a call to set the Photo field's tag filter, to ensure that no photos are displayed at form startup. A call is also made to handleSummarySelectPhotoGrid in order to initialize the state of the PhotoGrid. This method will be explained below.


// Triggered when the form is opened
function EH_AfterOpen() {

    // select none from image annotation

    _form.imageAnnotationTagFilter("Photo", [IMAGEANNOTATION_DUMMYTAG]);
    handleSummarySelectPhotoGrid();
}


Handle Add Photo Button Press Event

Next handle the event triggered when the "Add Photo" button is pressed, which is done in EH_HotspotPressed


// Triggered when a hotspot is pressed
function EH_HotspotPressed(fieldName) {
    if (fieldName == "AddPhoto") {
        addFeedSumPhoto()
    }
}

The contents of the addFeedSumPhoto() helper function is shown here. Afterwards, the flow of the code is described.


function addFeedSumPhoto() {
    var isAdding = (_form.fieldCaption("AddPhoto") == PHOTOSBUTTONCAPTION_ADD);
    var imgData = _form.imagesGet("Photo");

    // check for required fields
    if (!_form.hasImage("Photo")) {
        _form.showAlert(_form.fieldCaption("Photo") + " is required.");
        return;
    }

    // verify we have not added more than one image; use the current tag filter to indicate the selected image
    // note - when ia filter is active, any new images will automatically inherit the value of the tag we are filtering for
    var guidOfCurrent = _form.imageAnnotationTagFilter("Photo")[0]; // tag filter was set to the id of the selected photo in the grid handler, or dummy val
    var numAdded = imgData.filter(function(data) { return (data.Tags[0].Value == guidOfCurrent) && (data.RemovedDate == null) }).length;

    if (numAdded > 1) {
        _form.showAlert("A maximum of one photo may be added at a time");
        return;
    }

    if (_form.getValue("PhotoDescription") == "") {
        _form.showAlert(_form.fieldCaption("PhotoDescription") + " is required.");
         return;
    }

    // store some info
    var cols = _form.gridColumnData("PhotoGrid");
    var rowData = _form.gridRowData("PhotoGrid");

    // get current row data, then add or edit a row corresponding to the new photo
    var sampleRow = [];

    if (isAdding) {
        // add new sample row
        cols.forEach(function(col) { sampleRow.push(""); }) //default each value to empty string
        rowData.push(sampleRow);
    }
    else {
        // edit existing sample
        var ind = _form.getGridSelectedRowIndex("PhotoGrid");
        if (ind < 0) { return; } //unexpected

        sampleRow = rowData[ind]
    }

    // get index of new photo
    var ind = -1;
    for (var i=0; i<imgData.length; i++) {
        if ((imgData[i].RemovedDate == null) && (imgData[i].Tags[0].Value == guidOfCurrent)) {
            ind = i;
            break;
        }
    }

    if (ind < 0) { return; } // unexpected

    // use AttachmentID as unique ID to identify the photo, which will be used to filter the visible photo
    var guid = imgData[ind].AttachmentID;
    _form.imageSetTags("Photo", ind, [guid])

    // add photo and comment to grid
    _form.imageGetContent("Photo", { index: ind, includeInk: false })
        .then(function(content) {
            if ((content == 'undefined') || (content.length == 0)) {
                return;
            }

            return resizeBase64Img(content, 30);
        })
        .then(function(content) {
            var contentStr = String(content)

            // strip off the mime type
            if (contentStr.startsWith(MIME_TYPE_PNG)) {
                contentStr = contentStr.substring(MIME_TYPE_PNG.length);
            }

            // copy image to grid
            sampleRow[0] = contentStr;
            sampleRow[1] = _form.getValue("PhotoDescription");
            sampleRow[2] = guid;

            // finish up by setting the new row data to the grid
            _form.gridRowData("PhotoGrid", rowData);
        });
}
  • The caption of the AddPhoto button is used to determine whether an item is selected in PhotoGrid (indicating we are editing an existing item) or whether we are adding a new item.
  • imgData represents an array of ImgData objects containing metadata on the image annotation field. Note that it does not contain the image contents itself.
  • As a validation check, we confirm that the user is not adding more than one image as part of this operation. Note that the Image Annotation field does support adding multiple images, but only one image can be added per grid entry.
  • Because the Description is required, an additional validation check is performed (depending on your use case this might not be required).
  • The grid's row and column data are saved for future reference. If a new row is being added then the current row (sampleRow) is initialized as an empty row. If an existing row is being edited, sampleRow is set to point to that row.
  • The current photo is retrieved from the Photo Image Annotation field, and the tag for this image is set to be the AttachmentID of the image. The AttachmentID is automatically assigned by the Image Annotation field when an image is added. Because this represents a unique identifier, it serves double duty as the guid for the entry. By setting the Tag to contain this value, we can later filter the Image Annotation to only show the photo for this entry by setting the Image Annotation's filter to be this value.
  • The image contents are retrieved and added to the grid along with the description. Prior to adding the image contents, those contents are reduced in resolution through a call to the resizeBase64Image method (described below). Note that retrieving the image contents and resizing those contents are asynchronous processes, and must be handled accordingly.
  • After the resized content (thumbnail) is ready, a new row is added to the PhotoGrid containing the thumbnail, description, and GUID.


Handle Grid Update

The EH_AfterSetData event is called when grid contents are updated. This occurs immediately after a new photo entry is added, and also when the user selects a row from the grid.


// Triggered when a field's value changes
function EH_AfterSetData(fieldName) {
    if (fieldName == "PhotoGrid") {
        handleSummarySelectPhotoGrid();
    }
}

The contents of the handleSummarySelectPhotoGrid helper function is shown here. Afterwards, the flow of the code is described.

 

function handleSummarySelectPhotoGrid() {
    var ind = _form.getGridSelectedRowIndex("PhotoGrid");
    var gridRows = _form.gridRowData("PhotoGrid");
    var editMode = (0 <= ind);

    // set caption and move buttons based on whether or not we're in "edit mode," i.e. whether a row is selected
    _form.fieldCaption("AddPhoto", editMode ? PHOTOSBUTTONCAPTION_EDIT : PHOTOSBUTTONCAPTION_ADD);

    // set form fields to selected grid row
    var cols = _form.gridColumnData("PhotoGrid");

    if (editMode) {
        //populate form fields from this row
        var gridRow = gridRows[ind];

        _form.setValue("PhotoDescription", gridRow[1]);
        _form.imageAnnotationTagFilter("Photo", [gridRow[2]]);
    }
    else {
        // selecting none; clear all form fields
        _form.imageAnnotationTagFilter("Photo", [IMAGEANNOTATION_DUMMYTAG]); // will select none from image annotation
        _form.setValue("PhotoDescription", "");
    }
}
  • First the index of the selected row (if any) is retrieved. This determines whether the user is adding a new Photo entry or editing an existing entry. The caption for the add/edit button is set accordingly.
  • If a grid row is selected, the form fields are populated with the contents of the selected grid row. Note that for the Photo Image Annotation, its value is set by specifying the tag filter for the field.
  • If no rows are selected, the form fields are cleared. For the Photo Image Annotation this is done by setting the tag filter to be a dummy value that does not match any of its images.


Reduce Photo Size

The resizeBase64Img function called above serves to create a thumbnail copy of the Photo that is greatly reduced in size, thereby reducing the memory usage of the form session. It is done using the underlying HTML canvas object, which has a built-in method for rescaling. Depending on the aspect ratio of the source image, the resulting thumbnail will be restricted by the provided maximum dimension in either its height or its width.


function resizeBase64Img(base64, maxDim) {
    return new Promise(function(resolve, reject) {
        let img = document.createElement("img");
        img.src = base64;
        img.onload = function () {
            let scale = (img.width > img.height) ? (maxDim / img.width) : (maxDim / img.height);

            var canvas = document.createElement("canvas");
            canvas.width = img.width * scale;
            canvas.height = img.height * scale;

            let context = canvas.getContext("2d");
            context.scale(scale, scale);
            context.drawImage(img, 0, 0);

            resolve(canvas.toDataURL());     
        }
    });
}