Saturday, June 22, 2024

Return to Sender: POST a Suitelet without redirecting

    We all know the pain of submitting a form in NetSuite, only to have it error and lose the input. This happens because NetSuite forces a redirect to the new error page. It turns out we don't have to redirect to do something with our data (or display an error).

    We can POST our data back to NetSuite from a Client Script. This means we don't have to redirect, and we don't have to lose our data. While this can be done on any scriptable page, it's easiest on Suitelets. Let's make a simple example with a couple fields. Normally we'd add a submit button, but this would bring along the redirect logic; instead we'll add a regular button and define its functionality in a Client Script.

/**
 * @NApiVersion 2.1
 * @NScriptType Suitelet
 */
define(['N/ui/serverWidget'], (ui) => {
    function onRequest(context) {
        const { response, request } = context;

        if (request.method == 'GET') {
            let form = ui.createForm('Test Suitelet');
            //include out client script for our button to call
            form.clientScriptModulePath = './our_client_script_cs.js';

            form.addField({ id: 'custpage_field1', type: 'text', label: 'Test 1' });
            form.addField({ id: 'custpage_field2', type: 'text', label: 'Test 2' });
            form.addButton({ id: 'buttonid', label: 'Button', 
                functionName: 'cs_function_name' });

            response.writePage(form);
        }
    }

    return { onRequest };
});

   Depending on what we need to do, the CS might be able to handle it. Very often we need the governance, permissions, or modules of a server script though. Whatever server-side functionality we need, can be added to the same Suitelet that's rending our page; We'll just add a parameter so we can call it instead of the page. We can pass the rest of our data via the body of the POST.

/**
 * @NApiVersion 2.1
 * @NScriptType ClientScript
*/
define(['N/url', 'N/currentRecord', 'N/https'], (url, currentRec, https) => {
    function pageInit(context) { }

    function cs_function_name() {
const currentRecord = currentRec.get(); //no context obj, so use currentRec //Build POST body from a couple field values const [fieldValue1, fieldValue2] = ['custpage_field1', 'custpage_field2'].map(fld => currentRecord.getValue(fld)); const body = JSON.stringify({ fieldValue1, fieldValue2 }); const newURL = url.resolveScript({ scriptId: 'customscript_our_suitelet',
            deploymentId: 1, params: { server_flag: 1 } });
        const response = https.post({ url: newURL, body });
        if (response?.body) alert(JSON.parse(response.body)); //show Suitelet response 
    }

    return { pageInit, cs_function_name };
});

Reaction time

    Now we run into an issue with the user experience. They can't tell if their click has registered, if anything is happening, or if there was an error. This is where NetSuite would redirect you in order to provide feedback. That's a very outdated, static approach to web design though. NetSuite does actually give us some methods to dynamically provide feedback to the user. The Dialog and Message modules are two examples of this.

    I usually opt for the message module because it's unobtrusive, somewhat flexible, and can even be called from a User Event. So, let's display a message while we POST to our SL. Well, it turns out that doesn't work; the message doesn't display until after we get a response from our POST. While it's tempting to blame this on NetSuite, it's more down to how the browser calls JavaScript event handlers and when it updates the DOM.

Getting out of our own way

    Without getting too philosophical, JavaScript can only really do one thing at a time. If it's actively waiting on a POST response, it can't also be drawing a message. Unfortunately, the results of the message drawing will always happen last, even if called first. This is partly because the POST is "blocking" the message. We can avoid this by using promises because they're "non-blocking". I think of it as allowing us to passively wait for the POST. We can still react to the response from the POST very quickly, but we're free to do other things while we wait.

And then?

    A promise by itself isn't too helpful. We usually need to run some code after we're done waiting on the promise. One way to do this is to chain a "then" function onto the promise. It takes a callback function that will run after the promise is done. This lets us display a confirmation message to tell the user that the function completed successfully. But what if it wasn't successful?

const submittingMessage = message.create({ type: 2.0,
    title: 'Submitting', message: 'Please wait' });
submittingMessage.show(); //tell user to wait

https.post.promise({ url: newURL, body }).then((response) => {
    submittingMessage.hide(); //done waiting
    if (response?.body) //show confirmation
        message.create({ type: 1.0, title: 'Success', message: 'yay' }).show();
});


What's the catch?

    Previously, the way we handled errors didn't matter that much; the user's data was gone regardless. Now we have a chance to do something with any errors we encounter.

    Promises can also have a "catch" chained onto them. This works very much like a try/catch, but the try is implicit and wraps the promise. Here we can display an error message, or maybe prompt the user to store their data for later.

