The CreatorCon Call for Content is officially open! Get started here.

Trying to implement functionality to upload an attachment in a Service Portal widget

kristenmkar
Mega Sage

Hello! 

I created a custom portal widget that allows a user to download a document residing on a task, upload that document after they download it, and reject the document task if necessary - essentially our custom usage of document tasks.  It is working except for the uploading document piece - currently it looks like it is attaching in the widget, but does not actually attach to the document task record.  I have a Script Includes that closes out the task within the button and that is fine - but I can't figure out why the upload part won't work.  I also haven't been able to successfully re-enable the upload button when an attachment exists- the button will stay disabled with my current html script for some reason.  Finally, I'm not sure why the remove attachment won't show - I am in admin so I don't think its an ACL at this point. 

Here are my scripts if you could take a look! I am sure your giant brains can see what I am doing wrong! 🙂

Thanks in advance! 

 

HTML:

<!-- List Mode -->
<div ng-if="c.data.mode === 'list'" class="doc-task-list">

<!-- Active Tasks -->
<h4 ng-click="c.toggle('active')" class="collapsible-header">
<span ng-class="{'icon-vcr-right': !c.show.active, 'icon-vcr-down': c.show.active}"></span>
Active Tasks <span class="badge">{{c.data.tasks.active.length}}</span>
</h4>
<div ng-show="c.show.active">
<div ng-if="c.data.tasks.active.length === 0">
<p>No active tasks assigned to you.</p>
</div>

<div ng-repeat="task in c.data.tasks.active" class="task-card">
<h5>{{task.short_description}}</h5>
<p>Task: {{task.number}} | Parent: {{task.parent}}</p>
<p>State: <span class="state-badge active">{{task.state_display}}</span></p>

<!-- File upload input -->
<div class="task-card">
SAAR Attachment:
<input type="file" multiple
onchange="angular.element(this).scope().setFiles(this, '{{task.sys_id}}')" />

<div ng-show="taskFiles[task.sys_id].length">
<div ng-repeat="file in taskFiles[task.sys_id]">
<span>{{file.name}}</span>
( <span ng-switch="file.size > 1024*1024">
<span ng-switch-when="true">{{file.size / 1024 / 1024 | number:2}} MB</span>
<span ng-switch-default>{{file.size / 1024 | number:2}} kB</span>
</span> )
<span class="glyphicon glyphicon-remove-circle"
ng-click="removeFiles(file, task.sys_id)"></span>
</div>
</div>
</div>

<!-- Action buttons -->
<div class="action-buttons">
<button class="btn btn-primary" ng-click="c.downloadLatest(task.sys_id)">
Download SAAR Attachment for Signature
</button>
<button class="btn btn-success"
ng-click="uploadFiles(task.sys_id); c.completeTask(task.sys_id)"
ng-disabled="!taskFiles[task.sys_id] || !taskFiles[task.sys_id].length">
Upload PDF and Complete SAAR Signature
</button>
<button class="btn btn-danger" ng-click="c.rejectTask(task.sys_id)">
Reject SAAR
</button>
</div>
</div>
</div>

<!-- Completed Tasks -->
<h4 ng-click="c.toggle('completed')" class="collapsible-header">
<span ng-class="{'icon-vcr-right': !c.show.completed, 'icon-vcr-down': c.show.completed}"></span>
Completed Tasks <span class="badge">{{c.data.tasks.completed.length}}</span>
</h4>
<div ng-show="c.show.completed">
<div ng-if="c.data.tasks.completed.length === 0">
<p>No completed tasks.</p>
</div>

<div ng-repeat="task in c.data.tasks.completed" class="task-card completed">
<h5>{{task.short_description}}</h5>
<p>Task: {{task.number}} | Parent: {{task.parent}}</p>
<p>State: <span class="state-badge completed">{{task.state_display}}</span></p>
</div>
</div>
</div>

 

CSS:

.doc-task-list {
display: flex;
flex-direction: column;
gap: 20px;
}

.task-card {
padding: 15px;
border: 1px solid #d3d6dc;
border-radius: 6px;
background: #fff;
}

.task-card.completed {
background: #f4f4f4;
color: #888;
}

.action-buttons {
display: flex;
gap: 10px;
margin-top: 10px;
}

.badge {
display: inline-block;
min-width: 20px;
padding: 3px 8px;
font-size: 12px;
font-weight: 600;
color: #fff;
background-color: #5b9bd5;
border-radius: 10px;
vertical-align: middle;
margin-left: 6px;
}

.state-badge {
display: inline-block;
padding: 2px 8px;
font-size: 12px;
font-weight: 600;
border-radius: 12px;
color: #fff;
}

