
Frappe Impl Workspace
- 57 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/erpnext_anthropic_claude_development_skill_package
Create and customize Frappe Workspace desk pages with shortcuts, number cards, and dashboard charts while avoiding content/child-table desync.
About
Guides creating and customizing Frappe Workspace pages, the block-based dashboard and navigation pages in Desk. A developer uses it when building module dashboards, shortcuts, or number cards shipped with a custom app.
- Create and customize Workspace desk pages with the JSON content format
- Covers shortcuts, number cards, dashboard charts, and fixtures
Frappe Impl Workspace by the numbers
- 57 all-time installs (skills.sh)
- Ranked #1,238 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/erpnext_anthropic_claude_development_skill_package --skill frappe-impl-workspaceAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 57 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/erpnext_anthropic_claude_development_skill_package ↗ |
What it does
Create and customize Frappe Workspace desk pages with shortcuts, number cards, and dashboard charts while avoiding content/child-table desync.
Files
Frappe Workspace Implementation Workflow
Step-by-step workflows for creating and customizing Workspace pages. Workspaces are the block-based dashboard/navigation pages in Frappe Desk.
Version: v14/v15/v16 (version-specific features noted)
---
Quick Reference
| Concept | Description |
|---|---|
| Workspace | Block-based page with 12-column grid layout |
| Public Workspace | Visible to all permitted users; requires Workspace Manager role to edit |
| Private Workspace | Per-user dashboard under "My Workspaces"; any Desk User can create |
| Content field | JSON array storing the block layout |
| Child tables | 6 tables: charts, shortcuts, links, quick_lists, number_cards, custom_blocks |
| Module association | Primary access control mechanism |
---
Master Decision: What Do You Need?
NEED A WORKSPACE?
│
├─► Default DocType landing page?
│ └─► NO workspace needed — Frappe auto-generates list views
│
├─► Custom dashboard for a module?
│ └─► Create PUBLIC Workspace (Workspace Manager role required)
│
├─► Personal dashboard for a user?
│ └─► Create PRIVATE Workspace (appears under "My Workspaces")
│
└─► Navigation link in sidebar?
└─► type="Link" (internal) or type="URL" (external)
ADDING COMPONENTS?
│
├─► Key metrics (counts, sums) → Number Cards
├─► Trend / time-series data → Dashboard Charts
├─► Quick navigation links → Shortcuts
├─► Grouped link categories → Link Cards (Card Break + Links)
├─► Custom HTML/JS content → Custom HTML Blocks
└─► Recent record lists → Quick Lists---
Workspace DocType Structure
Key Fields
| Field | Type | Purpose |
|---|---|---|
label | Data | Display name in sidebar |
title | Data | Page title (defaults to label) |
module | Link → Module Def | Associates workspace with a module for access control |
parent_page | Link → Workspace | Nesting under another workspace in sidebar |
icon | Data | Sidebar icon (e.g., "chart-line") |
type | Select | Workspace / Link / URL (v15+) |
sequence_id | Int | Sidebar ordering |
content | JSON | Block layout as JSON array |
for_user | Data | If set, workspace is private to that user |
roles | Table → Has Role | Role-based access restrictions |
app | Data | Owning app identifier (v15+) |
indicator_color | Color | Sidebar indicator dot (v15+) |
Child Tables (6 total)
| Child Table | DocType | Purpose |
|---|---|---|
charts | Workspace Chart | Dashboard Chart references |
shortcuts | Workspace Shortcut | DocType/Report/Page/URL shortcuts |
links | Workspace Link | Grouped navigation links |
quick_lists | Workspace Quick List | Recent record lists |
number_cards | Workspace Number Card | Metric card references |
custom_blocks | Workspace Custom Block | HTML block references |
CRITICAL: ThecontentJSON and the child tables MUST stay in sync. ALWAYS use the Workspace Builder UI or programmatic API — NEVER manually edit thecontentJSON without updating child tables. Seereferences/anti-patterns.md.
---
Content JSON Format
The content field is a JSON array. Each element represents a block in the 12-column grid:
[
{
"id": "unique-block-id",
"type": "header",
"data": {"text": "Overview", "level": 4, "col": 12}
},
{
"id": "unique-block-id-2",
"type": "chart",
"data": {
"chart_name": "Sales Trends",
"col": 12
}
},
{
"id": "unique-block-id-3",
"type": "number_card",
"data": {
"number_card_name": "Open Orders",
"col": 4
}
},
{
"id": "unique-block-id-4",
"type": "shortcut",
"data": {
"shortcut_name": "New Sales Order",
"col": 4
}
},
{
"id": "unique-block-id-5",
"type": "spacer",
"data": {"col": 12}
}
]Block Types
| Type | data fields | Description |
|---|---|---|
header | text, level, col | Section heading (h3/h4/h5) |
chart | chart_name, col | References a Dashboard Chart doc |
number_card | number_card_name, col | References a Number Card doc |
shortcut | shortcut_name, col | References a Workspace Shortcut child |
card | card_name, col | Card break for grouped links |
quick_list | quick_list_name, col | Recent records for a DocType |
custom_block | custom_block_name, col | References a Custom HTML Block doc |
text | body, col | Rich text / Markdown block |
spacer | col | Empty vertical space |
onboarding | onboarding_name, col | Module onboarding widget |
col values MUST be 1-12 and represent grid column width. Blocks in the same row MUST sum to ≤ 12.---
Implementation Workflows
Workflow 1: Create a Public Workspace via UI
1. Navigate to /app/workspace → click + New Workspace 2. Set Label (appears in sidebar), Module, Icon 3. Use the Workspace Builder to drag-and-drop blocks 4. Add components: Charts, Number Cards, Shortcuts, Links 5. Click Save → workspace appears in sidebar for permitted users 6. In developer mode: JSON auto-exports to your app directory
Workflow 2: Create a Workspace Programmatically
import frappe
import json
workspace = frappe.new_doc("Workspace")
workspace.label = "Project Dashboard"
workspace.module = "Projects"
workspace.icon = "project"
workspace.type = "Workspace"
workspace.sequence_id = 10
# Build content blocks
workspace.content = json.dumps([
{
"id": frappe.generate_hash(length=10),
"type": "header",
"data": {"text": "Project Overview", "level": 4, "col": 12}
},
{
"id": frappe.generate_hash(length=10),
"type": "number_card",
"data": {"number_card_name": "Active Projects", "col": 4}
},
{
"id": frappe.generate_hash(length=10),
"type": "chart",
"data": {"chart_name": "Project Status", "col": 12}
}
])
# Add child table entries (MUST match content JSON)
workspace.append("number_cards", {
"number_card_name": "Active Projects"
})
workspace.append("charts", {
"chart_name": "Project Status"
})
# Role restrictions (optional)
workspace.append("roles", {"role": "Projects Manager"})
workspace.insert(ignore_permissions=True)
frappe.db.commit()ALWAYS add corresponding child-table rows when setting content JSON programmatically.Workflow 3: Create Supporting Documents First
Before adding components to a workspace, create the referenced documents:
Number Card:
card = frappe.new_doc("Number Card")
card.label = "Active Projects"
card.document_type = "Project"
card.function = "Count"
card.filters_json = json.dumps([["Project", "status", "=", "Open"]])
card.is_public = 1
card.insert(ignore_permissions=True)Dashboard Chart:
chart = frappe.new_doc("Dashboard Chart")
chart.chart_name = "Project Status"
chart.chart_type = "Group By"
chart.document_type = "Project"
chart.group_by_type = "Count"
chart.group_by_based_on = "status"
chart.type = "Donut"
chart.is_public = 1
chart.insert(ignore_permissions=True)Shortcut: Shortcuts are child-table entries on the Workspace, not standalone docs:
workspace.append("shortcuts", {
"label": "New Project",
"type": "DocType",
"link_to": "Project",
"color": "Blue",
"format": "{} Active",
"stats_filter": json.dumps([["Project", "status", "=", "Open"]])
})See references/workspace-components.md for complete component reference.
---
Permission Model
Three Layers of Access Control
Layer 1: Module Access (PRIMARY)
└─► User must have access to the workspace's module
└─► Controlled via "Module Def" and user's "Block Modules" list
Layer 2: Role Restrictions (OPTIONAL)
└─► workspace.roles child table
└─► If populated: ONLY users with listed roles see the workspace
└─► If empty: ALL users with module access see it
Layer 3: Workspace Manager Role
└─► Required to create/edit PUBLIC workspaces
└─► NOT required for private workspacesRules
- ALWAYS set
moduleon public workspaces — without it, the workspace is visible to ALL Desk users - ALWAYS add role restrictions for sensitive dashboards (financial, HR)
- NEVER set
for_useron workspaces shipped with an app — it creates a private workspace
---
Version Differences
| Feature | v14 | v15 | v16 |
|---|---|---|---|
| Workspace Builder UI | Basic | Redesigned (drag-drop grid) | Incremental fixes |
type field (Workspace/Link/URL) | Not available | Added | Available |
app field | Not available | Added | Available |
indicator_color | Not available | Added | Available |
| Name collision protection | Manual | Manual | Auto-deduplicate |
| Welcome header config | Not available | Not available | Added |
| Content JSON format | Same | Same | Same |
Migration Notes
- v14 → v15: Workspace Builder UI changed significantly; existing JSON content remains compatible
- v15 → v16: Minor field additions; no breaking changes to workspace structure
- ALWAYS test workspace rendering after major version upgrades
---
Shipping Workspaces with a Custom App
Directory Structure
myapp/
└── mymodule/
└── workspace/
└── my_workspace/
└── my_workspace.jsonExport Process
1. Enable Developer Mode (frappe.conf.developer_mode = 1) 2. Create/edit workspace via Workspace Builder UI 3. On save, Frappe auto-exports to the app directory above 4. Commit the JSON file to version control
CRITICAL: Ship Dependencies Too
A workspace JSON alone is NOT sufficient. You MUST also ship:
| Component | How to Ship |
|---|---|
| Number Cards | fixtures in hooks.py OR myapp/fixtures/ |
| Dashboard Charts | fixtures in hooks.py OR myapp/fixtures/ |
| Custom HTML Blocks | fixtures in hooks.py OR myapp/fixtures/ |
| Linked Reports | Already shipped via report directory structure |
| Linked Pages | Already shipped via page directory structure |
# hooks.py
fixtures = [
{"dt": "Number Card", "filters": [["module", "=", "My Module"]]},
{"dt": "Dashboard Chart", "filters": [["module", "=", "My Module"]]},
{"dt": "Custom HTML Block", "filters": [["name", "in", ["My Block"]]]},
]See references/shipping-with-app.md for complete shipping guide.
---
Common Patterns
Pattern 1: Module Dashboard with KPIs
[Header: "Key Metrics"]
[Number Card: Open Orders (col=3)] [Number Card: Revenue (col=3)]
[Number Card: Pending (col=3)] [Number Card: Overdue (col=3)]
[Spacer]
[Header: "Trends"]
[Chart: Monthly Revenue (col=12)]
[Header: "Quick Access"]
[Shortcut: New Order (col=4)] [Shortcut: Reports (col=4)] [Shortcut: Settings (col=4)]Pattern 2: Role-Based Workspace
# Sales Manager sees full dashboard; Sales User sees limited view
# Option A: Two separate workspaces with different role restrictions
# Option B: One workspace — use Number Card/Chart permissions to filter
# Option A implementation:
ws_manager = frappe.get_doc({"doctype": "Workspace", "label": "Sales Management", ...})
ws_manager.append("roles", {"role": "Sales Manager"})
ws_user = frappe.get_doc({"doctype": "Workspace", "label": "Sales Overview", ...})
ws_user.append("roles", {"role": "Sales User"})Pattern 3: Sidebar Hierarchy
# Parent workspace
parent = frappe.get_doc({"doctype": "Workspace", "label": "CRM", "module": "CRM"})
# Child workspaces (nested in sidebar)
child = frappe.get_doc({
"doctype": "Workspace",
"label": "Lead Pipeline",
"module": "CRM",
"parent_page": "CRM" # References parent workspace label
})---
Reference Files
| File | Content |
|---|---|
references/workspace-components.md | Number Cards, Dashboard Charts, Shortcuts, Custom Blocks — full API |
references/shipping-with-app.md | JSON format, fixtures, module structure, install hooks |
references/anti-patterns.md | Common workspace mistakes and how to avoid them |
Workspace Anti-Patterns
Common mistakes when creating and shipping Frappe Workspaces, and how to avoid them.
---
Anti-Pattern 1: Content/Child-Table Desync
The Mistake
Manually editing the content JSON field without updating the corresponding child tables (or vice versa).
# WRONG: Adding a chart to content but not to the charts child table
workspace.content = json.dumps([
{"id": "abc", "type": "chart", "data": {"chart_name": "My Chart", "col": 12}}
])
workspace.save() # Chart block appears but may not render correctlyWhy It Breaks
Frappe's Workspace Builder reads BOTH the content JSON (for layout) and the child tables (for component metadata). When they disagree:
- Blocks appear in the layout but show as empty/broken
- The Builder UI may remove orphaned entries on next save
- Export to JSON captures the inconsistent state
The Fix
ALWAYS update both simultaneously:
# CORRECT: Update content AND child table together
workspace.content = json.dumps([
{"id": "abc", "type": "chart", "data": {"chart_name": "My Chart", "col": 12}}
])
workspace.append("charts", {"chart_name": "My Chart"})
workspace.save()Or better: use the Workspace Builder UI, which keeps them in sync automatically.
---
Anti-Pattern 2: Missing Fixture Dependencies
The Mistake
Shipping a workspace JSON that references Number Cards, Dashboard Charts, or Custom HTML Blocks without including those documents as fixtures.
Why It Breaks
On the target site, the workspace installs but the referenced components don't exist. Result:
- Number Card blocks show "Number Card 'X' not found"
- Chart blocks render as empty frames
- Custom blocks silently fail
The Fix
ALWAYS declare dependencies in hooks.py fixtures:
fixtures = [
{"dt": "Number Card", "filters": [["module", "=", "My Module"]]},
{"dt": "Dashboard Chart", "filters": [["module", "=", "My Module"]]},
{"dt": "Custom HTML Block", "filters": [["name", "in", ["Block A", "Block B"]]]},
]ALWAYS run bench export-fixtures after creating or modifying these documents.
---
Anti-Pattern 3: Editing via DocType Form
The Mistake
Navigating to /app/workspace/My Workspace (the DocType form view) and directly editing fields like content, charts, or shortcuts.
Why It Breaks
- The DocType form does not validate content/child-table consistency
- The JSON editor in the form does not enforce the block schema
- Easy to create malformed JSON that crashes the Workspace Builder
The Fix
ALWAYS use the Workspace Builder UI: 1. Navigate to Desk 2. Click the workspace in the sidebar 3. Click the Edit (pencil) icon 4. Use the visual builder to add/remove/reorder blocks
Exception: programmatic creation in install scripts or fixtures is fine — but use the documented API pattern with both content and child tables.
---
Anti-Pattern 4: No Module Association
The Mistake
Creating a workspace without setting the module field.
workspace = frappe.new_doc("Workspace")
workspace.label = "Secret HR Dashboard"
# module not set!
workspace.insert()Why It Breaks
Without a module, the workspace is visible to ALL Desk users. There is no module-level access control. Even adding role restrictions provides only a secondary layer — the workspace still appears in API responses.
The Fix
ALWAYS set the module field:
workspace.module = "HR" # Only users with HR module access see thisFor sensitive workspaces, add role restrictions as a second layer:
workspace.append("roles", {"role": "HR Manager"})---
Anti-Pattern 5: Workspace Name Collides with DocType
The Mistake
Naming a workspace the same as an existing DocType (e.g., creating a workspace named "Sales Order").
Why It Breaks
- URL routing conflicts:
/app/sales-ordercould mean the workspace OR the DocType list - Frappe v14/v15 may silently prefer one over the other
- v16 has auto-deduplication but the behavior can be confusing
The Fix
ALWAYS use descriptive, unique names for workspaces:
# WRONG
workspace.label = "Sales Order"
# CORRECT
workspace.label = "Sales Dashboard"
workspace.label = "Sales Overview"
workspace.label = "Order Management"---
Anti-Pattern 6: Shipping with for_user Set
The Mistake
Exporting a workspace that has for_user set (e.g., it was created as a private workspace during development).
{
"name": "my_dashboard",
"for_user": "admin@example.com",
"label": "My Dashboard"
}Why It Breaks
On the target site, the workspace installs as a private workspace owned by a user that may not exist. No other users can see it.
The Fix
ALWAYS verify the exported JSON does NOT contain for_user:
# Check before committing
grep -l "for_user" myapp/mymodule/workspace/*//*.jsonIf found, remove the field from the JSON or re-export from a public workspace.
---
Anti-Pattern 7: Hardcoded sequence_id
The Mistake
Setting sequence_id to a low fixed value (like 0, 1, or 2) which conflicts with core ERPNext workspaces.
Why It Breaks
- Sidebar ordering becomes unpredictable
- Multiple apps competing for
sequence_id = 1cause random ordering - Users cannot reliably find workspaces in the expected position
The Fix
Use a higher sequence_id for custom app workspaces:
# WRONG
workspace.sequence_id = 1 # Conflicts with core workspaces
# CORRECT
workspace.sequence_id = 20 # Leaves room for core and other appsConvention: core ERPNext uses 0-15; custom apps should use 20+.
---
Anti-Pattern 8: Oversized Workspaces
The Mistake
Putting too many components on a single workspace — 10+ charts, 20+ number cards, dozens of shortcuts.
Why It Breaks
- Page load time increases dramatically (each chart/card is a separate API call)
- Mobile rendering becomes unusable
- Users cannot find relevant information in the noise
The Fix
Follow the "one purpose per workspace" principle:
- Overview workspace: 3-4 KPI cards + 1-2 charts + key shortcuts
- Detail workspace: Focused on one area with relevant charts and lists
- Use sidebar hierarchy (
parent_page) to organize related workspaces
Rule of thumb: if a workspace takes more than 3 seconds to load, split it.
---
Anti-Pattern 9: Ignoring Permission on Components
The Mistake
Creating Number Cards or Dashboard Charts that query DocTypes the workspace user may not have access to.
Why It Breaks
- Frappe enforces permissions at the data layer — the component returns empty/error
- Users see "Insufficient Permission" errors on their dashboard
- The workspace appears broken even though it's a permission issue
The Fix
ALWAYS ensure component permissions align with workspace roles:
# If workspace is restricted to Sales User role,
# all Number Cards and Charts must query DocTypes
# that Sales User has read access to.
# Verify:
frappe.has_permission("Sales Order", "read", user="sales@example.com")For mixed-permission dashboards, use separate workspaces per role (see Pattern 2 in SKILL.md).
---
Quick Checklist: Avoid All Anti-Patterns
- [ ] Content JSON and child tables are in sync
- [ ] All referenced Number Cards, Charts, and Custom Blocks are in fixtures
- [ ] Workspace was edited via Builder UI (not DocType form)
- [ ]
modulefield is set - [ ] Workspace name does not collide with any DocType name
- [ ]
for_useris NOT set in shipped JSON - [ ]
sequence_idis 20+ for custom app workspaces - [ ] Workspace has ≤ 6 charts and ≤ 8 number cards
- [ ] All component queries respect the target user's permissions
Shipping Workspaces with a Custom App
Complete guide for packaging workspaces and their dependencies for distribution.
---
Directory Structure
Frappe expects workspace JSON files in a specific location within your app:
myapp/
├── mymodule/
│ ├── workspace/
│ │ └── my_workspace/
│ │ └── my_workspace.json
│ └── module.json
├── hooks.py
└── fixtures/ # For Number Cards, Charts, Custom BlocksNaming Convention
- Directory name = workspace name (snake_case)
- JSON file name = workspace name (snake_case)
- The
namefield inside the JSON MUST match the directory/file name - NEVER use spaces in directory or file names
---
JSON File Format
The workspace JSON is a standard Frappe document export. Key fields:
{
"name": "my_workspace",
"doctype": "Workspace",
"label": "My Workspace",
"module": "My Module",
"icon": "chart-line",
"type": "Workspace",
"sequence_id": 10,
"content": "[{\"id\":\"abc123\",\"type\":\"header\",\"data\":{\"text\":\"Overview\",\"col\":12}}]",
"charts": [
{"chart": "My Chart Name"}
],
"shortcuts": [
{
"label": "New Order",
"type": "DocType",
"link_to": "Sales Order",
"color": "Blue"
}
],
"links": [
{
"type": "Card Break",
"label": "Reports"
},
{
"type": "Link",
"label": "Sales Analytics",
"link_to": "Sales Analytics",
"link_type": "Report"
}
],
"number_cards": [
{"number_card_name": "Open Orders"}
],
"custom_blocks": [
{"custom_block_name": "My Custom Block"}
],
"quick_lists": [],
"roles": [
{"role": "Sales Manager"}
]
}Critical Notes on the JSON
- The
contentfield is a stringified JSON array (JSON within JSON) - Child table arrays (
charts,shortcuts,links, etc.) MUST be consistent withcontent - NEVER include
for_userin shipped workspaces — it makes the workspace private - NEVER include
ownerormodified_byfields — Frappe sets these on install
---
Auto-Export in Developer Mode
When developer_mode = 1 in site_config.json:
1. Open the workspace in Workspace Builder 2. Make changes and click Save 3. Frappe automatically writes the JSON to your app directory 4. The file path is determined by the workspace's module field
Requirements
- The workspace's
moduleMUST belong to your app - The app MUST be installed on the site
- Developer mode MUST be enabled
Manual Export (if auto-export fails)
# In bench console
workspace = frappe.get_doc("Workspace", "My Workspace")
workspace.export_doc()---
Shipping Dependencies
The Problem
A workspace JSON references Number Cards, Dashboard Charts, and Custom HTML Blocks by name. These are separate DocType documents that do NOT auto-export with the workspace.
If you ship only the workspace JSON, the referenced components will be missing on the target site, resulting in empty blocks or errors.
Solution: Use Fixtures
Add dependent documents to hooks.py:
# hooks.py
fixtures = [
# Number Cards for your module
{
"dt": "Number Card",
"filters": [["module", "=", "My Module"]]
},
# Dashboard Charts for your module
{
"dt": "Dashboard Chart",
"filters": [["module", "=", "My Module"]]
},
# Custom HTML Blocks (filter by specific names)
{
"dt": "Custom HTML Block",
"filters": [["name", "in", [
"My Status Widget",
"My KPI Panel"
]]]
}
]Exporting Fixtures
# Export all fixtures defined in hooks.py
bench --site mysite.local export-fixtures
# This creates JSON files in:
# myapp/fixtures/number_card.json
# myapp/fixtures/dashboard_chart.json
# myapp/fixtures/custom_html_block.jsonImport Order on Installation
Frappe processes in this order during bench --site mysite.local install-app myapp:
1. Module definitions (module.json) 2. DocTypes and their configurations 3. Fixtures (Number Cards, Charts, Custom Blocks) 4. Workspaces (from workspace/ directories)
This order ensures dependencies exist before the workspace references them.
---
Alternative: Programmatic Setup in after_install
For complex setups, use a Python hook:
# hooks.py
after_install = "myapp.setup.install.after_install"
# myapp/setup/install.py
import frappe
import json
def after_install():
create_number_cards()
create_dashboard_charts()
# Workspace JSON is auto-imported — no need to create it here
def create_number_cards():
if not frappe.db.exists("Number Card", "Active Projects"):
card = frappe.new_doc("Number Card")
card.label = "Active Projects"
card.document_type = "Project"
card.function = "Count"
card.filters_json = json.dumps([["Project", "status", "=", "Open"]])
card.is_public = 1
card.insert(ignore_permissions=True)When to Use after_install vs Fixtures
| Approach | Use When |
|---|---|
| Fixtures | Simple documents that don't need conditional logic |
| after_install | Complex setup with conditions, defaults based on site config |
| Both | Fixtures for static data + after_install for dynamic setup |
---
Update / Migration Strategy
On App Update (bench --site mysite.local migrate)
- Workspace JSON: Auto-reimported (overwrites existing)
- Fixtures: Auto-reimported based on hooks.py definition
- Custom user modifications to public workspaces: Overwritten on migrate
Preserving User Customizations
Users who want to customize a shipped workspace should: 1. Duplicate the workspace (creates a private copy) 2. Customize the private copy 3. The original public workspace will update on migration without affecting the copy
Version-Specific Workspace Updates
# hooks.py — use after_migrate for version-aware updates
after_migrate = ["myapp.setup.migrate.after_migrate"]
# myapp/setup/migrate.py
def after_migrate():
# Check if new components need to be added
if not frappe.db.exists("Number Card", "New KPI Card"):
# Create the new dependency
create_new_kpi_card()---
Checklist: Shipping a Complete Workspace
- [ ] Workspace JSON exists at
myapp/mymodule/workspace/name/name.json - [ ]
modulefield is set to your app's module - [ ]
for_useris NOT set (would make it private) - [ ]
sequence_idis reasonable (not hardcoded to 0 or 1) - [ ] All referenced Number Cards are in fixtures
- [ ] All referenced Dashboard Charts are in fixtures
- [ ] All referenced Custom HTML Blocks are in fixtures
- [ ]
bench export-fixtureshas been run after any changes - [ ] Fixture JSON files are committed to version control
- [ ] Tested on a fresh site with
bench install-app - [ ] Verified workspace renders correctly after
bench migrate
Workspace Components Reference
Complete reference for all component types that can be added to a Frappe Workspace.
---
Shortcuts
Shortcuts provide quick-access buttons on the workspace. They are child-table entries on the Workspace DocType (Workspace Shortcut).
Fields
| Field | Type | Description |
|---|---|---|
label | Data | Display text on the shortcut button |
type | Select | DocType / Report / Page / URL |
link_to | Dynamic Link | Target document name (based on type) |
url | Data | External URL (when type = URL) |
color | Select | Button color: Grey, Blue, Orange, Green, Red, Yellow, Cyan, Pink |
format | Data | Stats format string, e.g., "{} Open" |
stats_filter | JSON | Filter for stats count display |
restrict_to_domain | Link | Domain restriction |
icon | Data | Icon name (optional) |
Example: DocType Shortcut with Stats
workspace.append("shortcuts", {
"label": "Open Orders",
"type": "DocType",
"link_to": "Sales Order",
"color": "Blue",
"format": "{} Open",
"stats_filter": json.dumps([
["Sales Order", "docstatus", "=", 1],
["Sales Order", "status", "=", "To Deliver and Bill"]
])
})Example: Report Shortcut
workspace.append("shortcuts", {
"label": "Sales Analytics",
"type": "Report",
"link_to": "Sales Analytics",
"color": "Orange"
})Example: URL Shortcut
workspace.append("shortcuts", {
"label": "Documentation",
"type": "URL",
"url": "https://docs.erpnext.com",
"color": "Grey"
})Rules
- ALWAYS set
color— defaults to Grey but explicit is better - ALWAYS match the
typewith the correct target field (link_toorurl) - Stats filters use the same format as frappe.get_list filters
formatstring uses{}as placeholder for the count value
---
Number Cards
Number Cards display single aggregate metrics. They are standalone documents (Number Card DocType) referenced from the workspace.
Three Types
1. Document Type (Aggregate)
Computes Count, Sum, Average, Min, or Max on a DocType field.
card = frappe.new_doc("Number Card")
card.label = "Total Revenue"
card.document_type = "Sales Invoice"
card.function = "Sum"
card.aggregate_function_based_on = "grand_total" # Required for Sum/Avg/Min/Max
card.filters_json = json.dumps([
["Sales Invoice", "docstatus", "=", 1],
["Sales Invoice", "posting_date", ">=", "2024-01-01"]
])
card.is_public = 1
card.show_percentage_stats = 1 # Show comparison with previous period
card.stats_time_interval = "Monthly"
card.insert()| Field | Required | Description |
|---|---|---|
label | Yes | Display name |
document_type | Yes | Source DocType |
function | Yes | Count / Sum / Average / Min / Max |
aggregate_function_based_on | For Sum/Avg/Min/Max | Field to aggregate |
filters_json | No | JSON filter array |
is_public | Yes | Set to 1 for workspace use |
show_percentage_stats | No | Show period-over-period comparison |
stats_time_interval | No | Daily / Weekly / Monthly / Yearly |
color | No | Card accent color |
2. Report
Pulls a value from a Report's output.
card = frappe.new_doc("Number Card")
card.label = "Monthly Profit"
card.type = "Report"
card.report_name = "Profit and Loss Statement"
card.report_field = "net_profit"
card.filters_json = json.dumps({"company": "My Company"})
card.is_public = 1
card.insert()3. Custom (Python Method)
Calls a whitelisted Python method that returns a numeric value.
# In your app's Python code:
@frappe.whitelist()
def get_active_subscription_count():
return frappe.db.count("Subscription", {"status": "Active"})
# Number Card document:
card = frappe.new_doc("Number Card")
card.label = "Active Subscriptions"
card.type = "Custom"
card.method = "myapp.api.get_active_subscription_count"
card.is_public = 1
card.insert()Rules
- ALWAYS set
is_public = 1for cards used in public workspaces - ALWAYS set
aggregate_function_based_onwhen function is Sum/Average/Min/Max - NEVER use
Countwithaggregate_function_based_on— Count ignores it - Custom method MUST be decorated with
@frappe.whitelist()
---
Dashboard Charts
Dashboard Charts display visual data representations. They are standalone documents (Dashboard Chart DocType).
Chart Types (data source)
chart_type | Description |
|---|---|
Count | Count documents over time |
Sum | Sum a field over time |
Average | Average a field over time |
Group By | Group documents by a field |
Custom | Whitelisted Python method |
Report | Data from a Report |
Visual Types
type | Best for |
|---|---|
Line | Time-series trends |
Bar | Categorical comparison |
Percentage | Part-of-whole (stacked bar) |
Pie | Distribution (≤8 categories) |
Donut | Distribution with center metric |
Heatmap | Activity over calendar year |
Example: Time-Series Count Chart
chart = frappe.new_doc("Dashboard Chart")
chart.chart_name = "Monthly New Customers"
chart.chart_type = "Count"
chart.document_type = "Customer"
chart.based_on = "creation" # Date field for time axis
chart.time_interval = "Monthly"
chart.timespan = "Last Year"
chart.type = "Line" # Visual type
chart.color = "#4CAF50"
chart.is_public = 1
chart.insert()Example: Group By Chart
chart = frappe.new_doc("Dashboard Chart")
chart.chart_name = "Orders by Status"
chart.chart_type = "Group By"
chart.document_type = "Sales Order"
chart.group_by_type = "Count"
chart.group_by_based_on = "status"
chart.type = "Donut"
chart.is_public = 1
chart.filters_json = json.dumps([["Sales Order", "docstatus", "=", 1]])
chart.insert()Example: Custom Chart (Python Method)
# Python method must return:
# {"labels": [...], "datasets": [{"name": "...", "values": [...]}]}
@frappe.whitelist()
def get_pipeline_chart():
stages = frappe.get_all("Opportunity",
fields=["sales_stage", "count(name) as count"],
group_by="sales_stage")
return {
"labels": [s.sales_stage for s in stages],
"datasets": [{"name": "Opportunities", "values": [s.count for s in stages]}]
}
# Chart document:
chart = frappe.new_doc("Dashboard Chart")
chart.chart_name = "Sales Pipeline"
chart.chart_type = "Custom"
chart.source = "myapp.api.get_pipeline_chart"
chart.type = "Bar"
chart.is_public = 1
chart.insert()Time Intervals
time_interval | timespan options |
|---|---|
Quarterly | Last Quarter, Last Year |
Monthly | Last Month, Last Quarter, Last Year, All Time |
Weekly | Last Month, Last Quarter, Last Year |
Daily | Last Week, Last Month, Last Quarter |
Rules
- ALWAYS set
is_public = 1for charts in public workspaces - ALWAYS set
based_onfor time-series charts (Count/Sum/Average) — must be a Date/Datetime field - NEVER use Pie/Donut for more than 8 categories — use Bar instead
- Custom chart methods MUST return
{"labels": [...], "datasets": [...]} - Group By charts do NOT need
based_on— they group by thegroup_by_based_onfield
---
Custom HTML Blocks
Custom HTML Blocks allow embedding arbitrary HTML, JavaScript, and CSS into a workspace. They are standalone documents (Custom HTML Block DocType).
Fields
| Field | Type | Description |
|---|---|---|
name | Data | Unique identifier |
html | Code (HTML) | HTML content |
script | Code (JS) | JavaScript (runs in block context) |
style | Code (CSS) | Scoped CSS |
private | Check | If checked, only visible to creator |
Example: Status Banner
block = frappe.new_doc("Custom HTML Block")
block.name = "system-status-banner"
block.html = """
<div class="system-status-widget">
<h4>System Status</h4>
<div id="status-content">Loading...</div>
</div>
"""
block.script = """
frappe.call({
method: "myapp.api.get_system_status",
callback: function(r) {
const el = document.getElementById("status-content");
if (r.message) {
el.innerHTML = `<span class="indicator-pill green">${r.message}</span>`;
}
}
});
"""
block.style = """
.system-status-widget {
padding: 15px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-md);
background: var(--card-bg);
}
"""
block.private = 0
block.insert()Adding to Workspace
# Reference in workspace content JSON
content_block = {
"id": frappe.generate_hash(length=10),
"type": "custom_block",
"data": {"custom_block_name": "system-status-banner", "col": 12}
}
# AND add child table entry
workspace.append("custom_blocks", {
"custom_block_name": "system-status-banner"
})Rules
- ALWAYS use Frappe CSS variables (
var(--border-color),var(--card-bg)) for theme compatibility - ALWAYS set
private = 0for blocks used in public workspaces - NEVER include
<script>tags in the HTML field — use thescriptfield instead - NEVER load external scripts/stylesheets — use Frappe's built-in libraries or bundle via app assets
- Custom blocks ship as fixtures, NOT as part of the workspace JSON
---
Quick Lists
Quick Lists show recent records for a DocType. They are child-table entries on the Workspace (Workspace Quick List).
Fields
| Field | Type | Description |
|---|---|---|
label | Data | Display heading |
document_type | Link → DocType | Source DocType |
quick_list_filter | JSON | Optional filter |
Example
workspace.append("quick_lists", {
"label": "Recent Invoices",
"document_type": "Sales Invoice",
"quick_list_filter": json.dumps([
["Sales Invoice", "docstatus", "=", 1]
])
})Rules
- Quick Lists show the most recent 5 records by default
- Filters follow the standard frappe.get_list filter format
- Quick Lists respect the user's DocType permissions — no extra permission handling needed