From 750bf2c1574941b9156a06202e795ad13ceefd6a Mon Sep 17 00:00:00 2001 From: Ali <010109ali@gmail.com> Date: Thu, 18 Jun 2026 12:53:53 +0000 Subject: [PATCH] feat: inline desk visibility editor (hide/block/add/reorder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editor companion to jey_theme. On /desk a System Manager can mark workspaces/ cards/links visible, hidden or blocked, add DocType/Report/Page links into a card, and reorder links within a card β€” all non-destructive (applied server-side by jey_theme, Workspace records untouched). Editor JS/CSS is loaded dynamically by jey_theme.js in design mode, not via app_include (avoids the preload Link header tripping the edge proxy's 4 KB buffer). Working config is gitignored. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + README.md | 58 ++-- jey_layout/api.py | 273 ++++++++++++++++ jey_layout/hooks.py | 7 +- jey_layout/public/css/jey_layout.css | 206 ++++++++++++ jey_layout/public/js/jey_layout.js | 453 +++++++++++++++++++++++++++ 6 files changed, 979 insertions(+), 21 deletions(-) create mode 100644 jey_layout/api.py create mode 100644 jey_layout/public/css/jey_layout.css create mode 100644 jey_layout/public/js/jey_layout.js diff --git a/.gitignore b/.gitignore index b50d332..1e68764 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ jspm_packages/ # Aider AI Chat .aider* + +# Runtime working layout written by the editor (machine-specific, not shipped) +jey_layout/config/desk_layout.json diff --git a/README.md b/README.md index b42b036..739049e 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,51 @@ ### Jey Layout -Desk layout editor (subsystems visibility) for jey_theme +Inline desk visibility editor for Frappe/ERPNext, companion to **jey_theme**. + +Lets a System Manager configure, directly on `/desk`, how each workspace's cards +and links behave β€” without ever modifying the Workspace records in the database +(non-destructive, applied server-side at request time, survives migrations). + +### What it does + +In edit mode a floating **✎ Layout** button turns on inline controls: + +- **πŸ‘ / 🚫 / πŸ”’** next to every link β€” *visible* / *hidden* (removed from the desk) + / *blocked* (also unreachable via URL/API/permissions, for everyone incl. Admin). +- **πŸ‘ / 🚫** on each card title, **πŸ‘ / 🚫 / πŸ”’** on the workspace title. +- **οΌ‹** to add a DocType / Report / Page link into a card. +- **β Ώ** drag handle to reorder links within a card; **βœ•** to remove an added link. + +The normal desk is the *applied* result (what users see); "Done editing" shows it. +**Export** copies the layout into `jey_theme`'s shipped snapshot for production. + +### How it works + +This app is the **editor only**. The runtime engine lives in **jey_theme** +(`desk_layout.py`, `desk_overrides.py`, `access_control.py`), which reads a JSON +config and filters/injects desk data server-side. This app provides: + +- the inline editor JS/CSS (loaded dynamically by `jey_theme.js` in design mode β€” + intentionally **not** via `app_include`, see jey_theme/CLAUDE.md for why); +- the whitelisted API (`jey_layout/api.py`) that reads the live desk structure and + saves the working config. + +Requires **jey_theme** (declared in `required_apps`). + +### Design machine + +Set `"jey_design_mode": 1` in the site's `site_config.json` to enable the editor on +that site. Without it, the app is inert (production sites just enforce the shipped +snapshot via jey_theme). ### Installation -You can install this app using the [bench](https://github.com/frappe/bench) CLI: - ```bash cd $PATH_TO_YOUR_BENCH -bench get-app $URL_OF_THIS_REPO --branch version-16 +bench get-app https://git.host.jeyerp.az/ali/jey_layout --branch version-16 bench install-app jey_layout ``` -### Contributing - -This app uses `pre-commit` for code formatting and linting. Please [install pre-commit](https://pre-commit.com/#installation) and enable it for this repository: - -```bash -cd apps/jey_layout -pre-commit install -``` - -Pre-commit is configured to use the following tools for checking and formatting your code: - -- ruff -- eslint -- prettier -- pyupgrade - ### License mit diff --git a/jey_layout/api.py b/jey_layout/api.py new file mode 100644 index 0000000..781aff1 --- /dev/null +++ b/jey_layout/api.py @@ -0,0 +1,273 @@ +"""Server API for the Jey Layout editor desk page. + +Reads the live desk structure (workspaces -> cards -> links), merges the saved +states from the working config, and persists the operator's choices back to the +working file. Export copies the working file into jey_theme's snapshot, which is +what every site enforces in production. + +All endpoints are restricted to System Manager. +""" + +import json +import os + +import frappe + +from jey_theme import desk_layout + +VISIBLE = desk_layout.VISIBLE +HIDDEN = desk_layout.HIDDEN +BLOCKED = desk_layout.BLOCKED + +DEFAULT_CARD_LABEL = "Link" # mirrors frappe's default card when links precede any Card Break + + +def _guard(): + frappe.only_for("System Manager") + + +def _read_working() -> dict: + return desk_layout._read_file(desk_layout.working_path()) + + +def _config_workspaces(cfg: dict) -> dict: + return (cfg or {}).get("workspaces") or {} + + +# --------------------------------------------------------------------------- +# Read +# --------------------------------------------------------------------------- +@frappe.whitelist() +def get_status(): + _guard() + return { + "design_mode": desk_layout.is_design_mode(), + "editing": desk_layout.is_editing(), + "working_path": desk_layout.working_path(), + "snapshot_path": desk_layout.snapshot_path(), + "jey_theme_installed": "jey_theme" in frappe.get_installed_apps(), + } + + +def _build_node(ws_row, cfg_ws): + """Build one workspace node (cards -> links) merged with saved states.""" + ws_cfg = cfg_ws.get(ws_row.name) or {} + cards_cfg = ws_cfg.get("cards") or {} + links_cfg = ws_cfg.get("links") or {} + + rows = frappe.get_all( + "Workspace Link", + filters={"parent": ws_row.name}, + fields=["type", "label", "link_type", "link_to"], + order_by="idx asc", + ) + + cards = [] + + def _flush(card): + if card and card["links"]: + cards.append(card) + + # Implicit default card for links that appear before the first Card Break. + current = {"label": DEFAULT_CARD_LABEL, "state": VISIBLE, "links": []} + + for row in rows: + if row.type == "Card Break": + _flush(current) + label = row.label or DEFAULT_CARD_LABEL + current = { + "label": label, + "state": (cards_cfg.get(label) or {}).get("state", VISIBLE), + "links": [], + } + continue + if not row.link_to: + continue + key = desk_layout.link_key(row.link_type, row.link_to) + current["links"].append({ + "link_type": row.link_type, + "link_to": row.link_to, + "label": row.label or row.link_to, + "state": (links_cfg.get(key) or {}).get("state", VISIBLE), + "added": False, + }) + _flush(current) + + if not cards: + return None + + # Inject added links into their target cards (labels here are untranslated, + # matching the config), then apply per-card ordering β€” the SAME logic that + # get_desktop_page applies, so DOM and node stay index-aligned for the editor. + _key_of = lambda l: desk_layout.link_key(l["link_type"], l["link_to"]) + cards_by_label = {} + for c in cards: + cards_by_label.setdefault(c["label"], c) + + for key, a in (ws_cfg.get("added") or {}).items(): + if not (isinstance(a, dict) and a.get("link_to") and a.get("link_type") and a.get("card")): + continue + card = cards_by_label.get(a["card"]) + if not card or any(_key_of(l) == key for l in card["links"]): + continue + card["links"].append({ + "link_type": a["link_type"], + "link_to": a["link_to"], + "label": a.get("label") or a["link_to"], + "state": (links_cfg.get(key) or {}).get("state", VISIBLE), + "added": True, + }) + + for label, keys in (ws_cfg.get("order") or {}).items(): + card = cards_by_label.get(label) + if card and isinstance(keys, list): + card["links"] = desk_layout.apply_order(card["links"], keys, _key_of) + + return { + "name": ws_row.name, + "title": ws_row.title or ws_row.name, + "module": ws_row.module, + "state": ws_cfg.get("state", VISIBLE), + "cards": cards, + } + + +@frappe.whitelist() +def get_workspace_node(ws_name): + """Authoritative structure for ONE workspace (untranslated card labels, + link identity, saved states). Used by the inline desk editor.""" + _guard() + ws_row = frappe.db.get_value( + "Workspace", ws_name, ["name", "title", "module"], as_dict=True + ) + if not ws_row: + return None + return _build_node(ws_row, _config_workspaces(_read_working())) + + +@frappe.whitelist() +def get_config(): + _guard() + cfg = _read_working() + return cfg or {"version": 1, "workspaces": {}} + + +@frappe.whitelist() +def get_desk_tree(): + """Live workspaces -> cards -> links, merged with saved states.""" + _guard() + cfg_ws = _config_workspaces(_read_working()) + workspaces = frappe.get_all( + "Workspace", + filters={"public": 1}, + fields=["name", "title", "module"], + order_by="sequence_id asc, name asc", + ) + tree = [] + for ws in workspaces: + node = _build_node(ws, cfg_ws) + if node: + tree.append(node) + return tree + + +# --------------------------------------------------------------------------- +# Write +# --------------------------------------------------------------------------- +def _validate_config(cfg: dict) -> dict: + if not isinstance(cfg, dict): + frappe.throw("Invalid config") + out_ws = {} + for ws_name, ws in (cfg.get("workspaces") or {}).items(): + if not isinstance(ws, dict): + continue + entry = {"state": ws.get("state", VISIBLE) if ws.get("state") in (VISIBLE, HIDDEN, BLOCKED) else VISIBLE} + cards = {} + for label, c in (ws.get("cards") or {}).items(): + state = (c or {}).get("state", VISIBLE) + if state in (VISIBLE, HIDDEN): + cards[label] = {"state": state} + links = {} + for key, link in (ws.get("links") or {}).items(): + state = (link or {}).get("state", VISIBLE) + if state in (VISIBLE, HIDDEN, BLOCKED): + links[key] = {"state": state} + added = {} + for key, a in (ws.get("added") or {}).items(): + if ( + isinstance(a, dict) + and a.get("link_type") in ("DocType", "Report", "Page") + and a.get("link_to") + and a.get("card") + ): + added[key] = { + "link_type": a["link_type"], + "link_to": a["link_to"], + "label": a.get("label") or a["link_to"], + "card": a["card"], + } + order = {} + for label, keys in (ws.get("order") or {}).items(): + if isinstance(keys, list): + order[label] = [k for k in keys if isinstance(k, str)] + if cards: + entry["cards"] = cards + if links: + entry["links"] = links + if added: + entry["added"] = added + if order: + entry["order"] = order + out_ws[ws_name] = entry + return {"version": 1, "workspaces": out_ws} + + +def _write(path: str, cfg: dict): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + json.dump(cfg, f, ensure_ascii=False, indent=2, sort_keys=True) + f.write("\n") + + +@frappe.whitelist() +def save_config(config): + _guard() + if isinstance(config, str): + config = json.loads(config) + cfg = _validate_config(config) + _write(desk_layout.working_path(), cfg) + # Drop the per-request cache so a follow-up preview request reflects the save. + if hasattr(frappe.local, desk_layout._CACHE_ATTR): + delattr(frappe.local, desk_layout._CACHE_ATTR) + return {"ok": True} + + +@frappe.whitelist() +def set_editing(on): + _guard() + on = frappe.parse_json(on) if isinstance(on, str) else on + desk_layout.set_editing(bool(on)) + return {"editing": desk_layout.is_editing()} + + +@frappe.whitelist() +def export_snapshot(): + """Copy the working config into jey_theme's shipped snapshot.""" + _guard() + cfg = _read_working() + if not cfg: + frappe.throw("Nothing to export β€” save a layout first.") + target = desk_layout.snapshot_path() + _write(target, _validate_config(cfg)) + return {"ok": True, "path": target} + + +@frappe.whitelist() +def reset_to_default(): + """Reload the working config from the shipped snapshot.""" + _guard() + cfg = desk_layout._read_file(desk_layout.snapshot_path()) + _write(desk_layout.working_path(), _validate_config(cfg or {"workspaces": {}})) + if hasattr(frappe.local, desk_layout._CACHE_ATTR): + delattr(frappe.local, desk_layout._CACHE_ATTR) + return {"ok": True} diff --git a/jey_layout/hooks.py b/jey_layout/hooks.py index 168214b..e11fdf0 100644 --- a/jey_layout/hooks.py +++ b/jey_layout/hooks.py @@ -8,7 +8,7 @@ app_license = "mit" # Apps # ------------------ -# required_apps = [] +required_apps = ["jey_theme"] # Each item in the list will be shown as an app in the apps page # add_to_apps_screen = [ @@ -25,6 +25,11 @@ app_license = "mit" # ------------------ # include js, css files in header of desk.html +# NOTE: We deliberately do NOT use app_include here. app_include assets are added +# to the HTTP `Link: rel=preload` response header; on this deployment that pushed +# the desk's total response headers over the edge proxy's 4 KB proxy_buffer_size β†’ +# 502 "upstream sent too big header". Instead jey_theme.js loads the editor +# dynamically (design-mode only), which adds nothing to the preload header. # app_include_css = "/assets/jey_layout/css/jey_layout.css" # app_include_js = "/assets/jey_layout/js/jey_layout.js" diff --git a/jey_layout/public/css/jey_layout.css b/jey_layout/public/css/jey_layout.css new file mode 100644 index 0000000..6733f00 --- /dev/null +++ b/jey_layout/public/css/jey_layout.css @@ -0,0 +1,206 @@ +/* Jey Layout β€” inline desk visibility editor */ + +/* Floating action button */ +#jey-fab { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 1050; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} +#jey-fab .jey-fab-row { + display: flex; + align-items: center; + gap: 8px; +} +#jey-fab .jey-saved { + font-size: 12px; + color: #fff; + background: var(--green-500, #28a745); + border-radius: 999px; + padding: 3px 10px; + box-shadow: var(--shadow-sm, 0 1px 4px rgba(0, 0, 0, 0.12)); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; +} +#jey-fab .jey-saved.show { + opacity: 1; +} +#jey-fab .jey-fab-btn { + border: 1px solid var(--border-color); + background: var(--card-bg, #fff); + color: var(--text-color); + border-radius: 999px; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); + cursor: pointer; +} + +/* Mode banner (edit / preview) */ +#jey-banner { + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + z-index: 1050; + max-width: 92vw; + font-size: 12px; + padding: 6px 14px; + border-radius: 0 0 8px 8px; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +#jey-banner.edit { + background: #e7f1ff; + border: 1px solid #b6d4fe; + color: #084298; +} +#jey-banner.preview { + background: #fff3cd; + border: 1px solid #ffe69c; + color: #664d03; +} +#jey-fab.on .jey-fab-btn { + background: var(--blue-500, #2490ef); + color: #fff; + border-color: var(--blue-500, #2490ef); +} +#jey-fab .jey-fab-menu { + display: none; + flex-direction: column; + gap: 6px; + background: var(--card-bg, #fff); + border: 1px solid var(--border-color); + border-radius: 10px; + padding: 8px; + box-shadow: var(--shadow-md, 0 2px 8px rgba(0, 0, 0, 0.15)); +} +#jey-fab.on .jey-fab-menu { + display: flex; +} +#jey-fab .jey-fab-menu button { + border: none; + background: transparent; + color: var(--text-color); + font-size: 12px; + text-align: left; + padding: 5px 8px; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; +} +#jey-fab .jey-fab-menu button:hover { + background: var(--bg-light-gray, #f4f5f6); +} + +/* Segmented 3-state control */ +.jey-seg { + display: inline-flex; + gap: 2px; + margin-left: 8px; + vertical-align: middle; + border-radius: 6px; + background: var(--bg-light-gray, #f4f5f6); + padding: 1px; +} +.jey-seg-btn { + border: none; + background: transparent; + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 3px 5px; + border-radius: 5px; + opacity: 0.45; + filter: grayscale(1); +} +.jey-seg-btn:hover { + opacity: 0.8; +} +.jey-seg-btn.active { + opacity: 1; + filter: none; + background: var(--card-bg, #fff); + box-shadow: 0 0 0 1px var(--border-color) inset; +} + +/* Link rows: control sits inside the , pushed to the right */ +body.jey-edit .links-widget-box a.link-item { + display: flex; + align-items: center; + justify-content: space-between; +} +.jey-link-seg { + flex: 0 0 auto; +} + +/* Right-side cluster inside a link row: handle Β· state Β· remove */ +.jey-drag { + margin-left: auto; + cursor: grab; + opacity: 0.4; + font-size: 13px; + line-height: 1; + padding: 0 4px; + align-self: center; +} +.jey-drag:hover { + opacity: 0.85; +} +.jey-remove { + border: none; + background: transparent; + cursor: pointer; + font-size: 11px; + line-height: 1; + padding: 2px 5px; + margin-left: 4px; + border-radius: 5px; + color: var(--red-500, #e24c4c); + opacity: 0.7; +} +.jey-remove:hover { + opacity: 1; + background: var(--red-100, #fce4e4); +} + +/* "Add link" button on the card title */ +.jey-add { + border: 1px solid var(--border-color); + background: var(--card-bg, #fff); + cursor: pointer; + font-size: 12px; + line-height: 1; + padding: 1px 6px; + margin-left: 8px; + border-radius: 6px; + color: var(--green-600, #1f9d57); + vertical-align: middle; +} +.jey-add:hover { + background: var(--bg-light-gray, #f4f5f6); +} + +/* State highlighting */ +.jey-row-hidden { + opacity: 0.55; +} +.jey-row-blocked .link-text, +.jey-row-blocked.link-item { + text-decoration: line-through; + color: var(--red-500, #e24c4c) !important; +} + +/* Make the editing affordance obvious */ +body.jey-edit .links-widget-box { + outline: 1px dashed var(--border-color); + outline-offset: 2px; +} diff --git a/jey_layout/public/js/jey_layout.js b/jey_layout/public/js/jey_layout.js new file mode 100644 index 0000000..3d144bb --- /dev/null +++ b/jey_layout/public/js/jey_layout.js @@ -0,0 +1,453 @@ +/** + * Jey Layout β€” inline desk visibility editor. + * + * Loaded dynamically by jey_theme.js (NOT via app_include, to avoid bloating the + * desk's `Link: rel=preload` response header β€” that tipped the edge proxy's 4 KB + * buffer into 502). Active only for System Manager on a site in design mode. + * + * Model: + * - NOT editing -> the layout is enforced (you see the real applied result, + * i.e. what end users get). This is the default. + * - Editing -> enforcement is suspended server-side so every item shows, + * and each card link / card title / workspace title gets a 3-state icon + * control (visible / hidden / blocked) that auto-saves to the working config. + * Toggling edit flips a server flag and reloads (the desk content itself must be + * re-fetched filtered/unfiltered). Export ships the snapshot to other sites. + */ +(function () { + const STATE = { VISIBLE: "visible", HIDDEN: "hidden", BLOCKED: "blocked" }; + const ICONS = { visible: "πŸ‘", hidden: "🚫", blocked: "πŸ”’" }; + const TITLES = { + visible: __("Visible"), + hidden: __("Hidden (visual only)"), + blocked: __("Blocked (no access for anyone)"), + }; + const LINK_STATES = [STATE.VISIBLE, STATE.HIDDEN, STATE.BLOCKED]; + const WS_STATES = [STATE.VISIBLE, STATE.HIDDEN, STATE.BLOCKED]; + const CARD_STATES = [STATE.VISIBLE, STATE.HIDDEN]; + const API = "jey_layout.api"; + + let status = null; + let config = { version: 1, workspaces: {} }; + const node_cache = {}; + let editing = false; + let save_timer = null; + let hb_timer = null; + let decorating = false; + let saved_timer = null; + + function is_admin() { + return frappe.user && frappe.user.has_role && frappe.user.has_role("System Manager"); + } + + async function xcall(method, args) { + try { + return await frappe.xcall(`${API}.${method}`, args); + } catch (e) { + return null; + } + } + + let inited = false; + async function init() { + if (inited) return; + inited = true; + if (!is_admin()) return; + status = await xcall("get_status"); + if (!status || !status.design_mode) return; // editor only on the design machine + config = (await xcall("get_config")) || { version: 1, workspaces: {} }; + config.workspaces = config.workspaces || {}; + editing = !!status.editing; + + mount_fab(); + update_fab(); + update_banner(); + + if (editing) { + $("body").addClass("jey-edit"); + decorate(); + start_heartbeat(); + } + + frappe.router.on("change", () => { + if (editing) decorate(); + }); + } + + // ---------------------------------------------------------------- FAB + banner + function mount_fab() { + if (document.getElementById("jey-fab")) return; + const $fab = $(` +
+
+ + +
+
+ + +
+
+ `).appendTo("body"); + + $fab.find(".jey-fab-btn").on("click", toggle_edit); + $fab.find('[data-act="export"]').on("click", do_export); + $fab.find('[data-act="reset"]').on("click", do_reset); + } + + function update_fab() { + const $fab = $("#jey-fab"); + $fab.toggleClass("on", editing); + $fab.find(".jey-fab-btn").html(editing ? `βœ“ ${__("Done editing")}` : `✎ ${__("Edit layout")}`); + } + + function update_banner() { + let $b = $("#jey-banner"); + if (!$b.length) $b = $('
').appendTo("body"); + if (editing) { + $b.attr("class", "edit").html( + `✎ ${__("EDIT MODE")} β€” ${__("all items shown")}; πŸ‘ ${__("visible")} Β· 🚫 ${__("hidden")} Β· πŸ”’ ${__("blocked")}. ` + + `${__("Click β€œβœ“ Done editing” to see the applied result.")}` + ).show(); + } else { + $b.hide(); + } + } + + function flash_saved() { + const $s = $("#jey-fab .jey-saved"); + $s.text(`βœ“ ${__("Saved")}`).addClass("show"); + clearTimeout(saved_timer); + saved_timer = setTimeout(() => $s.removeClass("show"), 1200); + } + + // ---------------------------------------------------------------- edit toggle + async function toggle_edit() { + const want = !editing; + // Persist any pending change before flipping (we are about to reload). + if (!want && save_timer) { + clearTimeout(save_timer); + await xcall("save_config", { config: JSON.stringify(config) }); + } + await xcall("set_editing", { on: want }); + location.reload(); + } + + function do_export() { + frappe.confirm( + __("Copy the saved layout into jey_theme's snapshot? Commit that file to roll it out to every site."), + async () => { + const r = await xcall("export_snapshot"); + if (r) { + frappe.msgprint({ + title: __("Exported"), + indicator: "green", + message: `${frappe.utils.escape_html(r.path)}`, + }); + } + } + ); + } + + function do_reset() { + frappe.confirm(__("Discard the working layout and reload from the shipped snapshot?"), async () => { + await xcall("reset_to_default"); + Object.keys(node_cache).forEach((k) => delete node_cache[k]); + config = (await xcall("get_config")) || { version: 1, workspaces: {} }; + config.workspaces = config.workspaces || {}; + if (editing) decorate(); + frappe.show_alert({ message: __("Reset done"), indicator: "blue" }); + }); + } + + // ---------------------------------------------------------------- decorate + function current_ws() { + let name = + (frappe.workspace && frappe.workspace._page && frappe.workspace._page.name) || + (frappe.workspace && frappe.workspace.current_page && frappe.workspace.current_page.name); + if (name) return name; + const r = (frappe.get_route && frappe.get_route()) || []; + if (r[0] === "Workspaces" && r[1]) return r[1]; + if (r.length === 1 && r[0]) return r[0]; + return null; + } + + function boxes() { + const $scoped = $(".layout-main-section .links-widget-box"); + return $scoped.length ? $scoped : $(".links-widget-box"); + } + + // Re-apply controls if the desk re-rendered and wiped them. Only acts when + // controls are missing, so it never loops. + function start_heartbeat() { + stop_heartbeat(); + hb_timer = setInterval(() => { + if (!editing) return stop_heartbeat(); + if (boxes().length && $(".jey-link-seg, .jey-card-seg").length === 0) decorate(); + }, 600); + } + function stop_heartbeat() { + if (hb_timer) clearInterval(hb_timer); + hb_timer = null; + } + + async function decorate() { + if (!editing || decorating) return; + const ws = current_ws(); + if (!ws) return; + decorating = true; + try { + let node = node_cache[ws]; + if (node === undefined) { + node = await xcall("get_workspace_node", { ws_name: ws }); + node_cache[ws] = node; + } + if (!node) return; + $(".jey-ctl").remove(); + decorate_header(ws, node); + decorate_cards(ws, node); + } finally { + decorating = false; + } + } + + function decorate_header(ws, node) { + const $title = ($(".layout-main-section .workspace-title").length + ? $(".layout-main-section .workspace-title") + : $(".workspace-title") + ).first(); + if (!$title.length || $title.find(".jey-ctl").length) return; + const seg = make_seg(WS_STATES, node.state, (s) => set_ws_state(ws, s)); + seg.addClass("jey-ws-seg"); + $title.append(seg); + } + + function clean_title($box) { + // The card label lives inside a child of .widget-title, + // so take the full text (descendants included), stripping only our controls. + const $t = $box.find(".widget-title").first().clone(); + $t.find(".jey-ctl").remove(); + return $t.text().trim(); + } + + function decorate_cards(ws, node) { + boxes().each(function () { + const $box = $(this); + const title = clean_title($box); + const card = node.cards.find((c) => __(c.label).trim() === title); + if (!card) return; // custom auto-card not backed by a Workspace Link + + // Card header: state control + "add link" button. + const $wt = $box.find(".widget-title").first(); + const cseg = make_seg(CARD_STATES, card.state, (s) => set_card_state(ws, card.label, s)); + cseg.addClass("jey-card-seg"); + $wt.append(cseg); + const $add = $(``); + $add.on("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + open_add_dialog(ws, card.label); + }); + $wt.append($add); + + // Links: drag handle + state control + (for added) remove button. + $box.find("a.link-item").each(function (idx) { + const link = card.links[idx]; + if (!link) return; + const key = `${link.link_type}::${link.link_to}`; + const $a = $(this); + $a.attr("data-jey-key", key); + + const $handle = $(`β Ώ`); + + const lseg = make_seg(LINK_STATES, link.state, (s) => { + link.state = s; + set_link_state(ws, key, s); + }); + lseg.addClass("jey-link-seg"); + lseg.data("row", this); + + apply_row_class($a, link.state); + $a.append($handle).append(lseg); + + if (link.added) { + const $rm = $(``); + $rm.on("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + remove_added(ws, card.label, key); + }); + $a.append($rm); + } + }); + + setup_sortable(ws, card.label, $box); + }); + } + + function setup_sortable(ws, card_label, $box) { + const body = $box.find(".widget-body")[0]; + if (!body || body._jeySortable || !window.Sortable) return; + body._jeySortable = new Sortable(body, { + handle: ".jey-drag", + draggable: "a.link-item", + animation: 120, + onEnd: () => { + const keys = $(body) + .find("a.link-item") + .map(function () { + return $(this).attr("data-jey-key"); + }) + .get() + .filter(Boolean); + set_card_order(ws, card_label, keys); + }, + }); + } + + function open_add_dialog(ws, card_label) { + const d = new frappe.ui.Dialog({ + title: __("Add link to β€œ{0}”", [card_label]), + fields: [ + { + fieldname: "link_type", + label: __("Type"), + fieldtype: "Select", + options: "DocType\nReport\nPage", + default: "DocType", + reqd: 1, + }, + { fieldname: "link_to", label: __("Target (DocType)"), fieldtype: "Link", options: "DocType", reqd: 1 }, + ], + primary_action_label: __("Add"), + primary_action: async (v) => { + if (!v.link_to) return; + const key = `${v.link_type}::${v.link_to}`; + const e = ensure_ws(ws); + e.added = e.added || {}; + e.added[key] = { link_type: v.link_type, link_to: v.link_to, label: v.link_to, card: card_label }; + d.hide(); + await save_now(); + delete node_cache[ws]; + location.reload(); // reload so the server injects & renders the new link + }, + }); + // Retarget the Link field when the type changes. + d.fields_dict.link_type.df.onchange = () => { + const t = d.get_value("link_type"); + const f = d.fields_dict.link_to; + f.df.options = t; // "DocType" | "Report" | "Page" are all valid doctypes to link to + f.df.label = __("Target ({0})", [t]); + f.refresh(); + d.set_value("link_to", ""); + }; + d.show(); + } + + function remove_added(ws, card_label, key) { + const e = config.workspaces[ws]; + if (!e) return; + if (e.added) delete e.added[key]; + if (e.links) delete e.links[key]; + if (e.order && e.order[card_label]) e.order[card_label] = e.order[card_label].filter((k) => k !== key); + prune_ws(ws); + save_now().then(() => { + delete node_cache[ws]; + location.reload(); + }); + } + + function set_card_order(ws, card_label, keys) { + const e = ensure_ws(ws); + e.order = e.order || {}; + e.order[card_label] = keys; + prune_ws(ws); + delete node_cache[ws]; + schedule_save(); + } + + function make_seg(states, current, onpick) { + const $seg = $(''); + states.forEach((s) => { + const $b = $( + `` + ); + if (s === current) $b.addClass("active"); + $b.on("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + $seg.find(".jey-seg-btn").removeClass("active"); + $b.addClass("active"); + const row = $seg.data("row"); + if (row) apply_row_class($(row), s); + onpick(s); + }); + $seg.append($b); + }); + return $seg; + } + + function apply_row_class($row, state) { + $row.removeClass("jey-row-hidden jey-row-blocked"); + if (state === STATE.HIDDEN) $row.addClass("jey-row-hidden"); + if (state === STATE.BLOCKED) $row.addClass("jey-row-blocked"); + } + + // ---------------------------------------------------------------- config writes + function ensure_ws(ws) { + config.workspaces[ws] = config.workspaces[ws] || {}; + return config.workspaces[ws]; + } + function prune_ws(ws) { + const e = config.workspaces[ws]; + if (!e) return; + if (e.cards && !Object.keys(e.cards).length) delete e.cards; + if (e.links && !Object.keys(e.links).length) delete e.links; + if (e.added && !Object.keys(e.added).length) delete e.added; + if (e.order && !Object.keys(e.order).length) delete e.order; + if (!e.state && !e.cards && !e.links && !e.added && !e.order) delete config.workspaces[ws]; + } + function set_ws_state(ws, state) { + const e = ensure_ws(ws); + if (state === STATE.VISIBLE) delete e.state; + else e.state = state; + prune_ws(ws); + schedule_save(); + } + function set_card_state(ws, label, state) { + const e = ensure_ws(ws); + e.cards = e.cards || {}; + if (state === STATE.VISIBLE) delete e.cards[label]; + else e.cards[label] = { state }; + prune_ws(ws); + schedule_save(); + } + function set_link_state(ws, key, state) { + const e = ensure_ws(ws); + e.links = e.links || {}; + if (state === STATE.VISIBLE) delete e.links[key]; + else e.links[key] = { state }; + prune_ws(ws); + schedule_save(); + } + + function schedule_save() { + clearTimeout(save_timer); + save_timer = setTimeout(async () => { + const r = await xcall("save_config", { config: JSON.stringify(config) }); + save_timer = null; + if (r) flash_saved(); + }, 500); + } + + async function save_now() { + clearTimeout(save_timer); + save_timer = null; + await xcall("save_config", { config: JSON.stringify(config) }); + } + + $(document).on("app_ready", init); + if (window.frappe && frappe.boot) { + frappe.after_ajax ? frappe.after_ajax(init) : setTimeout(init, 0); + } +})();