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.
The list view settings file
Section titled “The list view settings file”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.
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)};Status indicators
Section titled “Status indicators”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.
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}`]; },Column formatters
Section titled “Column formatters”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.
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 = '★'.repeat(Math.round(value)) + '☆'.repeat(5 - Math.round(value)); const color = value >= 4 ? '#4caf50' : value >= 3 ? '#ff9800' : '#f44336'; return `<span style="color:${color}">${stars}</span> (${value.toFixed(1)})`; }, },Header buttons and bulk actions
Section titled “Header buttons and bulk actions”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.
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', }); } ); }); },Row-level button
Section titled “Row-level button”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.
// 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, }); }, },