https.post.promise({ url: newURL, body }).then((response) => {
    ...
}).catch((reason) => {
    submittingMessage.hide();
    //show error to user
    message.create({ type: 3.0, title: 'Error', message: reason }).show();
});


Dominate Submitting

    Those are all the parts needed to roll a better form submitting solution. While I think this is the best application of these ideas, they can be used for other actions and other pages/records. Next time you add a button, consider if it could benefit!

Sunday, March 31, 2024

Sudo Make me a Sandwich: Adding Buttons for Privileged Actions


    Users have limited permissions, and for good reason. Sometimes we want to allow them to perform specific actions that are forbidden by their permissions though. Unfortunately, NetSuite doesn't have very granular controls for how users can interact with records.

    Let's say that a we have a "Trainer" role that can view, but not edit Employee records. Despite this, they need to be able to update Employee records to show that they trained a given employee. This involves setting a custom "Trained" checkbox, and a "Trained By" field that holds the ID of the trainer.
    The trainer would like to navigate to the Employee record, somehow set the fields, and move on. We can let them do this with a custom button.


Button, Button, Who's Got The Button


    Since our user doesn't have edit permissions on the record, this limits our methods of adding buttons. Unless we want to use a WorkFlow, a User Event is our only option. Here is the bare minimum to add our "Trained" button. We can deploy it for our Trainer role so that only they can see the button.

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */
define(['N/runtime'], (runtime) => {
    function beforeLoad(context) {
        context.form.addButton({ label: 'Trained', id: 'custpage_trained', functionName: 'setTrainedFields' });
    }

    return { beforeLoad };
});

    Now that we have the button, we need to make it do something. It's looking for a setTrainedFields() function that doesn't exist. We can add this function to a typical Client Script, but these only run on edit. One solution is to specify the CS from within the UE. This is done with the clientScriptModulePath property of the form object, and will make it load on view.

context.form.clientScriptModulePath = './setTrained_CS.js';



Building Your Clientele


    In our client script we need to somehow set two custom fields that we still don't have the required permissions to edit. Client scripts can't be set to run as a different role either. This means that we need to call yet another script, one that can have elevated permissions. The NetSuite help gives us a list of script types that can run as different roles here. Relevant options are: Suitelet, User Event, and Mass Update.    

    Scheduled scripts and Map/Reduces also run with elevated privileges, as they're hard coded to use the Administrator role. Unfortunately, these require the task module to call them, and it isn't available to client scripts.
    Mass Update scripts can't be called from other scripts at all, so this leaves Suitelets and User Events. We would typically use the redirect module to get to a Suitelet, but this isn't available to CSs either. We can manually redirect the browser though.
    A User Event would be convenient, since we're already using one to create the button. Normally we would use the beforeSubmit to change the field values, but no edit permission means beforeSubmit doesn't fire. We can continue to use the beforeLoad that we already created though.

    We can have our CS redirect to the same page we're on, but with the addition of a parameter that the UE can read. Here's an example.

/**
 * @NApiVersion 2.1
 * @NScriptType ClientScript
 */
define([], () => {
    function pageInit(context) { } //must have at least one entry point

    function setTrainedFields() { //reload record with param set to trigger UE
        window.location.replace(window.location + '&setTrained=T');
    }

    return { pageInit, setTrainedFields };
});



Friends Are Always Tellin’ Me You’re a User


    We just need to give our UE a role that can set the needed fields, and have it run when it sees the param from the CS. There is a wrinkle though; due to having to run in view mode beforeLoad, any values we change won't be shown in the UI. We have to redirect to the same record yet again in order to display the new values. Luckily, the redirect module is available now that we're in a UE.
    We can pass another param to indicate that the button action has completed.

let { form, newRecord, request } = context;

if (request.parameters['setTrained'] == 'T') { //set fields and reload
    const values = { custbody_trained: true, custbody_trainedby: runtime.getCurrentUser().id };
    record.submitFields({ type: 'employee', id: newRecord.id, values });
    redirect.toRecord({ type: 'employee', id: newRecord.id, parameters: { trainedSuccess: 'T' } });
}

    We use the second param to trigger a confirmation message to inform the user that the action was performed successfully.

if (request.parameters['trainedSuccess'] == 'T') //show green success message
    form.addPageInitMessage({ type: 0.0, message: 'Approval Successful', duration: 7000 });

    Here's one way to combine our UE logic:

/**
 * @NApiVersion 2.1
 * @NScriptType UserEventScript
 */
