Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions cypress/integration/es_components.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,64 @@ context("Espresso components", () => {
// .es-menu__item itself, not the inner label span.
cy.contains(".es-menu__item", "Archive").should("match", "button").and("be.disabled");
});

it("async options open in a loading state, then fill in when the promise settles", () => {
cy.contains(".explorer-group", "Async items")
.find('[aria-haspopup="menu"]')
.first()
.click();

// the panel opens immediately with the placeholder (aria-busy),
// no rows yet
cy.get(".es-menu[data-state='open']")
.should("have.attr", "aria-busy", "true")
.find(".es-menu__loading")
.should("exist");
cy.get(".es-menu__item").should("not.exist");

// the demo resolves after 600ms — the rows replace the placeholder
// and the busy state clears
cy.contains(".es-menu__item", "Fetched row 1").should("exist");
cy.get(".es-menu[data-state='open']").should("not.have.attr", "aria-busy");
cy.get(".es-menu__loading").should("not.exist");

// the swapped-in rows are live
cy.contains(".es-menu__item", "Fetched row 2").click();
cy.get(".es-menu[data-state='open']").should("not.exist");
cy.contains(".es-toast", "Row 2").should("exist");
});

it("a function submenu loads on hover and swaps in its rows", () => {
cy.contains(".explorer-group", "Async items")
.find('[aria-haspopup="menu"]')
.eq(1)
.click();

// hover intent starts the fetch and opens the panel after the delay
cy.contains(".es-menu__item", "Recent documents").trigger("pointerenter");
cy.get(".es-menu[data-state='open']").should("have.length", 2);

// resolved rows land in the submenu panel and activate normally
cy.contains(".es-menu__item", "Doc A").should("exist");
cy.contains(".es-menu__item", "Doc A").click();
cy.get(".es-menu[data-state='open']").should("not.exist");
cy.contains(".es-toast", "Doc A").should("exist");
});

it("a rejected submenu shows the failure notice instead of a dead panel", () => {
cy.contains(".explorer-group", "Async items")
.find('[aria-haspopup="menu"]')
.eq(1)
.click();

cy.contains(".es-menu__item", "Fails to load").trigger("pointerenter");
// the panel opens loading, then swaps to the inert notice
cy.get(".es-menu[data-state='open']").should("have.length", 2);
cy.contains(".es-menu__empty", "Couldn't load options").should("exist");
// the root menu is still alive — Escape closes everything
cy.focused().trigger("keydown", { key: "Escape" });
cy.get(".es-menu[data-state='open']").should("not.exist");
});
});

