Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/routes_customers.py +36 -16
- api/routes_grid.py +7 -4
- api/routes_products.py +13 -2
- api/routes_shares.py +100 -40
- api/routes_tables.py +13 -2
- platform/core/grid_events.py +96 -24
- platform/core/user_tables.py +20 -9
- web/src/customer-grid/ColumnMenu.tsx +49 -201
- web/src/customer-grid/CustomerGrid.tsx +13 -23
- web/src/customer-grid/ViewSidebar.tsx +13 -134
- web/src/customer-grid/types.ts +41 -9
- web/src/index.css +39 -108
- web/src/shell/ShareDialog.tsx +41 -40
- web/src/ui/icons.tsx +1 -1
api/routes_customers.py
CHANGED
|
@@ -158,10 +158,15 @@ def _measure_err(tag, e):
|
|
| 158 |
# `patch_shared_cell` wrote into a bucket no reader ever opened.
|
| 159 |
|
| 160 |
|
| 161 |
-
def _shared_key():
|
| 162 |
"""The store key this topic's per-user AND tenant-wide strata are both named from."""
|
| 163 |
-
import modules.customer_data as cl
|
| 164 |
-
return cl.TABLE_KEY
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
|
| 166 |
|
| 167 |
def shared_fields(st=None):
|
|
@@ -173,7 +178,8 @@ def shared_fields(st=None):
|
|
| 173 |
"""
|
| 174 |
from core import field_permissions, shared_overlay
|
| 175 |
try:
|
| 176 |
-
field_permissions.migrate_legacy_fields(
|
|
|
|
| 177 |
return shared_overlay.fields(_shared_key(), st=st)
|
| 178 |
except Exception: # noqa: BLE001
|
| 179 |
# Lenient like every other display read: an unreachable store degrades to "nothing is
|
|
@@ -197,7 +203,7 @@ def shared_cells(pids, st=None):
|
|
| 197 |
return {}
|
| 198 |
|
| 199 |
|
| 200 |
-
def _merge_shared_fields(fields, defs):
|
| 201 |
"""`fields` PLUS the tenant-wide columns this topic declares β `routes_tables._ut_shared_fields`
|
| 202 |
on the customer topic.
|
| 203 |
|
|
@@ -212,8 +218,20 @@ def _merge_shared_fields(fields, defs):
|
|
| 212 |
if not defs:
|
| 213 |
return fields
|
| 214 |
have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
|
| 219 |
def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
|
|
@@ -290,7 +308,7 @@ def grid_assembly(session: Session, scope: str = "customer", storage_key: str =
|
|
| 290 |
# ββ W38-T20 β AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
|
| 291 |
# position is load-bearing twice, once for the transitive closure and once because it is the
|
| 292 |
# only order in which the per-field grant marker is ever presented to `hidden_keys`.
|
| 293 |
-
fields = _merge_shared_fields(fields, _defs)
|
| 294 |
# THE FIELD WALL β a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides
|
| 295 |
# every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so
|
| 296 |
# shipping a dependent formula while withholding its input either leaks the input through
|
|
@@ -420,13 +438,14 @@ def _ctx_for(session: Session, pids, defs=None):
|
|
| 420 |
them; absent, they are read. One read per assembly rather than one per question asked of it.
|
| 421 |
"""
|
| 422 |
from core import grid_events
|
| 423 |
-
return grid_events.EventCtx(
|
| 424 |
-
uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[],
|
| 425 |
# C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the
|
| 426 |
# tenant-wide columns, because `fields=[]` here β the closure only needs the schema, not
|
| 427 |
# this user's column list, and a runtime column is part of that schema now.
|
| 428 |
-
hidden_keys=_hidden_for(session, defs=defs),
|
| 429 |
-
admin=session.admin,
|
|
|
|
| 430 |
|
| 431 |
|
| 432 |
|
|
@@ -505,10 +524,11 @@ def patch_customer(pid: int, body: dict = Body(default=None),
|
|
| 505 |
# saying "no such customer" would confirm the opposite to anyone who guessed right.
|
| 506 |
raise err(403, "out_of_scope", "that customer is not in your book")
|
| 507 |
payload = _payload(session)
|
| 508 |
-
ctx = grid_events.EventCtx(
|
| 509 |
-
uname=session.uname, allowed_pids=pool, fields=payload["fields"],
|
| 510 |
-
admin=session.admin, fallback_ws=None, seen_ids={},
|
| 511 |
-
hidden_keys=_hidden_for(session))
|
|
|
|
| 512 |
try:
|
| 513 |
grid_events.handle_one(
|
| 514 |
{"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
|
|
|
|
| 158 |
# `patch_shared_cell` wrote into a bucket no reader ever opened.
|
| 159 |
|
| 160 |
|
| 161 |
+
def _shared_key():
|
| 162 |
"""The store key this topic's per-user AND tenant-wide strata are both named from."""
|
| 163 |
+
import modules.customer_data as cl
|
| 164 |
+
return cl.TABLE_KEY
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _customer_table(session):
|
| 168 |
+
import core.table_store as table_store
|
| 169 |
+
return table_store.make(_shared_key(), st=session.runtime)
|
| 170 |
|
| 171 |
|
| 172 |
def shared_fields(st=None):
|
|
|
|
| 178 |
"""
|
| 179 |
from core import field_permissions, shared_overlay
|
| 180 |
try:
|
| 181 |
+
field_permissions.migrate_legacy_fields(
|
| 182 |
+
_shared_key(), st=st, grant_topic="customer_data", shared_key=_shared_key())
|
| 183 |
return shared_overlay.fields(_shared_key(), st=st)
|
| 184 |
except Exception: # noqa: BLE001
|
| 185 |
# Lenient like every other display read: an unreachable store degrades to "nothing is
|
|
|
|
| 203 |
return {}
|
| 204 |
|
| 205 |
|
| 206 |
+
def _merge_shared_fields(fields, defs, session=None):
|
| 207 |
"""`fields` PLUS the tenant-wide columns this topic declares β `routes_tables._ut_shared_fields`
|
| 208 |
on the customer topic.
|
| 209 |
|
|
|
|
| 218 |
if not defs:
|
| 219 |
return fields
|
| 220 |
have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
|
| 221 |
+
projected = []
|
| 222 |
+
for k, f in defs.items():
|
| 223 |
+
if k in have:
|
| 224 |
+
continue
|
| 225 |
+
item = dict(f, source="overlay", shared=True)
|
| 226 |
+
if session is not None:
|
| 227 |
+
from core import shares
|
| 228 |
+
role = shares.role_for(
|
| 229 |
+
"field", shares.field_oid("customer_data", k), session.uname,
|
| 230 |
+
is_admin=session.admin, st=session.runtime)
|
| 231 |
+
if role:
|
| 232 |
+
item["sharedRole"] = role
|
| 233 |
+
projected.append(item)
|
| 234 |
+
return list(fields or ()) + projected
|
| 235 |
|
| 236 |
|
| 237 |
def grid_assembly(session: Session, scope: str = "customer", storage_key: str = "",
|
|
|
|
| 308 |
# ββ W38-T20 β AND THE COLUMN DEFINITIONS, BEFORE THE WALL. See `_merge_shared_fields`: this
|
| 309 |
# position is load-bearing twice, once for the transitive closure and once because it is the
|
| 310 |
# only order in which the per-field grant marker is ever presented to `hidden_keys`.
|
| 311 |
+
fields = _merge_shared_fields(fields, _defs, session=session)
|
| 312 |
# THE FIELD WALL β a TRANSITIVE closure (C-PERM amendment 5), so hiding a field also hides
|
| 313 |
# every formula computed FROM it. Formulas evaluate in the browser from `{ref}`s, so
|
| 314 |
# shipping a dependent formula while withholding its input either leaks the input through
|
|
|
|
| 438 |
them; absent, they are read. One read per assembly rather than one per question asked of it.
|
| 439 |
"""
|
| 440 |
from core import grid_events
|
| 441 |
+
return grid_events.EventCtx(
|
| 442 |
+
uname=session.uname, allowed_pids=frozenset(pids or ()), fields=[],
|
| 443 |
# C-PERM: the write wall's field half. Computed from the CANONICAL contract PLUS the
|
| 444 |
# tenant-wide columns, because `fields=[]` here β the closure only needs the schema, not
|
| 445 |
# this user's column list, and a runtime column is part of that schema now.
|
| 446 |
+
hidden_keys=_hidden_for(session, defs=defs),
|
| 447 |
+
admin=session.admin, table=_customer_table(session), st=session.runtime,
|
| 448 |
+
fallback_ws=None, seen_ids={})
|
| 449 |
|
| 450 |
|
| 451 |
|
|
|
|
| 524 |
# saying "no such customer" would confirm the opposite to anyone who guessed right.
|
| 525 |
raise err(403, "out_of_scope", "that customer is not in your book")
|
| 526 |
payload = _payload(session)
|
| 527 |
+
ctx = grid_events.EventCtx(
|
| 528 |
+
uname=session.uname, allowed_pids=pool, fields=payload["fields"],
|
| 529 |
+
admin=session.admin, fallback_ws=None, seen_ids={},
|
| 530 |
+
hidden_keys=_hidden_for(session), table=_customer_table(session),
|
| 531 |
+
st=session.runtime)
|
| 532 |
try:
|
| 533 |
grid_events.handle_one(
|
| 534 |
{"id": f"patch:{pid}:{time.time_ns()}", "type": "overlay_patch",
|
api/routes_grid.py
CHANGED
|
@@ -54,7 +54,7 @@ def _ctx(session: Session, fields, pids, **kw):
|
|
| 54 |
|
| 55 |
module, canonical = _PMOD, pd_fields(consolidated=True, st=session.runtime)
|
| 56 |
kw.setdefault("table", pd.table_ops(session.runtime))
|
| 57 |
-
hidden = perm_scope.hidden_keys(session.user, module, canonical)
|
| 58 |
elif scope_key.startswith("ut_"):
|
| 59 |
# Wave 18 C3-UT: a user table has no module in the permission wall (its wall is
|
| 60 |
# `user_tables.may_open`, already applied by the assembly this route ran first), so
|
|
@@ -64,9 +64,12 @@ def _ctx(session: Session, fields, pids, **kw):
|
|
| 64 |
kw.setdefault("table",
|
| 65 |
table_store.make(f"{scope_key}_table_workspace", st=session.runtime))
|
| 66 |
hidden = frozenset()
|
| 67 |
-
else:
|
| 68 |
-
module, canonical = MODULE, aios_grid.FIELDS
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
| 70 |
return grid_events.EventCtx(
|
| 71 |
uname=session.uname, allowed_pids=pids, fields=fields, admin=session.admin,
|
| 72 |
# C-PERM: the write wall's field half, on the EVENTS transport too. This route takes the
|
|
|
|
| 54 |
|
| 55 |
module, canonical = _PMOD, pd_fields(consolidated=True, st=session.runtime)
|
| 56 |
kw.setdefault("table", pd.table_ops(session.runtime))
|
| 57 |
+
hidden = perm_scope.hidden_keys(session.user, module, canonical, st=session.runtime)
|
| 58 |
elif scope_key.startswith("ut_"):
|
| 59 |
# Wave 18 C3-UT: a user table has no module in the permission wall (its wall is
|
| 60 |
# `user_tables.may_open`, already applied by the assembly this route ran first), so
|
|
|
|
| 64 |
kw.setdefault("table",
|
| 65 |
table_store.make(f"{scope_key}_table_workspace", st=session.runtime))
|
| 66 |
hidden = frozenset()
|
| 67 |
+
else:
|
| 68 |
+
module, canonical = MODULE, aios_grid.FIELDS
|
| 69 |
+
import core.table_store as table_store
|
| 70 |
+
kw.setdefault("table", table_store.make(
|
| 71 |
+
"customer_table_workspace", st=session.runtime))
|
| 72 |
+
hidden = perm_scope.hidden_keys(session.user, module, canonical, st=session.runtime)
|
| 73 |
return grid_events.EventCtx(
|
| 74 |
uname=session.uname, allowed_pids=pids, fields=fields, admin=session.admin,
|
| 75 |
# C-PERM: the write wall's field half, on the EVENTS transport too. This route takes the
|
api/routes_products.py
CHANGED
|
@@ -366,8 +366,19 @@ def product_assembly(session: Session, scope: str = "product", storage_key: str
|
|
| 366 |
fields_base=fields_base)
|
| 367 |
|
| 368 |
have = {field.get("key") for field in fields if isinstance(field, dict)}
|
| 369 |
-
|
| 370 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
|
| 372 |
hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=session.runtime)
|
| 373 |
if hidden:
|
|
|
|
| 366 |
fields_base=fields_base)
|
| 367 |
|
| 368 |
have = {field.get("key") for field in fields if isinstance(field, dict)}
|
| 369 |
+
projected = []
|
| 370 |
+
from core import shares
|
| 371 |
+
for key, field in shared_defs.items():
|
| 372 |
+
if key in have:
|
| 373 |
+
continue
|
| 374 |
+
item = dict(field, source="overlay", shared=True)
|
| 375 |
+
role = shares.role_for(
|
| 376 |
+
"field", shares.field_oid("product_data", key), session.uname,
|
| 377 |
+
is_admin=session.admin, st=session.runtime)
|
| 378 |
+
if role:
|
| 379 |
+
item["sharedRole"] = role
|
| 380 |
+
projected.append(item)
|
| 381 |
+
fields = list(fields) + projected
|
| 382 |
|
| 383 |
hidden = perm_scope.hidden_keys(session.user, MODULE, fields, st=session.runtime)
|
| 384 |
if hidden:
|
api/routes_shares.py
CHANGED
|
@@ -85,10 +85,57 @@ def _kind_or_400(raw):
|
|
| 85 |
|
| 86 |
#: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
|
| 87 |
#: route is not told which topic β so resolving one means asking each.
|
| 88 |
-
_BUILTIN_TOPICS = ("customer", "product")
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
"""Every topic whose workspace could hold a view or folder for this tenant.
|
| 93 |
|
| 94 |
β `all_defs`, never `all_tables` β the latter is the whole 28.6 MB row payload (~703 ms on
|
|
@@ -121,15 +168,13 @@ def _owns_object(session, kind, oid):
|
|
| 121 |
# a brand-new column has no grant record, so `put_share` falls to this predicate β and
|
| 122 |
# without it the column's own creator is answered `404 no_object` on the first attempt to
|
| 123 |
# share the thing they just made.
|
| 124 |
-
table_key, field_key = shares.split_field_oid(oid)
|
| 125 |
-
if not table_key:
|
| 126 |
-
return False
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
return False
|
| 132 |
-
return bool(defn) and str(defn.get("createdBy") or "") == str(session.uname)
|
| 133 |
if kind == "database":
|
| 134 |
# β `may_open` is THE resolver for a user table (its own docstring says so) and already
|
| 135 |
# admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
|
|
@@ -242,8 +287,12 @@ def my_shares(session: Session = Depends(require_session)):
|
|
| 242 |
|
| 243 |
|
| 244 |
@router.get("/share/{kind}/{oid}")
|
| 245 |
-
def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
| 246 |
-
kind = _kind_or_400(kind)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
rec = shares.grants(kind, oid, st=session.runtime)
|
| 248 |
role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
|
| 249 |
may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
|
|
@@ -298,10 +347,14 @@ def _people(tenant):
|
|
| 298 |
|
| 299 |
|
| 300 |
@router.put("/share/{kind}/{oid}")
|
| 301 |
-
def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
| 302 |
-
session: Session = Depends(require_session)):
|
| 303 |
-
kind = _kind_or_400(kind)
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
rec = shares.grants(kind, oid, st=session.runtime)
|
| 306 |
# An object with NO grant record yet has no owner β the first person to share it claims it.
|
| 307 |
# That is safe because reaching this route at all means passing the surface's own wall, and
|
|
@@ -328,19 +381,24 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
|
| 328 |
"revoking is expressed")
|
| 329 |
_entries_or_400(session, entries)
|
| 330 |
if kind == "field":
|
| 331 |
-
# A field grant is a visibility
|
| 332 |
-
#
|
| 333 |
-
#
|
| 334 |
-
|
| 335 |
-
# after the dialog reports success.
|
| 336 |
-
from core import shared_overlay
|
| 337 |
table_key, field_key = shares.split_field_oid(oid)
|
| 338 |
-
defn
|
|
|
|
| 339 |
if not isinstance(defn, dict):
|
| 340 |
raise err(404, "no_object", "no such field, or it is not shared with this account")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
stamped = dict(defn)
|
|
|
|
| 342 |
stamped["granted"] = True
|
| 343 |
-
shared_overlay.put_field(
|
|
|
|
| 344 |
out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
|
| 345 |
st=session.runtime)
|
| 346 |
_notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
|
|
@@ -426,25 +484,27 @@ def _object_ref(session, kind, oid):
|
|
| 426 |
# answers `route=None` β and `_notify_new_grantees` returns EARLY. The grant lands and
|
| 427 |
# the receiver is never told, which is the silent half of owner item 18 reopened one
|
| 428 |
# kind over.
|
| 429 |
-
from routes_alerts import route_for_topic
|
| 430 |
-
table_key, field_key = shares.split_field_oid(oid)
|
| 431 |
-
if not table_key:
|
| 432 |
-
return ("A column", None, "")
|
| 433 |
-
try:
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
except Exception: # noqa: BLE001
|
| 437 |
-
defn = None
|
| 438 |
-
label = str((defn or {}).get("label") or "").strip() or field_key
|
| 439 |
# β TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
|
| 440 |
# by whatever the calling door already held: a `ut_*` database uses its bare key,
|
| 441 |
# while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
|
| 442 |
# `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
|
| 443 |
# `customer_data`), so the suffix comes off before it is asked β rather than a second
|
| 444 |
# route table being written here, which is how the two come apart.
|
| 445 |
-
_WS = "_table_workspace"
|
| 446 |
-
scope =
|
| 447 |
-
|
|
|
|
|
|
|
| 448 |
if kind == "database":
|
| 449 |
import core.user_tables as ut
|
| 450 |
defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
|
|
|
|
| 85 |
|
| 86 |
#: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
|
| 87 |
#: route is not told which topic β so resolving one means asking each.
|
| 88 |
+
_BUILTIN_TOPICS = ("customer", "product")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _field_storage_keys(table_key):
|
| 92 |
+
"""Resolve the client-facing field topic to its durable stores and grant topic."""
|
| 93 |
+
raw = str(table_key or "").strip()
|
| 94 |
+
if raw in ("customer_data", "customer_table_workspace"):
|
| 95 |
+
return "customer_table_workspace", "customer_table_workspace", "customer_data"
|
| 96 |
+
if raw in ("product_data", "product_table_workspace"):
|
| 97 |
+
return "product_table_workspace", "product_table_workspace", "product_data"
|
| 98 |
+
if raw.startswith("ut_"):
|
| 99 |
+
bare = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
|
| 100 |
+
return f"{bare}_table_workspace", bare, bare
|
| 101 |
+
workspace = raw if raw.endswith("_table_workspace") else f"{raw}_table_workspace"
|
| 102 |
+
shared = raw[:-len("_table_workspace")] if raw.endswith("_table_workspace") else raw
|
| 103 |
+
return workspace, shared, shared
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _field_definition(session, table_key, field_key):
|
| 107 |
+
"""Return the shared/private definition and the keys used by its write paths."""
|
| 108 |
+
workspace_key, shared_key, grant_topic = _field_storage_keys(table_key)
|
| 109 |
+
try:
|
| 110 |
+
from core import shared_overlay
|
| 111 |
+
shared = (shared_overlay.fields(shared_key, st=session.runtime) or {}).get(field_key)
|
| 112 |
+
if isinstance(shared, dict):
|
| 113 |
+
return shared, True, workspace_key, shared_key, grant_topic
|
| 114 |
+
import core.table_store as table_store
|
| 115 |
+
private = (table_store.make(workspace_key, st=session.runtime)
|
| 116 |
+
.workspace(session.uname).get("fields") or {}).get(field_key)
|
| 117 |
+
if isinstance(private, dict):
|
| 118 |
+
return private, False, workspace_key, shared_key, grant_topic
|
| 119 |
+
except Exception: # noqa: BLE001
|
| 120 |
+
pass
|
| 121 |
+
return None, False, workspace_key, shared_key, grant_topic
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _field_owner(session, definition, already_shared):
|
| 125 |
+
"""Resolve the creator for the share claim wall.
|
| 126 |
+
|
| 127 |
+
A private field is already namespaced by the caller's own workspace. Older field records
|
| 128 |
+
from before the host-side creator stamp therefore remain safely claimable by that workspace
|
| 129 |
+
owner, while a shared definition with no creator stays admin-only because its storage is
|
| 130 |
+
tenant-wide and cannot identify an owner from residency alone.
|
| 131 |
+
"""
|
| 132 |
+
owner = str((definition or {}).get("createdBy") or "").strip()
|
| 133 |
+
if owner:
|
| 134 |
+
return owner
|
| 135 |
+
return str(session.uname or "").strip() if not already_shared else ""
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _topics(session):
|
| 139 |
"""Every topic whose workspace could hold a view or folder for this tenant.
|
| 140 |
|
| 141 |
β `all_defs`, never `all_tables` β the latter is the whole 28.6 MB row payload (~703 ms on
|
|
|
|
| 168 |
# a brand-new column has no grant record, so `put_share` falls to this predicate β and
|
| 169 |
# without it the column's own creator is answered `404 no_object` on the first attempt to
|
| 170 |
# share the thing they just made.
|
| 171 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 172 |
+
if not table_key:
|
| 173 |
+
return False
|
| 174 |
+
defn, _shared, _workspace, _shared_key, _grant_topic = _field_definition(
|
| 175 |
+
session, table_key, field_key)
|
| 176 |
+
owner = _field_owner(session, defn, _shared)
|
| 177 |
+
return bool(defn) and owner.lower() == str(session.uname).strip().lower()
|
|
|
|
|
|
|
| 178 |
if kind == "database":
|
| 179 |
# β `may_open` is THE resolver for a user table (its own docstring says so) and already
|
| 180 |
# admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
|
|
|
|
| 287 |
|
| 288 |
|
| 289 |
@router.get("/share/{kind}/{oid}")
|
| 290 |
+
def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
| 291 |
+
kind = _kind_or_400(kind)
|
| 292 |
+
if kind == "field":
|
| 293 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 294 |
+
if table_key and field_key:
|
| 295 |
+
oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
|
| 296 |
rec = shares.grants(kind, oid, st=session.runtime)
|
| 297 |
role = shares.role_for(kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
|
| 298 |
may_admin = shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
|
|
|
|
| 347 |
|
| 348 |
|
| 349 |
@router.put("/share/{kind}/{oid}")
|
| 350 |
+
def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
| 351 |
+
session: Session = Depends(require_session)):
|
| 352 |
+
kind = _kind_or_400(kind)
|
| 353 |
+
if kind == "field":
|
| 354 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 355 |
+
if table_key and field_key:
|
| 356 |
+
oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
|
| 357 |
+
body = body or {}
|
| 358 |
rec = shares.grants(kind, oid, st=session.runtime)
|
| 359 |
# An object with NO grant record yet has no owner β the first person to share it claims it.
|
| 360 |
# That is safe because reaching this route at all means passing the surface's own wall, and
|
|
|
|
| 381 |
"revoking is expressed")
|
| 382 |
_entries_or_400(session, entries)
|
| 383 |
if kind == "field":
|
| 384 |
+
# A field grant is a visibility and edit wall. Promote a private custom
|
| 385 |
+
# field exactly once, then keep the requested Share field role as the
|
| 386 |
+
# authoritative override for the legacy permissions bag.
|
| 387 |
+
from core import field_permissions, shared_overlay
|
|
|
|
|
|
|
| 388 |
table_key, field_key = shares.split_field_oid(oid)
|
| 389 |
+
defn, already_shared, workspace_key, shared_key, grant_topic = _field_definition(
|
| 390 |
+
session, table_key, field_key)
|
| 391 |
if not isinstance(defn, dict):
|
| 392 |
raise err(404, "no_object", "no such field, or it is not shared with this account")
|
| 393 |
+
if not already_shared:
|
| 394 |
+
defn = field_permissions.promote_field(
|
| 395 |
+
workspace_key, shared_key, grant_topic,
|
| 396 |
+
session.uname, defn, st=session.runtime)
|
| 397 |
stamped = dict(defn)
|
| 398 |
+
stamped["shared"] = True
|
| 399 |
stamped["granted"] = True
|
| 400 |
+
shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
|
| 401 |
+
oid = shares.field_oid(grant_topic, field_key)
|
| 402 |
out = shares.set_grants(kind, oid, entries, owner=rec["owner"] or session.uname,
|
| 403 |
st=session.runtime)
|
| 404 |
_notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
|
|
|
|
| 484 |
# answers `route=None` β and `_notify_new_grantees` returns EARLY. The grant lands and
|
| 485 |
# the receiver is never told, which is the silent half of owner item 18 reopened one
|
| 486 |
# kind over.
|
| 487 |
+
from routes_alerts import route_for_topic
|
| 488 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 489 |
+
if not table_key:
|
| 490 |
+
return ("A column", None, "")
|
| 491 |
+
try:
|
| 492 |
+
defn, _shared, _workspace, shared_key, _grant_topic = _field_definition(
|
| 493 |
+
session, table_key, field_key)
|
| 494 |
+
except Exception: # noqa: BLE001
|
| 495 |
+
defn = None
|
| 496 |
+
label = str((defn or {}).get("label") or "").strip() or field_key
|
| 497 |
# β TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
|
| 498 |
# by whatever the calling door already held: a `ut_*` database uses its bare key,
|
| 499 |
# while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
|
| 500 |
# `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
|
| 501 |
# `customer_data`), so the suffix comes off before it is asked β rather than a second
|
| 502 |
# route table being written here, which is how the two come apart.
|
| 503 |
+
_WS = "_table_workspace"
|
| 504 |
+
scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
|
| 505 |
+
if scope is None:
|
| 506 |
+
scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
|
| 507 |
+
return (label, route_for_topic(scope) or None, "")
|
| 508 |
if kind == "database":
|
| 509 |
import core.user_tables as ut
|
| 510 |
defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
|
api/routes_tables.py
CHANGED
|
@@ -744,8 +744,19 @@ def _ut_shared_fields(session, table_key, fields):
|
|
| 744 |
if not defs:
|
| 745 |
return fields
|
| 746 |
have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
|
| 747 |
-
|
| 748 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
|
| 750 |
|
| 751 |
def _ut_shared_cells(session, table_key, pids):
|
|
|
|
| 744 |
if not defs:
|
| 745 |
return fields
|
| 746 |
have = {f.get("key") for f in (fields or ()) if isinstance(f, dict)}
|
| 747 |
+
from core import shares
|
| 748 |
+
projected = []
|
| 749 |
+
for k, f in defs.items():
|
| 750 |
+
if k in have:
|
| 751 |
+
continue
|
| 752 |
+
item = dict(f, source="overlay", shared=True)
|
| 753 |
+
role = shares.role_for(
|
| 754 |
+
"field", shares.field_oid(table_key, k), session.uname,
|
| 755 |
+
is_admin=session.admin, st=session.runtime)
|
| 756 |
+
if role:
|
| 757 |
+
item["sharedRole"] = role
|
| 758 |
+
projected.append(item)
|
| 759 |
+
return list(fields or ()) + projected
|
| 760 |
|
| 761 |
|
| 762 |
def _ut_shared_cells(session, table_key, pids):
|
platform/core/grid_events.py
CHANGED
|
@@ -261,6 +261,77 @@ def _tops(ctx):
|
|
| 261 |
return cl_mod.TABLE_OPS
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def _cohorts(ctx):
|
| 265 |
"""The COHORT store this ctx operates on β `_tops`'s sibling, and here for the same reason.
|
| 266 |
|
|
@@ -1199,11 +1270,7 @@ def handle_one(event, ctx):
|
|
| 1199 |
try:
|
| 1200 |
import core.shared_overlay as _shared_overlay
|
| 1201 |
table_key = str(ctx.table.table_key)
|
| 1202 |
-
shared_storage_key = (
|
| 1203 |
-
if table_key.endswith('_table_workspace') else table_key)
|
| 1204 |
-
grant_topic = ('product_data' if table_key == 'product_table_workspace'
|
| 1205 |
-
else 'customer_data' if table_key == 'customer_data'
|
| 1206 |
-
else shared_storage_key)
|
| 1207 |
shared_prior = (_shared_overlay.fields(
|
| 1208 |
shared_storage_key, st=ctx.table.st) or {}).get(key)
|
| 1209 |
shared_field = bool(isinstance(shared_prior, dict)
|
|
@@ -1211,6 +1278,11 @@ def handle_one(event, ctx):
|
|
| 1211 |
except Exception:
|
| 1212 |
shared_prior = None
|
| 1213 |
if shared_field:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1214 |
# Pin the type/source/key to the stored definition. Only the
|
| 1215 |
# user-authored settings below are allowed to change, so a browser
|
| 1216 |
# cannot turn an image field into another schema type while saving
|
|
@@ -1396,9 +1468,17 @@ def handle_one(event, ctx):
|
|
| 1396 |
# admin path and the legacy path are enforceable (and NC'd) right now.
|
| 1397 |
needs_values = False
|
| 1398 |
refused_perms = False
|
|
|
|
|
|
|
| 1399 |
if shared_field or key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX):
|
| 1400 |
prior = shared_prior if shared_field else ws.get('fields', {}).get(key)
|
| 1401 |
prior = prior if isinstance(prior, dict) else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1402 |
if prior is None and not shared_field:
|
| 1403 |
field['createdBy'] = uname
|
| 1404 |
# Wave-6 item 9: scope is chosen at CREATE, on the cohort page only β
|
|
@@ -1422,6 +1502,7 @@ def handle_one(event, ctx):
|
|
| 1422 |
may_change_permissions = bool(admin or (owner and owner.lower() == uname.lower()))
|
| 1423 |
if may_change_permissions:
|
| 1424 |
field['permissions'] = want
|
|
|
|
| 1425 |
else:
|
| 1426 |
field['permissions'] = have
|
| 1427 |
# Asked for a permissions change and did not get it: the client's
|
|
@@ -1440,18 +1521,13 @@ def handle_one(event, ctx):
|
|
| 1440 |
and isinstance(value, dict))
|
| 1441 |
]
|
| 1442 |
desired_shared = field.get('permissions', {}).get('edit') in ('collaborative', 'users')
|
| 1443 |
-
if shared_field and not desired_shared and shared_storage_key:
|
| 1444 |
-
# Demotion is a real data move: restore the creator's private definition and values
|
| 1445 |
-
# before dropping the tenant-wide copy, then remove its grant record.
|
| 1446 |
-
field = _fp.demote_field(ctx.table.table_key, shared_storage_key, grant_topic,
|
| 1447 |
-
field, st=ctx.table.st)
|
| 1448 |
-
shared_field = False
|
| 1449 |
if not shared_field and desired_shared and shared_storage_key and ctx.table is not None:
|
| 1450 |
# Promotion makes the permission choice true for the whole tenant, rather than merely
|
| 1451 |
# storing a collaborative label in Anna's private workspace.
|
| 1452 |
field = _fp.promote_field(ctx.table.table_key, shared_storage_key, grant_topic,
|
| 1453 |
field.get('createdBy') or uname, field, st=ctx.table.st)
|
| 1454 |
shared_field = True
|
|
|
|
| 1455 |
if shared_field:
|
| 1456 |
# A shared definition is the source of truth for every tenant
|
| 1457 |
# member. Persisting this through TableStore.save_field would put
|
|
@@ -1460,10 +1536,11 @@ def handle_one(event, ctx):
|
|
| 1460 |
import core.shared_overlay as _shared_overlay
|
| 1461 |
field = _shared_overlay.put_field(shared_storage_key, key, field,
|
| 1462 |
st=ctx.table.st)
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
|
|
|
| 1467 |
elif _store_of(ctx).available():
|
| 1468 |
field = (_tops(ctx).save_field(
|
| 1469 |
uname, field, reserved_names=_reserved_field_names,
|
|
@@ -2116,16 +2193,11 @@ def handle_one(event, ctx):
|
|
| 2116 |
if key in (ctx.hidden_keys or frozenset()):
|
| 2117 |
refused = True
|
| 2118 |
continue
|
| 2119 |
-
# Wave-5 item 1
|
| 2120 |
-
#
|
| 2121 |
-
#
|
| 2122 |
-
from core import field_permissions as _fp_patch
|
| 2123 |
definition = field_by_key.get(key) or {}
|
| 2124 |
-
|
| 2125 |
-
edit = perms.get('edit')
|
| 2126 |
-
owner = str(definition.get('createdBy') or '').strip().lower()
|
| 2127 |
-
permitted = (admin or (owner and owner == uname) or edit == 'collaborative' or
|
| 2128 |
-
(edit == 'users' and uname in (perms.get('users') or [])))
|
| 2129 |
if not permitted:
|
| 2130 |
refused = True
|
| 2131 |
continue
|
|
|
|
| 261 |
return cl_mod.TABLE_OPS
|
| 262 |
|
| 263 |
|
| 264 |
+
def _field_share_keys(ctx):
|
| 265 |
+
"""Return (shared storage key, grant topic) for this grid's field registry."""
|
| 266 |
+
raw = str(getattr(getattr(ctx, 'table', None), 'table_key', '') or '')
|
| 267 |
+
scope = str(getattr(ctx, 'scope_key', '') or '')
|
| 268 |
+
if raw == 'product_table_workspace' or scope == 'product':
|
| 269 |
+
return 'product_table_workspace', 'product_data'
|
| 270 |
+
if raw == 'customer_table_workspace' or scope in ('customer', 'cohort'):
|
| 271 |
+
return 'customer_table_workspace', 'customer_data'
|
| 272 |
+
if raw.startswith('ut_'):
|
| 273 |
+
bare = raw[:-len('_table_workspace')] if raw.endswith('_table_workspace') else raw
|
| 274 |
+
return bare, bare
|
| 275 |
+
if raw.endswith('_table_workspace'):
|
| 276 |
+
return raw, raw[:-len('_table_workspace')]
|
| 277 |
+
return raw, raw
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _field_share_role(ctx, key, definition):
|
| 281 |
+
"""Return the specific Share field role, if this definition is governed by it."""
|
| 282 |
+
if not isinstance(definition, dict) or not (definition.get('shared') or
|
| 283 |
+
definition.get('granted')):
|
| 284 |
+
return None
|
| 285 |
+
try:
|
| 286 |
+
import core.shares as _shares
|
| 287 |
+
_storage_key, topic = _field_share_keys(ctx)
|
| 288 |
+
return _shares.role_for(
|
| 289 |
+
'field', _shares.field_oid(topic, key), ctx.uname,
|
| 290 |
+
is_admin=ctx.admin, st=ctx.st or getattr(ctx.table, 'st', None))
|
| 291 |
+
except Exception:
|
| 292 |
+
return None
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def _may_edit_field_definition(ctx, key, definition):
|
| 296 |
+
"""The server wall for rename/type/settings edits on a field."""
|
| 297 |
+
if not isinstance(definition, dict):
|
| 298 |
+
return bool(ctx.admin)
|
| 299 |
+
if ctx.admin:
|
| 300 |
+
return True
|
| 301 |
+
owner = str(definition.get('createdBy') or '').strip().lower()
|
| 302 |
+
if owner and owner == str(ctx.uname or '').strip().lower():
|
| 303 |
+
return True
|
| 304 |
+
if definition.get('shared') or definition.get('granted'):
|
| 305 |
+
return _field_share_role(ctx, key, definition) in ('owner', 'edit')
|
| 306 |
+
if not owner:
|
| 307 |
+
return False
|
| 308 |
+
from core import field_permissions as _fp
|
| 309 |
+
perms = _fp.stored_permissions(definition, fallback='collaborative')
|
| 310 |
+
edit = perms.get('edit')
|
| 311 |
+
return edit == 'collaborative' or (
|
| 312 |
+
edit == 'users' and str(ctx.uname).lower() in {
|
| 313 |
+
str(user).lower() for user in (perms.get('users') or [])})
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _may_edit_field_value(ctx, key, definition):
|
| 317 |
+
"""The value-write wall; ordinary source-overlay fields remain collaborative by default."""
|
| 318 |
+
if not isinstance(definition, dict):
|
| 319 |
+
return bool(ctx.admin)
|
| 320 |
+
if ctx.admin:
|
| 321 |
+
return True
|
| 322 |
+
owner = str(definition.get('createdBy') or '').strip().lower()
|
| 323 |
+
if owner and owner == str(ctx.uname or '').strip().lower():
|
| 324 |
+
return True
|
| 325 |
+
if definition.get('shared') or definition.get('granted'):
|
| 326 |
+
return _field_share_role(ctx, key, definition) in ('owner', 'edit')
|
| 327 |
+
from core import field_permissions as _fp
|
| 328 |
+
perms = _fp.stored_permissions(definition, fallback='collaborative')
|
| 329 |
+
edit = perms.get('edit')
|
| 330 |
+
return edit == 'collaborative' or (
|
| 331 |
+
edit == 'users' and str(ctx.uname).lower() in {
|
| 332 |
+
str(user).lower() for user in (perms.get('users') or [])})
|
| 333 |
+
|
| 334 |
+
|
| 335 |
def _cohorts(ctx):
|
| 336 |
"""The COHORT store this ctx operates on β `_tops`'s sibling, and here for the same reason.
|
| 337 |
|
|
|
|
| 1270 |
try:
|
| 1271 |
import core.shared_overlay as _shared_overlay
|
| 1272 |
table_key = str(ctx.table.table_key)
|
| 1273 |
+
shared_storage_key, grant_topic = _field_share_keys(ctx)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1274 |
shared_prior = (_shared_overlay.fields(
|
| 1275 |
shared_storage_key, st=ctx.table.st) or {}).get(key)
|
| 1276 |
shared_field = bool(isinstance(shared_prior, dict)
|
|
|
|
| 1278 |
except Exception:
|
| 1279 |
shared_prior = None
|
| 1280 |
if shared_field:
|
| 1281 |
+
if not _may_edit_field_definition(ctx, key, shared_prior):
|
| 1282 |
+
return _refuse(
|
| 1283 |
+
ctx, 'field_read_only',
|
| 1284 |
+
'you can only change this field if you created it or have Can edit access',
|
| 1285 |
+
key)
|
| 1286 |
# Pin the type/source/key to the stored definition. Only the
|
| 1287 |
# user-authored settings below are allowed to change, so a browser
|
| 1288 |
# cannot turn an image field into another schema type while saving
|
|
|
|
| 1468 |
# admin path and the legacy path are enforceable (and NC'd) right now.
|
| 1469 |
needs_values = False
|
| 1470 |
refused_perms = False
|
| 1471 |
+
permission_sync = False
|
| 1472 |
+
promoted_this_save = False
|
| 1473 |
if shared_field or key.startswith('custom_') or key.startswith(_ag2.MEASURE_FIELD_PREFIX):
|
| 1474 |
prior = shared_prior if shared_field else ws.get('fields', {}).get(key)
|
| 1475 |
prior = prior if isinstance(prior, dict) else None
|
| 1476 |
+
if prior is not None and not shared_field \
|
| 1477 |
+
and not _may_edit_field_definition(ctx, key, prior):
|
| 1478 |
+
return _refuse(
|
| 1479 |
+
ctx, 'field_read_only',
|
| 1480 |
+
'you can only change this field if you created it or have Can edit access',
|
| 1481 |
+
key)
|
| 1482 |
if prior is None and not shared_field:
|
| 1483 |
field['createdBy'] = uname
|
| 1484 |
# Wave-6 item 9: scope is chosen at CREATE, on the cohort page only β
|
|
|
|
| 1502 |
may_change_permissions = bool(admin or (owner and owner.lower() == uname.lower()))
|
| 1503 |
if may_change_permissions:
|
| 1504 |
field['permissions'] = want
|
| 1505 |
+
permission_sync = has_permission_request
|
| 1506 |
else:
|
| 1507 |
field['permissions'] = have
|
| 1508 |
# Asked for a permissions change and did not get it: the client's
|
|
|
|
| 1521 |
and isinstance(value, dict))
|
| 1522 |
]
|
| 1523 |
desired_shared = field.get('permissions', {}).get('edit') in ('collaborative', 'users')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1524 |
if not shared_field and desired_shared and shared_storage_key and ctx.table is not None:
|
| 1525 |
# Promotion makes the permission choice true for the whole tenant, rather than merely
|
| 1526 |
# storing a collaborative label in Anna's private workspace.
|
| 1527 |
field = _fp.promote_field(ctx.table.table_key, shared_storage_key, grant_topic,
|
| 1528 |
field.get('createdBy') or uname, field, st=ctx.table.st)
|
| 1529 |
shared_field = True
|
| 1530 |
+
promoted_this_save = True
|
| 1531 |
if shared_field:
|
| 1532 |
# A shared definition is the source of truth for every tenant
|
| 1533 |
# member. Persisting this through TableStore.save_field would put
|
|
|
|
| 1536 |
import core.shared_overlay as _shared_overlay
|
| 1537 |
field = _shared_overlay.put_field(shared_storage_key, key, field,
|
| 1538 |
st=ctx.table.st)
|
| 1539 |
+
if permission_sync or promoted_this_save:
|
| 1540 |
+
import core.shares as _shares
|
| 1541 |
+
_shares.set_grants('field', _shares.field_oid(grant_topic, key),
|
| 1542 |
+
_fp.grant_entries(field.get('permissions')),
|
| 1543 |
+
owner=field.get('createdBy') or uname, st=ctx.table.st)
|
| 1544 |
elif _store_of(ctx).available():
|
| 1545 |
field = (_tops(ctx).save_field(
|
| 1546 |
uname, field, reserved_names=_reserved_field_names,
|
|
|
|
| 2193 |
if key in (ctx.hidden_keys or frozenset()):
|
| 2194 |
refused = True
|
| 2195 |
continue
|
| 2196 |
+
# Wave-5 item 1 plus Share field precedence: the client hiding its editor is
|
| 2197 |
+
# courtesy, this is the wall. A shared field's registry role overrides the legacy
|
| 2198 |
+
# permissions bag, so Can view remains read-only inside an editable view.
|
|
|
|
| 2199 |
definition = field_by_key.get(key) or {}
|
| 2200 |
+
permitted = _may_edit_field_value(ctx, key, definition)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2201 |
if not permitted:
|
| 2202 |
refused = True
|
| 2203 |
continue
|
platform/core/user_tables.py
CHANGED
|
@@ -2467,9 +2467,11 @@ def clean_machine_fields(fields):
|
|
| 2467 |
return clean, refused
|
| 2468 |
|
| 2469 |
|
| 2470 |
-
def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
|
| 2471 |
-
"""May `viewer` change THIS column's definition? Creator/admin always; others only when the
|
| 2472 |
-
field itself says `editRole: 'everyone'`
|
|
|
|
|
|
|
| 2473 |
table = get(table_key, st) or {}
|
| 2474 |
field = next((f for f in (table.get('fields') or []) if f.get('key') == str(fkey)), None)
|
| 2475 |
# Instagram's pre-set schema is product contract, not tenant configuration. Even an admin
|
|
@@ -2479,12 +2481,21 @@ def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
|
|
| 2479 |
and field['automation'].get('preset') is True \
|
| 2480 |
and not preset_editable(field):
|
| 2481 |
return False
|
| 2482 |
-
if may_open(table_key, viewer, is_admin, st) and (
|
| 2483 |
-
bool(is_admin) or table.get('createdBy') == viewer):
|
| 2484 |
-
return True
|
| 2485 |
-
for f in (table.get('fields') or []):
|
| 2486 |
-
if f.get('key') == str(fkey):
|
| 2487 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2488 |
return False
|
| 2489 |
|
| 2490 |
|
|
|
|
| 2467 |
return clean, refused
|
| 2468 |
|
| 2469 |
|
| 2470 |
+
def may_edit_field(table_key, fkey, viewer, is_admin=False, st=None):
|
| 2471 |
+
"""May `viewer` change THIS column's definition? Creator/admin always; others only when the
|
| 2472 |
+
field itself says `editRole: 'everyone'`, unless Share field has a specific role. The
|
| 2473 |
+
field-specific share role is authoritative for shared fields. Fail-closed on an unknown
|
| 2474 |
+
field."""
|
| 2475 |
table = get(table_key, st) or {}
|
| 2476 |
field = next((f for f in (table.get('fields') or []) if f.get('key') == str(fkey)), None)
|
| 2477 |
# Instagram's pre-set schema is product contract, not tenant configuration. Even an admin
|
|
|
|
| 2481 |
and field['automation'].get('preset') is True \
|
| 2482 |
and not preset_editable(field):
|
| 2483 |
return False
|
| 2484 |
+
if may_open(table_key, viewer, is_admin, st) and (
|
| 2485 |
+
bool(is_admin) or table.get('createdBy') == viewer):
|
| 2486 |
+
return True
|
| 2487 |
+
for f in (table.get('fields') or []):
|
| 2488 |
+
if f.get('key') == str(fkey):
|
| 2489 |
+
try:
|
| 2490 |
+
import core.shares as shares
|
| 2491 |
+
role = shares.role_for(
|
| 2492 |
+
'field', shares.field_oid(table_key, fkey), viewer,
|
| 2493 |
+
is_admin=is_admin, st=st)
|
| 2494 |
+
if role is not None:
|
| 2495 |
+
return role in ('owner', 'edit')
|
| 2496 |
+
except Exception:
|
| 2497 |
+
pass
|
| 2498 |
+
return f.get('editRole') == 'everyone'
|
| 2499 |
return False
|
| 2500 |
|
| 2501 |
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -11,35 +11,19 @@ import { FieldSelectButton } from "./FieldSelect";
|
|
| 11 |
import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames,
|
| 12 |
codeLanguageOf, directionLabel, isDerivedLink, isMachineOwned,
|
| 13 |
isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS,
|
| 14 |
-
ROLLUP_REF_OPS,
|
| 15 |
-
cleanFieldPermissions } from "./types";
|
| 16 |
import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
|
| 17 |
-
RollupRefOp, RollupSource, Viewer
|
| 18 |
import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| 19 |
import type { WindowSpec } from "./windows";
|
| 20 |
import { normalizeWindow, windowLabel } from "./windows";
|
| 21 |
import { WindowPicker } from "../filter-kit";
|
| 22 |
-
import {
|
| 23 |
import { AGG_LABELS, aggOptions } from "./aggregations";
|
| 24 |
import { validateFormula } from "./formulaEngine";
|
| 25 |
import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor,
|
| 26 |
OPTION_PALETTE } from "./choiceColors";
|
| 27 |
|
| 28 |
-
const FIELD_PERM_MARK: Record<FieldEditMode, string[]> = {
|
| 29 |
-
personal: ["M8 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4ZM4.8 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1"],
|
| 30 |
-
collaborative: ["M5.9 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M10.6 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z"],
|
| 31 |
-
users: ["M6 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z", "M2.8 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1", "M10.6 9.4l1.5 1.6 2.6-3"],
|
| 32 |
-
};
|
| 33 |
-
function FieldPermMark({ mode }: { mode: FieldEditMode }) {
|
| 34 |
-
const tone = mode === "personal" ? "neutral" : mode === "collaborative" ? "blue" : "green";
|
| 35 |
-
return <svg className="cg-perm-mark" width={16} height={16} viewBox="0 0 16 16" aria-hidden>
|
| 36 |
-
{FIELD_PERM_MARK[mode].map((d, i) => (
|
| 37 |
-
<path key={i} d={d} fill="none" stroke={FOLDER_TONE_PAINT[tone].stroke}
|
| 38 |
-
strokeWidth={1.35} strokeLinecap="round" strokeLinejoin="round" />
|
| 39 |
-
))}
|
| 40 |
-
</svg>;
|
| 41 |
-
}
|
| 42 |
-
|
| 43 |
export interface ColumnMenuState {
|
| 44 |
fieldKey: string;
|
| 45 |
anchor: AnchorRect;
|
|
@@ -275,9 +259,6 @@ interface ColumnMenuProps {
|
|
| 275 |
/** Wave-5 item 1 β Duplicate field. Supplied for creatable strata only (base Odoo fields
|
| 276 |
* offer no Duplicate β cloning the source of truth into an editable copy is out of scope). */
|
| 277 |
onDuplicate?: () => void;
|
| 278 |
-
/** Wave-5 item 1 β save a permissions change. Supplied iff the VIEWER may change them
|
| 279 |
-
* (isAdmin || createdBy === viewer.name, and only on creatable strata). */
|
| 280 |
-
onPermissions?: (permissions: FieldPermissions) => void;
|
| 281 |
/** Wave-5 item 10 β save a display format. Supplied for number/currency/formula and
|
| 282 |
* date/created_time fields. */
|
| 283 |
onFormat?: (format: FieldFormat) => void;
|
|
@@ -296,9 +277,6 @@ interface ColumnMenuProps {
|
|
| 296 |
/** Assignable people, from the host's real user list. Empty = the host did not supply one,
|
| 297 |
* and "Assignee" is offered without choices rather than with invented ones. */
|
| 298 |
userOptions?: string[];
|
| 299 |
-
/** Account identities used by field permissions. This is deliberately separate from
|
| 300 |
-
* user-valued cell choices, which are display labels rather than durable usernames. */
|
| 301 |
-
permissionUserOptions?: { username: string; name: string }[];
|
| 302 |
/**
|
| 303 |
* CG-8's measures, reused as the vocabulary of a FORMULA-MEASURE column (owner item 7):
|
| 304 |
* `Sales Β· the last 90 days` as a column the user creates. Empty = the semantic store is
|
|
@@ -355,7 +333,7 @@ type CreatePosition = "left" | "right" | "end";
|
|
| 355 |
* choices/stars/formula config), and the Change-field control that swaps the column to show
|
| 356 |
* another field.
|
| 357 |
*/
|
| 358 |
-
type MenuPane = "menu" | "
|
| 359 |
|
| 360 |
/**
|
| 361 |
* How each creatable type reads in the menu. DERIVED from CREATABLE_TYPES rather than listed
|
|
@@ -2048,7 +2026,6 @@ export default function ColumnMenu({
|
|
| 2048 |
onDelete,
|
| 2049 |
onEnrich,
|
| 2050 |
onDuplicate,
|
| 2051 |
-
onPermissions,
|
| 2052 |
onFormat,
|
| 2053 |
onAggregate,
|
| 2054 |
onSort,
|
|
@@ -2058,11 +2035,15 @@ export default function ColumnMenu({
|
|
| 2058 |
onGroupByField,
|
| 2059 |
onClearGroup,
|
| 2060 |
userOptions = [],
|
| 2061 |
-
permissionUserOptions = [],
|
| 2062 |
measures = [],
|
| 2063 |
tableKey,
|
| 2064 |
}: ColumnMenuProps) {
|
| 2065 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2066 |
const [note, setNote] = useState(field.note ?? "");
|
| 2067 |
const [position, setPosition] = useState<CreatePosition | null>(initialPosition ?? null);
|
| 2068 |
/** Delete is DESTRUCTIVE (a custom field's stored values go with it) β first click arms,
|
|
@@ -2172,10 +2153,6 @@ export default function ColumnMenu({
|
|
| 2172 |
* behind an Apply rather than emitted per keystroke: a custom range is typed one date at a
|
| 2173 |
* time, and each emit is a full host round trip that would repaint the column half-ready. */
|
| 2174 |
const [periodDraft, setPeriodDraft] = useState<WindowSpec | null>(null);
|
| 2175 |
-
/** Permissions pane draft. */
|
| 2176 |
-
const initialFieldPermissions = cleanFieldPermissions(field.permissions, "collaborative");
|
| 2177 |
-
const [permDraft, setPermDraft] = useState<FieldEditMode>(initialFieldPermissions.edit);
|
| 2178 |
-
const [permUsers, setPermUsers] = useState<string[]>(initialFieldPermissions.users ?? []);
|
| 2179 |
/** Format pane draft β seeded from the field, saved whole. */
|
| 2180 |
const [fmtDraft, setFmtDraft] = useState<FieldFormat>(() => ({ ...(field.format ?? {}) }));
|
| 2181 |
/** Item 9c β the create forms' Scope draft. 'cohort' is the DEFAULT per the contract. */
|
|
@@ -2560,6 +2537,7 @@ export default function ColumnMenu({
|
|
| 2560 |
// not. Both ride the same PATCH; the label is part of the definition either way.
|
| 2561 |
const editNameDirty =
|
| 2562 |
(!!onRename || !!onFieldConfig) && cleanRename !== "" && cleanRename !== field.label;
|
|
|
|
| 2563 |
const editOptionsChanged =
|
| 2564 |
needsOptions(editType) &&
|
| 2565 |
editOptions.join("\n") !== choiceOptions(field).join("\n");
|
|
@@ -2643,11 +2621,11 @@ export default function ColumnMenu({
|
|
| 2643 |
// is refused by the server, so Save is refused here instead: a 400 arriving after the click
|
| 2644 |
// is the same "named, configured, gone" surprise one layer later.
|
| 2645 |
(editType !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
|
| 2646 |
-
|
| 2647 |
-
|
| 2648 |
-
|
| 2649 |
-
|
| 2650 |
-
|
| 2651 |
/**
|
| 2652 |
* Item 15 β the renames, by ROW IDENTITY. A row that kept its id and changed its label was
|
| 2653 |
* renamed; a row with an id nobody has seen is new; an id that is gone was deleted. Empty
|
|
@@ -2664,7 +2642,7 @@ export default function ColumnMenu({
|
|
| 2664 |
: [];
|
| 2665 |
const applyEdit = () => {
|
| 2666 |
if (!canSaveEdit) return;
|
| 2667 |
-
if (editingRelational && onFieldConfig) {
|
| 2668 |
// β THE WHOLE BAG, EVERY SAVE. `_clean_rollup` is a pure cleaner over what it receives β
|
| 2669 |
// it does not merge with the stored bag β so a partial patch is a REBUILD, and an omitted
|
| 2670 |
// `limit` would silently become "no limit" rather than "unchanged".
|
|
@@ -2675,6 +2653,7 @@ export default function ColumnMenu({
|
|
| 2675 |
: { link: { table: editLinkTable,
|
| 2676 |
...(editLinkSingle ? { single: true } : {}) } }),
|
| 2677 |
});
|
|
|
|
| 2678 |
onClose();
|
| 2679 |
return;
|
| 2680 |
}
|
|
@@ -2740,6 +2719,7 @@ export default function ColumnMenu({
|
|
| 2740 |
// instead of it: renaming "Sales YTD" to "Sales" and re-pointing it at the last 90 days
|
| 2741 |
// is one edit to the user and must not silently drop half of itself.
|
| 2742 |
if (canPeriod && periodChanged && draftWindow) onPeriod!(draftWindow);
|
|
|
|
| 2743 |
onClose();
|
| 2744 |
};
|
| 2745 |
|
|
@@ -3118,7 +3098,7 @@ export default function ColumnMenu({
|
|
| 3118 |
dataKind="column-menu"
|
| 3119 |
>
|
| 3120 |
<PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
|
| 3121 |
-
<div className="cg-column-create">
|
| 3122 |
{onRename || editingRelational ? (
|
| 3123 |
<label>
|
| 3124 |
<span>Name</span>
|
|
@@ -3145,6 +3125,25 @@ export default function ColumnMenu({
|
|
| 3145 |
</span>
|
| 3146 |
</label>
|
| 3147 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3148 |
{/* C5-AUTOFIELD β an EXISTING automation column edits its CONFIG, not its type.
|
| 3149 |
β This guard is load-bearing, not tidiness. `automation` is excluded from
|
| 3150 |
RETYPE_TYPES (see its note), so rendering the picker for one would show a
|
|
@@ -3570,140 +3569,6 @@ export default function ColumnMenu({
|
|
| 3570 |
);
|
| 3571 |
}
|
| 3572 |
|
| 3573 |
-
// ------------------------------------------------------------------ NOTE pane (item 2)
|
| 3574 |
-
// "Edit field description" edits the USER's note β the canonical `description` is the
|
| 3575 |
-
// contract's default and is shown as context, never written from here.
|
| 3576 |
-
if (pane === "note") {
|
| 3577 |
-
return (
|
| 3578 |
-
<AnchoredOverlay
|
| 3579 |
-
anchor={state.anchor}
|
| 3580 |
-
className="cg-column-menu"
|
| 3581 |
-
onDismiss={onClose}
|
| 3582 |
-
role="dialog"
|
| 3583 |
-
ariaLabel={`Edit description for ${field.label}`}
|
| 3584 |
-
initialFocus="[data-overlay-autofocus]"
|
| 3585 |
-
dataKind="column-menu"
|
| 3586 |
-
>
|
| 3587 |
-
<PaneHead title="Edit field description" sub={field.label} onClose={onClose} />
|
| 3588 |
-
<div className="cg-column-create">
|
| 3589 |
-
{field.description && (
|
| 3590 |
-
<div className="cg-field-hint cg-desc-default">
|
| 3591 |
-
Default description: {field.description}
|
| 3592 |
-
</div>
|
| 3593 |
-
)}
|
| 3594 |
-
<label>
|
| 3595 |
-
<span>Your description</span>
|
| 3596 |
-
<textarea
|
| 3597 |
-
className="cg-input"
|
| 3598 |
-
data-overlay-autofocus
|
| 3599 |
-
autoFocus
|
| 3600 |
-
rows={4}
|
| 3601 |
-
value={note}
|
| 3602 |
-
placeholder={
|
| 3603 |
-
field.description
|
| 3604 |
-
? "Write your own to replace the defaultβ¦"
|
| 3605 |
-
: "Explain how this field should be usedβ¦"
|
| 3606 |
-
}
|
| 3607 |
-
onChange={(event) => setNote(event.target.value)}
|
| 3608 |
-
/>
|
| 3609 |
-
</label>
|
| 3610 |
-
<div className="cg-form-actions">
|
| 3611 |
-
<button
|
| 3612 |
-
type="button"
|
| 3613 |
-
className="cg-btn cg-btn--primary"
|
| 3614 |
-
disabled={note === (field.note ?? "")}
|
| 3615 |
-
onClick={() => {
|
| 3616 |
-
onNote(note);
|
| 3617 |
-
onClose();
|
| 3618 |
-
}}
|
| 3619 |
-
>
|
| 3620 |
-
Save
|
| 3621 |
-
</button>
|
| 3622 |
-
<button type="button" className="cg-btn" onClick={() => setPane("menu")}>
|
| 3623 |
-
Back
|
| 3624 |
-
</button>
|
| 3625 |
-
</div>
|
| 3626 |
-
</div>
|
| 3627 |
-
</AnchoredOverlay>
|
| 3628 |
-
);
|
| 3629 |
-
}
|
| 3630 |
-
|
| 3631 |
-
// ------------------------------------------------------------- PERMISSIONS pane (item 1)
|
| 3632 |
-
if (pane === "permissions") {
|
| 3633 |
-
return (
|
| 3634 |
-
<AnchoredOverlay
|
| 3635 |
-
anchor={state.anchor}
|
| 3636 |
-
className="cg-column-menu"
|
| 3637 |
-
onDismiss={onClose}
|
| 3638 |
-
role="dialog"
|
| 3639 |
-
ariaLabel={`Edit permissions for ${field.label}`}
|
| 3640 |
-
initialFocus="[data-overlay-autofocus]"
|
| 3641 |
-
dataKind="column-menu"
|
| 3642 |
-
>
|
| 3643 |
-
<PaneHead title="Edit field permissions" sub={field.label} onClose={onClose} />
|
| 3644 |
-
<div className="cg-column-create">
|
| 3645 |
-
<div className="cg-perm" role="radiogroup" aria-label="Who can edit this field">
|
| 3646 |
-
<span className="cg-perm-title">Who can edit</span>
|
| 3647 |
-
{FIELD_EDIT_MODES.map((mode) => (
|
| 3648 |
-
<label key={mode} className="cg-radio-row cg-perm-row">
|
| 3649 |
-
<input
|
| 3650 |
-
type="radio"
|
| 3651 |
-
name="cg-field-perm"
|
| 3652 |
-
data-overlay-autofocus={mode === "personal" ? true : undefined}
|
| 3653 |
-
checked={permDraft === mode}
|
| 3654 |
-
onChange={() => setPermDraft(mode)}
|
| 3655 |
-
/>
|
| 3656 |
-
<FieldPermMark mode={mode} />
|
| 3657 |
-
<span className="cg-perm-label">
|
| 3658 |
-
{FIELD_EDIT_LABELS[mode]}
|
| 3659 |
-
<span className="cg-perm-blurb">{FIELD_EDIT_BLURBS[mode]}</span>
|
| 3660 |
-
</span>
|
| 3661 |
-
</label>
|
| 3662 |
-
))}
|
| 3663 |
-
{permDraft === "users" && (
|
| 3664 |
-
<div className="cg-perm-users">
|
| 3665 |
-
{permissionUserOptions.length === 0 ? (
|
| 3666 |
-
<span className="cg-perm-empty">No other accounts to pick. This will save as Personal.</span>
|
| 3667 |
-
) : permissionUserOptions.map((person) => (
|
| 3668 |
-
<label key={person.username} className="cg-perm-user">
|
| 3669 |
-
<input
|
| 3670 |
-
type="checkbox"
|
| 3671 |
-
checked={permUsers.includes(person.username.toLowerCase())}
|
| 3672 |
-
onChange={(event) => setPermUsers((current) => event.target.checked
|
| 3673 |
-
? [...current, person.username.toLowerCase()].slice(0, 50)
|
| 3674 |
-
: current.filter((user) => user !== person.username.toLowerCase()))}
|
| 3675 |
-
/>
|
| 3676 |
-
<span>{person.name || person.username}</span>
|
| 3677 |
-
</label>
|
| 3678 |
-
))}
|
| 3679 |
-
{permissionUserOptions.length > 0 && permUsers.length === 0 && (
|
| 3680 |
-
<span className="cg-perm-empty">Pick at least one person, or this saves as Personal.</span>
|
| 3681 |
-
)}
|
| 3682 |
-
</div>
|
| 3683 |
-
)}
|
| 3684 |
-
</div>
|
| 3685 |
-
<div className="cg-form-actions">
|
| 3686 |
-
<button
|
| 3687 |
-
type="button"
|
| 3688 |
-
className="cg-btn cg-btn--primary"
|
| 3689 |
-
disabled={JSON.stringify({ edit: permDraft, users: permDraft === "users" ? permUsers : undefined }) ===
|
| 3690 |
-
JSON.stringify(cleanFieldPermissions(field.permissions, "collaborative"))}
|
| 3691 |
-
onClick={() => {
|
| 3692 |
-
onPermissions?.(cleanFieldPermissions({ edit: permDraft, users: permUsers }, "personal"));
|
| 3693 |
-
onClose();
|
| 3694 |
-
}}
|
| 3695 |
-
>
|
| 3696 |
-
Save
|
| 3697 |
-
</button>
|
| 3698 |
-
<button type="button" className="cg-btn" onClick={() => setPane("menu")}>
|
| 3699 |
-
Back
|
| 3700 |
-
</button>
|
| 3701 |
-
</div>
|
| 3702 |
-
</div>
|
| 3703 |
-
</AnchoredOverlay>
|
| 3704 |
-
);
|
| 3705 |
-
}
|
| 3706 |
-
|
| 3707 |
// ------------------------------------------------------------------ FORMAT pane (item 10)
|
| 3708 |
/**
|
| 3709 |
* ββ WAVE-29 T33 (owner item 17) β THE COLUMN SUMMARY PANE.
|
|
@@ -3875,11 +3740,6 @@ export default function ColumnMenu({
|
|
| 3875 |
// ------------------------------------------------------------------ the MENU pane
|
| 3876 |
// Wave-5 items 1/2/3: a flat action list. Editors open their OWN windows (the panes above);
|
| 3877 |
// nothing edits inline here. Conditional entries render IFF they apply to the current view.
|
| 3878 |
-
const canPermissions =
|
| 3879 |
-
!!onPermissions &&
|
| 3880 |
-
!!viewer &&
|
| 3881 |
-
(viewer.isAdmin || (field.createdBy != null && field.createdBy === viewer.name));
|
| 3882 |
-
|
| 3883 |
/**
|
| 3884 |
* ββ W39-T17 β THE SHARE ID FOR THIS COLUMN, or `null` if one cannot honestly be built.
|
| 3885 |
*
|
|
@@ -3922,7 +3782,7 @@ export default function ColumnMenu({
|
|
| 3922 |
|
| 3923 |
/**
|
| 3924 |
* ββ W39-T17 / R8 β MAY THIS VIEWER DELETE THIS COLUMN? The client half of the server's wall,
|
| 3925 |
-
* spelled with the
|
| 3926 |
* one: `createdBy` is a login username while the people picker deals in display names
|
| 3927 |
* ([[login-resolves-email-writes-do-not]]), and one spelling means a mismatch lands identically
|
| 3928 |
* in both places instead of differently in each.
|
|
@@ -3966,7 +3826,7 @@ export default function ColumnMenu({
|
|
| 3966 |
is one line that STATES the period and opens that pane, because the window is the
|
| 3967 |
first thing you want to know about a metric column and the menu had become the only
|
| 3968 |
place it was written down. A row, not a control: the menu pane edits nothing inline. */}
|
| 3969 |
-
{canPeriod && !schemaLocked && (
|
| 3970 |
<button
|
| 3971 |
type="button"
|
| 3972 |
className="cg-column-periodline"
|
|
@@ -3988,7 +3848,7 @@ export default function ColumnMenu({
|
|
| 3988 |
the one window for the field's name, type (with per-type choices/stars/formula)
|
| 3989 |
and the Change-field control. Offered on EVERY field β what a read-only field
|
| 3990 |
cannot change, the pane says honestly instead of hiding the door. */}
|
| 3991 |
-
{
|
| 3992 |
<button type="button" data-overlay-autofocus onClick={() => {
|
| 3993 |
setRenameDraft(field.label);
|
| 3994 |
setPane("edit");
|
|
@@ -4018,12 +3878,11 @@ export default function ColumnMenu({
|
|
| 4018 |
}}
|
| 4019 |
>
|
| 4020 |
{/* β AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
|
| 4021 |
-
|
| 4022 |
-
|
| 4023 |
-
|
| 4024 |
-
|
| 4025 |
-
|
| 4026 |
-
glyphs on two different questions. */}
|
| 4027 |
<MenuLabel
|
| 4028 |
icon={
|
| 4029 |
<svg width={16} height={16} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
@@ -4063,19 +3922,8 @@ export default function ColumnMenu({
|
|
| 4063 |
|
| 4064 |
<div className="cg-menu-sep" role="separator" aria-hidden />
|
| 4065 |
|
| 4066 |
-
{/* Group 2 β
|
| 4067 |
-
|
| 4068 |
-
rows are gone, not duplicated. */}
|
| 4069 |
-
{!schemaLocked && (
|
| 4070 |
-
<button type="button" onClick={() => setPane("note")}>
|
| 4071 |
-
<MenuLabel icon="description" text="Edit field description" />
|
| 4072 |
-
</button>
|
| 4073 |
-
)}
|
| 4074 |
-
{!schemaLocked && canPermissions && (
|
| 4075 |
-
<button type="button" onClick={() => setPane("permissions")}>
|
| 4076 |
-
<MenuLabel icon="permissions" text="Edit field permissions" />
|
| 4077 |
-
</button>
|
| 4078 |
-
)}
|
| 4079 |
{!schemaLocked && formatKind && (
|
| 4080 |
<button type="button" onClick={() => setPane("format")}>
|
| 4081 |
<MenuLabel icon="format" text="Field format" />
|
|
|
|
| 11 |
import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames,
|
| 12 |
codeLanguageOf, directionLabel, isDerivedLink, isMachineOwned,
|
| 13 |
isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS,
|
| 14 |
+
ROLLUP_REF_OPS, mayEditFieldDefinition } from "./types";
|
|
|
|
| 15 |
import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
|
| 16 |
+
RollupRefOp, RollupSource, Viewer } from "./types";
|
| 17 |
import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| 18 |
import type { WindowSpec } from "./windows";
|
| 19 |
import { normalizeWindow, windowLabel } from "./windows";
|
| 20 |
import { WindowPicker } from "../filter-kit";
|
| 21 |
+
import { TYPE_LABELS } from "./iconShapes";
|
| 22 |
import { AGG_LABELS, aggOptions } from "./aggregations";
|
| 23 |
import { validateFormula } from "./formulaEngine";
|
| 24 |
import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor,
|
| 25 |
OPTION_PALETTE } from "./choiceColors";
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
export interface ColumnMenuState {
|
| 28 |
fieldKey: string;
|
| 29 |
anchor: AnchorRect;
|
|
|
|
| 259 |
/** Wave-5 item 1 β Duplicate field. Supplied for creatable strata only (base Odoo fields
|
| 260 |
* offer no Duplicate β cloning the source of truth into an editable copy is out of scope). */
|
| 261 |
onDuplicate?: () => void;
|
|
|
|
|
|
|
|
|
|
| 262 |
/** Wave-5 item 10 β save a display format. Supplied for number/currency/formula and
|
| 263 |
* date/created_time fields. */
|
| 264 |
onFormat?: (format: FieldFormat) => void;
|
|
|
|
| 277 |
/** Assignable people, from the host's real user list. Empty = the host did not supply one,
|
| 278 |
* and "Assignee" is offered without choices rather than with invented ones. */
|
| 279 |
userOptions?: string[];
|
|
|
|
|
|
|
|
|
|
| 280 |
/**
|
| 281 |
* CG-8's measures, reused as the vocabulary of a FORMULA-MEASURE column (owner item 7):
|
| 282 |
* `Sales Β· the last 90 days` as a column the user creates. Empty = the semantic store is
|
|
|
|
| 333 |
* choices/stars/formula config), and the Change-field control that swaps the column to show
|
| 334 |
* another field.
|
| 335 |
*/
|
| 336 |
+
type MenuPane = "menu" | "edit" | "format" | "summary";
|
| 337 |
|
| 338 |
/**
|
| 339 |
* How each creatable type reads in the menu. DERIVED from CREATABLE_TYPES rather than listed
|
|
|
|
| 2026 |
onDelete,
|
| 2027 |
onEnrich,
|
| 2028 |
onDuplicate,
|
|
|
|
| 2029 |
onFormat,
|
| 2030 |
onAggregate,
|
| 2031 |
onSort,
|
|
|
|
| 2035 |
onGroupByField,
|
| 2036 |
onClearGroup,
|
| 2037 |
userOptions = [],
|
|
|
|
| 2038 |
measures = [],
|
| 2039 |
tableKey,
|
| 2040 |
}: ColumnMenuProps) {
|
| 2041 |
+
const canEditDefinition = !schemaLocked && mayEditFieldDefinition(field, viewer);
|
| 2042 |
+
const [pane, setPane] = useState<MenuPane>(
|
| 2043 |
+
schemaLocked || (initialPane === "edit" && !canEditDefinition)
|
| 2044 |
+
? "menu"
|
| 2045 |
+
: initialPane ?? "menu"
|
| 2046 |
+
);
|
| 2047 |
const [note, setNote] = useState(field.note ?? "");
|
| 2048 |
const [position, setPosition] = useState<CreatePosition | null>(initialPosition ?? null);
|
| 2049 |
/** Delete is DESTRUCTIVE (a custom field's stored values go with it) β first click arms,
|
|
|
|
| 2153 |
* behind an Apply rather than emitted per keystroke: a custom range is typed one date at a
|
| 2154 |
* time, and each emit is a full host round trip that would repaint the column half-ready. */
|
| 2155 |
const [periodDraft, setPeriodDraft] = useState<WindowSpec | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2156 |
/** Format pane draft β seeded from the field, saved whole. */
|
| 2157 |
const [fmtDraft, setFmtDraft] = useState<FieldFormat>(() => ({ ...(field.format ?? {}) }));
|
| 2158 |
/** Item 9c β the create forms' Scope draft. 'cohort' is the DEFAULT per the contract. */
|
|
|
|
| 2537 |
// not. Both ride the same PATCH; the label is part of the definition either way.
|
| 2538 |
const editNameDirty =
|
| 2539 |
(!!onRename || !!onFieldConfig) && cleanRename !== "" && cleanRename !== field.label;
|
| 2540 |
+
const editNoteDirty = note !== (field.note ?? "");
|
| 2541 |
const editOptionsChanged =
|
| 2542 |
needsOptions(editType) &&
|
| 2543 |
editOptions.join("\n") !== choiceOptions(field).join("\n");
|
|
|
|
| 2621 |
// is refused by the server, so Save is refused here instead: a 400 arriving after the click
|
| 2622 |
// is the same "named, configured, gone" surprise one layer later.
|
| 2623 |
(editType !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
|
| 2624 |
+
(editingRelational
|
| 2625 |
+
? (editRollupValid && (editRelationalTouched || editNameDirty || editNoteDirty) &&
|
| 2626 |
+
(field.type !== "link" || editLinkTable !== ""))
|
| 2627 |
+
: (editNameDirty || editRetypeTouched || editFormulaTouched ||
|
| 2628 |
+
(canPeriod && periodChanged) || editNoteDirty));
|
| 2629 |
/**
|
| 2630 |
* Item 15 β the renames, by ROW IDENTITY. A row that kept its id and changed its label was
|
| 2631 |
* renamed; a row with an id nobody has seen is new; an id that is gone was deleted. Empty
|
|
|
|
| 2642 |
: [];
|
| 2643 |
const applyEdit = () => {
|
| 2644 |
if (!canSaveEdit) return;
|
| 2645 |
+
if (editingRelational && onFieldConfig && (editRelationalTouched || editNameDirty)) {
|
| 2646 |
// β THE WHOLE BAG, EVERY SAVE. `_clean_rollup` is a pure cleaner over what it receives β
|
| 2647 |
// it does not merge with the stored bag β so a partial patch is a REBUILD, and an omitted
|
| 2648 |
// `limit` would silently become "no limit" rather than "unchanged".
|
|
|
|
| 2653 |
: { link: { table: editLinkTable,
|
| 2654 |
...(editLinkSingle ? { single: true } : {}) } }),
|
| 2655 |
});
|
| 2656 |
+
if (editNoteDirty) onNote(note);
|
| 2657 |
onClose();
|
| 2658 |
return;
|
| 2659 |
}
|
|
|
|
| 2719 |
// instead of it: renaming "Sales YTD" to "Sales" and re-pointing it at the last 90 days
|
| 2720 |
// is one edit to the user and must not silently drop half of itself.
|
| 2721 |
if (canPeriod && periodChanged && draftWindow) onPeriod!(draftWindow);
|
| 2722 |
+
if (editNoteDirty) onNote(note);
|
| 2723 |
onClose();
|
| 2724 |
};
|
| 2725 |
|
|
|
|
| 3098 |
dataKind="column-menu"
|
| 3099 |
>
|
| 3100 |
<PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
|
| 3101 |
+
<div className="cg-column-create cg-field-edit">
|
| 3102 |
{onRename || editingRelational ? (
|
| 3103 |
<label>
|
| 3104 |
<span>Name</span>
|
|
|
|
| 3125 |
</span>
|
| 3126 |
</label>
|
| 3127 |
)}
|
| 3128 |
+
<label className="cg-edit-description">
|
| 3129 |
+
<span>Description</span>
|
| 3130 |
+
{field.description && (
|
| 3131 |
+
<span className="cg-field-hint cg-desc-default">
|
| 3132 |
+
Default description: {field.description}
|
| 3133 |
+
</span>
|
| 3134 |
+
)}
|
| 3135 |
+
<textarea
|
| 3136 |
+
className="cg-input"
|
| 3137 |
+
rows={3}
|
| 3138 |
+
value={note}
|
| 3139 |
+
placeholder={
|
| 3140 |
+
field.description
|
| 3141 |
+
? "Write your own to replace the defaultβ¦"
|
| 3142 |
+
: "Explain how this field should be usedβ¦"
|
| 3143 |
+
}
|
| 3144 |
+
onChange={(event) => setNote(event.target.value)}
|
| 3145 |
+
/>
|
| 3146 |
+
</label>
|
| 3147 |
{/* C5-AUTOFIELD β an EXISTING automation column edits its CONFIG, not its type.
|
| 3148 |
β This guard is load-bearing, not tidiness. `automation` is excluded from
|
| 3149 |
RETYPE_TYPES (see its note), so rendering the picker for one would show a
|
|
|
|
| 3569 |
);
|
| 3570 |
}
|
| 3571 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3572 |
// ------------------------------------------------------------------ FORMAT pane (item 10)
|
| 3573 |
/**
|
| 3574 |
* ββ WAVE-29 T33 (owner item 17) β THE COLUMN SUMMARY PANE.
|
|
|
|
| 3740 |
// ------------------------------------------------------------------ the MENU pane
|
| 3741 |
// Wave-5 items 1/2/3: a flat action list. Editors open their OWN windows (the panes above);
|
| 3742 |
// nothing edits inline here. Conditional entries render IFF they apply to the current view.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3743 |
/**
|
| 3744 |
* ββ W39-T17 β THE SHARE ID FOR THIS COLUMN, or `null` if one cannot honestly be built.
|
| 3745 |
*
|
|
|
|
| 3782 |
|
| 3783 |
/**
|
| 3784 |
* ββ W39-T17 / R8 β MAY THIS VIEWER DELETE THIS COLUMN? The client half of the server's wall,
|
| 3785 |
+
* spelled with the same creator/admin identity rule as the server rather than a second
|
| 3786 |
* one: `createdBy` is a login username while the people picker deals in display names
|
| 3787 |
* ([[login-resolves-email-writes-do-not]]), and one spelling means a mismatch lands identically
|
| 3788 |
* in both places instead of differently in each.
|
|
|
|
| 3826 |
is one line that STATES the period and opens that pane, because the window is the
|
| 3827 |
first thing you want to know about a metric column and the menu had become the only
|
| 3828 |
place it was written down. A row, not a control: the menu pane edits nothing inline. */}
|
| 3829 |
+
{canPeriod && canEditDefinition && !schemaLocked && (
|
| 3830 |
<button
|
| 3831 |
type="button"
|
| 3832 |
className="cg-column-periodline"
|
|
|
|
| 3848 |
the one window for the field's name, type (with per-type choices/stars/formula)
|
| 3849 |
and the Change-field control. Offered on EVERY field β what a read-only field
|
| 3850 |
cannot change, the pane says honestly instead of hiding the door. */}
|
| 3851 |
+
{canEditDefinition && (
|
| 3852 |
<button type="button" data-overlay-autofocus onClick={() => {
|
| 3853 |
setRenameDraft(field.label);
|
| 3854 |
setPane("edit");
|
|
|
|
| 3878 |
}}
|
| 3879 |
>
|
| 3880 |
{/* β AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
|
| 3881 |
+
`MenuLabel`'s prop is `MenuIconName | ReactNode` and `ReactNode` admits any
|
| 3882 |
+
string, so `icon="share"` would COMPILE and paint an empty 16px box forever
|
| 3883 |
+
(`MENU_ICONS` has no such key, and `icons.tsx` is not this ticket's file). The
|
| 3884 |
+
three-node graph is the mark every product uses for this verb; it keeps sharing
|
| 3885 |
+
visually distinct from field editing. */}
|
|
|
|
| 3886 |
<MenuLabel
|
| 3887 |
icon={
|
| 3888 |
<svg width={16} height={16} viewBox="0 0 16 16" fill="none" aria-hidden>
|
|
|
|
| 3922 |
|
| 3923 |
<div className="cg-menu-sep" role="separator" aria-hidden />
|
| 3924 |
|
| 3925 |
+
{/* Group 2 β display format. Description now lives in Edit field directly below Name,
|
| 3926 |
+
so there is one definition editor instead of two nearby doors. */}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3927 |
{!schemaLocked && formatKind && (
|
| 3928 |
<button type="button" onClick={() => setPane("format")}>
|
| 3929 |
<MenuLabel icon="format" text="Field format" />
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -6739,7 +6739,6 @@ function CustomerGridSurface({
|
|
| 6739 |
viewer={viewer}
|
| 6740 |
onToggleLock={toggleViewLock}
|
| 6741 |
onToggleImportant={toggleViewImportant}
|
| 6742 |
-
userOptions={payload?.userOptions}
|
| 6743 |
/>
|
| 6744 |
)}
|
| 6745 |
{/* ββ W37-T29 / CONTRACT C6 (rulings R2, R10) β E's CHAT, DOCKED AND OPENABLE.
|
|
@@ -7828,12 +7827,6 @@ function CustomerGridSurface({
|
|
| 7828 |
// Creatable strata only β a base Odoo field offers no Duplicate (contract).
|
| 7829 |
menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
|
| 7830 |
}
|
| 7831 |
-
onPermissions={
|
| 7832 |
-
// Creatable strata only; ColumnMenu further gates on the viewer (creator/admin).
|
| 7833 |
-
menuField.custom
|
| 7834 |
-
? (permissions) => saveField({ ...menuField, permissions })
|
| 7835 |
-
: undefined
|
| 7836 |
-
}
|
| 7837 |
onFormat={(format) => saveField({ ...menuField, format })}
|
| 7838 |
/**
|
| 7839 |
* β WAVE-29 T33 (owner item 17) β the column summary, through whichever door owns this
|
|
@@ -7886,23 +7879,20 @@ function CustomerGridSurface({
|
|
| 7886 |
onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
|
| 7887 |
onClearGroup={() => updateConfig({ ...config, groupBy: null })}
|
| 7888 |
userOptions={payload?.userOptions ?? []}
|
| 7889 |
-
permissionUserOptions={payload?.permissionUserOptions ?? []}
|
| 7890 |
measures={measures}
|
| 7891 |
-
|
| 7892 |
-
|
| 7893 |
-
|
| 7894 |
-
|
| 7895 |
-
|
| 7896 |
-
|
| 7897 |
-
|
| 7898 |
-
|
| 7899 |
-
|
| 7900 |
-
|
| 7901 |
-
|
| 7902 |
-
|
| 7903 |
-
|
| 7904 |
-
unlike `linkTargets`, which both create paths genuinely need. */
|
| 7905 |
-
tableKey={isQueryPreview ? undefined : payload?.workspace?.storageKey}
|
| 7906 |
/>
|
| 7907 |
)}
|
| 7908 |
|
|
|
|
| 6739 |
viewer={viewer}
|
| 6740 |
onToggleLock={toggleViewLock}
|
| 6741 |
onToggleImportant={toggleViewImportant}
|
|
|
|
| 6742 |
/>
|
| 6743 |
)}
|
| 6744 |
{/* ββ W37-T29 / CONTRACT C6 (rulings R2, R10) β E's CHAT, DOCKED AND OPENABLE.
|
|
|
|
| 7827 |
// Creatable strata only β a base Odoo field offers no Duplicate (contract).
|
| 7828 |
menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
|
| 7829 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7830 |
onFormat={(format) => saveField({ ...menuField, format })}
|
| 7831 |
/**
|
| 7832 |
* β WAVE-29 T33 (owner item 17) β the column summary, through whichever door owns this
|
|
|
|
| 7879 |
onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
|
| 7880 |
onClearGroup={() => updateConfig({ ...config, groupBy: null })}
|
| 7881 |
userOptions={payload?.userOptions ?? []}
|
|
|
|
| 7882 |
measures={measures}
|
| 7883 |
+
/* ββ W39-T17 β field shares use the registry's canonical topic key, not the
|
| 7884 |
+
per-user browser storage key carried by `workspace.storageKey`. These are the same
|
| 7885 |
+
server-issued names used by the field-grant wall and the shared overlay write path.
|
| 7886 |
+
Query previews have no durable field share target, so the action is omitted. */
|
| 7887 |
+
tableKey={
|
| 7888 |
+
isQueryPreview
|
| 7889 |
+
? undefined
|
| 7890 |
+
: scope === "product"
|
| 7891 |
+
? "product_data"
|
| 7892 |
+
: scope?.startsWith("ut_")
|
| 7893 |
+
? scope
|
| 7894 |
+
: "customer_data"
|
| 7895 |
+
}
|
|
|
|
|
|
|
| 7896 |
/>
|
| 7897 |
)}
|
| 7898 |
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
@@ -9,7 +9,6 @@ import type {
|
|
| 9 |
FolderIcon,
|
| 10 |
GridFolder,
|
| 11 |
SavedView,
|
| 12 |
-
ViewEditMode,
|
| 13 |
ViewPermissions,
|
| 14 |
Viewer,
|
| 15 |
} from "./types";
|
|
@@ -18,12 +17,7 @@ import {
|
|
| 18 |
DEFAULT_FOLDER_TONE,
|
| 19 |
FOLDER_SHAPES,
|
| 20 |
FOLDER_TONES,
|
| 21 |
-
MAX_VIEW_USERS,
|
| 22 |
-
VIEW_EDIT_BLURBS,
|
| 23 |
-
VIEW_EDIT_LABELS,
|
| 24 |
-
VIEW_EDIT_MODES,
|
| 25 |
cleanFolderIcon,
|
| 26 |
-
cleanViewPermissions,
|
| 27 |
isModeFrozen,
|
| 28 |
isUndeletableView,
|
| 29 |
mayEditView,
|
|
@@ -37,7 +31,6 @@ import {
|
|
| 37 |
MODE_LABELS,
|
| 38 |
MODE_TONE,
|
| 39 |
} from "./iconShapes";
|
| 40 |
-
import { FOLDER_TONE_PAINT } from "./iconShapes";
|
| 41 |
import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
|
| 42 |
import { ROOT_FOLDER_ID } from "./folders";
|
| 43 |
// β W32-T23 (owner item 17) β THE BELL, from the shared layer rather than redrawn here.
|
|
@@ -59,12 +52,10 @@ import {
|
|
| 59 |
} from "./folders";
|
| 60 |
|
| 61 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 62 |
-
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
part you recognise on the second visit, and Airtable's own share step leads every row with
|
| 67 |
-
one.
|
| 68 |
|
| 69 |
β WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it
|
| 70 |
back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also
|
|
@@ -80,46 +71,8 @@ import {
|
|
| 80 |
never a literal, so these can never be the one place in the product with its own colours.
|
| 81 |
Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only
|
| 82 |
the linings". */
|
| 83 |
-
const PERM_TONE: Record<ViewEditMode, "neutral" | "blue" | "green"> = {
|
| 84 |
-
personal: "neutral",
|
| 85 |
-
collaborative: "blue",
|
| 86 |
-
users: "green",
|
| 87 |
-
};
|
| 88 |
-
|
| 89 |
/** head + shoulders, at `x`. One drawing, three uses β a second hand-drawn person is how two
|
| 90 |
* marks in one panel end up different heights. */
|
| 91 |
-
const person = (x: number) =>
|
| 92 |
-
`M${x} 7.4a2 2 0 1 0 0-4 2 2 0 0 0 0 4Z` +
|
| 93 |
-
`M${x - 3.2} 12.6c0-1.9 1.4-3.1 3.2-3.1s3.2 1.2 3.2 3.1`;
|
| 94 |
-
|
| 95 |
-
const PERM_MARK: Record<ViewEditMode, string[]> = {
|
| 96 |
-
// One person: only you.
|
| 97 |
-
personal: [person(8)],
|
| 98 |
-
// Two, side by side and overlapping: anyone who can see the table.
|
| 99 |
-
collaborative: [person(5.9), person(10.6)],
|
| 100 |
-
// One person and a tick: the people you picked.
|
| 101 |
-
users: [person(6), "M10.6 9.4l1.5 1.6 2.6-3"],
|
| 102 |
-
};
|
| 103 |
-
|
| 104 |
-
function PermMark({ mode }: { mode: ViewEditMode }) {
|
| 105 |
-
const paint = FOLDER_TONE_PAINT[PERM_TONE[mode]];
|
| 106 |
-
return (
|
| 107 |
-
<svg className="cg-perm-mark" width={16} height={16} viewBox="0 0 16 16" aria-hidden>
|
| 108 |
-
{PERM_MARK[mode].map((d, i) => (
|
| 109 |
-
<path
|
| 110 |
-
key={i}
|
| 111 |
-
d={d}
|
| 112 |
-
fill="none"
|
| 113 |
-
stroke={paint.stroke}
|
| 114 |
-
strokeWidth={1.35}
|
| 115 |
-
strokeLinecap="round"
|
| 116 |
-
strokeLinejoin="round"
|
| 117 |
-
/>
|
| 118 |
-
))}
|
| 119 |
-
</svg>
|
| 120 |
-
);
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
/* WAVE 20 item 18 (C-SHARE) β the system folder every view shared WITH you lands in until
|
| 124 |
you file it somewhere of your own. It is the only folder id this component recognises by
|
| 125 |
name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that
|
|
@@ -131,9 +84,7 @@ interface ViewSidebarProps {
|
|
| 131 |
activeViewId: string;
|
| 132 |
saveState: "saved" | "saving";
|
| 133 |
onSelect: (id: string) => void;
|
| 134 |
-
/**
|
| 135 |
-
* creation carries both. `permissions` is always explicit (C4: absent on create means
|
| 136 |
-
* personal, so an omission would silently contradict the form). */
|
| 137 |
onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
|
| 138 |
/**
|
| 139 |
* ββ W37-T41 β may this surface hold a CUSTOM VIEW? The row is dropped when it cannot.
|
|
@@ -284,10 +235,6 @@ interface ViewSidebarProps {
|
|
| 284 |
surface. They carried the transient-lock selection for the retired Cohorts section; a
|
| 285 |
locked view is now an ordinary member of `views` and is selected by `onSelect` like any
|
| 286 |
other, which is the whole point of R1. */
|
| 287 |
-
/** I17 (C4) β the tenant's real account list, for the "Specific users" picker. The HOST
|
| 288 |
-
* re-validates every name against core/users.py and drops what it cannot resolve, so this
|
| 289 |
-
* list is a convenience, never the authority. */
|
| 290 |
-
userOptions?: string[];
|
| 291 |
/**
|
| 292 |
* ββ W36-T05 (owner item 6 Β· R3 Β· R14) β THE VIEW AGENT, to the LEFT of this rail.
|
| 293 |
*
|
|
@@ -358,7 +305,6 @@ export default function ViewSidebar({
|
|
| 358 |
viewer,
|
| 359 |
onToggleLock,
|
| 360 |
onToggleImportant,
|
| 361 |
-
userOptions = [],
|
| 362 |
}: ViewSidebarProps) {
|
| 363 |
/**
|
| 364 |
* I14 β creating a view now always knows WHICH KIND of view is being created, because the
|
|
@@ -368,10 +314,6 @@ export default function ViewSidebar({
|
|
| 368 |
/** I14 β the "+ Create newβ¦" flyout's anchor. */
|
| 369 |
const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
|
| 370 |
const [name, setName] = useState("");
|
| 371 |
-
/** I17 (C4) β the create prompt's who-can-edit draft. Seeded PERSONAL, matching the host's
|
| 372 |
-
* create-side default, so the form and the store agree before the user touches anything. */
|
| 373 |
-
const [permEdit, setPermEdit] = useState<ViewEditMode>("personal");
|
| 374 |
-
const [permUsers, setPermUsers] = useState<string[]>([]);
|
| 375 |
const [menu, setMenu] = useState<{
|
| 376 |
viewId: string;
|
| 377 |
anchor: HTMLButtonElement;
|
|
@@ -686,16 +628,10 @@ export default function ViewSidebar({
|
|
| 686 |
const create = () => {
|
| 687 |
const value = name.trim();
|
| 688 |
if (!value || !creating) return;
|
| 689 |
-
//
|
| 690 |
-
//
|
| 691 |
-
|
| 692 |
-
// Cleaned through the same rules the host applies, so an empty "Specific users" grant
|
| 693 |
-
// collapses to 'personal' HERE too and the confirmation the user sees is the truth.
|
| 694 |
-
onCreate(value, creating, cleanViewPermissions(
|
| 695 |
-
{ edit: permEdit, users: permUsers }, "personal", userOptions));
|
| 696 |
setName("");
|
| 697 |
-
setPermEdit("personal");
|
| 698 |
-
setPermUsers([]);
|
| 699 |
setCreating(null);
|
| 700 |
// WAVE 20 item 20 β the two steps share ONE panel, so the anchor outlives step 1 and
|
| 701 |
// has to be released here. Left set, the flyout would spring back to the type chooser
|
|
@@ -712,8 +648,6 @@ export default function ViewSidebar({
|
|
| 712 |
setCreating(null);
|
| 713 |
setCreateMenu(null);
|
| 714 |
setName("");
|
| 715 |
-
setPermEdit("personal");
|
| 716 |
-
setPermUsers([]);
|
| 717 |
};
|
| 718 |
|
| 719 |
const createFolder = () => {
|
|
@@ -1091,65 +1025,10 @@ export default function ViewSidebar({
|
|
| 1091 |
if (event.key === "Escape") closeCreate();
|
| 1092 |
}}
|
| 1093 |
/>
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
{VIEW_EDIT_MODES.map((m) => (
|
| 1099 |
-
<label key={m} className="cg-radio-row cg-perm-row">
|
| 1100 |
-
<input
|
| 1101 |
-
type="radio"
|
| 1102 |
-
name="cg-view-perm"
|
| 1103 |
-
checked={permEdit === m}
|
| 1104 |
-
onChange={() => setPermEdit(m)}
|
| 1105 |
-
/>
|
| 1106 |
-
{/* β ITEM 1 β the mark is its OWN COLUMN, between the radio and the words.
|
| 1107 |
-
β Not inside `.cg-perm-label`: that element is a flex COLUMN (title over
|
| 1108 |
-
blurb), so a mark placed in it becomes a THIRD ROW under the sentence
|
| 1109 |
-
rather than a leading glyph β the wrong-parent trap
|
| 1110 |
-
([[wrong-parent-not-broken-control]]) in its cheapest form. */}
|
| 1111 |
-
<PermMark mode={m} />
|
| 1112 |
-
<span className="cg-perm-label">
|
| 1113 |
-
{VIEW_EDIT_LABELS[m]}
|
| 1114 |
-
<span className="cg-perm-blurb">{VIEW_EDIT_BLURBS[m]}</span>
|
| 1115 |
-
</span>
|
| 1116 |
-
</label>
|
| 1117 |
-
))}
|
| 1118 |
-
{permEdit === "users" && (
|
| 1119 |
-
<div className="cg-perm-users">
|
| 1120 |
-
{userOptions.length === 0 ? (
|
| 1121 |
-
// Never a silent empty box: an empty grant collapses to Personal
|
| 1122 |
-
// host-side, so say that rather than letting the user think they
|
| 1123 |
-
// shared it.
|
| 1124 |
-
<span className="cg-perm-empty">
|
| 1125 |
-
No other accounts to pick. This will save as Personal.
|
| 1126 |
-
</span>
|
| 1127 |
-
) : (
|
| 1128 |
-
userOptions.map((u) => (
|
| 1129 |
-
<label key={u} className="cg-perm-user">
|
| 1130 |
-
<input
|
| 1131 |
-
type="checkbox"
|
| 1132 |
-
checked={permUsers.includes(u)}
|
| 1133 |
-
onChange={(e) =>
|
| 1134 |
-
setPermUsers((cur) =>
|
| 1135 |
-
e.target.checked
|
| 1136 |
-
? [...cur, u].slice(0, MAX_VIEW_USERS)
|
| 1137 |
-
: cur.filter((x) => x !== u)
|
| 1138 |
-
)
|
| 1139 |
-
}
|
| 1140 |
-
/>
|
| 1141 |
-
<span>{u}</span>
|
| 1142 |
-
</label>
|
| 1143 |
-
))
|
| 1144 |
-
)}
|
| 1145 |
-
{userOptions.length > 0 && permUsers.length === 0 && (
|
| 1146 |
-
<span className="cg-perm-empty">
|
| 1147 |
-
Pick at least one person, or this saves as Personal.
|
| 1148 |
-
</span>
|
| 1149 |
-
)}
|
| 1150 |
-
</div>
|
| 1151 |
-
)}
|
| 1152 |
-
</div>
|
| 1153 |
<div className="cg-form-actions">
|
| 1154 |
<button
|
| 1155 |
type="button"
|
|
@@ -2128,8 +2007,8 @@ export default function ViewSidebar({
|
|
| 2128 |
>
|
| 2129 |
{/* β WAVE 33 Β· T15 (D-206) β A STAR, NOT A PADLOCK.
|
| 2130 |
β This row shipped wearing MenuIcon `permissions` / `unlock` β a padlock that
|
| 2131 |
-
"Share view", "Share folder"
|
| 2132 |
-
|
| 2133 |
is none of the three locks (DESIGN.md Β§4): it freezes no record, no column and
|
| 2134 |
no row set. So the answer is to drop the lock, not to title it β a title naming
|
| 2135 |
a lock that does not exist would be believed.
|
|
|
|
| 9 |
FolderIcon,
|
| 10 |
GridFolder,
|
| 11 |
SavedView,
|
|
|
|
| 12 |
ViewPermissions,
|
| 13 |
Viewer,
|
| 14 |
} from "./types";
|
|
|
|
| 17 |
DEFAULT_FOLDER_TONE,
|
| 18 |
FOLDER_SHAPES,
|
| 19 |
FOLDER_TONES,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
cleanFolderIcon,
|
|
|
|
| 21 |
isModeFrozen,
|
| 22 |
isUndeletableView,
|
| 23 |
mayEditView,
|
|
|
|
| 31 |
MODE_LABELS,
|
| 32 |
MODE_TONE,
|
| 33 |
} from "./iconShapes";
|
|
|
|
| 34 |
import { FolderMark, LockMark, MenuLabel, ModeIcon, ToneModeIcon } from "./icons";
|
| 35 |
import { ROOT_FOLDER_ID } from "./folders";
|
| 36 |
// β W32-T23 (owner item 17) β THE BELL, from the shared layer rather than redrawn here.
|
|
|
|
| 52 |
} from "./folders";
|
| 53 |
|
| 54 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 55 |
+
Share-view ownership note for the create door.
|
| 56 |
|
| 57 |
+
View creation now stays private and delegates audience/edit choices to the single Share view
|
| 58 |
+
dialog. Keep this note aligned with that ownership boundary if the sharing surface changes.
|
|
|
|
|
|
|
| 59 |
|
| 60 |
β WHY THE PATHS LIVE HERE AND NOT IN `icons.tsx`, stated so a later wave does not "tidy" it
|
| 61 |
back: `icons.tsx` renders `MODE_SHAPES` from `iconShapes.ts`, which the glide CANVAS also
|
|
|
|
| 71 |
never a literal, so these can never be the one place in the product with its own colours.
|
| 72 |
Outline-only, the register the owner set in 2026-07-29: "it doesn't have a full color, only
|
| 73 |
the linings". */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
/** head + shoulders, at `x`. One drawing, three uses β a second hand-drawn person is how two
|
| 75 |
* marks in one panel end up different heights. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
/* WAVE 20 item 18 (C-SHARE) β the system folder every view shared WITH you lands in until
|
| 77 |
you file it somewhere of your own. It is the only folder id this component recognises by
|
| 78 |
name, and as of wave 21 (item 9) it is DECLARED IN `folders.ts` beside the synthesis that
|
|
|
|
| 84 |
activeViewId: string;
|
| 85 |
saveState: "saved" | "saving";
|
| 86 |
onSelect: (id: string) => void;
|
| 87 |
+
/** The create door persists a private default; Share view owns audience and edit choices. */
|
|
|
|
|
|
|
| 88 |
onCreate: (name: string, mode: DisplayMode, permissions: ViewPermissions) => void;
|
| 89 |
/**
|
| 90 |
* ββ W37-T41 β may this surface hold a CUSTOM VIEW? The row is dropped when it cannot.
|
|
|
|
| 235 |
surface. They carried the transient-lock selection for the retired Cohorts section; a
|
| 236 |
locked view is now an ordinary member of `views` and is selected by `onSelect` like any
|
| 237 |
other, which is the whole point of R1. */
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
/**
|
| 239 |
* ββ W36-T05 (owner item 6 Β· R3 Β· R14) β THE VIEW AGENT, to the LEFT of this rail.
|
| 240 |
*
|
|
|
|
| 305 |
viewer,
|
| 306 |
onToggleLock,
|
| 307 |
onToggleImportant,
|
|
|
|
| 308 |
}: ViewSidebarProps) {
|
| 309 |
/**
|
| 310 |
* I14 β creating a view now always knows WHICH KIND of view is being created, because the
|
|
|
|
| 314 |
/** I14 β the "+ Create newβ¦" flyout's anchor. */
|
| 315 |
const [createMenu, setCreateMenu] = useState<HTMLButtonElement | null>(null);
|
| 316 |
const [name, setName] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
const [menu, setMenu] = useState<{
|
| 318 |
viewId: string;
|
| 319 |
anchor: HTMLButtonElement;
|
|
|
|
| 628 |
const create = () => {
|
| 629 |
const value = name.trim();
|
| 630 |
if (!value || !creating) return;
|
| 631 |
+
// Views start private. The Share view dialog is the single place where people choose the
|
| 632 |
+
// audience and whether each recipient can view or edit, so creation and sharing cannot drift.
|
| 633 |
+
onCreate(value, creating, { edit: "personal" });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 634 |
setName("");
|
|
|
|
|
|
|
| 635 |
setCreating(null);
|
| 636 |
// WAVE 20 item 20 β the two steps share ONE panel, so the anchor outlives step 1 and
|
| 637 |
// has to be released here. Left set, the flyout would spring back to the type chooser
|
|
|
|
| 648 |
setCreating(null);
|
| 649 |
setCreateMenu(null);
|
| 650 |
setName("");
|
|
|
|
|
|
|
| 651 |
};
|
| 652 |
|
| 653 |
const createFolder = () => {
|
|
|
|
| 1025 |
if (event.key === "Escape") closeCreate();
|
| 1026 |
}}
|
| 1027 |
/>
|
| 1028 |
+
<p className="cg-create-share-note">
|
| 1029 |
+
This view starts private. After you create it, use Share view to choose who can
|
| 1030 |
+
view or edit it.
|
| 1031 |
+
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
<div className="cg-form-actions">
|
| 1033 |
<button
|
| 1034 |
type="button"
|
|
|
|
| 2007 |
>
|
| 2008 |
{/* β WAVE 33 Β· T15 (D-206) β A STAR, NOT A PADLOCK.
|
| 2009 |
β This row shipped wearing MenuIcon `permissions` / `unlock` β a padlock that
|
| 2010 |
+
"Share view", "Share folder" and "Lock to a locked view" ALSO wear, untitled,
|
| 2011 |
+
in these same menus. Marking a view important
|
| 2012 |
is none of the three locks (DESIGN.md Β§4): it freezes no record, no column and
|
| 2013 |
no row set. So the answer is to drop the lock, not to title it β a title naming
|
| 2014 |
a lock that does not exist would be believed.
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -1581,6 +1581,7 @@ export type StoredFieldPermissions = {
|
|
| 1581 |
edit: FieldEditMode | "everyone" | "creator" | "admins";
|
| 1582 |
users?: string[];
|
| 1583 |
};
|
|
|
|
| 1584 |
export const FIELD_EDIT_LABELS: Record<FieldEditMode, string> = {
|
| 1585 |
personal: "Personal",
|
| 1586 |
collaborative: "Collaborative",
|
|
@@ -1617,12 +1618,14 @@ export function fieldEditMode(field: Pick<Field, "permissions">): FieldEditMode
|
|
| 1617 |
* the wall. Rules, in order:
|
| 1618 |
* - a cell that is read-only by NATURE (formula/created_time) or by stratum (source odoo)
|
| 1619 |
* is not editable for anyone;
|
| 1620 |
-
* - permissions absent = everyone (every pre-wave-5 field);
|
|
|
|
|
|
|
| 1621 |
* - a RESTRICTED field with no viewer info fails CLOSED;
|
| 1622 |
* - 'admins' = isAdmin; 'creator' = the stamped creator, plus admins (an admin locked out
|
| 1623 |
* of a leaver's field would need a database edit to fix anything).
|
| 1624 |
*/
|
| 1625 |
-
export function mayEditField(field: Field, viewer: Viewer | undefined): boolean {
|
| 1626 |
if (field.source !== "overlay" || READONLY_CELL_TYPES.has(field.type)) return false;
|
| 1627 |
// β 2026-08-07 β the DERIVED half of `link`. Read per-field rather than by type, because the
|
| 1628 |
// other half of the same type is a relation a person builds by hand. The host wall is
|
|
@@ -1639,6 +1642,12 @@ export function mayEditField(field: Field, viewer: Viewer | undefined): boolean
|
|
| 1639 |
const edit = permissions.edit;
|
| 1640 |
if (!viewer) return false;
|
| 1641 |
if (viewer.isAdmin) return true;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
if (edit === "collaborative") return true;
|
| 1643 |
return field.createdBy === viewer.name ||
|
| 1644 |
(edit === "users" && (permissions.users ?? []).some(
|
|
@@ -1692,7 +1701,27 @@ export function isMachineOwned(field: Field): boolean {
|
|
| 1692 |
* always did β a column YOU added beside the Odoo ones is yours even though its neighbours are not.
|
| 1693 |
*/
|
| 1694 |
export function isPresetField(field: Field): boolean {
|
| 1695 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1696 |
}
|
| 1697 |
|
| 1698 |
/**
|
|
@@ -2008,6 +2037,8 @@ export interface Field {
|
|
| 2008 |
custom?: boolean;
|
| 2009 |
/** True when the definition and its cell values live in the tenant-wide field stratum. */
|
| 2010 |
shared?: boolean;
|
|
|
|
|
|
|
| 2011 |
/**
|
| 2012 |
* Owner item 8 (2026-07-27): a PRE-SET measure field β the canonical contract converted a
|
| 2013 |
* frozen derived column (`revenue_ytd`, `revenue_ly`, `orders_24m`) into a measure whose
|
|
@@ -2100,9 +2131,9 @@ export interface Field {
|
|
| 2100 |
max?: number;
|
| 2101 |
/**
|
| 2102 |
* Wave-5 items 6/7 β the CANONICAL default description (ships with the contract,
|
| 2103 |
-
* aios_grid_fields.json). The user's per-field `note` OVERRIDES it: the header (i) and every
|
| 2104 |
-
* hover show `note || description`, and
|
| 2105 |
-
* stratum) β the canonical default is never writable from the client.
|
| 2106 |
*/
|
| 2107 |
description?: string;
|
| 2108 |
/**
|
|
@@ -2113,9 +2144,10 @@ export interface Field {
|
|
| 2113 |
*/
|
| 2114 |
createdBy?: string;
|
| 2115 |
/**
|
| 2116 |
-
* Wave-5
|
| 2117 |
-
*
|
| 2118 |
-
*
|
|
|
|
| 2119 |
*/
|
| 2120 |
permissions?: StoredFieldPermissions;
|
| 2121 |
/**
|
|
|
|
| 1581 |
edit: FieldEditMode | "everyone" | "creator" | "admins";
|
| 1582 |
users?: string[];
|
| 1583 |
};
|
| 1584 |
+
export type FieldShareRole = "view" | "edit" | "owner";
|
| 1585 |
export const FIELD_EDIT_LABELS: Record<FieldEditMode, string> = {
|
| 1586 |
personal: "Personal",
|
| 1587 |
collaborative: "Collaborative",
|
|
|
|
| 1618 |
* the wall. Rules, in order:
|
| 1619 |
* - a cell that is read-only by NATURE (formula/created_time) or by stratum (source odoo)
|
| 1620 |
* is not editable for anyone;
|
| 1621 |
+
* - permissions absent = everyone (every pre-wave-5 field);
|
| 1622 |
+
* - a field Share role is the field-specific override: `view` blocks edits and `edit` permits
|
| 1623 |
+
* them for a shared field. This is deliberately evaluated before the legacy permission bag.
|
| 1624 |
* - a RESTRICTED field with no viewer info fails CLOSED;
|
| 1625 |
* - 'admins' = isAdmin; 'creator' = the stamped creator, plus admins (an admin locked out
|
| 1626 |
* of a leaver's field would need a database edit to fix anything).
|
| 1627 |
*/
|
| 1628 |
+
export function mayEditField(field: Field, viewer: Viewer | undefined): boolean {
|
| 1629 |
if (field.source !== "overlay" || READONLY_CELL_TYPES.has(field.type)) return false;
|
| 1630 |
// β 2026-08-07 β the DERIVED half of `link`. Read per-field rather than by type, because the
|
| 1631 |
// other half of the same type is a relation a person builds by hand. The host wall is
|
|
|
|
| 1642 |
const edit = permissions.edit;
|
| 1643 |
if (!viewer) return false;
|
| 1644 |
if (viewer.isAdmin) return true;
|
| 1645 |
+
if (field.shared) {
|
| 1646 |
+
// A view can be editable while one of its fields is intentionally view-only. The field's
|
| 1647 |
+
// own Share field role is the narrower, more specific rule and therefore wins here.
|
| 1648 |
+
if (field.createdBy && field.createdBy.toLowerCase() === viewer.name.toLowerCase()) return true;
|
| 1649 |
+
return field.sharedRole === "edit" || field.sharedRole === "owner";
|
| 1650 |
+
}
|
| 1651 |
if (edit === "collaborative") return true;
|
| 1652 |
return field.createdBy === viewer.name ||
|
| 1653 |
(edit === "users" && (permissions.users ?? []).some(
|
|
|
|
| 1701 |
* always did β a column YOU added beside the Odoo ones is yours even though its neighbours are not.
|
| 1702 |
*/
|
| 1703 |
export function isPresetField(field: Field): boolean {
|
| 1704 |
+
if (field.preset === true) return true;
|
| 1705 |
+
return (field.source === "odoo" && !field.custom) || isMachineOwned(field);
|
| 1706 |
+
}
|
| 1707 |
+
|
| 1708 |
+
/**
|
| 1709 |
+
* May this viewer open the field-definition editor? This is separate from cell editability:
|
| 1710 |
+
* formula, rollup and other computed fields can have an editable definition even though their
|
| 1711 |
+
* values are not human-writable. Shared fields use the same specific Share field role as
|
| 1712 |
+
* `mayEditField`; legacy permission bags remain a compatibility fallback for old private fields.
|
| 1713 |
+
*/
|
| 1714 |
+
export function mayEditFieldDefinition(field: Field, viewer: Viewer | undefined): boolean {
|
| 1715 |
+
if (!viewer) return false;
|
| 1716 |
+
if (viewer.isAdmin) return true;
|
| 1717 |
+
if (field.createdBy && field.createdBy.toLowerCase() === viewer.name.toLowerCase()) return true;
|
| 1718 |
+
if (field.shared) return field.sharedRole === "edit" || field.sharedRole === "owner";
|
| 1719 |
+
if (!field.createdBy) return false;
|
| 1720 |
+
const permissions = cleanFieldPermissions(field.permissions, "collaborative");
|
| 1721 |
+
return permissions.edit === "collaborative" ||
|
| 1722 |
+
(permissions.edit === "users" && (permissions.users ?? []).some(
|
| 1723 |
+
(user) => user.toLowerCase() === viewer.name.toLowerCase()
|
| 1724 |
+
));
|
| 1725 |
}
|
| 1726 |
|
| 1727 |
/**
|
|
|
|
| 2037 |
custom?: boolean;
|
| 2038 |
/** True when the definition and its cell values live in the tenant-wide field stratum. */
|
| 2039 |
shared?: boolean;
|
| 2040 |
+
/** Host-computed effective Share field role for this viewer. */
|
| 2041 |
+
sharedRole?: FieldShareRole;
|
| 2042 |
/**
|
| 2043 |
* Owner item 8 (2026-07-27): a PRE-SET measure field β the canonical contract converted a
|
| 2044 |
* frozen derived column (`revenue_ytd`, `revenue_ly`, `orders_24m`) into a measure whose
|
|
|
|
| 2131 |
max?: number;
|
| 2132 |
/**
|
| 2133 |
* Wave-5 items 6/7 β the CANONICAL default description (ships with the contract,
|
| 2134 |
+
* aios_grid_fields.json). The user's per-field `note` OVERRIDES it: the header (i) and every
|
| 2135 |
+
* hover show `note || description`, and the Description control in Edit field edits the NOTE
|
| 2136 |
+
* (user stratum) β the canonical default is never writable from the client.
|
| 2137 |
*/
|
| 2138 |
description?: string;
|
| 2139 |
/**
|
|
|
|
| 2144 |
*/
|
| 2145 |
createdBy?: string;
|
| 2146 |
/**
|
| 2147 |
+
* Legacy Wave-5 compatibility bag for who may edit this field's values. New Share field
|
| 2148 |
+
* grants are authoritative when present; this bag remains only for older private fields.
|
| 2149 |
+
* The client HIDES what the viewer may not do (menu entries, editable cells); the HOST check
|
| 2150 |
+
* is the wall β fail-closed, enforced on overlay_patch and field_upsert.
|
| 2151 |
*/
|
| 2152 |
permissions?: StoredFieldPermissions;
|
| 2153 |
/**
|
web/src/index.css
CHANGED
|
@@ -1155,10 +1155,22 @@ body {
|
|
| 1155 |
.cg-column-create label + label {
|
| 1156 |
margin-top: 7px;
|
| 1157 |
}
|
| 1158 |
-
.cg-column-create .cg-input,
|
| 1159 |
-
.cg-column-create .cg-select {
|
| 1160 |
-
width: 100%;
|
| 1161 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1162 |
|
| 1163 |
/* glide-data-grid mounts overlay editors into this portal. */
|
| 1164 |
#portal {
|
|
@@ -1848,15 +1860,12 @@ body > .cg-overlay {
|
|
| 1848 |
padding: 2px 0 0;
|
| 1849 |
max-width: 220px;
|
| 1850 |
}
|
| 1851 |
-
/* R7 (2026-08-02) β the RADIO rows never got the treatment their checkbox twins have had
|
| 1852 |
-
since wave 2, and they are the ones that list FIELD LABELS (the Group-by popover): a
|
| 1853 |
-
two-line "Estimated missed revenue, last 12 months" pushed the row below it out of the
|
| 1854 |
-
rhythm and made the radio look mis-aligned with its own text.
|
| 1855 |
-
β `:not([class])` is load-bearing, not tidiness.
|
| 1856 |
-
|
| 1857 |
-
deliberately stacks a LABEL over a two-line blurb. That rule would have clipped every
|
| 1858 |
-
explanation in the permissions pane to one ellipsised line. Caught by walking every
|
| 1859 |
-
`cg-radio-row` call site rather than by an assertion; all eleven others are bare spans. */
|
| 1860 |
.cg-radio-row > span:not([class]) {
|
| 1861 |
flex: 1 1 auto;
|
| 1862 |
min-width: 0;
|
|
@@ -5386,9 +5395,8 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 5386 |
guessed: that flyout is ~215px wide on a ~31px row pitch with ~16px marks, against our
|
| 5387 |
178px / 30px / 15px. The type stays `--lp-fs-xs`: the owner asked for room, not for a
|
| 5388 |
larger font, and SC-1 fixes this surface's size. Room comes from the box, not the text. */
|
| 5389 |
-
/*
|
| 5390 |
-
|
| 5391 |
-
does, and at 212px all three wrapped, so a three-choice question read as six lines of grey.
|
| 5392 |
|
| 5393 |
β MEASURED, NOT GUESSED. Advance widths summed from the EMBEDDED Inter (the `@font-face`
|
| 5394 |
data URI at the top of this file β the font users actually see, which is the whole reason it
|
|
@@ -5399,8 +5407,8 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 5399 |
The box has to carry 235.40px of text plus everything left of it:
|
| 5400 |
10 flyout padding + 2 border + 4 form padding + 16 row padding
|
| 5401 |
+ 15 radio + 9 gap + 16 mark + 9 gap = 81px
|
| 5402 |
-
235.40 + 81 = 316.4, taken to 320 for subpixel accumulation.
|
| 5403 |
-
|
| 5404 |
|
| 5405 |
β `max-width` IS RESTATED AND THAT IS LOAD-BEARING, not tidiness: `.cg-view-menu` (the class
|
| 5406 |
this panel also wears) caps every menu at 260px, so `width: 320px` ALONE would have been
|
|
@@ -5520,59 +5528,6 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 5520 |
.cg-modes-locked .cg-mode-icon,
|
| 5521 |
.cg-modes-locked .cg-lock-mark { flex: 0 0 auto; }
|
| 5522 |
|
| 5523 |
-
/* I17 (C4) β the "Who can edit" step of the create prompt. Each choice carries what it
|
| 5524 |
-
actually DOES: a permission whose effect the user cannot predict is one they will set
|
| 5525 |
-
wrong, and this one defaults to the restrictive option. */
|
| 5526 |
-
.cg-perm {
|
| 5527 |
-
display: flex;
|
| 5528 |
-
flex-direction: column;
|
| 5529 |
-
gap: 2px;
|
| 5530 |
-
margin: 6px 0 2px;
|
| 5531 |
-
}
|
| 5532 |
-
.cg-perm-title {
|
| 5533 |
-
font-size: var(--lp-fs-2xs);
|
| 5534 |
-
font-weight: 600;
|
| 5535 |
-
color: var(--lp-muted);
|
| 5536 |
-
padding-bottom: 2px;
|
| 5537 |
-
}
|
| 5538 |
-
.cg-perm-row { align-items: flex-start; }
|
| 5539 |
-
.cg-perm-label {
|
| 5540 |
-
display: flex;
|
| 5541 |
-
flex-direction: column;
|
| 5542 |
-
gap: 1px;
|
| 5543 |
-
font-size: var(--lp-fs-2xs);
|
| 5544 |
-
}
|
| 5545 |
-
.cg-perm-blurb {
|
| 5546 |
-
color: var(--lp-muted);
|
| 5547 |
-
font-size: var(--lp-fs-3xs);
|
| 5548 |
-
line-height: 1.35;
|
| 5549 |
-
}
|
| 5550 |
-
.cg-perm-users {
|
| 5551 |
-
display: flex;
|
| 5552 |
-
flex-direction: column;
|
| 5553 |
-
gap: 2px;
|
| 5554 |
-
max-height: 148px;
|
| 5555 |
-
overflow-y: auto;
|
| 5556 |
-
margin: 2px 0 0 18px;
|
| 5557 |
-
padding: 4px 6px;
|
| 5558 |
-
border: 1px solid var(--lp-line);
|
| 5559 |
-
border-radius: var(--lp-r-md);
|
| 5560 |
-
}
|
| 5561 |
-
.cg-perm-user {
|
| 5562 |
-
display: flex;
|
| 5563 |
-
align-items: center;
|
| 5564 |
-
gap: 6px;
|
| 5565 |
-
font-size: var(--lp-fs-2xs);
|
| 5566 |
-
cursor: pointer;
|
| 5567 |
-
}
|
| 5568 |
-
/* Stated in the yellow-deep weight, not a pastel: this is a warning that what you picked is
|
| 5569 |
-
NOT what will be saved, and a 1.9:1 pastel would make it decorative. */
|
| 5570 |
-
.cg-perm-empty {
|
| 5571 |
-
color: var(--lp-yellow-deep);
|
| 5572 |
-
font-size: var(--lp-fs-3xs);
|
| 5573 |
-
line-height: 1.35;
|
| 5574 |
-
}
|
| 5575 |
-
|
| 5576 |
/* I2 β the cell hover tip. Inherits everything from `.cg-header-tip` (including
|
| 5577 |
`pointer-events: none`, which is the CONTRACT and not a style: a tip that can take the
|
| 5578 |
pointer swallows the next click). Only the wrapping differs: a cut-off Note is one long
|
|
@@ -10715,13 +10670,19 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 10715 |
border: 0;
|
| 10716 |
background: transparent;
|
| 10717 |
}
|
| 10718 |
-
.cg-create-flyout .cg-create-form > label:first-child {
|
| 10719 |
-
display: block;
|
| 10720 |
-
margin-bottom: 4px;
|
| 10721 |
-
font-size: var(--lp-fs-3xs);
|
| 10722 |
-
font-weight: 600;
|
| 10723 |
-
color: var(--lp-muted);
|
| 10724 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10725 |
/* β THE PANEL IS A MENU, AND `.cg-view-menu button` (0,1,1) OUT-RANKS `.cg-btn` (0,1,0).
|
| 10726 |
Moving the form inside the flyout therefore repainted its Create/Cancel as full-width,
|
| 10727 |
left-aligned, borderless MENU ROWS β the panel's own row style applied to a form's
|
|
@@ -10750,36 +10711,6 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 10750 |
.cg-create-flyout .cg-create-form .cg-btn--primary:hover:not(:disabled) {
|
| 10751 |
background: var(--lp-primary-hover);
|
| 10752 |
}
|
| 10753 |
-
/* The who-can-edit rows are `<label>`s, and `.cg-view-create label` (0,1,1) stacks every
|
| 10754 |
-
label in a create form into a COLUMN β so the radio sat above its own words rather than
|
| 10755 |
-
beside them. Pre-dates this wave (the same two rules met in the rail-inline form), but
|
| 10756 |
-
item 20 makes these three rows the whole content of a small panel, where a radio floating
|
| 10757 |
-
over its label is the first thing you see. Scoped to the flyout: nothing else that reads
|
| 10758 |
-
`.cg-view-create label` moves. */
|
| 10759 |
-
.cg-create-flyout .cg-perm-row {
|
| 10760 |
-
flex-direction: row;
|
| 10761 |
-
align-items: flex-start;
|
| 10762 |
-
gap: 9px;
|
| 10763 |
-
}
|
| 10764 |
-
|
| 10765 |
-
/* [W27-C] β WAVE 27 Β· OWNER ITEM 1 β the permission MARK's column.
|
| 10766 |
-
The row is a flex line: radio | mark | (label over blurb). `flex: 0 0 auto` is the same rule
|
| 10767 |
-
the create rows' own marks carry two hundred lines up β without it a long blurb shrinks the
|
| 10768 |
-
ICON before it wraps the text, and a 9px person is a smudge.
|
| 10769 |
-
The 1px nudge lands the 16px mark on the cap-height of the 12px label beside it; `flex-start`
|
| 10770 |
-
on the row aligns it to the TOP, which is where it belongs when the blurb is under the name. */
|
| 10771 |
-
.cg-perm-mark {
|
| 10772 |
-
flex: 0 0 auto;
|
| 10773 |
-
margin-top: 1px;
|
| 10774 |
-
}
|
| 10775 |
-
/* The user list belongs to the row above it, and it says so by indenting past the controls
|
| 10776 |
-
rather than by drawing a second box. It was 18px, measured against a row that started
|
| 10777 |
-
`radio | label`; item 1 inserts a 16px mark and a 9px gap between those two, so the same
|
| 10778 |
-
visual relationship is 18 + 25. Left as an indent rather than an exact alignment to the text
|
| 10779 |
-
column (57px): at that depth the checkbox list starts closer to the middle of the panel than
|
| 10780 |
-
to its left edge, which reads as a different section rather than a continuation of one. */
|
| 10781 |
-
.cg-create-flyout .cg-perm-users { margin-left: 43px; }
|
| 10782 |
-
|
| 10783 |
/* ββ Item 19 (C-FOLDER-REORDER) β dragging a FOLDER to reorder ββββββββββββββ
|
| 10784 |
A private drag MIME (`application/x-loopable-fold`) keeps this apart from the
|
| 10785 |
view drag, which carries `text/plain`; the indicator is an insertion RULE at
|
|
|
|
| 1155 |
.cg-column-create label + label {
|
| 1156 |
margin-top: 7px;
|
| 1157 |
}
|
| 1158 |
+
.cg-column-create .cg-input,
|
| 1159 |
+
.cg-column-create .cg-select {
|
| 1160 |
+
width: 100%;
|
| 1161 |
+
}
|
| 1162 |
+
/* The definition editor mixes labels, type blocks and toggle rows. A label-to-label rule is
|
| 1163 |
+
not enough here: the type picker is a block, so it used to sit directly against the first
|
| 1164 |
+
toggle below it. Keep every top-level editor section visibly separated while leaving nested
|
| 1165 |
+
option controls to their own layout rules. */
|
| 1166 |
+
.cg-field-edit > * + * { margin-top: 10px; }
|
| 1167 |
+
.cg-field-edit > .cg-form-actions { margin-top: 2px; }
|
| 1168 |
+
.cg-field-edit > .cg-edit-swap { margin-top: 14px; }
|
| 1169 |
+
.cg-field-edit .cg-type-block { gap: 7px; }
|
| 1170 |
+
.cg-field-edit .cg-edit-description textarea {
|
| 1171 |
+
min-height: 76px;
|
| 1172 |
+
resize: vertical;
|
| 1173 |
+
}
|
| 1174 |
|
| 1175 |
/* glide-data-grid mounts overlay editors into this portal. */
|
| 1176 |
#portal {
|
|
|
|
| 1860 |
padding: 2px 0 0;
|
| 1861 |
max-width: 220px;
|
| 1862 |
}
|
| 1863 |
+
/* R7 (2026-08-02) β the RADIO rows never got the treatment their checkbox twins have had
|
| 1864 |
+
since wave 2, and they are the ones that list FIELD LABELS (the Group-by popover): a
|
| 1865 |
+
two-line "Estimated missed revenue, last 12 months" pushed the row below it out of the
|
| 1866 |
+
rhythm and made the radio look mis-aligned with its own text.
|
| 1867 |
+
β `:not([class])` is load-bearing, not tidiness. It keeps this rule scoped to the simple
|
| 1868 |
+
field-label rows; richer controls own their own layout. */
|
|
|
|
|
|
|
|
|
|
| 1869 |
.cg-radio-row > span:not([class]) {
|
| 1870 |
flex: 1 1 auto;
|
| 1871 |
min-width: 0;
|
|
|
|
| 5395 |
guessed: that flyout is ~215px wide on a ~31px row pitch with ~16px marks, against our
|
| 5396 |
178px / 30px / 15px. The type stays `--lp-fs-xs`: the owner asked for room, not for a
|
| 5397 |
larger font, and SC-1 fixes this surface's size. Room comes from the box, not the text. */
|
| 5398 |
+
/* Share-view follow-up guidance. The create step is intentionally private; audience and edit
|
| 5399 |
+
choices belong to the Share view dialog rather than to this flyout.
|
|
|
|
| 5400 |
|
| 5401 |
β MEASURED, NOT GUESSED. Advance widths summed from the EMBEDDED Inter (the `@font-face`
|
| 5402 |
data URI at the top of this file β the font users actually see, which is the whole reason it
|
|
|
|
| 5407 |
The box has to carry 235.40px of text plus everything left of it:
|
| 5408 |
10 flyout padding + 2 border + 4 form padding + 16 row padding
|
| 5409 |
+ 15 radio + 9 gap + 16 mark + 9 gap = 81px
|
| 5410 |
+
235.40 + 81 = 316.4, taken to 320 for subpixel accumulation. The flyout remains roomy for
|
| 5411 |
+
the type picker and its private-first follow-up note.
|
| 5412 |
|
| 5413 |
β `max-width` IS RESTATED AND THAT IS LOAD-BEARING, not tidiness: `.cg-view-menu` (the class
|
| 5414 |
this panel also wears) caps every menu at 260px, so `width: 320px` ALONE would have been
|
|
|
|
| 5528 |
.cg-modes-locked .cg-mode-icon,
|
| 5529 |
.cg-modes-locked .cg-lock-mark { flex: 0 0 auto; }
|
| 5530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5531 |
/* I2 β the cell hover tip. Inherits everything from `.cg-header-tip` (including
|
| 5532 |
`pointer-events: none`, which is the CONTRACT and not a style: a tip that can take the
|
| 5533 |
pointer swallows the next click). Only the wrapping differs: a cut-off Note is one long
|
|
|
|
| 10670 |
border: 0;
|
| 10671 |
background: transparent;
|
| 10672 |
}
|
| 10673 |
+
.cg-create-flyout .cg-create-form > label:first-child {
|
| 10674 |
+
display: block;
|
| 10675 |
+
margin-bottom: 4px;
|
| 10676 |
+
font-size: var(--lp-fs-3xs);
|
| 10677 |
+
font-weight: 600;
|
| 10678 |
+
color: var(--lp-muted);
|
| 10679 |
+
}
|
| 10680 |
+
.cg-create-share-note {
|
| 10681 |
+
margin: 9px 0 2px;
|
| 10682 |
+
color: var(--lp-muted);
|
| 10683 |
+
font-size: var(--lp-fs-3xs);
|
| 10684 |
+
line-height: 1.45;
|
| 10685 |
+
}
|
| 10686 |
/* β THE PANEL IS A MENU, AND `.cg-view-menu button` (0,1,1) OUT-RANKS `.cg-btn` (0,1,0).
|
| 10687 |
Moving the form inside the flyout therefore repainted its Create/Cancel as full-width,
|
| 10688 |
left-aligned, borderless MENU ROWS β the panel's own row style applied to a form's
|
|
|
|
| 10711 |
.cg-create-flyout .cg-create-form .cg-btn--primary:hover:not(:disabled) {
|
| 10712 |
background: var(--lp-primary-hover);
|
| 10713 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10714 |
/* ββ Item 19 (C-FOLDER-REORDER) β dragging a FOLDER to reorder ββββββββββββββ
|
| 10715 |
A private drag MIME (`application/x-loopable-fold`) keeps this apart from the
|
| 10716 |
view drag, which carries `text/plain`; the indicator is an insertion RULE at
|
web/src/shell/ShareDialog.tsx
CHANGED
|
@@ -39,10 +39,14 @@ const KIND_WORD: Record<ShareKind, string> = {
|
|
| 39 |
field: "field",
|
| 40 |
};
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
/** What each role MEANS on each kind, in the reader's own terms. A role picker
|
| 43 |
-
*
|
| 44 |
-
*
|
| 45 |
-
* extended to the two new kinds rather than re-invented for them. */
|
| 46 |
const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
|
| 47 |
view: {
|
| 48 |
view: "Can open this view. Cannot rename, refilter or delete it.",
|
|
@@ -56,21 +60,9 @@ const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
|
|
| 56 |
view: "Can open this database and read its records.",
|
| 57 |
edit: "Can add, edit and delete its records.",
|
| 58 |
},
|
| 59 |
-
/**
|
| 60 |
-
* ββ W39-T17 β A COLUMN GRANT IS A VISIBILITY GRANT, AND THE BLURBS SAY ONLY THAT.
|
| 61 |
-
*
|
| 62 |
-
* β MEASURED IN THE SERVER, NOT ASSUMED. `core.perm_scope.field_grant_hidden` is the ONLY
|
| 63 |
-
* consumer of a `field`-kind grant, and it tests `role is None` β nothing else. So `view` and
|
| 64 |
-
* `edit` are the SAME capability on a column today: both lift the column out of the hidden set
|
| 65 |
-
* and neither confers value editing, which `types.mayEditField` decides from the field's OWN
|
| 66 |
-
* `permissions.edit` (everyone / creator / admins). A blurb promising "can change its values"
|
| 67 |
-
* would be a sentence the product does not honour, so the `edit` line names the real wall
|
| 68 |
-
* instead. Booked as a finding; the picker is the shared one and narrowing it is not this
|
| 69 |
-
* ticket's mandate.
|
| 70 |
-
*/
|
| 71 |
field: {
|
| 72 |
-
view: "Can see this column
|
| 73 |
-
edit: "Can
|
| 74 |
},
|
| 75 |
};
|
| 76 |
|
|
@@ -116,7 +108,21 @@ export default function ShareDialog({
|
|
| 116 |
setError("");
|
| 117 |
void (async () => {
|
| 118 |
try {
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if (!res.ok) {
|
| 121 |
// 4xx text is policy the reader needs; a 5xx's internals are not theirs.
|
| 122 |
if (!dead) setError(res.status >= 500
|
|
@@ -231,16 +237,21 @@ export default function ShareDialog({
|
|
| 231 |
Who can reach <strong>{label}</strong>, and what they can do with it.{" "}
|
| 232 |
{kind === "database"
|
| 233 |
? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant."
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
false of the column: `routes_tables.patch_shared_cell` stamps every grid-created
|
| 238 |
-
shared column `granted: True`, and `perm_scope.field_grant_hidden` then strips it
|
| 239 |
-
from every payload but its creator's and an admin's. So this door is not a widening
|
| 240 |
-
of something already visible, it is the ONLY way anybody else sees the column. */
|
| 241 |
-
? "A shared column starts out hidden from everybody else on this database. This is what lets the people you choose see it and its values."
|
| 242 |
-
: "Sharing never widens past this workspace: everyone here can already open the surface it lives on."}
|
| 243 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
|
| 245 |
{!state && !error ? (
|
| 246 |
<div className="shell-share-wait">
|
|
@@ -350,19 +361,9 @@ export default function ShareDialog({
|
|
| 350 |
void save(next).then((ok) => {
|
| 351 |
if (ok) {
|
| 352 |
onToast(
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
the only consumer of a field grant and it tests `role is None`, so
|
| 357 |
-
both roles buy the SAME thing (the column stops being hidden) and
|
| 358 |
-
neither one lets anybody write a cell β `types.mayEditField` decides
|
| 359 |
-
that from the field's own `permissions.edit`. A toast is the one
|
| 360 |
-
sentence a person reads after granting, so it is the worst place to
|
| 361 |
-
promise a capability the product does not honour. It names the
|
| 362 |
-
visibility that actually changed instead. See `ROLE_BLURB.field`. */
|
| 363 |
-
kind === "field"
|
| 364 |
-
? `${nameOf(pick)} can now see this field.`
|
| 365 |
-
: `${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`
|
| 366 |
);
|
| 367 |
setPick("");
|
| 368 |
}
|
|
|
|
| 39 |
field: "field",
|
| 40 |
};
|
| 41 |
|
| 42 |
+
// A newly-created field is posted through the standalone event queue. The
|
| 43 |
+
// share dialog can open before that POST reaches the API, so give field lookups
|
| 44 |
+
// a short, bounded chance to observe the definition before showing a 404.
|
| 45 |
+
const FIELD_SHARE_RETRY_MS = [150, 300, 600, 1000] as const;
|
| 46 |
+
|
| 47 |
/** What each role MEANS on each kind, in the reader's own terms. A role picker
|
| 48 |
+
* whose options are two nouns makes the reader guess; these sentences describe
|
| 49 |
+
* the effective capability at the point where the role is chosen. */
|
|
|
|
| 50 |
const ROLE_BLURB: Record<ShareKind, Record<ShareRole, string>> = {
|
| 51 |
view: {
|
| 52 |
view: "Can open this view. Cannot rename, refilter or delete it.",
|
|
|
|
| 60 |
view: "Can open this database and read its records.",
|
| 61 |
edit: "Can add, edit and delete its records.",
|
| 62 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
field: {
|
| 64 |
+
view: "Can see this column. Cannot change its values or field settings.",
|
| 65 |
+
edit: "Can change this column's values and field settings when the view is editable.",
|
| 66 |
},
|
| 67 |
};
|
| 68 |
|
|
|
|
| 108 |
setError("");
|
| 109 |
void (async () => {
|
| 110 |
try {
|
| 111 |
+
let res: Response;
|
| 112 |
+
for (let attempt = 0; ; attempt += 1) {
|
| 113 |
+
res = await fetch(path, { credentials: CREDENTIALS });
|
| 114 |
+
if (
|
| 115 |
+
res.status !== 404 ||
|
| 116 |
+
kind !== "field" ||
|
| 117 |
+
attempt >= FIELD_SHARE_RETRY_MS.length
|
| 118 |
+
) {
|
| 119 |
+
break;
|
| 120 |
+
}
|
| 121 |
+
await new Promise<void>((resolve) => {
|
| 122 |
+
window.setTimeout(resolve, FIELD_SHARE_RETRY_MS[attempt]);
|
| 123 |
+
});
|
| 124 |
+
if (dead) return;
|
| 125 |
+
}
|
| 126 |
if (!res.ok) {
|
| 127 |
// 4xx text is policy the reader needs; a 5xx's internals are not theirs.
|
| 128 |
if (!dead) setError(res.status >= 500
|
|
|
|
| 237 |
Who can reach <strong>{label}</strong>, and what they can do with it.{" "}
|
| 238 |
{kind === "database"
|
| 239 |
? "Sharing a database shares all of it: every record and every column. There is no per-row or per-column limit on a database grant."
|
| 240 |
+
: kind === "field"
|
| 241 |
+
? "A shared column starts out hidden from everybody else on this database. This is what lets the people you choose see it and its values."
|
| 242 |
+
: "Sharing never widens past this workspace: everyone here can already open the surface it lives on."}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
</p>
|
| 244 |
+
{kind === "view" ? (
|
| 245 |
+
<p className="shell-share-note">
|
| 246 |
+
View access is the general rule. A field's own Share field access takes precedence, so
|
| 247 |
+
a field shared as Can view stays read-only even inside an editable view.
|
| 248 |
+
</p>
|
| 249 |
+
) : kind === "field" ? (
|
| 250 |
+
<p className="shell-share-note">
|
| 251 |
+
Field access takes precedence over the view. Can view keeps values and field settings
|
| 252 |
+
read-only; Can edit allows changes when the view is editable.
|
| 253 |
+
</p>
|
| 254 |
+
) : null}
|
| 255 |
|
| 256 |
{!state && !error ? (
|
| 257 |
<div className="shell-share-wait">
|
|
|
|
| 361 |
void save(next).then((ok) => {
|
| 362 |
if (ok) {
|
| 363 |
onToast(
|
| 364 |
+
kind === "field"
|
| 365 |
+
? `${nameOf(pick)} can now ${pickRole === "edit" ? "edit" : "view"} this field.`
|
| 366 |
+
: `${nameOf(pick)} can now ${pickRole} this ${KIND_WORD[kind]}.`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 367 |
);
|
| 368 |
setPick("");
|
| 369 |
}
|
web/src/ui/icons.tsx
CHANGED
|
@@ -98,7 +98,7 @@ export function BellIcon({ size = 16, className }: { size?: number; className?:
|
|
| 98 |
*
|
| 99 |
* β WHAT D-206 IS ACTUALLY ABOUT, corrected against the source before this was drawn: the row does
|
| 100 |
* NOT render `LockMark`. It renders `MenuIcon "permissions"` β a *different* padlock β and so do
|
| 101 |
-
* "Share view", "Share folder"
|
| 102 |
* one padlock, five meanings, no titles, in menus a person reads at a glance. All three real
|
| 103 |
* `LockMark` sites already name their lock in a `title` (DESIGN.md Β§4), so the offending row was
|
| 104 |
* outside that rule's literal scope while breaking exactly the thing it protects.
|
|
|
|
| 98 |
*
|
| 99 |
* β WHAT D-206 IS ACTUALLY ABOUT, corrected against the source before this was drawn: the row does
|
| 100 |
* NOT render `LockMark`. It renders `MenuIcon "permissions"` β a *different* padlock β and so do
|
| 101 |
+
* "Share view", "Share folder" and "Lock to a locked view". Four rows,
|
| 102 |
* one padlock, five meanings, no titles, in menus a person reads at a glance. All three real
|
| 103 |
* `LockMark` sites already name their lock in a `title` (DESIGN.md Β§4), so the offending row was
|
| 104 |
* outside that rule's literal scope while breaking exactly the thing it protects.
|