Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/routes_odoo_tables.py +40 -5
- api/routes_shares.py +33 -0
- api/routes_tables.py +12 -1
- platform/core/store.py +5 -3
- platform/core/user_tables.py +12 -3
- platform/harness/measure_filter.py +6 -0
- platform/harness/runtime.py +5 -1
- web/src/customer-grid/ColumnMenu.tsx +79 -39
- web/src/customer-grid/CustomerGrid.tsx +177 -117
- web/src/customer-grid/FieldBatchAccessDialog.tsx +233 -0
- web/src/customer-grid/MapView.tsx +24 -4
- web/src/customer-grid/RouteNav.tsx +11 -1
- web/src/customer-grid/SwipeView.tsx +586 -586
- web/src/customer-grid/Toolbar.tsx +7 -3
- web/src/customer-grid/apiBridge.ts +7 -0
- web/src/customer-grid/icons.tsx +15 -0
- web/src/customer-grid/types.ts +23 -0
- web/src/customer-grid/useVisibleRows.ts +20 -9
- web/src/index.css +12 -0
- web/wiring_rows/wave42_c.py +15 -10
- web/wiring_rows/wave42_f.py +5 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "5d8aacc",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v53",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
5d8aacc
|
api/routes_odoo_tables.py
CHANGED
|
@@ -831,6 +831,24 @@ def _json_arg(raw, what):
|
|
| 831 |
return val
|
| 832 |
|
| 833 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 834 |
@router.get("/odoo-tables/{table_key}/rows")
|
| 835 |
def odoo_table_rows(table_key: str,
|
| 836 |
offset: int = Query(default=0, ge=0),
|
|
@@ -839,6 +857,7 @@ def odoo_table_rows(table_key: str,
|
|
| 839 |
filterConj: str = Query(default="and"),
|
| 840 |
sorts: str = Query(default=None),
|
| 841 |
search: str = Query(default=None),
|
|
|
|
| 842 |
session: Session = Depends(require_session)):
|
| 843 |
"""ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
|
| 844 |
|
|
@@ -985,6 +1004,10 @@ def odoo_table_rows(table_key: str,
|
|
| 985 |
base_where = (f'({spec["where"]}) AND {wall_sql}'
|
| 986 |
if (spec["where"] and wall_sql) else (wall_sql or spec["where"]))
|
| 987 |
limits, tree = [], _json_arg(filters, "filters")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 988 |
sort_spec = _json_arg(sorts, "sorts") or []
|
| 989 |
|
| 990 |
# R6's SECOND SENTENCE, ON THE WALL ITSELF. The precision note further down covers the
|
|
@@ -1046,7 +1069,7 @@ def odoo_table_rows(table_key: str,
|
|
| 1046 |
"recommendation": "compare against a whole number, or use a range that does not sit "
|
| 1047 |
"on a fractional boundary"})
|
| 1048 |
|
| 1049 |
-
|
| 1050 |
try:
|
| 1051 |
pred = _fs().compile_filter_tree(
|
| 1052 |
tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
|
|
@@ -1060,13 +1083,25 @@ def odoo_table_rows(table_key: str,
|
|
| 1060 |
raise err(400, "filter_unsupported",
|
| 1061 |
"a condition on an aggregate column belongs in HAVING, and that path is "
|
| 1062 |
"deliberately not built for windowed grids")
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
if str(search or "").strip():
|
| 1066 |
got = _fs().compile_search(search.strip(), cols)
|
| 1067 |
if got is not None:
|
| 1068 |
-
|
| 1069 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1070 |
|
| 1071 |
# ββ the order, made TOTAL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1072 |
# β `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one
|
|
|
|
| 831 |
return val
|
| 832 |
|
| 833 |
|
| 834 |
+
def _clean_include_pids(raw):
|
| 835 |
+
"""Bounded existing Link choices, or a named bad-argument refusal.
|
| 836 |
+
|
| 837 |
+
A picker condition narrows what a person may add. It never rewrites existing links, so a
|
| 838 |
+
read-through window carries those ids as an explicit exception. The normal table and row wall
|
| 839 |
+
remains outside that exception in `odoo_table_rows` below.
|
| 840 |
+
"""
|
| 841 |
+
if raw is None:
|
| 842 |
+
return []
|
| 843 |
+
if not isinstance(raw, list):
|
| 844 |
+
raise ValueError("includePids must be a JSON list of record ids")
|
| 845 |
+
if len(raw) > 500:
|
| 846 |
+
raise ValueError("includePids may name at most 500 existing linked records")
|
| 847 |
+
if any(type(pid) is not int or pid <= 0 for pid in raw):
|
| 848 |
+
raise ValueError("includePids must contain positive integer record ids")
|
| 849 |
+
return sorted(set(raw))
|
| 850 |
+
|
| 851 |
+
|
| 852 |
@router.get("/odoo-tables/{table_key}/rows")
|
| 853 |
def odoo_table_rows(table_key: str,
|
| 854 |
offset: int = Query(default=0, ge=0),
|
|
|
|
| 857 |
filterConj: str = Query(default="and"),
|
| 858 |
sorts: str = Query(default=None),
|
| 859 |
search: str = Query(default=None),
|
| 860 |
+
includePids: str = Query(default=None),
|
| 861 |
session: Session = Depends(require_session)):
|
| 862 |
"""ONE WINDOW over a connected grid, read straight from the mirror (contract C2).
|
| 863 |
|
|
|
|
| 1004 |
base_where = (f'({spec["where"]}) AND {wall_sql}'
|
| 1005 |
if (spec["where"] and wall_sql) else (wall_sql or spec["where"]))
|
| 1006 |
limits, tree = [], _json_arg(filters, "filters")
|
| 1007 |
+
try:
|
| 1008 |
+
include_pids = _clean_include_pids(_json_arg(includePids, "includePids"))
|
| 1009 |
+
except ValueError as e:
|
| 1010 |
+
raise err(400, "bad_argument", str(e))
|
| 1011 |
sort_spec = _json_arg(sorts, "sorts") or []
|
| 1012 |
|
| 1013 |
# R6's SECOND SENTENCE, ON THE WALL ITSELF. The precision note further down covers the
|
|
|
|
| 1069 |
"recommendation": "compare against a whole number, or use a range that does not sit "
|
| 1070 |
"on a fractional boundary"})
|
| 1071 |
|
| 1072 |
+
predicate_sql, predicate_params = None, []
|
| 1073 |
try:
|
| 1074 |
pred = _fs().compile_filter_tree(
|
| 1075 |
tree, conj=(filterConj if filterConj in ("and", "or") else "and"), columns=cols,
|
|
|
|
| 1083 |
raise err(400, "filter_unsupported",
|
| 1084 |
"a condition on an aggregate column belongs in HAVING, and that path is "
|
| 1085 |
"deliberately not built for windowed grids")
|
| 1086 |
+
predicate_sql = pred.sql
|
| 1087 |
+
predicate_params.extend(pred.params)
|
| 1088 |
if str(search or "").strip():
|
| 1089 |
got = _fs().compile_search(search.strip(), cols)
|
| 1090 |
if got is not None:
|
| 1091 |
+
predicate_sql = (f"({predicate_sql}) AND {got.sql}"
|
| 1092 |
+
if predicate_sql else got.sql)
|
| 1093 |
+
predicate_params.extend(got.params)
|
| 1094 |
+
|
| 1095 |
+
# Existing choices survive a new picker condition, but the table's permanent wall stays
|
| 1096 |
+
# outside this OR. A caller cannot use an old link id to reveal a record the database wall
|
| 1097 |
+
# would otherwise deny.
|
| 1098 |
+
if include_pids and predicate_sql:
|
| 1099 |
+
slots = ", ".join("?" for _ in include_pids)
|
| 1100 |
+
predicate_sql = f"(({predicate_sql}) OR ({spec['id']} IN ({slots})))"
|
| 1101 |
+
predicate_params.extend(include_pids)
|
| 1102 |
+
where = (f"({base_where}) AND ({predicate_sql})" if base_where and predicate_sql
|
| 1103 |
+
else (predicate_sql or base_where))
|
| 1104 |
+
params = [*wall_params, *predicate_params]
|
| 1105 |
|
| 1106 |
# ββ the order, made TOTAL βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1107 |
# β `tiebreak_sql` is not optional here: without a total order, LIMIT/OFFSET may return one
|
api/routes_shares.py
CHANGED
|
@@ -878,6 +878,39 @@ def get_share(kind: str, oid: str, session: Session = Depends(require_session)):
|
|
| 878 |
}
|
| 879 |
|
| 880 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
def _people(tenant):
|
| 882 |
"""[{username, name}] for this tenant β same population as `assignable_people`, with the
|
| 883 |
BINDING identity alongside the display one."""
|
|
|
|
| 878 |
}
|
| 879 |
|
| 880 |
|
| 881 |
+
@router.delete("/share/field/{oid}/mine")
|
| 882 |
+
def remove_my_field_share(oid: str, session: Session = Depends(require_session)):
|
| 883 |
+
"""Remove only this session's direct grant on one shared field.
|
| 884 |
+
|
| 885 |
+
This is deliberately not a shortened form of the replacing PUT route. A grantee may leave
|
| 886 |
+
a column shared to them, but may not submit a list that can revoke anybody else. A wildcard
|
| 887 |
+
grant is also not removable here: deleting it would remove the column from everybody.
|
| 888 |
+
"""
|
| 889 |
+
table_key, field_key = shares.split_field_oid(oid)
|
| 890 |
+
if not table_key or not field_key:
|
| 891 |
+
raise err(404, "no_object", "no such column, or it is not shared with this account")
|
| 892 |
+
oid = shares.field_oid(_field_storage_keys(table_key)[2], field_key)
|
| 893 |
+
rec = shares.grants("field", oid, st=session.runtime)
|
| 894 |
+
uname = str(session.uname or "").strip().lower()
|
| 895 |
+
direct = [entry for entry in rec["entries"] if entry.get("user") == uname]
|
| 896 |
+
if not direct:
|
| 897 |
+
if shares.role_for("field", oid, uname, is_admin=session.admin, st=session.runtime):
|
| 898 |
+
raise err(409, "not_direct_grant",
|
| 899 |
+
"this column is shared with everyone in this workspace, so there is no "
|
| 900 |
+
"personal grant to remove. Nothing was changed.")
|
| 901 |
+
raise err(404, "no_object", "no such column, or it is not shared with this account")
|
| 902 |
+
if shares.role_for("field", oid, uname, is_admin=session.admin, st=session.runtime) == "owner":
|
| 903 |
+
raise err(409, "owner_cannot_leave",
|
| 904 |
+
"you own this column. Reassign or delete it instead of removing yourself from it.")
|
| 905 |
+
kept = [entry for entry in rec["entries"] if entry.get("user") != uname]
|
| 906 |
+
try:
|
| 907 |
+
shares.set_grants("field", oid, kept, owner=rec["owner"], st=session.runtime,
|
| 908 |
+
expect=rec["entries"])
|
| 909 |
+
except shares.GrantsChanged as exc:
|
| 910 |
+
raise err(409, "grants_changed", str(exc))
|
| 911 |
+
return {"removed": True, "field": field_key}
|
| 912 |
+
|
| 913 |
+
|
| 914 |
def _people(tenant):
|
| 915 |
"""[{username, name}] for this tenant β same population as `assignable_people`, with the
|
| 916 |
BINDING identity alongside the display one."""
|
api/routes_tables.py
CHANGED
|
@@ -2491,6 +2491,11 @@ def _with_dropped(ut, out, body):
|
|
| 2491 |
def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
| 2492 |
"""Why was this column refused? The specific reason when we can name one, the general list
|
| 2493 |
otherwise β never a specific-sounding guess."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2494 |
# β WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag
|
| 2495 |
# below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment
|
| 2496 |
# column would otherwise be explained by the flow law -- "pick a flow, or make this an
|
|
@@ -2569,7 +2574,11 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
|
| 2569 |
migrated = dict(migrated or {}, workspace=True)
|
| 2570 |
except Exception: # noqa: BLE001
|
| 2571 |
migrated = dict(migrated or {}, workspace=False)
|
| 2572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2573 |
if not field:
|
| 2574 |
# C3: the same narrator as `add_field`. Flagging a SECOND column is the same act as
|
| 2575 |
# adding one, so it must get the same sentence naming the column that already holds the
|
|
@@ -2581,6 +2590,8 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
|
| 2581 |
field = synced.get("field") or field
|
| 2582 |
_refresh_relations(session)
|
| 2583 |
out = {"field": field}
|
|
|
|
|
|
|
| 2584 |
# ββ W41-T15 / C5 β the same report `add_field` carries, on the door that RETARGETS a link.
|
| 2585 |
# This one matters more: pointing an existing link at `customer_data` deletes the backlink the
|
| 2586 |
# old target held (the stale-inverse sweep) and cannot make a new one, so the column silently
|
|
|
|
| 2491 |
def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
| 2492 |
"""Why was this column refused? The specific reason when we can name one, the general list
|
| 2493 |
otherwise β never a specific-sounding guess."""
|
| 2494 |
+
# W42-T36: the validator returns only ``None``. Keep the option-cap wording in its
|
| 2495 |
+
# single owner so both add and patch tell the person exactly what was refused.
|
| 2496 |
+
option_refusal = ut.option_cap_refusal((body or {}).get("options"))
|
| 2497 |
+
if option_refusal:
|
| 2498 |
+
return option_refusal
|
| 2499 |
# β WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag
|
| 2500 |
# below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment
|
| 2501 |
# column would otherwise be explained by the flow law -- "pick a flow, or make this an
|
|
|
|
| 2574 |
migrated = dict(migrated or {}, workspace=True)
|
| 2575 |
except Exception: # noqa: BLE001
|
| 2576 |
migrated = dict(migrated or {}, workspace=False)
|
| 2577 |
+
# A type change can clear values that no longer have a meaning in the target column. Keep
|
| 2578 |
+
# that report separate from the field itself: callers that only need the field retain the
|
| 2579 |
+
# established shape, while a future editor can state the consequence from the same response.
|
| 2580 |
+
retype = {}
|
| 2581 |
+
field = ut.patch_field(table_key, fkey, body, st=session.runtime, preview=retype)
|
| 2582 |
if not field:
|
| 2583 |
# C3: the same narrator as `add_field`. Flagging a SECOND column is the same act as
|
| 2584 |
# adding one, so it must get the same sentence naming the column that already holds the
|
|
|
|
| 2590 |
field = synced.get("field") or field
|
| 2591 |
_refresh_relations(session)
|
| 2592 |
out = {"field": field}
|
| 2593 |
+
if retype:
|
| 2594 |
+
out["retype"] = retype
|
| 2595 |
# ββ W41-T15 / C5 β the same report `add_field` carries, on the door that RETARGETS a link.
|
| 2596 |
# This one matters more: pointing an existing link at `customer_data` deletes the backlink the
|
| 2597 |
# old target held (the stale-inverse sweep) and cannot make a new one, so the column silently
|
platform/core/store.py
CHANGED
|
@@ -656,13 +656,15 @@ class Store:
|
|
| 656 |
def upload_bytes(self, path_in_repo, data, message=None):
|
| 657 |
"""Persist RAW BYTES at an arbitrary path in the tenant dataset (wave-8 C5, documents).
|
| 658 |
Bytes are never cached in-process: large, cold, fetched on explicit user action."""
|
|
|
|
|
|
|
|
|
|
| 659 |
if not self.available():
|
| 660 |
raise RuntimeError('No HF_TOKEN configured β persistence is unavailable.')
|
| 661 |
# D-315: a blob is still this tenant's data. β No `parent_commit` here and that is correct,
|
| 662 |
# not an omission: a blob is written at its own path and read whole, so two writers cannot
|
| 663 |
# silently merge into one damaged document the way `{name}.json` can. The concurrency
|
| 664 |
# hazard D-305 describes is specific to the shared JSON blob.
|
| 665 |
-
data_binding.check_write(self.repo, f'write bytes {path_in_repo!r}')
|
| 666 |
with self._lock:
|
| 667 |
self._ensure_repo()
|
| 668 |
HfApi(token=_token()).upload_file(
|
|
@@ -693,9 +695,9 @@ class Store:
|
|
| 693 |
tolerable no-op. "You are not allowed to touch this store" is not a no-op β it is the
|
| 694 |
thing the operator has to be told.
|
| 695 |
"""
|
|
|
|
| 696 |
if not self.available():
|
| 697 |
return False
|
| 698 |
-
data_binding.check_write(self.repo, f'delete {path_in_repo!r}')
|
| 699 |
try:
|
| 700 |
HfApi(token=_token()).delete_file(
|
| 701 |
path_in_repo=path_in_repo, repo_id=self.repo, repo_type='dataset',
|
|
@@ -729,9 +731,9 @@ class Store:
|
|
| 729 |
have actually SEEN β and that any `update` queued behind it replays on top β instead of
|
| 730 |
being uploaded over an hour of unseen work.
|
| 731 |
"""
|
|
|
|
| 732 |
if not self.available():
|
| 733 |
raise RuntimeError('No HF_TOKEN configured β persistence is unavailable.')
|
| 734 |
-
data_binding.check_write(self.repo, f'put {name!r}')
|
| 735 |
with self._key_lock(name):
|
| 736 |
def _replace(_current):
|
| 737 |
return json.loads(json.dumps(data))
|
|
|
|
| 656 |
def upload_bytes(self, path_in_repo, data, message=None):
|
| 657 |
"""Persist RAW BYTES at an arbitrary path in the tenant dataset (wave-8 C5, documents).
|
| 658 |
Bytes are never cached in-process: large, cold, fetched on explicit user action."""
|
| 659 |
+
# Refusal dominates availability: an uncredentialed staging process must still learn that
|
| 660 |
+
# it is forbidden from targeting production, rather than receive a generic token error.
|
| 661 |
+
data_binding.check_write(self.repo, f'write bytes {path_in_repo!r}')
|
| 662 |
if not self.available():
|
| 663 |
raise RuntimeError('No HF_TOKEN configured β persistence is unavailable.')
|
| 664 |
# D-315: a blob is still this tenant's data. β No `parent_commit` here and that is correct,
|
| 665 |
# not an omission: a blob is written at its own path and read whole, so two writers cannot
|
| 666 |
# silently merge into one damaged document the way `{name}.json` can. The concurrency
|
| 667 |
# hazard D-305 describes is specific to the shared JSON blob.
|
|
|
|
| 668 |
with self._lock:
|
| 669 |
self._ensure_repo()
|
| 670 |
HfApi(token=_token()).upload_file(
|
|
|
|
| 695 |
tolerable no-op. "You are not allowed to touch this store" is not a no-op β it is the
|
| 696 |
thing the operator has to be told.
|
| 697 |
"""
|
| 698 |
+
data_binding.check_write(self.repo, f'delete {path_in_repo!r}')
|
| 699 |
if not self.available():
|
| 700 |
return False
|
|
|
|
| 701 |
try:
|
| 702 |
HfApi(token=_token()).delete_file(
|
| 703 |
path_in_repo=path_in_repo, repo_id=self.repo, repo_type='dataset',
|
|
|
|
| 731 |
have actually SEEN β and that any `update` queued behind it replays on top β instead of
|
| 732 |
being uploaded over an hour of unseen work.
|
| 733 |
"""
|
| 734 |
+
data_binding.check_write(self.repo, f'put {name!r}')
|
| 735 |
if not self.available():
|
| 736 |
raise RuntimeError('No HF_TOKEN configured β persistence is unavailable.')
|
|
|
|
| 737 |
with self._key_lock(name):
|
| 738 |
def _replace(_current):
|
| 739 |
return json.loads(json.dumps(data))
|
platform/core/user_tables.py
CHANGED
|
@@ -940,7 +940,9 @@ def checkbox_mark(field):
|
|
| 940 |
"""
|
| 941 |
f = field if isinstance(field, dict) else {}
|
| 942 |
return {'icon': f.get('icon') or CHECKBOX_ICON_DEFAULT,
|
| 943 |
-
|
|
|
|
|
|
|
| 944 |
|
| 945 |
|
| 946 |
def rating_symbol(field):
|
|
@@ -1209,7 +1211,7 @@ def clean_fields(raw):
|
|
| 1209 |
icon = str(f.get('icon') or '').strip().lower()
|
| 1210 |
if icon in CHECKBOX_ICONS:
|
| 1211 |
entry['icon'] = icon
|
| 1212 |
-
cb_color = str(f.get('color') or '').strip().upper()
|
| 1213 |
if _OPTION_COLOR_RE.fullmatch(cb_color):
|
| 1214 |
entry['color'] = cb_color
|
| 1215 |
if f.get('default') is True:
|
|
@@ -3066,7 +3068,14 @@ def _clean_field(raw, previous=None):
|
|
| 3066 |
or '').strip().lower()
|
| 3067 |
if icon in CHECKBOX_ICONS:
|
| 3068 |
out['icon'] = icon
|
| 3069 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3070 |
color = str(color_raw or '').strip().upper()
|
| 3071 |
if _OPTION_COLOR_RE.fullmatch(color):
|
| 3072 |
out['color'] = color
|
|
|
|
| 940 |
"""
|
| 941 |
f = field if isinstance(field, dict) else {}
|
| 942 |
return {'icon': f.get('icon') or CHECKBOX_ICON_DEFAULT,
|
| 943 |
+
# ``colour`` was accepted by an earlier field-editor draft. Read it for
|
| 944 |
+
# old definitions, but keep the product wire canonical as ``color``.
|
| 945 |
+
'color': f.get('color') or f.get('colour') or CHECKBOX_COLOR_DEFAULT}
|
| 946 |
|
| 947 |
|
| 948 |
def rating_symbol(field):
|
|
|
|
| 1211 |
icon = str(f.get('icon') or '').strip().lower()
|
| 1212 |
if icon in CHECKBOX_ICONS:
|
| 1213 |
entry['icon'] = icon
|
| 1214 |
+
cb_color = str(f.get('color') or f.get('colour') or '').strip().upper()
|
| 1215 |
if _OPTION_COLOR_RE.fullmatch(cb_color):
|
| 1216 |
entry['color'] = cb_color
|
| 1217 |
if f.get('default') is True:
|
|
|
|
| 3068 |
or '').strip().lower()
|
| 3069 |
if icon in CHECKBOX_ICONS:
|
| 3070 |
out['icon'] = icon
|
| 3071 |
+
# Accept the editor's British-spelling draft key as input as well as the
|
| 3072 |
+
# established API spelling, then persist one canonical ``color`` key.
|
| 3073 |
+
if 'color' in raw:
|
| 3074 |
+
color_raw = raw.get('color')
|
| 3075 |
+
elif 'colour' in raw:
|
| 3076 |
+
color_raw = raw.get('colour')
|
| 3077 |
+
else:
|
| 3078 |
+
color_raw = prev.get('color') or prev.get('colour')
|
| 3079 |
color = str(color_raw or '').strip().upper()
|
| 3080 |
if _OPTION_COLOR_RE.fullmatch(color):
|
| 3081 |
out['color'] = color
|
platform/harness/measure_filter.py
CHANGED
|
@@ -559,6 +559,12 @@ def resolve_pair(rule, today, team_id=None, allowed_pids=None):
|
|
| 559 |
f"question, and both look like they work.")
|
| 560 |
cmp = _CMP[op]
|
| 561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
pool = _pool_of(allowed_pids)
|
| 563 |
con = _sem._store_con()
|
| 564 |
try:
|
|
|
|
| 559 |
f"question, and both look like they work.")
|
| 560 |
cmp = _CMP[op]
|
| 561 |
|
| 562 |
+
# Validate BOTH windows before touching the store. Otherwise a malformed right-hand
|
| 563 |
+
# condition can be hidden by an unrelated cache-warming error from the left-hand query,
|
| 564 |
+
# leaving the caller with neither its refusal nor a safe answer.
|
| 565 |
+
_query_parts(lkey, rule.get('window'), today, team_id)
|
| 566 |
+
_query_parts(rkey, rhs.get('window'), today, team_id)
|
| 567 |
+
|
| 568 |
pool = _pool_of(allowed_pids)
|
| 569 |
con = _sem._store_con()
|
| 570 |
try:
|
platform/harness/runtime.py
CHANGED
|
@@ -267,7 +267,11 @@ class TenantRuntime:
|
|
| 267 |
first_odoo = next((e["id"] for e in entries if e.get("type") == "odoo"), None)
|
| 268 |
if first_odoo and keychain.unlocked():
|
| 269 |
return first_odoo
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
return ENV_ODOO_FLAG_KEY
|
| 272 |
return None
|
| 273 |
|
|
|
|
| 267 |
first_odoo = next((e["id"] for e in entries if e.get("type") == "odoo"), None)
|
| 268 |
if first_odoo and keychain.unlocked():
|
| 269 |
return first_odoo
|
| 270 |
+
# Tenant #0's compiled connector is the ENV fallback. Its presence is the
|
| 271 |
+
# capability contract for the system card; consulting ODOO_URL here instead
|
| 272 |
+
# makes the card (and its pause address) depend on the host process rather
|
| 273 |
+
# than the same source that odoo_source() resolves.
|
| 274 |
+
if self.key == "royal-imports" and self.odoo_source() is not None:
|
| 275 |
return ENV_ODOO_FLAG_KEY
|
| 276 |
return None
|
| 277 |
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import { FieldAgentChat } from "./FieldAgentChat";
|
|
| 6 |
import type { FieldProposal } from "./fieldAgent";
|
| 7 |
import { AnchoredOverlay } from "./OverlaySurface";
|
| 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
|
|
@@ -106,7 +106,13 @@ interface FieldConfigExtra {
|
|
| 106 |
* None for a link with no bag, so a create without this is a column that never appears. */
|
| 107 |
/** ββ W42-T08 (R1, C1) β plus, OPTIONALLY, the two-hop `via`. Absent is today's one-hop
|
| 108 |
* join, byte-identical to what this menu emitted before the hop existed. */
|
| 109 |
-
link?: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
/** β 2026-08-07 β a `rollup` column's aggregate. Same requirement, same reason.
|
| 111 |
* β 2026-08-09 β or, INSTEAD, a `source` binding: the read-through kind, which summarises
|
| 112 |
* rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
|
|
@@ -324,20 +330,11 @@ interface ColumnMenuProps {
|
|
| 324 |
* DIFFERENT ACT from `onDelete`, and therefore a different door: it destroys no definition and
|
| 325 |
* no values, and pointing "Remove from my fields" at the delete handler would be a benign word
|
| 326 |
* on a destructive action.
|
| 327 |
-
*
|
| 328 |
-
*
|
| 329 |
-
*
|
| 330 |
-
* at all: on screen, indistinguishable from a feature nobody built. Required means an unfed
|
| 331 |
-
* mount stops compiling instead of quietly offering nothing.
|
| 332 |
-
*
|
| 333 |
-
* β WHAT IT MAY NOT BE IS A SILENT NO-OP. No server door drops you off a field's own grant
|
| 334 |
-
* list: `PUT /share/{kind}/{oid}` needs `may_administer`, which a plain grantee does not hold,
|
| 335 |
-
* and sending `entries: []` there is the OWNER revoking everyone, a different act. So the host
|
| 336 |
-
* hands this the same refusal `useFieldBulkDoors::onRemoveField` already speaks for the Fields
|
| 337 |
-
* manager, which says so in one sentence. A button that renders, is pressed, and does nothing
|
| 338 |
-
* is strictly worse than a button that is absent.
|
| 339 |
*/
|
| 340 |
-
onRemoveFromMine: () => void;
|
| 341 |
/** ββ Wave-34 (R13) β run this AI enrichment column over many rows.
|
| 342 |
* `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI
|
| 343 |
* wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows
|
|
@@ -1147,6 +1144,8 @@ function TypePicker({
|
|
| 1147 |
type="button"
|
| 1148 |
role="option"
|
| 1149 |
aria-selected={value === r.value}
|
|
|
|
|
|
|
| 1150 |
className={"cg-type-row" + (value === r.value ? " is-on" : "")}
|
| 1151 |
onClick={() => onPick(r.value)}
|
| 1152 |
>
|
|
@@ -1162,30 +1161,19 @@ function TypePicker({
|
|
| 1162 |
}
|
| 1163 |
size={16}
|
| 1164 |
/>
|
| 1165 |
-
{/*
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
β The layout is stated here rather than in index.css because that stylesheet
|
| 1170 |
-
belongs to another fence this wave; `.cg-type-label` keeps its own `flex: 1`
|
| 1171 |
-
and only its single-line clamp is lifted, which is the property a second line
|
| 1172 |
-
needs. */}
|
| 1173 |
<span
|
| 1174 |
-
className="cg-type-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
whiteSpace: "normal", overflow: "visible", minWidth: 0,
|
| 1178 |
-
}}
|
| 1179 |
>
|
| 1180 |
-
<
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
fontWeight: 400, lineHeight: 1.3,
|
| 1185 |
-
}}
|
| 1186 |
-
>
|
| 1187 |
-
{TYPE_DESCRIPTIONS[r.value]}
|
| 1188 |
-
</span>
|
| 1189 |
</span>
|
| 1190 |
{value === r.value && (
|
| 1191 |
<svg
|
|
@@ -1556,6 +1544,10 @@ function ExtraTypeEditor({
|
|
| 1556 |
onLinkTable,
|
| 1557 |
linkSingle = false,
|
| 1558 |
onLinkSingle,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1559 |
hop,
|
| 1560 |
rollupPreset = null,
|
| 1561 |
rollupMode = "link",
|
|
@@ -1624,6 +1616,10 @@ function ExtraTypeEditor({
|
|
| 1624 |
onLinkTable?: (v: string) => void;
|
| 1625 |
linkSingle?: boolean;
|
| 1626 |
onLinkSingle?: (v: boolean) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1627 |
/**
|
| 1628 |
* ββ W42-T08 (R1, C1 + C2) β the HOP question, and it is REQUIRED at every mount on purpose.
|
| 1629 |
*
|
|
@@ -2045,6 +2041,19 @@ function ExtraTypeEditor({
|
|
| 2045 |
/>
|
| 2046 |
<span>Allow only one linked record</span>
|
| 2047 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2048 |
{/* ββ THIS SENTENCE IS FALSE ONCE A HOP IS SET, so it is not shown then. With a hop the
|
| 2049 |
cell does not hold "the records you pick": it holds the records the middle step
|
| 2050 |
resolves, and a panel asserting otherwise beside the control that changed it is the
|
|
@@ -2812,6 +2821,8 @@ export default function ColumnMenu({
|
|
| 2812 |
*/
|
| 2813 |
const [linkTable, setLinkTable] = useState("");
|
| 2814 |
const [linkSingle, setLinkSingle] = useState(false);
|
|
|
|
|
|
|
| 2815 |
// ββ W42-T08 (R1, C1) β the HOP draft for the create pane. `topic: ""` is "no hop", and it is
|
| 2816 |
// the default, so a Link field created without touching this question emits exactly the bag it
|
| 2817 |
// emitted before this existed.
|
|
@@ -2976,6 +2987,12 @@ export default function ColumnMenu({
|
|
| 2976 |
});
|
| 2977 |
const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
|
| 2978 |
const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2979 |
// ββ W42-T08 β SEEDED FROM THE STORED BAG, and this is not a nicety. The relational save
|
| 2980 |
// below is a REBUILD: it re-emits the whole `link` bag, so a hop the pane never read would be
|
| 2981 |
// DELETED by the one click meant to rename the column. Round-tripping it is what makes the
|
|
@@ -3173,6 +3190,9 @@ export default function ColumnMenu({
|
|
| 3173 |
link: {
|
| 3174 |
table: linkTable,
|
| 3175 |
...(linkSingle ? { single: true } : {}),
|
|
|
|
|
|
|
|
|
|
| 3176 |
...(via ? { via } : {}),
|
| 3177 |
},
|
| 3178 |
};
|
|
@@ -3420,6 +3440,8 @@ export default function ColumnMenu({
|
|
| 3420 |
? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
|
| 3421 |
: editLinkTable !== (field.link?.table ?? "") ||
|
| 3422 |
editLinkSingle !== (field.link?.single === true) ||
|
|
|
|
|
|
|
| 3423 |
// β COMPARED AS THE BAG, NOT AS THE DRAFT. `hopDraftFrom` normalises an absent `via` and
|
| 3424 |
// an absent `window` to the same seeded draft the empty one produces, so comparing the
|
| 3425 |
// drafts by `JSON.stringify` would report a hop change on a pane nobody touched, which is
|
|
@@ -3510,6 +3532,10 @@ export default function ColumnMenu({
|
|
| 3510 |
// refuses the second.
|
| 3511 |
: { link: { table: editLinkTable,
|
| 3512 |
...(editLinkSingle ? { single: true } : {}),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3513 |
...(hopVia(editHopDraft, hopScopeKey)
|
| 3514 |
? { via: hopVia(editHopDraft, hopScopeKey)! }
|
| 3515 |
: {}) } }),
|
|
@@ -3872,9 +3898,16 @@ export default function ColumnMenu({
|
|
| 3872 |
onAutomationUrlField={setAutomationUrlField}
|
| 3873 |
linkTargets={linkTargets}
|
| 3874 |
linkTable={linkTable}
|
| 3875 |
-
onLinkTable={
|
|
|
|
|
|
|
|
|
|
| 3876 |
linkSingle={linkSingle}
|
| 3877 |
onLinkSingle={setLinkSingle}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3878 |
hop={{
|
| 3879 |
topics: hopTopics,
|
| 3880 |
// The hop travels through a governed topic, so its window vocabulary is the one the
|
|
@@ -4192,9 +4225,16 @@ export default function ColumnMenu({
|
|
| 4192 |
onCodeLanguage={setEditCodeLanguage}
|
| 4193 |
idPrefix="cg-edit"
|
| 4194 |
linkTable={editLinkTable}
|
| 4195 |
-
onLinkTable={
|
|
|
|
|
|
|
|
|
|
| 4196 |
linkSingle={editLinkSingle}
|
| 4197 |
onLinkSingle={setEditLinkSingle}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4198 |
hop={{
|
| 4199 |
topics: hopTopics,
|
| 4200 |
windows: rollupSourceOffer.windows,
|
|
|
|
| 6 |
import type { FieldProposal } from "./fieldAgent";
|
| 7 |
import { AnchoredOverlay } from "./OverlaySurface";
|
| 8 |
import type { AnchorRect } from "./OverlaySurface";
|
| 9 |
+
import { FieldTypeIcon, InfoMark, 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
|
|
|
|
| 106 |
* None for a link with no bag, so a create without this is a column that never appears. */
|
| 107 |
/** ββ W42-T08 (R1, C1) β plus, OPTIONALLY, the two-hop `via`. Absent is today's one-hop
|
| 108 |
* join, byte-identical to what this menu emitted before the hop existed. */
|
| 109 |
+
link?: {
|
| 110 |
+
table: string;
|
| 111 |
+
single?: boolean;
|
| 112 |
+
via?: HopViaBag;
|
| 113 |
+
conditions?: RollupCondition[];
|
| 114 |
+
conditionConj?: "and" | "or";
|
| 115 |
+
};
|
| 116 |
/** β 2026-08-07 β a `rollup` column's aggregate. Same requirement, same reason.
|
| 117 |
* β 2026-08-09 β or, INSTEAD, a `source` binding: the read-through kind, which summarises
|
| 118 |
* rows that were never copied into the workspace. The host's `_clean_rollup` reads `source`
|
|
|
|
| 330 |
* DIFFERENT ACT from `onDelete`, and therefore a different door: it destroys no definition and
|
| 331 |
* no values, and pointing "Remove from my fields" at the delete handler would be a benign word
|
| 332 |
* on a destructive action.
|
| 333 |
+
* A host supplies this only when it has a real self-removal server door. The current API does
|
| 334 |
+
* not permit a grantee to withdraw only themself, so the grid intentionally leaves the row
|
| 335 |
+
* absent instead of wiring it to a toast or a destructive share rewrite.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
*/
|
| 337 |
+
onRemoveFromMine?: () => void;
|
| 338 |
/** ββ Wave-34 (R13) β run this AI enrichment column over many rows.
|
| 339 |
* `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI
|
| 340 |
* wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows
|
|
|
|
| 1144 |
type="button"
|
| 1145 |
role="option"
|
| 1146 |
aria-selected={value === r.value}
|
| 1147 |
+
aria-label={r.label}
|
| 1148 |
+
aria-describedby={`cg-type-description-${r.value}`}
|
| 1149 |
className={"cg-type-row" + (value === r.value ? " is-on" : "")}
|
| 1150 |
onClick={() => onPick(r.value)}
|
| 1151 |
>
|
|
|
|
| 1161 |
}
|
| 1162 |
size={16}
|
| 1163 |
/>
|
| 1164 |
+
{/* The type name is all this dense picker needs to scan. R24's load-bearing
|
| 1165 |
+
explanation lives on the compact (i): native title makes it discoverable on
|
| 1166 |
+
hover, while aria-describedby keeps it attached to the focused option. */}
|
| 1167 |
+
<span className="cg-type-label">{r.label}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
<span
|
| 1169 |
+
className="cg-type-info"
|
| 1170 |
+
title={TYPE_DESCRIPTIONS[r.value]}
|
| 1171 |
+
aria-hidden="true"
|
|
|
|
|
|
|
| 1172 |
>
|
| 1173 |
+
<InfoMark />
|
| 1174 |
+
</span>
|
| 1175 |
+
<span id={`cg-type-description-${r.value}`} className="cg-sr-only">
|
| 1176 |
+
{TYPE_DESCRIPTIONS[r.value]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
</span>
|
| 1178 |
{value === r.value && (
|
| 1179 |
<svg
|
|
|
|
| 1544 |
onLinkTable,
|
| 1545 |
linkSingle = false,
|
| 1546 |
onLinkSingle,
|
| 1547 |
+
linkConditions = [],
|
| 1548 |
+
onLinkConditions,
|
| 1549 |
+
linkConditionConj = "and",
|
| 1550 |
+
onLinkConditionConj,
|
| 1551 |
hop,
|
| 1552 |
rollupPreset = null,
|
| 1553 |
rollupMode = "link",
|
|
|
|
| 1616 |
onLinkTable?: (v: string) => void;
|
| 1617 |
linkSingle?: boolean;
|
| 1618 |
onLinkSingle?: (v: boolean) => void;
|
| 1619 |
+
linkConditions?: RollupCondition[];
|
| 1620 |
+
onLinkConditions?: (v: RollupCondition[]) => void;
|
| 1621 |
+
linkConditionConj?: "and" | "or";
|
| 1622 |
+
onLinkConditionConj?: (v: "and" | "or") => void;
|
| 1623 |
/**
|
| 1624 |
* ββ W42-T08 (R1, C1 + C2) β the HOP question, and it is REQUIRED at every mount on purpose.
|
| 1625 |
*
|
|
|
|
| 2041 |
/>
|
| 2042 |
<span>Allow only one linked record</span>
|
| 2043 |
</label>
|
| 2044 |
+
{linkTable ? (
|
| 2045 |
+
<RollupConditionList
|
| 2046 |
+
idPrefix={`${idPrefix}-link-condition`}
|
| 2047 |
+
title="Offer only"
|
| 2048 |
+
hint="Applied when choosing records. Links already saved in this column stay visible."
|
| 2049 |
+
conditions={linkConditions}
|
| 2050 |
+
onConditions={(value) => onLinkConditions?.(value)}
|
| 2051 |
+
conj={linkConditionConj}
|
| 2052 |
+
onConj={(value) => onLinkConditionConj?.(value)}
|
| 2053 |
+
targetFields={hopTargetFields}
|
| 2054 |
+
allowRef={false}
|
| 2055 |
+
/>
|
| 2056 |
+
) : null}
|
| 2057 |
{/* ββ THIS SENTENCE IS FALSE ONCE A HOP IS SET, so it is not shown then. With a hop the
|
| 2058 |
cell does not hold "the records you pick": it holds the records the middle step
|
| 2059 |
resolves, and a panel asserting otherwise beside the control that changed it is the
|
|
|
|
| 2821 |
*/
|
| 2822 |
const [linkTable, setLinkTable] = useState("");
|
| 2823 |
const [linkSingle, setLinkSingle] = useState(false);
|
| 2824 |
+
const [linkConditions, setLinkConditions] = useState<RollupCondition[]>([]);
|
| 2825 |
+
const [linkConditionConj, setLinkConditionConj] = useState<"and" | "or">("and");
|
| 2826 |
// ββ W42-T08 (R1, C1) β the HOP draft for the create pane. `topic: ""` is "no hop", and it is
|
| 2827 |
// the default, so a Link field created without touching this question emits exactly the bag it
|
| 2828 |
// emitted before this existed.
|
|
|
|
| 2987 |
});
|
| 2988 |
const [editLinkTable, setEditLinkTable] = useState(() => field.link?.table ?? "");
|
| 2989 |
const [editLinkSingle, setEditLinkSingle] = useState(() => field.link?.single === true);
|
| 2990 |
+
const [editLinkConditions, setEditLinkConditions] = useState<RollupCondition[]>(
|
| 2991 |
+
() => field.link?.conditions ?? []
|
| 2992 |
+
);
|
| 2993 |
+
const [editLinkConditionConj, setEditLinkConditionConj] = useState<"and" | "or">(
|
| 2994 |
+
() => field.link?.conditionConj ?? "and"
|
| 2995 |
+
);
|
| 2996 |
// ββ W42-T08 β SEEDED FROM THE STORED BAG, and this is not a nicety. The relational save
|
| 2997 |
// below is a REBUILD: it re-emits the whole `link` bag, so a hop the pane never read would be
|
| 2998 |
// DELETED by the one click meant to rename the column. Round-tripping it is what makes the
|
|
|
|
| 3190 |
link: {
|
| 3191 |
table: linkTable,
|
| 3192 |
...(linkSingle ? { single: true } : {}),
|
| 3193 |
+
...(linkConditions.length
|
| 3194 |
+
? { conditions: linkConditions, conditionConj: linkConditionConj }
|
| 3195 |
+
: {}),
|
| 3196 |
...(via ? { via } : {}),
|
| 3197 |
},
|
| 3198 |
};
|
|
|
|
| 3440 |
? JSON.stringify(editRollup) !== JSON.stringify(field.rollup ?? {})
|
| 3441 |
: editLinkTable !== (field.link?.table ?? "") ||
|
| 3442 |
editLinkSingle !== (field.link?.single === true) ||
|
| 3443 |
+
JSON.stringify(editLinkConditions) !== JSON.stringify(field.link?.conditions ?? []) ||
|
| 3444 |
+
editLinkConditionConj !== (field.link?.conditionConj ?? "and") ||
|
| 3445 |
// β COMPARED AS THE BAG, NOT AS THE DRAFT. `hopDraftFrom` normalises an absent `via` and
|
| 3446 |
// an absent `window` to the same seeded draft the empty one produces, so comparing the
|
| 3447 |
// drafts by `JSON.stringify` would report a hop change on a pane nobody touched, which is
|
|
|
|
| 3532 |
// refuses the second.
|
| 3533 |
: { link: { table: editLinkTable,
|
| 3534 |
...(editLinkSingle ? { single: true } : {}),
|
| 3535 |
+
...(editLinkConditions.length
|
| 3536 |
+
? { conditions: editLinkConditions,
|
| 3537 |
+
conditionConj: editLinkConditionConj }
|
| 3538 |
+
: {}),
|
| 3539 |
...(hopVia(editHopDraft, hopScopeKey)
|
| 3540 |
? { via: hopVia(editHopDraft, hopScopeKey)! }
|
| 3541 |
: {}) } }),
|
|
|
|
| 3898 |
onAutomationUrlField={setAutomationUrlField}
|
| 3899 |
linkTargets={linkTargets}
|
| 3900 |
linkTable={linkTable}
|
| 3901 |
+
onLinkTable={(value) => {
|
| 3902 |
+
setLinkTable(value);
|
| 3903 |
+
setLinkConditions([]);
|
| 3904 |
+
}}
|
| 3905 |
linkSingle={linkSingle}
|
| 3906 |
onLinkSingle={setLinkSingle}
|
| 3907 |
+
linkConditions={linkConditions}
|
| 3908 |
+
onLinkConditions={setLinkConditions}
|
| 3909 |
+
linkConditionConj={linkConditionConj}
|
| 3910 |
+
onLinkConditionConj={setLinkConditionConj}
|
| 3911 |
hop={{
|
| 3912 |
topics: hopTopics,
|
| 3913 |
// The hop travels through a governed topic, so its window vocabulary is the one the
|
|
|
|
| 4225 |
onCodeLanguage={setEditCodeLanguage}
|
| 4226 |
idPrefix="cg-edit"
|
| 4227 |
linkTable={editLinkTable}
|
| 4228 |
+
onLinkTable={(value) => {
|
| 4229 |
+
setEditLinkTable(value);
|
| 4230 |
+
setEditLinkConditions([]);
|
| 4231 |
+
}}
|
| 4232 |
linkSingle={editLinkSingle}
|
| 4233 |
onLinkSingle={setEditLinkSingle}
|
| 4234 |
+
linkConditions={editLinkConditions}
|
| 4235 |
+
onLinkConditions={setEditLinkConditions}
|
| 4236 |
+
linkConditionConj={editLinkConditionConj}
|
| 4237 |
+
onLinkConditionConj={setEditLinkConditionConj}
|
| 4238 |
hop={{
|
| 4239 |
topics: hopTopics,
|
| 4240 |
windows: rollupSourceOffer.windows,
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -70,8 +70,8 @@ import {
|
|
| 70 |
} from "./scriptViews";
|
| 71 |
import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews";
|
| 72 |
import { defaultViewConfig, headerMarksFor, useGridColumns } from "./useGridColumns";
|
| 73 |
-
import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay,
|
| 74 |
-
|
| 75 |
import { computeAggs } from "./aggregations";
|
| 76 |
import type { CohortSets } from "./useVisibleRows";
|
| 77 |
import type { MeasureSets } from "./useVisibleRows";
|
|
@@ -82,6 +82,8 @@ import JsonViewer from "./JsonViewer";
|
|
| 82 |
import RecordDetail from "./RecordDetail";
|
| 83 |
import ViewSidebar from "./ViewSidebar";
|
| 84 |
import ColumnMenu from "./ColumnMenu";
|
|
|
|
|
|
|
| 85 |
import type { ColumnMenuState, HopTopic } from "./ColumnMenu";
|
| 86 |
import { HEADER_ICONS } from "./iconShapes";
|
| 87 |
import { emitHostEvent, eventId } from "./hostBridge";
|
|
@@ -173,7 +175,7 @@ import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName,
|
|
| 173 |
viewDisplayName,
|
| 174 |
/* β W37-T26 (C4) β the view-membership leaf's two pure helpers. `viewRefsOf` is the ONE walk
|
| 175 |
the resolver, the cycle detector and the gate share; `viewFilterCycle` is the refusal. */
|
| 176 |
-
viewFilterCycle, viewRefsOf } from "./types";
|
| 177 |
/* ββ W41-T42 (owner instruction 23) β the pivot's whole vocabulary is DECLARED in `types.ts`
|
| 178 |
and only consumed here, because W41-T43 (this file) and W41-T44 (`RecordDetail.tsx`) open the
|
| 179 |
same feature through other doors and neither of them may edit `types.ts`. */
|
|
@@ -193,6 +195,7 @@ import type {
|
|
| 193 |
PivotWindow,
|
| 194 |
Row,
|
| 195 |
RowHeightMode,
|
|
|
|
| 196 |
SavedView,
|
| 197 |
ViewConfig,
|
| 198 |
} from "./types";
|
|
@@ -735,18 +738,10 @@ export function fieldDeleteDoor(
|
|
| 735 |
* would take the column off the screen while it still exists in the store, so the refusal would
|
| 736 |
* read as a success until the next workspace fetch put the column back.
|
| 737 |
*
|
| 738 |
-
*
|
| 739 |
-
*
|
| 740 |
-
*
|
| 741 |
-
*
|
| 742 |
-
* and the only opener this tree may reach is the `aios:share-open` event, which carries ONE oid.
|
| 743 |
-
* Firing it five times is exactly the five-dialog experience R3 asked to be replaced, so these
|
| 744 |
-
* say so in one sentence instead. `onRemoveField` has no door at all: dropping yourself from a
|
| 745 |
-
* colleague's grant list needs `may_administer`, which a plain grantee does not hold.
|
| 746 |
-
*
|
| 747 |
-
* β NOT CALLED FROM THIS COMPONENT YET, and that is a fence, not an oversight:
|
| 748 |
-
* `customer-grid/Toolbar.tsx` declares none of the five props and is the only thing standing
|
| 749 |
-
* between these doors and the panel. Wiring it is the next ticket's; see the handoff.
|
| 750 |
*/
|
| 751 |
export interface FieldBulkDoorDeps {
|
| 752 |
config: ViewConfig;
|
|
@@ -765,10 +760,7 @@ export interface FieldBulkDoorDeps {
|
|
| 765 |
|
| 766 |
export interface FieldBulkDoors {
|
| 767 |
onSetFieldsHidden: (keys: string[], hidden: boolean) => void;
|
| 768 |
-
onShareFields: (keys: string[]) => void;
|
| 769 |
-
onReassignFields: (keys: string[]) => void;
|
| 770 |
onDeleteFields: (keys: string[]) => void;
|
| 771 |
-
onRemoveField: (key: string) => void;
|
| 772 |
}
|
| 773 |
|
| 774 |
/**
|
|
@@ -900,30 +892,9 @@ export function useFieldBulkDoors(deps: FieldBulkDoorDeps): FieldBulkDoors {
|
|
| 900 |
stampFieldDelete, updateConfig, visible]
|
| 901 |
);
|
| 902 |
|
| 903 |
-
const onShareFields = useCallback((keys: string[]) => {
|
| 904 |
-
if (keys.length === 0) return;
|
| 905 |
-
signal(TOAST_EVENT,
|
| 906 |
-
"Sharing several fields in one go is not available yet, so share them one at a time from " +
|
| 907 |
-
"each column's own menu.");
|
| 908 |
-
}, []);
|
| 909 |
-
|
| 910 |
-
const onReassignFields = useCallback((keys: string[]) => {
|
| 911 |
-
if (keys.length === 0) return;
|
| 912 |
-
signal(TOAST_EVENT,
|
| 913 |
-
"Handing several fields to a new owner in one go is not available yet, so change the " +
|
| 914 |
-
"owner one column at a time from its share panel.");
|
| 915 |
-
}, []);
|
| 916 |
-
|
| 917 |
-
const onRemoveField = useCallback((key: string) => {
|
| 918 |
-
if (!key) return;
|
| 919 |
-
signal(TOAST_EVENT,
|
| 920 |
-
"Taking a shared field off your own list is not available yet, so hide it or ask the " +
|
| 921 |
-
"person who owns it to withdraw it.");
|
| 922 |
-
}, []);
|
| 923 |
-
|
| 924 |
return useMemo(
|
| 925 |
-
() => ({ onSetFieldsHidden,
|
| 926 |
-
[onDeleteFields,
|
| 927 |
);
|
| 928 |
}
|
| 929 |
|
|
@@ -1235,6 +1206,9 @@ interface CustomerGridProps {
|
|
| 1235 |
/** W42-T29 β the SOURCE record, withheld from its own candidate list so a record cannot be
|
| 1236 |
* offered as a link to itself. Applied on top of any projection above. */
|
| 1237 |
embeddedExcludeRecordId?: number;
|
|
|
|
|
|
|
|
|
|
| 1238 |
}
|
| 1239 |
|
| 1240 |
/**
|
|
@@ -1275,10 +1249,17 @@ export function scopeForLinkTable(table: unknown): SurfaceScope | undefined {
|
|
| 1275 |
return table as `ut_${string}`;
|
| 1276 |
}
|
| 1277 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
interface LinkGridModalProps {
|
| 1279 |
label: string;
|
| 1280 |
table: SurfaceScope;
|
| 1281 |
recordIds: readonly number[];
|
|
|
|
|
|
|
| 1282 |
editable?: boolean;
|
| 1283 |
single?: boolean;
|
| 1284 |
/** The source record, when this picker draws the source record's OWN database. */
|
|
@@ -1287,8 +1268,8 @@ interface LinkGridModalProps {
|
|
| 1287 |
onClose: () => void;
|
| 1288 |
}
|
| 1289 |
|
| 1290 |
-
function LinkGridModal({ label, table, recordIds,
|
| 1291 |
-
excludeRecordId, onSave, onClose }: LinkGridModalProps) {
|
| 1292 |
const panelRef = useRef<HTMLDivElement>(null);
|
| 1293 |
const [selectedIds, setSelectedIds] = useState<number[]>(() => [...recordIds]);
|
| 1294 |
const changeSelectedIds = useCallback((ids: number[]) => {
|
|
@@ -1348,6 +1329,8 @@ function LinkGridModal({ label, table, recordIds, editable = false, single = fal
|
|
| 1348 |
embedded
|
| 1349 |
embeddedRecordIds={editable ? undefined : recordIds}
|
| 1350 |
embeddedExcludeRecordId={editable ? excludeRecordId : undefined}
|
|
|
|
|
|
|
| 1351 |
embeddedSelectable={editable}
|
| 1352 |
embeddedSelectedIds={selectedIds}
|
| 1353 |
onEmbeddedSelectionChange={changeSelectedIds}
|
|
@@ -1425,6 +1408,8 @@ function CustomerGridSurface({
|
|
| 1425 |
embeddedSelectedIds = [],
|
| 1426 |
onEmbeddedSelectionChange,
|
| 1427 |
embeddedExcludeRecordId,
|
|
|
|
|
|
|
| 1428 |
}: CustomerGridProps = {}) {
|
| 1429 |
const isQueryPreview = queryBinding !== undefined;
|
| 1430 |
/**
|
|
@@ -1822,6 +1807,19 @@ function CustomerGridSurface({
|
|
| 1822 |
? `query:${queryBinding.workspaceBinding.key}`
|
| 1823 |
: payload?.workspace?.storageKey ?? `${LOCAL_KEY_PREFIX}${scope}`;
|
| 1824 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1825 |
// Initialize once per permission/data scope. Host state wins; local state
|
| 1826 |
// fills only missing objects and keeps the standalone path useful.
|
| 1827 |
useEffect(() => {
|
|
@@ -2256,6 +2254,45 @@ function CustomerGridSurface({
|
|
| 2256 |
const isUserTable = !embedded && scope.startsWith("ut_");
|
| 2257 |
const recordsMutable = payload?.recordsMutable !== false;
|
| 2258 |
const canMutateRecords = isUserTable && recordsMutable;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2259 |
/**
|
| 2260 |
* ββ W42-T12 (D-485, amendment A20) β THE FIELD MANAGER'S FIVE DOORS, CALLED AT LAST.
|
| 2261 |
*
|
|
@@ -2590,6 +2627,24 @@ function CustomerGridSurface({
|
|
| 2590 |
if (displayMode !== "map") setRoutePanelOpen(false);
|
| 2591 |
}, [displayMode]);
|
| 2592 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2593 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2594 |
// ββ WAVE 30 Β· W30-T42 (contract C2) β PAGING A READ-THROUGH GRID.
|
| 2595 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -2601,20 +2656,22 @@ function CustomerGridSurface({
|
|
| 2601 |
// question. So the predicate has to reach the evaluator that CAN answer it, which is SQL.
|
| 2602 |
//
|
| 2603 |
// β THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`).
|
| 2604 |
-
const windowPredicate = useMemo(
|
| 2605 |
-
|
| 2606 |
-
|
| 2607 |
-
|
|
|
|
| 2608 |
const windowRequest = useCallback(
|
| 2609 |
(offset: number) => ({
|
| 2610 |
offset,
|
| 2611 |
limit: WINDOW_ROWS,
|
| 2612 |
-
filters:
|
| 2613 |
-
filterConj:
|
| 2614 |
sorts: config.sorts,
|
| 2615 |
search,
|
|
|
|
| 2616 |
}),
|
| 2617 |
-
[
|
| 2618 |
);
|
| 2619 |
const sentPredicate = useRef<string | null>(null);
|
| 2620 |
useEffect(() => {
|
|
@@ -2775,6 +2832,23 @@ function CustomerGridSurface({
|
|
| 2775 |
});
|
| 2776 |
}, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
|
| 2777 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2778 |
/**
|
| 2779 |
* ββ W37-T26 / CONTRACT C4 β `view id -> the pids that view resolves to`, for the VIEW
|
| 2780 |
* MEMBERSHIP leaf (`Where View is [some view]`).
|
|
@@ -2872,16 +2946,17 @@ function CustomerGridSurface({
|
|
| 2872 |
[activeViewId, views, config, updateConfig]
|
| 2873 |
);
|
| 2874 |
|
| 2875 |
-
const
|
| 2876 |
// β W37-T26 (C4) β `viewSets` rides this context too. Without it a view leaf would ALWAYS
|
| 2877 |
// count as unresolved (the chip would cry wolf on a working filter) and, worse, a leaf naming
|
| 2878 |
// a DELETED view would look identical to one naming a live one.
|
| 2879 |
// ββ D-229 β `fieldByKey` rides this context so a condition naming a column this table no
|
| 2880 |
// longer has is COUNTED. `evalNode` ignores such a leaf (correctly), which means the grid was
|
| 2881 |
// showing MORE rows than the filter panel claimed with nothing on screen saying so.
|
| 2882 |
-
() =>
|
| 2883 |
[config.filters, cohortSets, viewSets, fieldByKey, today]
|
| 2884 |
);
|
|
|
|
| 2885 |
|
| 2886 |
// Wave-2 item 2c β COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
|
| 2887 |
// derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
|
|
@@ -2905,9 +2980,9 @@ function CustomerGridSurface({
|
|
| 2905 |
const scopedRows = useMemo(
|
| 2906 |
() =>
|
| 2907 |
cohortMode
|
| 2908 |
-
?
|
| 2909 |
-
:
|
| 2910 |
-
[cohortMode,
|
| 2911 |
);
|
| 2912 |
|
| 2913 |
const { visibleRows, pidToIndex } = useVisibleRows(
|
|
@@ -3400,7 +3475,7 @@ function CustomerGridSurface({
|
|
| 3400 |
embeddedSelectable ? embeddedSelectedIds : [],
|
| 3401 |
!embedded ? `aios:grid-selection:${scope}` : undefined
|
| 3402 |
);
|
| 3403 |
-
const embeddedSelectedKey =
|
| 3404 |
const selectedPidKey = [...selectedPids].join(",");
|
| 3405 |
useEffect(() => {
|
| 3406 |
if (!embeddedSelectable) return;
|
|
@@ -3409,8 +3484,13 @@ function CustomerGridSurface({
|
|
| 3409 |
}, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
|
| 3410 |
useEffect(() => {
|
| 3411 |
if (!embeddedSelectable) return;
|
| 3412 |
-
|
| 3413 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3414 |
/* ββββββββββββββββββββββββ owner item 16 / R4 / C-UNDO ββββββββββββββββββββββββ
|
| 3415 |
THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
|
| 3416 |
thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
|
|
@@ -8258,18 +8338,21 @@ function CustomerGridSurface({
|
|
| 8258 |
if (target && target.shared && target.kind === "route_order") deleteRouteField(key);
|
| 8259 |
else deleteField(key);
|
| 8260 |
}}
|
| 8261 |
-
/* ββ W42-T12 (D-485, amendment A20) β THE FIELD MANAGER'S FIVE DOORS AND WHO IS
|
| 8262 |
-
LOOKING. This is the mount that renders the manager, so this is the mount that has
|
| 8263 |
-
to feed it: a ticked selection with Hide, Show, Share, Reassign or Delete behind it
|
| 8264 |
-
now reaches `useFieldBulkDoors`, which batches each act into a single config write.
|
| 8265 |
-
`viewer` is what lets contract C2's rights pair be asked per row, so a colleague's
|
| 8266 |
-
shared column offers "Remove from my fields" and never a delete. */
|
| 8267 |
viewer={viewer}
|
| 8268 |
onSetFieldsHidden={fieldBulkDoors.onSetFieldsHidden}
|
| 8269 |
-
onShareFields={
|
| 8270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8271 |
onDeleteFields={fieldBulkDoors.onDeleteFields}
|
| 8272 |
-
onRemoveField={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8273 |
/* ββ W38-T07 (owner instruction 12) β THE ONE COLOUR CONTROL. This was `colorOn` plus a
|
| 8274 |
boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
|
| 8275 |
so on a table with two status columns the person got the first one and had no way to
|
|
@@ -8295,6 +8378,7 @@ function CustomerGridSurface({
|
|
| 8295 |
statusValues={statusValues}
|
| 8296 |
measures={measures}
|
| 8297 |
unresolvedCount={unresolvedCount}
|
|
|
|
| 8298 |
lists={lists}
|
| 8299 |
/* ββ W37-T26 (C4) β the views this database's conditions may name: every SAVED view
|
| 8300 |
except the one being edited (a view naming itself is the cycle a person reaches most
|
|
@@ -9597,34 +9681,14 @@ function CustomerGridSurface({
|
|
| 9597 |
}
|
| 9598 |
: undefined
|
| 9599 |
}
|
| 9600 |
-
|
| 9601 |
-
|
| 9602 |
-
|
| 9603 |
-
|
| 9604 |
-
|
| 9605 |
-
|
| 9606 |
-
|
| 9607 |
-
|
| 9608 |
-
* ββ IT REFUSES OUT LOUD, AND THAT IS DELIBERATE, NOT A STUB. There is no server door
|
| 9609 |
-
* for the act: `PUT /share/{kind}/{oid}` refuses anyone without `may_administer`, which
|
| 9610 |
-
* a plain grantee (the only person who ever sees this row) does not hold, and its
|
| 9611 |
-
* `entries: []` body is the OWNER revoking everyone, which is a different act with a
|
| 9612 |
-
* different blast radius. `useFieldBulkDoors::onRemoveField` already speaks the one
|
| 9613 |
-
* sentence that says so for the Fields manager's copy of this control, so both surfaces
|
| 9614 |
-
* say the same thing rather than drifting into two wordings of one refusal.
|
| 9615 |
-
* β NOT `deleteField`, and not the definition or route doors either. Dropping a share
|
| 9616 |
-
* destroys nothing, so pointing this at a delete would put a benign word on a
|
| 9617 |
-
* destructive act β the one failure W41-T29's note says must not ship.
|
| 9618 |
-
*
|
| 9619 |
-
* ββ PASSED AT BOTH `<ColumnMenu>` MOUNTS. This file's own history is the reason the
|
| 9620 |
-
* rule keeps being restated beside each prop: a prop fed at the full column menu and
|
| 9621 |
-
* not at its "+" twin is the half-landing that `linkTargets` and `hopTopics` both
|
| 9622 |
-
* carry warnings about, caught by grepping and never by the build.
|
| 9623 |
-
*/
|
| 9624 |
-
onRemoveFromMine={() => {
|
| 9625 |
-
fieldBulkDoors.onRemoveField(menuField.key);
|
| 9626 |
-
setColumnMenu(null);
|
| 9627 |
-
}}
|
| 9628 |
onDuplicate={
|
| 9629 |
// Creatable strata only β a base Odoo field offers no Duplicate (contract).
|
| 9630 |
menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
|
|
@@ -9732,28 +9796,8 @@ function CustomerGridSurface({
|
|
| 9732 |
onNote={() => undefined}
|
| 9733 |
onHide={() => undefined}
|
| 9734 |
onCreate={createField}
|
| 9735 |
-
/**
|
| 9736 |
-
|
| 9737 |
-
* `() => undefined` like the action handlers above it. The row is not reachable here
|
| 9738 |
-
* today (this mount opens straight into the create form, its nominal field is the
|
| 9739 |
-
* locked identity column, and `locked` plus `mayRemoveField`'s refusals stand between
|
| 9740 |
-
* it and the screen), and that is precisely why an inert stub would be so cheap to
|
| 9741 |
-
* write and so expensive later: the
|
| 9742 |
-
* moment anything makes it reachable, a silent no-op is D-485 restored at one door with
|
| 9743 |
-
* the twin working. The two doors say the same sentence or neither does.
|
| 9744 |
-
*
|
| 9745 |
-
* β `lockedKey` and not `menuField.key`: this mount's `field` IS the locked identity
|
| 9746 |
-
* column, so the twin's `menuField` does not exist in this scope. Copying the sibling
|
| 9747 |
-
* body verbatim would have compiled against the wrong identifier or not at all.
|
| 9748 |
-
*/
|
| 9749 |
-
onRemoveFromMine={() => {
|
| 9750 |
-
fieldBulkDoors.onRemoveField(lockedKey);
|
| 9751 |
-
setPlusMenu(null);
|
| 9752 |
-
}}
|
| 9753 |
-
/* The SAME answer as the sibling instance above, stated rather than defaulted: this is
|
| 9754 |
-
the "+" menu and it creates through the identical `createField`, so a different value
|
| 9755 |
-
here would mean one door offers the kind and its twin does not. The note above the
|
| 9756 |
-
other instance carries the reasoning, including why it is now unconditional. */
|
| 9757 |
geocodable={true}
|
| 9758 |
onChangeField={() => undefined}
|
| 9759 |
onCreateAndSwap={() => undefined}
|
|
@@ -10030,11 +10074,27 @@ function CustomerGridSurface({
|
|
| 10030 |
onClose={closeJson}
|
| 10031 |
/>
|
| 10032 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10033 |
{linkField && linkTargetScope && linkAt && (
|
| 10034 |
<LinkGridModal
|
| 10035 |
label={linkField.label}
|
| 10036 |
table={linkTargetScope}
|
| 10037 |
recordIds={linkedRecordIds}
|
|
|
|
|
|
|
| 10038 |
editable={canEditField(linkField) && !isDerivedLink(linkField)}
|
| 10039 |
single={linkField.link?.single === true}
|
| 10040 |
excludeRecordId={linkTargetScope === scope ? linkAt.pid : undefined}
|
|
|
|
| 70 |
} from "./scriptViews";
|
| 71 |
import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews";
|
| 72 |
import { defaultViewConfig, headerMarksFor, useGridColumns } from "./useGridColumns";
|
| 73 |
+
import { activeMeasureRuleIds, matchFilterTree, pendingMeasures, runPipeline, sliceForDisplay,
|
| 74 |
+
unresolvedConditionReasons, useVisibleRows } from "./useVisibleRows";
|
| 75 |
import { computeAggs } from "./aggregations";
|
| 76 |
import type { CohortSets } from "./useVisibleRows";
|
| 77 |
import type { MeasureSets } from "./useVisibleRows";
|
|
|
|
| 82 |
import RecordDetail from "./RecordDetail";
|
| 83 |
import ViewSidebar from "./ViewSidebar";
|
| 84 |
import ColumnMenu from "./ColumnMenu";
|
| 85 |
+
import FieldBatchAccessDialog from "./FieldBatchAccessDialog";
|
| 86 |
+
import type { FieldBatchAccessAction } from "./FieldBatchAccessDialog";
|
| 87 |
import type { ColumnMenuState, HopTopic } from "./ColumnMenu";
|
| 88 |
import { HEADER_ICONS } from "./iconShapes";
|
| 89 |
import { emitHostEvent, eventId } from "./hostBridge";
|
|
|
|
| 175 |
viewDisplayName,
|
| 176 |
/* β W37-T26 (C4) β the view-membership leaf's two pure helpers. `viewRefsOf` is the ONE walk
|
| 177 |
the resolver, the cycle detector and the gate share; `viewFilterCycle` is the refusal. */
|
| 178 |
+
viewFilterCycle, viewRefsOf, linkPickerFilterNodes } from "./types";
|
| 179 |
/* ββ W41-T42 (owner instruction 23) β the pivot's whole vocabulary is DECLARED in `types.ts`
|
| 180 |
and only consumed here, because W41-T43 (this file) and W41-T44 (`RecordDetail.tsx`) open the
|
| 181 |
same feature through other doors and neither of them may edit `types.ts`. */
|
|
|
|
| 195 |
PivotWindow,
|
| 196 |
Row,
|
| 197 |
RowHeightMode,
|
| 198 |
+
RollupCondition,
|
| 199 |
SavedView,
|
| 200 |
ViewConfig,
|
| 201 |
} from "./types";
|
|
|
|
| 738 |
* would take the column off the screen while it still exists in the store, so the refusal would
|
| 739 |
* read as a success until the next workspace fetch put the column back.
|
| 740 |
*
|
| 741 |
+
* Share, reassign, and self-removal deliberately have no bulk door here. The first two APIs are
|
| 742 |
+
* per field and require a person picker; the last has no safe server operation for a grantee.
|
| 743 |
+
* Passing a toast callback would render a button that does not perform its named action, so the
|
| 744 |
+
* Toolbar receives `null` for all three until their real contracts exist.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
*/
|
| 746 |
export interface FieldBulkDoorDeps {
|
| 747 |
config: ViewConfig;
|
|
|
|
| 760 |
|
| 761 |
export interface FieldBulkDoors {
|
| 762 |
onSetFieldsHidden: (keys: string[], hidden: boolean) => void;
|
|
|
|
|
|
|
| 763 |
onDeleteFields: (keys: string[]) => void;
|
|
|
|
| 764 |
}
|
| 765 |
|
| 766 |
/**
|
|
|
|
| 892 |
stampFieldDelete, updateConfig, visible]
|
| 893 |
);
|
| 894 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 895 |
return useMemo(
|
| 896 |
+
() => ({ onSetFieldsHidden, onDeleteFields }),
|
| 897 |
+
[onDeleteFields, onSetFieldsHidden]
|
| 898 |
);
|
| 899 |
}
|
| 900 |
|
|
|
|
| 1206 |
/** W42-T29 β the SOURCE record, withheld from its own candidate list so a record cannot be
|
| 1207 |
* offered as a link to itself. Applied on top of any projection above. */
|
| 1208 |
embeddedExcludeRecordId?: number;
|
| 1209 |
+
/** W42-T35 β static conditions narrow candidates only; saved links are never rewritten. */
|
| 1210 |
+
embeddedLinkConditions?: readonly RollupCondition[];
|
| 1211 |
+
embeddedLinkConditionConj?: "and" | "or";
|
| 1212 |
}
|
| 1213 |
|
| 1214 |
/**
|
|
|
|
| 1249 |
return table as `ut_${string}`;
|
| 1250 |
}
|
| 1251 |
|
| 1252 |
+
/**
|
| 1253 |
+
* W42-T35 β Link conditions deliberately use the server's Rollup vocabulary while the grid
|
| 1254 |
+
* evaluator speaks the view vocabulary. This is the sole adapter: a picker condition is static,
|
| 1255 |
+
* flat and has no rank, view, cohort or dynamic-field arm.
|
| 1256 |
+
*/
|
| 1257 |
interface LinkGridModalProps {
|
| 1258 |
label: string;
|
| 1259 |
table: SurfaceScope;
|
| 1260 |
recordIds: readonly number[];
|
| 1261 |
+
conditions?: readonly RollupCondition[];
|
| 1262 |
+
conditionConj?: "and" | "or";
|
| 1263 |
editable?: boolean;
|
| 1264 |
single?: boolean;
|
| 1265 |
/** The source record, when this picker draws the source record's OWN database. */
|
|
|
|
| 1268 |
onClose: () => void;
|
| 1269 |
}
|
| 1270 |
|
| 1271 |
+
function LinkGridModal({ label, table, recordIds, conditions = [], conditionConj = "and",
|
| 1272 |
+
editable = false, single = false, excludeRecordId, onSave, onClose }: LinkGridModalProps) {
|
| 1273 |
const panelRef = useRef<HTMLDivElement>(null);
|
| 1274 |
const [selectedIds, setSelectedIds] = useState<number[]>(() => [...recordIds]);
|
| 1275 |
const changeSelectedIds = useCallback((ids: number[]) => {
|
|
|
|
| 1329 |
embedded
|
| 1330 |
embeddedRecordIds={editable ? undefined : recordIds}
|
| 1331 |
embeddedExcludeRecordId={editable ? excludeRecordId : undefined}
|
| 1332 |
+
embeddedLinkConditions={editable ? conditions : undefined}
|
| 1333 |
+
embeddedLinkConditionConj={conditionConj}
|
| 1334 |
embeddedSelectable={editable}
|
| 1335 |
embeddedSelectedIds={selectedIds}
|
| 1336 |
onEmbeddedSelectionChange={changeSelectedIds}
|
|
|
|
| 1408 |
embeddedSelectedIds = [],
|
| 1409 |
onEmbeddedSelectionChange,
|
| 1410 |
embeddedExcludeRecordId,
|
| 1411 |
+
embeddedLinkConditions = [],
|
| 1412 |
+
embeddedLinkConditionConj = "and",
|
| 1413 |
}: CustomerGridProps = {}) {
|
| 1414 |
const isQueryPreview = queryBinding !== undefined;
|
| 1415 |
/**
|
|
|
|
| 1807 |
? `query:${queryBinding.workspaceBinding.key}`
|
| 1808 |
: payload?.workspace?.storageKey ?? `${LOCAL_KEY_PREFIX}${scope}`;
|
| 1809 |
|
| 1810 |
+
/** The field-share route speaks this durable surface key, never a browser cache key. */
|
| 1811 |
+
const fieldGrantTableKey = isQueryPreview
|
| 1812 |
+
? null
|
| 1813 |
+
: scope === "product"
|
| 1814 |
+
? "product_data"
|
| 1815 |
+
: scope.startsWith("ut_")
|
| 1816 |
+
? scope
|
| 1817 |
+
: "customer_data";
|
| 1818 |
+
const [fieldBatchAccess, setFieldBatchAccess] = useState<{
|
| 1819 |
+
action: FieldBatchAccessAction;
|
| 1820 |
+
keys: string[];
|
| 1821 |
+
} | null>(null);
|
| 1822 |
+
|
| 1823 |
// Initialize once per permission/data scope. Host state wins; local state
|
| 1824 |
// fills only missing objects and keeps the standalone path useful.
|
| 1825 |
useEffect(() => {
|
|
|
|
| 2254 |
const isUserTable = !embedded && scope.startsWith("ut_");
|
| 2255 |
const recordsMutable = payload?.recordsMutable !== false;
|
| 2256 |
const canMutateRecords = isUserTable && recordsMutable;
|
| 2257 |
+
const batchAccessFields = useMemo(
|
| 2258 |
+
() => (fieldBatchAccess?.keys ?? [])
|
| 2259 |
+
.map((key) => fieldByKey.get(key))
|
| 2260 |
+
.filter((field): field is Field => !!field)
|
| 2261 |
+
.map((field) => ({ key: field.key, label: field.label })),
|
| 2262 |
+
[fieldBatchAccess, fieldByKey]
|
| 2263 |
+
);
|
| 2264 |
+
const openFieldBatchAccess = useCallback((action: FieldBatchAccessAction, keys: string[]) => {
|
| 2265 |
+
if (!fieldGrantTableKey) {
|
| 2266 |
+
signal(TOAST_EVENT, "Field access is unavailable on this preview.");
|
| 2267 |
+
return;
|
| 2268 |
+
}
|
| 2269 |
+
setFieldBatchAccess({ action, keys: [...new Set(keys)] });
|
| 2270 |
+
}, [fieldGrantTableKey]);
|
| 2271 |
+
const removeFieldFromMine = useCallback((field: Field) => {
|
| 2272 |
+
if (!fieldGrantTableKey) return;
|
| 2273 |
+
void (async () => {
|
| 2274 |
+
const path = `${API_V1}/share/field/${encodeURIComponent(`${fieldGrantTableKey}:${field.key}`)}/mine`;
|
| 2275 |
+
const res = await fetch(path, { method: "DELETE", credentials: CREDENTIALS }).catch(() => null);
|
| 2276 |
+
if (!res) {
|
| 2277 |
+
signal(TOAST_EVENT, "Cannot reach the server.");
|
| 2278 |
+
return;
|
| 2279 |
+
}
|
| 2280 |
+
if (!res.ok) {
|
| 2281 |
+
const body = (await res.json().catch(() => null)) as { error?: { message?: unknown } } | null;
|
| 2282 |
+
const message = body?.error?.message;
|
| 2283 |
+
signal(TOAST_EVENT, typeof message === "string" && message.trim()
|
| 2284 |
+
? message.trim()
|
| 2285 |
+
: `The server answered ${res.status}.`);
|
| 2286 |
+
return;
|
| 2287 |
+
}
|
| 2288 |
+
setFields((current) => current.filter((item) => item.key !== field.key));
|
| 2289 |
+
updateConfig(configWithoutFields({ ...config, order, visible: [...visible] }, [field.key]));
|
| 2290 |
+
clearTopicRowsCache(topic.rowsPath);
|
| 2291 |
+
signal(WORKSPACE_STALE_EVENT);
|
| 2292 |
+
signal(ROWS_STALE_EVENT);
|
| 2293 |
+
signal(TOAST_EVENT, `${field.label} was removed from your fields.`);
|
| 2294 |
+
})();
|
| 2295 |
+
}, [config, fieldGrantTableKey, order, setFields, topic.rowsPath, updateConfig, visible]);
|
| 2296 |
/**
|
| 2297 |
* ββ W42-T12 (D-485, amendment A20) β THE FIELD MANAGER'S FIVE DOORS, CALLED AT LAST.
|
| 2298 |
*
|
|
|
|
| 2627 |
if (displayMode !== "map") setRoutePanelOpen(false);
|
| 2628 |
}, [displayMode]);
|
| 2629 |
|
| 2630 |
+
// W42-T35: the same static Link condition must reach the server when the target table is
|
| 2631 |
+
// windowed. Filtering only the loaded page would make matching records outside that page
|
| 2632 |
+
// unreachable. Existing selections remain exceptions so a new condition never erases a link.
|
| 2633 |
+
const embeddedLinkFilterNodes = useMemo(
|
| 2634 |
+
() => linkPickerFilterNodes(embeddedLinkConditions),
|
| 2635 |
+
[embeddedLinkConditions]
|
| 2636 |
+
);
|
| 2637 |
+
const linkPickerFilters = embeddedSelectable && embeddedLinkFilterNodes.length > 0
|
| 2638 |
+
? embeddedLinkFilterNodes
|
| 2639 |
+
: config.filters;
|
| 2640 |
+
const linkPickerFilterConj = embeddedSelectable && embeddedLinkFilterNodes.length > 0
|
| 2641 |
+
? embeddedLinkConditionConj
|
| 2642 |
+
: config.filterConj;
|
| 2643 |
+
const embeddedLinkSelectionKey = embeddedSelectedIds.join(",");
|
| 2644 |
+
const linkPickerPreservedIds = embeddedSelectable && embeddedLinkFilterNodes.length > 0
|
| 2645 |
+
? embeddedSelectedIds
|
| 2646 |
+
: [];
|
| 2647 |
+
|
| 2648 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 2649 |
// ββ WAVE 30 Β· W30-T42 (contract C2) β PAGING A READ-THROUGH GRID.
|
| 2650 |
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 2656 |
// question. So the predicate has to reach the evaluator that CAN answer it, which is SQL.
|
| 2657 |
//
|
| 2658 |
// β THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`).
|
| 2659 |
+
const windowPredicate = useMemo(() => {
|
| 2660 |
+
const base = windowPredicateKey(linkPickerFilters, linkPickerFilterConj, config.sorts, search);
|
| 2661 |
+
return linkPickerPreservedIds.length ? `${base}|link:${embeddedLinkSelectionKey}` : base;
|
| 2662 |
+
}, [linkPickerFilters, linkPickerFilterConj, config.sorts, search, linkPickerPreservedIds,
|
| 2663 |
+
embeddedLinkSelectionKey]);
|
| 2664 |
const windowRequest = useCallback(
|
| 2665 |
(offset: number) => ({
|
| 2666 |
offset,
|
| 2667 |
limit: WINDOW_ROWS,
|
| 2668 |
+
filters: linkPickerFilters,
|
| 2669 |
+
filterConj: linkPickerFilterConj,
|
| 2670 |
sorts: config.sorts,
|
| 2671 |
search,
|
| 2672 |
+
includePids: linkPickerPreservedIds,
|
| 2673 |
}),
|
| 2674 |
+
[linkPickerFilters, linkPickerFilterConj, config.sorts, search, linkPickerPreservedIds]
|
| 2675 |
);
|
| 2676 |
const sentPredicate = useRef<string | null>(null);
|
| 2677 |
useEffect(() => {
|
|
|
|
| 2832 |
});
|
| 2833 |
}, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]);
|
| 2834 |
|
| 2835 |
+
// W42-T35 β a link filter constrains the records an editor may ADD, not the relationships that
|
| 2836 |
+
// already exist. The latter remain in the modal so a new rule cannot erase an older choice.
|
| 2837 |
+
const embeddedLinkEligibleIds = useMemo(() => {
|
| 2838 |
+
if (!embeddedSelectable || embeddedLinkFilterNodes.length === 0)
|
| 2839 |
+
return new Set(computedRows.map((row) => row.pid));
|
| 2840 |
+
return new Set(computedRows.filter((row) => matchFilterTree(
|
| 2841 |
+
embeddedLinkFilterNodes, embeddedLinkConditionConj, row, fieldByKey, { today, strict: true }
|
| 2842 |
+
)).map((row) => row.pid));
|
| 2843 |
+
}, [computedRows, embeddedSelectable, embeddedLinkFilterNodes, embeddedLinkConditionConj,
|
| 2844 |
+
fieldByKey, today]);
|
| 2845 |
+
const linkCandidateRows = useMemo(() => {
|
| 2846 |
+
if (!embeddedSelectable || embeddedLinkFilterNodes.length === 0) return computedRows;
|
| 2847 |
+
const saved = new Set(embeddedSelectedIds);
|
| 2848 |
+
return computedRows.filter((row) => saved.has(row.pid) || embeddedLinkEligibleIds.has(row.pid));
|
| 2849 |
+
}, [computedRows, embeddedSelectable, embeddedLinkFilterNodes, embeddedLinkEligibleIds,
|
| 2850 |
+
embeddedLinkSelectionKey]);
|
| 2851 |
+
|
| 2852 |
/**
|
| 2853 |
* ββ W37-T26 / CONTRACT C4 β `view id -> the pids that view resolves to`, for the VIEW
|
| 2854 |
* MEMBERSHIP leaf (`Where View is [some view]`).
|
|
|
|
| 2946 |
[activeViewId, views, config, updateConfig]
|
| 2947 |
);
|
| 2948 |
|
| 2949 |
+
const unresolvedReasons = useMemo(
|
| 2950 |
// β W37-T26 (C4) β `viewSets` rides this context too. Without it a view leaf would ALWAYS
|
| 2951 |
// count as unresolved (the chip would cry wolf on a working filter) and, worse, a leaf naming
|
| 2952 |
// a DELETED view would look identical to one naming a live one.
|
| 2953 |
// ββ D-229 β `fieldByKey` rides this context so a condition naming a column this table no
|
| 2954 |
// longer has is COUNTED. `evalNode` ignores such a leaf (correctly), which means the grid was
|
| 2955 |
// showing MORE rows than the filter panel claimed with nothing on screen saying so.
|
| 2956 |
+
() => unresolvedConditionReasons(config.filters, { cohortSets, viewSets, fieldByKey, today }),
|
| 2957 |
[config.filters, cohortSets, viewSets, fieldByKey, today]
|
| 2958 |
);
|
| 2959 |
+
const unresolvedCount = unresolvedReasons.length;
|
| 2960 |
|
| 2961 |
// Wave-2 item 2c β COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows +
|
| 2962 |
// derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side.
|
|
|
|
| 2980 |
const scopedRows = useMemo(
|
| 2981 |
() =>
|
| 2982 |
cohortMode
|
| 2983 |
+
? linkCandidateRows.filter((r) => cohortMemberSet.has(r.pid))
|
| 2984 |
+
: linkCandidateRows,
|
| 2985 |
+
[cohortMode, linkCandidateRows, cohortMemberSet]
|
| 2986 |
);
|
| 2987 |
|
| 2988 |
const { visibleRows, pidToIndex } = useVisibleRows(
|
|
|
|
| 3475 |
embeddedSelectable ? embeddedSelectedIds : [],
|
| 3476 |
!embedded ? `aios:grid-selection:${scope}` : undefined
|
| 3477 |
);
|
| 3478 |
+
const embeddedSelectedKey = embeddedLinkSelectionKey;
|
| 3479 |
const selectedPidKey = [...selectedPids].join(",");
|
| 3480 |
useEffect(() => {
|
| 3481 |
if (!embeddedSelectable) return;
|
|
|
|
| 3484 |
}, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]);
|
| 3485 |
useEffect(() => {
|
| 3486 |
if (!embeddedSelectable) return;
|
| 3487 |
+
const previous = new Set(embeddedSelectedIds);
|
| 3488 |
+
const next = [...selectedPids].filter((pid) =>
|
| 3489 |
+
embeddedLinkFilterNodes.length === 0 || embeddedLinkEligibleIds.has(pid) || previous.has(pid)
|
| 3490 |
+
);
|
| 3491 |
+
onEmbeddedSelectionChange?.(next);
|
| 3492 |
+
}, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids, embeddedSelectedIds,
|
| 3493 |
+
embeddedLinkFilterNodes, embeddedLinkEligibleIds]);
|
| 3494 |
/* ββββββββββββββββββββββββ owner item 16 / R4 / C-UNDO ββββββββββββββββββββββββ
|
| 3495 |
THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one
|
| 3496 |
thing it cannot: reading the value a cell held BEFORE the write, which only exists at the
|
|
|
|
| 8338 |
if (target && target.shared && target.kind === "route_order") deleteRouteField(key);
|
| 8339 |
else deleteField(key);
|
| 8340 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8341 |
viewer={viewer}
|
| 8342 |
onSetFieldsHidden={fieldBulkDoors.onSetFieldsHidden}
|
| 8343 |
+
onShareFields={fieldGrantTableKey
|
| 8344 |
+
? (keys) => openFieldBatchAccess("share", keys)
|
| 8345 |
+
: null}
|
| 8346 |
+
onReassignFields={fieldGrantTableKey
|
| 8347 |
+
? (keys) => openFieldBatchAccess("reassign", keys)
|
| 8348 |
+
: null}
|
| 8349 |
onDeleteFields={fieldBulkDoors.onDeleteFields}
|
| 8350 |
+
onRemoveField={fieldGrantTableKey
|
| 8351 |
+
? (key) => {
|
| 8352 |
+
const field = fieldByKey.get(key);
|
| 8353 |
+
if (field) removeFieldFromMine(field);
|
| 8354 |
+
}
|
| 8355 |
+
: null}
|
| 8356 |
/* ββ W38-T07 (owner instruction 12) β THE ONE COLOUR CONTROL. This was `colorOn` plus a
|
| 8357 |
boolean toggle whose `true` arm guessed the column: `fields.find(type === "status")`,
|
| 8358 |
so on a table with two status columns the person got the first one and had no way to
|
|
|
|
| 8378 |
statusValues={statusValues}
|
| 8379 |
measures={measures}
|
| 8380 |
unresolvedCount={unresolvedCount}
|
| 8381 |
+
unresolvedReasons={unresolvedReasons}
|
| 8382 |
lists={lists}
|
| 8383 |
/* ββ W37-T26 (C4) β the views this database's conditions may name: every SAVED view
|
| 8384 |
except the one being edited (a view naming itself is the cycle a person reaches most
|
|
|
|
| 9681 |
}
|
| 9682 |
: undefined
|
| 9683 |
}
|
| 9684 |
+
onRemoveFromMine={
|
| 9685 |
+
menuField.shared && menuField.sharedRole !== "owner"
|
| 9686 |
+
? () => {
|
| 9687 |
+
removeFieldFromMine(menuField);
|
| 9688 |
+
setColumnMenu(null);
|
| 9689 |
+
}
|
| 9690 |
+
: undefined
|
| 9691 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9692 |
onDuplicate={
|
| 9693 |
// Creatable strata only β a base Odoo field offers no Duplicate (contract).
|
| 9694 |
menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
|
|
|
|
| 9796 |
onNote={() => undefined}
|
| 9797 |
onHide={() => undefined}
|
| 9798 |
onCreate={createField}
|
| 9799 |
+
/* Keep the twin aligned: no self-removal affordance without a safe server door. */
|
| 9800 |
+
onRemoveFromMine={undefined}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9801 |
geocodable={true}
|
| 9802 |
onChangeField={() => undefined}
|
| 9803 |
onCreateAndSwap={() => undefined}
|
|
|
|
| 10074 |
onClose={closeJson}
|
| 10075 |
/>
|
| 10076 |
)}
|
| 10077 |
+
{fieldBatchAccess && fieldGrantTableKey && batchAccessFields.length > 0 ? (
|
| 10078 |
+
<FieldBatchAccessDialog
|
| 10079 |
+
action={fieldBatchAccess.action}
|
| 10080 |
+
tableKey={fieldGrantTableKey}
|
| 10081 |
+
fields={batchAccessFields}
|
| 10082 |
+
onClose={() => setFieldBatchAccess(null)}
|
| 10083 |
+
onComplete={(message) => {
|
| 10084 |
+
clearTopicRowsCache(topic.rowsPath);
|
| 10085 |
+
signal(WORKSPACE_STALE_EVENT);
|
| 10086 |
+
signal(ROWS_STALE_EVENT);
|
| 10087 |
+
signal(TOAST_EVENT, message);
|
| 10088 |
+
}}
|
| 10089 |
+
/>
|
| 10090 |
+
) : null}
|
| 10091 |
{linkField && linkTargetScope && linkAt && (
|
| 10092 |
<LinkGridModal
|
| 10093 |
label={linkField.label}
|
| 10094 |
table={linkTargetScope}
|
| 10095 |
recordIds={linkedRecordIds}
|
| 10096 |
+
conditions={linkField.link?.conditions}
|
| 10097 |
+
conditionConj={linkField.link?.conditionConj}
|
| 10098 |
editable={canEditField(linkField) && !isDerivedLink(linkField)}
|
| 10099 |
single={linkField.link?.single === true}
|
| 10100 |
excludeRecordId={linkTargetScope === scope ? linkAt.pid : undefined}
|
web/src/customer-grid/FieldBatchAccessDialog.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / FieldBatchAccessDialog.tsx
|
| 3 |
+
//
|
| 4 |
+
// One small access editor for the Field manager's plural Share and Reassign
|
| 5 |
+
// actions. It deliberately uses the existing field-share routes rather than
|
| 6 |
+
// inventing a second permissions model: every selected field keeps its own
|
| 7 |
+
// grants, and the server remains the authority for each individual refusal.
|
| 8 |
+
// ---------------------------------------------------------------------------
|
| 9 |
+
|
| 10 |
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 11 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 12 |
+
|
| 13 |
+
export type FieldBatchAccessAction = "share" | "reassign";
|
| 14 |
+
|
| 15 |
+
interface Person {
|
| 16 |
+
user: string;
|
| 17 |
+
name: string;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
interface Entry {
|
| 21 |
+
user: string;
|
| 22 |
+
role: "view" | "edit";
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function asPeople(raw: unknown): Person[] {
|
| 26 |
+
if (!Array.isArray(raw)) return [];
|
| 27 |
+
const seen = new Set<string>();
|
| 28 |
+
const people: Person[] = [];
|
| 29 |
+
for (const item of raw) {
|
| 30 |
+
if (!item || typeof item !== "object") continue;
|
| 31 |
+
const candidate = item as Record<string, unknown>;
|
| 32 |
+
const user = typeof candidate.username === "string" ? candidate.username.trim().toLowerCase() : "";
|
| 33 |
+
const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
|
| 34 |
+
if (!user || seen.has(user)) continue;
|
| 35 |
+
seen.add(user);
|
| 36 |
+
people.push({ user, name: name || user });
|
| 37 |
+
}
|
| 38 |
+
return people;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
function asEntries(raw: unknown): Entry[] {
|
| 42 |
+
if (!Array.isArray(raw)) return [];
|
| 43 |
+
const seen = new Set<string>();
|
| 44 |
+
const entries: Entry[] = [];
|
| 45 |
+
for (const item of raw) {
|
| 46 |
+
if (!item || typeof item !== "object") continue;
|
| 47 |
+
const candidate = item as Record<string, unknown>;
|
| 48 |
+
const user = typeof candidate.user === "string" ? candidate.user.trim().toLowerCase() : "";
|
| 49 |
+
const role = candidate.role === "view" || candidate.role === "edit" ? candidate.role : null;
|
| 50 |
+
if (!user || !role || seen.has(user)) continue;
|
| 51 |
+
seen.add(user);
|
| 52 |
+
entries.push({ user, role });
|
| 53 |
+
}
|
| 54 |
+
return entries;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
async function replyMessage(res: Response): Promise<string> {
|
| 58 |
+
const body = (await res.json().catch(() => null)) as { error?: { message?: unknown } } | null;
|
| 59 |
+
const message = body?.error?.message;
|
| 60 |
+
return typeof message === "string" && message.trim() ? message.trim() : `The server answered ${res.status}.`;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export default function FieldBatchAccessDialog({
|
| 64 |
+
action,
|
| 65 |
+
tableKey,
|
| 66 |
+
fields,
|
| 67 |
+
onClose,
|
| 68 |
+
onComplete,
|
| 69 |
+
}: {
|
| 70 |
+
action: FieldBatchAccessAction;
|
| 71 |
+
tableKey: string;
|
| 72 |
+
fields: readonly { key: string; label: string }[];
|
| 73 |
+
onClose: () => void;
|
| 74 |
+
onComplete: (message: string) => void;
|
| 75 |
+
}) {
|
| 76 |
+
const [people, setPeople] = useState<Person[]>([]);
|
| 77 |
+
const [person, setPerson] = useState("");
|
| 78 |
+
const [role, setRole] = useState<Entry["role"]>("view");
|
| 79 |
+
const [busy, setBusy] = useState(false);
|
| 80 |
+
const [error, setError] = useState("");
|
| 81 |
+
const [result, setResult] = useState("");
|
| 82 |
+
const first = fields[0];
|
| 83 |
+
const title = action === "share" ? "Share fields" : "Reassign fields";
|
| 84 |
+
const subject = fields.length === 1 ? `β${first?.label ?? "field"}β` : `${fields.length} fields`;
|
| 85 |
+
|
| 86 |
+
const pathFor = useCallback(
|
| 87 |
+
(key: string) => `${API_V1}/share/field/${encodeURIComponent(`${tableKey}:${key}`)}`,
|
| 88 |
+
[tableKey]
|
| 89 |
+
);
|
| 90 |
+
|
| 91 |
+
useEffect(() => {
|
| 92 |
+
let alive = true;
|
| 93 |
+
if (!first) return () => { alive = false; };
|
| 94 |
+
void (async () => {
|
| 95 |
+
const res = await fetch(pathFor(first.key), { credentials: CREDENTIALS }).catch(() => null);
|
| 96 |
+
if (!alive) return;
|
| 97 |
+
if (!res) {
|
| 98 |
+
setError("Cannot reach the server.");
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
if (!res.ok) {
|
| 102 |
+
setError(await replyMessage(res));
|
| 103 |
+
return;
|
| 104 |
+
}
|
| 105 |
+
const body = (await res.json().catch(() => null)) as Record<string, unknown> | null;
|
| 106 |
+
const offered = asPeople(body?.people);
|
| 107 |
+
setPeople(offered);
|
| 108 |
+
if (offered.length === 0) setError("No eligible people were returned for this workspace.");
|
| 109 |
+
})();
|
| 110 |
+
return () => { alive = false; };
|
| 111 |
+
}, [first, pathFor]);
|
| 112 |
+
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
const onKey = (event: KeyboardEvent) => {
|
| 115 |
+
if (event.key === "Escape" && !busy) onClose();
|
| 116 |
+
};
|
| 117 |
+
window.addEventListener("keydown", onKey);
|
| 118 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 119 |
+
}, [busy, onClose]);
|
| 120 |
+
|
| 121 |
+
const canApply = !!person && !busy && !result;
|
| 122 |
+
const buttonText = action === "share" ? "Share" : "Reassign";
|
| 123 |
+
const personLabel = useMemo(
|
| 124 |
+
() => people.find((item) => item.user === person)?.name ?? person,
|
| 125 |
+
[people, person]
|
| 126 |
+
);
|
| 127 |
+
|
| 128 |
+
const apply = useCallback(() => {
|
| 129 |
+
if (!canApply) return;
|
| 130 |
+
void (async () => {
|
| 131 |
+
setBusy(true);
|
| 132 |
+
setError("");
|
| 133 |
+
let changed = 0;
|
| 134 |
+
const refused: string[] = [];
|
| 135 |
+
for (const field of fields) {
|
| 136 |
+
const path = pathFor(field.key);
|
| 137 |
+
const read = await fetch(path, { credentials: CREDENTIALS }).catch(() => null);
|
| 138 |
+
if (!read) {
|
| 139 |
+
refused.push(`${field.label}: Cannot reach the server.`);
|
| 140 |
+
continue;
|
| 141 |
+
}
|
| 142 |
+
if (!read.ok) {
|
| 143 |
+
refused.push(`${field.label}: ${await replyMessage(read)}`);
|
| 144 |
+
continue;
|
| 145 |
+
}
|
| 146 |
+
const current = (await read.json().catch(() => null)) as Record<string, unknown> | null;
|
| 147 |
+
const response = action === "share"
|
| 148 |
+
? await fetch(path, {
|
| 149 |
+
method: "PUT",
|
| 150 |
+
credentials: CREDENTIALS,
|
| 151 |
+
headers: { "Content-Type": "application/json" },
|
| 152 |
+
body: JSON.stringify({
|
| 153 |
+
entries: [
|
| 154 |
+
...asEntries(current?.entries).filter((entry) => entry.user !== person),
|
| 155 |
+
{ user: person, role },
|
| 156 |
+
],
|
| 157 |
+
}),
|
| 158 |
+
}).catch(() => null)
|
| 159 |
+
: await fetch(`${path}/owner`, {
|
| 160 |
+
method: "PUT",
|
| 161 |
+
credentials: CREDENTIALS,
|
| 162 |
+
headers: { "Content-Type": "application/json" },
|
| 163 |
+
body: JSON.stringify({ owner: person }),
|
| 164 |
+
}).catch(() => null);
|
| 165 |
+
if (!response) {
|
| 166 |
+
refused.push(`${field.label}: Cannot reach the server.`);
|
| 167 |
+
} else if (!response.ok) {
|
| 168 |
+
refused.push(`${field.label}: ${await replyMessage(response)}`);
|
| 169 |
+
} else {
|
| 170 |
+
changed += 1;
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
const noun = changed === 1 ? "field" : "fields";
|
| 174 |
+
const verb = action === "share" ? "shared with" : "reassigned to";
|
| 175 |
+
const message = changed > 0
|
| 176 |
+
? `${changed} ${noun} ${verb} ${personLabel}.`
|
| 177 |
+
: "No selected fields were changed.";
|
| 178 |
+
setResult(message);
|
| 179 |
+
if (refused.length > 0) setError(refused.join(" "));
|
| 180 |
+
if (changed > 0) onComplete(message);
|
| 181 |
+
setBusy(false);
|
| 182 |
+
})();
|
| 183 |
+
}, [action, canApply, fields, onComplete, pathFor, person, personLabel, role]);
|
| 184 |
+
|
| 185 |
+
return (
|
| 186 |
+
<div className="shell-newdb-scrim" onClick={() => (busy ? null : onClose())}>
|
| 187 |
+
<section
|
| 188 |
+
className="shell-newdb shell-share"
|
| 189 |
+
role="dialog"
|
| 190 |
+
aria-modal="true"
|
| 191 |
+
aria-label={`${title} ${subject}`}
|
| 192 |
+
onClick={(event) => event.stopPropagation()}
|
| 193 |
+
>
|
| 194 |
+
<h2>{title}</h2>
|
| 195 |
+
<p className="shell-newdb-sub">
|
| 196 |
+
{action === "share"
|
| 197 |
+
? `Give ${personLabel || "someone"} access to ${subject}. Existing access on each field stays in place.`
|
| 198 |
+
: `Make ${personLabel || "someone"} the owner of ${subject}. A field must already be shared before it can be reassigned.`}
|
| 199 |
+
</p>
|
| 200 |
+
<div className="shell-share-add">
|
| 201 |
+
<select
|
| 202 |
+
className="shell-share-select"
|
| 203 |
+
aria-label="Person"
|
| 204 |
+
value={person}
|
| 205 |
+
disabled={busy || !!result}
|
| 206 |
+
onChange={(event) => setPerson(event.target.value)}
|
| 207 |
+
>
|
| 208 |
+
<option value="">Choose a person</option>
|
| 209 |
+
{people.map((item) => <option key={item.user} value={item.user}>{item.name}</option>)}
|
| 210 |
+
</select>
|
| 211 |
+
{action === "share" ? (
|
| 212 |
+
<select
|
| 213 |
+
className="shell-share-select"
|
| 214 |
+
aria-label="Access level"
|
| 215 |
+
value={role}
|
| 216 |
+
disabled={busy || !!result}
|
| 217 |
+
onChange={(event) => setRole(event.target.value === "edit" ? "edit" : "view")}
|
| 218 |
+
>
|
| 219 |
+
<option value="view">Can view</option>
|
| 220 |
+
<option value="edit">Can edit</option>
|
| 221 |
+
</select>
|
| 222 |
+
) : null}
|
| 223 |
+
</div>
|
| 224 |
+
{result ? <p className="shell-share-summary">{result}</p> : null}
|
| 225 |
+
{error ? <p className="shell-newdb-err">{error}</p> : null}
|
| 226 |
+
<div className="shell-newdb-actions">
|
| 227 |
+
<button type="button" disabled={busy} onClick={onClose}>{result ? "Done" : "Cancel"}</button>
|
| 228 |
+
{!result ? <button type="button" className="login-submit" disabled={!canApply} onClick={apply}>{busy ? "Applying..." : buttonText}</button> : null}
|
| 229 |
+
</div>
|
| 230 |
+
</section>
|
| 231 |
+
</div>
|
| 232 |
+
);
|
| 233 |
+
}
|
web/src/customer-grid/MapView.tsx
CHANGED
|
@@ -511,6 +511,8 @@ export function MapView({
|
|
| 511 |
const [routeCols, setRouteCols] = useState<
|
| 512 |
(RouteNavRoute & { inputsHash: string; depot?: RouteDepot | null })[] | null
|
| 513 |
>(null);
|
|
|
|
|
|
|
| 514 |
/**
|
| 515 |
* ββ W42-T24 (contract C6) β THIS READER'S ROUTE FOLDERS, and they ride the SAME listing.
|
| 516 |
*
|
|
@@ -1175,20 +1177,36 @@ export function MapView({
|
|
| 1175 |
* the only way the STALE marker can exist at all.
|
| 1176 |
*/
|
| 1177 |
const loadRouteCols = useCallback(async () => {
|
|
|
|
| 1178 |
try {
|
| 1179 |
const res = await fetch("/api/v1/customers/route-order", { credentials: "same-origin" });
|
| 1180 |
-
const body =
|
| 1181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1182 |
// W42-T23 / C6 β the rail's own folder ROWS travel with the listing.
|
| 1183 |
setRouteFolders(Array.isArray(body?.folders) ? (body.folders as GridFolder[]) : []);
|
| 1184 |
setNextLabel(typeof body?.nextLabel === "string" ? body.nextLabel : "");
|
| 1185 |
} catch {
|
| 1186 |
-
|
| 1187 |
-
// planner still works, and the save below reports its own failure in its own words.
|
| 1188 |
setRouteCols([]);
|
|
|
|
|
|
|
| 1189 |
}
|
| 1190 |
}, []);
|
| 1191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1192 |
// β OWNER, 2026-08-23 β `routePanelOpen`, NOT `routeOn`. The saved-route picker is the first
|
| 1193 |
// control in the panel and it is how a person reaches somebody's plan without solving one of
|
| 1194 |
// their own, so the listing has to be in hand the moment the panel opens rather than after a
|
|
@@ -2499,6 +2517,8 @@ export function MapView({
|
|
| 2499 |
openKey={openedRoute}
|
| 2500 |
busyKey={routeBusyKey}
|
| 2501 |
notice={railNotice}
|
|
|
|
|
|
|
| 2502 |
onOpenRoute={openSavedRoute}
|
| 2503 |
onCreateRoute={startNewRoute}
|
| 2504 |
onRenameRoute={renameRoute}
|
|
|
|
| 511 |
const [routeCols, setRouteCols] = useState<
|
| 512 |
(RouteNavRoute & { inputsHash: string; depot?: RouteDepot | null })[] | null
|
| 513 |
>(null);
|
| 514 |
+
/** A failed route-list read is not an empty route list. */
|
| 515 |
+
const [routeListError, setRouteListError] = useState<string | null>(null);
|
| 516 |
/**
|
| 517 |
* ββ W42-T24 (contract C6) β THIS READER'S ROUTE FOLDERS, and they ride the SAME listing.
|
| 518 |
*
|
|
|
|
| 1177 |
* the only way the STALE marker can exist at all.
|
| 1178 |
*/
|
| 1179 |
const loadRouteCols = useCallback(async () => {
|
| 1180 |
+
setRouteListError(null);
|
| 1181 |
try {
|
| 1182 |
const res = await fetch("/api/v1/customers/route-order", { credentials: "same-origin" });
|
| 1183 |
+
const body = await res.json().catch(() => null);
|
| 1184 |
+
if (!res.ok || !Array.isArray(body?.fields)) {
|
| 1185 |
+
const detail = body?.detail?.message || body?.detail || body?.error?.message;
|
| 1186 |
+
setRouteListError(typeof detail === "string" && detail.trim()
|
| 1187 |
+
? detail
|
| 1188 |
+
: "Saved routes could not be loaded. Retry to see them.");
|
| 1189 |
+
setRouteCols([]);
|
| 1190 |
+
setRouteFolders([]);
|
| 1191 |
+
setNextLabel("");
|
| 1192 |
+
return;
|
| 1193 |
+
}
|
| 1194 |
+
setRouteCols(body.fields);
|
| 1195 |
// W42-T23 / C6 β the rail's own folder ROWS travel with the listing.
|
| 1196 |
setRouteFolders(Array.isArray(body?.folders) ? (body.folders as GridFolder[]) : []);
|
| 1197 |
setNextLabel(typeof body?.nextLabel === "string" ? body.nextLabel : "");
|
| 1198 |
} catch {
|
| 1199 |
+
setRouteListError("Saved routes could not be loaded. Retry to see them.");
|
|
|
|
| 1200 |
setRouteCols([]);
|
| 1201 |
+
setRouteFolders([]);
|
| 1202 |
+
setNextLabel("");
|
| 1203 |
}
|
| 1204 |
}, []);
|
| 1205 |
|
| 1206 |
+
const retryRouteList = useCallback(() => {
|
| 1207 |
+
void loadRouteCols();
|
| 1208 |
+
}, [loadRouteCols]);
|
| 1209 |
+
|
| 1210 |
// β OWNER, 2026-08-23 β `routePanelOpen`, NOT `routeOn`. The saved-route picker is the first
|
| 1211 |
// control in the panel and it is how a person reaches somebody's plan without solving one of
|
| 1212 |
// their own, so the listing has to be in hand the moment the panel opens rather than after a
|
|
|
|
| 2517 |
openKey={openedRoute}
|
| 2518 |
busyKey={routeBusyKey}
|
| 2519 |
notice={railNotice}
|
| 2520 |
+
listError={routeListError}
|
| 2521 |
+
onListRetry={retryRouteList}
|
| 2522 |
onOpenRoute={openSavedRoute}
|
| 2523 |
onCreateRoute={startNewRoute}
|
| 2524 |
onRenameRoute={renameRoute}
|
web/src/customer-grid/RouteNav.tsx
CHANGED
|
@@ -155,6 +155,9 @@ export interface RouteNavProps {
|
|
| 155 |
* row (standing rule 1's second sentence: reported, never silent).
|
| 156 |
*/
|
| 157 |
notice: string;
|
|
|
|
|
|
|
|
|
|
| 158 |
/** ONE CLICK OPENS A ROUTE. There is no second gesture and no confirm step. */
|
| 159 |
onOpenRoute: (key: string) => void;
|
| 160 |
/** CREATING IS A SEPARATE AFFORDANCE, never a row in the list of what exists. */
|
|
@@ -196,6 +199,8 @@ export function RouteNav({
|
|
| 196 |
openKey,
|
| 197 |
busyKey,
|
| 198 |
notice,
|
|
|
|
|
|
|
| 199 |
onOpenRoute,
|
| 200 |
onCreateRoute,
|
| 201 |
onRenameRoute,
|
|
@@ -482,7 +487,12 @@ export function RouteNav({
|
|
| 482 |
) : null}
|
| 483 |
|
| 484 |
<div className="cg-route-nav-list" {...sectionDrop(ROOT_FOLDER_ID)}>
|
| 485 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
<div className="cg-fold-empty">
|
| 487 |
No routes yet. Plan one on the map, then save it to keep it here.
|
| 488 |
</div>
|
|
|
|
| 155 |
* row (standing rule 1's second sentence: reported, never silent).
|
| 156 |
*/
|
| 157 |
notice: string;
|
| 158 |
+
/** A failed listing is distinct from the honest empty state and offers an immediate retry. */
|
| 159 |
+
listError: string | null;
|
| 160 |
+
onListRetry: () => void;
|
| 161 |
/** ONE CLICK OPENS A ROUTE. There is no second gesture and no confirm step. */
|
| 162 |
onOpenRoute: (key: string) => void;
|
| 163 |
/** CREATING IS A SEPARATE AFFORDANCE, never a row in the list of what exists. */
|
|
|
|
| 199 |
openKey,
|
| 200 |
busyKey,
|
| 201 |
notice,
|
| 202 |
+
listError,
|
| 203 |
+
onListRetry,
|
| 204 |
onOpenRoute,
|
| 205 |
onCreateRoute,
|
| 206 |
onRenameRoute,
|
|
|
|
| 487 |
) : null}
|
| 488 |
|
| 489 |
<div className="cg-route-nav-list" {...sectionDrop(ROOT_FOLDER_ID)}>
|
| 490 |
+
{listError ? (
|
| 491 |
+
<div className="cg-route-nav-notice" role="alert">
|
| 492 |
+
<span>{listError}</span>
|
| 493 |
+
<button type="button" className="cg-link-btn" onClick={onListRetry}>Retry</button>
|
| 494 |
+
</div>
|
| 495 |
+
) : routes.length === 0 ? (
|
| 496 |
<div className="cg-fold-empty">
|
| 497 |
No routes yet. Plan one on the map, then save it to keep it here.
|
| 498 |
</div>
|
web/src/customer-grid/SwipeView.tsx
CHANGED
|
@@ -1,586 +1,586 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / SwipeView.tsx
|
| 3 |
-
// β WAVE-27 item 8 (owner ruling R2, contract C3) β the SWIPE deck.
|
| 4 |
-
//
|
| 5 |
-
// One record at a time, decided left or right into two options of ONE
|
| 6 |
-
// single-select. R2 fixes the shape and it is narrow on purpose:
|
| 7 |
-
// Β· SINGLE-SELECT ONLY, one field, two of its options. No checkbox binding.
|
| 8 |
-
// Β· the deck holds only the records whose bound field is EMPTY β a record
|
| 9 |
-
// that already has a value has been decided, and re-asking is not triage.
|
| 10 |
-
// Β· a swipe writes through the NORMAL cell door (`onSwipe`, which the host
|
| 11 |
-
// maps to the same `patchAndRecord` a kanban card move uses), so undo,
|
| 12 |
-
// echo-suppression and the permission wall all hold without being
|
| 13 |
-
// re-implemented here.
|
| 14 |
-
//
|
| 15 |
-
// Honesty rules carried from the other modes:
|
| 16 |
-
// Β· nothing is silently hidden β the deck states what is left, and the
|
| 17 |
-
// empty state names the bound field rather than saying "all done".
|
| 18 |
-
// Β· a lost binding is SHOWN, never repaired by guessing. `_clean_display`
|
| 19 |
-
// validates that `fieldKey` names a field the table HAS and stops there;
|
| 20 |
-
// it cannot know the field is still a select, or that the two options are
|
| 21 |
-
// still in its vocabulary (its docstring draws that line explicitly). So
|
| 22 |
-
// those three losses land here, and each one says what happened and
|
| 23 |
-
// re-opens the picker β the `viewModes.tsx:190-194` rule, which exists
|
| 24 |
-
// because the wave-7 trap was a picker silently falling back to its first
|
| 25 |
-
// option.
|
| 26 |
-
// ---------------------------------------------------------------------------
|
| 27 |
-
|
| 28 |
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 29 |
-
// ββ W41-T48 (owner instruction 22) β opening a record from the swipe deck folds the shell's
|
| 30 |
-
// navigation rail exactly as a grid cell click does. Guard rationale: `MapView.tsx:2755`.
|
| 31 |
-
import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
|
| 32 |
-
import { choiceOptions } from "./types";
|
| 33 |
-
import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
|
| 34 |
-
// β `SurfaceScope` is `apiBridge`'s (a TYPE-only import, so nothing of that module is
|
| 35 |
-
// loaded here) β the same import `RecordComments` takes for the same prop.
|
| 36 |
-
import type { SurfaceScope } from "./apiBridge";
|
| 37 |
-
import RecordComments from "./RecordComments";
|
| 38 |
-
import { Documents } from "./Documents";
|
| 39 |
-
import { formatDisplay } from "./cells";
|
| 40 |
-
import { actionHref } from "./display";
|
| 41 |
-
import { optionTint } from "./choiceColors";
|
| 42 |
-
import { ModeIcon } from "./icons";
|
| 43 |
-
// β W29-T73 β the line its three siblings all have, and the one this file shipped without.
|
| 44 |
-
// `SwipeView.css` had ZERO references anywhere in `src/`, so T26's two new sections rendered
|
| 45 |
-
// unstyled while `.cg-swipe-card` (which lives in `index.css`) kept the card looking right β
|
| 46 |
-
// a partially-styled surface reads as a design choice, which is why nobody reported it and no
|
| 47 |
-
// gate could see it: a side-effect import references no symbol ([[artifact-with-no-importer]]).
|
| 48 |
-
import "./SwipeView.css";
|
| 49 |
-
|
| 50 |
-
export type SwipeSpec = NonNullable<DisplaySpec["swipe"]>;
|
| 51 |
-
|
| 52 |
-
/** The three ways a stored binding can rot that the host cannot see (see the header note). */
|
| 53 |
-
type BindingFault =
|
| 54 |
-
| { kind: "unbound" }
|
| 55 |
-
| { kind: "field-gone"; fieldKey: string }
|
| 56 |
-
| { kind: "not-select"; field: Field }
|
| 57 |
-
| { kind: "option-gone"; field: Field; missing: string[] };
|
| 58 |
-
|
| 59 |
-
function readBinding(spec: SwipeSpec | undefined, fieldByKey: Map<string, Field>):
|
| 60 |
-
{ ok: true; field: Field; spec: SwipeSpec } | { ok: false; fault: BindingFault } {
|
| 61 |
-
if (!spec) return { ok: false, fault: { kind: "unbound" } };
|
| 62 |
-
const field = fieldByKey.get(spec.fieldKey);
|
| 63 |
-
if (!field) return { ok: false, fault: { kind: "field-gone", fieldKey: spec.fieldKey } };
|
| 64 |
-
// R2 β SINGLE-select only. `multiselect` is deliberately not accepted: its cell holds a SET,
|
| 65 |
-
// so "write this option" would mean append-or-replace, and a triage gesture that sometimes
|
| 66 |
-
// adds and sometimes overwrites is two gestures wearing one button.
|
| 67 |
-
if (field.type !== "select") return { ok: false, fault: { kind: "not-select", field } };
|
| 68 |
-
const options = choiceOptions(field);
|
| 69 |
-
const has = new Set(options.map((o) => o.toLowerCase()));
|
| 70 |
-
const missing = [spec.leftOption, spec.rightOption].filter((o) => !has.has(o.toLowerCase()));
|
| 71 |
-
if (missing.length) return { ok: false, fault: { kind: "option-gone", field, missing } };
|
| 72 |
-
return { ok: true, field, spec };
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
/** The bound cell is EMPTY β the one predicate that decides deck membership (R2). */
|
| 76 |
-
const isUndecided = (row: Row, key: string): boolean =>
|
| 77 |
-
String(row[key] ?? "").trim() === "";
|
| 78 |
-
|
| 79 |
-
export function SwipeView({
|
| 80 |
-
rows,
|
| 81 |
-
fields,
|
| 82 |
-
fieldByKey,
|
| 83 |
-
spec,
|
| 84 |
-
cardKeys,
|
| 85 |
-
detailKeys,
|
| 86 |
-
scope,
|
| 87 |
-
viewer,
|
| 88 |
-
docs,
|
| 89 |
-
docPayload,
|
| 90 |
-
onDocAdd,
|
| 91 |
-
onDocFetch,
|
| 92 |
-
onDocDelete,
|
| 93 |
-
titleKey,
|
| 94 |
-
canWrite,
|
| 95 |
-
readOnlyReason,
|
| 96 |
-
canBind,
|
| 97 |
-
onSpec,
|
| 98 |
-
onSwipe,
|
| 99 |
-
onOpen,
|
| 100 |
-
}: {
|
| 101 |
-
/** DISTINCT data rows from the full pipeline, overlay edits layered β the kanban's contract.
|
| 102 |
-
* The deck re-derives from these, so a written record leaves it as soon as the optimistic
|
| 103 |
-
* patch lands; there is no local cursor to drift out of step with the data. */
|
| 104 |
-
rows: Row[];
|
| 105 |
-
/** Every field, for the picker's field list. */
|
| 106 |
-
fields: Field[];
|
| 107 |
-
fieldByKey: Map<string, Field>;
|
| 108 |
-
/** The stored binding, or undefined for a deck nobody has configured yet. */
|
| 109 |
-
spec: SwipeSpec | undefined;
|
| 110 |
-
/** The handful of visible fields the card lists under its title. */
|
| 111 |
-
cardKeys: string[];
|
| 112 |
-
titleKey: string;
|
| 113 |
-
/** May this viewer write the BOUND field's value? (the kanban's `canMove`) */
|
| 114 |
-
canWrite: boolean;
|
| 115 |
-
/** Stated when the deck cannot be decided β permissions, or a computed column. */
|
| 116 |
-
readOnlyReason: string | null;
|
| 117 |
-
/** May this viewer change what the deck is bound TO? A VIEW-config act, so it is the
|
| 118 |
-
* view-editing permission and NOT `canWrite` β conflating the two is
|
| 119 |
-
* [[schema-role-is-not-a-value-wall]]. REQUIRED, because a picker that silently does
|
| 120 |
-
* nothing is worse than no picker ([[wrong-parent-not-broken-control]]). */
|
| 121 |
-
canBind: boolean;
|
| 122 |
-
/** `undefined` DELETES the binding β absent is the honest unconfigured state, and storing a
|
| 123 |
-
* half-binding is what `cleanDisplay` drops on both engines (the `kanbanClamp` law). */
|
| 124 |
-
onSpec: (next: SwipeSpec | undefined) => void;
|
| 125 |
-
onSwipe: (pid: number, value: string) => void;
|
| 126 |
-
onOpen: (pid: number) => void;
|
| 127 |
-
/**
|
| 128 |
-
* ββ WAVE-29 T26 (owner R9) β WHAT MAKES THE CARD A RECORD RATHER THAN A SUMMARY.
|
| 129 |
-
*
|
| 130 |
-
* R9: *"the swipe CARD itself renders the record detail inline β fields, comments and
|
| 131 |
-
* attachments β so a reviewer decides without leaving the deck."* Everything below is that,
|
| 132 |
-
* and every one of them is OPTIONAL for one reason: this deck runs on hosts that supply
|
| 133 |
-
* different amounts. A card must render with none of them rather than throw β the standalone
|
| 134 |
-
* embed has no `scope`, and a deployment with no document storage serves no handlers.
|
| 135 |
-
*/
|
| 136 |
-
/** Every field the VIEW shows, in its order β not the three-key card summary. */
|
| 137 |
-
detailKeys?: string[];
|
| 138 |
-
/** The surface these records live on. Comments are keyed by it; absent β no comments section,
|
| 139 |
-
* which is the pre-existing precondition `RecordDetail` already carries. */
|
| 140 |
-
scope?: SurfaceScope;
|
| 141 |
-
viewer?: Viewer;
|
| 142 |
-
/** This record's attachments, and the plumbing. Handlers absent β the section is not rendered
|
| 143 |
-
* at all, exactly as `RecordDetail` decides it (a dead upload control is a promise the app
|
| 144 |
-
* cannot keep). */
|
| 145 |
-
/** Keyed by pid, because a deck shows many records β `RecordDetail` takes ONE record's list
|
| 146 |
-
* and that shape cannot serve a deck. */
|
| 147 |
-
docs?: Record<string, CustomerDoc[]>;
|
| 148 |
-
docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
|
| 149 |
-
/** β EVERY HANDLER TAKES THE PID, unlike `RecordDetail`'s, whose host closes over the ONE
|
| 150 |
-
* open record. A deck paints many records at once, so a handler bound to a single pid would
|
| 151 |
-
* attach every reviewer's upload to whichever card happened to be open. */
|
| 152 |
-
onDocAdd?: (
|
| 153 |
-
pid: number,
|
| 154 |
-
file: { name: string; mime: string; size: number; data_b64: string }
|
| 155 |
-
) => void;
|
| 156 |
-
onDocFetch?: (pid: number, docId: string) => void;
|
| 157 |
-
onDocDelete?: (pid: number, docId: string) => void;
|
| 158 |
-
}) {
|
| 159 |
-
// "Later" β session-only, never stored. A deck with no way past a record you cannot decide
|
| 160 |
-
// blocks on that record forever, so this is navigation WITHIN the deck rather than a third
|
| 161 |
-
// gesture: it writes nothing, survives no reload, and the count is disclosed in the empty
|
| 162 |
-
// state rather than quietly shrinking the deck (rule 8b).
|
| 163 |
-
const [later, setLater] = useState<Set<number>>(new Set());
|
| 164 |
-
const deckRef = useRef<HTMLDivElement>(null);
|
| 165 |
-
|
| 166 |
-
const binding = useMemo(() => readBinding(spec, fieldByKey), [spec, fieldByKey]);
|
| 167 |
-
const boundKey = binding.ok ? binding.field.key : null;
|
| 168 |
-
|
| 169 |
-
const undecided = useMemo(
|
| 170 |
-
() => (boundKey ? rows.filter((r) => isUndecided(r, boundKey)) : []),
|
| 171 |
-
[rows, boundKey]
|
| 172 |
-
);
|
| 173 |
-
const deck = useMemo(() => undecided.filter((r) => !later.has(r.pid)), [undecided, later]);
|
| 174 |
-
const card = deck[0];
|
| 175 |
-
|
| 176 |
-
// A record decided elsewhere (the grid, another user's echo) must not stay "later" forever β
|
| 177 |
-
// otherwise the disclosed skip count drifts away from what is actually on the deck.
|
| 178 |
-
useEffect(() => {
|
| 179 |
-
setLater((prev) => {
|
| 180 |
-
if (prev.size === 0) return prev;
|
| 181 |
-
const live = new Set(undecided.map((r) => r.pid));
|
| 182 |
-
const next = new Set([...prev].filter((pid) => live.has(pid)));
|
| 183 |
-
return next.size === prev.size ? prev : next;
|
| 184 |
-
});
|
| 185 |
-
}, [undecided]);
|
| 186 |
-
|
| 187 |
-
const decide = useCallback(
|
| 188 |
-
(value: string) => {
|
| 189 |
-
if (!card || !canWrite) return;
|
| 190 |
-
onSwipe(card.pid, value);
|
| 191 |
-
},
|
| 192 |
-
[card, canWrite, onSwipe]
|
| 193 |
-
);
|
| 194 |
-
|
| 195 |
-
// β / β decide, and they are the SAME two actions the buttons are rather than a second code
|
| 196 |
-
// path: the whole point of the mode is that one hand can clear a queue.
|
| 197 |
-
useEffect(() => {
|
| 198 |
-
if (!binding.ok || !canWrite || !card) return;
|
| 199 |
-
const onKey = (e: KeyboardEvent) => {
|
| 200 |
-
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
| 201 |
-
const el = document.activeElement as HTMLElement | null;
|
| 202 |
-
// Never steal an arrow key from a text field or a native control the user is inside.
|
| 203 |
-
if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA"))
|
| 204 |
-
return;
|
| 205 |
-
if (!deckRef.current?.contains(el ?? null) && el !== document.body) return;
|
| 206 |
-
e.preventDefault();
|
| 207 |
-
decide(e.key === "ArrowLeft" ? binding.spec.leftOption : binding.spec.rightOption);
|
| 208 |
-
};
|
| 209 |
-
window.addEventListener("keydown", onKey);
|
| 210 |
-
return () => window.removeEventListener("keydown", onKey);
|
| 211 |
-
}, [binding, canWrite, card, decide]);
|
| 212 |
-
|
| 213 |
-
if (!binding.ok) {
|
| 214 |
-
return (
|
| 215 |
-
<div className="cg-swipe" ref={deckRef}>
|
| 216 |
-
<SwipeBinder
|
| 217 |
-
fault={binding.fault}
|
| 218 |
-
fields={fields}
|
| 219 |
-
fieldByKey={fieldByKey}
|
| 220 |
-
spec={spec}
|
| 221 |
-
canBind={canBind}
|
| 222 |
-
onSpec={onSpec}
|
| 223 |
-
/>
|
| 224 |
-
</div>
|
| 225 |
-
);
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
const { field } = binding;
|
| 229 |
-
const leftTint = optionTint(field, binding.spec.leftOption);
|
| 230 |
-
const rightTint = optionTint(field, binding.spec.rightOption);
|
| 231 |
-
|
| 232 |
-
return (
|
| 233 |
-
<div className="cg-swipe" ref={deckRef} tabIndex={-1}>
|
| 234 |
-
<div className="cg-swipe-bar">
|
| 235 |
-
<span className="cg-swipe-bound">
|
| 236 |
-
<ModeIcon mode="swipe" />
|
| 237 |
-
Sorting into <strong>{field.label}</strong>
|
| 238 |
-
</span>
|
| 239 |
-
<span className="cg-swipe-left">
|
| 240 |
-
{deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort
|
| 241 |
-
</span>
|
| 242 |
-
{canBind && (
|
| 243 |
-
<button
|
| 244 |
-
type="button"
|
| 245 |
-
className="cg-link-btn"
|
| 246 |
-
onClick={() => onSpec(undefined)}
|
| 247 |
-
title="Choose a different field or different options for this deck"
|
| 248 |
-
>
|
| 249 |
-
Change
|
| 250 |
-
</button>
|
| 251 |
-
)}
|
| 252 |
-
</div>
|
| 253 |
-
{readOnlyReason && <div className="cg-kb-note">{readOnlyReason}</div>}
|
| 254 |
-
{card ? (
|
| 255 |
-
<div className="cg-swipe-deck">
|
| 256 |
-
<button
|
| 257 |
-
type="button"
|
| 258 |
-
className="cg-swipe-side cg-swipe-side--left"
|
| 259 |
-
disabled={!canWrite}
|
| 260 |
-
style={leftTint ? { background: leftTint.bg, color: leftTint.fg } : undefined}
|
| 261 |
-
onClick={() => decide(binding.spec.leftOption)}
|
| 262 |
-
title={`Set ${field.label} to ${binding.spec.leftOption} (left arrow key)`}
|
| 263 |
-
>
|
| 264 |
-
<span aria-hidden className="cg-swipe-arrow">
|
| 265 |
-
<svg width="16" height="16" viewBox="0 0 16 16">
|
| 266 |
-
<path
|
| 267 |
-
d="M11 3.2 6.2 8l4.8 4.8"
|
| 268 |
-
fill="none"
|
| 269 |
-
stroke="currentColor"
|
| 270 |
-
strokeWidth="1.6"
|
| 271 |
-
strokeLinecap="round"
|
| 272 |
-
strokeLinejoin="round"
|
| 273 |
-
/>
|
| 274 |
-
</svg>
|
| 275 |
-
</span>
|
| 276 |
-
{binding.spec.leftOption}
|
| 277 |
-
</button>
|
| 278 |
-
<article
|
| 279 |
-
className="cg-swipe-card"
|
| 280 |
-
role="button"
|
| 281 |
-
tabIndex={0}
|
| 282 |
-
onClick={(e) => {
|
| 283 |
-
// β W41-T48 β the card IS the deck's record open, so it folds the rail.
|
| 284 |
-
// β Nothing that is NOT an open can reach this line: every control inside the
|
| 285 |
-
// card (a url cell, the comments box, the document tray) already stops its own
|
| 286 |
-
// click, and the left/right decision buttons are siblings of the card, not
|
| 287 |
-
// children of it. The deck also has no pointer drag to disambiguate β its swipe
|
| 288 |
-
// is the two buttons and the arrow keys, neither of which opens anything.
|
| 289 |
-
// β `<article role="button">`, not a native button, so a keyboard activation
|
| 290 |
-
// never arrives here as a synthetic click; the keydown path below is the whole
|
| 291 |
-
// keyboard story and the two cannot both fire for one gesture.
|
| 292 |
-
if (e.detail <= 1) signal(NAV_MINIMIZE_EVENT);
|
| 293 |
-
onOpen(card.pid);
|
| 294 |
-
}}
|
| 295 |
-
onKeyDown={(e) => {
|
| 296 |
-
if (e.key !== "Enter" && e.key !== " ") return;
|
| 297 |
-
e.preventDefault();
|
| 298 |
-
// W41-T48 β `!e.repeat` is this path's `e.detail <= 1`: a HELD Enter or Space
|
| 299 |
-
// auto-repeats keydown, and every one of those repeats is the SAME gesture.
|
| 300 |
-
if (!e.repeat) signal(NAV_MINIMIZE_EVENT);
|
| 301 |
-
onOpen(card.pid);
|
| 302 |
-
}}
|
| 303 |
-
>
|
| 304 |
-
<h3 className="cg-swipe-title">{String(card[titleKey] ?? "")}</h3>
|
| 305 |
-
<div className="cg-swipe-cells">
|
| 306 |
-
{/* β WAVE-29 T26 (R9) β the RECORD's visible fields, not the three-key summary the
|
| 307 |
-
kanban card lends. `detailKeys` falls back to `cardKeys` so a host that has not
|
| 308 |
-
been widened yet renders exactly what it rendered before. */}
|
| 309 |
-
{(detailKeys && detailKeys.length ? detailKeys : cardKeys).map((k) => {
|
| 310 |
-
const f = fieldByKey.get(k);
|
| 311 |
-
if (!f) return null;
|
| 312 |
-
const text = formatDisplay(f, card[k]);
|
| 313 |
-
if (!text) return null;
|
| 314 |
-
// A url cell is a LINK here too (wave-26 item 11), through `display.ts`'s one
|
| 315 |
-
// scheme guard β and every gesture that reaches it must be stopped from also
|
| 316 |
-
// reaching the card, which is a button (the kanban card's note, same trap).
|
| 317 |
-
const href = actionHref(f, card[k]);
|
| 318 |
-
return (
|
| 319 |
-
<div key={k} className="cg-swipe-cell">
|
| 320 |
-
<span className="cg-lv-k">{f.label}</span>
|
| 321 |
-
{href ? (
|
| 322 |
-
<a
|
| 323 |
-
className="cg-lv-v"
|
| 324 |
-
href={href}
|
| 325 |
-
target="_blank"
|
| 326 |
-
rel="noopener noreferrer"
|
| 327 |
-
title={text}
|
| 328 |
-
onClick={(e) => e.stopPropagation()}
|
| 329 |
-
onKeyDown={(e) => e.stopPropagation()}
|
| 330 |
-
>
|
| 331 |
-
{text}
|
| 332 |
-
</a>
|
| 333 |
-
) : (
|
| 334 |
-
<span className="cg-lv-v">{text}</span>
|
| 335 |
-
)}
|
| 336 |
-
</div>
|
| 337 |
-
);
|
| 338 |
-
})}
|
| 339 |
-
</div>
|
| 340 |
-
{/* β R9's other two thirds. Both stop their own events: the card is a `role="button"`
|
| 341 |
-
that opens the record, and a click on a comment box or an upload control must not
|
| 342 |
-
also open the modal it exists to make unnecessary (the kanban card's link trap,
|
| 343 |
-
one surface over). */}
|
| 344 |
-
{onDocAdd && onDocFetch && onDocDelete && (
|
| 345 |
-
<div
|
| 346 |
-
className="cg-swipe-docs"
|
| 347 |
-
onClick={(e) => e.stopPropagation()}
|
| 348 |
-
onKeyDown={(e) => e.stopPropagation()}
|
| 349 |
-
role="presentation"
|
| 350 |
-
>
|
| 351 |
-
<Documents
|
| 352 |
-
pid={Number(card.pid)}
|
| 353 |
-
docs={docs?.[String(card.pid)] ?? []}
|
| 354 |
-
docPayload={docPayload}
|
| 355 |
-
onAdd={(file) => onDocAdd(card.pid, file)}
|
| 356 |
-
onFetch={(docId) => onDocFetch(card.pid, docId)}
|
| 357 |
-
onDelete={(docId) => onDocDelete(card.pid, docId)}
|
| 358 |
-
/>
|
| 359 |
-
</div>
|
| 360 |
-
)}
|
| 361 |
-
{scope != null && (
|
| 362 |
-
<div
|
| 363 |
-
className="cg-swipe-comments"
|
| 364 |
-
onClick={(e) => e.stopPropagation()}
|
| 365 |
-
onKeyDown={(e) => e.stopPropagation()}
|
| 366 |
-
role="presentation"
|
| 367 |
-
>
|
| 368 |
-
{/* β KEYED ON scope+pid, the same key `RecordDetail` uses: without it React
|
| 369 |
-
reuses the mounted instance across a card change and paints one record's
|
| 370 |
-
comments under another's name until the fetch returns. */}
|
| 371 |
-
<RecordComments
|
| 372 |
-
key={`${scope}:${Number(card.pid)}`}
|
| 373 |
-
scope={scope}
|
| 374 |
-
pid={Number(card.pid)}
|
| 375 |
-
viewer={viewer}
|
| 376 |
-
/>
|
| 377 |
-
</div>
|
| 378 |
-
)}
|
| 379 |
-
{canWrite && (
|
| 380 |
-
<button
|
| 381 |
-
type="button"
|
| 382 |
-
className="cg-link-btn cg-swipe-later"
|
| 383 |
-
onClick={(e) => {
|
| 384 |
-
e.stopPropagation();
|
| 385 |
-
setLater((prev) => new Set(prev).add(card.pid));
|
| 386 |
-
}}
|
| 387 |
-
>
|
| 388 |
-
Decide later
|
| 389 |
-
</button>
|
| 390 |
-
)}
|
| 391 |
-
</article>
|
| 392 |
-
<button
|
| 393 |
-
type="button"
|
| 394 |
-
className="cg-swipe-side cg-swipe-side--right"
|
| 395 |
-
disabled={!canWrite}
|
| 396 |
-
style={rightTint ? { background: rightTint.bg, color: rightTint.fg } : undefined}
|
| 397 |
-
onClick={() => decide(binding.spec.rightOption)}
|
| 398 |
-
title={`Set ${field.label} to ${binding.spec.rightOption} (right arrow key)`}
|
| 399 |
-
>
|
| 400 |
-
{binding.spec.rightOption}
|
| 401 |
-
<span aria-hidden className="cg-swipe-arrow">
|
| 402 |
-
<svg width="16" height="16" viewBox="0 0 16 16">
|
| 403 |
-
<path
|
| 404 |
-
d="M5 3.2 9.8 8 5 12.8"
|
| 405 |
-
fill="none"
|
| 406 |
-
stroke="currentColor"
|
| 407 |
-
strokeWidth="1.6"
|
| 408 |
-
strokeLinecap="round"
|
| 409 |
-
strokeLinejoin="round"
|
| 410 |
-
/>
|
| 411 |
-
</svg>
|
| 412 |
-
</span>
|
| 413 |
-
</button>
|
| 414 |
-
</div>
|
| 415 |
-
) : (
|
| 416 |
-
// The empty state NAMES the bound field (C3), because "nothing to sort" is ambiguous
|
| 417 |
-
// between "the deck is finished" and "the filter hid everything", and the two want
|
| 418 |
-
// different next actions. `later` is disclosed rather than quietly subtracted.
|
| 419 |
-
<div className="cg-mode-empty cg-swipe-empty">
|
| 420 |
-
{later.size > 0 ? (
|
| 421 |
-
<>
|
| 422 |
-
<p>
|
| 423 |
-
{later.size.toLocaleString()}{" "}
|
| 424 |
-
{later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else
|
| 425 |
-
in this view is missing a <strong>{field.label}</strong>.
|
| 426 |
-
</p>
|
| 427 |
-
<button type="button" className="cg-btn" onClick={() => setLater(new Set())}>
|
| 428 |
-
Bring them back
|
| 429 |
-
</button>
|
| 430 |
-
</>
|
| 431 |
-
) : (
|
| 432 |
-
<p>
|
| 433 |
-
Every record in this view already has a <strong>{field.label}</strong>. Widen the
|
| 434 |
-
view's filters to sort more.
|
| 435 |
-
</p>
|
| 436 |
-
)}
|
| 437 |
-
</div>
|
| 438 |
-
)}
|
| 439 |
-
</div>
|
| 440 |
-
);
|
| 441 |
-
}
|
| 442 |
-
|
| 443 |
-
/**
|
| 444 |
-
* The binding picker: field + two options. It is also the surface every LOST binding lands on,
|
| 445 |
-
* with the loss stated above it β one place that answers "what is this deck for", rather than an
|
| 446 |
-
* error screen beside a separate setup screen.
|
| 447 |
-
*/
|
| 448 |
-
function SwipeBinder({
|
| 449 |
-
fault,
|
| 450 |
-
fields,
|
| 451 |
-
fieldByKey,
|
| 452 |
-
spec,
|
| 453 |
-
canBind,
|
| 454 |
-
onSpec,
|
| 455 |
-
}: {
|
| 456 |
-
fault: BindingFault;
|
| 457 |
-
fields: Field[];
|
| 458 |
-
fieldByKey: Map<string, Field>;
|
| 459 |
-
spec: SwipeSpec | undefined;
|
| 460 |
-
canBind: boolean;
|
| 461 |
-
onSpec: (next: SwipeSpec | undefined) => void;
|
| 462 |
-
}) {
|
| 463 |
-
const selects = useMemo(
|
| 464 |
-
() => fields.filter((f) => f.type === "select" && choiceOptions(f).length >= 2),
|
| 465 |
-
[fields]
|
| 466 |
-
);
|
| 467 |
-
const [fieldKey, setFieldKey] = useState<string>(() => {
|
| 468 |
-
if (spec && fieldByKey.get(spec.fieldKey)?.type === "select") return spec.fieldKey;
|
| 469 |
-
return selects[0]?.key ?? "";
|
| 470 |
-
});
|
| 471 |
-
const chosen = fieldByKey.get(fieldKey);
|
| 472 |
-
const options = useMemo(() => (chosen ? choiceOptions(chosen) : []), [chosen]);
|
| 473 |
-
const [left, setLeft] = useState<string>("");
|
| 474 |
-
const [right, setRight] = useState<string>("");
|
| 475 |
-
|
| 476 |
-
// The two option selects follow the FIELD. Kept in an effect rather than derived so the user's
|
| 477 |
-
// pick survives re-renders, and reset whenever the field changes β carrying an option from the
|
| 478 |
-
// previous field would offer a value the new field cannot hold.
|
| 479 |
-
useEffect(() => {
|
| 480 |
-
setLeft(options[0] ?? "");
|
| 481 |
-
setRight(options.find((o) => o !== options[0]) ?? "");
|
| 482 |
-
}, [options]);
|
| 483 |
-
|
| 484 |
-
const message = ((): string => {
|
| 485 |
-
switch (fault.kind) {
|
| 486 |
-
case "field-gone":
|
| 487 |
-
return "The field this deck sorted into has been deleted. Pick another one.";
|
| 488 |
-
case "not-select":
|
| 489 |
-
return `β${fault.field.label}β is no longer a single-select, so it has no options to`
|
| 490 |
-
+ " sort into. Pick another field.";
|
| 491 |
-
case "option-gone":
|
| 492 |
-
return `${fault.missing.map((m) => `β${m}β`).join(" and ")} ${
|
| 493 |
-
fault.missing.length === 1 ? "is" : "are"
|
| 494 |
-
} no longer ${fault.missing.length === 1 ? "an option" : "options"} on β${
|
| 495 |
-
fault.field.label
|
| 496 |
-
}β. Pick the sides again.`;
|
| 497 |
-
default:
|
| 498 |
-
return "Sort records one at a time into two options of a single-select field.";
|
| 499 |
-
}
|
| 500 |
-
})();
|
| 501 |
-
|
| 502 |
-
if (!canBind) {
|
| 503 |
-
return (
|
| 504 |
-
<div className="cg-mode-empty">
|
| 505 |
-
<p>{message}</p>
|
| 506 |
-
<p>Its creator or an admin can set this view up.</p>
|
| 507 |
-
</div>
|
| 508 |
-
);
|
| 509 |
-
}
|
| 510 |
-
if (selects.length === 0) {
|
| 511 |
-
// Honest and specific: the mode is not broken, the TABLE has nothing to bind to. Naming the
|
| 512 |
-
// requirement is what turns a dead end into a next action.
|
| 513 |
-
return (
|
| 514 |
-
<div className="cg-mode-empty">
|
| 515 |
-
<p>
|
| 516 |
-
A swipe deck sorts into a single-select field with at least two options. This database
|
| 517 |
-
does not have one yet β add a single-select column, then come back.
|
| 518 |
-
</p>
|
| 519 |
-
</div>
|
| 520 |
-
);
|
| 521 |
-
}
|
| 522 |
-
const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase();
|
| 523 |
-
return (
|
| 524 |
-
<div className="cg-swipe-setup">
|
| 525 |
-
<p className="cg-swipe-setup-note">{message}</p>
|
| 526 |
-
<label className="cg-swipe-setup-row">
|
| 527 |
-
<span>Sort into</span>
|
| 528 |
-
<select
|
| 529 |
-
className="cg-select"
|
| 530 |
-
value={fieldKey}
|
| 531 |
-
onChange={(e) => setFieldKey(e.target.value)}
|
| 532 |
-
>
|
| 533 |
-
{/* Every option carries an explicit `value`: a <select> whose value names nothing
|
| 534 |
-
renders its FIRST option and reports a choice the user never made
|
| 535 |
-
([[cg-condition-builder-items]]). */}
|
| 536 |
-
{selects.map((f) => (
|
| 537 |
-
<option key={f.key} value={f.key}>
|
| 538 |
-
{f.label}
|
| 539 |
-
</option>
|
| 540 |
-
))}
|
| 541 |
-
</select>
|
| 542 |
-
</label>
|
| 543 |
-
<div className="cg-swipe-setup-sides">
|
| 544 |
-
<label className="cg-swipe-setup-row">
|
| 545 |
-
<span>Swipe left</span>
|
| 546 |
-
<select className="cg-select" value={left} onChange={(e) => setLeft(e.target.value)}>
|
| 547 |
-
{options.map((o) => (
|
| 548 |
-
<option key={o} value={o}>
|
| 549 |
-
{o}
|
| 550 |
-
</option>
|
| 551 |
-
))}
|
| 552 |
-
</select>
|
| 553 |
-
</label>
|
| 554 |
-
<label className="cg-swipe-setup-row">
|
| 555 |
-
<span>Swipe right</span>
|
| 556 |
-
<select className="cg-select" value={right} onChange={(e) => setRight(e.target.value)}>
|
| 557 |
-
{options.map((o) => (
|
| 558 |
-
<option key={o} value={o}>
|
| 559 |
-
{o}
|
| 560 |
-
</option>
|
| 561 |
-
))}
|
| 562 |
-
</select>
|
| 563 |
-
</label>
|
| 564 |
-
</div>
|
| 565 |
-
{!ready && left && right && (
|
| 566 |
-
// Stated, not silently refused: both engines DROP a binding whose sides are the same
|
| 567 |
-
// option, so a Save that looked like it worked would simply not persist.
|
| 568 |
-
<p className="cg-swipe-setup-warn">
|
| 569 |
-
The two sides must be different options β otherwise both gestures do the same thing.
|
| 570 |
-
</p>
|
| 571 |
-
)}
|
| 572 |
-
<button
|
| 573 |
-
type="button"
|
| 574 |
-
className="cg-btn cg-btn--primary"
|
| 575 |
-
disabled={!ready}
|
| 576 |
-
onClick={() =>
|
| 577 |
-
chosen && onSpec({ fieldKey: chosen.key, leftOption: left, rightOption: right })
|
| 578 |
-
}
|
| 579 |
-
>
|
| 580 |
-
Start sorting
|
| 581 |
-
</button>
|
| 582 |
-
</div>
|
| 583 |
-
);
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
export default SwipeView;
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / SwipeView.tsx
|
| 3 |
+
// β WAVE-27 item 8 (owner ruling R2, contract C3) β the SWIPE deck.
|
| 4 |
+
//
|
| 5 |
+
// One record at a time, decided left or right into two options of ONE
|
| 6 |
+
// single-select. R2 fixes the shape and it is narrow on purpose:
|
| 7 |
+
// Β· SINGLE-SELECT ONLY, one field, two of its options. No checkbox binding.
|
| 8 |
+
// Β· the deck holds only the records whose bound field is EMPTY β a record
|
| 9 |
+
// that already has a value has been decided, and re-asking is not triage.
|
| 10 |
+
// Β· a swipe writes through the NORMAL cell door (`onSwipe`, which the host
|
| 11 |
+
// maps to the same `patchAndRecord` a kanban card move uses), so undo,
|
| 12 |
+
// echo-suppression and the permission wall all hold without being
|
| 13 |
+
// re-implemented here.
|
| 14 |
+
//
|
| 15 |
+
// Honesty rules carried from the other modes:
|
| 16 |
+
// Β· nothing is silently hidden β the deck states what is left, and the
|
| 17 |
+
// empty state names the bound field rather than saying "all done".
|
| 18 |
+
// Β· a lost binding is SHOWN, never repaired by guessing. `_clean_display`
|
| 19 |
+
// validates that `fieldKey` names a field the table HAS and stops there;
|
| 20 |
+
// it cannot know the field is still a select, or that the two options are
|
| 21 |
+
// still in its vocabulary (its docstring draws that line explicitly). So
|
| 22 |
+
// those three losses land here, and each one says what happened and
|
| 23 |
+
// re-opens the picker β the `viewModes.tsx:190-194` rule, which exists
|
| 24 |
+
// because the wave-7 trap was a picker silently falling back to its first
|
| 25 |
+
// option.
|
| 26 |
+
// ---------------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 29 |
+
// ββ W41-T48 (owner instruction 22) β opening a record from the swipe deck folds the shell's
|
| 30 |
+
// navigation rail exactly as a grid cell click does. Guard rationale: `MapView.tsx:2755`.
|
| 31 |
+
import { NAV_MINIMIZE_EVENT, signal } from "../apiContract";
|
| 32 |
+
import { choiceOptions } from "./types";
|
| 33 |
+
import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
|
| 34 |
+
// β `SurfaceScope` is `apiBridge`'s (a TYPE-only import, so nothing of that module is
|
| 35 |
+
// loaded here) β the same import `RecordComments` takes for the same prop.
|
| 36 |
+
import type { SurfaceScope } from "./apiBridge";
|
| 37 |
+
import RecordComments from "./RecordComments";
|
| 38 |
+
import { Documents } from "./Documents";
|
| 39 |
+
import { formatDisplay } from "./cells";
|
| 40 |
+
import { actionHref } from "./display";
|
| 41 |
+
import { optionTint } from "./choiceColors";
|
| 42 |
+
import { ModeIcon } from "./icons";
|
| 43 |
+
// β W29-T73 β the line its three siblings all have, and the one this file shipped without.
|
| 44 |
+
// `SwipeView.css` had ZERO references anywhere in `src/`, so T26's two new sections rendered
|
| 45 |
+
// unstyled while `.cg-swipe-card` (which lives in `index.css`) kept the card looking right β
|
| 46 |
+
// a partially-styled surface reads as a design choice, which is why nobody reported it and no
|
| 47 |
+
// gate could see it: a side-effect import references no symbol ([[artifact-with-no-importer]]).
|
| 48 |
+
import "./SwipeView.css";
|
| 49 |
+
|
| 50 |
+
export type SwipeSpec = NonNullable<DisplaySpec["swipe"]>;
|
| 51 |
+
|
| 52 |
+
/** The three ways a stored binding can rot that the host cannot see (see the header note). */
|
| 53 |
+
type BindingFault =
|
| 54 |
+
| { kind: "unbound" }
|
| 55 |
+
| { kind: "field-gone"; fieldKey: string }
|
| 56 |
+
| { kind: "not-select"; field: Field }
|
| 57 |
+
| { kind: "option-gone"; field: Field; missing: string[] };
|
| 58 |
+
|
| 59 |
+
function readBinding(spec: SwipeSpec | undefined, fieldByKey: Map<string, Field>):
|
| 60 |
+
{ ok: true; field: Field; spec: SwipeSpec } | { ok: false; fault: BindingFault } {
|
| 61 |
+
if (!spec) return { ok: false, fault: { kind: "unbound" } };
|
| 62 |
+
const field = fieldByKey.get(spec.fieldKey);
|
| 63 |
+
if (!field) return { ok: false, fault: { kind: "field-gone", fieldKey: spec.fieldKey } };
|
| 64 |
+
// R2 β SINGLE-select only. `multiselect` is deliberately not accepted: its cell holds a SET,
|
| 65 |
+
// so "write this option" would mean append-or-replace, and a triage gesture that sometimes
|
| 66 |
+
// adds and sometimes overwrites is two gestures wearing one button.
|
| 67 |
+
if (field.type !== "select") return { ok: false, fault: { kind: "not-select", field } };
|
| 68 |
+
const options = choiceOptions(field);
|
| 69 |
+
const has = new Set(options.map((o) => o.toLowerCase()));
|
| 70 |
+
const missing = [spec.leftOption, spec.rightOption].filter((o) => !has.has(o.toLowerCase()));
|
| 71 |
+
if (missing.length) return { ok: false, fault: { kind: "option-gone", field, missing } };
|
| 72 |
+
return { ok: true, field, spec };
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** The bound cell is EMPTY β the one predicate that decides deck membership (R2). */
|
| 76 |
+
const isUndecided = (row: Row, key: string): boolean =>
|
| 77 |
+
String(row[key] ?? "").trim() === "";
|
| 78 |
+
|
| 79 |
+
export function SwipeView({
|
| 80 |
+
rows,
|
| 81 |
+
fields,
|
| 82 |
+
fieldByKey,
|
| 83 |
+
spec,
|
| 84 |
+
cardKeys,
|
| 85 |
+
detailKeys,
|
| 86 |
+
scope,
|
| 87 |
+
viewer,
|
| 88 |
+
docs,
|
| 89 |
+
docPayload,
|
| 90 |
+
onDocAdd,
|
| 91 |
+
onDocFetch,
|
| 92 |
+
onDocDelete,
|
| 93 |
+
titleKey,
|
| 94 |
+
canWrite,
|
| 95 |
+
readOnlyReason,
|
| 96 |
+
canBind,
|
| 97 |
+
onSpec,
|
| 98 |
+
onSwipe,
|
| 99 |
+
onOpen,
|
| 100 |
+
}: {
|
| 101 |
+
/** DISTINCT data rows from the full pipeline, overlay edits layered β the kanban's contract.
|
| 102 |
+
* The deck re-derives from these, so a written record leaves it as soon as the optimistic
|
| 103 |
+
* patch lands; there is no local cursor to drift out of step with the data. */
|
| 104 |
+
rows: Row[];
|
| 105 |
+
/** Every field, for the picker's field list. */
|
| 106 |
+
fields: Field[];
|
| 107 |
+
fieldByKey: Map<string, Field>;
|
| 108 |
+
/** The stored binding, or undefined for a deck nobody has configured yet. */
|
| 109 |
+
spec: SwipeSpec | undefined;
|
| 110 |
+
/** The handful of visible fields the card lists under its title. */
|
| 111 |
+
cardKeys: string[];
|
| 112 |
+
titleKey: string;
|
| 113 |
+
/** May this viewer write the BOUND field's value? (the kanban's `canMove`) */
|
| 114 |
+
canWrite: boolean;
|
| 115 |
+
/** Stated when the deck cannot be decided β permissions, or a computed column. */
|
| 116 |
+
readOnlyReason: string | null;
|
| 117 |
+
/** May this viewer change what the deck is bound TO? A VIEW-config act, so it is the
|
| 118 |
+
* view-editing permission and NOT `canWrite` β conflating the two is
|
| 119 |
+
* [[schema-role-is-not-a-value-wall]]. REQUIRED, because a picker that silently does
|
| 120 |
+
* nothing is worse than no picker ([[wrong-parent-not-broken-control]]). */
|
| 121 |
+
canBind: boolean;
|
| 122 |
+
/** `undefined` DELETES the binding β absent is the honest unconfigured state, and storing a
|
| 123 |
+
* half-binding is what `cleanDisplay` drops on both engines (the `kanbanClamp` law). */
|
| 124 |
+
onSpec: (next: SwipeSpec | undefined) => void;
|
| 125 |
+
onSwipe: (pid: number, value: string) => void;
|
| 126 |
+
onOpen: (pid: number) => void;
|
| 127 |
+
/**
|
| 128 |
+
* ββ WAVE-29 T26 (owner R9) β WHAT MAKES THE CARD A RECORD RATHER THAN A SUMMARY.
|
| 129 |
+
*
|
| 130 |
+
* R9: *"the swipe CARD itself renders the record detail inline β fields, comments and
|
| 131 |
+
* attachments β so a reviewer decides without leaving the deck."* Everything below is that,
|
| 132 |
+
* and every one of them is OPTIONAL for one reason: this deck runs on hosts that supply
|
| 133 |
+
* different amounts. A card must render with none of them rather than throw β the standalone
|
| 134 |
+
* embed has no `scope`, and a deployment with no document storage serves no handlers.
|
| 135 |
+
*/
|
| 136 |
+
/** Every field the VIEW shows, in its order β not the three-key card summary. */
|
| 137 |
+
detailKeys?: string[];
|
| 138 |
+
/** The surface these records live on. Comments are keyed by it; absent β no comments section,
|
| 139 |
+
* which is the pre-existing precondition `RecordDetail` already carries. */
|
| 140 |
+
scope?: SurfaceScope;
|
| 141 |
+
viewer?: Viewer;
|
| 142 |
+
/** This record's attachments, and the plumbing. Handlers absent β the section is not rendered
|
| 143 |
+
* at all, exactly as `RecordDetail` decides it (a dead upload control is a promise the app
|
| 144 |
+
* cannot keep). */
|
| 145 |
+
/** Keyed by pid, because a deck shows many records β `RecordDetail` takes ONE record's list
|
| 146 |
+
* and that shape cannot serve a deck. */
|
| 147 |
+
docs?: Record<string, CustomerDoc[]>;
|
| 148 |
+
docPayload?: { pid: number; docId: string; name: string; mime: string; data_b64: string };
|
| 149 |
+
/** β EVERY HANDLER TAKES THE PID, unlike `RecordDetail`'s, whose host closes over the ONE
|
| 150 |
+
* open record. A deck paints many records at once, so a handler bound to a single pid would
|
| 151 |
+
* attach every reviewer's upload to whichever card happened to be open. */
|
| 152 |
+
onDocAdd?: (
|
| 153 |
+
pid: number,
|
| 154 |
+
file: { name: string; mime: string; size: number; data_b64: string }
|
| 155 |
+
) => void;
|
| 156 |
+
onDocFetch?: (pid: number, docId: string) => void;
|
| 157 |
+
onDocDelete?: (pid: number, docId: string) => void;
|
| 158 |
+
}) {
|
| 159 |
+
// "Later" β session-only, never stored. A deck with no way past a record you cannot decide
|
| 160 |
+
// blocks on that record forever, so this is navigation WITHIN the deck rather than a third
|
| 161 |
+
// gesture: it writes nothing, survives no reload, and the count is disclosed in the empty
|
| 162 |
+
// state rather than quietly shrinking the deck (rule 8b).
|
| 163 |
+
const [later, setLater] = useState<Set<number>>(new Set());
|
| 164 |
+
const deckRef = useRef<HTMLDivElement>(null);
|
| 165 |
+
|
| 166 |
+
const binding = useMemo(() => readBinding(spec, fieldByKey), [spec, fieldByKey]);
|
| 167 |
+
const boundKey = binding.ok ? binding.field.key : null;
|
| 168 |
+
|
| 169 |
+
const undecided = useMemo(
|
| 170 |
+
() => (boundKey ? rows.filter((r) => isUndecided(r, boundKey)) : []),
|
| 171 |
+
[rows, boundKey]
|
| 172 |
+
);
|
| 173 |
+
const deck = useMemo(() => undecided.filter((r) => !later.has(r.pid)), [undecided, later]);
|
| 174 |
+
const card = deck[0];
|
| 175 |
+
|
| 176 |
+
// A record decided elsewhere (the grid, another user's echo) must not stay "later" forever β
|
| 177 |
+
// otherwise the disclosed skip count drifts away from what is actually on the deck.
|
| 178 |
+
useEffect(() => {
|
| 179 |
+
setLater((prev) => {
|
| 180 |
+
if (prev.size === 0) return prev;
|
| 181 |
+
const live = new Set(undecided.map((r) => r.pid));
|
| 182 |
+
const next = new Set([...prev].filter((pid) => live.has(pid)));
|
| 183 |
+
return next.size === prev.size ? prev : next;
|
| 184 |
+
});
|
| 185 |
+
}, [undecided]);
|
| 186 |
+
|
| 187 |
+
const decide = useCallback(
|
| 188 |
+
(value: string) => {
|
| 189 |
+
if (!card || !canWrite) return;
|
| 190 |
+
onSwipe(card.pid, value);
|
| 191 |
+
},
|
| 192 |
+
[card, canWrite, onSwipe]
|
| 193 |
+
);
|
| 194 |
+
|
| 195 |
+
// β / β decide, and they are the SAME two actions the buttons are rather than a second code
|
| 196 |
+
// path: the whole point of the mode is that one hand can clear a queue.
|
| 197 |
+
useEffect(() => {
|
| 198 |
+
if (!binding.ok || !canWrite || !card) return;
|
| 199 |
+
const onKey = (e: KeyboardEvent) => {
|
| 200 |
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
| 201 |
+
const el = document.activeElement as HTMLElement | null;
|
| 202 |
+
// Never steal an arrow key from a text field or a native control the user is inside.
|
| 203 |
+
if (el && (el.tagName === "INPUT" || el.tagName === "SELECT" || el.tagName === "TEXTAREA"))
|
| 204 |
+
return;
|
| 205 |
+
if (!deckRef.current?.contains(el ?? null) && el !== document.body) return;
|
| 206 |
+
e.preventDefault();
|
| 207 |
+
decide(e.key === "ArrowLeft" ? binding.spec.leftOption : binding.spec.rightOption);
|
| 208 |
+
};
|
| 209 |
+
window.addEventListener("keydown", onKey);
|
| 210 |
+
return () => window.removeEventListener("keydown", onKey);
|
| 211 |
+
}, [binding, canWrite, card, decide]);
|
| 212 |
+
|
| 213 |
+
if (!binding.ok) {
|
| 214 |
+
return (
|
| 215 |
+
<div className="cg-swipe" ref={deckRef}>
|
| 216 |
+
<SwipeBinder
|
| 217 |
+
fault={binding.fault}
|
| 218 |
+
fields={fields}
|
| 219 |
+
fieldByKey={fieldByKey}
|
| 220 |
+
spec={spec}
|
| 221 |
+
canBind={canBind}
|
| 222 |
+
onSpec={onSpec}
|
| 223 |
+
/>
|
| 224 |
+
</div>
|
| 225 |
+
);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
const { field } = binding;
|
| 229 |
+
const leftTint = optionTint(field, binding.spec.leftOption);
|
| 230 |
+
const rightTint = optionTint(field, binding.spec.rightOption);
|
| 231 |
+
|
| 232 |
+
return (
|
| 233 |
+
<div className="cg-swipe" ref={deckRef} tabIndex={-1}>
|
| 234 |
+
<div className="cg-swipe-bar">
|
| 235 |
+
<span className="cg-swipe-bound">
|
| 236 |
+
<ModeIcon mode="swipe" />
|
| 237 |
+
Sorting into <strong>{field.label}</strong>
|
| 238 |
+
</span>
|
| 239 |
+
<span className="cg-swipe-left">
|
| 240 |
+
{deck.length.toLocaleString()} {deck.length === 1 ? "record" : "records"} to sort
|
| 241 |
+
</span>
|
| 242 |
+
{canBind && (
|
| 243 |
+
<button
|
| 244 |
+
type="button"
|
| 245 |
+
className="cg-link-btn"
|
| 246 |
+
onClick={() => onSpec(undefined)}
|
| 247 |
+
title="Choose a different field or different options for this deck"
|
| 248 |
+
>
|
| 249 |
+
Change
|
| 250 |
+
</button>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
{readOnlyReason && <div className="cg-kb-note">{readOnlyReason}</div>}
|
| 254 |
+
{card ? (
|
| 255 |
+
<div className="cg-swipe-deck">
|
| 256 |
+
<button
|
| 257 |
+
type="button"
|
| 258 |
+
className="cg-swipe-side cg-swipe-side--left"
|
| 259 |
+
disabled={!canWrite}
|
| 260 |
+
style={leftTint ? { background: leftTint.bg, color: leftTint.fg } : undefined}
|
| 261 |
+
onClick={() => decide(binding.spec.leftOption)}
|
| 262 |
+
title={`Set ${field.label} to ${binding.spec.leftOption} (left arrow key)`}
|
| 263 |
+
>
|
| 264 |
+
<span aria-hidden className="cg-swipe-arrow">
|
| 265 |
+
<svg width="16" height="16" viewBox="0 0 16 16">
|
| 266 |
+
<path
|
| 267 |
+
d="M11 3.2 6.2 8l4.8 4.8"
|
| 268 |
+
fill="none"
|
| 269 |
+
stroke="currentColor"
|
| 270 |
+
strokeWidth="1.6"
|
| 271 |
+
strokeLinecap="round"
|
| 272 |
+
strokeLinejoin="round"
|
| 273 |
+
/>
|
| 274 |
+
</svg>
|
| 275 |
+
</span>
|
| 276 |
+
{binding.spec.leftOption}
|
| 277 |
+
</button>
|
| 278 |
+
<article
|
| 279 |
+
className="cg-swipe-card"
|
| 280 |
+
role="button"
|
| 281 |
+
tabIndex={0}
|
| 282 |
+
onClick={(e) => {
|
| 283 |
+
// β W41-T48 β the card IS the deck's record open, so it folds the rail.
|
| 284 |
+
// β Nothing that is NOT an open can reach this line: every control inside the
|
| 285 |
+
// card (a url cell, the comments box, the document tray) already stops its own
|
| 286 |
+
// click, and the left/right decision buttons are siblings of the card, not
|
| 287 |
+
// children of it. The deck also has no pointer drag to disambiguate β its swipe
|
| 288 |
+
// is the two buttons and the arrow keys, neither of which opens anything.
|
| 289 |
+
// β `<article role="button">`, not a native button, so a keyboard activation
|
| 290 |
+
// never arrives here as a synthetic click; the keydown path below is the whole
|
| 291 |
+
// keyboard story and the two cannot both fire for one gesture.
|
| 292 |
+
if (e.detail <= 1) signal(NAV_MINIMIZE_EVENT);
|
| 293 |
+
onOpen(card.pid);
|
| 294 |
+
}}
|
| 295 |
+
onKeyDown={(e) => {
|
| 296 |
+
if (e.key !== "Enter" && e.key !== " ") return;
|
| 297 |
+
e.preventDefault();
|
| 298 |
+
// W41-T48 β `!e.repeat` is this path's `e.detail <= 1`: a HELD Enter or Space
|
| 299 |
+
// auto-repeats keydown, and every one of those repeats is the SAME gesture.
|
| 300 |
+
if (!e.repeat) signal(NAV_MINIMIZE_EVENT);
|
| 301 |
+
onOpen(card.pid);
|
| 302 |
+
}}
|
| 303 |
+
>
|
| 304 |
+
<h3 className="cg-swipe-title">{String(card[titleKey] ?? "")}</h3>
|
| 305 |
+
<div className="cg-swipe-cells">
|
| 306 |
+
{/* β WAVE-29 T26 (R9) β the RECORD's visible fields, not the three-key summary the
|
| 307 |
+
kanban card lends. `detailKeys` falls back to `cardKeys` so a host that has not
|
| 308 |
+
been widened yet renders exactly what it rendered before. */}
|
| 309 |
+
{(detailKeys && detailKeys.length ? detailKeys : cardKeys).map((k) => {
|
| 310 |
+
const f = fieldByKey.get(k);
|
| 311 |
+
if (!f) return null;
|
| 312 |
+
const text = formatDisplay(f, card[k]);
|
| 313 |
+
if (!text) return null;
|
| 314 |
+
// A url cell is a LINK here too (wave-26 item 11), through `display.ts`'s one
|
| 315 |
+
// scheme guard β and every gesture that reaches it must be stopped from also
|
| 316 |
+
// reaching the card, which is a button (the kanban card's note, same trap).
|
| 317 |
+
const href = actionHref(f, card[k]);
|
| 318 |
+
return (
|
| 319 |
+
<div key={k} className="cg-swipe-cell">
|
| 320 |
+
<span className="cg-lv-k">{f.label}</span>
|
| 321 |
+
{href ? (
|
| 322 |
+
<a
|
| 323 |
+
className="cg-lv-v"
|
| 324 |
+
href={href}
|
| 325 |
+
target="_blank"
|
| 326 |
+
rel="noopener noreferrer"
|
| 327 |
+
title={text}
|
| 328 |
+
onClick={(e) => e.stopPropagation()}
|
| 329 |
+
onKeyDown={(e) => e.stopPropagation()}
|
| 330 |
+
>
|
| 331 |
+
{text}
|
| 332 |
+
</a>
|
| 333 |
+
) : (
|
| 334 |
+
<span className="cg-lv-v">{text}</span>
|
| 335 |
+
)}
|
| 336 |
+
</div>
|
| 337 |
+
);
|
| 338 |
+
})}
|
| 339 |
+
</div>
|
| 340 |
+
{/* β R9's other two thirds. Both stop their own events: the card is a `role="button"`
|
| 341 |
+
that opens the record, and a click on a comment box or an upload control must not
|
| 342 |
+
also open the modal it exists to make unnecessary (the kanban card's link trap,
|
| 343 |
+
one surface over). */}
|
| 344 |
+
{onDocAdd && onDocFetch && onDocDelete && (
|
| 345 |
+
<div
|
| 346 |
+
className="cg-swipe-docs"
|
| 347 |
+
onClick={(e) => e.stopPropagation()}
|
| 348 |
+
onKeyDown={(e) => e.stopPropagation()}
|
| 349 |
+
role="presentation"
|
| 350 |
+
>
|
| 351 |
+
<Documents
|
| 352 |
+
pid={Number(card.pid)}
|
| 353 |
+
docs={docs?.[String(card.pid)] ?? []}
|
| 354 |
+
docPayload={docPayload}
|
| 355 |
+
onAdd={(file) => onDocAdd(card.pid, file)}
|
| 356 |
+
onFetch={(docId) => onDocFetch(card.pid, docId)}
|
| 357 |
+
onDelete={(docId) => onDocDelete(card.pid, docId)}
|
| 358 |
+
/>
|
| 359 |
+
</div>
|
| 360 |
+
)}
|
| 361 |
+
{scope != null && (
|
| 362 |
+
<div
|
| 363 |
+
className="cg-swipe-comments"
|
| 364 |
+
onClick={(e) => e.stopPropagation()}
|
| 365 |
+
onKeyDown={(e) => e.stopPropagation()}
|
| 366 |
+
role="presentation"
|
| 367 |
+
>
|
| 368 |
+
{/* β KEYED ON scope+pid, the same key `RecordDetail` uses: without it React
|
| 369 |
+
reuses the mounted instance across a card change and paints one record's
|
| 370 |
+
comments under another's name until the fetch returns. */}
|
| 371 |
+
<RecordComments
|
| 372 |
+
key={`${scope}:${Number(card.pid)}`}
|
| 373 |
+
scope={scope}
|
| 374 |
+
pid={Number(card.pid)}
|
| 375 |
+
viewer={viewer}
|
| 376 |
+
/>
|
| 377 |
+
</div>
|
| 378 |
+
)}
|
| 379 |
+
{canWrite && (
|
| 380 |
+
<button
|
| 381 |
+
type="button"
|
| 382 |
+
className="cg-link-btn cg-swipe-later"
|
| 383 |
+
onClick={(e) => {
|
| 384 |
+
e.stopPropagation();
|
| 385 |
+
setLater((prev) => new Set(prev).add(card.pid));
|
| 386 |
+
}}
|
| 387 |
+
>
|
| 388 |
+
Decide later
|
| 389 |
+
</button>
|
| 390 |
+
)}
|
| 391 |
+
</article>
|
| 392 |
+
<button
|
| 393 |
+
type="button"
|
| 394 |
+
className="cg-swipe-side cg-swipe-side--right"
|
| 395 |
+
disabled={!canWrite}
|
| 396 |
+
style={rightTint ? { background: rightTint.bg, color: rightTint.fg } : undefined}
|
| 397 |
+
onClick={() => decide(binding.spec.rightOption)}
|
| 398 |
+
title={`Set ${field.label} to ${binding.spec.rightOption} (right arrow key)`}
|
| 399 |
+
>
|
| 400 |
+
{binding.spec.rightOption}
|
| 401 |
+
<span aria-hidden className="cg-swipe-arrow">
|
| 402 |
+
<svg width="16" height="16" viewBox="0 0 16 16">
|
| 403 |
+
<path
|
| 404 |
+
d="M5 3.2 9.8 8 5 12.8"
|
| 405 |
+
fill="none"
|
| 406 |
+
stroke="currentColor"
|
| 407 |
+
strokeWidth="1.6"
|
| 408 |
+
strokeLinecap="round"
|
| 409 |
+
strokeLinejoin="round"
|
| 410 |
+
/>
|
| 411 |
+
</svg>
|
| 412 |
+
</span>
|
| 413 |
+
</button>
|
| 414 |
+
</div>
|
| 415 |
+
) : (
|
| 416 |
+
// The empty state NAMES the bound field (C3), because "nothing to sort" is ambiguous
|
| 417 |
+
// between "the deck is finished" and "the filter hid everything", and the two want
|
| 418 |
+
// different next actions. `later` is disclosed rather than quietly subtracted.
|
| 419 |
+
<div className="cg-mode-empty cg-swipe-empty">
|
| 420 |
+
{later.size > 0 ? (
|
| 421 |
+
<>
|
| 422 |
+
<p>
|
| 423 |
+
{later.size.toLocaleString()}{" "}
|
| 424 |
+
{later.size === 1 ? "record is" : "records are"} set aside for later. Nothing else
|
| 425 |
+
in this view is missing a <strong>{field.label}</strong>.
|
| 426 |
+
</p>
|
| 427 |
+
<button type="button" className="cg-btn" onClick={() => setLater(new Set())}>
|
| 428 |
+
Bring them back
|
| 429 |
+
</button>
|
| 430 |
+
</>
|
| 431 |
+
) : (
|
| 432 |
+
<p>
|
| 433 |
+
Every record in this view already has a <strong>{field.label}</strong>. Widen the
|
| 434 |
+
view's filters to sort more.
|
| 435 |
+
</p>
|
| 436 |
+
)}
|
| 437 |
+
</div>
|
| 438 |
+
)}
|
| 439 |
+
</div>
|
| 440 |
+
);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
/**
|
| 444 |
+
* The binding picker: field + two options. It is also the surface every LOST binding lands on,
|
| 445 |
+
* with the loss stated above it β one place that answers "what is this deck for", rather than an
|
| 446 |
+
* error screen beside a separate setup screen.
|
| 447 |
+
*/
|
| 448 |
+
function SwipeBinder({
|
| 449 |
+
fault,
|
| 450 |
+
fields,
|
| 451 |
+
fieldByKey,
|
| 452 |
+
spec,
|
| 453 |
+
canBind,
|
| 454 |
+
onSpec,
|
| 455 |
+
}: {
|
| 456 |
+
fault: BindingFault;
|
| 457 |
+
fields: Field[];
|
| 458 |
+
fieldByKey: Map<string, Field>;
|
| 459 |
+
spec: SwipeSpec | undefined;
|
| 460 |
+
canBind: boolean;
|
| 461 |
+
onSpec: (next: SwipeSpec | undefined) => void;
|
| 462 |
+
}) {
|
| 463 |
+
const selects = useMemo(
|
| 464 |
+
() => fields.filter((f) => f.type === "select" && choiceOptions(f).length >= 2),
|
| 465 |
+
[fields]
|
| 466 |
+
);
|
| 467 |
+
const [fieldKey, setFieldKey] = useState<string>(() => {
|
| 468 |
+
if (spec && fieldByKey.get(spec.fieldKey)?.type === "select") return spec.fieldKey;
|
| 469 |
+
return selects[0]?.key ?? "";
|
| 470 |
+
});
|
| 471 |
+
const chosen = fieldByKey.get(fieldKey);
|
| 472 |
+
const options = useMemo(() => (chosen ? choiceOptions(chosen) : []), [chosen]);
|
| 473 |
+
const [left, setLeft] = useState<string>("");
|
| 474 |
+
const [right, setRight] = useState<string>("");
|
| 475 |
+
|
| 476 |
+
// The two option selects follow the FIELD. Kept in an effect rather than derived so the user's
|
| 477 |
+
// pick survives re-renders, and reset whenever the field changes β carrying an option from the
|
| 478 |
+
// previous field would offer a value the new field cannot hold.
|
| 479 |
+
useEffect(() => {
|
| 480 |
+
setLeft(options[0] ?? "");
|
| 481 |
+
setRight(options.find((o) => o !== options[0]) ?? "");
|
| 482 |
+
}, [options]);
|
| 483 |
+
|
| 484 |
+
const message = ((): string => {
|
| 485 |
+
switch (fault.kind) {
|
| 486 |
+
case "field-gone":
|
| 487 |
+
return "The field this deck sorted into has been deleted. Pick another one.";
|
| 488 |
+
case "not-select":
|
| 489 |
+
return `β${fault.field.label}β is no longer a single-select, so it has no options to`
|
| 490 |
+
+ " sort into. Pick another field.";
|
| 491 |
+
case "option-gone":
|
| 492 |
+
return `${fault.missing.map((m) => `β${m}β`).join(" and ")} ${
|
| 493 |
+
fault.missing.length === 1 ? "is" : "are"
|
| 494 |
+
} no longer ${fault.missing.length === 1 ? "an option" : "options"} on β${
|
| 495 |
+
fault.field.label
|
| 496 |
+
}β. Pick the sides again.`;
|
| 497 |
+
default:
|
| 498 |
+
return "Sort records one at a time into two options of a single-select field.";
|
| 499 |
+
}
|
| 500 |
+
})();
|
| 501 |
+
|
| 502 |
+
if (!canBind) {
|
| 503 |
+
return (
|
| 504 |
+
<div className="cg-mode-empty">
|
| 505 |
+
<p>{message}</p>
|
| 506 |
+
<p>Its creator or an admin can set this view up.</p>
|
| 507 |
+
</div>
|
| 508 |
+
);
|
| 509 |
+
}
|
| 510 |
+
if (selects.length === 0) {
|
| 511 |
+
// Honest and specific: the mode is not broken, the TABLE has nothing to bind to. Naming the
|
| 512 |
+
// requirement is what turns a dead end into a next action.
|
| 513 |
+
return (
|
| 514 |
+
<div className="cg-mode-empty">
|
| 515 |
+
<p>
|
| 516 |
+
A swipe deck sorts into a single-select field with at least two options. This database
|
| 517 |
+
does not have one yet β add a single-select column, then come back.
|
| 518 |
+
</p>
|
| 519 |
+
</div>
|
| 520 |
+
);
|
| 521 |
+
}
|
| 522 |
+
const ready = !!chosen && !!left && !!right && left.toLowerCase() !== right.toLowerCase();
|
| 523 |
+
return (
|
| 524 |
+
<div className="cg-swipe-setup">
|
| 525 |
+
<p className="cg-swipe-setup-note">{message}</p>
|
| 526 |
+
<label className="cg-swipe-setup-row">
|
| 527 |
+
<span>Sort into</span>
|
| 528 |
+
<select
|
| 529 |
+
className="cg-select"
|
| 530 |
+
value={fieldKey}
|
| 531 |
+
onChange={(e) => setFieldKey(e.target.value)}
|
| 532 |
+
>
|
| 533 |
+
{/* Every option carries an explicit `value`: a <select> whose value names nothing
|
| 534 |
+
renders its FIRST option and reports a choice the user never made
|
| 535 |
+
([[cg-condition-builder-items]]). */}
|
| 536 |
+
{selects.map((f) => (
|
| 537 |
+
<option key={f.key} value={f.key}>
|
| 538 |
+
{f.label}
|
| 539 |
+
</option>
|
| 540 |
+
))}
|
| 541 |
+
</select>
|
| 542 |
+
</label>
|
| 543 |
+
<div className="cg-swipe-setup-sides">
|
| 544 |
+
<label className="cg-swipe-setup-row">
|
| 545 |
+
<span>Swipe left</span>
|
| 546 |
+
<select className="cg-select" value={left} onChange={(e) => setLeft(e.target.value)}>
|
| 547 |
+
{options.map((o) => (
|
| 548 |
+
<option key={o} value={o}>
|
| 549 |
+
{o}
|
| 550 |
+
</option>
|
| 551 |
+
))}
|
| 552 |
+
</select>
|
| 553 |
+
</label>
|
| 554 |
+
<label className="cg-swipe-setup-row">
|
| 555 |
+
<span>Swipe right</span>
|
| 556 |
+
<select className="cg-select" value={right} onChange={(e) => setRight(e.target.value)}>
|
| 557 |
+
{options.map((o) => (
|
| 558 |
+
<option key={o} value={o}>
|
| 559 |
+
{o}
|
| 560 |
+
</option>
|
| 561 |
+
))}
|
| 562 |
+
</select>
|
| 563 |
+
</label>
|
| 564 |
+
</div>
|
| 565 |
+
{!ready && left && right && (
|
| 566 |
+
// Stated, not silently refused: both engines DROP a binding whose sides are the same
|
| 567 |
+
// option, so a Save that looked like it worked would simply not persist.
|
| 568 |
+
<p className="cg-swipe-setup-warn">
|
| 569 |
+
The two sides must be different options β otherwise both gestures do the same thing.
|
| 570 |
+
</p>
|
| 571 |
+
)}
|
| 572 |
+
<button
|
| 573 |
+
type="button"
|
| 574 |
+
className="cg-btn cg-btn--primary"
|
| 575 |
+
disabled={!ready}
|
| 576 |
+
onClick={() =>
|
| 577 |
+
chosen && onSpec({ fieldKey: chosen.key, leftOption: left, rightOption: right })
|
| 578 |
+
}
|
| 579 |
+
>
|
| 580 |
+
Start sorting
|
| 581 |
+
</button>
|
| 582 |
+
</div>
|
| 583 |
+
);
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
export default SwipeView;
|
web/src/customer-grid/Toolbar.tsx
CHANGED
|
@@ -167,6 +167,8 @@ export interface ToolbarProps {
|
|
| 167 |
* not, so the marker says something different and does not go away.
|
| 168 |
*/
|
| 169 |
unresolvedCount?: number;
|
|
|
|
|
|
|
| 170 |
/** Owner item 5 β the cohorts this user has, for `Where [Cohort] [is part of] [β¦]`. */
|
| 171 |
lists?: { id: string; name: string }[];
|
| 172 |
/** ββ W37-T26 (C4) β the views a `Where [View] [is] [β¦]` condition may name. A PASS-THROUGH:
|
|
@@ -722,6 +724,7 @@ export default function Toolbar({
|
|
| 722 |
measures = [],
|
| 723 |
pendingMeasureCount = 0,
|
| 724 |
unresolvedCount = 0,
|
|
|
|
| 725 |
lists = [],
|
| 726 |
viewChoices = [],
|
| 727 |
cohortLock,
|
|
@@ -1322,9 +1325,10 @@ export default function Toolbar({
|
|
| 1322 |
<span
|
| 1323 |
className="cg-count cg-count-pending"
|
| 1324 |
title={
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
-
|
|
|
|
| 1328 |
}
|
| 1329 |
>
|
| 1330 |
Filter unresolved
|
|
|
|
| 167 |
* not, so the marker says something different and does not go away.
|
| 168 |
*/
|
| 169 |
unresolvedCount?: number;
|
| 170 |
+
/** Exact reasons for unresolved conditions, shown in the marker's native hover text. */
|
| 171 |
+
unresolvedReasons?: readonly string[];
|
| 172 |
/** Owner item 5 β the cohorts this user has, for `Where [Cohort] [is part of] [β¦]`. */
|
| 173 |
lists?: { id: string; name: string }[];
|
| 174 |
/** ββ W37-T26 (C4) β the views a `Where [View] [is] [β¦]` condition may name. A PASS-THROUGH:
|
|
|
|
| 724 |
measures = [],
|
| 725 |
pendingMeasureCount = 0,
|
| 726 |
unresolvedCount = 0,
|
| 727 |
+
unresolvedReasons = [],
|
| 728 |
lists = [],
|
| 729 |
viewChoices = [],
|
| 730 |
cohortLock,
|
|
|
|
| 1325 |
<span
|
| 1326 |
className="cg-count cg-count-pending"
|
| 1327 |
title={
|
| 1328 |
+
unresolvedReasons.length
|
| 1329 |
+
? unresolvedReasons.join("\n")
|
| 1330 |
+
: `${unresolvedCount} condition${unresolvedCount === 1 ? "" : "s"} cannot be ` +
|
| 1331 |
+
"evaluated. Rows are hidden rather than shown under a count that would be wrong."
|
| 1332 |
}
|
| 1333 |
>
|
| 1334 |
Filter unresolved
|
web/src/customer-grid/apiBridge.ts
CHANGED
|
@@ -329,6 +329,8 @@ export interface RowWindowRequest {
|
|
| 329 |
filterConj?: string;
|
| 330 |
sorts?: SortSpec | null;
|
| 331 |
search?: string;
|
|
|
|
|
|
|
| 332 |
}
|
| 333 |
|
| 334 |
/**
|
|
@@ -360,6 +362,11 @@ export function windowRowsPath(tableKey: string, req: RowWindowRequest): string
|
|
| 360 |
if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts));
|
| 361 |
const search = (req.search ?? "").trim();
|
| 362 |
if (search) q.set("search", search);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`;
|
| 364 |
}
|
| 365 |
|
|
|
|
| 329 |
filterConj?: string;
|
| 330 |
sorts?: SortSpec | null;
|
| 331 |
search?: string;
|
| 332 |
+
/** Existing Link choices retained when a new picker condition no longer matches them. */
|
| 333 |
+
includePids?: readonly number[];
|
| 334 |
}
|
| 335 |
|
| 336 |
/**
|
|
|
|
| 362 |
if (req.sorts && req.sorts.length) q.set("sorts", JSON.stringify(req.sorts));
|
| 363 |
const search = (req.search ?? "").trim();
|
| 364 |
if (search) q.set("search", search);
|
| 365 |
+
if (req.includePids && req.includePids.length) {
|
| 366 |
+
const ids = [...new Set(req.includePids.filter((pid) => Number.isInteger(pid) && pid > 0))]
|
| 367 |
+
.sort((a, b) => a - b);
|
| 368 |
+
if (ids.length) q.set("includePids", JSON.stringify(ids));
|
| 369 |
+
}
|
| 370 |
return `odoo-tables/${encodeURIComponent(tableKey)}/rows?${q.toString()}`;
|
| 371 |
}
|
| 372 |
|
web/src/customer-grid/icons.tsx
CHANGED
|
@@ -104,6 +104,21 @@ export function FieldTypeIcon({
|
|
| 104 |
);
|
| 105 |
}
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
/** The display-mode mark for the View switcher (I18). */
|
| 108 |
export function ModeIcon({ mode, size = 14 }: { mode: DisplayMode; size?: number }) {
|
| 109 |
return (
|
|
|
|
| 104 |
);
|
| 105 |
}
|
| 106 |
|
| 107 |
+
/**
|
| 108 |
+
* The compact, outline-only information mark shared by field surfaces. Its canvas sibling is
|
| 109 |
+
* `HEADER_ICONS.aiosInfo`; keeping the same ring-and-i geometry makes an explanation read as the
|
| 110 |
+
* same affordance whether it sits in a grid header or beside a picker option.
|
| 111 |
+
*/
|
| 112 |
+
export function InfoMark({ size = 14 }: { size?: number }) {
|
| 113 |
+
return (
|
| 114 |
+
<svg width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 115 |
+
<circle cx="8" cy="8" r="6.1" stroke="currentColor" strokeWidth="1.25" />
|
| 116 |
+
<path d="M8 7.4v3.5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
| 117 |
+
<circle cx="8" cy="5.1" r="0.85" fill="currentColor" />
|
| 118 |
+
</svg>
|
| 119 |
+
);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
/** The display-mode mark for the View switcher (I18). */
|
| 123 |
export function ModeIcon({ mode, size = 14 }: { mode: DisplayMode; size?: number }) {
|
| 124 |
return (
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -2609,6 +2609,12 @@ export interface Field {
|
|
| 2609 |
reciprocal?: string;
|
| 2610 |
/** Airtable's `prefersSingleRecordLink` */
|
| 2611 |
single?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2612 |
/**
|
| 2613 |
* ββ W42-T09 (R1, contract C1) β THE TWO-HOP JOIN, DECLARED. **Absent is today's one-hop
|
| 2614 |
* link and is byte-identical to every link stored before the hop existed**, so there is no
|
|
@@ -3362,6 +3368,23 @@ export interface FilterRule {
|
|
| 3362 |
id?: string;
|
| 3363 |
}
|
| 3364 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3365 |
/**
|
| 3366 |
* Is this rule doing any work? A half-typed condition is INACTIVE and gets IGNORED by the
|
| 3367 |
* combinator β not treated as `true`, which under `or` would show every record.
|
|
|
|
| 2609 |
reciprocal?: string;
|
| 2610 |
/** Airtable's `prefersSingleRecordLink` */
|
| 2611 |
single?: boolean;
|
| 2612 |
+
/**
|
| 2613 |
+
* W42-T35 β static conditions that narrow the records offered by an ordinary Link picker.
|
| 2614 |
+
* They never rewrite existing link cells: a new filter constrains the next selection only.
|
| 2615 |
+
*/
|
| 2616 |
+
conditions?: RollupCondition[];
|
| 2617 |
+
conditionConj?: "and" | "or";
|
| 2618 |
/**
|
| 2619 |
* ββ W42-T09 (R1, contract C1) β THE TWO-HOP JOIN, DECLARED. **Absent is today's one-hop
|
| 2620 |
* link and is byte-identical to every link stored before the hop existed**, so there is no
|
|
|
|
| 3368 |
id?: string;
|
| 3369 |
}
|
| 3370 |
|
| 3371 |
+
/**
|
| 3372 |
+
* W42-T35 β the server validates a Link's static Rollup-style conditions, while the picker
|
| 3373 |
+
* reuses the grid's existing filter engine. This adapter is the one vocabulary crossing; it
|
| 3374 |
+
* cannot introduce a dynamic field, a set statistic, a view, a cohort, or a rank condition.
|
| 3375 |
+
*/
|
| 3376 |
+
export function linkPickerFilterNodes(conditions: readonly RollupCondition[]): FilterRule[] {
|
| 3377 |
+
return conditions.map((condition, index) => ({
|
| 3378 |
+
id: `link-picker-${index}-${condition.field}`,
|
| 3379 |
+
colId: condition.field,
|
| 3380 |
+
op: condition.op === "not_contains" ? "doesNotContain"
|
| 3381 |
+
: condition.op === "is_empty" ? "isEmpty"
|
| 3382 |
+
: condition.op === "is_not_empty" ? "isNotEmpty"
|
| 3383 |
+
: condition.op,
|
| 3384 |
+
value: condition.value ?? "",
|
| 3385 |
+
}));
|
| 3386 |
+
}
|
| 3387 |
+
|
| 3388 |
/**
|
| 3389 |
* Is this rule doing any work? A half-typed condition is INACTIVE and gets IGNORED by the
|
| 3390 |
* combinator β not treated as `true`, which under `or` would show every record.
|
web/src/customer-grid/useVisibleRows.ts
CHANGED
|
@@ -620,25 +620,27 @@ export function activeMeasureRuleIds(nodes: FilterNode[]): string[] {
|
|
| 620 |
* Detectable without the field map: `within` and `dateMode` are shapes only a DATE condition
|
| 621 |
* has, and `__cohort__` is a reserved key.
|
| 622 |
*/
|
| 623 |
-
export function
|
| 624 |
-
|
| 625 |
for (const node of nodes ?? []) {
|
| 626 |
if (isFilterGroup(node)) {
|
| 627 |
-
|
| 628 |
continue;
|
| 629 |
}
|
| 630 |
if (!isRuleActive(node)) continue;
|
| 631 |
if (isCohortRule(node)) {
|
| 632 |
// Any unresolvable member of the set makes the whole condition unanswerable β the same
|
| 633 |
// all-or-nothing rule evalNode applies, so the marker and the rows agree on WHY.
|
| 634 |
-
|
|
|
|
| 635 |
continue;
|
| 636 |
}
|
| 637 |
// C4 β a view leaf naming a view we cannot resolve (deleted, or not visible to this caller)
|
| 638 |
// is unanswerable, so it is COUNTED and the toolbar says so. Without this line the grid would
|
| 639 |
// empty with no explanation, which is D-124 exactly, arriving one leaf over.
|
| 640 |
if (isViewRule(node)) {
|
| 641 |
-
|
|
|
|
| 642 |
continue;
|
| 643 |
}
|
| 644 |
// ββ D-229 β a leaf whose COLUMN, or whose rhs COLUMN, this table no longer has. `evalNode`
|
|
@@ -648,18 +650,27 @@ export function unresolvedConditions(nodes: FilterNode[], ctx?: EvalCtx): number
|
|
| 648 |
if (ctx?.fieldByKey && !isMeasureRule(node)) {
|
| 649 |
const rhsKey = node.rhs ? rhsColId(node.rhs) ?? "" : null;
|
| 650 |
if (!ctx.fieldByKey.has(node.colId) || (rhsKey !== null && !ctx.fieldByKey.has(rhsKey))) {
|
| 651 |
-
|
|
|
|
| 652 |
continue;
|
| 653 |
}
|
| 654 |
}
|
| 655 |
if (isMeasureRule(node)) continue; // that is `pendingMeasures`' job
|
| 656 |
if (node.op === "within") {
|
| 657 |
-
if (!ctx?.today || !resolveWindow(node.dateWindow, ctx.today))
|
|
|
|
|
|
|
| 658 |
} else if (node.dateMode) {
|
| 659 |
-
if (!ctx?.today || resolveAnchor(node.dateMode, node.value, ctx.today) == null)
|
|
|
|
|
|
|
| 660 |
}
|
| 661 |
}
|
| 662 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
}
|
| 664 |
|
| 665 |
/** Root-level evaluation: the whole tree as one implicit group. Exported for
|
|
|
|
| 620 |
* Detectable without the field map: `within` and `dateMode` are shapes only a DATE condition
|
| 621 |
* has, and `__cohort__` is a reserved key.
|
| 622 |
*/
|
| 623 |
+
export function unresolvedConditionReasons(nodes: FilterNode[], ctx?: EvalCtx): string[] {
|
| 624 |
+
const reasons: string[] = [];
|
| 625 |
for (const node of nodes ?? []) {
|
| 626 |
if (isFilterGroup(node)) {
|
| 627 |
+
reasons.push(...unresolvedConditionReasons(node.children, ctx));
|
| 628 |
continue;
|
| 629 |
}
|
| 630 |
if (!isRuleActive(node)) continue;
|
| 631 |
if (isCohortRule(node)) {
|
| 632 |
// Any unresolvable member of the set makes the whole condition unanswerable β the same
|
| 633 |
// all-or-nothing rule evalNode applies, so the marker and the rows agree on WHY.
|
| 634 |
+
const unavailable = cohortIds(node.value).find((id) => !ctx?.cohortSets?.[id]);
|
| 635 |
+
if (unavailable) reasons.push(`The cohort "${unavailable}" used by this filter is unavailable.`);
|
| 636 |
continue;
|
| 637 |
}
|
| 638 |
// C4 β a view leaf naming a view we cannot resolve (deleted, or not visible to this caller)
|
| 639 |
// is unanswerable, so it is COUNTED and the toolbar says so. Without this line the grid would
|
| 640 |
// empty with no explanation, which is D-124 exactly, arriving one leaf over.
|
| 641 |
if (isViewRule(node)) {
|
| 642 |
+
const viewId = viewRuleId(node.value);
|
| 643 |
+
if (!ctx?.viewSets?.[viewId]) reasons.push(`The view "${viewId}" used by this filter is unavailable.`);
|
| 644 |
continue;
|
| 645 |
}
|
| 646 |
// ββ D-229 β a leaf whose COLUMN, or whose rhs COLUMN, this table no longer has. `evalNode`
|
|
|
|
| 650 |
if (ctx?.fieldByKey && !isMeasureRule(node)) {
|
| 651 |
const rhsKey = node.rhs ? rhsColId(node.rhs) ?? "" : null;
|
| 652 |
if (!ctx.fieldByKey.has(node.colId) || (rhsKey !== null && !ctx.fieldByKey.has(rhsKey))) {
|
| 653 |
+
const missing = !ctx.fieldByKey.has(node.colId) ? node.colId : rhsKey;
|
| 654 |
+
reasons.push(`This filter uses the field "${missing}", which you cannot access.`);
|
| 655 |
continue;
|
| 656 |
}
|
| 657 |
}
|
| 658 |
if (isMeasureRule(node)) continue; // that is `pendingMeasures`' job
|
| 659 |
if (node.op === "within") {
|
| 660 |
+
if (!ctx?.today || !resolveWindow(node.dateWindow, ctx.today)) {
|
| 661 |
+
reasons.push("This date-range condition cannot be resolved.");
|
| 662 |
+
}
|
| 663 |
} else if (node.dateMode) {
|
| 664 |
+
if (!ctx?.today || resolveAnchor(node.dateMode, node.value, ctx.today) == null) {
|
| 665 |
+
reasons.push("This date condition cannot be resolved.");
|
| 666 |
+
}
|
| 667 |
}
|
| 668 |
}
|
| 669 |
+
return reasons;
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
export function unresolvedConditions(nodes: FilterNode[], ctx?: EvalCtx): number {
|
| 673 |
+
return unresolvedConditionReasons(nodes, ctx).length;
|
| 674 |
}
|
| 675 |
|
| 676 |
/** Root-level evaluation: the whole tree as one implicit group. Exported for
|
web/src/index.css
CHANGED
|
@@ -601,6 +601,18 @@ body {
|
|
| 601 |
text-overflow: ellipsis;
|
| 602 |
white-space: nowrap;
|
| 603 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
.cg-type-check { color: var(--lp-blue-deep); flex: 0 0 auto; }
|
| 605 |
/* W40-T29 (owner instruction 31) - the create picker's Advanced group: a hairline, then a quiet
|
| 606 |
one-word heading, then the three kinds most people never want. Same typographic weight as
|
|
|
|
| 601 |
text-overflow: ellipsis;
|
| 602 |
white-space: nowrap;
|
| 603 |
}
|
| 604 |
+
.cg-type-info {
|
| 605 |
+
display: inline-flex;
|
| 606 |
+
align-items: center;
|
| 607 |
+
justify-content: center;
|
| 608 |
+
flex: 0 0 auto;
|
| 609 |
+
width: 16px;
|
| 610 |
+
height: 16px;
|
| 611 |
+
color: var(--lp-muted);
|
| 612 |
+
cursor: help;
|
| 613 |
+
}
|
| 614 |
+
.cg-type-row:hover .cg-type-info,
|
| 615 |
+
.cg-type-row:focus-visible .cg-type-info { color: var(--lp-blue-deep); }
|
| 616 |
.cg-type-check { color: var(--lp-blue-deep); flex: 0 0 auto; }
|
| 617 |
/* W40-T29 (owner instruction 31) - the create picker's Advanced group: a hairline, then a quiet
|
| 618 |
one-word heading, then the three kinds most people never want. Same typographic weight as
|
web/wiring_rows/wave42_c.py
CHANGED
|
@@ -91,22 +91,28 @@ ROWS: list[tuple[str, str, str, str, str]] = [
|
|
| 91 |
"filter-kit/FieldsHidePanel.tsx",
|
| 92 |
r"onSetFieldsHidden: \(\(keys: string\[\], hidden: boolean\) => void\) \| null;",
|
| 93 |
"customer-grid/CustomerGrid.tsx", r"onSetFieldsHidden=\{fieldBulkDoors\.onSetFieldsHidden\}"),
|
| 94 |
-
("W42-T12
|
| 95 |
"filter-kit/FieldsHidePanel.tsx",
|
| 96 |
r"onShareFields: \(\(keys: string\[\]\) => void\) \| null;",
|
| 97 |
-
"customer-grid/CustomerGrid.tsx",
|
| 98 |
-
|
|
|
|
| 99 |
"filter-kit/FieldsHidePanel.tsx",
|
| 100 |
r"onReassignFields: \(\(keys: string\[\]\) => void\) \| null;",
|
| 101 |
-
"customer-grid/CustomerGrid.tsx",
|
|
|
|
| 102 |
("W42-T12 onDeleteFields: the batch delete door, never a loop over the per-key one",
|
| 103 |
"filter-kit/FieldsHidePanel.tsx",
|
| 104 |
r"onDeleteFields: \(\(keys: string\[\]\) => void\) \| null;",
|
| 105 |
"customer-grid/CustomerGrid.tsx", r"onDeleteFields=\{fieldBulkDoors\.onDeleteFields\}"),
|
| 106 |
-
("W42-T12
|
| 107 |
"filter-kit/FieldsHidePanel.tsx",
|
| 108 |
r"onRemoveField: \(\(key: string\) => void\) \| null;",
|
| 109 |
-
"customer-grid/CustomerGrid.tsx",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
# ββ W42-T13 (instruction 2, D-485) β THE SEVENTH HANDLER, AND IT LIVES ON A DIFFERENT
|
| 112 |
# COMPONENT FROM T12's SIX. `onRemoveFromMine` is a `ColumnMenuProps` member, not a
|
|
@@ -139,11 +145,10 @@ ROWS: list[tuple[str, str, str, str, str]] = [
|
|
| 139 |
# and does nothing when pressed β D-485 turned from an absent control into the WORSE defect of
|
| 140 |
# a silent one, with the whole battery green. The bundle door is the only thing on this
|
| 141 |
# surface that speaks the refusal out loud, so pinning it pins the behaviour.
|
| 142 |
-
("W42-T13
|
| 143 |
-
"customer-grid/ColumnMenu.tsx", r"onRemoveFromMine: \(\) => void;",
|
| 144 |
"customer-grid/CustomerGrid.tsx",
|
| 145 |
-
r"onRemoveFromMine=\{
|
| 146 |
-
r"[\s\S]*onRemoveFromMine=\{\(\) => \{[\s\S]*?fieldBulkDoors\.onRemoveField\("),
|
| 147 |
|
| 148 |
# ββ W42-T58 (contract C4) β THE PER KEY VERDICT IS DECLARED IN ONE FILE AND READ IN ANOTHER.
|
| 149 |
#
|
|
|
|
| 91 |
"filter-kit/FieldsHidePanel.tsx",
|
| 92 |
r"onSetFieldsHidden: \(\(keys: string\[\], hidden: boolean\) => void\) \| null;",
|
| 93 |
"customer-grid/CustomerGrid.tsx", r"onSetFieldsHidden=\{fieldBulkDoors\.onSetFieldsHidden\}"),
|
| 94 |
+
("W42-T12 bulk Share opens the field access editor",
|
| 95 |
"filter-kit/FieldsHidePanel.tsx",
|
| 96 |
r"onShareFields: \(\(keys: string\[\]\) => void\) \| null;",
|
| 97 |
+
"customer-grid/CustomerGrid.tsx",
|
| 98 |
+
r"onShareFields=\{fieldGrantTableKey[\s\S]*openFieldBatchAccess\(\"share\""),
|
| 99 |
+
("W42-T12 bulk Reassign opens the field access editor",
|
| 100 |
"filter-kit/FieldsHidePanel.tsx",
|
| 101 |
r"onReassignFields: \(\(keys: string\[\]\) => void\) \| null;",
|
| 102 |
+
"customer-grid/CustomerGrid.tsx",
|
| 103 |
+
r"onReassignFields=\{fieldGrantTableKey[\s\S]*openFieldBatchAccess\(\"reassign\""),
|
| 104 |
("W42-T12 onDeleteFields: the batch delete door, never a loop over the per-key one",
|
| 105 |
"filter-kit/FieldsHidePanel.tsx",
|
| 106 |
r"onDeleteFields: \(\(keys: string\[\]\) => void\) \| null;",
|
| 107 |
"customer-grid/CustomerGrid.tsx", r"onDeleteFields=\{fieldBulkDoors\.onDeleteFields\}"),
|
| 108 |
+
("W42-T12 manager self-removal reaches the grantee-safe door",
|
| 109 |
"filter-kit/FieldsHidePanel.tsx",
|
| 110 |
r"onRemoveField: \(\(key: string\) => void\) \| null;",
|
| 111 |
+
"customer-grid/CustomerGrid.tsx",
|
| 112 |
+
r"onRemoveField=\{fieldGrantTableKey[\s\S]*removeFieldFromMine\(field\)"),
|
| 113 |
+
("W42-T12 the batch editor is mounted by the only manager host",
|
| 114 |
+
"customer-grid/FieldBatchAccessDialog.tsx", r"export default function FieldBatchAccessDialog",
|
| 115 |
+
"customer-grid/CustomerGrid.tsx", r"<FieldBatchAccessDialog"),
|
| 116 |
|
| 117 |
# ββ W42-T13 (instruction 2, D-485) β THE SEVENTH HANDLER, AND IT LIVES ON A DIFFERENT
|
| 118 |
# COMPONENT FROM T12's SIX. `onRemoveFromMine` is a `ColumnMenuProps` member, not a
|
|
|
|
| 145 |
# and does nothing when pressed β D-485 turned from an absent control into the WORSE defect of
|
| 146 |
# a silent one, with the whole battery green. The bundle door is the only thing on this
|
| 147 |
# surface that speaks the refusal out loud, so pinning it pins the behaviour.
|
| 148 |
+
("W42-T13 a shared grantee's ColumnMenu reaches self-removal",
|
| 149 |
+
"customer-grid/ColumnMenu.tsx", r"onRemoveFromMine\?: \(\) => void;",
|
| 150 |
"customer-grid/CustomerGrid.tsx",
|
| 151 |
+
r"onRemoveFromMine=\{[\s\S]*removeFieldFromMine\(menuField\)"),
|
|
|
|
| 152 |
|
| 153 |
# ββ W42-T58 (contract C4) β THE PER KEY VERDICT IS DECLARED IN ONE FILE AND READ IN ANOTHER.
|
| 154 |
#
|
web/wiring_rows/wave42_f.py
CHANGED
|
@@ -33,6 +33,11 @@ _T24: list[tuple[str, str, str, str, str]] = [
|
|
| 33 |
"customer-grid/RouteNav.tsx", r"onOpenRoute\(route\.key\)",
|
| 34 |
"customer-grid/MapView.tsx", r"<RouteNav\b[^>]*\bonOpenRoute=\{openSavedRoute\}",
|
| 35 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
(
|
| 37 |
"route-nav-create-is-its-own-affordance",
|
| 38 |
"customer-grid/RouteNav.tsx", r"onCreateRoute\(\);",
|
|
|
|
| 33 |
"customer-grid/RouteNav.tsx", r"onOpenRoute\(route\.key\)",
|
| 34 |
"customer-grid/MapView.tsx", r"<RouteNav\b[^>]*\bonOpenRoute=\{openSavedRoute\}",
|
| 35 |
),
|
| 36 |
+
(
|
| 37 |
+
"route-nav list failure reaches an error and retry, never an empty state",
|
| 38 |
+
"customer-grid/RouteNav.tsx", r"listError \? \(",
|
| 39 |
+
"customer-grid/MapView.tsx", r"<RouteNav\b[^>]*\blistError=\{routeListError\}[\s\S]{0,160}?\bonListRetry=\{retryRouteList\}",
|
| 40 |
+
),
|
| 41 |
(
|
| 42 |
"route-nav-create-is-its-own-affordance",
|
| 43 |
"customer-grid/RouteNav.tsx", r"onCreateRoute\(\);",
|