Custom Views
Add new list view types or override built-in views such as the Map view with custom rendering.
On this page
Add entirely new view types to Frappe's list view system or override existing views for specific doctypes. This allows replacing the default table-based list with custom interfaces (Kanban boards, card layouts, Vue apps, etc.).
Architecture Overview
Three components work together:
ListViewSelectoverride — Injects new view options into the sidebar drop downRouter registration — Tells Frappe's router about the new view slug
View class — The actual rendering implementation
All three go in a single app.bundle.js file included globally via hooks.py.
File Setup
myapp/public/js/app.bundle.jsIn hooks.py:
app_include_js = "myapp.app.bundle.js"Step 1: Override ListViewSelect
Add a custom view menu item for a specific doctype:
frappe.provide("frappe.views");
frappe.views.MyViewSelect = class MyViewSelect extends frappe.views.ListViewSelect {
setup_views() {
// Call parent to get all default views
super.setup_views();
// Add custom view only for a specific doctype
if (this.doctype === "Task") {
this.add_view_to_menu("Tasks", () => this.set_route("tasks"));
}
// Add a "List" option so users can switch back
this.add_view_to_menu("List", () => this.set_route("List"));
}
};
// Globally replace the ListViewSelect class
frappe.views.ListViewSelect = frappe.views.MyViewSelect;Important: The global replacement (frappe.views.ListViewSelect = ...) is necessary because Frappe instantiates ListViewSelect directly. There's no hook to register a custom one per doctype.
Step 2: Register the Route
// Tell the router about the new view slug
frappe.router.list_views.push("tasks");
frappe.router.list_views_route["tasks"] = "Tasks";The slug ("tasks") maps to a view name ("Tasks"), which Frappe uses to look up frappe.views.TasksView (convention: frappe.views.{Name}View).
Step 3: Implement the View Class
frappe.views.TasksView = class TasksView extends frappe.views.ListView {
setup_defaults() {
return super.setup_defaults().then(() => {
this.page_title = __("Tasks");
this.page_name = "tasks-view";
// Custom API endpoint for data fetching
this.method = "myapp.api.get_tasks";
});
}
// Override data parsing (custom API returns different structure)
prepare_data(r) {
this.data = r.message;
}
setup_page() {
return super.setup_page().then(() => {
// Fetch list settings for theming
frappe.model.with_doctype(this.doctype, () => {
this.list_settings = new frappe.base_list.ListSettings(this.doctype);
});
// Custom primary action
this.page.set_primary_action(__("New Task"), () => {
frappe.new_doc("Task");
}, "add");
});
}
// Remove loading skeletons
show_skeleton() {}
hide_skeleton() {}
// Remove default list header
render_header(refresh_header = false) {
this.$result?.find(".list-row-head").remove();
}
// Render a completely custom UI
render_list() {
if (!this.data?.length) {
this.render_no_result();
return;
}
// Clear previous content
this.$result.empty();
// Option A: Mount a Vue component
const container = document.createElement("div");
this.$result[0].appendChild(container);
const { createApp, h } = Vue;
createApp({
render() {
return h(TaskBoard, { docs: this.data });
},
data: () => ({ data: this.data }),
}).mount(container);
// Option B: Direct HTML rendering
// this.$result.html(this.data.map(row => `<div>...</div>`).join(""));
}
};Naming Convention
The view class must follow this pattern:
Route slug: "tasks"
Route name: "Tasks" (in frappe.router.list_views_route)
Class name: frappe.views.TasksView (frappe.views.{Name}View)Useful Methods to Override
| Method | Purpose |
|---|---|
setup_defaults() | Set page title, method, page_name |
prepare_data(r) | Parse API response into this.data |
setup_page() | Configure page actions, sidebar |
render_header() | Customize or remove the header row |
render_list() | Main rendering logic |
show_skeleton() / hide_skeleton() | Loading state (often disabled) |
get_args() | Customize API call arguments |
Complete Example
frappe.provide("frappe.views");
frappe.provide("frappe.ui.toolbar");
// 1. Override ListViewSelect
frappe.views.TaskViewSelect = class TaskViewSelect extends frappe.views.ListViewSelect {
setup_views() {
super.setup_views();
if (this.doctype === "Task") {
this.add_view_to_menu("Tasks", () => this.set_route("tasks"));
}
this.add_view_to_menu("List", () => this.set_route("List"));
}
};
// 2. Register route
frappe.router.list_views.push("tasks");
frappe.router.list_views_route["tasks"] = "Tasks";
// 3. Implement view
frappe.views.TasksView = class TasksView extends frappe.views.ListView {
prepare_data(r) { this.data = r.message; }
setup_defaults() {
return super.setup_defaults().then(() => {
this.page_title = __("Tasks");
this.method = "myapp.api.get_tasks";
});
}
show_skeleton() {}
hide_skeleton() {}
render_header() { this.$result?.find(".list-row-head").remove(); }
render_list() {
// Custom rendering here
}
};
// 4. Global override
frappe.views.ListViewSelect = frappe.views.TaskViewSelect;Overriding a Built-in View
You don't always need a new view type. To change how an existing view behaves (Map, Kanban, Calendar, and so on), subclass it and assign the subclass back to the same name. frappe.views.ListFactory looks the class up by name (frappe.views[view_name + "View"]) each time the route is opened, so the replacement takes effect everywhere without touching the router.
Put the override in the same globally included app.bundle.js, and limit the changes to the doctypes you care about so other doctypes keep the stock behavior.
Example: Custom Map View Popups
The Map view shows each record as a marker whose popup links to the document by name. This override shows a more useful label for one doctype.
Frappe v16 exposes small hook methods for this, so you only override what you need:
frappe.views.CustomMapView = class CustomMapView extends frappe.views.MapView {
setup_defaults() {
super.setup_defaults();
if (this.doctype === "<DOCTYPE>") {
// Make sure the label field is fetched with the list data
this._add_field("<LABEL_FIELD>");
}
}
get_feature_properties(row) {
return { ...super.get_feature_properties(row), label: row.<LABEL_FIELD> };
}
get_popup_content(feature) {
const { name, label } = feature.properties;
if (!label) return super.get_popup_content(feature);
return frappe.utils.get_form_link(this.doctype, name, true, frappe.utils.escape_html(label));
}
};
frappe.views.MapView = frappe.views.CustomMapView;In Frappe v15, the Map view builds its markers in a single get_coords() call and always labels popups with the document name, so get_coords() is the method to override. Use it to control which records and coordinates are mapped. This version reads latitude and longitude fields directly and loads every record that matches the current filters:
frappe.views.CustomMapView = class CustomMapView extends frappe.views.MapView {
get_coords() {
if (this.doctype !== "<DOCTYPE>") return super.get_coords();
return frappe.db
.get_list(this.doctype, {
fields: ["name", "latitude", "longitude"],
filters: this.get_filters_for_args(),
limit: 0, // no limit
})
.then((rows) => {
this.coords = {
type: "FeatureCollection",
features: rows
.filter((row) => row.latitude && row.longitude)
.map((row) => ({
type: "Feature",
properties: { name: row.name },
geometry: {
type: "Point",
// GeoJSON order is [longitude, latitude]
coordinates: [parseFloat(row.longitude), parseFloat(row.latitude)],
},
})),
};
});
}
};
frappe.views.MapView = frappe.views.CustomMapView;Important: Built-in views change between major versions (the v15 and v16 Map views share almost no method names). Re-check every override against the new source when you upgrade, and always fall back to super for doctypes you aren't customizing.
Sources
This article is in the public domain (CC0 1.0), code samples included. Use it however helps you.