🔹 What is an “Advanced” Suitelet?
An Advanced Suitelet goes beyond a simple form. It can:
- Act as a multi-step wizard (with Next/Back).
- Render dynamic sublists with server-side filtering and client-side helpers.
- Upload/parse files (CSV/JSON) and show results.
- Communicate with Client Scripts for richer UX.
- Use caching, saved searches, SuiteQL, and governance-safe patterns.
🔹 Key Concepts & Building Blocks
- Server UI:
N/ui/serverWidget
to build forms, fields, sublists, buttons. - State: Pass page state via request parameters or hidden fields.
- Data: Pull from
N/search
/ SuiteQL; paginate for large results. - Client Interop: Attach a Client Script for validation, field changes, and AJAX (via Suitelet endpoints).
- Files: Handle uploads with
N/file
. - Performance: Use
N/cache
and partial rendering.
✅ Example 1 — Multi-Step Wizard (3 Steps + Pagination)
Use this pattern for guided data entry (e.g., mass updates, bulk email, config setup).
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* Advanced Suitelet: Multi-Step Wizard with Pagination
*/
define(['N/ui/serverWidget','N/search','N/runtime','N/redirect','N/log'],
(form, search, runtime, redirect, log) => {
const PARAM_STEP = 'step';
const STEP = Object.freeze({ ONE: '1', TWO: '2', THREE: '3' });
const onRequest = (ctx) => {
try {
const req = ctx.request, res = ctx.response;
const step = (req.parameters[PARAM_STEP] || STEP.ONE);
if (req.method === 'GET') {
if (step === STEP.ONE) return renderStep1(ctx);
if (step === STEP.TWO) return renderStep2(ctx);
return renderStep3(ctx);
}
// POST handlers
if (step === STEP.ONE) return handleStep1Post(ctx);
if (step === STEP.TWO) return handleStep2Post(ctx);
return handleStep3Post(ctx);
} catch (e) {
log.error('Wizard Error', e);
const f = form.create({ title: 'Error' });
f.addField({ id:'custpage_err', type: 'inlinehtml', label:' '})
.defaultValue = `<div style="color:#b00;">${escapeHtml(e.message || e.toString())}</div>`;
ctx.response.writePage(f);
}
};
// ---- Step 1: Filters ----
function renderStep1(ctx) {
const f = form.create({ title: 'Wizard: Step 1 – Choose Filters' });
f.addField({ id:'custpage_category', type:'select', label:'Category', source:'classification' });
f.addField({ id:'custpage_pagesize', type:'integer', label:'Page Size' }).defaultValue = '50';
f.addSubmitButton({ label:'Next ▶' });
ctx.response.writePage(f);
}
function handleStep1Post(ctx) {
const p = ctx.request.parameters;
redirect.toSuitelet({
scriptId: runtime.getCurrentScript().id,
deploymentId: runtime.getCurrentScript().deploymentId,
parameters: { step: STEP.TWO, category: p.custpage_category || '', pagesize: p.custpage_pagesize || '50', page: '1' }
});
}
// ---- Step 2: Results + Pagination ----
function renderStep2(ctx) {
const p = ctx.request.parameters;
const page = parseInt(p.page || '1', 10);
const pageSize = Math.max(1, Math.min(1000, parseInt(p.pagesize || '50', 10)));
const category = p.category || '';
const f = form.create({ title: 'Wizard: Step 2 – Review & Select' });
// carry state
f.addField({ id:'custpage_category', type:'text', label:'category', displayType:'hidden' }).defaultValue = category;
f.addField({ id:'custpage_pagesize', type:'text', label:'pagesize', displayType:'hidden' }).defaultValue = String(pageSize);
// Sublist
const sl = f.addSublist({ id:'custpage_results', type:'list', label:'Items' });
sl.addMarkAllButtons();
sl.addField({ id:'custpage_select', type:'checkbox', label:'Pick' });
sl.addField({ id:'custpage_itemid', type:'text', label:'Item Internal ID' });
sl.addField({ id:'custpage_itemname', type:'text', label:'Item Name' });
// Fetch data (Saved Search example)
const results = searchItems(category, page, pageSize);
results.data.forEach((r, i) => {
sl.setSublistValue({ id:'custpage_itemid', line:i, value: String(r.id) });
sl.setSublistValue({ id:'custpage_itemname', line:i, value: r.name || '' });
});
// Pagination buttons
if (page > 1) {
f.addButton({
id:'custpage_prev', label:'◀ Previous',
functionName:`goPage(${page-1})`
});
}
if (page < results.totalPages) {
f.addButton({
id:'custpage_next', label:'Next ▶',
functionName:`goPage(${page+1})`
});
}
// Next step submit
f.addSubmitButton({ label:'Continue ▶' });
// Client helper for pagination
f.clientScriptModulePath = './cs_wizard_pagination.js';
ctx.response.writePage(f);
}
function handleStep2Post(ctx) {
// Collect selected lines
const lineCount = Number(ctx.request.getLineCount({ group:'custpage_results' })) || 0;
const selected = [];
for (let i=0; i<lineCount; i++) {
const pick = ctx.request.getSublistValue({ group:'custpage_results', name:'custpage_select', line:i });
if (pick === 'T') {
const id = ctx.request.getSublistValue({ group:'custpage_results', name:'custpage_itemid', line:i });
selected.push(id);
}
}
redirect.toSuitelet({
scriptId: runtime.getCurrentScript().id,
deploymentId: runtime.getCurrentScript().deploymentId,
parameters: {
step: STEP.THREE,
selected: selected.join(',')
}
});
}
// ---- Step 3: Confirm & Execute ----
function renderStep3(ctx) {
const f = form.create({ title: 'Wizard: Step 3 – Confirm & Execute' });
f.addField({ id:'custpage_selected', type:'longtext', label:'Selected IDs' })
.defaultValue = (ctx.request.parameters.selected || '');
f.addSubmitButton({ label:'Run Action' });
ctx.response.writePage(f);
}
function handleStep3Post(ctx) {
const ids = (ctx.request.parameters.custpage_selected || '').split(',').filter(Boolean);
// TODO: perform your bulk action (e.g., set a field, create related records)
const f = form.create({ title: 'Done' });
f.addField({ id:'custpage_msg', type:'inlinehtml', label:' '})
.defaultValue = `<div>Processed ${ids.length} record(s).</div>`;
ctx.response.writePage(f);
}
// ---- Helpers ----
function searchItems(category, page, pageSize) {
const s = search.create({
type: 'item',
filters: category ? [['class','anyof',category]] : [],
columns: ['internalid','itemid']
});
const start = (page-1)*pageSize, end = start + pageSize;
const all = s.run().getRange({ start, end }) || [];
// NOTE: to compute total pages, run a count or a lightweight duplicate query
const count = s.runPaged({ pageSize:1000 }).count;
return {
data: all.map(r => ({ id: r.getValue('internalid'), name: r.getValue('itemid') })),
total: count,
totalPages: Math.max(1, Math.ceil(count / pageSize))
};
}
function escapeHtml(str=''){ return String(str).replace(/[&<>"']/g, s=>({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[s])); }
return { onRequest };
});
Client Script (pagination helper): cs_wizard_pagination.js
/**
* @NApiVersion 2.1
* @NScriptType ClientScript
* Helper: Handle pagination button clicks
*/
define(['N/url','N/currentRecord'], (url, currentRecord) => {
function goPage(page) {
try {
const rec = currentRecord.get();
const params = {
step: '2',
page: String(page),
category: rec.getValue('custpage_category') || '',
pagesize: rec.getValue('custpage_pagesize') || '50'
};
const link = url.resolveScript({
scriptId: runtimeScriptId(), // filled by NetSuite at runtime
deploymentId: runtimeDeploymentId(), // ditto
params
});
window.location.href = link;
} catch (e) { console.log('goPage error', e); }
}
// NetSuite injects these at runtime; leave as global placeholders
/* global runtimeScriptId:false, runtimeDeploymentId:false */
return { goPage };
});
📌 What this shows
- Multi-step flow using query params.
- Dynamic sublist with select checkboxes.
- Lightweight pagination without blowing governance.
✅ Example 2 — Dynamic Sublist + Client Validation + Inline Search
Great for “picker” UIs (choose items/customers), with fast, partial refresh behavior.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* Dynamic Sublist with Filter Bar and Client Validation
*/
define(['N/ui/serverWidget','N/search','N/log'], (serverWidget, search, log) => {
const onRequest = (ctx) => {
try {
if (ctx.request.method === 'GET') return render(ctx);
return handlePost(ctx);
} catch (e) {
log.error('Dyn Sublist Error', e);
const f = serverWidget.createForm({ title:'Error' });
f.addField({id:'custpage_err',type:'inlinehtml',label:' '}).defaultValue = `<pre>${e.message}</pre>`;
ctx.response.writePage(f);
}
};
function render(ctx) {
const q = (ctx.request.parameters.q || '').trim();
const f = serverWidget.createForm({ title:'Dynamic Picker' });
const filter = f.addField({ id:'custpage_q', type:'text', label:'Search Item Name' });
filter.defaultValue = q;
const sl = f.addSublist({ id:'custpage_res', type:'list', label:'Results' });
sl.addField({ id:'custpage_pick', type:'checkbox', label:'Pick' });
sl.addField({ id:'custpage_id', type:'text', label:'ID' });
sl.addField({ id:'custpage_name', type:'text', label:'Name' });
// Query
const data = findItems(q, 200);
data.forEach((row, i) => {
sl.setSublistValue({ id:'custpage_id', line:i, value:String(row.id) });
sl.setSublistValue({ id:'custpage_name', line:i, value:row.name });
});
f.addButton({ id:'custpage_btn_search', label:'Search', functionName:'onSearch' });
f.addSubmitButton({ label:'Add Selected' });
f.clientScriptModulePath = './cs_dynamic_picker.js';
ctx.response.writePage(f);
}
function handlePost(ctx) {
const count = ctx.request.getLineCount({ group:'custpage_res' });
const picked = [];
for (let i=0;i<count;i++){
if (ctx.request.getSublistValue({ group:'custpage_res', name:'custpage_pick', line:i }) === 'T') {
picked.push(ctx.request.getSublistValue({ group:'custpage_res', name:'custpage_id', line:i }));
}
}
const f = serverWidget.createForm({ title:'Added!' });
f.addField({ id:'custpage_msg', type:'inlinehtml', label:' '})
.defaultValue = `<div>Selected: ${picked.join(', ') || '(none)'}</div>`;
ctx.response.writePage(f);
}
function findItems(q, limit=200){
const filters = q ? [['itemid','contains',q]] : [];
const s = search.create({ type:'item', filters, columns:['internalid','itemid']});
return (s.run().getRange({ start:0, end:limit })||[]).map(r=>({
id:r.getValue('internalid'), name:r.getValue('itemid')
}));
}
return { onRequest };
});
Client Script (cs_dynamic_picker.js
):
/**
* @NApiVersion 2.1
* @NScriptType ClientScript
* Adds search behavior & front-end validation
*/
define(['N/currentRecord','N/url','N/notification'], (currentRecord, url, notification) => {
function onSearch() {
try {
const rec = currentRecord.get();
const q = rec.getValue('custpage_q') || '';
if (q.length && q.length < 2) {
notification.show({ title:'Hint', message:'Type at least 2 characters for better results.', type: notification.Type.INFORMATION });
}
// Reload Suitelet with q param
const href = addParam(window.location.href, 'q', encodeURIComponent(q));
window.location.href = href;
} catch (e) { console.log('onSearch error', e); }
}
function addParam(urlStr, key, val) {
const u = new URL(urlStr);
u.searchParams.set(key, val); return u.toString();
}
return { onSearch };
});
📌 What this shows
- Client-side search without rebuilding the whole Suitelet logic.
- Basic validation and UX nudges.
✅ Example 3 — File Upload + CSV Parse + Results Preview
Perfect for mass imports/updates with confirmation before commit.
/**
* @NApiVersion 2.1
* @NScriptType Suitelet
* Upload a CSV, parse, preview, then confirm
*/
define(['N/ui/serverWidget','N/file','N/log'], (ui, file, log) => {
const onRequest = (ctx) => {
try {
if (ctx.request.method === 'GET') return renderUpload(ctx);
return handleUpload(ctx);
} catch (e) {
log.error('Upload Error', e);
const f = ui.createForm({ title:'Error' });
f.addField({ id:'custpage_err', type:'inlinehtml', label:' '}).defaultValue = `<pre>${e.message}</pre>`;
ctx.response.writePage(f);
}
};
function renderUpload(ctx){
const f = ui.createForm({ title:'CSV Import – Step 1: Upload' });
f.addField({ id:'custpage_csv', type:'file', label:'CSV File' });
f.addSubmitButton({ label:'Preview ▶' });
ctx.response.writePage(f);
}
function handleUpload(ctx){
const csvFile = ctx.request.files.custpage_csv;
if (!csvFile) throw new Error('No file received.');
const text = csvFile.getContents();
const rows = parseCsv(text).slice(0, 200); // cap preview
// Preview form
const f = ui.createForm({ title:'Step 2: Preview' });
const sl = f.addSublist({ id:'custpage_preview', type:'list', label:'First 200 Rows' });
// assuming simple CSV with cols: sku, qty
sl.addField({ id:'custpage_sku', type:'text', label:'SKU' });
sl.addField({ id:'custpage_qty', type:'text', label:'Qty' });
rows.forEach((r,i)=>{
sl.setSublistValue({ id:'custpage_sku', line:i, value:r[0]||'' });
sl.setSublistValue({ id:'custpage_qty', line:i, value:r[1]||'' });
});
// keep original content in hidden field for POST2 (confirm)
f.addField({ id:'custpage_payload', type:'longtext', label:'payload', displayType:'hidden' }).defaultValue = text;
f.addSubmitButton({ label:'Confirm & Process' });
ctx.response.writePage(f);
}
function parseCsv(txt='') {
// very naive CSV split for demo; replace with robust parser as needed
return txt.split(/\r?\n/).filter(Boolean).map(line => line.split(','));
}
return { onRequest };
});
📌 What this shows
N/file
upload handling.- Safe preview before processing.
- Use a second POST to commit changes (Map/Reduce recommended for big jobs).
🔹 Advanced Tips & Patterns
1) Client-Server Communication
- Add
form.clientScriptModulePath
to attach a Client Script. - For AJAX-like flows, set up a second Suitelet endpoint that returns JSON and call it via
https.request
from the browser (or useN/url.resolveScript
+fetch
).
2) Caching & Performance
- Use
N/cache
for small lookups (e.g., list of accounts, static maps). - Paginate large searches; avoid loading >1,000 rows at once.
- Use SuiteQL for complex joins (faster than joined searches sometimes).
3) Governance
- Suitelets run synchronously; keep heavy work off the request path.
- For bulk actions, queue a Map/Reduce and show a status page or task ID.
4) Security
- Validate all parameters (never trust query string).
- Hide internal IDs unless needed.
- Use permissions/roles to restrict access; check
runtime.getCurrentUser()
.
5) UX Enhancements
- Mark-all buttons on sublists.
- Inline HTML fields for callouts, warnings, and quick help.
- Add icons/emojis in labels sparingly to guide users.
🔹 Common Gotchas
- “You have attempted to access a page that is not available” → Role/permission or deployment audience issue.
- Blank pages after submit → Forgot
response.writePage()
or redirect. - Lost state → Persist critical data in hidden fields or query params.
✅ Wrap-Up
Advanced Suitelets let you build rich, guided tools right inside NetSuite—perfect for admin dashboards, mass actions, import wizards, and partner toolings. Use multi-step flows, dynamic sublists, client helpers, and offload heavy lifting to Map/Reduce for scale.
Leave a Reply