diff --git a/cypress/integration/es_components.js b/cypress/integration/es_components.js index 22843bd69f3a..8860c0295401 100644 --- a/cypress/integration/es_components.js +++ b/cypress/integration/es_components.js @@ -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", () => { diff --git a/frappe/boot.py b/frappe/boot.py index 554869c08af1..b32a6c7e806a 100644 --- a/frappe/boot.py +++ b/frappe/boot.py @@ -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 @@ -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") @@ -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, @@ -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 ( @@ -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() @@ -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, @@ -356,6 +378,8 @@ def load_desktop_data(bootinfo): ) ) + return app_data + def load_translations(bootinfo): from frappe.translate import get_translation_version diff --git a/frappe/core/page/component_explorer/component_explorer.js b/frappe/core/page/component_explorer/component_explorer.js index 63dcd9102d45..e16707bbccdb 100644 --- a/frappe/core/page/component_explorer/component_explorer.js +++ b/frappe/core/page/component_explorer/component_explorer.js @@ -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: [ diff --git a/frappe/desk/doctype/workspace/workspace.json b/frappe/desk/doctype/workspace/workspace.json index 0846e0348807..fafa65a1a9b5 100644 --- a/frappe/desk/doctype/workspace/workspace.json +++ b/frappe/desk/doctype/workspace/workspace.json @@ -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" @@ -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", diff --git a/frappe/desk/doctype/workspace/workspace.py b/frappe/desk/doctype/workspace/workspace.py index 0d5ef1cebf78..00373640af60 100644 --- a/frappe/desk/doctype/workspace/workspace.py +++ b/frappe/desk/doctype/workspace/workspace.py @@ -122,6 +122,12 @@ def validate(self): self.app = get_module_app(self.module) + # `app` is the workspace's mount point -- it decides which app's dock lists it. A bad value + # doesn't error anywhere downstream, it just silently drops the workspace off every dock, + # so reject it here rather than let it strand the workspace. + if self.app and self.app not in frappe.get_installed_apps(): + frappe.throw(_("{0} is not an installed app.").format(frappe.bold(self.app))) + def before_rename(self, old_name, new_name, merge=False): if self.public and not is_workspace_manager() and not disable_saving_as_public(): frappe.throw( @@ -311,6 +317,34 @@ def disable_saving_as_public(): ) +def workspace_payload(**extra): + """The desk state a workspace write invalidates, for the caller to swap into `frappe.boot`. + + `app_data` is in here because the dock is app-scoped: it lists `app_data[app].workspaces`, so + a workspace that just gained or changed its `app` only moves once that mapping is rebuilt. + Without it the desk needs a full reload to show the change. + """ + from frappe.boot import get_app_data + + workspaces = get_workspaces() + return { + "workspace_pages": workspaces, + "sidebar_items": get_sidebar_items(), + "app_data": get_app_data([d.name for d in workspaces.get("pages")]), + **extra, + } + + +def can_edit_workspace(doc): + """Whether the session user may change this workspace's settings (including its app mount). + + A Workspace Manager may edit any workspace; anyone may edit their own private one. The desk + mirrors this predicate to decide whether to offer the "mount to app" action or tell the + viewer to ask a Workspace Manager, so keep the two in step. + """ + return is_workspace_manager() or (not doc.public and doc.for_user == frappe.session.user) + + def get_link_type(key): key = key.lower() @@ -384,8 +418,7 @@ def new_page(new_page: dict): ) doc.save(ignore_permissions=True) - workspaces = get_workspaces() - return {"workspace_pages": workspaces, "sidebar_items": get_sidebar_items()} + return workspace_payload() @frappe.whitelist() @@ -393,7 +426,7 @@ def save_page(name: str, public: str | int, new_widgets: dict, blocks: str): public = frappe.parse_json(public) doc = frappe.get_doc("Workspace", name) - can_edit = is_workspace_manager() or (not doc.public and doc.for_user == frappe.session.user) + can_edit = can_edit_workspace(doc) if not can_edit: frappe.throw( _("You need the Workspace Manager role to edit this workspace."), @@ -488,7 +521,9 @@ def get_manageable_workspaces(): manager for a Workspace Manager (who should see every workspace, including other users' private ones). Everyone else sees only their own private workspaces. """ - fields = ["name", "title", "icon", "public", "for_user", "standard"] + # `app` comes along so the dialog can group the workspaces that aren't mounted to any app + # (and so appear on no dock) into their own list. + fields = ["name", "title", "icon", "public", "for_user", "standard", "app"] if is_workspace_manager(): filters = {} else: @@ -516,7 +551,7 @@ def get_workspace_settings(name: str): doc = frappe.get_cached_doc("Workspace", name) - can_edit = is_workspace_manager() or (not doc.public and doc.for_user == frappe.session.user) + can_edit = can_edit_workspace(doc) if not can_edit: frappe.throw( _("You need the Workspace Manager role to manage this workspace."), @@ -551,6 +586,7 @@ def get_workspace_settings(name: str): "standard": is_standard, "access": access, "roles": sorted(roles), + "app": doc.app, } @@ -562,17 +598,18 @@ def update_workspace_settings( indicator_color: str | None = None, access: str | None = None, roles: list | str | None = None, + app: str | None = None, ): - """Save appearance + access/roles for a workspace from the Manage Workspaces dialog. + """Save appearance + access/roles + app mount for a workspace from the Manage Workspaces dialog. - A standard (app-shipped) workspace keeps its app-owned title / route / visibility; only + A standard (app-shipped) workspace keeps its app-owned title / route / visibility / app; only its appearance and role gating are captured as a Workspace Customization delta. A custom (or developer-mode) workspace is edited in place, with `access` mapped onto the underlying `public` / `for_user` / `roles` fields (mirroring `new_page`). """ doc = frappe.get_doc("Workspace", name) - can_edit = is_workspace_manager() or (not doc.public and doc.for_user == frappe.session.user) + can_edit = can_edit_workspace(doc) if not can_edit: frappe.throw( _("You need the Workspace Manager role to edit this workspace."), @@ -593,7 +630,7 @@ def update_workspace_settings( ) upsert_settings_customization(name, icon=icon, indicator_color=indicator_color, roles=role_names) - return {"workspace_pages": get_workspaces(), "sidebar_items": get_sidebar_items(), "name": name} + return workspace_payload(name=name) # custom workspace: edit in place, mapping the access choice onto public / for_user / roles make_public = 0 if access == "private" else 1 @@ -611,6 +648,10 @@ def update_workspace_settings( doc.icon = icon if indicator_color is not None: doc.indicator_color = indicator_color + # the dock the workspace is mounted to. "" is a meaningful value (unmount it), so this is + # deliberately a `not None` check rather than a truthiness one. + if app is not None: + doc.app = app doc.set("roles", [{"role": r} for r in role_names]) if doc.public != make_public: doc.sequence_id = frappe.db.count("Workspace", {"public": make_public}, cache=True) @@ -636,7 +677,7 @@ def update_workspace_settings( if child.name != new_child_name: rename_doc("Workspace", child.name, new_child_name, force=True, ignore_permissions=True) - return {"workspace_pages": get_workspaces(), "sidebar_items": get_sidebar_items(), "name": new_name} + return workspace_payload(name=new_name) @frappe.whitelist() @@ -647,7 +688,7 @@ def delete_page(name: str): if doc.standard and not frappe.conf.developer_mode: frappe.throw(_("Standard workspaces cannot be deleted. Reset to standard instead.")) - can_edit = is_workspace_manager() or (not doc.public and doc.for_user == frappe.session.user) + can_edit = can_edit_workspace(doc) if not can_edit: frappe.throw( _("You need the Workspace Manager role to delete this workspace."), @@ -655,7 +696,71 @@ def delete_page(name: str): ) frappe.delete_doc("Workspace", name, ignore_permissions=True) - return {"workspace_pages": get_workspaces(), "sidebar_items": get_sidebar_items()} + return workspace_payload() + + +@frappe.whitelist() +def get_mountable_apps(): + """Apps a workspace may be mounted to, as `[{app_name, app_title}]`. + + The installed apps that declare `add_to_apps_screen` and that the user is allowed into -- + the same permission gate `load_desktop_data` applies when building `boot.app_data`. Apps + that declare no hook have no desk presence to mount into, so they're skipped. + + Companion apps (those pinned into a host app's dock via `add_to_workspace_dock`) stay in the + list: mounting to one is meaningful, and the desk resolves it to the host's rail at read + time via `rail_host_app`. + """ + apps = [] + for app_name in frappe.get_installed_apps(): + hooks = frappe.get_hooks("add_to_apps_screen", app_name=app_name) + if not hooks: + continue + + app_info = hooks[0] + has_permission = app_info.get("has_permission") + if has_permission and not frappe.get_attr(has_permission)(): + continue + + apps.append( + { + "app_name": app_info.get("name") or app_name, + "app_title": app_info.get("title") + or (frappe.get_hooks("app_title", app_name=app_name) or [None])[0] + or app_name, + } + ) + + return apps + + +@frappe.whitelist() +def mount_workspace(name: str, app: str): + """Mount a workspace onto an app's dock, from the in-page "not on any dock" prompt. + + Narrow counterpart to `update_workspace_settings` -- it only sets `app`, so the prompt + doesn't have to round-trip the workspace's title / icon / access just to place it. + """ + doc = frappe.get_doc("Workspace", name) + + if doc.standard and not frappe.conf.developer_mode: + # a standard workspace's app is owned by its module, and Workspace Customization has no + # field to record a per-site override in + frappe.throw(_("A standard workspace is mounted by the app that ships it.")) + + if not can_edit_workspace(doc): + frappe.throw( + _("You need the Workspace Manager role to mount this workspace."), + frappe.PermissionError, + ) + + if app not in [a["app_name"] for a in get_mountable_apps()]: + frappe.throw(_("{0} is not an app you can mount a workspace to.").format(frappe.bold(app))) + + doc.app = app + doc.save(ignore_permissions=True) + + return workspace_payload(name=doc.name) def last_sequence_id(doc): diff --git a/frappe/public/css/espresso/components/menu.css b/frappe/public/css/espresso/components/menu.css index 517441f87a92..0bce8713995b 100644 --- a/frappe/public/css/espresso/components/menu.css +++ b/frappe/public/css/espresso/components/menu.css @@ -238,6 +238,21 @@ color: var(--ink-gray-5); } +/* ---- loading state (async options/submenu, swapped out by fill()) ---- */ +.es-menu__loading { + display: flex; + align-items: center; + gap: calc(var(--spacing) * 2); + padding: calc(var(--spacing) * 1.5) calc(var(--spacing) * 2); + font-size: var(--text-base); + color: var(--ink-gray-5); +} + +.es-menu__loading > .es-spinner { + width: calc(var(--spacing) * 3.5); + height: calc(var(--spacing) * 3.5); +} + /* ---- motion: scale in from the trigger corner (per the frappe-ui spec: enter 180ms / exit 140ms, keyboard opens skip the scale and only get a short fade that masks the position-settle frame) ---- */ diff --git a/frappe/public/js/desk.bundle.js b/frappe/public/js/desk.bundle.js index 5d03276e7f9f..831d41981603 100644 --- a/frappe/public/js/desk.bundle.js +++ b/frappe/public/js/desk.bundle.js @@ -31,7 +31,7 @@ import "./frappe/ui/keyboard.js"; import "./frappe/ui/colors.js"; import "./frappe/ui/sidebar/sidebar_header.js"; import "./frappe/ui/sidebar/sidebar_header.html"; -import "./frappe/ui/sidebar/workspace_picker.js"; +import "./frappe/ui/sidebar/dock_manager.js"; import "./frappe/ui/sidebar/sidebar.html"; import "./frappe/ui/sidebar/sidebar_item.html"; import "./frappe/ui/sidebar/sidebar.js"; diff --git a/frappe/public/js/frappe/ui/components/context_menu.js b/frappe/public/js/frappe/ui/components/context_menu.js index 8d1fb24b146f..c5a60afa56f0 100644 --- a/frappe/public/js/frappe/ui/components/context_menu.js +++ b/frappe/public/js/frappe/ui/components/context_menu.js @@ -5,7 +5,7 @@ frappe.provide("frappe.ui"); /** * @typedef {Object} ContextMenuOpts * @property {Element|JQuery} target Right-clicking anywhere in this element opens the menu at the cursor. - * @property {Array|function} options Same menu items/groups as frappe.ui.Dropdown. A function is called fresh on every open — handy for menus that depend on which row was clicked. + * @property {Array|function} options Same menu items/groups as frappe.ui.Dropdown. A function is called fresh on every open — handy for menus that depend on which row was clicked — and may return a Promise of the items (the menu opens with a loading row and fills in when it settles). * @property {string} [empty_text] Shown when no items are visible. * @property {function} [on_open] Called with the contextmenu event, before the menu renders — set per-row options here. * @property {function} [on_close] Called with the reason: "activate" | "escape" | "outside" | "tab" | "owner". diff --git a/frappe/public/js/frappe/ui/components/dropdown.js b/frappe/public/js/frappe/ui/components/dropdown.js index 3b8185323a55..3bbd00be049c 100644 --- a/frappe/public/js/frappe/ui/components/dropdown.js +++ b/frappe/public/js/frappe/ui/components/dropdown.js @@ -8,7 +8,7 @@ frappe.provide("frappe.ui"); * @typedef {Object} DropdownOpts * @property {Element|JQuery} [trigger] An existing element that opens the menu. Omit to have one made from `button`. * @property {Object} [button] Options for the generated frappe.ui.button trigger (ignored when `trigger` is passed). - * @property {Array|function} options Menu items and { group, options } sections — see MenuItem in menu.js. A function is called fresh on every open. + * @property {Array|function} options Menu items and { group, options } sections — see MenuItem in menu.js. A function is called fresh on every open, and may return a Promise of the items — the menu opens with a loading row and fills in when it settles. Individual items can lazy-load the same way via a function `submenu`. * @property {"top"|"right"|"bottom"|"left"} [side="bottom"] Which side of the trigger the menu opens on. * @property {"start"|"center"|"end"} [align="start"] How the menu lines up along that side. * @property {number} [offset=4] Gap between trigger and menu, in px. diff --git a/frappe/public/js/frappe/ui/components/menu.js b/frappe/public/js/frappe/ui/components/menu.js index 84772b06a1d5..f46a677f2dc6 100644 --- a/frappe/public/js/frappe/ui/components/menu.js +++ b/frappe/public/js/frappe/ui/components/menu.js @@ -24,6 +24,11 @@ import { place } from "./position.js"; * * Rows are built with createElement + textContent, so labels can never * smuggle markup — there is no HTML channel here at all. + * + * Async: the root options (via a function returning a promise, or a bare + * promise) and function submenus may resolve later — the panel opens with a + * loading placeholder and fill() swaps the rows in when the data lands, + * re-measuring the position. Late resolutions after a close are discarded. */ /** @@ -38,7 +43,7 @@ import { place } from "./position.js"; * @property {function} [onclick] Called with the click event. The menu closes after. * @property {string} [href] Renders the row as a link. Code-running schemes are refused. * @property {function} [condition] Checked on every open; return false to hide the row. - * @property {MenuItem[]} [submenu] Nested rows — the row opens a side panel instead of acting. + * @property {MenuItem[]|function} [submenu] Nested rows — the row opens a side panel instead of acting. A function runs at hover-start (once per menu open, result cached) and may return the rows or a Promise of them; the panel shows a loading state until it settles. */ /** @@ -50,6 +55,10 @@ import { place } from "./position.js"; const THEMES = ["gray", "red"]; +function is_thenable(value) { + return !!value && typeof value.then === "function"; +} + const SUBMENU_OFFSET = 4; const SUBMENU_OPEN_DELAY = 150; const EXIT_MS = 140; @@ -220,12 +229,10 @@ function build_item(item, { reserve_icon_space, component, taken }) { return { el, mnemonic }; } -function build_panel(groups, { empty_text, component }) { - const panel = document.createElement("div"); - panel.className = "es-menu"; - panel.setAttribute("role", "menu"); - panel.setAttribute("tabindex", "-1"); - +// fill a bare panel with its group/row elements (or the empty text) and +// return the rows. Separate from build_panel so a panel that opened in its +// loading state can be filled in place when the async items arrive. +function render_content(panel, groups, { empty_text, component }) { const rows = []; // [{ el, item, mnemonic }] in visual order const taken = new Set(); // Alt-mnemonic letters, unique within a panel @@ -234,7 +241,7 @@ function build_panel(groups, { empty_text, component }) { empty.className = "es-menu__empty"; empty.textContent = empty_text || __("No options"); panel.appendChild(empty); - return { panel, rows }; + return rows; } for (const group of groups) { @@ -267,6 +274,39 @@ function build_panel(groups, { empty_text, component }) { panel.appendChild(group_el); } + return rows; +} + +// the placeholder shown while async items load: a spinner + "Loading...", +// inert like the empty state (no rows, so keyboard navigation has nothing +// to land on; Escape and outside-click work through the panel as usual) +function render_loading(panel) { + const loading = document.createElement("div"); + loading.className = "es-menu__loading"; + const spinner = document.createElement("span"); + spinner.className = "es-spinner"; + spinner.setAttribute("aria-hidden", "true"); + const text = document.createElement("span"); + text.textContent = __("Loading..."); + loading.append(spinner, text); + panel.appendChild(loading); + panel.setAttribute("aria-busy", "true"); +} + +// `groups === null` means the items are still loading (async source) — the +// panel opens with the loading placeholder and fill() swaps the rows in +function build_panel(groups, { empty_text, component }) { + const panel = document.createElement("div"); + panel.className = "es-menu"; + panel.setAttribute("role", "menu"); + panel.setAttribute("tabindex", "-1"); + + if (groups === null) { + render_loading(panel); + return { panel, rows: [] }; + } + + const rows = render_content(panel, groups, { empty_text, component }); return { panel, rows }; } @@ -287,6 +327,9 @@ export class MenuTree { this.lock_scroll = lock_scroll; this.panels = []; // [{ panel, rows, anchor, side, align, offset, parent_row }] this.closed = false; + // function submenus resolve once per menu open: row -> items or the + // pending promise (see get_submenu_items) + this.submenu_cache = new Map(); this.submenu_timer = null; this.typeahead_buffer = ""; this.typeahead_timer = null; @@ -295,17 +338,22 @@ export class MenuTree { open({ side = "bottom", align = "start", offset = 4, motion = "animated", focus = null }) { // options can be a function so the menu reflects right-now state - // (which row was clicked, what's selected); it runs on every open - const groups = normalize_options( - typeof this.options === "function" ? this.options() : this.options - ); - const entry = this.mount(groups, { - anchor: this.anchor, - side, - align, - offset, - motion, - }); + // (which row was clicked, what's selected); it runs on every open. + // A promise (or a function returning one) opens the menu in its + // loading state, and the rows fill in when it settles. + const value = typeof this.options === "function" ? this.options() : this.options; + const mount_opts = { anchor: this.anchor, side, align, offset, motion }; + let entry; + if (is_thenable(value)) { + entry = this.mount(null, mount_opts); + entry.pending_focus = focus === "first" || focus === "last" ? focus : null; + value.then( + (items) => this.fill(entry, normalize_options(items)), + (error) => this.fill_failed(entry, error) + ); + } else { + entry = this.mount(normalize_options(value), mount_opts); + } // pressing down anywhere that isn't a panel (or the trigger, which // handles its own clicks) closes the menu @@ -352,9 +400,11 @@ export class MenuTree { // keyboard opens land on a row right away (ArrowDown = first, // ArrowUp = last); mouse opens just focus the panel so keys work - // without stealing the highlight from wherever the pointer is - if (focus === "first") this.focus_step(entry, 1); - else if (focus === "last") this.focus_step(entry, -1); + // without stealing the highlight from wherever the pointer is. An + // async panel has no rows yet — focus the panel so Escape works, and + // fill() honors pending_focus when the rows arrive + if (focus === "first" && entry.rows.length) this.focus_step(entry, 1); + else if (focus === "last" && entry.rows.length) this.focus_step(entry, -1); else entry.panel.focus({ preventScroll: true }); } @@ -388,9 +438,26 @@ export class MenuTree { } bind_panel(entry) { - const { panel, rows } = entry; + this.bind_rows(entry); + + // whichever row has focus carries the highlight — this is the only + // place data-highlighted is ever set. Reads entry.rows live (not a + // captured copy) so an async fill's new rows are covered too. + entry.panel.addEventListener("focusin", (e) => { + for (const row of entry.rows) { + row.el.toggleAttribute("data-highlighted", row.el === e.target); + } + }); + + // key presses on focused rows bubble up to the panel, so one + // listener per panel covers all of its rows + entry.panel.addEventListener("keydown", (e) => this.handle_keydown(entry, e)); + } - for (const row of rows) { + // per-row listeners; called at mount and again after an async fill + // replaces a panel's rows + bind_rows(entry) { + for (const row of entry.rows) { row.el.addEventListener("click", (e) => { // disabled buttons don't fire click, but a scripted or AT // activation can still reach here — never act on a disabled row @@ -417,18 +484,47 @@ export class MenuTree { this.schedule_submenu(entry, row); }); } + } - // whichever row has focus carries the highlight — this is the only - // place data-highlighted is ever set - panel.addEventListener("focusin", (e) => { - for (const row of rows) { - row.el.toggleAttribute("data-highlighted", row.el === e.target); - } + // swap a loading panel's placeholder for the resolved rows, in place. + // Late resolutions are dropped: the whole menu may have closed, or just + // that submenu panel (hover moved on) — either way there's nothing to do. + fill(entry, groups) { + if (this.closed || !this.panels.includes(entry)) return; + // read focus before replaceChildren blurs whatever held it + const had_focus = + entry.panel === document.activeElement || entry.panel.contains(document.activeElement); + entry.panel.replaceChildren(); + entry.panel.removeAttribute("aria-busy"); + entry.rows = render_content(entry.panel, groups, { + empty_text: this.empty_text, + component: this.component, }); + this.bind_rows(entry); + // the panel's size just changed — measure, flip and clamp again + place(entry.panel, entry.anchor(), entry.side, entry.align, entry.offset); + if (entry.pending_focus) { + this.focus_step(entry, entry.pending_focus === "last" ? -1 : 1); + entry.pending_focus = null; + } else if (had_focus) { + entry.panel.focus({ preventScroll: true }); + } + } - // key presses on focused rows bubble up to the panel, so one - // listener per panel covers all of its rows - panel.addEventListener("keydown", (e) => this.handle_keydown(entry, e)); + // async items rejected: keep the panel up with an inert notice — a dead + // hover with no feedback would look like the menu is broken + fill_failed(entry, error) { + if (this.closed || !this.panels.includes(entry)) return; + console.warn(`frappe.ui.${this.component}: menu items failed to load`, error); + entry.panel.replaceChildren(); + entry.panel.removeAttribute("aria-busy"); + const failed = document.createElement("div"); + failed.className = "es-menu__empty"; + failed.textContent = __("Couldn't load options"); + entry.panel.appendChild(failed); + entry.rows = []; + entry.pending_focus = null; + place(entry.panel, entry.anchor(), entry.side, entry.align, entry.offset); } // all keyboard behavior for one panel. `entry` is the panel the focused @@ -605,6 +701,10 @@ export class MenuTree { // other row closes panels deeper than this one schedule_submenu(entry, row) { clearTimeout(this.submenu_timer); + // a function submenu starts resolving at hover-START, before the open + // delay — a fast source is ready before the panel ever shows, so the + // loading state often never appears at all + if (row.item.submenu) this.get_submenu_items(row); const depth = this.panels.indexOf(entry); this.submenu_timer = setTimeout(() => { if (this.closed) return; @@ -613,19 +713,41 @@ export class MenuTree { }, SUBMENU_OPEN_DELAY); } + // a submenu's items: an array passes through; a function runs once per + // menu open and its result — value or promise — is cached, so hovering + // away and back doesn't refetch. When a promise resolves, the cache is + // swapped to the plain items so the next open of that row is synchronous. + // A rejected promise stays cached (no retry until the next menu open); + // the rejection itself is handled where the panel consumes it. + get_submenu_items(row) { + const source = row.item.submenu; + if (typeof source !== "function") return source; + if (!this.submenu_cache.has(row)) { + const value = source(); + this.submenu_cache.set(row, value); + if (is_thenable(value)) { + value.then( + (items) => this.submenu_cache.set(row, items), + () => {} // fill_failed reports it; this guard just keeps the prefetch handled + ); + } + } + return this.submenu_cache.get(row); + } + open_submenu(parent_entry, row, { focus }) { // this row's submenu is already open — just step into it if asked const depth = this.panels.indexOf(parent_entry); const already = this.panels[depth + 1]; if (already && already.parent_row === row.el) { - if (focus) this.focus_step(already, 1); + if (focus && already.rows.length) this.focus_step(already, 1); return; } // a sibling's submenu may be open — only one branch at a time this.close_submenus_from(depth + 1); - const groups = normalize_options(row.item.submenu); - const entry = this.mount(groups, { + const source = this.get_submenu_items(row); + const mount_opts = { // the submenu hangs off its row and follows it around, opening // toward the reading direction (left in RTL) anchor: () => row.el.getBoundingClientRect(), @@ -634,10 +756,21 @@ export class MenuTree { offset: SUBMENU_OFFSET, motion: "animated", parent_row: row.el, - }); - // keyboard opens step onto the first row; hover opens leave focus - // on the parent row until the pointer moves in - if (focus) this.focus_step(entry, 1); + }; + let entry; + if (is_thenable(source)) { + entry = this.mount(null, mount_opts); + if (focus) entry.pending_focus = "first"; + source.then( + (items) => this.fill(entry, normalize_options(items)), + (error) => this.fill_failed(entry, error) + ); + } else { + entry = this.mount(normalize_options(source), mount_opts); + // keyboard opens step onto the first row; hover opens leave focus + // on the parent row until the pointer moves in + if (focus) this.focus_step(entry, 1); + } } // close every panel deeper than `depth` (the Math.max keeps the root diff --git a/frappe/public/js/frappe/ui/sidebar/dock_manager.js b/frappe/public/js/frappe/ui/sidebar/dock_manager.js new file mode 100644 index 000000000000..bd3b2eed9990 --- /dev/null +++ b/frappe/public/js/frappe/ui/sidebar/dock_manager.js @@ -0,0 +1,212 @@ +// Dock manager -- lets the user curate the workspace dock (rail) for the app they're currently +// in: which of that app's workspaces appear on it, and in what order. Two draggable areas: the +// left is a preview of the dock (their chosen workspaces, reorderable); the right is the rest of +// the app's workspaces, ready to drag in. +// +// Scoped to one app on purpose -- a dock belongs to an app, so there's nothing to choose between +// here and no app switcher. Workspaces in other apps are managed from those apps' docks; +// workspaces in no app at all appear on no dock, and are placed from Manage Workspaces. +// +// All data comes from `frappe.boot`; only the selection is saved. +frappe.ui.DockManager = class DockManager { + constructor() { + this.make(); + } + + make() { + this.app = frappe.current_app; + this.selection = this.initial_selection(); + + this.dialog = new frappe.ui.Dialog({ + title: this.app ? __("Manage {0} Dock", [__(this.app.app_title)]) : __("Manage Dock"), + size: "extra-large", + fields: [{ fieldtype: "HTML", fieldname: "picker" }], + primary_action_label: __("Save"), + primary_action: () => this.save(), + }); + + this.$body = $(this.dialog.fields_dict.picker.$wrapper); + this.render(); + this.dialog.show(); + } + + // Every workspace this app's dock can show, in the server's order. + app_workspaces() { + return ((this.app && this.app.workspaces) || []).filter((name) => this.has_meta(name)); + } + + // The user's curated picks for this app, in their order. `User.workspaces` is a single flat + // list across every app, so this app's picks are the ones naming its workspaces. Nothing + // curated for this app yet -> start from everything it offers (what the dock shows by + // default), so the user trims rather than builds from scratch. + initial_selection() { + const app_workspaces = this.app_workspaces(); + const curated = (frappe.boot.user_workspaces || []).filter((name) => + app_workspaces.includes(name) + ); + return curated.length ? curated : app_workspaces; + } + + has_meta(name) { + return !!frappe.workspaces[frappe.router.slug(name)]; + } + + get_ws(name) { + return frappe.workspaces[frappe.router.slug(name)] || { name, title: name }; + } + + render() { + this.$body.html(` +
${__( + "This workspace isn't in any app's sidebar yet, so there's no way to navigate to it. Pick the app it belongs to." + )}
`, + }, + { + label: __("App"), + fieldtype: "Select", + fieldname: "app", + reqd: 1, + options: this.app_select_options(apps), + default: frappe.current_app && frappe.current_app.app_name, + description: __("Which app's sidebar this workspace appears in"), + }, + ], + primary_action_label: __("Add"), + primary_action: (values) => { + mounted = true; + d.hide(); + frappe.call({ + method: "frappe.desk.doctype.workspace.workspace.mount_workspace", + args: { name: page.name, app: values.app }, + freeze: true, + callback: (r) => { + if (!r.message) return; + this.apply_manager_changes(r.message); + frappe.show_alert({ + message: __("Added {0} to {1}", [__(page.title), __(values.app)]), + indicator: "green", + }); + }, + }); + }, + }); + + // tracked so a re-render of the same workspace doesn't stack a second copy on top + d.page_name = page.name; + this.mount_dialog = d; + d.$wrapper.on("hidden.bs.modal", () => { + if (this.mount_dialog === d) this.mount_dialog = null; + // closed without picking an app -> take the hint and stop asking for this session + if (!mounted) { + this.dismissed_mount_prompts = this.dismissed_mount_prompts || new Set(); + this.dismissed_mount_prompts.add(page.name); + } + }); + + d.show(); + } + open_workspace_manager(current_page) { // Two-pane manager: the list of workspaces the user can manage on the left, the // selected workspace's access / appearance settings on the right. Replaces the @@ -378,12 +498,18 @@ frappe.views.Workspace = class Workspace { const manageable = r.message || []; if (!manageable.length) return; - // Standard = public app-shipped, Custom = public but user-created, - // Private = per-user workspaces. + // "Not in any app" first -- those workspaces are on no dock, so this is the + // list to triage. Then Standard = public app-shipped, Custom = public but + // user-created, Private = per-user workspaces. + const unmounted = (p) => !p.app && !p.standard && p.title !== WELCOME_WORKSPACE; const groups = [ + { label: __("Not in any app"), filter: unmounted }, { label: __("Standard"), filter: (p) => p.public && p.standard }, - { label: __("Custom"), filter: (p) => p.public && !p.standard }, - { label: __("Private"), filter: (p) => !p.public }, + { + label: __("Custom"), + filter: (p) => p.public && !p.standard && !unmounted(p), + }, + { label: __("Private"), filter: (p) => !p.public && !unmounted(p) }, ]; const tabs = []; groups.forEach(({ label, filter }) => { @@ -420,47 +546,45 @@ frappe.views.Workspace = class Workspace { }; } - render_workspace_manager_panel(panel, page) { + async render_workspace_manager_panel(panel, page) { panel.set_header({ title: __(page.title) }); panel.body.html(`