Skip to content

Custom List View with Actions

Problem: Enhance the ScoopJoy Franchise Outlet list view with bulk actions, custom columns, indicator dots, and visual cues — so an area manager can scan outlet health at a glance and act on several outlets at once.

Solution: Register a frappe.listview_settings object keyed by the DocType name. Frappe loads it automatically when the list renders, giving you hooks for status indicators, per-column formatters, header buttons, and checkbox-driven bulk actions.

Drop one JS file in the DocType folder. Below it is broken into the pieces the walkthrough covers; in the app it lives as a single object.

scoopjoy/scoopjoy/franchise_outlet/franchise_outlet_list.js
frappe.listview_settings['Franchise Outlet'] = {
// Extra fields to fetch (available in formatters/indicators even if not displayed)
add_fields: [
'status', 'franchise_type', 'city', 'current_month_revenue',
'avg_customer_rating', 'last_inspection_date', 'area_manager',
],
// Default filters when the list loads
filters: [['status', '!=', 'Closed']],
hide_name_column: true,
// ... (indicator, formatters, onload, button — shown below)
};

get_indicator returns a [label, color, filter_string] triple. The filter string (status,=,Active) makes the colored dot clickable — clicking it filters the list to that status. The fallback handles any status not in the map.

scoopjoy/scoopjoy/franchise_outlet/franchise_outlet_list.js
get_indicator(doc) {
const indicators = {
'Active': [__('Active'), 'green', 'status,=,Active'],
'Onboarding': [__('Onboarding'), 'blue', 'status,=,Onboarding'],
'Suspended': [__('Suspended'), 'red', 'status,=,Suspended'],
'Inactive': [__('Inactive'), 'darkgrey', 'status,=,Inactive'],
'Closed': [__('Closed'), 'grey', 'status,=,Closed'],
};
return indicators[doc.status] || [__(doc.status), 'grey', `status,=,${doc.status}`];
},

Each formatter receives (value, field, doc) and returns an HTML string. Here a franchise-type badge is appended to the name, revenue is colored by threshold, and the rating is rendered as star glyphs.

scoopjoy/scoopjoy/franchise_outlet/franchise_outlet_list.js
formatters: {
franchise_name(value, field, doc) {
// Add a badge for franchise type
const type_colors = {
'Premium': 'blue',
'Standard': 'orange',
'Express': 'grey',
};
const color = type_colors[doc.franchise_type] || 'grey';
return `${value} <span class="badge badge-${color}" style="font-size:10px;">${doc.franchise_type || ''}</span>`;
},
current_month_revenue(value) {
if (!value) return '<span class="text-muted">--</span>';
const formatted = format_currency(value, 'INR');
const color = value > 200000 ? 'green' : value > 100000 ? 'orange' : 'red';
return `<span class="text-${color} fw-bold">${formatted}</span>`;
},
avg_customer_rating(value) {
if (!value) return '<span class="text-muted">N/A</span>';
const stars = '&#9733;'.repeat(Math.round(value)) +
'&#9734;'.repeat(5 - Math.round(value));
const color = value >= 4 ? '#4caf50' : value >= 3 ? '#ff9800' : '#f44336';
return `<span style="color:${color}">${stars}</span> (${value.toFixed(1)})`;
},
},

onload(listview) runs once when the view mounts. Use listview.page.add_inner_button for header filter shortcuts and report buttons, and listview.page.add_action_item for bulk actions that appear once rows are checked. listview.get_checked_items() returns the selected docs; wrap destructive work in frappe.confirm and freeze the UI during the async call.

scoopjoy/scoopjoy/franchise_outlet/franchise_outlet_list.js
onload(listview) {
// Add custom filter shortcuts
listview.page.add_inner_button(__('Premium Only'), () => {
listview.filter_area.clear();
listview.filter_area.add([[listview.doctype, 'franchise_type', '=', 'Premium']]);
listview.refresh();
}, __('Quick Filters'));
listview.page.add_inner_button(__('Needs Inspection'), () => {
const thirty_days_ago = frappe.datetime.add_days(frappe.datetime.nowdate(), -30);
listview.filter_area.clear();
listview.filter_area.add([
[listview.doctype, 'last_inspection_date', '<', thirty_days_ago],
[listview.doctype, 'status', '=', 'Active'],
]);
listview.refresh();
}, __('Quick Filters'));
// Custom button in list view header
listview.page.add_inner_button(__('Export Performance Report'), async () => {
frappe.dom.freeze('Generating report...');
try {
const url = await frappe.xcall(
'scoopjoy.api.reports.generate_performance_csv'
);
frappe.dom.unfreeze();
window.open(url);
} catch (e) {
frappe.dom.unfreeze();
frappe.msgprint({ title: 'Error', indicator: 'red', message: e.message });
}
});
// Bulk action — "Send Monthly Report"
listview.page.add_action_item(__('Send Monthly Report'), () => {
const selected = listview.get_checked_items();
if (!selected.length) {
frappe.msgprint(__('Please select at least one outlet.'));
return;
}
frappe.confirm(
__('Send monthly reports to {0} outlet(s)?', [selected.length]),
async () => {
frappe.dom.freeze('Sending reports...');
try {
const result = await frappe.xcall(
'scoopjoy.api.reports.send_monthly_reports',
{ outlets: selected.map(d => d.name) }
);
frappe.dom.unfreeze();
frappe.msgprint({
title: 'Reports Sent',
indicator: 'green',
message: __('Successfully sent {0} reports. {1} failed.',
[result.sent, result.failed]),
});
} catch (e) {
frappe.dom.unfreeze();
frappe.msgprint({ title: 'Error', indicator: 'red', message: e.message });
}
}
);
});
// Bulk action: Toggle Active/Suspended
listview.page.add_action_item(__('Suspend Selected'), () => {
const selected = listview.get_checked_items();
if (!selected.length) {
frappe.msgprint(__('Please select at least one outlet.'));
return;
}
frappe.confirm(
__('Suspend {0} outlet(s)?', [selected.length]),
async () => {
for (const doc of selected) {
await frappe.xcall('frappe.client.set_value', {
doctype: 'Franchise Outlet',
name: doc.name,
fieldname: 'status',
value: 'Suspended',
});
}
listview.refresh();
frappe.show_alert({
message: __('{0} outlets suspended.', [selected.length]),
indicator: 'orange',
});
}
);
});
},

The button config renders a per-row button on hover. show(doc) decides visibility, and action(doc) runs on click — here it opens a pre-filled new Sales Invoice for an active outlet.

scoopjoy/scoopjoy/franchise_outlet/franchise_outlet_list.js
// Row-level button (appears on hover)
button: {
show(doc) {
return doc.status === 'Active';
},
get_label() {
return __('Quick Sale');
},
get_description(doc) {
return __('Record a quick sale for {0}', [doc.franchise_name]);
},
action(doc) {
frappe.new_doc('Sales Invoice', {
custom_franchise_outlet: doc.name,
customer: doc.default_customer,
});
},
},