- Subscribe to RSS Feed
- Mark as New
- Mark as Read
- Bookmark
- Subscribe
- Printer Friendly Page
- Report Inappropriate Content
In my last blog, From Form to PDF: Building a Record Export Button, I showed how a single UI Action can turn any record into a formatted PDF using PDFGenerationAPI. That’s well but question is:
"This is good, but some users want to edit it after downloading."
Fair point. A PDF is a final document. The moment someone needs to add a paragraph, correct a name, or send it to a customer in their own wording, PDF becomes the wrong format. They want Word.
So this blog is Part 2,same layout, different output. This time we produce a .doc file that opens in Word and is fully editable.
We will build it the same way as last time. A basic version first, then CSS, then the real use case, and finally the related list.
There Is No Word API, and We Do Not Need One
First thing to clear up. ServiceNow ships PDFGenerationAPI out of the box, but there is no equivalent Word generation API. When developers hit this, they usually start looking at MID Servers, third party tools, or Document Templates.
None of that is required.
Word has been able to open HTML since Word 2000. Give a file the .doc extension and the application/msword MIME type, and Word reads the markup as a normal document. Headings stay headings, tables stay tables, and every character is editable.
Which means the content building logic is the same HTML we wrote for the PDF. Only the attachment step changes.
The API: GlideSysAttachment
For the PDF, convertToPDF() created the attachment for us. Here we do it ourselves, and the API for that is GlideSysAttachment.
var attachment = new GlideSysAttachment();
var attSysId = attachment.write(current, fileName, contentType, content);
Four inputs. The GlideRecord to attach to, the file name, the MIME type, and the content as a string. It returns the sys_id of the new attachment, which is also our success check.
Three facts that will save you time:
It is server side only. UI Action, Business Rule, Script Include, Scheduled Job, Flow Designer script step. Not client scripts.
You must include the extension yourself. This is the opposite of convertToPDF(), which adds .pdf for you. Pass "INC0010001_Incident.doc", not "INC0010001_Incident".
Use .doc, never .docx. A .docx file is a zipped OOXML package, not HTML. Label HTML as .docx and Word will refuse to open it.
The UI Action
Navigate to System Definition > UI Actions and click New. Fill it like this:
|
Field |
Value |
|
Name |
Generate Word Document |
|
Table |
Incident [incident] |
|
Action name |
generate_incident_word |
|
Active |
checked |
|
Show insert |
unchecked |
|
Show update |
checked |
|
Form button |
checked |
|
Condition |
!current.isNewRecord() |
Show insert stays unchecked for the same reason as last time. An unsaved record has no data worth exporting and no sys_id to attach to. The condition covers the same case from the server side.
Step 1: A Basic Word Document
Smallest possible version first. One button, one line of text, one Word file. Paste this in the Script field:
var html = "<html><head>";
var html = "<html><head><meta charset='utf-8'></head><body>";
html += "</head><body>";
html += "<h2>Demo Word Document</h2>";
html += "<p>Generated from record " + current.number + "</p>";
html += "</body></html>";
var fileName = current.number + "_Demo.doc";
var attachment = new GlideSysAttachment();
var attSysId = attachment.write(current, fileName, "application/msword", html);
if (attSysId)
gs.addInfoMessage("Word document generated and attached successfully.");
else
gs.addErrorMessage("Word document generation failed.");
action.setRedirectURL(current);
Save it, open any incident, and click the button. The attachment appears on the record.
Download it and Word opens with the heading and the incident number.
It is plain, but it proves the whole approach works. Everything from here is just better HTML.
Step 2: Adding CSS
Two problems with the basic version.
Word opened it in Web Layout, so there are no page edges and no margins. And the content has no styling at all.
The layout problem is fixed in the <head>, and Word needs a specific
What each piece does:
- The Office namespaces tell Word this is a document, not a web page.
- The conditional comment forces Print Layout on open, so the user sees page boundaries immediately.
- The @page rule is one of the few CSS rules Word genuinely honours. Set paper size and margins here.
- The charset meta stops accented characters and currency symbols from turning into question marks.
For the content itself, use inline CSS on each tag, exactly as we did for the PDF. Word renders tables and inline styles reliably. It renders flexbox and CSS grid badly, so avoid both.
Here are the same three helpers from the PDF blog, unchanged:
var html = "";
html += "<html>";
html += "<head>";
html += "<meta charset='utf-8'>";
html += "<title>Demo Word Document</title>";
html += "</head>";
html += "<body>";
html += "<h2>Demo Word Document</h2>";
html += "<p>Generated from record <strong>" + current.number + "</strong></p>";
html += "</body>";
html += "</html>";
var fileName = current.number + "_Demo.doc";
var attachment = new GlideSysAttachment();
var attSysId = attachment.write(current, fileName, "application/msword", html);
if (attSysId)
gs.addInfoMessage("Word document generated and attached successfully.");
else
gs.addErrorMessage("Word document generation failed.");
action.setRedirectURL(current);
after saving again click on generate UI Action, attachment will be generate, remove the old one and download the new one, its preview is:
A reminder on dv(), because it matters just as much here. Reference, choice, and date fields store raw values. Print current.caller_id directly and the document shows a 32 character sys_id instead of a name. getDisplayValue() gives the readable version, and the helper keeps the script from breaking on empty fields.
Use Case 1: The Incident Form
Now the real requirement. The document has to mirror the form, main fields in the two column layout plus the Resolution Information section. Here is the complete UI Action script:
function dv(field) {
if (current[field] && current[field].getDisplayValue)
return current[field].getDisplayValue();
return "";
}
function formRow(label1, value1, label2, value2) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label1 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value1 || " ") + "</td>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label2 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value2 || " ") + "</td>" +
"</tr>";
}
function fullRow(label, value) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right; vertical-align:top;'><b>" + label + "</b></td>" +
"<td colspan='3' style='padding:6px 8px; border-bottom:1px solid #ccc; font-size:12px;'>" + (value || " ") + "</td>" +
"</tr>";
}
// Word wrapper
var html = "";
html += "<html xmlns:w='urn:schemas-microsoft-com:office:word'>";
html += "<head><meta charset='utf-8'>";
html += "<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View></w:WordDocument></xml><![endif]-->";
html += "<style>@page { size:A4; margin:1.5cm; } body { font-family:Arial, sans-serif; }</style>";
html += "</head><body>";
// Header bar
html += "<div style='background:#2e3a4d; color:#fff; padding:10px 15px; font-size:16px;'><b>Incident - " + current.number + "</b></div>";
html += "<br>";
// Main form fields
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Number", current.number, "Channel", dv("contact_type"));
html += formRow("Caller", dv("caller_id"), "State", dv("state"));
html += formRow("Category", dv("category"), "Impact", dv("impact"));
html += formRow("Subcategory", dv("subcategory"), "Urgency", dv("urgency"));
html += formRow("Service", dv("business_service"), "Priority", dv("priority"));
html += formRow("Service Offering", dv("service_offering"), "Assignment Group", dv("assignment_group"));
html += formRow("Configuration Item", dv("cmdb_ci"), "Assigned To", dv("assigned_to"));
html += fullRow("Short Description", current.short_description);
html += fullRow("Description", current.description);
html += "</table>";
html += "<br>";
html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Resolution Information</b></div>";
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Resolution Code", dv("close_code"), "Resolved By", dv("resolved_by"));
html += formRow("", "", "Resolved", dv("resolved_at"));
html += fullRow("Resolution Notes", current.close_notes);
html += "</table>";
html += "</body></html>";
var fileName = current.number + "_Incident.doc";
var oldDoc = new GlideRecord("sys_attachment");
oldDoc.addQuery("table_name", current.getTableName());
oldDoc.addQuery("table_sys_id", current.getUniqueValue());
oldDoc.addQuery("file_name", fileName);
oldDoc.query();
while (oldDoc.next()) {
oldDoc.deleteRecord();
}
var attachment = new GlideSysAttachment();
var attSysId = attachment.write(current, fileName, "application/msword", html);
if (attSysId)
gs.addInfoMessage("Word document generated and attached successfully.");
else
gs.addErrorMessage("Word document generation failed.");
action.setRedirectURL(current);
Note the delete block before the write. Without it, every click adds another attachment and the record ends up with five copies of the same document. Querying sys_attachment on table name, table sys_id, and file name removes only the copy this button owns.
Use Case 2: Adding the Related List
A record is rarely just its own fields. The incident form also carries related lists, and a document that claims to be a copy of the record should include them.
The approach is the same as the PDF version. Query the child table with GlideRecord, loop the results, build one row per record. Add this block after the Resolution Information section and before </body></html>:
function dv(field) {
if (current[field] && current[field].getDisplayValue)
return current[field].getDisplayValue();
return "";
}
function formRow(label1, value1, label2, value2) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label1 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value1 || " ") + "</td>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right;'><b>" + label2 + "</b></td>" +
"<td style='padding:6px 8px; width:35%; border-bottom:1px solid #ccc; font-size:12px;'>" + (value2 || " ") + "</td>" +
"</tr>";
}
function fullRow(label, value) {
return "<tr>" +
"<td style='padding:6px 8px; width:15%; color:#555; font-size:12px; text-align:right; vertical-align:top;'><b>" + label + "</b></td>" +
"<td colspan='3' style='padding:6px 8px; border-bottom:1px solid #ccc; font-size:12px;'>" + (value || " ") + "</td>" +
"</tr>";
}
// Word wrapper
var html = "";
html += "<html xmlns:w='urn:schemas-microsoft-com:office:word'>";
html += "<head><meta charset='utf-8'>";
html += "<!--[if gte mso 9]><xml><w:WordDocument><w:View>Print</w:View></w:WordDocument></xml><![endif]-->";
html += "<style>@page { size:A4; margin:1.5cm; } body { font-family:Arial, sans-serif; }</style>";
html += "</head><body>";
// Header bar
html += "<div style='background:#2e3a4d; color:#fff; padding:10px 15px; font-size:16px;'><b>Incident - " + current.number + "</b></div>";
html += "<br>";
// Main form fields
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Number", current.number, "Channel", dv("contact_type"));
html += formRow("Caller", dv("caller_id"), "State", dv("state"));
html += formRow("Category", dv("category"), "Impact", dv("impact"));
html += formRow("Subcategory", dv("subcategory"), "Urgency", dv("urgency"));
html += formRow("Service", dv("business_service"), "Priority", dv("priority"));
html += formRow("Service Offering", dv("service_offering"), "Assignment Group", dv("assignment_group"));
html += formRow("Configuration Item", dv("cmdb_ci"), "Assigned To", dv("assigned_to"));
html += fullRow("Short Description", current.short_description);
html += fullRow("Description", current.description);
html += "</table>";
html += "<br>";
// Resolution Information
html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Resolution Information</b></div>";
html += "<table style='width:100%; border-collapse:collapse;'>";
html += formRow("Resolution Code", dv("close_code"), "Resolved By", dv("resolved_by"));
html += formRow("", "", "Resolved", dv("resolved_at"));
html += fullRow("Resolution Notes", current.close_notes);
html += "</table>";
html += "<br>";
// Task SLAs related list
html += "<div style='background:#f4f5f7; border:1px solid #ccc; padding:6px 10px; font-size:13px;'><b>Task SLAs</b></div>";
html += "<table style='width:100%; border-collapse:collapse; font-size:11px;'>";
html += "<tr style='background:#e8eaed;'>";
html += "<th style='border:1px solid #ccc; padding:5px;'>SLA Definition</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Stage</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Business Time Left</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Business Elapsed Time</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Elapsed %</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Start Time</th>";
html += "<th style='border:1px solid #ccc; padding:5px;'>Stop Time</th>";
html += "</tr>";
var sla = new GlideRecord("task_sla");
sla.addQuery("task", current.getUniqueValue());
sla.query();
var found = false;
while (sla.next()) {
found = true;
html += "<tr>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.sla.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.stage.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_time_left.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_duration.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.business_percentage + "%</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + sla.start_time.getDisplayValue() + "</td>";
html += "<td style='border:1px solid #ccc; padding:5px;'>" + (sla.end_time.getDisplayValue() || "(empty)") + "</td>";
html += "</tr>";
}
if (!found) {
html += "<tr><td colspan='7' style='border:1px solid #ccc; padding:5px;'><b>No Task SLAs found</b></td></tr>";
}
html += "</table>";
html += "</body></html>";
// Remove the previous copy so we never stack duplicates
var fileName = current.number + "_Incident.doc";
var oldDoc = new GlideRecord("sys_attachment");
oldDoc.addQuery("table_name", current.getTableName());
oldDoc.addQuery("table_sys_id", current.getUniqueValue());
oldDoc.addQuery("file_name", fileName);
oldDoc.query();
while (oldDoc.next()) {
oldDoc.deleteRecord();
}
// Attach the new one
var attachment = new GlideSysAttachment();
var attSysId = attachment.write(current, fileName, "application/msword", html);
if (attSysId)
gs.addInfoMessage("Word document generated and attached successfully.");
else
gs.addErrorMessage("Word document generation failed.");
action.setRedirectURL(current);Three points in this block worth calling out:
The query uses sla.addQuery("task", current.getUniqueValue()). The task_sla table points to its parent through the task field. Every related list works this way, you only need to know which field on the child table points back.
sla.sla.getDisplayValue() is dot walking. The sla field is a reference to the SLA definition, so dot walking pulls the definition name without a second GlideRecord.
The found flag matters. On a record with no SLAs, the table would render as an empty broken grid. The flag gives you a clean "No Task SLAs found" row instead. Always handle the empty case, because users will open this on records you never tested.
Wrapping Up
PDF when the document is final. Word when someone still has to work on it. The layout logic is identical, and once you know Word opens styled HTML, the requirement collapses into one UI Action and one call to GlideSysAttachment.
And because the script uses getTableName() and getUniqueValue() instead of hardcoded values, moving this to Change Request, HR Case, or any custom table means changing the UI Action table and swapping the field names.
If you build this and something behaves differently on your instance, tell me in the comments. I read all of them.
- 75 Views
You must be a registered user to add a comment. If you've already registered, sign in. Otherwise, register and sign in.