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:
parent
b55967e426
commit
750bf2c157
|
|
@ -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
|
||||
|
|
|
|||
58
README.md
58
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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in New Issue