define(['N/record', 'N/runtime', 'N/redirect'], (record, runtime, redirect) => {
    function beforeLoad(context) {
        let { form, newRecord, request } = context;

        if (request.parameters['setTrained'] == 'T') { //set fields and reload
            const values = { custbody_trained: true, custbody_trainedby: runtime.getCurrentUser().id };
            record.submitFields({ type: 'employee', id: newRecord.id, values });
            redirect.toRecord({ type: 'employee', id: newRecord.id, parameters: { trainedSuccess: 'T' } });
        } else if (request.parameters['trainedSuccess'] == 'T') { //show green success message
            form.addPageInitMessage({ type: 0.0, message: 'Approval Successful', duration: 7000 });
        } else { //display button
            form.addButton({ label: 'Trained', id: 'custpage_trained', functionName: 'approveVendBill' });
            form.clientScriptModulePath = './setTrained_CS.js';
        }
    }

    return { beforeLoad };
});


The Time Has Come to… Push the Button


    Upon pushing the button, the page should reload with the 'setTrained' param. The UE will detect it, update the values, and reload again with the 'trainedSuccess' param. In this way, a user can edit a record without having edit permissions.

Friday, March 29, 2024

Starting With a Bang: Handling Item Group Explosions


    Item Groups are a perpetual source of pain when customizing transaction sublists. They break the conventions of how items are added to sublists by using a poorly documented process called "exploding".

This rhythmatic explosion is what your frame of mind has chosen - Nas
 

A Series of Unfortunate Events


    NetSuite will trigger several events as an Item Group is added and exploded. The most useful one is sublistChanged().
    It's important to know that Item Groups can have their start/end lines disabled. This means you won't see those extra lines on your transaction, and the explosion process will be slightly different.
    

First...

    sublistChanged fires as the IG item is committed. At this point, the sourcing is done, and you can read the itemtype sublist field to identify it as a 'Group' type item. You can't assume that every commit of an IG is the start of an explosion though! It could be an edit. We can read a couple of seemingly undocumented sublist fields to help tell them apart: groupsetup and includegroupwrapper.
    groupsetup tells us if the IG is already setup (exploded). 'T' if exploded, 'F' if not. It's only populated on the start line of an IG though. As mentioned earlier, this line can be disabled, meaning it might not be present. We can read the next field to know what to expect.
    includegroupwrapper tells us if the IG has start/end lines. 'T' if the lines are enabled, 'F' if not.

And maybe...

    sublistChanged will fire again to remove the IG item if includegroupwrapper is 'F'. This happens prior to the members being added, and will temporarily yield an empty sublist if no other items have been added.

Then...

    sublistChanged fires one last time once the members have been added. The line index will be set to the last line of the sublist. Confusingly, the values on the current line will match the line after the end of the item group. You can actually duplicate this line just by committing the current line. This seems to be an oversight, and is only noticeable when inserting an IG. When adding an IG to the end of a sublist, the line after the IG and the last line of the sublist will be the same blank line.

Finally...

    postSourcing fires on various tax-related fields, and signifies the end of the explosion process.


Getting to the Bottom of It


    Another difficulty is determining the last line of an IG. As previously mentioned, the explosion leaves us on the last line of the sublist, and not the last line of the IG.
    We can get the first line # of the IG during the first sublistChanged event. If we know the IG has an end line, we can search for it beneath the start line. This means testing each line for an itemtype of 'EndGroup'. If we encounter the start of another IG, or the end of the sublist before we find the end, then something has gone wrong.
    We need a different approach for IGs that don't have start/end lines enabled though. We can store the sublist's length on the first sublistChanged, and subtract it from the length on the second sublistChanged. This will give us the length of the IG. Adding this to the first line # gives us the last line #.


Putting it Together


    1. We detect the start of an explosion by waiting for a sublistChange that meets the following criteria:
  • context.sublistId is 'item'
  • context.operation is 'commit'
  • the current sublist value for itemtype is 'Group'
  • groupsetup is not 'T' or includegroupwrapper is not 'T'
Now that we've found the first line, we can store its number along with the current length of the sublist.
    
    2. We wait for the next sublistChange that's a commit. It should be for the last line of the sublist.
We determine the last line of the IG by either searching for the End Of Group item, or subtracting the original line count from the current line count and adding the start line #.

    3. We wait for postSourcing to fire for 'nexus' or other tax fields. Now we know the Item Group has exploded, where it is, and that it's safe to manipulate.

Don't you never, ever, pull my lever because I explode and my nine is easy to load - LL Cool J

 

Return to Sender: POST a Suitelet without redirecting

     We all know the pain of submitting a form in NetSuite, only to have it error and lose the input. This happens because NetSuite forces a...