.state-badge.active {
background-color: #0078d4;
}

.state-badge.completed {
background-color: #4caf50;
}

.state-badge.rejected {
background-color: #d9534f;
}

.state-badge.in-progress {
background-color: #f0ad4e;
}

.collapsible-header {
cursor: pointer;
display: flex;
align-items: center;
font-weight: 600;
margin: 15px 0 10px;
}

.collapsible-header span {
margin-right: 6px;
}

 

Server Script:

 

(function() {
  data.tasks = { active: [], completed: [] };
  data.task = {};
  data.mode = options.mode || "list";

  if (data.mode === "single") {
    data.sys_id = options.sys_id || $sp.getParameter("sys_id") || "";

    if (data.sys_id) {
      var gr = new GlideRecord("sn_doc_task");
      if (gr.get(data.sys_id)) {
        data.task = {
          sys_id: gr.getUniqueValue(),
          short_description: gr.getValue("short_description"),
          number: gr.getValue("number"),
          parent: gr.parent ? gr.parent.number.toString() : "",
          state: gr.getValue("state"),
          state_display: gr.getDisplayValue("state")
        };
      }
    }
  } else if (data.mode === "list") {
    var grList = new GlideRecord("sn_doc_task");
    grList.addQuery("assigned_to", gs.getUserID());
    grList.orderByDesc("sys_created_on");
    grList.query();

    while (grList.next()) {
      var taskObj = {
        sys_id: grList.getUniqueValue(),
        short_description: grList.getValue("short_description"),
        number: grList.getValue("number"),
        parent: grList.parent ? grList.parent.number.toString() : "",
        state: grList.getValue("state"),
        state_display: grList.getDisplayValue("state")
      };

      if (["7", "3", "21", "4"].indexOf(grList.getValue("state")) > -1) {
        data.tasks.completed.push(taskObj);
      } else {
        data.tasks.active.push(taskObj);
      }
    }
  }

  // provide table name for uploads
  data.table = "sn_doc_task";
})();
 
Client Controller:
api.controller = function($scope, spUtil, $http) {
  var c = this;

  // Collapse/Expand defaults
  c.show = { active: true, completed: false };

  c.toggle = function(section) {
    c.show[section] = !c.show[section];
  };

  c.completeTask = function(sys_id) {
    var ga = new GlideAjax("DocTaskAjax");
    ga.addParam("sysparm_name", "completeTask");
    ga.addParam("sys_id", sys_id);
    ga.getXMLAnswer(function(answer) {
      if (answer === "success") {
        spUtil.addInfoMessage("SAAR Uploaded and Document Task Completed.");
        c.refresh();
      } else {
        spUtil.addErrorMessage("Unable to complete Document Task: " + answer);
      }
    });
  };

  c.rejectTask = function(sys_id) {
    var ga = new GlideAjax("DocTaskAjax");
    ga.addParam("sysparm_name", "rejectTask");
    ga.addParam("sys_id", sys_id);
    ga.getXMLAnswer(function(answer) {
      if (answer === "rejected") {
        spUtil.addInfoMessage("Task rejected.");
        c.refresh();
      } else {
        spUtil.addErrorMessage("Unable to reject task: " + answer);
      }
    });
  };

  c.downloadLatest = function(sys_id) {
    $http.get("/api/now/attachment?table_name=sn_doc_task&table_sys_id=" + sys_id)
      .then(function(response) {
        var attachments = response.data.result || [];
        if (attachments.length > 0) {
          attachments.sort(function(a, b) {
            return new Date(b.sys_created_on) - new Date(a.sys_created_on);
          });
          window.open("/sys_attachment.do?sys_id=" + attachments[0].sys_id, "_blank");
        } else {
          spUtil.addErrorMessage("No attachment found.");
        }
      });
  };

  c.refresh = function() {
    c.server.update().then(function(response) {
      c.data = response.data;
    });
  };

  // ---------- File Upload Handling (per task) ----------
  $scope.taskFiles = {};

  $scope.setFiles = function(element, sys_id) {
    $scope.$apply(function() {
      if (!$scope.taskFiles[sys_id]) {
        $scope.taskFiles[sys_id] = [];
      }
      for (var i = 0; i < element.files.length; i++) {
        $scope.taskFiles[sys_id].push(element.files[i]);
      }
    });
  };

  $scope.removeFiles = function(file, sys_id) {
    var files = $scope.taskFiles[sys_id] || [];
    var index = files.indexOf(file);
    if (index > -1) {
      files.splice(index, 1);
    }
  };

  $scope.uploadFiles = function(sys_id) {
    var files = $scope.taskFiles[sys_id] || [];
    if (!files.length) return;

    files.forEach(function(file) {
      var fd = new FormData();
      fd.append("file", file);

      var request = {
        method: "POST",
        url: "/api/now/attachment/file?table_name=" + c.data.table +
             "&table_sys_id=" + sys_id +
             "&file_name=" + file.name,
        data: fd,
        headers: {
          "Content-Type": undefined, // Let browser set multipart/form-data
          "Accept": "application/json"
        },
        transformRequest: angular.identity
      };

      $http(request).then(function() {
        spUtil.addInfoMessage("File uploaded: " + file.name);
      }, function() {
        spUtil.addErrorMessage("Upload failed: " + file.name);
      });
    });
  };
};
2 ACCEPTED SOLUTIONS

