Skip to main content

Dashboard Best Practices

Guidelines for creating effective, performant, and user-friendly dashboard portlets.


Design Principles​

1. Single Purpose​

Each portlet should focus on one specific task or data view.

GoodBad
"Pending Approvals" - Lists items needing approval"Everything Dashboard" - Mix of unrelated data
"Sales This Week" - Clear time-bound metric"Sales and Inventory and Tasks" - Too broad
"Quick Order Entry" - Single form action"Order/Invoice/Payment Form" - Confusing

2. Glanceable Information​

Users should understand portlet content in seconds.

GOOD LAYOUT                          BAD LAYOUT
────────────── ──────────────
┌────────────────────┐ ┌────────────────────┐
│ Open Orders: 47 │ │ Order Stats │
│ ▲ 12% from yesterday│ │ Total: 47 │
│ │ │ Yesterday: 42 │
│ [View All] │ │ Last Week: 289 │
└────────────────────┘ │ Avg: 41.28 │
│ Std Dev: 8.2 │
Clear, actionable info │ Median: 39 │
└────────────────────┘
Too much detail

3. Consistent Styling​

Match NetSuite's UI patterns and your organization's style guide.

/* Recommended color palette */
.success { color: #27ae60; } /* Green - positive */
.warning { color: #f39c12; } /* Orange - attention */
.danger { color: #e74c3c; } /* Red - critical */
.info { color: #3498db; } /* Blue - informational */
.muted { color: #95a5a6; } /* Gray - secondary */

/* Standard spacing */
.portlet-content {
padding: 15px;
font-size: 13px;
line-height: 1.4;
}

Performance Guidelines​

Limit Data Volume​

// BAD: Load all records
search.create({
type: 'salesorder',
filters: [['mainline', 'is', 'T']]
}).run().each(result => {
portlet.addRow({ /* ... */ });
return true; // Processes all results
});

// GOOD: Limit to display needs
let count = 0;
const MAX_ROWS = 10;

search.create({
type: 'salesorder',
filters: [['mainline', 'is', 'T']],
columns: [
search.createColumn({ name: 'tranid', sort: search.Sort.DESC })
]
}).run().each(result => {
if (count >= MAX_ROWS) return false;
portlet.addRow({ /* ... */ });
count++;
return true;
});

Use Caching for Expensive Operations​

const cache = require('N/cache');

const getExpensiveData = () => {
const portletCache = cache.getCache({
name: 'DASHBOARD_CACHE',
scope: cache.Scope.PUBLIC
});

let data = portletCache.get({ key: 'summary_data' });

if (!data) {
data = JSON.stringify(calculateSummary());
portletCache.put({
key: 'summary_data',
value: data,
ttl: 300 // 5 minutes
});
}

return JSON.parse(data);
};

Minimize Search Columns​

// BAD: Select all columns
search.create({
type: 'salesorder',
columns: ['tranid', 'entity', 'trandate', 'status', 'total',
'shipdate', 'shipmethod', 'location', 'department',
'class', 'memo', 'custbody_field1', 'custbody_field2'
/* ... many unused columns */]
});

// GOOD: Only needed columns
search.create({
type: 'salesorder',
columns: ['tranid', 'entity', 'total'] // Display requirements only
});

User Experience​

Provide Actions​

Always include clear next steps for users.

// Add action links
portlet.addLine({
text: `
<div style="margin-top: 10px; text-align: right;">
<a href="${viewAllUrl}">View All</a> |
<a href="${createUrl}">Create New</a> |
<a href="${exportUrl}">Export</a>
</div>
`
});

Show Loading States for Complex Portlets​

portlet.html = `
<div id="portlet-content">
<div class="loading">
<img src="/images/loading.gif" alt="Loading..." />
<p>Loading data...</p>
</div>
</div>
<script>
// Load data async if needed
jQuery(document).ready(function() {
loadPortletData();
});
</script>
`;

Handle Empty States​

if (resultCount === 0) {
portlet.html = `
<div style="text-align: center; padding: 30px; color: #666;">
<img src="/images/icons/check.png" alt="Complete" style="width: 48px;" />
<p style="margin-top: 10px;">No pending approvals!</p>
<p style="font-size: 12px;">All items have been processed.</p>
</div>
`;
} else {
// Show data...
}

Accessibility​

Keyboard Navigation​

// Ensure links are focusable
portlet.html = `
<a href="${url}" tabindex="0">View Record</a>
`;

// Add skip links for complex portlets
portlet.html = `
<a href="#portlet-main" class="sr-only">Skip to main content</a>
<div id="portlet-main">
<!-- Content -->
</div>
`;

Screen Reader Support​

// Use semantic HTML
portlet.html = `
<table role="grid" aria-label="Open Orders">
<thead>
<tr>
<th scope="col">Order #</th>
<th scope="col">Customer</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
${rows}
</tbody>
</table>
`;

// Add alt text for visual elements
portlet.html = `
<img src="chart.png" alt="Sales chart showing 15% increase this month" />
`;

Color Contrast​

/* Ensure readable contrast ratios */
.status-text {
/* Bad: #aaa on #fff = 2.9:1 ratio */
/* Good: #666 on #fff = 5.7:1 ratio */
color: #666;
}

/* Don't rely on color alone */
.overdue {
color: #e74c3c;
font-weight: bold; /* Additional visual cue */
}
.overdue::before {
content: "⚠ "; /* Icon indicator */
}

Mobile Considerations​

/* Responsive portlet content */
.portlet-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}

.portlet-grid > div {
flex: 1 1 200px; /* Min 200px, grow equally */
}

/* Hide less important columns on mobile */
@media (max-width: 768px) {
.hide-mobile { display: none; }
}

/* Stack elements vertically */
@media (max-width: 600px) {
.portlet-row {
display: block;
margin-bottom: 15px;
padding-bottom: 15px;
border-bottom: 1px solid #eee;
}
}

Security Best Practices​

Respect Permissions​

const runtime = require('N/runtime');

const render = (params) => {
const user = runtime.getCurrentUser();

// Check permission before showing data
if (!user.getPermission({ name: 'LIST_CUSTINVC' })) {
params.portlet.html = '<p>You do not have permission to view invoices.</p>';
return;
}

// Filter data by user's restrictions
const filters = [['mainline', 'is', 'T']];

if (!user.roleCenter === 'ADMIN') {
filters.push('AND', ['subsidiary', 'anyof', user.subsidiary]);
}

// Continue with search...
};

Sanitize User Input​

const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};

// Use when displaying user-provided data
portlet.addRow({
memo: escapeHtml(record.getValue('memo'))
});

Validate Form Submissions​

// In submission handler Suitelet
const params = context.request.parameters;

// Validate required fields
if (!params.custpage_customer) {
throw error.create({
name: 'MISSING_REQUIRED',
message: 'Customer is required'
});
}

// Validate numeric input
const qty = parseInt(params.custpage_qty);
if (isNaN(qty) || qty < 1) {
throw error.create({
name: 'INVALID_QUANTITY',
message: 'Quantity must be a positive number'
});
}

Deployment Checklist​

ItemCheck
Role restrictions configured☐
Performance tested with real data volume☐
Empty state handled☐
Error handling in place☐
Logging for debugging☐
Mobile display tested☐
Accessibility reviewed☐
User documentation created☐

Common Pitfalls​

IssueSolution
Portlet times outReduce search scope, use caching
Data appears staleImplement cache refresh, show timestamp
Broken on certain rolesTest with multiple role perspectives
Layout breaksUse relative widths, test resize
Too many portlets slow pageLimit per-page portlets, use lazy loading

See Also​