feat: inline desk visibility editor (hide/block/add/reorder)

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 <noreply@anthropic.com>
This commit is contained in:
Ali 2026-06-18 12:53:53 +00:00
parent b55967e426
commit 750bf2c157
6 changed files with 979 additions and 21 deletions

3
.gitignore vendored
View File

@ -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

View File

@ -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

273
jey_layout/api.py Normal file
View File

@ -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}

View File

@ -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"

View File

@ -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 <a>, 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;
}

View File

@ -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 = $(`
<div id="jey-fab">
<div class="jey-fab-menu">
<button data-act="export">${__("Export to jey_theme")}</button>
<button data-act="reset">${__("Reset to default")}</button>
</div>
<div class="jey-fab-row">
<span class="jey-saved"></span>
<button class="jey-fab-btn"></button>
</div>
</div>
`).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 = $('<div id="jey-banner"></div>').appendTo("body");
if (editing) {
$b.attr("class", "edit").html(
`✎ <b>${__("EDIT MODE")}</b> — ${__("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: `<code>${frappe.utils.escape_html(r.path)}</code>`,
});
}
}
);
}
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 <span class="ellipsis"> 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 = $(`<button type="button" class="jey-ctl jey-add" title="${__("Add link")}"></button>`);
$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 = $(`<span class="jey-ctl jey-drag" title="${__("Drag to reorder")}">⠿</span>`);
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 = $(`<button type="button" class="jey-ctl jey-remove" title="${__("Remove added link")}">✕</button>`);
$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 = $('<span class="jey-ctl jey-seg" contenteditable="false"></span>');
states.forEach((s) => {
const $b = $(
`<button type="button" class="jey-seg-btn" data-state="${s}" title="${TITLES[s]}">${ICONS[s]}</button>`
);
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);
}
})();