Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/aios_session.py +124 -124
- api/deps.py +3 -0
- api/routes_admin.py +594 -33
- api/routes_auth.py +38 -3
- api/routes_automation.py +0 -0
- api/routes_shares.py +505 -410
- platform/aios_grid_fields.json +739 -565
- platform/core/data_binding.py +25 -0
- platform/core/field_permissions.py +213 -5
- platform/core/grid_events.py +171 -2
- platform/core/perm_scope.py +0 -0
- platform/core/shares.py +414 -296
- platform/core/users.py +566 -529
- platform/harness/semantic.py +0 -0
- platform/model/topics/odoo_customers.yml +223 -213
- platform/model/topics/odoo_products.yml +339 -191
- platform/modules/customer_data.py +73 -1
- platform/modules/inventory.py +46 -3
- platform/modules/product_data.py +0 -0
- platform/modules/products.py +109 -2
- web/public/sample_customers.json +574 -558
- web/src/customer-grid/ColumnMenu.tsx +196 -59
- web/src/customer-grid/CustomerGrid.tsx +0 -0
- web/src/customer-grid/RecordDetail.tsx +0 -0
- web/src/customer-grid/ViewSidebar.tsx +0 -0
- web/src/customer-grid/cells.ts +815 -807
- web/src/customer-grid/display.ts +950 -777
- web/src/customer-grid/export.ts +160 -11
- web/src/customer-grid/folders.ts +572 -492
- web/src/customer-grid/iconShapes.ts +840 -805
- web/src/customer-grid/overlayPlacement.ts +156 -37
- web/src/customer-grid/rowStar.css +87 -80
- web/src/customer-grid/types.ts +189 -1
- web/src/customer-grid/useGridColumns.ts +483 -422
- web/src/filter-kit/FieldsHidePanel.tsx +111 -18
- web/src/filter-kit/FilterBuilderPanel.tsx +45 -3
- web/src/filter-kit/ops.ts +426 -400
- web/src/index.css +0 -0
- web/src/settings/DatabasePermsPane.tsx +174 -0
- web/src/settings/ModulePermsList.tsx +101 -11
- web/src/settings/PermsEditor.tsx +0 -0
- web/src/settings/perms.css +173 -4
- web/src/settings/permsModel.ts +0 -0
- web/src/shell/Shell.tsx +0 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v52",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "0b2ab48",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v52",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
0b2ab48
|
api/aios_session.py
CHANGED
|
@@ -1,124 +1,124 @@
|
|
| 1 |
-
"""aios_session.py β X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
|
| 2 |
-
|
| 3 |
-
aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
|
| 4 |
-
|
| 5 |
-
`t` tenant slug Β· `u` username Β· `e` the user's revocation epoch Β· `i` idle deadline Β·
|
| 6 |
-
`x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` β no new dependency,
|
| 7 |
-
and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
|
| 8 |
-
no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
|
| 9 |
-
audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
|
| 10 |
-
|
| 11 |
-
WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
|
| 12 |
-
The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
|
| 13 |
-
cookie carries the epoch it was minted under, every verification re-reads the account's current
|
| 14 |
-
epoch, and a password change
|
| 15 |
-
user dies at once, without a session table.
|
| 16 |
-
|
| 17 |
-
β NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
|
| 18 |
-
per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
|
| 19 |
-
would instead let anyone who has read this repo forge a cookie for any user of any tenant β an
|
| 20 |
-
inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
|
| 21 |
-
happened so the app can say so at startup and a gate can assert it.
|
| 22 |
-
"""
|
| 23 |
-
import base64
|
| 24 |
-
import hmac
|
| 25 |
-
import json
|
| 26 |
-
import os
|
| 27 |
-
import secrets
|
| 28 |
-
import time
|
| 29 |
-
from hashlib import sha256
|
| 30 |
-
|
| 31 |
-
COOKIE_NAME = "aios_session"
|
| 32 |
-
IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
|
| 33 |
-
ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
|
| 34 |
-
|
| 35 |
-
_env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
|
| 36 |
-
EPHEMERAL_SECRET = not _env_secret
|
| 37 |
-
_SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
def _b64(raw: bytes) -> str:
|
| 41 |
-
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
def _unb64(txt: str) -> bytes:
|
| 45 |
-
return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def _sign(payload: bytes) -> str:
|
| 49 |
-
return _b64(hmac.new(_SECRET, payload, sha256).digest())
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def mint(tenant, username, epoch, now=None, absolute_expiry=None):
|
| 53 |
-
"""A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
|
| 54 |
-
window can never extend the 30-day ceiling β that would make the ceiling unreachable."""
|
| 55 |
-
now = int(now if now is not None else time.time())
|
| 56 |
-
claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
|
| 57 |
-
"i": now + IDLE_SECONDS,
|
| 58 |
-
"x": int(absolute_expiry if absolute_expiry is not None
|
| 59 |
-
else now + ABSOLUTE_SECONDS)}
|
| 60 |
-
payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 61 |
-
return f"{_b64(payload)}.{_sign(payload)}", claims
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def read(value, now=None):
|
| 65 |
-
"""Parse + verify a cookie value β claims dict, or None.
|
| 66 |
-
|
| 67 |
-
None for EVERY failure mode β bad shape, bad signature, expired, unparseable. The caller
|
| 68 |
-
answers 401 and must not learn which: an error that distinguishes "signature wrong" from
|
| 69 |
-
"expired" tells a forger whether their key is right.
|
| 70 |
-
"""
|
| 71 |
-
if not value or not isinstance(value, str) or value.count(".") != 1:
|
| 72 |
-
return None
|
| 73 |
-
body, sig = value.split(".", 1)
|
| 74 |
-
try:
|
| 75 |
-
payload = _unb64(body)
|
| 76 |
-
except Exception:
|
| 77 |
-
return None
|
| 78 |
-
# compare_digest on the SIGNATURE, always β a byte-wise early exit is a timing oracle.
|
| 79 |
-
if not hmac.compare_digest(sig, _sign(payload)):
|
| 80 |
-
return None
|
| 81 |
-
try:
|
| 82 |
-
claims = json.loads(payload.decode("utf-8"))
|
| 83 |
-
except Exception:
|
| 84 |
-
return None
|
| 85 |
-
if not isinstance(claims, dict):
|
| 86 |
-
return None
|
| 87 |
-
if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
|
| 88 |
-
return None
|
| 89 |
-
if not claims["u"] or not claims["t"]:
|
| 90 |
-
return None
|
| 91 |
-
try:
|
| 92 |
-
idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
|
| 93 |
-
except (KeyError, TypeError, ValueError):
|
| 94 |
-
return None
|
| 95 |
-
now = int(now if now is not None else time.time())
|
| 96 |
-
if now >= idle or now >= absolute:
|
| 97 |
-
return None
|
| 98 |
-
claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
|
| 99 |
-
return claims
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
def is_secure_request(request):
|
| 103 |
-
"""Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
|
| 104 |
-
unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
|
| 105 |
-
login would appear to succeed and then not work."""
|
| 106 |
-
host = (request.headers.get("host") or "").split(":")[0].lower()
|
| 107 |
-
proto = (request.headers.get("x-forwarded-proto")
|
| 108 |
-
or request.url.scheme or "http").split(",")[0].strip().lower()
|
| 109 |
-
if proto == "https":
|
| 110 |
-
return True
|
| 111 |
-
return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
def set_cookie(response, request, value):
|
| 115 |
-
response.set_cookie(
|
| 116 |
-
COOKIE_NAME, value, httponly=True, samesite="lax",
|
| 117 |
-
secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
def clear_cookie(response, request):
|
| 121 |
-
# Same attributes as when it was set: a browser matches on path/secure/samesite when
|
| 122 |
-
# deleting, and a mismatched delete silently leaves the cookie in place.
|
| 123 |
-
response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
|
| 124 |
-
secure=is_secure_request(request))
|
|
|
|
| 1 |
+
"""aios_session.py β X3: the signed, STATELESS session cookie (EXIT-3a, 2026-07-30).
|
| 2 |
+
|
| 3 |
+
aios_session = base64url(json {t,u,e,i,x}) . base64url(HMAC-SHA256)
|
| 4 |
+
|
| 5 |
+
`t` tenant slug Β· `u` username Β· `e` the user's revocation epoch Β· `i` idle deadline Β·
|
| 6 |
+
`x` absolute expiry. Signed with `AIOS_SESSION_SECRET` using stdlib `hmac` β no new dependency,
|
| 7 |
+
and nothing to store server-side, which is the whole point: EXIT-4 requires a process that holds
|
| 8 |
+
no per-tenant state, and a session TABLE is per-tenant state. D2's Postgres sessions-MIRROR (an
|
| 9 |
+
audit view, not the source of truth) arrives with C-2 and is deliberately deferred.
|
| 10 |
+
|
| 11 |
+
WHY STATELESS STILL REVOKES. A cookie nobody can delete sounds like a cookie nobody can revoke.
|
| 12 |
+
The answer is the `epoch` integer on the user record (`core.users.epoch` / `bump_epoch`): the
|
| 13 |
+
cookie carries the epoch it was minted under, every verification re-reads the account's current
|
| 14 |
+
epoch, and a password change, a deactivation, or (wave 40, R2) any LOGIN bumps it β so every outstanding cookie for that
|
| 15 |
+
user dies at once, without a session table.
|
| 16 |
+
|
| 17 |
+
β NO FALLBACK SECRET, EVER. With `AIOS_SESSION_SECRET` unset this module mints a RANDOM
|
| 18 |
+
per-process key: sessions then die on restart, which is an inconvenience. A hardcoded default
|
| 19 |
+
would instead let anyone who has read this repo forge a cookie for any user of any tenant β an
|
| 20 |
+
inconvenience is the correct trade against a forgery key. `EPHEMERAL_SECRET` records which
|
| 21 |
+
happened so the app can say so at startup and a gate can assert it.
|
| 22 |
+
"""
|
| 23 |
+
import base64
|
| 24 |
+
import hmac
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import secrets
|
| 28 |
+
import time
|
| 29 |
+
from hashlib import sha256
|
| 30 |
+
|
| 31 |
+
COOKIE_NAME = "aios_session"
|
| 32 |
+
IDLE_SECONDS = 8 * 60 * 60 # D2: reissued on every authenticated request
|
| 33 |
+
ABSOLUTE_SECONDS = 30 * 24 * 60 * 60 # D2: a hard ceiling no amount of activity extends
|
| 34 |
+
|
| 35 |
+
_env_secret = os.environ.get("AIOS_SESSION_SECRET") or ""
|
| 36 |
+
EPHEMERAL_SECRET = not _env_secret
|
| 37 |
+
_SECRET = _env_secret.encode("utf-8") if _env_secret else secrets.token_bytes(32)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _b64(raw: bytes) -> str:
|
| 41 |
+
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _unb64(txt: str) -> bytes:
|
| 45 |
+
return base64.urlsafe_b64decode(txt + "=" * (-len(txt) % 4))
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _sign(payload: bytes) -> str:
|
| 49 |
+
return _b64(hmac.new(_SECRET, payload, sha256).digest())
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def mint(tenant, username, epoch, now=None, absolute_expiry=None):
|
| 53 |
+
"""A fresh cookie value. `absolute_expiry` carries over on reissue so refreshing the idle
|
| 54 |
+
window can never extend the 30-day ceiling β that would make the ceiling unreachable."""
|
| 55 |
+
now = int(now if now is not None else time.time())
|
| 56 |
+
claims = {"t": str(tenant), "u": str(username), "e": int(epoch),
|
| 57 |
+
"i": now + IDLE_SECONDS,
|
| 58 |
+
"x": int(absolute_expiry if absolute_expiry is not None
|
| 59 |
+
else now + ABSOLUTE_SECONDS)}
|
| 60 |
+
payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
| 61 |
+
return f"{_b64(payload)}.{_sign(payload)}", claims
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def read(value, now=None):
|
| 65 |
+
"""Parse + verify a cookie value β claims dict, or None.
|
| 66 |
+
|
| 67 |
+
None for EVERY failure mode β bad shape, bad signature, expired, unparseable. The caller
|
| 68 |
+
answers 401 and must not learn which: an error that distinguishes "signature wrong" from
|
| 69 |
+
"expired" tells a forger whether their key is right.
|
| 70 |
+
"""
|
| 71 |
+
if not value or not isinstance(value, str) or value.count(".") != 1:
|
| 72 |
+
return None
|
| 73 |
+
body, sig = value.split(".", 1)
|
| 74 |
+
try:
|
| 75 |
+
payload = _unb64(body)
|
| 76 |
+
except Exception:
|
| 77 |
+
return None
|
| 78 |
+
# compare_digest on the SIGNATURE, always β a byte-wise early exit is a timing oracle.
|
| 79 |
+
if not hmac.compare_digest(sig, _sign(payload)):
|
| 80 |
+
return None
|
| 81 |
+
try:
|
| 82 |
+
claims = json.loads(payload.decode("utf-8"))
|
| 83 |
+
except Exception:
|
| 84 |
+
return None
|
| 85 |
+
if not isinstance(claims, dict):
|
| 86 |
+
return None
|
| 87 |
+
if not isinstance(claims.get("u"), str) or not isinstance(claims.get("t"), str):
|
| 88 |
+
return None
|
| 89 |
+
if not claims["u"] or not claims["t"]:
|
| 90 |
+
return None
|
| 91 |
+
try:
|
| 92 |
+
idle, absolute, epoch = int(claims["i"]), int(claims["x"]), int(claims["e"])
|
| 93 |
+
except (KeyError, TypeError, ValueError):
|
| 94 |
+
return None
|
| 95 |
+
now = int(now if now is not None else time.time())
|
| 96 |
+
if now >= idle or now >= absolute:
|
| 97 |
+
return None
|
| 98 |
+
claims["e"], claims["i"], claims["x"] = epoch, idle, absolute
|
| 99 |
+
return claims
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def is_secure_request(request):
|
| 103 |
+
"""Secure flag ON unless this is plain-HTTP localhost. Set unconditionally in production;
|
| 104 |
+
unset on a local http:// dev origin, where a Secure cookie is simply never sent back and
|
| 105 |
+
login would appear to succeed and then not work."""
|
| 106 |
+
host = (request.headers.get("host") or "").split(":")[0].lower()
|
| 107 |
+
proto = (request.headers.get("x-forwarded-proto")
|
| 108 |
+
or request.url.scheme or "http").split(",")[0].strip().lower()
|
| 109 |
+
if proto == "https":
|
| 110 |
+
return True
|
| 111 |
+
return host not in ("localhost", "127.0.0.1", "[::1]", "::1")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def set_cookie(response, request, value):
|
| 115 |
+
response.set_cookie(
|
| 116 |
+
COOKIE_NAME, value, httponly=True, samesite="lax",
|
| 117 |
+
secure=is_secure_request(request), max_age=ABSOLUTE_SECONDS, path="/")
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def clear_cookie(response, request):
|
| 121 |
+
# Same attributes as when it was set: a browser matches on path/secure/samesite when
|
| 122 |
+
# deleting, and a mismatched delete silently leaves the cookie in place.
|
| 123 |
+
response.delete_cookie(COOKIE_NAME, path="/", httponly=True, samesite="lax",
|
| 124 |
+
secure=is_secure_request(request))
|
api/deps.py
CHANGED
|
@@ -551,8 +551,11 @@ def _user_for(claims):
|
|
| 551 |
# The emergency master is TENANT #0's break-glass, not the platform's: a synthetic
|
| 552 |
# all-access identity minted into another tenant's session would be the exact
|
| 553 |
# cross-tenant widening the rule above closes.
|
|
|
|
|
|
|
| 554 |
return {"username": "admin", "name": "Administrator", "role": "admin",
|
| 555 |
"bus": "all", "modules": "all", "tenant": "royal-imports",
|
|
|
|
| 556 |
"epoch": int(claims.get("e") or 0)}
|
| 557 |
return None
|
| 558 |
|
|
|
|
| 551 |
# The emergency master is TENANT #0's break-glass, not the platform's: a synthetic
|
| 552 |
# all-access identity minted into another tenant's session would be the exact
|
| 553 |
# cross-tenant widening the rule above closes.
|
| 554 |
+
# β WAVE 40 (R2): the same mark `users.verify`'s master branch carries. The two dicts are
|
| 555 |
+
# documented mirrors of each other, so the key rides on both or they are not mirrors.
|
| 556 |
return {"username": "admin", "name": "Administrator", "role": "admin",
|
| 557 |
"bus": "all", "modules": "all", "tenant": "royal-imports",
|
| 558 |
+
"emergency_master": True,
|
| 559 |
"epoch": int(claims.get("e") or 0)}
|
| 560 |
return None
|
| 561 |
|
api/routes_admin.py
CHANGED
|
@@ -607,9 +607,7 @@ def _module_fields(key, session=None):
|
|
| 607 |
f"{key!r} is not a database in this workspace")
|
| 608 |
src = defn.get("fields") or []
|
| 609 |
else:
|
| 610 |
-
|
| 611 |
-
"product_data": aios_grid.product_fields}
|
| 612 |
-
provider = providers.get(key)
|
| 613 |
if provider is None:
|
| 614 |
# Reached only by a caller that skipped `_perm_modules`. Loud, because the alternative
|
| 615 |
# (`[]`) is an empty option list read as an ANSWER β wave 26 item 24, `if ([])` is
|
|
@@ -617,7 +615,32 @@ def _module_fields(key, session=None):
|
|
| 617 |
raise err(400, "ungoverned_module",
|
| 618 |
f"{key!r} has no permission-editor field schema. The editor governs "
|
| 619 |
f"{sorted(providers)} and this tenant's own databases")
|
| 620 |
-
src = provider()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
known = _server_side_vocabularies()
|
| 622 |
out = []
|
| 623 |
for f in src:
|
|
@@ -631,10 +654,145 @@ def _module_fields(key, session=None):
|
|
| 631 |
out.append({"key": f["key"], "label": f.get("label") or f["key"],
|
| 632 |
"type": f.get("type") or "text",
|
| 633 |
**({"options": list(opts)} if isinstance(opts, list) and opts else {}),
|
| 634 |
-
**({"pinned": True} if f.get("pinned") else {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
return out
|
| 636 |
|
| 637 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
def _server_side_vocabularies():
|
| 639 |
"""`{field key: [choice, β¦]}` for choice columns whose vocabulary is CLOSED and known here,
|
| 640 |
but which the grid contract does not declare.
|
|
@@ -654,23 +812,318 @@ def _server_side_vocabularies():
|
|
| 654 |
for the same reason `aios_grid`'s is: this route should not pull the analytics stack at
|
| 655 |
module load.
|
| 656 |
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 665 |
"""
|
| 666 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
import modules.inventory as inventory
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
|
| 675 |
|
| 676 |
def _clean_perms(v, governed_keys=None, session=None):
|
|
@@ -724,11 +1177,59 @@ def _clean_perms(v, governed_keys=None, session=None):
|
|
| 724 |
for key, raw in v.items():
|
| 725 |
if not isinstance(raw, dict):
|
| 726 |
raise err(400, "bad_perms", f"{key}: each entry must be an object")
|
| 727 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
hidden = raw.get("hiddenFields") or []
|
| 729 |
if not isinstance(hidden, list):
|
| 730 |
raise err(400, "bad_perms", f"{key}: hiddenFields must be a list")
|
| 731 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
if bad:
|
| 733 |
# A hiddenFields entry naming nothing hides nothing β and reads as a restriction
|
| 734 |
# that is not there.
|
|
@@ -746,15 +1247,50 @@ def _clean_perms(v, governed_keys=None, session=None):
|
|
| 746 |
if conj not in ("and", "or"):
|
| 747 |
raise err(400, "bad_filter", f"{key}: conj must be 'and' or 'or'")
|
| 748 |
nodes = tree["nodes"]
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
# ββ W38-T19 β `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT.
|
| 759 |
# `raw.get("metrics", True)` rather than a required key: a PUT composed by an older
|
| 760 |
# client, or a copy of a record written before this ticket, must not read as a
|
|
@@ -762,7 +1298,7 @@ def _clean_perms(v, governed_keys=None, session=None):
|
|
| 762 |
# the validator and the wall cannot disagree about what an absent key means.
|
| 763 |
out[key] = {"access": bool(raw.get("access", True)),
|
| 764 |
"filter": clean_tree,
|
| 765 |
-
"hiddenFields": sorted(
|
| 766 |
"metrics": bool(raw.get("metrics", True))}
|
| 767 |
_refuse_unshapeable_bu(out)
|
| 768 |
return out
|
|
@@ -1052,6 +1588,31 @@ def get_perms(username: str, session: Session = Depends(admin_gate)):
|
|
| 1052 |
# ββ W36-T22 / C2 β EVERY listed database, not an `enforced` subset of them. See
|
| 1053 |
# `perms.tenant_governable_modules` for why the flag is deleted rather than defaulted.
|
| 1054 |
governed = [m["key"] for m in modules]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1055 |
perms_out = {}
|
| 1056 |
for k in governed:
|
| 1057 |
e = stored.get(k)
|
|
@@ -1116,7 +1677,7 @@ def get_perms(username: str, session: Session = Depends(admin_gate)):
|
|
| 1116 |
# item 11's *"EVERY database should be able to be toggleable by admin"*, and it is
|
| 1117 |
# the SAME resolver `_clean_perms` validates against, so the picker cannot offer a
|
| 1118 |
# column the validator will reject.
|
| 1119 |
-
"fields_by_module":
|
| 1120 |
|
| 1121 |
|
| 1122 |
@router.put("/admin/users/{username}/perms")
|
|
|
|
| 607 |
f"{key!r} is not a database in this workspace")
|
| 608 |
src = defn.get("fields") or []
|
| 609 |
else:
|
| 610 |
+
provider = _static_field_providers().get(key)
|
|
|
|
|
|
|
| 611 |
if provider is None:
|
| 612 |
# Reached only by a caller that skipped `_perm_modules`. Loud, because the alternative
|
| 613 |
# (`[]`) is an empty option list read as an ANSWER β wave 26 item 24, `if ([])` is
|
|
|
|
| 615 |
raise err(400, "ungoverned_module",
|
| 616 |
f"{key!r} has no permission-editor field schema. The editor governs "
|
| 617 |
f"{sorted(providers)} and this tenant's own databases")
|
| 618 |
+
src = list(provider())
|
| 619 |
+
import core.perm_scope as perm_scope
|
| 620 |
+
# ββ W40-T05 / OWNER I16 β THE USER-GENERATED COLUMNS JOIN THE TOPIC'S VOCABULARY.
|
| 621 |
+
# *"Permission Filters must be able to filter on user-generated Fields too."* The two topic
|
| 622 |
+
# providers above are the STATIC contract (`aios_grid.FIELDS` = 35, `product_fields()` = 40
|
| 623 |
+
# including the built-in Image column), so until this line NO column a user created could
|
| 624 |
+
# ever reach the picker β measured, both topics, zero `custom_` keys in either. The `ut_*`
|
| 625 |
+
# arm never needed it: a user table's stored definition IS its user-generated schema.
|
| 626 |
+
#
|
| 627 |
+
# β ADDITIVE AND DEDUPED, so a declared column always wins its own key β the same rule the
|
| 628 |
+
# metric rows are appended under below. A user column shadowing a contract column would let
|
| 629 |
+
# an admin hide or filter something other than what they read off the label.
|
| 630 |
+
#
|
| 631 |
+
# β NO SESSION MEANS TODAY'S ANSWER, AND THAT IS CORRECT RATHER THAN DEGRADED. A workspace
|
| 632 |
+
# bucket is per-tenant data with no tenant-blind read, and two callers arrive without one:
|
| 633 |
+
# `_fold_legacy_scope` on the live PUT path and `verify_api`'s W30a probe. Both get the
|
| 634 |
+
# pre-set list they have always had, and the legacy fold cannot start dropping a leaf it used
|
| 635 |
+
# to keep, because `_prune_to_module` only ever narrows the LEGACY tree (`dba`/`agent`) to
|
| 636 |
+
# keys the contract already carries.
|
| 637 |
+
if key in _FIELD_PROVIDER_KEYS:
|
| 638 |
+
_have = {f.get("key") for f in src if isinstance(f, dict)}
|
| 639 |
+
for _uf in (perm_scope.user_generated_fields(
|
| 640 |
+
key, st=getattr(session, "runtime", None)) or ()):
|
| 641 |
+
if isinstance(_uf, dict) and _uf.get("key") and _uf["key"] not in _have:
|
| 642 |
+
_have.add(_uf["key"])
|
| 643 |
+
src.append(_uf)
|
| 644 |
known = _server_side_vocabularies()
|
| 645 |
out = []
|
| 646 |
for f in src:
|
|
|
|
| 654 |
out.append({"key": f["key"], "label": f.get("label") or f["key"],
|
| 655 |
"type": f.get("type") or "text",
|
| 656 |
**({"options": list(opts)} if isinstance(opts, list) and opts else {}),
|
| 657 |
+
**({"pinned": True} if f.get("pinned") else {}),
|
| 658 |
+
# ββ CONTRACT C3 / AM-2 β THE FOUR MEMBERSHIP BOOLEANS, ON EVERY ROW.
|
| 659 |
+
# READ OFF THE FIELD'S OWN DECLARATION, never inferred from its key, because
|
| 660 |
+
# the declaration is the authority the GRID already uses:
|
| 661 |
+
# `aios_grid.clean_measure_field` writes `custom: True` on a user-created
|
| 662 |
+
# column, the product contract declares `shared: true` on its nine
|
| 663 |
+
# tenant-wide-value columns, and `filterable: False` is how a column states
|
| 664 |
+
# the engine cannot filter it (`est_missed` is the one that does).
|
| 665 |
+
#
|
| 666 |
+
# β `shared` IS INFORMATIONAL AND MUST NEVER BECOME THE HIDE RULE. AM-2's
|
| 667 |
+
# rule is `metric || !custom`. Nine declared PRODUCT columns carry
|
| 668 |
+
# `shared: true` (`first_cost`, `supplier`, `notes`, ...) and every one of
|
| 669 |
+
# them has to stay hideable β `verify_api`'s legacy-fold leg hides
|
| 670 |
+
# `first_cost` by name. A client that excluded rows on `shared` would revoke
|
| 671 |
+
# the admin's control over nine pre-set columns, which is I15's own defect
|
| 672 |
+
# one word over.
|
| 673 |
+
"custom": bool(f.get("custom")),
|
| 674 |
+
"shared": (bool(f.get("shared"))
|
| 675 |
+
or f.get(perm_scope.FIELD_GRANT_MARK) is True),
|
| 676 |
+
"filterable": f.get("filterable") is not False,
|
| 677 |
+
"metric": False})
|
| 678 |
+
# ββ I13 β THE METRIC ROWS, APPENDED LAST so no declared column is ever displaced, and
|
| 679 |
+
# skipped on a key collision so a declared column always wins its own key.
|
| 680 |
+
taken = {f["key"] for f in out}
|
| 681 |
+
out.extend(m for m in _metric_fields(key) if m["key"] not in taken)
|
| 682 |
+
return out
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
def _metric_fields(key):
|
| 686 |
+
"""One `measure_`-namespaced pseudo-field per measure BOUND to `key`'s entity topic.
|
| 687 |
+
|
| 688 |
+
ββ I13 β THE ROW THE OWNER ASKED FOR: *"Fold 'Metrics fields (lookback measures over
|
| 689 |
+
this database)' into the 'Hide fields' checkboxes, one row per metric, named
|
| 690 |
+
'Metric - Revenue', 'Metric - Order' and so on, so a user can check the ones the
|
| 691 |
+
permissioning is limited to."* Until this existed the entire capability was ONE boolean
|
| 692 |
+
(`perm_scope.may_metrics`), so an administrator could revoke every metric on a database or
|
| 693 |
+
none of them.
|
| 694 |
+
|
| 695 |
+
ββ AND IT IS WHY I15 HAD NO CONTROL TO OFFER. *"Shantal has access to gross profit."*
|
| 696 |
+
"Gross profit" is the measure `margin` β label "Gross margin $", defined at
|
| 697 |
+
`platform/model/metrics/sales.yml::margin` and bound onto this grid by
|
| 698 |
+
`platform/model/topics/odoo_products.yml::measures`. **It is not a column in
|
| 699 |
+
`aios_grid_fields.json` and never was**, so a picker built from the declared contract alone
|
| 700 |
+
could not list it however carefully that contract was read. No key literally named
|
| 701 |
+
`gross_profit` reaches this grid at all.
|
| 702 |
+
|
| 703 |
+
β THE OFFER IS READ, NEVER RESTATED. `semantic.entity_measures` is the ONE catalogue, and
|
| 704 |
+
it REFUSES a key it cannot prove β a half-synced mirror, a metric with no `empty:` family,
|
| 705 |
+
a format with no grid type β with the cause available through `entity_measure_refusals`.
|
| 706 |
+
Re-measured 2026-08-24 in this tree, after W40-T10 bound the channel split: the product topic
|
| 707 |
+
declares **27 key-slots across five bindings** and the catalogue offers **24**;
|
| 708 |
+
`stock_in`/`stock_out`/`stock_net` are refused because `stock_move` has not finished syncing.
|
| 709 |
+
Hardcoding a declared list would offer three columns that render blank today AND would stop
|
| 710 |
+
growing the day a binding is added. The offer widens on its own when the mirror catches up,
|
| 711 |
+
because this asks rather than remembers β and the eighteen channel rows below
|
| 712 |
+
("Metric - Revenue (Fisch)", "Metric - Revenue (Amazon)", β¦) arrived here with NO change to
|
| 713 |
+
this function for exactly that reason.
|
| 714 |
+
|
| 715 |
+
ββ W40-T10 / owner item 26 (R7) β AND THAT IS WHY THE CHANNEL RIDES THE MEASURE KEY. A
|
| 716 |
+
per-metric channel spelled as a member of the stored field spec would be invisible here:
|
| 717 |
+
this row is minted from `m["key"]` and `m["label"]`, so a channel carried anywhere else
|
| 718 |
+
would give an admin eighteen columns they can neither tell apart nor hide separately.
|
| 719 |
+
`semantic._one_binding_offer` therefore namespaces the key and appends "(Fisch)" to the
|
| 720 |
+
label, and both halves land in this vocabulary for free.
|
| 721 |
+
|
| 722 |
+
β THE TOPIC IS DERIVED, NEVER MAPPED. `semantic.topic_for_grid` reads the `grid:` key the
|
| 723 |
+
topic file already declares, which is the reason its own docstring gives for existing: a
|
| 724 |
+
`{module: topic}` literal in a route would be a second statement of one fact and would
|
| 725 |
+
drift the day a topic is renamed. It answers for every arm β `product_data` ->
|
| 726 |
+
`odoo_products`, `customer_data` -> `odoo_customers` (which binds none), `ut_odoo_agents` ->
|
| 727 |
+
`odoo_agents` (which binds seven) β so this needs NO per-module branch, and a database
|
| 728 |
+
with no bound measure simply receives no rows.
|
| 729 |
+
|
| 730 |
+
ββ IT CANNOT RAISE, AND THAT IS LOAD-BEARING RATHER THAN DEFENSIVE. Two callers reach
|
| 731 |
+
`_module_fields` with NO session and outside any store fixture: `_fold_legacy_scope`, on the
|
| 732 |
+
live migration path of a PUT, and `verify_api`'s W30a probe, which runs ABOVE the line that
|
| 733 |
+
installs the fake store. A raise on either is not a red gate but a CRASHED one
|
| 734 |
+
[[gate-must-go-red-not-crash]], and on the route it is a 500 on the administrator's own save.
|
| 735 |
+
`entity_measures` already degrades to `[]` when the mirror cannot be read; this makes the
|
| 736 |
+
whole derivation degrade with it, so the payload simply carries no metric rows.
|
| 737 |
+
|
| 738 |
+
β `filterable: False`, AND IT IS A MEASUREMENT RATHER THAN A PREFERENCE. A pseudo-key is
|
| 739 |
+
not a column any row carries β a real Metric column is keyed `measure_<slug>_<rand>` by
|
| 740 |
+
`CustomerGrid.createField`. Measured: `clean_filter_tree` ACCEPTS a leaf on `measure_margin`
|
| 741 |
+
once the key is admitted, and `harness.filter_eval.permits` then answers **False for every
|
| 742 |
+
product row**, because the row has no such key. That is exactly the "deny-everything trap
|
| 743 |
+
wearing a saved-successfully toast" `_module_fields`' own docstring forbids. The governed way
|
| 744 |
+
to filter on a measure is the measure CONDITION channel (`measure_sets`, CG-8), not a field
|
| 745 |
+
leaf, so these rows join the HIDE vocabulary only.
|
| 746 |
+
|
| 747 |
+
β THE PREFIX IS `aios_grid.MEASURE_FIELD_PREFIX`, IMPORTED. Spelling "measure_" here would
|
| 748 |
+
be a second copy of a constant two features already share
|
| 749 |
+
[[constant-two-features-share]], and `verify_fields_contract` gates that the client keys its
|
| 750 |
+
real measure columns off the same one.
|
| 751 |
+
"""
|
| 752 |
+
try:
|
| 753 |
+
import aios_grid
|
| 754 |
+
import harness.semantic as semantic
|
| 755 |
+
topic = semantic.topic_for_grid(key)
|
| 756 |
+
if not topic:
|
| 757 |
+
return []
|
| 758 |
+
offer = semantic.entity_measures(topic)
|
| 759 |
+
except Exception: # noqa: BLE001
|
| 760 |
+
return []
|
| 761 |
+
out = []
|
| 762 |
+
for m in offer or ():
|
| 763 |
+
if not isinstance(m, dict):
|
| 764 |
+
continue
|
| 765 |
+
mkey = str(m.get("key") or "").strip()
|
| 766 |
+
if not mkey:
|
| 767 |
+
continue
|
| 768 |
+
# β THE LABEL IS THE MEASURE'S OWN, prefixed. A hand-written second vocabulary here
|
| 769 |
+
# would read "Metric - Gross profit" while the grid column read "Gross margin $", and an
|
| 770 |
+
# admin cannot hide what they cannot recognise. ASCII hyphen, exactly as the owner wrote
|
| 771 |
+
# it β CLAUDE.md rule 2 bars a dash in copy that reaches a screen.
|
| 772 |
+
label = str(m.get("label") or mkey).strip()
|
| 773 |
+
mtype = m.get("type")
|
| 774 |
+
out.append({"key": f"{aios_grid.MEASURE_FIELD_PREFIX}{mkey}",
|
| 775 |
+
"label": f"Metric - {label}",
|
| 776 |
+
"type": mtype if mtype in aios_grid.MEASURE_FIELD_TYPES else "currency",
|
| 777 |
+
"custom": False,
|
| 778 |
+
"shared": False,
|
| 779 |
+
"filterable": False,
|
| 780 |
+
"metric": True})
|
| 781 |
return out
|
| 782 |
|
| 783 |
|
| 784 |
+
#: The value every blank Odoo connector attribute displays as. The writer is
|
| 785 |
+
#: `modules/customers._partner_attrs` (`... or '(none)'`) and the client mirror is
|
| 786 |
+
#: `customer-grid/types.ts::CONNECTOR_BLANK`. It is a DELIBERATE, documented, visible value, so a
|
| 787 |
+
#: choice vocabulary that omitted it would deny an admin any rule about the blank rows.
|
| 788 |
+
BLANK = "(none)"
|
| 789 |
+
|
| 790 |
+
#: `_server_side_vocabularies`' per-process memo. `None` until a COMPLETE build succeeds; a build
|
| 791 |
+
#: with any failed arm is returned but NOT stored, so a transient Odoo outage cannot pin a short
|
| 792 |
+
#: answer for the life of the worker.
|
| 793 |
+
_VOCAB_CACHE = None
|
| 794 |
+
|
| 795 |
+
|
| 796 |
def _server_side_vocabularies():
|
| 797 |
"""`{field key: [choice, β¦]}` for choice columns whose vocabulary is CLOSED and known here,
|
| 798 |
but which the grid contract does not declare.
|
|
|
|
| 812 |
for the same reason `aios_grid`'s is: this route should not pull the analytics stack at
|
| 813 |
module load.
|
| 814 |
|
| 815 |
+
ββ W40-T06 / OWNER I11+I23 β NINE MORE CHOICE COLUMNS, AND WHY THE OLD REFUSAL EXPIRED.
|
| 816 |
+
Ruling R5 retyped ten Odoo customer columns off `text`, so nine of them now reach the branch
|
| 817 |
+
above and D-236 (`category` renders an EMPTY dropdown here) would have been multiplied by
|
| 818 |
+
nine rather than fixed. `category` was refused in this docstring because "discovering it
|
| 819 |
+
would mean a pool read on an admin route". That reason was WRONG about the cost, not about
|
| 820 |
+
the principle: none of the reads below is a pool read. Each is either a `read_group` over
|
| 821 |
+
`res.partner` (one grouped row per distinct value) or a whole small master table, and the
|
| 822 |
+
dearest of them was MEASURED at 1.8s cold, once per process.
|
| 823 |
+
|
| 824 |
+
ββ A VOCABULARY MUST MATCH THE CELL, NOT THE RAW SOURCE, and the pricelist is the proof.
|
| 825 |
+
`modules/customers._partner_attrs` writes `O.m2o_name(property_product_pricelist)`, which is
|
| 826 |
+
Odoo's DISPLAY name: the cell reads "Fisch (USD)". `product.pricelist.name` reads "Fisch".
|
| 827 |
+
A vocabulary built from `name` would therefore offer five options, and every one of them
|
| 828 |
+
would match ZERO rows. So every arm below reproduces the writer's own expression:
|
| 829 |
+
`display_name` for an m2o, `.strip().title()` for `city`, the `', '.join(...)` for the m2m
|
| 830 |
+
`agent`, and `'(none)'` wherever `_partner_attrs` collapses a blank (the set of fields that
|
| 831 |
+
do is documented in the contract JSON's own `_comment`).
|
| 832 |
+
MEASURED against real cells over all 5,282 partners, 2026-08-23: country 20 offered / 20
|
| 833 |
+
observed, state 64 / 64, agent 14 / 13, city 1,254 / 1,245, pricelist 5 / 4,
|
| 834 |
+
payment_terms 15 / 10, tags 6 / 5. β Read the direction: NOT ONE observed cell value is
|
| 835 |
+
missing from what is offered. The overshoot is partners outside the grid's own 24 month
|
| 836 |
+
population, and it is the SAFE direction. A missing option is the dangerous one, because it
|
| 837 |
+
silently denies the admin any rule about those rows.
|
| 838 |
+
|
| 839 |
+
β `tags` IS A MULTISELECT, so it is offered as MEMBERS, not as joined cells.
|
| 840 |
+
`choiceVocabulary` splits a multiselect cell on commas, so "Fisch, Royal" must arrive as
|
| 841 |
+
"Fisch" and "Royal". `agent` stays a SINGLE select per R5 and therefore keeps its joined
|
| 842 |
+
combinations. β NOTE FOR THE OWNER, not acted on here: `agent` is comma joined multi value
|
| 843 |
+
exactly as `tags` is (13 observed cells are combinations such as "Avi Ash, Martin
|
| 844 |
+
Pasternak"), so R5's own argument for making Tags a multiselect applies to it too. R5 rules
|
| 845 |
+
single select, so single select is what this builds.
|
| 846 |
+
|
| 847 |
+
β DEGRADE BY OMITTING THE KEY, NEVER BY RETURNING `[]`. `FilterBuilderPanel::choicesFor`
|
| 848 |
+
treats a supplied list as authoritative INCLUDING when it is empty, so an empty list asserts
|
| 849 |
+
"this column has no values". Every arm is therefore isolated in its own try/except and a
|
| 850 |
+
failed read leaves its key OUT, exactly as the original single-arm version did.
|
| 851 |
+
|
| 852 |
+
β CACHED PER PROCESS, because `_module_fields` runs on every admin GET and on every PUT via
|
| 853 |
+
`_fold_legacy_scope`. Only SUCCESSES are cached, so a transient Odoo outage cannot pin an
|
| 854 |
+
empty answer for the life of the worker. STALE WHEN: someone adds or renames a pricelist,
|
| 855 |
+
payment term, partner tag or product category; a customer moves city, state or country; an
|
| 856 |
+
agent is assigned or unassigned; a new salesperson takes their first order. None of those is
|
| 857 |
+
observable from here, and the refresh is a process restart, which a deploy already does.
|
| 858 |
+
|
| 859 |
+
β ONE COLUMN IS STILL DELIBERATELY ABSENT, and R6's second sentence says to name it rather
|
| 860 |
+
than let it look handled:
|
| 861 |
+
Β· `odoo_status` (customer) β WAS bare and is NOT any more: lane E answered `ASK D-15` by
|
| 862 |
+
declaring `options: ['Active','Archived']` on the CONTRACT, which is the right home (the
|
| 863 |
+
grid picks a declared list up for free) and is why this function never needed an arm for
|
| 864 |
+
it. Recorded because the ask, not a guess, is what settled it.
|
| 865 |
"""
|
| 866 |
+
global _VOCAB_CACHE
|
| 867 |
+
if _VOCAB_CACHE is not None:
|
| 868 |
+
return dict(_VOCAB_CACHE)
|
| 869 |
+
|
| 870 |
+
out = {}
|
| 871 |
+
|
| 872 |
+
def arm(fn):
|
| 873 |
+
"""Run one source. A failure omits its keys and never touches the others."""
|
| 874 |
+
try:
|
| 875 |
+
for key, values in (fn() or {}).items():
|
| 876 |
+
clean = sorted({str(v) for v in values if str(v or "").strip()})
|
| 877 |
+
if clean:
|
| 878 |
+
out[key] = clean
|
| 879 |
+
return True
|
| 880 |
+
except Exception: # noqa: BLE001
|
| 881 |
+
return False
|
| 882 |
+
|
| 883 |
+
def _stock_bucket():
|
| 884 |
+
# IMPORTED, NEVER RESTATED: the same constant `_bucket()` mints from, so a renamed band
|
| 885 |
+
# cannot leave a second vocabulary behind [[constant-two-features-share]]. Lazy for the
|
| 886 |
+
# same reason `aios_grid`'s import is: no analytics stack at module load.
|
| 887 |
import modules.inventory as inventory
|
| 888 |
+
return {"stock_bucket": list(inventory.COVERAGE_LABELS or ())}
|
| 889 |
+
|
| 890 |
+
def _categories():
|
| 891 |
+
import modules.inventory as inventory
|
| 892 |
+
# W40-T06: the root maps to None now, so "All" cannot reach either list. The sentinel is
|
| 893 |
+
# what `products.catalogue` and `sales._product_cat` substitute for that None.
|
| 894 |
+
cats = sorted({v for v in inventory._cat_main_map().values() if v})
|
| 895 |
+
return {"category": cats + ["(uncategorized)"],
|
| 896 |
+
"top_category": cats + ["(uncategorized)", BLANK]}
|
| 897 |
+
|
| 898 |
+
def _suppliers():
|
| 899 |
+
"""The product `supplier` column's observed values.
|
| 900 |
+
|
| 901 |
+
ββ THIS ARM EXISTS BECAUSE W40-T06 CREATED THE HOLE IT FILLS. `supplier` was `text`
|
| 902 |
+
until I18 made it a multiselect, and a choice column with no declared options and no
|
| 903 |
+
entry here is precisely D-236's empty dropdown. The flip would otherwise have ADDED a
|
| 904 |
+
tenth blank picker while this ticket was removing nine.
|
| 905 |
+
|
| 906 |
+
ββ DECISION OWED, AND IT IS VISIBLE ON SCREEN: 18 of the 88 distinct suppliers carry a
|
| 907 |
+
COMMA INSIDE THE COMPANY NAME ("CANDLE ARTISANS, INC", "BAOBEI INT'L CO.,LTD"), spanning
|
| 908 |
+
371 product codes. A multiselect cell is by contract a comma-joined SET, and
|
| 909 |
+
`types.ts::choiceVocabulary` SPLITS it, so the grid's own row-derived picker will offer
|
| 910 |
+
26 fragments ("CANDLE ARTISANS", "INC") where 18 real suppliers should be. That is a
|
| 911 |
+
data fact about supplier names, not a bug in the splitter: unlike `tags`, `supplier` is
|
| 912 |
+
a SINGLE value that merely contains punctuation. This function serves the WHOLE values,
|
| 913 |
+
which is what the cells actually hold and what an admin means to filter on. The kind
|
| 914 |
+
itself is the owner's to settle (`select` would make the collision disappear); I18 says
|
| 915 |
+
multi-select, so multi-select is what the contract carries.
|
| 916 |
+
|
| 917 |
+
β SEED VALUES, NOT LIVE CELLS. `supplier` is an OVERLAY column a user may retype per
|
| 918 |
+
record, and those edits live in the tenant's workspace, which this route cannot read
|
| 919 |
+
tenant-blind. So a supplier somebody typed by hand is missing from this list until the
|
| 920 |
+
mastersheet carries it. Cheap and safe: `supplier_master()` is a local file cached per
|
| 921 |
+
process, and it degrades to `{}` rather than raising.
|
| 922 |
+
"""
|
| 923 |
+
import modules.product_data as product_data
|
| 924 |
+
return {"supplier": [(v.get("supplier") or "").strip()
|
| 925 |
+
for v in (product_data.supplier_master() or {}).values()]}
|
| 926 |
+
|
| 927 |
+
def _masters():
|
| 928 |
+
import core.odoo as O
|
| 929 |
+
got = {}
|
| 930 |
+
for key, model in (("pricelist", "product.pricelist"),
|
| 931 |
+
("payment_terms", "account.payment.term"),
|
| 932 |
+
("tags", "res.partner.category")):
|
| 933 |
+
# display_name, never name: see the pricelist proof in the docstring.
|
| 934 |
+
got[key] = [c["display_name"] for c in O.search_read(model, [], ["display_name"])]
|
| 935 |
+
if key != "pricelist": # measured: every customer carries a pricelist
|
| 936 |
+
got[key] = got[key] + [BLANK]
|
| 937 |
+
return got
|
| 938 |
+
|
| 939 |
+
def _partner_dims():
|
| 940 |
+
import core.odoo as O
|
| 941 |
+
rg = lambda field: O.read_group("res.partner", [], ["id"], [field], lazy=False)
|
| 942 |
+
return {
|
| 943 |
+
"country": [O.m2o_name(r.get("country_id")) or BLANK for r in rg("country_id")],
|
| 944 |
+
"state": [O.m2o_name(r.get("state_id")) or BLANK for r in rg("state_id")],
|
| 945 |
+
# `.strip().title()` is `_partner_attrs`' own expression. Raw mirror values would
|
| 946 |
+
# offer "NEW YORK" where the cell says "New York": no match, in both directions.
|
| 947 |
+
"city": [(r.get("city") or "").strip().title() or BLANK for r in rg("city")],
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
def _agents():
|
| 951 |
+
import core.odoo as O
|
| 952 |
+
rows = O.search_read("res.partner", [], ["agent_ids"])
|
| 953 |
+
ids = {i for r in rows for i in (r.get("agent_ids") or [])}
|
| 954 |
+
names = {p["id"]: p["name"]
|
| 955 |
+
for p in O.search_read("res.partner", [("id", "in", list(ids))], ["name"])}
|
| 956 |
+
return {"agent": [", ".join(n for n in (names.get(i) for i in (r.get("agent_ids") or []))
|
| 957 |
+
if n) or BLANK for r in rows]}
|
| 958 |
+
|
| 959 |
+
def _salespeople():
|
| 960 |
+
# The ORDER TAKER over LTM (`sale.order.user_id`), which is what the column holds, NOT
|
| 961 |
+
# `res.users`: `modules/customer_data._salesperson_attrs` is the writer, and it reads the
|
| 962 |
+
# order's taker. Grouped, so the result is one row per distinct person.
|
| 963 |
+
import core.odoo as O
|
| 964 |
+
import core.periods as P
|
| 965 |
+
import modules.sales as S
|
| 966 |
+
mf, mt = P.ltm(P.today())
|
| 967 |
+
rows = O.read_group("sale.order", S.order_domain(str(mf), str(mt), None),
|
| 968 |
+
["amount_untaxed:sum"], ["user_id"], lazy=False)
|
| 969 |
+
return {"salesperson": [O.m2o_name(r.get("user_id")) or BLANK for r in rows]}
|
| 970 |
+
|
| 971 |
+
complete = all([arm(source) for source in
|
| 972 |
+
(_stock_bucket, _categories, _suppliers, _masters, _partner_dims,
|
| 973 |
+
_agents, _salespeople)])
|
| 974 |
+
if complete:
|
| 975 |
+
_VOCAB_CACHE = dict(out)
|
| 976 |
+
return out
|
| 977 |
+
|
| 978 |
+
|
| 979 |
+
def _prunable_vocabulary(key, session=None, fields=None):
|
| 980 |
+
"""The field keys `key` HAS right now β or `None` when that is not trustworthy enough to
|
| 981 |
+
prune a stored permission filter against.
|
| 982 |
+
|
| 983 |
+
ββ THE RETURN IS A SAFETY DEVICE, NOT A CONVENIENCE. The cascade below DELETES a filter leaf
|
| 984 |
+
naming a key that is not in this set, so a set that is short by one column revokes a live
|
| 985 |
+
permission rule β permanently, silently, and in the WIDENING direction. Three separate ways
|
| 986 |
+
to be short are refused here rather than papered over:
|
| 987 |
+
|
| 988 |
+
* a TOPIC whose workspace bucket could not be read (`user_generated_fields` answers `None`,
|
| 989 |
+
which is exactly why it distinguishes that from `[]`). This is the case that would fire
|
| 990 |
+
under ordinary store contention, and it is the dangerous one: measured in this tree, a
|
| 991 |
+
busy DuckDB already takes `_module_fields('product_data')` from 45 rows to 39.
|
| 992 |
+
* a SURFACE (Assistant, Agents), whose vocabulary is `[]` by design. Pruning against an
|
| 993 |
+
empty set would drop every leaf of anything stored there.
|
| 994 |
+
* any key whose schema read RAISES β an unknown module, a `ut_*` database whose definition
|
| 995 |
+
the store cannot serve.
|
| 996 |
+
|
| 997 |
+
β `fields` is threaded so the ONE `_module_fields` call a caller has already paid for is
|
| 998 |
+
reused. Recomputing it would double a read that costs a store round-trip per module on a
|
| 999 |
+
route that already makes one per governed database.
|
| 1000 |
+
"""
|
| 1001 |
+
import core.perm_scope as perm_scope
|
| 1002 |
+
|
| 1003 |
+
key = str(key or "")
|
| 1004 |
+
if key in _FIELD_PROVIDER_KEYS:
|
| 1005 |
+
if perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None)) is None:
|
| 1006 |
+
return None
|
| 1007 |
+
elif not key.startswith("ut_"):
|
| 1008 |
+
return None
|
| 1009 |
+
if fields is None:
|
| 1010 |
+
try:
|
| 1011 |
+
fields = _module_fields(key, session=session)
|
| 1012 |
+
except Exception: # noqa: BLE001
|
| 1013 |
+
return None
|
| 1014 |
+
return {f["key"] for f in fields if isinstance(f, dict) and f.get("key")} or None
|
| 1015 |
+
|
| 1016 |
+
|
| 1017 |
+
def _prune_stale_filters(block, session=None):
|
| 1018 |
+
"""`(block, {module: [dropped keys]})` β owner I16's cascade over a whole stored perms block.
|
| 1019 |
+
|
| 1020 |
+
ββ *"If the field is deleted, its permission filter goes with it."* Run at the ADMIN DOOR,
|
| 1021 |
+
on the record, never in the wall: `perm_scope.prune_filter_to_fields`' own docstring carries
|
| 1022 |
+
the reason (`verify_perm_scope`'s "a wall naming a DELETED column denies every row" leg must
|
| 1023 |
+
stay green, and it asserts on `apply_row_scope` directly).
|
| 1024 |
+
|
| 1025 |
+
β NON-DESTRUCTIVE and entry-wise: an entry with no filter, an entry whose vocabulary will not
|
| 1026 |
+
resolve, and an entry with nothing stale all come back as the object that went in. Only an
|
| 1027 |
+
entry that actually lost a leaf is rebuilt, so a caller can use the returned map as its
|
| 1028 |
+
"is a write needed" test rather than comparing documents.
|
| 1029 |
+
"""
|
| 1030 |
+
import core.perm_scope as perm_scope
|
| 1031 |
+
|
| 1032 |
+
out, dropped = dict(block or {}), {}
|
| 1033 |
+
for key, entry in (block or {}).items():
|
| 1034 |
+
if not isinstance(entry, dict) or not entry.get("filter"):
|
| 1035 |
+
continue
|
| 1036 |
+
vocab = _prunable_vocabulary(key, session=session)
|
| 1037 |
+
if vocab is None:
|
| 1038 |
+
continue
|
| 1039 |
+
tree, gone = perm_scope.prune_filter_to_fields(entry["filter"], vocab)
|
| 1040 |
+
if gone:
|
| 1041 |
+
out[key] = dict(entry, filter=tree)
|
| 1042 |
+
dropped[key] = gone
|
| 1043 |
+
return out, dropped
|
| 1044 |
+
|
| 1045 |
+
|
| 1046 |
+
def _static_field_providers():
|
| 1047 |
+
"""The STATIC contract behind each topic grid, read by TWO callers.
|
| 1048 |
+
|
| 1049 |
+
`_module_fields` builds the picker from it and then APPENDS the user-generated columns;
|
| 1050 |
+
`_row_wall_blind_keys` needs the same set to tell those two apart. A second copy of this map
|
| 1051 |
+
would let them disagree about which columns are the database's own, and the disagreement
|
| 1052 |
+
would show up as a permission filter refused on a column that was never user-generated.
|
| 1053 |
+
"""
|
| 1054 |
+
import aios_grid
|
| 1055 |
+
|
| 1056 |
+
return {"customer_data": lambda: aios_grid.FIELDS,
|
| 1057 |
+
"product_data": aios_grid.product_fields}
|
| 1058 |
+
|
| 1059 |
+
|
| 1060 |
+
def _leaf_col_ids(nodes):
|
| 1061 |
+
"""Every `colId` a filter tree names, at any depth. Groups carry `children`; leaves do not."""
|
| 1062 |
+
found = set()
|
| 1063 |
+
for node in nodes or ():
|
| 1064 |
+
if not isinstance(node, dict):
|
| 1065 |
+
continue
|
| 1066 |
+
if isinstance(node.get("children"), list):
|
| 1067 |
+
found |= _leaf_col_ids(node["children"])
|
| 1068 |
+
elif node.get("colId"):
|
| 1069 |
+
found.add(str(node["colId"]))
|
| 1070 |
+
return found
|
| 1071 |
+
|
| 1072 |
+
|
| 1073 |
+
def _row_wall_blind_keys(key, session=None):
|
| 1074 |
+
"""User-generated columns of a TOPIC grid, which the row wall structurally cannot answer.
|
| 1075 |
+
|
| 1076 |
+
THE DEFECT THIS REFUSES. W40-T05 taught `_module_fields` to append
|
| 1077 |
+
`perm_scope.user_generated_fields`, so the permission editor now OFFERS a column a user made
|
| 1078 |
+
(owner instruction 16), and this validator accepted a wall naming it. But on the two topic
|
| 1079 |
+
grids the wall runs at `routes_customers.py:277` / `:495` and `routes_products.py:118` as
|
| 1080 |
+
`apply_row_scope(rows, user, MODULE, <the STATIC contract>)`, over PRE-OVERLAY rows: the
|
| 1081 |
+
column is neither declared in that field list nor present on the row, and
|
| 1082 |
+
`filter_eval.permits` DENIES every leaf it cannot answer.
|
| 1083 |
+
|
| 1084 |
+
Measured on the integrated head, one leaf {colId: fld_region, op: eq, value: West}, two rows:
|
| 1085 |
+
|
| 1086 |
+
static contract + pre-overlay rows (THE LIVE CALL SITE) -> [] every row denied
|
| 1087 |
+
column declared + rows carrying the value -> ['1'] correct
|
| 1088 |
+
column declared + pre-overlay rows -> []
|
| 1089 |
+
a DECLARED odoo column, same shape -> ['1'] the evaluator is fine
|
| 1090 |
+
|
| 1091 |
+
So an administrator saves a rule, is told it saved, and that account opens an empty grid with
|
| 1092 |
+
nothing on screen saying why. Fail-CLOSED, and one click undoes it, but silent.
|
| 1093 |
+
|
| 1094 |
+
REFUSED AT THE WRITE, WHERE A PERSON IS PRESENT TO FIX IT, which is the same call
|
| 1095 |
+
`_refuse_unshapeable_bu` makes further down this file for the same reason. The alternative was
|
| 1096 |
+
to move the wall after the overlay merge, and that is not a small change: `apply_row_scope`
|
| 1097 |
+
runs before `pids` is taken precisely so a row this account may not see never enters the
|
| 1098 |
+
workspace, the cohorts or the measures. Reshaping that at QA time to fix a silent-deny would
|
| 1099 |
+
trade a visible defect for an invisible one.
|
| 1100 |
+
|
| 1101 |
+
NARROW BY CONSTRUCTION, three ways. Only the two TOPIC grids: a `ut_*` database enforces
|
| 1102 |
+
correctly today, because its stored definition IS its user-generated schema and its rows carry
|
| 1103 |
+
the values. Only the FILTER: `hiddenFields` still accepts these columns, because hiding is
|
| 1104 |
+
applied to the assembled field list, which does have them. And only columns the static
|
| 1105 |
+
contract does NOT declare, so a user column shadowing a real one cannot make the real one
|
| 1106 |
+
unfilterable.
|
| 1107 |
+
|
| 1108 |
+
`user_generated_fields` answers `None` when the vocabulary cannot be read, and `None` is
|
| 1109 |
+
handled by NOT narrowing, which is correct rather than lax: on that same answer
|
| 1110 |
+
`_module_fields` appended nothing, so there is nothing in `filter_keys` to take out.
|
| 1111 |
+
|
| 1112 |
+
THE PROPER FIX IS BOOKED, NOT DONE: teach the three doors to wall AFTER the overlay merge with
|
| 1113 |
+
a field list that declares the column. Until then instruction 16 is delivered for `ut_*`
|
| 1114 |
+
databases and REPORTED as unavailable on the two Odoo topics, which is what CLAUDE.md rule 1
|
| 1115 |
+
demands of a limit that cannot yet be removed.
|
| 1116 |
+
"""
|
| 1117 |
+
if key not in _FIELD_PROVIDER_KEYS:
|
| 1118 |
+
return set()
|
| 1119 |
+
import core.perm_scope as perm_scope
|
| 1120 |
+
|
| 1121 |
+
provider = _static_field_providers().get(key)
|
| 1122 |
+
declared = {f.get("key") for f in (provider() if provider else ())
|
| 1123 |
+
if isinstance(f, dict)}
|
| 1124 |
+
found = perm_scope.user_generated_fields(key, st=getattr(session, "runtime", None))
|
| 1125 |
+
return {f["key"] for f in (found or ())
|
| 1126 |
+
if isinstance(f, dict) and f.get("key") and f["key"] not in declared}
|
| 1127 |
|
| 1128 |
|
| 1129 |
def _clean_perms(v, governed_keys=None, session=None):
|
|
|
|
| 1177 |
for key, raw in v.items():
|
| 1178 |
if not isinstance(raw, dict):
|
| 1179 |
raise err(400, "bad_perms", f"{key}: each entry must be an object")
|
| 1180 |
+
# ββ AM-2 β ONE SOURCE, TWO VOCABULARIES, AND THE VALIDATOR READS BOTH. The
|
| 1181 |
+
# picker and this validator still call the SAME function, which is the property
|
| 1182 |
+
# `_module_fields`' docstring exists to protect; what changed is that a field now states
|
| 1183 |
+
# which vocabulary it belongs to, and each consumer reads its own membership instead of
|
| 1184 |
+
# the whole list.
|
| 1185 |
+
#
|
| 1186 |
+
# β THE SPLIT IS WHY THE METRIC ROWS ARE SAFE TO ADD. `hiddenFields` is validated
|
| 1187 |
+
# against EVERY key (a metric row is hideable β that is I13's whole point), while the
|
| 1188 |
+
# permanent FILTER is validated against `filterable` only. Without the second set,
|
| 1189 |
+
# admitting `measure_margin` would make {"colId": "measure_margin", ...} a storable
|
| 1190 |
+
# permanent wall, and `permits()` answers False for every row that has no such key:
|
| 1191 |
+
# measured, not feared. `clean_filter_tree` takes bare strings and cannot see the flag,
|
| 1192 |
+
# so the narrowing has to happen HERE, at the call.
|
| 1193 |
+
fields_here = _module_fields(key, session=session)
|
| 1194 |
+
valid_keys = {f["key"] for f in fields_here}
|
| 1195 |
+
filter_keys = {f["key"] for f in fields_here if f.get("filterable")}
|
| 1196 |
+
# W40-T05 x W40-T18, see `_row_wall_blind_keys`. A user-generated column of a TOPIC grid
|
| 1197 |
+
# is HIDEABLE (it stays in `valid_keys`) and NOT FILTERABLE, because the row wall on those
|
| 1198 |
+
# two doors runs over pre-overlay rows and denies every leaf it cannot answer.
|
| 1199 |
+
row_wall_blind = _row_wall_blind_keys(key, session)
|
| 1200 |
+
filter_keys -= row_wall_blind
|
| 1201 |
hidden = raw.get("hiddenFields") or []
|
| 1202 |
if not isinstance(hidden, list):
|
| 1203 |
raise err(400, "bad_perms", f"{key}: hiddenFields must be a list")
|
| 1204 |
+
submitted = {str(h) for h in hidden}
|
| 1205 |
+
# ββ A METRIC TICK IS ADMITTED ON ITS PREFIX, NOT ON THE LIVE OFFER, AND THAT CLOSES A
|
| 1206 |
+
# GET/PUT SKEW RATHER THAN WEAKENING THE GUARD.
|
| 1207 |
+
#
|
| 1208 |
+
# `_metric_fields` asks `semantic.entity_measures`, which REFUSES every key while the
|
| 1209 |
+
# mirror cannot be read β measured twice in this tree on 2026-08-23, when another
|
| 1210 |
+
# process held the DuckDB and the offer came back empty. That answer is deliberately not
|
| 1211 |
+
# cached (`_ENTITY_OFFER_CACHE` skips an indeterminate one), so the state recurs freely.
|
| 1212 |
+
# Without this line the sequence is: the editor GETs a payload carrying
|
| 1213 |
+
# `measure_margin`, the admin ticks "Metric - Gross margin $", the PUT lands a moment
|
| 1214 |
+
# later while the store is busy, `valid_keys` has 39 entries instead of 45, and the
|
| 1215 |
+
# administrator is told their own tick *"names unknown fields"*. That is this function's
|
| 1216 |
+
# ONE SOURCE invariant running backwards β the validator rejecting exactly what the
|
| 1217 |
+
# picker offered.
|
| 1218 |
+
#
|
| 1219 |
+
# β AND ADMITTING IT IS CORRECT, not merely convenient, because the ENFORCEMENT keys off
|
| 1220 |
+
# the measure NAME and not off the offer: `perm_scope._measure_bound_keys` hides every
|
| 1221 |
+
# column whose `measure.key` matches, whether or not the catalogue is answering right now.
|
| 1222 |
+
# So a tick stored during a warm-up walls the right columns the moment they render. A
|
| 1223 |
+
# metric key that never comes back hides nothing, which is the harmless direction; the
|
| 1224 |
+
# refusal is the harmful one.
|
| 1225 |
+
#
|
| 1226 |
+
# β NARROW BY CONSTRUCTION: only the `measure_` namespace is admitted this way, and only
|
| 1227 |
+
# with a non-empty suffix. Every other unknown key is still refused, because a
|
| 1228 |
+
# `hiddenFields` entry naming nothing reads as a restriction that is not there.
|
| 1229 |
+
_mpre = aios_grid.MEASURE_FIELD_PREFIX
|
| 1230 |
+
metric_ticks = {h for h in submitted
|
| 1231 |
+
if h.startswith(_mpre) and len(h) > len(_mpre)}
|
| 1232 |
+
bad = sorted(submitted - valid_keys - metric_ticks)
|
| 1233 |
if bad:
|
| 1234 |
# A hiddenFields entry naming nothing hides nothing β and reads as a restriction
|
| 1235 |
# that is not there.
|
|
|
|
| 1247 |
if conj not in ("and", "or"):
|
| 1248 |
raise err(400, "bad_filter", f"{key}: conj must be 'and' or 'or'")
|
| 1249 |
nodes = tree["nodes"]
|
| 1250 |
+
# ββ THE CASCADE DOES **NOT** RUN ON THE SUBMITTED TREE, AND W40-T05 SHIPPED THE
|
| 1251 |
+
# OPPOSITE FOR AN HOUR. It pruned here first, so that an administrator with a stale
|
| 1252 |
+
# tab would not be told their own saved rule was a client error. Sympathetic, and
|
| 1253 |
+
# WRONG: `verify_api`'s leg *"β a filter naming an unknown FIELD -> 400, never
|
| 1254 |
+
# saved-minus-the-bad-leaf"* went red on the very case it exists for β a PUT naming
|
| 1255 |
+
# `ghost` was accepted and stored minus that leaf. This validator is a SECURITY door,
|
| 1256 |
+
# and the sentence the refusal below already carried says why: a wall saved with its
|
| 1257 |
+
# bad conditions silently removed is WEAKER THAN THE ONE ON SCREEN.
|
| 1258 |
+
#
|
| 1259 |
+
# β SO THE CASCADE LIVES ON THE **STORED** RECORD ONLY, in `get_perms` (see
|
| 1260 |
+
# `_prunable_vocabulary`'s own docstring). That is the honest split, and it is enough
|
| 1261 |
+
# for I16: the leaf disappears when the record is next read, which is the same page
|
| 1262 |
+
# load a stale tab has to do anyway. A leaf in a payload the client just SENT is
|
| 1263 |
+
# refused, loudly, whatever its history β the server cannot tell a deleted column from
|
| 1264 |
+
# a typo in a submission, and only one of those two guesses is safe.
|
| 1265 |
+
# β AN EMPTIED FILTER IS `None`, NEVER `{"conj": "and", "nodes": []}`. Measured:
|
| 1266 |
+
# `filter_eval.permits` returns True on an empty node list, so an empty-but-present
|
| 1267 |
+
# tree admits every row β while `perm_scope.wall_declared` and `row_scope_applies`
|
| 1268 |
+
# both read it as TRUTHY and answer that a wall applies. A door would then build rows
|
| 1269 |
+
# in order to filter them against nothing, and the editor would paint a rule that is
|
| 1270 |
+
# not there. This also normalises a client that PUTs `{"nodes": []}` to mean "no
|
| 1271 |
+
# filter", which is what it has always meant on screen.
|
| 1272 |
+
if nodes:
|
| 1273 |
+
# Named before the generic refusal below, because "an unknown field" is exactly
|
| 1274 |
+
# what this is NOT: the picker offered it one request ago. The admin needs to be
|
| 1275 |
+
# told which column and why, or they will simply try again.
|
| 1276 |
+
blind = sorted(_leaf_col_ids(nodes) & row_wall_blind)
|
| 1277 |
+
if blind:
|
| 1278 |
+
raise err(400, "bad_filter",
|
| 1279 |
+
f"{key}: a permission filter cannot use {blind}. Those columns are "
|
| 1280 |
+
f"stored per record on top of this database rather than in it, and "
|
| 1281 |
+
f"the row wall runs before they are merged, so the rule would hide "
|
| 1282 |
+
f"EVERY row from this account with nothing on screen saying why. "
|
| 1283 |
+
f"Hide the column instead, or filter on one of the database's own "
|
| 1284 |
+
f"columns.")
|
| 1285 |
+
cleaned = aios_grid.clean_filter_tree(nodes, filter_keys, cohort_ids=None)
|
| 1286 |
+
if _leaf_count(cleaned) != _leaf_count(nodes):
|
| 1287 |
+
raise err(400, "bad_filter",
|
| 1288 |
+
f"{key}: the filter contains conditions this module cannot evaluate "
|
| 1289 |
+
f"(an unknown field, an unknown operator, or a cohort leaf β cohorts "
|
| 1290 |
+
f"are per-user and cannot be a permanent rule). Refused rather than "
|
| 1291 |
+
f"saved with the bad conditions silently removed, which would store "
|
| 1292 |
+
f"a weaker wall than the one on screen.")
|
| 1293 |
+
clean_tree = {"conj": conj, "nodes": cleaned}
|
| 1294 |
# ββ W38-T19 β `metrics` IS THE FOURTH FIELD OF AN ENTRY, AND ITS DEFAULT IS GRANT.
|
| 1295 |
# `raw.get("metrics", True)` rather than a required key: a PUT composed by an older
|
| 1296 |
# client, or a copy of a record written before this ticket, must not read as a
|
|
|
|
| 1298 |
# the validator and the wall cannot disagree about what an absent key means.
|
| 1299 |
out[key] = {"access": bool(raw.get("access", True)),
|
| 1300 |
"filter": clean_tree,
|
| 1301 |
+
"hiddenFields": sorted(submitted),
|
| 1302 |
"metrics": bool(raw.get("metrics", True))}
|
| 1303 |
_refuse_unshapeable_bu(out)
|
| 1304 |
return out
|
|
|
|
| 1588 |
# ββ W36-T22 / C2 β EVERY listed database, not an `enforced` subset of them. See
|
| 1589 |
# `perms.tenant_governable_modules` for why the flag is deleted rather than defaulted.
|
| 1590 |
governed = [m["key"] for m in modules]
|
| 1591 |
+
# β ONE `_module_fields` PASS, SHARED BY THE PICKER AND THE CASCADE. It used to be built
|
| 1592 |
+
# inline at the return; hoisted so the prune below reads the SAME vocabulary the payload
|
| 1593 |
+
# offers rather than paying a second store round-trip per database to ask again.
|
| 1594 |
+
fields_by_module = {k: _module_fields(k, session=session) for k in governed}
|
| 1595 |
+
# ββ W40-T05 / OWNER I16 β THE CASCADE, AT THE ADMIN DOOR. *"If the field is deleted, its
|
| 1596 |
+
# permission filter goes with it."* A stored leaf naming a column the database no longer has
|
| 1597 |
+
# is not ignored by the wall β `apply_row_scope` uses `permits`, which DENIES what it cannot
|
| 1598 |
+
# answer β so a deleted column silently converts that account's grid to zero rows. Dropping
|
| 1599 |
+
# the leaf here means the editor renders, and the record stores, only rules that still exist.
|
| 1600 |
+
#
|
| 1601 |
+
# ββ GUARDED ON `is_migrated`, AND THAT GUARD IS A BU LEAK AWAY FROM OPTIONAL.
|
| 1602 |
+
# `users.set_access(perms=β¦)` STAMPS `perms_v` in the same read-modify-write, so writing here
|
| 1603 |
+
# would MIGRATE an un-migrated record β and `_fold_legacy_scope` runs only WHILE a record is
|
| 1604 |
+
# un-migrated. A GET would then consume the one chance to fold the legacy `bus`/`agent` scope
|
| 1605 |
+
# in, and the account would silently acquire the other business unit's customers. The
|
| 1606 |
+
# `stored` test alone would very nearly cover it (an un-migrated record carries no perms
|
| 1607 |
+
# block), so the marker is asked explicitly rather than inferred from an empty dict.
|
| 1608 |
+
if stored and perm_scope.is_migrated(rec):
|
| 1609 |
+
_pruned, _gone = _prune_stale_filters(stored, session=session)
|
| 1610 |
+
if _gone:
|
| 1611 |
+
users.set_access(uname, perms=_pruned)
|
| 1612 |
+
# Re-read rather than trust the write: this route's whole job is to report what is
|
| 1613 |
+
# STORED, and a store that took nothing must not be reported as if it had.
|
| 1614 |
+
rec = _registry().get(uname) or rec
|
| 1615 |
+
stored = rec.get("perms") or _pruned
|
| 1616 |
perms_out = {}
|
| 1617 |
for k in governed:
|
| 1618 |
e = stored.get(k)
|
|
|
|
| 1677 |
# item 11's *"EVERY database should be able to be toggleable by admin"*, and it is
|
| 1678 |
# the SAME resolver `_clean_perms` validates against, so the picker cannot offer a
|
| 1679 |
# column the validator will reject.
|
| 1680 |
+
"fields_by_module": fields_by_module}
|
| 1681 |
|
| 1682 |
|
| 1683 |
@router.put("/admin/users/{username}/perms")
|
api/routes_auth.py
CHANGED
|
@@ -180,7 +180,41 @@ def login(request: Request, response: Response, body: dict = Body(default=None))
|
|
| 180 |
raise err(401, "invalid_credentials", "that username and password do not match")
|
| 181 |
|
| 182 |
_clear_failures(throttle_key)
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
aios_session.set_cookie(response, request, value)
|
| 185 |
# Wave 19 (R4): the login stamp. THE RESOLVED username, never the posted identifier β the
|
| 186 |
# person may have typed an email address and `users.verify` resolved it to the registry key;
|
|
@@ -214,8 +248,9 @@ def logout(request: Request):
|
|
| 214 |
|
| 215 |
β This clears the BROWSER's copy only β the signed value stays cryptographically valid until
|
| 216 |
it expires, which is the honest cost of a stateless session. "Sign me out everywhere" is
|
| 217 |
-
`core.users.bump_epoch` (a password change already does it
|
| 218 |
-
|
|
|
|
| 219 |
"""
|
| 220 |
out = Response(status_code=204)
|
| 221 |
aios_session.clear_cookie(out, request)
|
|
|
|
| 180 |
raise err(401, "invalid_credentials", "that username and password do not match")
|
| 181 |
|
| 182 |
_clear_failures(throttle_key)
|
| 183 |
+
|
| 184 |
+
# β WAVE 40, R2 β SINGLE SESSION, NEWEST WINS. Signing in here signs out every session this
|
| 185 |
+
# account already holds, so a forgotten sign-in on someone else's computer cannot lock the
|
| 186 |
+
# owner of the account out of it. NO SESSION REGISTRY IS BUILT and none is needed: the cookie
|
| 187 |
+
# is stateless and carries the epoch it was minted under, `deps._user_for` admits only on an
|
| 188 |
+
# exact match, so one bump kills every outstanding cookie for this user at once.
|
| 189 |
+
#
|
| 190 |
+
# β MINT FROM THE BUMPED VALUE, NEVER FROM `user["epoch"]` AGAIN. `user` was projected BEFORE
|
| 191 |
+
# the bump; minting from it would issue every cookie in the product at the OLD epoch, which
|
| 192 |
+
# `_user_for` refuses on the very next request β "200, then 401 forever".
|
| 193 |
+
#
|
| 194 |
+
# TWO IDENTITIES ARE EXEMPT, both by design:
|
| 195 |
+
# * `qa_runner` β the synthetic staging identity has no registry record to bump (its epoch is
|
| 196 |
+
# derived from APP_PASSWORD), and `verify_api.py` asserts its login path reads and writes
|
| 197 |
+
# NOTHING on tenant #0's `users` key, which a bump's read-modify-write would violate.
|
| 198 |
+
# * `emergency_master` β `deps._user_for` admits that identity whatever the record's epoch
|
| 199 |
+
# says, so a bump revokes nothing there and only risks a store write on the break-glass
|
| 200 |
+
# path that exists precisely for a store outage.
|
| 201 |
+
#
|
| 202 |
+
# A FAILED BUMP MUST NOT FAIL THE LOGIN. `store.update` raises `StoreWriteRefused` where the
|
| 203 |
+
# deployment may not write this tenant (mapped to 503 in `main.py`) and `RuntimeError` when the
|
| 204 |
+
# store is unavailable, so an unguarded bump would turn every sign-in on such a deployment into
|
| 205 |
+
# a 503. The sign-in stands at the epoch it can prove; only the sign-out-everywhere half is
|
| 206 |
+
# lost, and the log says so rather than leaving it silent.
|
| 207 |
+
session_epoch = int(user.get("epoch") or 0)
|
| 208 |
+
if not (user.get("qa_runner") or user.get("emergency_master")):
|
| 209 |
+
try:
|
| 210 |
+
bumped = users.bump_epoch(user["username"])
|
| 211 |
+
if bumped is not None:
|
| 212 |
+
session_epoch = int(bumped)
|
| 213 |
+
except Exception as exc:
|
| 214 |
+
print(f"[aios-api] login for {user['username']!r}: could not bump the session epoch "
|
| 215 |
+
f"({type(exc).__name__}). This sign-in stands, but the account's previous "
|
| 216 |
+
f"session was NOT signed out.")
|
| 217 |
+
value, _claims = aios_session.mint(tenant, user["username"], session_epoch)
|
| 218 |
aios_session.set_cookie(response, request, value)
|
| 219 |
# Wave 19 (R4): the login stamp. THE RESOLVED username, never the posted identifier β the
|
| 220 |
# person may have typed an email address and `users.verify` resolved it to the registry key;
|
|
|
|
| 248 |
|
| 249 |
β This clears the BROWSER's copy only β the signed value stays cryptographically valid until
|
| 250 |
it expires, which is the honest cost of a stateless session. "Sign me out everywhere" is
|
| 251 |
+
`core.users.bump_epoch` (a password change already does it, and since wave 40's R2 so does
|
| 252 |
+
every LOGIN β signing in anywhere signs out every other session the account holds); D2's
|
| 253 |
+
Postgres session mirror makes per-device revocation possible and arrives with C-2.
|
| 254 |
"""
|
| 255 |
out = Response(status_code=204)
|
| 256 |
aios_session.clear_cookie(out, request)
|
api/routes_automation.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_shares.py
CHANGED
|
@@ -1,90 +1,111 @@
|
|
| 1 |
-
"""routes_shares.py β the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
|
| 2 |
-
|
| 3 |
-
GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
|
| 4 |
-
PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
|
| 5 |
-
GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
|
| 6 |
-
|
| 7 |
-
`kind` β view | folder | database | field. Roles are `view` | `edit` β the same two words the
|
| 8 |
-
view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
|
| 9 |
-
vocabulary in the UI (R10: "the same picker views use").
|
| 10 |
-
|
| 11 |
-
ββ **W38-T16 β `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
|
| 12 |
-
(`shares.field_oid`). A bare column key repeats across databases β `notes` exists on a dozen β
|
| 13 |
-
so a grant stored under one would admit the grantee to every `notes` column in the tenant at
|
| 14 |
-
once. β THREE functions in this file branch on kind and ALL THREE need the new one, which is not
|
| 15 |
-
obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
|
| 16 |
-
is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
|
| 17 |
-
`_notify_new_grantees` returns early, and the grantee is **granted and never told** β owner item
|
| 18 |
-
18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
|
| 19 |
-
anything that is not a view.
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
the
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
#
|
| 78 |
-
#
|
| 79 |
-
#
|
| 80 |
-
#
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
_BUILTIN_TOPICS = ("customer", "product")
|
| 89 |
|
| 90 |
|
|
@@ -136,38 +157,38 @@ def _field_owner(session, definition, already_shared):
|
|
| 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
|
| 142 |
-
tenant #0) to answer a question about KEYS (D-185).
|
| 143 |
-
"""
|
| 144 |
-
try:
|
| 145 |
-
import core.user_tables as ut
|
| 146 |
-
return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
|
| 147 |
-
except Exception: # noqa: BLE001
|
| 148 |
-
return _BUILTIN_TOPICS
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
def _owns_object(session, kind, oid):
|
| 152 |
-
"""May this caller CLAIM an object that has no grant record yet β i.e. do they own it?
|
| 153 |
-
|
| 154 |
-
β THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
|
| 155 |
-
each topic's workspace in turn, which is N store reads; making every share call pay that
|
| 156 |
-
would put a loop on a route the manage-access dialog opens. The dangerous path is the one
|
| 157 |
-
where a caller is about to be stamped OWNER of something nobody owns β so the resolution runs
|
| 158 |
-
exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
|
| 159 |
-
"""
|
| 160 |
-
if session.admin:
|
| 161 |
-
return True
|
| 162 |
-
if kind == "field":
|
| 163 |
-
# ββ W38-T16 β A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
|
| 164 |
-
# (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
|
| 165 |
-
# D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
|
| 166 |
-
# deletable by one person and shareable by another.
|
| 167 |
-
# β THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
|
| 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
|
|
@@ -175,118 +196,118 @@ def _owns_object(session, kind, oid):
|
|
| 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"
|
| 181 |
-
# here would be the second definition this wave keeps finding.
|
| 182 |
-
try:
|
| 183 |
-
import core.user_tables as ut
|
| 184 |
-
return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
|
| 185 |
-
st=session.runtime))
|
| 186 |
-
except Exception: # noqa: BLE001
|
| 187 |
-
return False
|
| 188 |
-
try:
|
| 189 |
-
import core.table_store as table_store
|
| 190 |
-
except Exception: # noqa: BLE001
|
| 191 |
-
return False
|
| 192 |
-
for topic in _topics(session):
|
| 193 |
-
try:
|
| 194 |
-
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
|
| 195 |
-
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
|
| 196 |
-
except Exception: # noqa: BLE001
|
| 197 |
-
continue
|
| 198 |
-
if hit:
|
| 199 |
-
# `find_view`/`find_folder` answer `(owner_username, β¦)`. The claim belongs to the
|
| 200 |
-
# person whose personal stratum holds it β anybody else reaching this line is
|
| 201 |
-
# exactly the case S-1 describes.
|
| 202 |
-
return str(hit[0]) == str(session.uname)
|
| 203 |
-
return False
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
def _can_see_object(session, kind, oid):
|
| 207 |
-
"""May this caller READ an object's grant list β i.e. can they reach the object at all?
|
| 208 |
-
|
| 209 |
-
ββ THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
|
| 210 |
-
REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
|
| 211 |
-
ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
|
| 212 |
-
PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
|
| 213 |
-
`permissions.edit = "collaborative"` and no grant record yet β a view bob **can open and edit
|
| 214 |
-
in the grid** β answered `404` when bob opened its manage-access dialog. Measured before
|
| 215 |
-
fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
|
| 216 |
-
`404 no_object`.
|
| 217 |
-
β THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
|
| 218 |
-
and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
|
| 219 |
-
`permissions` (system B) β which is the one that actually decides who may OPEN it.
|
| 220 |
-
β And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
|
| 221 |
-
gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β
|
| 222 |
-
'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
|
| 223 |
-
legitimate collaborator their view does not exist.
|
| 224 |
-
|
| 225 |
-
β THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
|
| 226 |
-
owner β that is S-1, and widening this predicate onto `put_share` would re-open it.
|
| 227 |
-
"""
|
| 228 |
-
if _owns_object(session, kind, oid):
|
| 229 |
-
return True
|
| 230 |
-
if kind != "view":
|
| 231 |
-
# A folder carries no per-object visibility flag of its own, and a database's `may_open`
|
| 232 |
-
# (inside `_owns_object`) already admits grantees. Nothing wider to ask.
|
| 233 |
-
# β W38-T16 β AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
|
| 234 |
-
# `get_share` tests `role is None` first and a grant answers a role, so the only caller
|
| 235 |
-
# left is an account with no relationship to the column at all. Widening it would let any
|
| 236 |
-
# signed-in session enumerate who holds which column on a database they cannot open.
|
| 237 |
-
return False
|
| 238 |
-
try:
|
| 239 |
-
import core.table_store as table_store
|
| 240 |
-
for topic in _topics(session):
|
| 241 |
-
hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
|
| 242 |
-
if hit:
|
| 243 |
-
return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
|
| 244 |
-
session.uname, is_admin=session.admin))
|
| 245 |
-
except Exception: # noqa: BLE001
|
| 246 |
-
return False
|
| 247 |
-
return False
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
def _entries_or_400(session, entries):
|
| 251 |
-
"""Validate a grant list against the tenant's REAL, ACTIVE accounts β and refuse BY NAME.
|
| 252 |
-
|
| 253 |
-
β `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
|
| 254 |
-
a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
|
| 255 |
-
of a string and the role word β never that the user EXISTS, is ACTIVE, or is in this tenant**,
|
| 256 |
-
so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
|
| 257 |
-
sharer believes the person has access. That is item 18's plain reading.
|
| 258 |
-
β The correct population is computed THREE FUNCTIONS BELOW and served to the picker
|
| 259 |
-
(`_people`). One route, two populations, and the write door was the permissive one.
|
| 260 |
-
β `*` (everyone) is not a user and is admitted deliberately β it is R10's vocabulary for
|
| 261 |
-
"every account that can already open the surface".
|
| 262 |
-
"""
|
| 263 |
-
known = {p["username"].strip().lower() for p in _people(session.tenant)}
|
| 264 |
-
unknown = []
|
| 265 |
-
for e in entries or ():
|
| 266 |
-
if not isinstance(e, dict):
|
| 267 |
-
continue
|
| 268 |
-
user = str(e.get("user") or "").strip().lower()
|
| 269 |
-
if user and user != shares.EVERYONE and user not in known:
|
| 270 |
-
unknown.append(user)
|
| 271 |
-
if unknown:
|
| 272 |
-
raise err(400, "unknown_people",
|
| 273 |
-
"no active account in this workspace is named "
|
| 274 |
-
+ ", ".join(sorted(set(unknown)))
|
| 275 |
-
+ ". Nothing was shared. Pick people from the list rather than typing a name.")
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
@router.get("/share/mine")
|
| 279 |
-
def my_shares(session: Session = Depends(require_session)):
|
| 280 |
-
"""Everything shared WITH me, by kind β the "Shared with me" rail section (R10).
|
| 281 |
-
|
| 282 |
-
Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
|
| 283 |
-
in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
|
| 284 |
-
that is not malformed at all.
|
| 285 |
-
"""
|
| 286 |
-
return shares.shared_with(session.uname, st=session.runtime)
|
| 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":
|
|
@@ -303,50 +324,57 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
|
| 303 |
# a Member who owns an unshared View receives the people picker; a collaborator still does not.
|
| 304 |
if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
|
| 305 |
may_admin = True
|
| 306 |
-
# β W32-T26 (audit S-3) β A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
|
| 307 |
-
# who owns it, everyone it is granted to, and the tenant's whole usernameβname directory β
|
| 308 |
-
# to any signed-in session, about objects it cannot open. Now a caller with no role on an
|
| 309 |
-
# object must prove they can reach it, and gets a 404 otherwise: the same answer a
|
| 310 |
-
# non-existent id gives, so the route cannot be used to probe which ids are real.
|
| 311 |
-
# β `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
|
| 312 |
-
# caller who has no relationship with the object at all.
|
| 313 |
-
if role is None and not _can_see_object(session, kind, oid):
|
| 314 |
-
raise err(404, "no_object", "no such item, or it is not shared with this account")
|
| 315 |
-
return {
|
| 316 |
-
**rec,
|
| 317 |
-
"role": role,
|
| 318 |
-
"mayAdminister": may_admin,
|
| 319 |
-
# β WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
|
| 320 |
-
# them. `assignable_people` serves bare display names because `user`-kind CELLS store
|
| 321 |
-
# display names β that list's shape cannot change without migrating cell values β so
|
| 322 |
-
# this route serves objects of its own. Existing grants that were written as lowercased
|
| 323 |
-
# display names are normalised by the wave-21 cleanup script.
|
| 324 |
-
# β W32-T26 (audit S-3) β the roster is the EDITOR's data, so it rides only for a caller
|
| 325 |
-
# who may open the editor. A read-only grantee gets the grant list (their fair question is
|
| 326 |
-
# "who else has this?") and not a directory of every account in the workspace.
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)
|
|
@@ -355,31 +383,88 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
|
| 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 |
-
#
|
| 360 |
-
#
|
| 361 |
-
#
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 378 |
if not isinstance(entries, list):
|
| 379 |
raise err(400, "bad_entries",
|
| 380 |
"entries must be a list of {user, role}. Send [] to un-share, which is how "
|
| 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
|
|
@@ -399,91 +484,101 @@ def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
st=session.runtime)
|
| 404 |
-
_notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
|
| 405 |
-
return out
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
def _notify_new_grantees(session, kind, oid, before, after):
|
| 409 |
-
"""ββ W32-T28 (owner item 18's last clause, contract C3) β tell the RECEIVER, in their Inbox.
|
| 410 |
-
|
| 411 |
-
Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
|
| 412 |
-
silent: the grant landed in a rail section the receiver had to notice on their own, which is
|
| 413 |
-
why "I shared it with you" and "I never saw it" were both true.
|
| 414 |
-
|
| 415 |
-
β WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
|
| 416 |
-
because an alert is a live question about rows; a share is an EVENT that happened once, and
|
| 417 |
-
polling for it would mean re-deriving "was this new?" on every inbox open β the diff below
|
| 418 |
-
only exists here, at the moment the set changes.
|
| 419 |
-
|
| 420 |
-
β ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
|
| 421 |
-
so every save re-sends everyone who was already there. Diffing against `before` is what stops
|
| 422 |
-
a rename or a role change from ringing the bell for people whose access did not change.
|
| 423 |
-
β `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
|
| 424 |
-
the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
|
| 425 |
-
β IT NEVER RAISES. A notification that fails must not fail the share that triggered it β the
|
| 426 |
-
grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
|
| 427 |
-
"""
|
| 428 |
-
try:
|
| 429 |
-
was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
|
| 430 |
-
fresh = [str(e.get("user")) for e in (after or ())
|
| 431 |
-
if isinstance(e, dict) and e.get("user") not in was
|
| 432 |
-
and e.get("user") != shares.EVERYONE]
|
| 433 |
-
if not fresh:
|
| 434 |
-
return
|
| 435 |
-
import core.alerts as alerts
|
| 436 |
-
|
| 437 |
-
label, route, view_id = _object_ref(session, kind, oid)
|
| 438 |
-
if not route:
|
| 439 |
-
# β NO ROUTE, NO NOTIFICATION β the receiver would get a row that opens nothing, and
|
| 440 |
-
# `notification_view` would have to invent a target. Silence is the honest answer
|
| 441 |
-
# here; the rail still shows the grant under "Shared with me".
|
| 442 |
-
return
|
| 443 |
-
sharer = str(session.user.get("name") or session.uname)
|
| 444 |
-
for user in fresh:
|
| 445 |
-
# β THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
|
| 446 |
-
# agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
|
| 447 |
-
# `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
|
| 448 |
-
# from there rather than typed again β one vocabulary, one owner.
|
| 449 |
-
# ββ W33-T28 (`ASK C-14`, answered) β `actor` IS THE SENDER, AND IT IS THE ONLY WAY
|
| 450 |
-
# THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
|
| 451 |
-
# are honestly named by their machine; a SHARE has a real person, and only this call
|
| 452 |
-
# site knows who. β It is passed as its OWN field rather than recovered from the
|
| 453 |
-
# `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
|
| 454 |
-
# the first time the sentence is reworded, silently, in the header
|
| 455 |
-
# [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
|
| 456 |
-
alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
|
| 457 |
-
detail=f"{sharer} shared this with you", actor=sharer,
|
| 458 |
-
st=session.runtime)
|
| 459 |
-
except Exception: # noqa: BLE001
|
| 460 |
-
return
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
def _object_ref(session, kind, oid):
|
| 464 |
-
"""`(label, route, view_id)` β what to CALL the shared thing, and where it OPENS.
|
| 465 |
-
|
| 466 |
-
β THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
|
| 467 |
-
WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
|
| 468 |
-
`target: {module: "database", id: "view_42"}` β an instruction to open a database named
|
| 469 |
-
`view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
|
| 470 |
-
addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
|
| 471 |
-
travel. Caught by looking at the notification the driver actually produced, not by reading
|
| 472 |
-
the code back.
|
| 473 |
-
|
| 474 |
-
β `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
|
| 475 |
-
receiver nothing they can act on, and the id is already in the target.
|
| 476 |
-
β An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
|
| 477 |
-
a row that opens nowhere.
|
| 478 |
-
"""
|
| 479 |
-
try:
|
| 480 |
-
if kind == "field":
|
| 481 |
-
# ββ W38-T16 β A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
|
| 482 |
-
# is a column INSIDE a database, so the target that travels is the DATABASE. Without
|
| 483 |
-
# this branch the function falls through to the view/folder loop, finds nothing,
|
| 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:
|
|
@@ -494,39 +589,39 @@ def _object_ref(session, kind, oid):
|
|
| 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 {}
|
| 511 |
-
# A user table IS its own route key in both vocabularies (`route_for_topic`).
|
| 512 |
-
return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
|
| 513 |
-
import core.table_store as table_store
|
| 514 |
-
from routes_alerts import route_for_topic
|
| 515 |
-
for topic in _topics(session):
|
| 516 |
-
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
|
| 517 |
-
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
|
| 518 |
-
if not hit:
|
| 519 |
-
continue
|
| 520 |
-
route = route_for_topic(topic)
|
| 521 |
-
if not route:
|
| 522 |
-
break
|
| 523 |
-
row = hit[1] if len(hit) > 1 else {}
|
| 524 |
-
name = str((row or {}).get("name") or "").strip()
|
| 525 |
-
# β Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
|
| 526 |
-
# the grid and stops there rather than naming a view the receiver did not get.
|
| 527 |
-
return (name or ("A view" if kind == "view" else "A folder"),
|
| 528 |
-
route, str(oid) if kind == "view" else "")
|
| 529 |
-
except Exception: # noqa: BLE001
|
| 530 |
-
pass
|
| 531 |
-
return ({"view": "A view", "folder": "A folder",
|
| 532 |
-
"field": "A column"}.get(kind, "An item"), None, "")
|
|
|
|
| 1 |
+
"""routes_shares.py β the manage-access surface (wave 20, owner ruling R10, contract C-SHARE).
|
| 2 |
+
|
| 3 |
+
GET /api/v1/share/{kind}/{oid} -> {owner, entries:[{user,role}], mayAdminister, people}
|
| 4 |
+
PUT /api/v1/share/{kind}/{oid} <- {entries:[{user,role}]} (REPLACES the set)
|
| 5 |
+
GET /api/v1/share/mine -> {view:[id], folder:[id], database:[id]}
|
| 6 |
+
|
| 7 |
+
`kind` β view | folder | database | field. Roles are `view` | `edit` β the same two words the
|
| 8 |
+
view rail already speaks, now extended to folders, databases and COLUMNS so there is ONE
|
| 9 |
+
vocabulary in the UI (R10: "the same picker views use").
|
| 10 |
+
|
| 11 |
+
ββ **W38-T16 β `field` IS THE FOURTH KIND, AND ITS `oid` IS TOPIC-QUALIFIED: `"<table_key>:<field_key>"`**
|
| 12 |
+
(`shares.field_oid`). A bare column key repeats across databases β `notes` exists on a dozen β
|
| 13 |
+
so a grant stored under one would admit the grantee to every `notes` column in the tenant at
|
| 14 |
+
once. β THREE functions in this file branch on kind and ALL THREE need the new one, which is not
|
| 15 |
+
obvious because only two of them fail loudly: `_owns_object` (without it a column's own creator
|
| 16 |
+
is 404'd trying to share the thing they just made) and `_object_ref` (without it `route` is None,
|
| 17 |
+
`_notify_new_grantees` returns early, and the grantee is **granted and never told** β owner item
|
| 18 |
+
18's silent half, reopened one kind over). `_can_see_object` stays deliberately CLOSED for
|
| 19 |
+
anything that is not a view.
|
| 20 |
+
|
| 21 |
+
ββ **A "Can edit" GRANTEE MAY RE-SHARE A VIEW β OWNER RULING R4, BUILT AS W40-T02** (instruction
|
| 22 |
+
4: *"Edit View so a member can share a View as well, not just an admin"*). This REVERSES the flat
|
| 23 |
+
owner-or-admin sentence that stood here, and the reversal is bounded three ways, all enforced HERE
|
| 24 |
+
and not in the client:
|
| 25 |
+
|
| 26 |
+
1. **ONLY `kind='view'`.** `shares.RESHARE_KINDS` is the one spelling of that. `folder`,
|
| 27 |
+
`database` and `field` still require the owner or an admin, and the paragraph below is why
|
| 28 |
+
the `database` kind in particular was never a candidate: a `ut_*` grant's blast radius is a
|
| 29 |
+
whole table, where a view is one saved SELECTION over rows the receiver's own wall governs.
|
| 30 |
+
2. **A RE-SHARE MAY NEVER EXCEED THE RE-SHARER'S OWN ROLE**, and the strict reading ships: an
|
| 31 |
+
`edit` grantee may hand out `view` and NEVER `edit`. `shares.max_grantable_role` answers the
|
| 32 |
+
ceiling, `put_share` enforces it on the DELTA (a name arriving at `edit`, or one raised to
|
| 33 |
+
it) β never on every row of the body, because the `PUT` REPLACES and `ShareDialog.save`
|
| 34 |
+
therefore re-sends the whole list with the re-sharer's own `edit` row inside it. Conferring
|
| 35 |
+
`edit` stays the owner's alone.
|
| 36 |
+
3. **OWNERSHIP NEVER MOVES.** A caller re-sharing rather than owning passes the EXISTING owner
|
| 37 |
+
straight through (`put_share` below, stated rather than incidental).
|
| 38 |
+
|
| 39 |
+
β **SO OF THE TWO FEARS THE OLD SENTENCE NAMED, ONE IS ANSWERED AND ONE IS PRICED.** *"Grant
|
| 40 |
+
themselves ownership and lock you out"* is closed by (3) and by `set_grants`' sticky owner: there
|
| 41 |
+
is no path here by which a grantee becomes an owner. *"Widen a users-scoped view to everyone"* is
|
| 42 |
+
NOT closed β an `edit` grantee may add `*` β it is CAPPED by (2) at `Can view`, on a surface where
|
| 43 |
+
`require_session` plus the topic's own gate plus the receiver's row scope all still run first. That
|
| 44 |
+
is the capability R4 asked for, stated as a cost rather than left as a surprise. A `view` grantee
|
| 45 |
+
still cannot share at all, and still gets `403`. The client greys the editor for non-administrators
|
| 46 |
+
and offers "Can edit" to anyone it greys in; that is a courtesy, and these checks are the wall.
|
| 47 |
+
|
| 48 |
+
β **THE GRANT NEVER WIDENS PAST THE MODULE WALL β ON A GOVERNED MODULE.** `*` ("everyone") means
|
| 49 |
+
every account that can already open the surface: `require_session` plus the topic's own gate run
|
| 50 |
+
first, and for `customer_data` / `product_data` the receiver's own row scope and hidden-field
|
| 51 |
+
closure run BEFORE any foreign view is merged. Sharing there can only narrow-or-equal the set that
|
| 52 |
+
could already reach the data ([[aios-permissioning]]).
|
| 53 |
+
|
| 54 |
+
ββ **AND THAT SENTENCE IS FALSE FOR `kind='database'`, WHICH IS WHY IT NOW SAYS "ON A GOVERNED
|
| 55 |
+
MODULE" (W32-T26, audit S-8).** `routes_admin._PERM_MODULES` is `("customer_data","product_data")`
|
| 56 |
+
and `_clean_perms` **400s** on anything else, so **no row filter and no hidden field can even be
|
| 57 |
+
DECLARED for a `ut_*` database** β `routes_tables.py` makes zero `perm_scope` calls and passes
|
| 58 |
+
`hidden_keys=frozenset()`. There is no module wall behind a user table for a grant to be bounded
|
| 59 |
+
by: **this registry IS the wall.** So a `database` grant is ALL-OR-NOTHING β every row, every
|
| 60 |
+
column β and an `*` database grant admits every account in the tenant to all of it.
|
| 61 |
+
That is a real capability, deliberately kept; what was wrong was a docstring promising a second
|
| 62 |
+
wall that does not exist for this kind. Scoping user tables is booked, not done
|
| 63 |
+
(`waves/wave32/sharing-audit.md` S-8).
|
| 64 |
+
|
| 65 |
+
β **TWO SYSTEMS ANSWER "IS THIS SHARED", AND THEY ARE NOT THE SAME ONE (audit S-4).** THIS
|
| 66 |
+
registry decides who appears in *"Shared with me"* and who may re-share. **`table_store.is_shared`
|
| 67 |
+
β the view's own `permissions` β is what actually decides who may OPEN a view.** A grant here
|
| 68 |
+
whose object is invisible under that one is a row in a list that opens a refusal, which is what
|
| 69 |
+
made item 18 worth auditing. `_entries_or_400` closes the common cause (a name nobody has), but
|
| 70 |
+
the two vocabularies are still two.
|
| 71 |
+
"""
|
| 72 |
+
from fastapi import APIRouter, Body, Depends
|
| 73 |
+
|
| 74 |
+
import core.shares as shares
|
| 75 |
+
import core.users as users
|
| 76 |
+
from deps import Session, err, require_session
|
| 77 |
+
# β W32-T28 (C3) β the SHARE notification's topic word, imported from the module that CLASSIFIES
|
| 78 |
+
# it (`routes_alerts.notification_view`) rather than typed again here. The producer and the
|
| 79 |
+
# reader agreeing about one string is the whole difference between an Inbox row that opens the
|
| 80 |
+
# shared database and one that is quietly unclickable.
|
| 81 |
+
from routes_alerts import SHARE_TOPIC as _SHARE_TOPIC
|
| 82 |
+
|
| 83 |
+
router = APIRouter(prefix="/api/v1")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _kind_or_400(raw):
|
| 87 |
+
try:
|
| 88 |
+
return shares._check_kind(raw)
|
| 89 |
+
except ValueError as e:
|
| 90 |
+
raise err(400, "bad_kind", str(e))
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ββ ββ WAVE 32 Β· T26 (owner item 18, ruling R12) β THE WALL THIS FILE SAID IT HAD βββββββββββββ
|
| 94 |
+
#
|
| 95 |
+
# `put_share`'s comment used to justify the first-claim rule with *"reaching this route at all
|
| 96 |
+
# means passing the surface's own wall"*. **There was no such wall.** `kind` and `oid` are free
|
| 97 |
+
# strings off the URL and the only dependency was `require_session`, so any signed-in account
|
| 98 |
+
# could `PUT` a grant on an id it had never seen. Because the 403 sat behind `if rec["owner"]`,
|
| 99 |
+
# an object with no grant record skipped the check entirely and the caller was stamped OWNER β
|
| 100 |
+
# sticky, so **the real creator was then refused on their own view, permanently.** Driven, not
|
| 101 |
+
# argued: `waves/wave32/sharing-audit.md` S-1 carries the four-step transcript.
|
| 102 |
+
#
|
| 103 |
+
# β AND IT WAS SILENT ON BOTH SIDES. The claimant does not even see the object in their own
|
| 104 |
+
# "Shared with me" (`shared_with` excludes what you own), so nothing appears anywhere until the
|
| 105 |
+
# victim next opens the dialog.
|
| 106 |
+
|
| 107 |
+
#: The built-in grid topics. A view or folder lives in `{topic}_table_workspace`, and the share
|
| 108 |
+
#: route is not told which topic β so resolving one means asking each.
|
| 109 |
_BUILTIN_TOPICS = ("customer", "product")
|
| 110 |
|
| 111 |
|
|
|
|
| 157 |
|
| 158 |
|
| 159 |
def _topics(session):
|
| 160 |
+
"""Every topic whose workspace could hold a view or folder for this tenant.
|
| 161 |
+
|
| 162 |
+
β `all_defs`, never `all_tables` β the latter is the whole 28.6 MB row payload (~703 ms on
|
| 163 |
+
tenant #0) to answer a question about KEYS (D-185).
|
| 164 |
+
"""
|
| 165 |
+
try:
|
| 166 |
+
import core.user_tables as ut
|
| 167 |
+
return (*_BUILTIN_TOPICS, *(ut.all_defs(st=session.runtime) or {}))
|
| 168 |
+
except Exception: # noqa: BLE001
|
| 169 |
+
return _BUILTIN_TOPICS
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _owns_object(session, kind, oid):
|
| 173 |
+
"""May this caller CLAIM an object that has no grant record yet β i.e. do they own it?
|
| 174 |
+
|
| 175 |
+
β THIS GUARDS THE CLAIM, NOT THE READ, AND THAT IS DELIBERATE. Resolving a view means asking
|
| 176 |
+
each topic's workspace in turn, which is N store reads; making every share call pay that
|
| 177 |
+
would put a loop on a route the manage-access dialog opens. The dangerous path is the one
|
| 178 |
+
where a caller is about to be stamped OWNER of something nobody owns β so the resolution runs
|
| 179 |
+
exactly there, and the common path (a record exists, `may_administer` decides) is untouched.
|
| 180 |
+
"""
|
| 181 |
+
if session.admin:
|
| 182 |
+
return True
|
| 183 |
+
if kind == "field":
|
| 184 |
+
# ββ W38-T16 β A COLUMN'S OWNER IS ITS `createdBy`, WHICH THE CREATE DOOR ALREADY STAMPS
|
| 185 |
+
# (`routes_tables.patch_shared_cell`) and the DELETE door already reads as its wall (R8 /
|
| 186 |
+
# D-172: creator-or-admin). Read from the same place by all three, so a column cannot be
|
| 187 |
+
# deletable by one person and shareable by another.
|
| 188 |
+
# β THIS BRANCH IS NOT OPTIONAL AND ITS ABSENCE FAILS SILENTLY IN THE WORST DIRECTION:
|
| 189 |
+
# a brand-new column has no grant record, so `put_share` falls to this predicate β and
|
| 190 |
+
# without it the column's own creator is answered `404 no_object` on the first attempt to
|
| 191 |
+
# share the thing they just made.
|
| 192 |
table_key, field_key = shares.split_field_oid(oid)
|
| 193 |
if not table_key:
|
| 194 |
return False
|
|
|
|
| 196 |
session, table_key, field_key)
|
| 197 |
owner = _field_owner(session, defn, _shared)
|
| 198 |
return bool(defn) and owner.lower() == str(session.uname).strip().lower()
|
| 199 |
+
if kind == "database":
|
| 200 |
+
# β `may_open` is THE resolver for a user table (its own docstring says so) and already
|
| 201 |
+
# admits creator, admin, or a `database` grantee. Re-implementing "who owns a table"
|
| 202 |
+
# here would be the second definition this wave keeps finding.
|
| 203 |
+
try:
|
| 204 |
+
import core.user_tables as ut
|
| 205 |
+
return bool(ut.may_open(oid, session.uname, is_admin=session.admin,
|
| 206 |
+
st=session.runtime))
|
| 207 |
+
except Exception: # noqa: BLE001
|
| 208 |
+
return False
|
| 209 |
+
try:
|
| 210 |
+
import core.table_store as table_store
|
| 211 |
+
except Exception: # noqa: BLE001
|
| 212 |
+
return False
|
| 213 |
+
for topic in _topics(session):
|
| 214 |
+
try:
|
| 215 |
+
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
|
| 216 |
+
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
|
| 217 |
+
except Exception: # noqa: BLE001
|
| 218 |
+
continue
|
| 219 |
+
if hit:
|
| 220 |
+
# `find_view`/`find_folder` answer `(owner_username, β¦)`. The claim belongs to the
|
| 221 |
+
# person whose personal stratum holds it β anybody else reaching this line is
|
| 222 |
+
# exactly the case S-1 describes.
|
| 223 |
+
return str(hit[0]) == str(session.uname)
|
| 224 |
+
return False
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _can_see_object(session, kind, oid):
|
| 228 |
+
"""May this caller READ an object's grant list β i.e. can they reach the object at all?
|
| 229 |
+
|
| 230 |
+
ββ THIS IS DELIBERATELY WIDER THAN {@link _owns_object}, AND CONFLATING THE TWO IS A
|
| 231 |
+
REGRESSION I SHIPPED AND CAUGHT. The first version of T26 guarded BOTH doors with the
|
| 232 |
+
ownership test, which reads sensibly and is wrong for the read, because **`find_view` searches
|
| 233 |
+
PERSONAL STRATA ONLY** (its own docstring says so). So a view living in alice's stratum with
|
| 234 |
+
`permissions.edit = "collaborative"` and no grant record yet β a view bob **can open and edit
|
| 235 |
+
in the grid** β answered `404` when bob opened its manage-access dialog. Measured before
|
| 236 |
+
fixing: `table_store._may_see(view, "bob") is True` while `GET /share/view/vc` said
|
| 237 |
+
`404 no_object`.
|
| 238 |
+
β THAT IS THE AUDIT'S OWN S-4 BITING THE AUDIT'S OWN FIX: two systems answer "is this shared",
|
| 239 |
+
and the wall consulted the grant registry (system A) plus stratum ownership, never the view's
|
| 240 |
+
`permissions` (system B) β which is the one that actually decides who may OPEN it.
|
| 241 |
+
β And it hides the ANSWER, not just the editor. `ViewSidebar`'s Share row is deliberately not
|
| 242 |
+
gated on edit rights because *"hiding the row from everyone else would hide the ANSWER too β
|
| 243 |
+
'who has this?' is a fair question for anyone the view was shared with"*. A 404 there tells a
|
| 244 |
+
legitimate collaborator their view does not exist.
|
| 245 |
+
|
| 246 |
+
β THE CLAIM KEEPS THE NARROW TEST. Being able to SEE an object must not let you become its
|
| 247 |
+
owner β that is S-1, and widening this predicate onto `put_share` would re-open it.
|
| 248 |
+
"""
|
| 249 |
+
if _owns_object(session, kind, oid):
|
| 250 |
+
return True
|
| 251 |
+
if kind != "view":
|
| 252 |
+
# A folder carries no per-object visibility flag of its own, and a database's `may_open`
|
| 253 |
+
# (inside `_owns_object`) already admits grantees. Nothing wider to ask.
|
| 254 |
+
# β W38-T16 β AND `field` KEEPS THIS CLOSED, DELIBERATELY. A grantee never reaches here:
|
| 255 |
+
# `get_share` tests `role is None` first and a grant answers a role, so the only caller
|
| 256 |
+
# left is an account with no relationship to the column at all. Widening it would let any
|
| 257 |
+
# signed-in session enumerate who holds which column on a database they cannot open.
|
| 258 |
+
return False
|
| 259 |
+
try:
|
| 260 |
+
import core.table_store as table_store
|
| 261 |
+
for topic in _topics(session):
|
| 262 |
+
hit = table_store.make(f"{topic}_table_workspace", st=session.runtime).find_view(oid)
|
| 263 |
+
if hit:
|
| 264 |
+
return bool(table_store._may_see(hit[1] if len(hit) > 1 else {},
|
| 265 |
+
session.uname, is_admin=session.admin))
|
| 266 |
+
except Exception: # noqa: BLE001
|
| 267 |
+
return False
|
| 268 |
+
return False
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _entries_or_400(session, entries):
|
| 272 |
+
"""Validate a grant list against the tenant's REAL, ACTIVE accounts β and refuse BY NAME.
|
| 273 |
+
|
| 274 |
+
β `core.shares._clean_entries` silently drops junk, and its docstring argues that correctly:
|
| 275 |
+
a UI mid-save must not lose the whole list to one malformed row. **But it validates the SHAPE
|
| 276 |
+
of a string and the role word β never that the user EXISTS, is ACTIVE, or is in this tenant**,
|
| 277 |
+
so a typo'd name is stored, reported as a successful save, and never reaches anybody. The
|
| 278 |
+
sharer believes the person has access. That is item 18's plain reading.
|
| 279 |
+
β The correct population is computed THREE FUNCTIONS BELOW and served to the picker
|
| 280 |
+
(`_people`). One route, two populations, and the write door was the permissive one.
|
| 281 |
+
β `*` (everyone) is not a user and is admitted deliberately β it is R10's vocabulary for
|
| 282 |
+
"every account that can already open the surface".
|
| 283 |
+
"""
|
| 284 |
+
known = {p["username"].strip().lower() for p in _people(session.tenant)}
|
| 285 |
+
unknown = []
|
| 286 |
+
for e in entries or ():
|
| 287 |
+
if not isinstance(e, dict):
|
| 288 |
+
continue
|
| 289 |
+
user = str(e.get("user") or "").strip().lower()
|
| 290 |
+
if user and user != shares.EVERYONE and user not in known:
|
| 291 |
+
unknown.append(user)
|
| 292 |
+
if unknown:
|
| 293 |
+
raise err(400, "unknown_people",
|
| 294 |
+
"no active account in this workspace is named "
|
| 295 |
+
+ ", ".join(sorted(set(unknown)))
|
| 296 |
+
+ ". Nothing was shared. Pick people from the list rather than typing a name.")
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
@router.get("/share/mine")
|
| 300 |
+
def my_shares(session: Session = Depends(require_session)):
|
| 301 |
+
"""Everything shared WITH me, by kind β the "Shared with me" rail section (R10).
|
| 302 |
+
|
| 303 |
+
Registered before `/share/{kind}/{oid}` so the literal path wins the match; FastAPI resolves
|
| 304 |
+
in declaration order and `mine` would otherwise be read as a `kind`, answering 400 for a URL
|
| 305 |
+
that is not malformed at all.
|
| 306 |
+
"""
|
| 307 |
+
return shares.shared_with(session.uname, st=session.runtime)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
@router.get("/share/{kind}/{oid}")
|
| 311 |
def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
| 312 |
kind = _kind_or_400(kind)
|
| 313 |
if kind == "field":
|
|
|
|
| 324 |
# a Member who owns an unshared View receives the people picker; a collaborator still does not.
|
| 325 |
if not rec["owner"] and not may_admin and _owns_object(session, kind, oid):
|
| 326 |
may_admin = True
|
| 327 |
+
# β W32-T26 (audit S-3) β A STRANGER LEARNS NOTHING. This route used to answer for ANY id:
|
| 328 |
+
# who owns it, everyone it is granted to, and the tenant's whole usernameβname directory β
|
| 329 |
+
# to any signed-in session, about objects it cannot open. Now a caller with no role on an
|
| 330 |
+
# object must prove they can reach it, and gets a 404 otherwise: the same answer a
|
| 331 |
+
# non-existent id gives, so the route cannot be used to probe which ids are real.
|
| 332 |
+
# β `role is None` is the cheap pre-test, so the N-topic resolution below runs only for a
|
| 333 |
+
# caller who has no relationship with the object at all.
|
| 334 |
+
if role is None and not _can_see_object(session, kind, oid):
|
| 335 |
+
raise err(404, "no_object", "no such item, or it is not shared with this account")
|
| 336 |
+
return {
|
| 337 |
+
**rec,
|
| 338 |
+
"role": role,
|
| 339 |
+
"mayAdminister": may_admin,
|
| 340 |
+
# β WAVE 21 (C1 identity fix): grant entries BIND on USERNAMES, so the picker must carry
|
| 341 |
+
# them. `assignable_people` serves bare display names because `user`-kind CELLS store
|
| 342 |
+
# display names β that list's shape cannot change without migrating cell values β so
|
| 343 |
+
# this route serves objects of its own. Existing grants that were written as lowercased
|
| 344 |
+
# display names are normalised by the wave-21 cleanup script.
|
| 345 |
+
# β W32-T26 (audit S-3) β the roster is the EDITOR's data, so it rides only for a caller
|
| 346 |
+
# who may open the editor. A read-only grantee gets the grant list (their fair question is
|
| 347 |
+
# "who else has this?") and not a directory of every account in the workspace.
|
| 348 |
+
# ββ W40-T02 / R4 β AND THAT RULE IS WHY THIS LINE NEEDED NO EDIT. `may_administer` now
|
| 349 |
+
# answers True for an `edit` grantee on a VIEW, which MOVES that account into "may open
|
| 350 |
+
# the editor" β so the picker they need arrives by the roster riding on the same flag it
|
| 351 |
+
# always did. Gating it on anything else (owner, `role == 'owner'`, a fresh predicate)
|
| 352 |
+
# would be a second answer to a question this file already answers once, and would leave
|
| 353 |
+
# the new grantee with an editor and no people to put in it. A `view` grantee is still
|
| 354 |
+
# `may_admin=False` here and still gets `[]`.
|
| 355 |
+
"people": _people(session.tenant) if may_admin else [],
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _people(tenant):
|
| 360 |
+
"""[{username, name}] for this tenant β same population as `assignable_people`, with the
|
| 361 |
+
BINDING identity alongside the display one."""
|
| 362 |
+
try:
|
| 363 |
+
reg = users.registry() or {}
|
| 364 |
+
except Exception:
|
| 365 |
+
return []
|
| 366 |
+
want = str(tenant or '').strip().lower()
|
| 367 |
+
out = []
|
| 368 |
+
for uname, u in reg.items():
|
| 369 |
+
if not isinstance(u, dict) or u.get('active') is False:
|
| 370 |
+
continue
|
| 371 |
+
if want and str(u.get('tenant') or 'royal-imports').strip().lower() != want:
|
| 372 |
+
continue
|
| 373 |
+
out.append({"username": str(uname), "name": str(u.get('name') or uname)})
|
| 374 |
+
return sorted(out, key=lambda p: p["name"].lower())
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@router.put("/share/{kind}/{oid}")
|
| 378 |
def put_share(kind: str, oid: str, body: dict = Body(default=None),
|
| 379 |
session: Session = Depends(require_session)):
|
| 380 |
kind = _kind_or_400(kind)
|
|
|
|
| 383 |
if table_key and field_key:
|
| 384 |
oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
|
| 385 |
body = body or {}
|
| 386 |
+
rec = shares.grants(kind, oid, st=session.runtime)
|
| 387 |
+
# `claiming` is the "no owner yet, and this caller may become one" branch, hoisted to a name
|
| 388 |
+
# because TWO decisions below need it: the ceiling (a claimant is about to be the owner, so
|
| 389 |
+
# their ceiling is an owner's) and the owner written back (D3 β see `set_grants` at the end).
|
| 390 |
+
claiming = False
|
| 391 |
+
# An object with NO grant record yet has no owner β the first person to share it claims it.
|
| 392 |
+
# That is safe because reaching this route at all means passing the surface's own wall, and
|
| 393 |
+
# the alternative (refusing until somebody seeds an owner) would make a brand-new folder
|
| 394 |
+
# unshareable by the person who just made it.
|
| 395 |
+
if rec["owner"]:
|
| 396 |
+
if not shares.may_administer(kind, oid, session.uname, is_admin=session.admin,
|
| 397 |
+
st=session.runtime):
|
| 398 |
+
# β R4 / W40-T02 β `may_administer` now also admits an `edit` grantee on a VIEW, so
|
| 399 |
+
# the population refused here is narrower than the code word `not_owner` suggests: a
|
| 400 |
+
# `view` grantee, or an account with an `edit` role on a kind outside
|
| 401 |
+
# `shares.RESHARE_KINDS`. The code string is kept because clients match on it.
|
| 402 |
+
raise err(403, "not_owner",
|
| 403 |
+
"only the owner of this item (or an administrator) can change who it is "
|
| 404 |
+
"shared with")
|
| 405 |
+
# ββ W32-T26 (audit S-1) β THE CLAIM NOW HAS A PRECONDITION. An object with no grant record
|
| 406 |
+
# is still claimed by the first person to share it β that rule is right, and refusing until
|
| 407 |
+
# somebody seeds an owner would make a brand-new folder unshareable by the person who just
|
| 408 |
+
# made it. What was missing is the half the old comment ASSERTED and the code never did: the
|
| 409 |
+
# claimant has to be able to reach the object. Without this, any signed-in account could
|
| 410 |
+
# stamp itself owner of an id it had never seen and lock the real creator out for good.
|
| 411 |
+
elif not _owns_object(session, kind, oid):
|
| 412 |
+
raise err(404, "no_object", "no such item, or it is not shared with this account")
|
| 413 |
+
else:
|
| 414 |
+
claiming = True
|
| 415 |
+
entries = body.get("entries")
|
| 416 |
if not isinstance(entries, list):
|
| 417 |
raise err(400, "bad_entries",
|
| 418 |
"entries must be a list of {user, role}. Send [] to un-share, which is how "
|
| 419 |
"revoking is expressed")
|
| 420 |
_entries_or_400(session, entries)
|
| 421 |
+
# ββ R4 / W40-T02 β THE CEILING. `may_administer` above now opens this door to an `edit`
|
| 422 |
+
# grantee on a VIEW, so R4's other half ("a re-share may never exceed the role the re-sharer
|
| 423 |
+
# holds") needs a check of its own: that caller may hand out `view`, and conferring `edit`
|
| 424 |
+
# stays the owner's or an administrator's.
|
| 425 |
+
#
|
| 426 |
+
# β AFTER THE ADMISSION, NEVER BEFORE, AND THAT ORDER IS A SECURITY PROPERTY. A caller with
|
| 427 |
+
# no role at all must keep receiving `404 no_object` (audit S-1/S-3: a stranger learns
|
| 428 |
+
# nothing, so this route cannot be used to probe which ids are real). A ceiling raised first
|
| 429 |
+
# would answer that caller `403` and turn the one route hardened against id-probing back into
|
| 430 |
+
# an oracle that confirms an id exists. It also runs before the `field` promotion below, so a
|
| 431 |
+
# refusal cannot leave a column promoted with no grant written.
|
| 432 |
+
#
|
| 433 |
+
# β AND IT IS THE DELTA, NOT EVERY ROW OF THE BODY β read off the shipped client, not
|
| 434 |
+
# assumed. `ShareDialog.save` PUTs the WHOLE list every time ("a body assembled from a delta
|
| 435 |
+
# would revoke everyone it failed to mention"), so the re-sharer's OWN `{user, role: "edit"}`
|
| 436 |
+
# row rides in every payload they are able to produce. Refusing per-entry would `403` the
|
| 437 |
+
# exact re-share this ticket exists to enable, and the only body that would pass is one that
|
| 438 |
+
# revokes the re-sharer. So what is refused is edit access this caller is CREATING: a name
|
| 439 |
+
# arriving at `edit`, or an existing `view` grantee raised to it. A row that already stood at
|
| 440 |
+
# `edit` was the OWNER's decision, and is not this caller's to be refused for.
|
| 441 |
+
#
|
| 442 |
+
# β A CLAIMANT IS AN OWNER. The branch above admits a Member who owns an object that has no
|
| 443 |
+
# grant record yet, and `set_grants` is about to stamp them owner β asking the registry for
|
| 444 |
+
# their role here would answer `None` (no record exists to hold one) and refuse the first
|
| 445 |
+
# `edit` grant on every newly created view. Same predicate as the door, one line apart.
|
| 446 |
+
ceiling = "edit" if claiming else shares.max_grantable_role(
|
| 447 |
+
kind, oid, session.uname, is_admin=session.admin, st=session.runtime)
|
| 448 |
+
if ceiling != "edit":
|
| 449 |
+
held = {e.get("user"): e.get("role") for e in (rec["entries"] or ())}
|
| 450 |
+
raised = set()
|
| 451 |
+
for e in entries:
|
| 452 |
+
if not isinstance(e, dict):
|
| 453 |
+
continue
|
| 454 |
+
who = str(e.get("user") or "").strip().lower()
|
| 455 |
+
if who and str(e.get("role") or "").strip().lower() == "edit" \
|
| 456 |
+
and held.get(who) != "edit":
|
| 457 |
+
raised.add(who)
|
| 458 |
+
if raised:
|
| 459 |
+
noun = {"view": "view", "folder": "folder",
|
| 460 |
+
"database": "database", "field": "column"}.get(kind, "item")
|
| 461 |
+
named = ", ".join("everyone in this workspace" if w == shares.EVERYONE else w
|
| 462 |
+
for w in sorted(raised))
|
| 463 |
+
raise err(403, "grant_exceeds_role",
|
| 464 |
+
"you can share this " + noun + " at Can view, which is as far as your own "
|
| 465 |
+
"access reaches. Only its owner (or an administrator) can give somebody "
|
| 466 |
+
"Can edit, so nothing was saved. Set " + named
|
| 467 |
+
+ " to Can view and save again.")
|
| 468 |
if kind == "field":
|
| 469 |
# A field grant is a visibility and edit wall. Promote a private custom
|
| 470 |
# field exactly once, then keep the requested Share field role as the
|
|
|
|
| 484 |
stamped["granted"] = True
|
| 485 |
shared_overlay.put_field(shared_key, field_key, stamped, st=session.runtime)
|
| 486 |
oid = shares.field_oid(grant_topic, field_key)
|
| 487 |
+
# ββ R4 / W40-T02 (D3) β OWNERSHIP NEVER MOVES ON A RE-SHARE, AND IT IS SAID HERE RATHER
|
| 488 |
+
# THAN LEFT TO FALL OUT. `set_grants`' owner is sticky, so the old `rec["owner"] or
|
| 489 |
+
# session.uname` already happened not to transfer ownership β incidentally, as a property of
|
| 490 |
+
# the callee. R4 names ownership transfer as one of the two halves of the old protection that
|
| 491 |
+
# SURVIVES the widening, and a rule that survives by accident is one the next edit deletes
|
| 492 |
+
# without noticing. So the branch is explicit: a claimant becomes the owner, and everybody
|
| 493 |
+
# else β an owner re-saving, an admin, and now an `edit` grantee re-sharing β passes the
|
| 494 |
+
# EXISTING owner straight back through. `claiming` is the same flag the admission set, so
|
| 495 |
+
# there is no second answer to "is this person taking ownership".
|
| 496 |
+
out = shares.set_grants(kind, oid, entries,
|
| 497 |
+
owner=session.uname if claiming else rec["owner"],
|
| 498 |
st=session.runtime)
|
| 499 |
+
_notify_new_grantees(session, kind, oid, before=rec["entries"], after=out.get("entries") or [])
|
| 500 |
+
return out
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def _notify_new_grantees(session, kind, oid, before, after):
|
| 504 |
+
"""ββ W32-T28 (owner item 18's last clause, contract C3) β tell the RECEIVER, in their Inbox.
|
| 505 |
+
|
| 506 |
+
Owner item 18 ends *"being shared a database notifies the receiver"*. Until now sharing was
|
| 507 |
+
silent: the grant landed in a rail section the receiver had to notice on their own, which is
|
| 508 |
+
why "I shared it with you" and "I never saw it" were both true.
|
| 509 |
+
|
| 510 |
+
β WRITTEN ON THE SHARE, NEVER POLLED. `/notifications` re-evaluates view-ALERTS on read
|
| 511 |
+
because an alert is a live question about rows; a share is an EVENT that happened once, and
|
| 512 |
+
polling for it would mean re-deriving "was this new?" on every inbox open β the diff below
|
| 513 |
+
only exists here, at the moment the set changes.
|
| 514 |
+
|
| 515 |
+
β ONLY THE NEWLY ADDED. `PUT` REPLACES the whole entry set (revoking is expressed by absence),
|
| 516 |
+
so every save re-sends everyone who was already there. Diffing against `before` is what stops
|
| 517 |
+
a rename or a role change from ringing the bell for people whose access did not change.
|
| 518 |
+
β `*` IS NOT NOTIFIED: there is no user to name, and minting one notification per account in
|
| 519 |
+
the tenant on a single click is a broadcast nobody asked for. The rail still shows it.
|
| 520 |
+
β IT NEVER RAISES. A notification that fails must not fail the share that triggered it β the
|
| 521 |
+
grant is the user's actual intent, and `core.alerts.notify` writes with `flush='async'`.
|
| 522 |
+
"""
|
| 523 |
+
try:
|
| 524 |
+
was = {e.get("user") for e in (before or ()) if isinstance(e, dict)}
|
| 525 |
+
fresh = [str(e.get("user")) for e in (after or ())
|
| 526 |
+
if isinstance(e, dict) and e.get("user") not in was
|
| 527 |
+
and e.get("user") != shares.EVERYONE]
|
| 528 |
+
if not fresh:
|
| 529 |
+
return
|
| 530 |
+
import core.alerts as alerts
|
| 531 |
+
|
| 532 |
+
label, route, view_id = _object_ref(session, kind, oid)
|
| 533 |
+
if not route:
|
| 534 |
+
# β NO ROUTE, NO NOTIFICATION β the receiver would get a row that opens nothing, and
|
| 535 |
+
# `notification_view` would have to invent a target. Silence is the honest answer
|
| 536 |
+
# here; the rail still shows the grant under "Shared with me".
|
| 537 |
+
return
|
| 538 |
+
sharer = str(session.user.get("name") or session.uname)
|
| 539 |
+
for user in fresh:
|
| 540 |
+
# β THE SHAPE IS `routes_alerts.notification_view`'s SHARE BRANCH, and the two must
|
| 541 |
+
# agree or the Inbox row is unclickable: `topic` selects the branch and `key` becomes
|
| 542 |
+
# `alertId`, which that branch reads as the id to open. Both constants are IMPORTED
|
| 543 |
+
# from there rather than typed again β one vocabulary, one owner.
|
| 544 |
+
# ββ W33-T28 (`ASK C-14`, answered) β `actor` IS THE SENDER, AND IT IS THE ONLY WAY
|
| 545 |
+
# THE INBOX CAN NAME ONE. An alert and an automation have no person behind them and
|
| 546 |
+
# are honestly named by their machine; a SHARE has a real person, and only this call
|
| 547 |
+
# site knows who. β It is passed as its OWN field rather than recovered from the
|
| 548 |
+
# `detail` prose below: a sender parsed out of "<name> shared this with you" breaks
|
| 549 |
+
# the first time the sentence is reworded, silently, in the header
|
| 550 |
+
# [[grep-output-is-not-source]]. The prose stays as the body; this is the From.
|
| 551 |
+
alerts.notify(user, label, topic=_SHARE_TOPIC, key=route, row_id=view_id,
|
| 552 |
+
detail=f"{sharer} shared this with you", actor=sharer,
|
| 553 |
+
st=session.runtime)
|
| 554 |
+
except Exception: # noqa: BLE001
|
| 555 |
+
return
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def _object_ref(session, kind, oid):
|
| 559 |
+
"""`(label, route, view_id)` β what to CALL the shared thing, and where it OPENS.
|
| 560 |
+
|
| 561 |
+
β THE ROUTE IS RESOLVED HERE, NOT SHAPED IN THE CONSUMER, AND THE FIRST VERSION GOT IT
|
| 562 |
+
WRONG: it put the raw `oid` in the notification's key, so a shared VIEW produced
|
| 563 |
+
`target: {module: "database", id: "view_42"}` β an instruction to open a database named
|
| 564 |
+
`view_42`. It read perfectly in the payload and would have opened nothing. **A view is not
|
| 565 |
+
addressable on its own; it is a SELECTION inside a topic's grid**, so the pair is what has to
|
| 566 |
+
travel. Caught by looking at the notification the driver actually produced, not by reading
|
| 567 |
+
the code back.
|
| 568 |
+
|
| 569 |
+
β `label` never falls back to a raw id. A notification headed `ut_leads_3f2a` tells the
|
| 570 |
+
receiver nothing they can act on, and the id is already in the target.
|
| 571 |
+
β An unresolvable object answers `route=None`, and the caller then sends NOTHING rather than
|
| 572 |
+
a row that opens nowhere.
|
| 573 |
+
"""
|
| 574 |
+
try:
|
| 575 |
+
if kind == "field":
|
| 576 |
+
# ββ W38-T16 β A COLUMN IS NOT ADDRESSABLE ON ITS OWN, exactly as a view is not: it
|
| 577 |
+
# is a column INSIDE a database, so the target that travels is the DATABASE. Without
|
| 578 |
+
# this branch the function falls through to the view/folder loop, finds nothing,
|
| 579 |
+
# answers `route=None` β and `_notify_new_grantees` returns EARLY. The grant lands and
|
| 580 |
+
# the receiver is never told, which is the silent half of owner item 18 reopened one
|
| 581 |
+
# kind over.
|
| 582 |
from routes_alerts import route_for_topic
|
| 583 |
table_key, field_key = shares.split_field_oid(oid)
|
| 584 |
if not table_key:
|
|
|
|
| 589 |
except Exception: # noqa: BLE001
|
| 590 |
defn = None
|
| 591 |
label = str((defn or {}).get("label") or "").strip() or field_key
|
| 592 |
+
# β TWO SPELLINGS REACH THIS LINE AND ONE MAP ANSWERS BOTH. `shared_overlay` is keyed
|
| 593 |
+
# by whatever the calling door already held: a `ut_*` database uses its bare key,
|
| 594 |
+
# while a registry topic uses `<topic>_table_workspace` (`product_data.TABLE_KEY`).
|
| 595 |
+
# `route_for_topic` speaks the GRID SCOPE vocabulary (`customer`, not
|
| 596 |
+
# `customer_data`), so the suffix comes off before it is asked β rather than a second
|
| 597 |
+
# route table being written here, which is how the two come apart.
|
| 598 |
_WS = "_table_workspace"
|
| 599 |
scope = {"customer_data": "customer", "product_data": "product"}.get(table_key)
|
| 600 |
if scope is None:
|
| 601 |
scope = table_key[:-len(_WS)] if table_key.endswith(_WS) else table_key
|
| 602 |
return (label, route_for_topic(scope) or None, "")
|
| 603 |
+
if kind == "database":
|
| 604 |
+
import core.user_tables as ut
|
| 605 |
+
defn = (ut.all_defs(st=session.runtime) or {}).get(str(oid)) or {}
|
| 606 |
+
# A user table IS its own route key in both vocabularies (`route_for_topic`).
|
| 607 |
+
return (str(defn.get("label") or "").strip() or "A database", str(oid), "")
|
| 608 |
+
import core.table_store as table_store
|
| 609 |
+
from routes_alerts import route_for_topic
|
| 610 |
+
for topic in _topics(session):
|
| 611 |
+
ops = table_store.make(f"{topic}_table_workspace", st=session.runtime)
|
| 612 |
+
hit = ops.find_view(oid) if kind == "view" else ops.find_folder(oid)
|
| 613 |
+
if not hit:
|
| 614 |
+
continue
|
| 615 |
+
route = route_for_topic(topic)
|
| 616 |
+
if not route:
|
| 617 |
+
break
|
| 618 |
+
row = hit[1] if len(hit) > 1 else {}
|
| 619 |
+
name = str((row or {}).get("name") or "").strip()
|
| 620 |
+
# β Only a VIEW carries a selection. A folder is a rail grouping, so the target opens
|
| 621 |
+
# the grid and stops there rather than naming a view the receiver did not get.
|
| 622 |
+
return (name or ("A view" if kind == "view" else "A folder"),
|
| 623 |
+
route, str(oid) if kind == "view" else "")
|
| 624 |
+
except Exception: # noqa: BLE001
|
| 625 |
+
pass
|
| 626 |
+
return ({"view": "A view", "folder": "A folder",
|
| 627 |
+
"field": "A column"}.get(kind, "An item"), None, "")
|
platform/aios_grid_fields.json
CHANGED
|
@@ -1,396 +1,413 @@
|
|
| 1 |
-
{
|
| 2 |
-
"_comment": "CANONICAL field contract for the AIOS Airtable-style grid β the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,currency,int,date,pct} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02). `description` (wave 5) is the CANONICAL per-field description β every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank
|
| 3 |
-
"fields": [
|
| 4 |
-
{
|
| 5 |
-
"key": "customer",
|
| 6 |
-
"label": "Customer",
|
| 7 |
-
"type": "text",
|
| 8 |
-
"source": "odoo",
|
| 9 |
-
"pinned": true,
|
| 10 |
-
"default": true,
|
| 11 |
-
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 12 |
-
},
|
| 13 |
-
{
|
| 14 |
-
"key": "partner_id",
|
| 15 |
-
"label": "Odoo ID",
|
| 16 |
-
"type": "int",
|
| 17 |
-
"source": "odoo",
|
| 18 |
-
"derived": true,
|
| 19 |
-
"default": false,
|
| 20 |
-
"description": "The Odoo res.partner id β the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
|
| 21 |
-
},
|
| 22 |
-
{
|
| 23 |
-
"key": "odoo_status",
|
| 24 |
-
"label": "Odoo record",
|
| 25 |
-
"type": "status",
|
| 26 |
-
"source": "odoo",
|
| 27 |
-
"default": false,
|
| 28 |
-
"options": [
|
| 29 |
-
"Active",
|
| 30 |
-
"Archived"
|
| 31 |
-
],
|
| 32 |
-
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 33 |
-
},
|
| 34 |
-
{
|
| 35 |
-
"key": "agent",
|
| 36 |
-
"label": "Agent",
|
| 37 |
-
"type": "
|
| 38 |
-
"source": "odoo",
|
| 39 |
-
"default": true,
|
| 40 |
-
"description": "The sales agent who owns this account."
|
| 41 |
-
},
|
| 42 |
-
{
|
| 43 |
-
"key": "dba",
|
| 44 |
-
"label": "DBA",
|
| 45 |
-
"type": "select",
|
| 46 |
-
"source": "odoo",
|
| 47 |
-
"default": false,
|
| 48 |
-
"options": [
|
| 49 |
-
"Fisch",
|
| 50 |
-
"Royal",
|
| 51 |
-
"Both"
|
| 52 |
-
],
|
| 53 |
-
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 54 |
-
},
|
| 55 |
-
{
|
| 56 |
-
"key": "salesperson",
|
| 57 |
-
"label": "Salesperson",
|
| 58 |
-
"type": "
|
| 59 |
-
"source": "odoo",
|
| 60 |
-
"default": false,
|
| 61 |
-
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 62 |
-
},
|
| 63 |
-
{
|
| 64 |
-
"key": "street",
|
| 65 |
-
"label": "Street",
|
| 66 |
-
"type": "text",
|
| 67 |
-
"source": "odoo",
|
| 68 |
-
"default": false,
|
| 69 |
-
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 70 |
-
},
|
| 71 |
-
{
|
| 72 |
-
"key": "street2",
|
| 73 |
-
"label": "Street 2",
|
| 74 |
-
"type": "text",
|
| 75 |
-
"source": "odoo",
|
| 76 |
-
"default": false,
|
| 77 |
-
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 78 |
-
},
|
| 79 |
-
{
|
| 80 |
-
"key": "city",
|
| 81 |
-
"label": "City",
|
| 82 |
-
"type": "
|
| 83 |
-
"source": "odoo",
|
| 84 |
-
"default": true,
|
| 85 |
-
"description": "City on the customer's Odoo address."
|
| 86 |
-
},
|
| 87 |
-
{
|
| 88 |
-
"key": "state",
|
| 89 |
-
"label": "State",
|
| 90 |
-
"type": "
|
| 91 |
-
"source": "odoo",
|
| 92 |
-
"default": true,
|
| 93 |
-
"description": "State or province on the customer's Odoo address."
|
| 94 |
-
},
|
| 95 |
-
{
|
| 96 |
-
"key": "country",
|
| 97 |
-
"label": "Country",
|
| 98 |
-
"type": "
|
| 99 |
-
"source": "odoo",
|
| 100 |
-
"default": false,
|
| 101 |
-
"description": "Country on the customer's Odoo address."
|
| 102 |
-
},
|
| 103 |
-
{
|
| 104 |
-
"key": "zip",
|
| 105 |
-
"label": "ZIP",
|
| 106 |
-
"type": "text",
|
| 107 |
-
"source": "odoo",
|
| 108 |
-
"default": false,
|
| 109 |
-
"description": "Postal code on the customer's Odoo address."
|
| 110 |
-
},
|
| 111 |
-
{
|
| 112 |
-
"key": "
|
| 113 |
-
"label": "
|
| 114 |
-
"type": "
|
| 115 |
-
"source": "odoo",
|
| 116 |
-
"default":
|
| 117 |
-
"description": "
|
| 118 |
-
},
|
| 119 |
-
{
|
| 120 |
-
"key": "
|
| 121 |
-
"label": "
|
| 122 |
-
"type": "
|
| 123 |
-
"source": "odoo",
|
| 124 |
-
"default": false,
|
| 125 |
-
"description": "
|
| 126 |
-
},
|
| 127 |
-
{
|
| 128 |
-
"key": "
|
| 129 |
-
"label": "
|
| 130 |
-
"type": "
|
| 131 |
-
"source": "odoo",
|
| 132 |
-
"default": false,
|
| 133 |
-
"description": "
|
| 134 |
-
},
|
| 135 |
-
{
|
| 136 |
-
"key": "
|
| 137 |
-
"label": "
|
| 138 |
-
"type": "
|
| 139 |
-
"source": "odoo",
|
| 140 |
-
"default": false,
|
| 141 |
-
"description": "
|
| 142 |
-
},
|
| 143 |
-
{
|
| 144 |
-
"key": "
|
| 145 |
-
"label": "
|
| 146 |
-
"type": "
|
| 147 |
-
"source": "odoo",
|
| 148 |
-
"default":
|
| 149 |
-
"description": "
|
| 150 |
-
},
|
| 151 |
-
{
|
| 152 |
-
"key": "
|
| 153 |
-
"label": "
|
| 154 |
-
"type": "
|
| 155 |
-
"source": "odoo",
|
| 156 |
-
"default": true,
|
| 157 |
-
"description": "
|
| 158 |
-
},
|
| 159 |
-
{
|
| 160 |
-
"
|
| 161 |
-
"
|
| 162 |
-
"
|
| 163 |
-
"
|
| 164 |
-
"
|
| 165 |
-
"
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
"
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
"
|
| 176 |
-
"
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
"key": "
|
| 181 |
-
"label": "AR
|
| 182 |
-
"type": "currency",
|
| 183 |
-
"source": "odoo",
|
| 184 |
-
"default": false,
|
| 185 |
-
"description": "Invoiced money
|
| 186 |
-
},
|
| 187 |
-
{
|
| 188 |
-
"
|
| 189 |
-
"
|
| 190 |
-
"
|
| 191 |
-
"
|
| 192 |
-
"
|
| 193 |
-
"
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
"key": "
|
| 198 |
-
"label": "
|
| 199 |
-
"type": "currency",
|
| 200 |
-
"source": "odoo",
|
| 201 |
-
"default": false,
|
| 202 |
-
"description": "
|
| 203 |
-
},
|
| 204 |
-
{
|
| 205 |
-
"key": "
|
| 206 |
-
"label": "
|
| 207 |
-
"type": "currency",
|
| 208 |
-
"source": "odoo",
|
| 209 |
-
"default": false,
|
| 210 |
-
"description": "
|
| 211 |
-
},
|
| 212 |
-
{
|
| 213 |
-
"key": "
|
| 214 |
-
"label": "
|
| 215 |
-
"type": "currency",
|
| 216 |
-
"source": "odoo",
|
| 217 |
-
"default": false,
|
| 218 |
-
"description": "Overdue between
|
| 219 |
-
},
|
| 220 |
-
{
|
| 221 |
-
"key": "
|
| 222 |
-
"label": "
|
| 223 |
-
"type": "currency",
|
| 224 |
-
"source": "odoo",
|
| 225 |
-
"default": false,
|
| 226 |
-
"description": "Overdue between
|
| 227 |
-
},
|
| 228 |
-
{
|
| 229 |
-
"key": "
|
| 230 |
-
"label": "90
|
| 231 |
-
"type": "currency",
|
| 232 |
-
"source": "odoo",
|
| 233 |
-
"default": false,
|
| 234 |
-
"description": "Overdue
|
| 235 |
-
},
|
| 236 |
-
{
|
| 237 |
-
"key": "
|
| 238 |
-
"label": "
|
| 239 |
-
"type": "
|
| 240 |
-
"source": "odoo",
|
| 241 |
-
"default": false,
|
| 242 |
-
"description": "
|
| 243 |
-
},
|
| 244 |
-
{
|
| 245 |
-
"key": "
|
| 246 |
-
"label": "
|
| 247 |
-
"type": "
|
| 248 |
-
"source": "odoo",
|
| 249 |
-
"default": false,
|
| 250 |
-
"description": "
|
| 251 |
-
},
|
| 252 |
-
{
|
| 253 |
-
"key": "
|
| 254 |
-
"label": "Top category
|
| 255 |
-
"type": "
|
| 256 |
-
"source": "odoo",
|
| 257 |
-
"default": false,
|
| 258 |
-
"description": "
|
| 259 |
-
},
|
| 260 |
-
{
|
| 261 |
-
"key": "
|
| 262 |
-
"label": "
|
| 263 |
-
"type": "
|
| 264 |
-
"source": "odoo",
|
| 265 |
-
"default": false,
|
| 266 |
-
"description": "
|
| 267 |
-
},
|
| 268 |
-
{
|
| 269 |
-
"key": "
|
| 270 |
-
"label": "
|
| 271 |
-
"type": "
|
| 272 |
-
"source": "odoo",
|
| 273 |
-
"default": false,
|
| 274 |
-
"description": "
|
| 275 |
-
},
|
| 276 |
-
{
|
| 277 |
-
"key": "
|
| 278 |
-
"label": "
|
| 279 |
-
"type": "
|
| 280 |
-
"source": "odoo",
|
| 281 |
-
"default": false,
|
| 282 |
-
"description": "
|
| 283 |
-
},
|
| 284 |
-
{
|
| 285 |
-
"key": "
|
| 286 |
-
"label": "
|
| 287 |
-
"type": "int",
|
| 288 |
-
"source": "odoo",
|
| 289 |
-
"default": false,
|
| 290 |
-
"description": "Days
|
| 291 |
-
},
|
| 292 |
-
{
|
| 293 |
-
"key": "
|
| 294 |
-
"label": "
|
| 295 |
-
"type": "
|
| 296 |
-
"source": "
|
| 297 |
-
"default": false,
|
| 298 |
-
"description": "
|
| 299 |
-
}
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
"
|
| 326 |
-
"
|
| 327 |
-
"
|
| 328 |
-
"
|
| 329 |
-
"default": true,
|
| 330 |
-
"description": "
|
| 331 |
-
},
|
| 332 |
-
{
|
| 333 |
-
"key": "
|
| 334 |
-
"label": "
|
| 335 |
-
"type": "text",
|
| 336 |
-
"source": "
|
| 337 |
-
"default": true,
|
| 338 |
-
"description": "
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
"
|
| 343 |
-
"
|
| 344 |
-
"
|
| 345 |
-
"
|
| 346 |
-
"
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
"
|
| 352 |
-
"
|
| 353 |
-
"
|
| 354 |
-
"
|
| 355 |
-
"
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
"
|
| 361 |
-
"
|
| 362 |
-
"
|
| 363 |
-
"
|
| 364 |
-
"
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
"
|
| 370 |
-
"
|
| 371 |
-
"
|
| 372 |
-
"
|
| 373 |
-
"
|
| 374 |
-
},
|
| 375 |
-
{
|
| 376 |
-
"key": "
|
| 377 |
-
"label": "
|
| 378 |
-
"type": "currency",
|
| 379 |
-
"source": "
|
| 380 |
-
"
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
"
|
| 386 |
-
"
|
| 387 |
-
"
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
{
|
| 390 |
"key": "price_1",
|
| 391 |
"label": "Price 1",
|
| 392 |
"type": "currency",
|
| 393 |
"source": "odoo",
|
|
|
|
| 394 |
"description": "Cheapest live price for this SKU, across active pricelists."
|
| 395 |
},
|
| 396 |
{
|
|
@@ -398,6 +415,7 @@
|
|
| 398 |
"label": "Unit 1",
|
| 399 |
"type": "text",
|
| 400 |
"source": "odoo",
|
|
|
|
| 401 |
"description": "Package label for Price 1. Blank means Odoo has no package reference for that price."
|
| 402 |
},
|
| 403 |
{
|
|
@@ -405,6 +423,7 @@
|
|
| 405 |
"label": "Price 2",
|
| 406 |
"type": "currency",
|
| 407 |
"source": "odoo",
|
|
|
|
| 408 |
"description": "Second-cheapest live price for this SKU."
|
| 409 |
},
|
| 410 |
{
|
|
@@ -412,6 +431,7 @@
|
|
| 412 |
"label": "Unit 2",
|
| 413 |
"type": "text",
|
| 414 |
"source": "odoo",
|
|
|
|
| 415 |
"description": "Package label for Price 2."
|
| 416 |
},
|
| 417 |
{
|
|
@@ -419,6 +439,7 @@
|
|
| 419 |
"label": "Price 3",
|
| 420 |
"type": "currency",
|
| 421 |
"source": "odoo",
|
|
|
|
| 422 |
"description": "Third-cheapest live price for this SKU."
|
| 423 |
},
|
| 424 |
{
|
|
@@ -426,8 +447,153 @@
|
|
| 426 |
"label": "Unit 3",
|
| 427 |
"type": "text",
|
| 428 |
"source": "odoo",
|
|
|
|
| 429 |
"description": "Package label for Price 3."
|
| 430 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
{
|
| 432 |
"key": "pre_book_qty",
|
| 433 |
"label": "Pre-book qty",
|
|
@@ -444,180 +610,188 @@
|
|
| 444 |
},
|
| 445 |
{
|
| 446 |
"key": "rev_ytd",
|
| 447 |
-
"label": "Revenue YTD",
|
| 448 |
-
"type": "currency",
|
| 449 |
-
"source": "odoo",
|
| 450 |
-
"default": true,
|
| 451 |
-
"description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
|
| 452 |
-
},
|
| 453 |
-
{
|
| 454 |
-
"key": "rev_ly",
|
| 455 |
-
"label": "Revenue LY",
|
| 456 |
-
"type": "currency",
|
| 457 |
-
"source": "odoo",
|
| 458 |
-
"description": "Same period last year β seasonal wholesale compares like for like."
|
| 459 |
-
},
|
| 460 |
-
{
|
| 461 |
-
"key": "yoy_pct",
|
| 462 |
-
"label": "YoY %",
|
| 463 |
-
"type": "pct",
|
| 464 |
-
"source": "odoo",
|
| 465 |
-
"description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
|
| 466 |
-
},
|
| 467 |
-
{
|
| 468 |
-
"key": "qty_ytd",
|
| 469 |
-
"label": "Units YTD",
|
| 470 |
-
"type": "int",
|
| 471 |
-
"source": "odoo",
|
| 472 |
-
"description": "Units sold year to date."
|
| 473 |
-
},
|
| 474 |
-
{
|
| 475 |
-
"key": "orders_ytd",
|
| 476 |
-
"label": "Orders YTD",
|
| 477 |
-
"type": "int",
|
| 478 |
-
"source": "odoo",
|
| 479 |
-
"description": "Distinct orders containing this SKU, year to date."
|
| 480 |
-
},
|
| 481 |
-
{
|
| 482 |
-
"key": "on_hand",
|
| 483 |
-
"label": "On hand",
|
| 484 |
-
"type": "int",
|
| 485 |
-
"source": "odoo",
|
| 486 |
-
"description": "Units in stock. CONSOLIDATED β one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
|
| 487 |
-
},
|
| 488 |
-
{
|
| 489 |
-
"key": "unit_cost",
|
| 490 |
-
"label": "Unit cost",
|
| 491 |
-
"type": "currency",
|
| 492 |
-
"source": "odoo",
|
| 493 |
-
"description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
|
| 494 |
-
},
|
| 495 |
-
{
|
| 496 |
-
"key": "inv_value",
|
| 497 |
-
"label": "Stock value",
|
| 498 |
-
"type": "currency",
|
| 499 |
-
"source": "odoo",
|
| 500 |
-
"description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
|
| 501 |
-
},
|
| 502 |
-
{
|
| 503 |
-
"key": "qty_ltm",
|
| 504 |
-
"label": "Units LTM",
|
| 505 |
-
"type": "int",
|
| 506 |
-
"source": "odoo",
|
| 507 |
-
"description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
|
| 508 |
-
},
|
| 509 |
-
{
|
| 510 |
-
"key": "incoming",
|
| 511 |
-
"label": "Inbound units",
|
| 512 |
-
"type": "int",
|
| 513 |
-
"source": "odoo",
|
| 514 |
-
"description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Days of supply and both cover gap columns all count this as stock (owner, 2026-08-19)."
|
| 515 |
-
},
|
| 516 |
-
{
|
| 517 |
-
"key": "dos",
|
| 518 |
-
"label": "Days of supply",
|
| 519 |
-
"type": "int",
|
| 520 |
-
"source": "odoo",
|
| 521 |
-
"description": "Days of supply at the trailing twelve month rate, counting ON HAND PLUS INBOUND units as stock
|
| 522 |
-
},
|
| 523 |
-
{
|
| 524 |
-
"key": "cover_gap_d",
|
| 525 |
-
"label": "Cover gap (days)",
|
| 526 |
-
"type": "int",
|
| 527 |
-
"source": "odoo",
|
| 528 |
-
"default": false,
|
| 529 |
-
"description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. It counts INBOUND units as stock, exactly as Days of supply now does. The one thing it does differently: the burn rate is the FORWARD 8 month forecast rather than the trailing twelve month average, so a seasonal SKU reads differently here."
|
| 530 |
-
},
|
| 531 |
-
{
|
| 532 |
-
"key": "cover_gap_units",
|
| 533 |
-
"label": "Cover gap (units)",
|
| 534 |
-
"type": "int",
|
| 535 |
-
"source": "odoo",
|
| 536 |
-
"default": false,
|
| 537 |
-
"description": "The recommended reorder quantity: units of forecast demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or no forecast demand. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
|
| 538 |
-
},
|
| 539 |
-
{
|
| 540 |
-
"key": "demand_fwd",
|
| 541 |
-
"label": "Forecast units (8 mo)",
|
| 542 |
-
"type": "int",
|
| 543 |
-
"source": "odoo",
|
| 544 |
-
"description": "Units this SKU is expected to sell over the next 8 months, read as the units it actually sold in the same 8 calendar months one year ago. Seasonal on purpose: a flat annual average spreads Valentine's, Mother's Day and Christmas evenly across the year and understates the months buyers actually order for. A SKU too new to appear in that window falls back to its trailing twelve month rate scaled to 8 months. This is the burn rate both cover gap columns divide by, shown so the reorder quantity can be checked."
|
| 545 |
-
},
|
| 546 |
-
{
|
| 547 |
-
"key": "stock_bucket",
|
| 548 |
-
"label": "Stock status",
|
| 549 |
-
"type": "select",
|
| 550 |
-
"source": "odoo",
|
| 551 |
-
"description": "Dead / excess / healthy bucket, from the same days of supply figure beside it, so it counts inbound units too.
|
| 552 |
-
},
|
| 553 |
-
{
|
| 554 |
-
"key": "discontinued",
|
| 555 |
-
"label": "Discontinued",
|
| 556 |
-
"type": "select",
|
| 557 |
-
"source": "odoo",
|
| 558 |
-
"options": [
|
| 559 |
-
"Yes",
|
| 560 |
-
"No"
|
| 561 |
-
],
|
| 562 |
-
"description": "Whether Odoo carries the Discontinued product tag on this SKU. Every row gets an explicit Yes or No rather than a blank, because a blank reads as an inactive condition in the filter engine and would silently WIDEN any view that filtered on it."
|
| 563 |
-
},
|
| 564 |
-
{
|
| 565 |
-
"key": "
|
| 566 |
-
"label": "
|
| 567 |
-
"type": "
|
| 568 |
-
"source": "
|
| 569 |
-
"default": false,
|
| 570 |
-
"
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
"
|
| 574 |
-
"
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
"
|
| 578 |
-
"
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
"
|
| 582 |
-
"
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
"
|
| 586 |
-
"
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
"
|
| 590 |
-
"
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
"
|
| 594 |
-
"
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
"
|
| 598 |
-
"
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
"
|
| 602 |
-
"
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
"
|
| 606 |
-
"
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
"
|
| 610 |
-
"
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
"
|
| 614 |
-
"
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
"
|
| 618 |
-
"
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment": "CANONICAL field contract for the AIOS Airtable-style grid β the SINGLE source of truth. Consumed by platform/aios_grid.py (embed/Space host) and aios-web/api/main.py (standalone API), and regenerated into aios-web/web/public/sample_customers.json. Edit HERE only, then run aios-web/verify_fields_contract.py. source=odoo is READ-ONLY; source=overlay is the editable stratum (notes/tags) outside Odoo. type in {text,status,select,multiselect,currency,int,date,pct,url} (select = a fixed-choice READ-ONLY brand attribute; dba is the first, wave 2026-08-02. multiselect = one cell holding a comma-joined SET, so a choice list offers the MEMBERS, not the joined string; customer tags and product supplier are the first, wave 40 W40-T06. url = a link cell, GridCellKind.Uri, drawn by cells.ts and opened by CustomerGrid's scheme-guarded click path; maps_url is the first, wave 40 W40-T08). W40-T08 also adds created_on, the uniform records-created DATE column every database carries (owner I24). It is a plain date field and NOT the created_time TYPE on purpose: created_time renders the row-level _created datum and its value never lives at the field's own key, so it is invisible to the semantic topic, to a stored-cell read and to any host-side export, while a date field is an ordinary cell that all three can see. The _created datum still rides every row for a created_time column a user creates. `description` (wave 5) is the CANONICAL per-field description β every field must carry one, and since wave 7 (owner W8, 2026-07-28) every description is ONE SHORT PLAIN sentence (two only when a fact would otherwise mislead): what the field IS, nothing else β no filter tips, no '(none)' coaching, no rationale; the user's workspace NOTE overrides it in the (i) hover, never in this file. BUILDER FACT (documented here, deliberately NOT in user-facing text): blank connector attributes display as '(none)' (they are no longer all `text`: W40-T06 retyped eight of them to `select` and `tags` to `multiselect`, and the sentinel is a real member of every one of those vocabularies), so `is '(none)'` β not `is empty` β finds the blanks on agent/city/state/country/zip/payment_terms/pricelist/tags. filterable:false = the CONDITION BUILDER does not offer it (still displayed, still sortable); every such field must have a replacement declared in aios-web/verify_fields_contract.py. 2026-07-27 partner attributes: country/zip/payment_terms/pricelist/tags/customer_since all ship default:false. zip is TEXT because a postal code has leading zeros. Odoo's credit_limit (1% populated) and user_id salesperson (2%) are deliberately ABSENT; agent_ids is the salesperson field and AR is where credit exposure comes from. Wave-5 item 8 (2026-07-27): ltm_rev and at_risk are DELETED β LTM's replacement is a creatable Sales measure column (the demo column IS Sales Β· the last 12 months), at_risk's replacement is a formula field, e.g. MAX(0, {revenue_ly} - {revenue_ytd}). Wave-6 item 8 (2026-07-27, the no-buildable-presets rule): revenue_ytd, revenue_ly, orders_24m, aov and yoy_pct are DELETED β every one is self-buildable, so a frozen pre-set beside the builder was two ways to ask one question. Replacements (recorded in verify_fields_contract.py): creatable measure columns for Sales / Orders / Avg order $ over any period (harness/measure_filter.py ADMITTED carries revenue, orders and the composite aov), and a formula over two measure columns for YoY, e.g. ({sales_ytd} - {sales_ly}) / {sales_ly}. Stale view colIds naming the five self-heal on the next autosave (the established rule).",
|
| 3 |
+
"fields": [
|
| 4 |
+
{
|
| 5 |
+
"key": "customer",
|
| 6 |
+
"label": "Customer",
|
| 7 |
+
"type": "text",
|
| 8 |
+
"source": "odoo",
|
| 9 |
+
"pinned": true,
|
| 10 |
+
"default": true,
|
| 11 |
+
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 12 |
+
},
|
| 13 |
+
{
|
| 14 |
+
"key": "partner_id",
|
| 15 |
+
"label": "Odoo ID",
|
| 16 |
+
"type": "int",
|
| 17 |
+
"source": "odoo",
|
| 18 |
+
"derived": true,
|
| 19 |
+
"default": false,
|
| 20 |
+
"description": "The Odoo res.partner id β the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"key": "odoo_status",
|
| 24 |
+
"label": "Odoo record",
|
| 25 |
+
"type": "status",
|
| 26 |
+
"source": "odoo",
|
| 27 |
+
"default": false,
|
| 28 |
+
"options": [
|
| 29 |
+
"Active",
|
| 30 |
+
"Archived"
|
| 31 |
+
],
|
| 32 |
+
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"key": "agent",
|
| 36 |
+
"label": "Agent",
|
| 37 |
+
"type": "select",
|
| 38 |
+
"source": "odoo",
|
| 39 |
+
"default": true,
|
| 40 |
+
"description": "The sales agent who owns this account."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"key": "dba",
|
| 44 |
+
"label": "DBA",
|
| 45 |
+
"type": "select",
|
| 46 |
+
"source": "odoo",
|
| 47 |
+
"default": false,
|
| 48 |
+
"options": [
|
| 49 |
+
"Fisch",
|
| 50 |
+
"Royal",
|
| 51 |
+
"Both"
|
| 52 |
+
],
|
| 53 |
+
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
"key": "salesperson",
|
| 57 |
+
"label": "Salesperson",
|
| 58 |
+
"type": "select",
|
| 59 |
+
"source": "odoo",
|
| 60 |
+
"default": false,
|
| 61 |
+
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
"key": "street",
|
| 65 |
+
"label": "Street",
|
| 66 |
+
"type": "text",
|
| 67 |
+
"source": "odoo",
|
| 68 |
+
"default": false,
|
| 69 |
+
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 70 |
+
},
|
| 71 |
+
{
|
| 72 |
+
"key": "street2",
|
| 73 |
+
"label": "Street 2",
|
| 74 |
+
"type": "text",
|
| 75 |
+
"source": "odoo",
|
| 76 |
+
"default": false,
|
| 77 |
+
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"key": "city",
|
| 81 |
+
"label": "City",
|
| 82 |
+
"type": "select",
|
| 83 |
+
"source": "odoo",
|
| 84 |
+
"default": true,
|
| 85 |
+
"description": "City on the customer's Odoo address."
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"key": "state",
|
| 89 |
+
"label": "State",
|
| 90 |
+
"type": "select",
|
| 91 |
+
"source": "odoo",
|
| 92 |
+
"default": true,
|
| 93 |
+
"description": "State or province on the customer's Odoo address."
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"key": "country",
|
| 97 |
+
"label": "Country",
|
| 98 |
+
"type": "select",
|
| 99 |
+
"source": "odoo",
|
| 100 |
+
"default": false,
|
| 101 |
+
"description": "Country on the customer's Odoo address."
|
| 102 |
+
},
|
| 103 |
+
{
|
| 104 |
+
"key": "zip",
|
| 105 |
+
"label": "ZIP",
|
| 106 |
+
"type": "text",
|
| 107 |
+
"source": "odoo",
|
| 108 |
+
"default": false,
|
| 109 |
+
"description": "Postal code on the customer's Odoo address."
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
"key": "maps_url",
|
| 113 |
+
"label": "Google Maps",
|
| 114 |
+
"type": "url",
|
| 115 |
+
"source": "odoo",
|
| 116 |
+
"default": true,
|
| 117 |
+
"description": "A Google Maps link to this customer's address."
|
| 118 |
+
},
|
| 119 |
+
{
|
| 120 |
+
"key": "customer_since",
|
| 121 |
+
"label": "Customer since",
|
| 122 |
+
"type": "date",
|
| 123 |
+
"source": "odoo",
|
| 124 |
+
"default": false,
|
| 125 |
+
"description": "When this customer was first set up in Odoo."
|
| 126 |
+
},
|
| 127 |
+
{
|
| 128 |
+
"key": "tags",
|
| 129 |
+
"label": "Tags",
|
| 130 |
+
"type": "multiselect",
|
| 131 |
+
"source": "odoo",
|
| 132 |
+
"default": false,
|
| 133 |
+
"description": "Odoo labels on this customer, comma-separated."
|
| 134 |
+
},
|
| 135 |
+
{
|
| 136 |
+
"key": "pricelist",
|
| 137 |
+
"label": "Customer price list",
|
| 138 |
+
"type": "select",
|
| 139 |
+
"source": "odoo",
|
| 140 |
+
"default": false,
|
| 141 |
+
"description": "The price list this customer buys on."
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"key": "payment_terms",
|
| 145 |
+
"label": "Payment terms",
|
| 146 |
+
"type": "select",
|
| 147 |
+
"source": "odoo",
|
| 148 |
+
"default": false,
|
| 149 |
+
"description": "Payment terms on this customer's account β Net 30, for example."
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"key": "last_order",
|
| 153 |
+
"label": "Last order",
|
| 154 |
+
"type": "date",
|
| 155 |
+
"source": "odoo",
|
| 156 |
+
"default": true,
|
| 157 |
+
"description": "Date of the most recent confirmed order."
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"key": "overdue_days",
|
| 161 |
+
"label": "Overdue days",
|
| 162 |
+
"type": "int",
|
| 163 |
+
"source": "odoo",
|
| 164 |
+
"default": true,
|
| 165 |
+
"description": "How many days late this customer is running against their own usual ordering rhythm."
|
| 166 |
+
},
|
| 167 |
+
{
|
| 168 |
+
"_note": "filterable:false β DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
|
| 169 |
+
"key": "est_missed",
|
| 170 |
+
"label": "Est. missed $",
|
| 171 |
+
"type": "currency",
|
| 172 |
+
"source": "odoo",
|
| 173 |
+
"default": true,
|
| 174 |
+
"agg": "sum",
|
| 175 |
+
"filterable": false,
|
| 176 |
+
"description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 177 |
+
},
|
| 178 |
+
{
|
| 179 |
+
"_note": "wave 21 R1 β KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
|
| 180 |
+
"key": "ar_open",
|
| 181 |
+
"label": "AR current $",
|
| 182 |
+
"type": "currency",
|
| 183 |
+
"source": "odoo",
|
| 184 |
+
"default": false,
|
| 185 |
+
"description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"key": "ar_overdue",
|
| 189 |
+
"label": "AR overdue $",
|
| 190 |
+
"type": "currency",
|
| 191 |
+
"source": "odoo",
|
| 192 |
+
"default": false,
|
| 193 |
+
"description": "Invoiced money past due β same basis as the Collections page."
|
| 194 |
+
},
|
| 195 |
+
{
|
| 196 |
+
"_note": "wave 21 R1 β the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β no second oracle.",
|
| 197 |
+
"key": "ar_outstanding",
|
| 198 |
+
"label": "AR outstanding $",
|
| 199 |
+
"type": "currency",
|
| 200 |
+
"source": "odoo",
|
| 201 |
+
"default": false,
|
| 202 |
+
"description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 203 |
+
},
|
| 204 |
+
{
|
| 205 |
+
"key": "ar_exposure",
|
| 206 |
+
"label": "Credit exposure $",
|
| 207 |
+
"type": "currency",
|
| 208 |
+
"source": "odoo",
|
| 209 |
+
"default": false,
|
| 210 |
+
"description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 211 |
+
},
|
| 212 |
+
{
|
| 213 |
+
"key": "ar_aged_1_30",
|
| 214 |
+
"label": "1-30 days $",
|
| 215 |
+
"type": "currency",
|
| 216 |
+
"source": "odoo",
|
| 217 |
+
"default": false,
|
| 218 |
+
"description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 219 |
+
},
|
| 220 |
+
{
|
| 221 |
+
"key": "ar_aged_31_60",
|
| 222 |
+
"label": "31-60 days $",
|
| 223 |
+
"type": "currency",
|
| 224 |
+
"source": "odoo",
|
| 225 |
+
"default": false,
|
| 226 |
+
"description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
"key": "ar_aged_61_90",
|
| 230 |
+
"label": "61-90 days $",
|
| 231 |
+
"type": "currency",
|
| 232 |
+
"source": "odoo",
|
| 233 |
+
"default": false,
|
| 234 |
+
"description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"key": "ar_aged_90_plus",
|
| 238 |
+
"label": "90+ days $",
|
| 239 |
+
"type": "currency",
|
| 240 |
+
"source": "odoo",
|
| 241 |
+
"default": false,
|
| 242 |
+
"description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 243 |
+
},
|
| 244 |
+
{
|
| 245 |
+
"key": "days_to_pay",
|
| 246 |
+
"label": "Days to pay",
|
| 247 |
+
"type": "int",
|
| 248 |
+
"source": "odoo",
|
| 249 |
+
"default": false,
|
| 250 |
+
"description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 251 |
+
},
|
| 252 |
+
{
|
| 253 |
+
"key": "top_category",
|
| 254 |
+
"label": "Top category",
|
| 255 |
+
"type": "select",
|
| 256 |
+
"source": "odoo",
|
| 257 |
+
"default": false,
|
| 258 |
+
"description": "The category this customer spent the most on in the last 12 months."
|
| 259 |
+
},
|
| 260 |
+
{
|
| 261 |
+
"key": "top_category_pct",
|
| 262 |
+
"label": "Top category %",
|
| 263 |
+
"type": "pct",
|
| 264 |
+
"source": "odoo",
|
| 265 |
+
"default": false,
|
| 266 |
+
"description": "Share of last-12-months spend that went to the top category."
|
| 267 |
+
},
|
| 268 |
+
{
|
| 269 |
+
"key": "sku_count",
|
| 270 |
+
"label": "SKUs bought",
|
| 271 |
+
"type": "int",
|
| 272 |
+
"source": "odoo",
|
| 273 |
+
"default": false,
|
| 274 |
+
"description": "Distinct products bought in the last 12 months."
|
| 275 |
+
},
|
| 276 |
+
{
|
| 277 |
+
"key": "top_sku",
|
| 278 |
+
"label": "Top SKU",
|
| 279 |
+
"type": "text",
|
| 280 |
+
"source": "odoo",
|
| 281 |
+
"default": false,
|
| 282 |
+
"description": "The product this customer spent the most on in the last 12 months."
|
| 283 |
+
},
|
| 284 |
+
{
|
| 285 |
+
"key": "days_since",
|
| 286 |
+
"label": "Days since order",
|
| 287 |
+
"type": "int",
|
| 288 |
+
"source": "odoo",
|
| 289 |
+
"default": false,
|
| 290 |
+
"description": "Days since the last confirmed order."
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
"key": "typical_gap_days",
|
| 294 |
+
"label": "Typical gap days",
|
| 295 |
+
"type": "int",
|
| 296 |
+
"source": "odoo",
|
| 297 |
+
"default": false,
|
| 298 |
+
"description": "Days this customer usually goes between orders, from their own history."
|
| 299 |
+
},
|
| 300 |
+
{
|
| 301 |
+
"key": "created_on",
|
| 302 |
+
"label": "Record created",
|
| 303 |
+
"type": "date",
|
| 304 |
+
"source": "odoo",
|
| 305 |
+
"default": false,
|
| 306 |
+
"description": "The date this customer record was created in Odoo."
|
| 307 |
+
},
|
| 308 |
+
{
|
| 309 |
+
"key": "notes",
|
| 310 |
+
"label": "Notes",
|
| 311 |
+
"type": "text",
|
| 312 |
+
"source": "overlay",
|
| 313 |
+
"default": false,
|
| 314 |
+
"description": "Your notes on this customer. Saved in this app only, visible only to you."
|
| 315 |
+
}
|
| 316 |
+
],
|
| 317 |
+
"_product_comment": "ADDITIVE, wave 15 C-TOPIC. The PRODUCT table's field contract. Kept as a SEPARATE top-level key rather than restructuring `fields` into {customer_data, product_data}: both existing readers (aios_grid._load_fields, aios-web/api/main.py) index doc['fields'] directly, and reshaping that mid-wave would break the embed for a cosmetic gain. The keyed shape can arrive when both readers move in ONE commit; until then this is the product half and `fields` is the customer half.",
|
| 318 |
+
"_product_removed_buy_now": "OWNER, 2026-08-03: 'Buy signal' (key buy_now, a select of Buy now / OK) is NO LONGER A PRESET FIELD. It never earned one: it is a formula over two columns that are both still right here, and the platform has a formula field type for exactly that. THE FORMULA, which reproduces the retired column row for row (modules/product_data.validate proves the equivalence, and goes red if it ever stops holding): IF({lead_days} > 0, IF({dos} < {lead_days}, \"Buy now\", \"OK\"), \"\") . Every branch matches the old server rule, including the blanks - the formula engine refuses a comparison against a blank rather than coercing it to 0, so a SKU with no days-of-supply or no lead time comes out empty, which is 'we do not know' and not 'you are fine'. NOTE the column is still COMPUTED in product_data.pool(): it ships nowhere (rows_from_pool projects strictly through this contract, so no Field means no cell on the wire) and exists only as validate()'s oracle. A formula field is PER-USER, so nothing shared may filter on it - the Buy list view filters on dos/lead_days directly (_seed_wave17).",
|
| 319 |
+
"product_data": {
|
| 320 |
+
"identity": "pid",
|
| 321 |
+
"business_key": "code",
|
| 322 |
+
"fields": [
|
| 323 |
+
{
|
| 324 |
+
"key": "code",
|
| 325 |
+
"label": "SKU",
|
| 326 |
+
"type": "text",
|
| 327 |
+
"source": "odoo",
|
| 328 |
+
"pinned": true,
|
| 329 |
+
"default": true,
|
| 330 |
+
"description": "The SKU code β the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
|
| 331 |
+
},
|
| 332 |
+
{
|
| 333 |
+
"key": "product",
|
| 334 |
+
"label": "Product",
|
| 335 |
+
"type": "text",
|
| 336 |
+
"source": "odoo",
|
| 337 |
+
"default": true,
|
| 338 |
+
"description": "Product name as it appears in Odoo."
|
| 339 |
+
},
|
| 340 |
+
{
|
| 341 |
+
"key": "category",
|
| 342 |
+
"label": "Category",
|
| 343 |
+
"type": "select",
|
| 344 |
+
"source": "odoo",
|
| 345 |
+
"default": true,
|
| 346 |
+
"description": "Product category; '(uncategorized)' when Odoo carries none."
|
| 347 |
+
},
|
| 348 |
+
{
|
| 349 |
+
"key": "supplier",
|
| 350 |
+
"label": "Supplier",
|
| 351 |
+
"type": "multiselect",
|
| 352 |
+
"source": "overlay",
|
| 353 |
+
"default": true,
|
| 354 |
+
"description": "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet.",
|
| 355 |
+
"shared": true
|
| 356 |
+
},
|
| 357 |
+
{
|
| 358 |
+
"key": "origin_country",
|
| 359 |
+
"label": "Country",
|
| 360 |
+
"type": "text",
|
| 361 |
+
"source": "overlay",
|
| 362 |
+
"default": false,
|
| 363 |
+
"description": "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet.",
|
| 364 |
+
"shared": true
|
| 365 |
+
},
|
| 366 |
+
{
|
| 367 |
+
"key": "lead_days",
|
| 368 |
+
"label": "Lead time (days)",
|
| 369 |
+
"type": "int",
|
| 370 |
+
"source": "overlay",
|
| 371 |
+
"default": true,
|
| 372 |
+
"description": "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone.",
|
| 373 |
+
"shared": true
|
| 374 |
+
},
|
| 375 |
+
{
|
| 376 |
+
"key": "first_cost",
|
| 377 |
+
"label": "First cost",
|
| 378 |
+
"type": "currency",
|
| 379 |
+
"source": "overlay",
|
| 380 |
+
"default": false,
|
| 381 |
+
"description": "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone.",
|
| 382 |
+
"shared": true
|
| 383 |
+
},
|
| 384 |
+
{
|
| 385 |
+
"key": "price_fisch",
|
| 386 |
+
"label": "Fisch price",
|
| 387 |
+
"type": "currency",
|
| 388 |
+
"source": "odoo",
|
| 389 |
+
"description": "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
"key": "price_royal_1",
|
| 393 |
+
"label": "Royal 1 price",
|
| 394 |
+
"type": "currency",
|
| 395 |
+
"source": "odoo",
|
| 396 |
+
"description": "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 397 |
+
},
|
| 398 |
+
{
|
| 399 |
+
"key": "price_royal_2",
|
| 400 |
+
"label": "Royal 2 price",
|
| 401 |
+
"type": "currency",
|
| 402 |
+
"source": "odoo",
|
| 403 |
+
"description": "Royal 2 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 404 |
+
},
|
| 405 |
{
|
| 406 |
"key": "price_1",
|
| 407 |
"label": "Price 1",
|
| 408 |
"type": "currency",
|
| 409 |
"source": "odoo",
|
| 410 |
+
"default": false,
|
| 411 |
"description": "Cheapest live price for this SKU, across active pricelists."
|
| 412 |
},
|
| 413 |
{
|
|
|
|
| 415 |
"label": "Unit 1",
|
| 416 |
"type": "text",
|
| 417 |
"source": "odoo",
|
| 418 |
+
"default": false,
|
| 419 |
"description": "Package label for Price 1. Blank means Odoo has no package reference for that price."
|
| 420 |
},
|
| 421 |
{
|
|
|
|
| 423 |
"label": "Price 2",
|
| 424 |
"type": "currency",
|
| 425 |
"source": "odoo",
|
| 426 |
+
"default": false,
|
| 427 |
"description": "Second-cheapest live price for this SKU."
|
| 428 |
},
|
| 429 |
{
|
|
|
|
| 431 |
"label": "Unit 2",
|
| 432 |
"type": "text",
|
| 433 |
"source": "odoo",
|
| 434 |
+
"default": false,
|
| 435 |
"description": "Package label for Price 2."
|
| 436 |
},
|
| 437 |
{
|
|
|
|
| 439 |
"label": "Price 3",
|
| 440 |
"type": "currency",
|
| 441 |
"source": "odoo",
|
| 442 |
+
"default": false,
|
| 443 |
"description": "Third-cheapest live price for this SKU."
|
| 444 |
},
|
| 445 |
{
|
|
|
|
| 447 |
"label": "Unit 3",
|
| 448 |
"type": "text",
|
| 449 |
"source": "odoo",
|
| 450 |
+
"default": false,
|
| 451 |
"description": "Package label for Price 3."
|
| 452 |
},
|
| 453 |
+
{
|
| 454 |
+
"key": "price_fisch_t1",
|
| 455 |
+
"label": "Fisch tier 1 price",
|
| 456 |
+
"type": "currency",
|
| 457 |
+
"source": "odoo",
|
| 458 |
+
"default": false,
|
| 459 |
+
"description": "Fisch price tier 1, cheapest first. Blank when Fisch does not price this SKU."
|
| 460 |
+
},
|
| 461 |
+
{
|
| 462 |
+
"key": "unit_fisch_t1",
|
| 463 |
+
"label": "Fisch tier 1 unit",
|
| 464 |
+
"type": "text",
|
| 465 |
+
"source": "odoo",
|
| 466 |
+
"default": false,
|
| 467 |
+
"description": "Package label for the Fisch tier 1 price. Blank when Odoo names no package."
|
| 468 |
+
},
|
| 469 |
+
{
|
| 470 |
+
"key": "price_fisch_t2",
|
| 471 |
+
"label": "Fisch tier 2 price",
|
| 472 |
+
"type": "currency",
|
| 473 |
+
"source": "odoo",
|
| 474 |
+
"default": false,
|
| 475 |
+
"description": "Fisch price tier 2. Blank when Fisch prices this SKU at only one tier."
|
| 476 |
+
},
|
| 477 |
+
{
|
| 478 |
+
"key": "unit_fisch_t2",
|
| 479 |
+
"label": "Fisch tier 2 unit",
|
| 480 |
+
"type": "text",
|
| 481 |
+
"source": "odoo",
|
| 482 |
+
"default": false,
|
| 483 |
+
"description": "Package label for the Fisch tier 2 price. Blank when Odoo names no package."
|
| 484 |
+
},
|
| 485 |
+
{
|
| 486 |
+
"key": "price_fisch_t3",
|
| 487 |
+
"label": "Fisch tier 3 price",
|
| 488 |
+
"type": "currency",
|
| 489 |
+
"source": "odoo",
|
| 490 |
+
"default": false,
|
| 491 |
+
"description": "Fisch price tier 3. Blank when Fisch prices this SKU at fewer than three tiers."
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"key": "unit_fisch_t3",
|
| 495 |
+
"label": "Fisch tier 3 unit",
|
| 496 |
+
"type": "text",
|
| 497 |
+
"source": "odoo",
|
| 498 |
+
"default": false,
|
| 499 |
+
"description": "Package label for the Fisch tier 3 price. Blank when Odoo names no package."
|
| 500 |
+
},
|
| 501 |
+
{
|
| 502 |
+
"key": "price_royal1_t1",
|
| 503 |
+
"label": "Royal 1 tier 1 price",
|
| 504 |
+
"type": "currency",
|
| 505 |
+
"source": "odoo",
|
| 506 |
+
"default": false,
|
| 507 |
+
"description": "Royal 1 price tier 1, cheapest first. Blank when Royal 1 does not price this SKU."
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"key": "unit_royal1_t1",
|
| 511 |
+
"label": "Royal 1 tier 1 unit",
|
| 512 |
+
"type": "text",
|
| 513 |
+
"source": "odoo",
|
| 514 |
+
"default": false,
|
| 515 |
+
"description": "Package label for the Royal 1 tier 1 price. Blank when Odoo names no package."
|
| 516 |
+
},
|
| 517 |
+
{
|
| 518 |
+
"key": "price_royal1_t2",
|
| 519 |
+
"label": "Royal 1 tier 2 price",
|
| 520 |
+
"type": "currency",
|
| 521 |
+
"source": "odoo",
|
| 522 |
+
"default": false,
|
| 523 |
+
"description": "Royal 1 price tier 2. Blank when Royal 1 prices this SKU at only one tier."
|
| 524 |
+
},
|
| 525 |
+
{
|
| 526 |
+
"key": "unit_royal1_t2",
|
| 527 |
+
"label": "Royal 1 tier 2 unit",
|
| 528 |
+
"type": "text",
|
| 529 |
+
"source": "odoo",
|
| 530 |
+
"default": false,
|
| 531 |
+
"description": "Package label for the Royal 1 tier 2 price. Blank when Odoo names no package."
|
| 532 |
+
},
|
| 533 |
+
{
|
| 534 |
+
"key": "price_royal1_t3",
|
| 535 |
+
"label": "Royal 1 tier 3 price",
|
| 536 |
+
"type": "currency",
|
| 537 |
+
"source": "odoo",
|
| 538 |
+
"default": false,
|
| 539 |
+
"description": "Royal 1 price tier 3. Blank when Royal 1 prices this SKU at fewer than three tiers."
|
| 540 |
+
},
|
| 541 |
+
{
|
| 542 |
+
"key": "unit_royal1_t3",
|
| 543 |
+
"label": "Royal 1 tier 3 unit",
|
| 544 |
+
"type": "text",
|
| 545 |
+
"source": "odoo",
|
| 546 |
+
"default": false,
|
| 547 |
+
"description": "Package label for the Royal 1 tier 3 price. Blank when Odoo names no package."
|
| 548 |
+
},
|
| 549 |
+
{
|
| 550 |
+
"key": "price_royal2_t1",
|
| 551 |
+
"label": "Royal 2 tier 1 price",
|
| 552 |
+
"type": "currency",
|
| 553 |
+
"source": "odoo",
|
| 554 |
+
"default": false,
|
| 555 |
+
"description": "Royal 2 price tier 1, cheapest first. Blank when Royal 2 does not price this SKU."
|
| 556 |
+
},
|
| 557 |
+
{
|
| 558 |
+
"key": "unit_royal2_t1",
|
| 559 |
+
"label": "Royal 2 tier 1 unit",
|
| 560 |
+
"type": "text",
|
| 561 |
+
"source": "odoo",
|
| 562 |
+
"default": false,
|
| 563 |
+
"description": "Package label for the Royal 2 tier 1 price. Blank when Odoo names no package."
|
| 564 |
+
},
|
| 565 |
+
{
|
| 566 |
+
"key": "price_royal2_t2",
|
| 567 |
+
"label": "Royal 2 tier 2 price",
|
| 568 |
+
"type": "currency",
|
| 569 |
+
"source": "odoo",
|
| 570 |
+
"default": false,
|
| 571 |
+
"description": "Royal 2 price tier 2. Blank when Royal 2 prices this SKU at only one tier."
|
| 572 |
+
},
|
| 573 |
+
{
|
| 574 |
+
"key": "unit_royal2_t2",
|
| 575 |
+
"label": "Royal 2 tier 2 unit",
|
| 576 |
+
"type": "text",
|
| 577 |
+
"source": "odoo",
|
| 578 |
+
"default": false,
|
| 579 |
+
"description": "Package label for the Royal 2 tier 2 price. Blank when Odoo names no package."
|
| 580 |
+
},
|
| 581 |
+
{
|
| 582 |
+
"key": "price_royal2_t3",
|
| 583 |
+
"label": "Royal 2 tier 3 price",
|
| 584 |
+
"type": "currency",
|
| 585 |
+
"source": "odoo",
|
| 586 |
+
"default": false,
|
| 587 |
+
"description": "Royal 2 price tier 3. Blank when Royal 2 prices this SKU at fewer than three tiers."
|
| 588 |
+
},
|
| 589 |
+
{
|
| 590 |
+
"key": "unit_royal2_t3",
|
| 591 |
+
"label": "Royal 2 tier 3 unit",
|
| 592 |
+
"type": "text",
|
| 593 |
+
"source": "odoo",
|
| 594 |
+
"default": false,
|
| 595 |
+
"description": "Package label for the Royal 2 tier 3 price. Blank when Odoo names no package."
|
| 596 |
+
},
|
| 597 |
{
|
| 598 |
"key": "pre_book_qty",
|
| 599 |
"label": "Pre-book qty",
|
|
|
|
| 610 |
},
|
| 611 |
{
|
| 612 |
"key": "rev_ytd",
|
| 613 |
+
"label": "Revenue YTD",
|
| 614 |
+
"type": "currency",
|
| 615 |
+
"source": "odoo",
|
| 616 |
+
"default": true,
|
| 617 |
+
"description": "Year-to-date revenue for this SKU, BU-scoped when the caller is."
|
| 618 |
+
},
|
| 619 |
+
{
|
| 620 |
+
"key": "rev_ly",
|
| 621 |
+
"label": "Revenue LY",
|
| 622 |
+
"type": "currency",
|
| 623 |
+
"source": "odoo",
|
| 624 |
+
"description": "Same period last year β seasonal wholesale compares like for like."
|
| 625 |
+
},
|
| 626 |
+
{
|
| 627 |
+
"key": "yoy_pct",
|
| 628 |
+
"label": "YoY %",
|
| 629 |
+
"type": "pct",
|
| 630 |
+
"source": "odoo",
|
| 631 |
+
"description": "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
|
| 632 |
+
},
|
| 633 |
+
{
|
| 634 |
+
"key": "qty_ytd",
|
| 635 |
+
"label": "Units YTD",
|
| 636 |
+
"type": "int",
|
| 637 |
+
"source": "odoo",
|
| 638 |
+
"description": "Units sold year to date."
|
| 639 |
+
},
|
| 640 |
+
{
|
| 641 |
+
"key": "orders_ytd",
|
| 642 |
+
"label": "Orders YTD",
|
| 643 |
+
"type": "int",
|
| 644 |
+
"source": "odoo",
|
| 645 |
+
"description": "Distinct orders containing this SKU, year to date."
|
| 646 |
+
},
|
| 647 |
+
{
|
| 648 |
+
"key": "on_hand",
|
| 649 |
+
"label": "On hand",
|
| 650 |
+
"type": "int",
|
| 651 |
+
"source": "odoo",
|
| 652 |
+
"description": "Units in stock. CONSOLIDATED β one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
|
| 653 |
+
},
|
| 654 |
+
{
|
| 655 |
+
"key": "unit_cost",
|
| 656 |
+
"label": "Unit cost",
|
| 657 |
+
"type": "currency",
|
| 658 |
+
"source": "odoo",
|
| 659 |
+
"description": "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
|
| 660 |
+
},
|
| 661 |
+
{
|
| 662 |
+
"key": "inv_value",
|
| 663 |
+
"label": "Stock value",
|
| 664 |
+
"type": "currency",
|
| 665 |
+
"source": "odoo",
|
| 666 |
+
"description": "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
|
| 667 |
+
},
|
| 668 |
+
{
|
| 669 |
+
"key": "qty_ltm",
|
| 670 |
+
"label": "Units LTM",
|
| 671 |
+
"type": "int",
|
| 672 |
+
"source": "odoo",
|
| 673 |
+
"description": "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
|
| 674 |
+
},
|
| 675 |
+
{
|
| 676 |
+
"key": "incoming",
|
| 677 |
+
"label": "Inbound units",
|
| 678 |
+
"type": "int",
|
| 679 |
+
"source": "odoo",
|
| 680 |
+
"description": "Units already on order and not yet received, from Odoo's own incoming quantity on the product. In the product's STOCK unit of measure, the same unit as On hand, so the two can be added. Days of supply and both cover gap columns all count this as stock (owner, 2026-08-19)."
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
"key": "dos",
|
| 684 |
+
"label": "Days of supply",
|
| 685 |
+
"type": "int",
|
| 686 |
+
"source": "odoo",
|
| 687 |
+
"description": "Days of supply at the trailing twelve month rate, counting ON HAND PLUS INBOUND units as stock. A blank means there is no honest number to print, and Stock status beside it says which of two reasons applies: 'No recent sales' is stock on the shelf that sold nothing in the window, so there is no rate to divide by; 'No stock record' is a product the inventory read returned no row for, so its shelf is unknown rather than empty. A product that sold nothing and holds nothing reads 0, not blank. Same stock figure as the cover gap columns, so the two cannot disagree about how much you have. They still divide by different rates: this one is the trailing average, the cover gap uses the forward 8 month forecast, so a seasonal SKU reads differently in each. A business unit caller gets this column too, re-scoped: the shelf is the whole warehouse's, the rate is that unit's."
|
| 688 |
+
},
|
| 689 |
+
{
|
| 690 |
+
"key": "cover_gap_d",
|
| 691 |
+
"label": "Cover gap (days)",
|
| 692 |
+
"type": "int",
|
| 693 |
+
"source": "odoo",
|
| 694 |
+
"default": false,
|
| 695 |
+
"description": "Days of cover minus supplier lead time. Negative means it runs out before a reorder lands. It counts INBOUND units as stock, exactly as Days of supply now does. The one thing it does differently: the burn rate is the FORWARD 8 month forecast rather than the trailing twelve month average, so a seasonal SKU reads differently here."
|
| 696 |
+
},
|
| 697 |
+
{
|
| 698 |
+
"key": "cover_gap_units",
|
| 699 |
+
"label": "Cover gap (units)",
|
| 700 |
+
"type": "int",
|
| 701 |
+
"source": "odoo",
|
| 702 |
+
"default": false,
|
| 703 |
+
"description": "The recommended reorder quantity: units of forecast demand over the lead time that on-hand plus inbound does not cover. POSITIVE means buy this many; negative is surplus units; blank means we cannot say, because the SKU has no lead time on file or no forecast demand. Rounded AWAY from zero, so a real shortfall never rounds down to nothing."
|
| 704 |
+
},
|
| 705 |
+
{
|
| 706 |
+
"key": "demand_fwd",
|
| 707 |
+
"label": "Forecast units (8 mo)",
|
| 708 |
+
"type": "int",
|
| 709 |
+
"source": "odoo",
|
| 710 |
+
"description": "Units this SKU is expected to sell over the next 8 months, read as the units it actually sold in the same 8 calendar months one year ago. Seasonal on purpose: a flat annual average spreads Valentine's, Mother's Day and Christmas evenly across the year and understates the months buyers actually order for. A SKU too new to appear in that window falls back to its trailing twelve month rate scaled to 8 months. This is the burn rate both cover gap columns divide by, shown so the reorder quantity can be checked."
|
| 711 |
+
},
|
| 712 |
+
{
|
| 713 |
+
"key": "stock_bucket",
|
| 714 |
+
"label": "Stock status",
|
| 715 |
+
"type": "select",
|
| 716 |
+
"source": "odoo",
|
| 717 |
+
"description": "Dead / excess / healthy bucket, from the same days of supply figure beside it, so it counts inbound units too. Two of its values are not coverage bands at all. 'Out of stock' keys on the REAL shelf rather than the inbound-inclusive figure, because that is a present tense fact somebody can walk into the warehouse and check, so a row can honestly read 'Out of stock' with a days of supply beside it when the replenishment is on the water; Inbound units is why. 'No stock record' means the inventory read returned no row for this product, so its shelf is unknown rather than empty: it reads that way for the handful of products carrying no SKU code, and for every row at once if an inventory read fails, which is the case where a blank Days of supply is an outage rather than a slow seller. A business unit caller gets this column too, re-scoped through that unit's own sales rate."
|
| 718 |
+
},
|
| 719 |
+
{
|
| 720 |
+
"key": "discontinued",
|
| 721 |
+
"label": "Discontinued",
|
| 722 |
+
"type": "select",
|
| 723 |
+
"source": "odoo",
|
| 724 |
+
"options": [
|
| 725 |
+
"Yes",
|
| 726 |
+
"No"
|
| 727 |
+
],
|
| 728 |
+
"description": "Whether Odoo carries the Discontinued product tag on this SKU. Every row gets an explicit Yes or No rather than a blank, because a blank reads as an inactive condition in the filter engine and would silently WIDEN any view that filtered on it."
|
| 729 |
+
},
|
| 730 |
+
{
|
| 731 |
+
"key": "created_on",
|
| 732 |
+
"label": "Record created",
|
| 733 |
+
"type": "date",
|
| 734 |
+
"source": "odoo",
|
| 735 |
+
"default": false,
|
| 736 |
+
"description": "The date this product record was created in Odoo. Read from the live product.product create_date: the mirror carries write_date and no create_date, so there is no cheaper source. A SKU whose code is on more than one active record takes the EARLIEST of them, because the row is the SKU and that is when it first existed."
|
| 737 |
+
},
|
| 738 |
+
{
|
| 739 |
+
"key": "needs_pricing",
|
| 740 |
+
"label": "Needs pricing",
|
| 741 |
+
"type": "select",
|
| 742 |
+
"source": "overlay",
|
| 743 |
+
"default": false,
|
| 744 |
+
"options": [
|
| 745 |
+
"Yes"
|
| 746 |
+
],
|
| 747 |
+
"shared": true,
|
| 748 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the NEEDS PRICING tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 749 |
+
},
|
| 750 |
+
{
|
| 751 |
+
"key": "march_pricelist",
|
| 752 |
+
"label": "March pricelist",
|
| 753 |
+
"type": "select",
|
| 754 |
+
"source": "overlay",
|
| 755 |
+
"default": false,
|
| 756 |
+
"options": [
|
| 757 |
+
"Yes"
|
| 758 |
+
],
|
| 759 |
+
"shared": true,
|
| 760 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the March Pricelist tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 761 |
+
},
|
| 762 |
+
{
|
| 763 |
+
"key": "price_changes",
|
| 764 |
+
"label": "Price changes",
|
| 765 |
+
"type": "select",
|
| 766 |
+
"source": "overlay",
|
| 767 |
+
"default": false,
|
| 768 |
+
"options": [
|
| 769 |
+
"Yes"
|
| 770 |
+
],
|
| 771 |
+
"shared": true,
|
| 772 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the Price Changes tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 773 |
+
},
|
| 774 |
+
{
|
| 775 |
+
"key": "closeouts",
|
| 776 |
+
"label": "Closeouts",
|
| 777 |
+
"type": "select",
|
| 778 |
+
"source": "overlay",
|
| 779 |
+
"default": false,
|
| 780 |
+
"options": [
|
| 781 |
+
"Yes"
|
| 782 |
+
],
|
| 783 |
+
"shared": true,
|
| 784 |
+
"description": "Team-maintained. A SKU carries βYesβ when it appears on the Closeouts tab of the 2027 catalog workbook; no value means it is not on that list. Shared with everyone in the workspace β the whole point of R8 is that the TEAM's work lands in the system, not one importer's private column."
|
| 785 |
+
},
|
| 786 |
+
{
|
| 787 |
+
"key": "notes",
|
| 788 |
+
"label": "Notes",
|
| 789 |
+
"type": "text",
|
| 790 |
+
"source": "overlay",
|
| 791 |
+
"default": false,
|
| 792 |
+
"shared": true,
|
| 793 |
+
"description": "Team-maintained. The 2027 workbook's own non-Odoo columns (Product Description, Packing) folded into one field. Shared with everyone in the workspace."
|
| 794 |
+
}
|
| 795 |
+
]
|
| 796 |
+
}
|
| 797 |
+
}
|
platform/core/data_binding.py
CHANGED
|
@@ -263,9 +263,34 @@ def describe(repo=None):
|
|
| 263 |
|
| 264 |
β Carries no secret: a Space id and a dataset repo id are public names. The DATA is not, and
|
| 265 |
none of it is here. The field is session-gated anyway (see `routes_admin.settings`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
"""
|
| 267 |
rid = str(repo or '').strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
return {
|
|
|
|
| 269 |
'deployment': deployment_id(),
|
| 270 |
'production': is_production_deployment(),
|
| 271 |
'repo': rid,
|
|
|
|
| 263 |
|
| 264 |
β Carries no secret: a Space id and a dataset repo id are public names. The DATA is not, and
|
| 265 |
none of it is here. The field is session-gated anyway (see `routes_admin.settings`).
|
| 266 |
+
|
| 267 |
+
ββ W40-T13 (D-333) β IT ALSO NAMES THE STORE BACKEND, WHICH IS THE OTHER HALF OF THE SAME
|
| 268 |
+
QUESTION AND THE HALF THAT WENT MISSING. `repo` answers "WHICH STORE am I bound to"; after the
|
| 269 |
+
Postgres cutover the sharper question is "am I READING that store from HF or from Neon", and
|
| 270 |
+
the payload could not answer it at all. W36-T62's `done-when` said *"/settings reports the pg
|
| 271 |
+
backend"* and was therefore unmeasurable, which is how a whole cutover shipped with the
|
| 272 |
+
verification clause pointed at a field that did not exist. It exists now.
|
| 273 |
"""
|
| 274 |
rid = str(repo or '').strip()
|
| 275 |
+
# ββ THE IMPORT IS FUNCTION-LOCAL AND MUST STAY THAT WAY. `core/store.py` imports THIS module
|
| 276 |
+
# at its own module scope, so pulling `core.store` up to the top of this file is a hard import
|
| 277 |
+
# cycle that kills the app at boot rather than at a test. Do not "tidy" it upward.
|
| 278 |
+
#
|
| 279 |
+
# β AND AN UNRECOGNISED BACKEND REPORTS `unknown`, NEVER `hf`. `store.backend()` RAISES on a
|
| 280 |
+
# value it does not know, and this function is called from `routes_admin::_store_binding`
|
| 281 |
+
# inside a bare `except` whose fallback dict has no `backend` key at all β so a raise here
|
| 282 |
+
# would delete the field from the payload on exactly the deployment that most needs it. And
|
| 283 |
+
# defaulting to `hf` would be worse than raising: a misconfigured container would report the
|
| 284 |
+
# store it is NOT reading, which is the guess this field exists to replace. ImportError rides
|
| 285 |
+
# the same branch for the same reason: an honest "I could not tell" beats a confident wrong
|
| 286 |
+
# answer on a surface nobody can inspect from outside (D-160).
|
| 287 |
+
try:
|
| 288 |
+
import core.store as store # noqa: PLC0415 β cycle, see above
|
| 289 |
+
store_backend = store.backend()
|
| 290 |
+
except (RuntimeError, ImportError):
|
| 291 |
+
store_backend = 'unknown'
|
| 292 |
return {
|
| 293 |
+
'backend': store_backend,
|
| 294 |
'deployment': deployment_id(),
|
| 295 |
'production': is_production_deployment(),
|
| 296 |
'repo': rid,
|
platform/core/field_permissions.py
CHANGED
|
@@ -58,6 +58,161 @@ def grant_entries(permissions):
|
|
| 58 |
return []
|
| 59 |
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
def _field_is_migratable(field):
|
| 62 |
return (isinstance(field, dict) and field.get("custom") is True
|
| 63 |
and field.get("source") == "overlay")
|
|
@@ -74,13 +229,34 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
|
|
| 74 |
grant_key = str(grant_topic or key).strip()
|
| 75 |
shared_key = str(shared_key or key).strip()
|
| 76 |
if not key or st is None:
|
| 77 |
-
return {"promoted": 0, "normalized": 0}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
try:
|
| 79 |
document = st.get(key) or {}
|
| 80 |
except Exception:
|
| 81 |
-
return {"promoted": 0, "normalized": 0}
|
| 82 |
if not isinstance(document, dict):
|
| 83 |
-
return {"promoted": 0, "normalized": 0}
|
| 84 |
|
| 85 |
promoted = 0
|
| 86 |
normalized = 0
|
|
@@ -151,7 +327,10 @@ def migrate_legacy_fields(table_key, st=None, known_users=None, grant_topic=None
|
|
| 151 |
values.pop(field_key, None)
|
| 152 |
return data
|
| 153 |
st.update(key, _remove, flush="sync")
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
|
| 157 |
def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
|
|
@@ -162,7 +341,36 @@ def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None)
|
|
| 162 |
return dict(field)
|
| 163 |
shared = dict(field)
|
| 164 |
shared.update({"shared": True, "granted": True, "source": "overlay", "custom": True})
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
shared["permissions"] = permissions
|
| 167 |
shared["createdBy"] = str(shared.get("createdBy") or owner).strip().lower()
|
| 168 |
shared_overlay.put_field(shared_key, key, shared, st=st)
|
|
|
|
| 58 |
return []
|
| 59 |
|
| 60 |
|
| 61 |
+
def permissions_from_grants(entries):
|
| 62 |
+
"""ββ W40-T03 / CONTRACT C4 β THE CLASSIFICATION IS DERIVED FROM THE REGISTRY, NEVER STAMPED.
|
| 63 |
+
|
| 64 |
+
Owner instruction 21 (2026-08-23): *"A Field shared with only Karen Ganley shows under Hide
|
| 65 |
+
Fields as 'Shared with everyone' β it should be 'Shared with me'. Fix the logic. By default
|
| 66 |
+
every Field created is private until it is shared."*
|
| 67 |
+
|
| 68 |
+
The registry (`core/shares.py`) already knows exactly who a column is shared with; the
|
| 69 |
+
`permissions` bag on the definition is only the WORD the client sections on
|
| 70 |
+
(`FieldsHidePanel` buckets by `types.fieldEditMode`). Until now those two could disagree,
|
| 71 |
+
because `promote_field` stamped the bag from a hardcoded fallback BEFORE the request's own
|
| 72 |
+
grant entries were written. This function is the single place the bag is derived from the
|
| 73 |
+
grants, so the word on screen and the wall in the registry answer the same question.
|
| 74 |
+
|
| 75 |
+
β CLASSIFICATION IS ABOUT **WHO**, NOT ABOUT **ROLE**, and that is C4's wording rather than an
|
| 76 |
+
approximation: *"'Shared with me' means grants naming me"*. A `view`-role grantee is a person
|
| 77 |
+
the column is shared with exactly as an `edit`-role one is, so both count. The role itself is
|
| 78 |
+
NOT recoverable from this bag and is not meant to be β `shares.role_for` is what answers "may
|
| 79 |
+
this account change it", on every path that asks (see the four short-circuits named on
|
| 80 |
+
`reconcile_shared_permissions`).
|
| 81 |
+
|
| 82 |
+
β THE WILDCARD WINS, AND THE NAMED USERS ARE DISCARDED. `*` plus named users is still "shared
|
| 83 |
+
with everyone" on screen, because everyone can already reach it; carrying the named few
|
| 84 |
+
alongside would produce a bag that says `users` while the registry admits the room. Discarding
|
| 85 |
+
them is safe precisely because this bag is not a wall for a shared field: `role_for` keeps the
|
| 86 |
+
stronger of an everyone-grant and a personal one, so a named user's raised role survives in
|
| 87 |
+
the registry where it is actually read.
|
| 88 |
+
|
| 89 |
+
β `known_users` IS DELIBERATELY NOT PASSED, HERE OR BY ANY CALLER ON THIS PATH.
|
| 90 |
+
`clean_permissions` collapses to `{'edit': 'personal'}` when the filtered users list ends up
|
| 91 |
+
empty, so ONE grantee missing from the user registry would silently reflip a shared column to
|
| 92 |
+
Private β the owner's defect in the other direction, and just as invisible. `put_share` has
|
| 93 |
+
already validated every grantee through `_entries_or_400` before the grant is stored, so the
|
| 94 |
+
filter would only ever fire on a name that door already accepted.
|
| 95 |
+
|
| 96 |
+
β THE USERS LIST IS SORTED, AND THAT IS WHAT MAKES THE RECONCILE IDEMPOTENT rather than merely
|
| 97 |
+
convergent. `shares._clean_entries` preserves stored insertion order, so two grant sets holding
|
| 98 |
+
the same people in a different order would otherwise derive two different-but-equal bags and
|
| 99 |
+
rewrite each other forever on alternating reads (the D-358 egress class).
|
| 100 |
+
"""
|
| 101 |
+
users = []
|
| 102 |
+
seen = set()
|
| 103 |
+
for entry in entries or ():
|
| 104 |
+
if not isinstance(entry, dict):
|
| 105 |
+
continue
|
| 106 |
+
user = str(entry.get("user") or "").strip().lower()
|
| 107 |
+
if not user or user in seen:
|
| 108 |
+
continue
|
| 109 |
+
seen.add(user)
|
| 110 |
+
if user == shares.EVERYONE:
|
| 111 |
+
return clean_permissions({"edit": "collaborative"})
|
| 112 |
+
users.append(user)
|
| 113 |
+
if not users:
|
| 114 |
+
return clean_permissions({"edit": "personal"})
|
| 115 |
+
return clean_permissions({"edit": "users", "users": sorted(users)})
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _same_classification(left, right):
|
| 119 |
+
"""Do two canonical bags say the same thing about WHO?
|
| 120 |
+
|
| 121 |
+
β THE USERS LIST IS COMPARED AS A SET, not as a sequence, and that is a write-count decision
|
| 122 |
+
rather than a nicety. A stored bag minted before the sort above carries the registry's
|
| 123 |
+
insertion order; comparing sequences would rewrite every such column once for a change no
|
| 124 |
+
reader can see. `shared_overlay.put_field` is a `flush='sync'` store commit per field, so a
|
| 125 |
+
difference nobody can observe must not cost one.
|
| 126 |
+
"""
|
| 127 |
+
if left.get("edit") != right.get("edit"):
|
| 128 |
+
return False
|
| 129 |
+
return set(left.get("users") or ()) == set(right.get("users") or ())
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def reconcile_shared_permissions(shared_key, grant_topic, st=None):
|
| 133 |
+
"""Re-derive every SHARED definition's `permissions` bag from the grant registry. Returns the
|
| 134 |
+
number of definitions actually rewritten.
|
| 135 |
+
|
| 136 |
+
ββ THIS IS A **WRITE ON A READ PATH**, ON A TENANT-WIDE STRATUM, AND IT IS DELIBERATE.
|
| 137 |
+
`routes_customers.py`'s `ROUTE_KIND` comment argues the opposite case for route columns
|
| 138 |
+
(*"Backfilling every stored route definition on a read is a write nobody asked for, on a
|
| 139 |
+
tenant-wide stratum, triggered by opening a page"*) and settles it with "re-solving is one
|
| 140 |
+
click and it is the click the owner is already making". A SHARED FIELD HAS NO SUCH CLICK: the
|
| 141 |
+
owner's "Fisch Main Catalog" was shared once, months ago, and nothing will ever rewrite its
|
| 142 |
+
definition again. Leaving it to a future re-share is leaving it wrong forever. C4 also makes
|
| 143 |
+
the registry AUTHORITATIVE, which a stamp that is never re-derived cannot be. This function is
|
| 144 |
+
called from `migrate_legacy_fields`, which is already exactly such a pass β it runs
|
| 145 |
+
`flush='sync'` promotions on the same three read paths β so this adds a kind of write to a
|
| 146 |
+
door that already makes them, not a new door.
|
| 147 |
+
|
| 148 |
+
β ONLY THE SHARED STRATUM, CHECKED ON `shared`/`granted`. On a PER-USER private field the bag
|
| 149 |
+
IS the wall and re-deriving it would move a permission. On a shared one it is not, and that is
|
| 150 |
+
checked rather than reasoned β all four consumers short-circuit on the share marks BEFORE they
|
| 151 |
+
ever read the bag: `grid_events._may_edit_field_definition` and `::_may_edit_field_value` both
|
| 152 |
+
take `_field_share_role` on `definition.get('shared') or definition.get('granted')`;
|
| 153 |
+
`types.mayEditField` and `types.mayEditFieldDefinition` take the same branch in the same order
|
| 154 |
+
on the client; and `perm_scope.field_grant_hidden` keys off `FIELD_GRANT_MARK` plus
|
| 155 |
+
`shares.role_for` and never reads the bag at all. Nothing on this path is a permission
|
| 156 |
+
decision. This is the same fact the `ROUTE_KIND` comment states of its own change: *"AND IT
|
| 157 |
+
MOVES NO WALL, CHECKED RATHER THAN REASONED."*
|
| 158 |
+
|
| 159 |
+
β NO GRANT RECORD AT ALL MEANS **LEAVE IT ALONE**, and the distinction is why `shares.grants`
|
| 160 |
+
returning an owner with zero entries is a different answer from returning neither. A record
|
| 161 |
+
with an owner and no entries is a column somebody has managed and shared with nobody: that is
|
| 162 |
+
`personal`, positively. A column the registry has never heard of is a column this function
|
| 163 |
+
knows nothing about, and inventing a classification for it would be guessing in the direction
|
| 164 |
+
of a visible change.
|
| 165 |
+
|
| 166 |
+
β ONE `put_field` PER CHANGED DEFINITION, and the bound is what makes that acceptable. Only a
|
| 167 |
+
definition whose stored bag DISAGREES with its grants is rewritten, which in practice is the
|
| 168 |
+
handful first shared before this derivation existed; steady state is zero writes, proven by
|
| 169 |
+
the idempotence leg of the harness. `shared_overlay.put_rows`' docstring carries this repo's
|
| 170 |
+
measured scar for the opposite shape (*"18 writes against one document under the store's
|
| 171 |
+
coalescing single-flight landed ZERO while answering 200 eighteen times"*), so if a corpus ever
|
| 172 |
+
makes this pass write in bulk it must become ONE batched write rather than a loop.
|
| 173 |
+
"""
|
| 174 |
+
bucket = str(shared_key or "").strip()
|
| 175 |
+
grant_key = str(grant_topic or bucket).strip()
|
| 176 |
+
if not bucket or st is None:
|
| 177 |
+
return 0
|
| 178 |
+
rewritten = 0
|
| 179 |
+
for field_key, defn in list(shared_overlay.fields(bucket, st=st).items()):
|
| 180 |
+
# β EACH DEFINITION IS GUARDED ON ITS OWN. `shares.field_oid` RAISES on an empty key, and
|
| 181 |
+
# the caller wraps this whole function in one swallowing except β so a single malformed
|
| 182 |
+
# entry would otherwise skip every REMAINING column, including the one the owner reported,
|
| 183 |
+
# and report as "nothing to reclassify" with no failure anywhere near the cause.
|
| 184 |
+
try:
|
| 185 |
+
if not isinstance(defn, dict):
|
| 186 |
+
continue
|
| 187 |
+
if not (defn.get("shared") or defn.get("granted")):
|
| 188 |
+
continue
|
| 189 |
+
key = str(field_key or "").strip()
|
| 190 |
+
if not key:
|
| 191 |
+
continue
|
| 192 |
+
record = shares.grants("field", shares.field_oid(grant_key, key), st=st)
|
| 193 |
+
if not record.get("owner") and not record.get("entries"):
|
| 194 |
+
continue
|
| 195 |
+
derived = permissions_from_grants(record.get("entries"))
|
| 196 |
+
stored = defn.get("permissions")
|
| 197 |
+
# β A MISSING BAG IS ALWAYS A DIFFERENCE, EVEN WHEN THE DERIVATION SAYS `personal`.
|
| 198 |
+
# This is the leg that decides whether the fix is visible at all. The client's own
|
| 199 |
+
# fallback is `"collaborative"` (`types.cleanFieldPermissions(field.permissions,
|
| 200 |
+
# "collaborative")`), so a definition carrying NO bag renders as "Shared with
|
| 201 |
+
# everyone" no matter what the registry says. Comparing canonicalised values would
|
| 202 |
+
# find `personal == personal` and write nothing, and the column would keep reading
|
| 203 |
+
# "Shared with everyone" forever. The server must emit an EXPLICIT bag on every
|
| 204 |
+
# shared definition; absence is not a value the wire can carry.
|
| 205 |
+
if isinstance(stored, dict) and _same_classification(clean_permissions(stored), derived):
|
| 206 |
+
continue
|
| 207 |
+
updated = dict(defn)
|
| 208 |
+
updated["permissions"] = derived
|
| 209 |
+
shared_overlay.put_field(bucket, key, updated, st=st)
|
| 210 |
+
rewritten += 1
|
| 211 |
+
except Exception:
|
| 212 |
+
continue
|
| 213 |
+
return rewritten
|
| 214 |
+
|
| 215 |
+
|
| 216 |
def _field_is_migratable(field):
|
| 217 |
return (isinstance(field, dict) and field.get("custom") is True
|
| 218 |
and field.get("source") == "overlay")
|
|
|
|
| 229 |
grant_key = str(grant_topic or key).strip()
|
| 230 |
shared_key = str(shared_key or key).strip()
|
| 231 |
if not key or st is None:
|
| 232 |
+
return {"promoted": 0, "normalized": 0, "reclassified": 0}
|
| 233 |
+
# ββ W40-T03 β RECONCILE THE SHARED STRATUM'S CLASSIFICATION FIRST, IN ITS OWN SWALLOWING
|
| 234 |
+
# GUARD. This is the ONE seam that reaches every shared-field surface: all three readers
|
| 235 |
+
# (`routes_customers.shared_fields`, `routes_tables._ut_shared_fields`,
|
| 236 |
+
# `product_data`) already call this function with the right keys and read the definitions
|
| 237 |
+
# immediately afterwards, so a reconcile that writes into the shared overlay is picked up by
|
| 238 |
+
# that same read.
|
| 239 |
+
#
|
| 240 |
+
# β THE GUARD IS NOT DEFENSIVENESS, IT IS A BLAST RADIUS. `routes_customers.shared_fields`
|
| 241 |
+
# wraps its whole call in a `try/except` that degrades to `{}` β i.e. a raise in here would
|
| 242 |
+
# blank EVERY shared column on the customer grid and read as "nothing is shared yet". A
|
| 243 |
+
# classification that fails must leave the previous word on screen, never the page empty.
|
| 244 |
+
#
|
| 245 |
+
# β THE TWO KEYS ARE DIFFERENT STRINGS AND THE ORDER MATTERS. `shared_key` names the BUCKET
|
| 246 |
+
# (`product_table_workspace__shared`); `grant_key` names the registry TOPIC
|
| 247 |
+
# (`product_data:<field>`). For the product topic they are not the same value. Swapping them
|
| 248 |
+
# finds no grant record, the "no record" arm fires for every column, and the reconcile
|
| 249 |
+
# silently does nothing at all.
|
| 250 |
+
try:
|
| 251 |
+
reclassified = reconcile_shared_permissions(shared_key, grant_key, st=st)
|
| 252 |
+
except Exception:
|
| 253 |
+
reclassified = 0
|
| 254 |
try:
|
| 255 |
document = st.get(key) or {}
|
| 256 |
except Exception:
|
| 257 |
+
return {"promoted": 0, "normalized": 0, "reclassified": reclassified}
|
| 258 |
if not isinstance(document, dict):
|
| 259 |
+
return {"promoted": 0, "normalized": 0, "reclassified": reclassified}
|
| 260 |
|
| 261 |
promoted = 0
|
| 262 |
normalized = 0
|
|
|
|
| 327 |
values.pop(field_key, None)
|
| 328 |
return data
|
| 329 |
st.update(key, _remove, flush="sync")
|
| 330 |
+
# β `promoted` AND `normalized` KEEP THEIR NAMES AND THEIR MEANINGS.
|
| 331 |
+
# `aios-web/api/verify_field_permissions.py` asserts `result["promoted"] == 1`; renaming or
|
| 332 |
+
# folding either into the new count would turn a green gate red for a change it never made.
|
| 333 |
+
return {"promoted": promoted, "normalized": normalized, "reclassified": reclassified}
|
| 334 |
|
| 335 |
|
| 336 |
def promote_field(workspace_key, shared_key, grant_topic, owner, field, st=None):
|
|
|
|
| 341 |
return dict(field)
|
| 342 |
shared = dict(field)
|
| 343 |
shared.update({"shared": True, "granted": True, "source": "overlay", "custom": True})
|
| 344 |
+
# ββ W40-T03 / OWNER INSTRUCTION 21 β THE FALLBACK IS `personal`, AND IT IS THE ROOT CAUSE.
|
| 345 |
+
#
|
| 346 |
+
# Owner: *"By default every Field created is private until it is shared."*
|
| 347 |
+
#
|
| 348 |
+
# β THE BUG WAS AN ORDERING ONE, WHICH IS WHY IT LOOKED LIKE THE OPPOSITE OF ITSELF.
|
| 349 |
+
# `routes_shares.put_share` promotes FIRST and writes the request's own grant entries one line
|
| 350 |
+
# LATER, so on a first share this function cannot see who the field is being shared with. With
|
| 351 |
+
# `fallback="collaborative"` it therefore stamped `{'edit': 'collaborative'}` on EVERY first
|
| 352 |
+
# share β Karen-only included β and the bag was never re-derived afterwards. `set_grants`
|
| 353 |
+
# immediately replaced the transient `*` grant with Karen's, so the WALL was right the whole
|
| 354 |
+
# time; the permanent lie was the WORD, and the word is what the owner reads under Hide fields.
|
| 355 |
+
#
|
| 356 |
+
# β AN EXPLICIT BAG STILL WINS, AND THAT IS WHAT KEEPS THE OTHER CALLER BYTE-IDENTICAL.
|
| 357 |
+
# `grid_events.field_upsert` (the field editor's permission picker) sets
|
| 358 |
+
# `field['permissions']` in BOTH arms of its `may_change_permissions` branch before calling
|
| 359 |
+
# this, and only reaches it when that bag already says `collaborative` or `users`. The
|
| 360 |
+
# fallback is never consulted there, so this line moves nothing on that path.
|
| 361 |
+
#
|
| 362 |
+
# β THE CALL SITE CHANGES, NOT `stored_permissions`' OWN DEFAULT. That default answers a
|
| 363 |
+
# different question β *"what did an old field, written before this vocabulary existed,
|
| 364 |
+
# mean?"* β and `migrate_legacy_fields` still needs the old answer for its LEGACY promotion
|
| 365 |
+
# leg, which `verify_field_permissions.py` asserts to the letter.
|
| 366 |
+
#
|
| 367 |
+
# β `set_grants` BELOW IS LEFT IN PLACE. With `personal` it writes `[]` plus an owner, which is
|
| 368 |
+
# the shape `routes_customers.py`'s route claim states in full: *"the empty entry list is the
|
| 369 |
+
# point. `set_grants` keeps a record with an owner and no entries, so 'shared with nobody' is
|
| 370 |
+
# STORED and is a different fact from 'never shared'."* `reconcile_shared_permissions` reads
|
| 371 |
+
# exactly that difference, so dropping the call would make a freshly promoted column
|
| 372 |
+
# unclassifiable rather than private.
|
| 373 |
+
permissions = stored_permissions(shared, fallback="personal")
|
| 374 |
shared["permissions"] = permissions
|
| 375 |
shared["createdBy"] = str(shared.get("createdBy") or owner).strip().lower()
|
| 376 |
shared_overlay.put_field(shared_key, key, shared, st=st)
|
platform/core/grid_events.py
CHANGED
|
@@ -420,6 +420,92 @@ def _docs_key(ctx):
|
|
| 420 |
|
| 421 |
|
| 422 |
# ------------------------------------------------------------------ the workspace family (X1 amd.)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
|
| 424 |
"""Durable Airtable-style view/schema/overlay state, with an honest session fallback.
|
| 425 |
|
|
@@ -427,6 +513,11 @@ def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
|
|
| 427 |
with them. Own views win a same-id collision β a personal view is the user's own object and
|
| 428 |
must never be shadowed by somebody else's share.
|
| 429 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
β `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries
|
| 431 |
`memberPids`, which the writer's scope validated on the way IN β but the READER may have a
|
| 432 |
narrower scope. Handing a Fisch-scoped user a view whose member list was built by a
|
|
@@ -457,6 +548,69 @@ def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
|
|
| 457 |
except Exception as _ge:
|
| 458 |
_tel.error('sharedviews:granted', _ge)
|
| 459 |
granted = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
if shared or granted:
|
| 461 |
merged = dict(granted)
|
| 462 |
merged.update(shared) # the everyone-bucket outranks a grant
|
|
@@ -478,9 +632,16 @@ def _granted_views(ctx, uname):
|
|
| 478 |
The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's
|
| 479 |
bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS
|
| 480 |
bucket. An id granted on another topic simply finds nothing here and contributes nothing
|
| 481 |
-
(fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner`
|
| 482 |
-
client can synthesise the "Shared with me" group and disable edit affordances for
|
| 483 |
role=view β the stamps live on the PROJECTION only, never written back to the store.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
`role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the
|
| 485 |
registry listed only explicit entries), not the caller's rank.
|
| 486 |
"""
|
|
@@ -503,6 +664,12 @@ def _granted_views(ctx, uname):
|
|
| 503 |
v['shared'] = True
|
| 504 |
v['sharedRole'] = role
|
| 505 |
v['owner'] = owner
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 506 |
out[str(vid)] = v
|
| 507 |
|
| 508 |
# β D-37 (2026-08-05) β THE FOLDER LEG. W20's R10 said "a folder share = its views ride
|
|
@@ -537,6 +704,8 @@ def _granted_views(ctx, uname):
|
|
| 537 |
v['shared'] = True
|
| 538 |
v['sharedRole'] = role
|
| 539 |
v['owner'] = owner
|
|
|
|
|
|
|
| 540 |
# The folder's NAME rides so the client can group these together later; today it
|
| 541 |
# renders in the same "Shared with me" group the view leg feeds. Stamps live on the
|
| 542 |
# PROJECTION only and are never written back, exactly as above.
|
|
|
|
| 420 |
|
| 421 |
|
| 422 |
# ------------------------------------------------------------------ the workspace family (X1 amd.)
|
| 423 |
+
def _grant_marker(ctx, ws):
|
| 424 |
+
"""`has_grants(view_id) -> bool` for this tenant, or None when nothing here is granted at all.
|
| 425 |
+
|
| 426 |
+
ββ ONE PREDICATE, THREE MERGE LEGS β W40-T01, owner instruction 2, contract C1.
|
| 427 |
+
`table_workspace` assembles its `views` from three sources (granted / `__shared__` bucket /
|
| 428 |
+
the caller's own stratum) and a share mark has to mean the SAME thing on all three, or the
|
| 429 |
+
client is back to branching on who is looking. So the rule lives here once and the registry is
|
| 430 |
+
read ONCE per workspace read; a closure rather than three calls, because `granted_oids` exists
|
| 431 |
+
precisely to stop this becoming N reads of the whole grant bucket per rail.
|
| 432 |
+
|
| 433 |
+
ββ THE ASYMMETRY THIS CLOSES. Owner, verbatim: *"A shared View must show the
|
| 434 |
+
shared/collaborative icon in EVERY account, not only the recipient's."* Contract C1 states the
|
| 435 |
+
meaning that makes that true: **`shared` on a view means "this view has grants", not "granted
|
| 436 |
+
to me"**. The receiver's copy has been stamped since wave 21 (`_granted_views`); the SHARER's
|
| 437 |
+
copy never was and structurally could not be, because `_granted_views` and `shared_views` both
|
| 438 |
+
EXCLUDE what the caller owns by design β so the person who did the sharing is the one person
|
| 439 |
+
the old projection could never tell.
|
| 440 |
+
|
| 441 |
+
"Carries a grant" is EITHER of two things, and the folder leg is not optional: a receiver
|
| 442 |
+
already gets `shared` stamped through `_granted_views`' folder pass (D-37), so answering only
|
| 443 |
+
the direct-view half here would re-create exactly the asymmetry instruction 2 reports, one
|
| 444 |
+
level down.
|
| 445 |
+
|
| 446 |
+
β THE SOURCE IS THE R10 GRANT REGISTRY ONLY (`core/shares.py`), AND THE NARROWING IS
|
| 447 |
+
DELIBERATE β not an oversight for the next reader to "fix". `routes_shares.py`'s own module
|
| 448 |
+
docstring warns that TWO SYSTEMS answer "is this shared" and they are not the same one (audit
|
| 449 |
+
S-4): the other is the legacy `__shared__` bucket (`permissions.edit in ('collaborative',
|
| 450 |
+
'users')`). BUCKET MEMBERSHIP IS NOT A GRANT. A view living in that bucket is asked the same
|
| 451 |
+
question as any other β does the REGISTRY hold an entry for it β so a `collaborative` view
|
| 452 |
+
nobody ever granted carries no mark, and that stays booked rather than fixed here: marking it
|
| 453 |
+
would additionally need an `owner` stamp on that leg to keep the tooltip honest, which is a
|
| 454 |
+
copy change on a client file outside this ticket.
|
| 455 |
+
|
| 456 |
+
β WHICH IS WHY THE `__shared__` LEG STILL NEEDS ASKING, and it is the corner the own-views
|
| 457 |
+
leg cannot reach. `save_view` moves a `collaborative`/`users` view OUT of the creator's
|
| 458 |
+
personal stratum, so its OWNER has no copy in `ws['views']` at all; and `merged.update(shared)`
|
| 459 |
+
outranks the stamped `granted` copy, so a receiver's mark would be overwritten on the way
|
| 460 |
+
through. Both are answered by asking this predicate on that leg too. β The merge PRECEDENCE
|
| 461 |
+
is untouched β "the everyone-bucket outranks a grant" is deliberate and is not this ticket's
|
| 462 |
+
to reverse; the fix stamps the copy that wins, it does not re-arbitrate which copy wins.
|
| 463 |
+
|
| 464 |
+
β MEMBERSHIP OF THE ID IS THE WHOLE TEST, and there is one known false positive it cannot
|
| 465 |
+
see: a PINNED PER-USER id (`all-customers`, `tpl_overview`) exists once per user, so a grant
|
| 466 |
+
recorded against that id by anybody would mark every user's own copy. That is the same
|
| 467 |
+
first-match hazard `handle_one` documents at the foreign-view wall (W33-T13). It is decoration
|
| 468 |
+
on a view nobody shared, never a widening β and `granted_oids` cannot answer "whose" without
|
| 469 |
+
becoming the N-reads-per-rail shape it exists to avoid. Booked, not papered over.
|
| 470 |
+
|
| 471 |
+
β NO `createdBy` FILTER, on any leg. Ownership is not what this answers β "has grants" is a
|
| 472 |
+
property of the VIEW, which is the whole of C1 β and re-filtering on `createdBy` would drop
|
| 473 |
+
the mark off any record whose copy of it is absent or stale, the same class of trap that keeps
|
| 474 |
+
`scope_view` off the own-views leg. WHO is sharing is answered separately, by `sharedOut`.
|
| 475 |
+
|
| 476 |
+
β THE FOLDER MAP IS THE CALLER'S OWN `itemFolders`, which is the only placement map this read
|
| 477 |
+
has in hand. That is exact for the own-views leg. For a `__shared__` view it answers the
|
| 478 |
+
caller's own filing of it, and a folder grant on somebody else's folder cannot reach a view
|
| 479 |
+
in that bucket anyway (`find_folder` reads the owner's personal stratum, which a shared view
|
| 480 |
+
has already left) β so the direct-view half is what carries that leg in practice.
|
| 481 |
+
"""
|
| 482 |
+
import core.shares as shares
|
| 483 |
+
# The SAME store handle `_granted_views` resolves, and asked for the same reason: the grants
|
| 484 |
+
# bucket is tenant-scoped and must be read out of the tenant whose workspace this is. Written
|
| 485 |
+
# as `_tops(ctx).st` rather than `_store_of(ctx)` so the two legs cannot drift on tenancy.
|
| 486 |
+
st = getattr(_tops(ctx), 'st', None)
|
| 487 |
+
view_oids = shares.granted_oids('view', st=st)
|
| 488 |
+
folder_oids = shares.granted_oids('folder', st=st)
|
| 489 |
+
if not view_oids and not folder_oids:
|
| 490 |
+
return None # nothing in this tenant is granted: no marks, no closure
|
| 491 |
+
# β `itemFolders` IS KEYED BY SURFACE FIRST (`{'views': {itemId: folderId}, β¦}`) β reading item
|
| 492 |
+
# ids off the top level finds the surface NAMES and matches nothing, which is the same trap
|
| 493 |
+
# `table_store.find_folder` carries a note about. It is already in the payload above, so the
|
| 494 |
+
# folder leg costs no extra store read.
|
| 495 |
+
placed = (ws.get('itemFolders') or {}).get('views') or {}
|
| 496 |
+
|
| 497 |
+
def _has_grants(view_id):
|
| 498 |
+
key = str(view_id)
|
| 499 |
+
if key in view_oids:
|
| 500 |
+
return True
|
| 501 |
+
fid = str(placed.get(key) or '')
|
| 502 |
+
# `fid` truthiness FIRST: '' must never match a grant stored under the empty-string oid,
|
| 503 |
+
# which would mark every unfiled view at once.
|
| 504 |
+
return bool(fid and fid in folder_oids)
|
| 505 |
+
|
| 506 |
+
return _has_grants
|
| 507 |
+
|
| 508 |
+
|
| 509 |
def table_workspace(ctx, allowed_pids=None, consume_corrections=True):
|
| 510 |
"""Durable Airtable-style view/schema/overlay state, with an honest session fallback.
|
| 511 |
|
|
|
|
| 513 |
with them. Own views win a same-id collision β a personal view is the user's own object and
|
| 514 |
must never be shadowed by somebody else's share.
|
| 515 |
|
| 516 |
+
β W40-T01 (owner instruction 2, contract C1): every view that HAS grants carries
|
| 517 |
+
`hasShares: True` whoever is asking, and the copy belonging to the account doing the sharing
|
| 518 |
+
additionally carries `sharedOut: True`. Stamped on ALL THREE merge legs from ONE predicate
|
| 519 |
+
(`_grant_marker`), on the PROJECTION only, and never written back.
|
| 520 |
+
|
| 521 |
β `allowed_pids` is a SECURITY parameter, not a convenience. A shared view carries
|
| 522 |
`memberPids`, which the writer's scope validated on the way IN β but the READER may have a
|
| 523 |
narrower scope. Handing a Fisch-scoped user a view whose member list was built by a
|
|
|
|
| 548 |
except Exception as _ge:
|
| 549 |
_tel.error('sharedviews:granted', _ge)
|
| 550 |
granted = {}
|
| 551 |
+
# ββ W40-T01 / OWNER INSTRUCTION 2 / CONTRACT C1 β THE SHARE MARKS, ON THE TWO LEGS THAT
|
| 552 |
+
# ARRIVE UNSTAMPED. `granted` was already stamped by `_granted_views`; these two were not.
|
| 553 |
+
# β AND THIS SITS OUTSIDE THE `if shared or granted:` GUARD BELOW, deliberately. Four
|
| 554 |
+
# traps, all measured, each of which makes the "obvious" placement silently do nothing:
|
| 555 |
+
#
|
| 556 |
+
# β 1. THAT GUARD IS UNREACHABLE FOR THIS TICKET'S CASE. For the exact fixture the
|
| 557 |
+
# owner reports β I own a view, I shared it OUT, nothing is shared TO me β BOTH
|
| 558 |
+
# `shared` and `granted` are empty (`shared_views` and `_granted_views` each exclude
|
| 559 |
+
# what the caller owns, and a new view defaults to `permissions.edit='personal'` so
|
| 560 |
+
# it is not in the `__shared__` bucket either). The whole block is SKIPPED and
|
| 561 |
+
# `ws['views']` returns raw. Stamping "on the legs of the merge" is therefore not
|
| 562 |
+
# enough: the sharer's leg has to run unconditionally or it never runs at all.
|
| 563 |
+
#
|
| 564 |
+
# β 2. DO NOT ROUTE THESE THROUGH `scope_view`. Today own views only reach it when the
|
| 565 |
+
# caller has shares; make the comprehension unconditional and an own view whose
|
| 566 |
+
# `createdBy` is absent or mismatched starts being re-scoped, and `scope_view` with
|
| 567 |
+
# `allowed_pids=None` sets `memberPids: []` β the list, wiped, on the user's OWN view.
|
| 568 |
+
# Stamp beside that path, never into it. The granted/shared legs keep re-scoping
|
| 569 |
+
# exactly as before, and `scope_view` copies (`out = dict(view)`) so the stamps ride.
|
| 570 |
+
#
|
| 571 |
+
# β 3. `table_store.workspace()` RETURNS A SHALLOW COPY: the outer dict is fresh but each
|
| 572 |
+
# view VALUE is a live reference into the cached store document. Mutating one in place
|
| 573 |
+
# would poison the cache every other reader shares and could be written back. So a
|
| 574 |
+
# marked view is REPLACED with a copy; unmarked ones stay the same references they
|
| 575 |
+
# were, and nothing here is ever persisted (the `view_upsert` save path builds its
|
| 576 |
+
# record from a fixed key whitelist, so these stamps cannot survive a round trip).
|
| 577 |
+
# The `shared` leg is copied at the stamp for the same reason, rather than trusting
|
| 578 |
+
# `shared_views`' own `dict(v)` from a distance.
|
| 579 |
+
#
|
| 580 |
+
# β 4. THE `__shared__` LEG IS NOT OPTIONAL, and it is the corner leg 1 cannot reach.
|
| 581 |
+
# `merged.update(shared)` below OUTRANKS the stamped `granted` copy, so a receiver's
|
| 582 |
+
# mark would be overwritten on the way through; and the OWNER of such a view has no
|
| 583 |
+
# copy in `ws['views']` at all, because `save_view` moves a collaborative/users view
|
| 584 |
+
# OUT of the personal stratum. Unmarked in EVERY account: instruction 2's exact
|
| 585 |
+
# defect, one corner over. β The merge PRECEDENCE is untouched β stamping the copy
|
| 586 |
+
# that wins is not re-arbitrating which copy wins.
|
| 587 |
+
#
|
| 588 |
+
# `hasShares` is the marker, NOT `shared`: the client reads `view.shared` as "granted to
|
| 589 |
+
# ME" in two live places (it gates `mayEditView` and it groups the "Shared with me"
|
| 590 |
+
# section), so re-using that key would take the owner's own view away from them.
|
| 591 |
+
# `sharedOut` says WHO is doing the sharing β always true on the caller's own stratum, and
|
| 592 |
+
# on the `__shared__` leg only when the record's `createdBy` is this caller. It is what
|
| 593 |
+
# lets the client word the owner's tooltip as sharing OUT rather than re-using the
|
| 594 |
+
# receiver-worded copy, which C1 forbids.
|
| 595 |
+
# Absence is the negative: an unmarked view is left alone rather than stamped False.
|
| 596 |
+
try:
|
| 597 |
+
_has_grants = _grant_marker(ctx, ws) if (ws.get('views') or shared) else None
|
| 598 |
+
except Exception as _me:
|
| 599 |
+
# A share mark is DECORATION. It must never be the thing that takes down a workspace
|
| 600 |
+
# read, so it fails the way its two siblings above do: log and carry on unmarked.
|
| 601 |
+
_tel.error('sharedviews:marks', _me)
|
| 602 |
+
_has_grants = None
|
| 603 |
+
if _has_grants:
|
| 604 |
+
ws['views'] = {
|
| 605 |
+
vid: ({**v, 'hasShares': True, 'sharedOut': True}
|
| 606 |
+
if isinstance(v, dict) and _has_grants(vid) else v)
|
| 607 |
+
for vid, v in (ws.get('views') or {}).items()}
|
| 608 |
+
if shared:
|
| 609 |
+
shared = {
|
| 610 |
+
vid: ({**v, 'hasShares': True,
|
| 611 |
+
**({'sharedOut': True} if v.get('createdBy') == uname else {})}
|
| 612 |
+
if isinstance(v, dict) and _has_grants(vid) else v)
|
| 613 |
+
for vid, v in shared.items()}
|
| 614 |
if shared or granted:
|
| 615 |
merged = dict(granted)
|
| 616 |
merged.update(shared) # the everyone-bucket outranks a grant
|
|
|
|
| 632 |
The R10 registry stores bare ids, tenant-scoped through the SAME store handle this topic's
|
| 633 |
bucket uses; the record itself is fetched out of the OWNER's personal stratum of THIS
|
| 634 |
bucket. An id granted on another topic simply finds nothing here and contributes nothing
|
| 635 |
+
(fail-closed). Each projected view is stamped `shared` / `sharedRole` / `owner` / `hasShares`
|
| 636 |
+
so the client can synthesise the "Shared with me" group and disable edit affordances for
|
| 637 |
role=view β the stamps live on the PROJECTION only, never written back to the store.
|
| 638 |
+
`hasShares` (W40-T01, contract C1) is the viewer-INDEPENDENT one: `shared` still means
|
| 639 |
+
"granted to me" and only ever appears here, while `hasShares` means "this view has grants"
|
| 640 |
+
and is stamped on ALL THREE of `table_workspace`'s merge legs β this one, the caller's own
|
| 641 |
+
stratum, and the `__shared__` bucket β from the single predicate `_grant_marker`.
|
| 642 |
+
β A view in BOTH this projection and the `__shared__` bucket keeps the bucket's copy, because
|
| 643 |
+
`merged.update(shared)` outranks it there; that copy is stamped on its own leg, which is why
|
| 644 |
+
stamping here is not sufficient on its own.
|
| 645 |
`role_for` is asked with is_admin=False deliberately: the stamp reports the GRANT (the
|
| 646 |
registry listed only explicit entries), not the caller's rank.
|
| 647 |
"""
|
|
|
|
| 664 |
v['shared'] = True
|
| 665 |
v['sharedRole'] = role
|
| 666 |
v['owner'] = owner
|
| 667 |
+
# β W40-T01 (C1): the RECEIVER's half of the one marker both sides now carry. `shared`
|
| 668 |
+
# keeps its old meaning here β "granted to ME" β because the client still reads it that
|
| 669 |
+
# way; `hasShares` is the viewer-independent fact underneath it, and stamping it on this
|
| 670 |
+
# leg too is what lets the client draw ONE mark from ONE key instead of branching on who
|
| 671 |
+
# is looking. No `sharedOut`: this account is not the one sharing it.
|
| 672 |
+
v['hasShares'] = True
|
| 673 |
out[str(vid)] = v
|
| 674 |
|
| 675 |
# β D-37 (2026-08-05) β THE FOLDER LEG. W20's R10 said "a folder share = its views ride
|
|
|
|
| 704 |
v['shared'] = True
|
| 705 |
v['sharedRole'] = role
|
| 706 |
v['owner'] = owner
|
| 707 |
+
v['hasShares'] = True # W40-T01 (C1) β the folder pass carries it too, or a
|
| 708 |
+
# folder share would still be marked on one side only
|
| 709 |
# The folder's NAME rides so the client can group these together later; today it
|
| 710 |
# renders in the same "Shared with me" group the view leg feeds. Stamps live on the
|
| 711 |
# PROJECTION only and are never written back, exactly as above.
|
platform/core/perm_scope.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/core/shares.py
CHANGED
|
@@ -1,296 +1,414 @@
|
|
| 1 |
-
"""core/shares.py β ONE grant registry for every shareable object (wave 20, owner ruling R10).
|
| 2 |
-
|
| 3 |
-
WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
|
| 4 |
-
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
|
| 5 |
-
that can add or revoke people later, on any of the three.
|
| 6 |
-
|
| 7 |
-
WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
|
| 8 |
-
(`core/table_store.py`) and that stays β moving it would rewrite every stored view for no gain.
|
| 9 |
-
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
|
| 10 |
-
definition; giving each its own grant field would put the same three-line permission decision in
|
| 11 |
-
three files owned by two sessions, which is how the three drift. One registry, one predicate,
|
| 12 |
-
three callers.
|
| 13 |
-
|
| 14 |
-
shares.set_grants(kind, oid, entries, owner=β¦, st=β¦) # replaces the whole grant set
|
| 15 |
-
shares.grants(kind, oid, st=β¦) # -> {'owner': str, 'entries': [...]}
|
| 16 |
-
shares.role_for(kind, oid, user, is_admin=β¦, st=β¦) # -> 'owner'|'edit'|'view'|None
|
| 17 |
-
shares.
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
#:
|
| 56 |
-
#:
|
| 57 |
-
#:
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
#:
|
| 61 |
-
#:
|
| 62 |
-
#:
|
| 63 |
-
#:
|
| 64 |
-
#:
|
| 65 |
-
#:
|
| 66 |
-
#:
|
| 67 |
-
#:
|
| 68 |
-
#:
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
#:
|
| 72 |
-
#:
|
| 73 |
-
#:
|
| 74 |
-
#:
|
| 75 |
-
#:
|
| 76 |
-
#:
|
| 77 |
-
#:
|
| 78 |
-
#:
|
| 79 |
-
#:
|
| 80 |
-
#:
|
| 81 |
-
#:
|
| 82 |
-
#:
|
| 83 |
-
#:
|
| 84 |
-
#:
|
| 85 |
-
#:
|
| 86 |
-
#:
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
#:
|
| 92 |
-
#:
|
| 93 |
-
#:
|
| 94 |
-
#:
|
| 95 |
-
#:
|
| 96 |
-
#:
|
| 97 |
-
#:
|
| 98 |
-
#:
|
| 99 |
-
#:
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
return
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
def
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
return
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
def
|
| 239 |
-
"""
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""core/shares.py β ONE grant registry for every shareable object (wave 20, owner ruling R10).
|
| 2 |
+
|
| 3 |
+
WHAT R10 ASKED FOR: folders and databases share with the **same two-role vocabulary views
|
| 4 |
+
already use** (specific users or everyone; role = view | edit), plus one manage-access editor
|
| 5 |
+
that can add or revoke people later, on any of the three.
|
| 6 |
+
|
| 7 |
+
WHY A REGISTRY RATHER THAN A FIELD ON EACH OBJECT. A view already carries its own `permissions`
|
| 8 |
+
(`core/table_store.py`) and that stays β moving it would rewrite every stored view for no gain.
|
| 9 |
+
But a FOLDER is a value inside one user's workspace blob and a DATABASE is a `user_tables`
|
| 10 |
+
definition; giving each its own grant field would put the same three-line permission decision in
|
| 11 |
+
three files owned by two sessions, which is how the three drift. One registry, one predicate,
|
| 12 |
+
three callers.
|
| 13 |
+
|
| 14 |
+
shares.set_grants(kind, oid, entries, owner=β¦, st=β¦) # replaces the whole grant set
|
| 15 |
+
shares.grants(kind, oid, st=β¦) # -> {'owner': str, 'entries': [...]}
|
| 16 |
+
shares.role_for(kind, oid, user, is_admin=β¦, st=β¦) # -> 'owner'|'edit'|'view'|None
|
| 17 |
+
shares.max_grantable_role(kind, oid, user, β¦) # -> 'edit'|'view'|None (R4's ceiling)
|
| 18 |
+
shares.shared_with(user, kind=β¦, st=β¦) # -> [oid] this user was granted
|
| 19 |
+
|
| 20 |
+
THE ROLE VOCABULARY IS TWO WORDS AND THE DEFAULT IS THE NARROW ONE. `view` = may open and read;
|
| 21 |
+
`edit` = may also change the object's CONTENT.
|
| 22 |
+
|
| 23 |
+
ββ AND ON A VIEW, `edit` NOW ALSO MEANS "MAY RE-SHARE" β OWNER RULING R4, instruction 4 (*"Edit
|
| 24 |
+
View so a member can share a View as well, not just an admin"*), built as W40-T02. This REVERSES
|
| 25 |
+
the flat owner-or-admin rule this paragraph used to state, and it reverses it for ONE kind:
|
| 26 |
+
`RESHARE_KINDS` below is where the widening is spelled, once, and `folder` / `database` / `field`
|
| 27 |
+
are untouched β still the owner's or an admin's, for the reason given at that constant.
|
| 28 |
+
|
| 29 |
+
β WHICH HALF OF THE OLD PROTECTION SURVIVES, BECAUSE THE FEAR IT NAMED WAS REAL AND ONLY HALF OF
|
| 30 |
+
IT IS ANSWERED. The sentence here used to be *"a collaborator who could rewrite grants could grant
|
| 31 |
+
themselves sole ownership of somebody else's object, or quietly widen a users-scoped share to
|
| 32 |
+
everyone"*. Taking those one at a time:
|
| 33 |
+
* **SOLE OWNERSHIP IS BLOCKED β and now stated rather than incidental.** The owner is STICKY in
|
| 34 |
+
`set_grants` and `routes_shares.put_share` passes the EXISTING owner straight back through, so
|
| 35 |
+
a re-share cannot transfer ownership and no grantee can lock the creator out of their own view.
|
| 36 |
+
Ownership moves for nobody, by any path this registry offers.
|
| 37 |
+
* **WIDENING IS BOUNDED, NOT BLOCKED, AND THAT IS R4's DELIBERATE COST.** A re-share may never
|
| 38 |
+
exceed the role the re-sharer holds, and the STRICTER reading of that ships: an `edit` grantee
|
| 39 |
+
may add somebody β `*` included β at `view`, and can never mint `edit` access for anyone.
|
| 40 |
+
Handing out `edit` stays the owner's (or an admin's) alone. A `view` grantee still cannot share
|
| 41 |
+
at all. `max_grantable_role` is that ceiling, and `routes_shares.put_share` is where it bites.
|
| 42 |
+
|
| 43 |
+
β AN UNREADABLE GRANT IS NO GRANT. Every path here fails closed β junk in the bucket, a missing
|
| 44 |
+
owner, an unknown role string all resolve to None rather than to a default that opens something.
|
| 45 |
+
[[aios-permissioning]]: no fail-open defaults, ever.
|
| 46 |
+
|
| 47 |
+
β THE BUCKET IS TENANT-SCOPED THROUGH `st`, like every other product-data write. Passing the
|
| 48 |
+
session's `TenantRuntime` is what keeps Nurilab's grants in Nurilab's store; the module default
|
| 49 |
+
(`core.store`) is tenant #0 and exists for the same reason it does everywhere else β the ~28
|
| 50 |
+
callers that predate multi-tenancy. (This is the D-5/D-16 residency shape, and this module does
|
| 51 |
+
NOT repeat their mistake: `st` is threaded from the first line rather than retrofitted.)
|
| 52 |
+
"""
|
| 53 |
+
import core.store as store
|
| 54 |
+
|
| 55 |
+
#: The store key. One bucket per tenant holds every kind's grants, because "what am I shared on"
|
| 56 |
+
#: is a question across kinds β the "Shared with me" folder (R10) is exactly that query, and
|
| 57 |
+
#: three separate buckets would make it three reads that can disagree about what a user can see.
|
| 58 |
+
SHARES_KEY = 'object_shares'
|
| 59 |
+
|
| 60 |
+
#: The shareable kinds. A CLOSED vocabulary: an unknown kind raises rather than creating a new
|
| 61 |
+
#: namespace by typo, which would silently grant nothing to nobody and read as "sharing is broken".
|
| 62 |
+
#:
|
| 63 |
+
#: ββ W38-T16 β `field` IS THE FOURTH, AND NOTHING IN THIS FILE BRANCHES ON IT. Every function
|
| 64 |
+
#: below treats `kind` as an opaque bucket key (`_check_kind / grants / set_grants / role_for /
|
| 65 |
+
#: may_see / may_edit / may_administer / shared_with / drop_objects`), so the kind's whole cost
|
| 66 |
+
#: here is this tuple member. That is the point of one registry: the new object's WALL is written
|
| 67 |
+
#: once in `core.perm_scope`, and its DOORS once in `routes_shares.py` β never a fourth
|
| 68 |
+
#: permission decision in a fourth file ([[one-evaluator-per-question]]).
|
| 69 |
+
KINDS = ('view', 'folder', 'database', 'field')
|
| 70 |
+
|
| 71 |
+
#: `*` is "everyone who can already open the surface". It is NOT "every account on the platform".
|
| 72 |
+
#: Spelled as a single character so it can never collide with a username (usernames are lower-case
|
| 73 |
+
#: and non-empty by `core/users.py`, and are checked against this explicitly below).
|
| 74 |
+
#:
|
| 75 |
+
#: ββ **AND WHAT THAT MEANS DEPENDS ON THE KIND β THE LINE THIS NOTE USED TO CARRY WAS FALSE FOR
|
| 76 |
+
#: ONE OF THE THREE** (W33-T30, `waves/wave32/sharing-audit.md` S-8). It read *"the module/table
|
| 77 |
+
#: wall runs FIRST and this never widens past it"*, flatly, and the audit's own words for that are
|
| 78 |
+
#: *"the third docstring in this audit describing a check that is not on the path"*. Corrected
|
| 79 |
+
#: here rather than deleted, because the sentence is TRUE of two kinds and the difference is the
|
| 80 |
+
#: whole point:
|
| 81 |
+
#: * `kind='view'` / `kind='folder'` on a GOVERNED module (`customer_data`, `product_data`) β
|
| 82 |
+
#: the sentence holds. `require_session` plus the topic's own gate run first, and the
|
| 83 |
+
#: receiver's row scope and hidden-field closure are applied BEFORE any foreign view is
|
| 84 |
+
#: merged, so a grant can only narrow-or-equal what that account could already reach.
|
| 85 |
+
#: * `kind='database'` on a `ut_*` table β ββ **THE SENTENCE HOLDS HERE TOO NOW, AND THAT IS
|
| 86 |
+
#: W36-T21 / OWNER RULING R6 (audit S-8, CLOSED).** It did not until wave 36, and the reason
|
| 87 |
+
#: is worth keeping: `routes_admin._clean_perms` 400'd any key outside
|
| 88 |
+
#: `("customer_data", "product_data")`, so no row filter and no hidden field could even be
|
| 89 |
+
#: DECLARED for a user table, `routes_tables.py` made zero `perm_scope` calls, and it passed
|
| 90 |
+
#: `hidden_keys=frozenset()`. A `database` grant was therefore ALL-OR-NOTHING β every row,
|
| 91 |
+
#: every column β and this registry was the only wall behind it.
|
| 92 |
+
#:
|
| 93 |
+
#: β WHAT CHANGED: `perm_scope.scoped_table` (contract C1) is the ONE door to any database's
|
| 94 |
+
#: rows. `routes_tables` applies the permanent filter before `pids` is taken and the transitive
|
| 95 |
+
#: hidden-field closure after `workspace_wire`, on EVERY `ut_*` read β the same code that walls
|
| 96 |
+
#: `customer_data` β and a door that cannot apply them REFUSES rather than serving the lot. So a
|
| 97 |
+
#: `database` grant is once again bounded by a second wall: it decides WHETHER an account reaches
|
| 98 |
+
#: the database, and C1 decides WHICH rows and columns it then sees. An `*` grant still admits
|
| 99 |
+
#: every account in the tenant, and each of them still only sees what their own wall allows.
|
| 100 |
+
#:
|
| 101 |
+
#: β `routes_shares.py`'s module docstring carries the OLD sentence at the other door and is in
|
| 102 |
+
#: no wave-36 fence β the audit's own fix was "say so at both", so one of the two is now stale.
|
| 103 |
+
#: Booked in `mailbox/C.md` (C-14) rather than edited across a fence
|
| 104 |
+
#: [[two-gates-can-assert-opposite-things]].
|
| 105 |
+
EVERYONE = '*'
|
| 106 |
+
|
| 107 |
+
ROLES = ('view', 'edit')
|
| 108 |
+
|
| 109 |
+
#: ββ THE KINDS AN `edit` GRANTEE MAY RE-SHARE β R4 / W40-T02, and it is `view` ALONE.
|
| 110 |
+
#:
|
| 111 |
+
#: R4's words are "may re-share **A VIEW**", and the widening goes no further than the ruling
|
| 112 |
+
#: names. That restraint is the whole reason this is a tuple rather than a bare `role == 'edit'`
|
| 113 |
+
#: arm inside `may_administer`: every predicate in this file treats `kind` as an opaque bucket key
|
| 114 |
+
#: (see the note on `KINDS`), so an UNGATED widening would reach `folder`, `database` and `field`
|
| 115 |
+
#: in the same line β handing an `edit` grantee on a `ut_*` DATABASE the power to re-share the
|
| 116 |
+
#: whole table. The asymmetry is one of blast radius: a view is one saved SELECTION, opened through
|
| 117 |
+
#: the receiver's own module wall and row scope; a database grant admits an account to a database.
|
| 118 |
+
#: `routes_shares.py`'s module docstring carries that argument at the door where it is enforced.
|
| 119 |
+
#:
|
| 120 |
+
#: β ONE SPELLING, DELIBERATELY. `may_administer` and `max_grantable_role` both consult this, so
|
| 121 |
+
#: "who may open the editor" and "what may they hand out" cannot come apart about the same kind.
|
| 122 |
+
RESHARE_KINDS = ('view',)
|
| 123 |
+
|
| 124 |
+
#: ββ A FIELD's OBJECT ID IS TOPIC-QUALIFIED, AND THE SEPARATOR IS DECLARED HERE SO THERE IS ONE
|
| 125 |
+
#: SPELLING OF IT. A bare field key is NOT unique: `notes` exists on a dozen databases, and a
|
| 126 |
+
#: grant stored under it would admit a grantee to every `notes` column in the tenant at once β
|
| 127 |
+
#: the widening direction, silently, forever. `table_key` is the qualifier because it is the same
|
| 128 |
+
#: identifier `shared_overlay.bucket()` already keys the values by, so the grant and the data it
|
| 129 |
+
#: governs are named by the same string ([[one-question-two-normalizers]]).
|
| 130 |
+
#:
|
| 131 |
+
#: β A `ut_*` key and a registry topic key both match `[a-z0-9_]+` and neither can contain `:`,
|
| 132 |
+
#: so the split below is unambiguous in both directions.
|
| 133 |
+
FIELD_OID_SEP = ':'
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def field_oid(table_key, field_key):
|
| 137 |
+
"""`"<table_key>:<field_key>"` β the share id of ONE column on ONE database."""
|
| 138 |
+
table = str(table_key or '').strip()
|
| 139 |
+
field = str(field_key or '').strip()
|
| 140 |
+
if not table or not field:
|
| 141 |
+
raise ValueError('shares.field_oid: a field share names BOTH a database and a column. '
|
| 142 |
+
'A bare field key repeats across tables and would grant all of them')
|
| 143 |
+
return f'{table}{FIELD_OID_SEP}{field}'
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def split_field_oid(oid):
|
| 147 |
+
"""`(table_key, field_key)` or `(None, None)` for anything that is not a field oid.
|
| 148 |
+
|
| 149 |
+
β FAIL-CLOSED ON JUNK, like every other read here: a caller that cannot learn WHICH database
|
| 150 |
+
an id names must not fall back to "the one I happen to be looking at", which is how a grant
|
| 151 |
+
on somebody else's column would be read as a grant on this one.
|
| 152 |
+
"""
|
| 153 |
+
raw = str(oid or '')
|
| 154 |
+
table, sep, field = raw.partition(FIELD_OID_SEP)
|
| 155 |
+
if not sep or not table.strip() or not field.strip() or FIELD_OID_SEP in field:
|
| 156 |
+
return (None, None)
|
| 157 |
+
return (table.strip(), field.strip())
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _st(st):
|
| 161 |
+
return st if st is not None else store
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _check_kind(kind):
|
| 165 |
+
k = str(kind or '').strip().lower()
|
| 166 |
+
if k not in KINDS:
|
| 167 |
+
raise ValueError(f'{kind!r} is not a shareable kind. Use one of {", ".join(KINDS)}. '
|
| 168 |
+
f'This door refuses to invent a namespace from a typo.')
|
| 169 |
+
return k
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _clean_entries(entries):
|
| 173 |
+
"""Normalise + REJECT junk, returning [{'user': str, 'role': 'view'|'edit'}].
|
| 174 |
+
|
| 175 |
+
Silently dropping a malformed entry is right here and wrong elsewhere: the caller is a UI
|
| 176 |
+
that just listed the people it is about to grant, so a rejected row must not abort the whole
|
| 177 |
+
save β but an entry with an unknown ROLE must not be stored as something else's default
|
| 178 |
+
either. Dropped, never coerced.
|
| 179 |
+
"""
|
| 180 |
+
out, seen = [], set()
|
| 181 |
+
for e in entries or ():
|
| 182 |
+
if not isinstance(e, dict):
|
| 183 |
+
continue
|
| 184 |
+
user = str(e.get('user') or '').strip().lower()
|
| 185 |
+
role = str(e.get('role') or '').strip().lower()
|
| 186 |
+
if not user or role not in ROLES or user in seen:
|
| 187 |
+
continue
|
| 188 |
+
seen.add(user)
|
| 189 |
+
out.append({'user': user, 'role': role})
|
| 190 |
+
return out
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def grants(kind, oid, st=None):
|
| 194 |
+
"""`{'owner': str|None, 'entries': [{'user','role'}]}` β never raises on a junk bucket."""
|
| 195 |
+
kind = _check_kind(kind)
|
| 196 |
+
try:
|
| 197 |
+
bucket = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
|
| 198 |
+
rec = bucket.get(str(oid)) or {}
|
| 199 |
+
except Exception:
|
| 200 |
+
return {'owner': None, 'entries': []}
|
| 201 |
+
if not isinstance(rec, dict):
|
| 202 |
+
return {'owner': None, 'entries': []}
|
| 203 |
+
return {'owner': (str(rec.get('owner')).strip().lower() if rec.get('owner') else None),
|
| 204 |
+
'entries': _clean_entries(rec.get('entries'))}
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def set_grants(kind, oid, entries, owner=None, st=None):
|
| 208 |
+
"""REPLACE the grant set for one object. Returns the stored record.
|
| 209 |
+
|
| 210 |
+
β REPLACE, NOT MERGE, and that is the contract the UI needs: revoking is expressed by an
|
| 211 |
+
entry's ABSENCE. A merge-only API cannot remove anybody without a second verb, and the
|
| 212 |
+
manage-access editor R10 asks for is exactly "here is the list now".
|
| 213 |
+
"""
|
| 214 |
+
kind = _check_kind(kind)
|
| 215 |
+
oid = str(oid)
|
| 216 |
+
clean = _clean_entries(entries)
|
| 217 |
+
owner_l = str(owner).strip().lower() if owner else None
|
| 218 |
+
|
| 219 |
+
def _apply(data):
|
| 220 |
+
by_kind = dict(data.get(kind) or {})
|
| 221 |
+
prior = by_kind.get(oid) if isinstance(by_kind.get(oid), dict) else {}
|
| 222 |
+
# The owner is STICKY: set once, and a later save that omits it must not orphan the
|
| 223 |
+
# object. An ownerless grant record cannot answer "who may re-share this", so every
|
| 224 |
+
# administer check would fail closed and the object would become unmanageable.
|
| 225 |
+
keep_owner = owner_l or (str(prior.get('owner')).strip().lower()
|
| 226 |
+
if prior.get('owner') else None)
|
| 227 |
+
if not clean and not keep_owner:
|
| 228 |
+
by_kind.pop(oid, None) # fully un-shared and unowned: leave no empty husk
|
| 229 |
+
else:
|
| 230 |
+
by_kind[oid] = {'owner': keep_owner, 'entries': clean}
|
| 231 |
+
data[kind] = by_kind
|
| 232 |
+
return data
|
| 233 |
+
|
| 234 |
+
_st(st).update(SHARES_KEY, _apply, flush='async')
|
| 235 |
+
return grants(kind, oid, st=st)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def role_for(kind, oid, user, is_admin=False, st=None):
|
| 239 |
+
"""`'owner'` | `'edit'` | `'view'` | `None` β the caller's effective role, fail-closed.
|
| 240 |
+
|
| 241 |
+
An ADMIN reads as `'owner'`: an admin who could not administer an object could not
|
| 242 |
+
administer the tenant either, which is `table_store._may_administer`'s existing rule and is
|
| 243 |
+
kept identical here so the two cannot disagree about the same view.
|
| 244 |
+
"""
|
| 245 |
+
user = str(user or '').strip().lower()
|
| 246 |
+
if not user:
|
| 247 |
+
return None
|
| 248 |
+
rec = grants(kind, oid, st=st)
|
| 249 |
+
if is_admin or (rec['owner'] and rec['owner'] == user):
|
| 250 |
+
return 'owner'
|
| 251 |
+
best = None
|
| 252 |
+
for e in rec['entries']:
|
| 253 |
+
if e['user'] == user or e['user'] == EVERYONE:
|
| 254 |
+
# The STRONGER of the two wins when both a personal and an everyone grant exist:
|
| 255 |
+
# naming somebody explicitly is how you RAISE them above the room, so an
|
| 256 |
+
# everyone-view + alice-edit pair must leave alice editing.
|
| 257 |
+
if e['role'] == 'edit':
|
| 258 |
+
return 'edit'
|
| 259 |
+
best = best or 'view'
|
| 260 |
+
return best
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def may_see(kind, oid, user, is_admin=False, st=None):
|
| 264 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) is not None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def may_edit(kind, oid, user, is_admin=False, st=None):
|
| 268 |
+
return role_for(kind, oid, user, is_admin=is_admin, st=st) in ('owner', 'edit')
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def may_administer(kind, oid, user, is_admin=False, st=None):
|
| 272 |
+
"""May this caller change WHO ELSE reaches the object, and revoke them?
|
| 273 |
+
|
| 274 |
+
ββ R4 / W40-T02 β AN `edit` GRANTEE ANSWERS TRUE, ON A `RESHARE_KINDS` KIND. Owner
|
| 275 |
+
instruction 4: *"Edit View so a member can share a View as well, not just an admin"*. That
|
| 276 |
+
reverses this function's old owner-or-admin rule for exactly one kind; the module note says
|
| 277 |
+
which half of the protection survives and `RESHARE_KINDS` says why it stops at `view`.
|
| 278 |
+
|
| 279 |
+
β ADMINISTERING IS NOT GRANTING, AND THE SECOND QUESTION HAS ITS OWN PREDICATE. True here
|
| 280 |
+
means "may open the editor and rewrite the list"; it does NOT mean every role is theirs to
|
| 281 |
+
hand out. `max_grantable_role` is the ceiling, and R4's two halves are only both honoured if
|
| 282 |
+
a caller consults both ([[one-evaluator-per-question]]).
|
| 283 |
+
|
| 284 |
+
β THE GATE IS THE KIND, NOT THE ROLE ALONE. `role_for` is deliberately kind-agnostic, so
|
| 285 |
+
testing `== 'edit'` without `RESHARE_KINDS` would widen all four kinds in one line.
|
| 286 |
+
"""
|
| 287 |
+
role = role_for(kind, oid, user, is_admin=is_admin, st=st)
|
| 288 |
+
if role == 'owner':
|
| 289 |
+
return True
|
| 290 |
+
return role == 'edit' and _check_kind(kind) in RESHARE_KINDS
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def max_grantable_role(kind, oid, user, is_admin=False, st=None):
|
| 294 |
+
"""The STRONGEST role this caller may hand SOMEBODY ELSE on this object, fail-closed.
|
| 295 |
+
|
| 296 |
+
`'edit'` for the owner or an admin Β· `'view'` for an `edit` grantee on a `RESHARE_KINDS` kind
|
| 297 |
+
Β· `None` for anybody who may not administer the object at all.
|
| 298 |
+
|
| 299 |
+
ββ WHY A SECOND PREDICATE RATHER THAN A FLAG ON `may_administer` (R4 / W40-T02). R4 grants an
|
| 300 |
+
`edit` holder the right to re-share and caps it in the same breath: *"a re-share may never
|
| 301 |
+
exceed the role the re-sharer holds"*. Those are two different questions, and a single boolean
|
| 302 |
+
answering both is exactly how the cap gets dropped by the next caller that only needs the door.
|
| 303 |
+
|
| 304 |
+
β THE NARROWER OF R4's TWO READINGS SHIPS, AND ON PURPOSE. "Never exceed the role you hold"
|
| 305 |
+
reads either as *may grant up to and including `edit`* (an edit holder confers edit) or as *may
|
| 306 |
+
confer strictly less than the owner can*. This returns `'view'` β the second β because it is the
|
| 307 |
+
FAIL-CLOSED direction. A wrong `'view'` costs the owner one click to raise somebody; a wrong
|
| 308 |
+
`'edit'` lets a chain of collaborators propagate edit access the owner never approved, with
|
| 309 |
+
nothing in the store recording who widened it. W40-T02's `done-when` pins the same reading.
|
| 310 |
+
|
| 311 |
+
β THIS CAPS WHAT A CALLER GRANTS, NOT WHAT THE STORED SET ALREADY HOLDS, and the difference is
|
| 312 |
+
load-bearing. `routes_shares.put_share` enforces it against the DELTA β a name arriving at
|
| 313 |
+
`edit`, or an existing `view` grantee raised to it β never against every row of a body, because
|
| 314 |
+
the PUT REPLACES and the client therefore re-sends the whole list, the re-sharer's own `edit`
|
| 315 |
+
row included. The evidence for that is at the call site, where the body is.
|
| 316 |
+
"""
|
| 317 |
+
role = role_for(kind, oid, user, is_admin=is_admin, st=st)
|
| 318 |
+
if role == 'owner':
|
| 319 |
+
return 'edit'
|
| 320 |
+
if role == 'edit' and _check_kind(kind) in RESHARE_KINDS:
|
| 321 |
+
return 'view'
|
| 322 |
+
return None
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def shared_with(user, kind=None, st=None):
|
| 326 |
+
"""Every object id this user has been granted (excluding what they own).
|
| 327 |
+
|
| 328 |
+
This is the "Shared with me" query (R10). It EXCLUDES owned objects deliberately: a folder
|
| 329 |
+
you made is not something shared *with* you, and listing it there would make the system
|
| 330 |
+
folder a duplicate of the rail above it.
|
| 331 |
+
"""
|
| 332 |
+
user = str(user or '').strip().lower()
|
| 333 |
+
if not user:
|
| 334 |
+
return {}
|
| 335 |
+
try:
|
| 336 |
+
data = _st(st).get(SHARES_KEY) or {}
|
| 337 |
+
except Exception:
|
| 338 |
+
return {}
|
| 339 |
+
out = {}
|
| 340 |
+
for k in ([_check_kind(kind)] if kind else KINDS):
|
| 341 |
+
hits = []
|
| 342 |
+
for oid, rec in (data.get(k) or {}).items():
|
| 343 |
+
if not isinstance(rec, dict):
|
| 344 |
+
continue
|
| 345 |
+
owner = str(rec.get('owner') or '').strip().lower()
|
| 346 |
+
if owner == user:
|
| 347 |
+
continue
|
| 348 |
+
for e in _clean_entries(rec.get('entries')):
|
| 349 |
+
if e['user'] in (user, EVERYONE):
|
| 350 |
+
hits.append(str(oid))
|
| 351 |
+
break
|
| 352 |
+
out[k] = sorted(hits)
|
| 353 |
+
return out if kind is None else {_check_kind(kind): out[_check_kind(kind)]}
|
| 354 |
+
|
| 355 |
+
|
| 356 |
+
def granted_oids(kind, st=None):
|
| 357 |
+
"""Every object id of one `kind` that carries AT LEAST ONE grant entry β in ONE bucket read.
|
| 358 |
+
|
| 359 |
+
β W40-T01 / OWNER INSTRUCTION 2 ("a shared View must show the shared icon in EVERY account,
|
| 360 |
+
not only the recipient's"). `shared_with` answers *what was granted TO me* and deliberately
|
| 361 |
+
EXCLUDES what the caller owns β so it structurally cannot answer the other question a share
|
| 362 |
+
mark asks: *does this object have grants at all*. That question has no viewer in it, which is
|
| 363 |
+
why this is a separate function rather than a flag bolted onto `shared_with`.
|
| 364 |
+
|
| 365 |
+
β ONE READ, NOT N β and that is the whole reason this exists rather than a loop at the caller.
|
| 366 |
+
The obvious spelling is `grants(kind, oid)` per view, and `grants` re-reads the WHOLE bucket
|
| 367 |
+
on every call; a busy rail carries dozens of views, so painting one icon would cost dozens of
|
| 368 |
+
full reads of the tenant's entire grant registry. This reads the bucket once and hands back a
|
| 369 |
+
set the caller tests in O(1).
|
| 370 |
+
|
| 371 |
+
β AN ENTRY IS WHAT COUNTS, NOT A RECORD. `set_grants` keeps an owner-only HUSK after a full
|
| 372 |
+
revoke β the owner is sticky, deliberately, see its own note β so testing for the record's
|
| 373 |
+
mere EXISTENCE would leave the mark lit forever after the last person was removed. That is
|
| 374 |
+
exactly the revoke case, so it is the difference between this being right and being decorative
|
| 375 |
+
noise. `_clean_entries` is the same normaliser every other read here uses, so a junk entry
|
| 376 |
+
cannot mark an object either.
|
| 377 |
+
|
| 378 |
+
β FAIL-CLOSED like the rest of this module: an unreadable bucket answers the EMPTY set β no
|
| 379 |
+
marks β never a default that claims something is shared. An unknown KIND still RAISES, exactly
|
| 380 |
+
as `grants` / `shared_with` / `drop_objects` do: that is a typo in a caller, not junk in the
|
| 381 |
+
store, and swallowing it into "nothing is shared" would hide a caller that never works.
|
| 382 |
+
"""
|
| 383 |
+
kind = _check_kind(kind)
|
| 384 |
+
try:
|
| 385 |
+
by_kind = (_st(st).get(SHARES_KEY) or {}).get(kind) or {}
|
| 386 |
+
rows = list(by_kind.items())
|
| 387 |
+
except Exception:
|
| 388 |
+
return set()
|
| 389 |
+
return {str(oid) for oid, rec in rows
|
| 390 |
+
if isinstance(rec, dict) and _clean_entries(rec.get('entries'))}
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
def drop_objects(pairs, st=None):
|
| 394 |
+
"""Remove whole grant RECORDS, owner husk included β wave 21, item 6a (C3).
|
| 395 |
+
|
| 396 |
+
A deleted object's grants must die with it: `shared_with` would otherwise serve ghost ids
|
| 397 |
+
into every receiver's "Shared with me" forever, and the ghost would 404 on open. One
|
| 398 |
+
transaction for the whole sweep β a table delete drops its database grant plus a view
|
| 399 |
+
grant per view that lived in its bucket."""
|
| 400 |
+
want = {}
|
| 401 |
+
for kind, oid in pairs or ():
|
| 402 |
+
want.setdefault(_check_kind(kind), set()).add(str(oid))
|
| 403 |
+
if not want:
|
| 404 |
+
return
|
| 405 |
+
|
| 406 |
+
def _apply(data):
|
| 407 |
+
for kind, oids in want.items():
|
| 408 |
+
by_kind = data.get(kind)
|
| 409 |
+
if isinstance(by_kind, dict):
|
| 410 |
+
for oid in oids:
|
| 411 |
+
by_kind.pop(oid, None)
|
| 412 |
+
return data
|
| 413 |
+
|
| 414 |
+
_st(st).update(SHARES_KEY, _apply, flush='async')
|
platform/core/users.py
CHANGED
|
@@ -1,24 +1,24 @@
|
|
| 1 |
-
"""Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
|
| 2 |
-
|
| 3 |
-
Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β never stored or logged in plaintext.
|
| 4 |
-
A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
|
| 5 |
-
users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
|
| 6 |
-
|
| 7 |
-
Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
|
| 8 |
-
allowed_bus() β the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
|
| 9 |
-
"""
|
| 10 |
-
import os
|
| 11 |
-
import hmac
|
| 12 |
-
import hashlib
|
| 13 |
-
import secrets
|
| 14 |
-
|
| 15 |
-
import core.store as store
|
| 16 |
-
|
| 17 |
-
BU_LABELS = {5: 'Fisch', 6: 'Royal'}
|
| 18 |
-
_ITER = 200_000
|
| 19 |
-
|
| 20 |
-
#: Wave 15 C-PERM β the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
|
| 21 |
-
#: kept as a literal here so `users` does not import the permission layer it is read by.
|
| 22 |
PERMS_VERSION = 1
|
| 23 |
|
| 24 |
|
|
@@ -64,451 +64,488 @@ def qa_identity(username, pw=None):
|
|
| 64 |
return {'username': _QA_USERNAME, 'name': 'QA Runner', 'role': 'user',
|
| 65 |
'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
|
| 66 |
'epoch': qa_epoch, 'qa_runner': True}
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def _hash(pw, salt):
|
| 70 |
-
return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
|
| 74 |
-
perms=None, tenant='royal-imports', platform_admin=False):
|
| 75 |
-
salt = secrets.token_hex(16)
|
| 76 |
-
rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
|
| 77 |
-
'bus': bus, 'active': active, 'modules': modules,
|
| 78 |
-
'agent': agent or None, 'email': email or None,
|
| 79 |
-
# Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
|
| 80 |
-
# pre-wave record β no migration. Login binds the session to THIS value; the posted
|
| 81 |
-
# tenant field can hint but never override it.
|
| 82 |
-
'tenant': str(tenant or 'royal-imports').strip().lower()}
|
| 83 |
-
if platform_admin is True:
|
| 84 |
-
# Wave 19 (R3): the PLATFORM-operator flag β half of `core.platform_admin`'s double lock
|
| 85 |
-
# (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
|
| 86 |
-
# is not deliberately promoted keeps its pre-wave shape and answers False by absence.
|
| 87 |
-
# There is no UI writer and there never should be: it is set by provisioning, on purpose.
|
| 88 |
-
rec['platform_admin'] = True
|
| 89 |
-
if perms is not None:
|
| 90 |
-
# Wave 15 C-PERM. A record written WITH perms is migrated by construction β the marker
|
| 91 |
-
# and the block are set together, here, so no writer can create one without the other.
|
| 92 |
-
# (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
|
| 93 |
-
# would be silently ignored; a marker without a block would deny everything.)
|
| 94 |
-
rec['perms'] = perms
|
| 95 |
-
rec['perms_v'] = PERMS_VERSION
|
| 96 |
-
return rec
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
def registry():
|
| 100 |
-
return store.get('users')
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
def ensure_bootstrap():
|
| 104 |
-
"""Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
|
| 105 |
-
Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
|
| 106 |
-
path in verify()).
|
| 107 |
-
|
| 108 |
-
Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
|
| 109 |
-
is definitively False. A transient read failure at startup used to return {} and make this
|
| 110 |
-
re-seed just {admin} over the real accounts β that is the bug that wiped users on restart."""
|
| 111 |
-
if not store.available():
|
| 112 |
-
return
|
| 113 |
-
if store.exists('users'): # present, or uncertain -> never clobber
|
| 114 |
-
return
|
| 115 |
-
try:
|
| 116 |
-
reg = store.get('users', fresh=True)
|
| 117 |
-
except Exception:
|
| 118 |
-
return
|
| 119 |
-
if reg:
|
| 120 |
-
return
|
| 121 |
-
master = os.environ.get('APP_PASSWORD', '')
|
| 122 |
-
if not master:
|
| 123 |
-
return
|
| 124 |
-
try:
|
| 125 |
-
store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
|
| 126 |
-
except Exception:
|
| 127 |
-
pass
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def _public(username, u):
|
| 131 |
-
return {'username': username, 'name': u.get('name', username),
|
| 132 |
-
'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
|
| 133 |
-
'modules': u.get('modules', 'all'),
|
| 134 |
-
'agent': u.get('agent'), 'email': u.get('email'),
|
| 135 |
-
# Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
|
| 136 |
-
# does not travel β the same rule the perms block states below.
|
| 137 |
-
'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
|
| 138 |
-
# Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
|
| 139 |
-
# to every grid session via the workspace map); without it here the API session's
|
| 140 |
-
# user record silently drops it and /me can never show your own photo.
|
| 141 |
-
'avatar': u.get('avatar') or None,
|
| 142 |
-
# β WAVE 15 C-PERM β THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
|
| 143 |
-
# `deps._user_for` builds every API session from `_public()`, so a `perms` block
|
| 144 |
-
# dropped here is a restricted account served as an unrestricted one β silently, on
|
| 145 |
-
# every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
|
| 146 |
-
# same reason inverted: the marker without the block denies everything, the block
|
| 147 |
-
# without the marker is ignored. Two keys, one fact, never separated.
|
| 148 |
-
# `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
|
| 149 |
-
# rather than by grep β a projection is exactly the kind of wiring that looks
|
| 150 |
-
# present in three files and is absent in the one that runs.
|
| 151 |
-
**({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
|
| 152 |
-
**({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
|
| 153 |
-
# β WAVE 19 R3 β THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
|
| 154 |
-
# session from this projection, so the platform-admin flag travels here or
|
| 155 |
-
# `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
|
| 156 |
-
# admin plane 403s its own operator. Carried ONLY when the record says True, so a
|
| 157 |
-
# session dict for any other account is byte-identical to its pre-wave shape.
|
| 158 |
-
# Not a client leak: `routes_auth._public_user` is a whitelist projection and does
|
| 159 |
-
# not name this key, so it reaches no browser via /login or /me β the client's copy
|
| 160 |
-
# is the separate `platformAdmin` bool on GET /settings, which is derived from this.
|
| 161 |
-
**({'platform_admin': True} if u.get('platform_admin') is True else {}),
|
| 162 |
-
'epoch': int(u.get('epoch') or 0)}
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
# ------------------------------------------------------------------ session revocation (X3)
|
| 166 |
-
# The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
|
| 167 |
-
# delete from, so "log this user out everywhere" needs a number that lives with the account. The
|
| 168 |
-
# cookie carries the epoch it was minted under; bumping the account's epoch makes every
|
| 169 |
-
# outstanding cookie for that user fail verification on its next use. Absent == 0, so every
|
| 170 |
-
# record written before this wave is valid without a migration.
|
| 171 |
-
def epoch(username):
|
| 172 |
-
"""The current session epoch for `username`. None when there is no such account.
|
| 173 |
-
|
| 174 |
-
None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β
|
| 175 |
-
which the session verifier must treat as a reason to refuse, not as a default to compare
|
| 176 |
-
against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
|
| 177 |
-
that case explicitly rather than inventing an epoch for it here.)
|
| 178 |
-
"""
|
| 179 |
-
username = (username or '').strip().lower()
|
| 180 |
-
try:
|
| 181 |
-
u = (store.get('users') or {}).get(username)
|
| 182 |
-
except Exception:
|
| 183 |
-
return None
|
| 184 |
-
return int((u or {}).get('epoch') or 0) if u else None
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def bump_epoch(username):
|
| 188 |
-
"""Revoke every outstanding API session for this account.
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
#
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
#
|
| 348 |
-
#
|
| 349 |
-
#
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
def
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
def
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
def assignable_people(tenant=None):
|
| 490 |
-
"""Display names for `user`-typed overlay columns β the tenant's ACTIVE accounts.
|
| 491 |
-
|
| 492 |
-
Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
|
| 493 |
-
call rather than persisted with the column: a snapshot would keep offering people who
|
| 494 |
-
have left and never offer people who joined. Deactivated accounts are excluded; a value
|
| 495 |
-
already stored on a row is untouched β history should still say who owned something.
|
| 496 |
-
|
| 497 |
-
Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β the user registry
|
| 498 |
-
is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
|
| 499 |
-
cross-tenant name leak.
|
| 500 |
-
|
| 501 |
-
ββ WAVE 33 (W33-T37) β A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
|
| 502 |
-
return EVERY tenant's staff: `if want and β¦` is structurally fail-OPEN, and the exemption was
|
| 503 |
-
written for "None = unscoped (the Streamlit host, tenant #0's process)" β a host DELETED at
|
| 504 |
-
EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
|
| 505 |
-
roster one forgotten kwarg away, with no error to notice
|
| 506 |
-
([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
|
| 507 |
-
β The direction is the safety: this can only ever NARROW. All six live call sites pass
|
| 508 |
-
`session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
|
| 509 |
-
changes β and a future caller that forgets gets an empty picker somebody notices instead of a
|
| 510 |
-
leak nobody does.
|
| 511 |
-
"""
|
| 512 |
return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
|
| 513 |
|
| 514 |
|
|
@@ -517,67 +554,67 @@ def assignable_identities(tenant=None):
|
|
| 517 |
return [{"username": str(username).strip().lower(),
|
| 518 |
"name": str(record.get("name") or username)}
|
| 519 |
for username, record in sorted(_tenant_accounts(tenant), key=lambda item: item[0])]
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
def set_avatar(username, data_url):
|
| 523 |
-
"""Set (or clear, with None/'') the user's profile photo β a data URL (wave 14 C-AVATAR,
|
| 524 |
-
[[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
|
| 525 |
-
decoded size) because this value is served back to every grid session. Cleared by removing
|
| 526 |
-
the key, so records without a photo keep their pre-wave shape."""
|
| 527 |
-
username = (username or '').strip().lower()
|
| 528 |
-
|
| 529 |
-
def _set(reg):
|
| 530 |
-
u = reg.get(username)
|
| 531 |
-
if u:
|
| 532 |
-
if data_url:
|
| 533 |
-
u['avatar'] = str(data_url)
|
| 534 |
-
else:
|
| 535 |
-
u.pop('avatar', None)
|
| 536 |
-
return reg
|
| 537 |
-
store.update('users', _set)
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
def avatar_map(tenant=None):
|
| 541 |
-
"""{display name -> avatar data URL} for ACTIVE accounts with a photo β the companion of
|
| 542 |
-
`assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
|
| 543 |
-
name, so the display name is the only join a renderer has. Two active accounts sharing a
|
| 544 |
-
display name share one option; the first WITH a photo wins the key rather than a coin
|
| 545 |
-
flip deciding whether the option has a face. `tenant` scopes it exactly as
|
| 546 |
-
`assignable_people(tenant)` does, and for the same leak β including wave 33's fail-closed
|
| 547 |
-
blank, which both take from the ONE resolver below rather than each writing `if want and β¦`.
|
| 548 |
-
"""
|
| 549 |
-
out = {}
|
| 550 |
-
for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
|
| 551 |
-
av = u.get('avatar')
|
| 552 |
-
nm = str(u.get('name') or username)
|
| 553 |
-
if av and nm not in out:
|
| 554 |
-
out[nm] = str(av)
|
| 555 |
-
return out
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
def _tenant_accounts(tenant):
|
| 559 |
-
"""`[(username, record), β¦]` β the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
|
| 560 |
-
|
| 561 |
-
β THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
|
| 562 |
-
identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
|
| 563 |
-
blank slug in the same way (`if want and β¦`). Two copies of a wall is two places for one of
|
| 564 |
-
them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
|
| 565 |
-
the same predicate and is lane C's to fold in.
|
| 566 |
-
|
| 567 |
-
β `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
|
| 568 |
-
pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
|
| 569 |
-
normalises it the same way. What changed is the WANT side β a blank want is now a refusal
|
| 570 |
-
rather than a wildcard.
|
| 571 |
-
"""
|
| 572 |
-
want = str(tenant or '').strip().lower()
|
| 573 |
-
if not want:
|
| 574 |
-
# Not an error and not everything: nobody. See `assignable_people`'s note β the one
|
| 575 |
-
# caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
|
| 576 |
-
return []
|
| 577 |
-
try:
|
| 578 |
-
reg = registry() or {}
|
| 579 |
-
except Exception:
|
| 580 |
-
return []
|
| 581 |
-
return [(n, u) for n, u in reg.items()
|
| 582 |
-
if isinstance(u, dict) and u.get('active') is not False
|
| 583 |
-
and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
|
|
|
|
| 1 |
+
"""Per-user accounts for the platform, persisted in the HF Dataset store (users.json).
|
| 2 |
+
|
| 3 |
+
Passwords are salted + PBKDF2-HMAC-SHA256 (200k iterations) β never stored or logged in plaintext.
|
| 4 |
+
A bootstrap 'admin' account is seeded from APP_PASSWORD so the owner can always log in and create
|
| 5 |
+
users; APP_PASSWORD also works as an emergency master for 'admin' if the registry is unreachable.
|
| 6 |
+
|
| 7 |
+
Each account carries BU access ('all' or a list of team-ids [5=Fisch, 6=Royal]) which drives
|
| 8 |
+
allowed_bus() β the basis for per-Business-Unit permissioning (a Royal-only user never sees Fisch).
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
import hmac
|
| 12 |
+
import hashlib
|
| 13 |
+
import secrets
|
| 14 |
+
|
| 15 |
+
import core.store as store
|
| 16 |
+
|
| 17 |
+
BU_LABELS = {5: 'Fisch', 6: 'Royal'}
|
| 18 |
+
_ITER = 200_000
|
| 19 |
+
|
| 20 |
+
#: Wave 15 C-PERM β the explicit-resolution marker. Mirrors `core.perm_scope.PERMS_VERSION`;
|
| 21 |
+
#: kept as a literal here so `users` does not import the permission layer it is read by.
|
| 22 |
PERMS_VERSION = 1
|
| 23 |
|
| 24 |
|
|
|
|
| 64 |
return {'username': _QA_USERNAME, 'name': 'QA Runner', 'role': 'user',
|
| 65 |
'bus': 'all', 'modules': ['customers'], 'tenant': _QA_TENANT,
|
| 66 |
'epoch': qa_epoch, 'qa_runner': True}
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _hash(pw, salt):
|
| 70 |
+
return hashlib.pbkdf2_hmac('sha256', str(pw).encode('utf-8'), bytes.fromhex(salt), _ITER).hex()
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _record(pw, name, role, bus, active=True, modules='all', agent=None, email=None,
|
| 74 |
+
perms=None, tenant='royal-imports', platform_admin=False):
|
| 75 |
+
salt = secrets.token_hex(16)
|
| 76 |
+
rec = {'salt': salt, 'hash': _hash(pw, salt), 'name': name, 'role': role,
|
| 77 |
+
'bus': bus, 'active': active, 'modules': modules,
|
| 78 |
+
'agent': agent or None, 'email': email or None,
|
| 79 |
+
# Wave 18 (C1-TENANT, R1): the account's COMPANY. Absent == 'royal-imports' on every
|
| 80 |
+
# pre-wave record β no migration. Login binds the session to THIS value; the posted
|
| 81 |
+
# tenant field can hint but never override it.
|
| 82 |
+
'tenant': str(tenant or 'royal-imports').strip().lower()}
|
| 83 |
+
if platform_admin is True:
|
| 84 |
+
# Wave 19 (R3): the PLATFORM-operator flag β half of `core.platform_admin`'s double lock
|
| 85 |
+
# (the other half is `tenant == 'loopable'`). Written ONLY for True, so every record that
|
| 86 |
+
# is not deliberately promoted keeps its pre-wave shape and answers False by absence.
|
| 87 |
+
# There is no UI writer and there never should be: it is set by provisioning, on purpose.
|
| 88 |
+
rec['platform_admin'] = True
|
| 89 |
+
if perms is not None:
|
| 90 |
+
# Wave 15 C-PERM. A record written WITH perms is migrated by construction β the marker
|
| 91 |
+
# and the block are set together, here, so no writer can create one without the other.
|
| 92 |
+
# (`core.perm_scope` reads an unmarked record as legacy, so a block without its marker
|
| 93 |
+
# would be silently ignored; a marker without a block would deny everything.)
|
| 94 |
+
rec['perms'] = perms
|
| 95 |
+
rec['perms_v'] = PERMS_VERSION
|
| 96 |
+
return rec
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def registry():
|
| 100 |
+
return store.get('users')
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def ensure_bootstrap():
|
| 104 |
+
"""Seed an 'admin' account from APP_PASSWORD ONLY on a truly fresh store (no users file yet).
|
| 105 |
+
Idempotent; no-op if the store is unavailable (the app then falls back to the master-password
|
| 106 |
+
path in verify()).
|
| 107 |
+
|
| 108 |
+
Critically, this NEVER overwrites an existing registry: it seeds only when store.exists('users')
|
| 109 |
+
is definitively False. A transient read failure at startup used to return {} and make this
|
| 110 |
+
re-seed just {admin} over the real accounts β that is the bug that wiped users on restart."""
|
| 111 |
+
if not store.available():
|
| 112 |
+
return
|
| 113 |
+
if store.exists('users'): # present, or uncertain -> never clobber
|
| 114 |
+
return
|
| 115 |
+
try:
|
| 116 |
+
reg = store.get('users', fresh=True)
|
| 117 |
+
except Exception:
|
| 118 |
+
return
|
| 119 |
+
if reg:
|
| 120 |
+
return
|
| 121 |
+
master = os.environ.get('APP_PASSWORD', '')
|
| 122 |
+
if not master:
|
| 123 |
+
return
|
| 124 |
+
try:
|
| 125 |
+
store.put('users', {'admin': _record(master, 'Administrator', 'admin', 'all')})
|
| 126 |
+
except Exception:
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _public(username, u):
|
| 131 |
+
return {'username': username, 'name': u.get('name', username),
|
| 132 |
+
'role': u.get('role', 'user'), 'bus': u.get('bus', 'all'),
|
| 133 |
+
'modules': u.get('modules', 'all'),
|
| 134 |
+
'agent': u.get('agent'), 'email': u.get('email'),
|
| 135 |
+
# Wave 18 (C1-TENANT): the session's tenant binding travels on the projection or it
|
| 136 |
+
# does not travel β the same rule the perms block states below.
|
| 137 |
+
'tenant': str(u.get('tenant') or 'royal-imports').strip().lower(),
|
| 138 |
+
# Wave 14 C-AVATAR: the profile photo is public-safe by definition (it is served
|
| 139 |
+
# to every grid session via the workspace map); without it here the API session's
|
| 140 |
+
# user record silently drops it and /me can never show your own photo.
|
| 141 |
+
'avatar': u.get('avatar') or None,
|
| 142 |
+
# β WAVE 15 C-PERM β THE WALL TRAVELS ON THIS PROJECTION OR IT DOES NOT TRAVEL.
|
| 143 |
+
# `deps._user_for` builds every API session from `_public()`, so a `perms` block
|
| 144 |
+
# dropped here is a restricted account served as an unrestricted one β silently, on
|
| 145 |
+
# every route, with nothing to notice. `perms_v` must ride ALONG WITH it and for the
|
| 146 |
+
# same reason inverted: the marker without the block denies everything, the block
|
| 147 |
+
# without the marker is ignored. Two keys, one fact, never separated.
|
| 148 |
+
# `verify_api` asserts a restricted user's SESSION OBJECT carries both, at the mount
|
| 149 |
+
# rather than by grep β a projection is exactly the kind of wiring that looks
|
| 150 |
+
# present in three files and is absent in the one that runs.
|
| 151 |
+
**({'perms': u['perms']} if isinstance(u.get('perms'), dict) else {}),
|
| 152 |
+
**({'perms_v': int(u['perms_v'] or 0)} if u.get('perms_v') else {}),
|
| 153 |
+
# β WAVE 19 R3 β THE SAME RULE, ON A NEW FIELD. `deps._user_for` builds every API
|
| 154 |
+
# session from this projection, so the platform-admin flag travels here or
|
| 155 |
+
# `core.platform_admin.is_platform_admin(session.user)` is blind and the Loopable
|
| 156 |
+
# admin plane 403s its own operator. Carried ONLY when the record says True, so a
|
| 157 |
+
# session dict for any other account is byte-identical to its pre-wave shape.
|
| 158 |
+
# Not a client leak: `routes_auth._public_user` is a whitelist projection and does
|
| 159 |
+
# not name this key, so it reaches no browser via /login or /me β the client's copy
|
| 160 |
+
# is the separate `platformAdmin` bool on GET /settings, which is derived from this.
|
| 161 |
+
**({'platform_admin': True} if u.get('platform_admin') is True else {}),
|
| 162 |
+
'epoch': int(u.get('epoch') or 0)}
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
# ------------------------------------------------------------------ session revocation (X3)
|
| 166 |
+
# The API's session cookie is SIGNED AND STATELESS: there is no server-side session table to
|
| 167 |
+
# delete from, so "log this user out everywhere" needs a number that lives with the account. The
|
| 168 |
+
# cookie carries the epoch it was minted under; bumping the account's epoch makes every
|
| 169 |
+
# outstanding cookie for that user fail verification on its next use. Absent == 0, so every
|
| 170 |
+
# record written before this wave is valid without a migration.
|
| 171 |
+
def epoch(username):
|
| 172 |
+
"""The current session epoch for `username`. None when there is no such account.
|
| 173 |
+
|
| 174 |
+
None is NOT 0. 0 is "this account exists and has never been revoked"; None is "no record" β
|
| 175 |
+
which the session verifier must treat as a reason to refuse, not as a default to compare
|
| 176 |
+
against. (The APP_PASSWORD emergency-master admin has no record at all; the verifier handles
|
| 177 |
+
that case explicitly rather than inventing an epoch for it here.)
|
| 178 |
+
"""
|
| 179 |
+
username = (username or '').strip().lower()
|
| 180 |
+
try:
|
| 181 |
+
u = (store.get('users') or {}).get(username)
|
| 182 |
+
except Exception:
|
| 183 |
+
return None
|
| 184 |
+
return int((u or {}).get('epoch') or 0) if u else None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def bump_epoch(username):
|
| 188 |
+
"""Revoke every outstanding API session for this account, and RETURN the new epoch.
|
| 189 |
+
|
| 190 |
+
None when there was no such record: nothing was bumped, and inventing 0 would hand a caller an
|
| 191 |
+
epoch that no cookie should ever be minted under.
|
| 192 |
+
|
| 193 |
+
β WHY IT RETURNS β WAVE 40, R2 ("signing in on computer B signs computer A out immediately").
|
| 194 |
+
A login now bumps the epoch and then has to MINT under it, and the minting caller cannot find
|
| 195 |
+
that number by reading the registry back: between the write and the read another writer (this
|
| 196 |
+
module's own `touch_login` thread, a second login, an admin edit) can land, and a cookie minted
|
| 197 |
+
at the wrong epoch is refused by `deps._user_for` on the very next request β a 200 login
|
| 198 |
+
followed by 401 forever. The write is the only place that knows what it wrote.
|
| 199 |
+
|
| 200 |
+
The value comes from the document `store.update` RETURNED rather than from what we tried to
|
| 201 |
+
write: on the HF backend a cross-process rebase can rebuild that document from a newer copy and
|
| 202 |
+
replay this closure against it, so the LANDED epoch is the authority. If another process bumped
|
| 203 |
+
the same account concurrently the landed value is higher than ours, which is still exactly the
|
| 204 |
+
newest-wins answer R2 asks for. The closure-captured value is the fallback for a store whose
|
| 205 |
+
`update` returns nothing.
|
| 206 |
+
"""
|
| 207 |
+
username = (username or '').strip().lower()
|
| 208 |
+
bumped = {}
|
| 209 |
+
|
| 210 |
+
def _set(reg):
|
| 211 |
+
u = reg.get(username)
|
| 212 |
+
if u:
|
| 213 |
+
u['epoch'] = int(u.get('epoch') or 0) + 1
|
| 214 |
+
bumped['epoch'] = u['epoch']
|
| 215 |
+
return reg
|
| 216 |
+
landed = store.update('users', _set)
|
| 217 |
+
if 'epoch' not in bumped:
|
| 218 |
+
return None # no such record: nothing was revoked, so promise nothing
|
| 219 |
+
try:
|
| 220 |
+
rec = (landed or {}).get(username) or {}
|
| 221 |
+
if rec.get('epoch') is not None:
|
| 222 |
+
return int(rec['epoch'])
|
| 223 |
+
except Exception:
|
| 224 |
+
pass
|
| 225 |
+
return int(bumped['epoch'])
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def verify(username, pw):
|
| 229 |
+
"""Return a public user dict on success, else None. APP_PASSWORD is an emergency master for the
|
| 230 |
+
'admin' login even if the store is unreachable, so the owner is never locked out."""
|
| 231 |
+
username = (username or '').strip().lower()
|
| 232 |
+
if not username or not pw:
|
| 233 |
+
return None
|
| 234 |
+
master = os.environ.get('APP_PASSWORD', '')
|
| 235 |
+
try:
|
| 236 |
+
# read fresh so accounts created moments ago (UI or out-of-band) are recognised at once
|
| 237 |
+
reg = store.get('users', fresh=True)
|
| 238 |
+
except Exception:
|
| 239 |
+
reg = {}
|
| 240 |
+
u = reg.get(username)
|
| 241 |
+
# β KEY FIRST, EMAIL SECOND, AND SINCE WAVE 37 THAT ORDER IS A DECISION RATHER THAN AN ACCIDENT.
|
| 242 |
+
# R8 lets a username BE an email address, so a typed string can now match a registry KEY and a
|
| 243 |
+
# different account's `email` field at the same time. The key wins, here, by construction: this
|
| 244 |
+
# branch is only reached when `reg.get(username)` missed. `routes_admin.py::create_user` refuses
|
| 245 |
+
# to CREATE either collision (`username_shadows_email` / `email_shadows_username`) so the
|
| 246 |
+
# ambiguity cannot be introduced through the product; this line is what decides it for any
|
| 247 |
+
# record that arrived some other way.
|
| 248 |
+
if u is None and '@' in username:
|
| 249 |
+
# Wave 18 (R1): the login box takes a username OR an email β admin@nurilab.id signs in
|
| 250 |
+
# without knowing the slug an admin chose. First case-insensitive email match wins;
|
| 251 |
+
# ambiguity is an admin data problem, not a login feature.
|
| 252 |
+
for k, r in reg.items():
|
| 253 |
+
if isinstance(r, dict) and str(r.get('email') or '').strip().lower() == username:
|
| 254 |
+
username, u = k, r
|
| 255 |
+
break
|
| 256 |
+
if u and u.get('active', True) and hmac.compare_digest(_hash(pw, u['salt']), u['hash']):
|
| 257 |
+
return _public(username, u)
|
| 258 |
+
# emergency master: admin + APP_PASSWORD always works (covers first run / store outage)
|
| 259 |
+
if username == 'admin' and master and hmac.compare_digest(str(pw), master):
|
| 260 |
+
# β CARRY THE RECORD'S CURRENT EPOCH when there is a record to read, so the session cookie
|
| 261 |
+
# the API mints from this dict AGREES with the stored account.
|
| 262 |
+
#
|
| 263 |
+
# This is not what stops the emergency lockout β `deps._user_for`'s master fallback does
|
| 264 |
+
# that, and a negative control confirmed the lockout is gone with or without this line.
|
| 265 |
+
# What it fixes is subtler and is a SCOPE question: a cookie whose epoch disagrees with the
|
| 266 |
+
# record falls through to that master fallback, which hands back a SYNTHETIC identity
|
| 267 |
+
# (`bus: 'all'`, `modules: 'all'`). An admin whose record narrows either field would
|
| 268 |
+
# therefore be silently WIDENED to consolidated, all-module access for the life of that
|
| 269 |
+
# session. Matching the epoch means the record branch wins and the account's real scope
|
| 270 |
+
# applies, leaving the master fallback as the true last resort it is meant to be.
|
| 271 |
+
# 0 when the store is unreachable, which is the case this branch was written for.
|
| 272 |
+
#
|
| 273 |
+
# β WAVE 40 (R2) β `emergency_master` MARKS THIS DICT SO THE LOGIN CAN SKIP THE EPOCH BUMP.
|
| 274 |
+
# Every ordinary sign-in now bumps the account's epoch to sign its other sessions out, but
|
| 275 |
+
# this identity is deliberately NOT epoch-revocable: `deps._user_for`'s master fallback
|
| 276 |
+
# admits it whatever the record says. A bump here would therefore revoke nothing, while
|
| 277 |
+
# forcing a store WRITE on the one code path that exists for a store outage. Internal to
|
| 278 |
+
# the server: `routes_auth._public_user` is a whitelist projection and does not name it.
|
| 279 |
+
return {'username': 'admin', 'name': 'Administrator', 'role': 'admin', 'bus': 'all',
|
| 280 |
+
'modules': 'all', 'epoch': int((reg.get('admin') or {}).get('epoch') or 0),
|
| 281 |
+
'emergency_master': True}
|
| 282 |
+
return None
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def create_user(username, pw, name, role='user', bus='all', modules='all',
|
| 286 |
+
agent=None, email=None, tenant=None, platform_admin=None):
|
| 287 |
+
"""Create β or, from app.py's dialog, OVERWRITE β an account.
|
| 288 |
+
|
| 289 |
+
β X3: OVERWRITING AN ACCOUNT MUST NOT RESURRECT ITS OLD SESSIONS. `_record()` builds a fresh
|
| 290 |
+
record with no `epoch` key, i.e. absent == 0. So re-saving an existing username used to reset
|
| 291 |
+
the epoch to 0, and every cookie minted before that account's last password rotation started
|
| 292 |
+
verifying again β a silent un-revocation. `app.py`'s "Add / update a user" calls this function
|
| 293 |
+
for BOTH add and update, so the hole was reachable from the shipped UI.
|
| 294 |
+
|
| 295 |
+
Epoch revocation is only ever as strong as the NARROWEST write path that touches the record, so
|
| 296 |
+
the carry-and-bump lives here rather than in each caller: an overwrite is at least as
|
| 297 |
+
session-invalidating as a password change, and it usually IS one.
|
| 298 |
+
"""
|
| 299 |
+
username = (username or '').strip().lower()
|
| 300 |
+
if not username or not pw:
|
| 301 |
+
raise ValueError('username and password are required')
|
| 302 |
+
|
| 303 |
+
def _add(reg):
|
| 304 |
+
prior = reg.get(username) or {}
|
| 305 |
+
rec = _record(pw, name or username, role, bus, modules=modules,
|
| 306 |
+
agent=agent, email=email,
|
| 307 |
+
# An overwrite that names no tenant KEEPS the account's company β a
|
| 308 |
+
# rename must never quietly move a user between tenants.
|
| 309 |
+
tenant=(tenant or prior.get('tenant') or 'royal-imports'),
|
| 310 |
+
# Wave 19 (R3): CARRIED, for the same reason `epoch` is carried below β
|
| 311 |
+
# `_record` builds a FRESH record, so re-running the provisioner (it is
|
| 312 |
+
# documented as idempotent) or saving an account through this function
|
| 313 |
+
# would silently DEMOTE a platform admin and lock the operator out of
|
| 314 |
+
# their own plane. None = leave as it was; True/False = set it deliberately.
|
| 315 |
+
platform_admin=(prior.get('platform_admin') is True
|
| 316 |
+
if platform_admin is None else platform_admin is True))
|
| 317 |
+
if prior:
|
| 318 |
+
rec['epoch'] = int(prior.get('epoch') or 0) + 1
|
| 319 |
+
reg[username] = rec
|
| 320 |
+
return reg
|
| 321 |
+
store.update('users', _add)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def set_password(username, pw):
|
| 325 |
+
username = (username or '').strip().lower()
|
| 326 |
+
|
| 327 |
+
def _set(reg):
|
| 328 |
+
u = reg.get(username)
|
| 329 |
+
if u:
|
| 330 |
+
u['salt'] = secrets.token_hex(16)
|
| 331 |
+
u['hash'] = _hash(pw, u['salt'])
|
| 332 |
+
# X3: a password change revokes every outstanding API session for the account. Bumped
|
| 333 |
+
# INSIDE the same read-modify-write as the hash so the two can never disagree β a
|
| 334 |
+
# separate update() could rotate the password and leave old cookies live if the second
|
| 335 |
+
# write failed.
|
| 336 |
+
u['epoch'] = int(u.get('epoch') or 0) + 1
|
| 337 |
+
return reg
|
| 338 |
+
store.update('users', _set)
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def set_active(username, active):
|
| 342 |
+
username = (username or '').strip().lower()
|
| 343 |
+
|
| 344 |
+
def _set(reg):
|
| 345 |
+
if username in reg:
|
| 346 |
+
reg[username]['active'] = bool(active)
|
| 347 |
+
# X3: DEACTIVATION must kill live sessions, not just future logins β otherwise a
|
| 348 |
+
# disabled account keeps working until its cookie expires. Bumped on reactivation too:
|
| 349 |
+
# cheap, and it means a re-enabled account never resurrects a stale cookie.
|
| 350 |
+
reg[username]['epoch'] = int(reg[username].get('epoch') or 0) + 1
|
| 351 |
+
return reg
|
| 352 |
+
store.update('users', _set)
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
def set_platform_admin(username, on):
|
| 356 |
+
"""Promote/demote a PLATFORM administrator (wave 19, R3) without touching the password.
|
| 357 |
+
|
| 358 |
+
The narrow write, deliberately: `create_user` is the destructive path (fresh salt, fresh
|
| 359 |
+
hash, bumped epoch) and promoting somebody should not sign them out or rotate a credential.
|
| 360 |
+
Cleared by REMOVING the key, so a demoted record goes back to its pre-wave shape rather than
|
| 361 |
+
carrying a `False` that reads as "somebody considered this".
|
| 362 |
+
|
| 363 |
+
β This is the flag only. It grants nothing on its own β `core.platform_admin` also demands
|
| 364 |
+
the `loopable` tenant, and there is no code path anywhere that moves an account between
|
| 365 |
+
tenants, which is what makes the second lock hold.
|
| 366 |
+
"""
|
| 367 |
+
username = (username or '').strip().lower()
|
| 368 |
+
|
| 369 |
+
def _set(reg):
|
| 370 |
+
u = reg.get(username)
|
| 371 |
+
if u:
|
| 372 |
+
if on is True:
|
| 373 |
+
u['platform_admin'] = True
|
| 374 |
+
else:
|
| 375 |
+
u.pop('platform_admin', None)
|
| 376 |
+
return reg
|
| 377 |
+
store.update('users', _set)
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# ------------------------------------------------------------------ activity stamps (wave 19 R4)
|
| 381 |
+
# "When did this account last sign in, and is anyone actually using it?" β the two questions the
|
| 382 |
+
# Loopable admin plane exists to answer and that NOTHING in the product could answer before this
|
| 383 |
+
# wave (there is no login history, no audit log, no request log anywhere).
|
| 384 |
+
#
|
| 385 |
+
# β THIS IS THE FIRST HIGH-FREQUENCY WRITER `users.json` HAS EVER HAD, and that bucket also holds
|
| 386 |
+
# every password hash, `active`, `epoch` and the permission blocks. Three rules follow, and the
|
| 387 |
+
# second one is a correctness rule, not a performance one:
|
| 388 |
+
#
|
| 389 |
+
# 1. **In-place mutation of ONE key.** Never `_record()`, never a whole-record replace: a stamp
|
| 390 |
+
# that rebuilt the record would reset the salt/hash (locking the user out) or the epoch
|
| 391 |
+
# (silently un-revoking every cookie ever minted for them). `set_password`'s docstring
|
| 392 |
+
# explains why that class of bug is worth naming out loud.
|
| 393 |
+
#
|
| 394 |
+
# 2. **SYNCHRONOUS FLUSH β deliberately NOT the async path, and this reverses my first draft.**
|
| 395 |
+
# `store.update(flush='async')` rebases on the PROCESS CACHE once a key is `_owned`
|
| 396 |
+
# (`core/store.py:284`) and its worker uploads that whole cached blob. For a
|
| 397 |
+
# table-workspace key, written by one process, that is exactly right. For `users` it is a
|
| 398 |
+
# silent-revert machine: tenant #0's Streamlit host writes the SAME file, so an API process
|
| 399 |
+
# holding a cache from an hour ago would, on its next stamp, upload a blob in which a
|
| 400 |
+
# password rotation or a deactivation performed in the other host simply never happened.
|
| 401 |
+
# A telemetry stamp must not be able to resurrect a disabled account. `flush='sync'` does a
|
| 402 |
+
# FRESH strict read inside the store's lock and then uploads, which is the same discipline
|
| 403 |
+
# every other `users` writer already uses.
|
| 404 |
+
#
|
| 405 |
+
# 3. **OFF THE REQUEST THREAD, so rule 2 costs nothing.** A sync commit is a hub round-trip, and
|
| 406 |
+
# neither a sign-in nor a random request an hour later should wait for it. Each stamp runs on
|
| 407 |
+
# a short-lived daemon thread; `flush_stamps()` is how a test or a shutdown waits for them.
|
| 408 |
+
# Everything is fail-silent: a stamp is telemetry and may never turn a good login into a
|
| 409 |
+
# failed one β the plane shows an honest "never" instead.
|
| 410 |
+
def _now_iso():
|
| 411 |
+
import datetime as _dt
|
| 412 |
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec='seconds')
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
#: Live stamp threads, so `flush_stamps()` can join them. Bounded by construction β one thread per
|
| 416 |
+
#: stamp, and a stamp is at most one per login plus one per account per hour per process.
|
| 417 |
+
_STAMPS = []
|
| 418 |
+
_STAMPS_LOCK = __import__('threading').Lock()
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def _stamp(username, fields):
|
| 422 |
+
"""Merge `fields` into ONE account record, on a background thread, with a fresh read."""
|
| 423 |
+
username = (username or '').strip().lower()
|
| 424 |
+
if not username or not fields:
|
| 425 |
+
return None
|
| 426 |
+
|
| 427 |
+
def _set(reg):
|
| 428 |
+
u = reg.get(username)
|
| 429 |
+
if isinstance(u, dict):
|
| 430 |
+
u.update(fields)
|
| 431 |
+
return reg
|
| 432 |
+
|
| 433 |
+
def _work():
|
| 434 |
+
try:
|
| 435 |
+
store.update('users', _set) # sync: fresh strict read + blocking upload
|
| 436 |
+
except Exception:
|
| 437 |
+
pass # a lost stamp is a lost stamp, never an error
|
| 438 |
+
import threading
|
| 439 |
+
t = threading.Thread(target=_work, daemon=True, name=f'user-stamp:{username}')
|
| 440 |
+
with _STAMPS_LOCK:
|
| 441 |
+
_STAMPS[:] = [x for x in _STAMPS if x.is_alive()]
|
| 442 |
+
_STAMPS.append(t)
|
| 443 |
+
t.start()
|
| 444 |
+
return t
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def flush_stamps(timeout=10.0):
|
| 448 |
+
"""Block until outstanding stamp writes have been applied. For gates and shutdown hooks β
|
| 449 |
+
the app never needs it, exactly like `core.store.flush`."""
|
| 450 |
+
with _STAMPS_LOCK:
|
| 451 |
+
pending = list(_STAMPS)
|
| 452 |
+
for t in pending:
|
| 453 |
+
t.join(timeout=timeout)
|
| 454 |
+
return all(not t.is_alive() for t in pending)
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def touch_login(username, when=None):
|
| 458 |
+
"""Stamp `last_login` (ISO-8601, UTC, OFFSET-BEARING) on a SUCCESSFUL login.
|
| 459 |
+
|
| 460 |
+
`username` must be the RESOLVED account key, not what the person typed: `verify()` accepts an
|
| 461 |
+
email address and resolves it to the registry key, so stamping the typed identifier would
|
| 462 |
+
write a stamp onto a key that does not exist and create a phantom account in the registry.
|
| 463 |
+
|
| 464 |
+
`last_active` rides along β signing in IS activity, and setting both here means the plane's
|
| 465 |
+
two columns agree the moment somebody logs in rather than an hour later.
|
| 466 |
+
"""
|
| 467 |
+
stamp = when or _now_iso()
|
| 468 |
+
return _stamp(username, {'last_login': stamp, 'last_active': stamp})
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def touch_active(username, when=None):
|
| 472 |
+
"""Stamp `last_active` β "this session did something". Throttled BY THE CALLER (`deps.py`
|
| 473 |
+
holds a process-local last-seen map), so this is not a store round-trip per request."""
|
| 474 |
+
return _stamp(username, {'last_active': when or _now_iso()})
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def set_access(username, role=None, bus=None, modules=None, agent=None, email=None,
|
| 478 |
+
name=None, perms=None):
|
| 479 |
+
"""Update access fields. agent/email: pass '' to clear, None to leave unchanged β
|
| 480 |
+
the userβagent link scopes the Customer List page / digests to that agent's book.
|
| 481 |
+
|
| 482 |
+
`name` follows the same None-means-unchanged idiom. It is here because it had no setter at all:
|
| 483 |
+
a display name could previously only be changed by re-creating the record through
|
| 484 |
+
`create_user`, i.e. by also resetting the password (and, before the fix above, the session
|
| 485 |
+
epoch). Y4's `PATCH {name?}` needs the narrow write, not the destructive one."""
|
| 486 |
+
username = (username or '').strip().lower()
|
| 487 |
+
|
| 488 |
+
def _set(reg):
|
| 489 |
+
u = reg.get(username)
|
| 490 |
+
if u:
|
| 491 |
+
if name is not None:
|
| 492 |
+
u['name'] = name
|
| 493 |
+
if role is not None:
|
| 494 |
+
u['role'] = role
|
| 495 |
+
if bus is not None:
|
| 496 |
+
u['bus'] = bus
|
| 497 |
+
if modules is not None:
|
| 498 |
+
u['modules'] = modules
|
| 499 |
+
if agent is not None:
|
| 500 |
+
u['agent'] = agent or None
|
| 501 |
+
if email is not None:
|
| 502 |
+
u['email'] = email or None
|
| 503 |
+
if perms is not None:
|
| 504 |
+
# Wave 15 C-PERM. Writing perms MIGRATES the record: the marker goes on in the
|
| 505 |
+
# same read-modify-write, so a record can never end up with one and not the
|
| 506 |
+
# other (see `_record`). Whole-block replace, matching the PUT route's shape β
|
| 507 |
+
# a merge would make "remove this restriction" unexpressible.
|
| 508 |
+
u['perms'] = perms
|
| 509 |
+
u['perms_v'] = PERMS_VERSION
|
| 510 |
+
return reg
|
| 511 |
+
store.update('users', _set)
|
| 512 |
+
|
| 513 |
+
|
| 514 |
+
def allowed_bus_labels(user):
|
| 515 |
+
"""BU labels this user may select. 'all' -> All+Fisch+Royal; a single BU -> just that BU (no
|
| 516 |
+
'All', so the other BU is never reachable); multiple -> All + each."""
|
| 517 |
+
bus = (user or {}).get('bus', 'all')
|
| 518 |
+
if bus == 'all':
|
| 519 |
+
return ['All', 'Fisch', 'Royal']
|
| 520 |
+
labels = [BU_LABELS[b] for b in bus if b in BU_LABELS]
|
| 521 |
+
if not labels:
|
| 522 |
+
return ['All', 'Fisch', 'Royal']
|
| 523 |
+
return (['All'] + labels) if len(labels) > 1 else labels
|
| 524 |
+
|
| 525 |
+
|
| 526 |
def assignable_people(tenant=None):
|
| 527 |
+
"""Display names for `user`-typed overlay columns β the tenant's ACTIVE accounts.
|
| 528 |
+
|
| 529 |
+
Moved from app.py (2026-07-31) so both hosts serve the same choices. Resolved on every
|
| 530 |
+
call rather than persisted with the column: a snapshot would keep offering people who
|
| 531 |
+
have left and never offer people who joined. Deactivated accounts are excluded; a value
|
| 532 |
+
already stored on a row is untouched β history should still say who owned something.
|
| 533 |
+
|
| 534 |
+
Wave 18 (C1-TENANT): pass `tenant` to scope the choices to ONE company β the user registry
|
| 535 |
+
is a global control-plane bucket, and a Nurilab picker offering Royal's staff is a
|
| 536 |
+
cross-tenant name leak.
|
| 537 |
+
|
| 538 |
+
ββ WAVE 33 (W33-T37) β A BLANK `tenant` NOW RETURNS NOTHING. It used to skip the filter and
|
| 539 |
+
return EVERY tenant's staff: `if want and β¦` is structurally fail-OPEN, and the exemption was
|
| 540 |
+
written for "None = unscoped (the Streamlit host, tenant #0's process)" β a host DELETED at
|
| 541 |
+
EXIT-6. So the sanctioned caller no longer exists, and what was left is a whole-platform
|
| 542 |
+
roster one forgotten kwarg away, with no error to notice
|
| 543 |
+
([[gate-must-go-red-not-crash]]'s sibling: a wall that answers instead of refusing).
|
| 544 |
+
β The direction is the safety: this can only ever NARROW. All six live call sites pass
|
| 545 |
+
`session.tenant`, which `aios_session.read` refuses to admit blank, so nothing legitimate
|
| 546 |
+
changes β and a future caller that forgets gets an empty picker somebody notices instead of a
|
| 547 |
+
leak nobody does.
|
| 548 |
+
"""
|
| 549 |
return sorted({str(u.get('name') or n) for n, u in _tenant_accounts(tenant)})
|
| 550 |
|
| 551 |
|
|
|
|
| 554 |
return [{"username": str(username).strip().lower(),
|
| 555 |
"name": str(record.get("name") or username)}
|
| 556 |
for username, record in sorted(_tenant_accounts(tenant), key=lambda item: item[0])]
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
def set_avatar(username, data_url):
|
| 560 |
+
"""Set (or clear, with None/'') the user's profile photo β a data URL (wave 14 C-AVATAR,
|
| 561 |
+
[[loopable-wave14-split]] item 11). Stored VERBATIM; the API route owns validation (mime +
|
| 562 |
+
decoded size) because this value is served back to every grid session. Cleared by removing
|
| 563 |
+
the key, so records without a photo keep their pre-wave shape."""
|
| 564 |
+
username = (username or '').strip().lower()
|
| 565 |
+
|
| 566 |
+
def _set(reg):
|
| 567 |
+
u = reg.get(username)
|
| 568 |
+
if u:
|
| 569 |
+
if data_url:
|
| 570 |
+
u['avatar'] = str(data_url)
|
| 571 |
+
else:
|
| 572 |
+
u.pop('avatar', None)
|
| 573 |
+
return reg
|
| 574 |
+
store.update('users', _set)
|
| 575 |
+
|
| 576 |
+
|
| 577 |
+
def avatar_map(tenant=None):
|
| 578 |
+
"""{display name -> avatar data URL} for ACTIVE accounts with a photo β the companion of
|
| 579 |
+
`assignable_people()`, keyed by the SAME vocabulary: a `user` cell stores the display
|
| 580 |
+
name, so the display name is the only join a renderer has. Two active accounts sharing a
|
| 581 |
+
display name share one option; the first WITH a photo wins the key rather than a coin
|
| 582 |
+
flip deciding whether the option has a face. `tenant` scopes it exactly as
|
| 583 |
+
`assignable_people(tenant)` does, and for the same leak β including wave 33's fail-closed
|
| 584 |
+
blank, which both take from the ONE resolver below rather than each writing `if want and β¦`.
|
| 585 |
+
"""
|
| 586 |
+
out = {}
|
| 587 |
+
for username, u in sorted(_tenant_accounts(tenant), key=lambda kv: kv[0]):
|
| 588 |
+
av = u.get('avatar')
|
| 589 |
+
nm = str(u.get('name') or username)
|
| 590 |
+
if av and nm not in out:
|
| 591 |
+
out[nm] = str(av)
|
| 592 |
+
return out
|
| 593 |
+
|
| 594 |
+
|
| 595 |
+
def _tenant_accounts(tenant):
|
| 596 |
+
"""`[(username, record), β¦]` β the ACTIVE accounts of exactly ONE tenant. FAIL-CLOSED.
|
| 597 |
+
|
| 598 |
+
β THE ONE PLACE THE TENANT FILTER FOR THE USER REGISTRY IS WRITTEN. It was written twice,
|
| 599 |
+
identically, in `assignable_people` and `avatar_map`, and both copies were fail-OPEN on a
|
| 600 |
+
blank slug in the same way (`if want and β¦`). Two copies of a wall is two places for one of
|
| 601 |
+
them to be fixed [[one-question-two-normalizers]]; `routes_shares._people` is a THIRD copy of
|
| 602 |
+
the same predicate and is lane C's to fold in.
|
| 603 |
+
|
| 604 |
+
β `str(u.get('tenant') or 'royal-imports')` is kept on the RECORD side deliberately: a
|
| 605 |
+
pre-wave-18 account genuinely has no `tenant` key and IS tenant #0's, and `users._public`
|
| 606 |
+
normalises it the same way. What changed is the WANT side β a blank want is now a refusal
|
| 607 |
+
rather than a wildcard.
|
| 608 |
+
"""
|
| 609 |
+
want = str(tenant or '').strip().lower()
|
| 610 |
+
if not want:
|
| 611 |
+
# Not an error and not everything: nobody. See `assignable_people`'s note β the one
|
| 612 |
+
# caller this exemption was written for (the Streamlit host) was deleted at EXIT-6.
|
| 613 |
+
return []
|
| 614 |
+
try:
|
| 615 |
+
reg = registry() or {}
|
| 616 |
+
except Exception:
|
| 617 |
+
return []
|
| 618 |
+
return [(n, u) for n, u in reg.items()
|
| 619 |
+
if isinstance(u, dict) and u.get('active') is not False
|
| 620 |
+
and str(u.get('tenant') or 'royal-imports').strip().lower() == want]
|
platform/harness/semantic.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/model/topics/odoo_customers.yml
CHANGED
|
@@ -1,213 +1,223 @@
|
|
| 1 |
-
# ββ W33-T49 (owner item 13) β an ENTITY topic: the semantic layer can finally see
|
| 2 |
-
# the databases a person actually opens, not only the line and document grains.
|
| 3 |
-
#
|
| 4 |
-
# β THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
|
| 5 |
-
# and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
|
| 6 |
-
# field row: add the column to the grid contract and re-emit, or the agent is being
|
| 7 |
-
# trained on a schema the product does not have.
|
| 8 |
-
key: odoo_customers
|
| 9 |
-
label: "Odoo customers"
|
| 10 |
-
entity: res.partner
|
| 11 |
-
# The database this topic DESCRIBES β the same store key the nav opens (W33-T46).
|
| 12 |
-
grid: customer_data
|
| 13 |
-
subject: "odoo:res.partner"
|
| 14 |
-
grain: "one row per customer in tenant #0's scoped book β a REGISTRY, not a dated event stream. Identity is the Odoo `res.partner` id (the row's `pid` IS that id)."
|
| 15 |
-
scope:
|
| 16 |
-
population: "the customer book `modules/customer_data.pool()` builds; the grid's own contract states it as one row per customer who ordered in the last 24 months"
|
| 17 |
-
identity: "the Odoo res.partner id β exposed as the `partner_id` column (W33-T43) and used as the row pid, so there is ONE id per row and no second one"
|
| 18 |
-
merged: "W33-T44 retired `ut_odoo_customers`, which presented this same subject. This topic describes the SURVIVING database, `customer_data`, which keeps its store bucket so no saved view, grant, cohort or formula moved"
|
| 19 |
-
no_date: "a registry has no date dimension; ask sales_lines or customer_invoices for anything time-windowed about a customer"
|
| 20 |
-
store:
|
| 21 |
-
table: res_partner
|
| 22 |
-
alias: p
|
| 23 |
-
# NO date_col β a registry is not a dated event stream. Stated rather than
|
| 24 |
-
# omitted, so its absence reads as a fact and not as an unfinished file.
|
| 25 |
-
dims:
|
| 26 |
-
agent: {label: "Sales agent"}
|
| 27 |
-
state: {label: "State"}
|
| 28 |
-
country: {label: "Country"}
|
| 29 |
-
|
| 30 |
-
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 31 |
-
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 32 |
-
# from another topic; a `link` points at another database.
|
| 33 |
-
fields:
|
| 34 |
-
- key: customer
|
| 35 |
-
label: "Customer"
|
| 36 |
-
type: text
|
| 37 |
-
kind: data
|
| 38 |
-
means: "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 39 |
-
- key: partner_id
|
| 40 |
-
label: "Odoo ID"
|
| 41 |
-
type: int
|
| 42 |
-
kind: data
|
| 43 |
-
means: "The Odoo res.partner id β this row's identity, and the key every Odoo document joins on. W33-T43 / owner item 12: 'One unique ID per database always.' It is DERIVED rather than read off the pool row because a customer row's pid IS the partner id, so the value is already on every row and a second copy in the pool would be a second source for one fact."
|
| 44 |
-
- key: odoo_status
|
| 45 |
-
label: "Odoo record"
|
| 46 |
-
type: status
|
| 47 |
-
kind: data
|
| 48 |
-
means: "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 49 |
-
- key: agent
|
| 50 |
-
label: "Agent"
|
| 51 |
-
type:
|
| 52 |
-
kind: data
|
| 53 |
-
means: "The sales agent who owns this account."
|
| 54 |
-
- key: dba
|
| 55 |
-
label: "DBA"
|
| 56 |
-
type: select
|
| 57 |
-
kind: data
|
| 58 |
-
means: "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 59 |
-
- key: salesperson
|
| 60 |
-
label: "Salesperson"
|
| 61 |
-
type:
|
| 62 |
-
kind: data
|
| 63 |
-
means: "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 64 |
-
- key: street
|
| 65 |
-
label: "Street"
|
| 66 |
-
type: text
|
| 67 |
-
kind: data
|
| 68 |
-
means: "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 69 |
-
- key: street2
|
| 70 |
-
label: "Street 2"
|
| 71 |
-
type: text
|
| 72 |
-
kind: data
|
| 73 |
-
means: "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 74 |
-
- key: city
|
| 75 |
-
label: "City"
|
| 76 |
-
type:
|
| 77 |
-
kind: data
|
| 78 |
-
means: "City on the customer's Odoo address."
|
| 79 |
-
- key: state
|
| 80 |
-
label: "State"
|
| 81 |
-
type:
|
| 82 |
-
kind: data
|
| 83 |
-
means: "State or province on the customer's Odoo address."
|
| 84 |
-
- key: country
|
| 85 |
-
label: "Country"
|
| 86 |
-
type:
|
| 87 |
-
kind: data
|
| 88 |
-
means: "Country on the customer's Odoo address."
|
| 89 |
-
- key: zip
|
| 90 |
-
label: "ZIP"
|
| 91 |
-
type: text
|
| 92 |
-
kind: data
|
| 93 |
-
means: "Postal code on the customer's Odoo address."
|
| 94 |
-
- key:
|
| 95 |
-
label: "
|
| 96 |
-
type:
|
| 97 |
-
kind: data
|
| 98 |
-
means: "
|
| 99 |
-
- key:
|
| 100 |
-
label: "
|
| 101 |
-
type:
|
| 102 |
-
kind: data
|
| 103 |
-
means: "
|
| 104 |
-
- key:
|
| 105 |
-
label: "
|
| 106 |
-
type:
|
| 107 |
-
kind: data
|
| 108 |
-
means: "
|
| 109 |
-
- key:
|
| 110 |
-
label: "
|
| 111 |
-
type:
|
| 112 |
-
kind: data
|
| 113 |
-
means: "
|
| 114 |
-
- key:
|
| 115 |
-
label: "
|
| 116 |
-
type:
|
| 117 |
-
kind: data
|
| 118 |
-
means: "
|
| 119 |
-
- key:
|
| 120 |
-
label: "
|
| 121 |
-
type:
|
| 122 |
-
kind: data
|
| 123 |
-
means: "
|
| 124 |
-
- key:
|
| 125 |
-
label: "
|
| 126 |
-
type:
|
| 127 |
-
kind: data
|
| 128 |
-
means: "
|
| 129 |
-
- key:
|
| 130 |
-
label: "
|
| 131 |
-
type: currency
|
| 132 |
-
kind: data
|
| 133 |
-
means: "
|
| 134 |
-
- key:
|
| 135 |
-
label: "AR
|
| 136 |
-
type: currency
|
| 137 |
-
kind: data
|
| 138 |
-
means: "Invoiced money
|
| 139 |
-
- key:
|
| 140 |
-
label: "AR
|
| 141 |
-
type: currency
|
| 142 |
-
kind: data
|
| 143 |
-
means: "
|
| 144 |
-
- key:
|
| 145 |
-
label: "
|
| 146 |
-
type: currency
|
| 147 |
-
kind: data
|
| 148 |
-
means: "
|
| 149 |
-
- key:
|
| 150 |
-
label: "
|
| 151 |
-
type: currency
|
| 152 |
-
kind: data
|
| 153 |
-
means: "
|
| 154 |
-
- key:
|
| 155 |
-
label: "
|
| 156 |
-
type: currency
|
| 157 |
-
kind: data
|
| 158 |
-
means: "Overdue between
|
| 159 |
-
- key:
|
| 160 |
-
label: "
|
| 161 |
-
type: currency
|
| 162 |
-
kind: data
|
| 163 |
-
means: "Overdue between
|
| 164 |
-
- key:
|
| 165 |
-
label: "90
|
| 166 |
-
type: currency
|
| 167 |
-
kind: data
|
| 168 |
-
means: "Overdue
|
| 169 |
-
- key:
|
| 170 |
-
label: "
|
| 171 |
-
type:
|
| 172 |
-
kind: data
|
| 173 |
-
means: "
|
| 174 |
-
- key:
|
| 175 |
-
label: "
|
| 176 |
-
type:
|
| 177 |
-
kind: data
|
| 178 |
-
means: "
|
| 179 |
-
- key:
|
| 180 |
-
label: "Top category
|
| 181 |
-
type:
|
| 182 |
-
kind: data
|
| 183 |
-
means: "
|
| 184 |
-
- key:
|
| 185 |
-
label: "
|
| 186 |
-
type:
|
| 187 |
-
kind: data
|
| 188 |
-
means: "
|
| 189 |
-
- key:
|
| 190 |
-
label: "
|
| 191 |
-
type:
|
| 192 |
-
kind: data
|
| 193 |
-
means: "
|
| 194 |
-
- key:
|
| 195 |
-
label: "
|
| 196 |
-
type:
|
| 197 |
-
kind: data
|
| 198 |
-
means: "
|
| 199 |
-
- key:
|
| 200 |
-
label: "
|
| 201 |
-
type: int
|
| 202 |
-
kind: data
|
| 203 |
-
means: "Days
|
| 204 |
-
- key:
|
| 205 |
-
label: "
|
| 206 |
-
type:
|
| 207 |
-
kind: data
|
| 208 |
-
means: "
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ W33-T49 (owner item 13) β an ENTITY topic: the semantic layer can finally see
|
| 2 |
+
# the databases a person actually opens, not only the line and document grains.
|
| 3 |
+
#
|
| 4 |
+
# β THE `fields:` BLOCK IS GENERATED FROM THE GRID'S OWN FIELD CONTRACT
|
| 5 |
+
# and is held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a
|
| 6 |
+
# field row: add the column to the grid contract and re-emit, or the agent is being
|
| 7 |
+
# trained on a schema the product does not have.
|
| 8 |
+
key: odoo_customers
|
| 9 |
+
label: "Odoo customers"
|
| 10 |
+
entity: res.partner
|
| 11 |
+
# The database this topic DESCRIBES β the same store key the nav opens (W33-T46).
|
| 12 |
+
grid: customer_data
|
| 13 |
+
subject: "odoo:res.partner"
|
| 14 |
+
grain: "one row per customer in tenant #0's scoped book β a REGISTRY, not a dated event stream. Identity is the Odoo `res.partner` id (the row's `pid` IS that id)."
|
| 15 |
+
scope:
|
| 16 |
+
population: "the customer book `modules/customer_data.pool()` builds; the grid's own contract states it as one row per customer who ordered in the last 24 months"
|
| 17 |
+
identity: "the Odoo res.partner id β exposed as the `partner_id` column (W33-T43) and used as the row pid, so there is ONE id per row and no second one"
|
| 18 |
+
merged: "W33-T44 retired `ut_odoo_customers`, which presented this same subject. This topic describes the SURVIVING database, `customer_data`, which keeps its store bucket so no saved view, grant, cohort or formula moved"
|
| 19 |
+
no_date: "a registry has no date dimension; ask sales_lines or customer_invoices for anything time-windowed about a customer"
|
| 20 |
+
store:
|
| 21 |
+
table: res_partner
|
| 22 |
+
alias: p
|
| 23 |
+
# NO date_col β a registry is not a dated event stream. Stated rather than
|
| 24 |
+
# omitted, so its absence reads as a fact and not as an unfinished file.
|
| 25 |
+
dims:
|
| 26 |
+
agent: {label: "Sales agent"}
|
| 27 |
+
state: {label: "State"}
|
| 28 |
+
country: {label: "Country"}
|
| 29 |
+
|
| 30 |
+
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 31 |
+
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 32 |
+
# from another topic; a `link` points at another database.
|
| 33 |
+
fields:
|
| 34 |
+
- key: customer
|
| 35 |
+
label: "Customer"
|
| 36 |
+
type: text
|
| 37 |
+
kind: data
|
| 38 |
+
means: "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 39 |
+
- key: partner_id
|
| 40 |
+
label: "Odoo ID"
|
| 41 |
+
type: int
|
| 42 |
+
kind: data
|
| 43 |
+
means: "The Odoo res.partner id β this row's identity, and the key every Odoo document joins on. W33-T43 / owner item 12: 'One unique ID per database always.' It is DERIVED rather than read off the pool row because a customer row's pid IS the partner id, so the value is already on every row and a second copy in the pool would be a second source for one fact."
|
| 44 |
+
- key: odoo_status
|
| 45 |
+
label: "Odoo record"
|
| 46 |
+
type: status
|
| 47 |
+
kind: data
|
| 48 |
+
means: "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 49 |
+
- key: agent
|
| 50 |
+
label: "Agent"
|
| 51 |
+
type: select
|
| 52 |
+
kind: data
|
| 53 |
+
means: "The sales agent who owns this account."
|
| 54 |
+
- key: dba
|
| 55 |
+
label: "DBA"
|
| 56 |
+
type: select
|
| 57 |
+
kind: data
|
| 58 |
+
means: "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 59 |
+
- key: salesperson
|
| 60 |
+
label: "Salesperson"
|
| 61 |
+
type: select
|
| 62 |
+
kind: data
|
| 63 |
+
means: "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 64 |
+
- key: street
|
| 65 |
+
label: "Street"
|
| 66 |
+
type: text
|
| 67 |
+
kind: data
|
| 68 |
+
means: "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 69 |
+
- key: street2
|
| 70 |
+
label: "Street 2"
|
| 71 |
+
type: text
|
| 72 |
+
kind: data
|
| 73 |
+
means: "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 74 |
+
- key: city
|
| 75 |
+
label: "City"
|
| 76 |
+
type: select
|
| 77 |
+
kind: data
|
| 78 |
+
means: "City on the customer's Odoo address."
|
| 79 |
+
- key: state
|
| 80 |
+
label: "State"
|
| 81 |
+
type: select
|
| 82 |
+
kind: data
|
| 83 |
+
means: "State or province on the customer's Odoo address."
|
| 84 |
+
- key: country
|
| 85 |
+
label: "Country"
|
| 86 |
+
type: select
|
| 87 |
+
kind: data
|
| 88 |
+
means: "Country on the customer's Odoo address."
|
| 89 |
+
- key: zip
|
| 90 |
+
label: "ZIP"
|
| 91 |
+
type: text
|
| 92 |
+
kind: data
|
| 93 |
+
means: "Postal code on the customer's Odoo address."
|
| 94 |
+
- key: maps_url
|
| 95 |
+
label: "Google Maps"
|
| 96 |
+
type: url
|
| 97 |
+
kind: data
|
| 98 |
+
means: "A Google Maps link to this customer's address."
|
| 99 |
+
- key: customer_since
|
| 100 |
+
label: "Customer since"
|
| 101 |
+
type: date
|
| 102 |
+
kind: data
|
| 103 |
+
means: "When this customer was first set up in Odoo."
|
| 104 |
+
- key: tags
|
| 105 |
+
label: "Tags"
|
| 106 |
+
type: multiselect
|
| 107 |
+
kind: data
|
| 108 |
+
means: "Odoo labels on this customer, comma-separated."
|
| 109 |
+
- key: pricelist
|
| 110 |
+
label: "Customer price list"
|
| 111 |
+
type: select
|
| 112 |
+
kind: data
|
| 113 |
+
means: "The price list this customer buys on."
|
| 114 |
+
- key: payment_terms
|
| 115 |
+
label: "Payment terms"
|
| 116 |
+
type: select
|
| 117 |
+
kind: data
|
| 118 |
+
means: "Payment terms on this customer's account β Net 30, for example."
|
| 119 |
+
- key: last_order
|
| 120 |
+
label: "Last order"
|
| 121 |
+
type: date
|
| 122 |
+
kind: data
|
| 123 |
+
means: "Date of the most recent confirmed order."
|
| 124 |
+
- key: overdue_days
|
| 125 |
+
label: "Overdue days"
|
| 126 |
+
type: int
|
| 127 |
+
kind: data
|
| 128 |
+
means: "How many days late this customer is running against their own usual ordering rhythm."
|
| 129 |
+
- key: est_missed
|
| 130 |
+
label: "Est. missed $"
|
| 131 |
+
type: currency
|
| 132 |
+
kind: data
|
| 133 |
+
means: "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 134 |
+
- key: ar_open
|
| 135 |
+
label: "AR current $"
|
| 136 |
+
type: currency
|
| 137 |
+
kind: data
|
| 138 |
+
means: "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 139 |
+
- key: ar_overdue
|
| 140 |
+
label: "AR overdue $"
|
| 141 |
+
type: currency
|
| 142 |
+
kind: data
|
| 143 |
+
means: "Invoiced money past due β same basis as the Collections page."
|
| 144 |
+
- key: ar_outstanding
|
| 145 |
+
label: "AR outstanding $"
|
| 146 |
+
type: currency
|
| 147 |
+
kind: data
|
| 148 |
+
means: "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 149 |
+
- key: ar_exposure
|
| 150 |
+
label: "Credit exposure $"
|
| 151 |
+
type: currency
|
| 152 |
+
kind: data
|
| 153 |
+
means: "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 154 |
+
- key: ar_aged_1_30
|
| 155 |
+
label: "1-30 days $"
|
| 156 |
+
type: currency
|
| 157 |
+
kind: data
|
| 158 |
+
means: "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 159 |
+
- key: ar_aged_31_60
|
| 160 |
+
label: "31-60 days $"
|
| 161 |
+
type: currency
|
| 162 |
+
kind: data
|
| 163 |
+
means: "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 164 |
+
- key: ar_aged_61_90
|
| 165 |
+
label: "61-90 days $"
|
| 166 |
+
type: currency
|
| 167 |
+
kind: data
|
| 168 |
+
means: "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 169 |
+
- key: ar_aged_90_plus
|
| 170 |
+
label: "90+ days $"
|
| 171 |
+
type: currency
|
| 172 |
+
kind: data
|
| 173 |
+
means: "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 174 |
+
- key: days_to_pay
|
| 175 |
+
label: "Days to pay"
|
| 176 |
+
type: int
|
| 177 |
+
kind: data
|
| 178 |
+
means: "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 179 |
+
- key: top_category
|
| 180 |
+
label: "Top category"
|
| 181 |
+
type: select
|
| 182 |
+
kind: data
|
| 183 |
+
means: "The category this customer spent the most on in the last 12 months."
|
| 184 |
+
- key: top_category_pct
|
| 185 |
+
label: "Top category %"
|
| 186 |
+
type: pct
|
| 187 |
+
kind: data
|
| 188 |
+
means: "Share of last-12-months spend that went to the top category."
|
| 189 |
+
- key: sku_count
|
| 190 |
+
label: "SKUs bought"
|
| 191 |
+
type: int
|
| 192 |
+
kind: data
|
| 193 |
+
means: "Distinct products bought in the last 12 months."
|
| 194 |
+
- key: top_sku
|
| 195 |
+
label: "Top SKU"
|
| 196 |
+
type: text
|
| 197 |
+
kind: data
|
| 198 |
+
means: "The product this customer spent the most on in the last 12 months."
|
| 199 |
+
- key: days_since
|
| 200 |
+
label: "Days since order"
|
| 201 |
+
type: int
|
| 202 |
+
kind: data
|
| 203 |
+
means: "Days since the last confirmed order."
|
| 204 |
+
- key: typical_gap_days
|
| 205 |
+
label: "Typical gap days"
|
| 206 |
+
type: int
|
| 207 |
+
kind: data
|
| 208 |
+
means: "Days this customer usually goes between orders, from their own history."
|
| 209 |
+
- key: created_on
|
| 210 |
+
label: "Record created"
|
| 211 |
+
type: date
|
| 212 |
+
kind: data
|
| 213 |
+
means: "The date this customer record was created in Odoo."
|
| 214 |
+
- key: notes
|
| 215 |
+
label: "Notes"
|
| 216 |
+
type: text
|
| 217 |
+
kind: data
|
| 218 |
+
means: "Your notes on this customer. Saved in this app only, visible only to you."
|
| 219 |
+
|
| 220 |
+
ai_context: >
|
| 221 |
+
The customer REGISTRY as a person sees it in the app β the database labelled 'Odoo customers'.
|
| 222 |
+
Use it to answer 'who is this customer', 'which customers exist', 'which agent owns them', 'where are they', and for the AR columns it carries (ar_open, ar_overdue, days_to_pay), which are reconciled by modules/ar.py.
|
| 223 |
+
β For anything WINDOWED or at line grain go to sales_lines / customer_invoices / receivables β those topics own the time dimension and this one has none.
|
platform/model/topics/odoo_products.yml
CHANGED
|
@@ -1,52 +1,63 @@
|
|
| 1 |
-
# ββ W33-T49 (owner item 13) β an ENTITY topic: the semantic layer can finally see
|
| 2 |
-
# the databases a person actually opens, not only the line and document grains.
|
| 3 |
-
#
|
| 4 |
-
# β THE `fields:` BLOCK IS
|
| 5 |
-
#
|
| 6 |
-
#
|
| 7 |
-
#
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
#
|
| 35 |
-
#
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
#
|
| 41 |
-
#
|
| 42 |
-
#
|
| 43 |
-
#
|
| 44 |
-
#
|
| 45 |
-
#
|
| 46 |
-
#
|
| 47 |
-
#
|
| 48 |
-
#
|
| 49 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
measures:
|
| 51 |
- source: sales_lines
|
| 52 |
dim: product_code
|
|
@@ -57,81 +68,123 @@ measures:
|
|
| 57 |
# crm.team 7 is not part of either wholesale BU.
|
| 58 |
scope_variant: all_channels
|
| 59 |
# β The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
|
| 60 |
-
# together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
|
| 61 |
-
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
| 62 |
-
# β REPORTED, NOT SILENTLY DROPPED (standing rule 1's second sentence, applied to a catalogue).
|
| 63 |
-
# Each of these is a real metric `proto/P3-metric-catalog.md` measured and this wave does not
|
| 64 |
-
# ship, with the CAUSE and the fix β so the next session extends the list instead of
|
| 65 |
-
# re-measuring, and nobody reads the six as "all Odoo can answer".
|
| 66 |
-
not_yet:
|
| 67 |
-
- key: days_since_last_sale
|
| 68 |
-
cause: "needs `agg: max` over a DATE, which `semantic._measure_sql` does not implement (sum | count | count_distinct only), and a date-typed measure could not render anyway: `aios_grid.MEASURE_FIELD_TYPES` is {currency, int, pct}"
|
| 69 |
-
fix: "add `agg: max` to `_measure_sql` and express the metric as an INT β `date_diff('day', max(date), today)` β so it renders as a number of days rather than a date. Measured live at 4.61 s over 3,348 SKUs"
|
| 70 |
-
- key: distinct_orders
|
| 71 |
-
cause: "`__count` on the live path is LINE count, not ORDER count (12,707 lines vs 12,645 distinct SKU-order pairs); the honest figure needs a 2-level groupby measured at 10.87 s live"
|
| 72 |
-
fix: "on the STORE path this is `count(DISTINCT l.order_id)` in one pass β add it as a `count_distinct` metric on `sales_lines` with field `order_id`"
|
| 73 |
-
|
| 74 |
-
# ββ W37-T13 β PHYSICAL MOVEMENT. `stock.move` and `stock_location` are mirrored now (the
|
| 75 |
-
# `not_yet` entry that used to sit here said "not mirrored, so there is no store binding to
|
| 76 |
-
# group"; that is what changed). The direction rule is TWO-SIDED and lives in the metrics'
|
| 77 |
-
# `store_filter_sql` β see `model/metrics/stock.yml`, which carries the 58% trap in full.
|
| 78 |
-
- source: stock_moves
|
| 79 |
-
dim: product_code
|
| 80 |
-
keys: [stock_in, stock_out, stock_net]
|
| 81 |
-
not_yet:
|
| 82 |
-
- key: stock_in_excl_adjustments
|
| 83 |
-
cause: "adjustments and scrap are ~30% of IN / ~20% of OUT and the two obvious exclusions are NOT equivalent - the 5,320-unit gap between them is entirely `Virtual Locations/Scrap`, so the choice is a business ruling rather than a filter"
|
| 84 |
-
fix: "ask the owner which exclusion they mean, then add it as a SEPARATE metric with `store_filter_sql` narrowed on `sl.usage`/`sd.usage` - never by changing what `stock_in` means"
|
| 85 |
-
|
| 86 |
-
#
|
| 87 |
-
#
|
| 88 |
-
#
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
- key: price_royal_2
|
| 136 |
label: "Royal 2 price"
|
| 137 |
type: currency
|
|
@@ -141,7 +194,7 @@ fields:
|
|
| 141 |
label: "Price 1"
|
| 142 |
type: currency
|
| 143 |
kind: data
|
| 144 |
-
means: "Cheapest live package price for this SKU across active pricelists."
|
| 145 |
- key: unit_1
|
| 146 |
label: "Unit 1"
|
| 147 |
type: text
|
|
@@ -151,7 +204,7 @@ fields:
|
|
| 151 |
label: "Price 2"
|
| 152 |
type: currency
|
| 153 |
kind: data
|
| 154 |
-
means: "Second-cheapest live package price
|
| 155 |
- key: unit_2
|
| 156 |
label: "Unit 2"
|
| 157 |
type: text
|
|
@@ -161,12 +214,102 @@ fields:
|
|
| 161 |
label: "Price 3"
|
| 162 |
type: currency
|
| 163 |
kind: data
|
| 164 |
-
means: "Third-cheapest live package price
|
| 165 |
- key: unit_3
|
| 166 |
label: "Unit 3"
|
| 167 |
type: text
|
| 168 |
kind: data
|
| 169 |
means: "Package label for Price 3."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
- key: pre_book_qty
|
| 171 |
label: "Pre-book qty"
|
| 172 |
type: int
|
|
@@ -178,67 +321,72 @@ fields:
|
|
| 178 |
kind: data
|
| 179 |
means: "Units still owed on confirmed, not-fully-delivered wholesale goods orders, including already-due promises. Delivery-charge service lines are excluded."
|
| 180 |
- key: rev_ytd
|
| 181 |
-
label: "Revenue YTD"
|
| 182 |
-
type: currency
|
| 183 |
-
kind: data
|
| 184 |
-
means: "Year-to-date revenue for this SKU, BU-scoped when the caller is."
|
| 185 |
-
- key: rev_ly
|
| 186 |
-
label: "Revenue LY"
|
| 187 |
-
type: currency
|
| 188 |
-
kind: data
|
| 189 |
-
means: "Same period last year β seasonal wholesale compares like for like."
|
| 190 |
-
- key: yoy_pct
|
| 191 |
-
label: "YoY %"
|
| 192 |
-
type: pct
|
| 193 |
-
kind: data
|
| 194 |
-
means: "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
|
| 195 |
-
- key: qty_ytd
|
| 196 |
-
label: "Units YTD"
|
| 197 |
-
type: int
|
| 198 |
-
kind: data
|
| 199 |
-
means: "Units sold year to date."
|
| 200 |
-
- key: orders_ytd
|
| 201 |
-
label: "Orders YTD"
|
| 202 |
-
type: int
|
| 203 |
-
kind: data
|
| 204 |
-
means: "Distinct orders containing this SKU, year to date."
|
| 205 |
-
- key: on_hand
|
| 206 |
-
label: "On hand"
|
| 207 |
-
type: int
|
| 208 |
-
kind: data
|
| 209 |
-
means: "Units in stock. CONSOLIDATED β one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
|
| 210 |
-
- key: unit_cost
|
| 211 |
-
label: "Unit cost"
|
| 212 |
-
type: currency
|
| 213 |
-
kind: data
|
| 214 |
-
means: "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
|
| 215 |
-
- key: inv_value
|
| 216 |
-
label: "Stock value"
|
| 217 |
-
type: currency
|
| 218 |
-
kind: data
|
| 219 |
-
means: "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
|
| 220 |
-
- key: qty_ltm
|
| 221 |
-
label: "Units LTM"
|
| 222 |
-
type: int
|
| 223 |
-
kind: data
|
| 224 |
-
means: "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
|
| 225 |
-
- key: dos
|
| 226 |
-
label: "Days of supply"
|
| 227 |
-
type: int
|
| 228 |
-
kind: data
|
| 229 |
-
means: "Days of supply at the
|
| 230 |
-
- key: cover_gap_d
|
| 231 |
-
label: "Cover gap (days)"
|
| 232 |
-
type: int
|
| 233 |
-
kind: data
|
| 234 |
-
means: "Days of supply minus lead time. Negative means it runs out before a reorder lands."
|
| 235 |
-
- key: stock_bucket
|
| 236 |
-
label: "Stock status"
|
| 237 |
-
type: select
|
| 238 |
-
kind: data
|
| 239 |
-
means: "Dead / excess / healthy bucket from the inventory module.
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ββ W33-T49 (owner item 13) β an ENTITY topic: the semantic layer can finally see
|
| 2 |
+
# the databases a person actually opens, not only the line and document grains.
|
| 3 |
+
#
|
| 4 |
+
# β THE `fields:` BLOCK IS A HAND-MAINTAINED, CURATED SUBSET OF THE GRID'S FIELD
|
| 5 |
+
# CONTRACT, AND NOTHING ENFORCES IT. This comment used to say the block was GENERATED
|
| 6 |
+
# and "held to it by `verify_query.py::section_entity_topics`. Do not hand-edit a field
|
| 7 |
+
# row." MEASURED 2026-08-24: `section_entity_topics` does not exist anywhere in the tree
|
| 8 |
+
# and no gate compares this file to `aios_grid_fields.json`, so that sentence was an
|
| 9 |
+
# unchecked claim forbidding the only edit anyone can actually make
|
| 10 |
+
# ([[a-declared-gate-is-an-unchecked-claim]]).
|
| 11 |
+
#
|
| 12 |
+
# What is TRUE: 9 of the product contract's keys are deliberately absent (D-400 β
|
| 13 |
+
# incoming, cover_gap_units, demand_fwd, discontinued, needs_pricing, march_pricelist,
|
| 14 |
+
# price_changes, closeouts, notes), and no key here is absent from the contract. So a
|
| 15 |
+
# field row IS hand-edited, and the rule is the honest one: the key, type and label must
|
| 16 |
+
# match `aios_grid_fields.json`, because the agent is otherwise trained on a schema the
|
| 17 |
+
# product does not have. Building the missing gate is booked; until it exists, this
|
| 18 |
+
# paragraph is the only thing keeping the two in step.
|
| 19 |
+
key: odoo_products
|
| 20 |
+
label: "Odoo products"
|
| 21 |
+
entity: product.product
|
| 22 |
+
# The database this topic DESCRIBES β the same store key the nav opens (W33-T46).
|
| 23 |
+
grid: product_data
|
| 24 |
+
subject: "odoo:product.product"
|
| 25 |
+
grain: "one row per SKU in the active catalogue β a CATALOGUE, not a dated event stream"
|
| 26 |
+
scope:
|
| 27 |
+
population: "every ACTIVE product, sold or not (measured 5,862). Deliberately not the sales-window universe: that lands at 2,717 and hides ~2,550 SKUs that have never sold in wholesale scope"
|
| 28 |
+
identity: "the SKU code (`default_code`). β 33 active products carry NO code and are keyed `pid:<odoo product id>` instead, so every active product has exactly one row and none is dropped"
|
| 29 |
+
merged: "W33-T44 retired `ut_odoo_products`, which presented this same subject on Odoo's product_id. This topic describes the SURVIVING database, `product_data`"
|
| 30 |
+
no_date: "a catalogue has no date dimension; ask sales_lines for movement"
|
| 31 |
+
store:
|
| 32 |
+
table: product_product
|
| 33 |
+
alias: pp
|
| 34 |
+
# NO date_col β a registry is not a dated event stream. Stated rather than
|
| 35 |
+
# omitted, so its absence reads as a fact and not as an unfinished file.
|
| 36 |
+
dims:
|
| 37 |
+
category: {label: "Category"}
|
| 38 |
+
supplier: {label: "Supplier"}
|
| 39 |
+
|
| 40 |
+
# ββ W37-T10 / owner item 4 (ruling R1) β THE LOOKBACK-MEASURE BINDING. Owner: *"Odoo products
|
| 41 |
+
# should have a lookbsck metrics like sales etc."* This block is what turns `measures: []` on the
|
| 42 |
+
# product workspace into a real catalogue, and it is DECLARED HERE rather than hand-written in a
|
| 43 |
+
# route for the reason `_rollup_source_offer` gives about itself: a second list is a second
|
| 44 |
+
# definition of the same fact, and the two drift the day somebody adds a metric.
|
| 45 |
+
#
|
| 46 |
+
# β THE JOIN KEY IS THE TRAP CONTRACT C1 NAMES, and this is where it is answered. `source` is a
|
| 47 |
+
# FACT topic; `dim` is the dim of THAT topic which carries THIS entity's identity. `sales_lines`
|
| 48 |
+
# has two candidates and only one is right: `product` groups by Odoo's `product_id`, while this
|
| 49 |
+
# database's identity (see `scope.identity` above) is `default_code`. Binding to `product` would
|
| 50 |
+
# key every cell on a number no grid row carries β a column of nulls that reads as "never sold".
|
| 51 |
+
#
|
| 52 |
+
# β A SINGLE-TOPIC GROUPED QUERY, which is why `semantic.store_query`'s cross-topic refusal does
|
| 53 |
+
# not bite: `revenue` lives ON `sales_lines` and we group `sales_lines` by its OWN dim. The same
|
| 54 |
+
# refusal is real and DOES bite `aov`/`orders` (denominator `orders` lives on `sales_orders`), so
|
| 55 |
+
# those two are absent from `keys` below and `entity_measures()` re-proves that rather than
|
| 56 |
+
# trusting this list.
|
| 57 |
+
# β A LIST SINCE W37-T13: an entity's columns legitimately come from more than ONE fact topic.
|
| 58 |
+
# Sales movement is `sales_lines`; physical movement is `stock_moves`. Same grid, same join key
|
| 59 |
+
# (`product_code`), different tables β forcing stock into the sales topic would have defined a
|
| 60 |
+
# metric against rows it does not have.
|
| 61 |
measures:
|
| 62 |
- source: sales_lines
|
| 63 |
dim: product_code
|
|
|
|
| 68 |
# crm.team 7 is not part of either wholesale BU.
|
| 69 |
scope_variant: all_channels
|
| 70 |
# β The SHIP-FIRST six of `proto/P3-metric-catalog.md`, and they cost ONE grouped query
|
| 71 |
+
# together (measured 0.22 s over 1,670 groups against the mirror; 2.55 s live).
|
| 72 |
+
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
| 73 |
+
# β REPORTED, NOT SILENTLY DROPPED (standing rule 1's second sentence, applied to a catalogue).
|
| 74 |
+
# Each of these is a real metric `proto/P3-metric-catalog.md` measured and this wave does not
|
| 75 |
+
# ship, with the CAUSE and the fix β so the next session extends the list instead of
|
| 76 |
+
# re-measuring, and nobody reads the six as "all Odoo can answer".
|
| 77 |
+
not_yet:
|
| 78 |
+
- key: days_since_last_sale
|
| 79 |
+
cause: "needs `agg: max` over a DATE, which `semantic._measure_sql` does not implement (sum | count | count_distinct only), and a date-typed measure could not render anyway: `aios_grid.MEASURE_FIELD_TYPES` is {currency, int, pct}"
|
| 80 |
+
fix: "add `agg: max` to `_measure_sql` and express the metric as an INT β `date_diff('day', max(date), today)` β so it renders as a number of days rather than a date. Measured live at 4.61 s over 3,348 SKUs"
|
| 81 |
+
- key: distinct_orders
|
| 82 |
+
cause: "`__count` on the live path is LINE count, not ORDER count (12,707 lines vs 12,645 distinct SKU-order pairs); the honest figure needs a 2-level groupby measured at 10.87 s live"
|
| 83 |
+
fix: "on the STORE path this is `count(DISTINCT l.order_id)` in one pass β add it as a `count_distinct` metric on `sales_lines` with field `order_id`"
|
| 84 |
+
|
| 85 |
+
# ββ W37-T13 β PHYSICAL MOVEMENT. `stock.move` and `stock_location` are mirrored now (the
|
| 86 |
+
# `not_yet` entry that used to sit here said "not mirrored, so there is no store binding to
|
| 87 |
+
# group"; that is what changed). The direction rule is TWO-SIDED and lives in the metrics'
|
| 88 |
+
# `store_filter_sql` β see `model/metrics/stock.yml`, which carries the 58% trap in full.
|
| 89 |
+
- source: stock_moves
|
| 90 |
+
dim: product_code
|
| 91 |
+
keys: [stock_in, stock_out, stock_net]
|
| 92 |
+
not_yet:
|
| 93 |
+
- key: stock_in_excl_adjustments
|
| 94 |
+
cause: "adjustments and scrap are ~30% of IN / ~20% of OUT and the two obvious exclusions are NOT equivalent - the 5,320-unit gap between them is entirely `Virtual Locations/Scrap`, so the choice is a business ruling rather than a filter"
|
| 95 |
+
fix: "ask the owner which exclusion they mean, then add it as a SEPARATE metric with `store_filter_sql` narrowed on `sl.usage`/`sd.usage` - never by changing what `stock_in` means"
|
| 96 |
+
|
| 97 |
+
# ββ W40-T10 / owner item 26 (ruling R7, contract C6) β PER-METRIC CHANNEL SCOPE. Owner:
|
| 98 |
+
# *"Separate revenue and the other Odoo products metrics by Analytics, as a filter under the
|
| 99 |
+
# Metrics field, specific to the Odoo databases."* R7 makes it PER METRIC rather than per grid, so
|
| 100 |
+
# "Revenue (Fisch)" and "Revenue (Amazon)" sit side by side on one grid and answer differently.
|
| 101 |
+
#
|
| 102 |
+
# β THE CHANNEL IS A BINDING, NOT A MEMBER OF THE SAVED FIELD. C6 words it as part of the stored
|
| 103 |
+
# Metric spec and it CANNOT be one: `aios_grid.clean_measure_field` rebuilds that dict from known
|
| 104 |
+
# members and returns `{key, window}`, so a `channel` member is dropped on save with nothing to
|
| 105 |
+
# notice, and `aios_grid.py` is in no lane's fence. The key survives, so the channel rides the
|
| 106 |
+
# KEY: `semantic._one_binding_offer` derives the suffix and the "(Fisch)" from the variant name,
|
| 107 |
+
# and the catalogue offers `revenue` / `revenue_fisch` / `revenue_royal` / `revenue_amazon` as
|
| 108 |
+
# four distinct keys. No client change: the picker already offers whatever the catalogue offers.
|
| 109 |
+
#
|
| 110 |
+
# β THESE MUST STAY *AFTER* THE TWO ABOVE. `entity_measure_binding()` and two gates
|
| 111 |
+
# (`verify_product_pool::W39-T11`, `verify_odoo_relational::_prove_entity_measures`) read
|
| 112 |
+
# `bindings[0]` as "the" binding; putting a channel binding first would silently retarget them.
|
| 113 |
+
#
|
| 114 |
+
# β `not_yet:` is deliberately NOT repeated here. It documents the METRIC and why the wave does
|
| 115 |
+
# not ship it, which is a fact about the metric and not about the channel β copying it four times
|
| 116 |
+
# would be four statements of one thing, and the day one is answered three would go stale.
|
| 117 |
+
#
|
| 118 |
+
# β COST: one grouped scan PER CHANNEL, because `entity_measure_values` groups by
|
| 119 |
+
# `(topic, dim, scope_variant)`. Four channel columns of the same measure over the same window
|
| 120 |
+
# are four scans, ~0.22 s each against the mirror. A grid carrying one channel costs exactly what
|
| 121 |
+
# it costs today.
|
| 122 |
+
- source: sales_lines
|
| 123 |
+
dim: product_code
|
| 124 |
+
scope_variant: channel_fisch
|
| 125 |
+
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
| 126 |
+
- source: sales_lines
|
| 127 |
+
dim: product_code
|
| 128 |
+
scope_variant: channel_royal
|
| 129 |
+
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
| 130 |
+
# β `amazon` IS THE EXCLUDED PARTNER, NOT crm.team 7 β measured, and the difference is real:
|
| 131 |
+
# GIFTWARE DEALS carries 24 orders on team 7 AND 2 on team 5, so the partner and the team name
|
| 132 |
+
# different sets. `core/odoo.sale_line_domain` already fences wholesale on the PARTNER, so taking
|
| 133 |
+
# the team here would be a second, disagreeing idea of what Amazon is.
|
| 134 |
+
- source: sales_lines
|
| 135 |
+
dim: product_code
|
| 136 |
+
scope_variant: channel_amazon
|
| 137 |
+
keys: [units, revenue, margin, cogs, margin_pct, asp]
|
| 138 |
+
|
| 139 |
+
# key / label / type / kind, derived from the grid contract. `kind` says where the
|
| 140 |
+
# value COMES FROM: a `data` column is stored on the row; a `rollup` is computed
|
| 141 |
+
# from another topic; a `link` points at another database.
|
| 142 |
+
fields:
|
| 143 |
+
- key: code
|
| 144 |
+
label: "SKU"
|
| 145 |
+
type: text
|
| 146 |
+
kind: data
|
| 147 |
+
means: "The SKU code β the product's real business key. `pid` is a stable CRC32 of it because the grid keys on an integer."
|
| 148 |
+
- key: product
|
| 149 |
+
label: "Product"
|
| 150 |
+
type: text
|
| 151 |
+
kind: data
|
| 152 |
+
means: "Product name as it appears in Odoo."
|
| 153 |
+
- key: category
|
| 154 |
+
label: "Category"
|
| 155 |
+
type: select
|
| 156 |
+
kind: data
|
| 157 |
+
means: "Product category; '(uncategorized)' when Odoo carries none."
|
| 158 |
+
- key: supplier
|
| 159 |
+
label: "Supplier"
|
| 160 |
+
type: multiselect
|
| 161 |
+
kind: data
|
| 162 |
+
means: "Who makes it. Editable here and shared with everyone in the workspace; seeded from the inventory mastersheet."
|
| 163 |
+
- key: origin_country
|
| 164 |
+
label: "Country"
|
| 165 |
+
type: text
|
| 166 |
+
kind: data
|
| 167 |
+
means: "Country of origin. Editable here and shared with everyone; seeded from the inventory mastersheet."
|
| 168 |
+
- key: lead_days
|
| 169 |
+
label: "Lead time (days)"
|
| 170 |
+
type: int
|
| 171 |
+
kind: data
|
| 172 |
+
means: "Order-to-arrival days for this supplier. Drives the buy signal. Editable and shared with everyone."
|
| 173 |
+
- key: first_cost
|
| 174 |
+
label: "First cost"
|
| 175 |
+
type: currency
|
| 176 |
+
kind: data
|
| 177 |
+
means: "Quoted unit cost at origin, before freight and duty. Editable and shared with everyone."
|
| 178 |
+
- key: price_fisch
|
| 179 |
+
label: "Fisch price"
|
| 180 |
+
type: currency
|
| 181 |
+
kind: data
|
| 182 |
+
means: "Fisch pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 183 |
+
- key: price_royal_1
|
| 184 |
+
label: "Royal 1 price"
|
| 185 |
+
type: currency
|
| 186 |
+
kind: data
|
| 187 |
+
means: "Royal 1 pricelist price for this SKU. Blank when that list prices it nowhere."
|
| 188 |
- key: price_royal_2
|
| 189 |
label: "Royal 2 price"
|
| 190 |
type: currency
|
|
|
|
| 194 |
label: "Price 1"
|
| 195 |
type: currency
|
| 196 |
kind: data
|
| 197 |
+
means: "Cheapest live package price for this SKU pooled across ALL active pricelists and unattributed. Ask the per-list tier fields for what a named list charges."
|
| 198 |
- key: unit_1
|
| 199 |
label: "Unit 1"
|
| 200 |
type: text
|
|
|
|
| 204 |
label: "Price 2"
|
| 205 |
type: currency
|
| 206 |
kind: data
|
| 207 |
+
means: "Second-cheapest live package price pooled across ALL pricelists, unattributed."
|
| 208 |
- key: unit_2
|
| 209 |
label: "Unit 2"
|
| 210 |
type: text
|
|
|
|
| 214 |
label: "Price 3"
|
| 215 |
type: currency
|
| 216 |
kind: data
|
| 217 |
+
means: "Third-cheapest live package price pooled across ALL pricelists, unattributed."
|
| 218 |
- key: unit_3
|
| 219 |
label: "Unit 3"
|
| 220 |
type: text
|
| 221 |
kind: data
|
| 222 |
means: "Package label for Price 3."
|
| 223 |
+
- key: price_fisch_t1
|
| 224 |
+
label: "Fisch tier 1 price"
|
| 225 |
+
type: currency
|
| 226 |
+
kind: data
|
| 227 |
+
means: "Fisch price tier 1, cheapest first. Blank when Fisch does not price this SKU."
|
| 228 |
+
- key: unit_fisch_t1
|
| 229 |
+
label: "Fisch tier 1 unit"
|
| 230 |
+
type: text
|
| 231 |
+
kind: data
|
| 232 |
+
means: "Package label for the Fisch tier 1 price. Blank when Odoo names no package."
|
| 233 |
+
- key: price_fisch_t2
|
| 234 |
+
label: "Fisch tier 2 price"
|
| 235 |
+
type: currency
|
| 236 |
+
kind: data
|
| 237 |
+
means: "Fisch price tier 2. Blank when Fisch prices this SKU at only one tier."
|
| 238 |
+
- key: unit_fisch_t2
|
| 239 |
+
label: "Fisch tier 2 unit"
|
| 240 |
+
type: text
|
| 241 |
+
kind: data
|
| 242 |
+
means: "Package label for the Fisch tier 2 price. Blank when Odoo names no package."
|
| 243 |
+
- key: price_fisch_t3
|
| 244 |
+
label: "Fisch tier 3 price"
|
| 245 |
+
type: currency
|
| 246 |
+
kind: data
|
| 247 |
+
means: "Fisch price tier 3. Blank when Fisch prices this SKU at fewer than three tiers."
|
| 248 |
+
- key: unit_fisch_t3
|
| 249 |
+
label: "Fisch tier 3 unit"
|
| 250 |
+
type: text
|
| 251 |
+
kind: data
|
| 252 |
+
means: "Package label for the Fisch tier 3 price. Blank when Odoo names no package."
|
| 253 |
+
- key: price_royal1_t1
|
| 254 |
+
label: "Royal 1 tier 1 price"
|
| 255 |
+
type: currency
|
| 256 |
+
kind: data
|
| 257 |
+
means: "Royal 1 price tier 1, cheapest first. Blank when Royal 1 does not price this SKU."
|
| 258 |
+
- key: unit_royal1_t1
|
| 259 |
+
label: "Royal 1 tier 1 unit"
|
| 260 |
+
type: text
|
| 261 |
+
kind: data
|
| 262 |
+
means: "Package label for the Royal 1 tier 1 price. Blank when Odoo names no package."
|
| 263 |
+
- key: price_royal1_t2
|
| 264 |
+
label: "Royal 1 tier 2 price"
|
| 265 |
+
type: currency
|
| 266 |
+
kind: data
|
| 267 |
+
means: "Royal 1 price tier 2. Blank when Royal 1 prices this SKU at only one tier."
|
| 268 |
+
- key: unit_royal1_t2
|
| 269 |
+
label: "Royal 1 tier 2 unit"
|
| 270 |
+
type: text
|
| 271 |
+
kind: data
|
| 272 |
+
means: "Package label for the Royal 1 tier 2 price. Blank when Odoo names no package."
|
| 273 |
+
- key: price_royal1_t3
|
| 274 |
+
label: "Royal 1 tier 3 price"
|
| 275 |
+
type: currency
|
| 276 |
+
kind: data
|
| 277 |
+
means: "Royal 1 price tier 3. Blank when Royal 1 prices this SKU at fewer than three tiers."
|
| 278 |
+
- key: unit_royal1_t3
|
| 279 |
+
label: "Royal 1 tier 3 unit"
|
| 280 |
+
type: text
|
| 281 |
+
kind: data
|
| 282 |
+
means: "Package label for the Royal 1 tier 3 price. Blank when Odoo names no package."
|
| 283 |
+
- key: price_royal2_t1
|
| 284 |
+
label: "Royal 2 tier 1 price"
|
| 285 |
+
type: currency
|
| 286 |
+
kind: data
|
| 287 |
+
means: "Royal 2 price tier 1, cheapest first. Blank when Royal 2 does not price this SKU."
|
| 288 |
+
- key: unit_royal2_t1
|
| 289 |
+
label: "Royal 2 tier 1 unit"
|
| 290 |
+
type: text
|
| 291 |
+
kind: data
|
| 292 |
+
means: "Package label for the Royal 2 tier 1 price. Blank when Odoo names no package."
|
| 293 |
+
- key: price_royal2_t2
|
| 294 |
+
label: "Royal 2 tier 2 price"
|
| 295 |
+
type: currency
|
| 296 |
+
kind: data
|
| 297 |
+
means: "Royal 2 price tier 2. Blank when Royal 2 prices this SKU at only one tier."
|
| 298 |
+
- key: unit_royal2_t2
|
| 299 |
+
label: "Royal 2 tier 2 unit"
|
| 300 |
+
type: text
|
| 301 |
+
kind: data
|
| 302 |
+
means: "Package label for the Royal 2 tier 2 price. Blank when Odoo names no package."
|
| 303 |
+
- key: price_royal2_t3
|
| 304 |
+
label: "Royal 2 tier 3 price"
|
| 305 |
+
type: currency
|
| 306 |
+
kind: data
|
| 307 |
+
means: "Royal 2 price tier 3. Blank when Royal 2 prices this SKU at fewer than three tiers."
|
| 308 |
+
- key: unit_royal2_t3
|
| 309 |
+
label: "Royal 2 tier 3 unit"
|
| 310 |
+
type: text
|
| 311 |
+
kind: data
|
| 312 |
+
means: "Package label for the Royal 2 tier 3 price. Blank when Odoo names no package."
|
| 313 |
- key: pre_book_qty
|
| 314 |
label: "Pre-book qty"
|
| 315 |
type: int
|
|
|
|
| 321 |
kind: data
|
| 322 |
means: "Units still owed on confirmed, not-fully-delivered wholesale goods orders, including already-due promises. Delivery-charge service lines are excluded."
|
| 323 |
- key: rev_ytd
|
| 324 |
+
label: "Revenue YTD"
|
| 325 |
+
type: currency
|
| 326 |
+
kind: data
|
| 327 |
+
means: "Year-to-date revenue for this SKU, BU-scoped when the caller is."
|
| 328 |
+
- key: rev_ly
|
| 329 |
+
label: "Revenue LY"
|
| 330 |
+
type: currency
|
| 331 |
+
kind: data
|
| 332 |
+
means: "Same period last year β seasonal wholesale compares like for like."
|
| 333 |
+
- key: yoy_pct
|
| 334 |
+
label: "YoY %"
|
| 335 |
+
type: pct
|
| 336 |
+
kind: data
|
| 337 |
+
means: "Year-over-year change; null when last year was zero (a ratio to zero is not a number)."
|
| 338 |
+
- key: qty_ytd
|
| 339 |
+
label: "Units YTD"
|
| 340 |
+
type: int
|
| 341 |
+
kind: data
|
| 342 |
+
means: "Units sold year to date."
|
| 343 |
+
- key: orders_ytd
|
| 344 |
+
label: "Orders YTD"
|
| 345 |
+
type: int
|
| 346 |
+
kind: data
|
| 347 |
+
means: "Distinct orders containing this SKU, year to date."
|
| 348 |
+
- key: on_hand
|
| 349 |
+
label: "On hand"
|
| 350 |
+
type: int
|
| 351 |
+
kind: data
|
| 352 |
+
means: "Units in stock. CONSOLIDATED β one physical warehouse, not brand-tagged, so this column is ABSENT for a BU-scoped caller rather than silently company-wide."
|
| 353 |
+
- key: unit_cost
|
| 354 |
+
label: "Unit cost"
|
| 355 |
+
type: currency
|
| 356 |
+
kind: data
|
| 357 |
+
means: "Inventory unit cost. Consolidated; absent for a BU-scoped caller."
|
| 358 |
+
- key: inv_value
|
| 359 |
+
label: "Stock value"
|
| 360 |
+
type: currency
|
| 361 |
+
kind: data
|
| 362 |
+
means: "On-hand value at cost. Consolidated; absent for a BU-scoped caller."
|
| 363 |
+
- key: qty_ltm
|
| 364 |
+
label: "Units LTM"
|
| 365 |
+
type: int
|
| 366 |
+
kind: data
|
| 367 |
+
means: "Units sold in the last twelve months. Consolidated; absent for a BU-scoped caller."
|
| 368 |
+
- key: dos
|
| 369 |
+
label: "Days of supply"
|
| 370 |
+
type: int
|
| 371 |
+
kind: data
|
| 372 |
+
means: "Days of supply at the trailing twelve month rate, counting on hand plus inbound units as stock. Blank means there is no honest number: either the product sold nothing in the window and Stock status reads 'No recent sales', or the inventory read returned no row for it and Stock status reads 'No stock record'. A product that sold nothing and holds nothing reads 0, not blank. A business unit caller gets this column too, re-scoped: the shelf is the whole warehouse's, the rate is that unit's."
|
| 373 |
+
- key: cover_gap_d
|
| 374 |
+
label: "Cover gap (days)"
|
| 375 |
+
type: int
|
| 376 |
+
kind: data
|
| 377 |
+
means: "Days of supply minus lead time. Negative means it runs out before a reorder lands."
|
| 378 |
+
- key: stock_bucket
|
| 379 |
+
label: "Stock status"
|
| 380 |
+
type: select
|
| 381 |
+
kind: data
|
| 382 |
+
means: "Dead / excess / healthy bucket from the inventory module, off the same days of supply figure beside it. Two values are not coverage bands: 'Out of stock' keys on the real shelf rather than the inbound-inclusive figure, and 'No stock record' means the inventory read returned no row for this product, so its shelf is unknown rather than empty. A business unit caller gets this column too, re-scoped through that unit's own sales rate."
|
| 383 |
+
- key: created_on
|
| 384 |
+
label: "Record created"
|
| 385 |
+
type: date
|
| 386 |
+
kind: data
|
| 387 |
+
means: "The date this product record was created in Odoo. The same uniform records-created column the customer topic carries."
|
| 388 |
+
|
| 389 |
+
ai_context: >
|
| 390 |
+
The product CATALOGUE β the database labelled 'Odoo products'.
|
| 391 |
+
Use it for 'what do we sell', 'what does it cost' (first_cost/unit_cost), 'who supplies it', 'what is on hand' and the stock-coverage columns.
|
| 392 |
+
β Revenue and unit columns here are WINDOWED SNAPSHOTS (rev_ytd, qty_ltm); for any other window, or for line grain, go to sales_lines.
|
platform/modules/customer_data.py
CHANGED
|
@@ -18,6 +18,9 @@ tie. Persistence: HF store key 'customer_lists'
|
|
| 18 |
The userβagent link (users.py 'agent' field) scopes the pool to that agent's book; users with
|
| 19 |
no link (owner/CFO/admin) see the whole book.
|
| 20 |
"""
|
|
|
|
|
|
|
|
|
|
| 21 |
import core.odoo as O
|
| 22 |
import core.periods as P
|
| 23 |
import core.store as store
|
|
@@ -31,6 +34,57 @@ LIMIT = 500 # honest builder display cap β always shown with the full
|
|
| 31 |
KEY = 'customer_lists'
|
| 32 |
TABLE_KEY = 'customer_table_workspace'
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# ------------------------------------------------------------------ the metric pool
|
| 35 |
# Field metadata for the rule builder: key -> (label, kind). 'num' fields take >=/<=/>/</=,
|
| 36 |
# 'text' fields take contains/=. Every field is a column of pool() rows.
|
|
@@ -577,6 +631,19 @@ def _pool_build(agent_name, team_id, limit, fast=False):
|
|
| 577 |
# Row-level datum for the `created_time` field type (wave-5 item 11) β rides every
|
| 578 |
# row like pid, no column of its own until a user creates one.
|
| 579 |
'_created': a.get('created_at', ''),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 580 |
'tags': a.get('tags', '(none)'), 'pricelist': a.get('pricelist', '(none)'),
|
| 581 |
# AR / credit exposure β composed from the already-reconciled ar.py blocks, so the
|
| 582 |
# Customer table and the Collections page cannot disagree.
|
|
@@ -679,7 +746,12 @@ def _reconcile_ledger(rows, total, agent_name, team_id):
|
|
| 679 |
'agent': '(none)', 'street': '(none)', 'street2': '(none)',
|
| 680 |
'city': '(none)', 'state': '(none)', 'country': '(none)',
|
| 681 |
'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
|
| 682 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
|
| 684 |
'days_to_pay': None,
|
| 685 |
**{k: 0.0 for k in AR_BUCKET_FIELDS.values()},
|
|
|
|
| 18 |
The userβagent link (users.py 'agent' field) scopes the pool to that agent's book; users with
|
| 19 |
no link (owner/CFO/admin) see the whole book.
|
| 20 |
"""
|
| 21 |
+
import re
|
| 22 |
+
from urllib.parse import quote_plus
|
| 23 |
+
|
| 24 |
import core.odoo as O
|
| 25 |
import core.periods as P
|
| 26 |
import core.store as store
|
|
|
|
| 34 |
KEY = 'customer_lists'
|
| 35 |
TABLE_KEY = 'customer_table_workspace'
|
| 36 |
|
| 37 |
+
# ------------------------------------------------------------------ the Google Maps link
|
| 38 |
+
#: The sentinel every blank connector attribute collapses to (`customers._partner_attrs` writes
|
| 39 |
+
#: it; the client's `CONNECTOR_BLANK` renders it). It is a real, visible value on the grid, NOT a
|
| 40 |
+
#: null β so an address builder that treats it as text pastes the literal string "(none)" into a
|
| 41 |
+
#: query and sends the reader to a place called none.
|
| 42 |
+
BLANK_ATTR = '(none)'
|
| 43 |
+
|
| 44 |
+
#: A trailing ISO country code in parentheses, as `O.m2o_name(state_id)` yields it: Odoo's state
|
| 45 |
+
#: names read "Texas (US)". β ANCHORED and UPPERCASE-ONLY on purpose. A greedy "strip the last
|
| 46 |
+
#: parenthetical" would eat a legitimate name that happens to end in brackets, and there is no
|
| 47 |
+
#: reason to risk that for a tidiness fix.
|
| 48 |
+
_STATE_COUNTRY_SUFFIX = re.compile(r'\s*\([A-Z]{2,3}\)$')
|
| 49 |
+
|
| 50 |
+
#: Google's documented Maps URLs endpoint for a place SEARCH. The same shape the client already
|
| 51 |
+
#: builds for a pin (`customer-grid/mapProjection.ts`), so the two doors agree.
|
| 52 |
+
MAPS_SEARCH_URL = 'https://www.google.com/maps/search/?api=1&query='
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _addr_part(v):
|
| 56 |
+
"""One address line, or '' β the sentinel and whitespace both read as absent."""
|
| 57 |
+
s = str(v or '').strip()
|
| 58 |
+
return '' if not s or s == BLANK_ATTR else s
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def maps_url(a):
|
| 62 |
+
"""A Google Maps link to a partner's address, or '' when there is no address to point at.
|
| 63 |
+
|
| 64 |
+
ββ W40-T08 (owner I27) β SERVER-SIDE, and that is the whole design, per the wave's AM-1. The
|
| 65 |
+
owner's instruction offered a client Formula as one option; D-115 says a formula column's
|
| 66 |
+
values exist only in the browser, so the link would be blank in every host-side export and
|
| 67 |
+
invisible to anything reading the stored cell. Computed here it is an ordinary cell value on
|
| 68 |
+
the pool row, so it exports, filters and reads back like any other column.
|
| 69 |
+
|
| 70 |
+
β BLANK UNLESS THE ROW CAN ACTUALLY BE LOCATED. The gate is street OR city OR zip: a country
|
| 71 |
+
alone ("United States") resolves to a link that opens the middle of a continent, which is
|
| 72 |
+
worse than an empty cell because it looks like an answer. MEASURED 2026-08-23: 140 of 3,641
|
| 73 |
+
customers have no street, city or zip, and those are the rows this returns '' for.
|
| 74 |
+
|
| 75 |
+
β `state` is stripped of its country suffix. It arrives as `O.m2o_name(state_id)`, i.e.
|
| 76 |
+
"Texas (US)", and the country is already its own part of the address. Google resolves the
|
| 77 |
+
string either way, so this is a tidiness call, not a correctness one.
|
| 78 |
+
"""
|
| 79 |
+
street, street2 = _addr_part(a.get('street')), _addr_part(a.get('street2'))
|
| 80 |
+
city, zipcode = _addr_part(a.get('city')), _addr_part(a.get('zip'))
|
| 81 |
+
if not (street or city or zipcode):
|
| 82 |
+
return ''
|
| 83 |
+
state = _STATE_COUNTRY_SUFFIX.sub('', _addr_part(a.get('state')))
|
| 84 |
+
parts = [p for p in (street, street2, city, state, zipcode,
|
| 85 |
+
_addr_part(a.get('country'))) if p]
|
| 86 |
+
return MAPS_SEARCH_URL + quote_plus(', '.join(parts))
|
| 87 |
+
|
| 88 |
# ------------------------------------------------------------------ the metric pool
|
| 89 |
# Field metadata for the rule builder: key -> (label, kind). 'num' fields take >=/<=/>/</=,
|
| 90 |
# 'text' fields take contains/=. Every field is a column of pool() rows.
|
|
|
|
| 631 |
# Row-level datum for the `created_time` field type (wave-5 item 11) β rides every
|
| 632 |
# row like pid, no column of its own until a user creates one.
|
| 633 |
'_created': a.get('created_at', ''),
|
| 634 |
+
# ββ W40-T08 (owner I24) β the DECLARED pre-set date column, beside the row datum
|
| 635 |
+
# above rather than instead of it. `_created` is only ever readable through a
|
| 636 |
+
# `created_time` column the user has to create; this is a real cell under its own key,
|
| 637 |
+
# so it exports, filters and appears in the semantic topic. Truncated the way
|
| 638 |
+
# `customer_since` is, and for the same reason: a date-typed column compared
|
| 639 |
+
# ISO-LEXICALLY drifts by the time component if the time is left on.
|
| 640 |
+
# β The value is IDENTICAL to `customer_since` on this database, because both come
|
| 641 |
+
# from `res.partner.create_date`. That is not a duplicate by accident: "Customer
|
| 642 |
+
# since" is a business fact about the relationship and cannot be the uniform
|
| 643 |
+
# records-created column I24 asks every database for (Products has no such notion).
|
| 644 |
+
'created_on': str(a.get('created_at') or '')[:10],
|
| 645 |
+
# ββ W40-T08 (owner I27) β the Google Maps link, replacing the hardcoded "Go" column.
|
| 646 |
+
'maps_url': maps_url(a),
|
| 647 |
'tags': a.get('tags', '(none)'), 'pricelist': a.get('pricelist', '(none)'),
|
| 648 |
# AR / credit exposure β composed from the already-reconciled ar.py blocks, so the
|
| 649 |
# Customer table and the Collections page cannot disagree.
|
|
|
|
| 746 |
'agent': '(none)', 'street': '(none)', 'street2': '(none)',
|
| 747 |
'city': '(none)', 'state': '(none)', 'country': '(none)',
|
| 748 |
'zip': '(none)', 'payment_terms': '(none)', 'customer_since': '',
|
| 749 |
+
# W40-T08 lands on BOTH row templates, which is the warning above being obeyed
|
| 750 |
+
# rather than restated. A revived row has no address and no create_date to read,
|
| 751 |
+
# so both are blank β and blank is exactly what `maps_url` returns for an
|
| 752 |
+
# addressless customer anyway, so the archived rows agree with the live ones.
|
| 753 |
+
'_created': '', 'created_on': '', 'maps_url': '',
|
| 754 |
+
'tags': '(none)', 'pricelist': '(none)',
|
| 755 |
'ar_open': 0.0, 'ar_overdue': 0.0, 'ar_outstanding': 0.0, 'ar_exposure': 0.0,
|
| 756 |
'days_to_pay': None,
|
| 757 |
**{k: 0.0 for k in AR_BUCKET_FIELDS.values()},
|
platform/modules/inventory.py
CHANGED
|
@@ -14,11 +14,29 @@ import core.periods as P
|
|
| 14 |
|
| 15 |
|
| 16 |
def _cat_main_map():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
cats = O.search_read('product.category', [], ['id', 'complete_name'])
|
| 18 |
out = {}
|
| 19 |
for c in cats:
|
| 20 |
parts = [x.strip() for x in (c['complete_name'] or '').split('/')]
|
| 21 |
-
out[c['id']] = parts[1] if len(parts) >= 2 else
|
| 22 |
return out
|
| 23 |
|
| 24 |
|
|
@@ -220,8 +238,33 @@ def _bucket(dos_raw, on_hand, qty_ltm):
|
|
| 220 |
return _COVERAGE_BUCKETS[-1][0]
|
| 221 |
|
| 222 |
|
| 223 |
-
#
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
|
| 226 |
|
| 227 |
def sku_inventory(target_days=180, carry_pct=_CARRY_PCT, t=None):
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
def _cat_main_map():
|
| 17 |
+
"""{category id: MAIN category name}, the SECOND segment of Odoo's `complete_name` path.
|
| 18 |
+
|
| 19 |
+
THE ROOT MAPS TO `None`, NOT TO ITS OWN NAME (owner instruction 23, wave 40). Odoo roots the
|
| 20 |
+
whole category tree at one node whose `complete_name` is the bare word "All", and the old
|
| 21 |
+
`parts[0]` fallback handed that word out as though it were a category. It is not one: it is
|
| 22 |
+
the tree's root, and a product filed directly on it is UNCATEGORISED. Exactly one of the 74
|
| 23 |
+
categories is single segment, so the root is the only value that could ever reach this branch.
|
| 24 |
+
|
| 25 |
+
MEASURED live 2026-08-23: 3,334 of 5,841 coded products (57.1%) sit directly on the root, so
|
| 26 |
+
this is the MAJORITY of the catalogue and not an edge case.
|
| 27 |
+
|
| 28 |
+
`None` IS THE CONTRACT, and all three callers already honour it. `inventory._build`,
|
| 29 |
+
`financial._product_cat` and `sales._product_cat` each read this map through
|
| 30 |
+
`... or '(uncategorized)'`, which is the sentinel the product grid's own field description
|
| 31 |
+
already promises: "Product category; '(uncategorized)' when Odoo carries none". So the root's
|
| 32 |
+
products land on the DECLARED blank value instead of on an invented label, and no caller has
|
| 33 |
+
to learn a new convention.
|
| 34 |
+
"""
|
| 35 |
cats = O.search_read('product.category', [], ['id', 'complete_name'])
|
| 36 |
out = {}
|
| 37 |
for c in cats:
|
| 38 |
parts = [x.strip() for x in (c['complete_name'] or '').split('/')]
|
| 39 |
+
out[c['id']] = parts[1] if len(parts) >= 2 else None
|
| 40 |
return out
|
| 41 |
|
| 42 |
|
|
|
|
| 238 |
return _COVERAGE_BUCKETS[-1][0]
|
| 239 |
|
| 240 |
|
| 241 |
+
#: β AM-4 (2026-08-23), BRANCH (c) β WHAT A SKU WITH NO INVENTORY ROW SAYS FOR ITSELF.
|
| 242 |
+
#: `_bucket` never returns this and never can: it is `product_data._dos_with_inbound`'s answer for
|
| 243 |
+
#: `on_hand is None`, a case that returns before `_bucket` is reached. It lives here, named, rather
|
| 244 |
+
#: than as a literal there, because two places must spell it identically β the product row that
|
| 245 |
+
#: carries it, and the permission editor's dropdown, which `routes_admin::
|
| 246 |
+
#: _server_side_vocabularies` fills from the list below.
|
| 247 |
+
#:
|
| 248 |
+
#: β THE WORDING IS LOAD-BEARING, NOT DECORATION, and AM-4 is explicit about why.
|
| 249 |
+
#: `product_data._inventory_by_code` degrades to `{}` on ANY failure, so during an inventory-read
|
| 250 |
+
#: outage EVERY product in the catalogue renders this label at once. "Stock not tracked" would
|
| 251 |
+
#: then be a catalogue-wide false claim about the warehouse, and "never sold" is false for most of
|
| 252 |
+
#: these rows even on a good day. What is genuinely absent in BOTH cases β the ~33 products with
|
| 253 |
+
#: no `default_code`, and an outage β is the RECORD, so that is what the label names.
|
| 254 |
+
NO_STOCK_RECORD = 'No stock record'
|
| 255 |
+
|
| 256 |
+
# Every value a `stock_bucket` cell can hold, and the vocabulary the permission editor offers for
|
| 257 |
+
# that column (`routes_admin::_server_side_vocabularies` imports this rather than restating it).
|
| 258 |
+
# The coverage BANDS run worstβbest between the two ends; the last two are not on that axis at all
|
| 259 |
+
# β 'No recent sales' is a full shelf nobody is buying from, and `NO_STOCK_RECORD` means there is
|
| 260 |
+
# no reading of the shelf to band in the first place.
|
| 261 |
+
# β THE ORDER IS FOR A READER OF THIS FILE, NOT A CONTRACT ANY SURFACE HONOURS. Measured
|
| 262 |
+
# 2026-08-24: `routes_admin.py:868` re-sorts every supplied vocabulary alphabetically, and the
|
| 263 |
+
# grid's own dropdown (`types.ts::choiceVocabulary`) discovers values from loaded ROWS and sorts
|
| 264 |
+
# them too. Same SET on both, never this sequence. Do not add a label here expecting it to land in
|
| 265 |
+
# a particular slot on screen.
|
| 266 |
+
COVERAGE_LABELS = (['Out of stock'] + [b[0] for b in _COVERAGE_BUCKETS]
|
| 267 |
+
+ ['No recent sales', NO_STOCK_RECORD])
|
| 268 |
|
| 269 |
|
| 270 |
def sku_inventory(target_days=180, carry_pct=_CARRY_PCT, t=None):
|
platform/modules/product_data.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/modules/products.py
CHANGED
|
@@ -289,8 +289,13 @@ def catalogue():
|
|
| 289 |
# to aggregate it (`Fault 2: Cannot aggregate field 'incoming_qty'`), so nothing downstream
|
| 290 |
# may push a filter or a read_group on it back to Odoo. It is materialised into the pool here
|
| 291 |
# and summed on our side; `validate()` reconciles that sum against purchase-order lines.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 292 |
prods = O.search_read('product.product', dom,
|
| 293 |
-
['id', 'default_code', 'display_name', 'name',
|
| 294 |
'product_tag_ids', 'incoming_qty'],
|
| 295 |
limit=50000)
|
| 296 |
n = O.get_odoo().search_count('product.product', dom)
|
|
@@ -323,13 +328,23 @@ def catalogue():
|
|
| 323 |
# downstream in `aios_grid.py` and has to be carried from the source read.
|
| 324 |
row = out.setdefault(code, {'product': p.get('display_name') or p.get('name') or code,
|
| 325 |
'category': code_cat.get(code, '(uncategorized)'),
|
| 326 |
-
'id': p['id'],
|
| 327 |
'discontinued': 'No', 'incoming': 0.0})
|
| 328 |
# β ACROSS EVERY RECORD SHARING THE CODE, not just the first one that won above. A SKU
|
| 329 |
# whose variants are separate records carries its inbound on whichever record the PO named,
|
| 330 |
# and 'first record wins' would drop the rest β a buy list that under-counts what is
|
| 331 |
# already on the water tells you to re-order stock you have already bought.
|
| 332 |
row['incoming'] += float(p.get('incoming_qty') or 0.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
if disc_tag is not None and disc_tag in (p.get('product_tag_ids') or ()):
|
| 334 |
row['discontinued'] = 'Yes'
|
| 335 |
return out
|
|
@@ -432,6 +447,98 @@ PRICELIST_COLUMNS = (
|
|
| 432 |
("price_royal_2", "Royal 2"),
|
| 433 |
)
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
|
| 436 |
def pricelist_by_code():
|
| 437 |
"""`({code: {column_key: price}}, report)` β the date-valid FIXED base-tier price for each
|
|
|
|
| 289 |
# to aggregate it (`Fault 2: Cannot aggregate field 'incoming_qty'`), so nothing downstream
|
| 290 |
# may push a filter or a read_group on it back to Odoo. It is materialised into the pool here
|
| 291 |
# and summed on our side; `validate()` reconciles that sum against purchase-order lines.
|
| 292 |
+
# ββ W40-T08 (owner I24) β `create_date` RIDES THIS READ TOO, for the same reason the two
|
| 293 |
+
# above do. It has to come from the LIVE record: the DuckDB mirror's `product_product` carries
|
| 294 |
+
# `write_date` and NO `create_date`, so there is no cheaper source, and this read already pulls
|
| 295 |
+
# every active product once. MEASURED 2026-08-23 against live Odoo: 5,874 active products, of
|
| 296 |
+
# which 0 have a null `create_date`, so the column is populated for the whole catalogue.
|
| 297 |
prods = O.search_read('product.product', dom,
|
| 298 |
+
['id', 'default_code', 'display_name', 'name', 'create_date',
|
| 299 |
'product_tag_ids', 'incoming_qty'],
|
| 300 |
limit=50000)
|
| 301 |
n = O.get_odoo().search_count('product.product', dom)
|
|
|
|
| 328 |
# downstream in `aios_grid.py` and has to be carried from the source read.
|
| 329 |
row = out.setdefault(code, {'product': p.get('display_name') or p.get('name') or code,
|
| 330 |
'category': code_cat.get(code, '(uncategorized)'),
|
| 331 |
+
'id': p['id'], 'created_at': '',
|
| 332 |
'discontinued': 'No', 'incoming': 0.0})
|
| 333 |
# β ACROSS EVERY RECORD SHARING THE CODE, not just the first one that won above. A SKU
|
| 334 |
# whose variants are separate records carries its inbound on whichever record the PO named,
|
| 335 |
# and 'first record wins' would drop the rest β a buy list that under-counts what is
|
| 336 |
# already on the water tells you to re-order stock you have already bought.
|
| 337 |
row['incoming'] += float(p.get('incoming_qty') or 0.0)
|
| 338 |
+
# β W40-T08 β the EARLIEST create_date across the records sharing this code, not the
|
| 339 |
+
# first record's. A row here is a SKU, and the honest answer to "when was this record
|
| 340 |
+
# created" for a merged SKU is when the earliest of its records appeared. Only `2112-12`
|
| 341 |
+
# is affected today (5,873 active products, 5,872 distinct codes), so this is a one-row
|
| 342 |
+
# policy call rather than a measurable difference; it is written down because the
|
| 343 |
+
# first-wins convention above would otherwise read as covering this key too.
|
| 344 |
+
# β Lexical `<` is a real date comparison here: Odoo datetimes are fixed-width ISO.
|
| 345 |
+
_cd = str(p.get('create_date') or '')
|
| 346 |
+
if _cd and (not row['created_at'] or _cd < row['created_at']):
|
| 347 |
+
row['created_at'] = _cd
|
| 348 |
if disc_tag is not None and disc_tag in (p.get('product_tag_ids') or ()):
|
| 349 |
row['discontinued'] = 'Yes'
|
| 350 |
return out
|
|
|
|
| 447 |
("price_royal_2", "Royal 2"),
|
| 448 |
)
|
| 449 |
|
| 450 |
+
#: ββ W40-T09 / RULING R6 (owner I25: *"Fisch price and Royal price each have 3 units and 3
|
| 451 |
+
#: prices. Separate them and put all of them in the Odoo fields."*) β HOW MANY (price, unit)
|
| 452 |
+
#: PAIRS EACH DECLARED LIST SHOWS.
|
| 453 |
+
#:
|
| 454 |
+
#: β THIS CONSTANT IS THE CAP, AND NAMING IT IS THE POINT. `tier_prices_by_code` below returns
|
| 455 |
+
#: EVERY live tier a SKU has; only the WRITE into the grid is limited, and it is limited here in
|
| 456 |
+
#: one place rather than by a `range(1, 4)` inlined at the call site the way the pooled
|
| 457 |
+
#: `price_1..3` block was. MEASURED on the live book 2026-08-24 over 3,996 priced codes: exactly
|
| 458 |
+
#: ONE SKU/list pair exceeds this depth β `6AB` on Fisch, which carries FOUR tiers β so raising
|
| 459 |
+
#: this number to 4 is the whole of the fix if the owner ever wants that fourth cell. Max tiers
|
| 460 |
+
#: observed per list: Fisch 4, Royal 1 3, Royal 2 3, Public Pricelist 1.
|
| 461 |
+
PRICELIST_TIER_DEPTH = 3
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
def _pricelist_slug(col):
|
| 465 |
+
"""`price_royal_1` -> `royal1`, the token the per-list tier keys are built on.
|
| 466 |
+
|
| 467 |
+
β THE UNDERSCORE IS REMOVED DELIBERATELY, and it is not cosmetic. `price_royal_1` ALREADY
|
| 468 |
+
EXISTS and means "the Royal 1 list's base price", so a tier key of `price_royal_1_t1` shares
|
| 469 |
+
a prefix with a different column and is ambiguous to anyone diffing the contract by eye.
|
| 470 |
+
`price_royal1_t1` cannot be mistaken for a suffix of it.
|
| 471 |
+
"""
|
| 472 |
+
return col[len("price_"):].replace("_", "")
|
| 473 |
+
|
| 474 |
+
|
| 475 |
+
#: `[(price_key, unit_key, pricelist_name, tier_no)]` β the 18 columns R6 declares, DERIVED from
|
| 476 |
+
#: `PRICELIST_COLUMNS` so this tenant's list names live in exactly one place. A fourth pricelist
|
| 477 |
+
#: added above becomes six more tier columns here with no second edit.
|
| 478 |
+
PRICELIST_TIER_COLUMNS = tuple(
|
| 479 |
+
(f"price_{_pricelist_slug(col)}_t{n}", f"unit_{_pricelist_slug(col)}_t{n}", name, n)
|
| 480 |
+
for col, name in PRICELIST_COLUMNS
|
| 481 |
+
for n in range(1, PRICELIST_TIER_DEPTH + 1)
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
def per_list_tier_cells(tiers):
|
| 486 |
+
"""One SKU's `tier_prices_by_code` entry -> `{column_key: value}` for all 18 R6 cells.
|
| 487 |
+
|
| 488 |
+
ββ THE WHOLE OF W40-T09, and it is a GROUP-BY, not a new read. `tier_prices_by_code`
|
| 489 |
+
already carries the pricelist NAME on every entry, so attributing prices to the list that
|
| 490 |
+
charges them costs nothing beyond this loop.
|
| 491 |
+
|
| 492 |
+
β **IT NEVER BORROWS.** A list this SKU is not priced on gets `None` in all six of its
|
| 493 |
+
cells β never the cheapest price from another list, never a fallback. That is R6's negative
|
| 494 |
+
control and it is the reason the pooled `price_1..3` block could not simply be relabelled:
|
| 495 |
+
those three are the cheapest three POOLED ACROSS ALL LISTS and unattributed, so on a SKU
|
| 496 |
+
priced only on Fisch they would put a Fisch price under a Royal heading.
|
| 497 |
+
MEASURED: `BOUT-WH` has three tiers, all Fisch, on NEITHER Royal list β one SKU that is
|
| 498 |
+
simultaneously the cheapest-first case and this negative control.
|
| 499 |
+
|
| 500 |
+
β CHEAPEST-FIRST IS GUARANTEED HERE, not inherited. `tier_prices_by_code` does sort its
|
| 501 |
+
lists globally by price, and a stable group-by would preserve that within each list β but a
|
| 502 |
+
caller handing in a fixture, or a future change to that sort key, would silently reorder the
|
| 503 |
+
cells. Sorting the group is one line and makes the guarantee local to the function that
|
| 504 |
+
makes it. The tiebreak matches the reader's: unit name, then list name.
|
| 505 |
+
|
| 506 |
+
β A LIST NOT IN `PRICELIST_COLUMNS` IS INVISIBLE HERE BY DESIGN β the columns are named
|
| 507 |
+
after the three lists the contract declares. MEASURED: 3 active SKUs are priced ONLY on
|
| 508 |
+
Public Pricelist, so they carry all eighteen cells empty. `tier_prices_by_code` still sees
|
| 509 |
+
those prices; nothing is lost from the honest set, only from these named columns.
|
| 510 |
+
"""
|
| 511 |
+
grouped = {}
|
| 512 |
+
for tier in tiers or ():
|
| 513 |
+
grouped.setdefault(tier.get("pricelist"), []).append(tier)
|
| 514 |
+
for got in grouped.values():
|
| 515 |
+
got.sort(key=lambda t: (t.get("unit_price") or 0.0,
|
| 516 |
+
str(t.get("unit") or "").lower(),
|
| 517 |
+
str(t.get("pricelist") or "").lower()))
|
| 518 |
+
cells = {}
|
| 519 |
+
for price_key, unit_key, name, n in PRICELIST_TIER_COLUMNS:
|
| 520 |
+
got = grouped.get(name) or []
|
| 521 |
+
tier = got[n - 1] if len(got) >= n else None
|
| 522 |
+
cells[price_key] = tier.get("unit_price") if tier else None
|
| 523 |
+
cells[unit_key] = tier.get("unit") if tier else None
|
| 524 |
+
return cells
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def tier_cap_overflow(tiers):
|
| 528 |
+
"""`{pricelist_name: how many tiers PRICELIST_TIER_DEPTH hides}` for one SKU, `{}` when none.
|
| 529 |
+
|
| 530 |
+
β STANDING RULE 1's SECOND SENTENCE, in code: *"if there is lag or it can't be done, you
|
| 531 |
+
need to explicitly tell me why and recommend a fix."* R6 declares three pairs per list, so a
|
| 532 |
+
fourth tier is dropped β and a dropped tier that nobody counts is the silent cap the rule
|
| 533 |
+
forbids. `validate_price_and_unit_cells` turns this into a named, reported figure.
|
| 534 |
+
"""
|
| 535 |
+
counts = {}
|
| 536 |
+
for tier in tiers or ():
|
| 537 |
+
counts[tier.get("pricelist")] = counts.get(tier.get("pricelist"), 0) + 1
|
| 538 |
+
declared = {name for _p, _u, name, _n in PRICELIST_TIER_COLUMNS}
|
| 539 |
+
return {name: n - PRICELIST_TIER_DEPTH for name, n in counts.items()
|
| 540 |
+
if name in declared and n > PRICELIST_TIER_DEPTH}
|
| 541 |
+
|
| 542 |
|
| 543 |
def pricelist_by_code():
|
| 544 |
"""`({code: {column_key: price}}, report)` β the date-valid FIXED base-tier price for each
|
web/public/sample_customers.json
CHANGED
|
@@ -1,558 +1,574 @@
|
|
| 1 |
-
{
|
| 2 |
-
"fields": [
|
| 3 |
-
{
|
| 4 |
-
"key": "customer",
|
| 5 |
-
"label": "Customer",
|
| 6 |
-
"type": "text",
|
| 7 |
-
"source": "odoo",
|
| 8 |
-
"pinned": true,
|
| 9 |
-
"default": true,
|
| 10 |
-
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 11 |
-
},
|
| 12 |
-
{
|
| 13 |
-
"key": "partner_id",
|
| 14 |
-
"label": "Odoo ID",
|
| 15 |
-
"type": "int",
|
| 16 |
-
"source": "odoo",
|
| 17 |
-
"derived": true,
|
| 18 |
-
"default": false,
|
| 19 |
-
"description": "The Odoo res.partner id β the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
|
| 20 |
-
},
|
| 21 |
-
{
|
| 22 |
-
"key": "odoo_status",
|
| 23 |
-
"label": "Odoo record",
|
| 24 |
-
"type": "status",
|
| 25 |
-
"source": "odoo",
|
| 26 |
-
"default": false,
|
| 27 |
-
"options": [
|
| 28 |
-
"Active",
|
| 29 |
-
"Archived"
|
| 30 |
-
],
|
| 31 |
-
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 32 |
-
},
|
| 33 |
-
{
|
| 34 |
-
"key": "agent",
|
| 35 |
-
"label": "Agent",
|
| 36 |
-
"type": "
|
| 37 |
-
"source": "odoo",
|
| 38 |
-
"default": true,
|
| 39 |
-
"description": "The sales agent who owns this account."
|
| 40 |
-
},
|
| 41 |
-
{
|
| 42 |
-
"key": "dba",
|
| 43 |
-
"label": "DBA",
|
| 44 |
-
"type": "select",
|
| 45 |
-
"source": "odoo",
|
| 46 |
-
"default": false,
|
| 47 |
-
"options": [
|
| 48 |
-
"Fisch",
|
| 49 |
-
"Royal",
|
| 50 |
-
"Both"
|
| 51 |
-
],
|
| 52 |
-
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 53 |
-
},
|
| 54 |
-
{
|
| 55 |
-
"key": "salesperson",
|
| 56 |
-
"label": "Salesperson",
|
| 57 |
-
"type": "
|
| 58 |
-
"source": "odoo",
|
| 59 |
-
"default": false,
|
| 60 |
-
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 61 |
-
},
|
| 62 |
-
{
|
| 63 |
-
"key": "street",
|
| 64 |
-
"label": "Street",
|
| 65 |
-
"type": "text",
|
| 66 |
-
"source": "odoo",
|
| 67 |
-
"default": false,
|
| 68 |
-
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 69 |
-
},
|
| 70 |
-
{
|
| 71 |
-
"key": "street2",
|
| 72 |
-
"label": "Street 2",
|
| 73 |
-
"type": "text",
|
| 74 |
-
"source": "odoo",
|
| 75 |
-
"default": false,
|
| 76 |
-
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 77 |
-
},
|
| 78 |
-
{
|
| 79 |
-
"key": "city",
|
| 80 |
-
"label": "City",
|
| 81 |
-
"type": "
|
| 82 |
-
"source": "odoo",
|
| 83 |
-
"default": true,
|
| 84 |
-
"description": "City on the customer's Odoo address."
|
| 85 |
-
},
|
| 86 |
-
{
|
| 87 |
-
"key": "state",
|
| 88 |
-
"label": "State",
|
| 89 |
-
"type": "
|
| 90 |
-
"source": "odoo",
|
| 91 |
-
"default": true,
|
| 92 |
-
"description": "State or province on the customer's Odoo address."
|
| 93 |
-
},
|
| 94 |
-
{
|
| 95 |
-
"key": "country",
|
| 96 |
-
"label": "Country",
|
| 97 |
-
"type": "
|
| 98 |
-
"source": "odoo",
|
| 99 |
-
"default": false,
|
| 100 |
-
"description": "Country on the customer's Odoo address."
|
| 101 |
-
},
|
| 102 |
-
{
|
| 103 |
-
"key": "zip",
|
| 104 |
-
"label": "ZIP",
|
| 105 |
-
"type": "text",
|
| 106 |
-
"source": "odoo",
|
| 107 |
-
"default": false,
|
| 108 |
-
"description": "Postal code on the customer's Odoo address."
|
| 109 |
-
},
|
| 110 |
-
{
|
| 111 |
-
"key": "
|
| 112 |
-
"label": "
|
| 113 |
-
"type": "
|
| 114 |
-
"source": "odoo",
|
| 115 |
-
"default":
|
| 116 |
-
"description": "
|
| 117 |
-
},
|
| 118 |
-
{
|
| 119 |
-
"key": "
|
| 120 |
-
"label": "
|
| 121 |
-
"type": "
|
| 122 |
-
"source": "odoo",
|
| 123 |
-
"default": false,
|
| 124 |
-
"description": "
|
| 125 |
-
},
|
| 126 |
-
{
|
| 127 |
-
"key": "
|
| 128 |
-
"label": "
|
| 129 |
-
"type": "
|
| 130 |
-
"source": "odoo",
|
| 131 |
-
"default": false,
|
| 132 |
-
"description": "
|
| 133 |
-
},
|
| 134 |
-
{
|
| 135 |
-
"key": "
|
| 136 |
-
"label": "
|
| 137 |
-
"type": "
|
| 138 |
-
"source": "odoo",
|
| 139 |
-
"default": false,
|
| 140 |
-
"description": "
|
| 141 |
-
},
|
| 142 |
-
{
|
| 143 |
-
"key": "
|
| 144 |
-
"label": "
|
| 145 |
-
"type": "
|
| 146 |
-
"source": "odoo",
|
| 147 |
-
"default":
|
| 148 |
-
"description": "
|
| 149 |
-
},
|
| 150 |
-
{
|
| 151 |
-
"key": "
|
| 152 |
-
"label": "
|
| 153 |
-
"type": "
|
| 154 |
-
"source": "odoo",
|
| 155 |
-
"default": true,
|
| 156 |
-
"description": "
|
| 157 |
-
},
|
| 158 |
-
{
|
| 159 |
-
"
|
| 160 |
-
"
|
| 161 |
-
"
|
| 162 |
-
"
|
| 163 |
-
"
|
| 164 |
-
"
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
"
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
"
|
| 171 |
-
"
|
| 172 |
-
"
|
| 173 |
-
"
|
| 174 |
-
"
|
| 175 |
-
"
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
"key": "
|
| 180 |
-
"label": "AR
|
| 181 |
-
"type": "currency",
|
| 182 |
-
"source": "odoo",
|
| 183 |
-
"default": false,
|
| 184 |
-
"description": "Invoiced money
|
| 185 |
-
},
|
| 186 |
-
{
|
| 187 |
-
"
|
| 188 |
-
"
|
| 189 |
-
"
|
| 190 |
-
"
|
| 191 |
-
"
|
| 192 |
-
"
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
"key": "
|
| 197 |
-
"label": "
|
| 198 |
-
"type": "currency",
|
| 199 |
-
"source": "odoo",
|
| 200 |
-
"default": false,
|
| 201 |
-
"description": "
|
| 202 |
-
},
|
| 203 |
-
{
|
| 204 |
-
"key": "
|
| 205 |
-
"label": "
|
| 206 |
-
"type": "currency",
|
| 207 |
-
"source": "odoo",
|
| 208 |
-
"default": false,
|
| 209 |
-
"description": "
|
| 210 |
-
},
|
| 211 |
-
{
|
| 212 |
-
"key": "
|
| 213 |
-
"label": "
|
| 214 |
-
"type": "currency",
|
| 215 |
-
"source": "odoo",
|
| 216 |
-
"default": false,
|
| 217 |
-
"description": "Overdue between
|
| 218 |
-
},
|
| 219 |
-
{
|
| 220 |
-
"key": "
|
| 221 |
-
"label": "
|
| 222 |
-
"type": "currency",
|
| 223 |
-
"source": "odoo",
|
| 224 |
-
"default": false,
|
| 225 |
-
"description": "Overdue between
|
| 226 |
-
},
|
| 227 |
-
{
|
| 228 |
-
"key": "
|
| 229 |
-
"label": "90
|
| 230 |
-
"type": "currency",
|
| 231 |
-
"source": "odoo",
|
| 232 |
-
"default": false,
|
| 233 |
-
"description": "Overdue
|
| 234 |
-
},
|
| 235 |
-
{
|
| 236 |
-
"key": "
|
| 237 |
-
"label": "
|
| 238 |
-
"type": "
|
| 239 |
-
"source": "odoo",
|
| 240 |
-
"default": false,
|
| 241 |
-
"description": "
|
| 242 |
-
},
|
| 243 |
-
{
|
| 244 |
-
"key": "
|
| 245 |
-
"label": "
|
| 246 |
-
"type": "
|
| 247 |
-
"source": "odoo",
|
| 248 |
-
"default": false,
|
| 249 |
-
"description": "
|
| 250 |
-
},
|
| 251 |
-
{
|
| 252 |
-
"key": "
|
| 253 |
-
"label": "Top category
|
| 254 |
-
"type": "
|
| 255 |
-
"source": "odoo",
|
| 256 |
-
"default": false,
|
| 257 |
-
"description": "
|
| 258 |
-
},
|
| 259 |
-
{
|
| 260 |
-
"key": "
|
| 261 |
-
"label": "
|
| 262 |
-
"type": "
|
| 263 |
-
"source": "odoo",
|
| 264 |
-
"default": false,
|
| 265 |
-
"description": "
|
| 266 |
-
},
|
| 267 |
-
{
|
| 268 |
-
"key": "
|
| 269 |
-
"label": "
|
| 270 |
-
"type": "
|
| 271 |
-
"source": "odoo",
|
| 272 |
-
"default": false,
|
| 273 |
-
"description": "
|
| 274 |
-
},
|
| 275 |
-
{
|
| 276 |
-
"key": "
|
| 277 |
-
"label": "
|
| 278 |
-
"type": "
|
| 279 |
-
"source": "odoo",
|
| 280 |
-
"default": false,
|
| 281 |
-
"description": "
|
| 282 |
-
},
|
| 283 |
-
{
|
| 284 |
-
"key": "
|
| 285 |
-
"label": "
|
| 286 |
-
"type": "int",
|
| 287 |
-
"source": "odoo",
|
| 288 |
-
"default": false,
|
| 289 |
-
"description": "Days
|
| 290 |
-
},
|
| 291 |
-
{
|
| 292 |
-
"key": "
|
| 293 |
-
"label": "
|
| 294 |
-
"type": "
|
| 295 |
-
"source": "
|
| 296 |
-
"default": false,
|
| 297 |
-
"description": "
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
"
|
| 303 |
-
"
|
| 304 |
-
"
|
| 305 |
-
"
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
"
|
| 309 |
-
"
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
-
"
|
| 313 |
-
"
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
"
|
| 319 |
-
"
|
| 320 |
-
"
|
| 321 |
-
"
|
| 322 |
-
"
|
| 323 |
-
"
|
| 324 |
-
"
|
| 325 |
-
"
|
| 326 |
-
"
|
| 327 |
-
"
|
| 328 |
-
"
|
| 329 |
-
"
|
| 330 |
-
"
|
| 331 |
-
"
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
"
|
| 335 |
-
"
|
| 336 |
-
"
|
| 337 |
-
"
|
| 338 |
-
"
|
| 339 |
-
"
|
| 340 |
-
"
|
| 341 |
-
"
|
| 342 |
-
"
|
| 343 |
-
"
|
| 344 |
-
"
|
| 345 |
-
"
|
| 346 |
-
"
|
| 347 |
-
"
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
"
|
| 351 |
-
"
|
| 352 |
-
"
|
| 353 |
-
"
|
| 354 |
-
"
|
| 355 |
-
"
|
| 356 |
-
"
|
| 357 |
-
"
|
| 358 |
-
"
|
| 359 |
-
"
|
| 360 |
-
"
|
| 361 |
-
"
|
| 362 |
-
"
|
| 363 |
-
"
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
"
|
| 367 |
-
"
|
| 368 |
-
"
|
| 369 |
-
"
|
| 370 |
-
"
|
| 371 |
-
"
|
| 372 |
-
"
|
| 373 |
-
"
|
| 374 |
-
"
|
| 375 |
-
"
|
| 376 |
-
"
|
| 377 |
-
"
|
| 378 |
-
"
|
| 379 |
-
"
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
"
|
| 383 |
-
"
|
| 384 |
-
"
|
| 385 |
-
"
|
| 386 |
-
"
|
| 387 |
-
"
|
| 388 |
-
"
|
| 389 |
-
"
|
| 390 |
-
"
|
| 391 |
-
"
|
| 392 |
-
"
|
| 393 |
-
"
|
| 394 |
-
"
|
| 395 |
-
"
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
"
|
| 399 |
-
"
|
| 400 |
-
"
|
| 401 |
-
"
|
| 402 |
-
"
|
| 403 |
-
"
|
| 404 |
-
"
|
| 405 |
-
"
|
| 406 |
-
"
|
| 407 |
-
"
|
| 408 |
-
"
|
| 409 |
-
"
|
| 410 |
-
"
|
| 411 |
-
"
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
"
|
| 415 |
-
"
|
| 416 |
-
"
|
| 417 |
-
"
|
| 418 |
-
"
|
| 419 |
-
"
|
| 420 |
-
"
|
| 421 |
-
"
|
| 422 |
-
"
|
| 423 |
-
"
|
| 424 |
-
"
|
| 425 |
-
"
|
| 426 |
-
"
|
| 427 |
-
"
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
"
|
| 431 |
-
"
|
| 432 |
-
"
|
| 433 |
-
"
|
| 434 |
-
"
|
| 435 |
-
"
|
| 436 |
-
"
|
| 437 |
-
"
|
| 438 |
-
"
|
| 439 |
-
"
|
| 440 |
-
"
|
| 441 |
-
"
|
| 442 |
-
"
|
| 443 |
-
"
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
"
|
| 447 |
-
"
|
| 448 |
-
"
|
| 449 |
-
"
|
| 450 |
-
"
|
| 451 |
-
"
|
| 452 |
-
"
|
| 453 |
-
"
|
| 454 |
-
"
|
| 455 |
-
"
|
| 456 |
-
"
|
| 457 |
-
"
|
| 458 |
-
"
|
| 459 |
-
"
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
"
|
| 463 |
-
"
|
| 464 |
-
"
|
| 465 |
-
"
|
| 466 |
-
"
|
| 467 |
-
"
|
| 468 |
-
"
|
| 469 |
-
"
|
| 470 |
-
"
|
| 471 |
-
"
|
| 472 |
-
"
|
| 473 |
-
"
|
| 474 |
-
"
|
| 475 |
-
"
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
"
|
| 479 |
-
"
|
| 480 |
-
"
|
| 481 |
-
"
|
| 482 |
-
"
|
| 483 |
-
"
|
| 484 |
-
"
|
| 485 |
-
"
|
| 486 |
-
"
|
| 487 |
-
"
|
| 488 |
-
"
|
| 489 |
-
"
|
| 490 |
-
"
|
| 491 |
-
"
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
"
|
| 495 |
-
"
|
| 496 |
-
"
|
| 497 |
-
"
|
| 498 |
-
"
|
| 499 |
-
"
|
| 500 |
-
"
|
| 501 |
-
"
|
| 502 |
-
"
|
| 503 |
-
"
|
| 504 |
-
"
|
| 505 |
-
"
|
| 506 |
-
"
|
| 507 |
-
"
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
"
|
| 511 |
-
"
|
| 512 |
-
"
|
| 513 |
-
"
|
| 514 |
-
"
|
| 515 |
-
"
|
| 516 |
-
"
|
| 517 |
-
"
|
| 518 |
-
"
|
| 519 |
-
"
|
| 520 |
-
"
|
| 521 |
-
"
|
| 522 |
-
"
|
| 523 |
-
"
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
"
|
| 527 |
-
"
|
| 528 |
-
"
|
| 529 |
-
"
|
| 530 |
-
"
|
| 531 |
-
"
|
| 532 |
-
"
|
| 533 |
-
"
|
| 534 |
-
"
|
| 535 |
-
"
|
| 536 |
-
"
|
| 537 |
-
"
|
| 538 |
-
"
|
| 539 |
-
"
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
"
|
| 543 |
-
"
|
| 544 |
-
"
|
| 545 |
-
"
|
| 546 |
-
"
|
| 547 |
-
"
|
| 548 |
-
"
|
| 549 |
-
"
|
| 550 |
-
"
|
| 551 |
-
"
|
| 552 |
-
"
|
| 553 |
-
"
|
| 554 |
-
"
|
| 555 |
-
"
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"fields": [
|
| 3 |
+
{
|
| 4 |
+
"key": "customer",
|
| 5 |
+
"label": "Customer",
|
| 6 |
+
"type": "text",
|
| 7 |
+
"source": "odoo",
|
| 8 |
+
"pinned": true,
|
| 9 |
+
"default": true,
|
| 10 |
+
"description": "The customer's name in Odoo. One row per customer who ordered in the last 24 months."
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
"key": "partner_id",
|
| 14 |
+
"label": "Odoo ID",
|
| 15 |
+
"type": "int",
|
| 16 |
+
"source": "odoo",
|
| 17 |
+
"derived": true,
|
| 18 |
+
"default": false,
|
| 19 |
+
"description": "The Odoo res.partner id β the key every Odoo document joins on. DERIVED: this row's pid IS the partner id, so a stored copy would be a second source."
|
| 20 |
+
},
|
| 21 |
+
{
|
| 22 |
+
"key": "odoo_status",
|
| 23 |
+
"label": "Odoo record",
|
| 24 |
+
"type": "status",
|
| 25 |
+
"source": "odoo",
|
| 26 |
+
"default": false,
|
| 27 |
+
"options": [
|
| 28 |
+
"Active",
|
| 29 |
+
"Archived"
|
| 30 |
+
],
|
| 31 |
+
"description": "Whether this customer still exists in Odoo. Archived means deleted there."
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
"key": "agent",
|
| 35 |
+
"label": "Agent",
|
| 36 |
+
"type": "select",
|
| 37 |
+
"source": "odoo",
|
| 38 |
+
"default": true,
|
| 39 |
+
"description": "The sales agent who owns this account."
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
"key": "dba",
|
| 43 |
+
"label": "DBA",
|
| 44 |
+
"type": "select",
|
| 45 |
+
"source": "odoo",
|
| 46 |
+
"default": false,
|
| 47 |
+
"options": [
|
| 48 |
+
"Fisch",
|
| 49 |
+
"Royal",
|
| 50 |
+
"Both"
|
| 51 |
+
],
|
| 52 |
+
"description": "The brand this customer buys from - Fisch, Royal, or both. Amazon-channel orders are not a DBA."
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"key": "salesperson",
|
| 56 |
+
"label": "Salesperson",
|
| 57 |
+
"type": "select",
|
| 58 |
+
"source": "odoo",
|
| 59 |
+
"default": false,
|
| 60 |
+
"description": "Who keyed in most of this customer's orders β not the Agent, who owns the account."
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"key": "street",
|
| 64 |
+
"label": "Street",
|
| 65 |
+
"type": "text",
|
| 66 |
+
"source": "odoo",
|
| 67 |
+
"default": false,
|
| 68 |
+
"description": "First address line, from res.partner directly - not the geocoder, so a customer the map cannot place still shows its address."
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"key": "street2",
|
| 72 |
+
"label": "Street 2",
|
| 73 |
+
"type": "text",
|
| 74 |
+
"source": "odoo",
|
| 75 |
+
"default": false,
|
| 76 |
+
"description": "Second address line (suite, unit, floor) on the customer's Odoo address."
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"key": "city",
|
| 80 |
+
"label": "City",
|
| 81 |
+
"type": "select",
|
| 82 |
+
"source": "odoo",
|
| 83 |
+
"default": true,
|
| 84 |
+
"description": "City on the customer's Odoo address."
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
"key": "state",
|
| 88 |
+
"label": "State",
|
| 89 |
+
"type": "select",
|
| 90 |
+
"source": "odoo",
|
| 91 |
+
"default": true,
|
| 92 |
+
"description": "State or province on the customer's Odoo address."
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"key": "country",
|
| 96 |
+
"label": "Country",
|
| 97 |
+
"type": "select",
|
| 98 |
+
"source": "odoo",
|
| 99 |
+
"default": false,
|
| 100 |
+
"description": "Country on the customer's Odoo address."
|
| 101 |
+
},
|
| 102 |
+
{
|
| 103 |
+
"key": "zip",
|
| 104 |
+
"label": "ZIP",
|
| 105 |
+
"type": "text",
|
| 106 |
+
"source": "odoo",
|
| 107 |
+
"default": false,
|
| 108 |
+
"description": "Postal code on the customer's Odoo address."
|
| 109 |
+
},
|
| 110 |
+
{
|
| 111 |
+
"key": "maps_url",
|
| 112 |
+
"label": "Google Maps",
|
| 113 |
+
"type": "url",
|
| 114 |
+
"source": "odoo",
|
| 115 |
+
"default": true,
|
| 116 |
+
"description": "A Google Maps link to this customer's address."
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"key": "customer_since",
|
| 120 |
+
"label": "Customer since",
|
| 121 |
+
"type": "date",
|
| 122 |
+
"source": "odoo",
|
| 123 |
+
"default": false,
|
| 124 |
+
"description": "When this customer was first set up in Odoo."
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"key": "tags",
|
| 128 |
+
"label": "Tags",
|
| 129 |
+
"type": "multiselect",
|
| 130 |
+
"source": "odoo",
|
| 131 |
+
"default": false,
|
| 132 |
+
"description": "Odoo labels on this customer, comma-separated."
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
"key": "pricelist",
|
| 136 |
+
"label": "Customer price list",
|
| 137 |
+
"type": "select",
|
| 138 |
+
"source": "odoo",
|
| 139 |
+
"default": false,
|
| 140 |
+
"description": "The price list this customer buys on."
|
| 141 |
+
},
|
| 142 |
+
{
|
| 143 |
+
"key": "payment_terms",
|
| 144 |
+
"label": "Payment terms",
|
| 145 |
+
"type": "select",
|
| 146 |
+
"source": "odoo",
|
| 147 |
+
"default": false,
|
| 148 |
+
"description": "Payment terms on this customer's account β Net 30, for example."
|
| 149 |
+
},
|
| 150 |
+
{
|
| 151 |
+
"key": "last_order",
|
| 152 |
+
"label": "Last order",
|
| 153 |
+
"type": "date",
|
| 154 |
+
"source": "odoo",
|
| 155 |
+
"default": true,
|
| 156 |
+
"description": "Date of the most recent confirmed order."
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"key": "overdue_days",
|
| 160 |
+
"label": "Overdue days",
|
| 161 |
+
"type": "int",
|
| 162 |
+
"source": "odoo",
|
| 163 |
+
"default": true,
|
| 164 |
+
"description": "How many days late this customer is running against their own usual ordering rhythm."
|
| 165 |
+
},
|
| 166 |
+
{
|
| 167 |
+
"_note": "filterable:false β DERIVED ANALYTIC: est_missed is min(cycles missed, 3) x AOV, a score we compute rather than an object the business has, so a condition on it would read as a fact about the customer when it is a fact about our arithmetic. It still displays and still sorts. Until wave 6 this flag also covered the frozen-window presets (revenue_ytd / revenue_ly / orders_24m / aov / yoy_pct); those are now DELETED outright under the owner's no-buildable-presets rule β see _comment. est_missed itself STAYS: no creatable measure or formula reproduces the cadence model behind it.",
|
| 168 |
+
"key": "est_missed",
|
| 169 |
+
"label": "Est. missed $",
|
| 170 |
+
"type": "currency",
|
| 171 |
+
"source": "odoo",
|
| 172 |
+
"default": true,
|
| 173 |
+
"agg": "sum",
|
| 174 |
+
"filterable": false,
|
| 175 |
+
"description": "Estimated sales missed while quiet: missed orders (capped at 3) times average order value. An estimate, not money owed."
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"_note": "wave 21 R1 β KEY UNCHANGED, LABEL RENAMED. The computation is a DISJOINT split (ar.py credit_exposure): this column is only the not-yet-due residual, its sibling is the past-grace residual, and the two sum to the total. Under the label 'AR open $' the majority-late book read as 'Overdue > Open', which is nonsense in AR vocabulary β 'open' universally means the total. The label now says what the number is; the key stays so saved views and filters keep working.",
|
| 179 |
+
"key": "ar_open",
|
| 180 |
+
"label": "AR current $",
|
| 181 |
+
"type": "currency",
|
| 182 |
+
"source": "odoo",
|
| 183 |
+
"default": false,
|
| 184 |
+
"description": "Invoiced money owed but not yet due (a 5-day grace applies before it counts as overdue)."
|
| 185 |
+
},
|
| 186 |
+
{
|
| 187 |
+
"key": "ar_overdue",
|
| 188 |
+
"label": "AR overdue $",
|
| 189 |
+
"type": "currency",
|
| 190 |
+
"source": "odoo",
|
| 191 |
+
"default": false,
|
| 192 |
+
"description": "Invoiced money past due β same basis as the Collections page."
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
"_note": "wave 21 R1 β the TOTAL, added beside the rename above. AR current $ + AR overdue $, i.e. what most people mean by 'open AR'. Composed from the same ar.credit_exposure rows the siblings use, so it is transitively reconciled by ar.validate()'s residual read_group tie β no second oracle.",
|
| 196 |
+
"key": "ar_outstanding",
|
| 197 |
+
"label": "AR outstanding $",
|
| 198 |
+
"type": "currency",
|
| 199 |
+
"source": "odoo",
|
| 200 |
+
"default": false,
|
| 201 |
+
"description": "Total invoiced money owed right now: AR current $ plus AR overdue $."
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
"key": "ar_exposure",
|
| 205 |
+
"label": "Credit exposure $",
|
| 206 |
+
"type": "currency",
|
| 207 |
+
"source": "odoo",
|
| 208 |
+
"default": false,
|
| 209 |
+
"description": "The most you could be out if they stopped paying today: open, overdue, draft and not-yet-invoiced."
|
| 210 |
+
},
|
| 211 |
+
{
|
| 212 |
+
"key": "ar_aged_1_30",
|
| 213 |
+
"label": "1-30 days $",
|
| 214 |
+
"type": "currency",
|
| 215 |
+
"source": "odoo",
|
| 216 |
+
"default": false,
|
| 217 |
+
"description": "Overdue between 1 and 30 days. The four aging buckets sum to AR overdue $."
|
| 218 |
+
},
|
| 219 |
+
{
|
| 220 |
+
"key": "ar_aged_31_60",
|
| 221 |
+
"label": "31-60 days $",
|
| 222 |
+
"type": "currency",
|
| 223 |
+
"source": "odoo",
|
| 224 |
+
"default": false,
|
| 225 |
+
"description": "Overdue between 31 and 60 days. The four aging buckets sum to AR overdue $."
|
| 226 |
+
},
|
| 227 |
+
{
|
| 228 |
+
"key": "ar_aged_61_90",
|
| 229 |
+
"label": "61-90 days $",
|
| 230 |
+
"type": "currency",
|
| 231 |
+
"source": "odoo",
|
| 232 |
+
"default": false,
|
| 233 |
+
"description": "Overdue between 61 and 90 days. The four aging buckets sum to AR overdue $."
|
| 234 |
+
},
|
| 235 |
+
{
|
| 236 |
+
"key": "ar_aged_90_plus",
|
| 237 |
+
"label": "90+ days $",
|
| 238 |
+
"type": "currency",
|
| 239 |
+
"source": "odoo",
|
| 240 |
+
"default": false,
|
| 241 |
+
"description": "Overdue by more than 90 days. The four aging buckets sum to AR overdue $."
|
| 242 |
+
},
|
| 243 |
+
{
|
| 244 |
+
"key": "days_to_pay",
|
| 245 |
+
"label": "Days to pay",
|
| 246 |
+
"type": "int",
|
| 247 |
+
"source": "odoo",
|
| 248 |
+
"default": false,
|
| 249 |
+
"description": "Average days to pay an invoice in full. Blank means no fully paid invoice yet."
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"key": "top_category",
|
| 253 |
+
"label": "Top category",
|
| 254 |
+
"type": "select",
|
| 255 |
+
"source": "odoo",
|
| 256 |
+
"default": false,
|
| 257 |
+
"description": "The category this customer spent the most on in the last 12 months."
|
| 258 |
+
},
|
| 259 |
+
{
|
| 260 |
+
"key": "top_category_pct",
|
| 261 |
+
"label": "Top category %",
|
| 262 |
+
"type": "pct",
|
| 263 |
+
"source": "odoo",
|
| 264 |
+
"default": false,
|
| 265 |
+
"description": "Share of last-12-months spend that went to the top category."
|
| 266 |
+
},
|
| 267 |
+
{
|
| 268 |
+
"key": "sku_count",
|
| 269 |
+
"label": "SKUs bought",
|
| 270 |
+
"type": "int",
|
| 271 |
+
"source": "odoo",
|
| 272 |
+
"default": false,
|
| 273 |
+
"description": "Distinct products bought in the last 12 months."
|
| 274 |
+
},
|
| 275 |
+
{
|
| 276 |
+
"key": "top_sku",
|
| 277 |
+
"label": "Top SKU",
|
| 278 |
+
"type": "text",
|
| 279 |
+
"source": "odoo",
|
| 280 |
+
"default": false,
|
| 281 |
+
"description": "The product this customer spent the most on in the last 12 months."
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"key": "days_since",
|
| 285 |
+
"label": "Days since order",
|
| 286 |
+
"type": "int",
|
| 287 |
+
"source": "odoo",
|
| 288 |
+
"default": false,
|
| 289 |
+
"description": "Days since the last confirmed order."
|
| 290 |
+
},
|
| 291 |
+
{
|
| 292 |
+
"key": "typical_gap_days",
|
| 293 |
+
"label": "Typical gap days",
|
| 294 |
+
"type": "int",
|
| 295 |
+
"source": "odoo",
|
| 296 |
+
"default": false,
|
| 297 |
+
"description": "Days this customer usually goes between orders, from their own history."
|
| 298 |
+
},
|
| 299 |
+
{
|
| 300 |
+
"key": "created_on",
|
| 301 |
+
"label": "Record created",
|
| 302 |
+
"type": "date",
|
| 303 |
+
"source": "odoo",
|
| 304 |
+
"default": false,
|
| 305 |
+
"description": "The date this customer record was created in Odoo."
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
"key": "notes",
|
| 309 |
+
"label": "Notes",
|
| 310 |
+
"type": "text",
|
| 311 |
+
"source": "overlay",
|
| 312 |
+
"default": false,
|
| 313 |
+
"description": "Your notes on this customer. Saved in this app only, visible only to you."
|
| 314 |
+
}
|
| 315 |
+
],
|
| 316 |
+
"rows": [
|
| 317 |
+
{
|
| 318 |
+
"pid": 101,
|
| 319 |
+
"customer": "Poppy Flowers",
|
| 320 |
+
"status": "New",
|
| 321 |
+
"agent": "Naomi Linnell Rivera",
|
| 322 |
+
"city": "Charlottesville",
|
| 323 |
+
"state": "Virginia (US)",
|
| 324 |
+
"last_order": "2026-07-21",
|
| 325 |
+
"est_missed": 0,
|
| 326 |
+
"notes": "",
|
| 327 |
+
"country": "United States",
|
| 328 |
+
"zip": "02720",
|
| 329 |
+
"payment_terms": "30 Days",
|
| 330 |
+
"pricelist": "Fisch 1 (USD)",
|
| 331 |
+
"tags": "Royal",
|
| 332 |
+
"customer_since": "2023-01-10",
|
| 333 |
+
"salesperson": "Jessica",
|
| 334 |
+
"ar_open": 0,
|
| 335 |
+
"ar_overdue": 0,
|
| 336 |
+
"ar_exposure": 0,
|
| 337 |
+
"top_category": "Styrofoam",
|
| 338 |
+
"top_category_pct": 0.47,
|
| 339 |
+
"sku_count": 93,
|
| 340 |
+
"top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
|
| 341 |
+
"days_to_pay": 34,
|
| 342 |
+
"_created": "2023-01-15 09:10:00",
|
| 343 |
+
"lat": 25.7617,
|
| 344 |
+
"lon": -80.1918,
|
| 345 |
+
"odoo_status": "Archived",
|
| 346 |
+
"dba": "Royal",
|
| 347 |
+
"ar_outstanding": 0
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"pid": 102,
|
| 351 |
+
"customer": "Meadow & Vine Wholesale",
|
| 352 |
+
"status": "Growing",
|
| 353 |
+
"agent": "Carla Jimenez",
|
| 354 |
+
"city": "Portland",
|
| 355 |
+
"state": "Oregon (US)",
|
| 356 |
+
"last_order": "2026-07-19",
|
| 357 |
+
"est_missed": 0,
|
| 358 |
+
"notes": "Expanding to a second storefront.",
|
| 359 |
+
"country": "United States",
|
| 360 |
+
"zip": "77041",
|
| 361 |
+
"payment_terms": "Immediate Payment",
|
| 362 |
+
"pricelist": "Royal 1 (USD)",
|
| 363 |
+
"tags": "Royal, Key account",
|
| 364 |
+
"customer_since": "2024-02-11",
|
| 365 |
+
"salesperson": "Naomi",
|
| 366 |
+
"ar_open": 1240.5,
|
| 367 |
+
"ar_overdue": 0,
|
| 368 |
+
"ar_exposure": 1740.5,
|
| 369 |
+
"top_category": "Ribbon",
|
| 370 |
+
"top_category_pct": 0.95,
|
| 371 |
+
"sku_count": 4,
|
| 372 |
+
"top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
|
| 373 |
+
"days_to_pay": null,
|
| 374 |
+
"_created": "2023-02-15 09:11:00",
|
| 375 |
+
"lat": 27.9506,
|
| 376 |
+
"lon": -82.4572,
|
| 377 |
+
"odoo_status": "Active",
|
| 378 |
+
"dba": "Fisch",
|
| 379 |
+
"ar_outstanding": 1240.5
|
| 380 |
+
},
|
| 381 |
+
{
|
| 382 |
+
"pid": 103,
|
| 383 |
+
"customer": "Bluestem Floral Supply",
|
| 384 |
+
"status": "Growing",
|
| 385 |
+
"agent": "Naomi Linnell Rivera",
|
| 386 |
+
"city": "Kansas City",
|
| 387 |
+
"state": "Missouri (US)",
|
| 388 |
+
"last_order": "2026-07-17",
|
| 389 |
+
"est_missed": 0,
|
| 390 |
+
"notes": "",
|
| 391 |
+
"country": "United States",
|
| 392 |
+
"zip": "11219",
|
| 393 |
+
"payment_terms": "60 Days",
|
| 394 |
+
"pricelist": "Royal 1 (USD)",
|
| 395 |
+
"tags": "Fisch",
|
| 396 |
+
"customer_since": "2025-03-12",
|
| 397 |
+
"salesperson": "Karen",
|
| 398 |
+
"ar_open": 0,
|
| 399 |
+
"ar_overdue": 5120.25,
|
| 400 |
+
"ar_exposure": 5120.25,
|
| 401 |
+
"top_category": "Foams & Finishes",
|
| 402 |
+
"top_category_pct": 0.31,
|
| 403 |
+
"sku_count": 27,
|
| 404 |
+
"top_sku": "SATIN RIBBON 2IN",
|
| 405 |
+
"days_to_pay": 61,
|
| 406 |
+
"_created": "2023-03-15 09:12:00",
|
| 407 |
+
"lat": 28.5384,
|
| 408 |
+
"lon": -81.3789,
|
| 409 |
+
"odoo_status": "Active",
|
| 410 |
+
"dba": "Both",
|
| 411 |
+
"ar_outstanding": 5120.25
|
| 412 |
+
},
|
| 413 |
+
{
|
| 414 |
+
"pid": 104,
|
| 415 |
+
"customer": "Camellia Row Florist",
|
| 416 |
+
"status": "Declining",
|
| 417 |
+
"agent": "Devon Marsh",
|
| 418 |
+
"city": "Savannah",
|
| 419 |
+
"state": "Georgia (US)",
|
| 420 |
+
"last_order": "2026-05-30",
|
| 421 |
+
"est_missed": 41200,
|
| 422 |
+
"notes": "Switched some volume to a local grower.",
|
| 423 |
+
"country": "United States",
|
| 424 |
+
"zip": "07649",
|
| 425 |
+
"payment_terms": "30 Days",
|
| 426 |
+
"pricelist": "Fisch 1 (USD)",
|
| 427 |
+
"tags": "(none)",
|
| 428 |
+
"customer_since": "2026-04-13",
|
| 429 |
+
"salesperson": "(none)",
|
| 430 |
+
"ar_open": 8300,
|
| 431 |
+
"ar_overdue": 940,
|
| 432 |
+
"ar_exposure": 11440,
|
| 433 |
+
"top_category": "All",
|
| 434 |
+
"top_category_pct": 1.0,
|
| 435 |
+
"sku_count": 1,
|
| 436 |
+
"top_sku": "(none)",
|
| 437 |
+
"days_to_pay": 12,
|
| 438 |
+
"_created": "2023-04-15 09:13:00",
|
| 439 |
+
"lat": 30.3322,
|
| 440 |
+
"lon": -81.6557,
|
| 441 |
+
"odoo_status": "Active",
|
| 442 |
+
"dba": "Royal",
|
| 443 |
+
"ar_outstanding": 9240
|
| 444 |
+
},
|
| 445 |
+
{
|
| 446 |
+
"pid": 105,
|
| 447 |
+
"customer": "Harborlight Wholesale Blooms",
|
| 448 |
+
"status": "Growing",
|
| 449 |
+
"agent": "Carla Jimenez",
|
| 450 |
+
"city": "Seattle",
|
| 451 |
+
"state": "Washington (US)",
|
| 452 |
+
"last_order": "2026-07-22",
|
| 453 |
+
"est_missed": 0,
|
| 454 |
+
"notes": "Top-10 account.",
|
| 455 |
+
"country": "United States",
|
| 456 |
+
"zip": "33125",
|
| 457 |
+
"payment_terms": "Immediate Payment",
|
| 458 |
+
"pricelist": "Royal 1 (USD)",
|
| 459 |
+
"tags": "Royal",
|
| 460 |
+
"customer_since": "2023-05-14",
|
| 461 |
+
"salesperson": "Jessica",
|
| 462 |
+
"ar_open": 0,
|
| 463 |
+
"ar_overdue": 0,
|
| 464 |
+
"ar_exposure": 0,
|
| 465 |
+
"top_category": "Styrofoam",
|
| 466 |
+
"top_category_pct": 0.47,
|
| 467 |
+
"sku_count": 93,
|
| 468 |
+
"top_sku": "AQUAFOAM FLORAL FOAM BRICK | 48-Piece per Pack",
|
| 469 |
+
"days_to_pay": 34,
|
| 470 |
+
"_created": "2023-05-15 09:14:00",
|
| 471 |
+
"lat": 26.1224,
|
| 472 |
+
"lon": -80.1373,
|
| 473 |
+
"odoo_status": "Active",
|
| 474 |
+
"dba": "",
|
| 475 |
+
"ar_outstanding": 0
|
| 476 |
+
},
|
| 477 |
+
{
|
| 478 |
+
"pid": 106,
|
| 479 |
+
"customer": "Dogwood & Fern Co.",
|
| 480 |
+
"status": "Dormant",
|
| 481 |
+
"agent": "Devon Marsh",
|
| 482 |
+
"city": "Asheville",
|
| 483 |
+
"state": "North Carolina (US)",
|
| 484 |
+
"last_order": "2026-02-11",
|
| 485 |
+
"est_missed": 52400,
|
| 486 |
+
"notes": "No spring order this year.",
|
| 487 |
+
"country": "United States",
|
| 488 |
+
"zip": "90210",
|
| 489 |
+
"payment_terms": "60 Days",
|
| 490 |
+
"pricelist": "Royal 1 (USD)",
|
| 491 |
+
"tags": "Royal, Key account",
|
| 492 |
+
"customer_since": "2024-06-15",
|
| 493 |
+
"salesperson": "Naomi",
|
| 494 |
+
"ar_open": 1240.5,
|
| 495 |
+
"ar_overdue": 0,
|
| 496 |
+
"ar_exposure": 1740.5,
|
| 497 |
+
"top_category": "Ribbon",
|
| 498 |
+
"top_category_pct": 0.95,
|
| 499 |
+
"sku_count": 4,
|
| 500 |
+
"top_sku": "2\" X 24\" X 36\" GREEN STYROFOAM BOARD",
|
| 501 |
+
"days_to_pay": null,
|
| 502 |
+
"_created": "2023-06-15 09:15:00",
|
| 503 |
+
"lat": 27.3364,
|
| 504 |
+
"lon": -82.5307,
|
| 505 |
+
"odoo_status": "Active",
|
| 506 |
+
"dba": "Fisch",
|
| 507 |
+
"ar_outstanding": 1240.5
|
| 508 |
+
},
|
| 509 |
+
{
|
| 510 |
+
"pid": 107,
|
| 511 |
+
"customer": "Verbena Market Florals",
|
| 512 |
+
"status": "Lost",
|
| 513 |
+
"agent": "Naomi Linnell Rivera",
|
| 514 |
+
"city": "Austin",
|
| 515 |
+
"state": "Texas (US)",
|
| 516 |
+
"last_order": "2025-11-04",
|
| 517 |
+
"est_missed": 78300,
|
| 518 |
+
"notes": "Went with a competitor on freight terms.",
|
| 519 |
+
"country": "United States",
|
| 520 |
+
"zip": "08701",
|
| 521 |
+
"payment_terms": "30 Days",
|
| 522 |
+
"pricelist": "Fisch 1 (USD)",
|
| 523 |
+
"tags": "Fisch",
|
| 524 |
+
"customer_since": "2025-07-16",
|
| 525 |
+
"salesperson": "Karen",
|
| 526 |
+
"ar_open": 0,
|
| 527 |
+
"ar_overdue": 5120.25,
|
| 528 |
+
"ar_exposure": 5120.25,
|
| 529 |
+
"top_category": "Foams & Finishes",
|
| 530 |
+
"top_category_pct": 0.31,
|
| 531 |
+
"sku_count": 27,
|
| 532 |
+
"top_sku": "SATIN RIBBON 2IN",
|
| 533 |
+
"days_to_pay": 61,
|
| 534 |
+
"_created": "2023-07-15 09:16:00",
|
| 535 |
+
"lat": null,
|
| 536 |
+
"lon": null,
|
| 537 |
+
"odoo_status": "Active",
|
| 538 |
+
"dba": "Both",
|
| 539 |
+
"ar_outstanding": 5120.25
|
| 540 |
+
},
|
| 541 |
+
{
|
| 542 |
+
"pid": 108,
|
| 543 |
+
"customer": "Larkspur Lane Supply",
|
| 544 |
+
"status": "New",
|
| 545 |
+
"agent": "Carla Jimenez",
|
| 546 |
+
"city": "Denver",
|
| 547 |
+
"state": "Colorado (US)",
|
| 548 |
+
"last_order": "2026-07-14",
|
| 549 |
+
"est_missed": 0,
|
| 550 |
+
"notes": "First order in April.",
|
| 551 |
+
"country": "United States",
|
| 552 |
+
"zip": "60614",
|
| 553 |
+
"payment_terms": "Immediate Payment",
|
| 554 |
+
"pricelist": "Royal 1 (USD)",
|
| 555 |
+
"tags": "(none)",
|
| 556 |
+
"customer_since": "2026-08-17",
|
| 557 |
+
"salesperson": "(none)",
|
| 558 |
+
"ar_open": 8300,
|
| 559 |
+
"ar_overdue": 940,
|
| 560 |
+
"ar_exposure": 11440,
|
| 561 |
+
"top_category": "All",
|
| 562 |
+
"top_category_pct": 1.0,
|
| 563 |
+
"sku_count": 1,
|
| 564 |
+
"top_sku": "(none)",
|
| 565 |
+
"days_to_pay": 12,
|
| 566 |
+
"_created": "2023-08-15 09:17:00",
|
| 567 |
+
"lat": 33.749,
|
| 568 |
+
"lon": -84.388,
|
| 569 |
+
"odoo_status": "Active",
|
| 570 |
+
"dba": "Royal",
|
| 571 |
+
"ar_outstanding": 9240
|
| 572 |
+
}
|
| 573 |
+
]
|
| 574 |
+
}
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { useMemo, useRef, useState } from "react";
|
| 2 |
// β W36-T07 (owner item 10) β the field agent. Its own module for `ColumnMenu`'s own reason:
|
| 3 |
// this file pulls the whole grid and cannot load under a node gate, so the chat had to sit
|
| 4 |
// where a test can render it.
|
|
@@ -8,8 +8,16 @@ import { AnchoredOverlay } from "./OverlaySurface";
|
|
| 8 |
import type { AnchorRect } from "./OverlaySurface";
|
| 9 |
import { FieldTypeIcon, MenuLabel } from "./icons";
|
| 10 |
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, mayEditFieldDefinition } from "./types";
|
| 15 |
import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
|
|
@@ -18,7 +26,7 @@ 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,
|
|
@@ -135,6 +143,16 @@ interface ColumnMenuProps {
|
|
| 135 |
locked: boolean;
|
| 136 |
/** Product-owned preset fields may still drive view actions, but their schema is immutable. */
|
| 137 |
schemaLocked?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
/** Wave-5 item 1 β who is looking. Gates the permissions entry (creator-or-admin). */
|
| 139 |
viewer?: Viewer;
|
| 140 |
/** Wave-5 item 3 β what the CURRENT VIEW does with this field, so the conditional
|
|
@@ -879,8 +897,18 @@ type CreateKind = FieldType | "measure" | "geocode";
|
|
| 879 |
* of them end up disagreeing about what is offered.
|
| 880 |
* β NO DASH of any kind in the label β it reaches the screen, and `web_prose` cannot see a
|
| 881 |
* wrapped JSX sentence, so this one is on the author.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
*/
|
| 883 |
-
const GEOCODE_LABEL = "Coordinates
|
| 884 |
const GEOCODE_ADDRESS_TYPES: readonly FieldType[] = ["text", "url", "email", "phone"];
|
| 885 |
|
| 886 |
/**
|
|
@@ -941,6 +969,11 @@ function TypePicker({
|
|
| 941 |
? [{ value: "geocode" as CreateKind, label: GEOCODE_LABEL }]
|
| 942 |
: []),
|
| 943 |
].filter((r) => !needle || r.label.toLowerCase().includes(needle));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 944 |
return (
|
| 945 |
<div className="cg-type-picker">
|
| 946 |
<div className="cg-type-search">
|
|
@@ -962,44 +995,64 @@ function TypePicker({
|
|
| 962 |
/>
|
| 963 |
</div>
|
| 964 |
<div className="cg-type-list" role="listbox" aria-label="Field type">
|
| 965 |
-
{
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
aria-hidden="true"
|
| 992 |
>
|
| 993 |
-
|
| 994 |
-
|
| 995 |
-
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
|
|
|
|
|
|
| 999 |
/>
|
| 1000 |
-
|
| 1001 |
-
|
| 1002 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1003 |
))}
|
| 1004 |
{rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
|
| 1005 |
</div>
|
|
@@ -2000,6 +2053,7 @@ export default function ColumnMenu({
|
|
| 2000 |
geocodable,
|
| 2001 |
locked,
|
| 2002 |
schemaLocked = false,
|
|
|
|
| 2003 |
viewer,
|
| 2004 |
sortedDir,
|
| 2005 |
isFiltered,
|
|
@@ -2038,6 +2092,18 @@ export default function ColumnMenu({
|
|
| 2038 |
measures = [],
|
| 2039 |
tableKey,
|
| 2040 |
}: ColumnMenuProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2041 |
const canEditDefinition = !schemaLocked && mayEditFieldDefinition(field, viewer);
|
| 2042 |
const [pane, setPane] = useState<MenuPane>(
|
| 2043 |
schemaLocked || (initialPane === "edit" && !canEditDefinition)
|
|
@@ -2600,6 +2666,23 @@ export default function ColumnMenu({
|
|
| 2600 |
*/
|
| 2601 |
const editingRelational =
|
| 2602 |
!!onFieldConfig && (field.type === "rollup" || field.type === "link");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2603 |
const editRollupValid =
|
| 2604 |
field.type !== "rollup" ||
|
| 2605 |
(editRollup.source
|
|
@@ -2729,10 +2812,15 @@ export default function ColumnMenu({
|
|
| 2729 |
* the overlay stratum and user-created columns. Hidden fields included in both: "change this
|
| 2730 |
* to Notes" is the whole point, and swapping BACK to a hidden custom field is the documented
|
| 2731 |
* restore path. The locked primary column cannot be swapped.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2732 |
*/
|
| 2733 |
const swapOptions = fields.filter((f) => f.key !== field.key);
|
| 2734 |
const presetSwap = swapOptions.filter((f) => f.source === "odoo" && !f.custom);
|
| 2735 |
const yourSwap = swapOptions.filter((f) => !(f.source === "odoo" && !f.custom));
|
|
|
|
| 2736 |
|
| 2737 |
// Item 8c β the period draft is valid when it normalizes and differs from what the field
|
| 2738 |
// already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
|
|
@@ -3099,7 +3187,7 @@ export default function ColumnMenu({
|
|
| 3099 |
>
|
| 3100 |
<PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
|
| 3101 |
<div className="cg-column-create cg-field-edit">
|
| 3102 |
-
{
|
| 3103 |
<label>
|
| 3104 |
<span>Name</span>
|
| 3105 |
<input
|
|
@@ -3132,9 +3220,19 @@ export default function ColumnMenu({
|
|
| 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
|
|
@@ -3441,8 +3539,18 @@ export default function ColumnMenu({
|
|
| 3441 |
Back
|
| 3442 |
</button>
|
| 3443 |
</div>
|
| 3444 |
-
{/* -------- Change field (owner item 9, folded in here by owner item 8) --------
|
| 3445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3446 |
<div className="cg-edit-swap">
|
| 3447 |
<span className="cg-type-title">Change field</span>
|
| 3448 |
<div className="cg-field-hint">
|
|
@@ -3459,8 +3567,10 @@ export default function ColumnMenu({
|
|
| 3459 |
ariaLabel="Change this column to another field"
|
| 3460 |
placeholder="Choose a fieldβ¦"
|
| 3461 |
// Focus falls here when the pane has no editable Name (a read-only source
|
| 3462 |
-
// field): the swap picker is then the pane's first live control.
|
| 3463 |
-
|
|
|
|
|
|
|
| 3464 |
value={swapTo || undefined}
|
| 3465 |
onChange={setSwapTo}
|
| 3466 |
fields={[
|
|
@@ -3880,21 +3990,37 @@ export default function ColumnMenu({
|
|
| 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).
|
| 3884 |
-
|
| 3885 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3886 |
<MenuLabel
|
| 3887 |
icon={
|
| 3888 |
-
<svg width={16} height={16} viewBox="0 0 16 16"
|
| 3889 |
-
|
| 3890 |
-
|
| 3891 |
-
|
| 3892 |
-
|
| 3893 |
-
|
| 3894 |
-
|
| 3895 |
-
|
| 3896 |
-
|
| 3897 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3898 |
</svg>
|
| 3899 |
}
|
| 3900 |
text="Share field"
|
|
@@ -4051,7 +4177,18 @@ export default function ColumnMenu({
|
|
| 4051 |
</button>
|
| 4052 |
</>
|
| 4053 |
)}
|
| 4054 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4055 |
<button
|
| 4056 |
type="button"
|
| 4057 |
className="is-danger"
|
|
|
|
| 1 |
+
import { Fragment, useMemo, useRef, useState } from "react";
|
| 2 |
// β W36-T07 (owner item 10) β the field agent. Its own module for `ColumnMenu`'s own reason:
|
| 3 |
// this file pulls the whole grid and cannot load under a node gate, so the chat had to sit
|
| 4 |
// where a test can render it.
|
|
|
|
| 8 |
import type { AnchorRect } from "./OverlaySurface";
|
| 9 |
import { FieldTypeIcon, MenuLabel } from "./icons";
|
| 10 |
import { FieldSelectButton } from "./FieldSelect";
|
| 11 |
+
// ββ W40-T24 β the pre-set definition lock. It lives in `display.ts` rather than here because a
|
| 12 |
+
// rule stated inside JSX cannot be LOADED by a node gate; `display.ts`'s header carries the full
|
| 13 |
+
// argument, and `_test/gridUx.test.ts` is what runs it.
|
| 14 |
+
import { columnDefinitionOffer } from "./display";
|
| 15 |
+
// ββ W40-T29 β the ADVANCED grouping is a pure function in `types.ts` for the reason
|
| 16 |
+
// `columnDefinitionOffer` sits in `display.ts`: this file imports React, so nothing stated inside
|
| 17 |
+
// it can be LOADED by a node gate, and "which types are advanced" is exactly the kind of decision
|
| 18 |
+
// that must be runnable rather than read back to itself as source text.
|
| 19 |
import { CODE_LANGUAGE_LABELS, CODE_LANGUAGES, CREATABLE_TYPES, choiceOptions, choiceRenames,
|
| 20 |
+
codeLanguageOf, directionLabel, groupTypePickerRows, isDerivedLink, isMachineOwned,
|
| 21 |
isProfileField, parseOptions, ratingMax, ROLLUP_FN_LABELS, ROLLUP_FNS,
|
| 22 |
ROLLUP_REF_OPS, mayEditFieldDefinition } from "./types";
|
| 23 |
import type { AggName, Field, FieldFormat, FieldScope, FieldType, Measure, RollupCondition, RollupFn,
|
|
|
|
| 26 |
import type { WindowSpec } from "./windows";
|
| 27 |
import { normalizeWindow, windowLabel } from "./windows";
|
| 28 |
import { WindowPicker } from "../filter-kit";
|
| 29 |
+
import { SHARE_SHAPE, TYPE_LABELS } from "./iconShapes";
|
| 30 |
import { AGG_LABELS, aggOptions } from "./aggregations";
|
| 31 |
import { validateFormula } from "./formulaEngine";
|
| 32 |
import { BRAND_SWATCHES, defaultOptionColor, nearestSwatch, normalizeOptionColor,
|
|
|
|
| 143 |
locked: boolean;
|
| 144 |
/** Product-owned preset fields may still drive view actions, but their schema is immutable. */
|
| 145 |
schemaLocked?: boolean;
|
| 146 |
+
/**
|
| 147 |
+
* ββ W40-T24 β the grid is a `ut_*` runtime database rather than an Odoo-backed topic. It
|
| 148 |
+
* feeds `columnDefinitionOffer` (display.ts), which is this menu's ONE statement of the pre-set
|
| 149 |
+
* definition lock, and EVERY verdict reads it: a shared-definition column on a user database is
|
| 150 |
+
* the user's to reshape, and the identical shape on an Odoo-backed topic is not.
|
| 151 |
+
* β OPTIONAL and false by default only for the "+" create instance, which has no real subject
|
| 152 |
+
* column β it passes `locked`, so its verdicts are the closed ones either way. A real column
|
| 153 |
+
* menu must pass it; defaulting is a convenience for the create door, not a shrug.
|
| 154 |
+
*/
|
| 155 |
+
isUserTable?: boolean;
|
| 156 |
/** Wave-5 item 1 β who is looking. Gates the permissions entry (creator-or-admin). */
|
| 157 |
viewer?: Viewer;
|
| 158 |
/** Wave-5 item 3 β what the CURRENT VIEW does with this field, so the conditional
|
|
|
|
| 897 |
* of them end up disagreeing about what is offered.
|
| 898 |
* β NO DASH of any kind in the label β it reaches the screen, and `web_prose` cannot see a
|
| 899 |
* wrapped JSX sentence, so this one is on the author.
|
| 900 |
+
*
|
| 901 |
+
* ββ W40-T29 (owner instruction 31) β SHORTENED TO ONE NOUN. The label used to carry a trailing
|
| 902 |
+
* clause naming where the numbers come from, which is the IMPLEMENTATION read out at someone
|
| 903 |
+
* scanning a list of nouns; every other row in that list is the thing you get, not how it is got,
|
| 904 |
+
* and the source column is picked one control later anyway. The old wording is not repeated here:
|
| 905 |
+
* a rename that leaves its own before-picture in a doc block is how the next reader reinstates it,
|
| 906 |
+
* and `_test/gridUx.test.ts` asserts the whole file is free of it.
|
| 907 |
+
* β THE IDENTIFIER STAYS. One producer, one consumer, and `verify_grid_ux.py`'s geocode-door scan
|
| 908 |
+
* matches `label: GEOCODE_LABEL` BY NAME, so inlining the string here would blind the gate rather
|
| 909 |
+
* than satisfy it.
|
| 910 |
*/
|
| 911 |
+
const GEOCODE_LABEL = "Coordinates";
|
| 912 |
const GEOCODE_ADDRESS_TYPES: readonly FieldType[] = ["text", "url", "email", "phone"];
|
| 913 |
|
| 914 |
/**
|
|
|
|
| 969 |
? [{ value: "geocode" as CreateKind, label: GEOCODE_LABEL }]
|
| 970 |
: []),
|
| 971 |
].filter((r) => !needle || r.label.toLowerCase().includes(needle));
|
| 972 |
+
// ββ W40-T29 (owner instruction 31) β the list stopped being a wall of 22 nouns here, and the
|
| 973 |
+
// grouping is applied to the ALREADY-FILTERED rows on purpose: one find box still reaches both
|
| 974 |
+
// groups, and a group the needle empties is dropped by `groupTypePickerRows` heading and all
|
| 975 |
+
// rather than leaving a label with nothing behind it (R8).
|
| 976 |
+
const groups = groupTypePickerRows(rows);
|
| 977 |
return (
|
| 978 |
<div className="cg-type-picker">
|
| 979 |
<div className="cg-type-search">
|
|
|
|
| 995 |
/>
|
| 996 |
</div>
|
| 997 |
<div className="cg-type-list" role="listbox" aria-label="Field type">
|
| 998 |
+
{groups.map((group) => (
|
| 999 |
+
// β A FRAGMENT, NOT A WRAPPER, and the reason is an accessibility one rather than a
|
| 1000 |
+
// layout one. `.cg-type-list` is the flex column that lays the rows out, so a wrapping
|
| 1001 |
+
// div would need `display: contents` to stay out of the way β which DROPS the element
|
| 1002 |
+
// from the accessibility tree, inside the one container that is claiming to be a
|
| 1003 |
+
// listbox. `FieldSelect.tsx` reached this conclusion first for its own grouped list
|
| 1004 |
+
// ("Pre-set fields" / "Your fields" / "New field"); this follows it rather than
|
| 1005 |
+
// inventing a second answer to a question the codebase already settled.
|
| 1006 |
+
// β The heading is `role="presentation"` for the same reason: a listbox's children are
|
| 1007 |
+
// options, and a bare div claiming nothing is what keeps the heading out of the option
|
| 1008 |
+
// set while leaving it visible. Arrow-key navigation elsewhere collects
|
| 1009 |
+
// `button.cg-type-row`, so a non-button child is invisible to it by construction.
|
| 1010 |
+
<Fragment key={group.heading ?? "ordinary"}>
|
| 1011 |
+
{group.heading && (
|
| 1012 |
+
<div className="cg-type-group" role="presentation">
|
| 1013 |
+
{group.heading}
|
| 1014 |
+
</div>
|
| 1015 |
+
)}
|
| 1016 |
+
{group.rows.map((r) => (
|
| 1017 |
+
<button
|
| 1018 |
+
key={r.value}
|
| 1019 |
+
type="button"
|
| 1020 |
+
role="option"
|
| 1021 |
+
aria-selected={value === r.value}
|
| 1022 |
+
className={"cg-type-row" + (value === r.value ? " is-on" : "")}
|
| 1023 |
+
onClick={() => onPick(r.value)}
|
|
|
|
| 1024 |
>
|
| 1025 |
+
{/* Each row wears the mark its COLUMN will wear. A measure column's real type is
|
| 1026 |
+
currency/int/pct and a geocode column's is `text`, so both pseudo-kinds borrow
|
| 1027 |
+
rather than inventing a glyph no column ever shows (the iconShapes ruling). */}
|
| 1028 |
+
<FieldTypeIcon
|
| 1029 |
+
type={
|
| 1030 |
+
r.value === "measure" ? "currency" : r.value === "geocode" ? "text" : r.value
|
| 1031 |
+
}
|
| 1032 |
+
size={16}
|
| 1033 |
/>
|
| 1034 |
+
<span className="cg-type-label">{r.label}</span>
|
| 1035 |
+
{value === r.value && (
|
| 1036 |
+
<svg
|
| 1037 |
+
className="cg-type-check"
|
| 1038 |
+
width="14"
|
| 1039 |
+
height="14"
|
| 1040 |
+
viewBox="0 0 16 16"
|
| 1041 |
+
fill="none"
|
| 1042 |
+
aria-hidden="true"
|
| 1043 |
+
>
|
| 1044 |
+
<path
|
| 1045 |
+
d="m3.5 8.5 3 3 6-6.5"
|
| 1046 |
+
stroke="currentColor"
|
| 1047 |
+
strokeWidth="1.6"
|
| 1048 |
+
strokeLinecap="round"
|
| 1049 |
+
strokeLinejoin="round"
|
| 1050 |
+
/>
|
| 1051 |
+
</svg>
|
| 1052 |
+
)}
|
| 1053 |
+
</button>
|
| 1054 |
+
))}
|
| 1055 |
+
</Fragment>
|
| 1056 |
))}
|
| 1057 |
{rows.length === 0 && <div className="cg-pop-note">No matching type.</div>}
|
| 1058 |
</div>
|
|
|
|
| 2053 |
geocodable,
|
| 2054 |
locked,
|
| 2055 |
schemaLocked = false,
|
| 2056 |
+
isUserTable = false,
|
| 2057 |
viewer,
|
| 2058 |
sortedDir,
|
| 2059 |
isFiltered,
|
|
|
|
| 2092 |
measures = [],
|
| 2093 |
tableKey,
|
| 2094 |
}: ColumnMenuProps) {
|
| 2095 |
+
/**
|
| 2096 |
+
* ββ W40-T24 (owner instruction 18, contract C2) β the pre-set DEFINITION lock, read from the
|
| 2097 |
+
* one place that states it. `display.ts` carries the whole argument, including the measured
|
| 2098 |
+
* reason the lock is spelt `isUserSchemaField` and NOT `isPresetField` (nine product_data
|
| 2099 |
+
* contract columns, Supplier among them, are declared `source: "overlay"` so that their cells
|
| 2100 |
+
* stay typeable, and `isPresetField` answers false for every one).
|
| 2101 |
+
*
|
| 2102 |
+
* β ONLY `swap` AND `remove` ARE READ HERE, and that is the design, not an omission: `rename`
|
| 2103 |
+
* and `retype` are already enforced at the host by withholding their handlers, and a second
|
| 2104 |
+
* refusal in this file could only subtract from a door the host deliberately opened.
|
| 2105 |
+
*/
|
| 2106 |
+
const offer = columnDefinitionOffer(field, { isUserTable, isPrimary: locked });
|
| 2107 |
const canEditDefinition = !schemaLocked && mayEditFieldDefinition(field, viewer);
|
| 2108 |
const [pane, setPane] = useState<MenuPane>(
|
| 2109 |
schemaLocked || (initialPane === "edit" && !canEditDefinition)
|
|
|
|
| 2666 |
*/
|
| 2667 |
const editingRelational =
|
| 2668 |
!!onFieldConfig && (field.type === "rollup" || field.type === "link");
|
| 2669 |
+
/**
|
| 2670 |
+
* ββ W40-T24 β WHERE THE EDIT PANE'S OPENING FOCUS LANDS, decided ONCE.
|
| 2671 |
+
*
|
| 2672 |
+
* `AnchoredOverlay`'s `initialFocus` is the selector `[data-overlay-autofocus]` and it does
|
| 2673 |
+
* `querySelector(...)?.focus()` β a MISS is silent, and focus then stays on the grid header
|
| 2674 |
+
* button behind the overlay. That is what removing the swap picker from a pre-set column would
|
| 2675 |
+
* have caused: the picker carried `overlayAutofocus={!onRename}`, so on Supplier it WAS the
|
| 2676 |
+
* pane's focus target, and taking it away leaves a dialog nobody's keyboard is inside. The
|
| 2677 |
+
* defect would not show in a screenshot of the fix.
|
| 2678 |
+
*
|
| 2679 |
+
* Precedence, first match wins, exactly as `querySelector` reads the DOM: the editable Name box
|
| 2680 |
+
* Β· the swap picker Β· the Description box, which is the one control a pre-set column still owns.
|
| 2681 |
+
* β It also settles a latent double-marking: an `editingRelational` column with no `onRename`
|
| 2682 |
+
* put the attribute on the Name box AND the picker, and which one won was document order rather
|
| 2683 |
+
* than a decision.
|
| 2684 |
+
*/
|
| 2685 |
+
const nameIsFocusTarget = !!onRename || editingRelational;
|
| 2686 |
const editRollupValid =
|
| 2687 |
field.type !== "rollup" ||
|
| 2688 |
(editRollup.source
|
|
|
|
| 2812 |
* the overlay stratum and user-created columns. Hidden fields included in both: "change this
|
| 2813 |
* to Notes" is the whole point, and swapping BACK to a hidden custom field is the documented
|
| 2814 |
* restore path. The locked primary column cannot be swapped.
|
| 2815 |
+
*
|
| 2816 |
+
* ββ W40-T24 β NOR CAN A PRE-SET ONE, which is `offer.swap`. This list is what the picker
|
| 2817 |
+
* would contain; `swapOffered` is whether the picker exists at all, and it is read twice (the
|
| 2818 |
+
* control itself, and the focus fallback that has to know whether the control is there).
|
| 2819 |
*/
|
| 2820 |
const swapOptions = fields.filter((f) => f.key !== field.key);
|
| 2821 |
const presetSwap = swapOptions.filter((f) => f.source === "odoo" && !f.custom);
|
| 2822 |
const yourSwap = swapOptions.filter((f) => !(f.source === "odoo" && !f.custom));
|
| 2823 |
+
const swapOffered = offer.swap && swapOptions.length > 0;
|
| 2824 |
|
| 2825 |
// Item 8c β the period draft is valid when it normalizes and differs from what the field
|
| 2826 |
// already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
|
|
|
|
| 3187 |
>
|
| 3188 |
<PaneHead title="Edit field" sub={typeLine} onClose={onClose} />
|
| 3189 |
<div className="cg-column-create cg-field-edit">
|
| 3190 |
+
{nameIsFocusTarget ? (
|
| 3191 |
<label>
|
| 3192 |
<span>Name</span>
|
| 3193 |
<input
|
|
|
|
| 3220 |
Default description: {field.description}
|
| 3221 |
</span>
|
| 3222 |
)}
|
| 3223 |
+
{/* ββ W40-T24 β LAST IN THE FOCUS PRECEDENCE, and the only reason it is marked at
|
| 3224 |
+
all: on a pre-set column the Name box is disabled and the swap picker is now
|
| 3225 |
+
gone, so without this the pane would open with `[data-overlay-autofocus]` matching
|
| 3226 |
+
nothing and the keyboard left outside the dialog. The Description is the one
|
| 3227 |
+
control a pre-set column still owns, which makes it the honest landing spot as
|
| 3228 |
+
well as the available one. `undefined` REMOVES the attribute (React drops it), so
|
| 3229 |
+
exactly one element in the pane ever carries it. */}
|
| 3230 |
<textarea
|
| 3231 |
className="cg-input"
|
| 3232 |
rows={3}
|
| 3233 |
+
data-overlay-autofocus={
|
| 3234 |
+
!nameIsFocusTarget && !swapOffered ? true : undefined
|
| 3235 |
+
}
|
| 3236 |
value={note}
|
| 3237 |
placeholder={
|
| 3238 |
field.description
|
|
|
|
| 3539 |
Back
|
| 3540 |
</button>
|
| 3541 |
</div>
|
| 3542 |
+
{/* -------- Change field (owner item 9, folded in here by owner item 8) --------
|
| 3543 |
+
ββ W40-T24 (owner instruction 18, contract C2) β GATED ON THE PRE-SET LOCK, which
|
| 3544 |
+
it never was. The condition here used to be `!locked` alone, and `locked` means
|
| 3545 |
+
"is the pinned primary column" β so every contract column on every grid offered to
|
| 3546 |
+
be redefined into something else, which is the "choose a field dropdown that makes
|
| 3547 |
+
no sense" the owner reported about Supplier and was never only about Supplier.
|
| 3548 |
+
It is the SAME predicate that already withholds the Field-type picker and the
|
| 3549 |
+
editable Name box, which is what makes this pane say one thing about the column
|
| 3550 |
+
instead of three. `offer.swap` carries the `!locked` half (the primary column is the
|
| 3551 |
+
row identity and can never be swapped away); `swapOptions.length > 0` stays because
|
| 3552 |
+
an empty picker is its own fake affordance. */}
|
| 3553 |
+
{swapOffered && (
|
| 3554 |
<div className="cg-edit-swap">
|
| 3555 |
<span className="cg-type-title">Change field</span>
|
| 3556 |
<div className="cg-field-hint">
|
|
|
|
| 3567 |
ariaLabel="Change this column to another field"
|
| 3568 |
placeholder="Choose a fieldβ¦"
|
| 3569 |
// Focus falls here when the pane has no editable Name (a read-only source
|
| 3570 |
+
// field): the swap picker is then the pane's first live control. Second in the
|
| 3571 |
+
// precedence `nameIsFocusTarget` documents, and reading THAT rather than
|
| 3572 |
+
// `!onRename` is what keeps the attribute on exactly one element.
|
| 3573 |
+
overlayAutofocus={!nameIsFocusTarget}
|
| 3574 |
value={swapTo || undefined}
|
| 3575 |
onChange={setSwapTo}
|
| 3576 |
fields={[
|
|
|
|
| 3990 |
{/* β AN INLINE NODE, NOT A `MenuIcon` NAME, and that is forced rather than chosen.
|
| 3991 |
`MenuLabel`'s prop is `MenuIconName | ReactNode` and `ReactNode` admits any
|
| 3992 |
string, so `icon="share"` would COMPILE and paint an empty 16px box forever
|
| 3993 |
+
(`MENU_ICONS` has no such key, and `icons.tsx` is not this ticket's file).
|
| 3994 |
+
ββ W40-T24 β THE GEOMETRY IS `SHARE_SHAPE`, NOT A THREE-NODE GRAPH ANY MORE.
|
| 3995 |
+
The argument that stood here β that the graph is "the mark every product uses for
|
| 3996 |
+
this verb" β was true of a product where it was the only share drawing. It is not
|
| 3997 |
+
any more: W40-T20 put `SHARE_SHAPE` (two people) on the grid header's shared-field
|
| 3998 |
+
sprite and W40-T21 put it on Share view and Share folder, so this row was the last
|
| 3999 |
+
place one verb spoke in a second drawing β a field whose HEADER wears two people,
|
| 4000 |
+
shared from a row wearing a graph. DESIGN.md Β§4's "one mark, one meaning" is the
|
| 4001 |
+
rule and `iconShapes.ts` is the vocabulary; paths as DATA is what stops the two
|
| 4002 |
+
painters drifting, which is exactly what a hand-written copy here would restart.
|
| 4003 |
+
β Rendered the way `ViewSidebar.ShareMark` renders it β same viewBox, same 1.35
|
| 4004 |
+
stroke, same round caps β so the rail's share and this one are one mark and not
|
| 4005 |
+
two readings of one array. */}
|
| 4006 |
<MenuLabel
|
| 4007 |
icon={
|
| 4008 |
+
<svg width={16} height={16} viewBox="0 0 16 16" aria-hidden>
|
| 4009 |
+
{SHARE_SHAPE.map((s) =>
|
| 4010 |
+
s.fill ? (
|
| 4011 |
+
<path key={s.d} d={s.d} fill="currentColor" />
|
| 4012 |
+
) : (
|
| 4013 |
+
<path
|
| 4014 |
+
key={s.d}
|
| 4015 |
+
d={s.d}
|
| 4016 |
+
fill="none"
|
| 4017 |
+
stroke="currentColor"
|
| 4018 |
+
strokeWidth={1.35}
|
| 4019 |
+
strokeLinecap="round"
|
| 4020 |
+
strokeLinejoin="round"
|
| 4021 |
+
/>
|
| 4022 |
+
)
|
| 4023 |
+
)}
|
| 4024 |
</svg>
|
| 4025 |
}
|
| 4026 |
text="Share field"
|
|
|
|
| 4177 |
</button>
|
| 4178 |
</>
|
| 4179 |
)}
|
| 4180 |
+
{/* ββ W40-T24 (contract C2) β `offer.remove` IS THE PRE-SET LOCK, ADDED BESIDE the two
|
| 4181 |
+
conditions that were already here rather than replacing either. `onDelete` asks
|
| 4182 |
+
whether the host wired a delete DOOR at all (it has three, one per stratum) and
|
| 4183 |
+
`schemaLocked` is `CustomerGrid.isSchemaLocked`; AND-ing a third refusal can only
|
| 4184 |
+
subtract, so nothing is duplicated and no working delete is widened.
|
| 4185 |
+
β On the Odoo grids this is behaviour-neutral: a pre-set column already had no door,
|
| 4186 |
+
so Delete never rendered. What changes is that the absence is now the LOCK's answer
|
| 4187 |
+
rather than a coincidence of wiring, which is the half of D-413 the client can settle.
|
| 4188 |
+
β The pre-set ROLLUP keeps its Delete, by the 2026-08-09 owner ruling `isSchemaLocked`
|
| 4189 |
+
already carries. `columnDefinitionOffer` restates that exception rather than dropping
|
| 4190 |
+
it β see its note. */}
|
| 4191 |
+
{!schemaLocked && offer.remove && onDelete && (
|
| 4192 |
<button
|
| 4193 |
type="button"
|
| 4194 |
className="is-danger"
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/RecordDetail.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/cells.ts
CHANGED
|
@@ -1,807 +1,815 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / cells.ts
|
| 3 |
-
// The ONLY place that turns a (field, value) into a glide GridCell. glide has
|
| 4 |
-
// no per-column formatter β all formatting is per-cell here, in getCellContent.
|
| 5 |
-
//
|
| 6 |
-
// currency/int -> NumberCell (raw number kept for copy/paste, right-aligned)
|
| 7 |
-
// pct -> NumberCell showing v.toFixed(1)+"%" (yoy_pct is already in percent-points)
|
| 8 |
-
// date -> TextCell (locale date string; read-only, no picker in core)
|
| 9 |
-
// status -> BubbleCell (Airtable-style colored pill via themeOverride)
|
| 10 |
-
// checkbox -> BooleanCell (wave-5 item 11; overlay stores '1' or '')
|
| 11 |
-
// url -> UriCell (renders as a link; opening is CustomerGrid's click path)
|
| 12 |
-
// phone/email -> TextCell (actionable links in the record drawer)
|
| 13 |
-
// rating -> Custom (canvas-drawn stars β SVG/vector paths, NEVER emoji)
|
| 14 |
-
// created_time -> TextCell (read-only; the row's `_created`, injected at its key)
|
| 15 |
-
// formula -> NumberCell (client-computed, read-only; blank = could not compute)
|
| 16 |
-
// text/other -> TextCell
|
| 17 |
-
//
|
| 18 |
-
// Wave-5 item 10: `field.format` shapes the DISPLAY string (thousands /
|
| 19 |
-
// decimals 0..4 / 34.0M abbreviation for numbers; include-time + local|utc
|
| 20 |
-
// for dates). A field with NO format renders byte-identically to before the
|
| 21 |
-
// format existed β parity is asserted by keeping the default paths in terms
|
| 22 |
-
// of the same toLocaleString calls.
|
| 23 |
-
//
|
| 24 |
-
// Editability is the CALLER's decision (permissions + stratum + type); this
|
| 25 |
-
// module just applies the flag it is handed.
|
| 26 |
-
// ---------------------------------------------------------------------------
|
| 27 |
-
|
| 28 |
-
import { GridCellKind } from "@glideapps/glide-data-grid";
|
| 29 |
-
import type {
|
| 30 |
-
CustomCell,
|
| 31 |
-
CustomRenderer,
|
| 32 |
-
GridCell,
|
| 33 |
-
Theme,
|
| 34 |
-
} from "@glideapps/glide-data-grid";
|
| 35 |
-
import { assetUrl } from "./catalogData";
|
| 36 |
-
import type { Field } from "./types";
|
| 37 |
-
import { ratingMax } from "./types";
|
| 38 |
-
import {
|
| 39 |
-
LP_BLUE_TEXT,
|
| 40 |
-
LP_BLUE_TINT,
|
| 41 |
-
LP_GREEN_DEEP,
|
| 42 |
-
LP_GREEN_TINT,
|
| 43 |
-
LP_RED_DEEP,
|
| 44 |
-
LP_RED_TINT,
|
| 45 |
-
LP_YELLOW_DEEP,
|
| 46 |
-
LP_YELLOW_TINT,
|
| 47 |
-
STATUS_BUBBLE,
|
| 48 |
-
} from "./theme";
|
| 49 |
-
// β ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
|
| 50 |
-
// another. C-AVATAR names choiceColors' as the avatar fallback's source, so the two stay
|
| 51 |
-
// distinguishable at the call site rather than one silently shadowing the other.
|
| 52 |
-
import { optionTint, pickTint as choicePickTint } from "./choiceColors";
|
| 53 |
-
import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
|
| 54 |
-
codePreview, formulaIsText, jsonPreview, num, numberText, numericIsBlank,
|
| 55 |
-
userCellPayload } from "./display";
|
| 56 |
-
import type { AutomationState, CellValue, UserCellData } from "./display";
|
| 57 |
-
|
| 58 |
-
// Wave-7 (item W2): the pure display-string half moved to display.ts so the
|
| 59 |
-
// export builders can run under node without dragging glide along. Re-exported
|
| 60 |
-
// here so every existing import keeps working unchanged.
|
| 61 |
-
export { checkboxOn, dateTimeText, formatDisplay } from "./display";
|
| 62 |
-
// C-AVATAR β the pure halves live in display.ts (node-reachable, so a gate can hold them);
|
| 63 |
-
// re-exported here so callers keep importing "the cell module" for everything avatar-shaped.
|
| 64 |
-
export { avatarInitials, avatarSize, userCellPayload } from "./display";
|
| 65 |
-
export type { UserCellData } from "./display";
|
| 66 |
-
// C5-AUTOFIELD β same arrangement: the string parsing is pure and lives in display.ts (a node
|
| 67 |
-
// gate can hold it); only the canvas tint table below needs this module.
|
| 68 |
-
export { automationDetail, automationState, automationStateLabel } from "./display";
|
| 69 |
-
export type { AutomationState } from "./display";
|
| 70 |
-
// Wave-23 C7 β same arrangement again: the parse, the preview and the pretty-printer are pure
|
| 71 |
-
// (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
|
| 72 |
-
export { codePreview, jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";
|
| 73 |
-
|
| 74 |
-
/
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
*
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
}
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
export
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
ctx.
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
/
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
*
|
| 179 |
-
*
|
| 180 |
-
*
|
| 181 |
-
*
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
const
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
ctx.
|
| 233 |
-
ctx.
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
ctx.
|
| 248 |
-
ctx.
|
| 249 |
-
ctx.
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
return true;
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
//
|
| 273 |
-
//
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
img.
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
/
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
const
|
| 328 |
-
const
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
//
|
| 332 |
-
const
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
ctx.
|
| 338 |
-
ctx.
|
| 339 |
-
//
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
//
|
| 351 |
-
//
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
ctx.
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
/
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
/
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
case "
|
| 450 |
-
return {
|
| 451 |
-
kind: GridCellKind.Number,
|
| 452 |
-
data: noNumber ? undefined : num(v),
|
| 453 |
-
displayData: noNumber ? "" : numberText(num(v), field.format),
|
| 454 |
-
contentAlign: "right",
|
| 455 |
-
...ro,
|
| 456 |
-
};
|
| 457 |
-
case "
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
//
|
| 467 |
-
//
|
| 468 |
-
//
|
| 469 |
-
//
|
| 470 |
-
//
|
| 471 |
-
//
|
| 472 |
-
//
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
case "
|
| 523 |
-
return {
|
| 524 |
-
kind: GridCellKind.Text,
|
| 525 |
-
data: String(v ?? ""),
|
| 526 |
-
displayData: dateTimeText(field, v),
|
| 527 |
-
copyData: String(v ?? ""),
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
//
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
//
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
//
|
| 576 |
-
//
|
| 577 |
-
//
|
| 578 |
-
//
|
| 579 |
-
//
|
| 580 |
-
//
|
| 581 |
-
//
|
| 582 |
-
//
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
//
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
//
|
| 623 |
-
//
|
| 624 |
-
//
|
| 625 |
-
//
|
| 626 |
-
//
|
| 627 |
-
//
|
| 628 |
-
//
|
| 629 |
-
//
|
| 630 |
-
//
|
| 631 |
-
//
|
| 632 |
-
//
|
| 633 |
-
//
|
| 634 |
-
//
|
| 635 |
-
//
|
| 636 |
-
//
|
| 637 |
-
//
|
| 638 |
-
// `
|
| 639 |
-
//
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
//
|
| 670 |
-
//
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
//
|
| 691 |
-
//
|
| 692 |
-
//
|
| 693 |
-
//
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
// β 2026-08-
|
| 712 |
-
//
|
| 713 |
-
//
|
| 714 |
-
//
|
| 715 |
-
//
|
| 716 |
-
// `
|
| 717 |
-
//
|
| 718 |
-
//
|
| 719 |
-
//
|
| 720 |
-
//
|
| 721 |
-
//
|
| 722 |
-
//
|
| 723 |
-
//
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
allowOverlay: false,
|
| 746 |
-
|
| 747 |
-
};
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
//
|
| 758 |
-
//
|
| 759 |
-
//
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
//
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
.
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / cells.ts
|
| 3 |
+
// The ONLY place that turns a (field, value) into a glide GridCell. glide has
|
| 4 |
+
// no per-column formatter β all formatting is per-cell here, in getCellContent.
|
| 5 |
+
//
|
| 6 |
+
// currency/int -> NumberCell (raw number kept for copy/paste, right-aligned)
|
| 7 |
+
// pct -> NumberCell showing v.toFixed(1)+"%" (yoy_pct is already in percent-points)
|
| 8 |
+
// date -> TextCell (locale date string; read-only, no picker in core)
|
| 9 |
+
// status -> BubbleCell (Airtable-style colored pill via themeOverride)
|
| 10 |
+
// checkbox -> BooleanCell (wave-5 item 11; overlay stores '1' or '')
|
| 11 |
+
// url -> UriCell (renders as a link; opening is CustomerGrid's click path)
|
| 12 |
+
// phone/email -> TextCell (actionable links in the record drawer)
|
| 13 |
+
// rating -> Custom (canvas-drawn stars β SVG/vector paths, NEVER emoji)
|
| 14 |
+
// created_time -> TextCell (read-only; the row's `_created`, injected at its key)
|
| 15 |
+
// formula -> NumberCell (client-computed, read-only; blank = could not compute)
|
| 16 |
+
// text/other -> TextCell
|
| 17 |
+
//
|
| 18 |
+
// Wave-5 item 10: `field.format` shapes the DISPLAY string (thousands /
|
| 19 |
+
// decimals 0..4 / 34.0M abbreviation for numbers; include-time + local|utc
|
| 20 |
+
// for dates). A field with NO format renders byte-identically to before the
|
| 21 |
+
// format existed β parity is asserted by keeping the default paths in terms
|
| 22 |
+
// of the same toLocaleString calls.
|
| 23 |
+
//
|
| 24 |
+
// Editability is the CALLER's decision (permissions + stratum + type); this
|
| 25 |
+
// module just applies the flag it is handed.
|
| 26 |
+
// ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
import { GridCellKind } from "@glideapps/glide-data-grid";
|
| 29 |
+
import type {
|
| 30 |
+
CustomCell,
|
| 31 |
+
CustomRenderer,
|
| 32 |
+
GridCell,
|
| 33 |
+
Theme,
|
| 34 |
+
} from "@glideapps/glide-data-grid";
|
| 35 |
+
import { assetUrl } from "./catalogData";
|
| 36 |
+
import type { Field } from "./types";
|
| 37 |
+
import { ratingMax } from "./types";
|
| 38 |
+
import {
|
| 39 |
+
LP_BLUE_TEXT,
|
| 40 |
+
LP_BLUE_TINT,
|
| 41 |
+
LP_GREEN_DEEP,
|
| 42 |
+
LP_GREEN_TINT,
|
| 43 |
+
LP_RED_DEEP,
|
| 44 |
+
LP_RED_TINT,
|
| 45 |
+
LP_YELLOW_DEEP,
|
| 46 |
+
LP_YELLOW_TINT,
|
| 47 |
+
STATUS_BUBBLE,
|
| 48 |
+
} from "./theme";
|
| 49 |
+
// β ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
|
| 50 |
+
// another. C-AVATAR names choiceColors' as the avatar fallback's source, so the two stay
|
| 51 |
+
// distinguishable at the call site rather than one silently shadowing the other.
|
| 52 |
+
import { optionTint, pickTint as choicePickTint } from "./choiceColors";
|
| 53 |
+
import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
|
| 54 |
+
codePreview, formulaIsText, jsonPreview, num, numberText, numericIsBlank,
|
| 55 |
+
userCellPayload } from "./display";
|
| 56 |
+
import type { AutomationState, CellValue, UserCellData } from "./display";
|
| 57 |
+
|
| 58 |
+
// Wave-7 (item W2): the pure display-string half moved to display.ts so the
|
| 59 |
+
// export builders can run under node without dragging glide along. Re-exported
|
| 60 |
+
// here so every existing import keeps working unchanged.
|
| 61 |
+
export { checkboxOn, dateTimeText, formatDisplay } from "./display";
|
| 62 |
+
// C-AVATAR β the pure halves live in display.ts (node-reachable, so a gate can hold them);
|
| 63 |
+
// re-exported here so callers keep importing "the cell module" for everything avatar-shaped.
|
| 64 |
+
export { avatarInitials, avatarSize, userCellPayload } from "./display";
|
| 65 |
+
export type { UserCellData } from "./display";
|
| 66 |
+
// C5-AUTOFIELD β same arrangement: the string parsing is pure and lives in display.ts (a node
|
| 67 |
+
// gate can hold it); only the canvas tint table below needs this module.
|
| 68 |
+
export { automationDetail, automationState, automationStateLabel } from "./display";
|
| 69 |
+
export type { AutomationState } from "./display";
|
| 70 |
+
// Wave-23 C7 β same arrangement again: the parse, the preview and the pretty-printer are pure
|
| 71 |
+
// (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
|
| 72 |
+
export { codePreview, jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";
|
| 73 |
+
|
| 74 |
+
// β W40-T23 (owner instruction 14) β "typing a letter opens the picker" rests on two pure
|
| 75 |
+
// decisions: which keystroke counts as typing, and which choices a query keeps. Same arrangement
|
| 76 |
+
// as every block above β they live in display.ts so a node gate can EXECUTE them (this file
|
| 77 |
+
// imports `GridCellKind` as a value, so nothing in it is node-reachable), and are re-exported
|
| 78 |
+
// here so callers keep importing "the cell module". Their second consumer is the record
|
| 79 |
+
// drawer's chip row, which is a different control answering the same instruction.
|
| 80 |
+
export { matchChoices, pickerTypeSeed } from "./display";
|
| 81 |
+
|
| 82 |
+
/**
|
| 83 |
+
* Wave-18 C5-AUTOFIELD β the cell tint per automation state.
|
| 84 |
+
*
|
| 85 |
+
* Standing brand rule: the PASTEL is the fill and the measured `-deep` variant carries the ink.
|
| 86 |
+
* `bgCell` + `textDark` is that pairing at cell scale, and every combination below is one of the
|
| 87 |
+
* pairs already measured for `STATUS_BUBBLE` (β₯ 4.9:1), so nothing new needed measuring.
|
| 88 |
+
*
|
| 89 |
+
* `none` deliberately has NO override: a cell that has never run reads as an ordinary empty
|
| 90 |
+
* cell, because a tint would claim the automation had produced something.
|
| 91 |
+
*/
|
| 92 |
+
const AUTOMATION_TINT: Record<AutomationState, Partial<Theme> | undefined> = {
|
| 93 |
+
ok: { bgCell: LP_GREEN_TINT, textDark: LP_GREEN_DEEP },
|
| 94 |
+
partial: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
|
| 95 |
+
blocked: { bgCell: LP_YELLOW_TINT, textDark: LP_YELLOW_DEEP },
|
| 96 |
+
error: { bgCell: LP_RED_TINT, textDark: LP_RED_DEEP },
|
| 97 |
+
queued: { bgCell: LP_BLUE_TINT, textDark: LP_BLUE_TEXT },
|
| 98 |
+
none: undefined,
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
/** Stable pill tint for a user-defined choice. Hashed from the VALUE, so the same choice keeps
|
| 102 |
+
* its colour across rows, sessions and users without anyone picking one β and a renamed option
|
| 103 |
+
* simply gets a new colour rather than inheriting a stale mapping. */
|
| 104 |
+
const PICK_TINTS = [
|
| 105 |
+
// 2026-07-31 (owner item 7): ONE construction β every pill is a `-tint` wash carrying its
|
| 106 |
+
// family's `-deep` ink, the standing brand rule ("pastels are fills; text takes the deep
|
| 107 |
+
// weight"). The old second pass (full pastel + near-black ink) is GONE: it is what read as
|
| 108 |
+
// "black font on a saturated chip". Six families now β the four C1 hues, the brand purple,
|
| 109 |
+
// and a neutral grey β all mirrored from index.css tokens; every pairing measures β₯ 5.0:1.
|
| 110 |
+
{ bg: "#EDF3FD", fg: "#4F6079" }, // blue-tint / LP_BLUE_TEXT
|
| 111 |
+
{ bg: "#EBF6EF", fg: "#35754E" }, // green-tint / green-deep
|
| 112 |
+
{ bg: "#FBF4E0", fg: "#7E6428" }, // yellow-tint/ yellow-deep
|
| 113 |
+
{ bg: "#FCEEEC", fg: "#A3453C" }, // red-tint / red-deep
|
| 114 |
+
{ bg: "#F1EEFB", fg: "#6B57A8" }, // purple-tint/ purple-deep (--lp-purple family)
|
| 115 |
+
{ bg: "#EEF1F4", fg: "#4B5563" }, // neutral wash / slate β the sixth distinct family
|
| 116 |
+
];
|
| 117 |
+
|
| 118 |
+
export function pickTint(v: string): { bg: string; fg: string } {
|
| 119 |
+
let h = 0;
|
| 120 |
+
for (let i = 0; i < v.length; i += 1) h = (h * 31 + v.charCodeAt(i)) >>> 0;
|
| 121 |
+
return PICK_TINTS[h % PICK_TINTS.length];
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// --- rating: a canvas-drawn star row (owner constant: vector, never emoji) --
|
| 125 |
+
|
| 126 |
+
export interface RatingCellData {
|
| 127 |
+
kind: "aios-rating";
|
| 128 |
+
/** 0 = unset (draws all-empty outlines). */
|
| 129 |
+
value: number;
|
| 130 |
+
max: number;
|
| 131 |
+
}
|
| 132 |
+
export type RatingCell = CustomCell<RatingCellData>;
|
| 133 |
+
|
| 134 |
+
function drawStar(
|
| 135 |
+
ctx: CanvasRenderingContext2D,
|
| 136 |
+
cx: number,
|
| 137 |
+
cy: number,
|
| 138 |
+
r: number,
|
| 139 |
+
filled: boolean
|
| 140 |
+
): void {
|
| 141 |
+
ctx.beginPath();
|
| 142 |
+
for (let i = 0; i < 10; i += 1) {
|
| 143 |
+
const rad = (Math.PI / 5) * i - Math.PI / 2;
|
| 144 |
+
const rr = i % 2 === 0 ? r : r * 0.45;
|
| 145 |
+
const px = cx + Math.cos(rad) * rr;
|
| 146 |
+
const py = cy + Math.sin(rad) * rr;
|
| 147 |
+
if (i === 0) ctx.moveTo(px, py);
|
| 148 |
+
else ctx.lineTo(px, py);
|
| 149 |
+
}
|
| 150 |
+
ctx.closePath();
|
| 151 |
+
if (filled) {
|
| 152 |
+
// I7c: C1's yellow is a FILL token (1.38:1) and a star has to be seen, so the
|
| 153 |
+
// filled star rides the deep amber variant: 3.29:1 on white, clearing the 3:1
|
| 154 |
+
// non-text bar the old gold #C8A24B never did (it measured 2.41:1).
|
| 155 |
+
ctx.fillStyle = "#A98A3E";
|
| 156 |
+
ctx.fill();
|
| 157 |
+
} else {
|
| 158 |
+
ctx.strokeStyle = "rgba(118, 143, 182, 0.55)"; // blue-deep outline, empty slots
|
| 159 |
+
ctx.lineWidth = 1;
|
| 160 |
+
ctx.stroke();
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
// --- user: the assignee AVATAR (wave-14 item 11 / R6 / contract C-AVATAR) -----
|
| 165 |
+
//
|
| 166 |
+
// R6: a grid cell shows the PHOTO OR ICON ONLY β no name text. (Pickers and the record modal
|
| 167 |
+
// are where a name belongs; the column is a glanceable "who owns this", and thirty repetitions
|
| 168 |
+
// of "Farhan Sanyoto" down a column is the thing the owner asked to stop reading.)
|
| 169 |
+
//
|
| 170 |
+
// The name is still the cell's COPY value. An avatar-only cell with no `copyData` makes an
|
| 171 |
+
// assignee column copy as nothing β a real regression that no screenshot shows.
|
| 172 |
+
|
| 173 |
+
export type UserCell = CustomCell<UserCellData>;
|
| 174 |
+
|
| 175 |
+
/**
|
| 176 |
+
* Decoded avatar images, keyed by their data URL. `null` = this URL failed to decode, so we
|
| 177 |
+
* stop retrying it and paint the fallback forever (a corrupt stored photo must not spin).
|
| 178 |
+
*
|
| 179 |
+
* β Module scope, like the tint tables above: an Image per cell per frame would re-decode a
|
| 180 |
+
* base64 payload on every scroll tick.
|
| 181 |
+
*/
|
| 182 |
+
const _avatarImgs = new Map<string, HTMLImageElement | null>();
|
| 183 |
+
let _avatarRepaint: (() => void) | undefined;
|
| 184 |
+
|
| 185 |
+
/**
|
| 186 |
+
* β THE HALF THAT IS EASY TO FORGET. glide repaints when `getCellContent`'s identity changes β
|
| 187 |
+
* it knows nothing about an `Image.onload` that fires three frames later. Without this hook the
|
| 188 |
+
* fallback initials paint once and STAY, and because the fallback is a deliberate, correct-looking
|
| 189 |
+
* design, nothing on screen says the photo never arrived. (Same shape as wave-13's "honest empty
|
| 190 |
+
* state that made the failure look deliberate".)
|
| 191 |
+
*
|
| 192 |
+
* CustomerGrid calls this once and bumps a counter that is in `useGetCellContent`'s deps.
|
| 193 |
+
*/
|
| 194 |
+
export function setAvatarRepaint(fn: (() => void) | undefined): void {
|
| 195 |
+
_avatarRepaint = fn;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function avatarImage(dataUrl: string): HTMLImageElement | null {
|
| 199 |
+
const hit = _avatarImgs.get(dataUrl);
|
| 200 |
+
if (hit !== undefined) return hit;
|
| 201 |
+
// A DOM-less environment (a node gate, a locked-down embed) has no Image constructor. Cache
|
| 202 |
+
// the refusal so the fallback is what paints, rather than throwing inside a draw call.
|
| 203 |
+
if (typeof Image === "undefined") {
|
| 204 |
+
_avatarImgs.set(dataUrl, null);
|
| 205 |
+
return null;
|
| 206 |
+
}
|
| 207 |
+
const img = new Image();
|
| 208 |
+
_avatarImgs.set(dataUrl, img);
|
| 209 |
+
img.onload = () => _avatarRepaint?.();
|
| 210 |
+
img.onerror = () => {
|
| 211 |
+
_avatarImgs.set(dataUrl, null);
|
| 212 |
+
_avatarRepaint?.();
|
| 213 |
+
};
|
| 214 |
+
img.src = dataUrl;
|
| 215 |
+
return img;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
/** C-AVATAR β the circle, photo or not. Exported so any other canvas surface paints the same
|
| 219 |
+
* avatar rather than growing a second almost-matching one. */
|
| 220 |
+
export function drawAvatar(
|
| 221 |
+
ctx: CanvasRenderingContext2D,
|
| 222 |
+
cx: number,
|
| 223 |
+
cy: number,
|
| 224 |
+
r: number,
|
| 225 |
+
name: string,
|
| 226 |
+
photo: string | undefined,
|
| 227 |
+
fontFamily: string
|
| 228 |
+
): void {
|
| 229 |
+
const img = photo ? avatarImage(photo) : null;
|
| 230 |
+
// `complete` alone is not enough: a FAILED decode is also "complete", with a zero natural size.
|
| 231 |
+
if (img && img.complete && img.naturalWidth > 0) {
|
| 232 |
+
ctx.save();
|
| 233 |
+
ctx.beginPath();
|
| 234 |
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
| 235 |
+
ctx.clip();
|
| 236 |
+
// COVER, not contain: a portrait must fill the circle, not sit letterboxed inside it.
|
| 237 |
+
const scale = Math.max((r * 2) / img.naturalWidth, (r * 2) / img.naturalHeight);
|
| 238 |
+
const w = img.naturalWidth * scale;
|
| 239 |
+
const h = img.naturalHeight * scale;
|
| 240 |
+
ctx.drawImage(img, cx - w / 2, cy - h / 2, w, h);
|
| 241 |
+
ctx.restore();
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
// The fallback, and it is the DEFAULT state until HOST's C-AVATAR lands β never a broken-image
|
| 245 |
+
// glyph. Hashed from the full name so one person keeps one colour across rows and sessions.
|
| 246 |
+
const tint = choicePickTint(name);
|
| 247 |
+
ctx.save();
|
| 248 |
+
ctx.beginPath();
|
| 249 |
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
| 250 |
+
ctx.fillStyle = tint.bg;
|
| 251 |
+
ctx.fill();
|
| 252 |
+
ctx.fillStyle = tint.fg;
|
| 253 |
+
ctx.font = `600 ${Math.round(r * 0.9)}px ${fontFamily}`;
|
| 254 |
+
ctx.textAlign = "center";
|
| 255 |
+
ctx.textBaseline = "middle";
|
| 256 |
+
ctx.fillText(avatarInitials(name), cx, cy + 0.5);
|
| 257 |
+
ctx.restore();
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/** Registered once on the DataEditor (customRenderers), beside the rating renderer. */
|
| 261 |
+
export const userCellRenderer: CustomRenderer<UserCell> = {
|
| 262 |
+
kind: GridCellKind.Custom,
|
| 263 |
+
isMatch: (cell): cell is UserCell =>
|
| 264 |
+
(cell.data as UserCellData | undefined)?.kind === "aios-user",
|
| 265 |
+
draw: (args, cell) => {
|
| 266 |
+
const { ctx, rect, theme } = args;
|
| 267 |
+
const { name, photo } = cell.data;
|
| 268 |
+
if (!name) return true; // unassigned paints nothing β an empty circle would look assigned
|
| 269 |
+
const d = avatarSize(rect.height);
|
| 270 |
+
const cx = rect.x + theme.cellHorizontalPadding + d / 2;
|
| 271 |
+
const cy = rect.y + rect.height / 2;
|
| 272 |
+
// Never paint outside the cell: a column dragged narrow drops the avatar rather than
|
| 273 |
+
// bleeding it over its neighbour.
|
| 274 |
+
if (cx + d / 2 > rect.x + rect.width - theme.cellHorizontalPadding) return true;
|
| 275 |
+
drawAvatar(ctx, cx, cy, d / 2, name, photo, theme.fontFamily);
|
| 276 |
+
return true;
|
| 277 |
+
},
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
// --- image: the record THUMBNAIL (wave-19 R7 / contract C5) -------------------
|
| 281 |
+
//
|
| 282 |
+
// The cell holds a REFERENCE, never bytes (`types.ts: imageRefKind`), so what paints here is an
|
| 283 |
+
// <img> the browser fetches from the asset routes and caches like any other image. Same
|
| 284 |
+
// module-scoped decode cache and same repaint hook as the avatar above, and for the same reason:
|
| 285 |
+
// glide repaints on `getCellContent` identity, and knows nothing about an `onload` three frames
|
| 286 |
+
// later. Without the hook the placeholder paints once and stays β a deliberate-looking empty
|
| 287 |
+
// frame over a picture that did arrive.
|
| 288 |
+
|
| 289 |
+
export interface ImageCellData {
|
| 290 |
+
kind: "aios-image";
|
| 291 |
+
/** The resolved URL, or "" when the cell is empty (paints the placeholder frame). */
|
| 292 |
+
url: string;
|
| 293 |
+
/** The raw cell value β the copy/export payload, so a picture column is not invisible in a CSV. */
|
| 294 |
+
ref: string;
|
| 295 |
+
}
|
| 296 |
+
export type ImageCell = CustomCell<ImageCellData>;
|
| 297 |
+
|
| 298 |
+
const _cellImgs = new Map<string, HTMLImageElement | null>();
|
| 299 |
+
|
| 300 |
+
function cellImage(url: string): HTMLImageElement | null {
|
| 301 |
+
const hit = _cellImgs.get(url);
|
| 302 |
+
if (hit !== undefined) return hit;
|
| 303 |
+
if (typeof Image === "undefined") {
|
| 304 |
+
_cellImgs.set(url, null);
|
| 305 |
+
return null;
|
| 306 |
+
}
|
| 307 |
+
const img = new Image();
|
| 308 |
+
_cellImgs.set(url, img);
|
| 309 |
+
img.onload = () => _avatarRepaint?.();
|
| 310 |
+
img.onerror = () => {
|
| 311 |
+
// A reference that does not resolve is NOT retried: an unknown SKU code is the ordinary
|
| 312 |
+
// state of a product with no master on file, and retrying it every repaint would be a
|
| 313 |
+
// 404 per scroll tick per row.
|
| 314 |
+
_cellImgs.set(url, null);
|
| 315 |
+
_avatarRepaint?.();
|
| 316 |
+
};
|
| 317 |
+
img.src = url;
|
| 318 |
+
return img;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/** Registered once on the DataEditor (customRenderers), beside rating and user. */
|
| 322 |
+
export const imageCellRenderer: CustomRenderer<ImageCell> = {
|
| 323 |
+
kind: GridCellKind.Custom,
|
| 324 |
+
isMatch: (cell): cell is ImageCell =>
|
| 325 |
+
(cell.data as ImageCellData | undefined)?.kind === "aios-image",
|
| 326 |
+
draw: (args, cell) => {
|
| 327 |
+
const { ctx, rect, theme } = args;
|
| 328 |
+
const { url } = cell.data;
|
| 329 |
+
const pad = theme.cellHorizontalPadding;
|
| 330 |
+
// Square, inset by 3px top and bottom so a tall row shows a bigger picture and a short one
|
| 331 |
+
// still leaves the grid line visible.
|
| 332 |
+
const size = Math.max(0, Math.min(rect.height - 6, rect.width - pad * 2));
|
| 333 |
+
if (size <= 2) return true; // column dragged too narrow: paint nothing, never bleed
|
| 334 |
+
const x = rect.x + pad;
|
| 335 |
+
const y = rect.y + (rect.height - size) / 2;
|
| 336 |
+
const img = url ? cellImage(url) : null;
|
| 337 |
+
ctx.save();
|
| 338 |
+
ctx.beginPath();
|
| 339 |
+
// A 3px radius, matching the choice pills β one rounding vocabulary across the canvas.
|
| 340 |
+
const r = Math.min(3, size / 2);
|
| 341 |
+
ctx.moveTo(x + r, y);
|
| 342 |
+
ctx.arcTo(x + size, y, x + size, y + size, r);
|
| 343 |
+
ctx.arcTo(x + size, y + size, x, y + size, r);
|
| 344 |
+
ctx.arcTo(x, y + size, x, y, r);
|
| 345 |
+
ctx.arcTo(x, y, x + size, y, r);
|
| 346 |
+
ctx.closePath();
|
| 347 |
+
// `complete` alone is not enough β a FAILED decode is also complete, with a zero natural size.
|
| 348 |
+
if (img && img.complete && img.naturalWidth > 0) {
|
| 349 |
+
ctx.clip();
|
| 350 |
+
// CONTAIN, not cover: a product photo cropped to a square loses the thing being sold.
|
| 351 |
+
// Letterboxing inside the frame is the honest fit for a catalogue picture.
|
| 352 |
+
const scale = Math.min(size / img.naturalWidth, size / img.naturalHeight);
|
| 353 |
+
const w = img.naturalWidth * scale;
|
| 354 |
+
const h = img.naturalHeight * scale;
|
| 355 |
+
ctx.drawImage(img, x + (size - w) / 2, y + (size - h) / 2, w, h);
|
| 356 |
+
} else {
|
| 357 |
+
// The empty frame: a quiet outline, never a broken-image glyph and never a coloured block
|
| 358 |
+
// that would read as content. Identical whether the cell is empty or the fetch failed β
|
| 359 |
+
// the record modal is where a user finds out which, in words.
|
| 360 |
+
ctx.fillStyle = LP_BLUE_TINT;
|
| 361 |
+
ctx.fill();
|
| 362 |
+
ctx.strokeStyle = "rgba(118, 143, 182, 0.45)";
|
| 363 |
+
ctx.lineWidth = 1;
|
| 364 |
+
ctx.stroke();
|
| 365 |
+
}
|
| 366 |
+
ctx.restore();
|
| 367 |
+
return true;
|
| 368 |
+
},
|
| 369 |
+
};
|
| 370 |
+
|
| 371 |
+
/** Registered once on the DataEditor (customRenderers). Pure canvas paths. */
|
| 372 |
+
export const ratingCellRenderer: CustomRenderer<RatingCell> = {
|
| 373 |
+
kind: GridCellKind.Custom,
|
| 374 |
+
isMatch: (cell): cell is RatingCell =>
|
| 375 |
+
(cell.data as RatingCellData | undefined)?.kind === "aios-rating",
|
| 376 |
+
draw: (args, cell) => {
|
| 377 |
+
const { ctx, rect, theme } = args;
|
| 378 |
+
const { value, max } = cell.data;
|
| 379 |
+
const size = 13;
|
| 380 |
+
const gap = 3;
|
| 381 |
+
const cy = rect.y + rect.height / 2;
|
| 382 |
+
let cx = rect.x + theme.cellHorizontalPadding + size / 2;
|
| 383 |
+
const maxRight = rect.x + rect.width - theme.cellHorizontalPadding;
|
| 384 |
+
for (let i = 0; i < max; i += 1) {
|
| 385 |
+
if (cx + size / 2 > maxRight) break; // never paint outside the cell
|
| 386 |
+
drawStar(ctx, cx, cy, size / 2, i < value);
|
| 387 |
+
cx += size + gap;
|
| 388 |
+
}
|
| 389 |
+
return true;
|
| 390 |
+
},
|
| 391 |
+
};
|
| 392 |
+
|
| 393 |
+
/** Build the cell for one (field, value). `editable` gates overlay + readonly.
|
| 394 |
+
*
|
| 395 |
+
* β A BLANK numeric cell renders EMPTY, never "$0" (2026-07-27). `''`/null is how a value that
|
| 396 |
+
* could not be computed degrades β a measure column whose store query failed, an overlay number
|
| 397 |
+
* nobody typed, a FORMULA that hit an error β and painting it as $0 would state a number nobody
|
| 398 |
+
* computed. A real 0 arrives as the NUMBER 0 (the pool and the zero-group both emit it) and
|
| 399 |
+
* still renders "$0". Mirrors the blank-vs-zero rule the engine already has (`isBlank`: 0 is
|
| 400 |
+
* NOT blank). */
|
| 401 |
+
export function makeCell(
|
| 402 |
+
field: Field,
|
| 403 |
+
v: CellValue,
|
| 404 |
+
editable: boolean,
|
| 405 |
+
/** C-AVATAR β username β data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
|
| 406 |
+
* username absent from it, paints the initials fallback. */
|
| 407 |
+
userAvatars?: Record<string, string>
|
| 408 |
+
): GridCell {
|
| 409 |
+
// β NO MACHINE WASH (owner item 2, 2026-08-06): *"Remove the light grey highlight for the
|
| 410 |
+
// column that is supposedly pre-set."* Wave-23 R9 introduced a grey background on every
|
| 411 |
+
// machine-owned cell; on a `ut_*` database that is EVERY column but one, so the grid read as a
|
| 412 |
+
// sea of grey with a white stripe rather than as a table. The fact it was trying to state β
|
| 413 |
+
// *something else fills this column* β is now stated in WORDS where a reader will meet it
|
| 414 |
+
// (the `Pre-set` chip in Hide fields, `isPresetField`) and enforced where it matters (the
|
| 415 |
+
// cells refuse the edit, `isMachineWritten`). A label beats a tint the reader has to be taught.
|
| 416 |
+
return baseCell(field, v, editable, userAvatars);
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
/* β THE MACHINE WASH IS RETIRED (owner item 2, 2026-08-06).
|
| 420 |
+
|
| 421 |
+
Wave-23 C8/R9 washed every machine-owned cell grey. On the Customer grid that was a handful
|
| 422 |
+
of columns; on a `ut_*` database it is EVERY column but the identity one, so the owner's
|
| 423 |
+
Instagram table rendered as a grey sheet with one white stripe. `withMachineWash` and
|
| 424 |
+
`theme.machineCellTheme` are deleted with it β a composer nothing composes is a subject a gate
|
| 425 |
+
can still go green on ([[gate-answers-the-wrong-question]]), so the precedence assertions that
|
| 426 |
+
guarded it were RETARGETED onto the new law rather than dropped: `makeCell` adds no wash, and
|
| 427 |
+
a machine cell keeps exactly the override it already carried.
|
| 428 |
+
|
| 429 |
+
What replaced the signal, because removing it without replacing it would be a loss:
|
| 430 |
+
Β· the `Pre-set` chip in Hide fields (`isPresetField`) β the same word Odoo columns use;
|
| 431 |
+
Β· the custom-field DOT no longer painted on them (they are not yours to edit);
|
| 432 |
+
Β· and the cells genuinely refuse the edit now (`isMachineWritten`), which the wash never did. */
|
| 433 |
+
|
| 434 |
+
function baseCell(
|
| 435 |
+
field: Field,
|
| 436 |
+
v: CellValue,
|
| 437 |
+
editable: boolean,
|
| 438 |
+
userAvatars?: Record<string, string>
|
| 439 |
+
): GridCell {
|
| 440 |
+
const ro = { allowOverlay: editable, readonly: !editable };
|
| 441 |
+
const blank = v == null || v === "";
|
| 442 |
+
// β W29-T81 β A NUMERIC COLUMN HOLDING A NON-NUMBER PAINTS NOTHING, not `0`. `blank` above is
|
| 443 |
+
// emptiness; this is emptiness OR a value no reader would call a number. The import door can
|
| 444 |
+
// now put a spreadsheet's "seventeen-ish" in an `int` column, and `num()` answers 0 for it β
|
| 445 |
+
// a fabricated figure on the canvas beside an honest em-dash in the record panel.
|
| 446 |
+
const noNumber = numericIsBlank(v);
|
| 447 |
+
|
| 448 |
+
switch (field.type) {
|
| 449 |
+
case "currency":
|
| 450 |
+
return {
|
| 451 |
+
kind: GridCellKind.Number,
|
| 452 |
+
data: noNumber ? undefined : num(v),
|
| 453 |
+
displayData: noNumber ? "" : "$" + numberText(num(v), field.format),
|
| 454 |
+
contentAlign: "right",
|
| 455 |
+
...ro,
|
| 456 |
+
};
|
| 457 |
+
case "int":
|
| 458 |
+
return {
|
| 459 |
+
kind: GridCellKind.Number,
|
| 460 |
+
data: noNumber ? undefined : num(v),
|
| 461 |
+
displayData: noNumber ? "" : numberText(num(v), field.format),
|
| 462 |
+
contentAlign: "right",
|
| 463 |
+
...ro,
|
| 464 |
+
};
|
| 465 |
+
case "formula": {
|
| 466 |
+
// Computed client-side (formulaEngine.ts) over values ALREADY injected at this key by
|
| 467 |
+
// CustomerGrid's computedRows. Read-only by nature β the caller passes editable=false.
|
| 468 |
+
// 2026-07-31 (owner item 2): a formula may now return TEXT (CONCATENATE, &, TEXT(),
|
| 469 |
+
// TRUE/FALSE) β a non-numeric result renders as a text cell, never as NaN.
|
| 470 |
+
//
|
| 471 |
+
// β THE TEST WAS `!Number.isFinite(num(v))` AND IT NEVER FIRED. `num()` returns 0 for
|
| 472 |
+
// anything non-finite, so that read `Number.isFinite(0)` β always true β and every text
|
| 473 |
+
// formula printed as `0` here and in `formatDisplay`, which held its own copy of the same
|
| 474 |
+
// broken test. `formulaIsText` is now the ONE test, asked of the RAW value, shared by both
|
| 475 |
+
// renderers precisely because two copies is how they drifted. See its note in display.ts.
|
| 476 |
+
// β `formulaIsBlank`, NOT this function's shared `blank` (which is `v === ""`). A formula
|
| 477 |
+
// returning a SPACE is neither empty by that test nor text by the one below it, so it fell
|
| 478 |
+
// through to the numeric path and painted `0` β `Number(" ")` is 0. The two predicates are
|
| 479 |
+
// written to be total over a formula's three states; using only one of them re-opens the
|
| 480 |
+
// gap in miniature.
|
| 481 |
+
const formulaBlank = formulaIsBlank(v);
|
| 482 |
+
if (!formulaBlank && formulaIsText(v)) {
|
| 483 |
+
return {
|
| 484 |
+
kind: GridCellKind.Text,
|
| 485 |
+
data: v,
|
| 486 |
+
displayData: v,
|
| 487 |
+
allowOverlay: false,
|
| 488 |
+
readonly: true,
|
| 489 |
+
};
|
| 490 |
+
}
|
| 491 |
+
const asNum = num(v);
|
| 492 |
+
return {
|
| 493 |
+
kind: GridCellKind.Number,
|
| 494 |
+
data: formulaBlank ? undefined : asNum,
|
| 495 |
+
displayData: formulaBlank ? "" : numberText(asNum, field.format),
|
| 496 |
+
contentAlign: "right",
|
| 497 |
+
allowOverlay: false,
|
| 498 |
+
readonly: true,
|
| 499 |
+
};
|
| 500 |
+
}
|
| 501 |
+
case "pct":
|
| 502 |
+
return {
|
| 503 |
+
kind: GridCellKind.Number,
|
| 504 |
+
data: noNumber ? undefined : num(v),
|
| 505 |
+
displayData: noNumber ? "" : num(v).toFixed(1) + "%",
|
| 506 |
+
contentAlign: "right",
|
| 507 |
+
...ro,
|
| 508 |
+
};
|
| 509 |
+
/* β WAVE-26 ITEM 3 β `copyData` IS THE STORED STAMP, and it is not decoration.
|
| 510 |
+
MEASURED in the installed glide (`data-editor/copy-paste.js::convertCellToBuffer`): a
|
| 511 |
+
`Text` cell copies as `copyData ?? displayData`, and the text/plain buffer takes that
|
| 512 |
+
FORMATTED string β so without this line, copying a date puts the DISPLAY string on the
|
| 513 |
+
clipboard. That was survivable while the display read `8/5/2026` (no space, so
|
| 514 |
+
`new Date()` re-parsed it); item 3 changed it to `Aug 5, 2026`, whose first space
|
| 515 |
+
`parseStamp` turns into `AugT5, 2026` β an Invalid Date that renders verbatim forever.
|
| 516 |
+
β So a display change silently became a WRITE change, one paste away. The same argument
|
| 517 |
+
`userCellPayload` records for the assignee cell: a value's clipboard identity is its
|
| 518 |
+
STORED form, and leaving it to a fallback makes it depend on which buffer the browser
|
| 519 |
+
hands back (the text/html one carries `gdg-raw-value`, the plain one does not).
|
| 520 |
+
β `coerceClipboardValue` normalises a pasted display string as the second half of this;
|
| 521 |
+
neither is sufficient alone β this one fixes OUR copy, that one fixes Excel's. */
|
| 522 |
+
case "date":
|
| 523 |
+
return {
|
| 524 |
+
kind: GridCellKind.Text,
|
| 525 |
+
data: String(v ?? ""),
|
| 526 |
+
displayData: dateTimeText(field, v),
|
| 527 |
+
copyData: String(v ?? ""),
|
| 528 |
+
...ro,
|
| 529 |
+
};
|
| 530 |
+
case "created_time":
|
| 531 |
+
return {
|
| 532 |
+
kind: GridCellKind.Text,
|
| 533 |
+
data: String(v ?? ""),
|
| 534 |
+
displayData: dateTimeText(field, v),
|
| 535 |
+
copyData: String(v ?? ""),
|
| 536 |
+
allowOverlay: false,
|
| 537 |
+
readonly: true,
|
| 538 |
+
};
|
| 539 |
+
case "checkbox":
|
| 540 |
+
// glide toggles a non-readonly BooleanCell on click and reports it through
|
| 541 |
+
// onCellEdited β no overlay editor involved.
|
| 542 |
+
return {
|
| 543 |
+
kind: GridCellKind.Boolean,
|
| 544 |
+
data: checkboxOn(v),
|
| 545 |
+
allowOverlay: false,
|
| 546 |
+
readonly: !editable,
|
| 547 |
+
};
|
| 548 |
+
case "url":
|
| 549 |
+
// Renders as a link. OPENING is CustomerGrid's click path (scheme-guarded);
|
| 550 |
+
// hoverEffect gives the pointer affordance.
|
| 551 |
+
return {
|
| 552 |
+
kind: GridCellKind.Uri,
|
| 553 |
+
data: String(v ?? ""),
|
| 554 |
+
hoverEffect: true,
|
| 555 |
+
...ro,
|
| 556 |
+
};
|
| 557 |
+
case "image": {
|
| 558 |
+
// Wave-19 R7 / C5. `allowOverlay:false` for the same reason `select` has it: the value is
|
| 559 |
+
// a REFERENCE that is picked or uploaded, never typed into a cell β the record modal owns
|
| 560 |
+
// the picker and the upload. `copyData` carries the raw ref so the column is not invisible
|
| 561 |
+
// in a copy or an export (the lesson the avatar cell booked one wave earlier).
|
| 562 |
+
const ref = String(v ?? "").trim();
|
| 563 |
+
return {
|
| 564 |
+
kind: GridCellKind.Custom,
|
| 565 |
+
data: {
|
| 566 |
+
kind: "aios-image",
|
| 567 |
+
url: ref ? assetUrl(ref, "web") : "",
|
| 568 |
+
ref,
|
| 569 |
+
} satisfies ImageCellData,
|
| 570 |
+
copyData: ref,
|
| 571 |
+
allowOverlay: false,
|
| 572 |
+
};
|
| 573 |
+
}
|
| 574 |
+
case "json": {
|
| 575 |
+
// β Wave-23 C7 (owner item 5) β the compact preview; the DOCUMENT lives in the viewer.
|
| 576 |
+
//
|
| 577 |
+
// `allowOverlay: false` for the same reason `select` and `image` carry it: glide's text
|
| 578 |
+
// overlay is a one-line box, and a one-line box over a 32 KB document is an editor that
|
| 579 |
+
// can only damage the value β one keystroke in the wrong place and a well-formed payload
|
| 580 |
+
// becomes unparseable, saved. CustomerGrid opens the viewer on click instead (the same
|
| 581 |
+
// `onCellClicked` path the pickers use), and THAT is where the raw text is editable, with
|
| 582 |
+
// parse-on-save.
|
| 583 |
+
//
|
| 584 |
+
// β `readonly` is NOT set, and the difference matters: `allowOverlay:false` means "no
|
| 585 |
+
// inline editor", while `readonly` would tell glide the CELL cannot change β which would
|
| 586 |
+
// also block the paste path that legitimately writes a whole document into it.
|
| 587 |
+
//
|
| 588 |
+
// β `copyData` carries the RAW document, never the preview (the lesson the image cell
|
| 589 |
+
// booked in wave 19). Copy a json column and you get the payload; copy the preview and
|
| 590 |
+
// you get the sentence "{β¦} 5 keys", which is not data and cannot be pasted back.
|
| 591 |
+
const raw = String(v ?? "");
|
| 592 |
+
const text = jsonPreview(raw);
|
| 593 |
+
return {
|
| 594 |
+
kind: GridCellKind.Text,
|
| 595 |
+
data: text,
|
| 596 |
+
displayData: text,
|
| 597 |
+
copyData: raw,
|
| 598 |
+
allowOverlay: false,
|
| 599 |
+
};
|
| 600 |
+
}
|
| 601 |
+
case "code": {
|
| 602 |
+
// β Wave-27 item 13 (R13) β the compact preview; the SNIPPET lives in the editor.
|
| 603 |
+
//
|
| 604 |
+
// Every rule the json cell above states applies here for the same reasons, so this is
|
| 605 |
+
// written the same way rather than differently: `allowOverlay:false` because glide's
|
| 606 |
+
// one-line text overlay over a multi-line snippet is an editor that can only damage the
|
| 607 |
+
// value; `readonly` deliberately NOT set, so the paste path that legitimately writes a
|
| 608 |
+
// whole snippet still works; `copyData` the RAW text, never the preview, or copying a code
|
| 609 |
+
// column would yield "SELECT * FROM β¦ +12 more", which is not data and cannot be pasted
|
| 610 |
+
// back (the lesson the image cell booked in wave 19, and the W26 date-cell repeat of it).
|
| 611 |
+
const raw = String(v ?? "");
|
| 612 |
+
const text = codePreview(raw);
|
| 613 |
+
return {
|
| 614 |
+
kind: GridCellKind.Text,
|
| 615 |
+
data: text,
|
| 616 |
+
displayData: text,
|
| 617 |
+
copyData: raw,
|
| 618 |
+
allowOverlay: false,
|
| 619 |
+
};
|
| 620 |
+
}
|
| 621 |
+
case "ai_enrich": {
|
| 622 |
+
// ββ Wave-34 (owner ruling R13) β the AI ENRICHMENT cell. The value is an ordinary stored
|
| 623 |
+
// string, so this is a Text cell; what it is NOT is a `formula` (recomputed in the browser)
|
| 624 |
+
// or a `rollup` (read through from another table). Nobody has to be told a number came from
|
| 625 |
+
// somewhere else here, because the answer is words.
|
| 626 |
+
//
|
| 627 |
+
// Written the same way `code` and `json` are, and for their reason rather than a new one:
|
| 628 |
+
// Β· `allowOverlay: false` β glide's ONE-LINE overlay editor over multi-line prose is an
|
| 629 |
+
// editor that can only damage the value. The place a person edits an answer is the
|
| 630 |
+
// record drawer, which shows the whole thing.
|
| 631 |
+
// Β· `readonly` deliberately NOT set, so the paste path that legitimately writes a whole
|
| 632 |
+
// answer still works.
|
| 633 |
+
// Β· `copyData` the RAW text, never the preview, or copying this column would yield
|
| 634 |
+
// "Ann is a florist +2 more", which is not data and cannot be pasted back. That is the
|
| 635 |
+
// lesson the image cell booked in wave 19 and the date cell repeated in wave 26.
|
| 636 |
+
//
|
| 637 |
+
// β EDITING IS THE POINT, NOT AN AFTERTHOUGHT, which is why the column is NOT in
|
| 638 |
+
// `READONLY_CELL_TYPES` and is NOT in `is_computed_cell` server-side: typing over a cell is
|
| 639 |
+
// what marks it the human's, and `core.user_tables.ai_enrich_human_authored` then protects
|
| 640 |
+
// it from every automatic run forever.
|
| 641 |
+
//
|
| 642 |
+
// β OWED, AND NAMED RATHER THAN FAKED: this branch cannot paint the agent-written /
|
| 643 |
+
// human-edited / stale / errored STATE, because `makeCell(field, v, editable, avatars)` is
|
| 644 |
+
// handed the value and never the row, and the per-cell mark lives in a stratum the row
|
| 645 |
+
// payload does not carry. The drawer shows the state; the canvas will once the call site in
|
| 646 |
+
// `CustomerGrid.tsx` (lane C's file) passes it. Rendering a guessed state here would be
|
| 647 |
+
// worse than none: "done" on a cell nothing has run for is a claim.
|
| 648 |
+
const raw = String(v ?? "");
|
| 649 |
+
const text = codePreview(raw);
|
| 650 |
+
return {
|
| 651 |
+
kind: GridCellKind.Text,
|
| 652 |
+
data: text,
|
| 653 |
+
displayData: text,
|
| 654 |
+
copyData: raw,
|
| 655 |
+
allowOverlay: false,
|
| 656 |
+
};
|
| 657 |
+
}
|
| 658 |
+
case "rating": {
|
| 659 |
+
const max = ratingMax(field);
|
| 660 |
+
const n = Math.max(0, Math.min(max, Math.round(num(v))));
|
| 661 |
+
return {
|
| 662 |
+
kind: GridCellKind.Custom,
|
| 663 |
+
data: { kind: "aios-rating", value: blank ? 0 : n, max } satisfies RatingCellData,
|
| 664 |
+
copyData: blank ? "" : String(n),
|
| 665 |
+
allowOverlay: false,
|
| 666 |
+
};
|
| 667 |
+
}
|
| 668 |
+
case "automation": {
|
| 669 |
+
// Wave-18 C5-AUTOFIELD. The cell is what the last RUN wrote β `ok Β· 2026-08-03 14:10 Β·
|
| 670 |
+
// 12 posts` β so it is read-only by NATURE, not by policy: a value typed here would be
|
| 671 |
+
// overwritten by the next run with nothing anywhere saying so. The column's behaviour is
|
| 672 |
+
// configured through its gear, the way a formula's expression is.
|
| 673 |
+
//
|
| 674 |
+
// A tinted CELL rather than a bubble, and that is the whole design: the state and the
|
| 675 |
+
// detail are one sentence, and splitting them into two pills would put "2026-08-03 14:10
|
| 676 |
+
// Β· 12 posts" in a bubble, which is not what a bubble is for. `bgCell` is the one theme
|
| 677 |
+
// key glide alpha-blends (theme.ts:172), so the tints below are the flat pastels rather
|
| 678 |
+
// than anything semi-transparent.
|
| 679 |
+
const text = String(v ?? "");
|
| 680 |
+
return {
|
| 681 |
+
kind: GridCellKind.Text,
|
| 682 |
+
data: text,
|
| 683 |
+
displayData: text,
|
| 684 |
+
allowOverlay: false,
|
| 685 |
+
readonly: true,
|
| 686 |
+
themeOverride: AUTOMATION_TINT[automationState(text)],
|
| 687 |
+
};
|
| 688 |
+
}
|
| 689 |
+
case "link": {
|
| 690 |
+
// β 2026-08-07 β a relation, painted as ONE bubble saying how many rows it reaches.
|
| 691 |
+
//
|
| 692 |
+
// β NOT one bubble per linked row, and that is a measured decision rather than a
|
| 693 |
+
// simplification: the cell holds row IDS, and a column of `1,2,3` pills tells a reader
|
| 694 |
+
// nothing β the ids are not names. Airtable can paint the linked record's PRIMARY value
|
| 695 |
+
// because it has that row loaded; this client has not loaded the other table at all. So
|
| 696 |
+
// the honest cell is the COUNT, and the record drawer is where the rows themselves belong.
|
| 697 |
+
//
|
| 698 |
+
// β `copyData` carries the RAW id list, never the "12 posts" sentence β the W26 date-cell
|
| 699 |
+
// lesson, where a glide Text cell copies `copyData ?? displayData` and the FORMATTED
|
| 700 |
+
// string silently reached the clipboard and then the paste path. A count is not data and
|
| 701 |
+
// cannot be pasted back into a relation.
|
| 702 |
+
const ids = String(v ?? "").split(",").map((s) => s.trim()).filter((s) => s !== "");
|
| 703 |
+
return {
|
| 704 |
+
kind: GridCellKind.Bubble,
|
| 705 |
+
data: ids.length ? [ids.length === 1 ? "1 record" : `${ids.length} records`] : [],
|
| 706 |
+
copyData: String(v ?? ""),
|
| 707 |
+
allowOverlay: false,
|
| 708 |
+
};
|
| 709 |
+
}
|
| 710 |
+
case "rollup": {
|
| 711 |
+
// β 2026-08-07 β an aggregate the HOST computed. Read-only by nature, exactly like
|
| 712 |
+
// `formula`: the value is arithmetic, and a value box over arithmetic is an editor that
|
| 713 |
+
// can only produce a number the next refresh throws away.
|
| 714 |
+
//
|
| 715 |
+
// β BLANK STAYS BLANK. `_rollup_fold` returns "" for "no rows to aggregate" and only the
|
| 716 |
+
// count family ever returns a real 0 β so painting `0` here for an empty cell would invent
|
| 717 |
+
// the measurement the server just refused to invent. `num(v)` would do exactly that
|
| 718 |
+
// (`num("")` is 0), which is why the raw string is rendered rather than a parsed number.
|
| 719 |
+
// β 2026-08-10 (owner: *"I want to be able to use commas for numbers so instead of 1000
|
| 720 |
+
// its 1,000"*) β A ROLLUP IS FORMATTED LIKE ANY OTHER NUMBER, and it was the only numeric
|
| 721 |
+
// kind that was not. `int` and `currency` have gone through `numberText` since wave 5 and
|
| 722 |
+
// `formula` since it became a real column; a rollup rendered its raw fold string, so an
|
| 723 |
+
// "Avg views" column read `1491552.43` while the `Followers` column beside it read
|
| 724 |
+
// `56,147,007`. Two numeric columns, two dialects, one grid.
|
| 725 |
+
//
|
| 726 |
+
// β THE BLANK RULE IS PRESERVED EXACTLY, and it is why this is a guarded branch rather
|
| 727 |
+
// than a call. `_rollup_fold` returns "" for "no rows to aggregate" and only the count
|
| 728 |
+
// family ever returns a real 0, so `num(v)` β which maps "" to 0 β would paint the
|
| 729 |
+
// measurement the server just refused to invent. A blank stays the empty string, and a
|
| 730 |
+
// fold that is not a number at all (`concatenate`, `arrayunique`, `latest` over text)
|
| 731 |
+
// keeps its own text.
|
| 732 |
+
const raw = String(v ?? "");
|
| 733 |
+
const asNum = raw.trim() === "" ? NaN : Number(raw);
|
| 734 |
+
const text = Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
|
| 735 |
+
return {
|
| 736 |
+
kind: GridCellKind.Text,
|
| 737 |
+
data: text,
|
| 738 |
+
displayData: text,
|
| 739 |
+
// β THE CLIPBOARD TAKES THE RAW NUMBER, NOT THE FORMATTED ONE β wave 26's measured
|
| 740 |
+
// defect on `date`, where a glide Text cell copies `copyData ?? displayData` and the
|
| 741 |
+
// formatted string reached the clipboard, so pasting `Aug 5, 2026` into a number column
|
| 742 |
+
// stored the words. `1,491,552.43` pastes as text everywhere; `1491552.43` pastes as
|
| 743 |
+
// a number.
|
| 744 |
+
copyData: raw,
|
| 745 |
+
allowOverlay: false,
|
| 746 |
+
readonly: true,
|
| 747 |
+
};
|
| 748 |
+
}
|
| 749 |
+
case "status":
|
| 750 |
+
return {
|
| 751 |
+
kind: GridCellKind.Bubble,
|
| 752 |
+
data: [String(v ?? "")],
|
| 753 |
+
allowOverlay: false,
|
| 754 |
+
themeOverride: STATUS_BUBBLE[String(v ?? "").toLowerCase()],
|
| 755 |
+
};
|
| 756 |
+
case "user": {
|
| 757 |
+
// Wave-14 item 11 / R6 β the AVATAR ONLY, no name text. Split out of the `select` branch
|
| 758 |
+
// below (it was a name pill until this wave) because "who owns this" is a face, not a
|
| 759 |
+
// sentence, once a column has thirty rows of it.
|
| 760 |
+
//
|
| 761 |
+
// Still `allowOverlay:false`, for the same reason `select` is: the value is PICKED, never
|
| 762 |
+
// typed, and CustomerGrid opens its anchored picker on click (onCellClicked β isPickType,
|
| 763 |
+
// which already includes `user`, so the click path needs no change).
|
| 764 |
+
//
|
| 765 |
+
// β `copyData` carries the NAME. The cell shows no text, so without this the column would
|
| 766 |
+
// copy and export as empty β invisible in every screenshot. Built by `userCellPayload` in
|
| 767 |
+
// display.ts so a node gate can actually assert that (this module imports glide).
|
| 768 |
+
return {
|
| 769 |
+
kind: GridCellKind.Custom,
|
| 770 |
+
...userCellPayload(String(v ?? ""), userAvatars),
|
| 771 |
+
allowOverlay: false,
|
| 772 |
+
};
|
| 773 |
+
}
|
| 774 |
+
case "select": {
|
| 775 |
+
// Picked, never typed. `allowOverlay:false` keeps glide's text editor OUT of the way β
|
| 776 |
+
// a free-text editor on a constrained field is how a column ends up holding "Done",
|
| 777 |
+
// "done" and "DONE" as three different values. CustomerGrid opens an anchored picker on
|
| 778 |
+
// click instead (onCellClicked), which is also why this stays dependency-free: glide's
|
| 779 |
+
// dropdown cell lives in a separate package we deliberately have not added.
|
| 780 |
+
const s = String(v ?? "");
|
| 781 |
+
const tint = optionTint(field, s);
|
| 782 |
+
return {
|
| 783 |
+
kind: GridCellKind.Bubble,
|
| 784 |
+
data: s ? [s] : [],
|
| 785 |
+
allowOverlay: false,
|
| 786 |
+
themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
|
| 787 |
+
};
|
| 788 |
+
}
|
| 789 |
+
case "multiselect": {
|
| 790 |
+
// The cell holds a comma-joined SET (the `multi` contract the Cohorts column uses), so
|
| 791 |
+
// every member paints as its own pill. One themeOverride per CELL is all glide offers, so
|
| 792 |
+
// the pills share the first member's tint rather than each carrying its own.
|
| 793 |
+
const parts = String(v ?? "")
|
| 794 |
+
.split(",")
|
| 795 |
+
.map((s) => s.trim())
|
| 796 |
+
.filter((s) => s !== "");
|
| 797 |
+
const tint = parts.length ? optionTint(field, parts[0]) : undefined;
|
| 798 |
+
return {
|
| 799 |
+
kind: GridCellKind.Bubble,
|
| 800 |
+
data: parts,
|
| 801 |
+
allowOverlay: false,
|
| 802 |
+
themeOverride: tint ? { bgBubble: tint.bg, textBubble: tint.fg } : undefined,
|
| 803 |
+
};
|
| 804 |
+
}
|
| 805 |
+
default:
|
| 806 |
+
// text / status-family fallthrough β phone and email ride this branch too: typed as
|
| 807 |
+
// text in the grid, rendered as actionable tel:/mailto: links in the record drawer.
|
| 808 |
+
return {
|
| 809 |
+
kind: GridCellKind.Text,
|
| 810 |
+
data: String(v ?? ""),
|
| 811 |
+
displayData: String(v ?? ""),
|
| 812 |
+
...ro,
|
| 813 |
+
};
|
| 814 |
+
}
|
| 815 |
+
}
|
web/src/customer-grid/display.ts
CHANGED
|
@@ -1,777 +1,950 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / display.ts
|
| 3 |
-
// The pure DISPLAY-STRING half of cells.ts, split out in wave 7 (item W2): the
|
| 4 |
-
// export builders run under node in the verify gates, and importing them
|
| 5 |
-
// through cells.ts dragged the whole glide-data-grid package into a plain
|
| 6 |
-
// script. Everything here depends only on types.ts. cells.ts re-exports these
|
| 7 |
-
// names, so every existing import keeps working; the formatting is the same
|
| 8 |
-
// bytes it was β this is a move, not a change.
|
| 9 |
-
//
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
//
|
| 17 |
-
//
|
| 18 |
-
//
|
| 19 |
-
//
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
/
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
*
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
*
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
return
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
*
|
| 90 |
-
*
|
| 91 |
-
*
|
| 92 |
-
*
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
*
|
| 99 |
-
*
|
| 100 |
-
*
|
| 101 |
-
*
|
| 102 |
-
*
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
*
|
| 109 |
-
*
|
| 110 |
-
*
|
| 111 |
-
*
|
| 112 |
-
*
|
| 113 |
-
*
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
*
|
| 120 |
-
*
|
| 121 |
-
* `
|
| 122 |
-
*
|
| 123 |
-
*
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
*
|
| 130 |
-
*
|
| 131 |
-
*
|
| 132 |
-
*
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
*
|
| 154 |
-
*
|
| 155 |
-
*
|
| 156 |
-
*
|
| 157 |
-
* `Number(
|
| 158 |
-
*
|
| 159 |
-
*
|
| 160 |
-
*
|
| 161 |
-
*
|
| 162 |
-
*
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
*
|
| 169 |
-
*
|
| 170 |
-
*
|
| 171 |
-
*
|
| 172 |
-
*
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
*
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
*/
|
| 194 |
-
export function
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
*/
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
*
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
*
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
*
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
*
|
| 259 |
-
*
|
| 260 |
-
*
|
| 261 |
-
*
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
*
|
| 269 |
-
*
|
| 270 |
-
*
|
| 271 |
-
*
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
*
|
| 276 |
-
*
|
| 277 |
-
*
|
| 278 |
-
*
|
| 279 |
-
*
|
| 280 |
-
*
|
| 281 |
-
*
|
| 282 |
-
*
|
| 283 |
-
*
|
| 284 |
-
*
|
| 285 |
-
*
|
| 286 |
-
*
|
| 287 |
-
*
|
| 288 |
-
*
|
| 289 |
-
*
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
}
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
*
|
| 330 |
-
*
|
| 331 |
-
*
|
| 332 |
-
*
|
| 333 |
-
*
|
| 334 |
-
|
| 335 |
-
*
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
*
|
| 371 |
-
*
|
| 372 |
-
*
|
| 373 |
-
*
|
| 374 |
-
*
|
| 375 |
-
*
|
| 376 |
-
*
|
| 377 |
-
*
|
| 378 |
-
*
|
| 379 |
-
*
|
| 380 |
-
*
|
| 381 |
-
*
|
| 382 |
-
*
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
*
|
| 387 |
-
*
|
| 388 |
-
*
|
| 389 |
-
*
|
| 390 |
-
*
|
| 391 |
-
*
|
| 392 |
-
*
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
*
|
| 452 |
-
*
|
| 453 |
-
*
|
| 454 |
-
*
|
| 455 |
-
*
|
| 456 |
-
*
|
| 457 |
-
*
|
| 458 |
-
*
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
//
|
| 503 |
-
//
|
| 504 |
-
//
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
//
|
| 508 |
-
// object branch and
|
| 509 |
-
//
|
| 510 |
-
//
|
| 511 |
-
//
|
| 512 |
-
//
|
| 513 |
-
//
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
//
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
return clip(
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
}
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
}
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
*
|
| 657 |
-
*
|
| 658 |
-
*
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
*/
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
}
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
//
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
return
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
return
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
return
|
| 723 |
-
case "
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
//
|
| 730 |
-
//
|
| 731 |
-
//
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
//
|
| 735 |
-
//
|
| 736 |
-
//
|
| 737 |
-
return codePreview(v);
|
| 738 |
-
case "
|
| 739 |
-
//
|
| 740 |
-
//
|
| 741 |
-
//
|
| 742 |
-
//
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
//
|
| 746 |
-
//
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
//
|
| 752 |
-
//
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
//
|
| 756 |
-
//
|
| 757 |
-
//
|
| 758 |
-
//
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
// (
|
| 762 |
-
//
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
//
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
return
|
| 776 |
-
|
| 777 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / display.ts
|
| 3 |
+
// The pure DISPLAY-STRING half of cells.ts, split out in wave 7 (item W2): the
|
| 4 |
+
// export builders run under node in the verify gates, and importing them
|
| 5 |
+
// through cells.ts dragged the whole glide-data-grid package into a plain
|
| 6 |
+
// script. Everything here depends only on types.ts. cells.ts re-exports these
|
| 7 |
+
// names, so every existing import keeps working; the formatting is the same
|
| 8 |
+
// bytes it was β this is a move, not a change.
|
| 9 |
+
//
|
| 10 |
+
// β W40-T24 β `columnDefinitionOffer` at the foot of this file is NOT a display
|
| 11 |
+
// string, and it is here on purpose rather than by drift. It is a decision the
|
| 12 |
+
// Edit-field pane takes, and the pane lives in `ColumnMenu.tsx`, which imports
|
| 13 |
+
// React and therefore cannot be LOADED by the node gates β a rule stated only
|
| 14 |
+
// inside JSX is a rule no gate can run. Its natural home is `types.ts` beside
|
| 15 |
+
// the two predicates it composes; that file belongs to another lane this wave,
|
| 16 |
+
// so it lives in the nearest module that is both node-reachable and already
|
| 17 |
+
// imported by `_test/gridUx.test.ts`. If types.ts is ever free, move it there
|
| 18 |
+
// and leave a re-export: what must not happen is a second copy.
|
| 19 |
+
// ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
import type { Field, FieldFormat } from "./types";
|
| 22 |
+
import { isUserSchemaField, ratingMax } from "./types";
|
| 23 |
+
|
| 24 |
+
export type CellValue = string | number | null | undefined;
|
| 25 |
+
|
| 26 |
+
// --- C-AVATAR (wave-14 item 11): the two PURE halves of the assignee avatar ---
|
| 27 |
+
// They live here rather than in cells.ts for this module's founding reason: cells.ts imports
|
| 28 |
+
// glide, so nothing in it can be reached by a node gate. The canvas drawing stays there; the
|
| 29 |
+
// arithmetic and the string handling β the parts that can be wrong in ways a screenshot of one
|
| 30 |
+
// avatar will not show β are here, and `cells.ts` re-exports both.
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Up to two letters for the fallback circle.
|
| 34 |
+
*
|
| 35 |
+
* β The LOCAL PART only. Usernames in this tenant are email-shaped, and splitting
|
| 36 |
+
* "fsanyoto@gmail.com" on its separators gives ["fsanyoto@gmail", "com"] β "FC" β a person's
|
| 37 |
+
* avatar reading as their mail provider. Cutting at "@" first gives "FS".
|
| 38 |
+
*
|
| 39 |
+
* β A HYPHEN IS NOT A SEPARATOR. Space, dot and underscore divide a given name from a family
|
| 40 |
+
* name in a username; a hyphen almost always JOINS a compound one β Jo-Anne, Marie-Claire,
|
| 41 |
+
* Al-Rashid. Treating it as a separator turns "jo-anne_smith" into "JA", which is not that
|
| 42 |
+
* person's initials and is wrong in the one way nobody can spot: it looks like initials.
|
| 43 |
+
*/
|
| 44 |
+
export function avatarInitials(name: string): string {
|
| 45 |
+
const local = name.split("@")[0];
|
| 46 |
+
const parts = local.trim().split(/[\s._]+/).filter(Boolean);
|
| 47 |
+
if (parts.length === 0) return "?";
|
| 48 |
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
| 49 |
+
return (parts[0][0] + parts[1][0]).toUpperCase();
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/**
|
| 53 |
+
* The avatar's diameter for a row of `height` px: 22 on a comfortable row, never larger, and
|
| 54 |
+
* never so small it stops being a face. Bounded on BOTH sides on purpose β a `tall` row (48px)
|
| 55 |
+
* must not grow a 42px portrait, and a `short` one (28px) must not shrink to a dot.
|
| 56 |
+
*/
|
| 57 |
+
export function avatarSize(height: number): number {
|
| 58 |
+
return Math.max(14, Math.min(22, height - 6));
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
export interface UserCellData {
|
| 62 |
+
kind: "aios-user";
|
| 63 |
+
/** The assignee's username. "" = unassigned; the cell then paints nothing at all. */
|
| 64 |
+
name: string;
|
| 65 |
+
/** C-AVATAR data URL, when the workspace served one for this user. */
|
| 66 |
+
photo?: string;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
/**
|
| 70 |
+
* The `user` cell's payload β HERE rather than inline in `cells.ts` for one reason: `copyData`.
|
| 71 |
+
*
|
| 72 |
+
* β R6 removed the NAME from the cell, so the cell renders no text at all. glide's copy and
|
| 73 |
+
* export take `copyData`, which means an assignee column silently copies and exports as EMPTY
|
| 74 |
+
* unless it is set β a real regression that appears in no screenshot, because the screenshot
|
| 75 |
+
* shows the avatar working perfectly. `cells.ts` imports glide and is therefore unreachable from
|
| 76 |
+
* a node gate; this is, so the claim can actually be asserted.
|
| 77 |
+
*/
|
| 78 |
+
export function userCellPayload(
|
| 79 |
+
name: string,
|
| 80 |
+
userAvatars?: Record<string, string>
|
| 81 |
+
): { data: UserCellData; copyData: string } {
|
| 82 |
+
return {
|
| 83 |
+
data: { kind: "aios-user", name, photo: userAvatars?.[name] },
|
| 84 |
+
copyData: name,
|
| 85 |
+
};
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
/**
|
| 89 |
+
* The number to PAINT, with `0` as the fallback every existing caller already relies on.
|
| 90 |
+
*
|
| 91 |
+
* ββ IT MUST PARSE THE SAME WAY THE FOLD DOES, AND FOR ONE COMMIT IT DID NOT. This was a bare
|
| 92 |
+
* `Number(v)`, so `"1,234.50"` β `NaN` β **0**: the canvas painted `$0` while the totals row
|
| 93 |
+
* underneath counted 1,234.5. The T81 fix that introduced `numericIsBlank` made the BLANK
|
| 94 |
+
* decision consistent and left the VALUE on this function, so the cell stopped being blank and
|
| 95 |
+
* started being a fabricated zero β the exact defect T81 exists to kill, one step to the right.
|
| 96 |
+
* β Caught by an adversarial read, NOT by the leg named "the fold counts exactly what the cell
|
| 97 |
+
* shows" β which only ever called `computeAggs` and never evaluated what the cell shows. A test
|
| 98 |
+
* whose NAME makes a claim its assertion does not is worse than no test.
|
| 99 |
+
* β REACHABLE, and by more than one path: the import door refuses-without-rewriting (so `$45`
|
| 100 |
+
* is storable by design, and `api_api` asserts it imports), and `automation_engine._rollup_fold`
|
| 101 |
+
* returns raw source cells as STRINGS for `latest`/`min`/`max`.
|
| 102 |
+
*/
|
| 103 |
+
export function num(v: CellValue): number {
|
| 104 |
+
return numericOrUndefined(v) ?? 0;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
/**
|
| 108 |
+
* β W29-T81 β HAS THIS NUMERIC CELL NOTHING TO SHOW? Blank, or a stored value that is not a
|
| 109 |
+
* number at all.
|
| 110 |
+
*
|
| 111 |
+
* `num()` above answers **0** for anything unparseable, which is a measurement where there was
|
| 112 |
+
* none. That is survivable for a column only this app writes; it stopped being survivable when
|
| 113 |
+
* the import door began accepting rows from a spreadsheet β a `qty` column holding
|
| 114 |
+
* "seventeen-ish" painted `0` on the canvas while the record panel honestly showed an em-dash.
|
| 115 |
+
* Two surfaces disagreeing about the same cell, and the one that looks authoritative is the one
|
| 116 |
+
* making the number up.
|
| 117 |
+
*
|
| 118 |
+
* β The FOLD was already right (`aggregations.numOf` skips what will not parse), so the totals
|
| 119 |
+
* row never counted these β it is the painted cell alone. Same family as `formulaIsText` below,
|
| 120 |
+
* asked of the RAW value for the same reason.
|
| 121 |
+
* β `" "` is blank, not a zero: `Number(" ")` is 0, which is how a whitespace cell becomes a
|
| 122 |
+
* number nobody typed.
|
| 123 |
+
*/
|
| 124 |
+
export function numericIsBlank(v: CellValue): boolean {
|
| 125 |
+
return numericOrUndefined(v) === undefined;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* ββ THE ONE NUMERIC READING OF A STORED CELL, and it has to be one.
|
| 130 |
+
*
|
| 131 |
+
* `numericIsBlank` decides whether the CANVAS paints anything; `aggregations.numOf` decides
|
| 132 |
+
* whether the TOTALS ROW counts it. They were written separately and normalised differently β
|
| 133 |
+
* this one trimmed, that one stripped `$`, `,` and spaces β so `"1,234.50"` painted BLANK in the
|
| 134 |
+
* cell and was COUNTED in the total underneath it. That is T81's own defect ("two surfaces
|
| 135 |
+
* disagreeing about one cell") reintroduced in the opposite direction, by the fix for it, and no
|
| 136 |
+
* test could see it because every value in the legs was comma-free.
|
| 137 |
+
*
|
| 138 |
+
* β Human spellings are ACCEPTED here on purpose. `coerceClipboardValue` canonicalises what the
|
| 139 |
+
* UI writes, but the import door refuses-without-rewriting by design, so a `"$45"` posted by curl
|
| 140 |
+
* is storable β and the honest reading of that cell is 45 on BOTH surfaces, never 45 in the total
|
| 141 |
+
* and nothing in the cell ([[one-evaluator-per-question]]).
|
| 142 |
+
*/
|
| 143 |
+
export function numericOrUndefined(v: CellValue): number | undefined {
|
| 144 |
+
if (v === null || v === undefined || v === "") return undefined;
|
| 145 |
+
if (typeof v === "number") return Number.isFinite(v) ? v : undefined;
|
| 146 |
+
const cleaned = String(v).replace(/[$,\s]/g, "");
|
| 147 |
+
if (cleaned === "") return undefined; // " " is blank, not the zero `Number("")` gives
|
| 148 |
+
const n = Number(cleaned);
|
| 149 |
+
return Number.isFinite(n) ? n : undefined;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
/**
|
| 153 |
+
* β IS THIS FORMULA RESULT TEXT? β and it must be asked of the RAW value.
|
| 154 |
+
*
|
| 155 |
+
* A formula may return text since 2026-07-31 (owner item 2: CONCATENATE, `&`, TEXT(),
|
| 156 |
+
* TRUE/FALSE, and any `IF(cond, "yes", "no")`). Both renderers tried to detect that with
|
| 157 |
+
* `!Number.isFinite(num(v))` β and `num()` above returns **0** for anything non-finite, so the
|
| 158 |
+
* test was `Number.isFinite(0)`, which is always true. The text branch therefore never ran in
|
| 159 |
+
* EITHER renderer, and every text-returning formula printed as `0`: on the canvas, in the list,
|
| 160 |
+
* on kanban cards, in the record panel and in all four export formats.
|
| 161 |
+
*
|
| 162 |
+
* The feature had never worked. It surfaced by rendering the owner's own Buy signal formula
|
| 163 |
+
* through the shipped bundle rather than by asking whether the code looked right.
|
| 164 |
+
*
|
| 165 |
+
* ONE function, exported, called by both `formatDisplay` and `makeCell` β they are meant to be
|
| 166 |
+
* one rendering, and the way they drifted was each holding its own copy of this test.
|
| 167 |
+
* `Number("")` is 0, so the empty case is excluded explicitly rather than relied upon.
|
| 168 |
+
*
|
| 169 |
+
* A TYPE PREDICATE, not a bare boolean: `makeCell` builds a `TextCell` from the value straight
|
| 170 |
+
* after this test, and without the narrowing the caller has to re-assert the string it just
|
| 171 |
+
* proved β which is the kind of cast that outlives the reason for it.
|
| 172 |
+
*/
|
| 173 |
+
export function formulaIsText(v: CellValue): v is string {
|
| 174 |
+
return typeof v === "string" && v.trim() !== "" && !Number.isFinite(Number(v));
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
/**
|
| 178 |
+
* β AND THE BLANK TEST HAS TO AGREE WITH IT, or a value falls between the two.
|
| 179 |
+
*
|
| 180 |
+
* `formulaIsText` excludes whitespace-only strings (a `" "` result is not TEXT worth painting).
|
| 181 |
+
* The callers' own blank guard was `v === ""`, which does not catch `" "` β so a formula
|
| 182 |
+
* returning a space matched NEITHER, fell through to the numeric path, and printed `0`, because
|
| 183 |
+
* `Number(" ")` is 0. The exact bug this pair was written to fix, surviving in a narrower case.
|
| 184 |
+
*
|
| 185 |
+
* A formula's result is therefore one of exactly three things, and these two predicates make the
|
| 186 |
+
* three TOTAL: blank (null, empty, or whitespace), text, or a number. Reachable in practice β
|
| 187 |
+
* `CONCATENATE(" ", "")` is a space, and so is `TRIM()` of one.
|
| 188 |
+
*/
|
| 189 |
+
export function formulaIsBlank(v: CellValue): boolean {
|
| 190 |
+
return v == null || (typeof v === "string" && v.trim() === "");
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/** A checkbox cell's boolean, out of the overlay's '1'-or-empty contract. */
|
| 194 |
+
export function checkboxOn(v: CellValue | boolean): boolean {
|
| 195 |
+
return v === "1" || v === 1 || v === true;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* Item 10 β the number DISPLAY string. With no format: exactly the pre-wave-5
|
| 200 |
+
* rendering (toLocaleString). `abbrev` wins over decimals when the magnitude
|
| 201 |
+
* calls for it (34.0M β one decimal, k/M/B); `thousands: false` drops the
|
| 202 |
+
* separators; `decimals` fixes 0..4 places.
|
| 203 |
+
*/
|
| 204 |
+
export function numberText(v: number, fmt: FieldFormat | undefined): string {
|
| 205 |
+
if (fmt?.abbrev && Math.abs(v) >= 1000) {
|
| 206 |
+
const abs = Math.abs(v);
|
| 207 |
+
const [div, suffix] =
|
| 208 |
+
abs >= 1e9 ? [1e9, "B"] : abs >= 1e6 ? [1e6, "M"] : [1e3, "k"];
|
| 209 |
+
return (v / div).toFixed(1) + suffix;
|
| 210 |
+
}
|
| 211 |
+
const d =
|
| 212 |
+
fmt && Number.isInteger(fmt.decimals) && (fmt.decimals as number) >= 0 &&
|
| 213 |
+
(fmt.decimals as number) <= 4
|
| 214 |
+
? (fmt.decimals as number)
|
| 215 |
+
: null;
|
| 216 |
+
const thousands = fmt?.thousands !== false;
|
| 217 |
+
if (d != null)
|
| 218 |
+
return thousands
|
| 219 |
+
? v.toLocaleString(undefined, {
|
| 220 |
+
minimumFractionDigits: d,
|
| 221 |
+
maximumFractionDigits: d,
|
| 222 |
+
})
|
| 223 |
+
: v.toFixed(d);
|
| 224 |
+
return thousands ? v.toLocaleString() : String(v);
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* Parse a stored date/datetime string. Odoo datetimes arrive as
|
| 229 |
+
* "YYYY-MM-DD HH:MM:SS" and are UTC by Odoo convention, so a time-carrying
|
| 230 |
+
* value with no zone gets a Z; a bare date keeps today's parse (UTC midnight)
|
| 231 |
+
* so format-less rendering stays byte-identical to the pre-wave-5 path.
|
| 232 |
+
*
|
| 233 |
+
* β EXPORTED in wave 29 (W29-T30) because the xlsx writer needs a Date to turn
|
| 234 |
+
* into an Excel serial, and the alternative was a SECOND date parser in
|
| 235 |
+
* `export.ts` β two evaluators for one question, which is how the two drift and
|
| 236 |
+
* a stamp exports as a different day than it renders
|
| 237 |
+
* ([[one-evaluator-per-question]]). It stays the only parser in this module.
|
| 238 |
+
*/
|
| 239 |
+
export function parseStamp(raw: string): Date | null {
|
| 240 |
+
const s = raw.includes(" ") ? raw.replace(" ", "T") : raw;
|
| 241 |
+
const iso =
|
| 242 |
+
/T\d{2}:\d{2}/.test(s) && !/(?:[zZ]|[+-]\d{2}:?\d{2})$/.test(s) ? s + "Z" : s;
|
| 243 |
+
const d = new Date(iso);
|
| 244 |
+
return Number.isNaN(d.getTime()) ? null : d;
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
/** A BARE calendar day: the shape Odoo sends for a `date`, and the shape wave 26's migration
|
| 248 |
+
* converted the Instagram preset stamps into. `parseStamp` gives it UTC midnight. */
|
| 249 |
+
const BARE_DAY = /^\d{4}-\d{2}-\d{2}$/;
|
| 250 |
+
|
| 251 |
+
/**
|
| 252 |
+
* Is this stored value a calendar DAY rather than an INSTANT? β the question that decides which
|
| 253 |
+
* clock renders it (see `dateTimeText`).
|
| 254 |
+
*
|
| 255 |
+
* Exported, and it is the whole reason the test for this is worth anything: the alternative is
|
| 256 |
+
* asserting a rendered string, which depends on the machine's timezone β so the check would pass
|
| 257 |
+
* on the owner's box (+07:00, where the two clocks agree on the day) and could only ever go red
|
| 258 |
+
* somewhere else. A predicate is the same decision with no ambient state in it.
|
| 259 |
+
*
|
| 260 |
+
* β The SHAPE OF THE RAW STRING, never `field.type`: wave 26 re-typed columns that hold full ISO
|
| 261 |
+
* stamps to `date`, so one `date` field can carry both shapes at once during a migration.
|
| 262 |
+
*/
|
| 263 |
+
export function isBareDay(v: CellValue): boolean {
|
| 264 |
+
return BARE_DAY.test(String(v ?? "").trim());
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/**
|
| 268 |
+
* β Wave-26 item 3 (owner, R3) β the day, spelled. `Aug 5, 2026`, never `8/5/2026` and never
|
| 269 |
+
* the raw stamp. One vocabulary for the date half whether or not a time follows it, so a
|
| 270 |
+
* `date` column and a `created_time` column in the same table read alike.
|
| 271 |
+
*/
|
| 272 |
+
const DAY_PARTS = { year: "numeric", month: "short", day: "numeric" } as const;
|
| 273 |
+
|
| 274 |
+
/**
|
| 275 |
+
* Item 10 β the date DISPLAY string. `time` includes the time of day
|
| 276 |
+
* (created_time defaults to true β a creation stamp without its time reads as
|
| 277 |
+
* a duplicate of nothing); `tz: 'utc'` renders the tenant-neutral clock.
|
| 278 |
+
* Unparseable input renders VERBATIM rather than "Invalid Date" β the raw
|
| 279 |
+
* string is at least true.
|
| 280 |
+
*
|
| 281 |
+
* β WAVE 26 ITEM 3 (owner, via R3). The complaint was literal: cells read
|
| 282 |
+
* `2026-08-05T14:03:11+07:00`. SESSION A's half re-typed the Instagram preset
|
| 283 |
+
* columns from `text` to `date` (which is what routed them here at all) and
|
| 284 |
+
* migrated the stored cells to bare `YYYY-MM-DD`; this half is that a date must
|
| 285 |
+
* READ as one. Two changes, and the second is the one that is not cosmetic:
|
| 286 |
+
*
|
| 287 |
+
* β **A BARE `YYYY-MM-DD` IS A CALENDAR DAY, NOT AN INSTANT, so it renders in
|
| 288 |
+
* UTC.** `parseStamp` gives a bare date UTC midnight, so rendering it in the
|
| 289 |
+
* viewer's local zone shows the PREVIOUS DAY to everyone west of Greenwich β a
|
| 290 |
+
* value stored as the 5th reading as the 4th, silently, for a whole hemisphere.
|
| 291 |
+
* `ui/fmt.ts:date()` already refused to inherit this and its comment names this
|
| 292 |
+
* function as the holdout; it is no longer one. The tenant runs at +07:00, where
|
| 293 |
+
* the two agree, which is exactly why it could sit here unnoticed β and why the
|
| 294 |
+
* US market in COMMERCIALIZATION_PLAN C1b would have met it first.
|
| 295 |
+
*
|
| 296 |
+
* A value that CARRIES a time is a real instant and keeps the existing zone
|
| 297 |
+
* behaviour (local, or UTC when the field says so): shifting those to UTC would
|
| 298 |
+
* introduce the same off-by-a-day from the other direction.
|
| 299 |
+
*/
|
| 300 |
+
export function dateTimeText(
|
| 301 |
+
// β STRUCTURAL, not `Field`: this reads exactly two members, and item 8's post rows hold a
|
| 302 |
+
// raw `posted_at` with no column behind it. Widening the parameter (rather than fabricating a
|
| 303 |
+
// fake Field at the call site) is what lets the posts list render its days through the SAME
|
| 304 |
+
// formatter as a date cell instead of growing a second one. Every existing caller passes a
|
| 305 |
+
// `Field`, which is assignable.
|
| 306 |
+
field: { type: Field["type"]; format?: FieldFormat },
|
| 307 |
+
v: CellValue
|
| 308 |
+
): string {
|
| 309 |
+
if (v == null || v === "") return "";
|
| 310 |
+
const raw = String(v);
|
| 311 |
+
const d = parseStamp(raw);
|
| 312 |
+
if (!d) return raw;
|
| 313 |
+
const fmt = field.format;
|
| 314 |
+
const withTime = fmt?.time ?? field.type === "created_time";
|
| 315 |
+
const utc = fmt?.tz === "utc" || isBareDay(raw);
|
| 316 |
+
const zone = utc ? { timeZone: "UTC" as const } : undefined;
|
| 317 |
+
return withTime
|
| 318 |
+
? d.toLocaleString(undefined, {
|
| 319 |
+
...DAY_PARTS,
|
| 320 |
+
hour: "numeric",
|
| 321 |
+
minute: "2-digit",
|
| 322 |
+
second: "2-digit",
|
| 323 |
+
...zone,
|
| 324 |
+
})
|
| 325 |
+
: d.toLocaleDateString(undefined, { ...DAY_PARTS, ...zone });
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
/**
|
| 329 |
+
* The plain-string rendering of one (field, value), mirroring makeCell's
|
| 330 |
+
* displayData. Reused by the record-detail panel and the W2 export builders so
|
| 331 |
+
* a value reads identically on the canvas, in the panel and in a file. Returns
|
| 332 |
+
* "" for empty so callers can substitute their own placeholder.
|
| 333 |
+
*/
|
| 334 |
+
/**
|
| 335 |
+
* Wave-18 C5-AUTOFIELD β the STATE word of an automation cell.
|
| 336 |
+
*
|
| 337 |
+
* An automation cell holds one machine-written line: `state Β· when Β· detail`, e.g.
|
| 338 |
+
* `ok Β· 2026-08-03 14:10 Β· 12 posts`. The state is the first token, and every surface that
|
| 339 |
+
* paints the cell β canvas tint, record modal, the rail β reads it through HERE rather than
|
| 340 |
+
* re-splitting the string, because two parsers of one format is how the canvas and the panel
|
| 341 |
+
* end up disagreeing about whether a run succeeded (the exact way `formula` broke above).
|
| 342 |
+
*
|
| 343 |
+
* `"none"` is the honest answer for a cell nothing has written yet β NOT "ok". A column that
|
| 344 |
+
* has never run must not look like a column that ran and found nothing.
|
| 345 |
+
*/
|
| 346 |
+
export type AutomationState = "ok" | "partial" | "error" | "blocked" | "queued" | "none";
|
| 347 |
+
|
| 348 |
+
const AUTOMATION_STATES: AutomationState[] = ["ok", "partial", "error", "blocked", "queued"];
|
| 349 |
+
|
| 350 |
+
export function automationState(v: CellValue): AutomationState {
|
| 351 |
+
const head = String(v ?? "").split("Β·")[0].trim().toLowerCase();
|
| 352 |
+
return (AUTOMATION_STATES as string[]).includes(head)
|
| 353 |
+
? (head as AutomationState)
|
| 354 |
+
: "none";
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
/** Everything after the state word β the timestamp and the run's own detail. */
|
| 358 |
+
export function automationDetail(v: CellValue): string {
|
| 359 |
+
const parts = String(v ?? "").split("Β·");
|
| 360 |
+
return parts.length > 1 ? parts.slice(1).join("Β·").trim() : "";
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
/** Sentence-cased state, for anywhere a word reads better than a token. */
|
| 364 |
+
export function automationStateLabel(v: CellValue): string {
|
| 365 |
+
const s = automationState(v);
|
| 366 |
+
return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
/**
|
| 370 |
+
* β Wave-23 C7 (owner item 5) β THE JSON PREVIEW: what a 200px cell says about a document.
|
| 371 |
+
*
|
| 372 |
+
* `MAX_JSON_BYTES` is the contract's ceiling, mirrored from the host's write validation so the
|
| 373 |
+
* viewer can refuse a paste with the same number the server would (a client that lets you type
|
| 374 |
+
* 40 KB and then shows you a server refusal has wasted the edit).
|
| 375 |
+
*
|
| 376 |
+
* The four cases, and each one is a decision rather than a formatting preference:
|
| 377 |
+
* Β· **a single-pair object shows THE PAIR.** `{handle: "royalimports"}` is more useful than
|
| 378 |
+
* "1 key" and it is the shape most machine writes actually have. Past one pair the pairs
|
| 379 |
+
* stop fitting and the honest answer is the count.
|
| 380 |
+
* Β· **many keys / many items β `{β¦} N keys` / `[β¦] N items`.** Showing the FIRST pair of a
|
| 381 |
+
* twelve-key object would let a reader take one arbitrary value β whichever key the writer's
|
| 382 |
+
* serializer happened to emit first β for the cell's content.
|
| 383 |
+
* Β· **a bare scalar renders as itself.** `12`, `"ok"`, `true` and `null` are all valid JSON
|
| 384 |
+
* documents, and wrapping them in braces would describe a shape they do not have.
|
| 385 |
+
* Β· β **text that does not parse renders AS ITSELF, never as a shape.** The host validates on
|
| 386 |
+
* write, so this only happens to a value that predates the validation or arrived another
|
| 387 |
+
* way β and the one thing the preview must never do is claim a document is well-formed. The
|
| 388 |
+
* viewer's raw tab is where such a value gets read and repaired.
|
| 389 |
+
*
|
| 390 |
+
* Blank stays blank: an empty json cell is a document nobody has written, and `{}` is a document
|
| 391 |
+
* somebody wrote that is empty. Two different facts, two different cells.
|
| 392 |
+
*/
|
| 393 |
+
export const MAX_JSON_BYTES = 32 * 1024;
|
| 394 |
+
|
| 395 |
+
/**
|
| 396 |
+
* β WAVE 31 Β· T22 (D-173) β is this parsed value the SERVER'S STAND-IN for a document it did not
|
| 397 |
+
* send, and if so how big was the real one? Returns a short size string, or null.
|
| 398 |
+
*
|
| 399 |
+
* β THE SHAPE IS `routes_tables._thin_json`'s OWN, and it is matched on `_truncated === true`
|
| 400 |
+
* plus a numeric `bytes` β never on the key alone, because a person's own document could contain
|
| 401 |
+
* a `_truncated` key and must not be reported as absent. `_url` is deliberately NOT required: the
|
| 402 |
+
* viewer keys its fetch on it, but a preview that refused to warn when it was missing would go
|
| 403 |
+
* quiet in exactly the degraded case that most needs a warning.
|
| 404 |
+
*/
|
| 405 |
+
export function truncatedDoc(value: unknown): string | null {
|
| 406 |
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
| 407 |
+
const v = value as Record<string, unknown>;
|
| 408 |
+
if (v._truncated !== true) return null;
|
| 409 |
+
const bytes = typeof v.bytes === "number" && isFinite(v.bytes) ? Math.max(0, v.bytes) : 0;
|
| 410 |
+
if (!bytes) return "not shown";
|
| 411 |
+
return bytes >= 1024 ? `${Math.round(bytes / 1024).toLocaleString()} KB not shown`
|
| 412 |
+
: `${bytes.toLocaleString()} bytes not shown`;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
/** Does this text parse as JSON? The ONE test, shared by the preview, the cell and the viewer's
|
| 416 |
+
* save β three copies of a try/catch is how they end up disagreeing about `""` or `NaN`. */
|
| 417 |
+
export function jsonParse(v: CellValue): { ok: boolean; value?: unknown } {
|
| 418 |
+
const s = String(v ?? "").trim();
|
| 419 |
+
if (s === "") return { ok: false };
|
| 420 |
+
try {
|
| 421 |
+
return { ok: true, value: JSON.parse(s) as unknown };
|
| 422 |
+
} catch {
|
| 423 |
+
return { ok: false };
|
| 424 |
+
}
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
/**
|
| 428 |
+
* β WAVE-27 item 13 (R13) β one compact line for a CODE cell.
|
| 429 |
+
*
|
| 430 |
+
* Deliberately NOT `jsonPreview`, and the difference is the whole reason `code` is its own kind:
|
| 431 |
+
* a json preview SUMMARISES a parsed document ("{β¦} 5 keys"), which it can only do because the
|
| 432 |
+
* value is guaranteed to parse. A snippet has no structure to summarise and often does not parse
|
| 433 |
+
* at all β half-written SQL is the normal state of one β so the honest preview is the first
|
| 434 |
+
* non-blank LINE, clipped, plus the line count when there is more underneath. That way the cell
|
| 435 |
+
* says what the snippet starts with AND that there is more, instead of inventing a shape for it.
|
| 436 |
+
*/
|
| 437 |
+
export function codePreview(v: CellValue): string {
|
| 438 |
+
const raw = String(v ?? "");
|
| 439 |
+
if (raw.trim() === "") return "";
|
| 440 |
+
const lines = raw.split("\n");
|
| 441 |
+
const firstIdx = lines.findIndex((l) => l.trim() !== "");
|
| 442 |
+
const first = clip((lines[firstIdx < 0 ? 0 : firstIdx] ?? "").trim(), 60);
|
| 443 |
+
// The count is of REAL lines, so a snippet padded with blank lines does not claim depth it
|
| 444 |
+
// does not have β and it is checkable against what opening the editor shows
|
| 445 |
+
// ([[no-unverifiable-aggregates]]).
|
| 446 |
+
const n = lines.filter((l) => l.trim() !== "").length;
|
| 447 |
+
return n > 1 ? `${first} +${n - 1} more` : first;
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
/**
|
| 451 |
+
* ββ W37-T14 / T15 β THE TWO SHAPES WAVE 37 ADDED, READ AS WHAT THEY ARE.
|
| 452 |
+
*
|
| 453 |
+
* β FOUND BY QA ON THE DEPLOYED BUILD, not by a gate: `1.5BOPP`'s Units cell rendered
|
| 454 |
+
* **`[β¦] 2 items`** and its Tier prices cell the same way, because both are `type: "json"` and
|
| 455 |
+
* both fell through to the generic array branch below. The server was serving the right data the
|
| 456 |
+
* whole time β `[{name:"SLEEVE",qty:12},{name:"Master",qty:144}]` β and the grid said nothing
|
| 457 |
+
* about it. A column whose entire job is to show which units a SKU sells in, showing a count of
|
| 458 |
+
* items, is the [[permitted-is-not-answerable]] shape in the RENDER layer.
|
| 459 |
+
*
|
| 460 |
+
* β KEYED ON THE SHAPE, NEVER ON THE COLUMN KEY. A key test (`field.key === "units"`) would
|
| 461 |
+
* miss the same document on a user database, on a rollup, or under any rename β and this file
|
| 462 |
+
* has no access to the field anyway. Both shapes are exact: EVERY element must be an object
|
| 463 |
+
* carrying exactly the pair, so an unrelated array of objects still falls through to the generic
|
| 464 |
+
* branch and is not silently relabelled.
|
| 465 |
+
*
|
| 466 |
+
* β `Γ` is U+00D7 MULTIPLICATION SIGN and `Β·` is U+00B7 MIDDLE DOT β neither is an em or en
|
| 467 |
+
* dash, so this copy is inside standing rule 2 rather than an exception to it.
|
| 468 |
+
*/
|
| 469 |
+
function namedPairList(value: unknown): string | null {
|
| 470 |
+
if (!Array.isArray(value) || value.length === 0) return null;
|
| 471 |
+
const rows = value as Record<string, unknown>[];
|
| 472 |
+
const every = (a: string, b: string) =>
|
| 473 |
+
rows.every(
|
| 474 |
+
(r) =>
|
| 475 |
+
r !== null &&
|
| 476 |
+
typeof r === "object" &&
|
| 477 |
+
!Array.isArray(r) &&
|
| 478 |
+
typeof r[a] === "string" &&
|
| 479 |
+
typeof r[b] === "number",
|
| 480 |
+
);
|
| 481 |
+
// W37-T15 β units of measure: the NAME and its conversion factor, which is the whole point.
|
| 482 |
+
if (every("name", "qty"))
|
| 483 |
+
return rows.map((r) => `${String(r.name)} Γ${trimNum(r.qty as number)}`).join(" Β· ");
|
| 484 |
+
// W37-T14 β the pricelists that really price this SKU today, each with its price.
|
| 485 |
+
if (every("pricelist", "unit_price"))
|
| 486 |
+
return rows
|
| 487 |
+
.map((r) => `${String(r.pricelist)} $${numberText(r.unit_price as number, { decimals: 2 })}`)
|
| 488 |
+
.join(" Β· ");
|
| 489 |
+
return null;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
/** `12` not `12.0`, and `1.5` kept β a conversion factor is a count far more often than not. */
|
| 493 |
+
function trimNum(n: number): string {
|
| 494 |
+
return Number.isFinite(n) ? String(Number(n.toFixed(4))) : String(n);
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/** One compact line for a json cell. See the note above for why each case reads as it does. */
|
| 498 |
+
export function jsonPreview(v: CellValue): string {
|
| 499 |
+
const raw = String(v ?? "").trim();
|
| 500 |
+
if (raw === "") return "";
|
| 501 |
+
const parsed = jsonParse(raw);
|
| 502 |
+
// β First line only, and clipped: an unparseable value is often a whole pasted response, and
|
| 503 |
+
// a cell is not where a 4 KB blob gets read. It is shown rather than hidden because the
|
| 504 |
+
// reader has to be able to see that the column holds something the app could not open.
|
| 505 |
+
if (!parsed.ok) return clip(raw.split("\n")[0], 60);
|
| 506 |
+
const value = parsed.value;
|
| 507 |
+
// β ITEM 8 β a posts window says what it holds. Before wave 26 this document fell through to
|
| 508 |
+
// the generic object branch and every creator's `posts` cell read `{β¦} 4 keys`, which is true
|
| 509 |
+
// about JSON and says nothing about the record.
|
| 510 |
+
// β The ARRAY LENGTH, never the document's own `n`: the count in a cell has to be checkable
|
| 511 |
+
// against what opening it shows ([[no-unverifiable-aggregates]]).
|
| 512 |
+
// ββ WAVE 31 Β· T22 (D-173) β A DOCUMENT THE LIST DID NOT SEND SAYS SO, WITH ITS SIZE.
|
| 513 |
+
//
|
| 514 |
+
// β THE SWALLOW, and it is one branch below this one. `routes_tables._thin_json` replaces an
|
| 515 |
+
// oversized `json` cell with a stand-in β `{_truncated, bytes, _url}` β so the big document
|
| 516 |
+
// does not ride a list response (measured: `source_payload` was 95.6β98.5% of every IG grid's
|
| 517 |
+
// bytes). That stand-in is itself valid JSON with three keys, so it fell through to the generic
|
| 518 |
+
// object branch and the cell read **`{β¦} 3 keys`** β a confident, checkable-looking claim about
|
| 519 |
+
// a document that is not there, and indistinguishable from a real three-key document. R6's
|
| 520 |
+
// second sentence applies to our own wire: a value we declined to send is reported, never
|
| 521 |
+
// disguised.
|
| 522 |
+
// β THE SIZE IS THE SERVER'S OWN `bytes`, not a guess, and it is what makes the cell honest β
|
| 523 |
+
// "this column holds 41 KB you have not been shown" is a different fact from "3 keys".
|
| 524 |
+
const thinned = truncatedDoc(value);
|
| 525 |
+
if (thinned) return `{β¦} ${thinned}`;
|
| 526 |
+
const shown = postsWindowOf(value);
|
| 527 |
+
if (shown)
|
| 528 |
+
return shown.posts.length === 0
|
| 529 |
+
? "No posts"
|
| 530 |
+
: `${shown.posts.length} post${shown.posts.length === 1 ? "" : "s"}`;
|
| 531 |
+
// β W37-T14/T15 β the two known shapes read as themselves; anything else keeps the old count.
|
| 532 |
+
const pairs = namedPairList(value);
|
| 533 |
+
if (pairs) return clip(pairs, 60);
|
| 534 |
+
if (Array.isArray(value))
|
| 535 |
+
return value.length === 0 ? "[]" : `[β¦] ${value.length} item${value.length === 1 ? "" : "s"}`;
|
| 536 |
+
if (value !== null && typeof value === "object") {
|
| 537 |
+
const keys = Object.keys(value as Record<string, unknown>);
|
| 538 |
+
if (keys.length === 0) return "{}";
|
| 539 |
+
if (keys.length === 1)
|
| 540 |
+
return clip(`{${keys[0]}: ${scalarText((value as Record<string, unknown>)[keys[0]])}}`, 60);
|
| 541 |
+
return `{β¦} ${keys.length} keys`;
|
| 542 |
+
}
|
| 543 |
+
return clip(scalarText(value), 60);
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
/** The day, spelled, for a caller holding a raw value and no column β the posts list's
|
| 547 |
+
* `posted_at`. Routed through `dateTimeText` so it can never drift from a date CELL. */
|
| 548 |
+
export function dayText(v: CellValue): string {
|
| 549 |
+
return dateTimeText({ type: "date" }, v);
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
/* βββ β WAVE 26 ITEM 8 (contract C2) β THE `posts` WINDOW βββ
|
| 553 |
+
The preset `posts` cell holds ONE object written by the engine's `posts_window`:
|
| 554 |
+
|
| 555 |
+
{ n, metrics, as_of, posts: [{ shortcode, url, posted_at, type, caption,
|
| 556 |
+
views?, likes?, comments? }] }
|
| 557 |
+
|
| 558 |
+
Two laws come with it and both are enforced below rather than in the components:
|
| 559 |
+
|
| 560 |
+
β `metrics: false` β views/likes/comments are ABSENT KEYS, NEVER 0. A reader must render an
|
| 561 |
+
absent metric as NOTHING. Coercing it to a number here β `Number(p.views) || 0` is the
|
| 562 |
+
natural thing to type β would fabricate a measurement, which is the engine's own
|
| 563 |
+
blank-never-zero law broken at the display seam, silently and plausibly (a creator with no
|
| 564 |
+
metrics bought would read as a creator with no engagement).
|
| 565 |
+
|
| 566 |
+
β IT IS A WINDOW, NOT THE HISTORY. `ut_ig_posts` / `ut_ig_post_snapshots` accumulate; this
|
| 567 |
+
cell is the last N (R1/R3 β "one store for one series"). So nothing here may present the
|
| 568 |
+
cell as a total.
|
| 569 |
+
|
| 570 |
+
β DETECTED BY SHAPE, never by the field's key. A user may name a column `posts`, and the
|
| 571 |
+
preset key is not a contract the renderer can see from the value alone. An object carrying a
|
| 572 |
+
`posts` ARRAY OF OBJECTS is what makes "3 posts" a true sentence about it, whoever wrote it.
|
| 573 |
+
βββ */
|
| 574 |
+
export interface PostSummary {
|
| 575 |
+
shortcode?: string;
|
| 576 |
+
url?: string;
|
| 577 |
+
posted_at?: string;
|
| 578 |
+
type?: string;
|
| 579 |
+
caption?: string;
|
| 580 |
+
/** β `undefined` when the metric was not bought. Never 0 β see the note above. */
|
| 581 |
+
views?: number;
|
| 582 |
+
likes?: number;
|
| 583 |
+
comments?: number;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
export interface PostsWindow {
|
| 587 |
+
/** The engine's own count. May differ from `posts.length`; readers show the LENGTH, because
|
| 588 |
+
* that is the number a reader can check against what is in front of them. */
|
| 589 |
+
n: number;
|
| 590 |
+
metrics: boolean;
|
| 591 |
+
as_of?: string;
|
| 592 |
+
posts: PostSummary[];
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
function str(v: unknown): string | undefined {
|
| 596 |
+
return typeof v === "string" && v !== "" ? v : undefined;
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
/** β A number ONLY when the key really holds one. `undefined` for absent, for null, and for a
|
| 600 |
+
* non-numeric β anything else invents a measurement. */
|
| 601 |
+
function metric(v: unknown): number | undefined {
|
| 602 |
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
/** The window behind an ALREADY-PARSED value, or null when this is not one. */
|
| 606 |
+
export function postsWindowOf(value: unknown): PostsWindow | null {
|
| 607 |
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
| 608 |
+
const doc = value as Record<string, unknown>;
|
| 609 |
+
const rows = doc.posts;
|
| 610 |
+
if (!Array.isArray(rows)) return null;
|
| 611 |
+
if (!rows.every((r) => r !== null && typeof r === "object" && !Array.isArray(r))) return null;
|
| 612 |
+
const posts: PostSummary[] = rows.map((r) => {
|
| 613 |
+
const p = r as Record<string, unknown>;
|
| 614 |
+
return {
|
| 615 |
+
shortcode: str(p.shortcode),
|
| 616 |
+
url: str(p.url),
|
| 617 |
+
posted_at: str(p.posted_at),
|
| 618 |
+
type: str(p.type),
|
| 619 |
+
caption: str(p.caption),
|
| 620 |
+
views: metric(p.views),
|
| 621 |
+
likes: metric(p.likes),
|
| 622 |
+
comments: metric(p.comments),
|
| 623 |
+
};
|
| 624 |
+
});
|
| 625 |
+
return {
|
| 626 |
+
n: typeof doc.n === "number" ? doc.n : posts.length,
|
| 627 |
+
metrics: doc.metrics === true,
|
| 628 |
+
as_of: str(doc.as_of),
|
| 629 |
+
posts,
|
| 630 |
+
};
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
/** The window behind a stored CELL, or null. */
|
| 634 |
+
export function postsWindow(v: CellValue): PostsWindow | null {
|
| 635 |
+
const parsed = jsonParse(v);
|
| 636 |
+
return parsed.ok ? postsWindowOf(parsed.value) : null;
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
/** A nested value, small enough to sit inside a one-pair preview. Objects and arrays collapse
|
| 640 |
+
* to their own marks rather than recursing β a preview that unfolds is not a preview. */
|
| 641 |
+
function scalarText(v: unknown): string {
|
| 642 |
+
if (v === null) return "null";
|
| 643 |
+
if (Array.isArray(v)) return `[β¦] ${v.length}`;
|
| 644 |
+
if (typeof v === "object") return `{β¦} ${Object.keys(v as object).length}`;
|
| 645 |
+
return typeof v === "string" ? v : String(v);
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
/** β An ellipsis CHARACTER, not three dots: the grid's canvas measures text and three periods
|
| 649 |
+
* are three glyphs wide. Same mark the group-bar fitter uses. */
|
| 650 |
+
function clip(s: string, n: number): string {
|
| 651 |
+
return s.length <= n ? s : s.slice(0, n - 1) + "β¦";
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
/**
|
| 655 |
+
* The document, indented for the viewer's pretty tab. Returns the RAW TEXT UNCHANGED when it
|
| 656 |
+
* does not parse β re-indenting is not repair, and handing a reader a "prettified" version of
|
| 657 |
+
* something the app could not read would hide the only thing they need to see.
|
| 658 |
+
*/
|
| 659 |
+
export function jsonPretty(v: CellValue): string {
|
| 660 |
+
const raw = String(v ?? "");
|
| 661 |
+
const parsed = jsonParse(raw);
|
| 662 |
+
return parsed.ok ? JSON.stringify(parsed.value, null, 2) : raw;
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
/**
|
| 666 |
+
* Wave-5 item 11 β the actionable link behind a url/email/phone value, or null when the value
|
| 667 |
+
* does not parse as one (the raw text still shows; a link that goes nowhere is worse than no
|
| 668 |
+
* link). url without a scheme gets https://; `javascript:` can never come out of here.
|
| 669 |
+
*
|
| 670 |
+
* β MOVED here from `RecordDetail.tsx` for wave-26 item 11, when the KANBAN CARD became a
|
| 671 |
+
* second surface that needs it. Two copies of a scheme guard is how one of them ends up
|
| 672 |
+
* accepting `javascript:` β the same argument that moved `formatDisplay`'s formula test into one
|
| 673 |
+
* function after the canvas and the panel had drifted. It is also the only reason this claim is
|
| 674 |
+
* testable at all: both call sites are React components, and this module is glide-free and
|
| 675 |
+
* React-free, so a node gate can reach it.
|
| 676 |
+
*/
|
| 677 |
+
export function actionHref(field: Field, v: CellValue): string | null {
|
| 678 |
+
const s = String(v ?? "").trim();
|
| 679 |
+
if (!s) return null;
|
| 680 |
+
if (field.type === "url") {
|
| 681 |
+
if (/^https?:\/\//i.test(s)) return s;
|
| 682 |
+
if (/^[\w-]+(\.[\w-]+)+/.test(s)) return `https://${s}`;
|
| 683 |
+
return null;
|
| 684 |
+
}
|
| 685 |
+
if (field.type === "email")
|
| 686 |
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s) ? `mailto:${s}` : null;
|
| 687 |
+
if (field.type === "phone") {
|
| 688 |
+
const digits = s.replace(/[\s().-]/g, "");
|
| 689 |
+
return /^\+?\d{5,}$/.test(digits) ? `tel:${digits}` : null;
|
| 690 |
+
}
|
| 691 |
+
return null;
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
export function formatDisplay(field: Field, v: CellValue): string {
|
| 695 |
+
switch (field.type) {
|
| 696 |
+
case "currency":
|
| 697 |
+
return v == null || v === "" ? "" : "$" + numberText(num(v), field.format);
|
| 698 |
+
case "formula": {
|
| 699 |
+
// β A FORMULA MAY RETURN TEXT (owner item 2, 2026-07-31 β CONCATENATE, `&`, TEXT(),
|
| 700 |
+
// and any `IF(cond, "yes", "no")`). `makeCell` learned that; this function did not, and
|
| 701 |
+
// the two are supposed to be one rendering. So every surface that reads THIS one β
|
| 702 |
+
// ListView, KanbanView, the calendar's summary cells, the record-detail panel and ALL
|
| 703 |
+
// FOUR export formats β printed a text result as `0`, because `num("Buy now")` is NaN.
|
| 704 |
+
// The canvas showed the words and the file showed a zero, for the same cell.
|
| 705 |
+
//
|
| 706 |
+
// Found by rendering the owner's own Buy signal formula through the shipped bundle
|
| 707 |
+
// (_qa_owner_20260803). The branch below is `makeCell`'s test, verbatim: a non-blank,
|
| 708 |
+
// non-numeric STRING is its own display.
|
| 709 |
+
// The three states, in the one order that makes them total β see `formulaIsBlank`.
|
| 710 |
+
if (formulaIsBlank(v)) return "";
|
| 711 |
+
if (formulaIsText(v)) return v;
|
| 712 |
+
return numberText(num(v), field.format);
|
| 713 |
+
}
|
| 714 |
+
case "int":
|
| 715 |
+
return v == null || v === "" ? "" : numberText(num(v), field.format);
|
| 716 |
+
case "pct":
|
| 717 |
+
return v == null || v === "" ? "" : num(v).toFixed(1) + "%";
|
| 718 |
+
case "date":
|
| 719 |
+
case "created_time":
|
| 720 |
+
return dateTimeText(field, v);
|
| 721 |
+
case "checkbox":
|
| 722 |
+
return checkboxOn(v) ? "Checked" : "";
|
| 723 |
+
case "rating": {
|
| 724 |
+
const n = num(v);
|
| 725 |
+
return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
|
| 726 |
+
}
|
| 727 |
+
case "json":
|
| 728 |
+
// Wave-23 C7 β the SAME compact line the canvas cell paints. Explicit here rather than
|
| 729 |
+
// left to `default` for the reason the `formula` note above records: this function feeds
|
| 730 |
+
// ListView, KanbanView, the calendar, the record panel and all four EXPORT formats, and
|
| 731 |
+
// falling through would dump a whole 32 KB document into a CSV cell.
|
| 732 |
+
return jsonPreview(v);
|
| 733 |
+
case "code":
|
| 734 |
+
// β Wave-27 item 13 (R13) β the same compact line the canvas cell paints, and explicit
|
| 735 |
+
// for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
|
| 736 |
+
// would put raw newlines into a CSV cell, a kanban card and a calendar chip.
|
| 737 |
+
return codePreview(v);
|
| 738 |
+
case "ai_enrich":
|
| 739 |
+
// ββ Wave-34 (owner ruling R13) β an enrichment answer is model-authored PROSE and can run
|
| 740 |
+
// to several lines, so it is explicit here for exactly the reason `json` and `code` are:
|
| 741 |
+
// this function feeds ListView, KanbanView, the calendar, the record panel and all four
|
| 742 |
+
// EXPORT formats, and `default` would put raw newlines into a CSV cell.
|
| 743 |
+
// β `codePreview` is REUSED rather than given a prose-flavoured twin. Its "first line
|
| 744 |
+
// +N more" is already this product's one way of saying "a multi-line value in a one-line
|
| 745 |
+
// slot", and the count is checkable against what the drawer shows; a second helper would
|
| 746 |
+
// be a second answer to one question ([[one-question-two-normalizers]]).
|
| 747 |
+
return codePreview(v);
|
| 748 |
+
case "automation":
|
| 749 |
+
// The machine-written line, verbatim. Explicit rather than left to the `default` branch
|
| 750 |
+
// below: `formula` fell through a default once and printed every text result as `0` for
|
| 751 |
+
// months, and the lesson recorded there is that a type whose rendering is deliberate
|
| 752 |
+
// should SAY so where a reader looks for it.
|
| 753 |
+
return String(v ?? "");
|
| 754 |
+
case "metric":
|
| 755 |
+
// Wave-22 C7 β a server-computed number over the master snapshot series. BLANK IS A
|
| 756 |
+
// STATE, never zero: the engine sends "" when the window holds no snapshots, and
|
| 757 |
+
// rendering that as 0 would fabricate a measurement (the engine's own blank-never-zero
|
| 758 |
+
// law, kept at the display seam too).
|
| 759 |
+
return v == null || v === "" ? "" : numberText(num(v), field.format);
|
| 760 |
+
case "rollup": {
|
| 761 |
+
// β WAVE 29 (W29-T30) β THE MISSING CASE. `rollup` fell through to `default` and printed
|
| 762 |
+
// its raw fold string, so ONE cell rendered two ways: the canvas showed `1,491,552.43`
|
| 763 |
+
// (`cells.ts`'s rollup branch, comma-fixed 2026-08-10 on the owner's instruction) while
|
| 764 |
+
// every CSV, PDF, JSON-display and Excel export showed `1491552.43`. Same family as D-121
|
| 765 |
+
// (the record panel ignoring a field's format) β three surfaces, one concept.
|
| 766 |
+
//
|
| 767 |
+
// β THE BLANK RULE IS THE POINT, and it is why this is a guarded branch and not a call to
|
| 768 |
+
// `numberText(num(v), ...)`. `_rollup_fold` returns "" for "no rows to aggregate" and only
|
| 769 |
+
// the count family ever returns a real 0, so `num("")` β which is 0 β would paint the
|
| 770 |
+
// measurement the server just refused to invent. A fold that is not a number at all
|
| 771 |
+
// (`concatenate`, `arrayunique`, `latest` over text) keeps its own text.
|
| 772 |
+
// β Mirrors `cells.ts`'s branch line for line, deliberately: one concept, one rendering.
|
| 773 |
+
const raw = String(v ?? "");
|
| 774 |
+
const asNum = raw.trim() === "" ? NaN : Number(raw);
|
| 775 |
+
return Number.isFinite(asNum) ? numberText(asNum, field.format) : raw;
|
| 776 |
+
}
|
| 777 |
+
case "multiselect":
|
| 778 |
+
// The comma-joined SET, read back with breathing room ("A, B" not "A,B").
|
| 779 |
+
return String(v ?? "")
|
| 780 |
+
.split(",")
|
| 781 |
+
.map((s) => s.trim())
|
| 782 |
+
.filter((s) => s !== "")
|
| 783 |
+
.join(", ");
|
| 784 |
+
default:
|
| 785 |
+
return String(v ?? "");
|
| 786 |
+
}
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
// --- W40-T23: the two rules behind "typing a letter opens the picker" ---------
|
| 790 |
+
// MOVED here from cells.ts, for this module's founding reason and no other: cells.ts imports
|
| 791 |
+
// `GridCellKind` as a VALUE, so nothing in it can be reached by a node gate, and these two are
|
| 792 |
+
// exactly the kind of decision that can be wrong in ways a screenshot will never show β which
|
| 793 |
+
// keystroke counts as typing, and which choices a query keeps. cells.ts re-exports both, so
|
| 794 |
+
// every existing import keeps working; the bodies are the same bytes they were β this is a
|
| 795 |
+
// move, not a change.
|
| 796 |
+
//
|
| 797 |
+
// Their consumers are two DIFFERENT controls β the grid's anchored picker and the record
|
| 798 |
+
// drawer's chip row β which is the whole reason they are functions rather than expressions
|
| 799 |
+
// inside one handler. One rule, two surfaces, no second copy to drift.
|
| 800 |
+
|
| 801 |
+
/**
|
| 802 |
+
* The character a keystroke should SEED a choice picker with, or `null` when the keystroke is
|
| 803 |
+
* not somebody typing.
|
| 804 |
+
*
|
| 805 |
+
* β THE `null` CASES ARE THE CONTRACT, not the leftovers. `Ctrl+C` must still copy, `Enter`
|
| 806 |
+
* must still walk the column, `ContextMenu` must still open the header menu β each of those is
|
| 807 |
+
* a live branch of `onGridKeyDown` that this predicate has to decline before it runs.
|
| 808 |
+
*
|
| 809 |
+
* Β· one code point only, so every named key (`Enter`, `ArrowDown`, `Tab`, `F2`, `ContextMenu`)
|
| 810 |
+
* is rejected by LENGTH rather than by a list somebody has to keep complete;
|
| 811 |
+
* Β· no Ctrl / Meta / Alt, which is what keeps `Ctrl+C` a copy and `Alt+F` a menu;
|
| 812 |
+
* Β· SHIFT IS DELIBERATELY NOT TESTED β `Shift+V` is a capital V, and a guard copied from the
|
| 813 |
+
* Enter branch (where Shift means "go up") would ship a feature that works on `v` and not on
|
| 814 |
+
* `V`;
|
| 815 |
+
* Β· a SPACE is refused. It reads as "no filter" anyway once trimmed, and in the record drawer
|
| 816 |
+
* Space is the key that ACTIVATES the focused chip β a bare keystroke must never write a
|
| 817 |
+
* value.
|
| 818 |
+
*/
|
| 819 |
+
export function pickerTypeSeed(
|
| 820 |
+
key: string,
|
| 821 |
+
mods: { ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }
|
| 822 |
+
): string | null {
|
| 823 |
+
if (mods.ctrlKey || mods.metaKey || mods.altKey) return null;
|
| 824 |
+
// `[...key]` counts CODE POINTS, not UTF-16 units: an astral character arrives as a
|
| 825 |
+
// length-2 string and is one keystroke, so `key.length === 1` would silently refuse it.
|
| 826 |
+
const chars = [...key];
|
| 827 |
+
if (chars.length !== 1) return null;
|
| 828 |
+
if (key === " ") return null;
|
| 829 |
+
return key;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
/**
|
| 833 |
+
* Which choices survive a query. The house rule, verbatim from `FieldSelect.tsx`: trimmed,
|
| 834 |
+
* case-insensitive, SUBSTRING (not prefix) β so "v" finds "Delivered" as well as "Void", which
|
| 835 |
+
* is what a person means by "filter to options containing v".
|
| 836 |
+
*
|
| 837 |
+
* An empty (or all-whitespace) query returns the list unchanged, so the seeded and unseeded
|
| 838 |
+
* pickers are the same component in the same state rather than two code paths.
|
| 839 |
+
*
|
| 840 |
+
* β Returns a COPY, never the input array: the grid maps over the result every render, and a
|
| 841 |
+
* caller that sorted or spliced an alias of the field's own choice list would be editing the
|
| 842 |
+
* column definition through a filter.
|
| 843 |
+
*/
|
| 844 |
+
export function matchChoices(
|
| 845 |
+
choices: readonly string[],
|
| 846 |
+
query: string
|
| 847 |
+
): string[] {
|
| 848 |
+
const needle = query.trim().toLowerCase();
|
| 849 |
+
if (!needle) return [...choices];
|
| 850 |
+
return choices.filter((choice) => choice.toLowerCase().includes(needle));
|
| 851 |
+
}
|
| 852 |
+
|
| 853 |
+
// ---------------------------------------------------------------------------
|
| 854 |
+
// ββ W40-T24 β THE PRE-SET COLUMN'S DEFINITION LOCK, STATED ONCE
|
| 855 |
+
// ---------------------------------------------------------------------------
|
| 856 |
+
|
| 857 |
+
/** The four definition verbs the Edit-field pane and the column menu can offer. */
|
| 858 |
+
export interface ColumnDefinitionOffer {
|
| 859 |
+
/** "Change field" β point this column slot at a different field, or at a new one. */
|
| 860 |
+
swap: boolean;
|
| 861 |
+
/** The editable Name box in the Edit-field pane. */
|
| 862 |
+
rename: boolean;
|
| 863 |
+
/** The Field-type picker in the Edit-field pane. */
|
| 864 |
+
retype: boolean;
|
| 865 |
+
/** The "Delete field" row. Spelt `remove` only because `delete` reads as the operator. */
|
| 866 |
+
remove: boolean;
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
/**
|
| 870 |
+
* ββ W40-T24 (owner instruction 18, contract C2) β **WHICH DEFINITION CONTROLS DOES THIS
|
| 871 |
+
* COLUMN OFFER?** One function, one answer, so the four verbs cannot drift apart.
|
| 872 |
+
*
|
| 873 |
+
* Owner, verbatim: *"the Supplier field is wrong. Editable at record level, yes, but 'Edit
|
| 874 |
+
* field' offers a 'Choose field' dropdown that makes no sense. Audit every pre-set field so the
|
| 875 |
+
* structure is standard: a pre-set field's definition is not editable and shows no 'choose a
|
| 876 |
+
* field' dropdown."* The complaint arrived about Supplier and was never about Supplier: the swap
|
| 877 |
+
* picker was gated on `!locked` alone, and `locked` means "is the pinned primary column". Zero
|
| 878 |
+
* pre-set awareness, so EVERY contract column on every grid carried the same exposure, and a fix
|
| 879 |
+
* written for Supplier would have been the wrong shape of fix.
|
| 880 |
+
*
|
| 881 |
+
* ββ **THE LOCK IS `isUserSchemaField`, AND `isPresetField` CANNOT DO THIS JOB β MEASURED, NOT
|
| 882 |
+
* ASSUMED.** The obvious reach is for the predicate that carries the word, and it is wrong here.
|
| 883 |
+
* `platform/aios_grid_fields.json` declares NINE product_data contract columns β
|
| 884 |
+
* `supplier`, `origin_country`, `lead_days`, `first_cost`, `needs_pricing`, `march_pricelist`,
|
| 885 |
+
* `price_changes`, `closeouts`, `notes` β as `{"source": "overlay", "shared": true}` with no
|
| 886 |
+
* `custom` and no `preset` key. That is deliberate and it is what makes their CELLS typeable
|
| 887 |
+
* (`mayEditField` requires `source === "overlay"`), which is the owner's *"editable at record
|
| 888 |
+
* level, yes"*. Run `isPresetField` over any of them and it answers **false**: it recognises the
|
| 889 |
+
* Odoo-sourced stratum and the machine-owned one, and the overlay-stratum contract columns are
|
| 890 |
+
* neither. Gate the swap picker on it and Supplier β the column the instruction names β keeps
|
| 891 |
+
* the dropdown. β So the two predicates are NOT complements and the choice is not a matter of
|
| 892 |
+
* taste: one of them is false for the subject of the ticket.
|
| 893 |
+
*
|
| 894 |
+
* Β· `isUserSchemaField` β *is this column's shape THIS user's to change?* False for all nine
|
| 895 |
+
* of those, false for the Odoo-sourced columns (Category, Agent), false for a machine-owned
|
| 896 |
+
* automation column, and TRUE for a `custom_` overlay column the user made and for every
|
| 897 |
+
* `ut_*` shared-definition column. That is the pre-set line drawn exactly where C2 draws it.
|
| 898 |
+
* Β· `isPresetField` β *is this column filled FOR you?* Still the right predicate for the
|
| 899 |
+
* PRE-SET CHIP and the custom-field dot, which is what it was written for. It answers a
|
| 900 |
+
* question about where the VALUES come from; this lock is about who owns the DEFINITION, and
|
| 901 |
+
* the nine columns above are the proof that those are different sets.
|
| 902 |
+
*
|
| 903 |
+
* β **THE SWAP TAKES THE SAME PREDICATE AS RETYPE, WHICH IS THE POINT.** `CustomerGrid.
|
| 904 |
+
* changeField` only rewrites `config.order`/`config.visible`, so the swap is mechanically a view
|
| 905 |
+
* edit β but it sits INSIDE the Edit-field pane, and a pane whose Name box is disabled and whose
|
| 906 |
+
* type picker is absent while a third control offers to make this column something else is
|
| 907 |
+
* incoherent whatever the plumbing does. One predicate for all of the pane's definition verbs is
|
| 908 |
+
* what makes the pane say one thing.
|
| 909 |
+
*
|
| 910 |
+
* β **`rename` AND `retype` ARE REPORTED HERE, ENFORCED AT THE HOST β DELIBERATELY.** The host
|
| 911 |
+
* withholds the `onRename`/`onRetype` PROPS (`CustomerGrid.tsx`, the `onRetype=` and `onRename=`
|
| 912 |
+
* prop chains at the `<ColumnMenu>` render), and a control whose handler is undefined is not
|
| 913 |
+
* rendered at all. Enforcing the same refusal a second time inside the menu could only SUBTRACT
|
| 914 |
+
* from columns that work today β the Destination column's rename (owner item 3/6, 2026-08-23)
|
| 915 |
+
* reaches the pane through the route branch, not through the stratum β and two enforcement
|
| 916 |
+
* points that agree today are how one silently becomes the other's contradiction. So these two
|
| 917 |
+
* fields mirror the host's real rule, route carve-out included, and exist so a node gate can
|
| 918 |
+
* assert what the JSX does. If the host chain changes, this changes with it.
|
| 919 |
+
*
|
| 920 |
+
* β **`remove` IS THE LOCK'S VERDICT ONLY.** Whether a delete DOOR exists at all is a separate
|
| 921 |
+
* question the host answers three different ways (per-user overlay, `ut_*` shared definition,
|
| 922 |
+
* route order), and `ColumnMenu` keeps `onDelete &&` plus its `schemaLocked` prop beside this.
|
| 923 |
+
* AND-ing can only subtract, so nothing here duplicates `CustomerGrid.isSchemaLocked` β which is
|
| 924 |
+
* not exported, and copying it out would be D-413's disease in a fresh location.
|
| 925 |
+
* β THE ROLLUP CLAUSE IS AN OWNER RULING, NOT A HACK (2026-08-09): *"No rollup field should be
|
| 926 |
+
* uneditable, everything is custom and changeable always."* A rollup holds no data of its own β
|
| 927 |
+
* it is a question asked of other rows, and re-asking it costs nothing β so deleting a pre-set
|
| 928 |
+
* one destroys no measurement. `isSchemaLocked` carries the SAME exception in the same words
|
| 929 |
+
* (`&& field.type !== "rollup"`); dropping it here would revoke a live ruling by accident.
|
| 930 |
+
*
|
| 931 |
+
* @param opts.isUserTable the grid is a `ut_*` runtime database, not an Odoo-backed topic.
|
| 932 |
+
* @param opts.isPrimary this is the pinned primary column (the menu's `locked` prop) β the row
|
| 933 |
+
* identity, which can never be swapped away whatever else is true of it.
|
| 934 |
+
*/
|
| 935 |
+
export function columnDefinitionOffer(
|
| 936 |
+
field: Field,
|
| 937 |
+
opts: { isUserTable: boolean; isPrimary: boolean }
|
| 938 |
+
): ColumnDefinitionOffer {
|
| 939 |
+
const ownShape = isUserSchemaField(field, opts.isUserTable);
|
| 940 |
+
// The route order column's own rename and delete doors. Matched on the DECLARED `kind`, never
|
| 941 |
+
// on the `route_` key prefix β the server declares the kind precisely so nobody has to read a
|
| 942 |
+
// key as a type, and the host's own chain matches it the same way.
|
| 943 |
+
const routeOrder = field.shared === true && field.kind === "route_order" && !opts.isPrimary;
|
| 944 |
+
return {
|
| 945 |
+
swap: !opts.isPrimary && ownShape,
|
| 946 |
+
rename: ownShape || routeOrder,
|
| 947 |
+
retype: ownShape,
|
| 948 |
+
remove: ownShape || routeOrder || field.type === "rollup",
|
| 949 |
+
};
|
| 950 |
+
}
|
web/src/customer-grid/export.ts
CHANGED
|
@@ -27,7 +27,7 @@
|
|
| 27 |
// the installed build) β so a Blob URL + a[download] click works in the embed.
|
| 28 |
// ---------------------------------------------------------------------------
|
| 29 |
|
| 30 |
-
import type { Field, Row } from "./types";
|
| 31 |
import {
|
| 32 |
formatDisplay,
|
| 33 |
formulaIsBlank,
|
|
@@ -47,6 +47,108 @@ export const EXPORT_LABELS: Record<ExportFormat, string> = {
|
|
| 47 |
json: "JSON",
|
| 48 |
};
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
// ------------------------------------------------------------------ filename
|
| 51 |
|
| 52 |
/** `<view or cohort name> - <today>.<ext>` β `today` is the PAYLOAD's string
|
|
@@ -319,12 +421,18 @@ function buildStyles(codes: string[]): string {
|
|
| 319 |
/** A minimal SpreadsheetML workbook: one sheet, NUMERIC kinds written as real
|
| 320 |
* numbers with a number format that reproduces the grid's rendering, everything
|
| 321 |
* else an inline string carrying the DISPLAY value (contract C2 β what the grid
|
| 322 |
-
* shows is what the file says).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
export function buildXlsx(
|
| 324 |
fields: Field[],
|
| 325 |
rows: Row[],
|
| 326 |
name: string,
|
| 327 |
-
today: string | undefined
|
|
|
|
| 328 |
): Uint8Array {
|
| 329 |
const enc = new TextEncoder();
|
| 330 |
const styles = styleTable();
|
|
@@ -378,11 +486,45 @@ export function buildXlsx(
|
|
| 378 |
}
|
| 379 |
|
| 380 |
const rowXml = (cells: string[]) => `<row>${cells.join("")}</row>`;
|
| 381 |
-
const
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
const sheet =
|
| 387 |
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
| 388 |
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
|
|
@@ -590,13 +732,20 @@ export function triggerDownload(filename: string, blob: Blob): void {
|
|
| 590 |
window.setTimeout(() => URL.revokeObjectURL(url), 4000);
|
| 591 |
}
|
| 592 |
|
| 593 |
-
/** One door for all four formats: build, wrap, download.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
export function runExport(
|
| 595 |
format: ExportFormat,
|
| 596 |
name: string,
|
| 597 |
today: string | undefined,
|
| 598 |
fields: Field[],
|
| 599 |
-
rows: Row[]
|
|
|
|
| 600 |
): void {
|
| 601 |
const filename = exportFilename(name, today, format);
|
| 602 |
if (format === "csv") {
|
|
@@ -617,7 +766,7 @@ export function runExport(
|
|
| 617 |
if (format === "xlsx") {
|
| 618 |
triggerDownload(
|
| 619 |
filename,
|
| 620 |
-
new Blob([buildXlsx(fields, rows, name, today).buffer as ArrayBuffer], {
|
| 621 |
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 622 |
})
|
| 623 |
);
|
|
|
|
| 27 |
// the installed build) β so a Blob URL + a[download] click works in the embed.
|
| 28 |
// ---------------------------------------------------------------------------
|
| 29 |
|
| 30 |
+
import type { Field, Row, VisibleRow } from "./types";
|
| 31 |
import {
|
| 32 |
formatDisplay,
|
| 33 |
formulaIsBlank,
|
|
|
|
| 47 |
json: "JSON",
|
| 48 |
};
|
| 49 |
|
| 50 |
+
// --------------------------------------------------------------- grouping
|
| 51 |
+
//
|
| 52 |
+
// β WAVE 40, OWNER INSTRUCTION 22 (W40-T26) β *"A grouped View exported to Excel
|
| 53 |
+
// must show the grouping in the Excel file."* Until now the export ran its
|
| 54 |
+
// pipeline with `groupBy` hardcoded to null, so a view grouped by City exported
|
| 55 |
+
// as one undifferentiated block and the file said nothing the screen said.
|
| 56 |
+
//
|
| 57 |
+
// β AMENDMENT AM-3 (2026-08-23) IS THE CONTRACT: this ships VISIBLE grouping,
|
| 58 |
+
// not COLLAPSIBLE grouping. A labelled band row carrying the group value and its
|
| 59 |
+
// count precedes each block. Excel's +/- outline gutter needs `sheetFormatPr`,
|
| 60 |
+
// `outlineLevel` and friends in a strict `CT_Worksheet` element order; that is
|
| 61 |
+
// booked as a follow-up rather than built here, and NOTHING below invents a new
|
| 62 |
+
// OOXML element β the bands are ordinary text rows through the existing
|
| 63 |
+
// `rowXml()`/`cell()` helpers, which is why the container's invariants hold.
|
| 64 |
+
|
| 65 |
+
/** One VISIBLE group block: the bucket the pipeline formed, its band text and
|
| 66 |
+
* the rows that belong to it, in the order the grid paints them. */
|
| 67 |
+
export interface ExportGroup {
|
| 68 |
+
/** The bucket key exactly as `groupRows` formed it (the trimmed cell text; ""
|
| 69 |
+
* for the blank bucket, "1"/"" for a checkbox, ONE member of a multi cell). */
|
| 70 |
+
key: string;
|
| 71 |
+
/** The grid's own group-bar label for that key β "(empty)", "Checked",
|
| 72 |
+
* "Unchecked" or the cell text. Used verbatim when the key has no display
|
| 73 |
+
* rendering of its own, so the file never shows a nameless band. */
|
| 74 |
+
label: string;
|
| 75 |
+
/** The bucket's size, from the pipeline. Equal to `rows.length` here: the
|
| 76 |
+
* export always runs with an EMPTY collapsed set, so no bucket is folded. */
|
| 77 |
+
count: number;
|
| 78 |
+
rows: Row[];
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
/** The grouped shape handed to a builder. `field` is the column the view is
|
| 82 |
+
* grouped BY, so a band renders its value the same way that value renders in
|
| 83 |
+
* its own column (see `bandText`); absent if the field has gone missing. */
|
| 84 |
+
export interface ExportGrouping {
|
| 85 |
+
field?: Field;
|
| 86 |
+
blocks: ExportGroup[];
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
/**
|
| 90 |
+
* β WHICH FORMATS CARRY GROUPING, and it is a DECISION rather than an accident.
|
| 91 |
+
*
|
| 92 |
+
* EXCEL ONLY. The owner's words are "exported to Excel" and the ticket's
|
| 93 |
+
* `done-when` names the xlsx; the other three are left exactly as they are, in
|
| 94 |
+
* both the grouped and ungrouped case:
|
| 95 |
+
*
|
| 96 |
+
* Β· JSON is a RAW-VALUE contract β `{ fields, rows }` where every row is a
|
| 97 |
+
* record carrying its stable pid. A band has no pid, and grouping a MULTI
|
| 98 |
+
* field lists one record under EVERY member of its cell, so feeding the
|
| 99 |
+
* bucketed walk into this builder would put duplicate pids into a
|
| 100 |
+
* machine-readable file. That is a data defect, not a feature.
|
| 101 |
+
* Β· PDF is a paginated table: a band row is a layout change (band fill, page
|
| 102 |
+
* breaks, the repeated header) of exactly the kind AM-3 defers.
|
| 103 |
+
* Β· CSV has no band vocabulary and its rows would silently REORDER.
|
| 104 |
+
*
|
| 105 |
+
* β It selects which BUILDER receives the grouped structure. It must never
|
| 106 |
+
* decide which ROWS were matched: `exportRows` runs its record pipeline
|
| 107 |
+
* identically for all four formats, so the same view exported twice always
|
| 108 |
+
* carries the same records.
|
| 109 |
+
*/
|
| 110 |
+
export function groupingApplies(format: ExportFormat): boolean {
|
| 111 |
+
return format === "xlsx";
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* ββ THE SENTINEL WALK β the half of instruction 22 that is invisible from the
|
| 116 |
+
* outside, and the reason unhardcoding a `groupBy` on its own changes NOTHING.
|
| 117 |
+
*
|
| 118 |
+
* A grouped pipeline result is not a tree. It is a FLAT `VisibleRow[]`
|
| 119 |
+
* interleaving `data` rows with `group-header` / `group-footer` sentinels, and
|
| 120 |
+
* every caller in the grid narrows it with a `kind === "data"` filter that
|
| 121 |
+
* throws all of them away. The export did the same. So: a header OPENS a block,
|
| 122 |
+
* a footer CLOSES it, and a data row joins whichever block is open.
|
| 123 |
+
*
|
| 124 |
+
* β A data row arriving with NO open block is skipped rather than inventing an
|
| 125 |
+
* unnamed bucket for it. `groupRows` always emits a header first, so this is a
|
| 126 |
+
* defensive branch and not a case in the product β but a nameless band is worse
|
| 127 |
+
* than a missing one, because it looks deliberate.
|
| 128 |
+
*
|
| 129 |
+
* β A footer only ever ENDS a block, so the table-wide total footer the grid
|
| 130 |
+
* appends under `TOTAL_GROUP_KEY` cannot open a phantom group here.
|
| 131 |
+
*
|
| 132 |
+
* Lives in `export.ts` rather than inline at the call site so a node gate can
|
| 133 |
+
* reach it: this walk is exactly the kind of thing that is wrong in a way no
|
| 134 |
+
* screenshot shows.
|
| 135 |
+
*/
|
| 136 |
+
export function toExportGroups(bucketed: VisibleRow[]): ExportGroup[] {
|
| 137 |
+
const blocks: ExportGroup[] = [];
|
| 138 |
+
let block: ExportGroup | null = null;
|
| 139 |
+
for (const vr of bucketed) {
|
| 140 |
+
if (vr.kind === "group-header") {
|
| 141 |
+
block = { key: vr.groupKey, label: vr.label, count: vr.count, rows: [] };
|
| 142 |
+
blocks.push(block);
|
| 143 |
+
} else if (vr.kind === "group-footer") {
|
| 144 |
+
block = null;
|
| 145 |
+
} else if (block) {
|
| 146 |
+
block.rows.push(vr.record);
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
return blocks;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
// ------------------------------------------------------------------ filename
|
| 153 |
|
| 154 |
/** `<view or cohort name> - <today>.<ext>` β `today` is the PAYLOAD's string
|
|
|
|
| 421 |
/** A minimal SpreadsheetML workbook: one sheet, NUMERIC kinds written as real
|
| 422 |
* numbers with a number format that reproduces the grid's rendering, everything
|
| 423 |
* else an inline string carrying the DISPLAY value (contract C2 β what the grid
|
| 424 |
+
* shows is what the file says).
|
| 425 |
+
*
|
| 426 |
+
* β W40-T26. `grouping` is OPTIONAL and absent means UNGROUPED: the body is
|
| 427 |
+
* then built from `rows` by the same expression as before, so an ungrouped
|
| 428 |
+
* export is byte-identical to the pre-grouping writer. Present, it replaces the
|
| 429 |
+
* flat body with band-then-block, and `rows` is not read. */
|
| 430 |
export function buildXlsx(
|
| 431 |
fields: Field[],
|
| 432 |
rows: Row[],
|
| 433 |
name: string,
|
| 434 |
+
today: string | undefined,
|
| 435 |
+
grouping?: ExportGrouping
|
| 436 |
): Uint8Array {
|
| 437 |
const enc = new TextEncoder();
|
| 438 |
const styles = styleTable();
|
|
|
|
| 486 |
}
|
| 487 |
|
| 488 |
const rowXml = (cells: string[]) => `<row>${cells.join("")}</row>`;
|
| 489 |
+
const dataRow = (r: Row) => rowXml(fields.map((f) => dataCell(f, r[f.key])));
|
| 490 |
+
|
| 491 |
+
/**
|
| 492 |
+
* β W40-T26 / AM-3 β THE BAND'S TEXT: the group's VALUE and its COUNT, and
|
| 493 |
+
* nothing else. Not a sentence about grouping (DESIGN.md Β§4), not the canvas's
|
| 494 |
+
* βΌ/βΆ marker (there is nothing to collapse in a file), no dash of any kind.
|
| 495 |
+
*
|
| 496 |
+
* β THE VALUE GOES THROUGH `formatDisplay`, THE SAME FUNCTION EVERY OTHER
|
| 497 |
+
* EXPORTED VALUE GOES THROUGH. A date bucket's key is the raw "2026-07-28" and
|
| 498 |
+
* that column's own cells arrive in Excel as serials rendered "Jul 28, 2026";
|
| 499 |
+
* printing the key verbatim would make one file say the same value two ways.
|
| 500 |
+
*
|
| 501 |
+
* β AND IT FALLS BACK, because `formatDisplay` returns "" for exactly the
|
| 502 |
+
* buckets that most need a name: the blank bucket ("" -> "(empty)") and an
|
| 503 |
+
* unchecked checkbox ("" -> "Unchecked"). The pipeline's own label is the
|
| 504 |
+
* grid's group-bar wording, so the fallback is the screen's answer rather than
|
| 505 |
+
* a second one invented here.
|
| 506 |
+
*/
|
| 507 |
+
const bandText = (g: ExportGroup): string => {
|
| 508 |
+
const shown = grouping?.field ? formatDisplay(grouping.field, g.key) : "";
|
| 509 |
+
return `${shown || g.label} (${g.count.toLocaleString()})`;
|
| 510 |
+
};
|
| 511 |
+
|
| 512 |
+
// The header row is LABELS β text, and it stays text.
|
| 513 |
+
const bodyRows = [rowXml(fields.map((f) => cell(f.label)))];
|
| 514 |
+
if (grouping) {
|
| 515 |
+
// A band is ONE text cell. Cells carry no `r` attribute in this writer, so
|
| 516 |
+
// position is order and a one-cell row lands in column A β the same
|
| 517 |
+
// mechanism every other row here already relies on. No merge, no span:
|
| 518 |
+
// `mergeCells` is a new CT_Worksheet element and AM-3 defers that class of
|
| 519 |
+
// change.
|
| 520 |
+
for (const g of grouping.blocks) {
|
| 521 |
+
bodyRows.push(rowXml([cell(bandText(g))]));
|
| 522 |
+
for (const r of g.rows) bodyRows.push(dataRow(r));
|
| 523 |
+
}
|
| 524 |
+
} else {
|
| 525 |
+
for (const r of rows) bodyRows.push(dataRow(r));
|
| 526 |
+
}
|
| 527 |
+
const body = bodyRows.join("");
|
| 528 |
const sheet =
|
| 529 |
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
| 530 |
`<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">` +
|
|
|
|
| 732 |
window.setTimeout(() => URL.revokeObjectURL(url), 4000);
|
| 733 |
}
|
| 734 |
|
| 735 |
+
/** One door for all four formats: build, wrap, download.
|
| 736 |
+
*
|
| 737 |
+
* β W40-T26. `grouping` reaches the xlsx builder and nothing else β see
|
| 738 |
+
* `groupingApplies` for why, and note that the CALLER has already decided: it
|
| 739 |
+
* passes `undefined` for every format that does not carry bands, so the three
|
| 740 |
+
* other builders are called with exactly the arguments they were called with
|
| 741 |
+
* before this ticket existed. */
|
| 742 |
export function runExport(
|
| 743 |
format: ExportFormat,
|
| 744 |
name: string,
|
| 745 |
today: string | undefined,
|
| 746 |
fields: Field[],
|
| 747 |
+
rows: Row[],
|
| 748 |
+
grouping?: ExportGrouping
|
| 749 |
): void {
|
| 750 |
const filename = exportFilename(name, today, format);
|
| 751 |
if (format === "csv") {
|
|
|
|
| 766 |
if (format === "xlsx") {
|
| 767 |
triggerDownload(
|
| 768 |
filename,
|
| 769 |
+
new Blob([buildXlsx(fields, rows, name, today, grouping).buffer as ArrayBuffer], {
|
| 770 |
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 771 |
})
|
| 772 |
);
|
web/src/customer-grid/folders.ts
CHANGED
|
@@ -1,492 +1,572 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / folders.ts
|
| 3 |
-
// Wave-8 I11c (contract C4) β the folder MODEL for the Views and Cohorts rails,
|
| 4 |
-
// pure and React-free so verify_folders.py can run it under node.
|
| 5 |
-
//
|
| 6 |
-
// One level, deliberately. Nesting brings cycle-checking, move-into-your-own-
|
| 7 |
-
// descendant, and recursive delete semantics with it; the owner asked for
|
| 8 |
-
// folders you can drag things into, and a flat model is the whole of that.
|
| 9 |
-
//
|
| 10 |
-
// THE PART THAT IS EASY TO GET WRONG β the echo. Folder operations ride the
|
| 11 |
-
// same once-by-id event log as everything else, so between the emit and the
|
| 12 |
-
// host's echo there is a window where the payload still describes the world as
|
| 13 |
-
// it was BEFORE the click. Render that naively and a just-deleted folder
|
| 14 |
-
// reappears for one round trip (the "delete blip"), a rename flickers back to
|
| 15 |
-
// the old name, and a dragged view jumps home. So this module reconciles the
|
| 16 |
-
// host's copy against this browser's own recent stamps, exactly as
|
| 17 |
-
// optimism.ts::reconcileFields does for fields β same ECHO_RECENT_MS window,
|
| 18 |
-
// same rule that a STALE stamp yields to the host (divergence is not an echo).
|
| 19 |
-
// ---------------------------------------------------------------------------
|
| 20 |
-
|
| 21 |
-
import { ECHO_RECENT_MS } from "./viewEcho";
|
| 22 |
-
import type { GridFolder } from "./types";
|
| 23 |
-
|
| 24 |
-
/** What this browser did recently, by folder id / item id. Values are epoch ms
|
| 25 |
-
* from THIS machine's clock β both sides of every comparison are local, so
|
| 26 |
-
* this is not the tenant-day rule (that one is about two ENGINES agreeing). */
|
| 27 |
-
export interface FolderStamps {
|
| 28 |
-
created?: Record<string, number>;
|
| 29 |
-
renamed?: Record<string, { at: number; name: string }>;
|
| 30 |
-
deleted?: Record<string, number>;
|
| 31 |
-
/** itemId -> {at, folderId} for a drag this browser just performed. */
|
| 32 |
-
moved?: Record<string, { at: number; folderId: string | null }>;
|
| 33 |
-
/**
|
| 34 |
-
* WAVE 20 item 19 (C-FOLDER-REORDER) β the FULL folder order this browser just set.
|
| 35 |
-
*
|
| 36 |
-
* ONE stamp, not one per folder, because a reorder is one decision about a list: the
|
| 37 |
-
* order the user dropped into is the order they want, and reconstructing it from N
|
| 38 |
-
* per-folder stamps would let two of them age out at different moments and leave a
|
| 39 |
-
* sequence nobody ever chose. The host answers with `order` NUMBERS on each folder
|
| 40 |
-
* (that is the durable form); this is what to render until it does.
|
| 41 |
-
*/
|
| 42 |
-
ordered?: { at: number; order: string[] };
|
| 43 |
-
/**
|
| 44 |
-
* β WAVE 27 Β· OWNER ITEM 5 (contract C7) β the FULL VIEW order this browser just set.
|
| 45 |
-
*
|
| 46 |
-
* A separate stamp from `ordered` above, deliberately, even though both are "a list this
|
| 47 |
-
* browser dragged into shape": they age independently and they are different decisions. One
|
| 48 |
-
* stamp holding both would make reordering a folder revive a view order the user had already
|
| 49 |
-
* let go of, and vice versa β the exact "a sequence nobody ever chose" failure `ordered`'s own
|
| 50 |
-
* note refuses one level down.
|
| 51 |
-
*/
|
| 52 |
-
orderedViews?: { at: number; order: string[] };
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
export const FOLDER_STAMP_MAX = 64;
|
| 56 |
-
|
| 57 |
-
const isRecent = (t: number | undefined, now: number): boolean =>
|
| 58 |
-
typeof t === "number" && now - t <= ECHO_RECENT_MS;
|
| 59 |
-
|
| 60 |
-
/** Drop stamps past the echo window so the map cannot grow without bound and a
|
| 61 |
-
* long-lived tab cannot keep asserting an edit nobody remembers. */
|
| 62 |
-
export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number): FolderStamps {
|
| 63 |
-
const out: FolderStamps = {};
|
| 64 |
-
const keepNum = (rec: Record<string, number> | undefined) => {
|
| 65 |
-
if (!rec) return undefined;
|
| 66 |
-
const kept = Object.entries(rec).filter(([, t]) => isRecent(t, now));
|
| 67 |
-
return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
|
| 68 |
-
};
|
| 69 |
-
const keepObj = <T extends { at: number }>(rec: Record<string, T> | undefined) => {
|
| 70 |
-
if (!rec) return undefined;
|
| 71 |
-
const kept = Object.entries(rec).filter(([, v]) => isRecent(v.at, now));
|
| 72 |
-
return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
|
| 73 |
-
};
|
| 74 |
-
const created = keepNum(stamps?.created);
|
| 75 |
-
const renamed = keepObj(stamps?.renamed);
|
| 76 |
-
const deleted = keepNum(stamps?.deleted);
|
| 77 |
-
const moved = keepObj(stamps?.moved);
|
| 78 |
-
if (created) out.created = created;
|
| 79 |
-
if (renamed) out.renamed = renamed;
|
| 80 |
-
if (deleted) out.deleted = deleted;
|
| 81 |
-
if (moved) out.moved = moved;
|
| 82 |
-
// Item 19: a single stamp, so it is kept or dropped whole β pruning it by halves is
|
| 83 |
-
// exactly the partial sequence the field's own note refuses.
|
| 84 |
-
if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
|
| 85 |
-
// Item 5 (C7): the same rule for the VIEW order, kept or dropped whole for the same reason.
|
| 86 |
-
if (isRecent(stamps?.orderedViews?.at, now) && stamps?.orderedViews)
|
| 87 |
-
out.orderedViews = stamps.orderedViews;
|
| 88 |
-
return out;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
/**
|
| 92 |
-
* β WAVE 27 Β· OWNER ITEM 5 (contract C7) β the rail's view order, as this browser last set it.
|
| 93 |
-
*
|
| 94 |
-
* β WHY IT IS NEEDED AT ALL: the server assembles view order (`aios_grid.py:1602-1647`) and the
|
| 95 |
-
* echo is one round trip behind the drop. Without this the row springs back to its old place the
|
| 96 |
-
* instant the workspace refreshes, which reads as "the drag did not work" β the NO-BLIP law's
|
| 97 |
-
* subject, applied to a sequence instead of to a value.
|
| 98 |
-
*
|
| 99 |
-
* β PAST THE ECHO WINDOW THE SERVER WINS, unconditionally. That asymmetry is the whole design of
|
| 100 |
-
* this module: inside the window a local drag is newer truth; outside it, a difference between
|
| 101 |
-
* the copies is divergence between SESSIONS, and the durable store decides.
|
| 102 |
-
*
|
| 103 |
-
* β IDS THE STAMP DOES NOT NAME KEEP THEIR SERVER ORDER, appended after the named ones β the
|
| 104 |
-
* same rule the host applies to a `folder_reorder` payload. A view created in another tab since
|
| 105 |
-
* the drop is not evidence that the drop was wrong; dropping it would be this function deleting
|
| 106 |
-
* a view from the rail to defend a sequence.
|
| 107 |
-
*/
|
| 108 |
-
export function applyViewOrder<T extends { id: string }>(
|
| 109 |
-
views: T[],
|
| 110 |
-
stamps: FolderStamps | undefined,
|
| 111 |
-
now: number
|
| 112 |
-
): T[] {
|
| 113 |
-
const stamp = stamps?.orderedViews;
|
| 114 |
-
if (!stamp || !isRecent(stamp.at, now) || !Array.isArray(stamp.order)) return views;
|
| 115 |
-
const byId = new Map(views.map((v) => [v.id, v]));
|
| 116 |
-
const out: T[] = [];
|
| 117 |
-
const placed = new Set<string>();
|
| 118 |
-
for (const id of stamp.order) {
|
| 119 |
-
const v = byId.get(id);
|
| 120 |
-
if (!v || placed.has(id)) continue;
|
| 121 |
-
placed.add(id);
|
| 122 |
-
out.push(v);
|
| 123 |
-
}
|
| 124 |
-
for (const v of views) if (!placed.has(v.id)) out.push(v);
|
| 125 |
-
return out;
|
| 126 |
-
}
|
| 127 |
-
|
| 128 |
-
/**
|
| 129 |
-
* The host's folder list, corrected by what this browser just did.
|
| 130 |
-
*
|
| 131 |
-
* deleted recently -> DROP it, even though the echo still lists it
|
| 132 |
-
* (the tombstone rule; without this a deleted folder
|
| 133 |
-
* blinks back for one round trip)
|
| 134 |
-
* renamed recently -> keep OUR name until the echo carries it
|
| 135 |
-
* created recently -> keep OURS if the echo has not caught up yet
|
| 136 |
-
*
|
| 137 |
-
* Everything stale yields to the host: past the window, a difference between
|
| 138 |
-
* the copies is divergence between sessions, and host state is the durable
|
| 139 |
-
* truth. That asymmetry is the whole design.
|
| 140 |
-
*/
|
| 141 |
-
export function reconcileFolders(
|
| 142 |
-
hostFolders: GridFolder[] | undefined,
|
| 143 |
-
localFolders: GridFolder[] | undefined,
|
| 144 |
-
stamps: FolderStamps | undefined,
|
| 145 |
-
now: number
|
| 146 |
-
): GridFolder[] {
|
| 147 |
-
const host = hostFolders ?? [];
|
| 148 |
-
const out: GridFolder[] = [];
|
| 149 |
-
const seen = new Set<string>();
|
| 150 |
-
|
| 151 |
-
for (const f of host) {
|
| 152 |
-
if (isRecent(stamps?.deleted?.[f.id], now)) continue; // tombstone
|
| 153 |
-
seen.add(f.id);
|
| 154 |
-
const rename = stamps?.renamed?.[f.id];
|
| 155 |
-
out.push(isRecent(rename?.at, now) && rename ? { ...f, name: rename.name } : f);
|
| 156 |
-
}
|
| 157 |
-
|
| 158 |
-
// A folder this browser created that the echo has not yet returned. Skipped
|
| 159 |
-
// when it was also deleted since β creating and deleting inside one window
|
| 160 |
-
// must net to nothing, not to a ghost.
|
| 161 |
-
for (const f of localFolders ?? []) {
|
| 162 |
-
if (seen.has(f.id)) continue;
|
| 163 |
-
if (!isRecent(stamps?.created?.[f.id], now)) continue;
|
| 164 |
-
if (isRecent(stamps?.deleted?.[f.id], now)) continue;
|
| 165 |
-
out.push(f);
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
|
| 169 |
-
|
| 170 |
-
// ββ WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
|
| 171 |
-
//
|
| 172 |
-
// Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
|
| 173 |
-
// Β· the host's `order` numbers are the durable truth and stay the base sequence, so a
|
| 174 |
-
// folder the stamp never names keeps exactly the place the server gave it;
|
| 175 |
-
// Β· `Array.prototype.sort` is stable (ES2019), so every unnamed folder β one created in
|
| 176 |
-
// another tab between the drag and the echo, say β holds its relative position at the
|
| 177 |
-
// end instead of being flung to the front by a missing rank.
|
| 178 |
-
// A stamped id that has since been DELETED needs no handling: the tombstone pass above
|
| 179 |
-
// already dropped it, and `rank` is only ever consulted for folders that survived.
|
| 180 |
-
const ordered = stamps?.ordered;
|
| 181 |
-
if (isRecent(ordered?.at, now) && ordered) {
|
| 182 |
-
const rank = new Map(ordered.order.map((id, i) => [id, i]));
|
| 183 |
-
out.sort(
|
| 184 |
-
(a, b) =>
|
| 185 |
-
(rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
|
| 186 |
-
(rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
|
| 187 |
-
);
|
| 188 |
-
}
|
| 189 |
-
return out;
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
-
/**
|
| 193 |
-
* Where an item actually belongs right now: this browser's recent drag wins
|
| 194 |
-
* over the host's echo, and a folder that no longer exists resolves to ROOT.
|
| 195 |
-
*
|
| 196 |
-
* The second half matters as much as the first. `folder_delete` moves contents
|
| 197 |
-
* to root host-side, but the client sees the folder vanish one render before
|
| 198 |
-
* the items' `folderId` is rewritten β and an item pointing at a folder nobody
|
| 199 |
-
* renders would simply not appear in any group. Resolving a dangling ref to
|
| 200 |
-
* root is what stops a folder delete from making views look deleted too.
|
| 201 |
-
*/
|
| 202 |
-
export function resolveFolderId(
|
| 203 |
-
itemId: string,
|
| 204 |
-
hostFolderId: string | null | undefined,
|
| 205 |
-
folders: GridFolder[],
|
| 206 |
-
stamps: FolderStamps | undefined,
|
| 207 |
-
now: number
|
| 208 |
-
): string | null {
|
| 209 |
-
const moved = stamps?.moved?.[itemId];
|
| 210 |
-
const id = isRecent(moved?.at, now) && moved ? moved.folderId : (hostFolderId ?? null);
|
| 211 |
-
if (id == null) return null;
|
| 212 |
-
// β W32-T27 β the RESERVED root placement is not a dangling reference. It names no folder by
|
| 213 |
-
// design, so the `folders.some(...)` test below would null it and hand the item straight back
|
| 214 |
-
// to the Shared bucket, re-creating item 20 one layer down from where it was fixed.
|
| 215 |
-
if (id === ROOT_FOLDER_ID) return ROOT_FOLDER_ID;
|
| 216 |
-
return folders.some((f) => f.id === id) ? id : null;
|
| 217 |
-
}
|
| 218 |
-
|
| 219 |
-
export interface FolderGroup<T> {
|
| 220 |
-
folder: GridFolder | null; // null = the root group
|
| 221 |
-
items: T[];
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
*
|
| 244 |
-
*
|
| 245 |
-
*
|
| 246 |
-
*
|
| 247 |
-
*
|
| 248 |
-
*
|
| 249 |
-
*
|
| 250 |
-
*
|
| 251 |
-
*
|
| 252 |
-
*
|
| 253 |
-
*
|
| 254 |
-
*
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
*
|
| 260 |
-
*
|
| 261 |
-
*
|
| 262 |
-
*
|
| 263 |
-
*
|
| 264 |
-
*
|
| 265 |
-
*
|
| 266 |
-
*
|
| 267 |
-
*
|
| 268 |
-
*
|
| 269 |
-
*
|
| 270 |
-
*
|
| 271 |
-
*
|
| 272 |
-
*
|
| 273 |
-
*
|
| 274 |
-
*
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
*
|
| 279 |
-
*
|
| 280 |
-
*
|
| 281 |
-
*
|
| 282 |
-
*
|
| 283 |
-
*
|
| 284 |
-
*
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
*
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
*
|
| 300 |
-
*
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
*
|
| 307 |
-
*
|
| 308 |
-
*
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
*
|
| 316 |
-
*
|
| 317 |
-
*
|
| 318 |
-
*
|
| 319 |
-
*
|
| 320 |
-
*
|
| 321 |
-
*
|
| 322 |
-
*
|
| 323 |
-
*
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
*
|
| 328 |
-
*
|
| 329 |
-
*
|
| 330 |
-
*
|
| 331 |
-
*
|
| 332 |
-
*
|
| 333 |
-
*
|
| 334 |
-
*
|
| 335 |
-
*
|
| 336 |
-
*
|
| 337 |
-
*
|
| 338 |
-
*
|
| 339 |
-
*
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
*
|
| 344 |
-
*
|
| 345 |
-
*
|
| 346 |
-
*
|
| 347 |
-
*
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
*
|
| 360 |
-
|
| 361 |
-
*
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
/
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
/
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / folders.ts
|
| 3 |
+
// Wave-8 I11c (contract C4) β the folder MODEL for the Views and Cohorts rails,
|
| 4 |
+
// pure and React-free so verify_folders.py can run it under node.
|
| 5 |
+
//
|
| 6 |
+
// One level, deliberately. Nesting brings cycle-checking, move-into-your-own-
|
| 7 |
+
// descendant, and recursive delete semantics with it; the owner asked for
|
| 8 |
+
// folders you can drag things into, and a flat model is the whole of that.
|
| 9 |
+
//
|
| 10 |
+
// THE PART THAT IS EASY TO GET WRONG β the echo. Folder operations ride the
|
| 11 |
+
// same once-by-id event log as everything else, so between the emit and the
|
| 12 |
+
// host's echo there is a window where the payload still describes the world as
|
| 13 |
+
// it was BEFORE the click. Render that naively and a just-deleted folder
|
| 14 |
+
// reappears for one round trip (the "delete blip"), a rename flickers back to
|
| 15 |
+
// the old name, and a dragged view jumps home. So this module reconciles the
|
| 16 |
+
// host's copy against this browser's own recent stamps, exactly as
|
| 17 |
+
// optimism.ts::reconcileFields does for fields β same ECHO_RECENT_MS window,
|
| 18 |
+
// same rule that a STALE stamp yields to the host (divergence is not an echo).
|
| 19 |
+
// ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
import { ECHO_RECENT_MS } from "./viewEcho";
|
| 22 |
+
import type { GridFolder } from "./types";
|
| 23 |
+
|
| 24 |
+
/** What this browser did recently, by folder id / item id. Values are epoch ms
|
| 25 |
+
* from THIS machine's clock β both sides of every comparison are local, so
|
| 26 |
+
* this is not the tenant-day rule (that one is about two ENGINES agreeing). */
|
| 27 |
+
export interface FolderStamps {
|
| 28 |
+
created?: Record<string, number>;
|
| 29 |
+
renamed?: Record<string, { at: number; name: string }>;
|
| 30 |
+
deleted?: Record<string, number>;
|
| 31 |
+
/** itemId -> {at, folderId} for a drag this browser just performed. */
|
| 32 |
+
moved?: Record<string, { at: number; folderId: string | null }>;
|
| 33 |
+
/**
|
| 34 |
+
* WAVE 20 item 19 (C-FOLDER-REORDER) β the FULL folder order this browser just set.
|
| 35 |
+
*
|
| 36 |
+
* ONE stamp, not one per folder, because a reorder is one decision about a list: the
|
| 37 |
+
* order the user dropped into is the order they want, and reconstructing it from N
|
| 38 |
+
* per-folder stamps would let two of them age out at different moments and leave a
|
| 39 |
+
* sequence nobody ever chose. The host answers with `order` NUMBERS on each folder
|
| 40 |
+
* (that is the durable form); this is what to render until it does.
|
| 41 |
+
*/
|
| 42 |
+
ordered?: { at: number; order: string[] };
|
| 43 |
+
/**
|
| 44 |
+
* β WAVE 27 Β· OWNER ITEM 5 (contract C7) β the FULL VIEW order this browser just set.
|
| 45 |
+
*
|
| 46 |
+
* A separate stamp from `ordered` above, deliberately, even though both are "a list this
|
| 47 |
+
* browser dragged into shape": they age independently and they are different decisions. One
|
| 48 |
+
* stamp holding both would make reordering a folder revive a view order the user had already
|
| 49 |
+
* let go of, and vice versa β the exact "a sequence nobody ever chose" failure `ordered`'s own
|
| 50 |
+
* note refuses one level down.
|
| 51 |
+
*/
|
| 52 |
+
orderedViews?: { at: number; order: string[] };
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export const FOLDER_STAMP_MAX = 64;
|
| 56 |
+
|
| 57 |
+
const isRecent = (t: number | undefined, now: number): boolean =>
|
| 58 |
+
typeof t === "number" && now - t <= ECHO_RECENT_MS;
|
| 59 |
+
|
| 60 |
+
/** Drop stamps past the echo window so the map cannot grow without bound and a
|
| 61 |
+
* long-lived tab cannot keep asserting an edit nobody remembers. */
|
| 62 |
+
export function pruneFolderStamps(stamps: FolderStamps | undefined, now: number): FolderStamps {
|
| 63 |
+
const out: FolderStamps = {};
|
| 64 |
+
const keepNum = (rec: Record<string, number> | undefined) => {
|
| 65 |
+
if (!rec) return undefined;
|
| 66 |
+
const kept = Object.entries(rec).filter(([, t]) => isRecent(t, now));
|
| 67 |
+
return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
|
| 68 |
+
};
|
| 69 |
+
const keepObj = <T extends { at: number }>(rec: Record<string, T> | undefined) => {
|
| 70 |
+
if (!rec) return undefined;
|
| 71 |
+
const kept = Object.entries(rec).filter(([, v]) => isRecent(v.at, now));
|
| 72 |
+
return kept.length ? Object.fromEntries(kept.slice(-FOLDER_STAMP_MAX)) : undefined;
|
| 73 |
+
};
|
| 74 |
+
const created = keepNum(stamps?.created);
|
| 75 |
+
const renamed = keepObj(stamps?.renamed);
|
| 76 |
+
const deleted = keepNum(stamps?.deleted);
|
| 77 |
+
const moved = keepObj(stamps?.moved);
|
| 78 |
+
if (created) out.created = created;
|
| 79 |
+
if (renamed) out.renamed = renamed;
|
| 80 |
+
if (deleted) out.deleted = deleted;
|
| 81 |
+
if (moved) out.moved = moved;
|
| 82 |
+
// Item 19: a single stamp, so it is kept or dropped whole β pruning it by halves is
|
| 83 |
+
// exactly the partial sequence the field's own note refuses.
|
| 84 |
+
if (isRecent(stamps?.ordered?.at, now) && stamps?.ordered) out.ordered = stamps.ordered;
|
| 85 |
+
// Item 5 (C7): the same rule for the VIEW order, kept or dropped whole for the same reason.
|
| 86 |
+
if (isRecent(stamps?.orderedViews?.at, now) && stamps?.orderedViews)
|
| 87 |
+
out.orderedViews = stamps.orderedViews;
|
| 88 |
+
return out;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
/**
|
| 92 |
+
* β WAVE 27 Β· OWNER ITEM 5 (contract C7) β the rail's view order, as this browser last set it.
|
| 93 |
+
*
|
| 94 |
+
* β WHY IT IS NEEDED AT ALL: the server assembles view order (`aios_grid.py:1602-1647`) and the
|
| 95 |
+
* echo is one round trip behind the drop. Without this the row springs back to its old place the
|
| 96 |
+
* instant the workspace refreshes, which reads as "the drag did not work" β the NO-BLIP law's
|
| 97 |
+
* subject, applied to a sequence instead of to a value.
|
| 98 |
+
*
|
| 99 |
+
* β PAST THE ECHO WINDOW THE SERVER WINS, unconditionally. That asymmetry is the whole design of
|
| 100 |
+
* this module: inside the window a local drag is newer truth; outside it, a difference between
|
| 101 |
+
* the copies is divergence between SESSIONS, and the durable store decides.
|
| 102 |
+
*
|
| 103 |
+
* β IDS THE STAMP DOES NOT NAME KEEP THEIR SERVER ORDER, appended after the named ones β the
|
| 104 |
+
* same rule the host applies to a `folder_reorder` payload. A view created in another tab since
|
| 105 |
+
* the drop is not evidence that the drop was wrong; dropping it would be this function deleting
|
| 106 |
+
* a view from the rail to defend a sequence.
|
| 107 |
+
*/
|
| 108 |
+
export function applyViewOrder<T extends { id: string }>(
|
| 109 |
+
views: T[],
|
| 110 |
+
stamps: FolderStamps | undefined,
|
| 111 |
+
now: number
|
| 112 |
+
): T[] {
|
| 113 |
+
const stamp = stamps?.orderedViews;
|
| 114 |
+
if (!stamp || !isRecent(stamp.at, now) || !Array.isArray(stamp.order)) return views;
|
| 115 |
+
const byId = new Map(views.map((v) => [v.id, v]));
|
| 116 |
+
const out: T[] = [];
|
| 117 |
+
const placed = new Set<string>();
|
| 118 |
+
for (const id of stamp.order) {
|
| 119 |
+
const v = byId.get(id);
|
| 120 |
+
if (!v || placed.has(id)) continue;
|
| 121 |
+
placed.add(id);
|
| 122 |
+
out.push(v);
|
| 123 |
+
}
|
| 124 |
+
for (const v of views) if (!placed.has(v.id)) out.push(v);
|
| 125 |
+
return out;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
/**
|
| 129 |
+
* The host's folder list, corrected by what this browser just did.
|
| 130 |
+
*
|
| 131 |
+
* deleted recently -> DROP it, even though the echo still lists it
|
| 132 |
+
* (the tombstone rule; without this a deleted folder
|
| 133 |
+
* blinks back for one round trip)
|
| 134 |
+
* renamed recently -> keep OUR name until the echo carries it
|
| 135 |
+
* created recently -> keep OURS if the echo has not caught up yet
|
| 136 |
+
*
|
| 137 |
+
* Everything stale yields to the host: past the window, a difference between
|
| 138 |
+
* the copies is divergence between sessions, and host state is the durable
|
| 139 |
+
* truth. That asymmetry is the whole design.
|
| 140 |
+
*/
|
| 141 |
+
export function reconcileFolders(
|
| 142 |
+
hostFolders: GridFolder[] | undefined,
|
| 143 |
+
localFolders: GridFolder[] | undefined,
|
| 144 |
+
stamps: FolderStamps | undefined,
|
| 145 |
+
now: number
|
| 146 |
+
): GridFolder[] {
|
| 147 |
+
const host = hostFolders ?? [];
|
| 148 |
+
const out: GridFolder[] = [];
|
| 149 |
+
const seen = new Set<string>();
|
| 150 |
+
|
| 151 |
+
for (const f of host) {
|
| 152 |
+
if (isRecent(stamps?.deleted?.[f.id], now)) continue; // tombstone
|
| 153 |
+
seen.add(f.id);
|
| 154 |
+
const rename = stamps?.renamed?.[f.id];
|
| 155 |
+
out.push(isRecent(rename?.at, now) && rename ? { ...f, name: rename.name } : f);
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
// A folder this browser created that the echo has not yet returned. Skipped
|
| 159 |
+
// when it was also deleted since β creating and deleting inside one window
|
| 160 |
+
// must net to nothing, not to a ghost.
|
| 161 |
+
for (const f of localFolders ?? []) {
|
| 162 |
+
if (seen.has(f.id)) continue;
|
| 163 |
+
if (!isRecent(stamps?.created?.[f.id], now)) continue;
|
| 164 |
+
if (isRecent(stamps?.deleted?.[f.id], now)) continue;
|
| 165 |
+
out.push(f);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
out.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.name.localeCompare(b.name));
|
| 169 |
+
|
| 170 |
+
// ββ WAVE 20 item 19 (C-FOLDER-REORDER): this browser's drag, until the echo carries it.
|
| 171 |
+
//
|
| 172 |
+
// Applied AFTER the host sort and as a SEPARATE pass, both deliberately:
|
| 173 |
+
// Β· the host's `order` numbers are the durable truth and stay the base sequence, so a
|
| 174 |
+
// folder the stamp never names keeps exactly the place the server gave it;
|
| 175 |
+
// Β· `Array.prototype.sort` is stable (ES2019), so every unnamed folder β one created in
|
| 176 |
+
// another tab between the drag and the echo, say β holds its relative position at the
|
| 177 |
+
// end instead of being flung to the front by a missing rank.
|
| 178 |
+
// A stamped id that has since been DELETED needs no handling: the tombstone pass above
|
| 179 |
+
// already dropped it, and `rank` is only ever consulted for folders that survived.
|
| 180 |
+
const ordered = stamps?.ordered;
|
| 181 |
+
if (isRecent(ordered?.at, now) && ordered) {
|
| 182 |
+
const rank = new Map(ordered.order.map((id, i) => [id, i]));
|
| 183 |
+
out.sort(
|
| 184 |
+
(a, b) =>
|
| 185 |
+
(rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) -
|
| 186 |
+
(rank.get(b.id) ?? Number.MAX_SAFE_INTEGER)
|
| 187 |
+
);
|
| 188 |
+
}
|
| 189 |
+
return out;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/**
|
| 193 |
+
* Where an item actually belongs right now: this browser's recent drag wins
|
| 194 |
+
* over the host's echo, and a folder that no longer exists resolves to ROOT.
|
| 195 |
+
*
|
| 196 |
+
* The second half matters as much as the first. `folder_delete` moves contents
|
| 197 |
+
* to root host-side, but the client sees the folder vanish one render before
|
| 198 |
+
* the items' `folderId` is rewritten β and an item pointing at a folder nobody
|
| 199 |
+
* renders would simply not appear in any group. Resolving a dangling ref to
|
| 200 |
+
* root is what stops a folder delete from making views look deleted too.
|
| 201 |
+
*/
|
| 202 |
+
export function resolveFolderId(
|
| 203 |
+
itemId: string,
|
| 204 |
+
hostFolderId: string | null | undefined,
|
| 205 |
+
folders: GridFolder[],
|
| 206 |
+
stamps: FolderStamps | undefined,
|
| 207 |
+
now: number
|
| 208 |
+
): string | null {
|
| 209 |
+
const moved = stamps?.moved?.[itemId];
|
| 210 |
+
const id = isRecent(moved?.at, now) && moved ? moved.folderId : (hostFolderId ?? null);
|
| 211 |
+
if (id == null) return null;
|
| 212 |
+
// β W32-T27 β the RESERVED root placement is not a dangling reference. It names no folder by
|
| 213 |
+
// design, so the `folders.some(...)` test below would null it and hand the item straight back
|
| 214 |
+
// to the Shared bucket, re-creating item 20 one layer down from where it was fixed.
|
| 215 |
+
if (id === ROOT_FOLDER_ID) return ROOT_FOLDER_ID;
|
| 216 |
+
return folders.some((f) => f.id === id) ? id : null;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
export interface FolderGroup<T> {
|
| 220 |
+
folder: GridFolder | null; // null = the root group
|
| 221 |
+
items: T[];
|
| 222 |
+
/**
|
| 223 |
+
* ββ WAVE 40 Β· T28 (owner instruction 30) β THE PIN GROUP: unfiled default views, ABOVE
|
| 224 |
+
* every folder.
|
| 225 |
+
*
|
| 226 |
+
* β IT IS A SECOND `folder: null` GROUP, and that is why this flag exists rather than a
|
| 227 |
+
* reserved folder id. The pin group and the ungrouped section are both "views in no folder",
|
| 228 |
+
* so neither can carry a heading and neither may be given a synthetic id β an id would be
|
| 229 |
+
* emitted by the rail's own drag surfaces (`item_move`, `folder_reorder`) against a folder
|
| 230 |
+
* that exists in nobody's store, which is the failure {@link isSyntheticFolderId} was
|
| 231 |
+
* introduced to stop. A flag says "this bucket renders first" and says nothing else, so the
|
| 232 |
+
* rail can key, class and drop-guard the two apart without either becoming a place.
|
| 233 |
+
*
|
| 234 |
+
* β ABSENT on every other group, never `false`, so a caller that does not pass `isPinned`
|
| 235 |
+
* gets output that is byte-identical (JSON included) to the pre-T28 shape.
|
| 236 |
+
*/
|
| 237 |
+
pinned?: true;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/**
|
| 241 |
+
* WAVE 20 item 18 / WAVE 21 item 9 (ruling R12, contract C1) β the SYNTHETIC folder
|
| 242 |
+
* that every view shared WITH you appears under.
|
| 243 |
+
*
|
| 244 |
+
* β IT IS NOT A STORED FOLDER, and nothing may ever write one with this id. It has no
|
| 245 |
+
* record in `folders`, no `order`, no icon, and the rail refuses every action on it
|
| 246 |
+
* (`ViewSidebar` suppresses the row menu for exactly this id): it cannot be renamed into
|
| 247 |
+
* something else, duplicated into a second copy of other people's work, or deleted. It is a
|
| 248 |
+
* READING of the view list β "these arrived by grant" β rendered as a group because that is
|
| 249 |
+
* the only shape this rail has for "a set of views with something in common".
|
| 250 |
+
*
|
| 251 |
+
* Declared HERE rather than in the component (where it lived through wave 20) so the
|
| 252 |
+
* synthesis below is pure, and `verify_folders.py` can run it under node like every other
|
| 253 |
+
* rule in this file. A constant a gate cannot reach is a contract nobody checks.
|
| 254 |
+
*/
|
| 255 |
+
export const SHARED_FOLDER_ID = "__shared__";
|
| 256 |
+
export const SHARED_FOLDER_NAME = "Shared with me";
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* ββ WAVE 34 Β· T25 (ruling R11) β A SHARED **FOLDER** GETS ITS OWN GROUP, under this prefix.
|
| 260 |
+
*
|
| 261 |
+
* R11: *"A folder (with the Views inside it) can be shared with a team member."* Everything below
|
| 262 |
+
* the client already worked and was VERIFIED before this was written: `core/shares.py::KINDS` has
|
| 263 |
+
* carried `'folder'` since wave 20, the rail already offers "Share folder", and
|
| 264 |
+
* `grid_events.py::_granted_views` has projected each granted folder's views since `D-37`. What
|
| 265 |
+
* did not work is the last inch: every shared view landed in ONE flat "Shared with me" group, so
|
| 266 |
+
* the receiver got loose views and **the folder itself was invisible**.
|
| 267 |
+
*
|
| 268 |
+
* β The name has been on the wire the whole time. `_granted_views` stamps
|
| 269 |
+
* `v['sharedFolder'] = <folder name>` with the comment *"so the client can group these together
|
| 270 |
+
* later"*, and a census found ZERO readers of it anywhere in `web/src` β a flag shipped without
|
| 271 |
+
* its reader. This is the reader.
|
| 272 |
+
*
|
| 273 |
+
* β A PREFIX RATHER THAN ONE RESERVED ID, because there can be several: two colleagues can each
|
| 274 |
+
* share a folder with you. {@link isSyntheticFolderId} is what every affordance must ask instead
|
| 275 |
+
* of comparing against `SHARED_FOLDER_ID` β a rail that suppressed actions on one synthetic group
|
| 276 |
+
* and offered them on another would emit `item_move`/`folder_rename` against a folder id that
|
| 277 |
+
* exists in nobody's store.
|
| 278 |
+
*
|
| 279 |
+
* ββ THE KEY IS **OWNER + NAME**, NOT NAME, AND THE FIRST DRAFT OF THIS FUNCTION GOT IT WRONG.
|
| 280 |
+
* It keyed on the folder name alone while the comment right here claimed to handle two colleagues
|
| 281 |
+
* sharing folders with the SAME NAME β so those two folders merged into ONE group, and the head's
|
| 282 |
+
* `title` (which reads the first item's `owner`) misattributed every view from the second sharer.
|
| 283 |
+
* Per-view `sharedRole` was never affected, so nothing leaked; what broke was the rail telling you
|
| 284 |
+
* whose work you were reading, which is the entire point of the group. **A comment that describes
|
| 285 |
+
* the case the code does not handle is worse than no comment**, because it is what a reviewer
|
| 286 |
+
* checks against. Found by this ticket's own verifier reading the two side by side.
|
| 287 |
+
*
|
| 288 |
+
* β NOTHING MAY EVER WRITE ONE. Like `SHARED_FOLDER_ID` these ids are a READING of the view list,
|
| 289 |
+
* not a record: no `order`, no icon, no row in `folders`.
|
| 290 |
+
*/
|
| 291 |
+
export const SHARED_FOLDER_PREFIX = "__sharedfld__:";
|
| 292 |
+
|
| 293 |
+
/**
|
| 294 |
+
* The synthetic group id for a folder somebody shared with you.
|
| 295 |
+
*
|
| 296 |
+
* β `owner` FIRST and separated by a character usernames cannot contain. `core/users.py` keeps
|
| 297 |
+
* usernames lower-case and non-empty; a folder NAME is free text and can contain anything a user
|
| 298 |
+
* types, `:` included. Putting the free-text half last means no pair of (owner, name) can spell
|
| 299 |
+
* another pair's id, whatever somebody calls a folder.
|
| 300 |
+
*/
|
| 301 |
+
export function sharedFolderGroupId(owner: string, name: string): string {
|
| 302 |
+
return `${SHARED_FOLDER_PREFIX}${owner}:${name}`;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
/**
|
| 306 |
+
* Is this group id SYNTHESISED rather than stored? True for "Shared with me" and for every
|
| 307 |
+
* shared-folder group. Every drag, drop, rename and delete affordance in the rail asks this.
|
| 308 |
+
*/
|
| 309 |
+
export function isSyntheticFolderId(id: string | null | undefined): boolean {
|
| 310 |
+
return id === SHARED_FOLDER_ID
|
| 311 |
+
|| (typeof id === "string" && id.startsWith(SHARED_FOLDER_PREFIX));
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/**
|
| 315 |
+
* ββ WAVE 32 Β· T27 (owner item 20) β **"FILED AT ROOT", AS A VALUE.**
|
| 316 |
+
*
|
| 317 |
+
* Owner: *a shared View cannot be moved out of the Shared folder.* The cause is that **root was
|
| 318 |
+
* represented by ABSENCE at every layer**, and absence cannot distinguish two different facts:
|
| 319 |
+
*
|
| 320 |
+
* Β· `folderId == null` because the receiver never filed this view β it should show under
|
| 321 |
+
* "Shared with me", which is where a grant LANDS;
|
| 322 |
+
* Β· `folderId == null` because the receiver deliberately dragged it OUT of that group.
|
| 323 |
+
*
|
| 324 |
+
* `groupByFolder` had to guess, and it guessed "shared" β so filing a shared view at root put it
|
| 325 |
+
* straight back where it came from. **The root bucket was unreachable for a shared view by
|
| 326 |
+
* construction**, which is exactly why only folderβfolder moves ever appeared to work.
|
| 327 |
+
*
|
| 328 |
+
* β THE SENTINEL IS STORED, NOT DERIVED, AND THAT IS THE WHOLE FIX. Both other layers wrote the
|
| 329 |
+
* same absence and must both learn this value: `core/grid_events.py`'s `item_move` branch
|
| 330 |
+
* (`if target is None: cur.pop(item_id, None) # back to the root`) and
|
| 331 |
+
* `aios_grid.clean_item_folders`, whose own docstring states the defect one level deeper β
|
| 332 |
+
* *"nothing stores 'this item is in no folder'"*. A client-only fix is impossible; there is
|
| 333 |
+
* nothing to read back.
|
| 334 |
+
*
|
| 335 |
+
* β It is a RESERVED id in the same namespace as real folder ids, so `resolveFolderId` must pass
|
| 336 |
+
* it through rather than treating it as dangling, and `clean_item_folders` must admit it beside
|
| 337 |
+
* `fid in fids`. It is deliberately NOT rendered as a group: {@link groupByFolder} maps it onto
|
| 338 |
+
* the ordinary root bucket, so nothing in the rail ever shows the word.
|
| 339 |
+
*/
|
| 340 |
+
export const ROOT_FOLDER_ID = "__root__";
|
| 341 |
+
|
| 342 |
+
/**
|
| 343 |
+
* Group items into folders + a root bucket, in folder order, root LAST.
|
| 344 |
+
*
|
| 345 |
+
* Root last because the rails are read top-down and folders are the structure
|
| 346 |
+
* the user made; ungrouped items are the leftovers. Every item appears exactly
|
| 347 |
+
* once β a grouping that can drop an item would make a view look deleted.
|
| 348 |
+
*
|
| 349 |
+
* ββ WAVE 40 Β· T28 (owner instruction 30) β WITH ONE BUCKET IN FRONT OF THE FOLDERS. The
|
| 350 |
+
* unfiled DEFAULT views lift out of the leftovers and render first, because they are not
|
| 351 |
+
* leftovers: they are the row every user lands on. See `isPinned` below for the whole rule,
|
| 352 |
+
* including the stored value that makes "unless the user moves it down" answerable. Omitting
|
| 353 |
+
* that predicate leaves this function byte-identical to the pre-T28 one, root LAST included,
|
| 354 |
+
* which is what the cohort rail and every existing test still get.
|
| 355 |
+
*
|
| 356 |
+
* β WAVE 21 item 9 (R12/C1) β `isShared` adds the synthetic "Shared with me" group,
|
| 357 |
+
* AFTER root, and three things about it are deliberate:
|
| 358 |
+
*
|
| 359 |
+
* Β· **After root, not before it.** C1 says LAST in as many words. It reads correctly
|
| 360 |
+
* too: the rail is "my folders, my loose views, and then other people's".
|
| 361 |
+
* Β· **A shared view the receiver has FILED still goes to their folder.** The `__shared__`
|
| 362 |
+
* group is where a grant LANDS, not a cage it stays in β the rail's own note calls
|
| 363 |
+
* moving out "per-receiver placement" and that must keep working. So the synthetic
|
| 364 |
+
* group collects only the shared views that resolved to ROOT.
|
| 365 |
+
* Β· **An empty group does not render.** A folder head with nothing under it says "here is
|
| 366 |
+
* something you cannot reach" β the same reason `foldNav` drops empty nav folders.
|
| 367 |
+
*
|
| 368 |
+
* Omitting `isShared` leaves the function byte-identical to the pre-wave-21 one, which is
|
| 369 |
+
* what every existing caller (the cohort rail, the tests) still gets.
|
| 370 |
+
*/
|
| 371 |
+
export function groupByFolder<T>(
|
| 372 |
+
items: T[],
|
| 373 |
+
folders: GridFolder[],
|
| 374 |
+
folderIdOf: (item: T) => string | null,
|
| 375 |
+
isShared?: (item: T) => boolean,
|
| 376 |
+
/**
|
| 377 |
+
* β WAVE 34 Β· T25 (R11) β the folder a shared item arrived through, if it did.
|
| 378 |
+
*
|
| 379 |
+
* Returns the folder's NAME and its OWNER, because the pair is the identity: the wire carries
|
| 380 |
+
* no folder id for a grant (`_granted_views` stamps a name and an owner, never an id), and the
|
| 381 |
+
* name alone is not unique across sharers.
|
| 382 |
+
*
|
| 383 |
+
* Optional, so every existing caller (the cohort rail, the tests) keeps the pre-T25 shape
|
| 384 |
+
* byte-for-byte: with it omitted, every shared item falls into the flat "Shared with me"
|
| 385 |
+
* group exactly as before. Supplied, the shared views SPLIT by the folder that carried them,
|
| 386 |
+
* and the folder finally appears on the receiver's screen.
|
| 387 |
+
*/
|
| 388 |
+
sharedFolderOf?: (item: T) => { name: string; owner: string } | null,
|
| 389 |
+
/**
|
| 390 |
+
* ββ WAVE 40 Β· T28 (owner instruction 30) β IS THIS VIEW A DEFAULT THAT PINS TO THE TOP?
|
| 391 |
+
*
|
| 392 |
+
* Owner: *"when it is not in a folder it must sit at the very top above all folders unless
|
| 393 |
+
* the user moves it down."* The rail passes `isUndeletableView`, so the three ids nobody can
|
| 394 |
+
* delete β the per-database default, Starred, and the IG Overview β are the pinned set. A
|
| 395 |
+
* PREDICATE rather than an id list for the same reason `mayFileView` takes a verdict: this
|
| 396 |
+
* module is "pure and React-free so verify_folders.py can run it under node" (see the header)
|
| 397 |
+
* and its only imports are one constant and one type. Importing the id set would point this
|
| 398 |
+
* file at `types.ts` for a decision the caller already knows the answer to.
|
| 399 |
+
*
|
| 400 |
+
* ββ THE PIN ASKS FOR **ABSENCE**, NOT FOR THE ROOT SENTINEL, AND THAT IS WHERE "UNLESS THE
|
| 401 |
+
* USER MOVES IT DOWN" LIVES. W32-T27 split the one meaning of "root" into two stored values
|
| 402 |
+
* precisely so this question could be asked: `folderId == null` is *"nobody has ever placed
|
| 403 |
+
* this"*, and {@link ROOT_FOLDER_ID} is *"the user dragged it here on purpose"*. So the pin
|
| 404 |
+
* engages on the first and YIELDS on the second β drag the default view down onto the
|
| 405 |
+
* ungrouped section and `item_move` stores the sentinel, the lift stops applying, and it
|
| 406 |
+
* renders among the ordinary loose views where it was dropped. Filing it into a folder is the
|
| 407 |
+
* same yield by the instruction's own words. Both are DURABLE: the sentinel survives
|
| 408 |
+
* `aios_grid.clean_item_folders`, so the placement outlives the session that made it.
|
| 409 |
+
*
|
| 410 |
+
* β Deleting the folder a pinned view was filed into DROPS the placement (contents fall to
|
| 411 |
+
* root by absence, not by sentinel β `grid_events.py`'s `folder_delete`), so the pin
|
| 412 |
+
* re-engages. That is the only path back to the top, and it is a consequence rather than a
|
| 413 |
+
* feature: nothing in the rail today clears a placement to absence.
|
| 414 |
+
*/
|
| 415 |
+
isPinned?: (item: T) => boolean
|
| 416 |
+
): FolderGroup<T>[] {
|
| 417 |
+
const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
|
| 418 |
+
const root: T[] = [];
|
| 419 |
+
const shared: T[] = [];
|
| 420 |
+
/** W40-T28 β unfiled pinned views, in the order they arrived, rendered above every folder. */
|
| 421 |
+
const pinned: T[] = [];
|
| 422 |
+
/** synthetic group id -> {name, views}. Keyed by OWNER+NAME; see `SHARED_FOLDER_PREFIX`. */
|
| 423 |
+
const sharedFolders = new Map<string, { name: string; items: T[] }>();
|
| 424 |
+
for (const item of items) {
|
| 425 |
+
const id = folderIdOf(item);
|
| 426 |
+
const bucket = id == null ? undefined : buckets.get(id);
|
| 427 |
+
// ββ W40-T28 (owner instruction 30) β AN UNFILED DEFAULT LIFTS OUT OF THE ROOT BUCKET.
|
| 428 |
+
//
|
| 429 |
+
// ββ `id == null` IS THE WHOLE OF "UNLESS THE USER MOVES IT DOWN", AND IT IS TESTED HERE
|
| 430 |
+
// β FIRST β RATHER THAN INHERITED FROM THE CHAIN BELOW. Placed after the other branches this
|
| 431 |
+
// clause reads identically and DOES NOTHING: a folder id is taken by `buckets`, the root
|
| 432 |
+
// sentinel by its own branch, and a dangling ref was nulled by `resolveFolderId` long before
|
| 433 |
+
// this line, so `id` is already absent by the time control arrives and the test can be
|
| 434 |
+
// deleted with no observable change. A rule no mutation can break is a rule no gate is
|
| 435 |
+
// checking ([[a-declared-gate-is-an-unchecked-claim]]) β so the placement question is asked
|
| 436 |
+
// where its answer still decides something, and `verify_folders.py` can red it on its own leg.
|
| 437 |
+
//
|
| 438 |
+
// β WHAT ABSENCE MEANS IS W32-T27's SPLIT, reused rather than reinvented: `folderId == null`
|
| 439 |
+
// is "nobody has ever placed this", {@link ROOT_FOLDER_ID} is "the user dragged it here on
|
| 440 |
+
// purpose". So the pin engages on the first and yields to the second β drop the default view
|
| 441 |
+
// onto the ungrouped section and `item_move` stores the sentinel, the lift stops applying,
|
| 442 |
+
// and it renders among the loose views where it was dropped. Filing it into a folder is the
|
| 443 |
+
// same yield by the instruction's own words.
|
| 444 |
+
//
|
| 445 |
+
// β `!isShared` KEEPS THE GRANT BRANCH UNTOUCHED. A pinned view is per-user system state and
|
| 446 |
+
// can never BE a grant, so the two cannot collide in practice β the guard is what makes that
|
| 447 |
+
// a structural fact rather than an assumption, and it is why every shared-grouping leg stays
|
| 448 |
+
// byte-identical to the pre-T28 one.
|
| 449 |
+
if (id == null && !isShared?.(item) && isPinned?.(item)) pinned.push(item);
|
| 450 |
+
else if (bucket) bucket.push(item);
|
| 451 |
+
// ββ W32-T27 (owner item 20) β THE ROOT BUCKET IS REACHABLE FOR A SHARED VIEW NOW.
|
| 452 |
+
// `ROOT_FOLDER_ID` is the receiver saying "I filed this at the top level"; absence still
|
| 453 |
+
// means "this arrived by grant and I have not filed it". Before this line the two were one
|
| 454 |
+
// value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
|
| 455 |
+
// the next render β the owner's item 20, in one branch.
|
| 456 |
+
else if (id === ROOT_FOLDER_ID) root.push(item);
|
| 457 |
+
else if (isShared?.(item)) {
|
| 458 |
+
// β W34-T25 (R11): a shared view that arrived through a FOLDER goes to that folder's own
|
| 459 |
+
// group; one that was granted directly still goes to the flat "Shared with me". The two
|
| 460 |
+
// are different facts and the receiver can see which is which.
|
| 461 |
+
const via = sharedFolderOf?.(item);
|
| 462 |
+
const name = via ? String(via.name || "").trim() : "";
|
| 463 |
+
const owner = via ? String(via.owner || "").trim() : "";
|
| 464 |
+
// β BOTH halves required. A folder name with no owner cannot be told apart from another
|
| 465 |
+
// sharer's folder of the same name, so it falls back to the flat group rather than
|
| 466 |
+
// inventing a group that might merge two people's work under one person's label.
|
| 467 |
+
if (name && owner) {
|
| 468 |
+
const key = sharedFolderGroupId(owner, name);
|
| 469 |
+
const bucket2 = sharedFolders.get(key);
|
| 470 |
+
if (bucket2) bucket2.items.push(item);
|
| 471 |
+
else sharedFolders.set(key, { name, items: [item] });
|
| 472 |
+
} else shared.push(item);
|
| 473 |
+
}
|
| 474 |
+
else root.push(item);
|
| 475 |
+
}
|
| 476 |
+
const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
|
| 477 |
+
// ββ W40-T28 β IN FRONT OF EVERY FOLDER, which is the half of instruction 30 this module owns.
|
| 478 |
+
// β An EMPTY pin group is never emitted, the same rule the shared group carries: a bucket with
|
| 479 |
+
// nothing in it is a section the user cannot reach, and here it would also be a phantom second
|
| 480 |
+
// root the rail would have to render around.
|
| 481 |
+
if (pinned.length) out.unshift({ folder: null, items: pinned, pinned: true });
|
| 482 |
+
out.push({ folder: null, items: root });
|
| 483 |
+
if (shared.length)
|
| 484 |
+
out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
|
| 485 |
+
// β SORTED, not first-seen. A Map preserves insertion order, which here is the order the VIEWS
|
| 486 |
+
// happen to arrive in β so one view moving could reorder whole folders in the rail for no reason
|
| 487 |
+
// a reader could name. Sorting on the composite ID (owner first) is stable across every payload
|
| 488 |
+
// AND groups one colleague's folders together, which reads better than interleaving two people's
|
| 489 |
+
// folders alphabetically by name.
|
| 490 |
+
for (const key of [...sharedFolders.keys()].sort((a, b) => a.localeCompare(b))) {
|
| 491 |
+
const grp = sharedFolders.get(key)!;
|
| 492 |
+
out.push({ folder: { id: key, name: grp.name }, items: grp.items });
|
| 493 |
+
}
|
| 494 |
+
return out;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/**
|
| 498 |
+
* WAVE 20 item 19 (C-FOLDER-REORDER) β where a dragged folder lands: the full order with
|
| 499 |
+
* `draggedId` moved to sit immediately BEFORE `beforeId`, or last when that is null (the
|
| 500 |
+
* drop on the ungrouped section below every folder).
|
| 501 |
+
*
|
| 502 |
+
* Here rather than inside the rail because it is the only part of the drag a test can hold:
|
| 503 |
+
* the drop handler is DOM, the emit is the caller's, and this is the arithmetic that decides
|
| 504 |
+
* what the user sees. `null` means "emit nothing" β an unknown id, or a drop that changes
|
| 505 |
+
* nothing. Returning the unchanged array instead would be worse than useless: the caller
|
| 506 |
+
* cannot tell it apart from a real reorder, so every no-op drag would write the store, bump
|
| 507 |
+
* every reader's payload, and reconcile to the identical list.
|
| 508 |
+
*
|
| 509 |
+
* β The dragged id is REMOVED BEFORE the target index is read. Taking the index first and
|
| 510 |
+
* splicing after is the classic off-by-one here: dragging a folder DOWNWARD would land it one
|
| 511 |
+
* place short of where it was dropped, and only in that direction β the shape of bug that
|
| 512 |
+
* survives a demo and gets reported as "it sometimes doesn't move".
|
| 513 |
+
*/
|
| 514 |
+
export function reorderFolderIds(
|
| 515 |
+
ids: string[],
|
| 516 |
+
draggedId: string,
|
| 517 |
+
beforeId: string | null
|
| 518 |
+
): string[] | null {
|
| 519 |
+
if (!ids.includes(draggedId)) return null;
|
| 520 |
+
// β DROPPED ON ITSELF. Without this the id is filtered out, `indexOf` cannot find its own
|
| 521 |
+
// target, and the "not found" branch sends the folder to the END β so releasing a drag over
|
| 522 |
+
// the folder you picked up would quietly move it to the bottom of the rail. Found by this
|
| 523 |
+
// function's own gate the minute the arithmetic left the component; the drop handler's
|
| 524 |
+
// indicator suppresses the same case visually, which is exactly why it would never have
|
| 525 |
+
// been noticed there.
|
| 526 |
+
if (beforeId === draggedId) return null;
|
| 527 |
+
const rest = ids.filter((id) => id !== draggedId);
|
| 528 |
+
const found = beforeId ? rest.indexOf(beforeId) : -1;
|
| 529 |
+
const at = found < 0 ? rest.length : found;
|
| 530 |
+
const next = [...rest.slice(0, at), draggedId, ...rest.slice(at)];
|
| 531 |
+
if (next.length === ids.length && next.every((id, i) => id === ids[i])) return null;
|
| 532 |
+
return next;
|
| 533 |
+
}
|
| 534 |
+
|
| 535 |
+
/**
|
| 536 |
+
* ββ WAVE 34 Β· T22 (ruling R4, `D-249`) β MAY THIS VIEWER FILE THIS VIEW INTO A FOLDER?
|
| 537 |
+
*
|
| 538 |
+
* β NOT THE SAME QUESTION AS `mayEditView`, AND CONFLATING THEM IS THE DEFECT. The rail gated its
|
| 539 |
+
* `draggable` on `mayEditView`, which fail-closes on a shared view whose role is not `edit` β so a
|
| 540 |
+
* read-only shared view could not BEGIN a drag, and the owner's report is that a view cannot be
|
| 541 |
+
* taken out of a folder. Filing is not a content edit: it writes a PLACEMENT into the receiver's
|
| 542 |
+
* own workspace stratum and never touches the owner's object.
|
| 543 |
+
*
|
| 544 |
+
* β THE SERVER ALREADY AGREED, IN TWO PLACES, BEFORE THIS FUNCTION EXISTED. `grid_events.py::
|
| 545 |
+
* table_workspace` merges granted views into `ws['views']`, which is the very set `item_move`'s
|
| 546 |
+
* own-ids test reads β so the write this drag produces is ACCEPTED today. And the `view_reorder`
|
| 547 |
+
* branch beside it carries the rule in prose: *"NO OWNERSHIP FILTER β¦ refusing ids they do not own
|
| 548 |
+
* would make exactly those un-draggable, which is the half of sharing people notice."* The client
|
| 549 |
+
* was the only refuser, and it was refusing a write the server was ready to take.
|
| 550 |
+
*
|
| 551 |
+
* β `mayEdit` IS A PARAMETER, NOT AN IMPORT, and that is deliberate: this module is
|
| 552 |
+
* "pure and React-free so verify_folders.py can run it under node" (see the header), and its only
|
| 553 |
+
* imports today are one constant and one type. Taking the verdict rather than the resolver keeps
|
| 554 |
+
* the truth table testable in isolation and keeps this file's dependency surface where its own
|
| 555 |
+
* header promised it would stay.
|
| 556 |
+
*
|
| 557 |
+
* β USE IT ONLY AT THE DRAG SITE. Every other affordance the rail gates on `mayEditView` β rename,
|
| 558 |
+
* description, delete, permissions β really is a content edit on somebody else's object, and the
|
| 559 |
+
* server really does refuse those.
|
| 560 |
+
*/
|
| 561 |
+
export function mayFileView(view: { shared?: boolean }, mayEdit: boolean): boolean {
|
| 562 |
+
return view.shared === true || mayEdit;
|
| 563 |
+
}
|
| 564 |
+
|
| 565 |
+
/** A fresh folder id. Client-generated, like every other id in this component. */
|
| 566 |
+
export function newFolderId(): string {
|
| 567 |
+
const rand =
|
| 568 |
+
typeof crypto !== "undefined" && "randomUUID" in crypto
|
| 569 |
+
? crypto.randomUUID().slice(0, 8)
|
| 570 |
+
: Math.random().toString(36).slice(2, 10);
|
| 571 |
+
return `fld_${rand}`;
|
| 572 |
+
}
|
web/src/customer-grid/iconShapes.ts
CHANGED
|
@@ -1,355 +1,355 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / iconShapes.ts
|
| 3 |
-
// Wave-8 items I18 + I20 β ONE geometry source for the grid's icon vocabulary,
|
| 4 |
-
// rendered by TWO very different painters:
|
| 5 |
-
//
|
| 6 |
-
// - React <FieldTypeIcon> / <ModeIcon> β DOM svg in panels, popovers, menus
|
| 7 |
-
// - glide headerIcons sprites β canvas, drawn from an SVG *string*
|
| 8 |
-
//
|
| 9 |
-
// Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
|
| 10 |
-
// never be a React component. Keeping the paths as DATA (IconShape[]) and giving
|
| 11 |
-
// each painter its own thin renderer is what stops the two from drifting β the
|
| 12 |
-
// alternative (hand-copying every path into a template literal) guarantees the
|
| 13 |
-
// header and the panel eventually disagree about what a "date" looks like.
|
| 14 |
-
//
|
| 15 |
-
// Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
|
| 16 |
-
// currentColor. Vector paths only β NEVER emoji (owner constant).
|
| 17 |
-
// ---------------------------------------------------------------------------
|
| 18 |
-
|
| 19 |
-
import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
|
| 20 |
-
import {
|
| 21 |
-
LP_BLUE,
|
| 22 |
-
LP_BLUE_DEEP,
|
| 23 |
-
LP_GREEN,
|
| 24 |
-
LP_GREEN_DEEP,
|
| 25 |
-
LP_LINE,
|
| 26 |
-
LP_MUTED,
|
| 27 |
-
LP_RED,
|
| 28 |
-
LP_RED_DEEP,
|
| 29 |
-
LP_YELLOW,
|
| 30 |
-
LP_YELLOW_DEEP,
|
| 31 |
-
} from "./theme";
|
| 32 |
-
|
| 33 |
-
/** One drawing primitive. `fill: true` fills the path instead of stroking it
|
| 34 |
-
* (the rating star is the only shape that reads better solid). */
|
| 35 |
-
export type IconShape = { d: string; fill?: boolean };
|
| 36 |
-
|
| 37 |
-
/** A circle as a path β two half-arcs. Sprites are SVG *source*, so every shape
|
| 38 |
-
* has to survive being serialized into a string; paths do, <circle> elements
|
| 39 |
-
* would need a second serializer branch for no benefit. */
|
| 40 |
-
const circle = (cx: number, cy: number, r: number): string =>
|
| 41 |
-
`M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
|
| 42 |
-
|
| 43 |
-
const CALENDAR: IconShape[] = [
|
| 44 |
-
{ d: "M3.2 4.6h9.6v8.2H3.2z" },
|
| 45 |
-
{ d: "M3.2 7.2h9.6" },
|
| 46 |
-
{ d: "M5.8 3v3.2" },
|
| 47 |
-
{ d: "M10.2 3v3.2" },
|
| 48 |
-
];
|
| 49 |
-
|
| 50 |
-
/**
|
| 51 |
-
* Field type β icon geometry. A TOTAL record on purpose: adding a FieldType
|
| 52 |
-
* without an icon is a compile error, not a silently blank header.
|
| 53 |
-
*/
|
| 54 |
-
export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
|
| 55 |
-
text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
|
| 56 |
-
status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
|
| 57 |
-
currency: [
|
| 58 |
-
{ d: "M8 2.8v10.4" },
|
| 59 |
-
{ d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
|
| 60 |
-
],
|
| 61 |
-
int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
|
| 62 |
-
date: CALENDAR,
|
| 63 |
-
pct: [
|
| 64 |
-
{ d: circle(4.6, 4.6, 1.6) },
|
| 65 |
-
{ d: circle(11.4, 11.4, 1.6) },
|
| 66 |
-
{ d: "M12.2 3.9L3.8 12.3" },
|
| 67 |
-
],
|
| 68 |
-
select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
|
| 69 |
-
user: [
|
| 70 |
-
{ d: circle(8, 6, 2.4) },
|
| 71 |
-
{ d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
|
| 72 |
-
],
|
| 73 |
-
multiselect: [
|
| 74 |
-
{ d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
|
| 75 |
-
{ d: "M7.2 5.7h6M7.2 10.3h6" },
|
| 76 |
-
],
|
| 77 |
-
checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
|
| 78 |
-
phone: [
|
| 79 |
-
{ d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
|
| 80 |
-
],
|
| 81 |
-
email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
|
| 82 |
-
url: [
|
| 83 |
-
{ d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
|
| 84 |
-
{ d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
|
| 85 |
-
{ d: "M6.2 9.8l3.6-3.6" },
|
| 86 |
-
],
|
| 87 |
-
rating: [
|
| 88 |
-
{
|
| 89 |
-
d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
|
| 90 |
-
fill: true,
|
| 91 |
-
},
|
| 92 |
-
],
|
| 93 |
-
created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
|
| 94 |
-
formula: [
|
| 95 |
-
{ d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
|
| 96 |
-
{ d: "M4.2 7.6h4.6" },
|
| 97 |
-
{ d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
|
| 98 |
-
],
|
| 99 |
-
// Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
|
| 100 |
-
// with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
|
| 101 |
-
// Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
|
| 102 |
-
// (`created_time` owns the closed rim + hands).
|
| 103 |
-
automation: [
|
| 104 |
-
{ d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
|
| 105 |
-
{ d: "M4.2 6L5.3 4.1 3.1 4.5" },
|
| 106 |
-
{ d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
|
| 107 |
-
],
|
| 108 |
-
// Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
|
| 109 |
-
// series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
|
| 110 |
-
// the formula fx (that computes over the ROW) and not a bare number (int owns ##).
|
| 111 |
-
metric: [
|
| 112 |
-
{ d: "M3.2 3.2v9.6h9.6" },
|
| 113 |
-
{ d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
|
| 114 |
-
],
|
| 115 |
-
// Wave-23 C7 β TWO BRACES facing each other with a dot between them: the universal mark for
|
| 116 |
-
// "a structured document", and the one glyph in this table that draws its own SYNTAX rather
|
| 117 |
-
// than a picture of what the value means. Deliberately not a document page (nothing here owns
|
| 118 |
-
// that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
|
| 119 |
-
// of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
|
| 120 |
-
// reading as parentheses at small sizes.
|
| 121 |
-
json: [
|
| 122 |
-
{ d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
|
| 123 |
-
{ d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
|
| 124 |
-
{ d: circle(8, 8, 0.85), fill: true },
|
| 125 |
-
],
|
| 126 |
-
// β Wave-27 item 13 (R13) β THE ANGLE BRACKETS, the mark every editor on earth uses for
|
| 127 |
-
// "this is source". Drawn as two chevrons with a slash leaning between them, which is what
|
| 128 |
-
// separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
|
| 129 |
-
// draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
|
| 130 |
-
// and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
|
| 131 |
-
// `text` already trade on that reading).
|
| 132 |
-
code: [
|
| 133 |
-
{ d: "M5.6 4.9 2.6 8l3 3.1" },
|
| 134 |
-
{ d: "M10.4 4.9 13.4 8l-3 3.1" },
|
| 135 |
-
{ d: "M9.1 3.6 6.9 12.4" },
|
| 136 |
-
],
|
| 137 |
-
// Wave-19 R7 β a framed picture: the mount, a sun, and the hill line every photo glyph
|
| 138 |
-
// resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
|
| 139 |
-
image: [
|
| 140 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 141 |
-
{ d: circle(6, 6.3, 1.1) },
|
| 142 |
-
{ d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
|
| 143 |
-
],
|
| 144 |
-
// β 2026-08-07 β TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
|
| 145 |
-
// "this points at something else". Drawn as two rounded rectangles overlapping at the centre
|
| 146 |
-
// rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
|
| 147 |
-
// relation that exists in both directions whether or not you follow it.
|
| 148 |
-
link: [
|
| 149 |
-
{ d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
|
| 150 |
-
{ d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
|
| 151 |
-
{ d: "M5.6 8h4.8" },
|
| 152 |
-
],
|
| 153 |
-
// β 2026-08-07 β THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
|
| 154 |
-
// collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
|
| 155 |
-
// claim SUM when the function is chosen per column) and not a funnel (that is filtering,
|
| 156 |
-
// which is what `limit` does β a different half of the same field).
|
| 157 |
-
rollup: [
|
| 158 |
-
{ d: "M3.2 4.4h9.6" },
|
| 159 |
-
{ d: "M4.8 8h6.4" },
|
| 160 |
-
{ d: "M6.6 11.6h2.8" },
|
| 161 |
-
],
|
| 162 |
-
// β WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β AI ENRICHMENT.
|
| 163 |
-
// The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
|
| 164 |
-
// on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
|
| 165 |
-
// second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
|
| 166 |
-
// (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
|
| 167 |
-
// head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
|
| 168 |
-
// β TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
|
| 169 |
-
// The small companion is what makes the mark say "sparkle".
|
| 170 |
-
ai_enrich: [
|
| 171 |
-
{ d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
|
| 172 |
-
{ d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
|
| 173 |
-
],
|
| 174 |
-
};
|
| 175 |
-
|
| 176 |
-
/**
|
| 177 |
-
* Display mode β icon geometry (I18). Also total: the wave-8 Dashboard mode
|
| 178 |
-
* cannot land in DISPLAY_MODES without the compiler demanding its icon here.
|
| 179 |
-
*/
|
| 180 |
-
export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
|
| 181 |
-
grid: [
|
| 182 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 183 |
-
{ d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
|
| 184 |
-
],
|
| 185 |
-
list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
|
| 186 |
-
// I19c β a framed set of bars: "several charts", not "one chart".
|
| 187 |
-
chart: [
|
| 188 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 189 |
-
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 190 |
-
],
|
| 191 |
-
// I10 (C2) β the LEGACY key. Kept so this record stays total over DisplayMode, which is
|
| 192 |
-
// what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
|
| 193 |
-
// promise. Same drawing: a stored 'dashboard' IS a chart.
|
| 194 |
-
dashboard: [
|
| 195 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 196 |
-
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 197 |
-
],
|
| 198 |
-
calendar: CALENDAR,
|
| 199 |
-
kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
|
| 200 |
-
map: [
|
| 201 |
-
{ d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
|
| 202 |
-
{ d: circle(8, 6.1, 1.3) },
|
| 203 |
-
],
|
| 204 |
-
// 2026-08-02 item 7 β the time-series view. Drawn as a framed grid with a trend running
|
| 205 |
-
// through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
|
| 206 |
-
// a per-row line toggle. Deliberately not the plain `line` chart mark β a chart view and a
|
| 207 |
-
// time-series table must not be the same picture in the same rail.
|
| 208 |
-
timeseries: [
|
| 209 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 210 |
-
{ d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
|
| 211 |
-
{ d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
|
| 212 |
-
],
|
| 213 |
-
// Wave-18 C6-CATALOG β an OPEN BOOK: two facing leaves with a spine between them, and a
|
| 214 |
-
// product block sitting on the left one. Every other mode in this table draws a way of
|
| 215 |
-
// arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
|
| 216 |
-
// with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
|
| 217 |
-
// not a framed grid (`grid`/`chart`/`timeseries` share the frame).
|
| 218 |
-
catalog: [
|
| 219 |
-
{ d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
|
| 220 |
-
{ d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
|
| 221 |
-
{ d: "M3.9 6.4h2.3v2.8H3.9z" },
|
| 222 |
-
],
|
| 223 |
-
// Wave-23 C9 β a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
|
| 224 |
-
// them. Every other mode here draws a way of ARRANGING records that already exist; this one
|
| 225 |
-
// has to read as a record being MADE, so it is the only mark whose bottom line is open.
|
| 226 |
-
// Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
|
| 227 |
-
// (an edit affordance means something else app-wide).
|
| 228 |
-
// β Wave-27 C3 (owner item 8) β a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
|
| 229 |
-
// other mark here draws an arrangement of many records; this one has to read as ONE record
|
| 230 |
-
// with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
|
| 231 |
-
// (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
|
| 232 |
-
// this vocabulary draws a body part, and it would read as "drag" rather than "decide").
|
| 233 |
-
swipe: [
|
| 234 |
-
{ d: "M5.4 3.4h5.2v9.2H5.4z" },
|
| 235 |
-
{ d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
|
| 236 |
-
{ d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
|
| 237 |
-
],
|
| 238 |
-
form: [
|
| 239 |
-
{ d: "M3.4 2.8h9.2v10.4H3.4z" },
|
| 240 |
-
{ d: "M5.6 5.6h4.8" },
|
| 241 |
-
{ d: "M5.6 8h4.8" },
|
| 242 |
-
{ d: "M5.6 10.6h2.6" },
|
| 243 |
-
],
|
| 244 |
-
// W36-T04 β angle brackets over a baseline: the universal mark for "this is code", and the one
|
| 245 |
-
// shape in this table that is about the AUTHORING rather than about the arrangement of rows.
|
| 246 |
-
script: [
|
| 247 |
-
{ d: "M6 5.4 3.2 8l2.8 2.6M10 5.4 12.8 8 10 10.6" },
|
| 248 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 249 |
-
],
|
| 250 |
-
};
|
| 251 |
-
|
| 252 |
-
/**
|
| 253 |
-
* Wave-9 I16 β one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
|
| 254 |
-
* vocabulary cannot ship without a drawing.
|
| 255 |
-
*
|
| 256 |
-
* β The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
|
| 257 |
-
* an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
|
| 258 |
-
* picker is NOT a native `<select>` β it is a radio-row list like the mode switcher, which
|
| 259 |
-
* is the only shape that can carry a real mark.
|
| 260 |
-
*
|
| 261 |
-
* The key type is a string union declared here rather than imported from chartData.ts: this
|
| 262 |
-
* module is a leaf (types + theme only) and chartData imports IT, not the reverse.
|
| 263 |
-
*/
|
| 264 |
-
export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
|
| 265 |
-
export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
|
| 266 |
-
bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
|
| 267 |
-
line: [
|
| 268 |
-
{ d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
|
| 269 |
-
{ d: circle(5.8, 7.8, 0.9) },
|
| 270 |
-
{ d: circle(8.4, 9.8, 0.9) },
|
| 271 |
-
],
|
| 272 |
-
area: [
|
| 273 |
-
{ d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
|
| 274 |
-
{ d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
|
| 275 |
-
],
|
| 276 |
-
donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
|
| 277 |
-
// A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
|
| 278 |
-
// reads as "one number" beside four marks that all read as "a distribution".
|
| 279 |
-
kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
|
| 280 |
-
// Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
|
| 281 |
-
// values, not a distribution), with a header band and a column rule.
|
| 282 |
-
table: [
|
| 283 |
-
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 284 |
-
{ d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
|
| 285 |
-
],
|
| 286 |
-
};
|
| 287 |
-
|
| 288 |
-
export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
|
| 289 |
-
bar: "Bar",
|
| 290 |
-
line: "Line",
|
| 291 |
-
area: "Area",
|
| 292 |
-
donut: "Donut",
|
| 293 |
-
kpi: "Single value",
|
| 294 |
-
table: "Table",
|
| 295 |
-
};
|
| 296 |
-
|
| 297 |
-
/** I16 β the pastel each chart kind wears, same family rule as MODE_TONE. */
|
| 298 |
-
export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
|
| 299 |
-
bar: "blue",
|
| 300 |
-
line: "green",
|
| 301 |
-
area: "green",
|
| 302 |
-
donut: "yellow",
|
| 303 |
-
kpi: "neutral",
|
| 304 |
-
table: "neutral",
|
| 305 |
-
};
|
| 306 |
-
|
| 307 |
-
/**
|
| 308 |
-
* Wave-9 contract C5 (I15) β folder icon geometry. TOTAL over `FolderShape`, so a shape key
|
| 309 |
-
* added to the wire contract in types.ts cannot ship without a drawing.
|
| 310 |
-
*
|
| 311 |
-
* Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
|
| 312 |
-
* the same rail and read as one family. `folder` is first because it is the default every
|
| 313 |
-
* pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
|
| 314 |
-
*/
|
| 315 |
-
export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
|
| 316 |
-
folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
|
| 317 |
-
star: [
|
| 318 |
-
{ d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
|
| 319 |
-
],
|
| 320 |
-
flag: [
|
| 321 |
-
{ d: "M4.2 13.4V2.9" },
|
| 322 |
-
{ d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
|
| 323 |
-
],
|
| 324 |
-
tag: [
|
| 325 |
-
{ d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
|
| 326 |
-
{ d: circle(5.6, 5.6, 1) },
|
| 327 |
-
],
|
| 328 |
-
bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
|
| 329 |
-
// Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
|
| 330 |
-
// Reusing the geometry rather than drawing a second "chart" is the whole point of the
|
| 331 |
-
// one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
|
| 332 |
-
grid: MODE_SHAPES.grid,
|
| 333 |
-
chart: MODE_SHAPES.dashboard,
|
| 334 |
-
map: MODE_SHAPES.map,
|
| 335 |
-
users: TYPE_SHAPES.user,
|
| 336 |
-
clock: TYPE_SHAPES.created_time,
|
| 337 |
-
heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
|
| 338 |
-
bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
|
| 339 |
-
};
|
| 340 |
-
|
| 341 |
-
/**
|
| 342 |
-
* Tone key β the pastel it FILLS with, and the -d weight it STROKES with.
|
| 343 |
-
*
|
| 344 |
-
* Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
|
| 345 |
-
* that a base pastel at that size smudges β LP_BLUE measures 1.88:1 on white. So the pastel
|
| 346 |
-
* is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
|
| 347 |
-
* outline (an outline that reads at all).
|
| 348 |
-
*
|
| 349 |
-
* The default tone is `neutral` β HOST's C5 key, not "grey". The whitelist is SHARED between
|
| 350 |
-
* the two ends, so the name matters more than the word: a tone the host does not recognise
|
| 351 |
-
* degrades to the default and the user's choice silently disappears on reload.
|
| 352 |
-
*/
|
| 353 |
export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
|
| 354 |
neutral: { fill: LP_LINE, stroke: LP_MUTED },
|
| 355 |
blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
|
|
@@ -368,456 +368,491 @@ export function folderTonePaint(tone: unknown): { fill: string; stroke: string }
|
|
| 368 |
return (typeof tone === "string" ? FOLDER_TONE_PAINT[tone as FolderTone] : undefined)
|
| 369 |
?? FOLDER_TONE_PAINT.neutral;
|
| 370 |
}
|
| 371 |
-
|
| 372 |
-
export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
|
| 373 |
-
neutral: "Neutral",
|
| 374 |
-
blue: "Blue",
|
| 375 |
-
green: "Green",
|
| 376 |
-
yellow: "Yellow",
|
| 377 |
-
red: "Red",
|
| 378 |
-
};
|
| 379 |
-
|
| 380 |
-
export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
|
| 381 |
-
folder: "Folder",
|
| 382 |
-
star: "Star",
|
| 383 |
-
flag: "Flag",
|
| 384 |
-
tag: "Tag",
|
| 385 |
-
bookmark: "Bookmark",
|
| 386 |
-
grid: "Table",
|
| 387 |
-
chart: "Chart",
|
| 388 |
-
map: "Map",
|
| 389 |
-
users: "People",
|
| 390 |
-
clock: "Clock",
|
| 391 |
-
heart: "Heart",
|
| 392 |
-
bolt: "Priority",
|
| 393 |
-
};
|
| 394 |
-
|
| 395 |
-
/**
|
| 396 |
-
* Wave-9 I14 β the tone each CREATABLE view type wears in the "+ Create newβ¦" flyout.
|
| 397 |
-
*
|
| 398 |
-
* The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
|
| 399 |
-
* is a list you read rather than scan. Assigned by family, not by rotation: the two
|
| 400 |
-
* record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
|
| 401 |
-
* share yellow, chart is green because it is the analytical one, map is red because it is
|
| 402 |
-
* the geographic one. Folder is grey β it is not a view, and the flyout's last row should
|
| 403 |
-
* not compete with the six above it.
|
| 404 |
-
*/
|
| 405 |
-
/**
|
| 406 |
-
* Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
|
| 407 |
-
* sits beside the geometry, the way TYPE_LABELS does β the mode switcher, the create flyout
|
| 408 |
-
* and the create prompt now read ONE table instead of three. C2's "Dashboard" β "Chart"
|
| 409 |
-
* rename is a single line here as a direct result.
|
| 410 |
-
*/
|
| 411 |
-
export const MODE_LABELS: Record<DisplayMode, string> = {
|
| 412 |
-
grid: "Grid",
|
| 413 |
-
chart: "Chart",
|
| 414 |
-
// Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
|
| 415 |
-
// read before normalisation must never render a blank switcher chip.
|
| 416 |
-
dashboard: "Chart",
|
| 417 |
-
list: "List",
|
| 418 |
-
calendar: "Calendar",
|
| 419 |
-
kanban: "Kanban",
|
| 420 |
-
map: "Map",
|
| 421 |
-
timeseries: "Time series",
|
| 422 |
-
catalog: "Catalog",
|
| 423 |
-
// Wave-23 C9 β the mode that COLLECTS records. "Form", the word the whole product uses for
|
| 424 |
-
// it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
|
| 425 |
-
// be the one surface calling it something else.
|
| 426 |
-
form: "Form",
|
| 427 |
-
// β Wave-27 C3 (item 8) β the owner's own word for it. Not "Triage" or "Review": the gesture
|
| 428 |
-
// IS the name here, and the two candidates both collide with vocabulary this product already
|
| 429 |
-
// spends elsewhere (a `review` automation decision, the retired review lanes).
|
| 430 |
-
swipe: "Swipe",
|
| 431 |
-
// W36-T04 β the owner's own words are "code script"; "Script" is the half that is not implied
|
| 432 |
-
// by the icon, and the product has no other surface competing for the noun.
|
| 433 |
-
//
|
| 434 |
-
// ββ W37-T41 (owner ruling, 2026-08-19) β RENAMED TO "Custom", and the one-word form is the
|
| 435 |
-
// ruling rather than a shortening of it. Every wave-37 governing text calls this surface the
|
| 436 |
-
// "Custom View": PRD items 10 / R2, T41's own `done-when`, D-391, and `ScriptViewPanel`'s
|
| 437 |
-
// shipped copy ("Picking Custom View mints a view with an EMPTY source"). "Script" was the
|
| 438 |
-
// product calling one thing two names, which is the defect `swipe`'s label note warns about
|
| 439 |
-
// one table up.
|
| 440 |
-
//
|
| 441 |
-
// β IT IS NOT SPELLED "Custom View" HERE, and the reason is this table's job rather than a
|
| 442 |
-
// preference. Every value is a KIND NOUN that four call sites COMPOSE into a phrase:
|
| 443 |
-
// `ViewSidebar` renders `New ${MODE_LABELS[creating].toLowerCase()} view` (:1060), the same
|
| 444 |
-
// string as the flyout's aria-label (:1045), `${MODE_LABELS[mode]} view, locked` (:1619) and
|
| 445 |
-
// `It also stays a ${MODE_LABELS[mode].toLowerCase()}.` (:1626). A label already carrying the
|
| 446 |
-
// noun reads "New custom view view" at two of them. One word keeps the create prompt reading
|
| 447 |
-
// "New custom view", the locked aria-label reading "Custom view, locked", and the picker row
|
| 448 |
-
// reading "Custom" beside Grid, Chart and Map, which are kind nouns too.
|
| 449 |
-
script: "Custom",
|
| 450 |
-
};
|
| 451 |
-
|
| 452 |
-
/**
|
| 453 |
-
* I14 β the view types the "+ Create newβ¦" flyout OFFERS, in the order it lists them.
|
| 454 |
-
*
|
| 455 |
-
* Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
|
| 456 |
-
* C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
|
| 457 |
-
* rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β one list cannot
|
| 458 |
-
* express both. Living in this pure data module means the gate can assert the offered set
|
| 459 |
-
* without importing a React component, and I10 becomes a one-line edit in one file.
|
| 460 |
-
*/
|
| 461 |
-
// 2026-08-02 item 7 β `timeseries` was deliberately held OUT of this list until the host
|
| 462 |
-
// accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
|
| 463 |
-
// wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
|
| 464 |
-
// not know, so offering it early would let a user create a view that silently reverts to a
|
| 465 |
-
// grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
|
| 466 |
-
// `DISPLAY_MODES += timeseries`, so it is offerable now.
|
| 467 |
-
//
|
| 468 |
-
// wave17 GRID, owner item 10 β THE ORDER BELOW IS THE OWNER'S, stated verbatim:
|
| 469 |
-
// Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
|
| 470 |
-
//
|
| 471 |
-
// β It supersedes the two orderings this list has carried before it, and the reasoning that
|
| 472 |
-
// produced them is now WRONG rather than merely outranked, so it is not left here to be
|
| 473 |
-
// re-applied: `timeseries` was "listed last⦠it belongs beside Chart", and `list` sat second
|
| 474 |
-
// as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
|
| 475 |
-
// a product decision, so it is asserted in `verify_icons` rather than left to a comment β
|
| 476 |
-
// nothing else on screen would go red if a future edit re-sorted it "sensibly".
|
| 477 |
-
// Wave-18 C6-CATALOG β `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
|
| 478 |
-
// accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
|
| 479 |
-
// "C6 HOST MIRROR APPLIED β you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
|
| 480 |
-
// It lands LAST by contract: the owner's seven-mode order above is a product decision and the
|
| 481 |
-
// new mode joins the end of it rather than being sorted into it.
|
| 482 |
-
// β Wave-27 C3 (owner item 8) β `swipe` is HELD OUT of this list, and the reason corrects a
|
| 483 |
-
// mistake this file made an hour earlier.
|
| 484 |
-
//
|
| 485 |
-
// β THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
|
| 486 |
-
// mode the HOST has not accepted", and one session owning both registries this wave genuinely
|
| 487 |
-
// does close that half β which is what made it tempting to skip. But the rule the hold really
|
| 488 |
-
// encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
|
| 489 |
-
// exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
|
| 490 |
-
// for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
|
| 491 |
-
// a grid under a chip reading "View Β· Swipe" β surviving reload, with the agreement leg green
|
| 492 |
-
// because it only ever compared two lists.
|
| 493 |
-
//
|
| 494 |
-
// So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
|
| 495 |
-
// handshake: `verify_icons` DERIVES the condition β either `swipe` is absent here, or
|
| 496 |
-
// `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
|
| 497 |
-
// direction, sitting unoffered because its host mirror never landed (DEBT D-90).
|
| 498 |
-
//
|
| 499 |
-
// β THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
|
| 500 |
-
// Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
|
| 501 |
-
// `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
|
| 502 |
-
// "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β a negative control FOR the
|
| 503 |
-
// swipe carry β and the owner could not find the view because **nothing imported the file.**
|
| 504 |
-
// The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
|
| 505 |
-
// it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
|
| 506 |
-
// A hold that is safe to leave in place is a hold nothing forces you to lift.
|
| 507 |
-
// The audit query that found it, after the battery did not: for each artifact a wave adds, grep
|
| 508 |
-
// for its CONSUMER β who imports/mounts/registers it β excluding the file itself, `_test/`, css
|
| 509 |
-
// and comments. `SwipeView` had four hits and three were prose.
|
| 510 |
-
// Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
|
| 511 |
-
// It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
|
| 512 |
-
// mode joins the end of it rather than being sorted into it.
|
| 513 |
-
export const CREATABLE_MODES: DisplayMode[] = [
|
| 514 |
-
"grid",
|
| 515 |
-
"chart",
|
| 516 |
-
"calendar",
|
| 517 |
-
"kanban",
|
| 518 |
-
"timeseries",
|
| 519 |
-
"map",
|
| 520 |
-
"list",
|
| 521 |
-
"catalog",
|
| 522 |
-
"swipe",
|
| 523 |
-
// Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
|
| 524 |
-
// `form` is offerable and its `HELD_MODES` entry came out in the same edit β the hold's own text
|
| 525 |
-
// named this mount as its release condition. β Found by `verify_icons.py::mode_parity` law E
|
| 526 |
-
// during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
|
| 527 |
-
// the Interface group, and between them the DOOR was never opened β the wave's biggest new
|
| 528 |
-
// surface was unreachable behind two green tickets.
|
| 529 |
-
"form",
|
| 530 |
-
// ββ W37-T41 (owner item 10 / R2) β `script` JOINS THE OFFER, and this is the release of the
|
| 531 |
-
// longest-held entry in this file. The hold's own release condition, written into
|
| 532 |
-
// `icons.test.ts::HELD_MODES`, named THREE things that had to land in one change: the name
|
| 533 |
-
// joins this list, `CustomerGrid.tsx` gains a `displayMode === "script"` branch, and the
|
| 534 |
-
// HELD_MODES entry comes out. All three are in this change.
|
| 535 |
-
//
|
| 536 |
-
// β AND A FOURTH THAT THE HOLD DID NOT NAME, which is the one that decides whether the
|
| 537 |
-
// feature works: `CustomerGrid.tsx::createView` had to learn that picking this mode mints a
|
| 538 |
-
// SCRIPT VIEW through `createScriptView` rather than persisting a workspace view. A script
|
| 539 |
-
// view is not a workspace view (it is its source and its version history, in E's own
|
| 540 |
-
// per-database store), so the three hops above alone would have offered the mode, written
|
| 541 |
-
// `config.display.mode = "script"` into a stored view, left `activeScriptId` null and drawn
|
| 542 |
-
// nothing, with `mode_parity` GREEN. That is wave 27's `swipe` defect exactly, reached
|
| 543 |
-
// through a different door.
|
| 544 |
-
// β OFFERED ONLY WHERE IT CAN EXIST. `ViewSidebar` takes `scriptable` and drops this row
|
| 545 |
-
// when it is false: script views are listed and resolved only on `ut_*` databases
|
| 546 |
-
// (`CustomerGrid`'s `scriptRows` effect gates on `hostWorkspace && isUserTable`), so offering
|
| 547 |
-
// it on a pool scope would mint a view the rail can never show
|
| 548 |
-
// ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
|
| 549 |
-
"script",
|
| 550 |
-
];
|
| 551 |
-
|
| 552 |
-
/**
|
| 553 |
-
* β WAVE-29 R6 (owner item 10) β the kind dropdown splits under TWO headers, and the strings are
|
| 554 |
-
* the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
|
| 555 |
-
* not "Custom interface".
|
| 556 |
-
*
|
| 557 |
-
* The line the two groups draw: a **View** ARRANGES the records β the same rows, re-shaped (a
|
| 558 |
-
* grid, a board, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a picture of the
|
| 559 |
-
* world with records placed on it, a catalog is a published artifact, a form is a door records come
|
| 560 |
-
* IN through and shows no records at all.
|
| 561 |
-
*
|
| 562 |
-
* β WAVE-33 item 9 AMENDED THAT LINE, and the owner drew it one notch differently than R6 did:
|
| 563 |
-
* `swipe` and `timeseries` moved from View to Interface. The reading that makes both rulings one
|
| 564 |
-
* rule β and the one the next mode should be grouped by β is **what the surface is FOR**, not how
|
| 565 |
-
* many rows it shows. A deck triages records one at a time and WRITES to them; a trend answers a
|
| 566 |
-
* question about a measure over time; neither hands you the row set re-shaped, which is what every
|
| 567 |
-
* remaining View does. β Do NOT re-derive the split from `MODE_TONE` below: the tones still say
|
| 568 |
-
* `catalog`/`form` are neutral because they arrange nothing, and `swipe`/`timeseries` are toned,
|
| 569 |
-
* so tone and group are no longer the same cut. Group membership lives HERE and only here.
|
| 570 |
-
*
|
| 571 |
-
* β TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
|
| 572 |
-
* vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
|
| 573 |
-
* map, and a mode whose group label matched nothing would silently stop being offered while
|
| 574 |
-
* every existing check (paintable, labelled, toned, unique, ordered) stayed green.
|
| 575 |
-
* `dashboard` is grouped like the `chart` it is the legacy spelling of β it is never offered, and
|
| 576 |
-
* a partial map is a worse answer than an unused entry.
|
| 577 |
-
*/
|
| 578 |
-
export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
|
| 579 |
-
export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];
|
| 580 |
-
|
| 581 |
-
export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
|
| 582 |
-
grid: "View",
|
| 583 |
-
list: "View",
|
| 584 |
-
kanban: "View",
|
| 585 |
-
calendar: "View",
|
| 586 |
-
chart: "View",
|
| 587 |
-
dashboard: "View",
|
| 588 |
-
// β WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
|
| 589 |
-
// of under View, when a user toggle it." See the amended taxonomy note above β a deck and a
|
| 590 |
-
// trend are both surfaces a person WORKS IN, not re-shapings of the row set.
|
| 591 |
-
timeseries: "Interface",
|
| 592 |
-
swipe: "Interface",
|
| 593 |
-
map: "Interface",
|
| 594 |
-
catalog: "Interface",
|
| 595 |
-
form: "Interface",
|
| 596 |
-
// W36-T04 β "Interface", by the W33 item-9 taxonomy: a script view is a surface somebody WORKS
|
| 597 |
-
// IN (writes, runs, reads an answer), not a re-shaping of the row set.
|
| 598 |
-
script: "Interface",
|
| 599 |
-
};
|
| 600 |
-
|
| 601 |
-
/**
|
| 602 |
-
* The offered modes, split into R6's two groups β DERIVED, never a third hand-written list.
|
| 603 |
-
*
|
| 604 |
-
* β ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
|
| 605 |
-
* is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
|
| 606 |
-
* is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
|
| 607 |
-
* `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
|
| 608 |
-
* every pair it ordered is still in that relative order here.
|
| 609 |
-
*/
|
| 610 |
-
export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
|
| 611 |
-
MODE_GROUP_LABELS.map((label) => ({
|
| 612 |
-
label,
|
| 613 |
-
modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
|
| 614 |
-
}));
|
| 615 |
-
|
| 616 |
-
export const MODE_TONE: Record<DisplayMode, FolderTone> = {
|
| 617 |
-
grid: "blue",
|
| 618 |
-
list: "blue",
|
| 619 |
-
chart: "green",
|
| 620 |
-
dashboard: "green",
|
| 621 |
-
calendar: "yellow",
|
| 622 |
-
kanban: "yellow",
|
| 623 |
-
map: "red",
|
| 624 |
-
// Green with `chart`: it is the other analytical mode, and the two belong to one family.
|
| 625 |
-
timeseries: "green",
|
| 626 |
-
// Wave-18 C6-CATALOG β NEUTRAL, and it is the honest pick rather than the leftover one. The
|
| 627 |
-
// four colour tones each name a family of ways to arrange records (blue = tabular, green =
|
| 628 |
-
// analytical, yellow = board/date, red = spatial); a catalog arranges nothing β it is a
|
| 629 |
-
// published artifact. Giving it a colour would file it under a family it is not in.
|
| 630 |
-
catalog: "neutral",
|
| 631 |
-
// Wave-23 C9 β NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
|
| 632 |
-
// name families of ways to ARRANGE records (blue tabular, green analytical, yellow
|
| 633 |
-
// board/date, red spatial). A form arranges nothing β it is a door records come in through β
|
| 634 |
-
// so giving it a colour would file it under a family it is not in.
|
| 635 |
-
form: "neutral",
|
| 636 |
-
// β Wave-27 C3 β YELLOW, with `kanban`, and this is a family claim rather than a leftover:
|
| 637 |
-
// a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
|
| 638 |
-
// two are one family seen at two zooms β all the lanes at once, or one card at a time. Filing
|
| 639 |
-
// it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
|
| 640 |
-
// two are neutral: this mode does arrange records, and it arranges them by the board's field.
|
| 641 |
-
swipe: "yellow",
|
| 642 |
-
// W36-T04 β NEUTRAL, by `catalog`'s and `form`'s rule rather than by elimination: the four
|
| 643 |
-
// tones name families of ways to ARRANGE records (blue tabular, green analytical, yellow
|
| 644 |
-
// board/date, red spatial). A script arranges nothing; it emits whatever it computes.
|
| 645 |
-
script: "neutral",
|
| 646 |
-
};
|
| 647 |
-
|
| 648 |
-
/**
|
| 649 |
-
* Human labels for every field type. Lives here beside the icons so the two
|
| 650 |
-
* halves of "how a field type presents itself" stay in one file (ColumnMenu
|
| 651 |
-
* imports it rather than keeping a second copy).
|
| 652 |
-
*/
|
| 653 |
-
export const TYPE_LABELS: Record<FieldType, string> = {
|
| 654 |
-
// β WAVE-29 item 3 β the owner's own words: "Change 'Single line text' to 'Text', keep it
|
| 655 |
-
// simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
|
| 656 |
-
// its long-text sibling); this product has no multi-line text kind, so the qualifier
|
| 657 |
-
// distinguished the type from nothing and only made the commonest row in the menu the longest.
|
| 658 |
-
// β
The STORED key is `"text"` and always was β the old string was never persisted anywhere,
|
| 659 |
-
// client or server, so this is a label change with no migration behind it.
|
| 660 |
-
text: "Text",
|
| 661 |
-
select: "Single select",
|
| 662 |
-
multiselect: "Multi select",
|
| 663 |
-
user: "Assignee",
|
| 664 |
-
int: "Number",
|
| 665 |
-
currency: "Currency",
|
| 666 |
-
pct: "Percent",
|
| 667 |
-
date: "Date",
|
| 668 |
-
checkbox: "Checkbox",
|
| 669 |
-
phone: "Phone number",
|
| 670 |
-
email: "Email",
|
| 671 |
-
url: "URL",
|
| 672 |
-
rating: "Rating",
|
| 673 |
-
created_time: "Created time",
|
| 674 |
-
formula: "Formula",
|
| 675 |
-
// Wave-18 C5-AUTOFIELD (D's spec, applied by C).
|
| 676 |
-
automation: "Automation",
|
| 677 |
-
// Wave-22 C7 β spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
|
| 678 |
-
// on headers and the field gear, not the create menu.
|
| 679 |
-
metric: "Metric",
|
| 680 |
-
// Wave-19 R7 β the picture column.
|
| 681 |
-
image: "Image",
|
| 682 |
-
// Wave-23 C7 β the structured-document column. "JSON" rather than "Structured data": it is
|
| 683 |
-
// the word on the wire, in the viewer's raw tab and in every error the server can return, and
|
| 684 |
-
// a friendlier synonym would be the only place in the product using a different one.
|
| 685 |
-
json: "JSON",
|
| 686 |
-
// β 2026-08-07 β Airtable's own wording, deliberately. "Link to another record" is what a
|
| 687 |
-
// person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
|
| 688 |
-
// "Reference") would make the feature they came for look absent.
|
| 689 |
-
link: "Link to another record",
|
| 690 |
-
rollup: "Rollup",
|
| 691 |
-
// β Wave-27 item 13 (R13) β "Code", not "Snippet" or "Source": it is the word the field kind
|
| 692 |
-
// is called everywhere else in this wave (the ruling, the language picker, the viewer header),
|
| 693 |
-
// and it says what the column holds without implying the product will run it.
|
| 694 |
-
code: "Code",
|
| 695 |
-
// β WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β the owner's own noun for the kind: "a field kind
|
| 696 |
-
// called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
|
| 697 |
-
// "Generate" (that names the verb, and the column's value is the point, not the act).
|
| 698 |
-
ai_enrich: "AI enrichment",
|
| 699 |
-
status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
|
| 700 |
-
};
|
| 701 |
-
|
| 702 |
-
/**
|
| 703 |
-
* β WAVE-29 C7 (item 17) β THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
|
| 704 |
-
* what the totals row and the per-group subtotals compute. Server twin:
|
| 705 |
-
* `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
|
| 706 |
-
* compares them name-for-name in order β the cross-language boundary is the one a type cannot
|
| 707 |
-
* police, so it gets a gate.
|
| 708 |
-
*
|
| 709 |
-
* β ONE CLIENT LIST, IMPORTED β never re-declared. `aggregations.ts` and the field editor import
|
| 710 |
-
* from here rather than keeping their own copy, which is why this lives in the pure data module
|
| 711 |
-
* beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
|
| 712 |
-
* the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
|
| 713 |
-
* mirror that is an import cannot fall out of step at all.
|
| 714 |
-
*
|
| 715 |
-
* β NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
|
| 716 |
-
* and gatekeeps a STORED value β renaming it would silently turn saved charts into sums. This
|
| 717 |
-
* list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
|
| 718 |
-
* summary and a rollup fold say the same word for the same operation.
|
| 719 |
-
*
|
| 720 |
-
* β `median` is net-new β in neither `CHART_AGGS` nor `ROLLUP_FNS`.
|
| 721 |
-
* β `count` counts ROWS in the scope, not non-blank cells.
|
| 722 |
-
* β Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
|
| 723 |
-
* for that is ALREADY `isNumericFieldType` (types.ts) β do not write a second one. `count` is
|
| 724 |
-
* legal on any type.
|
| 725 |
-
*/
|
| 726 |
-
// β W29-T74 β an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
|
| 727 |
-
// `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
|
| 728 |
-
// type the compiler already owns β the same defect as the array below, one level up.
|
| 729 |
-
export type FieldAgg = AggName;
|
| 730 |
-
|
| 731 |
-
/** ORDERED β the order is the picker's order, on both engines. */
|
| 732 |
-
export const FIELD_AGGS: readonly FieldAgg[] = [
|
| 733 |
-
"sum",
|
| 734 |
-
"average",
|
| 735 |
-
"median",
|
| 736 |
-
"min",
|
| 737 |
-
"max",
|
| 738 |
-
"count",
|
| 739 |
-
];
|
| 740 |
-
|
| 741 |
-
/**
|
| 742 |
-
* Human labels, in the summary bar's own compact register (Airtable's wording).
|
| 743 |
-
*
|
| 744 |
-
* β `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
|
| 745 |
-
* has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
|
| 746 |
-
* same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
|
| 747 |
-
* called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
|
| 748 |
-
*/
|
| 749 |
-
export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
|
| 750 |
-
sum: "Sum",
|
| 751 |
-
average: "Average",
|
| 752 |
-
median: "Median",
|
| 753 |
-
min: "Min",
|
| 754 |
-
max: "Max",
|
| 755 |
-
count: "Count",
|
| 756 |
-
};
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
// ------------------------------------------------------------ glide sprites
|
| 760 |
-
|
| 761 |
-
/** Serialize one shape to SVG source in an explicit colour (canvas sprites get
|
| 762 |
-
* no `currentColor` β glide hands the painter the theme colours directly). */
|
| 763 |
-
function shapeSource(s: IconShape, color: string): string {
|
| 764 |
-
return s.fill
|
| 765 |
-
? `<path d="${s.d}" fill="${color}"/>`
|
| 766 |
-
: `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
|
| 767 |
-
`stroke-linecap="round" stroke-linejoin="round"/>`;
|
| 768 |
-
}
|
| 769 |
-
|
| 770 |
-
function sprite(shapes: IconShape[]) {
|
| 771 |
-
return ({ fgColor }: { fgColor: string }) =>
|
| 772 |
-
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
| 773 |
-
shapes.map((s) => shapeSource(s, fgColor)).join("") +
|
| 774 |
-
`</svg>`;
|
| 775 |
-
}
|
| 776 |
-
|
| 777 |
-
/** Glide header-icon NAME for a field type β the `icon` a GridColumn asks for. */
|
| 778 |
-
export function typeIconName(type: FieldType): string {
|
| 779 |
-
return `t_${type}`;
|
| 780 |
-
}
|
| 781 |
-
|
| 782 |
-
/**
|
| 783 |
-
* The sprite map handed to <DataEditor headerIcons>. One entry per field type
|
| 784 |
-
* (I20 draws the type mark in every column header), built from the same shapes
|
| 785 |
-
* the React icons use.
|
| 786 |
-
*
|
| 787 |
-
* Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
|
| 788 |
-
* why theme.ts must set it β the library default is #FFFFFF, i.e. invisible on
|
| 789 |
-
* our header (that was I21's actual bug, not a too-pale hex of ours).
|
| 790 |
-
*/
|
| 791 |
-
export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
|
| 792 |
-
Object.fromEntries(
|
| 793 |
-
(Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
|
| 794 |
-
);
|
| 795 |
-
|
| 796 |
-
/**
|
| 797 |
-
* The header sprite map handed to <DataEditor headerIcons>. Two families:
|
| 798 |
-
*
|
| 799 |
-
* t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
|
| 800 |
-
* from the same shapes the React icons use. Painted by glide in
|
| 801 |
-
* `theme.fgIconHeader`.
|
| 802 |
-
* aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
|
| 803 |
-
* OUTLINE ONLY: a dark-grey ring with a transparent interior, per
|
| 804 |
-
* the owner. It still deliberately IGNORES the colours glide hands
|
| 805 |
-
* it, for the reason wave-8 recorded - glide's "special" variant is
|
| 806 |
-
* accentColor behind bgHeader, which under the C1 pastels is a pale
|
| 807 |
-
* glyph on a pale disc, i.e. I21 in a new costume.
|
| 808 |
-
* β It is NO LONGER a column `overlayIcon`. Glide draws an overlay
|
| 809 |
-
* at a hard-coded offset from the TYPE mark on the far LEFT of the
|
| 810 |
-
* header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
|
| 811 |
-
* aligned. It is now painted by CustomerGrid's `drawHeader`
|
| 812 |
-
* callback at `infoMarkRect()` - see overlayPlacement.ts.
|
| 813 |
-
*/
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / iconShapes.ts
|
| 3 |
+
// Wave-8 items I18 + I20 β ONE geometry source for the grid's icon vocabulary,
|
| 4 |
+
// rendered by TWO very different painters:
|
| 5 |
+
//
|
| 6 |
+
// - React <FieldTypeIcon> / <ModeIcon> β DOM svg in panels, popovers, menus
|
| 7 |
+
// - glide headerIcons sprites β canvas, drawn from an SVG *string*
|
| 8 |
+
//
|
| 9 |
+
// Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
|
| 10 |
+
// never be a React component. Keeping the paths as DATA (IconShape[]) and giving
|
| 11 |
+
// each painter its own thin renderer is what stops the two from drifting β the
|
| 12 |
+
// alternative (hand-copying every path into a template literal) guarantees the
|
| 13 |
+
// header and the panel eventually disagree about what a "date" looks like.
|
| 14 |
+
//
|
| 15 |
+
// Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
|
| 16 |
+
// currentColor. Vector paths only β NEVER emoji (owner constant).
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
import type { AggName, DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
|
| 20 |
+
import {
|
| 21 |
+
LP_BLUE,
|
| 22 |
+
LP_BLUE_DEEP,
|
| 23 |
+
LP_GREEN,
|
| 24 |
+
LP_GREEN_DEEP,
|
| 25 |
+
LP_LINE,
|
| 26 |
+
LP_MUTED,
|
| 27 |
+
LP_RED,
|
| 28 |
+
LP_RED_DEEP,
|
| 29 |
+
LP_YELLOW,
|
| 30 |
+
LP_YELLOW_DEEP,
|
| 31 |
+
} from "./theme";
|
| 32 |
+
|
| 33 |
+
/** One drawing primitive. `fill: true` fills the path instead of stroking it
|
| 34 |
+
* (the rating star is the only shape that reads better solid). */
|
| 35 |
+
export type IconShape = { d: string; fill?: boolean };
|
| 36 |
+
|
| 37 |
+
/** A circle as a path β two half-arcs. Sprites are SVG *source*, so every shape
|
| 38 |
+
* has to survive being serialized into a string; paths do, <circle> elements
|
| 39 |
+
* would need a second serializer branch for no benefit. */
|
| 40 |
+
const circle = (cx: number, cy: number, r: number): string =>
|
| 41 |
+
`M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
|
| 42 |
+
|
| 43 |
+
const CALENDAR: IconShape[] = [
|
| 44 |
+
{ d: "M3.2 4.6h9.6v8.2H3.2z" },
|
| 45 |
+
{ d: "M3.2 7.2h9.6" },
|
| 46 |
+
{ d: "M5.8 3v3.2" },
|
| 47 |
+
{ d: "M10.2 3v3.2" },
|
| 48 |
+
];
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Field type β icon geometry. A TOTAL record on purpose: adding a FieldType
|
| 52 |
+
* without an icon is a compile error, not a silently blank header.
|
| 53 |
+
*/
|
| 54 |
+
export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
|
| 55 |
+
text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
|
| 56 |
+
status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
|
| 57 |
+
currency: [
|
| 58 |
+
{ d: "M8 2.8v10.4" },
|
| 59 |
+
{ d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
|
| 60 |
+
],
|
| 61 |
+
int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
|
| 62 |
+
date: CALENDAR,
|
| 63 |
+
pct: [
|
| 64 |
+
{ d: circle(4.6, 4.6, 1.6) },
|
| 65 |
+
{ d: circle(11.4, 11.4, 1.6) },
|
| 66 |
+
{ d: "M12.2 3.9L3.8 12.3" },
|
| 67 |
+
],
|
| 68 |
+
select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
|
| 69 |
+
user: [
|
| 70 |
+
{ d: circle(8, 6, 2.4) },
|
| 71 |
+
{ d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
|
| 72 |
+
],
|
| 73 |
+
multiselect: [
|
| 74 |
+
{ d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
|
| 75 |
+
{ d: "M7.2 5.7h6M7.2 10.3h6" },
|
| 76 |
+
],
|
| 77 |
+
checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
|
| 78 |
+
phone: [
|
| 79 |
+
{ d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
|
| 80 |
+
],
|
| 81 |
+
email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
|
| 82 |
+
url: [
|
| 83 |
+
{ d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
|
| 84 |
+
{ d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
|
| 85 |
+
{ d: "M6.2 9.8l3.6-3.6" },
|
| 86 |
+
],
|
| 87 |
+
rating: [
|
| 88 |
+
{
|
| 89 |
+
d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
|
| 90 |
+
fill: true,
|
| 91 |
+
},
|
| 92 |
+
],
|
| 93 |
+
created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
|
| 94 |
+
formula: [
|
| 95 |
+
{ d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
|
| 96 |
+
{ d: "M4.2 7.6h4.6" },
|
| 97 |
+
{ d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
|
| 98 |
+
],
|
| 99 |
+
// Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
|
| 100 |
+
// with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
|
| 101 |
+
// Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
|
| 102 |
+
// (`created_time` owns the closed rim + hands).
|
| 103 |
+
automation: [
|
| 104 |
+
{ d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
|
| 105 |
+
{ d: "M4.2 6L5.3 4.1 3.1 4.5" },
|
| 106 |
+
{ d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
|
| 107 |
+
],
|
| 108 |
+
// Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
|
| 109 |
+
// series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
|
| 110 |
+
// the formula fx (that computes over the ROW) and not a bare number (int owns ##).
|
| 111 |
+
metric: [
|
| 112 |
+
{ d: "M3.2 3.2v9.6h9.6" },
|
| 113 |
+
{ d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
|
| 114 |
+
],
|
| 115 |
+
// Wave-23 C7 β TWO BRACES facing each other with a dot between them: the universal mark for
|
| 116 |
+
// "a structured document", and the one glyph in this table that draws its own SYNTAX rather
|
| 117 |
+
// than a picture of what the value means. Deliberately not a document page (nothing here owns
|
| 118 |
+
// that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
|
| 119 |
+
// of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
|
| 120 |
+
// reading as parentheses at small sizes.
|
| 121 |
+
json: [
|
| 122 |
+
{ d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
|
| 123 |
+
{ d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
|
| 124 |
+
{ d: circle(8, 8, 0.85), fill: true },
|
| 125 |
+
],
|
| 126 |
+
// β Wave-27 item 13 (R13) β THE ANGLE BRACKETS, the mark every editor on earth uses for
|
| 127 |
+
// "this is source". Drawn as two chevrons with a slash leaning between them, which is what
|
| 128 |
+
// separates it from `json` two entries up: json draws BRACES (a document's own syntax), code
|
| 129 |
+
// draws BRACKETS (a snippet's). Deliberately not a terminal prompt (that reads as "run this",
|
| 130 |
+
// and R13 is explicit there is no execution engine) and not a page of lines (`list` mode and
|
| 131 |
+
// `text` already trade on that reading).
|
| 132 |
+
code: [
|
| 133 |
+
{ d: "M5.6 4.9 2.6 8l3 3.1" },
|
| 134 |
+
{ d: "M10.4 4.9 13.4 8l-3 3.1" },
|
| 135 |
+
{ d: "M9.1 3.6 6.9 12.4" },
|
| 136 |
+
],
|
| 137 |
+
// Wave-19 R7 β a framed picture: the mount, a sun, and the hill line every photo glyph
|
| 138 |
+
// resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
|
| 139 |
+
image: [
|
| 140 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 141 |
+
{ d: circle(6, 6.3, 1.1) },
|
| 142 |
+
{ d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
|
| 143 |
+
],
|
| 144 |
+
// β 2026-08-07 β TWO INTERLOCKING CHAIN LINKS, the one glyph everybody already reads as
|
| 145 |
+
// "this points at something else". Drawn as two rounded rectangles overlapping at the centre
|
| 146 |
+
// rather than as an arrow into a box: an arrow would mean navigation, and a link column is a
|
| 147 |
+
// relation that exists in both directions whether or not you follow it.
|
| 148 |
+
link: [
|
| 149 |
+
{ d: "M6.6 5.2H4.9a2.8 2.8 0 000 5.6h1.7" },
|
| 150 |
+
{ d: "M9.4 5.2h1.7a2.8 2.8 0 010 5.6H9.4" },
|
| 151 |
+
{ d: "M5.6 8h4.8" },
|
| 152 |
+
],
|
| 153 |
+
// β 2026-08-07 β THREE BARS FOLDING INTO ONE, read top-to-bottom: many linked values
|
| 154 |
+
// collapsing to a single aggregate. Deliberately not a sigma (too fine at 16px, and it would
|
| 155 |
+
// claim SUM when the function is chosen per column) and not a funnel (that is filtering,
|
| 156 |
+
// which is what `limit` does β a different half of the same field).
|
| 157 |
+
rollup: [
|
| 158 |
+
{ d: "M3.2 4.4h9.6" },
|
| 159 |
+
{ d: "M4.8 8h6.4" },
|
| 160 |
+
{ d: "M6.6 11.6h2.8" },
|
| 161 |
+
],
|
| 162 |
+
// β WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β AI ENRICHMENT.
|
| 163 |
+
// The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
|
| 164 |
+
// on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
|
| 165 |
+
// second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
|
| 166 |
+
// (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
|
| 167 |
+
// head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
|
| 168 |
+
// β TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
|
| 169 |
+
// The small companion is what makes the mark say "sparkle".
|
| 170 |
+
ai_enrich: [
|
| 171 |
+
{ d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
|
| 172 |
+
{ d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
|
| 173 |
+
],
|
| 174 |
+
};
|
| 175 |
+
|
| 176 |
+
/**
|
| 177 |
+
* Display mode β icon geometry (I18). Also total: the wave-8 Dashboard mode
|
| 178 |
+
* cannot land in DISPLAY_MODES without the compiler demanding its icon here.
|
| 179 |
+
*/
|
| 180 |
+
export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
|
| 181 |
+
grid: [
|
| 182 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 183 |
+
{ d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
|
| 184 |
+
],
|
| 185 |
+
list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
|
| 186 |
+
// I19c β a framed set of bars: "several charts", not "one chart".
|
| 187 |
+
chart: [
|
| 188 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 189 |
+
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 190 |
+
],
|
| 191 |
+
// I10 (C2) β the LEGACY key. Kept so this record stays total over DisplayMode, which is
|
| 192 |
+
// what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
|
| 193 |
+
// promise. Same drawing: a stored 'dashboard' IS a chart.
|
| 194 |
+
dashboard: [
|
| 195 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 196 |
+
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 197 |
+
],
|
| 198 |
+
calendar: CALENDAR,
|
| 199 |
+
kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
|
| 200 |
+
map: [
|
| 201 |
+
{ d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
|
| 202 |
+
{ d: circle(8, 6.1, 1.3) },
|
| 203 |
+
],
|
| 204 |
+
// 2026-08-02 item 7 β the time-series view. Drawn as a framed grid with a trend running
|
| 205 |
+
// through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
|
| 206 |
+
// a per-row line toggle. Deliberately not the plain `line` chart mark β a chart view and a
|
| 207 |
+
// time-series table must not be the same picture in the same rail.
|
| 208 |
+
timeseries: [
|
| 209 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 210 |
+
{ d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
|
| 211 |
+
{ d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
|
| 212 |
+
],
|
| 213 |
+
// Wave-18 C6-CATALOG β an OPEN BOOK: two facing leaves with a spine between them, and a
|
| 214 |
+
// product block sitting on the left one. Every other mode in this table draws a way of
|
| 215 |
+
// arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
|
| 216 |
+
// with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
|
| 217 |
+
// not a framed grid (`grid`/`chart`/`timeseries` share the frame).
|
| 218 |
+
catalog: [
|
| 219 |
+
{ d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
|
| 220 |
+
{ d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
|
| 221 |
+
{ d: "M3.9 6.4h2.3v2.8H3.9z" },
|
| 222 |
+
],
|
| 223 |
+
// Wave-23 C9 β a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
|
| 224 |
+
// them. Every other mode here draws a way of ARRANGING records that already exist; this one
|
| 225 |
+
// has to read as a record being MADE, so it is the only mark whose bottom line is open.
|
| 226 |
+
// Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
|
| 227 |
+
// (an edit affordance means something else app-wide).
|
| 228 |
+
// β Wave-27 C3 (owner item 8) β a CARD WITH TWO ARROWS LEAVING IT, left and right. Every
|
| 229 |
+
// other mark here draws an arrangement of many records; this one has to read as ONE record
|
| 230 |
+
// with two exits, because that is exactly what the deck is. Deliberately not a stack of cards
|
| 231 |
+
// (that is a lane, and `kanban` owns it) and not a hand or a gesture glyph (nothing else in
|
| 232 |
+
// this vocabulary draws a body part, and it would read as "drag" rather than "decide").
|
| 233 |
+
swipe: [
|
| 234 |
+
{ d: "M5.4 3.4h5.2v9.2H5.4z" },
|
| 235 |
+
{ d: "M3.6 8H1.4M2.8 6.8 1.4 8l1.4 1.2" },
|
| 236 |
+
{ d: "M12.4 8h2.2M13.2 6.8 14.6 8l-1.4 1.2" },
|
| 237 |
+
],
|
| 238 |
+
form: [
|
| 239 |
+
{ d: "M3.4 2.8h9.2v10.4H3.4z" },
|
| 240 |
+
{ d: "M5.6 5.6h4.8" },
|
| 241 |
+
{ d: "M5.6 8h4.8" },
|
| 242 |
+
{ d: "M5.6 10.6h2.6" },
|
| 243 |
+
],
|
| 244 |
+
// W36-T04 β angle brackets over a baseline: the universal mark for "this is code", and the one
|
| 245 |
+
// shape in this table that is about the AUTHORING rather than about the arrangement of rows.
|
| 246 |
+
script: [
|
| 247 |
+
{ d: "M6 5.4 3.2 8l2.8 2.6M10 5.4 12.8 8 10 10.6" },
|
| 248 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 249 |
+
],
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
/**
|
| 253 |
+
* Wave-9 I16 β one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
|
| 254 |
+
* vocabulary cannot ship without a drawing.
|
| 255 |
+
*
|
| 256 |
+
* β The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
|
| 257 |
+
* an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
|
| 258 |
+
* picker is NOT a native `<select>` β it is a radio-row list like the mode switcher, which
|
| 259 |
+
* is the only shape that can carry a real mark.
|
| 260 |
+
*
|
| 261 |
+
* The key type is a string union declared here rather than imported from chartData.ts: this
|
| 262 |
+
* module is a leaf (types + theme only) and chartData imports IT, not the reverse.
|
| 263 |
+
*/
|
| 264 |
+
export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
|
| 265 |
+
export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
|
| 266 |
+
bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
|
| 267 |
+
line: [
|
| 268 |
+
{ d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
|
| 269 |
+
{ d: circle(5.8, 7.8, 0.9) },
|
| 270 |
+
{ d: circle(8.4, 9.8, 0.9) },
|
| 271 |
+
],
|
| 272 |
+
area: [
|
| 273 |
+
{ d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
|
| 274 |
+
{ d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
|
| 275 |
+
],
|
| 276 |
+
donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
|
| 277 |
+
// A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
|
| 278 |
+
// reads as "one number" beside four marks that all read as "a distribution".
|
| 279 |
+
kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
|
| 280 |
+
// Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
|
| 281 |
+
// values, not a distribution), with a header band and a column rule.
|
| 282 |
+
table: [
|
| 283 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 284 |
+
{ d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
|
| 285 |
+
],
|
| 286 |
+
};
|
| 287 |
+
|
| 288 |
+
export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
|
| 289 |
+
bar: "Bar",
|
| 290 |
+
line: "Line",
|
| 291 |
+
area: "Area",
|
| 292 |
+
donut: "Donut",
|
| 293 |
+
kpi: "Single value",
|
| 294 |
+
table: "Table",
|
| 295 |
+
};
|
| 296 |
+
|
| 297 |
+
/** I16 β the pastel each chart kind wears, same family rule as MODE_TONE. */
|
| 298 |
+
export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
|
| 299 |
+
bar: "blue",
|
| 300 |
+
line: "green",
|
| 301 |
+
area: "green",
|
| 302 |
+
donut: "yellow",
|
| 303 |
+
kpi: "neutral",
|
| 304 |
+
table: "neutral",
|
| 305 |
+
};
|
| 306 |
+
|
| 307 |
+
/**
|
| 308 |
+
* Wave-9 contract C5 (I15) β folder icon geometry. TOTAL over `FolderShape`, so a shape key
|
| 309 |
+
* added to the wire contract in types.ts cannot ship without a drawing.
|
| 310 |
+
*
|
| 311 |
+
* Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
|
| 312 |
+
* the same rail and read as one family. `folder` is first because it is the default every
|
| 313 |
+
* pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
|
| 314 |
+
*/
|
| 315 |
+
export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
|
| 316 |
+
folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
|
| 317 |
+
star: [
|
| 318 |
+
{ d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
|
| 319 |
+
],
|
| 320 |
+
flag: [
|
| 321 |
+
{ d: "M4.2 13.4V2.9" },
|
| 322 |
+
{ d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
|
| 323 |
+
],
|
| 324 |
+
tag: [
|
| 325 |
+
{ d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
|
| 326 |
+
{ d: circle(5.6, 5.6, 1) },
|
| 327 |
+
],
|
| 328 |
+
bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
|
| 329 |
+
// Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
|
| 330 |
+
// Reusing the geometry rather than drawing a second "chart" is the whole point of the
|
| 331 |
+
// one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
|
| 332 |
+
grid: MODE_SHAPES.grid,
|
| 333 |
+
chart: MODE_SHAPES.dashboard,
|
| 334 |
+
map: MODE_SHAPES.map,
|
| 335 |
+
users: TYPE_SHAPES.user,
|
| 336 |
+
clock: TYPE_SHAPES.created_time,
|
| 337 |
+
heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
|
| 338 |
+
bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
/**
|
| 342 |
+
* Tone key β the pastel it FILLS with, and the -d weight it STROKES with.
|
| 343 |
+
*
|
| 344 |
+
* Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
|
| 345 |
+
* that a base pastel at that size smudges β LP_BLUE measures 1.88:1 on white. So the pastel
|
| 346 |
+
* is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
|
| 347 |
+
* outline (an outline that reads at all).
|
| 348 |
+
*
|
| 349 |
+
* The default tone is `neutral` β HOST's C5 key, not "grey". The whitelist is SHARED between
|
| 350 |
+
* the two ends, so the name matters more than the word: a tone the host does not recognise
|
| 351 |
+
* degrades to the default and the user's choice silently disappears on reload.
|
| 352 |
+
*/
|
| 353 |
export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
|
| 354 |
neutral: { fill: LP_LINE, stroke: LP_MUTED },
|
| 355 |
blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
|
|
|
|
| 368 |
return (typeof tone === "string" ? FOLDER_TONE_PAINT[tone as FolderTone] : undefined)
|
| 369 |
?? FOLDER_TONE_PAINT.neutral;
|
| 370 |
}
|
| 371 |
+
|
| 372 |
+
export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
|
| 373 |
+
neutral: "Neutral",
|
| 374 |
+
blue: "Blue",
|
| 375 |
+
green: "Green",
|
| 376 |
+
yellow: "Yellow",
|
| 377 |
+
red: "Red",
|
| 378 |
+
};
|
| 379 |
+
|
| 380 |
+
export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
|
| 381 |
+
folder: "Folder",
|
| 382 |
+
star: "Star",
|
| 383 |
+
flag: "Flag",
|
| 384 |
+
tag: "Tag",
|
| 385 |
+
bookmark: "Bookmark",
|
| 386 |
+
grid: "Table",
|
| 387 |
+
chart: "Chart",
|
| 388 |
+
map: "Map",
|
| 389 |
+
users: "People",
|
| 390 |
+
clock: "Clock",
|
| 391 |
+
heart: "Heart",
|
| 392 |
+
bolt: "Priority",
|
| 393 |
+
};
|
| 394 |
+
|
| 395 |
+
/**
|
| 396 |
+
* Wave-9 I14 β the tone each CREATABLE view type wears in the "+ Create newβ¦" flyout.
|
| 397 |
+
*
|
| 398 |
+
* The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
|
| 399 |
+
* is a list you read rather than scan. Assigned by family, not by rotation: the two
|
| 400 |
+
* record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
|
| 401 |
+
* share yellow, chart is green because it is the analytical one, map is red because it is
|
| 402 |
+
* the geographic one. Folder is grey β it is not a view, and the flyout's last row should
|
| 403 |
+
* not compete with the six above it.
|
| 404 |
+
*/
|
| 405 |
+
/**
|
| 406 |
+
* Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
|
| 407 |
+
* sits beside the geometry, the way TYPE_LABELS does β the mode switcher, the create flyout
|
| 408 |
+
* and the create prompt now read ONE table instead of three. C2's "Dashboard" β "Chart"
|
| 409 |
+
* rename is a single line here as a direct result.
|
| 410 |
+
*/
|
| 411 |
+
export const MODE_LABELS: Record<DisplayMode, string> = {
|
| 412 |
+
grid: "Grid",
|
| 413 |
+
chart: "Chart",
|
| 414 |
+
// Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
|
| 415 |
+
// read before normalisation must never render a blank switcher chip.
|
| 416 |
+
dashboard: "Chart",
|
| 417 |
+
list: "List",
|
| 418 |
+
calendar: "Calendar",
|
| 419 |
+
kanban: "Kanban",
|
| 420 |
+
map: "Map",
|
| 421 |
+
timeseries: "Time series",
|
| 422 |
+
catalog: "Catalog",
|
| 423 |
+
// Wave-23 C9 β the mode that COLLECTS records. "Form", the word the whole product uses for
|
| 424 |
+
// it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
|
| 425 |
+
// be the one surface calling it something else.
|
| 426 |
+
form: "Form",
|
| 427 |
+
// β Wave-27 C3 (item 8) β the owner's own word for it. Not "Triage" or "Review": the gesture
|
| 428 |
+
// IS the name here, and the two candidates both collide with vocabulary this product already
|
| 429 |
+
// spends elsewhere (a `review` automation decision, the retired review lanes).
|
| 430 |
+
swipe: "Swipe",
|
| 431 |
+
// W36-T04 β the owner's own words are "code script"; "Script" is the half that is not implied
|
| 432 |
+
// by the icon, and the product has no other surface competing for the noun.
|
| 433 |
+
//
|
| 434 |
+
// ββ W37-T41 (owner ruling, 2026-08-19) β RENAMED TO "Custom", and the one-word form is the
|
| 435 |
+
// ruling rather than a shortening of it. Every wave-37 governing text calls this surface the
|
| 436 |
+
// "Custom View": PRD items 10 / R2, T41's own `done-when`, D-391, and `ScriptViewPanel`'s
|
| 437 |
+
// shipped copy ("Picking Custom View mints a view with an EMPTY source"). "Script" was the
|
| 438 |
+
// product calling one thing two names, which is the defect `swipe`'s label note warns about
|
| 439 |
+
// one table up.
|
| 440 |
+
//
|
| 441 |
+
// β IT IS NOT SPELLED "Custom View" HERE, and the reason is this table's job rather than a
|
| 442 |
+
// preference. Every value is a KIND NOUN that four call sites COMPOSE into a phrase:
|
| 443 |
+
// `ViewSidebar` renders `New ${MODE_LABELS[creating].toLowerCase()} view` (:1060), the same
|
| 444 |
+
// string as the flyout's aria-label (:1045), `${MODE_LABELS[mode]} view, locked` (:1619) and
|
| 445 |
+
// `It also stays a ${MODE_LABELS[mode].toLowerCase()}.` (:1626). A label already carrying the
|
| 446 |
+
// noun reads "New custom view view" at two of them. One word keeps the create prompt reading
|
| 447 |
+
// "New custom view", the locked aria-label reading "Custom view, locked", and the picker row
|
| 448 |
+
// reading "Custom" beside Grid, Chart and Map, which are kind nouns too.
|
| 449 |
+
script: "Custom",
|
| 450 |
+
};
|
| 451 |
+
|
| 452 |
+
/**
|
| 453 |
+
* I14 β the view types the "+ Create newβ¦" flyout OFFERS, in the order it lists them.
|
| 454 |
+
*
|
| 455 |
+
* Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
|
| 456 |
+
* C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
|
| 457 |
+
* rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β one list cannot
|
| 458 |
+
* express both. Living in this pure data module means the gate can assert the offered set
|
| 459 |
+
* without importing a React component, and I10 becomes a one-line edit in one file.
|
| 460 |
+
*/
|
| 461 |
+
// 2026-08-02 item 7 β `timeseries` was deliberately held OUT of this list until the host
|
| 462 |
+
// accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
|
| 463 |
+
// wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
|
| 464 |
+
// not know, so offering it early would let a user create a view that silently reverts to a
|
| 465 |
+
// grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
|
| 466 |
+
// `DISPLAY_MODES += timeseries`, so it is offerable now.
|
| 467 |
+
//
|
| 468 |
+
// wave17 GRID, owner item 10 β THE ORDER BELOW IS THE OWNER'S, stated verbatim:
|
| 469 |
+
// Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
|
| 470 |
+
//
|
| 471 |
+
// β It supersedes the two orderings this list has carried before it, and the reasoning that
|
| 472 |
+
// produced them is now WRONG rather than merely outranked, so it is not left here to be
|
| 473 |
+
// re-applied: `timeseries` was "listed last⦠it belongs beside Chart", and `list` sat second
|
| 474 |
+
// as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
|
| 475 |
+
// a product decision, so it is asserted in `verify_icons` rather than left to a comment β
|
| 476 |
+
// nothing else on screen would go red if a future edit re-sorted it "sensibly".
|
| 477 |
+
// Wave-18 C6-CATALOG β `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
|
| 478 |
+
// accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
|
| 479 |
+
// "C6 HOST MIRROR APPLIED β you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
|
| 480 |
+
// It lands LAST by contract: the owner's seven-mode order above is a product decision and the
|
| 481 |
+
// new mode joins the end of it rather than being sorted into it.
|
| 482 |
+
// β Wave-27 C3 (owner item 8) β `swipe` is HELD OUT of this list, and the reason corrects a
|
| 483 |
+
// mistake this file made an hour earlier.
|
| 484 |
+
//
|
| 485 |
+
// β THE HOLD WAS NEVER ONLY ABOUT THE TWO REGISTRIES. It was first written as "do not offer a
|
| 486 |
+
// mode the HOST has not accepted", and one session owning both registries this wave genuinely
|
| 487 |
+
// does close that half β which is what made it tempting to skip. But the rule the hold really
|
| 488 |
+
// encodes is broader and this wave proved it: **do not offer a mode whose CONSUMER does not
|
| 489 |
+
// exist.** `swipe` was briefly listed here while `CustomerGrid`'s mode dispatch had no branch
|
| 490 |
+
// for it, so picking "Swipe" wrote a mode the host now happily PERSISTS, and the body rendered
|
| 491 |
+
// a grid under a chip reading "View Β· Swipe" β surviving reload, with the agreement leg green
|
| 492 |
+
// because it only ever compared two lists.
|
| 493 |
+
//
|
| 494 |
+
// So the hold stood until `SwipeView` was mounted (contract C3), and it was not a mailbox
|
| 495 |
+
// handshake: `verify_icons` DERIVES the condition β either `swipe` is absent here, or
|
| 496 |
+
// `CustomerGrid.tsx` mounts `SwipeView`. `form` (wave-23) is the same defect from the other
|
| 497 |
+
// direction, sitting unoffered because its host mirror never landed (DEBT D-90).
|
| 498 |
+
//
|
| 499 |
+
// β THE HOLD OUTLIVED THE WAVE, AND THAT IS THE LESSON WORTH MORE THAN THE FEATURE.
|
| 500 |
+
// Wave 27 closed with the mount NEVER LANDING. `SwipeView.tsx` shipped, the server accepted
|
| 501 |
+
// `swipe`, all four maps above carried it, 50 gates were green, the wave-27 mailbox recorded
|
| 502 |
+
// "RESOLVED BY C (swipe mounted)", the close-out booked D-102 β a negative control FOR the
|
| 503 |
+
// swipe carry β and the owner could not find the view because **nothing imported the file.**
|
| 504 |
+
// The derived gate could not catch it: its condition is a DISJUNCTION and **absence satisfies
|
| 505 |
+
// it**, so the unshipped state was permanently green ([[gate-can-report-green-on-nothing]]).
|
| 506 |
+
// A hold that is safe to leave in place is a hold nothing forces you to lift.
|
| 507 |
+
// The audit query that found it, after the battery did not: for each artifact a wave adds, grep
|
| 508 |
+
// for its CONSUMER β who imports/mounts/registers it β excluding the file itself, `_test/`, css
|
| 509 |
+
// and comments. `SwipeView` had four hits and three were prose.
|
| 510 |
+
// Mounted 2026-08-09 (`CustomerGrid.tsx`, `displayMode === "swipe"`), so `swipe` is offerable.
|
| 511 |
+
// It lands LAST, by `catalog`'s rule: the owner's mode order is a product decision and a new
|
| 512 |
+
// mode joins the end of it rather than being sorted into it.
|
| 513 |
+
export const CREATABLE_MODES: DisplayMode[] = [
|
| 514 |
+
"grid",
|
| 515 |
+
"chart",
|
| 516 |
+
"calendar",
|
| 517 |
+
"kanban",
|
| 518 |
+
"timeseries",
|
| 519 |
+
"map",
|
| 520 |
+
"list",
|
| 521 |
+
"catalog",
|
| 522 |
+
"swipe",
|
| 523 |
+
// Mounted 2026-08-11 (`CustomerGrid.tsx`, `displayMode === "form"` renders `FormInterface`), so
|
| 524 |
+
// `form` is offerable and its `HELD_MODES` entry came out in the same edit β the hold's own text
|
| 525 |
+
// named this mount as its release condition. β Found by `verify_icons.py::mode_parity` law E
|
| 526 |
+
// during /validate-wave, NOT by the lane that mounted it: T29 mounted the component and T41 built
|
| 527 |
+
// the Interface group, and between them the DOOR was never opened β the wave's biggest new
|
| 528 |
+
// surface was unreachable behind two green tickets.
|
| 529 |
+
"form",
|
| 530 |
+
// ββ W37-T41 (owner item 10 / R2) β `script` JOINS THE OFFER, and this is the release of the
|
| 531 |
+
// longest-held entry in this file. The hold's own release condition, written into
|
| 532 |
+
// `icons.test.ts::HELD_MODES`, named THREE things that had to land in one change: the name
|
| 533 |
+
// joins this list, `CustomerGrid.tsx` gains a `displayMode === "script"` branch, and the
|
| 534 |
+
// HELD_MODES entry comes out. All three are in this change.
|
| 535 |
+
//
|
| 536 |
+
// β AND A FOURTH THAT THE HOLD DID NOT NAME, which is the one that decides whether the
|
| 537 |
+
// feature works: `CustomerGrid.tsx::createView` had to learn that picking this mode mints a
|
| 538 |
+
// SCRIPT VIEW through `createScriptView` rather than persisting a workspace view. A script
|
| 539 |
+
// view is not a workspace view (it is its source and its version history, in E's own
|
| 540 |
+
// per-database store), so the three hops above alone would have offered the mode, written
|
| 541 |
+
// `config.display.mode = "script"` into a stored view, left `activeScriptId` null and drawn
|
| 542 |
+
// nothing, with `mode_parity` GREEN. That is wave 27's `swipe` defect exactly, reached
|
| 543 |
+
// through a different door.
|
| 544 |
+
// β OFFERED ONLY WHERE IT CAN EXIST. `ViewSidebar` takes `scriptable` and drops this row
|
| 545 |
+
// when it is false: script views are listed and resolved only on `ut_*` databases
|
| 546 |
+
// (`CustomerGrid`'s `scriptRows` effect gates on `hostWorkspace && isUserTable`), so offering
|
| 547 |
+
// it on a pool scope would mint a view the rail can never show
|
| 548 |
+
// ([[permitted-is-not-answerable]]). It lands LAST by `catalog`'s rule.
|
| 549 |
+
"script",
|
| 550 |
+
];
|
| 551 |
+
|
| 552 |
+
/**
|
| 553 |
+
* β WAVE-29 R6 (owner item 10) β the kind dropdown splits under TWO headers, and the strings are
|
| 554 |
+
* the owner's own: exactly `View` and `Interface`. Not "View as" (what the popover said before),
|
| 555 |
+
* not "Custom interface".
|
| 556 |
+
*
|
| 557 |
+
* The line the two groups draw: a **View** ARRANGES the records β the same rows, re-shaped (a
|
| 558 |
+
* grid, a board, a chart). An **Interface** is a SURFACE BUILT OVER them: a map is a picture of the
|
| 559 |
+
* world with records placed on it, a catalog is a published artifact, a form is a door records come
|
| 560 |
+
* IN through and shows no records at all.
|
| 561 |
+
*
|
| 562 |
+
* β WAVE-33 item 9 AMENDED THAT LINE, and the owner drew it one notch differently than R6 did:
|
| 563 |
+
* `swipe` and `timeseries` moved from View to Interface. The reading that makes both rulings one
|
| 564 |
+
* rule β and the one the next mode should be grouped by β is **what the surface is FOR**, not how
|
| 565 |
+
* many rows it shows. A deck triages records one at a time and WRITES to them; a trend answers a
|
| 566 |
+
* question about a measure over time; neither hands you the row set re-shaped, which is what every
|
| 567 |
+
* remaining View does. β Do NOT re-derive the split from `MODE_TONE` below: the tones still say
|
| 568 |
+
* `catalog`/`form` are neutral because they arrange nothing, and `swipe`/`timeseries` are toned,
|
| 569 |
+
* so tone and group are no longer the same cut. Group membership lives HERE and only here.
|
| 570 |
+
*
|
| 571 |
+
* β TOTAL over `DisplayMode`, so tsc refuses a new mode with no group rather than letting it
|
| 572 |
+
* vanish from the dropdown: membership is derived by FILTERING `CREATABLE_MODES` through this
|
| 573 |
+
* map, and a mode whose group label matched nothing would silently stop being offered while
|
| 574 |
+
* every existing check (paintable, labelled, toned, unique, ordered) stayed green.
|
| 575 |
+
* `dashboard` is grouped like the `chart` it is the legacy spelling of β it is never offered, and
|
| 576 |
+
* a partial map is a worse answer than an unused entry.
|
| 577 |
+
*/
|
| 578 |
+
export const MODE_GROUP_LABELS = ["View", "Interface"] as const;
|
| 579 |
+
export type ModeGroup = (typeof MODE_GROUP_LABELS)[number];
|
| 580 |
+
|
| 581 |
+
export const MODE_GROUP: Record<DisplayMode, ModeGroup> = {
|
| 582 |
+
grid: "View",
|
| 583 |
+
list: "View",
|
| 584 |
+
kanban: "View",
|
| 585 |
+
calendar: "View",
|
| 586 |
+
chart: "View",
|
| 587 |
+
dashboard: "View",
|
| 588 |
+
// β WAVE-33 item 9 (owner, verbatim): "Let's move Swipe and Time-series under Interface instead
|
| 589 |
+
// of under View, when a user toggle it." See the amended taxonomy note above β a deck and a
|
| 590 |
+
// trend are both surfaces a person WORKS IN, not re-shapings of the row set.
|
| 591 |
+
timeseries: "Interface",
|
| 592 |
+
swipe: "Interface",
|
| 593 |
+
map: "Interface",
|
| 594 |
+
catalog: "Interface",
|
| 595 |
+
form: "Interface",
|
| 596 |
+
// W36-T04 β "Interface", by the W33 item-9 taxonomy: a script view is a surface somebody WORKS
|
| 597 |
+
// IN (writes, runs, reads an answer), not a re-shaping of the row set.
|
| 598 |
+
script: "Interface",
|
| 599 |
+
};
|
| 600 |
+
|
| 601 |
+
/**
|
| 602 |
+
* The offered modes, split into R6's two groups β DERIVED, never a third hand-written list.
|
| 603 |
+
*
|
| 604 |
+
* β ORDER: R6 names each group's MEMBERS; the order inside a group stays `CREATABLE_MODES`', which
|
| 605 |
+
* is the wave-17 owner ruling ("Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List") and
|
| 606 |
+
* is separately asserted. The two rulings are compatible read this way and only this way: R6 moved
|
| 607 |
+
* `map` out of the run of views, so wave-17's single sequence can no longer exist as one list, but
|
| 608 |
+
* every pair it ordered is still in that relative order here.
|
| 609 |
+
*/
|
| 610 |
+
export const CREATABLE_GROUPS: readonly { label: ModeGroup; modes: DisplayMode[] }[] =
|
| 611 |
+
MODE_GROUP_LABELS.map((label) => ({
|
| 612 |
+
label,
|
| 613 |
+
modes: CREATABLE_MODES.filter((m) => MODE_GROUP[m] === label),
|
| 614 |
+
}));
|
| 615 |
+
|
| 616 |
+
export const MODE_TONE: Record<DisplayMode, FolderTone> = {
|
| 617 |
+
grid: "blue",
|
| 618 |
+
list: "blue",
|
| 619 |
+
chart: "green",
|
| 620 |
+
dashboard: "green",
|
| 621 |
+
calendar: "yellow",
|
| 622 |
+
kanban: "yellow",
|
| 623 |
+
map: "red",
|
| 624 |
+
// Green with `chart`: it is the other analytical mode, and the two belong to one family.
|
| 625 |
+
timeseries: "green",
|
| 626 |
+
// Wave-18 C6-CATALOG β NEUTRAL, and it is the honest pick rather than the leftover one. The
|
| 627 |
+
// four colour tones each name a family of ways to arrange records (blue = tabular, green =
|
| 628 |
+
// analytical, yellow = board/date, red = spatial); a catalog arranges nothing β it is a
|
| 629 |
+
// published artifact. Giving it a colour would file it under a family it is not in.
|
| 630 |
+
catalog: "neutral",
|
| 631 |
+
// Wave-23 C9 β NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
|
| 632 |
+
// name families of ways to ARRANGE records (blue tabular, green analytical, yellow
|
| 633 |
+
// board/date, red spatial). A form arranges nothing β it is a door records come in through β
|
| 634 |
+
// so giving it a colour would file it under a family it is not in.
|
| 635 |
+
form: "neutral",
|
| 636 |
+
// β Wave-27 C3 β YELLOW, with `kanban`, and this is a family claim rather than a leftover:
|
| 637 |
+
// a swipe deck writes the SAME single-select a kanban stacks by (R2 binds it to one), so the
|
| 638 |
+
// two are one family seen at two zooms β all the lanes at once, or one card at a time. Filing
|
| 639 |
+
// it neutral (the `catalog`/`form` reasoning) would be wrong for the opposite reason those
|
| 640 |
+
// two are neutral: this mode does arrange records, and it arranges them by the board's field.
|
| 641 |
+
swipe: "yellow",
|
| 642 |
+
// W36-T04 β NEUTRAL, by `catalog`'s and `form`'s rule rather than by elimination: the four
|
| 643 |
+
// tones name families of ways to ARRANGE records (blue tabular, green analytical, yellow
|
| 644 |
+
// board/date, red spatial). A script arranges nothing; it emits whatever it computes.
|
| 645 |
+
script: "neutral",
|
| 646 |
+
};
|
| 647 |
+
|
| 648 |
+
/**
|
| 649 |
+
* Human labels for every field type. Lives here beside the icons so the two
|
| 650 |
+
* halves of "how a field type presents itself" stay in one file (ColumnMenu
|
| 651 |
+
* imports it rather than keeping a second copy).
|
| 652 |
+
*/
|
| 653 |
+
export const TYPE_LABELS: Record<FieldType, string> = {
|
| 654 |
+
// β WAVE-29 item 3 β the owner's own words: "Change 'Single line text' to 'Text', keep it
|
| 655 |
+
// simple for the Field type". Airtable's phrase described the column's SHAPE (one line, versus
|
| 656 |
+
// its long-text sibling); this product has no multi-line text kind, so the qualifier
|
| 657 |
+
// distinguished the type from nothing and only made the commonest row in the menu the longest.
|
| 658 |
+
// β
The STORED key is `"text"` and always was β the old string was never persisted anywhere,
|
| 659 |
+
// client or server, so this is a label change with no migration behind it.
|
| 660 |
+
text: "Text",
|
| 661 |
+
select: "Single select",
|
| 662 |
+
multiselect: "Multi select",
|
| 663 |
+
user: "Assignee",
|
| 664 |
+
int: "Number",
|
| 665 |
+
currency: "Currency",
|
| 666 |
+
pct: "Percent",
|
| 667 |
+
date: "Date",
|
| 668 |
+
checkbox: "Checkbox",
|
| 669 |
+
phone: "Phone number",
|
| 670 |
+
email: "Email",
|
| 671 |
+
url: "URL",
|
| 672 |
+
rating: "Rating",
|
| 673 |
+
created_time: "Created time",
|
| 674 |
+
formula: "Formula",
|
| 675 |
+
// Wave-18 C5-AUTOFIELD (D's spec, applied by C).
|
| 676 |
+
automation: "Automation",
|
| 677 |
+
// Wave-22 C7 β spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
|
| 678 |
+
// on headers and the field gear, not the create menu.
|
| 679 |
+
metric: "Metric",
|
| 680 |
+
// Wave-19 R7 β the picture column.
|
| 681 |
+
image: "Image",
|
| 682 |
+
// Wave-23 C7 β the structured-document column. "JSON" rather than "Structured data": it is
|
| 683 |
+
// the word on the wire, in the viewer's raw tab and in every error the server can return, and
|
| 684 |
+
// a friendlier synonym would be the only place in the product using a different one.
|
| 685 |
+
json: "JSON",
|
| 686 |
+
// β 2026-08-07 β Airtable's own wording, deliberately. "Link to another record" is what a
|
| 687 |
+
// person migrating from Airtable searches this menu for, and inventing a synonym ("Relation",
|
| 688 |
+
// "Reference") would make the feature they came for look absent.
|
| 689 |
+
link: "Link to another record",
|
| 690 |
+
rollup: "Rollup",
|
| 691 |
+
// β Wave-27 item 13 (R13) β "Code", not "Snippet" or "Source": it is the word the field kind
|
| 692 |
+
// is called everywhere else in this wave (the ruling, the language picker, the viewer header),
|
| 693 |
+
// and it says what the column holds without implying the product will run it.
|
| 694 |
+
code: "Code",
|
| 695 |
+
// β WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β the owner's own noun for the kind: "a field kind
|
| 696 |
+
// called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
|
| 697 |
+
// "Generate" (that names the verb, and the column's value is the point, not the act).
|
| 698 |
+
ai_enrich: "AI enrichment",
|
| 699 |
+
status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
|
| 700 |
+
};
|
| 701 |
+
|
| 702 |
+
/**
|
| 703 |
+
* β WAVE-29 C7 (item 17) β THE COLUMN-SUMMARY vocabulary: what a field's `agg` may be, which is
|
| 704 |
+
* what the totals row and the per-group subtotals compute. Server twin:
|
| 705 |
+
* `platform/aios_grid.py::FIELD_AGGS`, and `verify_icons.py::agg_parity` reads BOTH FILES and
|
| 706 |
+
* compares them name-for-name in order β the cross-language boundary is the one a type cannot
|
| 707 |
+
* police, so it gets a gate.
|
| 708 |
+
*
|
| 709 |
+
* β ONE CLIENT LIST, IMPORTED β never re-declared. `aggregations.ts` and the field editor import
|
| 710 |
+
* from here rather than keeping their own copy, which is why this lives in the pure data module
|
| 711 |
+
* beside `TYPE_LABELS` and `CREATABLE_MODES`: a second client list would need a second gate, and
|
| 712 |
+
* the two would drift in the direction nobody is watching. C7 says "C publishes, E mirrors"; a
|
| 713 |
+
* mirror that is an import cannot fall out of step at all.
|
| 714 |
+
*
|
| 715 |
+
* β NOT the chart vocabulary. `CHART_AGGS` (`aios_grid.py`, `viz/chartData.ts`) spells it `avg`
|
| 716 |
+
* and gatekeeps a STORED value β renaming it would silently turn saved charts into sums. This
|
| 717 |
+
* list spells it `average`, matching `ROLLUP_FNS` (16 names, live in production), so a column
|
| 718 |
+
* summary and a rollup fold say the same word for the same operation.
|
| 719 |
+
*
|
| 720 |
+
* β `median` is net-new β in neither `CHART_AGGS` nor `ROLLUP_FNS`.
|
| 721 |
+
* β `count` counts ROWS in the scope, not non-blank cells.
|
| 722 |
+
* β Which types may carry which: `sum/average/median/min/max` are numeric-only and the evaluator
|
| 723 |
+
* for that is ALREADY `isNumericFieldType` (types.ts) β do not write a second one. `count` is
|
| 724 |
+
* legal on any type.
|
| 725 |
+
*/
|
| 726 |
+
// β W29-T74 β an ALIAS of `types.AggName`, not a fifth copy of the union. `Field.agg` is typed
|
| 727 |
+
// `AggName`, so a second literal here would be a type that has to be kept in step by eye with a
|
| 728 |
+
// type the compiler already owns β the same defect as the array below, one level up.
|
| 729 |
+
export type FieldAgg = AggName;
|
| 730 |
+
|
| 731 |
+
/** ORDERED β the order is the picker's order, on both engines. */
|
| 732 |
+
export const FIELD_AGGS: readonly FieldAgg[] = [
|
| 733 |
+
"sum",
|
| 734 |
+
"average",
|
| 735 |
+
"median",
|
| 736 |
+
"min",
|
| 737 |
+
"max",
|
| 738 |
+
"count",
|
| 739 |
+
];
|
| 740 |
+
|
| 741 |
+
/**
|
| 742 |
+
* Human labels, in the summary bar's own compact register (Airtable's wording).
|
| 743 |
+
*
|
| 744 |
+
* β `FIELD_AGG_LABELS`, not `AGG_LABELS`, and the prefix is load-bearing: `viewModes.tsx` already
|
| 745 |
+
* has a module-local `AGG_LABELS` for the CALENDAR summary picker over `CHART_AGGS`, where the
|
| 746 |
+
* same five names wear different words ("Total", "Lowest", "Highest") for a day cell. Two tables
|
| 747 |
+
* called `AGG_LABELS` describing two vocabularies is how a future import lands on the wrong one.
|
| 748 |
+
*/
|
| 749 |
+
export const FIELD_AGG_LABELS: Record<FieldAgg, string> = {
|
| 750 |
+
sum: "Sum",
|
| 751 |
+
average: "Average",
|
| 752 |
+
median: "Median",
|
| 753 |
+
min: "Min",
|
| 754 |
+
max: "Max",
|
| 755 |
+
count: "Count",
|
| 756 |
+
};
|
| 757 |
+
|
| 758 |
+
|
| 759 |
+
// ------------------------------------------------------------ glide sprites
|
| 760 |
+
|
| 761 |
+
/** Serialize one shape to SVG source in an explicit colour (canvas sprites get
|
| 762 |
+
* no `currentColor` β glide hands the painter the theme colours directly). */
|
| 763 |
+
function shapeSource(s: IconShape, color: string): string {
|
| 764 |
+
return s.fill
|
| 765 |
+
? `<path d="${s.d}" fill="${color}"/>`
|
| 766 |
+
: `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
|
| 767 |
+
`stroke-linecap="round" stroke-linejoin="round"/>`;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
function sprite(shapes: IconShape[]) {
|
| 771 |
+
return ({ fgColor }: { fgColor: string }) =>
|
| 772 |
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
| 773 |
+
shapes.map((s) => shapeSource(s, fgColor)).join("") +
|
| 774 |
+
`</svg>`;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
/** Glide header-icon NAME for a field type β the `icon` a GridColumn asks for. */
|
| 778 |
+
export function typeIconName(type: FieldType): string {
|
| 779 |
+
return `t_${type}`;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
/**
|
| 783 |
+
* The sprite map handed to <DataEditor headerIcons>. One entry per field type
|
| 784 |
+
* (I20 draws the type mark in every column header), built from the same shapes
|
| 785 |
+
* the React icons use.
|
| 786 |
+
*
|
| 787 |
+
* Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
|
| 788 |
+
* why theme.ts must set it β the library default is #FFFFFF, i.e. invisible on
|
| 789 |
+
* our header (that was I21's actual bug, not a too-pale hex of ours).
|
| 790 |
+
*/
|
| 791 |
+
export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
|
| 792 |
+
Object.fromEntries(
|
| 793 |
+
(Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
|
| 794 |
+
);
|
| 795 |
+
|
| 796 |
+
/**
|
| 797 |
+
* The header sprite map handed to <DataEditor headerIcons>. Two families:
|
| 798 |
+
*
|
| 799 |
+
* t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
|
| 800 |
+
* from the same shapes the React icons use. Painted by glide in
|
| 801 |
+
* `theme.fgIconHeader`.
|
| 802 |
+
* aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
|
| 803 |
+
* OUTLINE ONLY: a dark-grey ring with a transparent interior, per
|
| 804 |
+
* the owner. It still deliberately IGNORES the colours glide hands
|
| 805 |
+
* it, for the reason wave-8 recorded - glide's "special" variant is
|
| 806 |
+
* accentColor behind bgHeader, which under the C1 pastels is a pale
|
| 807 |
+
* glyph on a pale disc, i.e. I21 in a new costume.
|
| 808 |
+
* β It is NO LONGER a column `overlayIcon`. Glide draws an overlay
|
| 809 |
+
* at a hard-coded offset from the TYPE mark on the far LEFT of the
|
| 810 |
+
* header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
|
| 811 |
+
* aligned. It is now painted by CustomerGrid's `drawHeader`
|
| 812 |
+
* callback at `infoMarkRect()` - see overlayPlacement.ts.
|
| 813 |
+
*/
|
| 814 |
+
/**
|
| 815 |
+
* β W40-T20 β THE share glyph: two people, one behind the other. The ONE geometry for "this is
|
| 816 |
+
* shared", as data, so the canvas sprite below and any React consumer paint the same shape
|
| 817 |
+
* instead of two hand-copied SVGs that drift.
|
| 818 |
+
*
|
| 819 |
+
* β NOT A PADLOCK. `LockMark` (icons.tsx) is a padlock body plus a shackle and it means a LOCK
|
| 820 |
+
* (DESIGN.md Β§4, THE THREE LOCKS). A share is a different fact about a column and wears a
|
| 821 |
+
* different mark, which is the whole of "one mark, one meaning" β D-254 is what happens when it
|
| 822 |
+
* does not.
|
| 823 |
+
*
|
| 824 |
+
* Traced off the rail's `.cg-view-shared` mark (ViewSidebar.tsx) at the same viewBox, so a shared
|
| 825 |
+
* VIEW in the rail and a shared FIELD in a header say "shared" with the same drawing. The head is
|
| 826 |
+
* an arc path rather than a `<circle>` because `IconShape` is path data and one serializer
|
| 827 |
+
* (`shapeSource`) paints the entire vocabulary.
|
| 828 |
+
*/
|
| 829 |
+
export const SHARE_SHAPE: IconShape[] = [
|
| 830 |
+
// The near figure: head, then shoulders.
|
| 831 |
+
{ d: "M5.6 3.4a2.2 2.2 0 1 1 0 4.4 2.2 2.2 0 1 1 0-4.4" },
|
| 832 |
+
{ d: "M1.9 12.6c0-2 1.7-3.2 3.7-3.2s3.7 1.2 3.7 3.2" },
|
| 833 |
+
// The far figure, cropped by the near one: half a head and a shoulder.
|
| 834 |
+
{ d: "M10.6 4.1a2.2 2.2 0 0 1 0 4.2M11.4 9.7c1.6.3 2.7 1.4 2.7 2.9" },
|
| 835 |
+
];
|
| 836 |
+
|
| 837 |
+
export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
|
| 838 |
+
...TYPE_SPRITES,
|
| 839 |
+
/**
|
| 840 |
+
* β W40-T20 β the shared-field mark, drawn by `CustomerGrid.drawGridHeader` on the innermost
|
| 841 |
+
* slot of `headerMarkFit`'s strip.
|
| 842 |
+
*
|
| 843 |
+
* It IGNORES the colours glide hands it, for exactly the reason `aiosInfo` does and recorded in
|
| 844 |
+
* that entry's note: glide's variants resolve to the accent behind `bgHeader`, which under the
|
| 845 |
+
* C1 pastels is a pale glyph on a pale disc. LP_MUTED keeps it legible on every header state.
|
| 846 |
+
*/
|
| 847 |
+
aiosShared: () =>
|
| 848 |
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
| 849 |
+
SHARE_SHAPE.map((s) => shapeSource(s, LP_MUTED)).join("") +
|
| 850 |
+
`</svg>`,
|
| 851 |
+
aiosInfo: () =>
|
| 852 |
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
| 853 |
+
`<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
|
| 854 |
+
`<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
|
| 855 |
+
`stroke-linecap="round"/>` +
|
| 856 |
+
`<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
|
| 857 |
+
`</svg>`,
|
| 858 |
+
};
|
web/src/customer-grid/overlayPlacement.ts
CHANGED
|
@@ -213,6 +213,18 @@ export function fitGroupLabel(
|
|
| 213 |
export const CUSTOM_MARK_SIZE = 6;
|
| 214 |
/** Clearance BETWEEN two marks in the strip. */
|
| 215 |
export const HEADER_MARK_GAP = 6;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
/**
|
| 218 |
* R11 β where the header's right-hand marks go, laid out from the OUTSIDE IN.
|
|
@@ -257,16 +269,83 @@ export function headerMarkLayout(
|
|
| 257 |
return out;
|
| 258 |
}
|
| 259 |
|
| 260 |
-
/** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it
|
| 261 |
-
* truncation reserve and the drawing path read this, so they cannot
|
| 262 |
-
* marks a column carries or which is which.
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
const out: number[] = [];
|
| 265 |
if (hasInfoMark) out.push(INFO_MARK_SIZE);
|
| 266 |
if (isCustomField) out.push(CUSTOM_MARK_SIZE);
|
|
|
|
| 267 |
return out;
|
| 268 |
}
|
| 269 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
// ------------------------------------------------- owner item 16: the header title vs the (i)
|
| 271 |
|
| 272 |
/**
|
|
@@ -310,13 +389,22 @@ export function headerLabelSpace(
|
|
| 310 |
* the label gets reserved room for only one of them.
|
| 311 |
*/
|
| 312 |
marks: boolean | readonly number[],
|
| 313 |
-
menuSlotWidth = HEADER_MENU_SLOT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
): number | null {
|
| 315 |
const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks;
|
| 316 |
-
// The INNERMOST mark is the one the label must clear, and
|
| 317 |
-
//
|
| 318 |
-
|
| 319 |
-
|
|
|
|
|
|
|
| 320 |
return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP);
|
| 321 |
}
|
| 322 |
|
|
@@ -423,45 +511,76 @@ export function expandButtonRect(
|
|
| 423 |
return { x, y, size };
|
| 424 |
}
|
| 425 |
|
| 426 |
-
// --------------------------------
|
| 427 |
|
| 428 |
/** Smaller than the Expand button: a mark, not an action with a destination. */
|
| 429 |
export const STAR_BTN_SIZE = 18;
|
| 430 |
-
/**
|
| 431 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 432 |
|
| 433 |
/**
|
| 434 |
-
* ββ
|
| 435 |
-
* the
|
| 436 |
-
*
|
| 437 |
-
*
|
| 438 |
-
*
|
| 439 |
-
*
|
| 440 |
-
*
|
| 441 |
-
*
|
| 442 |
-
*
|
| 443 |
-
*
|
| 444 |
-
*
|
| 445 |
-
*
|
| 446 |
-
*
|
| 447 |
-
*
|
| 448 |
-
*
|
| 449 |
-
*
|
| 450 |
-
*
|
| 451 |
-
*
|
| 452 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
*/
|
| 454 |
export function starButtonRect(
|
| 455 |
primary: { x: number; y: number; width: number; height: number },
|
| 456 |
box?: { x: number; y: number; width: number; height: number },
|
| 457 |
size = STAR_BTN_SIZE,
|
| 458 |
-
|
| 459 |
): { x: number; y: number; size: number } | null {
|
| 460 |
-
|
| 461 |
-
//
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 465 |
if (box) {
|
| 466 |
if (x < box.x || x + size > box.x + box.width) return null;
|
| 467 |
if (y < box.y || y + size > box.y + box.height) return null;
|
|
|
|
| 213 |
export const CUSTOM_MARK_SIZE = 6;
|
| 214 |
/** Clearance BETWEEN two marks in the strip. */
|
| 215 |
export const HEADER_MARK_GAP = 6;
|
| 216 |
+
/**
|
| 217 |
+
* β W40-T20 β the SHARE mark, the strip's third slot: "this field is shared, so what you type
|
| 218 |
+
* here is what everyone with access to this table reads".
|
| 219 |
+
*
|
| 220 |
+
* 13px is the size `ViewSidebar` renders the same two-people glyph at, so one concept reads at
|
| 221 |
+
* one weight wherever the product says it.
|
| 222 |
+
*
|
| 223 |
+
* β IT IS NOT A PADLOCK AND IT IS NOT `LockMark`. A share and a lock are different facts about a
|
| 224 |
+
* column (DESIGN.md Β§4, THE THREE LOCKS: a share says who else can see the column, a lock says
|
| 225 |
+
* what may be changed), and one glyph carrying both meanings is D-254 in a second surface.
|
| 226 |
+
*/
|
| 227 |
+
export const SHARE_MARK_SIZE = 13;
|
| 228 |
|
| 229 |
/**
|
| 230 |
* R11 β where the header's right-hand marks go, laid out from the OUTSIDE IN.
|
|
|
|
| 269 |
return out;
|
| 270 |
}
|
| 271 |
|
| 272 |
+
/** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it, the share
|
| 273 |
+
* mark innermost. Both the truncation reserve and the drawing path read this, so they cannot
|
| 274 |
+
* disagree about how many marks a column carries or which is which.
|
| 275 |
+
*
|
| 276 |
+
* β W40-T20 β `isSharedField` is OPTIONAL and defaults to false, deliberately: every pre-T20
|
| 277 |
+
* call site asks the two-boolean question and must keep getting the two-boolean answer. The
|
| 278 |
+
* share mark goes LAST because last is INNERMOST, and innermost is the slot `headerMarkFit`
|
| 279 |
+
* gives up first β so a column too tight for three marks loses the new one rather than the two
|
| 280 |
+
* that shipped before it. */
|
| 281 |
+
export function headerMarkSizes(
|
| 282 |
+
hasInfoMark: boolean,
|
| 283 |
+
isCustomField: boolean,
|
| 284 |
+
isSharedField = false
|
| 285 |
+
): number[] {
|
| 286 |
const out: number[] = [];
|
| 287 |
if (hasInfoMark) out.push(INFO_MARK_SIZE);
|
| 288 |
if (isCustomField) out.push(CUSTOM_MARK_SIZE);
|
| 289 |
+
if (isSharedField) out.push(SHARE_MARK_SIZE);
|
| 290 |
return out;
|
| 291 |
}
|
| 292 |
|
| 293 |
+
/**
|
| 294 |
+
* β W40-T20 β does this field's header carry a share mark? THE rule, in one exported place.
|
| 295 |
+
*
|
| 296 |
+
* It lives here rather than inside `CustomerGrid.drawGridHeader` for the reason the rest of this
|
| 297 |
+
* module exists: a decision buried in a React canvas callback is unobservable to anything but a
|
| 298 |
+
* screenshot, and this one has two consumers (the drawer, and the label reserve) that must never
|
| 299 |
+
* answer it differently.
|
| 300 |
+
*
|
| 301 |
+
* β STRUCTURALLY TYPED ON PURPOSE. This module imports nothing β that is what lets a gate compile
|
| 302 |
+
* it with tsc and run it under node β so it takes the one property it reads rather than `Field`.
|
| 303 |
+
*
|
| 304 |
+
* β `shared` means "this definition lives in the tenant-wide field stratum", i.e. THIS FIELD HAS
|
| 305 |
+
* BEEN SHARED β not "somebody shared it WITH ME" (contract C1). The server stamps it on the
|
| 306 |
+
* owner's own copy too (`routes_customers._merge_shared_fields`, `shares.role_for` returning
|
| 307 |
+
* `"owner"`), so the mark reads as "this column is shared" for everyone who can see it, and the
|
| 308 |
+
* copy that explains it must not be worded as though the reader were only ever the receiver.
|
| 309 |
+
*/
|
| 310 |
+
export function hasShareMark(field: { shared?: boolean }): boolean {
|
| 311 |
+
return field.shared === true;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/**
|
| 315 |
+
* β W40-T20 β the strip that DEGRADES, and the ONE function both the drawer and the label
|
| 316 |
+
* reserve go through.
|
| 317 |
+
*
|
| 318 |
+
* Why it had to exist. `headerMarkLayout` is ALL-OR-NOTHING by contract, and that contract is
|
| 319 |
+
* what stops the reserve and the drawer disagreeing (see its note). It is also why a third mark
|
| 320 |
+
* could not simply be appended: on a column with room for exactly two, asking for three returns
|
| 321 |
+
* `null`, and the (i) and the user-created dot β both shipped, both gated β would have vanished
|
| 322 |
+
* to make room for a mark that was then not drawn either. Adding a feature by deleting two is
|
| 323 |
+
* not adding a feature.
|
| 324 |
+
*
|
| 325 |
+
* So the strip gives way from the INSIDE, one droppable slot at a time. `droppable` is how many
|
| 326 |
+
* TRAILING slots may be surrendered; everything ahead of them keeps the all-or-nothing behaviour
|
| 327 |
+
* it shipped with. **`droppable = 0` is exactly `headerMarkLayout`** β which is why every
|
| 328 |
+
* pre-T20 caller, and every negative control aimed at that function, is untouched by this.
|
| 329 |
+
*
|
| 330 |
+
* Returns the surviving layout AND how many slots survived, because a drawer needs to know which
|
| 331 |
+
* marks it may paint, not merely where they would have gone.
|
| 332 |
+
*/
|
| 333 |
+
export function headerMarkFit(
|
| 334 |
+
header: { x: number; y: number; width: number; height: number },
|
| 335 |
+
menuSlotWidth: number,
|
| 336 |
+
sizes: readonly number[],
|
| 337 |
+
droppable = 0
|
| 338 |
+
): { layout: { x: number; y: number; size: number }[]; kept: number } | null {
|
| 339 |
+
// Never below one mark: a strip that "degrades" to nothing is the all-or-nothing answer, and
|
| 340 |
+
// `headerMarkLayout` already gives that by returning null.
|
| 341 |
+
const floor = Math.max(1, sizes.length - Math.max(0, droppable));
|
| 342 |
+
for (let kept = sizes.length; kept >= floor; kept--) {
|
| 343 |
+
const layout = headerMarkLayout(header, menuSlotWidth, sizes.slice(0, kept));
|
| 344 |
+
if (layout) return { layout, kept };
|
| 345 |
+
}
|
| 346 |
+
return null;
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
// ------------------------------------------------- owner item 16: the header title vs the (i)
|
| 350 |
|
| 351 |
/**
|
|
|
|
| 389 |
* the label gets reserved room for only one of them.
|
| 390 |
*/
|
| 391 |
marks: boolean | readonly number[],
|
| 392 |
+
menuSlotWidth = HEADER_MENU_SLOT,
|
| 393 |
+
/**
|
| 394 |
+
* β W40-T20 β how many TRAILING marks this column is willing to give up, passed straight to
|
| 395 |
+
* `headerMarkFit`. It defaults to 0, so every pre-T20 caller keeps the all-or-nothing reserve
|
| 396 |
+
* it had; a caller that asks for a droppable share mark must say so here too, or it reserves
|
| 397 |
+
* room for a strip the drawer will not paint.
|
| 398 |
+
*/
|
| 399 |
+
droppable = 0
|
| 400 |
): number | null {
|
| 401 |
const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks;
|
| 402 |
+
// The INNERMOST mark is the one the label must clear, and the strip is fitted through
|
| 403 |
+
// `headerMarkFit` β the SAME function the drawer uses β so the reserve is taken against the
|
| 404 |
+
// marks that actually SURVIVE the column's width, never against the ones merely asked for.
|
| 405 |
+
const fit = headerMarkFit({ x: 0, y: 0, width, height: 32 }, menuSlotWidth, sizes, droppable);
|
| 406 |
+
if (!fit) return null;
|
| 407 |
+
const layout = fit.layout;
|
| 408 |
return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP);
|
| 409 |
}
|
| 410 |
|
|
|
|
| 511 |
return { x, y, size };
|
| 512 |
}
|
| 513 |
|
| 514 |
+
// -------------------------------- the record star, BESIDE the Expand (owner instruction 29)
|
| 515 |
|
| 516 |
/** Smaller than the Expand button: a mark, not an action with a destination. */
|
| 517 |
export const STAR_BTN_SIZE = 18;
|
| 518 |
+
/**
|
| 519 |
+
* The clear space between the star and the Expand it now sits against.
|
| 520 |
+
*
|
| 521 |
+
* 4px is not a taste call: it is `.cg-detail-nav`'s own `gap`, which is what a group of adjacent
|
| 522 |
+
* icon buttons already measures elsewhere in this grid, and it is on `DESIGN.md` Β§4's 4/8 grid.
|
| 523 |
+
*/
|
| 524 |
+
export const STAR_BTN_GAP = 4;
|
| 525 |
|
| 526 |
/**
|
| 527 |
+
* ββ OWNER INSTRUCTION 29 (wave 40, 2026-08-24) β where the row's star sits: **immediately to
|
| 528 |
+
* the LEFT of the Expand button**, at the right end of the primary cell, level with it.
|
| 529 |
+
*
|
| 530 |
+
* β THIS REVERSES W35-T29 (R5, R6), AND THE OLD ARGUMENT IS RECORDED HERE RATHER THAN DELETED,
|
| 531 |
+
* because a reversed ruling whose reasoning is left standing is how the next session restores it.
|
| 532 |
+
* R5 put the star at the LEFT end of the primary cell and the Expand at the right, deliberately:
|
| 533 |
+
* Airtable keeps a row's own marks in the gutter, and book-ending the cell meant neither control
|
| 534 |
+
* could ever land on the other. That was a real design, it shipped, and the owner has now ruled
|
| 535 |
+
* against it: *"The star label on a record must sit exactly beside and to the LEFT of the record
|
| 536 |
+
* expand icon, on every database."* Beside beats book-ended. The two are now ONE CLUSTER at the
|
| 537 |
+
* right end, and everything below follows from that single fact.
|
| 538 |
+
*
|
| 539 |
+
* β DERIVED FROM THE EXPAND, NOT COMPUTED ALONGSIDE IT. This function asks `expandButtonRect`
|
| 540 |
+
* where its neighbour is and steps left from the answer. That is what makes "beside" a property
|
| 541 |
+
* rather than a coincidence: two independent copies of the same arithmetic drift, and this file
|
| 542 |
+
* exists because that drift is invisible to tsc, to the build and to a screenshot taken at one
|
| 543 |
+
* size ([[ui-invisible-to-assertions]]).
|
| 544 |
+
*
|
| 545 |
+
* β ITS MINIMA ARE THE EXPAND'S NOW, AND THAT REPLACES THE OPPOSITE CLAIM R5 MADE HERE. The old
|
| 546 |
+
* comment argued the star should keep LOWER minima than the Expand, so "a row that cannot carry
|
| 547 |
+
* the Expand can still carry this", and warned that sharing `EXPAND_BTN_MIN_ROW` would hide the
|
| 548 |
+
* star "on exactly the compact row heights a long list is read at". That protection was sound for
|
| 549 |
+
* a mark living at the far end of the cell and is incoherent for one anchored to the Expand: a
|
| 550 |
+
* star placed beside a button that is not there is a mark floating where its neighbour should be.
|
| 551 |
+
* It also protected a case this product cannot produce. `CustomerGrid.ROW_PX` is the whole set of
|
| 552 |
+
* data-row heights and it is `{ short: 28, medium: 34, tall: 48 }` β every one of them clears
|
| 553 |
+
* `EXPAND_BTN_MIN_ROW` (26), so the band the old minima defended (20..25px) is empty. Deleting
|
| 554 |
+
* the second height gate rather than re-tuning it is what makes "both or neither" structural.
|
| 555 |
+
*
|
| 556 |
+
* β THE WIDTH GATE IS THIS FUNCTION'S OWN, AND IT IS DELIBERATELY STRICTER THAN THE EXPAND'S.
|
| 557 |
+
* Two controls plus a readable name need more of the cell than one did, so the star refuses at a
|
| 558 |
+
* width the Expand still accepts. The asymmetry is on purpose and runs only in this direction:
|
| 559 |
+
* item 17 removed the click that used to open a record, so the Expand is the ONLY pointer route
|
| 560 |
+
* to one and must not disappear from a narrow primary column to keep a mark company.
|
| 561 |
+
*
|
| 562 |
+
* `null` when the Expand is absent for any reason, when the cell is too narrow for the pair, or
|
| 563 |
+
* when the star's own rect would fall outside the grid's box β absent rather than clamped, for
|
| 564 |
+
* the reason the Expand is: a clamped `position: fixed` button sits at the edge pointing at a row
|
| 565 |
+
* whose cell is somewhere else.
|
| 566 |
*/
|
| 567 |
export function starButtonRect(
|
| 568 |
primary: { x: number; y: number; width: number; height: number },
|
| 569 |
box?: { x: number; y: number; width: number; height: number },
|
| 570 |
size = STAR_BTN_SIZE,
|
| 571 |
+
gap = STAR_BTN_GAP
|
| 572 |
): { x: number; y: number; size: number } | null {
|
| 573 |
+
// No Expand, no star. The row height gate, the label gate and the box clamp are all inherited
|
| 574 |
+
// from this one call, so the two controls cannot disagree about whether the row carries them.
|
| 575 |
+
const expand = expandButtonRect(primary, box);
|
| 576 |
+
if (!expand) return null;
|
| 577 |
+
// Room for BOTH controls, their gap, the gutter at either side, and a name still worth reading.
|
| 578 |
+
if (primary.width < EXPAND_BTN_SIZE + EXPAND_BTN_INSET * 2 + gap + size + EXPAND_BTN_MIN_LABEL)
|
| 579 |
+
return null;
|
| 580 |
+
const x = expand.x - gap - size;
|
| 581 |
+
// Level with its neighbour BY CONSTRUCTION: the same centre whatever the two sizes are, so
|
| 582 |
+
// "beside" means level and not merely adjacent, and no second centring rule can drift.
|
| 583 |
+
const y = expand.y + Math.round((expand.size - size) / 2);
|
| 584 |
if (box) {
|
| 585 |
if (x < box.x || x + size > box.x + box.width) return null;
|
| 586 |
if (y < box.y || y + size > box.y + box.height) return null;
|
web/src/customer-grid/rowStar.css
CHANGED
|
@@ -1,80 +1,87 @@
|
|
| 1 |
-
/* ---------------------------------------------------------------------------
|
| 2 |
-
customer-grid / rowStar.css β W35-T29 (rulings R5, R6), the record star.
|
| 3 |
-
|
| 4 |
-
β ITS OWN SHEET, AND THE REASON IS OWNERSHIP RATHER THAN TIDINESS. `.cg-row-expand`, the hover
|
| 5 |
-
control this one sits beside, lives in `index.css`, which belongs to another lane this wave. A
|
| 6 |
-
new surface brings its own stylesheet (the wave's own rule) and a change to an existing
|
| 7 |
-
`.cg-*` rule is that lane's ticket.
|
| 8 |
-
|
| 9 |
-
β IMPORTED BY `CustomerGrid.tsx`, the module that RENDERS these classes β never by a lazy
|
| 10 |
-
parent. W35-T22 in this same wave was exactly that mistake one directory over: `query.css` was
|
| 11 |
-
imported only by a `lazy()` page while the component drawing its classes shipped in another
|
| 12 |
-
chunk, so the rail painted unstyled for a network round trip.
|
| 13 |
-
--------------------------------------------------------------------------- */
|
| 14 |
-
|
| 15 |
-
/* The hover control
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ---------------------------------------------------------------------------
|
| 2 |
+
customer-grid / rowStar.css β W35-T29 (rulings R5, R6), the record star.
|
| 3 |
+
|
| 4 |
+
β ITS OWN SHEET, AND THE REASON IS OWNERSHIP RATHER THAN TIDINESS. `.cg-row-expand`, the hover
|
| 5 |
+
control this one sits beside, lives in `index.css`, which belongs to another lane this wave. A
|
| 6 |
+
new surface brings its own stylesheet (the wave's own rule) and a change to an existing
|
| 7 |
+
`.cg-*` rule is that lane's ticket.
|
| 8 |
+
|
| 9 |
+
β IMPORTED BY `CustomerGrid.tsx`, the module that RENDERS these classes β never by a lazy
|
| 10 |
+
parent. W35-T22 in this same wave was exactly that mistake one directory over: `query.css` was
|
| 11 |
+
imported only by a `lazy()` page while the component drawing its classes shipped in another
|
| 12 |
+
chunk, so the rail painted unstyled for a network round trip.
|
| 13 |
+
--------------------------------------------------------------------------- */
|
| 14 |
+
|
| 15 |
+
/* β The hover control IMMEDIATELY TO THE LEFT OF THE EXPAND BUTTON, at the right end of the
|
| 16 |
+
primary cell and level with it β owner instruction 29 (wave 40, 2026-08-24). It REVERSES
|
| 17 |
+
W35-T29 (R5, R6), which deliberately put this mark at the LEFT end so the two book-ended
|
| 18 |
+
the cell; that line stood right here and is gone because the code no longer does it.
|
| 19 |
+
Both are real DOM buttons laid over the canvas at glide's own bounds, so the rect IS the
|
| 20 |
+
hit test and there is no second copy of the geometry to drift from the drawing.
|
| 21 |
+
|
| 22 |
+
β NO GEOMETRY LIVES IN THIS SHEET. `left`/`top`/`width`/`height` are written inline from
|
| 23 |
+
`starButtonRect`, which now derives them from `expandButtonRect` β so the reversal is a
|
| 24 |
+
change of arithmetic, not of style, and nothing here had to move with it. */
|
| 25 |
+
.cg-row-star {
|
| 26 |
+
position: fixed;
|
| 27 |
+
z-index: 55;
|
| 28 |
+
display: inline-flex;
|
| 29 |
+
align-items: center;
|
| 30 |
+
justify-content: center;
|
| 31 |
+
padding: 0;
|
| 32 |
+
border: 1px solid transparent;
|
| 33 |
+
border-radius: var(--lp-r-sm);
|
| 34 |
+
background: transparent;
|
| 35 |
+
color: var(--lp-muted);
|
| 36 |
+
cursor: pointer;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/* β A STARRED ROW'S MARK IS NOT HOVER-ONLY. The hover control is how you SET one; a row that is
|
| 40 |
+
already starred has to say so while the pointer is elsewhere, or the grid cannot answer the
|
| 41 |
+
question the feature exists for. D-253 is a booked defect whose whole content is "satisfied
|
| 42 |
+
only on hover" β this is that lesson applied to a mark rather than to an explanation.
|
| 43 |
+
The always-on mark is drawn by the same button with `is-on`; the caller keeps it mounted for a
|
| 44 |
+
starred row whether or not the pointer is over it. */
|
| 45 |
+
.cg-row-star.is-on {
|
| 46 |
+
color: var(--lp-yellow-deep);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.cg-row-star:hover {
|
| 50 |
+
border-color: var(--lp-line);
|
| 51 |
+
background: var(--cg-white);
|
| 52 |
+
color: var(--lp-yellow-deep);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.cg-row-star:focus-visible {
|
| 56 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 57 |
+
outline-offset: 1px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.cg-row-star:disabled {
|
| 61 |
+
cursor: default;
|
| 62 |
+
opacity: 0.55;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
/* The drawer's own copy of the control (`RecordDetail`), which is in the flow rather than over a
|
| 66 |
+
canvas β so it takes the colour rules above and none of the positioning.
|
| 67 |
+
β OWNER ITEM 12 (2026-08-23) β it now sits INSIDE `.cg-detail-nav`, beside the prev/next keys,
|
| 68 |
+
so it is sized and bordered like them: a group of three buttons reads as a group only if the
|
| 69 |
+
third one is the same shape. The border is transparent until hover, which is what still tells
|
| 70 |
+
an ACTION apart from the two NAVIGATION keys beside it. */
|
| 71 |
+
.cg-detail-star {
|
| 72 |
+
display: inline-flex;
|
| 73 |
+
align-items: center;
|
| 74 |
+
justify-content: center;
|
| 75 |
+
width: 28px;
|
| 76 |
+
height: 28px;
|
| 77 |
+
padding: 0;
|
| 78 |
+
border: 1px solid transparent;
|
| 79 |
+
border-radius: var(--lp-r-md);
|
| 80 |
+
background: transparent;
|
| 81 |
+
color: var(--lp-muted);
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.cg-detail-star.is-on { color: var(--lp-yellow-deep); }
|
| 86 |
+
.cg-detail-star:hover { border-color: var(--lp-line); color: var(--lp-yellow-deep); }
|
| 87 |
+
.cg-detail-star:disabled { cursor: default; opacity: 0.55; }
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -201,6 +201,81 @@ export const CREATABLE_TYPES: readonly FieldType[] = [
|
|
| 201 |
"ai_enrich",
|
| 202 |
];
|
| 203 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
/**
|
| 205 |
* β Wave-19 R7 / C5 β THE THREE THINGS AN `image` CELL MAY HOLD, and how each resolves.
|
| 206 |
*
|
|
@@ -3397,6 +3472,35 @@ export interface SavedView {
|
|
| 3397 |
/** C1 β whose view this is, when it is not mine. The username, so it matches what every
|
| 3398 |
* grant is stored against (see `shareModel.parsePeople`'s note on the two shapes). */
|
| 3399 |
owner?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3400 |
/** C4 β the folder this view sits in; null/absent = root. */
|
| 3401 |
folderId?: string | null;
|
| 3402 |
id: string;
|
|
@@ -3477,6 +3581,62 @@ export function allViewName(scope: string): string {
|
|
| 3477 |
return scope === "product" ? "All products" : "All customers";
|
| 3478 |
}
|
| 3479 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3480 |
/**
|
| 3481 |
* The ONE rule for "this view cannot be deleted", mirroring what the host actually enforces
|
| 3482 |
* (`app.py:5410` refuses `view_delete` for `all-customers`). The client hides exactly what
|
|
@@ -3746,7 +3906,10 @@ export function viewEditMode(view: Pick<SavedView, "permissions">): ViewEditMode
|
|
| 3746 |
* at the host, not merely hidden here.
|
| 3747 |
*/
|
| 3748 |
export function mayEditView(
|
| 3749 |
-
|
|
|
|
|
|
|
|
|
|
| 3750 |
viewer: Viewer | undefined
|
| 3751 |
): boolean {
|
| 3752 |
/**
|
|
@@ -3772,6 +3935,31 @@ export function mayEditView(
|
|
| 3772 |
* ONE place, three consumers: the rail's row menu, the rail's drag, and the grid's own
|
| 3773 |
* delete/config-write guards all ask this function. Putting the rule in any one of them
|
| 3774 |
* would leave the other two offering an edit the server refuses.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3775 |
*/
|
| 3776 |
if (view.shared && (view.sharedRole ?? "view") !== "edit") return false;
|
| 3777 |
const mode = viewEditMode(view);
|
|
|
|
| 201 |
"ai_enrich",
|
| 202 |
];
|
| 203 |
|
| 204 |
+
/**
|
| 205 |
+
* ββ W40-T29 (owner instruction 31) β WHICH CREATE KINDS READ AS ADVANCED. Three of them, and
|
| 206 |
+
* the whole discipline of this block is that the answer is a PRESENTATION grouping and nothing
|
| 207 |
+
* else.
|
| 208 |
+
*
|
| 209 |
+
* β THE LIST ABOVE IS NOT PARTITIONED, AND MUST NEVER BE. `CREATABLE_TYPES` is one half of a
|
| 210 |
+
* cross-language contract `verify_fields_contract.py` diffs against `aios_grid.CUSTOM_FIELD_TYPES`,
|
| 211 |
+
* and the `json` scar recorded in its own comment is exactly what happens when a type reaches every
|
| 212 |
+
* table except that one: real, documented, unreachable, with every individual file internally
|
| 213 |
+
* consistent. Lifting `automation` and `json` out of it into an "advanced list" would reproduce
|
| 214 |
+
* that failure deliberately. So the grouping is applied to the ROWS the picker has already built,
|
| 215 |
+
* at render time, and the contract array is READ, never rewritten.
|
| 216 |
+
*
|
| 217 |
+
* β `automation` and `json` are typed `FieldType`, so a typo fails `tsc`. `geocode` cannot be:
|
| 218 |
+
* it is a PSEUDO-KIND (see `Field.geocode` for why five vocabularies stay untouched by it) and no
|
| 219 |
+
* compiler can hold it. The asymmetry is honest rather than an oversight, and it is the reason the
|
| 220 |
+
* exported set below is `string[]` while its first half is not.
|
| 221 |
+
*/
|
| 222 |
+
const ADVANCED_FIELD_KINDS: readonly FieldType[] = ["automation", "json"];
|
| 223 |
+
|
| 224 |
+
/** The advanced set the picker groups on: the two real types above plus the geocode pseudo-kind,
|
| 225 |
+
* which joins by VALUE because it cannot join by type. */
|
| 226 |
+
export const ADVANCED_CREATE_KINDS: readonly string[] = [...ADVANCED_FIELD_KINDS, "geocode"];
|
| 227 |
+
|
| 228 |
+
/**
|
| 229 |
+
* The group's heading. ONE WORD: `DESIGN.md` Β§4 forbids over-explaining, so the list does not get
|
| 230 |
+
* a sentence about what makes a type advanced.
|
| 231 |
+
* β Not "ADVANCED" β `CLAUDE.md` bans ALL-CAPS app chrome. The owner spelled it in capitals in
|
| 232 |
+
* instruction 31; that is emphasis in prose, not a label.
|
| 233 |
+
*/
|
| 234 |
+
export const ADVANCED_TYPES_HEADING = "Advanced";
|
| 235 |
+
|
| 236 |
+
/** A run of field-type picker rows under an optional heading. `heading: null` is the ordinary
|
| 237 |
+
* group, deliberately unlabelled: naming the majority case is chrome nobody reads, and the
|
| 238 |
+
* divider under it already says where the ordinary list ends. */
|
| 239 |
+
export interface TypePickerGroup<T> {
|
| 240 |
+
heading: string | null;
|
| 241 |
+
rows: T[];
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
/**
|
| 245 |
+
* ββ W40-T29 β split a flat field-type picker list into the ordinary rows and the advanced ones.
|
| 246 |
+
*
|
| 247 |
+
* It lives in this file, and not in `ColumnMenu.tsx` where it is rendered, for the reason
|
| 248 |
+
* `display.ts`'s header states at length: `ColumnMenu.tsx` imports React and therefore cannot be
|
| 249 |
+
* LOADED by the node gates, so a rule stated inside JSX is a rule no gate can run. `columnDefinitionOffer`
|
| 250 |
+
* had to settle for `display.ts` because this file was held by another lane that wave; the two
|
| 251 |
+
* belong together and this one made it home.
|
| 252 |
+
*
|
| 253 |
+
* β A STABLE PARTITION, NEVER A SORT. Each group keeps the order it was handed, so the ordinary
|
| 254 |
+
* group reads in `CREATABLE_TYPES`' own order and the advanced group in the order those three
|
| 255 |
+
* happen to sit in the caller's list. Re-ordering here would be a second opinion about a
|
| 256 |
+
* vocabulary that already has an owner, and the day someone reorders the contract array the
|
| 257 |
+
* picker would silently disagree with it.
|
| 258 |
+
*
|
| 259 |
+
* β AN EMPTY GROUP IS OMITTED ENTIRELY, and that is R8's fake-affordance rule rather than
|
| 260 |
+
* tidiness: the caller filters this list through a find box, so typing "text" empties the advanced
|
| 261 |
+
* group, and a heading left standing over nothing is a label with nothing behind it. Deciding it
|
| 262 |
+
* here rather than in JSX is also what makes the behaviour runnable by a node gate instead of
|
| 263 |
+
* being an accident of a CSS `:empty` selector.
|
| 264 |
+
*/
|
| 265 |
+
export function groupTypePickerRows<T extends { value: string }>(
|
| 266 |
+
rows: readonly T[]
|
| 267 |
+
): TypePickerGroup<T>[] {
|
| 268 |
+
const ordinary: T[] = [];
|
| 269 |
+
const advanced: T[] = [];
|
| 270 |
+
for (const row of rows) {
|
| 271 |
+
(ADVANCED_CREATE_KINDS.includes(row.value) ? advanced : ordinary).push(row);
|
| 272 |
+
}
|
| 273 |
+
const out: TypePickerGroup<T>[] = [];
|
| 274 |
+
if (ordinary.length) out.push({ heading: null, rows: ordinary });
|
| 275 |
+
if (advanced.length) out.push({ heading: ADVANCED_TYPES_HEADING, rows: advanced });
|
| 276 |
+
return out;
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
/**
|
| 280 |
* β Wave-19 R7 / C5 β THE THREE THINGS AN `image` CELL MAY HOLD, and how each resolves.
|
| 281 |
*
|
|
|
|
| 3472 |
/** C1 β whose view this is, when it is not mine. The username, so it matches what every
|
| 3473 |
* grant is stored against (see `shareModel.parsePeople`'s note on the two shapes). */
|
| 3474 |
owner?: string;
|
| 3475 |
+
/**
|
| 3476 |
+
* ββ WAVE 40 Β· AM-5 (owner instruction 2) β THIS VIEW HAS GRANTS, WHOEVER IS ASKING.
|
| 3477 |
+
*
|
| 3478 |
+
* Viewer-INDEPENDENT, and it is what draws the share mark. C1 was written as "redefine
|
| 3479 |
+
* `shared` to mean this view has grants"; AM-5 settles it as a NEW key instead, because
|
| 3480 |
+
* redefining `shared` would have locked an owner out of their own view through
|
| 3481 |
+
* `mayEditView` and moved it into the recipient's "Shared with me" group. So `shared`
|
| 3482 |
+
* keeps meaning "granted TO me" and this means "somebody can see this".
|
| 3483 |
+
*
|
| 3484 |
+
* β ABSENCE IS THE NEGATIVE. Nothing is ever stamped `false`; a view with no grants
|
| 3485 |
+
* simply carries neither key. Produced by `platform/core/grid_events.py::_grant_marker`
|
| 3486 |
+
* on all three merge legs from one predicate, and derived on every read from the grant
|
| 3487 |
+
* registry rather than stored.
|
| 3488 |
+
*
|
| 3489 |
+
* β IT DOES NOT IMPLY `sharedRole`. A view living BOTH in the legacy `__shared__` bucket
|
| 3490 |
+
* and in the grant registry reaches the recipient with `hasShares` and no role stamp,
|
| 3491 |
+
* because the bucket leg outranks the granted copy. `viewShareMark` has a third branch
|
| 3492 |
+
* for exactly that, and it must not be collapsed into the other two.
|
| 3493 |
+
*/
|
| 3494 |
+
hasShares?: boolean;
|
| 3495 |
+
/**
|
| 3496 |
+
* β WAVE 40 Β· AM-5 β THIS ACCOUNT IS THE ONE SHARING IT OUT.
|
| 3497 |
+
*
|
| 3498 |
+
* Only ever on the sharer's copy, which is how the owner's tooltip can read as sharing OUT
|
| 3499 |
+
* without reusing the receiver wording C1 forbids. Unconditional on the caller's own
|
| 3500 |
+
* stratum, `createdBy == uname` on the `__shared__` leg, and NEVER on a view arriving
|
| 3501 |
+
* through `_granted_views`.
|
| 3502 |
+
*/
|
| 3503 |
+
sharedOut?: boolean;
|
| 3504 |
/** C4 β the folder this view sits in; null/absent = root. */
|
| 3505 |
folderId?: string | null;
|
| 3506 |
id: string;
|
|
|
|
| 3581 |
return scope === "product" ? "All products" : "All customers";
|
| 3582 |
}
|
| 3583 |
|
| 3584 |
+
/**
|
| 3585 |
+
* ββ WAVE 40 Β· T28 (owner instruction 30) β WHAT THE PINNED SYSTEM VIEW IS CALLED ON SCREEN.
|
| 3586 |
+
*
|
| 3587 |
+
* Owner: *"Rename 'All customers' and every other undeletable default view to 'Default view'."*
|
| 3588 |
+
* Per assumption A2 that is the PER-DATABASE default ONLY. `UNDELETABLE_VIEW_IDS` holds three
|
| 3589 |
+
* ids, and renaming all three would put three rows reading "Default view" in one rail, which is
|
| 3590 |
+
* a worse rail than the one the instruction is fixing. Starred and IG Overview keep the names
|
| 3591 |
+
* their own servers mint; they pin to the top beside this one and are told apart by their names.
|
| 3592 |
+
*/
|
| 3593 |
+
export const DEFAULT_VIEW_NAME = "Default view";
|
| 3594 |
+
|
| 3595 |
+
/**
|
| 3596 |
+
* β THE HOST'S OWN MINTS, DERIVED RATHER THAN RE-SPELLED, so the three nouns keep exactly one
|
| 3597 |
+
* home on this side of the wire. `aios_grid.views_from_defs(system_name=β¦)` produces the same
|
| 3598 |
+
* three strings; this set is what {@link viewDisplayName} recognises as "still the mint, nobody
|
| 3599 |
+
* has renamed it".
|
| 3600 |
+
*/
|
| 3601 |
+
const SYSTEM_VIEW_MINTS = new Set([
|
| 3602 |
+
allViewName("customer"),
|
| 3603 |
+
allViewName("product"),
|
| 3604 |
+
allViewName("ut_"),
|
| 3605 |
+
]);
|
| 3606 |
+
|
| 3607 |
+
/**
|
| 3608 |
+
* ββ WAVE 40 Β· T28 β the name to PAINT for a view, which is not always the name stored on it.
|
| 3609 |
+
*
|
| 3610 |
+
* β A DISPLAY RULE, NOT A RENAME, and that distinction is the whole reason this function exists
|
| 3611 |
+
* instead of a changed `allViewName`. `views_from_defs` still mints the system view as "All
|
| 3612 |
+
* customers" / "All products" / "All records", and that host half is in NO lane's fence this
|
| 3613 |
+
* wave. A client that minted a different string would disagree with the echo and repaint the
|
| 3614 |
+
* rail on every workspace arrival β the flicker `allViewName`'s own note refuses one paragraph
|
| 3615 |
+
* up. Resolving at the point of PAINT collapses both spellings onto ONE output, so the rail
|
| 3616 |
+
* shows the same characters whether the name came from this browser's mint or from the host's
|
| 3617 |
+
* echo, and there is no frame in which the two differ. When the host half lands, "Default view"
|
| 3618 |
+
* is not in the mint set and passes through verbatim, so the two stay in step through the change.
|
| 3619 |
+
*
|
| 3620 |
+
* β IT MUST NOT CLOBBER A USER'S RENAME, and `all-customers` really IS renamable β this was
|
| 3621 |
+
* checked rather than assumed. `grid_events.py`'s `view_upsert` exempts it from the reserved-name
|
| 3622 |
+
* list by id (`if view_id != 'all-customers'`), and a saved record under the pinned id REPLACES
|
| 3623 |
+
* the projection through the ordinary saved-config overlay, so a rename of the default view
|
| 3624 |
+
* persists and means what it says. The override therefore fires only while the stored name is
|
| 3625 |
+
* STILL one of the mints β or empty, which is what a half-assembled workspace carries. Anything
|
| 3626 |
+
* a person typed passes through unchanged.
|
| 3627 |
+
*
|
| 3628 |
+
* β SCOPE-FREE, deliberately. The rail has no `scope` prop, and threading one in would give this
|
| 3629 |
+
* decision two homes; more importantly a stale echo carrying another topic's noun must still
|
| 3630 |
+
* resolve, rather than leaking "All records" onto a customer rail for one round trip.
|
| 3631 |
+
*
|
| 3632 |
+
* β ONLY `ALL_VIEW_ID`. A2 in one line: every other id, undeletable or not, keeps its own name.
|
| 3633 |
+
*/
|
| 3634 |
+
export function viewDisplayName(view: Pick<SavedView, "id" | "name">): string {
|
| 3635 |
+
if (view.id !== ALL_VIEW_ID) return view.name;
|
| 3636 |
+
const stored = view.name ?? "";
|
| 3637 |
+
return stored === "" || SYSTEM_VIEW_MINTS.has(stored) ? DEFAULT_VIEW_NAME : stored;
|
| 3638 |
+
}
|
| 3639 |
+
|
| 3640 |
/**
|
| 3641 |
* The ONE rule for "this view cannot be deleted", mirroring what the host actually enforces
|
| 3642 |
* (`app.py:5410` refuses `view_delete` for `all-customers`). The client hides exactly what
|
|
|
|
| 3906 |
* at the host, not merely hidden here.
|
| 3907 |
*/
|
| 3908 |
export function mayEditView(
|
| 3909 |
+
// `owner` joined this Pick with wave 40's C1 and STAYS, although the guard below no longer
|
| 3910 |
+
// reads it: the field is part of what a caller must be able to hand this function, and
|
| 3911 |
+
// narrowing the type back would be a second change with its own blast radius.
|
| 3912 |
+
view: Pick<SavedView, "permissions" | "createdBy" | "shared" | "sharedRole" | "owner">,
|
| 3913 |
viewer: Viewer | undefined
|
| 3914 |
): boolean {
|
| 3915 |
/**
|
|
|
|
| 3935 |
* ONE place, three consumers: the rail's row menu, the rail's drag, and the grid's own
|
| 3936 |
* delete/config-write guards all ask this function. Putting the rule in any one of them
|
| 3937 |
* would leave the other two offering an edit the server refuses.
|
| 3938 |
+
*
|
| 3939 |
+
* ββ WAVE 40 Β· CONTRACT C1 (owner instruction 2) β THIS GUARD BRIEFLY READ
|
| 3940 |
+
* `view.shared && view.owner && β¦`, AND THE `&& view.owner` IS GONE AGAIN. Recorded rather
|
| 3941 |
+
* than quietly reverted, because the reasoning that added it was sound and only its PREMISE
|
| 3942 |
+
* was wrong, and the next person reading C1 will reach for it again.
|
| 3943 |
+
*
|
| 3944 |
+
* The premise was that C1 would REDEFINE `shared` to mean "this view has grants" and stamp it
|
| 3945 |
+
* on the sharer's own leg too. On such a leg there is no `sharedRole` (ownership is not a
|
| 3946 |
+
* grant), `?? "view"` would read read-only, and this line would have locked an owner out of
|
| 3947 |
+
* every view they had ever shared. `owner` was added as the discriminator, since it already
|
| 3948 |
+
* means "whose view this is, WHEN IT IS NOT MINE".
|
| 3949 |
+
*
|
| 3950 |
+
* β BUT `shared` WAS NEVER REDEFINED. AM-5 settles C1 the other way: the marker is a NEW
|
| 3951 |
+
* key, `hasShares`, and `shared` / `sharedRole` / `owner` keep meaning "granted TO me, and on
|
| 3952 |
+
* what terms". Verified in the server, not inferred: `core/grid_events.py` writes `shared` on
|
| 3953 |
+
* exactly two lines, both inside `_granted_views`, both after `if owner == uname: continue`,
|
| 3954 |
+
* and both stamping `sharedRole` and `owner` in the same three statements. The sharer's own
|
| 3955 |
+
* leg gets `hasShares` and `sharedOut` and NEVER `shared`.
|
| 3956 |
+
*
|
| 3957 |
+
* β SO THE EXTRA CONDITION PROTECTED AGAINST NOTHING AND COST A FAIL-CLOSED CHECK. With it,
|
| 3958 |
+
* any `shared` view reaching the client without an `owner` fell through and read as EDITABLE.
|
| 3959 |
+
* `folders.test` had asserted the opposite since wave 21 and went red on all four of its C1
|
| 3960 |
+
* legs β "a view-role share is NOT editable", "β¦not by an ADMIN either", "β¦and not via the
|
| 3961 |
+
* unstamped-createdBy escape hatch", "a shared view with NO role reads as read-only". The
|
| 3962 |
+
* gate was right. Read those four names as this line's negative control.
|
| 3963 |
*/
|
| 3964 |
if (view.shared && (view.sharedRole ?? "view") !== "edit") return false;
|
| 3965 |
const mode = viewEditMode(view);
|
web/src/customer-grid/useGridColumns.ts
CHANGED
|
@@ -1,422 +1,483 @@
|
|
| 1 |
-
import { useCallback, useMemo } from "react";
|
| 2 |
-
import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
|
| 3 |
-
import type { Field, FilterNode, ViewConfig } from "./types";
|
| 4 |
-
import { isFilterGroup, isRuleActive, measureColumnIndex,
|
| 5 |
-
|
| 6 |
-
import {
|
| 7 |
-
import {
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
*
|
| 16 |
-
*
|
| 17 |
-
*
|
| 18 |
-
*
|
| 19 |
-
*
|
| 20 |
-
*
|
| 21 |
-
*
|
| 22 |
-
*
|
| 23 |
-
*
|
| 24 |
-
*
|
| 25 |
-
*
|
| 26 |
-
*
|
| 27 |
-
*
|
| 28 |
-
*
|
| 29 |
-
*
|
| 30 |
-
*
|
| 31 |
-
*
|
| 32 |
-
*
|
| 33 |
-
*
|
| 34 |
-
*
|
| 35 |
-
*
|
| 36 |
-
*
|
| 37 |
-
*
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
*
|
| 66 |
-
*
|
| 67 |
-
*
|
| 68 |
-
*
|
| 69 |
-
*
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
*
|
| 86 |
-
*
|
| 87 |
-
*
|
| 88 |
-
*
|
| 89 |
-
*
|
| 90 |
-
*
|
| 91 |
-
*
|
| 92 |
-
* β
|
| 93 |
-
*
|
| 94 |
-
*
|
| 95 |
-
*
|
| 96 |
-
*
|
| 97 |
-
*
|
| 98 |
-
*
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
*
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
*
|
| 145 |
-
*
|
| 146 |
-
*
|
| 147 |
-
*
|
| 148 |
-
*
|
| 149 |
-
*
|
| 150 |
-
*
|
| 151 |
-
*
|
| 152 |
-
*
|
| 153 |
-
* `
|
| 154 |
-
*
|
| 155 |
-
*
|
| 156 |
-
*
|
| 157 |
-
*
|
| 158 |
-
*
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
*
|
| 165 |
-
*
|
| 166 |
-
*
|
| 167 |
-
*
|
| 168 |
-
*
|
| 169 |
-
*
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
if (
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
[
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
);
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useCallback, useMemo } from "react";
|
| 2 |
+
import type { GridColumn, Theme } from "@glideapps/glide-data-grid";
|
| 3 |
+
import type { Field, FilterNode, ViewConfig } from "./types";
|
| 4 |
+
import { isFilterGroup, isMachineOwned, isRuleActive, measureColumnIndex,
|
| 5 |
+
ruleColumnKeys } from "./types";
|
| 6 |
+
import { typeIconName } from "./iconShapes";
|
| 7 |
+
import { fitHeaderTitle, hasShareMark, HEADER_MENU_SLOT, headerLabelSpace,
|
| 8 |
+
headerMarkSizes } from "./overlayPlacement";
|
| 9 |
+
import { COLUMN_TONE_THEME, INVOLVED_HEADER_FONT, lightTheme } from "./theme";
|
| 10 |
+
import type { ControlTone } from "./theme";
|
| 11 |
+
|
| 12 |
+
const DEFAULT_WIDTH = 150;
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Owner item 16 β measure header text in GLIDE'S OWN HEADER FONT.
|
| 16 |
+
*
|
| 17 |
+
* β NOT the cell font. Glide's `headerFontStyle` is "600 13px" and its `baseFontStyle` is
|
| 18 |
+
* "13px" (`common/styles.js:73-75`); semibold is materially wider, so measuring with the cell
|
| 19 |
+
* font under-measures, under-truncates, and leaves the label running under the (i) β the exact
|
| 20 |
+
* defect this item is about, surviving a fix that looked correct in the diff.
|
| 21 |
+
*
|
| 22 |
+
* β β Wave-14 item 2 adds the SAME trap one weight up. An involved column paints its header at
|
| 23 |
+
* **700** (`INVOLVED_HEADER_FONT`), so measuring every column at 600 would under-truncate
|
| 24 |
+
* exactly the filtered/sorted/grouped columns β the ones the owner is looking at. Hence TWO
|
| 25 |
+
* cached contexts, and both font strings are read off the theme layer rather than retyped here:
|
| 26 |
+
* a literal in this file is a copy that can drift from the one glide actually paints with, which
|
| 27 |
+
* is the whole failure mode above.
|
| 28 |
+
*
|
| 29 |
+
* β Wave-15 R5 gave each control its OWN hue, so there are now three involved-column themes
|
| 30 |
+
* instead of one. They deliberately share `INVOLVED_HEADER_FONT`, so ONE bold context still
|
| 31 |
+
* measures all three correctly β but the constant, not any one of the three theme objects, is
|
| 32 |
+
* what this reads. Picking `COLUMN_TONE_THEME.filter.headerFontStyle` would have measured every
|
| 33 |
+
* involved column against whichever tone happened to be listed first.
|
| 34 |
+
*
|
| 35 |
+
* One lazily-built offscreen context per weight, the same shape as CustomerGrid's
|
| 36 |
+
* `measureCellText`. A context can legitimately be null (a headless canvas, a locked-down
|
| 37 |
+
* embed); 0 then measures as "everything fits" and no title is truncated, which is the safe
|
| 38 |
+
* direction to fail β a title that slightly overlaps beats one silently cut to "Cβ¦".
|
| 39 |
+
*/
|
| 40 |
+
const _hdrCtx: Record<"base" | "bold", CanvasRenderingContext2D | null | undefined> = {
|
| 41 |
+
base: undefined,
|
| 42 |
+
bold: undefined,
|
| 43 |
+
};
|
| 44 |
+
function measureHeaderText(text: string, bold = false): number {
|
| 45 |
+
const slot = bold ? "bold" : "base";
|
| 46 |
+
if (_hdrCtx[slot] === undefined) {
|
| 47 |
+
const ctx = document.createElement("canvas").getContext("2d");
|
| 48 |
+
if (ctx) {
|
| 49 |
+
const style =
|
| 50 |
+
(bold ? INVOLVED_HEADER_FONT : lightTheme.headerFontStyle) ?? "600 13px";
|
| 51 |
+
ctx.font = `${style} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`;
|
| 52 |
+
}
|
| 53 |
+
_hdrCtx[slot] = ctx;
|
| 54 |
+
}
|
| 55 |
+
const ctx = _hdrCtx[slot];
|
| 56 |
+
return ctx ? ctx.measureText(text).width : 0;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
/** The (i) is drawn for exactly this condition. */
|
| 60 |
+
function hasInfoMark(field: Field): boolean {
|
| 61 |
+
return !!(field.note || field.description);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* The user-created-field DOT's condition β owner item 4 (2026-08-06): *"why is the Field still
|
| 66 |
+
* have the Dot at the header to mark its a custom editable field?"*
|
| 67 |
+
*
|
| 68 |
+
* β `!isMachineOwned` is half of the rule, and it is the half that USED TO BE MISSING FROM THIS
|
| 69 |
+
* FILE. `source === "overlay"` alone is true of every column in a `ut_*` database, including the
|
| 70 |
+
* ones an automation spawns and fills, so the reserve kept room for a dot the drawer refused to
|
| 71 |
+
* paint. Nothing went red: over-reserving only shortens a title slightly, which is why it sat
|
| 72 |
+
* here through two waves. It is fixed by DELETION of the second copy, not by a second correction
|
| 73 |
+
* that could drift again β see `headerMarksFor`.
|
| 74 |
+
*/
|
| 75 |
+
function hasCustomMark(field: Field): boolean {
|
| 76 |
+
return field.source === "overlay" && !isMachineOwned(field);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* β W40-T20 β THE header mark strip a field carries, resolved ONCE for both of its readers: this
|
| 81 |
+
* module's truncation RESERVE and `CustomerGrid.drawGridHeader`'s PAINTING.
|
| 82 |
+
*
|
| 83 |
+
* β WHY IT IS A FUNCTION AND NOT A COMMENT. What stood here before was the claim *"both this
|
| 84 |
+
* reserve and drawGridHeader's painting read `headerMarkSizes`, so a column can never reserve
|
| 85 |
+
* room for one mark and then be given two"* β and it was FALSE IN TWO WAYS at once. The two call
|
| 86 |
+
* sites each resolved the booleans themselves, and they had already diverged on the dot
|
| 87 |
+
* (`hasCustomMark` above); adding the share mark would have diverged them a second time, on the
|
| 88 |
+
* mark whose whole purpose is to sit INNERMOST, i.e. exactly where a wrong reserve lets a title
|
| 89 |
+
* paint through a glyph. A shared resolver is the only version of that sentence that stays true
|
| 90 |
+
* when the next mark is added.
|
| 91 |
+
*
|
| 92 |
+
* β `droppable` TRAVELS WITH `sizes` for the same reason. They are two halves of one answer:
|
| 93 |
+
* reserving for a strip of three while the drawer paints two puts the label's right edge in the
|
| 94 |
+
* wrong place, and reserving for three while the drawer ALSO refuses all three (the `droppable`
|
| 95 |
+
* mismatch) returns `null`, which means "do not truncate at all" and paints the full label
|
| 96 |
+
* straight through both surviving marks. Passing one without the other is worse than passing
|
| 97 |
+
* neither, so neither is passed alone.
|
| 98 |
+
*/
|
| 99 |
+
export function headerMarksFor(field: Field): {
|
| 100 |
+
info: boolean;
|
| 101 |
+
custom: boolean;
|
| 102 |
+
shared: boolean;
|
| 103 |
+
sizes: number[];
|
| 104 |
+
droppable: number;
|
| 105 |
+
} {
|
| 106 |
+
const info = hasInfoMark(field);
|
| 107 |
+
const custom = hasCustomMark(field);
|
| 108 |
+
const shared = hasShareMark(field);
|
| 109 |
+
return {
|
| 110 |
+
info,
|
| 111 |
+
custom,
|
| 112 |
+
shared,
|
| 113 |
+
sizes: headerMarkSizes(info, custom, shared),
|
| 114 |
+
// Only the share mark may be given up, and only because it is innermost. See `headerMarkFit`.
|
| 115 |
+
droppable: shared ? 1 : 0,
|
| 116 |
+
};
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/** Every column key an ACTIVE filter rule names, anywhere in the tree. Inactive (half-typed)
|
| 120 |
+
* rules are skipped: a rule that is not narrowing anything must not tint a column as though
|
| 121 |
+
* it were β that is the same "it looks like it is working" lie the engine refuses to tell.
|
| 122 |
+
*
|
| 123 |
+
* β Wave-20 item 2: the key a rule NAMES is not always the column it is ABOUT β a measure
|
| 124 |
+
* condition carries the MEASURE key. `ruleColumnKeys` is the one resolution (types.ts); it
|
| 125 |
+
* is what stopped a filtered-and-sorted measure column wearing the sort hue. */
|
| 126 |
+
function filteredKeys(
|
| 127 |
+
nodes: FilterNode[],
|
| 128 |
+
out: Set<string>,
|
| 129 |
+
measureCols: Map<string, string[]>
|
| 130 |
+
): void {
|
| 131 |
+
for (const node of nodes ?? []) {
|
| 132 |
+
if (isFilterGroup(node)) {
|
| 133 |
+
filteredKeys(node.children, out, measureCols);
|
| 134 |
+
continue;
|
| 135 |
+
}
|
| 136 |
+
if (isRuleActive(node)) for (const key of ruleColumnKeys(node, measureCols)) out.add(key);
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
/**
|
| 141 |
+
* The column's theme override, or `undefined` β glide's fast path for "no override" is an absent
|
| 142 |
+
* object, so an empty `{}` would cost a ~35-key theme merge per column per frame.
|
| 143 |
+
*
|
| 144 |
+
* β Wave-14 item 1 (R11): a user-created field contributes no theme. Its yellow header wash is
|
| 145 |
+
* dead and its replacement is a painted DOT, not a background β so the only thing that can put a
|
| 146 |
+
* themeOverride on a column is being INVOLVED in a control.
|
| 147 |
+
*
|
| 148 |
+
* β Wave-15 R5: the returned object is one of the three SHARED `COLUMN_TONE_THEME` references
|
| 149 |
+
* (never a fresh object), which is what keeps glide's theme merge cache-friendly across frames.
|
| 150 |
+
*
|
| 151 |
+
* β Wave-29 R8 (2026-08-11): those three references now carry **HEADER keys only**. glide builds a
|
| 152 |
+
* header theme from the column override alone and a cell theme from column β row β cell
|
| 153 |
+
* (`data-grid-render.header.js:59`, `common/styles.js mergeAndRealizeTheme`), so the tone reaching
|
| 154 |
+
* a column theme is exactly how a sort used to repaint record bodies. With no `bgCell` in the
|
| 155 |
+
* table there is no longer a path from "this column is sorted" to a cell's background at all β
|
| 156 |
+
* which is a property of what `COLUMN_TONE_THEME` CONTAINS, not of anything this function does.
|
| 157 |
+
* A body wash belongs to `getRowThemeOverride` (status) or the cell itself (automation).
|
| 158 |
+
*/
|
| 159 |
+
function columnTheme(tone: ControlTone | undefined): Partial<Theme> | undefined {
|
| 160 |
+
return tone ? COLUMN_TONE_THEME[tone] : undefined;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Owner item 22 β key β which control involves it, or absent.
|
| 165 |
+
*
|
| 166 |
+
* β ONE tone per column, and the precedence is a decision, not an accident: **filter > sort >
|
| 167 |
+
* group** (owner R6, wave 15). A filter changes WHICH ROWS you are looking at; when a column is
|
| 168 |
+
* doing two jobs the header names the most consequential one. Blending two tints would produce a
|
| 169 |
+
* third colour that matches no chip in the toolbar, which is worse than picking.
|
| 170 |
+
*
|
| 171 |
+
* β THE PRECEDENCE IS THE WRITE ORDER BELOW, and it reads backwards: later `set` calls WIN, so
|
| 172 |
+
* the least important control is written FIRST. It was `sort, group, filter` β i.e. filter >
|
| 173 |
+
* group > sort β until R6 named the order explicitly; getting this wrong is a one-line edit that
|
| 174 |
+
* changes a colour on screen and nothing else, so it is stated here rather than left to be
|
| 175 |
+
* re-derived from the sequence.
|
| 176 |
+
*/
|
| 177 |
+
/* wave17 GRID β item 2 / owner R5. EXPORTED, and only since the row band died.
|
| 178 |
+
`activeControlTone` used to sit below this function and restate the same precedence for the
|
| 179 |
+
whole-table band; the band was its only consumer, so R5 took both. That left the surviving
|
| 180 |
+
precedence β the one that still paints β asserted by nothing, because `verify_icons` was
|
| 181 |
+
testing the twin. Exported so the gate can reach the function that is actually on screen. */
|
| 182 |
+
export function columnTones(
|
| 183 |
+
config: ViewConfig,
|
| 184 |
+
/* wave20 item 2 β the FIELDS, because a measure condition names its measure and only the
|
| 185 |
+
field list can say which column displays it. Defaulted so the signature stays callable
|
| 186 |
+
with a config alone (the pre-wave-20 gate legs), and because a caller with no fields
|
| 187 |
+
legitimately has no measure columns to resolve. */
|
| 188 |
+
fields: Field[] = []
|
| 189 |
+
): Map<string, ControlTone> {
|
| 190 |
+
const out = new Map<string, ControlTone>();
|
| 191 |
+
if (config.groupBy) out.set(config.groupBy, "group");
|
| 192 |
+
for (const s of config.sorts ?? []) out.set(s.colId, "sort");
|
| 193 |
+
const filtered = new Set<string>();
|
| 194 |
+
filteredKeys(config.filters ?? [], filtered, measureColumnIndex(fields));
|
| 195 |
+
for (const key of filtered) out.set(key, "filter");
|
| 196 |
+
return out;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/**
|
| 200 |
+
* β WAVE 30 (owner item 2 scouting) β THE OVERLAY ARM IS GONE, and the arithmetic is the reason.
|
| 201 |
+
*
|
| 202 |
+
* This read `field.default !== false || field.source === "overlay"`. The second arm was written
|
| 203 |
+
* when `overlay` meant "a column a user added here", and those columns have no `default` key at
|
| 204 |
+
* all β so the arm was a belt-and-braces no-op for them. Then `user_tables._clean_field` started
|
| 205 |
+
* stamping `source: "overlay"` on EVERY `ut_*` field, and every connected database became a `ut_*`
|
| 206 |
+
* one: the exception quietly became the rule and swallowed the first arm entirely.
|
| 207 |
+
*
|
| 208 |
+
* β WHAT THAT COST, measured on the live orders grid: `odoo_relational.order_fields` declares
|
| 209 |
+
* `default: False` on `odoo_id`, `state`, `customer_link` and `partner_id`, and every one of them
|
| 210 |
+
* opened SHOWN. The grid arrives wide, and "Hide fields" reads inactive because
|
| 211 |
+
* `shownCount === fields.length` β so the control that says "some columns are hidden" says the
|
| 212 |
+
* opposite of what its own table declares. A second instance of [[fallback-that-became-the-rule]].
|
| 213 |
+
*
|
| 214 |
+
* β DROPPING THE ARM DOES NOT HIDE USER COLUMNS, and this is the check that matters before
|
| 215 |
+
* believing the fix. A column somebody created carries no `default` key, `undefined !== false` is
|
| 216 |
+
* true, and it stays visible. `_clean_field` also keeps `default` ONLY when it is `True`, so a
|
| 217 |
+
* `ut_*` column can never arrive carrying `default: false` by accident β only a shipped field
|
| 218 |
+
* CONTRACT (which is written straight into the store, bypassing that validator) can declare it.
|
| 219 |
+
*
|
| 220 |
+
* β THIS PREDICATE HAS A SERVER TWIN and the twin is the one that decides on first open:
|
| 221 |
+
* `platform/aios_grid.py:_default_view_config` carries the same expression and builds the "All
|
| 222 |
+
* records" system view the client pins as its landing default. Fixing one side alone changes
|
| 223 |
+
* nothing a user sees β see `mailbox/F.md` F-1 ([[one-question-two-normalizers]] across two
|
| 224 |
+
* languages).
|
| 225 |
+
*/
|
| 226 |
+
function isDefaultVisible(field: Field): boolean {
|
| 227 |
+
if (field.default === false) return false;
|
| 228 |
+
/**
|
| 229 |
+
* ββ OWNER, 2026-08-23 β A NEW VIEW SHOWS THE DATABASE'S OWN COLUMNS AND NOTHING ELSE.
|
| 230 |
+
*
|
| 231 |
+
* Owner: *"when any new View is created only shareable pre-set data should show. Shared to me
|
| 232 |
+
* or Shared to everyone should be auto hidden."*
|
| 233 |
+
*
|
| 234 |
+
* β `custom || shared` IS THE WHOLE PREDICATE, and it is chosen because it is exactly the
|
| 235 |
+
* membership test `FieldsHidePanel` already uses to draw its two SHARED sections:
|
| 236 |
+
* "Shared with me" (field.custom || field.shared) && fieldEditMode === "users"
|
| 237 |
+
* "Shared with everyone" (field.custom || field.shared) && fieldEditMode === "collaborative"
|
| 238 |
+
* Both sections are subsets of `custom || shared`, so hiding on that flag hides both by
|
| 239 |
+
* construction rather than by keeping a second list of section names in step with the panel.
|
| 240 |
+
*
|
| 241 |
+
* β IT ALSO HIDES THE UNTITLED SECTION'S CREATED COLUMNS, and that is deliberate rather than
|
| 242 |
+
* over-reach. A route order carries `shared: true` with `edit: personal`, so it is filed under
|
| 243 |
+
* no heading at all β and 52 of them landed on the customer grid the same day this was asked
|
| 244 |
+
* for. The owner's first sentence is the rule ("ONLY pre-set data shows"); the second names the
|
| 245 |
+
* two headings, it does not bound the set.
|
| 246 |
+
*
|
| 247 |
+
* β MEASURED ON THE LIVE CUSTOMER GRID before it was written: of 105 fields, 77 opened SHOWN.
|
| 248 |
+
* 52 route columns, 14 collaborative `custom_*`, 3 `measure_*`, one legacy route and 7 plain
|
| 249 |
+
* Odoo columns. After this, the 7 remain β which is precisely what
|
| 250 |
+
* `aios_grid_fields.json` declares as `default: true`, i.e. the contract's own answer before
|
| 251 |
+
* anybody's columns piled on top of it.
|
| 252 |
+
*
|
| 253 |
+
* ββ A STORED VIEW IS UNTOUCHED. `normalizeConfig` spreads the saved `config` OVER this
|
| 254 |
+
* base, so a view that already recorded its own `visible` keeps it. What moves is a view born
|
| 255 |
+
* without one: the "All records" system view, a new view, a cohort view.
|
| 256 |
+
*/
|
| 257 |
+
if (field.custom === true || field.shared === true) return false;
|
| 258 |
+
return true;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
export function defaultViewConfig(fields: Field[]): ViewConfig {
|
| 262 |
+
const shown = fields.filter(isDefaultVisible).map((field) => field.key);
|
| 263 |
+
const hidden = fields.filter((field) => !isDefaultVisible(field)).map((field) => field.key);
|
| 264 |
+
return {
|
| 265 |
+
filters: [],
|
| 266 |
+
filterConj: "and",
|
| 267 |
+
sorts: [],
|
| 268 |
+
groupBy: null,
|
| 269 |
+
colorBy: null,
|
| 270 |
+
rowHeightMode: "short",
|
| 271 |
+
order: [...shown, ...hidden],
|
| 272 |
+
visible: shown,
|
| 273 |
+
widths: {},
|
| 274 |
+
memberPids: [],
|
| 275 |
+
};
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function reconcileOrder(order: string[], fields: Field[]): string[] {
|
| 279 |
+
const valid = new Set(fields.map((field) => field.key));
|
| 280 |
+
const kept = order.filter((key, index) => valid.has(key) && order.indexOf(key) === index);
|
| 281 |
+
for (const field of fields) if (!kept.includes(field.key)) kept.push(field.key);
|
| 282 |
+
const locked = fields.find((field) => field.pinned)?.key ?? fields[0]?.key;
|
| 283 |
+
if (locked && kept[0] !== locked) {
|
| 284 |
+
const without = kept.filter((key) => key !== locked);
|
| 285 |
+
return [locked, ...without];
|
| 286 |
+
}
|
| 287 |
+
return kept;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
function reconcileVisible(visible: string[], fields: Field[], lockedKey: string): Set<string> {
|
| 291 |
+
const valid = new Set(fields.map((field) => field.key));
|
| 292 |
+
const out = new Set(visible.filter((key) => valid.has(key)));
|
| 293 |
+
if (out.size === 0) {
|
| 294 |
+
for (const field of fields) if (isDefaultVisible(field)) out.add(field.key);
|
| 295 |
+
}
|
| 296 |
+
if (lockedKey) out.add(lockedKey);
|
| 297 |
+
return out;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
export interface GridColumnsApi {
|
| 301 |
+
visibleCols: GridColumn[];
|
| 302 |
+
fieldByKey: Map<string, Field>;
|
| 303 |
+
order: string[];
|
| 304 |
+
visible: Set<string>;
|
| 305 |
+
widths: Record<string, number>;
|
| 306 |
+
lockedKey: string;
|
| 307 |
+
onColumnResize: (col: GridColumn, newSize: number) => void;
|
| 308 |
+
onColumnMoved: (from: number, to: number) => void;
|
| 309 |
+
onColumnProposeMove: (from: number, to: number) => boolean;
|
| 310 |
+
setColumnVisible: (key: string, show: boolean) => void;
|
| 311 |
+
insertColumn: (key: string, anchorKey: string | null, side: "left" | "right" | "end") => void;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
/**
|
| 315 |
+
* Controlled column adapter. ViewConfig is the durable source of truth, so
|
| 316 |
+
* choosing a saved view is one atomic state change instead of a chain of hook
|
| 317 |
+
* setters that can flash or overwrite one another.
|
| 318 |
+
*/
|
| 319 |
+
export function useGridColumns(
|
| 320 |
+
fields: Field[],
|
| 321 |
+
config: ViewConfig,
|
| 322 |
+
onConfig: (next: ViewConfig) => void
|
| 323 |
+
): GridColumnsApi {
|
| 324 |
+
const fieldByKey = useMemo(
|
| 325 |
+
() => new Map(fields.map((field) => [field.key, field])),
|
| 326 |
+
[fields]
|
| 327 |
+
);
|
| 328 |
+
const lockedKey = useMemo(
|
| 329 |
+
() => fields.find((field) => field.pinned)?.key ?? fields[0]?.key ?? "",
|
| 330 |
+
[fields]
|
| 331 |
+
);
|
| 332 |
+
const order = useMemo(() => reconcileOrder(config.order, fields), [config.order, fields]);
|
| 333 |
+
const visible = useMemo(
|
| 334 |
+
() => reconcileVisible(config.visible, fields, lockedKey),
|
| 335 |
+
[config.visible, fields, lockedKey]
|
| 336 |
+
);
|
| 337 |
+
|
| 338 |
+
// Item 22 β recomputed only when the three controls move, not on every width drag.
|
| 339 |
+
// Wave-20 item 2 adds `fields`: a measure condition can only be resolved to the column that
|
| 340 |
+
// displays it, and a new measure column must re-tint without waiting for a control to move.
|
| 341 |
+
const tones = useMemo(
|
| 342 |
+
() => columnTones(config, fields),
|
| 343 |
+
[config.filters, config.sorts, config.groupBy, fields] // eslint-disable-line react-hooks/exhaustive-deps
|
| 344 |
+
);
|
| 345 |
+
|
| 346 |
+
const visibleCols = useMemo<GridColumn[]>(
|
| 347 |
+
() =>
|
| 348 |
+
order
|
| 349 |
+
.filter((key) => visible.has(key))
|
| 350 |
+
.map((key) => {
|
| 351 |
+
const field = fieldByKey.get(key)!;
|
| 352 |
+
const width = config.widths[key] ?? DEFAULT_WIDTH;
|
| 353 |
+
// Wave-14 item 2 β an INVOLVED column's header paints at 700, so it must be MEASURED
|
| 354 |
+
// at 700. `tones` is already a dep of this memo, so this costs nothing.
|
| 355 |
+
const involved = tones.has(key);
|
| 356 |
+
// R11 / W40-T20 β the marks this header carries, in the strip's canonical order. This
|
| 357 |
+
// reserve and `CustomerGrid.drawGridHeader`'s painting call the SAME resolver, so a
|
| 358 |
+
// column cannot reserve room for one strip and then be given a different one. That is
|
| 359 |
+
// a property of `headerMarksFor` being one function, not of two call sites agreeing.
|
| 360 |
+
const marks = headerMarksFor(field);
|
| 361 |
+
return {
|
| 362 |
+
id: key,
|
| 363 |
+
// Owner item 16 β ELLIPSISED so the title can never run under the (i). Glide
|
| 364 |
+
// truncates nothing: `drawHeaderInner` calls `fillText(c.title, β¦)` with no clip
|
| 365 |
+
// and no max width, so on a narrow column the name simply painted through the
|
| 366 |
+
// mark. Shortening the title glide is given is the whole fix β the alternative,
|
| 367 |
+
// clipping inside `drawHeader`, cuts mid-glyph with no ellipsis to say it did.
|
| 368 |
+
// The FULL name stays reachable: CustomerGrid's header tip leads with it whenever
|
| 369 |
+
// this returns something shorter than `field.label`.
|
| 370 |
+
// β W40-T20 β `menuSlotWidth` is passed EXPLICITLY so `droppable` can be. It is the
|
| 371 |
+
// constant that was already defaulting here, so the number is unchanged; what changes
|
| 372 |
+
// is that the reserve now clears the marks the drawer actually paints rather than the
|
| 373 |
+
// ones it was asked for.
|
| 374 |
+
title: fitHeaderTitle(
|
| 375 |
+
field.label,
|
| 376 |
+
headerLabelSpace(width, marks.sizes, HEADER_MENU_SLOT, marks.droppable),
|
| 377 |
+
(text) => measureHeaderText(text, involved)
|
| 378 |
+
),
|
| 379 |
+
width,
|
| 380 |
+
hasMenu: true,
|
| 381 |
+
menuIcon: "dots",
|
| 382 |
+
// Wave-8 I20 β the field-TYPE mark leads every header, so the column says what
|
| 383 |
+
// KIND of thing it holds before you read a single cell (same geometry as the
|
| 384 |
+
// Fields panel's rows β icons.tsx).
|
| 385 |
+
icon: typeIconName(field.type),
|
| 386 |
+
// Wave-9 I3 β the (i) is NO LONGER `overlayIcon`. Glide draws an overlay at a
|
| 387 |
+
// hard-coded offset from the TYPE mark (drawHeaderInner: `drawX + 9`), i.e.
|
| 388 |
+
// pinned to the far LEFT of the header, and no prop moves it; the owner asked
|
| 389 |
+
// for it right-aligned and centred with the field name. It is drawn instead by
|
| 390 |
+
// CustomerGrid's `drawHeader` callback, which resolves the same
|
| 391 |
+
// `field.note || field.description` condition from the field itself.
|
| 392 |
+
// The hover text is still CustomerGrid's floating tip (pointer-events: none β
|
| 393 |
+
// a tooltip must never swallow the next click, [[ui-invisible-to-assertions]]).
|
| 394 |
+
// Wave-14 R4 β being INVOLVED in a filter/sort/group is the only thing that tints a
|
| 395 |
+
// column now, and all three tint it the same faint warm grey; the hue that says
|
| 396 |
+
// WHICH control lives on the toolbar chip. User-created ownership moved out of the
|
| 397 |
+
// theme entirely and is the dot drawn in the mark strip (R11).
|
| 398 |
+
themeOverride: columnTheme(tones.get(key)),
|
| 399 |
+
};
|
| 400 |
+
}),
|
| 401 |
+
[order, visible, config.widths, fieldByKey, tones]
|
| 402 |
+
);
|
| 403 |
+
|
| 404 |
+
const onColumnResize = useCallback(
|
| 405 |
+
(column: GridColumn, newSize: number) => {
|
| 406 |
+
if (!column.id) return;
|
| 407 |
+
onConfig({
|
| 408 |
+
...config,
|
| 409 |
+
widths: { ...config.widths, [column.id]: Math.round(newSize) },
|
| 410 |
+
});
|
| 411 |
+
},
|
| 412 |
+
[config, onConfig]
|
| 413 |
+
);
|
| 414 |
+
|
| 415 |
+
const onColumnMoved = useCallback(
|
| 416 |
+
(from: number, to: number) => {
|
| 417 |
+
if (from === 0 || to === 0) return;
|
| 418 |
+
const visibleKeys = order.filter((key) => visible.has(key));
|
| 419 |
+
if (
|
| 420 |
+
from < 0 ||
|
| 421 |
+
from >= visibleKeys.length ||
|
| 422 |
+
to < 0 ||
|
| 423 |
+
to >= visibleKeys.length
|
| 424 |
+
)
|
| 425 |
+
return;
|
| 426 |
+
const movedKeys = visibleKeys.slice();
|
| 427 |
+
const [moved] = movedKeys.splice(from, 1);
|
| 428 |
+
movedKeys.splice(to, 0, moved);
|
| 429 |
+
let index = 0;
|
| 430 |
+
const nextOrder = order.map((key) =>
|
| 431 |
+
visible.has(key) ? movedKeys[index++] : key
|
| 432 |
+
);
|
| 433 |
+
onConfig({ ...config, order: nextOrder });
|
| 434 |
+
},
|
| 435 |
+
[config, onConfig, order, visible]
|
| 436 |
+
);
|
| 437 |
+
|
| 438 |
+
const onColumnProposeMove = useCallback(
|
| 439 |
+
(from: number, to: number) => from !== 0 && to !== 0,
|
| 440 |
+
[]
|
| 441 |
+
);
|
| 442 |
+
|
| 443 |
+
const setColumnVisible = useCallback(
|
| 444 |
+
(key: string, show: boolean) => {
|
| 445 |
+
if (!show && key === lockedKey) return;
|
| 446 |
+
const next = new Set(visible);
|
| 447 |
+
if (show) next.add(key);
|
| 448 |
+
else next.delete(key);
|
| 449 |
+
onConfig({ ...config, visible: [...next] });
|
| 450 |
+
},
|
| 451 |
+
[config, lockedKey, onConfig, visible]
|
| 452 |
+
);
|
| 453 |
+
|
| 454 |
+
const insertColumn = useCallback(
|
| 455 |
+
(key: string, anchorKey: string | null, side: "left" | "right" | "end") => {
|
| 456 |
+
const nextOrder = order.filter((item) => item !== key);
|
| 457 |
+
if (side === "end" || !anchorKey) {
|
| 458 |
+
nextOrder.push(key);
|
| 459 |
+
} else {
|
| 460 |
+
const anchor = Math.max(0, nextOrder.indexOf(anchorKey));
|
| 461 |
+
nextOrder.splice(anchor + (side === "right" ? 1 : 0), 0, key);
|
| 462 |
+
}
|
| 463 |
+
const nextVisible = new Set(visible);
|
| 464 |
+
nextVisible.add(key);
|
| 465 |
+
onConfig({ ...config, order: nextOrder, visible: [...nextVisible] });
|
| 466 |
+
},
|
| 467 |
+
[config, onConfig, order, visible]
|
| 468 |
+
);
|
| 469 |
+
|
| 470 |
+
return {
|
| 471 |
+
visibleCols,
|
| 472 |
+
fieldByKey,
|
| 473 |
+
order,
|
| 474 |
+
visible,
|
| 475 |
+
widths: config.widths,
|
| 476 |
+
lockedKey,
|
| 477 |
+
onColumnResize,
|
| 478 |
+
onColumnMoved,
|
| 479 |
+
onColumnProposeMove,
|
| 480 |
+
setColumnVisible,
|
| 481 |
+
insertColumn,
|
| 482 |
+
};
|
| 483 |
+
}
|
web/src/filter-kit/FieldsHidePanel.tsx
CHANGED
|
@@ -14,7 +14,9 @@
|
|
| 14 |
// ---------------------------------------------------------------------------
|
| 15 |
|
| 16 |
import { useState } from "react";
|
| 17 |
-
|
|
|
|
|
|
|
| 18 |
// β WAVE 30 item 2 (R5) β `fieldLabel` is the ONE resolver for a field's NAME, and every read
|
| 19 |
// of `f.label` in this file goes through it. `Field.label` is declared `string` and arrives
|
| 20 |
// over the WIRE, where nothing enforces that; the search filter below did
|
|
@@ -28,12 +30,25 @@ import type { Field } from "../customer-grid/types";
|
|
| 28 |
import { fieldEditMode, fieldLabel, isPresetField } from "../customer-grid/types";
|
| 29 |
import { FieldTypeIcon } from "../customer-grid/icons";
|
| 30 |
import { TYPE_LABELS } from "../customer-grid/iconShapes";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
export interface FieldsHidePanelProps {
|
| 33 |
/** Owner item 23 β in the view's COLUMN ORDER, not definition order. Dragging a row here
|
| 34 |
* rewrites `config.order`, so a list showing a different sequence from the one it edits
|
| 35 |
-
* would move a field to a position the user never pointed at.
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
| 37 |
/** The keys that are HIDDEN. Every field not named here renders as shown. */
|
| 38 |
hidden: ReadonlySet<string>;
|
| 39 |
onToggle: (key: string) => void;
|
|
@@ -52,6 +67,27 @@ export interface FieldsHidePanelProps {
|
|
| 52 |
* affordance renders for them at all. */
|
| 53 |
deletableKeys?: ReadonlySet<string>;
|
| 54 |
onDeleteField?: (key: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
/**
|
|
@@ -72,6 +108,10 @@ export function FieldsHidePanel({
|
|
| 72 |
onFieldOrder,
|
| 73 |
deletableKeys,
|
| 74 |
onDeleteField,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
}: FieldsHidePanelProps) {
|
| 76 |
const [query, setQuery] = useState("");
|
| 77 |
/** W9 β destructive, so the trash arms first (the grid's confirm pattern): first
|
|
@@ -83,16 +123,52 @@ export function FieldsHidePanel({
|
|
| 83 |
* that slot. */
|
| 84 |
const [dragKey, setDragKey] = useState<string | null>(null);
|
| 85 |
const [overKey, setOverKey] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
const q = query.trim().toLowerCase();
|
| 87 |
-
const shown = q ? fields.filter((f) =>
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
/**
|
| 97 |
* Item 23 β reorder is OFF while a search is narrowing the list. Dropping "City" above
|
| 98 |
* "State" in a list that is hiding the eight fields between them means nothing the user can
|
|
@@ -209,10 +285,10 @@ export function FieldsHidePanel({
|
|
| 209 |
draggable={canReorder && !locked}
|
| 210 |
aria-label={
|
| 211 |
locked
|
| 212 |
-
? `${
|
| 213 |
: canReorder
|
| 214 |
-
? `Reorder ${
|
| 215 |
-
: `Reorder ${
|
| 216 |
}
|
| 217 |
title={
|
| 218 |
canReorder && !locked
|
|
@@ -262,7 +338,7 @@ export function FieldsHidePanel({
|
|
| 262 |
draws (icons.tsx). Titled, because this list is where the icon
|
| 263 |
vocabulary is learned. */}
|
| 264 |
<FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
|
| 265 |
-
<span className="cg-check-label">{
|
| 266 |
{/* β OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
|
| 267 |
like how Odoo fields are. This is always the standard label."* Right β the chip
|
| 268 |
asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
|
|
@@ -283,7 +359,7 @@ export function FieldsHidePanel({
|
|
| 283 |
type="button"
|
| 284 |
className={"cg-check-del" + (armed ? " is-armed" : "")}
|
| 285 |
aria-label={
|
| 286 |
-
armed ? `Permanently delete ${
|
| 287 |
}
|
| 288 |
title={armed ? undefined : "Removes this field and its values on every row."}
|
| 289 |
onClick={(e) => {
|
|
@@ -317,7 +393,24 @@ export function FieldsHidePanel({
|
|
| 317 |
})}
|
| 318 |
</div>
|
| 319 |
))}
|
| 320 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
<div className="cg-builder-empty">No field matches your search.</div>
|
| 322 |
)}
|
| 323 |
</div>
|
|
|
|
| 14 |
// ---------------------------------------------------------------------------
|
| 15 |
|
| 16 |
import { useState } from "react";
|
| 17 |
+
// β W40-T16 β the bare `Field` type import is GONE, not misplaced: the prop below is
|
| 18 |
+
// `PermsField[]` now, which extends it, so importing both would leave one unread and
|
| 19 |
+
// `tsconfig.app.json`'s `noUnusedLocals` reds the build on exactly that.
|
| 20 |
// β WAVE 30 item 2 (R5) β `fieldLabel` is the ONE resolver for a field's NAME, and every read
|
| 21 |
// of `f.label` in this file goes through it. `Field.label` is declared `string` and arrives
|
| 22 |
// over the WIRE, where nothing enforces that; the search filter below did
|
|
|
|
| 30 |
import { fieldEditMode, fieldLabel, isPresetField } from "../customer-grid/types";
|
| 31 |
import { FieldTypeIcon } from "../customer-grid/icons";
|
| 32 |
import { TYPE_LABELS } from "../customer-grid/iconShapes";
|
| 33 |
+
// ββ W40-T16 (owner instruction 13) β A METRIC IS A ROW LIKE ANY OTHER, and the ONE thing that
|
| 34 |
+
// makes it read differently is its NAME. `metricLabel` is that rule, declared once beside the
|
| 35 |
+
// flag it depends on, and it is a pure string function so this panel does not acquire a runtime
|
| 36 |
+
// dependency on anything the permission editor fetches or holds.
|
| 37 |
+
// β THE FIRST IMPORT FROM `settings/**` INTO THE KIT, and it is deliberately the leaf:
|
| 38 |
+
// `permsModel.ts` imports nothing at runtime (its only import is `import type`), so the module
|
| 39 |
+
// graph stays acyclic and `verify_ui.py`'s render smoke β which mounts `PermsEditor` and reaches
|
| 40 |
+
// this file through it β still resolves under bare node.
|
| 41 |
+
import type { PermsField } from "../settings/permsModel";
|
| 42 |
+
import { metricLabel, presetHideFields } from "../settings/permsModel";
|
| 43 |
|
| 44 |
export interface FieldsHidePanelProps {
|
| 45 |
/** Owner item 23 β in the view's COLUMN ORDER, not definition order. Dragging a row here
|
| 46 |
* rewrites `config.order`, so a list showing a different sequence from the one it edits
|
| 47 |
+
* would move a field to a position the user never pointed at.
|
| 48 |
+
* β W40-T16 β `PermsField[]`, which is `Field` plus an OPTIONAL metric flag. The grid
|
| 49 |
+
* toolbar keeps passing `Field[]` and keeps compiling, because an optional extra key makes
|
| 50 |
+
* the two assignable; its fields never carry the flag, so no metric row can appear there. */
|
| 51 |
+
fields: PermsField[];
|
| 52 |
/** The keys that are HIDDEN. Every field not named here renders as shown. */
|
| 53 |
hidden: ReadonlySet<string>;
|
| 54 |
onToggle: (key: string) => void;
|
|
|
|
| 67 |
* affordance renders for them at all. */
|
| 68 |
deletableKeys?: ReadonlySet<string>;
|
| 69 |
onDeleteField?: (key: string) => void;
|
| 70 |
+
/**
|
| 71 |
+
* ββ W40-T17 (owner instruction 15) β DOES THIS MOUNT FILE ROWS UNDER "Shared with me" AND
|
| 72 |
+
* "Shared with everyone"? Absent means YES, which is the grid's behaviour and every behaviour
|
| 73 |
+
* this panel has ever had. The manage-user permission editor passes `false`, because a
|
| 74 |
+
* permission is set on the database's own pre-set columns and on nothing else: owner, verbatim,
|
| 75 |
+
* *"stop displaying 'Shared with me' / 'Shared with everyone' fields under Hide Fields -
|
| 76 |
+
* permission on pre-set Fields only."*
|
| 77 |
+
*
|
| 78 |
+
* β A PROP WITH A SAFE DEFAULT, NEVER AN UNCONDITIONAL REMOVAL, AND THE REASON IS THAT THIS
|
| 79 |
+
* PANEL SERVES TWO MOUNTS. `customer-grid/Toolbar.tsx` mounts it for the grid, where the two
|
| 80 |
+
* sections were ADDED on purpose by the 2026-08-21 hotfix and are what a user reads to tell
|
| 81 |
+
* their own columns from the tenant's. Deleting them here would silently take a shipped
|
| 82 |
+
* affordance off a surface this ticket never looked at, and no diff of the permission editor
|
| 83 |
+
* would show it. The grid passes nothing and is therefore untouched, byte for byte.
|
| 84 |
+
*
|
| 85 |
+
* β `false` DOES NOT MERELY DROP TWO SECTIONS. It swaps the whole membership question for
|
| 86 |
+
* AM-2's: `permsModel::presetHideFields`, i.e. `metric || !custom`. Filing the same rows under
|
| 87 |
+
* one untitled heading would still list somebody's personal column as a thing an admin can
|
| 88 |
+
* permission, which is the half of instruction 15 the section titles only made visible.
|
| 89 |
+
*/
|
| 90 |
+
sharedSections?: boolean;
|
| 91 |
}
|
| 92 |
|
| 93 |
/**
|
|
|
|
| 108 |
onFieldOrder,
|
| 109 |
deletableKeys,
|
| 110 |
onDeleteField,
|
| 111 |
+
// ββ W40-T17 β THE DEFAULT IS THE WHOLE SAFETY PROPERTY. `true` here is what makes the grid's
|
| 112 |
+
// mount need no edit at all: `customer-grid/Toolbar.tsx` passes no `sharedSections`, so it
|
| 113 |
+
// renders the three sections below exactly as it did before this ticket.
|
| 114 |
+
sharedSections = true,
|
| 115 |
}: FieldsHidePanelProps) {
|
| 116 |
const [query, setQuery] = useState("");
|
| 117 |
/** W9 β destructive, so the trash arms first (the grid's confirm pattern): first
|
|
|
|
| 123 |
* that slot. */
|
| 124 |
const [dragKey, setDragKey] = useState<string | null>(null);
|
| 125 |
const [overKey, setOverKey] = useState<string | null>(null);
|
| 126 |
+
/**
|
| 127 |
+
* ββ W40-T16 (owner instruction 13) β HOW A ROW IS NAMED, in one place, for the same reason
|
| 128 |
+
* wave 30's R5 put `fieldLabel` in one place: this panel reads a field's name at SIX sites (the
|
| 129 |
+
* search filter, three grip aria-labels, the visible label and two delete aria-labels), and a
|
| 130 |
+
* name resolved differently at any one of them means what a user SEARCHES and what they READ
|
| 131 |
+
* answer the same question differently.
|
| 132 |
+
*
|
| 133 |
+
* β `fieldLabel` STAYS THE RESOLVER. This adds the metric prefix on top of it and nothing else,
|
| 134 |
+
* so a label-less definition arriving over the wire is still guarded exactly as before.
|
| 135 |
+
* A field with no metric flag comes back byte-identical to `fieldLabel(f)` β which is what
|
| 136 |
+
* keeps the GRID's mount of this panel unchanged, since grid fields never carry the flag.
|
| 137 |
+
*/
|
| 138 |
+
const rowLabel = (f: PermsField): string =>
|
| 139 |
+
f.isMetric ? metricLabel(fieldLabel(f)) : fieldLabel(f);
|
| 140 |
const q = query.trim().toLowerCase();
|
| 141 |
+
const shown = q ? fields.filter((f) => rowLabel(f).toLowerCase().includes(q)) : fields;
|
| 142 |
+
/**
|
| 143 |
+
* ββ W40-T17 (owner instruction 15) β TWO MOUNTS, TWO QUESTIONS, ONE PANEL.
|
| 144 |
+
*
|
| 145 |
+
* The GRID asks "whose column is this?" and answers it with three sections, because a person
|
| 146 |
+
* looking at their own table needs to tell their columns from the tenant's. The PERMISSION
|
| 147 |
+
* EDITOR asks "which columns may an admin set a rule on?", and AM-2 answers that with
|
| 148 |
+
* `metric || !custom` β the database's own pre-set columns, plus every bound measure. They are
|
| 149 |
+
* not the same list narrowed; they are different questions with different answers.
|
| 150 |
+
*
|
| 151 |
+
* β THE THREE-SECTION ARM IS UNCHANGED AND MUST STAY SPELLED EXACTLY THIS WAY.
|
| 152 |
+
* `verify_map.py`'s `private-by-default-is-anchored` leg reads this file for the literal
|
| 153 |
+
* `fieldEditMode(field) === "collaborative"`: it is what pins "Shared with everyone" to the
|
| 154 |
+
* `collaborative` answer, so a route column stamped `edit: personal` is filed as private by
|
| 155 |
+
* rule rather than by luck. Rename the parameter or move the predicate and that gate reports a
|
| 156 |
+
* fact about route columns going stale, one surface away from here.
|
| 157 |
+
*
|
| 158 |
+
* β THE NARROW ARM RENDERS ONE UNTITLED SECTION, which is what the first section has always
|
| 159 |
+
* been. No heading appears and no heading disappears: `section.title` is falsy, so the rows sit
|
| 160 |
+
* directly under the search box exactly as the personal rows do on the grid today.
|
| 161 |
+
*/
|
| 162 |
+
const sections = sharedSections
|
| 163 |
+
? [
|
| 164 |
+
{ key: "personal", title: "", fields: shown.filter((field) =>
|
| 165 |
+
(!field.custom && !field.shared) || fieldEditMode(field) === "personal") },
|
| 166 |
+
{ key: "me", title: "Shared with me", fields: shown.filter((field) =>
|
| 167 |
+
(field.custom || field.shared) && fieldEditMode(field) === "users") },
|
| 168 |
+
{ key: "everyone", title: "Shared with everyone", fields: shown.filter((field) =>
|
| 169 |
+
(field.custom || field.shared) && fieldEditMode(field) === "collaborative") },
|
| 170 |
+
]
|
| 171 |
+
: [{ key: "personal", title: "", fields: presetHideFields(shown) }];
|
| 172 |
/**
|
| 173 |
* Item 23 β reorder is OFF while a search is narrowing the list. Dropping "City" above
|
| 174 |
* "State" in a list that is hiding the eight fields between them means nothing the user can
|
|
|
|
| 285 |
draggable={canReorder && !locked}
|
| 286 |
aria-label={
|
| 287 |
locked
|
| 288 |
+
? `${rowLabel(f)} is always first`
|
| 289 |
: canReorder
|
| 290 |
+
? `Reorder ${rowLabel(f)}: drag, or use the arrow keys`
|
| 291 |
+
: `Reorder ${rowLabel(f)} (clear the search first)`
|
| 292 |
}
|
| 293 |
title={
|
| 294 |
canReorder && !locked
|
|
|
|
| 338 |
draws (icons.tsx). Titled, because this list is where the icon
|
| 339 |
vocabulary is learned. */}
|
| 340 |
<FieldTypeIcon type={f.type} title={TYPE_LABELS[f.type]} />
|
| 341 |
+
<span className="cg-check-label">{rowLabel(f)}</span>
|
| 342 |
{/* β OWNER ITEM 3 (2026-08-06): *"it doesn't show pre-set when I hide fields just
|
| 343 |
like how Odoo fields are. This is always the standard label."* Right β the chip
|
| 344 |
asked `source === "odoo"`, so a column an AUTOMATION fills (source `overlay`)
|
|
|
|
| 359 |
type="button"
|
| 360 |
className={"cg-check-del" + (armed ? " is-armed" : "")}
|
| 361 |
aria-label={
|
| 362 |
+
armed ? `Permanently delete ${rowLabel(f)}?` : `Delete ${rowLabel(f)}`
|
| 363 |
}
|
| 364 |
title={armed ? undefined : "Removes this field and its values on every row."}
|
| 365 |
onClick={(e) => {
|
|
|
|
| 393 |
})}
|
| 394 |
</div>
|
| 395 |
))}
|
| 396 |
+
{/* ββ W40-T17 β GATED ON WHAT THE SECTIONS ACTUALLY HOLD, not on `shown`, and the two
|
| 397 |
+
stopped being the same question the moment a mount could narrow the vocabulary. On the
|
| 398 |
+
narrow arm a query matching only somebody's own columns leaves `shown` non-empty and
|
| 399 |
+
every section EMPTY: a search box with nothing under it and no sentence saying why,
|
| 400 |
+
which reads as a broken panel rather than as a search that found nothing.
|
| 401 |
+
β THE GRID IS UNAFFECTED, BY PARTITION RATHER THAN BY LUCK. `fieldEditMode` answers
|
| 402 |
+
exactly one of `personal` / `users` / `collaborative` (`types.ts::FIELD_EDIT_MODES`),
|
| 403 |
+
and the three predicates above split on that answer, so every field lands in exactly
|
| 404 |
+
one section and `sections.every(empty)` is TRUE precisely when `shown` is empty. Same
|
| 405 |
+
render, same condition, one fewer way to paint a blank.
|
| 406 |
+
β THE SENTENCE IS UNCHANGED, AND THE ONE CASE IT WOULD NOT FIT IS NAMED RATHER THAN
|
| 407 |
+
ASSUMED AWAY. With no query at all, this branch needs a module whose EVERY field is
|
| 408 |
+
`custom` and none a metric. No such module exists: an Odoo topic's columns are the
|
| 409 |
+
contract in `aios_grid.FIELDS`, and a `ut_*` database's are stamped `source: overlay`
|
| 410 |
+
by `core.user_tables._clean_field`, which never writes `custom`. A new copy for a
|
| 411 |
+
state nothing can reach would be a paragraph the empty-state rule (DESIGN.md Β§4)
|
| 412 |
+
exists to refuse; if a wire ever makes it reachable, that is when it earns a line. */}
|
| 413 |
+
{sections.every((section) => section.fields.length === 0) && (
|
| 414 |
<div className="cg-builder-empty">No field matches your search.</div>
|
| 415 |
)}
|
| 416 |
</div>
|
web/src/filter-kit/FilterBuilderPanel.tsx
CHANGED
|
@@ -36,6 +36,14 @@ import type { AnchorMode } from "../customer-grid/windows";
|
|
| 36 |
import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
|
| 37 |
import { FieldSelectButton } from "../customer-grid/FieldSelect";
|
| 38 |
import type { FieldSelectItem } from "../customer-grid/FieldSelect";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
import { WindowPicker } from "./WindowPicker";
|
| 40 |
import {
|
| 41 |
COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
|
|
@@ -543,6 +551,18 @@ function LeafRow({
|
|
| 543 |
// C4 β the view-membership leaf, on exactly the terms the cohort leaf above it runs.
|
| 544 |
const isView = rule.colId === VIEW_FIELD;
|
| 545 |
const hasViews = viewChoices.length > 0;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
const t = isMeasure
|
| 547 |
? measureByKey.get(rule.colId)?.type ?? "currency"
|
| 548 |
: fieldByKey.get(rule.colId)?.type ?? "text";
|
|
@@ -639,6 +659,27 @@ function LeafRow({
|
|
| 639 |
const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
|
| 640 |
const plainOps = offeredOps.filter((op) => !isRankOp(op));
|
| 641 |
const rankedOps = offeredOps.filter((op) => isRankOp(op));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 642 |
|
| 643 |
return (
|
| 644 |
<span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
|
|
@@ -650,12 +691,13 @@ function LeafRow({
|
|
| 650 |
adds the type MARK to each row and takes NO grouping with it: the marks are what the
|
| 651 |
optgroup was reaching for, and they say it per row instead of splitting the list. */}
|
| 652 |
<FieldSelectButton
|
| 653 |
-
className=
|
| 654 |
-
ariaLabel=
|
|
|
|
| 655 |
value={rule.colId}
|
| 656 |
onChange={onField}
|
| 657 |
fields={[
|
| 658 |
-
...
|
| 659 |
...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
|
| 660 |
// A cohort leaf asks about membership of a SET, which is the multiselect mark's
|
| 661 |
// own meaning β the vocabulary already had the right glyph for it.
|
|
|
|
| 36 |
import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
|
| 37 |
import { FieldSelectButton } from "../customer-grid/FieldSelect";
|
| 38 |
import type { FieldSelectItem } from "../customer-grid/FieldSelect";
|
| 39 |
+
// β W40-T18 β the deleted-column vocabulary. Same direction `filter-kit/FieldsHidePanel.tsx`
|
| 40 |
+
// already imports `metricLabel` / `presetHideFields` from: `settings/permsModel` is a PURE model
|
| 41 |
+
// module with no runtime import of its own, it is already in the grid bundle because that panel
|
| 42 |
+
// mounts in the grid toolbar, and putting these three beside it is what lets `perms.test.ts`
|
| 43 |
+
// assert them under bare node β nothing in this file can be imported by that harness.
|
| 44 |
+
import {
|
| 45 |
+
DELETED_FIELD_NOTE, isDeletedFieldRef, withDeletedFieldLabel,
|
| 46 |
+
} from "../settings/permsModel";
|
| 47 |
import { WindowPicker } from "./WindowPicker";
|
| 48 |
import {
|
| 49 |
COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
|
|
|
|
| 551 |
// C4 β the view-membership leaf, on exactly the terms the cohort leaf above it runs.
|
| 552 |
const isView = rule.colId === VIEW_FIELD;
|
| 553 |
const hasViews = viewChoices.length > 0;
|
| 554 |
+
// ββ W40-T18 (owner instruction 16) β THE CONDITION WHOSE COLUMN IS GONE.
|
| 555 |
+
//
|
| 556 |
+
// `withCurrentField` resolves the current key against `fieldByKey`, the COMPLETE list, and
|
| 557 |
+
// appends whatever it finds so a narrowed picker never orphans history. When it finds nothing
|
| 558 |
+
// it appends the RAW KEY as the label. That row is the defect: `custom_1723489` reads as a
|
| 559 |
+
// field name, and an admin cannot tell a deleted column from a badly named one.
|
| 560 |
+
//
|
| 561 |
+
// β MEASURE-SHAPED RULES ARE EXCLUDED. `isMeasureRule` is a test of the rule's SHAPE (it
|
| 562 |
+
// carries a window), so a measure whose key has gone would otherwise be named a deleted
|
| 563 |
+
// COLUMN, which it is not. It keeps today's rendering; see the handoff.
|
| 564 |
+
const goneColumn = !isMeasure
|
| 565 |
+
&& isDeletedFieldRef(rule.colId, fieldByKey, measureByKey, [COHORT_FIELD, VIEW_FIELD]);
|
| 566 |
const t = isMeasure
|
| 567 |
? measureByKey.get(rule.colId)?.type ?? "currency"
|
| 568 |
: fieldByKey.get(rule.colId)?.type ?? "text";
|
|
|
|
| 659 |
const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
|
| 660 |
const plainOps = offeredOps.filter((op) => !isRankOp(op));
|
| 661 |
const rankedOps = offeredOps.filter((op) => isRankOp(op));
|
| 662 |
+
// ββ W40-T18 β HOW A GONE COLUMN READS, in three parts and no new CSS.
|
| 663 |
+
//
|
| 664 |
+
// (1) THE ROW. `withCurrentField` has already synthesised a picker row labelled with the raw
|
| 665 |
+
// key; `withDeletedFieldLabel` rewrites that one row to the word and moves the key into
|
| 666 |
+
// the muted `hint` line `FieldSelect` paints under a label. Nothing is removed from the
|
| 667 |
+
// list and no condition is dropped: this panel edits live permission records, and a rule
|
| 668 |
+
// that vanishes on render is a rule nobody decided to delete.
|
| 669 |
+
const offeredFields = goneColumn
|
| 670 |
+
? withDeletedFieldLabel(
|
| 671 |
+
withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts), rule.colId)
|
| 672 |
+
: withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts);
|
| 673 |
+
// (2) THE MARK. `is-missing` is index.css's OWN state for "set to something this table cannot
|
| 674 |
+
// offer" β amber border, amber label β and it is APPENDED rather than written fresh
|
| 675 |
+
// because this panel also mounts in the grid toolbar, while every lane-G stylesheet loads
|
| 676 |
+
// with the settings modal alone. The button paints that state unaided when a value
|
| 677 |
+
// resolves to no row; it cannot here, precisely because a row was synthesised for it, so
|
| 678 |
+
// the caller says it instead.
|
| 679 |
+
const fieldClass = "cg-cond-field" + (goneColumn ? " is-missing" : "");
|
| 680 |
+
// (3) THE NAME A SCREEN READER HEARS, which `aria-label` supplies INSTEAD of the visible
|
| 681 |
+
// label β so the state and the key both belong in it or neither is announced at all.
|
| 682 |
+
const fieldAria = goneColumn ? `Field, deleted column ${rule.colId}` : "Field";
|
| 683 |
|
| 684 |
return (
|
| 685 |
<span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
|
|
|
|
| 691 |
adds the type MARK to each row and takes NO grouping with it: the marks are what the
|
| 692 |
optgroup was reaching for, and they say it per row instead of splitting the list. */}
|
| 693 |
<FieldSelectButton
|
| 694 |
+
className={fieldClass}
|
| 695 |
+
ariaLabel={fieldAria}
|
| 696 |
+
title={goneColumn ? DELETED_FIELD_NOTE : undefined}
|
| 697 |
value={rule.colId}
|
| 698 |
onChange={onField}
|
| 699 |
fields={[
|
| 700 |
+
...offeredFields,
|
| 701 |
...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
|
| 702 |
// A cohort leaf asks about membership of a SET, which is the multiselect mark's
|
| 703 |
// own meaning β the vocabulary already had the right glyph for it.
|
web/src/filter-kit/ops.ts
CHANGED
|
@@ -1,400 +1,426 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// filter-kit / ops.ts
|
| 3 |
-
// The filter/sort operator VOCABULARY β which operators each field type offers,
|
| 4 |
-
// and how each one reads to a human.
|
| 5 |
-
//
|
| 6 |
-
// Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
|
| 7 |
-
// the permission editor and the grid toolbar offer ONE vocabulary rather than two
|
| 8 |
-
// that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
|
| 9 |
-
// and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
|
| 10 |
-
// the same contract; these are only the labels the popovers render.
|
| 11 |
-
// ---------------------------------------------------------------------------
|
| 12 |
-
|
| 13 |
-
import type {
|
| 14 |
-
Conjunction,
|
| 15 |
-
Field,
|
| 16 |
-
FieldType,
|
| 17 |
-
FilterNode,
|
| 18 |
-
FilterOp,
|
| 19 |
-
FilterRule,
|
| 20 |
-
Measure,
|
| 21 |
-
} from "../customer-grid/types";
|
| 22 |
-
import {
|
| 23 |
-
COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
|
| 24 |
-
isDateFamilyType, isNumericFieldType, isRankOp, normalizeCohortOp,
|
| 25 |
-
} from "../customer-grid/types";
|
| 26 |
-
import type { CohortOp } from "../customer-grid/types";
|
| 27 |
-
import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
|
| 28 |
-
import type { WindowKind, WindowSpec } from "../customer-grid/windows";
|
| 29 |
-
|
| 30 |
-
// The type families live in types.ts (ONE definition for every UI surface); the engine keeps
|
| 31 |
-
// its own deliberately self-contained copies β see isNumericFieldType's doc.
|
| 32 |
-
export const isNumericType = isNumericFieldType;
|
| 33 |
-
export const isDateFamily = isDateFamilyType;
|
| 34 |
-
|
| 35 |
-
/**
|
| 36 |
-
* Operators offered for a field type (first = the default for new conditions).
|
| 37 |
-
* Mirrors Airtable's own per-type sets, verified against the live product
|
| 38 |
-
* (2026-07-25): a text field offers exactly contains / does not contain / is /
|
| 39 |
-
* is not / is empty / is not empty. Every type ends with the two value-free ops.
|
| 40 |
-
*
|
| 41 |
-
* NOTE on isEmpty over the Customer List contract: several Odoo-derived money
|
| 42 |
-
* columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
|
| 43 |
-
* built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
|
| 44 |
-
* matches NOTHING there β 0 is a real value, not a blank. The columns where it
|
| 45 |
-
* genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
|
| 46 |
-
* every overlay field (notes / custom) β "customers with no notes yet" is the
|
| 47 |
-
* common case. Offered on all types anyway, exactly as Airtable does.
|
| 48 |
-
*
|
| 49 |
-
* β AND the SENTINEL columns, which are a third case and the one that surprises:
|
| 50 |
-
* agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
|
| 51 |
-
* collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
|
| 52 |
-
* null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
|
| 53 |
-
* though 76 customers have no country and 79 no zip β the blanks are found with
|
| 54 |
-
* `is` `(none)`, which is also what the cell visibly says. Each of those fields
|
| 55 |
-
* carries that sentence as its column `note`, because a source comment is not
|
| 56 |
-
* where the person filtering is looking. DATE attributes keep '' instead
|
| 57 |
-
* (customer_since, last_order): '(none)' in a date column would sort and compare
|
| 58 |
-
* as text against ISO dates.
|
| 59 |
-
*/
|
| 60 |
-
export function opsForType(t: FieldType): FilterOp[] {
|
| 61 |
-
// Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
|
| 62 |
-
// unchecked" IS `isEmpty` β the honest mapping onto the existing vocabulary (no new
|
| 63 |
-
// FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
|
| 64 |
-
// renders. First = the default: "is checked".
|
| 65 |
-
if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
|
| 66 |
-
if (isNumericType(t))
|
| 67 |
-
return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
|
| 68 |
-
if (isDateFamily(t))
|
| 69 |
-
// Owner item 3, in the order they asked for it. `between` is GONE from the offer β "is
|
| 70 |
-
// within" plus a custom range says the same thing and reads better β but it stays in the
|
| 71 |
-
// ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
|
| 72 |
-
// rendering its own operator instead of silently showing the first one in this list.
|
| 73 |
-
return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
|
| 74 |
-
// `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
|
| 75 |
-
// not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β
|
| 76 |
-
// it would find "Done" inside "Not done" and read as a bug.
|
| 77 |
-
if (t === "status" || t === "select" || t === "user")
|
| 78 |
-
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
| 79 |
-
// A `multiselect` cell is a comma-joined SET, so the useful question is membership β "has" β
|
| 80 |
-
// which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
|
| 81 |
-
// ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
|
| 82 |
-
// value is still picked from the declared options, so the substring caveat above is bounded
|
| 83 |
-
// by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
|
| 84 |
-
if (t === "multiselect")
|
| 85 |
-
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
|
| 86 |
-
// β Wave-23 C7 β `json` gets EXACTLY the two value-free ops, and the omissions are the
|
| 87 |
-
// decision. `contains` over a serialized document is a substring test against punctuation and
|
| 88 |
-
// key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
|
| 89 |
-
// fragment of 5120, and every one of those reads as a working filter. `eq` is worse β two
|
| 90 |
-
// documents that mean the same thing differ by key order and whitespace, so "is" would answer
|
| 91 |
-
// false for a record that plainly matches. "Has anything been captured here yet" is the
|
| 92 |
-
// question a json column can answer soundly, and it is the one people actually ask.
|
| 93 |
-
//
|
| 94 |
-
// β NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
|
| 95 |
-
// `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
|
| 96 |
-
// are TYPE-INDEPENDENT β they test blankness before any type dispatch β so the lock-step is
|
| 97 |
-
// untouched and nothing on the server had to move for this line. (The contract's prose spells
|
| 98 |
-
// them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
|
| 99 |
-
if (t === "json") return ["isNotEmpty", "isEmpty"];
|
| 100 |
-
// Wave-18 C5-AUTOFIELD β `automation` lands HERE, on the text ops, and that is the whole
|
| 101 |
-
// v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
|
| 102 |
-
// `contains ok` and `contains error` are the two questions anyone actually asks of the
|
| 103 |
-
// column, and both are already sound over a string. Nothing new enters the operator
|
| 104 |
-
// vocabulary, so the `filter_sql.py` lock-step is untouched β a new op for "state is" would
|
| 105 |
-
// have to be mirrored in the Python engine, and the substring answer is the same answer.
|
| 106 |
-
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
/**
|
| 110 |
-
* C-OPS β the RANK operators, worded so the row still reads as a sentence.
|
| 111 |
-
*
|
| 112 |
-
* The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
|
| 113 |
-
* between ten records and ten percent, so `top N` and `top N%` are two operators and the box
|
| 114 |
-
* supplies N. The alternative β a "%" suffix element after the input β would put a second
|
| 115 |
-
* thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
|
| 116 |
-
*
|
| 117 |
-
* β AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
|
| 118 |
-
* `cohortOpLabel` below, and `withCurrentField` below) β about 15 characters of 12.5px Inter.
|
| 119 |
-
* "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
|
| 120 |
-
* and no screenshot in this session could catch. Dropping the "is " costs nothing
|
| 121 |
-
*
|
| 122 |
-
*
|
| 123 |
-
*
|
| 124 |
-
*
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
*
|
| 143 |
-
*
|
| 144 |
-
*
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
case "
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
β
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
case "
|
| 217 |
-
return "
|
| 218 |
-
case "
|
| 219 |
-
return "
|
| 220 |
-
case "
|
| 221 |
-
return "
|
| 222 |
-
case "
|
| 223 |
-
return "is
|
| 224 |
-
|
| 225 |
-
return
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
case "
|
| 237 |
-
return "
|
| 238 |
-
case "
|
| 239 |
-
return "
|
| 240 |
-
|
| 241 |
-
return
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
*
|
| 289 |
-
*
|
| 290 |
-
* `
|
| 291 |
-
*
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
*
|
| 296 |
-
export
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
*
|
| 309 |
-
*
|
| 310 |
-
*
|
| 311 |
-
*/
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
*
|
| 335 |
-
*
|
| 336 |
-
*
|
| 337 |
-
*
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
/**
|
| 350 |
-
*
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
export function
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// filter-kit / ops.ts
|
| 3 |
+
// The filter/sort operator VOCABULARY β which operators each field type offers,
|
| 4 |
+
// and how each one reads to a human.
|
| 5 |
+
//
|
| 6 |
+
// Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
|
| 7 |
+
// the permission editor and the grid toolbar offer ONE vocabulary rather than two
|
| 8 |
+
// that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
|
| 9 |
+
// and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
|
| 10 |
+
// the same contract; these are only the labels the popovers render.
|
| 11 |
+
// ---------------------------------------------------------------------------
|
| 12 |
+
|
| 13 |
+
import type {
|
| 14 |
+
Conjunction,
|
| 15 |
+
Field,
|
| 16 |
+
FieldType,
|
| 17 |
+
FilterNode,
|
| 18 |
+
FilterOp,
|
| 19 |
+
FilterRule,
|
| 20 |
+
Measure,
|
| 21 |
+
} from "../customer-grid/types";
|
| 22 |
+
import {
|
| 23 |
+
COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
|
| 24 |
+
isDateFamilyType, isNumericFieldType, isRankOp, normalizeCohortOp,
|
| 25 |
+
} from "../customer-grid/types";
|
| 26 |
+
import type { CohortOp } from "../customer-grid/types";
|
| 27 |
+
import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
|
| 28 |
+
import type { WindowKind, WindowSpec } from "../customer-grid/windows";
|
| 29 |
+
|
| 30 |
+
// The type families live in types.ts (ONE definition for every UI surface); the engine keeps
|
| 31 |
+
// its own deliberately self-contained copies β see isNumericFieldType's doc.
|
| 32 |
+
export const isNumericType = isNumericFieldType;
|
| 33 |
+
export const isDateFamily = isDateFamilyType;
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Operators offered for a field type (first = the default for new conditions).
|
| 37 |
+
* Mirrors Airtable's own per-type sets, verified against the live product
|
| 38 |
+
* (2026-07-25): a text field offers exactly contains / does not contain / is /
|
| 39 |
+
* is not / is empty / is not empty. Every type ends with the two value-free ops.
|
| 40 |
+
*
|
| 41 |
+
* NOTE on isEmpty over the Customer List contract: several Odoo-derived money
|
| 42 |
+
* columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
|
| 43 |
+
* built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
|
| 44 |
+
* matches NOTHING there β 0 is a real value, not a blank. The columns where it
|
| 45 |
+
* genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
|
| 46 |
+
* every overlay field (notes / custom) β "customers with no notes yet" is the
|
| 47 |
+
* common case. Offered on all types anyway, exactly as Airtable does.
|
| 48 |
+
*
|
| 49 |
+
* β AND the SENTINEL columns, which are a third case and the one that surprises:
|
| 50 |
+
* agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
|
| 51 |
+
* collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
|
| 52 |
+
* null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
|
| 53 |
+
* though 76 customers have no country and 79 no zip β the blanks are found with
|
| 54 |
+
* `is` `(none)`, which is also what the cell visibly says. Each of those fields
|
| 55 |
+
* carries that sentence as its column `note`, because a source comment is not
|
| 56 |
+
* where the person filtering is looking. DATE attributes keep '' instead
|
| 57 |
+
* (customer_since, last_order): '(none)' in a date column would sort and compare
|
| 58 |
+
* as text against ISO dates.
|
| 59 |
+
*/
|
| 60 |
+
export function opsForType(t: FieldType): FilterOp[] {
|
| 61 |
+
// Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
|
| 62 |
+
// unchecked" IS `isEmpty` β the honest mapping onto the existing vocabulary (no new
|
| 63 |
+
// FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
|
| 64 |
+
// renders. First = the default: "is checked".
|
| 65 |
+
if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
|
| 66 |
+
if (isNumericType(t))
|
| 67 |
+
return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
|
| 68 |
+
if (isDateFamily(t))
|
| 69 |
+
// Owner item 3, in the order they asked for it. `between` is GONE from the offer β "is
|
| 70 |
+
// within" plus a custom range says the same thing and reads better β but it stays in the
|
| 71 |
+
// ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
|
| 72 |
+
// rendering its own operator instead of silently showing the first one in this list.
|
| 73 |
+
return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
|
| 74 |
+
// `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
|
| 75 |
+
// not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β
|
| 76 |
+
// it would find "Done" inside "Not done" and read as a bug.
|
| 77 |
+
if (t === "status" || t === "select" || t === "user")
|
| 78 |
+
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
| 79 |
+
// A `multiselect` cell is a comma-joined SET, so the useful question is membership β "has" β
|
| 80 |
+
// which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
|
| 81 |
+
// ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
|
| 82 |
+
// value is still picked from the declared options, so the substring caveat above is bounded
|
| 83 |
+
// by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
|
| 84 |
+
if (t === "multiselect")
|
| 85 |
+
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
|
| 86 |
+
// β Wave-23 C7 β `json` gets EXACTLY the two value-free ops, and the omissions are the
|
| 87 |
+
// decision. `contains` over a serialized document is a substring test against punctuation and
|
| 88 |
+
// key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
|
| 89 |
+
// fragment of 5120, and every one of those reads as a working filter. `eq` is worse β two
|
| 90 |
+
// documents that mean the same thing differ by key order and whitespace, so "is" would answer
|
| 91 |
+
// false for a record that plainly matches. "Has anything been captured here yet" is the
|
| 92 |
+
// question a json column can answer soundly, and it is the one people actually ask.
|
| 93 |
+
//
|
| 94 |
+
// β NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
|
| 95 |
+
// `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
|
| 96 |
+
// are TYPE-INDEPENDENT β they test blankness before any type dispatch β so the lock-step is
|
| 97 |
+
// untouched and nothing on the server had to move for this line. (The contract's prose spells
|
| 98 |
+
// them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
|
| 99 |
+
if (t === "json") return ["isNotEmpty", "isEmpty"];
|
| 100 |
+
// Wave-18 C5-AUTOFIELD β `automation` lands HERE, on the text ops, and that is the whole
|
| 101 |
+
// v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
|
| 102 |
+
// `contains ok` and `contains error` are the two questions anyone actually asks of the
|
| 103 |
+
// column, and both are already sound over a string. Nothing new enters the operator
|
| 104 |
+
// vocabulary, so the `filter_sql.py` lock-step is untouched β a new op for "state is" would
|
| 105 |
+
// have to be mirrored in the Python engine, and the substring answer is the same answer.
|
| 106 |
+
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* C-OPS β the RANK operators, worded so the row still reads as a sentence.
|
| 111 |
+
*
|
| 112 |
+
* The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
|
| 113 |
+
* between ten records and ten percent, so `top N` and `top N%` are two operators and the box
|
| 114 |
+
* supplies N. The alternative β a "%" suffix element after the input β would put a second
|
| 115 |
+
* thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
|
| 116 |
+
*
|
| 117 |
+
* β AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
|
| 118 |
+
* `cohortOpLabel` below, and `withCurrentField` below) β about 15 characters of 12.5px Inter.
|
| 119 |
+
* "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
|
| 120 |
+
* and no screenshot in this session could catch. Dropping the "is " still costs nothing, and
|
| 121 |
+
* instruction 19 SHARPENED that argument rather than refuting it: the four numeric comparisons
|
| 122 |
+
* beside these were bare glyphs (`β₯ > β€ <`) and are now prefix-free WORDS ("more than",
|
| 123 |
+
* "less than or equal to"), so the row still reads without an "is " β and those words are
|
| 124 |
+
* longer than the glyphs they replaced, so this control has LESS spare room than when the
|
| 125 |
+
* width above was measured, not more. (Only those four are prefix-free; `eq` / `neq` /
|
| 126 |
+
* `between` still read "is" / "is not" / "is between", and the rank ops sit beside all of
|
| 127 |
+
* them.) The `Ranked` divider carries the "these are a different kind of question" signal.
|
| 128 |
+
* Fifth time this control has outgrown a label; the label gives way, never the control.
|
| 129 |
+
*/
|
| 130 |
+
export const RANK_OP_LABELS: Record<string, string> = {
|
| 131 |
+
topN: "top N",
|
| 132 |
+
bottomN: "bottom N",
|
| 133 |
+
inTopPct: "top N%",
|
| 134 |
+
inBottomPct: "bottom N%",
|
| 135 |
+
aboveAvg: "above average",
|
| 136 |
+
belowAvg: "below average",
|
| 137 |
+
inQuartile: "in quartile",
|
| 138 |
+
inDecile: "in decile",
|
| 139 |
+
};
|
| 140 |
+
|
| 141 |
+
/**
|
| 142 |
+
* The value a rank op starts at when it is chosen.
|
| 143 |
+
*
|
| 144 |
+
* Seeded rather than left blank, and this is a deliberate break from how the other operators
|
| 145 |
+
* behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
|
| 146 |
+
* sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
|
| 147 |
+
* broken control rather than as an unfinished sentence. Every rank op has an obvious default β
|
| 148 |
+
* ten, or the top slice β so there is a right answer to seed. The two averages take none.
|
| 149 |
+
*/
|
| 150 |
+
export function rankDefault(op: FilterOp): string {
|
| 151 |
+
switch (op) {
|
| 152 |
+
case "topN":
|
| 153 |
+
case "bottomN":
|
| 154 |
+
case "inTopPct":
|
| 155 |
+
case "inBottomPct":
|
| 156 |
+
case "inDecile":
|
| 157 |
+
return "10";
|
| 158 |
+
case "inQuartile":
|
| 159 |
+
return "4";
|
| 160 |
+
default:
|
| 161 |
+
return ""; // aboveAvg / belowAvg β value-free
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
/* β `hasRankCondition` LIVED HERE AND IS DELETED WITH THE PARAGRAPH IT GATED (wave 34, R5).
|
| 166 |
+
Its whole job was to decide whether to draw the builder's rank explainer; the owner has
|
| 167 |
+
removed that paragraph, and the predicate had exactly one caller besides its own recursion.
|
| 168 |
+
β `isRankOp` stays: `opLabel` below still calls it. `isMeasureRule` was `hasRankCondition`'s
|
| 169 |
+
ONLY use in this module, so THIS FILE'S IMPORT of it went too β the symbol itself lives on in
|
| 170 |
+
`types.ts` with plenty of callers, including `FilterBuilderPanel` two files over. The
|
| 171 |
+
distinction matters: an unused IMPORT is dead weight here, an unused EXPORT would be dead code
|
| 172 |
+
there, and only one of those was true.
|
| 173 |
+
β β THIS COMMENT'S FIRST DRAFT SAID BOTH SYMBOLS "STAY, they have many callers" β and `tsc`
|
| 174 |
+
refuted it in the next run. That is the THIRD time in one session a confident claim about who
|
| 175 |
+
calls a symbol was wrong within a minute of writing it. The habit worth keeping is not "be more
|
| 176 |
+
careful"; it is that the compiler answers this question for free, so ask it before asserting. */
|
| 177 |
+
|
| 178 |
+
/**
|
| 179 |
+
* Human label for an operator, read naturally per field type.
|
| 180 |
+
*
|
| 181 |
+
* β Instruction 19 (wave 40) β a numeric comparison reads as WORDS, never as a symbol, because
|
| 182 |
+
* "non-mathematical people get them confused". C5 makes this the ONE operator-label map, so the
|
| 183 |
+
* grid filter builder, the permission filter builder, view filters and measure conditions all
|
| 184 |
+
* changed together and no surface hand-writes an operator string of its own. (A measure
|
| 185 |
+
* condition needs no separate map: `FilterBuilderPanel` resolves its type from the measure
|
| 186 |
+
* itself and falls back to `currency`, and `MEASURE_OPS` offers exactly the six comparisons
|
| 187 |
+
* below, so a measure row reads its label off this same numeric branch.)
|
| 188 |
+
*
|
| 189 |
+
* β NO leading "is " on the four comparisons, unlike `automation/CondBuilder.tsx::OP_WORDS`
|
| 190 |
+
* whose voice otherwise sets the house standard here. These labels already outrun the control
|
| 191 |
+
* that renders them; W40-T30 widens `.cg-select` in `index.css` for "less than or equal to"
|
| 192 |
+
* exactly, and an "is " prefix would outrun the wider control too. This file does not own that
|
| 193 |
+
* width, and it must not buy itself slack by abbreviating: "at least" / "at most" are shorter
|
| 194 |
+
* and are precisely the shorthand instruction 19 exists to remove.
|
| 195 |
+
*
|
| 196 |
+
* `gte` is "more than or equal to", not "greater than or equal to", so one axis carries one
|
| 197 |
+
* vocabulary. "more than" and "less than" are the owner's own two examples; a set learned once
|
| 198 |
+
* beats two synonyms for the same direction, which is the whole point of the instruction.
|
| 199 |
+
*/
|
| 200 |
+
export function opLabel(op: FilterOp, t: FieldType): string {
|
| 201 |
+
// Rank first and type-independently: a rank op means the same thing on every numeric
|
| 202 |
+
// family, and a view whose column has since changed type must still SAY what it holds
|
| 203 |
+
// rather than falling through to a raw key.
|
| 204 |
+
if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
|
| 205 |
+
// Checkbox first: its two value-free ops READ as states, not as blankness β the storage
|
| 206 |
+
// contract ('1' or blank) is an implementation detail the sentence must not leak.
|
| 207 |
+
if (t === "checkbox") {
|
| 208 |
+
if (op === "isNotEmpty") return "is checked";
|
| 209 |
+
if (op === "isEmpty") return "is unchecked";
|
| 210 |
+
}
|
| 211 |
+
// Type-independent: Airtable words these identically for every field type.
|
| 212 |
+
if (op === "isEmpty") return "is empty";
|
| 213 |
+
if (op === "isNotEmpty") return "is not empty";
|
| 214 |
+
if (isDateFamily(t)) {
|
| 215 |
+
switch (op) {
|
| 216 |
+
case "eq":
|
| 217 |
+
return "is";
|
| 218 |
+
case "neq":
|
| 219 |
+
return "is not";
|
| 220 |
+
case "within":
|
| 221 |
+
return "is within";
|
| 222 |
+
case "lt":
|
| 223 |
+
return "is before";
|
| 224 |
+
case "lte":
|
| 225 |
+
return "is on or before";
|
| 226 |
+
case "gt":
|
| 227 |
+
return "is after";
|
| 228 |
+
case "gte":
|
| 229 |
+
return "is on or after";
|
| 230 |
+
case "between":
|
| 231 |
+
return "is between"; // no longer offered; still rendered for a saved view
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
if (isNumericType(t)) {
|
| 235 |
+
switch (op) {
|
| 236 |
+
case "gte":
|
| 237 |
+
return "more than or equal to";
|
| 238 |
+
case "gt":
|
| 239 |
+
return "more than";
|
| 240 |
+
case "lte":
|
| 241 |
+
return "less than or equal to";
|
| 242 |
+
case "lt":
|
| 243 |
+
return "less than";
|
| 244 |
+
case "eq":
|
| 245 |
+
return "is";
|
| 246 |
+
case "neq":
|
| 247 |
+
return "is not";
|
| 248 |
+
case "between":
|
| 249 |
+
return "is between";
|
| 250 |
+
default:
|
| 251 |
+
return op;
|
| 252 |
+
}
|
| 253 |
+
}
|
| 254 |
+
if (t === "multiselect") {
|
| 255 |
+
// Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β only
|
| 256 |
+
// the label changes, so the engine and its Python mirror stay untouched.
|
| 257 |
+
switch (op) {
|
| 258 |
+
case "contains":
|
| 259 |
+
return "has";
|
| 260 |
+
case "doesNotContain":
|
| 261 |
+
return "does not have";
|
| 262 |
+
case "eq":
|
| 263 |
+
return "is exactly";
|
| 264 |
+
case "neq":
|
| 265 |
+
return "is not exactly";
|
| 266 |
+
default:
|
| 267 |
+
return op;
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
// text / status. (Airtable suffixes value-taking ops with "β¦" in the OPEN
|
| 271 |
+
// dropdown only; a native <select> shows one string in both states, so we use
|
| 272 |
+
// the closed-state wording β which is what's on screen almost all the time.)
|
| 273 |
+
switch (op) {
|
| 274 |
+
case "contains":
|
| 275 |
+
return "contains";
|
| 276 |
+
case "doesNotContain":
|
| 277 |
+
return "does not contain";
|
| 278 |
+
case "eq":
|
| 279 |
+
return "is";
|
| 280 |
+
case "neq":
|
| 281 |
+
return "is not";
|
| 282 |
+
default:
|
| 283 |
+
return op;
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
/**
|
| 288 |
+
* The offered list, PLUS whatever the rule is actually set to.
|
| 289 |
+
*
|
| 290 |
+
* A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β
|
| 291 |
+
* so a saved view holding a dropped operator (a date `between`) or a column that has since left
|
| 292 |
+
* the filter list (`at_risk`, once its measure replacement shipped) would display something the
|
| 293 |
+
* rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
|
| 294 |
+
* and wrong: the class of failure a screenshot catches and an assertion does not.
|
| 295 |
+
*/
|
| 296 |
+
export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
|
| 297 |
+
return current && !offered.includes(current) ? [...offered, current] : [...offered];
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/** A group's summary line, mirroring Airtable's wording. */
|
| 301 |
+
export function groupSummary(conj: Conjunction): string {
|
| 302 |
+
return conj === "or"
|
| 303 |
+
? "Any of the following are trueβ¦"
|
| 304 |
+
: "All of the following are trueβ¦";
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
/**
|
| 308 |
+
* CG-8 β the operators a MEASURE condition may use, and why they are not `opsForType`.
|
| 309 |
+
*
|
| 310 |
+
* A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
|
| 311 |
+
* comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
|
| 312 |
+
* that is defined to be 0 when there is nothing to add up β the resolver refuses all three by
|
| 313 |
+
* name. Offering them here would produce a condition the server can only reject.
|
| 314 |
+
*
|
| 315 |
+
* The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
|
| 316 |
+
* `custom` window kind β so nothing is lost.
|
| 317 |
+
*/
|
| 318 |
+
export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
|
| 319 |
+
|
| 320 |
+
/** CG-9 β narrower still when the other side is a measure. `=` and `β ` between two sums of
|
| 321 |
+
* floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
|
| 322 |
+
export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
|
| 323 |
+
|
| 324 |
+
/**
|
| 325 |
+
* A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
|
| 326 |
+
* operators. `eq`/`neq` are not offered β the validator rewrites them to `anyOf`/`noneOf`, and
|
| 327 |
+
* `withCurrent` would otherwise show a legacy view an operator it can never choose again.
|
| 328 |
+
*/
|
| 329 |
+
export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
|
| 330 |
+
|
| 331 |
+
/**
|
| 332 |
+
* β "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
|
| 333 |
+
* 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β inside the box only
|
| 334 |
+
* until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
|
| 335 |
+
* says the same thing, so the LABEL gives way, not the control. Third time this rule has been
|
| 336 |
+
* applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
|
| 337 |
+
*/
|
| 338 |
+
export function cohortOpLabel(op: FilterOp | CohortOp): string {
|
| 339 |
+
switch (normalizeCohortOp(op)) {
|
| 340 |
+
case "allOf":
|
| 341 |
+
return "is all of";
|
| 342 |
+
case "noneOf":
|
| 343 |
+
return "is none of";
|
| 344 |
+
default:
|
| 345 |
+
return "is any of";
|
| 346 |
+
}
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
/** Item 20 β the mark a MEASURE row wears in every picker on this surface. Named once so the
|
| 350 |
+
* three lists that offer measures cannot drift into two different glyphs for one concept. */
|
| 351 |
+
export const MEASURE_MARK: FieldSelectType = "measure";
|
| 352 |
+
|
| 353 |
+
/** A measure condition is identified by its window, so a new one needs a default window. */
|
| 354 |
+
export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
|
| 355 |
+
|
| 356 |
+
/** The default RANGE for `is within` β the owner's first example ("the past month"). */
|
| 357 |
+
export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
|
| 358 |
+
|
| 359 |
+
/**
|
| 360 |
+
* The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
|
| 361 |
+
* the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
|
| 362 |
+
* week Β· this calendar month Β· this calendar year.
|
| 363 |
+
*
|
| 364 |
+
* Plus `custom`, which is not a nicety: dropping `between` from the date operators would
|
| 365 |
+
* otherwise remove the ability to ask for two exact dates at all. The remaining kinds
|
| 366 |
+
* (yesterday, last_quarter, ytd_last_year, the forward-looking onesβ¦) stay available to a
|
| 367 |
+
* MEASURE's window, where they make sense; "last order is within year to date, last year" is a
|
| 368 |
+
* question nobody asks.
|
| 369 |
+
*/
|
| 370 |
+
export const WITHIN_KINDS: WindowKind[] = [
|
| 371 |
+
"last_n_days", "past_week", "past_month", "past_year",
|
| 372 |
+
"this_week", "this_month", "this_year", "custom",
|
| 373 |
+
];
|
| 374 |
+
|
| 375 |
+
/**
|
| 376 |
+
* The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
|
| 377 |
+
* no longer offers it. See `withCurrent` β a select whose value is missing from its options
|
| 378 |
+
* silently displays a different field than the rule holds. This is not hypothetical: the
|
| 379 |
+
* shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
|
| 380 |
+
* replacement arrived.
|
| 381 |
+
*/
|
| 382 |
+
export function withCurrentField(
|
| 383 |
+
fields: Field[],
|
| 384 |
+
current: string,
|
| 385 |
+
fieldByKey: Map<string, Field>,
|
| 386 |
+
measureByKey: Map<string, Measure>,
|
| 387 |
+
hasCohorts: boolean
|
| 388 |
+
): FieldSelectItem[] {
|
| 389 |
+
// Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
|
| 390 |
+
// list, same order, same `withCurrent` rule β only the shape of a row grew a field.
|
| 391 |
+
const out: FieldSelectItem[] = fields.map((f) => ({
|
| 392 |
+
key: f.key, label: f.label, type: f.type,
|
| 393 |
+
}));
|
| 394 |
+
const known = new Set(out.map((o) => o.key));
|
| 395 |
+
if (!current || known.has(current) || measureByKey.has(current)) return out;
|
| 396 |
+
if (current === COHORT_FIELD && hasCohorts) return out;
|
| 397 |
+
// The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
|
| 398 |
+
// suffix CLIPPED to "At risk $ (not filter" β a truncated parenthetical reads as a rendering
|
| 399 |
+
// bug, and the property that matters is that the condition says what it says. Found by
|
| 400 |
+
// reading the screenshot; every assertion in the run was green.
|
| 401 |
+
const f = fieldByKey.get(current);
|
| 402 |
+
out.push({ key: current, label: f ? f.label : current, type: f?.type });
|
| 403 |
+
return out;
|
| 404 |
+
}
|
| 405 |
+
|
| 406 |
+
/** Ids only have to be unique within one view's tree; they never leave this browser's view. */
|
| 407 |
+
let ruleSeq = 0;
|
| 408 |
+
export function newRuleId(): string {
|
| 409 |
+
ruleSeq += 1;
|
| 410 |
+
return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
/** A fresh leaf condition on the first available field. */
|
| 414 |
+
export function newCondition(fields: Field[]): FilterRule | null {
|
| 415 |
+
const f0 = fields[0];
|
| 416 |
+
if (!f0) return null;
|
| 417 |
+
return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
/** Total LEAF conditions in a filter tree (groups contribute their contents). */
|
| 421 |
+
export function countConditions(nodes: FilterNode[]): number {
|
| 422 |
+
let n = 0;
|
| 423 |
+
for (const node of nodes)
|
| 424 |
+
n += isFilterGroup(node) ? countConditions(node.children) : 1;
|
| 425 |
+
return n;
|
| 426 |
+
}
|
web/src/index.css
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/settings/DatabasePermsPane.tsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// settings / DatabasePermsPane.tsx β ONE DATABASE'S RULE, given the whole pane.
|
| 3 |
+
// (wave 40, owner instruction 7 Β· W40-T14.)
|
| 4 |
+
//
|
| 5 |
+
// β WHY IT EXISTS, IN THE OWNER'S OWN WORDS: *"Manage user, Access section: show
|
| 6 |
+
// only the Database, with just a checkbox and an Edit button (put the Metrics
|
| 7 |
+
// field toggle under there). Edit opens the whole database's Fields and Filters
|
| 8 |
+
// to toggle, with a Back button to return."*
|
| 9 |
+
//
|
| 10 |
+
// β THE PARENTHESIS IN THAT QUOTE IS SUPERSEDED, AND ONLY THE PARENTHESIS. Owner instruction 13
|
| 11 |
+
// (W40-T16) folded the Metrics toggle one level further in: it is no longer a box "under there"
|
| 12 |
+
// but one CHECKBOX PER METRIC inside this pane's Hide-fields list. The room, the Back button and
|
| 13 |
+
// the Fields/Filters pairing are instruction 7's and are unchanged. Read the quote as the reason
|
| 14 |
+
// this file exists, never as a description of the controls it draws today.
|
| 15 |
+
//
|
| 16 |
+
// What it replaces is a single-row INLINE ACCORDION. That shape was right when
|
| 17 |
+
// the list was two databases and the panels were the only thing under them; it
|
| 18 |
+
// is wrong now, because a filter builder and a field list opened inside a row of
|
| 19 |
+
// a list of every database the tenant has are two menus that failed to close.
|
| 20 |
+
// A rule about one database gets a room, and the room says which database.
|
| 21 |
+
//
|
| 22 |
+
// β IT OWNS NO STATE AND WRITES NOTHING. Every value comes from the caller's
|
| 23 |
+
// DRAFT and every change goes back out as a callback, exactly as
|
| 24 |
+
// `ModulePermsList` does β `PermsEditor` holds the draft, the confirm and the
|
| 25 |
+
// PUT. This component is a layout and a set of handlers, so the account page
|
| 26 |
+
// keeps one source of truth for what is about to be saved.
|
| 27 |
+
//
|
| 28 |
+
// ββ THE `lockedKey` / `hideableKeys` PAIR BELOW IS LOAD-BEARING AND ITS
|
| 29 |
+
// FAILURE IS SILENT. Drop either and one click on "Hide all" hides the database's
|
| 30 |
+
// own identity column: a record whose faithful enforcement is a table of blank
|
| 31 |
+
// rows, which the server's PUT validation ACCEPTS because the identity column is
|
| 32 |
+
// a perfectly known field key. Both halves ship together or the pane is a defect.
|
| 33 |
+
// ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
|
| 36 |
+
import type { FilterTree } from "../customer-grid/types";
|
| 37 |
+
import type { PermsModule, PermsRecord } from "./permsModel";
|
| 38 |
+
import { filterOf, hiddenSetFor, hideAllKeys, identityKey, showAllKeys } from "./permsModel";
|
| 39 |
+
import "./perms.css";
|
| 40 |
+
|
| 41 |
+
export interface DatabasePermsPaneProps {
|
| 42 |
+
/** The database being edited: its label titles the pane, its fields feed both panels. */
|
| 43 |
+
module: PermsModule;
|
| 44 |
+
/**
|
| 45 |
+
* β THE WHOLE DRAFT, keyed by module, NOT this module's entry. `filterOf` and `hiddenSetFor`
|
| 46 |
+
* are where "an absent filter is an empty tree" and "absent hidden fields is an empty set" are
|
| 47 |
+
* spelled, once; taking a bare entry would make this file spell them a second time, and two
|
| 48 |
+
* spellings of one default are what those helpers exist to prevent.
|
| 49 |
+
*/
|
| 50 |
+
entries: PermsRecord;
|
| 51 |
+
/** Return to the account page. The SAME function the account page's Escape handler calls. */
|
| 52 |
+
onBack: () => void;
|
| 53 |
+
onFilter: (next: FilterTree | null) => void;
|
| 54 |
+
onToggleHidden: (fieldKey: string) => void;
|
| 55 |
+
onSetHidden: (keys: string[]) => void;
|
| 56 |
+
/*
|
| 57 |
+
* ββ W40-T16 (OWNER INSTRUCTION 13) β `onMetrics` IS GONE FROM THIS PANE, AND SO IS THE BOX
|
| 58 |
+
* IT DREW. Owner, verbatim: *"Fold 'Metrics fields (lookback measures over this database)' into
|
| 59 |
+
* the 'Hide fields' checkboxes, one row per metric, named 'Metric - Revenue', 'Metric - Order'
|
| 60 |
+
* and so on, so a user can check the ones the permissioning is limited to."*
|
| 61 |
+
*
|
| 62 |
+
* β A DELETION RATHER THAN A HIDE, because the control it replaces answered a COARSER
|
| 63 |
+
* question. One box said "this account may build measures over this database, or may not";
|
| 64 |
+
* the Hide-fields list below now carries one checkbox per bound measure, so the same admin
|
| 65 |
+
* decides WHICH ones. Leaving the blanket box beside them would put two controls on one screen
|
| 66 |
+
* that can contradict each other, and the record has no way to express the contradiction.
|
| 67 |
+
*
|
| 68 |
+
* β THE CAPABILITY ITSELF IS UNTOUCHED. `PermsEntry.metrics` still rides the wire, still parses
|
| 69 |
+
* (`parseEntry`) and is still emitted (`toPutBody`), because a record written before this
|
| 70 |
+
* ticket may carry an explicit revocation. What honours it here is `hiddenSetFor` below, on the
|
| 71 |
+
* READ side: a blanket `metrics: false` paints every metric row as hidden without rewriting
|
| 72 |
+
* anybody's stored rule. See the note at that call.
|
| 73 |
+
*/
|
| 74 |
+
/** People this tenant can name in a `user` condition. Absent β the panel says so. */
|
| 75 |
+
userOptions?: string[];
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
export function DatabasePermsPane({
|
| 79 |
+
module,
|
| 80 |
+
entries,
|
| 81 |
+
onBack,
|
| 82 |
+
onFilter,
|
| 83 |
+
onToggleHidden,
|
| 84 |
+
onSetHidden,
|
| 85 |
+
userOptions,
|
| 86 |
+
}: DatabasePermsPaneProps) {
|
| 87 |
+
return (
|
| 88 |
+
<div className="set-dbperm">
|
| 89 |
+
<div className="set-dbperm-head">
|
| 90 |
+
{/* β ONE WORD, AND THE SHORTNESS IS FORCED RATHER THAN CHOSEN. The account's own exit,
|
| 91 |
+
"Back to accounts", is rendered by `PermsEditor` DIRECTLY ABOVE this pane and stays
|
| 92 |
+
there while the room is open. Spelling this one "Back to account" would put two
|
| 93 |
+
controls one letter apart on one screen, going to different places, which is a worse
|
| 94 |
+
room than either label is a good one. Position already says where this one goes: it
|
| 95 |
+
sits on the database title's line, so it leaves the database. `DESIGN.md` Β§4 asks for
|
| 96 |
+
the shorter label wherever the longer one is narration, and here it also happens to
|
| 97 |
+
be the only unambiguous one. */}
|
| 98 |
+
<button type="button" className="set-secondary set-dbperm-back" onClick={onBack}>
|
| 99 |
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden focusable="false">
|
| 100 |
+
<path
|
| 101 |
+
d="M9.5 4 5.5 8l4 4"
|
| 102 |
+
stroke="currentColor"
|
| 103 |
+
strokeWidth={1.3}
|
| 104 |
+
strokeLinecap="round"
|
| 105 |
+
strokeLinejoin="round"
|
| 106 |
+
/>
|
| 107 |
+
</svg>
|
| 108 |
+
Back
|
| 109 |
+
</button>
|
| 110 |
+
{/* The pane says which database it is about. Without it every one of these rooms looks
|
| 111 |
+
identical, and the reader's only clue is what they clicked a scroll ago. */}
|
| 112 |
+
<h4 className="set-h4 set-dbperm-title">{module.label}</h4>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
{/* THE KIT'S OWN PANELS (contract C-KIT), not lookalikes: the same two the grid toolbar
|
| 116 |
+
mounts, so an admin writes a condition with the control they already know and there is
|
| 117 |
+
one condition grammar in the product rather than two that drift within a wave.
|
| 118 |
+
They STACK here rather than sitting side by side: the pane is theirs alone now, and a
|
| 119 |
+
filter and a field list read as two decisions taken in order, not one wide row. */}
|
| 120 |
+
<div className="set-dbperm-panels">
|
| 121 |
+
<div className="cg-pop set-perm-pop">
|
| 122 |
+
<FilterBuilderPanel
|
| 123 |
+
fields={module.fields}
|
| 124 |
+
filters={filterOf(entries, module.key)}
|
| 125 |
+
onChange={onFilter}
|
| 126 |
+
userOptions={userOptions}
|
| 127 |
+
/>
|
| 128 |
+
</div>
|
| 129 |
+
<div className="cg-pop set-perm-pop">
|
| 130 |
+
{/* ββ W40-T16 β THIS LIST IS WHERE THE METRICS RULE NOW LIVES. `module.fields` carries
|
| 131 |
+
C3's `measure_`-namespaced pseudo-fields, `permsModel::parseField` marks each one,
|
| 132 |
+
and the panel draws it as an ordinary checkbox named "Metric - Revenue". Checking it
|
| 133 |
+
puts that field's key into `hiddenFields` through the same `onToggle` every other
|
| 134 |
+
row uses, so there is no second write path for a metric and no second record shape.
|
| 135 |
+
β `hiddenSetFor`, NOT `hiddenSet`, AND THE DIFFERENCE IS THE MIGRATION. A record
|
| 136 |
+
written before this ticket can carry a blanket `metrics: false` (W38-T19's box), and
|
| 137 |
+
the control that wrote it is gone from this pane. The union paints every metric row
|
| 138 |
+
as hidden for such a record, so the old revocation is HONOURED on screen rather than
|
| 139 |
+
silently reading as a grant. It is a read-side union on purpose: folding it into
|
| 140 |
+
`hiddenFields` would edit somebody's stored rule as a side effect of opening a page. */}
|
| 141 |
+
<FieldsHidePanel
|
| 142 |
+
fields={module.fields}
|
| 143 |
+
hidden={hiddenSetFor(entries, module.key, module.fields)}
|
| 144 |
+
onToggle={onToggleHidden}
|
| 145 |
+
/* ββ W40-T17 (OWNER INSTRUCTION 15) β THE ONE PROP THAT MAKES THIS MOUNT DIFFER
|
| 146 |
+
FROM THE GRID'S. Owner, verbatim: *"stop displaying 'Shared with me' / 'Shared
|
| 147 |
+
with everyone' fields under Hide Fields - permission on pre-set Fields only."*
|
| 148 |
+
`false` drops both sections AND swaps the membership rule for AM-2's
|
| 149 |
+
`metric || !custom`, so this list is the database's own columns plus its bound
|
| 150 |
+
measures and nothing else.
|
| 151 |
+
β IT IS PASSED HERE AND NOWHERE ELSE. `customer-grid/Toolbar.tsx` mounts the same
|
| 152 |
+
panel and passes no `sharedSections`, so the grid keeps all three sections exactly
|
| 153 |
+
as the 2026-08-21 hotfix left them. That is the whole reason this is a prop rather
|
| 154 |
+
than a deletion: the two surfaces are supposed to differ now. */
|
| 155 |
+
sharedSections={false}
|
| 156 |
+
// ββ BOTH HALVES OF THE TRAP, and neither is decoration. `lockedKey` disables the
|
| 157 |
+
// identity column's own checkbox; `hideableKeys` is what keeps "Hide all" from
|
| 158 |
+
// including it anyway. One without the other still ships blank rows.
|
| 159 |
+
lockedKey={identityKey(module.fields)}
|
| 160 |
+
/* ββ W40-T17 β THE BULK ACTIONS REACH EXACTLY WHAT THIS ROOM LISTS, and the
|
| 161 |
+
narrowing above is what owes them the change: a list showing four rows whose
|
| 162 |
+
"Show all" clears hidden state for seven fields would WIDEN a permission through
|
| 163 |
+
a control that never displayed the columns it just revealed. `showAllKeys`
|
| 164 |
+
subtracts the governed keys instead of emptying the record; `hideAllKeys` adds
|
| 165 |
+
them without disturbing what the record hides elsewhere. Both strip the identity
|
| 166 |
+
key, so the trap above stays closed from this side too. */
|
| 167 |
+
onHideAll={() => onSetHidden(hideAllKeys(entries, module.key, module.fields))}
|
| 168 |
+
onShowAll={() => onSetHidden(showAllKeys(entries, module.key, module.fields))}
|
| 169 |
+
/>
|
| 170 |
+
</div>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
);
|
| 174 |
+
}
|
web/src/settings/ModulePermsList.tsx
CHANGED
|
@@ -30,7 +30,8 @@ import type { ReactNode } from "react";
|
|
| 30 |
import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
|
| 31 |
import type { FilterTree } from "../customer-grid/types";
|
| 32 |
import type { PermsModule, PermsRecord } from "./permsModel";
|
| 33 |
-
import { filterOf,
|
|
|
|
| 34 |
import "./perms.css";
|
| 35 |
|
| 36 |
export interface ModulePermsListProps {
|
|
@@ -54,10 +55,32 @@ export interface ModulePermsListProps {
|
|
| 54 |
* reds `web_ui` on a build break in a file that ticket may not repair.
|
| 55 |
*/
|
| 56 |
onMetrics?: (key: string, on: boolean) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
/** People this tenant can name in a `user` condition. Absent β the panel says so. */
|
| 58 |
userOptions?: string[];
|
| 59 |
-
/**
|
| 60 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
headExtra?: (m: PermsModule) => ReactNode;
|
| 62 |
/** Shown when the tenant governs nothing at all. */
|
| 63 |
emptyNote?: string;
|
|
@@ -71,10 +94,15 @@ export function ModulePermsList({
|
|
| 71 |
onToggleHidden,
|
| 72 |
onSetHidden,
|
| 73 |
onMetrics,
|
|
|
|
| 74 |
userOptions,
|
| 75 |
headExtra,
|
| 76 |
emptyNote,
|
| 77 |
}: ModulePermsListProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
// β Owner item 11: *"the database should not show all immediately the detail
|
| 79 |
// (for filter or hide fields)"*. Single-valued rather than a Set, deliberately
|
| 80 |
// β the complaint was a wall of panels, and an accordion cannot become one by
|
|
@@ -127,8 +155,25 @@ export function ModulePermsList({
|
|
| 127 |
controls keeps every new one off that rule β one flex child in,
|
| 128 |
one flex child out. */}
|
| 129 |
<span className="set-perm-headend">
|
| 130 |
-
|
| 131 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
<button
|
| 133 |
type="button"
|
| 134 |
className="set-secondary set-perm-disclose"
|
|
@@ -138,7 +183,7 @@ export function ModulePermsList({
|
|
| 138 |
{shown ? "Hide detail" : "Conditions and fields"}
|
| 139 |
</button>
|
| 140 |
) : null}
|
| 141 |
-
{headExtra?.(m) ?? null}
|
| 142 |
</span>
|
| 143 |
</div>
|
| 144 |
|
|
@@ -154,7 +199,28 @@ export function ModulePermsList({
|
|
| 154 |
`space-between` and lives in `index.css`, which this ticket's fence
|
| 155 |
does not contain; a new flex child there would need CSS that cannot
|
| 156 |
be written, so the box takes its own line under the head. */}
|
| 157 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
<label className="set-check">
|
| 159 |
<input
|
| 160 |
type="checkbox"
|
|
@@ -179,7 +245,10 @@ export function ModulePermsList({
|
|
| 179 |
</p>
|
| 180 |
) : null}
|
| 181 |
|
| 182 |
-
{
|
|
|
|
|
|
|
|
|
|
| 183 |
<div className="set-perm-panels">
|
| 184 |
<div className="cg-pop set-perm-pop">
|
| 185 |
<FilterBuilderPanel
|
|
@@ -190,9 +259,15 @@ export function ModulePermsList({
|
|
| 190 |
/>
|
| 191 |
</div>
|
| 192 |
<div className="cg-pop set-perm-pop">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
<FieldsHidePanel
|
| 194 |
fields={m.fields}
|
| 195 |
-
hidden={
|
| 196 |
onToggle={(key) => onToggleHidden(m.key, key)}
|
| 197 |
// β `lockedKey` IS NOT OPTIONAL HERE, whatever the prop says.
|
| 198 |
// Absent, the panel locks nothing and one click on "Hide all"
|
|
@@ -201,8 +276,23 @@ export function ModulePermsList({
|
|
| 201 |
// validation would accept because the identity column is a
|
| 202 |
// perfectly KNOWN field key.
|
| 203 |
lockedKey={identityKey(m.fields)}
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
/>
|
| 207 |
</div>
|
| 208 |
</div>
|
|
|
|
| 30 |
import { FilterBuilderPanel, FieldsHidePanel } from "../filter-kit";
|
| 31 |
import type { FilterTree } from "../customer-grid/types";
|
| 32 |
import type { PermsModule, PermsRecord } from "./permsModel";
|
| 33 |
+
import { filterOf, hiddenSetFor, hideAllKeys, identityKey, moduleSummary,
|
| 34 |
+
showAllKeys } from "./permsModel";
|
| 35 |
import "./perms.css";
|
| 36 |
|
| 37 |
export interface ModulePermsListProps {
|
|
|
|
| 55 |
* reds `web_ui` on a build break in a file that ticket may not repair.
|
| 56 |
*/
|
| 57 |
onMetrics?: (key: string, on: boolean) => void;
|
| 58 |
+
/**
|
| 59 |
+
* ββ W40-T14 (owner instruction 7) β THE DRILL-IN. When supplied, the row is a checkbox and
|
| 60 |
+
* ONE way in: this opens a full-pane editor of that database's Fields and Filters, and the row
|
| 61 |
+
* renders no summary, no accordion, no inline panels, no Metrics box and no `headExtra`. Owner,
|
| 62 |
+
* verbatim: *"show only the Database, with just a checkbox and an Edit button (put the Metrics
|
| 63 |
+
* field toggle under there)"*.
|
| 64 |
+
*
|
| 65 |
+
* β OPTIONAL FOR THE SAME FORCED REASON `onMetrics` IS. `manage-agent/ManageAgentPane.tsx`
|
| 66 |
+
* mounts this component and is outside W40-T14's fence; a required prop stops it compiling in a
|
| 67 |
+
* file this ticket may not repair. Absent β every branch below is the accordion this file has
|
| 68 |
+
* always rendered, byte for byte.
|
| 69 |
+
*/
|
| 70 |
+
onEdit?: (key: string) => void;
|
| 71 |
/** People this tenant can name in a `user` condition. Absent β the panel says so. */
|
| 72 |
userOptions?: string[];
|
| 73 |
+
/**
|
| 74 |
+
* Anything the CALLER wants in a row's head. Returning null is the normal case and costs the
|
| 75 |
+
* row nothing.
|
| 76 |
+
*
|
| 77 |
+
* β NO CALLER SUPPLIES IT TODAY, AND THE PROP IS KEPT ANYWAY. It carried the account editor's
|
| 78 |
+
* per-row "Copy to" door, which owner instruction 7 DELETED (W40-T14) β the account-level
|
| 79 |
+
* "Apply this access toβ¦" survives and is the whole copy affordance now. What is left here is
|
| 80 |
+
* a seam, declared because `manage-agent/ManageAgentPane.tsx` mounts this same component from
|
| 81 |
+
* outside that ticket's fence and removing a prop is a breaking change to a file it may not
|
| 82 |
+
* repair. A drilled row ignores it regardless: `onEdit` means "a checkbox and one way in".
|
| 83 |
+
*/
|
| 84 |
headExtra?: (m: PermsModule) => ReactNode;
|
| 85 |
/** Shown when the tenant governs nothing at all. */
|
| 86 |
emptyNote?: string;
|
|
|
|
| 94 |
onToggleHidden,
|
| 95 |
onSetHidden,
|
| 96 |
onMetrics,
|
| 97 |
+
onEdit,
|
| 98 |
userOptions,
|
| 99 |
headExtra,
|
| 100 |
emptyNote,
|
| 101 |
}: ModulePermsListProps) {
|
| 102 |
+
// β W40-T14 β WHICH MODE, read ONCE. Every branch below asks this same question, and a second
|
| 103 |
+
// spelling of it (`onEdit !== undefined` here, `!!onEdit` there) is how two of them come to
|
| 104 |
+
// disagree about which room they are in.
|
| 105 |
+
const drilled = onEdit !== undefined;
|
| 106 |
// β Owner item 11: *"the database should not show all immediately the detail
|
| 107 |
// (for filter or hide fields)"*. Single-valued rather than a Set, deliberately
|
| 108 |
// β the complaint was a wall of panels, and an accordion cannot become one by
|
|
|
|
| 155 |
controls keeps every new one off that rule β one flex child in,
|
| 156 |
one flex child out. */}
|
| 157 |
<span className="set-perm-headend">
|
| 158 |
+
{/* β W40-T14 β the summary, the accordion and `headExtra` are the ACCORDION
|
| 159 |
+
room's furniture. The drill-in row is "a checkbox and one way in", so it
|
| 160 |
+
renders the Edit button in their place and nothing else. */}
|
| 161 |
+
{drilled ? null : (
|
| 162 |
+
<span className="set-perm-sum">{moduleSummary(entry, schemaless)}</span>
|
| 163 |
+
)}
|
| 164 |
+
{canDetail && drilled ? (
|
| 165 |
+
// β `canDetail` (= `on && !schemaless`), NOT unconditional: a database nobody
|
| 166 |
+
// may open has no rule to edit, and a schemaless surface has no fields and no
|
| 167 |
+
// filter β the help paragraph below already says so for that second case.
|
| 168 |
+
<button
|
| 169 |
+
type="button"
|
| 170 |
+
className="set-secondary set-perm-edit"
|
| 171 |
+
onClick={() => onEdit(m.key)}
|
| 172 |
+
>
|
| 173 |
+
Edit
|
| 174 |
+
</button>
|
| 175 |
+
) : null}
|
| 176 |
+
{canDetail && !drilled ? (
|
| 177 |
<button
|
| 178 |
type="button"
|
| 179 |
className="set-secondary set-perm-disclose"
|
|
|
|
| 183 |
{shown ? "Hide detail" : "Conditions and fields"}
|
| 184 |
</button>
|
| 185 |
) : null}
|
| 186 |
+
{drilled ? null : headExtra?.(m) ?? null}
|
| 187 |
</span>
|
| 188 |
</div>
|
| 189 |
|
|
|
|
| 199 |
`space-between` and lives in `index.css`, which this ticket's fence
|
| 200 |
does not contain; a new flex child there would need CSS that cannot
|
| 201 |
be written, so the box takes its own line under the head. */}
|
| 202 |
+
{/* β W40-T14 β `!drilled &&` is the whole change here. Owner instruction 7 moved this
|
| 203 |
+
box INTO the database's own pane ("put the Metrics field toggle under there"), so
|
| 204 |
+
the drill-in row must not draw a second one.
|
| 205 |
+
ββ W40-T16 β AND AFTER THIS TICKET THE BOX BELOW RENDERS NOWHERE AT ALL. Owner
|
| 206 |
+
instruction 13 folded the blanket toggle into the HIDE-FIELDS list, one checkbox
|
| 207 |
+
per metric, and `DatabasePermsPane`'s copy is deleted; `PermsEditor` passes
|
| 208 |
+
`onEdit` for every account row, so `!drilled` suppresses this one. β AND THE
|
| 209 |
+
OTHER CALLER DOES NOT MOUNT IT EITHER β MEASURED, not assumed:
|
| 210 |
+
`manage-agent/ManageAgentPane.tsx` passes exactly `modules, entries, onAccess,
|
| 211 |
+
onFilter, onToggleHidden, onSetHidden, userOptions, emptyNote`, and no
|
| 212 |
+
`onMetrics`. So nothing in the UI writes `PermsEntry.metrics` any more.
|
| 213 |
+
β THAT IS INSTRUCTION 13, NOT A DEFECT: a blanket control REPLACED by finer ones
|
| 214 |
+
is the whole ask. The boolean survives as a legacy value β read by `parseEntry`,
|
| 215 |
+
emitted by `toPutBody`, honoured on screen by `hiddenSetFor` β and every record
|
| 216 |
+
written from here on carries it GRANTED.
|
| 217 |
+
β THE JSX IS KEPT AS A SEAM, DELIBERATELY AND WITH ITS COST NAMED. It is what
|
| 218 |
+
lets a future room govern the capability without re-deriving the prop, and it
|
| 219 |
+
carries `verify_ui.py`'s `t19-unmounted` / `t19-control-gone` controls, the only
|
| 220 |
+
two in that file built from code that actually shipped broken. Deleting it is
|
| 221 |
+
legal β nothing passes the prop, so `tsc` would not notice β but it is a decision
|
| 222 |
+
to take out loud, in the same change as those two gate blocks, never a tidy-up. */}
|
| 223 |
+
{!drilled && on && !schemaless && onMetrics ? (
|
| 224 |
<label className="set-check">
|
| 225 |
<input
|
| 226 |
type="checkbox"
|
|
|
|
| 245 |
</p>
|
| 246 |
) : null}
|
| 247 |
|
| 248 |
+
{/* β `!drilled` is written even though `openDetail` can never leave `null` in that
|
| 249 |
+
mode (nothing renders the disclosure that sets it). An invariant held by an absent
|
| 250 |
+
button is one refactor away from being false; the gate on the thing itself is not. */}
|
| 251 |
+
{!drilled && canDetail && shown ? (
|
| 252 |
<div className="set-perm-panels">
|
| 253 |
<div className="cg-pop set-perm-pop">
|
| 254 |
<FilterBuilderPanel
|
|
|
|
| 259 |
/>
|
| 260 |
</div>
|
| 261 |
<div className="cg-pop set-perm-pop">
|
| 262 |
+
{/* β W40-T16 β `hiddenSetFor`, the same resolver the manage-user pane uses.
|
| 263 |
+
ONE spelling of "which keys read as hidden": this room's fields carry no
|
| 264 |
+
metric flag today (the agent editor's payload has no `measure_` pseudo-
|
| 265 |
+
fields), so the union adds nothing here and the accordion is unchanged.
|
| 266 |
+
It is written anyway because the alternative is two answers to one question
|
| 267 |
+
in two files, which is the drift this component was extracted to prevent. */}
|
| 268 |
<FieldsHidePanel
|
| 269 |
fields={m.fields}
|
| 270 |
+
hidden={hiddenSetFor(entries, m.key, m.fields)}
|
| 271 |
onToggle={(key) => onToggleHidden(m.key, key)}
|
| 272 |
// β `lockedKey` IS NOT OPTIONAL HERE, whatever the prop says.
|
| 273 |
// Absent, the panel locks nothing and one click on "Hide all"
|
|
|
|
| 276 |
// validation would accept because the identity column is a
|
| 277 |
// perfectly KNOWN field key.
|
| 278 |
lockedKey={identityKey(m.fields)}
|
| 279 |
+
/* ββ W40-T17 (owner instruction 15) β UNCONDITIONAL HERE, NOT A SECOND
|
| 280 |
+
PROP, AND THAT IS WHAT THIS COMPONENT IS. `ModulePermsList` IS the
|
| 281 |
+
permissioning list: every mount of it β the account editor's Access
|
| 282 |
+
section and `manage-agent/ManageAgentPane` alike β is editing the same
|
| 283 |
+
`perms` record through the same wall, so its panel is always a
|
| 284 |
+
permissioning panel and there is no caller for whom the shared sections
|
| 285 |
+
would be right. Owner: *"stop displaying 'Shared with me' / 'Shared with
|
| 286 |
+
everyone' fields under Hide Fields - permission on pre-set Fields only."*
|
| 287 |
+
That is a sentence about permissioning, not about one screen.
|
| 288 |
+
β IT IS ALSO WHAT KEEPS THE TWO ROOMS IDENTICAL. This file exists because
|
| 289 |
+
the owner ruled manage-agent is *"the same exact layout"* as manage user
|
| 290 |
+
(wave 33); gating one room and not the other would put the divergence
|
| 291 |
+
inside the very component extracted to prevent it β and LATENTLY, since
|
| 292 |
+
neither section fills up until the server ships `custom`/`shared`. */
|
| 293 |
+
sharedSections={false}
|
| 294 |
+
onHideAll={() => onSetHidden(m.key, hideAllKeys(entries, m.key, m.fields))}
|
| 295 |
+
onShowAll={() => onSetHidden(m.key, showAllKeys(entries, m.key, m.fields))}
|
| 296 |
/>
|
| 297 |
</div>
|
| 298 |
</div>
|
web/src/settings/PermsEditor.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/settings/perms.css
CHANGED
|
@@ -23,10 +23,13 @@
|
|
| 23 |
flex: none;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
/* Smaller
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
| 30 |
.set-perm-disclose {
|
| 31 |
font-size: var(--lp-fs-2xs);
|
| 32 |
padding: 3px 9px;
|
|
@@ -40,8 +43,174 @@
|
|
| 40 |
color: var(--lp-blue-deep);
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
@media (max-width: 640px) {
|
| 44 |
/* The head wraps before the label truncates: a database name is the one thing
|
| 45 |
in this row that cannot be guessed from context. */
|
| 46 |
.set-perm-headend { flex-wrap: wrap; justify-content: flex-end; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
|
|
|
| 23 |
flex: none;
|
| 24 |
}
|
| 25 |
|
| 26 |
+
/* Smaller and quieter than a primary action: this button reveals detail, it does
|
| 27 |
+
not commit anything. It borrows `.set-secondary`'s border, surface and focus
|
| 28 |
+
ring rather than restating them β a second definition of the button chrome is a
|
| 29 |
+
second place for the focus ring to go missing.
|
| 30 |
+
β STILL LIVE, and only in the agent editor. Wave 40 replaced the account
|
| 31 |
+
editor's accordion with a full-pane room (`.set-perm-edit` below); the agent
|
| 32 |
+
editor mounts the same list without `onEdit` and keeps this disclosure. */
|
| 33 |
.set-perm-disclose {
|
| 34 |
font-size: var(--lp-fs-2xs);
|
| 35 |
padding: 3px 9px;
|
|
|
|
| 43 |
color: var(--lp-blue-deep);
|
| 44 |
}
|
| 45 |
|
| 46 |
+
/* ββ wave 40, owner instruction 7 (W40-T14): the row's ONE way in, and the room
|
| 47 |
+
it opens ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
+
*
|
| 49 |
+
* WHY THE ACCORDION BECAME A ROOM. Wave 33's disclosure answered "show every
|
| 50 |
+
* database, not every panel", and it did β while the list was two rows. It is now
|
| 51 |
+
* every database the tenant has, and a filter builder unfolded inside one row of
|
| 52 |
+
* that list reads as a menu that failed to close. Owner: *"Edit opens the whole
|
| 53 |
+
* database's Fields and Filters to toggle, with a Back button to return."*
|
| 54 |
+
*/
|
| 55 |
+
|
| 56 |
+
/* Sized and quietened like the disclosure it replaces, and borrowing
|
| 57 |
+
`.set-secondary`'s border, surface and focus ring for the same reason that one
|
| 58 |
+
does: a second definition of the button chrome is a second place for the focus
|
| 59 |
+
ring to go missing. */
|
| 60 |
+
.set-perm-edit {
|
| 61 |
+
font-size: var(--lp-fs-2xs);
|
| 62 |
+
padding: 3px 9px;
|
| 63 |
+
white-space: nowrap;
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/* The way out and the name of the room share one line, so the reader learns
|
| 67 |
+
where they are and how to leave in a single glance. `baseline` rather than
|
| 68 |
+
`center`: the title's cap height should sit on the button's label, which is
|
| 69 |
+
the alignment every other head on this page uses. */
|
| 70 |
+
.set-dbperm-head {
|
| 71 |
+
display: flex;
|
| 72 |
+
align-items: baseline;
|
| 73 |
+
gap: 12px;
|
| 74 |
+
margin-bottom: 16px;
|
| 75 |
+
}
|
| 76 |
+
/* The chevron rides with the words rather than beside them. */
|
| 77 |
+
.set-dbperm-back { display: inline-flex; align-items: center; gap: 6px; flex: none; }
|
| 78 |
+
/* `.set-h4` carries block margins meant for a heading that starts a section; here
|
| 79 |
+
it is a flex item on the head's own line. */
|
| 80 |
+
.set-dbperm-title { margin: 0; min-width: 0; }
|
| 81 |
+
/* β W40-T16 β `.set-dbperm-metrics` IS DELETED WITH THE BOX IT SPACED. Owner instruction 13
|
| 82 |
+
folded the blanket Metrics checkbox into the Hide-fields list as one row per metric, so the
|
| 83 |
+
rule had no element left to apply to. A rule that matches nothing is how a stylesheet ends up
|
| 84 |
+
describing a screen that no longer exists. */
|
| 85 |
+
|
| 86 |
+
/* THE PANELS STACK, one decision per eye level. A grid rather than a column flex
|
| 87 |
+
box on purpose: `.set-perm-pop` carries `flex: 1 1 320px` for the side-by-side
|
| 88 |
+
accordion, and flex properties are inert on a GRID item, so this container gets
|
| 89 |
+
the stacking it wants without redefining a rule that belongs to `index.css`. */
|
| 90 |
+
.set-dbperm-panels {
|
| 91 |
+
display: grid;
|
| 92 |
+
grid-template-columns: minmax(0, 1fr);
|
| 93 |
+
gap: 14px;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/* ββ wave 40, owner instructions 8 + 9 + 10 (W40-T15): the account page stops
|
| 97 |
+
shouting, and Save sits where the hand is ββββββββββββββββββββββββββββββββββ
|
| 98 |
+
*
|
| 99 |
+
* Three complaints, one page. The header put an exit, a person and two chips on
|
| 100 |
+
* one flex row; the reset-password control was split across a two-column grid
|
| 101 |
+
* with its consequence read BEFORE the button that causes it; and a long access
|
| 102 |
+
* list had its only Save at the bottom.
|
| 103 |
+
*
|
| 104 |
+
* β EVERY SELECTOR BELOW THAT OVERRIDES AN `index.css` DEFAULT CARRIES TWO
|
| 105 |
+
* CLASSES ON PURPOSE. A single-class rule here would tie at 0-1-0 with the rule
|
| 106 |
+
* it means to beat, and the winner would then be vite's import order across two
|
| 107 |
+
* stylesheets β a thing no reader of either file can see. Nothing in `index.css`
|
| 108 |
+
* is edited or restated; these only bound the new arrangement.
|
| 109 |
+
*/
|
| 110 |
+
|
| 111 |
+
/* --- instruction 9c: the way out is page chrome, on its own line ----------- */
|
| 112 |
+
/* The quiet-button register this system already uses (`.shell-newfolder`: muted
|
| 113 |
+
ink, wash on hover, nothing else), with the native chrome reset in the BASE
|
| 114 |
+
rule as `DESIGN.md` Β§4 requires. β `font: inherit` FIRST and the size after
|
| 115 |
+
it: a shorthand declared below its own longhands re-expands and undoes them,
|
| 116 |
+
which is the Alerts-button scar `verify_ui.py`'s R5b cascade check exists to
|
| 117 |
+
catch. The negative left margin is optical, not spacing β it pulls the
|
| 118 |
+
chevron out to the page's text edge so the label starts where the title does. */
|
| 119 |
+
.set-perm-back {
|
| 120 |
+
display: inline-flex;
|
| 121 |
+
align-items: center;
|
| 122 |
+
gap: 6px;
|
| 123 |
+
border: 0;
|
| 124 |
+
background: transparent;
|
| 125 |
+
font: inherit;
|
| 126 |
+
font-size: var(--lp-fs-2xs);
|
| 127 |
+
color: var(--lp-muted);
|
| 128 |
+
padding: 4px 8px;
|
| 129 |
+
margin: 0 0 10px -8px;
|
| 130 |
+
border-radius: var(--lp-r-sm);
|
| 131 |
+
cursor: pointer;
|
| 132 |
+
}
|
| 133 |
+
.set-perm-back:hover { background: var(--lp-wash); color: var(--lp-ink); }
|
| 134 |
+
.set-perm-back:focus-visible { outline: 2px solid var(--lp-blue-deep); outline-offset: 1px; }
|
| 135 |
+
|
| 136 |
+
/* The identity is the page TITLE now, so it owns the space under the head
|
| 137 |
+
rather than sharing a row with the exit. */
|
| 138 |
+
.set-perm-ident { margin-bottom: 16px; }
|
| 139 |
+
/* The chips sit beside the name, which is where `index.css` already argued they
|
| 140 |
+
belong: pushed to the far end they read as a status of the dialog rather than
|
| 141 |
+
of the person.
|
| 142 |
+
β `baseline`, NOT the `center` the old head used, and the difference is which
|
| 143 |
+
sibling the chip is beside. On that row the chip sat next to a TWO-LINE block
|
| 144 |
+
(name over username) and index.css records the reason for centring it: a chip
|
| 145 |
+
top-aligned against two lines looks dropped. Here its sibling is the one-line
|
| 146 |
+
title, so the chip's text sits on the title's baseline, which is the alignment
|
| 147 |
+
`.set-perm-modhead` and `.set-dbperm-head` already use for a heading and a
|
| 148 |
+
smaller control on one line. The username moved out of this row entirely. */
|
| 149 |
+
.set-perm-nameline {
|
| 150 |
+
display: flex;
|
| 151 |
+
align-items: baseline;
|
| 152 |
+
flex-wrap: wrap;
|
| 153 |
+
gap: 10px;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
/* --- instruction 9a + 9b: the password group reads top to bottom ----------- */
|
| 157 |
+
/* Label, box and button on one line and one baseline; the consequence under
|
| 158 |
+
them. `flex-end` rather than `baseline` because the label sits ABOVE the box:
|
| 159 |
+
what has to line up is the bottom of the input and the bottom of the button,
|
| 160 |
+
which are both 32px tall. */
|
| 161 |
+
.set-pw-row {
|
| 162 |
+
display: flex;
|
| 163 |
+
align-items: flex-end;
|
| 164 |
+
flex-wrap: wrap;
|
| 165 |
+
gap: 12px;
|
| 166 |
+
}
|
| 167 |
+
/* `.set-field` carries 13px of bottom margin meant for stacked form rows; here
|
| 168 |
+
it would lift the box off the button's baseline. The width keeps the box the
|
| 169 |
+
size a password is, instead of letting it stretch the whole card. */
|
| 170 |
+
.set-pw-group .set-pw-field { flex: 1 1 220px; max-width: 320px; margin-bottom: 0; }
|
| 171 |
+
/* The gap goes ABOVE the sentence now that the sentence is last. */
|
| 172 |
+
.set-perms .set-pw-note { margin: 8px 0 0; }
|
| 173 |
+
|
| 174 |
+
/* --- instruction 10: Save access at the head of the section too ------------ */
|
| 175 |
+
/* The trailing controls of the Access head, grouped as ONE flex child so
|
| 176 |
+
`index.css`'s `space-between` on `.set-perm-sect` still sees exactly two. */
|
| 177 |
+
.set-perm-sectend {
|
| 178 |
+
display: flex;
|
| 179 |
+
align-items: center;
|
| 180 |
+
gap: 8px;
|
| 181 |
+
flex: none;
|
| 182 |
+
}
|
| 183 |
+
/* Sized to the copy door beside it rather than to the 32px action row at the
|
| 184 |
+
bottom of the page: controls sharing a row share a height (`DESIGN.md` Β§4),
|
| 185 |
+
and this one is riding a heading's line. It stays `.set-primary` in colour,
|
| 186 |
+
because it commits the same write the bottom button commits. */
|
| 187 |
+
.set-perms .set-perm-savetop {
|
| 188 |
+
height: 28px;
|
| 189 |
+
padding: 0 12px;
|
| 190 |
+
font-size: var(--lp-fs-2xs);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
/* --- instruction 8: "Database" and "Module" ------------------------------- */
|
| 194 |
+
/* A label for a group of cards, one step quieter than the `Access` heading it
|
| 195 |
+
sits under, so the reader gets the hierarchy from weight rather than from
|
| 196 |
+
indentation. Sentence case: the product has no ALL-CAPS chrome.
|
| 197 |
+
β The bottom margin is deliberately small because `.set-perm-mod` brings 14px
|
| 198 |
+
of its own top margin and adjacent margins collapse to the larger β anything
|
| 199 |
+
under 14px here has no effect at all, so 4px says "as tight as the card
|
| 200 |
+
allows" rather than pretending to a number the layout cannot honour. */
|
| 201 |
+
.set-perms .set-perm-group {
|
| 202 |
+
margin: 22px 0 4px;
|
| 203 |
+
font-size: var(--lp-fs-2xs);
|
| 204 |
+
color: var(--lp-muted);
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
@media (max-width: 640px) {
|
| 208 |
/* The head wraps before the label truncates: a database name is the one thing
|
| 209 |
in this row that cannot be guessed from context. */
|
| 210 |
.set-perm-headend { flex-wrap: wrap; justify-content: flex-end; }
|
| 211 |
+
.set-dbperm-head { flex-wrap: wrap; }
|
| 212 |
+
/* The Access heading keeps its own line and the two controls take the next
|
| 213 |
+
one, rather than the copy door and Save being crushed into a strip beside
|
| 214 |
+
the word. */
|
| 215 |
+
.set-perm-sectend { flex-wrap: wrap; justify-content: flex-end; }
|
| 216 |
}
|
web/src/settings/permsModel.ts
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/shell/Shell.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|