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.
| Good | Bad |
|---|---|
| "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
| Item | Check |
|---|---|
| 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
| Issue | Solution |
|---|---|
| Portlet times out | Reduce search scope, use caching |
| Data appears stale | Implement cache refresh, show timestamp |
| Broken on certain roles | Test with multiple role perspectives |
| Layout breaks | Use relative widths, test resize |
| Too many portlets slow page | Limit per-page portlets, use lazy loading |
See Also
- Portlets Overview - Introduction to portlets
- Script Portlets - SuiteScript examples
- List/Form Portlets - Interactive portlets