describe("Toast — legacy HTML message sanitisation", () => {
Expand Down
52 changes: 38 additions & 14 deletions frappe/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -241,12 +241,9 @@ def get_app_rail_host_map():
def load_desktop_data(bootinfo):
from frappe.desk.desktop import get_user_workspaces

allowed_pages = [d.name for d in bootinfo.workspaces.get("pages")]
# Companion apps pin their workspaces into a host app's dock (rail) via `add_to_workspace_dock`,
# instead of taking an apps-screen slot of their own. Resolved once, merged per host app below.
rail_map = get_app_rail_map()
# ...and their own workspaces resolve their app context (dock + header) to that host app, so a
# companion app appears to live inside the host's rail rather than flipping to a shell of its own.
# A companion app's workspaces resolve their app context (dock + header) to the host app they
# were pinned into via `add_to_workspace_dock`, so the companion appears to live inside the
# host's rail rather than flipping the desk to a shell of its own.
bootinfo.app_rail_host = get_app_rail_host_map()
# The user's curated workspace selection (`User.workspaces`), ordered. Kept separate from
# `bootinfo.workspaces` (which holds every permitted workspace link) so the workspace selector
Expand All @@ -255,7 +252,26 @@ def load_desktop_data(bootinfo):
bootinfo.workspace_sidebar_item = get_sidebar_items()
bootinfo.default_workspace_map = build_default_workspace_map(bootinfo.workspace_sidebar_item)
bootinfo.module_wise_workspaces = get_controller("Workspace").get_module_wise_workspaces()
bootinfo.app_data = []
bootinfo.app_data = get_app_data([d.name for d in bootinfo.workspaces.get("pages")])


def get_app_data(allowed_pages: list[str]) -> list[dict]:
"""The apps the desk knows about, each with the workspaces that belong to it.

This is what backs the apps (desktop) screen and the workspace dock: the dock lists
`app_data[app].workspaces` for whichever app is in context. Kept as its own function so
anything that re-mounts a workspace can hand the client a fresh copy without duplicating
the grouping rules (see `mount_workspace`).

`allowed_pages` is the set of workspace names the user may see -- `bootinfo.workspaces.pages`,
i.e. every public workspace they're permitted plus their own private ones.
"""
app_data = []

# Companion apps pin their workspaces into a host app's dock (rail) via `add_to_workspace_dock`,
# instead of taking an apps-screen slot of their own. Resolved once, merged per host app below.
rail_map = get_app_rail_map()
app_rail_host = get_app_rail_host_map()

Workspace = frappe.qb.DocType("Workspace")
Module = frappe.qb.DocType("Module Def")
Expand All @@ -274,7 +290,7 @@ def load_desktop_data(bootinfo):
# labelled (e.g. the sidebar header subtitle) instead of falling back to the user's
# name. on_apps_screen stays False so it never shows on the apps screen, and an
# empty `workspaces` keeps the desk-side lookups from breaking.
bootinfo.app_data.append(
app_data.append(
dict(
on_apps_screen=False,
sequence_id=app_info.get("sequence_id") or DEFAULT_APP_SEQUENCE_ID,
Expand All @@ -294,9 +310,14 @@ def load_desktop_data(bootinfo):

# A workspace belongs to this app if its module is the app's (standard, app-shipped
# workspaces) or its `app` field points at it (custom workspaces have no module). Use a
# left join so module-less custom workspaces aren't dropped, and keep only public ones --
# private workspaces are surfaced separately by the selector's private listing. Ordered by
# `sequence_id` so the dock lists them in the workspace record's configured order.
# left join so module-less custom workspaces aren't dropped. Ordered by `sequence_id` so
# the dock lists them in the workspace record's configured order.
#
# Private workspaces are included on the same footing as public ones: a private workspace
# mounted to an app belongs on that app's dock, and nowhere else. Restricting to the
# session user is belt-and-braces -- `allowed_pages` already covers it, since
# `get_workspaces()` only ever extends its page list with the user's *own* private
# workspaces -- but it keeps the query honest on its own terms.
workspaces = [
r[0]
for r in (
Expand All @@ -305,7 +326,8 @@ def load_desktop_data(bootinfo):
.on(Workspace.module == Module.name)
.select(Workspace.name)
.where(
((Module.app_name == app_name) | (Workspace.app == app_name)) & (Workspace.public == 1)
((Module.app_name == app_name) | (Workspace.app == app_name))
& ((Workspace.public == 1) | (Workspace.for_user == frappe.session.user))
)
.orderby(Workspace.sequence_id)
.run()
Expand All @@ -320,12 +342,12 @@ def load_desktop_data(bootinfo):
if rail_workspace in allowed_pages and rail_workspace not in workspaces:
workspaces.append(rail_workspace)

bootinfo.app_data.append(
app_data.append(
dict(
# whether the app opts into the apps screen via the add_to_apps_screen hook. An app
# that pins into a host app's dock never takes a slot of its own, even if it still
# declares add_to_apps_screen from before the dock existed -- the dock hook wins.
on_apps_screen=bool(apps) and app_name not in bootinfo.app_rail_host,
on_apps_screen=bool(apps) and app_name not in app_rail_host,
# Sort order for the apps (desktop) screen; lower shows first, Framework is pinned
# last (sequence_id 1000). Apps that don't declare one fall to a middle default.
sequence_id=app_info.get("sequence_id") or DEFAULT_APP_SEQUENCE_ID,
Expand Down Expand Up @@ -356,6 +378,8 @@ def load_desktop_data(bootinfo):
)
)

return app_data


def load_translations(bootinfo):
from frappe.translate import get_translation_version
Expand Down
70 changes: 70 additions & 0 deletions frappe/core/page/component_explorer/component_explorer.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,76 @@ frappe.pages["component-explorer"].on_page_load = function (wrapper) {
},
],
},
{
title: __("Async items (loading state)"),
items: [
{
button: { label: "Async options" },
options: () =>
new Promise((resolve) =>
setTimeout(
() =>
resolve([
{
label: "Fetched row 1",
onclick: () =>
frappe.ui.toast({ message: "Row 1" }),
},
{
label: "Fetched row 2",
onclick: () =>
frappe.ui.toast({ message: "Row 2" }),
},
]),
600
)
),
},
{
button: { label: "Async submenu" },
options: [
{
label: "Recent documents",
icon: "clock",
submenu: () =>
new Promise((resolve) =>
setTimeout(
() =>
resolve([
{
label: "Doc A",
onclick: () =>
frappe.ui.toast({
message: "Doc A",
}),
},
{
label: "Doc B",
onclick: () =>
frappe.ui.toast({
message: "Doc B",
}),
},
]),
600
)
),
},
{
label: "Fails to load",
icon: "cloud-off",
submenu: () =>
new Promise((resolve, reject) =>
setTimeout(
() => reject(new Error("network down")),
600
)
),
},
],
},
],
},
{
title: __("Selected row, descriptions and links"),
items: [
Expand Down
4 changes: 2 additions & 2 deletions frappe/desk/doctype/workspace/workspace.json
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@
"options": "green\ncyan\nblue\norange\nyellow\ngray\ngrey\nred\npink\ndarkgrey\npurple\nlight-blue"
},
{
"depends_on": "eval: doc.standard",
"description": "The app this workspace is mounted to. Decides which app's dock (workspace rail) lists it. A standard workspace inherits this from its module; a custom one must be mounted explicitly, or it appears on no dock at all.",
"fieldname": "app",
"fieldtype": "Data",
"label": "App"
Expand Down Expand Up @@ -291,7 +291,7 @@
],
"in_create": 1,
"links": [],
"modified": "2026-07-06 23:20:00.000000",
"modified": "2026-07-26 19:50:45.000000",
"modified_by": "Administrator",
"module": "Desk",
"name": "Workspace",
Expand Down
Loading
Loading