ChrisBurks
Giga Sage

Hi @kristenmkar ,

 

The script isn't working because of the onchange on your input. I don't think it's doing what you think it's doing.

Once you used vanillajs or the browser api "onchange" you stepped out of the AngularJS realm. I know in your code you make up for it with the $apply and scope however that's not applied on the onchange itself. So the sys_id is not actually being captured. 

<input type="file" multiple
onchange="angular.element(this).scope().setFiles(this, '{{task.sys_id}}')" />

In this scenario '{{task.sys_id}}' will be passing '{{task.sys_id}}' as the value, not the actual sys_id. 

Just like the angular class was used to get to the "setFiles" method you'll need to do the same thing to get the task.sys_id.

Try doing this:

<input type="file" multiple
onchange="angular.element(this).scope().setFiles(this, angular.element(this).scope().task.sys_id)" />

 

Edit:

Other ways to pass/get the task sys_id:

<input type="file" multiple
onchange="angular.element(this).scope().setFiles(this)" ng-attr-task_sys_id="{{task.sys_id}}" />

And in the setFiles method get the value of the attribute "tasksysid" value

  $scope.setFiles = function(element) {
    var sys_id = element.attributes.tasksysid.value;
    $scope.$apply(function() {
      if (!$scope.taskFiles[sys_id]) {
        $scope.taskFiles[sys_id] = [];
      }
      for (var i = 0; i < element.files.length; i++) {
        $scope.taskFiles[sys_id].push(element.files[i]);
      }
    });
  };

 

 

View solution in original post

kaushal_snow
Mega Sage

Hi @kristenmkar ,

 

Ensure table_name and table_sys_id are correct and passed to the attachment REST call...In the upload REST URL (/api/now/attachment/file?...), make sure table_sys_id=sys_id matches the record you want.....Double check c.data.table equals "sn_doc_task" or whichever table you want.....

 

Modify your onchange input to get the actual sys_id properly....Instead of onchange="angular.element(this).scope().setFiles(this, '{{task.sys_id}}')", use something like onchange="angular.element(this).scope().setFiles(this, task.sys_id)" if that's valid, or store task.sys_id in an attribute and retrieve it in the handler....Ensure the Angular digest ($scope.$apply) is triggered correctly.

 

 

If you found my response helpful, please mark it as ‘Accept as Solution’ and ‘Helpful’. This helps other community members find the right answer more easily and supports the community.

 

Thanks and Regards,
Kaushal Kumar Jha - ServiceNow Consultant - Lets connect on Linkedin: https://www.linkedin.com/in/kaushalkrjha/

View solution in original post

16 REPLIES 16

According to the code provided, you won't see any errors in the script include as the files aren't getting attached via the script include (c.completeTask -> GlideAjax call). The script include is only updating the record. 

The attachment is happening client side ($scope.uploadFiles)  via the Attachment API which is where the error would show up if any occur. 

In reality the GlideAjax call isn't needed because the widget has access to the server side. The process could be done all in one call through $scope.uploadFiles where the function would attach the file. Then if no errors happened then within that same function send a call to the server side of the widget to update the record either by the script include (not via GlideAjax but server side) or just make your GlideRecord script to update the record. 

If there are any errors don't send to the server. 

That way the process isn't broken up like it is currently. Plus what could be happening is that you're really executing the two functions at the same time via the ngClick.It's probably a timing issue depending upon how large or how many files are being attached thus no errors show.  

Bottom line is that you would have more control over the process if you contained the two and base the flow off of the files being attached. 

 

I totally agree with your suggestion and think this will definitely solve my issue - I have been working on my client and server scripts and I don't think I have my client script correct with my new logic - I apologize for all the questions, I am still working on being fluent in this widget scripting! (I also haven't added rejection to my client script yet, was trying to focus on the Complete first :)) 

 

Server Script: 

