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 AI Chat
|
||||||
.aider*
|
.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
|
### 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
|
### Installation
|
||||||
|
|
||||||
You can install this app using the [bench](https://github.com/frappe/bench) CLI:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd $PATH_TO_YOUR_BENCH
|
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
|
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
|
### License
|
||||||
|
|
||||||
mit
|
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
|
# Apps
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|
||||||
# required_apps = []
|
required_apps = ["jey_theme"]
|
||||||
|
|
||||||
# Each item in the list will be shown as an app in the apps page
|
# Each item in the list will be shown as an app in the apps page
|
||||||
# add_to_apps_screen = [
|
# add_to_apps_screen = [
|
||||||
|
|
@ -25,6 +25,11 @@ app_license = "mit"
|
||||||
# ------------------
|
# ------------------
|
||||||
|
|
||||||
# include js, css files in header of desk.html
|
# 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_css = "/assets/jey_layout/css/jey_layout.css"
|
||||||
# app_include_js = "/assets/jey_layout/js/jey_layout.js"
|
# 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