(function() {
  data.tasks = { active: [], completed: [] };
  data.task = {};
  data.mode = options.mode || "list";

  if (data.mode === "single") {
    data.sys_id = options.sys_id || $sp.getParameter("sys_id") || "";

    if (data.sys_id) {
      var gr = new GlideRecord("sn_doc_task");
      if (gr.get(data.sys_id)) {
        data.task = {
          sys_id: gr.getUniqueValue(),
          short_description: gr.getValue("short_description"),
          number: gr.getValue("number"),
          parent: gr.parent ? gr.parent.number.toString() : "",
          state: gr.getValue("state"),
          state_display: gr.getDisplayValue("state")
        };
      }
    }
  } else if (data.mode === "list") {
    var grList = new GlideRecord("sn_doc_task");
    grList.addQuery("assigned_to", gs.getUserID());
    grList.orderByDesc("sys_created_on");
    grList.query();

    while (grList.next()) {
      var taskObj = {
        sys_id: grList.getUniqueValue(),
        short_description: grList.getValue("short_description"),
        number: grList.getValue("number"),
        parent: grList.parent ? grList.parent.number.toString() : "",
        state: grList.getValue("state"),
        state_display: grList.getDisplayValue("state")
      };

      if (["7", "3", "21", "4"].indexOf(grList.getValue("state")) > -1) {
        data.tasks.completed.push(taskObj);
      } else {
        data.tasks.active.push(taskObj);
      }
    }
 
  if (input && input.action === "completeTask") {
    var grComplete = new GlideRecord("sn_doc_task");
    if (grComplete.get(input.sys_id)) {
      grComplete.setValue("state", 7); // Completed
      grComplete.setValue("u_closed_date", new GlideDateTime());
      grComplete.update();
      data.message = "Task marked complete.";
    }
  }

  if (input && input.action === "rejectTask") {
    var gr2 = new GlideRecord("sn_doc_task");
    if (gr2.get(input.sys_id)) {
      gr2.setValue("state", 8); // Rejected
      gr2.setValue("u_rejected_reason", input.reason || "");
      gr2.update();
      data.message = "Task rejected.";
    }
  }
  }
})();
 
Client Script:
 
api.controller = function($scope, spUtil, $http) {
    var c = this;

    // Collapse/Expand defaults
    c.show = {
        active: true,
        completed: false
    };

    c.toggle = function(section) {
        c.show[section] = !c.show[section];
    };

    c.downloadLatest = function(sys_id) {
        $http.get("/api/now/attachment?table_name=sn_doc_task&table_sys_id=" + sys_id)
            .then(function(response) {
                var attachments = response.data.result || [];
                if (attachments.length > 0) {
                    attachments.sort(function(a, b) {
                        return new Date(b.sys_created_on) - new Date(a.sys_created_on);
                    });
                    window.open("/sys_attachment.do?sys_id=" + attachments[0].sys_id, "_blank");
                } else {
                    spUtil.addErrorMessage("No attachment found.");
                }
            });
    };

    c.refresh = function() {
        c.server.update().then(function(response) {
            c.data = window.location.href = 'xxxxxxx'; //hidden 
        });
    };

    // ---------- File Upload Handling (per task) ----------
    $scope.taskFiles = {};

    $scope.setFiles = function(element, sys_id) {
        $scope.$apply(function() {
            if (!$scope.taskFiles[sys_id]) {
                $scope.taskFiles[sys_id] = [];
            }
            for (var i = 0; i < element.files.length; i++) {
                $scope.taskFiles[sys_id].push(element.files[i]);
            }
        });
    };

    $scope.removeFiles = function(file, sys_id) {
        var files = $scope.taskFiles[sys_id] || [];
        var index = files.indexOf(file);
        if (index > -1) {
            files.splice(index, 1);
        }
    };

    $scope.uploadFiles = function(sys_id) {
        var files = $scope.taskFiles[sys_id] || [];
        if (!files.length) return;

        files.forEach(function(file) {
            var fd = new FormData();
            fd.append("file", file);

            var request = {
                method: "POST",
                url: "/api/now/attachment/file?table_name=" + c.data.table +
                    "&table_sys_id=" + sys_id +
                    "&file_name=" + file.name,
                data: fd,
                headers: {
                    "Content-Type": undefined, // Let browser set multipart/form-data
                    "Accept": "application/json"
                },
                transformRequest: angular.identity
            };

            $http(request).then(function() {
                c.server.get({
                        action: "completeTask",
                        sys_id: sys_id
                    }),
                    function() {
                        spUtil.addErrorMessage("Upload failed: " + file.name);
                    };
            });
        });
    };

};