Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/ai_review.py +207 -0
- api/automation_engine.py +1089 -94
- api/main.py +21 -0
- api/routes_automation.py +66 -3
- api/routes_connectors.py +176 -0
- api/routes_forms.py +399 -0
- api/routes_nav.py +132 -1
- api/routes_tables.py +28 -4
- api/routes_templates.py +148 -0
- platform/aios_grid.py +7 -1
- platform/core/alerts.py +38 -0
- platform/core/grid_events.py +22 -0
- platform/core/keychain.py +7 -1
- platform/core/user_tables.py +33 -3
- platform/core/view_templates.py +296 -0
- web/src/alerts/AlertsPane.tsx +38 -3
- web/src/alerts/alertsModel.ts +49 -0
- web/src/apiContract.ts +46 -0
- web/src/automation/AutomationBoard.tsx +19 -1
- web/src/automation/AutomationBuilder.tsx +1558 -0
- web/src/automation/AutomationCreate.tsx +123 -3
- web/src/automation/AutomationDetail.tsx +0 -0
- web/src/automation/AutomationSurface.tsx +178 -16
- web/src/automation/AutomationTrigger.tsx +64 -23
- web/src/automation/CondBuilder.tsx +344 -0
- web/src/automation/automationApi.ts +177 -21
- web/src/connectors/ConnectorsPage.tsx +274 -0
- web/src/customer-grid/ColumnMenu.tsx +12 -3
- web/src/customer-grid/CustomerGrid.tsx +47 -0
- web/src/customer-grid/JsonViewer.tsx +361 -0
- web/src/customer-grid/RecordDetail.css +46 -0
- web/src/customer-grid/RecordDetail.tsx +60 -1
- web/src/customer-grid/cells.ts +64 -2
- web/src/customer-grid/display.ts +92 -0
- web/src/customer-grid/iconShapes.ts +524 -489
- web/src/customer-grid/theme.ts +75 -0
- web/src/customer-grid/types.ts +81 -4
- web/src/filter-kit/ops.ts +14 -0
- web/src/forms/FormPublic.tsx +266 -0
- web/src/home/HomePage.tsx +299 -0
- web/src/home/TemplatePicker.tsx +217 -0
- web/src/home/homeModel.ts +208 -0
- web/src/index.css +0 -0
- web/src/settings/permsModel.ts +4 -0
- web/src/shell/Shell.tsx +727 -123
- web/src/shell/nav.ts +123 -4
- web/src/viz/seriesData.ts +5 -0
- web/src/viz/types.ts +9 -1
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v11",
|
| 6 |
"sha": "3126617",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v12 (4908f39)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v12",
|
| 6 |
+
"sha": "4908f39",
|
| 7 |
+
"date": "2026-08-06",
|
| 8 |
+
"subject": "release v12"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v11",
|
| 12 |
"sha": "3126617",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v12 (4908f39)
|
api/ai_review.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AI REVIEW β let a model decide which stage a card moves to (wave 23, owner ruling R4/R14).
|
| 2 |
+
|
| 3 |
+
β WHAT THIS IS. A review stage holds a record until somebody decides where it goes next. R4 made
|
| 4 |
+
that somebody optionally a MODEL: the engine hands over the record's own values, the review's
|
| 5 |
+
prompt, and the list of stages the review is allowed to send a card to, and gets back ONE of
|
| 6 |
+
those stage labels plus a one-line reason. Every decision is written to the same `reviews` audit
|
| 7 |
+
log a human click writes to, tagged `by: "ai"` with the provider and model that made it.
|
| 8 |
+
|
| 9 |
+
β FAIL-CLOSED IN EVERY DIRECTION, and this is the whole safety story. No key configured, a
|
| 10 |
+
network failure, a slow answer, a malformed answer, or an answer naming a stage the review does
|
| 11 |
+
not offer β all return `("", {...})`, and the caller leaves the card exactly where a human would
|
| 12 |
+
have found it. The feature can be absent, broken or wrong and the worst outcome is a person doing
|
| 13 |
+
the work. Nothing here can move a card somewhere the review does not already permit.
|
| 14 |
+
|
| 15 |
+
β CHEAP FIRST (owner R14, verbatim: *"Claude is a bit too expensive"*). The ladder is
|
| 16 |
+
`groq β cerebras β openrouter β anthropic`; the first CONFIGURED provider wins, and Anthropic is
|
| 17 |
+
last rather than absent β it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
|
| 18 |
+
pins one; `AIOS_AI_MODEL` overrides the model.
|
| 19 |
+
|
| 20 |
+
β WHY RAW HTTP RATHER THAN THE `anthropic` SDK, stated because it is a deliberate deviation from
|
| 21 |
+
the /claude-api skill's default and not an oversight. Three of the four legs are OpenAI-chat-shaped
|
| 22 |
+
endpoints with no shared SDK, so a ladder built on the SDK would be one SDK leg beside three
|
| 23 |
+
hand-rolled ones β two implementations of the same call, the seam this repo keeps closing. And
|
| 24 |
+
`aios-web/api/requirements.txt` is PINNED to what the verify battery proves ([[pin-deps-space-
|
| 25 |
+
rebuilds]]): adding a dependency there rebuilds the container, which is a real deploy risk to take
|
| 26 |
+
for one leg of an optional feature. `requests` is already a dependency and `harness/analyst.py`
|
| 27 |
+
established per-provider raw HTTP as the house pattern. Booked as a DEBT line so the integrator
|
| 28 |
+
can overturn it deliberately rather than by drift.
|
| 29 |
+
|
| 30 |
+
The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
|
| 31 |
+
`stop_reason: "refusal"` is checked BEFORE reading `content` β a refusal answers HTTP 200 with an
|
| 32 |
+
empty content list, so code that indexes `content[0]` unconditionally breaks on it.
|
| 33 |
+
"""
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
import json
|
| 37 |
+
import os
|
| 38 |
+
import re
|
| 39 |
+
|
| 40 |
+
import requests
|
| 41 |
+
|
| 42 |
+
#: The ladder. Order IS the policy (R14) β cheapest capable first, Anthropic last.
|
| 43 |
+
PROVIDERS = [
|
| 44 |
+
{"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
|
| 45 |
+
"url": "https://api.groq.com/openai/v1/chat/completions",
|
| 46 |
+
"model": "llama-3.3-70b-versatile"},
|
| 47 |
+
{"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
|
| 48 |
+
"url": "https://api.cerebras.ai/v1/chat/completions",
|
| 49 |
+
"model": "gpt-oss-120b"},
|
| 50 |
+
{"name": "openrouter", "env": "OPENROUTER_API_KEY", "shape": "openai",
|
| 51 |
+
"url": "https://openrouter.ai/api/v1/chat/completions",
|
| 52 |
+
"model": "openai/gpt-4o-mini"},
|
| 53 |
+
# β haiku-class DELIBERATELY, not the default Opus tier: R14 put Anthropic on this ladder as
|
| 54 |
+
# the backstop for a one-line classification, and this is the cheapest current Claude that
|
| 55 |
+
# does it well. A bigger model here would be spending the owner's money to pick between two
|
| 56 |
+
# labels it already has in front of it.
|
| 57 |
+
{"name": "anthropic", "env": "ANTHROPIC_API_KEY", "shape": "anthropic",
|
| 58 |
+
"url": "https://api.anthropic.com/v1/messages",
|
| 59 |
+
"model": "claude-haiku-4-5"},
|
| 60 |
+
]
|
| 61 |
+
ANTHROPIC_VERSION = "2023-06-01"
|
| 62 |
+
TIMEOUT_SECONDS = float(os.environ.get("AIOS_AI_REVIEW_TIMEOUT") or 20)
|
| 63 |
+
MAX_FIELD_CHARS = 200 # per value handed to the model
|
| 64 |
+
MAX_FIELDS = 30 # columns handed to the model
|
| 65 |
+
MAX_REASON = 200
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def ladder():
|
| 69 |
+
"""The providers that are actually usable here, in order. Empty = the feature is off."""
|
| 70 |
+
pin = (os.environ.get("AIOS_AI_REVIEW_PROVIDER") or "").strip().lower()
|
| 71 |
+
live = [p for p in PROVIDERS if (os.environ.get(p["env"]) or "").strip()]
|
| 72 |
+
if pin:
|
| 73 |
+
live = [p for p in live if p["name"] == pin]
|
| 74 |
+
return live
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def configured():
|
| 78 |
+
return bool(ladder())
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _record_text(row, fields):
|
| 82 |
+
"""The record, as the model sees it. Values are truncated and the column set is bounded β
|
| 83 |
+
an automation table can carry a 32 KB JSON blob per row (C7) and a review decision does not
|
| 84 |
+
need it. Machine bookkeeping columns are dropped: a stage cell naming the stage the card is
|
| 85 |
+
sitting at would be the model reading its own question back."""
|
| 86 |
+
keys = [k for k in (fields or list((row or {}).keys()))
|
| 87 |
+
if not str(k).startswith("stage_")][:MAX_FIELDS]
|
| 88 |
+
lines = []
|
| 89 |
+
for k in keys:
|
| 90 |
+
v = (row or {}).get(k)
|
| 91 |
+
if v is None or str(v).strip() == "":
|
| 92 |
+
continue
|
| 93 |
+
lines.append(f"{k}: {str(v)[:MAX_FIELD_CHARS]}")
|
| 94 |
+
return "\n".join(lines) or "(this record has no filled-in values)"
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _instruction(prompt, options, label):
|
| 98 |
+
return (
|
| 99 |
+
f"You are deciding what happens to one record waiting at a review step called "
|
| 100 |
+
f"{label!r} in a workflow.\n\n"
|
| 101 |
+
f"The person who built this workflow told you: {prompt}\n\n"
|
| 102 |
+
f"Choose EXACTLY ONE of these next steps, by its exact name:\n"
|
| 103 |
+
+ "\n".join(f"- {o}" for o in options)
|
| 104 |
+
+ "\n\nAnswer with one line of JSON and nothing else:\n"
|
| 105 |
+
'{"choice": "<one name from the list above>", "reason": "<one short sentence>"}\n'
|
| 106 |
+
"If the record does not give you enough to decide, answer "
|
| 107 |
+
'{"choice": "", "reason": "why not"} and a person will decide instead.'
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _parse(text, options):
|
| 112 |
+
"""The model's line β `(choice, reason)`. A choice that is not one of the offered stages is
|
| 113 |
+
DISCARDED, not fuzzy-matched: the offered list is a permission boundary, and a near-miss
|
| 114 |
+
resolved by string distance is how a card ends up somewhere nobody authorised."""
|
| 115 |
+
raw = str(text or "").strip()
|
| 116 |
+
obj = None
|
| 117 |
+
m = re.search(r"\{.*\}", raw, re.S)
|
| 118 |
+
if m:
|
| 119 |
+
try:
|
| 120 |
+
obj = json.loads(m.group(0))
|
| 121 |
+
except (ValueError, TypeError):
|
| 122 |
+
obj = None
|
| 123 |
+
if not isinstance(obj, dict):
|
| 124 |
+
return "", ""
|
| 125 |
+
choice = str(obj.get("choice") or "").strip()
|
| 126 |
+
reason = str(obj.get("reason") or "").strip()[:MAX_REASON]
|
| 127 |
+
for opt in options:
|
| 128 |
+
if choice.lower() == str(opt).lower():
|
| 129 |
+
return str(opt), reason # the OFFERED spelling wins, never the model's
|
| 130 |
+
return "", reason
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _call_openai(p, model, system, user, timeout):
|
| 134 |
+
r = requests.post(p["url"], timeout=timeout,
|
| 135 |
+
headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
|
| 136 |
+
"Content-Type": "application/json"},
|
| 137 |
+
json={"model": model, "max_tokens": 300, "temperature": 0,
|
| 138 |
+
"messages": [{"role": "system", "content": system},
|
| 139 |
+
{"role": "user", "content": user}]})
|
| 140 |
+
if r.status_code >= 400:
|
| 141 |
+
return "", f"{p['name']} answered {r.status_code}"
|
| 142 |
+
body = r.json()
|
| 143 |
+
choices = body.get("choices") or []
|
| 144 |
+
if not choices:
|
| 145 |
+
return "", f"{p['name']} returned no choices"
|
| 146 |
+
return str(((choices[0] or {}).get("message") or {}).get("content") or ""), ""
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _call_anthropic(p, model, system, user, timeout):
|
| 150 |
+
r = requests.post(p["url"], timeout=timeout,
|
| 151 |
+
headers={"x-api-key": os.environ[p["env"]].strip(),
|
| 152 |
+
"anthropic-version": ANTHROPIC_VERSION,
|
| 153 |
+
"content-type": "application/json"},
|
| 154 |
+
json={"model": model, "max_tokens": 300, "system": system,
|
| 155 |
+
"messages": [{"role": "user", "content": user}]})
|
| 156 |
+
if r.status_code >= 400:
|
| 157 |
+
return "", f"anthropic answered {r.status_code}"
|
| 158 |
+
body = r.json()
|
| 159 |
+
# β stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
|
| 160 |
+
# reading content[0] before this check turns a refusal into an IndexError inside a run.
|
| 161 |
+
if body.get("stop_reason") == "refusal":
|
| 162 |
+
return "", "anthropic declined to answer this record"
|
| 163 |
+
parts = [b.get("text") or "" for b in (body.get("content") or [])
|
| 164 |
+
if isinstance(b, dict) and b.get("type") == "text"]
|
| 165 |
+
if not parts:
|
| 166 |
+
return "", "anthropic returned no text"
|
| 167 |
+
return "".join(parts), ""
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def decide(*, prompt, options, row, fields=(), label="Review", timeout=None):
|
| 171 |
+
"""Pick this record's next stage. Returns `(choice, meta)`.
|
| 172 |
+
|
| 173 |
+
`choice` is "" whenever a person should decide β which is every failure mode there is.
|
| 174 |
+
`meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
|
| 175 |
+
"""
|
| 176 |
+
opts = [str(o) for o in (options or []) if str(o).strip()]
|
| 177 |
+
if not opts:
|
| 178 |
+
return "", {"problem": "the review offers no next stages"}
|
| 179 |
+
if not str(prompt or "").strip():
|
| 180 |
+
return "", {"problem": "the review has no prompt for the model to follow"}
|
| 181 |
+
live = ladder()
|
| 182 |
+
if not live:
|
| 183 |
+
return "", {"problem": "no AI provider is configured on this deployment"}
|
| 184 |
+
system = _instruction(prompt, opts, label)
|
| 185 |
+
user = "Here is the record:\n\n" + _record_text(row, fields)
|
| 186 |
+
tmo = float(timeout or TIMEOUT_SECONDS)
|
| 187 |
+
override = (os.environ.get("AIOS_AI_MODEL") or "").strip()
|
| 188 |
+
problems = []
|
| 189 |
+
for p in live:
|
| 190 |
+
model = override or p["model"]
|
| 191 |
+
try:
|
| 192 |
+
text, err = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
|
| 193 |
+
p, model, system, user, tmo)
|
| 194 |
+
except Exception as e: # noqa: BLE001
|
| 195 |
+
text, err = "", f"{p['name']} failed: {type(e).__name__}"
|
| 196 |
+
if err:
|
| 197 |
+
problems.append(err)
|
| 198 |
+
continue # ladder: a dead provider degrades to the next one
|
| 199 |
+
choice, reason = _parse(text, opts)
|
| 200 |
+
if not choice:
|
| 201 |
+
# The provider ANSWERED and declined (or answered unusably). That is a decision about
|
| 202 |
+
# this record, not a fault in the provider, so it does NOT fall through to a more
|
| 203 |
+
# expensive one β the card goes to a human, which is what the model just asked for.
|
| 204 |
+
return "", {"provider": p["name"], "model": model,
|
| 205 |
+
"problem": reason or "the model did not choose one of the stages"}
|
| 206 |
+
return choice, {"provider": p["name"], "model": model, "reason": reason}
|
| 207 |
+
return "", {"problem": "; ".join(problems)[:300] or "no provider answered"}
|
api/automation_engine.py
CHANGED
|
@@ -24,6 +24,7 @@ the skill version takes a URL from a developer on a CLI; this takes one from a r
|
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import datetime as _dt
|
|
|
|
| 27 |
import ipaddress
|
| 28 |
import json
|
| 29 |
import os
|
|
@@ -949,6 +950,59 @@ def clean_schedule(raw, previous=None):
|
|
| 949 |
return out
|
| 950 |
|
| 951 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 952 |
def clean_definition(raw, previous=None, username=""):
|
| 953 |
"""Whole-definition validation. Returns `(defn, error)`."""
|
| 954 |
raw = raw if isinstance(raw, dict) else {}
|
|
@@ -973,10 +1027,19 @@ def clean_definition(raw, previous=None, username=""):
|
|
| 973 |
else prev.get("trigger"), prev.get("trigger"))
|
| 974 |
if terr:
|
| 975 |
return None, terr
|
|
|
|
|
|
|
|
|
|
|
|
|
| 976 |
return {
|
| 977 |
"id": _s(prev.get("id") or raw.get("id"), 40),
|
| 978 |
"name": name, "kind": kind, "config": config, "schedule": schedule,
|
| 979 |
"trigger": trigger,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
"status": prev.get("status") or {"state": "idle", "lastRunAt": "", "lastSummary": ""},
|
| 981 |
"runs": list(prev.get("runs") or [])[:MAX_RUNS],
|
| 982 |
# C3-A2(5): the review-decision audit survives a Save the way `state` does β an
|
|
@@ -1027,10 +1090,61 @@ def _new_id(existing):
|
|
| 1027 |
return f"auto_{n}"
|
| 1028 |
|
| 1029 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1030 |
def create(rt, raw, username=""):
|
| 1031 |
existing = all_definitions(rt)
|
| 1032 |
if len(existing) >= MAX_AUTOMATIONS:
|
| 1033 |
return None, f"this workspace is at the {MAX_AUTOMATIONS}-automation limit"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1034 |
defn, err = clean_definition(raw, None, username)
|
| 1035 |
if err:
|
| 1036 |
return None, err
|
|
@@ -1054,7 +1168,12 @@ def patch(rt, auto_id, raw, username=""):
|
|
| 1054 |
if prev is None:
|
| 1055 |
return None, "no such automation"
|
| 1056 |
merged = dict(prev)
|
| 1057 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1058 |
if k in (raw or {}):
|
| 1059 |
merged[k] = raw[k]
|
| 1060 |
defn, err = clean_definition(merged, prev, username)
|
|
@@ -3082,15 +3201,27 @@ def graph(defn):
|
|
| 3082 |
|
| 3083 |
on = bool(sched.get("enabled"))
|
| 3084 |
trg = defn.get("trigger") or {}
|
| 3085 |
-
if trg.get("key") in ("
|
| 3086 |
# C3 (wave 22): the trigger node SAYS which trigger this flow has β the board and rail
|
| 3087 |
-
# read the same node, so an event-triggered flow must not read "Schedule".
|
|
|
|
|
|
|
|
|
|
| 3088 |
t_on = bool(trg.get("enabled", True)) and not trg.get("paused")
|
| 3089 |
sub = TRIGGER_LABELS.get(trg["key"], trg["key"])
|
| 3090 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3091 |
"record_created": str(trg.get("table") or ""),
|
|
|
|
|
|
|
| 3092 |
"webhook": "POST the hook URL to fire it",
|
| 3093 |
"email": str(trg.get("query") or "")}.get(trg["key"], "")
|
|
|
|
|
|
|
| 3094 |
if trg.get("paused"):
|
| 3095 |
det = str(defn.get("statusNote") or "paused")
|
| 3096 |
nodes = [node("trigger", "trigger", sub,
|
|
@@ -3279,6 +3410,91 @@ MAX_LANES = 6
|
|
| 3279 |
LANE_OPS = ("=", "!=", ">", ">=", "<", "<=", "includes", "not_includes",
|
| 3280 |
"is_empty", "is_not_empty")
|
| 3281 |
LANE_NULLARY_OPS = ("is_empty", "is_not_empty")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3282 |
#: Lane labels a flow already uses for its fixed stages. A lane literally called "Review" would
|
| 3283 |
#: collide with the stage the label is a choice FOR, and the board could no longer tell a
|
| 3284 |
#: routed card from a gated one.
|
|
@@ -3319,25 +3535,10 @@ def clean_lanes(raw):
|
|
| 3319 |
lid = f"{base}_{n}"
|
| 3320 |
n += 1
|
| 3321 |
seen_ids.add(lid)
|
| 3322 |
-
|
| 3323 |
-
|
| 3324 |
-
|
| 3325 |
-
|
| 3326 |
-
return None, f"the condition on {label!r} must be an object"
|
| 3327 |
-
field = _s(cond_raw.get("field"), 80).strip()
|
| 3328 |
-
op = _s(cond_raw.get("op") or cond_raw.get("operator"), 20).strip()
|
| 3329 |
-
if not field:
|
| 3330 |
-
return None, f"the condition on {label!r} names no field"
|
| 3331 |
-
if op not in LANE_OPS:
|
| 3332 |
-
return None, (f"{op or 'that comparison'!r} is not one of: "
|
| 3333 |
-
+ ", ".join(LANE_OPS))
|
| 3334 |
-
cond = {"field": field, "op": op}
|
| 3335 |
-
if op not in LANE_NULLARY_OPS:
|
| 3336 |
-
v = cond_raw.get("value")
|
| 3337 |
-
if v is None or (isinstance(v, str) and not v.strip()):
|
| 3338 |
-
return None, f"give a value to compare {field!r} against on {label!r}"
|
| 3339 |
-
cond["value"] = v.strip() if isinstance(v, str) else v
|
| 3340 |
-
else:
|
| 3341 |
defaults += 1
|
| 3342 |
if defaults > 1:
|
| 3343 |
return None, ("only one lane may have no condition β it is the catch-all, "
|
|
@@ -3354,7 +3555,11 @@ def _lane_num(v):
|
|
| 3354 |
|
| 3355 |
|
| 3356 |
def lane_match(cond, row):
|
| 3357 |
-
"""Does `row` satisfy
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3358 |
|
| 3359 |
β REFUSE-NEVER-COERCE, per record: an ordering comparison whose either side does not parse
|
| 3360 |
as a number answers False β the record simply does not enter the lane β never "treat blank
|
|
@@ -3363,6 +3568,11 @@ def lane_match(cond, row):
|
|
| 3363 |
"""
|
| 3364 |
if cond is None:
|
| 3365 |
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3366 |
raw = (row or {}).get(cond.get("field"))
|
| 3367 |
op = cond.get("op")
|
| 3368 |
if op == "is_empty":
|
|
@@ -3398,6 +3608,219 @@ def route_record(lanes, row):
|
|
| 3398 |
return ""
|
| 3399 |
|
| 3400 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3401 |
def stages_for(defn):
|
| 3402 |
"""One automation β `(stages, routes)` β C1's vocabulary, derived from `graph()` so the
|
| 3403 |
board can never disagree with the runner about what the flow does.
|
|
@@ -3475,12 +3898,335 @@ def stages_for(defn):
|
|
| 3475 |
routes.append({"from": "write", "to": lane["id"],
|
| 3476 |
"label": lane["label"] if cond is None else _lane_sentence(cond),
|
| 3477 |
"condition": dict(cond) if cond else None})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3478 |
return stages, routes
|
| 3479 |
|
| 3480 |
|
| 3481 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3482 |
if cond is None:
|
| 3483 |
-
return "Everything else"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3484 |
v = cond.get("value")
|
| 3485 |
return f"{cond.get('field')} {cond.get('op')}" + ("" if v is None else f" {v}")
|
| 3486 |
|
|
@@ -3527,14 +4273,27 @@ def ensure_stage_field(rt, table_key, defn, username="automation"):
|
|
| 3527 |
"Declined": {"tracked": ""}}
|
| 3528 |
field = {"key": fkey, "label": f"Stage β {defn.get('name') or defn.get('id')}"[:80],
|
| 3529 |
"type": "select", "source": "overlay", "options": choices, "automation": auto}
|
| 3530 |
-
|
| 3531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3532 |
for f in (ut_get(rt, key) or {}).get("fields") or []:
|
| 3533 |
if f.get("key") == fkey:
|
| 3534 |
have = f
|
| 3535 |
-
|
|
|
|
| 3536 |
if have is not None and have.get("options") == choices and \
|
| 3537 |
-
(have.get("automation") or {}) == auto:
|
| 3538 |
return fkey # nothing would change β save the commit
|
| 3539 |
|
| 3540 |
def _up(cur):
|
|
@@ -3547,8 +4306,14 @@ def ensure_stage_field(rt, table_key, defn, username="automation"):
|
|
| 3547 |
f["options"] = choices
|
| 3548 |
f["automation"] = auto
|
| 3549 |
f["type"] = "select"
|
| 3550 |
-
|
| 3551 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3552 |
return cur
|
| 3553 |
|
| 3554 |
rt.update(UT_STORE_KEY, _up, flush="sync")
|
|
@@ -3732,12 +4497,24 @@ def move_card(rt, auto_id, row_id, to, username="", admin=False):
|
|
| 3732 |
MAX_REVIEWS = 100
|
| 3733 |
|
| 3734 |
|
| 3735 |
-
def review_audit(rt, auto_id, row_id, from_label, to_label, username
|
|
|
|
| 3736 |
"""C3-A2(5): a review decision is AUDITED β who moved which card where, when. Appended to
|
| 3737 |
the definition (newest first, bounded) by BOTH doors: `move_card` here, and the grid door
|
| 3738 |
-
in `core.grid_events`, which writes the same shape mechanically off the field's `flowId`.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3739 |
entry = {"ts": _iso(), "user": _s(username, 80), "rowId": str(row_id),
|
| 3740 |
-
"from": _s(from_label, 60), "to": _s(to_label, 60)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3741 |
|
| 3742 |
def _up(cur):
|
| 3743 |
cur = cur if isinstance(cur, dict) else {}
|
|
@@ -3972,12 +4749,45 @@ def purge_subject(rt, handle):
|
|
| 3972 |
# EVER (a high-water mark over row ids, so an undo-restored row cannot re-fire);
|
| 3973 |
# event_field fires every transition.
|
| 3974 |
|
| 3975 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3976 |
TRIGGER_LABELS = {
|
| 3977 |
-
"manual": "Manual",
|
| 3978 |
-
"
|
| 3979 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3980 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3981 |
#: The settle window for field-change bursts (A2(1)). 0 evaluates INLINE β the gates run there,
|
| 3982 |
#: and so would a deployment that prefers immediacy over coalescing.
|
| 3983 |
EVENT_SETTLE_SECONDS = float(os.environ.get("AIOS_EVENT_SETTLE_SECONDS") or 15)
|
|
@@ -4016,6 +4826,11 @@ def clean_trigger(raw, previous=None):
|
|
| 4016 |
prev = previous if isinstance(previous, dict) else {}
|
| 4017 |
key = _s(raw.get("key") or raw.get("kind") or prev.get("key") or prev.get("kind"),
|
| 4018 |
30).strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4019 |
if key not in TRIGGER_KEYS:
|
| 4020 |
return None, (f"{key or 'that trigger'!r} is not one of: " + ", ".join(TRIGGER_KEYS))
|
| 4021 |
if key in ("manual", "schedule"):
|
|
@@ -4026,40 +4841,51 @@ def clean_trigger(raw, previous=None):
|
|
| 4026 |
"enabled": bool(raw["enabled"]) if "enabled" in raw else
|
| 4027 |
bool(prev.get("enabled", True)),
|
| 4028 |
"paused": bool(raw["paused"]) if "paused" in raw else bool(prev.get("paused"))}
|
| 4029 |
-
if key in
|
| 4030 |
table = _s(raw.get("table") if "table" in raw else prev.get("table"), 60).strip()
|
| 4031 |
if table and not table.startswith(UT_PREFIX):
|
| 4032 |
return None, ("event triggers watch blank databases (ut_*) this wave β "
|
| 4033 |
f"{table!r} is not one")
|
| 4034 |
out["table"] = table
|
| 4035 |
if key == "event_field":
|
| 4036 |
-
|
| 4037 |
-
|
| 4038 |
-
|
| 4039 |
-
|
| 4040 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4041 |
else:
|
| 4042 |
-
|
| 4043 |
-
|
| 4044 |
-
|
| 4045 |
-
|
| 4046 |
-
|
| 4047 |
-
|
| 4048 |
-
|
| 4049 |
-
|
| 4050 |
-
|
| 4051 |
-
|
| 4052 |
-
|
| 4053 |
-
|
| 4054 |
-
|
| 4055 |
-
|
| 4056 |
-
|
| 4057 |
-
|
| 4058 |
-
|
| 4059 |
-
|
| 4060 |
-
return None, f"give a value to compare {fkey!r} against"
|
| 4061 |
-
cond["value"] = v.strip() if isinstance(v, str) else v
|
| 4062 |
-
out["when"] = cond
|
| 4063 |
if key == "webhook":
|
| 4064 |
# The token is MINTED here, once, and survives every later patch β rotating it on
|
| 4065 |
# every Save would silently break the external caller the URL was given to.
|
|
@@ -4067,12 +4893,26 @@ def clean_trigger(raw, previous=None):
|
|
| 4067 |
if key == "email":
|
| 4068 |
out["query"] = _s(raw.get("query") if "query" in raw else prev.get("query"),
|
| 4069 |
200).strip() or "in:inbox is:unread"
|
| 4070 |
-
out["configured"] =
|
| 4071 |
-
out.get("table")) if key == "record_created" else bool(
|
| 4072 |
-
out.get("table") and out.get("field")) if key == "event_field" else True
|
| 4073 |
return out, None
|
| 4074 |
|
| 4075 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4076 |
def _secrets_token():
|
| 4077 |
import secrets as _sec
|
| 4078 |
return _sec.token_urlsafe(24)
|
|
@@ -4126,17 +4966,31 @@ _SETTLE = {}
|
|
| 4126 |
_SETTLE_LOCK = threading.Lock()
|
| 4127 |
|
| 4128 |
|
| 4129 |
-
def _settle_buffer(rt, tenant, auto_id, row_id, after, log=print):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4130 |
key = (tenant, str(auto_id))
|
|
|
|
| 4131 |
if EVENT_SETTLE_SECONDS <= 0:
|
| 4132 |
with _SETTLE_LOCK:
|
| 4133 |
buf = _SETTLE.setdefault(key, {"rows": {}})
|
| 4134 |
-
buf["rows"]
|
| 4135 |
_settle_eval(rt, tenant, auto_id, log=log)
|
| 4136 |
return
|
| 4137 |
with _SETTLE_LOCK:
|
| 4138 |
buf = _SETTLE.setdefault(key, {"rows": {}})
|
| 4139 |
-
buf["rows"]
|
| 4140 |
timer = buf.get("timer")
|
| 4141 |
if timer is not None:
|
| 4142 |
timer.cancel() # the burst continues β push the window out
|
|
@@ -4147,24 +5001,88 @@ def _settle_buffer(rt, tenant, auto_id, row_id, after, log=print):
|
|
| 4147 |
timer.start()
|
| 4148 |
|
| 4149 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4150 |
def _settle_eval(rt, tenant, auto_id, log=print):
|
| 4151 |
"""Evaluate one settled burst: edge over per-record armed state, flood hold, then fire."""
|
| 4152 |
with _SETTLE_LOCK:
|
| 4153 |
buf = _SETTLE.pop((tenant, str(auto_id)), None)
|
| 4154 |
-
|
| 4155 |
-
|
|
|
|
| 4156 |
return
|
| 4157 |
d = all_definitions(rt).get(str(auto_id))
|
| 4158 |
trg = (d or {}).get("trigger") or {}
|
| 4159 |
-
if trg.get("key")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4160 |
return
|
| 4161 |
-
when = trg.get("when")
|
| 4162 |
fired = []
|
| 4163 |
-
if
|
|
|
|
| 4164 |
disarmed = set((d.get("state") or {}).get("eventDisarmed") or [])
|
| 4165 |
nxt = set(disarmed)
|
| 4166 |
-
for rid
|
| 4167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4168 |
if rid not in disarmed:
|
| 4169 |
fired.append(rid) # falseβtrue THIS evaluation: the edge
|
| 4170 |
nxt.add(rid)
|
|
@@ -4175,7 +5093,7 @@ def _settle_eval(rt, tenant, auto_id, log=print):
|
|
| 4175 |
if not fired:
|
| 4176 |
return
|
| 4177 |
else:
|
| 4178 |
-
fired = list(
|
| 4179 |
if len(fired) > FLOOD_LIMIT:
|
| 4180 |
_commit_run(rt, auto_id, "partial",
|
| 4181 |
f"the trigger matched {len(fired)} records in one evaluation β more than "
|
|
@@ -4188,14 +5106,21 @@ def _settle_eval(rt, tenant, auto_id, log=print):
|
|
| 4188 |
def _seed_event_state(rt, defn):
|
| 4189 |
"""A2(1)'s enable rule: records ALREADY matching when the trigger is set start DISARMED, so
|
| 4190 |
turning the trigger on fires nothing β the first fire needs a real falseβtrue transition.
|
| 4191 |
-
Evaluated over the definition rows (the machine-written truth this trigger class watches).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4192 |
trg = (defn or {}).get("trigger") or {}
|
| 4193 |
-
if trg.get("key")
|
|
|
|
| 4194 |
return
|
| 4195 |
-
|
| 4196 |
-
|
| 4197 |
-
|
| 4198 |
-
|
|
|
|
| 4199 |
set_state(rt, defn.get("id"), {"eventDisarmed": matching[:5000]})
|
| 4200 |
|
| 4201 |
|
|
@@ -4212,27 +5137,76 @@ def grid_hook(evt):
|
|
| 4212 |
table = str(evt.get("table") or "")
|
| 4213 |
kind = str(evt.get("type") or "")
|
| 4214 |
defs = all_definitions(st)
|
|
|
|
|
|
|
| 4215 |
for aid, d in defs.items():
|
| 4216 |
trg = (d or {}).get("trigger") or {}
|
| 4217 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4218 |
continue
|
| 4219 |
-
if kind == "record_created" and
|
| 4220 |
-
and str(trg.get("table") or "") == table:
|
| 4221 |
-
rid = str(evt.get("rowId") or "")
|
| 4222 |
hw = _ig_int((d.get("state") or {}).get("rcHighwater")) or 0
|
| 4223 |
ridn = int(rid) if rid.isdigit() else None
|
| 4224 |
if ridn is None or ridn <= hw:
|
| 4225 |
continue # once per record EVER (A2(4)) β undo-proof
|
| 4226 |
set_state(st, aid, {"rcHighwater": ridn})
|
| 4227 |
trigger_fire(st, tenant, aid)
|
| 4228 |
-
elif kind == "event_field" and
|
| 4229 |
-
|
| 4230 |
-
|
| 4231 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4232 |
except Exception as e: # noqa: BLE001
|
| 4233 |
print(f"[aios-auto] trigger hook failed: {type(e).__name__}: {e}")
|
| 4234 |
|
| 4235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4236 |
def hook_fire(rt, tenant, auto_id, token):
|
| 4237 |
"""The webhook trigger's decision, separated from FastAPI so the gate can drive it.
|
| 4238 |
Returns `(status, payload)` β 404 unknown, 403 wrong/missing token or wrong trigger kind,
|
|
@@ -4393,6 +5367,27 @@ def run_now(rt, tenant, auto_id, username="automation", log=print):
|
|
| 4393 |
log(f"[aios-auto] run {auto_id} failed: {type(e).__name__}: {e}")
|
| 4394 |
return _commit_run(rt, auto_id, "error",
|
| 4395 |
f"{type(e).__name__}: {str(e)[:200]}", {}, False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4396 |
return _commit_run(rt, auto_id, state, summary, counts, state != "error", affected,
|
| 4397 |
steps)
|
| 4398 |
finally:
|
|
|
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import datetime as _dt
|
| 27 |
+
import hmac
|
| 28 |
import ipaddress
|
| 29 |
import json
|
| 30 |
import os
|
|
|
|
| 950 |
return out
|
| 951 |
|
| 952 |
|
| 953 |
+
# ββ WAVE 23 Β· C5 β the ENDING, and the cycle counter that makes a loop countable. βββββββββββββ
|
| 954 |
+
# The owner's words: "we can make this automation a loop, so the ending should always be defined,
|
| 955 |
+
# either it ends somewhere deterministic like Closed/Failed, or it goes to reset automatically,
|
| 956 |
+
# or the user have to click a button to reset, or after a certain amount of time it can
|
| 957 |
+
# automatically reset to first cycle."
|
| 958 |
+
#
|
| 959 |
+
# β `terminal` IS THE DEFAULT and every existing automation gets it, because a stored definition
|
| 960 |
+
# that predates this field must not start moving records on its own the day the code ships. A
|
| 961 |
+
# loop is a thing somebody turns on.
|
| 962 |
+
ENDING_MODES = ("terminal", "auto_reset", "manual_reset", "timed_reset")
|
| 963 |
+
MAX_RESET_HOURS = 720 # 30 days; past that "it resets eventually" is a lie
|
| 964 |
+
#: The per-record cycle counter's cell suffix, beside `stage_<id>` and `stage_<id>_at`.
|
| 965 |
+
CYCLES_SUFFIX = "_cycles"
|
| 966 |
+
|
| 967 |
+
|
| 968 |
+
def cycles_field_key(auto_id):
|
| 969 |
+
return stage_field_key(auto_id) + CYCLES_SUFFIX
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def clean_ending(raw, previous=None):
|
| 973 |
+
"""Validate `{mode, hours?}`. Returns `(ending, error)`."""
|
| 974 |
+
raw = raw if isinstance(raw, dict) else {}
|
| 975 |
+
prev = previous if isinstance(previous, dict) else {}
|
| 976 |
+
mode = _s(raw.get("mode") or prev.get("mode"), 20).strip() or "terminal"
|
| 977 |
+
if mode not in ENDING_MODES:
|
| 978 |
+
return None, f"{mode!r} is not one of: " + ", ".join(ENDING_MODES)
|
| 979 |
+
out = {"mode": mode}
|
| 980 |
+
if mode == "timed_reset":
|
| 981 |
+
try:
|
| 982 |
+
hours = int(raw.get("hours") if raw.get("hours") is not None else prev.get("hours")
|
| 983 |
+
or 24)
|
| 984 |
+
except (TypeError, ValueError):
|
| 985 |
+
return None, "the reset delay must be a whole number of hours"
|
| 986 |
+
if hours < 1 or hours > MAX_RESET_HOURS:
|
| 987 |
+
return None, f"the reset delay is between 1 and {MAX_RESET_HOURS} hours"
|
| 988 |
+
out["hours"] = hours
|
| 989 |
+
return out, None
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
def clean_flow(raw, previous=None):
|
| 993 |
+
"""Validate `{actions, ending}` β the builder's half of a definition. `(flow, error)`."""
|
| 994 |
+
raw = raw if isinstance(raw, dict) else {}
|
| 995 |
+
prev = previous if isinstance(previous, dict) else {}
|
| 996 |
+
actions, err = clean_actions(raw.get("actions") if "actions" in raw else prev.get("actions"))
|
| 997 |
+
if err:
|
| 998 |
+
return None, err
|
| 999 |
+
ending, err = clean_ending(raw.get("ending") if "ending" in raw else prev.get("ending"),
|
| 1000 |
+
prev.get("ending"))
|
| 1001 |
+
if err:
|
| 1002 |
+
return None, err
|
| 1003 |
+
return {"actions": actions, "ending": ending}, None
|
| 1004 |
+
|
| 1005 |
+
|
| 1006 |
def clean_definition(raw, previous=None, username=""):
|
| 1007 |
"""Whole-definition validation. Returns `(defn, error)`."""
|
| 1008 |
raw = raw if isinstance(raw, dict) else {}
|
|
|
|
| 1027 |
else prev.get("trigger"), prev.get("trigger"))
|
| 1028 |
if terr:
|
| 1029 |
return None, terr
|
| 1030 |
+
flow, ferr = clean_flow(raw.get("flow") if "flow" in raw else prev.get("flow"),
|
| 1031 |
+
prev.get("flow"))
|
| 1032 |
+
if ferr:
|
| 1033 |
+
return None, ferr
|
| 1034 |
return {
|
| 1035 |
"id": _s(prev.get("id") or raw.get("id"), 40),
|
| 1036 |
"name": name, "kind": kind, "config": config, "schedule": schedule,
|
| 1037 |
"trigger": trigger,
|
| 1038 |
+
# WAVE 23 Β· C4/C5 β the BUILDER's half: the ordered actions a record walks and what
|
| 1039 |
+
# happens when it reaches the end. Absent on every definition written before this wave,
|
| 1040 |
+
# which is exactly why `clean_flow` defaults it to "no actions, terminal ending": the
|
| 1041 |
+
# code shipping must not put existing automations into a loop.
|
| 1042 |
+
"flow": flow,
|
| 1043 |
"status": prev.get("status") or {"state": "idle", "lastRunAt": "", "lastSummary": ""},
|
| 1044 |
"runs": list(prev.get("runs") or [])[:MAX_RUNS],
|
| 1045 |
# C3-A2(5): the review-decision audit survives a Save the way `state` does β an
|
|
|
|
| 1090 |
return f"auto_{n}"
|
| 1091 |
|
| 1092 |
|
| 1093 |
+
#: C2's three answers to "which database does this automation work on?" β the FIRST question the
|
| 1094 |
+
#: create wizard asks (owner item 3: the kind is not the first thing a person picks, the data is).
|
| 1095 |
+
TARGET_MODES = ("existing", "new", "automated")
|
| 1096 |
+
|
| 1097 |
+
|
| 1098 |
+
def resolve_target(rt, raw, username=""):
|
| 1099 |
+
"""C2: turn the wizard's `target` into a bound table key. Returns `(config_patch, error)`.
|
| 1100 |
+
|
| 1101 |
+
- `existing` β bind a database that is already there.
|
| 1102 |
+
- `new` β mint a blank one now, so the automation has somewhere to write before its first
|
| 1103 |
+
run instead of conjuring a table the person never agreed to.
|
| 1104 |
+
- `automated` β leave it to the runner: a scraping automation MINTS its target on first run
|
| 1105 |
+
(`ut_ensure`), stamped `source: "Automation"`, which is what makes it a machine database.
|
| 1106 |
+
Nothing is created here, deliberately β an empty table created up front for a scrape that
|
| 1107 |
+
never runs is litter nobody can explain.
|
| 1108 |
+
"""
|
| 1109 |
+
if not isinstance(raw, dict) or not raw:
|
| 1110 |
+
return {}, None # no target block = the pre-wizard shape
|
| 1111 |
+
mode = _s(raw.get("mode"), 20).strip() or "existing"
|
| 1112 |
+
if mode not in TARGET_MODES:
|
| 1113 |
+
return None, f"{mode!r} is not one of: " + ", ".join(TARGET_MODES)
|
| 1114 |
+
if mode == "automated":
|
| 1115 |
+
label = " ".join(_s(raw.get("label"), 60).split())
|
| 1116 |
+
return ({"targetLabel": label} if label else {}), None
|
| 1117 |
+
if mode == "existing":
|
| 1118 |
+
key = _s(raw.get("table"), 60).strip()
|
| 1119 |
+
if not key:
|
| 1120 |
+
return None, "choose the database this automation works on"
|
| 1121 |
+
t = ut_get(rt, key)
|
| 1122 |
+
if t is None:
|
| 1123 |
+
return None, f"{key!r} is not a database in this workspace"
|
| 1124 |
+
return {"targetTable": key, "targetLabel": t.get("label") or key}, None
|
| 1125 |
+
label = " ".join(_s(raw.get("label"), 60).split())
|
| 1126 |
+
if not label:
|
| 1127 |
+
return None, "name the new database"
|
| 1128 |
+
import core.user_tables as _ut_new
|
| 1129 |
+
key = _ut_new.create(label, username or "automation", st=rt)
|
| 1130 |
+
if not key:
|
| 1131 |
+
return None, ("the database could not be created β this workspace may be at its table "
|
| 1132 |
+
"limit")
|
| 1133 |
+
return {"targetTable": key, "targetLabel": label}, None
|
| 1134 |
+
|
| 1135 |
+
|
| 1136 |
def create(rt, raw, username=""):
|
| 1137 |
existing = all_definitions(rt)
|
| 1138 |
if len(existing) >= MAX_AUTOMATIONS:
|
| 1139 |
return None, f"this workspace is at the {MAX_AUTOMATIONS}-automation limit"
|
| 1140 |
+
raw = dict(raw or {})
|
| 1141 |
+
# C2 runs BEFORE validation so a `new` target's freshly-minted key is part of the config
|
| 1142 |
+
# `clean_definition` sees β one write, not a create-then-patch the client has to sequence.
|
| 1143 |
+
patch_cfg, terr = resolve_target(rt, raw.pop("target", None), username)
|
| 1144 |
+
if terr:
|
| 1145 |
+
return None, terr
|
| 1146 |
+
if patch_cfg:
|
| 1147 |
+
raw["config"] = {**(raw.get("config") or {}), **patch_cfg}
|
| 1148 |
defn, err = clean_definition(raw, None, username)
|
| 1149 |
if err:
|
| 1150 |
return None, err
|
|
|
|
| 1168 |
if prev is None:
|
| 1169 |
return None, "no such automation"
|
| 1170 |
merged = dict(prev)
|
| 1171 |
+
# β A HAND-MAINTAINED PATCHABLE-KEY LIST, and it is the silent-drop seat of this module: a
|
| 1172 |
+
# key missing here is accepted by the route, validated by `clean_definition`, and then
|
| 1173 |
+
# DISCARDED β the client shows a saved flow that the store never received, and nothing goes
|
| 1174 |
+
# red. `flow` joined it in the same edit that created `flow` (wave 23), which is the only
|
| 1175 |
+
# ordering that never has a window where the bug exists.
|
| 1176 |
+
for k in ("name", "config", "schedule", "kind", "trigger", "flow"):
|
| 1177 |
if k in (raw or {}):
|
| 1178 |
merged[k] = raw[k]
|
| 1179 |
defn, err = clean_definition(merged, prev, username)
|
|
|
|
| 3201 |
|
| 3202 |
on = bool(sched.get("enabled"))
|
| 3203 |
trg = defn.get("trigger") or {}
|
| 3204 |
+
if trg.get("key") in TRIGGER_KEYS and trg.get("key") not in ("manual", "schedule"):
|
| 3205 |
# C3 (wave 22): the trigger node SAYS which trigger this flow has β the board and rail
|
| 3206 |
+
# read the same node, so an event-triggered flow must not read "Schedule". The membership
|
| 3207 |
+
# test is the KEY SET, not a hand-listed tuple: wave 23 added four triggers and the old
|
| 3208 |
+
# tuple would have quietly rendered every one of them as "Schedule" (they are stored, so
|
| 3209 |
+
# the else-branch was reachable) β green, compiling, and wrong on screen.
|
| 3210 |
t_on = bool(trg.get("enabled", True)) and not trg.get("paused")
|
| 3211 |
sub = TRIGGER_LABELS.get(trg["key"], trg["key"])
|
| 3212 |
+
watched = ", ".join(trg.get("fields") or []) or "any field"
|
| 3213 |
+
det = {"event_field": (f"{trg.get('table', '')}"
|
| 3214 |
+
+ (f".{trg.get('field')}" if trg.get("field") else "")
|
| 3215 |
+
+ (f" β {_lane_sentence(trg.get('when'))}"
|
| 3216 |
+
if trg.get("when") else "")),
|
| 3217 |
+
"record_updated": f"{trg.get('table', '')} β {watched}",
|
| 3218 |
"record_created": str(trg.get("table") or ""),
|
| 3219 |
+
"enters_view": f"{trg.get('table', '')} β view {trg.get('viewId') or 'unset'}",
|
| 3220 |
+
"form_submitted": str(trg.get("table") or ""),
|
| 3221 |
"webhook": "POST the hook URL to fire it",
|
| 3222 |
"email": str(trg.get("query") or "")}.get(trg["key"], "")
|
| 3223 |
+
if not trg.get("configured", True):
|
| 3224 |
+
det = "Finish setting this trigger up before it can fire"
|
| 3225 |
if trg.get("paused"):
|
| 3226 |
det = str(defn.get("statusNote") or "paused")
|
| 3227 |
nodes = [node("trigger", "trigger", sub,
|
|
|
|
| 3410 |
LANE_OPS = ("=", "!=", ">", ">=", "<", "<=", "includes", "not_includes",
|
| 3411 |
"is_empty", "is_not_empty")
|
| 3412 |
LANE_NULLARY_OPS = ("is_empty", "is_not_empty")
|
| 3413 |
+
|
| 3414 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3415 |
+
# WAVE 23 Β· C4 β CONDITION TREES. `Cond = leaf | {all: [Condβ¦]} | {any: [Condβ¦]}`
|
| 3416 |
+
#
|
| 3417 |
+
# A leaf is exactly what wave 22 called a lane condition (`{field, op, value?}`), which is why
|
| 3418 |
+
# this generalisation needed no migration: a stored leaf IS a valid tree, `cond_match` dispatches
|
| 3419 |
+
# on shape, and nothing at rest is rewritten until the owner saves that automation. (The doc
|
| 3420 |
+
# calls this "legacy single-condition lanes auto-wrap as {all:[leaf]} on read" β wrapping is the
|
| 3421 |
+
# same function applied one level up, so the cheaper honest version is to evaluate the leaf where
|
| 3422 |
+
# it lies and never touch the bytes.)
|
| 3423 |
+
#
|
| 3424 |
+
# β DEPTH IS BOUNDED AND THE BOUND IS ENFORCED AT WRITE TIME, not at evaluation time. An
|
| 3425 |
+
# unbounded tree is an unbounded evaluation on a hook that runs inside somebody's keystroke, and
|
| 3426 |
+
# "the server got slow" is the failure nobody traces back to a filter somebody nested 40 deep.
|
| 3427 |
+
# β REFUSE-NEVER-COERCE all the way down (`clean_predicates`' discipline): an empty group, an
|
| 3428 |
+
# unknown comparison, a valueless compare are all REFUSED with the reason named. A group that
|
| 3429 |
+
# quietly dropped its unanswerable leaf would WIDEN β the tri-state scar the filter engine
|
| 3430 |
+
# carries ([[cg-filter-engine-sql-port]]), reproduced here where nothing would report it.
|
| 3431 |
+
MAX_COND_DEPTH = 3
|
| 3432 |
+
MAX_COND_CHILDREN = 12
|
| 3433 |
+
COND_GROUP_KEYS = ("all", "any")
|
| 3434 |
+
|
| 3435 |
+
|
| 3436 |
+
def clean_cond(raw, depth=0, where=""):
|
| 3437 |
+
"""Validate one condition TREE. Returns `(cond|None, error)`; `(None, None)` means "no
|
| 3438 |
+
condition", which is legal everywhere a condition is optional (the catch-all lane, an
|
| 3439 |
+
unconditioned trigger, an action group that always runs)."""
|
| 3440 |
+
if raw in (None, "", {}):
|
| 3441 |
+
return None, None
|
| 3442 |
+
at = f" on {where}" if where else ""
|
| 3443 |
+
if not isinstance(raw, dict):
|
| 3444 |
+
return None, f"the condition{at} must be an object"
|
| 3445 |
+
conj = [k for k in COND_GROUP_KEYS if k in raw]
|
| 3446 |
+
if len(conj) > 1:
|
| 3447 |
+
return None, (f"the condition{at} sets both 'all' and 'any' β a group is one or the "
|
| 3448 |
+
f"other")
|
| 3449 |
+
if conj:
|
| 3450 |
+
key = conj[0]
|
| 3451 |
+
if depth + 1 > MAX_COND_DEPTH:
|
| 3452 |
+
return None, (f"conditions nest at most {MAX_COND_DEPTH} levels deep β "
|
| 3453 |
+
f"the group{at} is deeper")
|
| 3454 |
+
kids_raw = raw.get(key)
|
| 3455 |
+
if not isinstance(kids_raw, list) or not kids_raw:
|
| 3456 |
+
return None, f"the '{key}' group{at} needs at least one condition inside it"
|
| 3457 |
+
if len(kids_raw) > MAX_COND_CHILDREN:
|
| 3458 |
+
return None, (f"a group holds at most {MAX_COND_CHILDREN} conditions β "
|
| 3459 |
+
f"the '{key}' group{at} has {len(kids_raw)}")
|
| 3460 |
+
kids = []
|
| 3461 |
+
for child in kids_raw:
|
| 3462 |
+
c, err = clean_cond(child, depth + 1, where)
|
| 3463 |
+
if err:
|
| 3464 |
+
return None, err
|
| 3465 |
+
if c is None:
|
| 3466 |
+
return None, (f"an empty condition sits inside the '{key}' group{at} β "
|
| 3467 |
+
f"finish it or remove it")
|
| 3468 |
+
kids.append(c)
|
| 3469 |
+
return {key: kids}, None
|
| 3470 |
+
field = _s(raw.get("field"), 80).strip()
|
| 3471 |
+
op = _s(raw.get("op") or raw.get("operator"), 20).strip()
|
| 3472 |
+
if not field:
|
| 3473 |
+
return None, f"the condition{at} names no field"
|
| 3474 |
+
if op not in LANE_OPS:
|
| 3475 |
+
return None, f"{op or 'that comparison'!r} is not one of: " + ", ".join(LANE_OPS)
|
| 3476 |
+
cond = {"field": field, "op": op}
|
| 3477 |
+
if op not in LANE_NULLARY_OPS:
|
| 3478 |
+
v = raw.get("value")
|
| 3479 |
+
if v is None or (isinstance(v, str) and not v.strip()):
|
| 3480 |
+
return None, f"give a value to compare {field!r} against{at}"
|
| 3481 |
+
cond["value"] = v.strip() if isinstance(v, str) else v
|
| 3482 |
+
return cond, None
|
| 3483 |
+
|
| 3484 |
+
|
| 3485 |
+
def cond_fields(cond):
|
| 3486 |
+
"""Every field name a tree reads β the set a caller must have on the row image before the
|
| 3487 |
+
answer means anything."""
|
| 3488 |
+
if not isinstance(cond, dict):
|
| 3489 |
+
return set()
|
| 3490 |
+
for key in COND_GROUP_KEYS:
|
| 3491 |
+
if key in cond:
|
| 3492 |
+
out = set()
|
| 3493 |
+
for child in cond.get(key) or []:
|
| 3494 |
+
out |= cond_fields(child)
|
| 3495 |
+
return out
|
| 3496 |
+
f = cond.get("field")
|
| 3497 |
+
return {f} if f else set()
|
| 3498 |
#: Lane labels a flow already uses for its fixed stages. A lane literally called "Review" would
|
| 3499 |
#: collide with the stage the label is a choice FOR, and the board could no longer tell a
|
| 3500 |
#: routed card from a gated one.
|
|
|
|
| 3535 |
lid = f"{base}_{n}"
|
| 3536 |
n += 1
|
| 3537 |
seen_ids.add(lid)
|
| 3538 |
+
cond, cerr = clean_cond(entry.get("when"), where=repr(label))
|
| 3539 |
+
if cerr:
|
| 3540 |
+
return None, cerr
|
| 3541 |
+
if cond is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3542 |
defaults += 1
|
| 3543 |
if defaults > 1:
|
| 3544 |
return None, ("only one lane may have no condition β it is the catch-all, "
|
|
|
|
| 3555 |
|
| 3556 |
|
| 3557 |
def lane_match(cond, row):
|
| 3558 |
+
"""Does `row` satisfy this condition TREE? None matches everything (the catch-all).
|
| 3559 |
+
|
| 3560 |
+
Named for the lane that first needed it; it is now C4's whole evaluator, and every wave-22
|
| 3561 |
+
caller (`route_record`, `_settle_eval`, `_seed_event_state`) gained tree support by keeping
|
| 3562 |
+
this name rather than growing a second entry point that could disagree with it.
|
| 3563 |
|
| 3564 |
β REFUSE-NEVER-COERCE, per record: an ordering comparison whose either side does not parse
|
| 3565 |
as a number answers False β the record simply does not enter the lane β never "treat blank
|
|
|
|
| 3568 |
"""
|
| 3569 |
if cond is None:
|
| 3570 |
return True
|
| 3571 |
+
if isinstance(cond, dict):
|
| 3572 |
+
if "all" in cond:
|
| 3573 |
+
return all(lane_match(c, row) for c in cond.get("all") or [])
|
| 3574 |
+
if "any" in cond:
|
| 3575 |
+
return any(lane_match(c, row) for c in cond.get("any") or [])
|
| 3576 |
raw = (row or {}).get(cond.get("field"))
|
| 3577 |
op = cond.get("op")
|
| 3578 |
if op == "is_empty":
|
|
|
|
| 3608 |
return ""
|
| 3609 |
|
| 3610 |
|
| 3611 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3612 |
+
# WAVE 23 Β· C4 β ACTIONS (owner ruling R3). The half of the Airtable builder that DOES things.
|
| 3613 |
+
#
|
| 3614 |
+
# `flow.actions` is an ordered list walked per RECORD, after the flow's machine steps have run.
|
| 3615 |
+
# A `group` holds nested actions behind a condition β Airtable's "conditional action group",
|
| 3616 |
+
# which the owner explicitly asked to be NESTABLE (R3 supersedes the wave-22 research brief's
|
| 3617 |
+
# rec-8 "no nesting" verdict; that recommendation was about keeping a CANVAS legible, and this
|
| 3618 |
+
# builder is a column, not a canvas).
|
| 3619 |
+
#
|
| 3620 |
+
# β THE CATALOG IS SERVER-OWNED AND INCLUDES WHAT WE HAVE NOT BUILT. `ACTION_CATALOG` carries a
|
| 3621 |
+
# `ready` flag per kind, so the "+ Add advanced logic or action" menu paints Send email / Slack /
|
| 3622 |
+
# Run script / Generate with AI faded with a reason instead of omitting them β the same honesty
|
| 3623 |
+
# rule the trigger list follows, and the same enforcement: `clean_actions` REFUSES an unready
|
| 3624 |
+
# kind with a sentence, so the faded state is a wall and not a styling choice.
|
| 3625 |
+
MAX_ACTIONS = 20 # per automation, counting nested ones
|
| 3626 |
+
MAX_GROUP_DEPTH = 2 # a group inside a group inside a group is a flowchart, not a flow
|
| 3627 |
+
MAX_ACTION_VALUES = 20 # cells one update/create action may write
|
| 3628 |
+
FIND_LIMIT_MAX = 100
|
| 3629 |
+
ACTION_KINDS = ("group", "update_record", "create_record", "find_records", "review")
|
| 3630 |
+
ACTION_CATALOG = [
|
| 3631 |
+
{"kind": "group", "label": "Conditional logic", "group": "Advanced logic", "ready": True,
|
| 3632 |
+
"detail": "Run different actions based off a certain condition"},
|
| 3633 |
+
{"kind": "update_record", "label": "Update record", "group": "Actions", "ready": True,
|
| 3634 |
+
"detail": "Write values onto the record walking the flow"},
|
| 3635 |
+
{"kind": "create_record", "label": "Create record", "group": "Actions", "ready": True,
|
| 3636 |
+
"detail": "Add a row to another database"},
|
| 3637 |
+
{"kind": "find_records", "label": "Find records", "group": "Actions", "ready": True,
|
| 3638 |
+
"detail": "Look rows up by condition; the run log opens them"},
|
| 3639 |
+
{"kind": "review", "label": "Manual review", "group": "Advanced logic", "ready": True,
|
| 3640 |
+
"detail": "Hold the record for a person β or an AI β to decide where it goes next"},
|
| 3641 |
+
{"kind": "repeating_group", "label": "Repeating group", "group": "Advanced logic",
|
| 3642 |
+
"ready": False, "detail": "Run the same actions on every item in a list"},
|
| 3643 |
+
{"kind": "send_email", "label": "Send email", "group": "Actions", "ready": False,
|
| 3644 |
+
"detail": "Needs a send scope on the Gmail connection"},
|
| 3645 |
+
{"kind": "slack", "label": "Send Slack message", "group": "Actions", "ready": False,
|
| 3646 |
+
"detail": "Needs the Slack connector"},
|
| 3647 |
+
{"kind": "run_script", "label": "Run script", "group": "Actions", "ready": False,
|
| 3648 |
+
"detail": "Not built β a sandbox is its own decision"},
|
| 3649 |
+
{"kind": "generate_ai", "label": "Generate with AI", "group": "Actions", "ready": False,
|
| 3650 |
+
"detail": "Review stages can already be decided by AI"},
|
| 3651 |
+
]
|
| 3652 |
+
REVIEW_DECIDERS = ("user", "ai")
|
| 3653 |
+
MAX_AI_PROMPT = 600
|
| 3654 |
+
|
| 3655 |
+
|
| 3656 |
+
def action_catalog():
|
| 3657 |
+
"""The catalog as the wire carries it β a copy, because a caller that mutated the module
|
| 3658 |
+
constant would change every later reader's answer."""
|
| 3659 |
+
return [dict(a) for a in ACTION_CATALOG]
|
| 3660 |
+
|
| 3661 |
+
|
| 3662 |
+
def _action_label(act):
|
| 3663 |
+
for row in ACTION_CATALOG:
|
| 3664 |
+
if row["kind"] == act.get("kind"):
|
| 3665 |
+
return row["label"]
|
| 3666 |
+
return str(act.get("kind") or "Action")
|
| 3667 |
+
|
| 3668 |
+
|
| 3669 |
+
def clean_actions(raw, depth=0, _seen=None, _count=None):
|
| 3670 |
+
"""Validate `flow.actions`. Returns `(actions, error)` β refuses, never coerces.
|
| 3671 |
+
|
| 3672 |
+
Ids are STABLE: a caller's well-formed `id` is kept, so selecting an action in the builder
|
| 3673 |
+
survives a Save. A missing or colliding one is minted `act_<n>`; minting on every clean would
|
| 3674 |
+
move the selection under the person editing it.
|
| 3675 |
+
"""
|
| 3676 |
+
if raw in (None, ""):
|
| 3677 |
+
return [], None
|
| 3678 |
+
if not isinstance(raw, list):
|
| 3679 |
+
return None, "actions must be a list"
|
| 3680 |
+
seen = _seen if _seen is not None else set()
|
| 3681 |
+
count = _count if _count is not None else [0]
|
| 3682 |
+
out = []
|
| 3683 |
+
for entry in raw:
|
| 3684 |
+
if not isinstance(entry, dict):
|
| 3685 |
+
return None, "each action must be an object with a kind"
|
| 3686 |
+
kind = _s(entry.get("kind"), 30).strip()
|
| 3687 |
+
row = next((r for r in ACTION_CATALOG if r["kind"] == kind), None)
|
| 3688 |
+
if row is None:
|
| 3689 |
+
return None, (f"{kind or 'that action'!r} is not one of: "
|
| 3690 |
+
+ ", ".join(a["kind"] for a in ACTION_CATALOG))
|
| 3691 |
+
if not row["ready"]:
|
| 3692 |
+
return None, (f"{row['label']!r} is on the menu but not built yet β "
|
| 3693 |
+
f"{row['detail'][0].lower()}{row['detail'][1:]}")
|
| 3694 |
+
count[0] += 1
|
| 3695 |
+
if count[0] > MAX_ACTIONS:
|
| 3696 |
+
return None, f"an automation runs at most {MAX_ACTIONS} actions"
|
| 3697 |
+
aid = _s(entry.get("id"), 40).strip()
|
| 3698 |
+
if not re.fullmatch(r"act_[a-z0-9_]{1,32}", aid or "") or aid in seen:
|
| 3699 |
+
n = 1
|
| 3700 |
+
while f"act_{n}" in seen:
|
| 3701 |
+
n += 1
|
| 3702 |
+
aid = f"act_{n}"
|
| 3703 |
+
seen.add(aid)
|
| 3704 |
+
when, cerr = clean_cond(entry.get("when"), where=f"action {aid}")
|
| 3705 |
+
if cerr:
|
| 3706 |
+
return None, cerr
|
| 3707 |
+
cfg_raw = entry.get("config") if isinstance(entry.get("config"), dict) else {}
|
| 3708 |
+
cfg, cerr = _clean_action_config(kind, cfg_raw, depth, seen, count)
|
| 3709 |
+
if cerr:
|
| 3710 |
+
return None, cerr
|
| 3711 |
+
out.append({"id": aid, "kind": kind,
|
| 3712 |
+
"enabled": bool(entry.get("enabled", True)),
|
| 3713 |
+
"when": when, "config": cfg})
|
| 3714 |
+
return out, None
|
| 3715 |
+
|
| 3716 |
+
|
| 3717 |
+
def _clean_action_config(kind, cfg, depth, seen, count):
|
| 3718 |
+
"""One action's `config`, per kind. Returns `(config, error)`."""
|
| 3719 |
+
if kind == "group":
|
| 3720 |
+
if depth + 1 > MAX_GROUP_DEPTH:
|
| 3721 |
+
return None, (f"conditional groups nest at most {MAX_GROUP_DEPTH} deep β "
|
| 3722 |
+
f"past that a flow is a flowchart and belongs on the board")
|
| 3723 |
+
cond, cerr = clean_cond(cfg.get("cond"), where="the group")
|
| 3724 |
+
if cerr:
|
| 3725 |
+
return None, cerr
|
| 3726 |
+
kids, kerr = clean_actions(cfg.get("actions"), depth + 1, seen, count)
|
| 3727 |
+
if kerr:
|
| 3728 |
+
return None, kerr
|
| 3729 |
+
if not kids:
|
| 3730 |
+
return None, "a conditional group with no actions inside it does nothing"
|
| 3731 |
+
return {"cond": cond, "actions": kids}, None
|
| 3732 |
+
if kind in ("update_record", "create_record"):
|
| 3733 |
+
values = cfg.get("values")
|
| 3734 |
+
if not isinstance(values, dict) or not values:
|
| 3735 |
+
return None, f"the {kind.replace('_', ' ')} action writes no values"
|
| 3736 |
+
if len(values) > MAX_ACTION_VALUES:
|
| 3737 |
+
return None, f"one action writes at most {MAX_ACTION_VALUES} cells"
|
| 3738 |
+
clean_vals = {}
|
| 3739 |
+
for k, v in values.items():
|
| 3740 |
+
key = _s(k, 80).strip()
|
| 3741 |
+
if not key:
|
| 3742 |
+
return None, "a value is written to a field with no name"
|
| 3743 |
+
if v is not None and not isinstance(v, (str, int, float, bool)):
|
| 3744 |
+
return None, (f"the value for {key!r} must be text or a number β "
|
| 3745 |
+
f"an automation writes cells, not objects")
|
| 3746 |
+
clean_vals[key] = _s(v, 500) if isinstance(v, str) else v
|
| 3747 |
+
out = {"values": clean_vals}
|
| 3748 |
+
if kind == "create_record":
|
| 3749 |
+
table = _s(cfg.get("table"), 60).strip()
|
| 3750 |
+
if not table:
|
| 3751 |
+
return None, "the create record action names no database"
|
| 3752 |
+
if not table.startswith(UT_PREFIX):
|
| 3753 |
+
return None, (f"actions write to blank databases (ut_*) this wave β "
|
| 3754 |
+
f"{table!r} is not one")
|
| 3755 |
+
out["table"] = table
|
| 3756 |
+
return out, None
|
| 3757 |
+
if kind == "find_records":
|
| 3758 |
+
table = _s(cfg.get("table"), 60).strip()
|
| 3759 |
+
if not table:
|
| 3760 |
+
return None, "the find records action names no database"
|
| 3761 |
+
cond, cerr = clean_cond(cfg.get("cond"), where="the find")
|
| 3762 |
+
if cerr:
|
| 3763 |
+
return None, cerr
|
| 3764 |
+
try:
|
| 3765 |
+
limit = int(cfg.get("limit") or 25)
|
| 3766 |
+
except (TypeError, ValueError):
|
| 3767 |
+
return None, "the find records limit must be a whole number"
|
| 3768 |
+
if limit < 1 or limit > FIND_LIMIT_MAX:
|
| 3769 |
+
return None, f"the find records limit is between 1 and {FIND_LIMIT_MAX}"
|
| 3770 |
+
return {"table": table, "cond": cond, "limit": limit}, None
|
| 3771 |
+
if kind == "review":
|
| 3772 |
+
by = _s(cfg.get("decidedBy"), 10).strip() or "user"
|
| 3773 |
+
if by not in REVIEW_DECIDERS:
|
| 3774 |
+
return None, f"a review is decided by one of: {', '.join(REVIEW_DECIDERS)}"
|
| 3775 |
+
label = " ".join(_s(cfg.get("label"), 60).split()) or "Review"
|
| 3776 |
+
if label.strip().lower() in _RESERVED_STAGE_LABELS and label != "Review":
|
| 3777 |
+
return None, f"{label!r} is the name of a fixed stage β name the review something else"
|
| 3778 |
+
nxt = cfg.get("next")
|
| 3779 |
+
outs = []
|
| 3780 |
+
if isinstance(nxt, list):
|
| 3781 |
+
for entry in nxt[:MAX_LANES]:
|
| 3782 |
+
lbl = " ".join(_s(entry, 60).split())
|
| 3783 |
+
if lbl and lbl not in outs:
|
| 3784 |
+
outs.append(lbl)
|
| 3785 |
+
if not outs:
|
| 3786 |
+
outs = ["Approved", "Declined"]
|
| 3787 |
+
prompt = _s(cfg.get("prompt"), MAX_AI_PROMPT).strip()
|
| 3788 |
+
if by == "ai" and not prompt:
|
| 3789 |
+
return None, ("an AI review needs a prompt β tell it what to decide, or set the "
|
| 3790 |
+
"review back to a person")
|
| 3791 |
+
return {"decidedBy": by, "label": label, "next": outs, "prompt": prompt}, None
|
| 3792 |
+
return {}, None
|
| 3793 |
+
|
| 3794 |
+
|
| 3795 |
+
def walk_actions(actions):
|
| 3796 |
+
"""Every action in the tree, depth-first, groups included. One walker, so "how many actions
|
| 3797 |
+
does this flow have" and "which review stages exist" cannot answer differently."""
|
| 3798 |
+
for act in actions or []:
|
| 3799 |
+
yield act
|
| 3800 |
+
if act.get("kind") == "group":
|
| 3801 |
+
for kid in walk_actions((act.get("config") or {}).get("actions")):
|
| 3802 |
+
yield kid
|
| 3803 |
+
|
| 3804 |
+
|
| 3805 |
+
def review_actions(defn):
|
| 3806 |
+
"""The review actions of one definition, in flow order β the stages a card can REST at."""
|
| 3807 |
+
flow = (defn or {}).get("flow") or {}
|
| 3808 |
+
return [a for a in walk_actions(flow.get("actions")) if a.get("kind") == "review"
|
| 3809 |
+
and a.get("enabled", True)]
|
| 3810 |
+
|
| 3811 |
+
|
| 3812 |
+
def interpolate(text, row):
|
| 3813 |
+
"""`{{field_key}}` β the record's value. Unknown keys resolve to EMPTY, deliberately: an
|
| 3814 |
+
action that wrote the literal `{{status}}` into a cell because somebody typo'd the key would
|
| 3815 |
+
put template syntax in front of a customer, and a blank is the visible failure."""
|
| 3816 |
+
if not isinstance(text, str) or "{{" not in text:
|
| 3817 |
+
return text
|
| 3818 |
+
def _sub(m):
|
| 3819 |
+
v = (row or {}).get(m.group(1).strip())
|
| 3820 |
+
return "" if v is None else str(v)
|
| 3821 |
+
return re.sub(r"\{\{([^}]{1,80})\}\}", _sub, text)
|
| 3822 |
+
|
| 3823 |
+
|
| 3824 |
def stages_for(defn):
|
| 3825 |
"""One automation β `(stages, routes)` β C1's vocabulary, derived from `graph()` so the
|
| 3826 |
board can never disagree with the runner about what the flow does.
|
|
|
|
| 3898 |
routes.append({"from": "write", "to": lane["id"],
|
| 3899 |
"label": lane["label"] if cond is None else _lane_sentence(cond),
|
| 3900 |
"condition": dict(cond) if cond else None})
|
| 3901 |
+
|
| 3902 |
+
# ββ WAVE 23 Β· C4/C6 β REVIEW ACTIONS ARE STAGES. βββββββββββββββββββββββββββββββββββββββββ
|
| 3903 |
+
# A review action is the one action kind that HOLDS a record rather than doing something to
|
| 3904 |
+
# it, so it has to exist on the board: a flow whose review lived only in the builder would
|
| 3905 |
+
# gate records at a place the kanban cannot show, which is the whole complaint the board was
|
| 3906 |
+
# built to answer. Its declared exits become assignable stages too β a gate with nowhere to
|
| 3907 |
+
# go is a hole records fall into.
|
| 3908 |
+
tail = max((s["col"] for s in stages), default=0)
|
| 3909 |
+
for i, act in enumerate(review_actions(defn)):
|
| 3910 |
+
acfg = act.get("config") or {}
|
| 3911 |
+
rid = act["id"]
|
| 3912 |
+
stages.append({"id": rid, "label": acfg.get("label") or "Review", "kind": "review",
|
| 3913 |
+
"col": tail + 1 + (i * 2), "row": 0, "enabled": True, "toggle": "",
|
| 3914 |
+
"status": "idle", "panel": "review_action", "posture": "strict",
|
| 3915 |
+
"record": True, "assignable": True, "actionId": rid,
|
| 3916 |
+
"decidedBy": acfg.get("decidedBy") or "user",
|
| 3917 |
+
"detail": ("An AI decides where each card goes β a person can override it"
|
| 3918 |
+
if acfg.get("decidedBy") == "ai"
|
| 3919 |
+
else "Cards wait here for a person to move them on")})
|
| 3920 |
+
for j, label in enumerate(acfg.get("next") or []):
|
| 3921 |
+
oid = f"{rid}_out_{j}"
|
| 3922 |
+
stages.append({"id": oid, "label": label, "kind": "action",
|
| 3923 |
+
"col": tail + 2 + (i * 2), "row": j, "enabled": True, "toggle": "",
|
| 3924 |
+
"status": "idle", "panel": "review_action", "posture": "auto",
|
| 3925 |
+
"record": True, "assignable": True, "actionId": rid,
|
| 3926 |
+
"detail": f"Where {acfg.get('label') or 'Review'} sends a card"})
|
| 3927 |
+
routes.append({"from": rid, "to": oid, "label": label, "condition": None})
|
| 3928 |
return stages, routes
|
| 3929 |
|
| 3930 |
|
| 3931 |
+
def terminal_stage_ids(stages, routes):
|
| 3932 |
+
"""Stages a record can REST at with nowhere further to go β the ends C5's `ending` acts on.
|
| 3933 |
+
Derived from the routes rather than declared, so a flow cannot gain an exit without this
|
| 3934 |
+
noticing (and a lane that stops being terminal stops being reset)."""
|
| 3935 |
+
assignable = {s["id"] for s in stages if s.get("assignable")}
|
| 3936 |
+
onward = {r["from"] for r in routes if r["to"] in assignable and r["to"] != r["from"]}
|
| 3937 |
+
return [sid for sid in assignable if sid not in onward]
|
| 3938 |
+
|
| 3939 |
+
|
| 3940 |
+
def _stage_of(stages, sid):
|
| 3941 |
+
for s in stages:
|
| 3942 |
+
if s["id"] == sid:
|
| 3943 |
+
return s
|
| 3944 |
+
return None
|
| 3945 |
+
|
| 3946 |
+
|
| 3947 |
+
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 3948 |
+
# WAVE 23 Β· C4/C5/C6 β THE ACTION RUNNER. One record at a time, actions in declared order.
|
| 3949 |
+
#
|
| 3950 |
+
# β ONE COALESCED WRITE PER TABLE PER RUN. The runner accumulates patches in memory and commits
|
| 3951 |
+
# them once at the end (the 256-commits/hr budget every writer in this module respects). A
|
| 3952 |
+
# per-action `rt.update` would be correct and would also spend the tenant's whole commit budget
|
| 3953 |
+
# on one busy flow.
|
| 3954 |
+
# β A REVIEW SUSPENDS THAT RECORD AND NOTHING ELSE. When a card reaches a review action the
|
| 3955 |
+
# runner stamps its stage and stops walking THAT record β later actions belong to the branch a
|
| 3956 |
+
# human (or the AI) has not chosen yet. Other records keep going; a gate is not a global pause.
|
| 3957 |
+
# β EVERY WRITE HERE IS MACHINE-ORIGIN and goes through this module's own writers, never the
|
| 3958 |
+
# human doors β which is exactly what keeps A2(2)'s loop prevention structural: an action's write
|
| 3959 |
+
# cannot fire an event trigger, its own or a sibling's.
|
| 3960 |
+
|
| 3961 |
+
def _act_row_patch(patches, table, rid, values):
|
| 3962 |
+
patches.setdefault(table, {}).setdefault(str(rid), {}).update(values)
|
| 3963 |
+
|
| 3964 |
+
|
| 3965 |
+
def _flow_table(defn):
|
| 3966 |
+
"""The database a flow's actions and ending operate on: the automation's own target, or the
|
| 3967 |
+
table its event trigger watches when the flow has no target of its own."""
|
| 3968 |
+
cfg = (defn or {}).get("config") or {}
|
| 3969 |
+
if defn.get("kind") == "discover_instagram":
|
| 3970 |
+
return cfg.get("targetTable") or DISCOVER_TABLE
|
| 3971 |
+
return cfg.get("targetTable") or ((defn.get("trigger") or {}).get("table") or "")
|
| 3972 |
+
|
| 3973 |
+
|
| 3974 |
+
def apply_actions(rt, defn, table_key, row_ids, username="automation", log=print):
|
| 3975 |
+
"""Walk `flow.actions` for each of `row_ids`, then apply the ENDING. Returns the run counts.
|
| 3976 |
+
|
| 3977 |
+
β The ending runs even when there are NO actions, and that is not a detail: a discovery
|
| 3978 |
+
automation's terminal stages (`tracked`/`declined`) come from `stages_for`, not from actions,
|
| 3979 |
+
so an early return on an empty action list would have silently disabled the owner's loop
|
| 3980 |
+
feature on the flagship flow β the one kind most likely to want `auto_reset`. Caught in
|
| 3981 |
+
review; the tests all happened to pass a non-empty action list, which is why it was green.
|
| 3982 |
+
"""
|
| 3983 |
+
flow = (defn or {}).get("flow") or {}
|
| 3984 |
+
actions = flow.get("actions") or []
|
| 3985 |
+
counts = {"actionsRun": 0, "updated": 0, "created": 0, "found": 0, "review": 0,
|
| 3986 |
+
"aiDecided": 0, "cycles": 0}
|
| 3987 |
+
if not table_key:
|
| 3988 |
+
return counts
|
| 3989 |
+
table = str(table_key)
|
| 3990 |
+
rows = dict((ut_get(rt, table) or {}).get("rows") or {})
|
| 3991 |
+
if not rows:
|
| 3992 |
+
return counts
|
| 3993 |
+
stages, routes = stages_for(defn)
|
| 3994 |
+
skey = stage_field_key(defn.get("id"))
|
| 3995 |
+
stamp = _iso()
|
| 3996 |
+
patches, creates, ai_budget = {}, {}, [AI_DECISIONS_PER_RUN]
|
| 3997 |
+
|
| 3998 |
+
def _walk(acts, row, rid, depth=0):
|
| 3999 |
+
"""Returns True when the record was SUSPENDED (a review holds it)."""
|
| 4000 |
+
for act in acts:
|
| 4001 |
+
if not act.get("enabled", True):
|
| 4002 |
+
continue
|
| 4003 |
+
if not lane_match(act.get("when"), row):
|
| 4004 |
+
continue
|
| 4005 |
+
kind = act.get("kind")
|
| 4006 |
+
cfg = act.get("config") or {}
|
| 4007 |
+
counts["actionsRun"] += 1
|
| 4008 |
+
if kind == "group":
|
| 4009 |
+
if not lane_match(cfg.get("cond"), row):
|
| 4010 |
+
continue
|
| 4011 |
+
if _walk(cfg.get("actions") or [], row, rid, depth + 1):
|
| 4012 |
+
return True
|
| 4013 |
+
elif kind == "update_record":
|
| 4014 |
+
vals = {k: interpolate(v, row) for k, v in (cfg.get("values") or {}).items()}
|
| 4015 |
+
row.update(vals) # later actions see the write, as they must
|
| 4016 |
+
_act_row_patch(patches, table, rid, vals)
|
| 4017 |
+
counts["updated"] += 1
|
| 4018 |
+
elif kind == "create_record":
|
| 4019 |
+
target = str(cfg.get("table") or "")
|
| 4020 |
+
vals = {k: interpolate(v, row) for k, v in (cfg.get("values") or {}).items()}
|
| 4021 |
+
creates.setdefault(target, []).append(vals)
|
| 4022 |
+
counts["created"] += 1
|
| 4023 |
+
elif kind == "find_records":
|
| 4024 |
+
found = find_records(rt, cfg.get("table"), cfg.get("cond"),
|
| 4025 |
+
int(cfg.get("limit") or 25))
|
| 4026 |
+
counts["found"] += len(found)
|
| 4027 |
+
elif kind == "review":
|
| 4028 |
+
label = cfg.get("label") or "Review"
|
| 4029 |
+
at = str(row.get(skey) or "").strip()
|
| 4030 |
+
if at == label:
|
| 4031 |
+
return True # already waiting here β do not re-stamp
|
| 4032 |
+
if at and at in (cfg.get("next") or []):
|
| 4033 |
+
# β ALREADY DECIDED β the card went through this gate on an earlier run, so
|
| 4034 |
+
# the flow continues past it. Without this the next run would stamp a
|
| 4035 |
+
# finished card back to the review gate and hold it there again, forever:
|
| 4036 |
+
# a card could never leave a flow that runs on a schedule. Found by this
|
| 4037 |
+
# wave's own terminal-ending negative control, which is the argument for
|
| 4038 |
+
# writing the NC in the same change as the feature.
|
| 4039 |
+
continue
|
| 4040 |
+
decided = ""
|
| 4041 |
+
if cfg.get("decidedBy") == "ai" and ai_budget[0] > 0:
|
| 4042 |
+
ai_budget[0] -= 1
|
| 4043 |
+
decided = ai_decide(rt, defn, act, row, rid, log=log)
|
| 4044 |
+
if decided:
|
| 4045 |
+
counts["aiDecided"] += 1
|
| 4046 |
+
_act_row_patch(patches, table, rid,
|
| 4047 |
+
{skey: decided, skey + "_at": stamp})
|
| 4048 |
+
row[skey] = decided
|
| 4049 |
+
continue # the AI moved it on β keep walking
|
| 4050 |
+
_act_row_patch(patches, table, rid, {skey: label, skey + "_at": stamp})
|
| 4051 |
+
row[skey] = label
|
| 4052 |
+
counts["review"] += 1
|
| 4053 |
+
return True
|
| 4054 |
+
return False
|
| 4055 |
+
|
| 4056 |
+
held = []
|
| 4057 |
+
if actions:
|
| 4058 |
+
for rid in list(row_ids or [])[:FLOOD_LIMIT]:
|
| 4059 |
+
row = dict(rows.get(str(rid)) or {})
|
| 4060 |
+
if not row:
|
| 4061 |
+
continue
|
| 4062 |
+
if _walk(actions, row, str(rid)):
|
| 4063 |
+
held.append(str(rid))
|
| 4064 |
+
counts["cycles"] = _apply_ending(rt, defn, table, rows, patches, stages, routes, stamp)
|
| 4065 |
+
_commit_action_writes(rt, table, patches, creates, username, log)
|
| 4066 |
+
if held:
|
| 4067 |
+
notify_review(rt, defn, held, log=log)
|
| 4068 |
+
return counts
|
| 4069 |
+
|
| 4070 |
+
|
| 4071 |
+
#: C6 cap: AI decisions ONE run may make. A flow that suddenly matches 500 records must not turn
|
| 4072 |
+
#: into 500 paid calls before anybody notices β the rest simply wait for a human, which is the
|
| 4073 |
+
#: behaviour the feature degrades to everywhere else too.
|
| 4074 |
+
AI_DECISIONS_PER_RUN = 25
|
| 4075 |
+
|
| 4076 |
+
|
| 4077 |
+
def ai_decide(rt, defn, act, row, row_id="", log=print):
|
| 4078 |
+
"""R4/C6: let the model pick this card's next stage. Returns the chosen stage LABEL, or ""
|
| 4079 |
+
to leave the card for a person.
|
| 4080 |
+
|
| 4081 |
+
β FAIL-CLOSED IN EVERY DIRECTION: no provider configured, a network failure, a malformed
|
| 4082 |
+
answer, or a label the review does not offer all return "" β and "" means the card sits at
|
| 4083 |
+
the review gate exactly as it would with no AI at all. The feature can be broken, absent or
|
| 4084 |
+
wrong and the worst outcome is a human doing the work.
|
| 4085 |
+
"""
|
| 4086 |
+
cfg = act.get("config") or {}
|
| 4087 |
+
options = list(cfg.get("next") or [])
|
| 4088 |
+
if not options:
|
| 4089 |
+
return ""
|
| 4090 |
+
try:
|
| 4091 |
+
import ai_review
|
| 4092 |
+
except Exception as e: # noqa: BLE001
|
| 4093 |
+
log(f"[aios-auto] ai review unavailable: {type(e).__name__}: {e}")
|
| 4094 |
+
return ""
|
| 4095 |
+
fields = [f.get("key") for f in
|
| 4096 |
+
(ut_get(rt, ((defn.get("config") or {}).get("targetTable")
|
| 4097 |
+
or (defn.get("trigger") or {}).get("table") or "")) or {}
|
| 4098 |
+
).get("fields") or []]
|
| 4099 |
+
choice, meta = ai_review.decide(prompt=cfg.get("prompt") or "", options=options,
|
| 4100 |
+
row=row, fields=[f for f in fields if f],
|
| 4101 |
+
label=cfg.get("label") or "Review")
|
| 4102 |
+
if not choice:
|
| 4103 |
+
if meta.get("problem"):
|
| 4104 |
+
log(f"[aios-auto] ai review declined to answer: {meta['problem']}")
|
| 4105 |
+
return ""
|
| 4106 |
+
review_audit(rt, defn.get("id"), row_id, cfg.get("label") or "Review",
|
| 4107 |
+
choice, meta.get("provider") or "ai", by="ai",
|
| 4108 |
+
note=meta.get("reason") or "", model=meta.get("model") or "")
|
| 4109 |
+
return choice
|
| 4110 |
+
|
| 4111 |
+
|
| 4112 |
+
def notify_review(rt, defn, row_ids, log=print):
|
| 4113 |
+
"""C6: one notification per RUN naming the count β never one per record.
|
| 4114 |
+
|
| 4115 |
+
A discovery pass that gates 300 candidates would otherwise put 300 rows in somebody's inbox
|
| 4116 |
+
and the inbox would stop being read, which costs more than the notification gains. The board
|
| 4117 |
+
is where the individual cards live; the bell says "there is work", once.
|
| 4118 |
+
"""
|
| 4119 |
+
if not row_ids:
|
| 4120 |
+
return
|
| 4121 |
+
owner = str((defn or {}).get("createdBy") or "").strip()
|
| 4122 |
+
if not owner:
|
| 4123 |
+
return
|
| 4124 |
+
n = len(row_ids)
|
| 4125 |
+
try:
|
| 4126 |
+
import core.alerts as alerts
|
| 4127 |
+
alerts.notify(owner,
|
| 4128 |
+
f"{defn.get('name') or 'An automation'} β {n} "
|
| 4129 |
+
f"record{'' if n == 1 else 's'} waiting for review",
|
| 4130 |
+
topic="automation", key=str(defn.get("id") or ""), st=rt)
|
| 4131 |
+
except Exception as e: # noqa: BLE001
|
| 4132 |
+
log(f"[aios-auto] review notification failed: {type(e).__name__}: {e}")
|
| 4133 |
+
|
| 4134 |
+
|
| 4135 |
+
def find_records(rt, table_key, cond, limit=25):
|
| 4136 |
+
"""The `find_records` action's read: matching row ids, bounded and DISCLOSED (the caller puts
|
| 4137 |
+
the count in the run log, where it drills β [[no-unverifiable-aggregates]])."""
|
| 4138 |
+
rows = (ut_get(rt, str(table_key or "")) or {}).get("rows") or {}
|
| 4139 |
+
out = []
|
| 4140 |
+
for rid, row in rows.items():
|
| 4141 |
+
if lane_match(cond, row or {}):
|
| 4142 |
+
out.append(str(rid))
|
| 4143 |
+
if len(out) >= max(1, min(int(limit or 25), FIND_LIMIT_MAX)):
|
| 4144 |
+
break
|
| 4145 |
+
return out
|
| 4146 |
+
|
| 4147 |
+
|
| 4148 |
+
def _apply_ending(rt, defn, table, rows, patches, stages, routes, stamp):
|
| 4149 |
+
"""C5: send terminal cards back round, per the ending mode. Returns cycles started.
|
| 4150 |
+
|
| 4151 |
+
A RESET CLEARS THE STAMP rather than writing a start stage, so the card falls back to the
|
| 4152 |
+
DERIVED bucket `_card_stage` would give a record that had never been stamped at all β which
|
| 4153 |
+
is what "back to the first cycle" means, and it needs no opinion about which stage is first.
|
| 4154 |
+
"""
|
| 4155 |
+
ending = ((defn or {}).get("flow") or {}).get("ending") or {}
|
| 4156 |
+
mode = ending.get("mode") or "terminal"
|
| 4157 |
+
if mode in ("terminal", "manual_reset"):
|
| 4158 |
+
return 0
|
| 4159 |
+
terminals = set(terminal_stage_ids(stages, routes))
|
| 4160 |
+
if not terminals:
|
| 4161 |
+
return 0
|
| 4162 |
+
labels = {s["label"] for s in stages if s["id"] in terminals}
|
| 4163 |
+
skey = stage_field_key(defn.get("id"))
|
| 4164 |
+
cutoff = None
|
| 4165 |
+
if mode == "timed_reset":
|
| 4166 |
+
cutoff = _now() - _dt.timedelta(hours=int(ending.get("hours") or 24))
|
| 4167 |
+
started = 0
|
| 4168 |
+
for rid, row in rows.items():
|
| 4169 |
+
merged = {**(row or {}), **((patches.get(table) or {}).get(str(rid)) or {})}
|
| 4170 |
+
if str(merged.get(skey) or "").strip() not in labels:
|
| 4171 |
+
continue
|
| 4172 |
+
if cutoff is not None:
|
| 4173 |
+
at = _parse_iso(str(merged.get(skey + "_at") or ""))
|
| 4174 |
+
if at is None or at > cutoff:
|
| 4175 |
+
continue
|
| 4176 |
+
n = (_ig_int(merged.get(cycles_field_key(defn.get("id")))) or 0) + 1
|
| 4177 |
+
_act_row_patch(patches, table, rid,
|
| 4178 |
+
{skey: "", skey + "_at": stamp,
|
| 4179 |
+
cycles_field_key(defn.get("id")): n})
|
| 4180 |
+
started += 1
|
| 4181 |
+
return started
|
| 4182 |
+
|
| 4183 |
+
|
| 4184 |
+
def _commit_action_writes(rt, table, patches, creates, username, log):
|
| 4185 |
+
"""The ONE write. Row patches merge into the target's rows; creates append to their own
|
| 4186 |
+
tables through the ordinary cap-respecting path."""
|
| 4187 |
+
if not patches and not creates:
|
| 4188 |
+
return
|
| 4189 |
+
for tkey, rowpatch in (patches or {}).items():
|
| 4190 |
+
cur = dict((ut_get(rt, tkey) or {}).get("rows") or {})
|
| 4191 |
+
for rid, vals in rowpatch.items():
|
| 4192 |
+
cur[str(rid)] = {**(cur.get(str(rid)) or {}), **vals}
|
| 4193 |
+
ut_write_rows(rt, tkey, cur)
|
| 4194 |
+
for tkey, new_rows in (creates or {}).items():
|
| 4195 |
+
t = ut_get(rt, tkey)
|
| 4196 |
+
if t is None:
|
| 4197 |
+
log(f"[aios-auto] create_record: {tkey} no longer exists β {len(new_rows)} skipped")
|
| 4198 |
+
continue
|
| 4199 |
+
cur = dict(t.get("rows") or {})
|
| 4200 |
+
nxt = max([int(r) for r in cur if str(r).isdigit()] or [0]) + 1
|
| 4201 |
+
cap = row_cap(tkey)
|
| 4202 |
+
for vals in new_rows:
|
| 4203 |
+
if len(cur) >= cap:
|
| 4204 |
+
log(f"[aios-auto] create_record: {tkey} at its {cap}-row cap")
|
| 4205 |
+
break
|
| 4206 |
+
cur[str(nxt)] = dict(vals)
|
| 4207 |
+
nxt += 1
|
| 4208 |
+
ut_write_rows(rt, tkey, cur)
|
| 4209 |
+
|
| 4210 |
+
|
| 4211 |
+
def _lane_sentence(cond, top=True):
|
| 4212 |
+
"""One condition tree β the sentence the board and the field definition carry.
|
| 4213 |
+
|
| 4214 |
+
C4: a GROUP renders as its children joined by "and"/"or" and parenthesised when nested, so a
|
| 4215 |
+
lane label never claims a flat comparison the tree does not make. The server composes it for
|
| 4216 |
+
the same reason it composes every other stage `detail` β a client paraphrase of a structure
|
| 4217 |
+
the engine evaluates is a second implementation of the same sentence, free to drift from it.
|
| 4218 |
+
"""
|
| 4219 |
if cond is None:
|
| 4220 |
+
return "Everything else" if top else ""
|
| 4221 |
+
if isinstance(cond, dict):
|
| 4222 |
+
for key, joiner in (("all", " and "), ("any", " or ")):
|
| 4223 |
+
if key in cond:
|
| 4224 |
+
parts = [_lane_sentence(c, False) for c in cond.get(key) or []]
|
| 4225 |
+
parts = [p for p in parts if p]
|
| 4226 |
+
if not parts:
|
| 4227 |
+
return ""
|
| 4228 |
+
inner = joiner.join(parts)
|
| 4229 |
+
return inner if top or len(parts) == 1 else f"({inner})"
|
| 4230 |
v = cond.get("value")
|
| 4231 |
return f"{cond.get('field')} {cond.get('op')}" + ("" if v is None else f" {v}")
|
| 4232 |
|
|
|
|
| 4273 |
"Declined": {"tracked": ""}}
|
| 4274 |
field = {"key": fkey, "label": f"Stage β {defn.get('name') or defn.get('id')}"[:80],
|
| 4275 |
"type": "select", "source": "overlay", "options": choices, "automation": auto}
|
| 4276 |
+
# β WAVE 23 (C5, D-35's minimum) β THE CYCLE COLUMN, created beside the stage field when
|
| 4277 |
+
# the flow can loop. The owner asked to SEE how many times a record has been round, and
|
| 4278 |
+
# `_apply_ending` was writing the cell without anything ever declaring the column: the value
|
| 4279 |
+
# would sit in the row, correct and invisible, because a ut grid renders the DEFINITION's
|
| 4280 |
+
# fields. A machine-written cell with no field definition is the shape of a number nobody
|
| 4281 |
+
# can find. Tagged `automation` so it greys out and refuses human writes like every other
|
| 4282 |
+
# machine column (item 4 / R9).
|
| 4283 |
+
cyc_key = cycles_field_key(defn.get("id"))
|
| 4284 |
+
loops = (((defn.get("flow") or {}).get("ending") or {}).get("mode") or "terminal") != \
|
| 4285 |
+
"terminal"
|
| 4286 |
+
cyc_field = {"key": cyc_key, "label": "Cycles"[:80], "type": "int", "source": "overlay",
|
| 4287 |
+
"automation": {"flowId": str(defn.get("id") or ""), "cyclesField": True}}
|
| 4288 |
+
|
| 4289 |
+
have, have_cyc = None, False
|
| 4290 |
for f in (ut_get(rt, key) or {}).get("fields") or []:
|
| 4291 |
if f.get("key") == fkey:
|
| 4292 |
have = f
|
| 4293 |
+
if f.get("key") == cyc_key:
|
| 4294 |
+
have_cyc = True
|
| 4295 |
if have is not None and have.get("options") == choices and \
|
| 4296 |
+
(have.get("automation") or {}) == auto and have_cyc == loops:
|
| 4297 |
return fkey # nothing would change β save the commit
|
| 4298 |
|
| 4299 |
def _up(cur):
|
|
|
|
| 4306 |
f["options"] = choices
|
| 4307 |
f["automation"] = auto
|
| 4308 |
f["type"] = "select"
|
| 4309 |
+
break
|
| 4310 |
+
else:
|
| 4311 |
+
t["fields"].append(field)
|
| 4312 |
+
if loops and not any(f.get("key") == cyc_key for f in t["fields"]):
|
| 4313 |
+
t["fields"].append(cyc_field)
|
| 4314 |
+
# β A flow switched BACK to `terminal` keeps its cycle column rather than dropping it:
|
| 4315 |
+
# the counts already in those cells are a record of work that really happened, and a
|
| 4316 |
+
# schema change must not be a way to lose data nobody asked to delete.
|
| 4317 |
return cur
|
| 4318 |
|
| 4319 |
rt.update(UT_STORE_KEY, _up, flush="sync")
|
|
|
|
| 4497 |
MAX_REVIEWS = 100
|
| 4498 |
|
| 4499 |
|
| 4500 |
+
def review_audit(rt, auto_id, row_id, from_label, to_label, username,
|
| 4501 |
+
by="user", note="", model=""):
|
| 4502 |
"""C3-A2(5): a review decision is AUDITED β who moved which card where, when. Appended to
|
| 4503 |
the definition (newest first, bounded) by BOTH doors: `move_card` here, and the grid door
|
| 4504 |
+
in `core.grid_events`, which writes the same shape mechanically off the field's `flowId`.
|
| 4505 |
+
|
| 4506 |
+
β WAVE 23 (R4/C6): an AI decision writes THE SAME ROW with `by: "ai"` plus the model that
|
| 4507 |
+
made it and the one-line reason it gave. One audit log, not two β a reader asking "who
|
| 4508 |
+
decided this card" must not have to know there are two places to look, and the moment an
|
| 4509 |
+
AI decision is invisible beside a human one the log stops being an audit.
|
| 4510 |
+
"""
|
| 4511 |
entry = {"ts": _iso(), "user": _s(username, 80), "rowId": str(row_id),
|
| 4512 |
+
"from": _s(from_label, 60), "to": _s(to_label, 60),
|
| 4513 |
+
"by": "ai" if by == "ai" else "user"}
|
| 4514 |
+
if note:
|
| 4515 |
+
entry["note"] = _s(note, 300)
|
| 4516 |
+
if model:
|
| 4517 |
+
entry["model"] = _s(model, 60)
|
| 4518 |
|
| 4519 |
def _up(cur):
|
| 4520 |
cur = cur if isinstance(cur, dict) else {}
|
|
|
|
| 4749 |
# EVER (a high-water mark over row ids, so an undo-restored row cannot re-fire);
|
| 4750 |
# event_field fires every transition.
|
| 4751 |
|
| 4752 |
+
# ββ WAVE 23 Β· C3 β the trigger vocabulary v2 (owner ruling R2). βββββββββββββββββββββββββββββββ
|
| 4753 |
+
# Airtable's phrasing, because the owner asked for Airtable's builder and a trigger list that
|
| 4754 |
+
# renames the same events is a second vocabulary to learn for no gain.
|
| 4755 |
+
#
|
| 4756 |
+
# β `event_field` KEPT ITS KEY and changed its LABEL to "When a record matches conditions".
|
| 4757 |
+
# Renaming the key would have orphaned every stored trigger in production for a caption; the key
|
| 4758 |
+
# is the contract with the store, the label is the contract with the reader, and they are allowed
|
| 4759 |
+
# to disagree. What genuinely widened is its SHAPE: the watched field is now OPTIONAL, so the
|
| 4760 |
+
# trigger covers Airtable's condition-only form (any write to the table, evaluated against a C4
|
| 4761 |
+
# tree) as well as wave 22's watch-one-field form. Both are the same edge rule underneath.
|
| 4762 |
+
#
|
| 4763 |
+
# β PLANNED β STORABLE. `button_clicked` / `comment_added` ride the wire so the picker can show
|
| 4764 |
+
# them faded with a reason (R2: "never a dead control") β and `clean_trigger` REFUSES them with a
|
| 4765 |
+
# sentence. A vocabulary that renders an option the validator rejects is the wave-9 silent-drop
|
| 4766 |
+
# class wearing a friendlier face; here the two lists are separate on purpose and the refusal
|
| 4767 |
+
# names the state rather than pretending the key is unknown.
|
| 4768 |
+
TRIGGER_KEYS = ("manual", "schedule", "event_field", "record_updated", "record_created",
|
| 4769 |
+
"enters_view", "webhook", "email", "form_submitted")
|
| 4770 |
+
TRIGGER_PLANNED = ("button_clicked", "comment_added")
|
| 4771 |
TRIGGER_LABELS = {
|
| 4772 |
+
"manual": "Manual",
|
| 4773 |
+
"schedule": "At a scheduled time",
|
| 4774 |
+
"event_field": "When a record matches conditions",
|
| 4775 |
+
"record_updated": "When a record is updated",
|
| 4776 |
+
"record_created": "When a record is created",
|
| 4777 |
+
"enters_view": "When a record enters a view",
|
| 4778 |
+
"webhook": "When a webhook is received",
|
| 4779 |
+
"email": "When an email arrives",
|
| 4780 |
+
"form_submitted": "When a form is submitted",
|
| 4781 |
+
"button_clicked": "When a button is clicked",
|
| 4782 |
+
"comment_added": "When a comment is added",
|
| 4783 |
}
|
| 4784 |
+
#: Triggers that watch a database and therefore need one named before they can fire.
|
| 4785 |
+
TRIGGER_TABLE_KEYS = ("event_field", "record_updated", "record_created", "enters_view",
|
| 4786 |
+
"form_submitted")
|
| 4787 |
+
#: Triggers the ROW HOOKS drive (as opposed to the tick, or an inbound HTTP call). Named once so
|
| 4788 |
+
#: `grid_hook` and the gates read the same list instead of two matching `in (...)` tuples.
|
| 4789 |
+
TRIGGER_ROW_KEYS = ("event_field", "record_updated", "record_created", "enters_view")
|
| 4790 |
+
MAX_WATCH_FIELDS = 12
|
| 4791 |
#: The settle window for field-change bursts (A2(1)). 0 evaluates INLINE β the gates run there,
|
| 4792 |
#: and so would a deployment that prefers immediacy over coalescing.
|
| 4793 |
EVENT_SETTLE_SECONDS = float(os.environ.get("AIOS_EVENT_SETTLE_SECONDS") or 15)
|
|
|
|
| 4826 |
prev = previous if isinstance(previous, dict) else {}
|
| 4827 |
key = _s(raw.get("key") or raw.get("kind") or prev.get("key") or prev.get("kind"),
|
| 4828 |
30).strip()
|
| 4829 |
+
if key in TRIGGER_PLANNED:
|
| 4830 |
+
# Declared on the wire, refused at the door β see the TRIGGER_PLANNED note. The sentence
|
| 4831 |
+
# says WHY rather than "unknown trigger", because the picker legitimately showed it.
|
| 4832 |
+
return None, (f"{TRIGGER_LABELS[key]!r} is on the list but not built yet β "
|
| 4833 |
+
f"it renders so you can see it is coming, and it cannot be saved")
|
| 4834 |
if key not in TRIGGER_KEYS:
|
| 4835 |
return None, (f"{key or 'that trigger'!r} is not one of: " + ", ".join(TRIGGER_KEYS))
|
| 4836 |
if key in ("manual", "schedule"):
|
|
|
|
| 4841 |
"enabled": bool(raw["enabled"]) if "enabled" in raw else
|
| 4842 |
bool(prev.get("enabled", True)),
|
| 4843 |
"paused": bool(raw["paused"]) if "paused" in raw else bool(prev.get("paused"))}
|
| 4844 |
+
if key in TRIGGER_TABLE_KEYS:
|
| 4845 |
table = _s(raw.get("table") if "table" in raw else prev.get("table"), 60).strip()
|
| 4846 |
if table and not table.startswith(UT_PREFIX):
|
| 4847 |
return None, ("event triggers watch blank databases (ut_*) this wave β "
|
| 4848 |
f"{table!r} is not one")
|
| 4849 |
out["table"] = table
|
| 4850 |
if key == "event_field":
|
| 4851 |
+
# The watched field is OPTIONAL now (C3-v2): named β "when THIS field changes and the
|
| 4852 |
+
# record matches"; unnamed β Airtable's plain "when a record matches conditions",
|
| 4853 |
+
# evaluated on any write to the table. The condition is a C4 TREE over the WHOLE row β
|
| 4854 |
+
# wave 22 could only test the written value, so it refused a condition on any other
|
| 4855 |
+
# field; `_settle_eval` now reads the row itself, which is what lifted that restriction.
|
| 4856 |
+
out["field"] = _s(raw.get("field") if "field" in raw else prev.get("field"), 80).strip()
|
| 4857 |
+
cond, cerr = clean_cond(raw.get("when") if "when" in raw else prev.get("when"),
|
| 4858 |
+
where="the trigger")
|
| 4859 |
+
if cerr:
|
| 4860 |
+
return None, cerr
|
| 4861 |
+
out["when"] = cond
|
| 4862 |
+
if key == "record_updated":
|
| 4863 |
+
# Airtable's shape: watch named fields, or leave the list empty for "any field". Empty
|
| 4864 |
+
# is the WIDER reading and it is the default there too, so it stays the default here.
|
| 4865 |
+
watch_raw = raw.get("fields") if "fields" in raw else prev.get("fields")
|
| 4866 |
+
if watch_raw in (None, ""):
|
| 4867 |
+
watch = []
|
| 4868 |
+
elif not isinstance(watch_raw, list):
|
| 4869 |
+
return None, "the watched-field list must be a list of field keys"
|
| 4870 |
else:
|
| 4871 |
+
watch = [_s(f, 80).strip() for f in watch_raw if _s(f, 80).strip()]
|
| 4872 |
+
if len(watch) > MAX_WATCH_FIELDS:
|
| 4873 |
+
return None, (f"a record-updated trigger watches at most {MAX_WATCH_FIELDS} "
|
| 4874 |
+
f"fields β leave the list empty to watch every field")
|
| 4875 |
+
out["fields"] = watch
|
| 4876 |
+
cond, cerr = clean_cond(raw.get("when") if "when" in raw else prev.get("when"),
|
| 4877 |
+
where="the trigger")
|
| 4878 |
+
if cerr:
|
| 4879 |
+
return None, cerr
|
| 4880 |
+
out["when"] = cond
|
| 4881 |
+
if key == "enters_view":
|
| 4882 |
+
out["viewId"] = _s(raw.get("viewId") if "viewId" in raw else prev.get("viewId"),
|
| 4883 |
+
80).strip()
|
| 4884 |
+
if key == "form_submitted":
|
| 4885 |
+
# Blank = any form on that database. Naming one narrows to it, which is what a table
|
| 4886 |
+
# carrying an intake form AND a correction form needs.
|
| 4887 |
+
out["formToken"] = _s(raw.get("formToken") if "formToken" in raw
|
| 4888 |
+
else prev.get("formToken"), 64).strip()
|
|
|
|
|
|
|
|
|
|
| 4889 |
if key == "webhook":
|
| 4890 |
# The token is MINTED here, once, and survives every later patch β rotating it on
|
| 4891 |
# every Save would silently break the external caller the URL was given to.
|
|
|
|
| 4893 |
if key == "email":
|
| 4894 |
out["query"] = _s(raw.get("query") if "query" in raw else prev.get("query"),
|
| 4895 |
200).strip() or "in:inbox is:unread"
|
| 4896 |
+
out["configured"] = _trigger_configured(out)
|
|
|
|
|
|
|
| 4897 |
return out, None
|
| 4898 |
|
| 4899 |
|
| 4900 |
+
def _trigger_configured(trg):
|
| 4901 |
+
"""Is this trigger complete enough to fire? One reader, because "configured" is asserted in
|
| 4902 |
+
three places (the wire, the graph node, the hooks) and three copies of a boolean is how a
|
| 4903 |
+
surface says "ready" about a trigger the engine skips."""
|
| 4904 |
+
key = str((trg or {}).get("key") or "")
|
| 4905 |
+
if key in TRIGGER_TABLE_KEYS and not trg.get("table"):
|
| 4906 |
+
return False
|
| 4907 |
+
if key == "event_field":
|
| 4908 |
+
# Neither a watched field nor a condition = "fire on anything, ever" β which is not a
|
| 4909 |
+
# trigger, it is a description of the table. Refuse to call that configured.
|
| 4910 |
+
return bool(trg.get("field") or trg.get("when"))
|
| 4911 |
+
if key == "enters_view":
|
| 4912 |
+
return bool(trg.get("viewId"))
|
| 4913 |
+
return True
|
| 4914 |
+
|
| 4915 |
+
|
| 4916 |
def _secrets_token():
|
| 4917 |
import secrets as _sec
|
| 4918 |
return _sec.token_urlsafe(24)
|
|
|
|
| 4966 |
_SETTLE_LOCK = threading.Lock()
|
| 4967 |
|
| 4968 |
|
| 4969 |
+
def _settle_buffer(rt, tenant, auto_id, row_id, field="", after=None, log=print):
|
| 4970 |
+
"""Buffer ONE touched row for a coalesced evaluation, carrying the written cell.
|
| 4971 |
+
|
| 4972 |
+
β WAVE 23 β THE WRITTEN VALUE IS STILL LOAD-BEARING, and an earlier draft of this wave
|
| 4973 |
+
dropped it on the theory that `_settle_eval` could just read the row. It cannot, and the
|
| 4974 |
+
reason is worth stating because it is invisible from this file: an ordinary ut cell typed at
|
| 4975 |
+
the grid door lands in the editor's PER-USER OVERLAY stratum
|
| 4976 |
+
(`grid_events.overlay_patch` β `table_store.patch_overlay`), not in the `user_tables`
|
| 4977 |
+
definition rows. Only stage-field writes go through `patch_cells` to the shared rows. So for
|
| 4978 |
+
the common case the value that just changed exists ONLY in this event, and a definition-row
|
| 4979 |
+
read sees the pre-write value β the trigger would evaluate stale and never fire.
|
| 4980 |
+
`_settle_eval` therefore MERGES: the definition row underneath (which is what lets a C4 tree
|
| 4981 |
+
read the record's other columns) with the written cells on top.
|
| 4982 |
+
"""
|
| 4983 |
key = (tenant, str(auto_id))
|
| 4984 |
+
cell = {str(field): after} if field else {}
|
| 4985 |
if EVENT_SETTLE_SECONDS <= 0:
|
| 4986 |
with _SETTLE_LOCK:
|
| 4987 |
buf = _SETTLE.setdefault(key, {"rows": {}})
|
| 4988 |
+
buf["rows"].setdefault(str(row_id), {}).update(cell)
|
| 4989 |
_settle_eval(rt, tenant, auto_id, log=log)
|
| 4990 |
return
|
| 4991 |
with _SETTLE_LOCK:
|
| 4992 |
buf = _SETTLE.setdefault(key, {"rows": {}})
|
| 4993 |
+
buf["rows"].setdefault(str(row_id), {}).update(cell)
|
| 4994 |
timer = buf.get("timer")
|
| 4995 |
if timer is not None:
|
| 4996 |
timer.cancel() # the burst continues β push the window out
|
|
|
|
| 5001 |
timer.start()
|
| 5002 |
|
| 5003 |
|
| 5004 |
+
def view_filter(rt, table_key, view_id):
|
| 5005 |
+
"""`(tree, fields, problem)` for one saved view on a user table β the substrate the
|
| 5006 |
+
`enters_view` trigger tests membership against (C3-v2 / owner R2).
|
| 5007 |
+
|
| 5008 |
+
Personal strata first (`find_view`), then the shared bucket, because a view somebody shared
|
| 5009 |
+
is exactly the kind an automation gets pointed at. A view that has been deleted answers a
|
| 5010 |
+
PROBLEM rather than an empty tree: an empty tree matches everything, so degrading to one
|
| 5011 |
+
would turn "when a record enters Overdue" into "on every write", which is the widening this
|
| 5012 |
+
module refuses everywhere else.
|
| 5013 |
+
"""
|
| 5014 |
+
key = str(table_key or "")
|
| 5015 |
+
vid = str(view_id or "").strip()
|
| 5016 |
+
if not key or not vid:
|
| 5017 |
+
return None, [], "the trigger names no view"
|
| 5018 |
+
try:
|
| 5019 |
+
import core.table_store as table_store
|
| 5020 |
+
tops = table_store.make(f"{key}_table_workspace", st=rt)
|
| 5021 |
+
found = tops.find_view(vid)
|
| 5022 |
+
view = (found[1] if found else None) or tops.shared_view(vid)
|
| 5023 |
+
except Exception as e: # noqa: BLE001
|
| 5024 |
+
return None, [], f"the view could not be read ({type(e).__name__})"
|
| 5025 |
+
if not isinstance(view, dict):
|
| 5026 |
+
return None, [], f"view {vid!r} no longer exists on {key}"
|
| 5027 |
+
cfg = view.get("config") or {}
|
| 5028 |
+
tree = {"nodes": cfg.get("filters") or [], "conj": cfg.get("filterConj") or "and"}
|
| 5029 |
+
return tree, list((ut_get(rt, key) or {}).get("fields") or []), ""
|
| 5030 |
+
|
| 5031 |
+
|
| 5032 |
+
def _row_gate(rt, defn, trg):
|
| 5033 |
+
"""The MATCH gate for a row-event trigger: `(row -> bool) | None`, plus a problem string.
|
| 5034 |
+
|
| 5035 |
+
None means the trigger has NO match gate β the write itself is the event (wave 22's "the
|
| 5036 |
+
field changed", and `record_updated` over any field). A problem means the gate cannot be
|
| 5037 |
+
built, and the caller must then fire NOTHING: a gate we cannot evaluate is not a gate that
|
| 5038 |
+
passes.
|
| 5039 |
+
"""
|
| 5040 |
+
key = trg.get("key")
|
| 5041 |
+
if key == "enters_view":
|
| 5042 |
+
tree, fields, problem = view_filter(rt, trg.get("table"), trg.get("viewId"))
|
| 5043 |
+
if problem:
|
| 5044 |
+
return None, problem
|
| 5045 |
+
import harness.filter_eval as filter_eval
|
| 5046 |
+
return (lambda row: filter_eval.matches(tree, row, fields)), ""
|
| 5047 |
+
when = trg.get("when")
|
| 5048 |
+
if when:
|
| 5049 |
+
return (lambda row: lane_match(when, row)), ""
|
| 5050 |
+
return None, ""
|
| 5051 |
+
|
| 5052 |
+
|
| 5053 |
def _settle_eval(rt, tenant, auto_id, log=print):
|
| 5054 |
"""Evaluate one settled burst: edge over per-record armed state, flood hold, then fire."""
|
| 5055 |
with _SETTLE_LOCK:
|
| 5056 |
buf = _SETTLE.pop((tenant, str(auto_id)), None)
|
| 5057 |
+
written = dict((buf or {}).get("rows") or {})
|
| 5058 |
+
touched = list(written)
|
| 5059 |
+
if not touched:
|
| 5060 |
return
|
| 5061 |
d = all_definitions(rt).get(str(auto_id))
|
| 5062 |
trg = (d or {}).get("trigger") or {}
|
| 5063 |
+
if trg.get("key") not in ("event_field", "record_updated", "enters_view") \
|
| 5064 |
+
or trg.get("paused") or not trg.get("enabled", True) \
|
| 5065 |
+
or not trg.get("configured", True):
|
| 5066 |
+
return
|
| 5067 |
+
gate, problem = _row_gate(rt, d, trg)
|
| 5068 |
+
if problem:
|
| 5069 |
+
# LOUD, once, and it does not fire. A trigger pointed at a deleted view is a broken
|
| 5070 |
+
# automation, not a quiet no-op β the note is what the surface shows instead of "On".
|
| 5071 |
+
if (d.get("statusNote") or "") != problem:
|
| 5072 |
+
_pause_trigger(rt, auto_id, problem)
|
| 5073 |
+
log(f"[aios-auto] trigger gate: {auto_id} {problem}")
|
| 5074 |
return
|
|
|
|
| 5075 |
fired = []
|
| 5076 |
+
if gate is not None:
|
| 5077 |
+
rows = (ut_get(rt, trg.get("table") or "") or {}).get("rows") or {}
|
| 5078 |
disarmed = set((d.get("state") or {}).get("eventDisarmed") or [])
|
| 5079 |
nxt = set(disarmed)
|
| 5080 |
+
for rid in touched:
|
| 5081 |
+
# The merge (see `_settle_buffer`): the shared definition row underneath so a C4
|
| 5082 |
+
# tree can read the record's other columns, the just-written cells on top because
|
| 5083 |
+
# for an ordinary ut column this event is the ONLY place that value exists yet.
|
| 5084 |
+
if gate({**(rows.get(rid) or rows.get(str(rid)) or {}),
|
| 5085 |
+
**(written.get(rid) or {})}):
|
| 5086 |
if rid not in disarmed:
|
| 5087 |
fired.append(rid) # falseβtrue THIS evaluation: the edge
|
| 5088 |
nxt.add(rid)
|
|
|
|
| 5093 |
if not fired:
|
| 5094 |
return
|
| 5095 |
else:
|
| 5096 |
+
fired = list(touched) # "the field changed" β the burst is the edge
|
| 5097 |
if len(fired) > FLOOD_LIMIT:
|
| 5098 |
_commit_run(rt, auto_id, "partial",
|
| 5099 |
f"the trigger matched {len(fired)} records in one evaluation β more than "
|
|
|
|
| 5106 |
def _seed_event_state(rt, defn):
|
| 5107 |
"""A2(1)'s enable rule: records ALREADY matching when the trigger is set start DISARMED, so
|
| 5108 |
turning the trigger on fires nothing β the first fire needs a real falseβtrue transition.
|
| 5109 |
+
Evaluated over the definition rows (the machine-written truth this trigger class watches).
|
| 5110 |
+
|
| 5111 |
+
Wave 23: one seeder for all three gated triggers, reading the SAME `_row_gate` the evaluation
|
| 5112 |
+
reads. Two implementations of "does this row match" is how a seed disagrees with the edge it
|
| 5113 |
+
is supposed to arm, and the symptom would be a flood of fires the moment somebody enables it.
|
| 5114 |
+
"""
|
| 5115 |
trg = (defn or {}).get("trigger") or {}
|
| 5116 |
+
if trg.get("key") not in ("event_field", "record_updated", "enters_view") \
|
| 5117 |
+
or not trg.get("configured"):
|
| 5118 |
return
|
| 5119 |
+
gate, problem = _row_gate(rt, defn, trg)
|
| 5120 |
+
if gate is None or problem:
|
| 5121 |
+
return # no match gate β nothing to arm; a broken gate seeds nothing
|
| 5122 |
+
rows = (ut_get(rt, trg.get("table") or "") or {}).get("rows") or {}
|
| 5123 |
+
matching = sorted(str(rid) for rid, row in rows.items() if gate(row or {}))
|
| 5124 |
set_state(rt, defn.get("id"), {"eventDisarmed": matching[:5000]})
|
| 5125 |
|
| 5126 |
|
|
|
|
| 5137 |
table = str(evt.get("table") or "")
|
| 5138 |
kind = str(evt.get("type") or "")
|
| 5139 |
defs = all_definitions(st)
|
| 5140 |
+
field = str(evt.get("field") or "")
|
| 5141 |
+
rid = str(evt.get("rowId") or "")
|
| 5142 |
for aid, d in defs.items():
|
| 5143 |
trg = (d or {}).get("trigger") or {}
|
| 5144 |
+
key = str(trg.get("key") or "")
|
| 5145 |
+
if trg.get("paused") or not trg.get("enabled", True) \
|
| 5146 |
+
or not trg.get("configured", True) \
|
| 5147 |
+
or key not in TRIGGER_ROW_KEYS \
|
| 5148 |
+
or str(trg.get("table") or "") != table:
|
| 5149 |
continue
|
| 5150 |
+
if kind == "record_created" and key == "record_created":
|
|
|
|
|
|
|
| 5151 |
hw = _ig_int((d.get("state") or {}).get("rcHighwater")) or 0
|
| 5152 |
ridn = int(rid) if rid.isdigit() else None
|
| 5153 |
if ridn is None or ridn <= hw:
|
| 5154 |
continue # once per record EVER (A2(4)) β undo-proof
|
| 5155 |
set_state(st, aid, {"rcHighwater": ridn})
|
| 5156 |
trigger_fire(st, tenant, aid)
|
| 5157 |
+
elif kind == "event_field" and key == "event_field" and (
|
| 5158 |
+
not trg.get("field") or str(trg.get("field")) == field):
|
| 5159 |
+
# A NAMED field narrows to writes on it (wave 22's shape); an unnamed one is
|
| 5160 |
+
# Airtable's plain condition trigger and watches every column.
|
| 5161 |
+
_settle_buffer(st, tenant, aid, rid, field, evt.get("after"))
|
| 5162 |
+
elif kind == "event_field" and key == "record_updated" and (
|
| 5163 |
+
not (trg.get("fields") or []) or field in (trg.get("fields") or [])):
|
| 5164 |
+
_settle_buffer(st, tenant, aid, rid, field, evt.get("after"))
|
| 5165 |
+
elif key == "enters_view":
|
| 5166 |
+
# BOTH kinds feed it: a row can enter a view by being edited into its filter or
|
| 5167 |
+
# by being CREATED already inside it. Listening only to edits would silently miss
|
| 5168 |
+
# every new record β the half of the definition a reader assumes is covered.
|
| 5169 |
+
_settle_buffer(st, tenant, aid, rid, field, evt.get("after"))
|
| 5170 |
except Exception as e: # noqa: BLE001
|
| 5171 |
print(f"[aios-auto] trigger hook failed: {type(e).__name__}: {e}")
|
| 5172 |
|
| 5173 |
|
| 5174 |
+
def form_fired(rt, table_key, row_id, values=None, form_token=""):
|
| 5175 |
+
"""β THE FROZEN SIGNATURE session D calls from the public form door (contract C9/W23-W7).
|
| 5176 |
+
|
| 5177 |
+
One submitted form row β every `form_submitted` automation watching that database fires.
|
| 5178 |
+
Returns the list of automation ids that started, so the door can log what it set off (and so
|
| 5179 |
+
the gate can assert it, rather than asserting a side effect nobody can see).
|
| 5180 |
+
|
| 5181 |
+
β THIS IS A HUMAN DOOR, deliberately: an anonymous submission is a person filling in a form,
|
| 5182 |
+
so it fires triggers exactly like typing into a cell does. The loop-prevention law is not
|
| 5183 |
+
weakened by that β the engine's own writers still never reach here (only `routes_forms` calls
|
| 5184 |
+
it), so an automation cannot create a form row and re-fire itself.
|
| 5185 |
+
|
| 5186 |
+
`values` is accepted and unused today: the row is already written when this is called, and
|
| 5187 |
+
the flow reads it from the table. It stays in the signature because the caller HAS it and a
|
| 5188 |
+
later refire policy ("only when field X was submitted") needs it β a parameter added later
|
| 5189 |
+
would mean changing D's call site in a wave that does not own it.
|
| 5190 |
+
"""
|
| 5191 |
+
started, table = [], str(table_key or "")
|
| 5192 |
+
if not table:
|
| 5193 |
+
return started
|
| 5194 |
+
tenant = str(getattr(rt, "key", "") or "royal-imports")
|
| 5195 |
+
for aid, d in all_definitions(rt).items():
|
| 5196 |
+
trg = (d or {}).get("trigger") or {}
|
| 5197 |
+
if trg.get("key") != "form_submitted" or trg.get("paused") \
|
| 5198 |
+
or not trg.get("enabled", True) or not trg.get("configured", True):
|
| 5199 |
+
continue
|
| 5200 |
+
if str(trg.get("table") or "") != table:
|
| 5201 |
+
continue
|
| 5202 |
+
want = str(trg.get("formToken") or "")
|
| 5203 |
+
if want and not hmac.compare_digest(want, str(form_token or "")):
|
| 5204 |
+
continue # this automation watches a DIFFERENT form on that table
|
| 5205 |
+
if trigger_fire(rt, tenant, aid):
|
| 5206 |
+
started.append(aid)
|
| 5207 |
+
return started
|
| 5208 |
+
|
| 5209 |
+
|
| 5210 |
def hook_fire(rt, tenant, auto_id, token):
|
| 5211 |
"""The webhook trigger's decision, separated from FastAPI so the gate can drive it.
|
| 5212 |
Returns `(status, payload)` β 404 unknown, 403 wrong/missing token or wrong trigger kind,
|
|
|
|
| 5367 |
log(f"[aios-auto] run {auto_id} failed: {type(e).__name__}: {e}")
|
| 5368 |
return _commit_run(rt, auto_id, "error",
|
| 5369 |
f"{type(e).__name__}: {str(e)[:200]}", {}, False)
|
| 5370 |
+
# β WAVE 23 (C4/C5) β THE FLOW RUNS HERE, after the machine steps and before the run is
|
| 5371 |
+
# committed, over the records this run actually touched. ONE call site rather than three
|
| 5372 |
+
# inside the runners: every kind gets actions and endings for free, and a fourth runner
|
| 5373 |
+
# cannot forget to opt in.
|
| 5374 |
+
#
|
| 5375 |
+
# β Its absence was the wave's most expensive near-miss: actions were stored, validated,
|
| 5376 |
+
# wired to the wire and covered by twelve gate checks that all called `apply_actions`
|
| 5377 |
+
# DIRECTLY β so the whole feature was green and unreachable. A person would have built a
|
| 5378 |
+
# flow, pressed Run now, and watched nothing happen. The gate now drives `run_now`.
|
| 5379 |
+
#
|
| 5380 |
+
# A failing action must not fail the RUN: the machine steps already wrote their rows and
|
| 5381 |
+
# reporting that as an error would misdescribe what happened. It degrades to `partial`
|
| 5382 |
+
# with the reason in the summary β the cap_note discipline.
|
| 5383 |
+
try:
|
| 5384 |
+
a_counts = apply_actions(rt, defn, _flow_table(defn), affected or [],
|
| 5385 |
+
username=username, log=log)
|
| 5386 |
+
counts = {**(counts or {}), **{k: v for k, v in a_counts.items() if v}}
|
| 5387 |
+
except Exception as e: # noqa: BLE001
|
| 5388 |
+
log(f"[aios-auto] actions on {auto_id} failed: {type(e).__name__}: {e}")
|
| 5389 |
+
state = "partial" if state != "error" else state
|
| 5390 |
+
summary = f"{summary} β the actions did not finish ({type(e).__name__})"
|
| 5391 |
return _commit_run(rt, auto_id, state, summary, counts, state != "error", affected,
|
| 5392 |
steps)
|
| 5393 |
finally:
|
api/main.py
CHANGED
|
@@ -71,6 +71,9 @@ import routes_records # noqa: E402
|
|
| 71 |
import routes_shares # noqa: E402 (wave 20 R10 β grants for views, folders and databases)
|
| 72 |
import routes_uploads # noqa: E402 (wave 21 C5 β tabular preview for Select-from-file)
|
| 73 |
import routes_tables # noqa: E402 (wave 18 C3-UT β user-created databases over the wire)
|
|
|
|
|
|
|
|
|
|
| 74 |
from core import grid_events # noqa: E402
|
| 75 |
from deps import Session, module_gate # noqa: E402
|
| 76 |
|
|
@@ -202,6 +205,24 @@ app.include_router(routes_platform_admin.router)
|
|
| 202 |
app.include_router(routes_alerts.router)
|
| 203 |
app.include_router(routes_shares.router)
|
| 204 |
app.include_router(routes_uploads.router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 205 |
|
| 206 |
|
| 207 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
|
|
|
| 71 |
import routes_shares # noqa: E402 (wave 20 R10 β grants for views, folders and databases)
|
| 72 |
import routes_uploads # noqa: E402 (wave 21 C5 β tabular preview for Select-from-file)
|
| 73 |
import routes_tables # noqa: E402 (wave 18 C3-UT β user-created databases over the wire)
|
| 74 |
+
import routes_connectors # noqa: E402 (wave 23 C11 β the connectors directory; SESSION A's router)
|
| 75 |
+
import routes_forms # noqa: E402 (wave 23 C9 β the PUBLIC form door; SESSION D's router)
|
| 76 |
+
import routes_templates # noqa: E402 (wave 23 C12 β template apply doors; SESSION E's router)
|
| 77 |
from core import grid_events # noqa: E402
|
| 78 |
from deps import Session, module_gate # noqa: E402
|
| 79 |
|
|
|
|
| 205 |
app.include_router(routes_alerts.router)
|
| 206 |
app.include_router(routes_shares.router)
|
| 207 |
app.include_router(routes_uploads.router)
|
| 208 |
+
# β WAVE 23 β THE THREE NEW ROUTERS. Mounted here, above the static catch-all at the bottom of this
|
| 209 |
+
# file, because `app.mount("/", _AppStatic(...), html=True)` swallows everything it is reached by:
|
| 210 |
+
# a router included AFTER it answers 404 forever while importing fine, type-checking fine and
|
| 211 |
+
# passing its own gate. That is the wave-20 declared-but-unmounted shape with a different cause.
|
| 212 |
+
#
|
| 213 |
+
# β ALL THREE EXISTED, COMPLETE AND GATED, WITH NO MOUNT until the close-out audit β three finished
|
| 214 |
+
# features that would have shipped dead. The workers each posted a mount ask and said they would
|
| 215 |
+
# signal "ready" first; C waited for a signal that never came while the files landed anyway. **The
|
| 216 |
+
# lesson is not "read the mailbox harder": `verify_api`'s enumeration is what caught it, so the
|
| 217 |
+
# control is that the enumeration must NAME every router in this file.**
|
| 218 |
+
app.include_router(routes_templates.router) # item 7 / C12 β template registry, session-gated
|
| 219 |
+
app.include_router(routes_connectors.router) # item 10 / C11 β the connectors directory
|
| 220 |
+
# β routes_forms is the FIRST NEW PUBLIC DOOR since the `_DEV_FIXTURES` scar (see :257 below). Its
|
| 221 |
+
# two paths are DELIBERATELY unauthenticated β a form is filled in by someone with no account β so
|
| 222 |
+
# it joins `/api/health`, the automation hook and the tick on the exempt list. It resolves a token
|
| 223 |
+
# by scanning tenants with a constant-time compare and answers a uniform 403, so a bad token cannot
|
| 224 |
+
# distinguish "no such form" from "not yours", and it never echoes a tenant, table or slug.
|
| 225 |
+
app.include_router(routes_forms.router) # item 8 / C9 β the PUBLIC form door
|
| 226 |
|
| 227 |
|
| 228 |
# --- DEPRECATED ALIASES (removed when S2's shell flips; kept so the current bundle keeps working)
|
api/routes_automation.py
CHANGED
|
@@ -26,6 +26,11 @@ router = APIRouter(prefix="/api/v1")
|
|
| 26 |
# C5 (wave 22): the OAuth connector surface rides INSIDE this router β main.py belongs to no
|
| 27 |
# session this wave, and this router is already mounted there. `/api/v1` + `/oauth/...`.
|
| 28 |
router.include_router(routes_oauth.router)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
#: The registry key this surface carries (C-AUTONAV β A adds the row; the gate is live now, so
|
| 31 |
#: the day the row lands the wall is already the one that was tested).
|
|
@@ -66,13 +71,27 @@ def _wire(defn, tenant):
|
|
| 66 |
# client, for the same reason `cronPresets` does: the engine that RUNS the steps is the
|
| 67 |
# only thing entitled to say what the steps are.
|
| 68 |
"graph": engine.graph(defn),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
|
| 72 |
def _triggers_vocab(session):
|
| 73 |
-
"""C3's server-owned trigger list: `[{key, label, ready, needs}]`, keys EXACTLY
|
| 74 |
-
`engine.TRIGGER_KEYS`. `ready:false` + `needs` renders as a
|
| 75 |
-
dead control, never a client-side union.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
tick_on = _tick_state()["enabled"]
|
| 77 |
g = oauth_connect.status(session.runtime, session.uname).get("google") or {}
|
| 78 |
email_ready = bool(g.get("configured")) and bool(g.get("connected")) \
|
|
@@ -83,14 +102,18 @@ def _triggers_vocab(session):
|
|
| 83 |
"manual": (True, ""),
|
| 84 |
"schedule": (tick_on, "" if tick_on else "arm_tick"),
|
| 85 |
"event_field": (True, ""),
|
|
|
|
| 86 |
"record_created": (True, ""),
|
|
|
|
| 87 |
"webhook": (True, ""),
|
| 88 |
"email": (email_ready, email_needs),
|
|
|
|
| 89 |
}
|
| 90 |
out = []
|
| 91 |
for k in engine.TRIGGER_KEYS:
|
| 92 |
ready, needs = per[k]
|
| 93 |
row = {"key": k, "label": engine.TRIGGER_LABELS[k], "ready": ready, "needs": needs,
|
|
|
|
| 94 |
# A3(3): the connect affordance is SERVER-COMPOSED β the client never maps a
|
| 95 |
# `needs` token to a route, so B's CONNECT_PROVIDERS shim deletes itself.
|
| 96 |
"connect": None}
|
|
@@ -98,9 +121,23 @@ def _triggers_vocab(session):
|
|
| 98 |
row["connect"] = {"provider": "google",
|
| 99 |
"startUrl": "/api/v1/oauth/google/start"}
|
| 100 |
out.append(row)
|
|
|
|
|
|
|
|
|
|
| 101 |
return out
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
def _tick_state():
|
| 105 |
"""β WAVE 21 (C6 amendment A1) β can a SCHEDULE fire on this deployment?
|
| 106 |
|
|
@@ -167,6 +204,28 @@ def list_automations(session: Session = Depends(_GATE)):
|
|
| 167 |
"nullaryLaneOps": list(engine.LANE_NULLARY_OPS),
|
| 168 |
"maxLanes": engine.MAX_LANES,
|
| 169 |
"cardCap": engine.BOARD_CARD_CAP},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
# Wave 21 C6-A1: {"enabled": bool, "source": "in-process"|"external"|""} β see
|
| 171 |
# `_tick_state` for why one boolean off AIOS_AUTOMATIONS alone would lie.
|
| 172 |
"tick": _tick_state()}
|
|
@@ -244,6 +303,10 @@ def get_automation(auto_id: str, session: Session = Depends(_GATE)):
|
|
| 244 |
|
| 245 |
@router.post("/automations")
|
| 246 |
def create_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
if not session.runtime.available():
|
| 248 |
raise err(503, "store_unavailable", "the tenant store is unavailable β nothing was saved")
|
| 249 |
defn, error = engine.create(session.runtime, body or {}, username=session.uname)
|
|
|
|
| 26 |
# C5 (wave 22): the OAuth connector surface rides INSIDE this router β main.py belongs to no
|
| 27 |
# session this wave, and this router is already mounted there. `/api/v1` + `/oauth/...`.
|
| 28 |
router.include_router(routes_oauth.router)
|
| 29 |
+
# β WAVE 23 (C11): the connectors directory rides this router for the same reason the OAuth
|
| 30 |
+
# routes do β it is mounted already, so E's page needs no main.py change to reach it. (C still
|
| 31 |
+
# owns main.py; this avoids an unnecessary ask across a fence.)
|
| 32 |
+
import routes_connectors # noqa: E402
|
| 33 |
+
router.include_router(routes_connectors.router)
|
| 34 |
|
| 35 |
#: The registry key this surface carries (C-AUTONAV β A adds the row; the gate is live now, so
|
| 36 |
#: the day the row lands the wall is already the one that was tested).
|
|
|
|
| 71 |
# client, for the same reason `cronPresets` does: the engine that RUNS the steps is the
|
| 72 |
# only thing entitled to say what the steps are.
|
| 73 |
"graph": engine.graph(defn),
|
| 74 |
+
# β WAVE 23 (C4/C5) β THE BUILDER'S OWN STATE, and its absence here was a silent-drop
|
| 75 |
+
# bug caught in review rather than by a gate: `flow` was stored, patchable and validated,
|
| 76 |
+
# but never sent back. B would have saved a flow through PATCH, got a 200, and watched
|
| 77 |
+
# every action vanish on reload β the classic "it didn't save" with nothing red anywhere.
|
| 78 |
+
# Defaulted rather than conditional so a pre-wave definition reads as an empty flow with
|
| 79 |
+
# a terminal ending instead of `undefined`, which the client would have to special-case.
|
| 80 |
+
"flow": defn.get("flow") or {"actions": [], "ending": {"mode": "terminal"}},
|
| 81 |
}
|
| 82 |
|
| 83 |
|
| 84 |
def _triggers_vocab(session):
|
| 85 |
+
"""C3's server-owned trigger list: `[{key, label, ready, needs, planned}]`, keys EXACTLY
|
| 86 |
+
`engine.TRIGGER_KEYS` + `engine.TRIGGER_PLANNED`. `ready:false` + `needs` renders as a
|
| 87 |
+
not-configured state β never a dead control, never a client-side union.
|
| 88 |
+
|
| 89 |
+
β WAVE 23 (R2): the list is the WHOLE Airtable-parity vocabulary, and the two triggers we
|
| 90 |
+
have not built ride it with `planned: true`. That is the honest version of "show all, wire
|
| 91 |
+
eight": the picker paints them faded with a reason instead of a shorter list that quietly
|
| 92 |
+
implies the missing ones do not exist. `clean_trigger` refuses them, so the faded state is
|
| 93 |
+
enforced at the door and not merely in the client's `disabled` attribute.
|
| 94 |
+
"""
|
| 95 |
tick_on = _tick_state()["enabled"]
|
| 96 |
g = oauth_connect.status(session.runtime, session.uname).get("google") or {}
|
| 97 |
email_ready = bool(g.get("configured")) and bool(g.get("connected")) \
|
|
|
|
| 102 |
"manual": (True, ""),
|
| 103 |
"schedule": (tick_on, "" if tick_on else "arm_tick"),
|
| 104 |
"event_field": (True, ""),
|
| 105 |
+
"record_updated": (True, ""),
|
| 106 |
"record_created": (True, ""),
|
| 107 |
+
"enters_view": (True, ""),
|
| 108 |
"webhook": (True, ""),
|
| 109 |
"email": (email_ready, email_needs),
|
| 110 |
+
"form_submitted": (True, ""),
|
| 111 |
}
|
| 112 |
out = []
|
| 113 |
for k in engine.TRIGGER_KEYS:
|
| 114 |
ready, needs = per[k]
|
| 115 |
row = {"key": k, "label": engine.TRIGGER_LABELS[k], "ready": ready, "needs": needs,
|
| 116 |
+
"planned": False,
|
| 117 |
# A3(3): the connect affordance is SERVER-COMPOSED β the client never maps a
|
| 118 |
# `needs` token to a route, so B's CONNECT_PROVIDERS shim deletes itself.
|
| 119 |
"connect": None}
|
|
|
|
| 121 |
row["connect"] = {"provider": "google",
|
| 122 |
"startUrl": "/api/v1/oauth/google/start"}
|
| 123 |
out.append(row)
|
| 124 |
+
for k in engine.TRIGGER_PLANNED:
|
| 125 |
+
out.append({"key": k, "label": engine.TRIGGER_LABELS[k], "ready": False,
|
| 126 |
+
"needs": "coming_soon", "planned": True, "connect": None})
|
| 127 |
return out
|
| 128 |
|
| 129 |
|
| 130 |
+
def _ai_ready():
|
| 131 |
+
"""Can an AI review actually decide on this deployment? (C6/R14.) A boolean, never the
|
| 132 |
+
provider list and never a key β the surface needs to say "not configured" honestly, and that
|
| 133 |
+
needs exactly one bit."""
|
| 134 |
+
try:
|
| 135 |
+
import ai_review
|
| 136 |
+
return bool(ai_review.configured())
|
| 137 |
+
except Exception: # noqa: BLE001
|
| 138 |
+
return False
|
| 139 |
+
|
| 140 |
+
|
| 141 |
def _tick_state():
|
| 142 |
"""β WAVE 21 (C6 amendment A1) β can a SCHEDULE fire on this deployment?
|
| 143 |
|
|
|
|
| 204 |
"nullaryLaneOps": list(engine.LANE_NULLARY_OPS),
|
| 205 |
"maxLanes": engine.MAX_LANES,
|
| 206 |
"cardCap": engine.BOARD_CARD_CAP},
|
| 207 |
+
# β WAVE 23 C4 (R3) β the ACTION MENU, including what we have not built. Each row
|
| 208 |
+
# carries `ready`, so B paints "Send email" and "Run script" faded with the server's
|
| 209 |
+
# own reason instead of omitting them β the owner asked for Airtable's full menu, and
|
| 210 |
+
# a shorter list would imply those actions do not exist. `clean_actions` REFUSES an
|
| 211 |
+
# unready kind, so the faded state is a wall rather than a styling choice.
|
| 212 |
+
"actionsCatalog": engine.action_catalog(),
|
| 213 |
+
# The builder's own vocabulary: how deep a condition tree may nest, how deep groups
|
| 214 |
+
# may nest, and the ceilings. B reads these instead of hard-coding the same numbers
|
| 215 |
+
# into its "+ Add condition" affordance.
|
| 216 |
+
"flow": {"condOps": list(engine.LANE_OPS),
|
| 217 |
+
"nullaryCondOps": list(engine.LANE_NULLARY_OPS),
|
| 218 |
+
"maxCondDepth": engine.MAX_COND_DEPTH,
|
| 219 |
+
"maxCondChildren": engine.MAX_COND_CHILDREN,
|
| 220 |
+
"maxGroupDepth": engine.MAX_GROUP_DEPTH,
|
| 221 |
+
"maxActions": engine.MAX_ACTIONS,
|
| 222 |
+
"endingModes": list(engine.ENDING_MODES),
|
| 223 |
+
"maxResetHours": engine.MAX_RESET_HOURS,
|
| 224 |
+
"reviewDeciders": list(engine.REVIEW_DECIDERS),
|
| 225 |
+
# Honest, per-deployment: with no LLM key configured the AI-review option
|
| 226 |
+
# renders as not-configured rather than as a control that silently holds
|
| 227 |
+
# every card for a human (C6's fail-closed path, made visible).
|
| 228 |
+
"aiReady": _ai_ready()},
|
| 229 |
# Wave 21 C6-A1: {"enabled": bool, "source": "in-process"|"external"|""} β see
|
| 230 |
# `_tick_state` for why one boolean off AIOS_AUTOMATIONS alone would lie.
|
| 231 |
"tick": _tick_state()}
|
|
|
|
| 303 |
|
| 304 |
@router.post("/automations")
|
| 305 |
def create_automation(body: dict = Body(default=None), session: Session = Depends(_GATE)):
|
| 306 |
+
"""Create an automation. β WAVE 23 (C2): the body may carry
|
| 307 |
+
`target: {mode: "existing"|"new"|"automated", table?, label?}` β the wizard's FIRST question,
|
| 308 |
+
answered before the kind. `new` mints the blank database in the same call, so the automation
|
| 309 |
+
is never saved pointing at a table that does not exist yet."""
|
| 310 |
if not session.runtime.available():
|
| 311 |
raise err(503, "store_unavailable", "the tenant store is unavailable β nothing was saved")
|
| 312 |
defn, error = engine.create(session.runtime, body or {}, username=session.uname)
|
api/routes_connectors.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""THE CONNECTORS DIRECTORY (wave 23, contract C11 / owner ruling R8).
|
| 2 |
+
|
| 3 |
+
β WHAT THE OWNER ASKED FOR, verbatim: *"Build a connector module where it stores everythingβ¦ BUT
|
| 4 |
+
if its not yet integrated make it faded grey or something so i can use it as a to-do list for
|
| 5 |
+
integrations."* So this endpoint deliberately answers with things we have NOT built. A directory
|
| 6 |
+
that listed only working connectors would be honest about each row and dishonest about the shape
|
| 7 |
+
of the product β the owner wants to see the map, including the empty parts.
|
| 8 |
+
|
| 9 |
+
β THE ROW SET IS COMPOSED HERE, ONCE, FROM THE REGISTRIES THAT ALREADY EXIST β the OAuth
|
| 10 |
+
provider registry (`oauth_connect.PROVIDERS`), the keychain's entry types, the automation source
|
| 11 |
+
registry, and the env-configured built-ins. It is NOT a hand-written list of cards. That matters
|
| 12 |
+
because the alternative rots in a specific way: a provider gets wired up somewhere and the
|
| 13 |
+
directory keeps calling it "planned" for a wave or two, which is exactly the misinformation this
|
| 14 |
+
surface exists to prevent. The only hand-written part is `PLANNED` β the things that have no
|
| 15 |
+
registry entry anywhere yet, which is precisely what "not yet integrated" means.
|
| 16 |
+
|
| 17 |
+
β STATE IS PER-READER. `connected` is a fact about THIS user for a per-user credential (OAuth
|
| 18 |
+
slots are per-user by R7) and about the tenant for a shared one (Odoo, Bright Data). A directory
|
| 19 |
+
that reported another user's Gmail as "connected" would be both wrong and a small disclosure.
|
| 20 |
+
"""
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import os
|
| 24 |
+
|
| 25 |
+
from fastapi import APIRouter, Depends
|
| 26 |
+
|
| 27 |
+
import oauth_connect
|
| 28 |
+
from deps import Session, require_session
|
| 29 |
+
|
| 30 |
+
# β THE PREFIX IS LOAD-BEARING AND IT WAS MISSING (found at the wave-23 close-out audit).
|
| 31 |
+
# This router declared a bare `APIRouter()`, so its one path was served at `/connectors/directory`
|
| 32 |
+
# while `ConnectorsPage.tsx` fetches `${API_V1}/connectors/directory` β the directory would have
|
| 33 |
+
# 404'd in production with every gate green. Every other router in this package self-declares
|
| 34 |
+
# `prefix="/api/v1"`; this was the only one that did not.
|
| 35 |
+
#
|
| 36 |
+
# β AND THE WIRING GATE COULD NOT SEE IT. `verify_wiring`'s W23-W4 row matches the SERVER side on
|
| 37 |
+
# the substring `connectors/directory` and the CLIENT side on `${API_V1}/connectors/directory` β
|
| 38 |
+
# both matched, both sides "wired", prefixes disagreeing. A cross-fence row that greps each side
|
| 39 |
+
# independently proves the two files mention the same path SEGMENT, never that they agree on the
|
| 40 |
+
# same URL. `verify_api` now asserts the prefix on every mounted router instead.
|
| 41 |
+
router = APIRouter(prefix="/api/v1")
|
| 42 |
+
|
| 43 |
+
#: The to-do list (R8). Order is the owner's reading order, not alphabetical: the things most
|
| 44 |
+
#: likely to come next first. `queue` mirrors the standing provider queue in wave22-split C5-A2
|
| 45 |
+
#: so the two cannot drift into disagreeing about what is coming.
|
| 46 |
+
PLANNED = [
|
| 47 |
+
{"key": "slack", "label": "Slack", "kind": "oauth",
|
| 48 |
+
"desc": "Send messages into a channel when an automation reaches a step."},
|
| 49 |
+
{"key": "airtable", "label": "Airtable", "kind": "oauth",
|
| 50 |
+
"desc": "Import an existing base as a database."},
|
| 51 |
+
{"key": "outlook", "label": "Microsoft Outlook", "kind": "oauth",
|
| 52 |
+
"desc": "The Outlook half of the email trigger."},
|
| 53 |
+
{"key": "quickbooks", "label": "QuickBooks", "kind": "oauth",
|
| 54 |
+
"desc": "Accounting actuals beside the Odoo sales data."},
|
| 55 |
+
{"key": "xero", "label": "Xero", "kind": "oauth",
|
| 56 |
+
"desc": "Accounting actuals for tenants who do not use QuickBooks."},
|
| 57 |
+
{"key": "google_sheets", "label": "Google Sheets", "kind": "oauth",
|
| 58 |
+
"desc": "Read a sheet as a database; the Google connection already exists."},
|
| 59 |
+
{"key": "google_drive", "label": "Google Drive", "kind": "oauth",
|
| 60 |
+
"desc": "Attach files from Drive to records."},
|
| 61 |
+
{"key": "google_calendar", "label": "Google Calendar", "kind": "oauth",
|
| 62 |
+
"desc": "Turn dated records into calendar entries."},
|
| 63 |
+
{"key": "tiktok", "label": "TikTok", "kind": "token",
|
| 64 |
+
"desc": "The second social source beside Instagram."},
|
| 65 |
+
{"key": "meta", "label": "WhatsApp and Instagram (Meta)", "kind": "oauth",
|
| 66 |
+
"desc": "Blocked on Meta business verification; Instagram data comes via Bright Data."},
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
#: Token connectors an admin can wire up TODAY by pasting a key β no OAuth client, no app review.
|
| 70 |
+
TOKEN_CONNECTORS = [
|
| 71 |
+
{"key": "stripe", "label": "Stripe", "entry": "stripe",
|
| 72 |
+
"desc": "Payments and payouts against your customer records.",
|
| 73 |
+
"hint": "Create a restricted key with read access in the Stripe dashboard."},
|
| 74 |
+
{"key": "shopify", "label": "Shopify", "entry": "shopify",
|
| 75 |
+
"desc": "Orders and products from a Shopify storefront.",
|
| 76 |
+
"hint": "Create a custom app in your store admin and copy its Admin API token."},
|
| 77 |
+
]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def _entry_types(rt):
|
| 81 |
+
"""Which keychain entry types this tenant has actually stored. One read, and it never raises
|
| 82 |
+
β a LOCKED keychain must degrade to "nothing connected", never to a 500 on a page whose whole
|
| 83 |
+
job is to tell you what is connected."""
|
| 84 |
+
try:
|
| 85 |
+
import core.keychain as kc
|
| 86 |
+
return {str(e.get("type") or "") for e in (kc.list_entries(rt) or [])}
|
| 87 |
+
except Exception: # noqa: BLE001
|
| 88 |
+
return set()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def directory(rt, uname, is_admin=False):
|
| 92 |
+
"""The composed row set. Pure over the session, so the gate drives it without a request."""
|
| 93 |
+
rows = []
|
| 94 |
+
have = _entry_types(rt)
|
| 95 |
+
|
| 96 |
+
# --- Odoo: the reference connector. Env credentials are tenant #0's only (the R3 rule), so
|
| 97 |
+
# a stored keychain entry is what makes it connected for anybody else.
|
| 98 |
+
rows.append({
|
| 99 |
+
"key": "odoo", "label": "Odoo", "kind": "builtin",
|
| 100 |
+
"desc": "Sales, invoices, products and customers from your Odoo ERP.",
|
| 101 |
+
"state": "connected" if ("odoo" in have or os.environ.get("ODOO_URL")) else "available",
|
| 102 |
+
"manage": "keychain" if is_admin else "",
|
| 103 |
+
})
|
| 104 |
+
|
| 105 |
+
# --- OAuth providers, straight off the registry (so a new entry appears here for free).
|
| 106 |
+
st = oauth_connect.status(rt, uname)
|
| 107 |
+
for slug, meta in oauth_connect.PROVIDERS.items():
|
| 108 |
+
row_st = st.get(slug) or {}
|
| 109 |
+
rows.append({
|
| 110 |
+
"key": slug, "label": meta.get("label") or slug.title(), "kind": "oauth",
|
| 111 |
+
"desc": "Gmail for the email trigger; the same connection unlocks Sheets, Drive "
|
| 112 |
+
"and Calendar later." if slug == "google" else "",
|
| 113 |
+
# THREE distinct states, and the distinction is the point: a provider nobody has
|
| 114 |
+
# configured on this deployment is not the same as one this user has not connected,
|
| 115 |
+
# and until now both rendered identically (the C11 status() gap).
|
| 116 |
+
"state": ("connected" if row_st.get("connected") and not row_st.get("reconnect")
|
| 117 |
+
else "reconnect" if row_st.get("reconnect")
|
| 118 |
+
else "available" if row_st.get("configured")
|
| 119 |
+
else "unconfigured"),
|
| 120 |
+
"connectedAs": row_st.get("email") or "",
|
| 121 |
+
"startUrl": f"/api/v1/oauth/{slug}/start",
|
| 122 |
+
"manage": "oauth",
|
| 123 |
+
"needs": "" if row_st.get("configured") else "the owner registers the client",
|
| 124 |
+
})
|
| 125 |
+
|
| 126 |
+
# --- Token connectors (R8): connectable today, and honest that data flows arrive later.
|
| 127 |
+
for t in TOKEN_CONNECTORS:
|
| 128 |
+
rows.append({
|
| 129 |
+
"key": t["key"], "label": t["label"], "kind": "token", "desc": t["desc"],
|
| 130 |
+
"hint": t["hint"],
|
| 131 |
+
"state": "connected" if t["entry"] in have else "available",
|
| 132 |
+
"manage": "keychain" if is_admin else "",
|
| 133 |
+
# Said once, on the row, rather than in a paragraph somewhere (R13): storing the key
|
| 134 |
+
# is real and useful, and it is not the same as the data being on screen.
|
| 135 |
+
"note": "Storing the key connects the account; reading its data lands in a later "
|
| 136 |
+
"release.",
|
| 137 |
+
})
|
| 138 |
+
|
| 139 |
+
# --- Built-ins that are configuration, not credentials.
|
| 140 |
+
import automation_engine as _eng
|
| 141 |
+
rows.append({
|
| 142 |
+
"key": "brightdata", "label": "Bright Data", "kind": "builtin",
|
| 143 |
+
"desc": "Instagram profile and post capture for the discovery automations.",
|
| 144 |
+
"state": "connected" if _eng.bd_ready() else "unconfigured",
|
| 145 |
+
"needs": "" if _eng.bd_ready() else "AIOS_BRIGHTDATA_KEY is not set on this deployment",
|
| 146 |
+
"manage": "",
|
| 147 |
+
})
|
| 148 |
+
rows.append({
|
| 149 |
+
"key": "webhooks", "label": "Webhooks", "kind": "builtin",
|
| 150 |
+
"desc": "Let another system start an automation by calling a URL.",
|
| 151 |
+
"state": "connected", "manage": "automation",
|
| 152 |
+
})
|
| 153 |
+
|
| 154 |
+
for p in PLANNED:
|
| 155 |
+
rows.append({**p, "state": "planned",
|
| 156 |
+
"needs": "not built yet", "manage": ""})
|
| 157 |
+
return rows
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@router.get("/connectors/directory")
|
| 161 |
+
def connectors_directory(session: Session = Depends(require_session)):
|
| 162 |
+
"""C11: every connector this platform knows about, with its state for THIS reader.
|
| 163 |
+
|
| 164 |
+
Session-gated rather than admin-gated on purpose: a non-admin should be able to see what the
|
| 165 |
+
workspace is connected to (and connect their OWN per-user accounts, which is the whole point
|
| 166 |
+
of R7's per-user slots). `manage` is what carries admin-ness β it is empty for a non-admin,
|
| 167 |
+
so the client renders the card without an action rather than offering a door that 403s.
|
| 168 |
+
"""
|
| 169 |
+
# β `session.admin`, NOT `is_admin`. A `getattr(session, "is_admin", False)` here reads as
|
| 170 |
+
# careful and is silently False for every admin on earth β the manage actions would simply
|
| 171 |
+
# never appear, with nothing to catch it. Attribute name checked against `deps.Session`.
|
| 172 |
+
rows = directory(session.runtime, session.uname, bool(session.admin))
|
| 173 |
+
return {"connectors": rows,
|
| 174 |
+
"counts": {"connected": sum(1 for r in rows if r["state"] == "connected"),
|
| 175 |
+
"available": sum(1 for r in rows if r["state"] == "available"),
|
| 176 |
+
"planned": sum(1 for r in rows if r["state"] == "planned")}}
|
api/routes_forms.py
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_forms.py β β wave-23 item 8 (contract W23-C9): THE PUBLIC FORM DOOR.
|
| 2 |
+
|
| 3 |
+
β BOTH ROUTES HERE ARE UNAUTHENTICATED, AND THAT IS THE POINT. A form link goes to somebody who
|
| 4 |
+
has no account β a supplier, a lead, a warehouse hand with a phone β so `Depends(require_session)`
|
| 5 |
+
is exactly what must NOT be on them. Everything that would normally be decided by a session is
|
| 6 |
+
therefore decided by the TOKEN, and this file is the whole of that decision:
|
| 7 |
+
|
| 8 |
+
* the token is minted server-side (`secrets.token_urlsafe(24)` β 192 bits) and stored on ONE
|
| 9 |
+
view. There is no enumeration to brute-force and no id in the URL to increment.
|
| 10 |
+
* a wrong token answers ONE thing for every tenant, every table and every typo: 403
|
| 11 |
+
`bad_form_token`. It never leaks whether a form exists, whether a tenant exists, or which of
|
| 12 |
+
the two you got wrong β the automation-hook pattern (`routes_automation.py:359-381`) one
|
| 13 |
+
level up, because a public door that answers 404-vs-403 is an oracle.
|
| 14 |
+
* the payload a valid token buys is the SMALLEST thing that can render a form: title,
|
| 15 |
+
description, submit label, and the fields with their labels, types and options. No tenant
|
| 16 |
+
slug, no table key, no row count, no field key that is not on the form, no username.
|
| 17 |
+
|
| 18 |
+
WHAT AN ATTACKER WITH A VALID TOKEN CAN DO, stated plainly because it is the honest boundary:
|
| 19 |
+
append rows to ONE user table, up to the per-token daily cap, with values that pass this file's
|
| 20 |
+
validation. They cannot read a row, cannot see any other field, cannot reach another table, and
|
| 21 |
+
cannot cause an automation to run as anybody (`form_fired` is a HUMAN door by A's design β see
|
| 22 |
+
its contract note in the split doc).
|
| 23 |
+
|
| 24 |
+
β THE `_DEV_FIXTURES` AUDIT (main.py:257-295) applied to this router, because that scar is
|
| 25 |
+
exactly "a thing became publicly reachable and nobody looked": the only bytes these routes can
|
| 26 |
+
return are the ones assembled in `_public_form` below, field by field. There is no passthrough of
|
| 27 |
+
a stored dict anywhere in this file β every response is built from named keys.
|
| 28 |
+
|
| 29 |
+
β PROTECTIONS ARE IN-PROCESS AND THEREFORE PER WORKER. One Space process today, so the sliding
|
| 30 |
+
window is real; the day there are two, both halves of a limit are halved-per-worker rather than
|
| 31 |
+
bypassed. Booked as DEBT rather than described as a guarantee β a rate limit that quietly stops
|
| 32 |
+
being one is worse than none.
|
| 33 |
+
"""
|
| 34 |
+
import hmac
|
| 35 |
+
import secrets
|
| 36 |
+
import time
|
| 37 |
+
|
| 38 |
+
from fastapi import APIRouter, Request
|
| 39 |
+
|
| 40 |
+
from deps import err
|
| 41 |
+
|
| 42 |
+
router = APIRouter(prefix="/api/v1")
|
| 43 |
+
|
| 44 |
+
#: The mint. 24 bytes url-safe = 32 characters, 192 bits β the same shape the automation hook's
|
| 45 |
+
#: per-automation token uses, so there is ONE token strength in the product rather than two.
|
| 46 |
+
TOKEN_BYTES = 24
|
| 47 |
+
|
| 48 |
+
#: The honeypot's field name. It is rendered by `FormPublic.tsx` as a real input, visually
|
| 49 |
+
#: hidden and `autocomplete="off"` + `tabIndex={-1}` β a human never sees it and a keyboard user
|
| 50 |
+
#: never lands on it, so anything that fills it is a script walking the DOM. β Named for
|
| 51 |
+
#: something a bot WANTS to fill; a field called `honeypot` is one `if` away from being skipped.
|
| 52 |
+
HONEYPOT_FIELD = "website_url"
|
| 53 |
+
|
| 54 |
+
#: v1 request protections (contract C9). Per IP and per token respectively.
|
| 55 |
+
RATE_WINDOW_S = 60
|
| 56 |
+
RATE_PER_WINDOW = 30
|
| 57 |
+
DAILY_PER_TOKEN = 500
|
| 58 |
+
MAX_BODY_BYTES = 16 * 1024
|
| 59 |
+
#: One text answer's ceiling. Generous for a paragraph, far below the body cap, and it exists so
|
| 60 |
+
#: a single 15 KB answer cannot be smuggled past a body check that only measures the whole.
|
| 61 |
+
MAX_VALUE_LEN = 4000
|
| 62 |
+
|
| 63 |
+
#: `{ip: [timestamps]}` and `{token: (day, count)}`. Module-level and unbounded-by-design within
|
| 64 |
+
#: a day: an IP list is pruned to the window on every touch, and the token map holds one small
|
| 65 |
+
#: tuple per form that was submitted to today.
|
| 66 |
+
_HITS: dict = {}
|
| 67 |
+
_DAILY: dict = {}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _rate_ok(ip: str, now: float) -> bool:
|
| 71 |
+
"""A sliding window, not a fixed bucket. A fixed one lets a caller spend the whole allowance
|
| 72 |
+
at 11:59:59 and the whole next allowance at 12:00:00 β double the rate at the boundary,
|
| 73 |
+
which is the moment a script is most likely to be hammering."""
|
| 74 |
+
seen = [t for t in _HITS.get(ip, ()) if now - t < RATE_WINDOW_S]
|
| 75 |
+
seen.append(now)
|
| 76 |
+
_HITS[ip] = seen
|
| 77 |
+
# β Prune the WHOLE map occasionally, or a process that has served a million IPs holds a
|
| 78 |
+
# million lists forever. Cheap and amortised: only when the map is already large.
|
| 79 |
+
if len(_HITS) > 4096:
|
| 80 |
+
for k in [k for k, v in _HITS.items() if not v or now - v[-1] > RATE_WINDOW_S]:
|
| 81 |
+
_HITS.pop(k, None)
|
| 82 |
+
return len(seen) <= RATE_PER_WINDOW
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _daily_ok(token: str, now: float) -> bool:
|
| 86 |
+
day = int(now // 86400)
|
| 87 |
+
d, n = _DAILY.get(token, (day, 0))
|
| 88 |
+
if d != day:
|
| 89 |
+
d, n = day, 0
|
| 90 |
+
n += 1
|
| 91 |
+
_DAILY[token] = (d, n)
|
| 92 |
+
return n <= DAILY_PER_TOKEN
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _client_ip(request: Request) -> str:
|
| 96 |
+
"""β `X-Forwarded-For`'s FIRST entry, and only because this app runs behind exactly one
|
| 97 |
+
proxy (the HF Space router). It is caller-controlled, so it is a rate-limit key and NOTHING
|
| 98 |
+
else β never a permission input. Falls back to the socket peer."""
|
| 99 |
+
fwd = request.headers.get("x-forwarded-for") or ""
|
| 100 |
+
first = fwd.split(",")[0].strip()
|
| 101 |
+
return first or (request.client.host if request.client else "?")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _refuse():
|
| 105 |
+
"""THE ONE REFUSAL. Every failure to resolve a token answers this β wrong token, disabled
|
| 106 |
+
form, deleted view, deleted table, a tenant whose store is down. Callers must not branch a
|
| 107 |
+
more specific message out of it: the difference between "no such form" and "that form is
|
| 108 |
+
disabled" tells an enumerator which tokens are real."""
|
| 109 |
+
return err(403, "bad_form_token", "that form link is not valid")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _same(a: str, b: str) -> bool:
|
| 113 |
+
"""Constant-time. A `==` here leaks a token's prefix through timing, one character at a
|
| 114 |
+
time, which is the whole reason the automation hook compares this way too."""
|
| 115 |
+
return hmac.compare_digest(str(a or ""), str(b or ""))
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# --- resolution ------------------------------------------------------------------------------
|
| 119 |
+
#
|
| 120 |
+
# β A FULL SCAN, deliberately, and its cost is BOOKED (DEBT β "the form-token index"). The
|
| 121 |
+
# automation hook resolves the same way (`routes_automation.py:370` walks `known_tenants()`)
|
| 122 |
+
# because the alternative β a global tokenβtenant map β is a second index that has to be written
|
| 123 |
+
# on every mint, regenerate, view delete and table delete, and an index that misses one of those
|
| 124 |
+
# is a live form that answers 403 or, far worse, a dead token that still resolves. With one
|
| 125 |
+
# process, a handful of tenants and a 30/min ceiling in front of it, the scan is affordable; the
|
| 126 |
+
# index becomes worth its risk when either number grows.
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _iter_form_views(rt):
|
| 130 |
+
"""Yield `(table_key, view_id, form_spec)` for every form-enabled view in one tenant.
|
| 131 |
+
|
| 132 |
+
A form spec lives at `view.config.display.form` and carries the token β see W23-C9. Reads
|
| 133 |
+
only; nothing here writes, so a malformed stratum is skipped rather than repaired.
|
| 134 |
+
"""
|
| 135 |
+
import core.user_tables as user_tables
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
tables = user_tables.all_tables(st=rt) or {}
|
| 139 |
+
except Exception: # noqa: BLE001
|
| 140 |
+
return
|
| 141 |
+
for key in list(tables):
|
| 142 |
+
try:
|
| 143 |
+
bucket = rt.get(f"{key}_table_workspace") or {}
|
| 144 |
+
except Exception: # noqa: BLE001
|
| 145 |
+
continue
|
| 146 |
+
if not isinstance(bucket, dict):
|
| 147 |
+
continue
|
| 148 |
+
for ws in bucket.values():
|
| 149 |
+
if not isinstance(ws, dict):
|
| 150 |
+
continue
|
| 151 |
+
for view_id, view in (ws.get("views") or {}).items():
|
| 152 |
+
if not isinstance(view, dict):
|
| 153 |
+
continue
|
| 154 |
+
display = ((view.get("config") or {}).get("display") or {})
|
| 155 |
+
spec = display.get("form")
|
| 156 |
+
if isinstance(spec, dict) and spec.get("token"):
|
| 157 |
+
yield str(key), str(view_id), spec
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _resolve(token: str):
|
| 161 |
+
"""`(runtime, tenant_slug, table_key, form_spec)` for a token, or None.
|
| 162 |
+
|
| 163 |
+
β THE LOOP DOES NOT SHORT-CIRCUIT ON THE TENANT, only on the match. A `break` on the first
|
| 164 |
+
tenant whose store answered would make "the third tenant's form" depend on the first two
|
| 165 |
+
being healthy, and a form that stops working because somebody else's store blipped is the
|
| 166 |
+
kind of failure nobody can reproduce.
|
| 167 |
+
"""
|
| 168 |
+
from harness import runtime as _rt
|
| 169 |
+
|
| 170 |
+
if not token or len(token) < 16:
|
| 171 |
+
return None
|
| 172 |
+
for slug in _rt.known_tenants():
|
| 173 |
+
try:
|
| 174 |
+
rt = _rt.get_runtime(slug)
|
| 175 |
+
except Exception: # noqa: BLE001
|
| 176 |
+
continue
|
| 177 |
+
for table_key, _view_id, spec in _iter_form_views(rt):
|
| 178 |
+
if _same(str(spec.get("token") or ""), token):
|
| 179 |
+
return rt, slug, table_key, spec
|
| 180 |
+
return None
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
# --- the public shape -------------------------------------------------------------------------
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _public_form(rt, table_key: str, spec: dict) -> dict:
|
| 187 |
+
"""The ONLY bytes a valid token buys. Built key by key β there is no `**stored` anywhere in
|
| 188 |
+
this function, which is what makes "nothing else leaks" a property of the code rather than a
|
| 189 |
+
promise about what happens to be in the bag today.
|
| 190 |
+
|
| 191 |
+
β The field ORDER is the form's own (`spec['fields']`), not the table's: the builder let
|
| 192 |
+
somebody arrange these questions, and re-deriving the order from the schema would silently
|
| 193 |
+
rearrange a form every time a column was added.
|
| 194 |
+
"""
|
| 195 |
+
import core.user_tables as user_tables
|
| 196 |
+
|
| 197 |
+
defn = user_tables.get(table_key, st=rt) or {}
|
| 198 |
+
by_key = {f.get("key"): f for f in (defn.get("fields") or []) if isinstance(f, dict)}
|
| 199 |
+
required = {str(k) for k in (spec.get("required") or [])}
|
| 200 |
+
out = []
|
| 201 |
+
for key in (spec.get("fields") or []):
|
| 202 |
+
f = by_key.get(str(key))
|
| 203 |
+
# A field deleted since the form was built is DROPPED, never rendered as a dead input β
|
| 204 |
+
# and never an error: losing one column must not take a live form offline.
|
| 205 |
+
if not f or f.get("source") not in (None, "overlay"):
|
| 206 |
+
continue
|
| 207 |
+
row = {"key": str(f.get("key")), "label": str(f.get("label") or f.get("key")),
|
| 208 |
+
"type": str(f.get("type") or "text"), "required": str(key) in required}
|
| 209 |
+
options = f.get("options")
|
| 210 |
+
if isinstance(options, list) and options:
|
| 211 |
+
row["options"] = [str(o) for o in options][:200]
|
| 212 |
+
if f.get("type") == "rating":
|
| 213 |
+
row["max"] = int(f.get("max") or 5)
|
| 214 |
+
out.append(row)
|
| 215 |
+
return {
|
| 216 |
+
"title": str(spec.get("title") or "")[:200],
|
| 217 |
+
"desc": str(spec.get("desc") or "")[:1000],
|
| 218 |
+
"submitLabel": str(spec.get("submitLabel") or "")[:60] or "Submit",
|
| 219 |
+
"fields": out,
|
| 220 |
+
"honeypot": HONEYPOT_FIELD,
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
# --- validation -------------------------------------------------------------------------------
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _clean_values(fields: list, raw: dict):
|
| 228 |
+
"""`(values, error_sentence)` β REFUSE, NEVER COERCE (C9, and the engine's own law).
|
| 229 |
+
|
| 230 |
+
The temptation with a public form is to be forgiving: read "12 units" as 12, "yes" as
|
| 231 |
+
checked, "tomorrow" as a date. Every one of those writes a number nobody typed into somebody
|
| 232 |
+
else's database, and the person who submitted the form is not there to see it happen. So a
|
| 233 |
+
value that does not answer its own field's question is refused with a sentence naming the
|
| 234 |
+
FIELD LABEL β the only name the submitter has ever seen.
|
| 235 |
+
"""
|
| 236 |
+
out = {}
|
| 237 |
+
for f in fields:
|
| 238 |
+
key, label, ftype = f["key"], f["label"], f["type"]
|
| 239 |
+
v = raw.get(key)
|
| 240 |
+
text = "" if v is None else str(v)
|
| 241 |
+
text = text.strip() if isinstance(v, str) else text
|
| 242 |
+
if len(text) > MAX_VALUE_LEN:
|
| 243 |
+
return None, f"β{label}β is too long β {MAX_VALUE_LEN} characters at most."
|
| 244 |
+
if text == "":
|
| 245 |
+
if f.get("required"):
|
| 246 |
+
return None, f"β{label}β is required."
|
| 247 |
+
continue
|
| 248 |
+
if ftype in ("int", "currency", "pct", "rating"):
|
| 249 |
+
try:
|
| 250 |
+
num = float(text.replace(",", ""))
|
| 251 |
+
except ValueError:
|
| 252 |
+
return None, f"β{label}β must be a number."
|
| 253 |
+
if ftype == "rating":
|
| 254 |
+
top = int(f.get("max") or 5)
|
| 255 |
+
if not (1 <= num <= top) or num != int(num):
|
| 256 |
+
return None, f"β{label}β must be a whole number from 1 to {top}."
|
| 257 |
+
text = str(int(num)) if ftype in ("int", "rating") else str(num)
|
| 258 |
+
elif ftype == "checkbox":
|
| 259 |
+
# The storage contract is '1' or blank (types.ts). Anything a checkbox can actually
|
| 260 |
+
# send is one of these four spellings; anything else is not a checkbox answer.
|
| 261 |
+
if text.lower() not in ("1", "true", "on", "yes", "0", "false", "off", "no"):
|
| 262 |
+
return None, f"β{label}β must be checked or unchecked."
|
| 263 |
+
text = "1" if text.lower() in ("1", "true", "on", "yes") else ""
|
| 264 |
+
elif ftype in ("select", "status"):
|
| 265 |
+
options = f.get("options") or []
|
| 266 |
+
if options and text not in options:
|
| 267 |
+
return None, f"β{label}β must be one of the listed choices."
|
| 268 |
+
elif ftype == "multiselect":
|
| 269 |
+
options = f.get("options") or []
|
| 270 |
+
parts = [p.strip() for p in text.split(",") if p.strip()]
|
| 271 |
+
if options and any(p not in options for p in parts):
|
| 272 |
+
return None, f"β{label}β must be chosen from the listed choices."
|
| 273 |
+
text = ", ".join(parts)
|
| 274 |
+
elif ftype == "date":
|
| 275 |
+
# ISO only. A public form has no timezone, no locale and no user to ask, so
|
| 276 |
+
# "03/04/2026" is genuinely ambiguous and guessing it wrong is a silent data error.
|
| 277 |
+
if len(text) != 10 or text[4] != "-" or text[7] != "-":
|
| 278 |
+
return None, f"β{label}β must be a date."
|
| 279 |
+
try:
|
| 280 |
+
time.strptime(text, "%Y-%m-%d")
|
| 281 |
+
except ValueError:
|
| 282 |
+
return None, f"β{label}β must be a real date."
|
| 283 |
+
elif ftype == "email":
|
| 284 |
+
if "@" not in text or text.startswith("@") or text.endswith("@"):
|
| 285 |
+
return None, f"β{label}β must be an email address."
|
| 286 |
+
out[key] = text
|
| 287 |
+
return out, ""
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# --- the routes -------------------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
@router.get("/forms/{token}")
|
| 294 |
+
def get_form(token: str, request: Request):
|
| 295 |
+
"""Render-time payload for a public form. Rate-limited like the POST: an unauthenticated GET
|
| 296 |
+
that walks every tenant's workspace is the cheapest way to make this process do work."""
|
| 297 |
+
if not _rate_ok(_client_ip(request), time.time()):
|
| 298 |
+
raise err(429, "too_many_requests", "too many requests β wait a moment and try again")
|
| 299 |
+
found = _resolve(token)
|
| 300 |
+
if not found:
|
| 301 |
+
raise _refuse()
|
| 302 |
+
rt, _slug, table_key, spec = found
|
| 303 |
+
return _public_form(rt, table_key, spec)
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
async def _bounded_body(request: Request) -> dict:
|
| 307 |
+
"""The request body, READ WITH A BOUND β never `Body(...)`, never `await request.body()`.
|
| 308 |
+
|
| 309 |
+
β THE FIRST VERSION OF THIS ROUTE TOOK `body: dict = Body(default=None)` AND ITS 16 KB CAP
|
| 310 |
+
CAPPED NOTHING. FastAPI reads and JSON-parses the WHOLE body before the handler's first line
|
| 311 |
+
runs, so a `content-length` check inside the handler is inspected after the allocation it
|
| 312 |
+
claims to prevent β and `content-length` is caller-supplied anyway, so omitting it or sending
|
| 313 |
+
chunked left no bound at all. On the one unauthenticated write path in the product.
|
| 314 |
+
|
| 315 |
+
β It was GREEN, and that is the part worth remembering: the gate leg set the header itself,
|
| 316 |
+
so it only ever exercised the honest path. A protection whose test supplies the very value it
|
| 317 |
+
is protecting against is testing its own politeness.
|
| 318 |
+
|
| 319 |
+
Streaming with a running total is the actual bound: the read STOPS at the ceiling rather than
|
| 320 |
+
discovering afterwards that it should have.
|
| 321 |
+
"""
|
| 322 |
+
size, chunks = 0, []
|
| 323 |
+
async for chunk in request.stream():
|
| 324 |
+
size += len(chunk)
|
| 325 |
+
if size > MAX_BODY_BYTES:
|
| 326 |
+
raise err(413, "body_too_large", "that submission is too large")
|
| 327 |
+
chunks.append(chunk)
|
| 328 |
+
import json
|
| 329 |
+
try:
|
| 330 |
+
parsed = json.loads(b"".join(chunks) or b"{}")
|
| 331 |
+
except ValueError:
|
| 332 |
+
raise err(400, "no_values", "that submission could not be read")
|
| 333 |
+
return parsed if isinstance(parsed, dict) else {}
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@router.post("/forms/{token}")
|
| 337 |
+
async def submit_form(token: str, request: Request):
|
| 338 |
+
"""One submission: validate, append the row, fire `form_submitted`.
|
| 339 |
+
|
| 340 |
+
β THE ORDER IS LOAD-BEARING. The row is written FIRST and the trigger fired second, off the
|
| 341 |
+
id the write returned β so an automation can never run against a record that does not exist,
|
| 342 |
+
and a trigger that raises cannot cost the submitter the answer they just typed.
|
| 343 |
+
"""
|
| 344 |
+
now = time.time()
|
| 345 |
+
if not _rate_ok(_client_ip(request), now):
|
| 346 |
+
raise err(429, "too_many_requests", "too many requests β wait a moment and try again")
|
| 347 |
+
body = await _bounded_body(request)
|
| 348 |
+
found = _resolve(token)
|
| 349 |
+
if not found:
|
| 350 |
+
raise _refuse()
|
| 351 |
+
rt, _slug, table_key, spec = found
|
| 352 |
+
|
| 353 |
+
values = (body or {}).get("values")
|
| 354 |
+
if not isinstance(values, dict):
|
| 355 |
+
raise err(400, "no_values", "that submission was empty")
|
| 356 |
+
|
| 357 |
+
# β THE HONEYPOT ANSWERS 200 AND WRITES NOTHING. A 403 would tell the script it was
|
| 358 |
+
# detected, and the next version of it simply stops filling the field. A success it can
|
| 359 |
+
# never verify is the only answer that costs the operator nothing to give.
|
| 360 |
+
if str(values.get(HONEYPOT_FIELD) or "").strip():
|
| 361 |
+
return {"ok": True}
|
| 362 |
+
|
| 363 |
+
form = _public_form(rt, table_key, spec)
|
| 364 |
+
if not form["fields"]:
|
| 365 |
+
raise _refuse()
|
| 366 |
+
clean, problem = _clean_values(form["fields"], values)
|
| 367 |
+
if problem:
|
| 368 |
+
raise err(400, "invalid_submission", problem)
|
| 369 |
+
|
| 370 |
+
# β THE DAILY CAP IS SPENT ON A WRITE, NOT ON A REQUEST, and the order is the whole point.
|
| 371 |
+
# Counted before validation β where it was first β 500 malformed POSTs take a LIVE form
|
| 372 |
+
# offline for 24 hours, and one address can send them in about 17 minutes under the per-IP
|
| 373 |
+
# window. The cap exists to bound what reaches the tenant's table; pacing abuse is the
|
| 374 |
+
# window's job, and a refusal costs the attacker the same either way.
|
| 375 |
+
if not _daily_ok(token, time.time()):
|
| 376 |
+
raise err(429, "form_daily_cap",
|
| 377 |
+
"this form has reached today's submission limit β try again tomorrow")
|
| 378 |
+
|
| 379 |
+
import core.user_tables as user_tables
|
| 380 |
+
|
| 381 |
+
# β THE STAMP. `user_tables.add_row` filters values to the table's own field keys, so a
|
| 382 |
+
# `created_by` pair passed in `values` would simply be dropped β the row itself has no meta
|
| 383 |
+
# slot today. The `username` argument is the channel that DOES survive: it rides the
|
| 384 |
+
# `record_created` row event, so every trigger and audit downstream sees `form:<prefix>`
|
| 385 |
+
# rather than a blank actor. Booked as a dated amendment in the split doc; a persistent
|
| 386 |
+
# per-row stamp needs `add_row` to grow one, which is SESSION A's file.
|
| 387 |
+
actor = f"form:{token[:8]}"
|
| 388 |
+
row_id = user_tables.add_row(table_key, clean, username=actor, st=rt)
|
| 389 |
+
if not row_id:
|
| 390 |
+
raise err(409, "not_accepted", "that submission could not be saved β try again later")
|
| 391 |
+
|
| 392 |
+
# W23-W7 β A's FROZEN signature, called verbatim (CP1-a). Failure here must not turn a
|
| 393 |
+
# SAVED row into an error for the submitter: the answer is theirs and it is already stored.
|
| 394 |
+
try:
|
| 395 |
+
import automation_engine as engine
|
| 396 |
+
engine.form_fired(rt, table_key, row_id, values=clean, form_token=token)
|
| 397 |
+
except Exception: # noqa: BLE001
|
| 398 |
+
pass
|
| 399 |
+
return {"ok": True}
|
api/routes_nav.py
CHANGED
|
@@ -10,6 +10,8 @@ The full rule set β archived is invisible to everyone, `group_only` rows are e
|
|
| 10 |
`prefs.json` Library preference is deliberately NOT applied β is documented on
|
| 11 |
`core.perms.nav_pages`, which is where it belongs: one place, both callers.
|
| 12 |
"""
|
|
|
|
|
|
|
| 13 |
from fastapi import APIRouter, Body, Depends
|
| 14 |
|
| 15 |
from deps import Session, err, perms, require_session
|
|
@@ -50,6 +52,33 @@ _ICON_SHAPES = frozenset({"folder", "star", "flag", "tag", "bookmark", "grid",
|
|
| 50 |
_ICON_TONES = frozenset({"neutral", "blue", "green", "yellow", "red"})
|
| 51 |
_MAX_NAV_NAME = 60
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
def _clean_nav_prefs(raw, page_keys):
|
| 55 |
"""Validated wholesale replacement, the `clean_folders` posture: prune, never invent.
|
|
@@ -131,6 +160,43 @@ def _read_nav_meta(runtime):
|
|
| 131 |
return out
|
| 132 |
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
@router.get("/nav")
|
| 135 |
def nav(session: Session = Depends(require_session)):
|
| 136 |
"""`{pages: [{key,label,source?,chrome}], landing}`.
|
|
@@ -220,7 +286,72 @@ def nav(session: Session = Depends(require_session)):
|
|
| 220 |
landing = perms.landing_page(session.user)
|
| 221 |
if landing and not any(p.get("key") == landing for p in pages):
|
| 222 |
landing = pages[0].get("key")
|
| 223 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
|
| 226 |
@router.post("/nav/meta")
|
|
|
|
| 10 |
`prefs.json` Library preference is deliberately NOT applied β is documented on
|
| 11 |
`core.perms.nav_pages`, which is where it belongs: one place, both callers.
|
| 12 |
"""
|
| 13 |
+
import time
|
| 14 |
+
|
| 15 |
from fastapi import APIRouter, Body, Depends
|
| 16 |
|
| 17 |
from deps import Session, err, perms, require_session
|
|
|
|
| 52 |
_ICON_TONES = frozenset({"neutral", "blue", "green", "yellow", "red"})
|
| 53 |
_MAX_NAV_NAME = 60
|
| 54 |
|
| 55 |
+
#: WAVE 23 (contract C10 / ruling R7) β the Home landing's RECENTS.
|
| 56 |
+
#:
|
| 57 |
+
#: PER-USER, like `nav_prefs` two buckets up and unlike `nav_meta`: what I opened last is
|
| 58 |
+
#: nobody else's business, and a tenant-wide "recently opened" would be a surveillance feature
|
| 59 |
+
#: rather than a convenience one.
|
| 60 |
+
#:
|
| 61 |
+
#: β A MAP KEYED BY PAGE, NOT AN APPEND LOG, and the difference is the whole feature.
|
| 62 |
+
#: `{username: {pageKey: <epoch seconds>}}` β re-opening a database OVERWRITES its stamp. An
|
| 63 |
+
#: append-only list capped at 50 fills with fifty copies of the same ten databases inside one
|
| 64 |
+
#: working session, and the cap then evicts OLDEST-FIRST: the tenth database you touched falls
|
| 65 |
+
#: off the list while forty slots hold repeat visits to the first. Keying by page makes
|
| 66 |
+
#: "recent" mean what the word means, and makes the cap bound the number of DATABASES
|
| 67 |
+
#: remembered rather than the number of clicks.
|
| 68 |
+
_NAV_RECENTS_KEY = "nav_recents"
|
| 69 |
+
_MAX_RECENTS = 50
|
| 70 |
+
|
| 71 |
+
#: β EPOCH SECONDS (UTC by definition), never a formatted stamp β and this is a correction of a
|
| 72 |
+
#: precedent, not a preference. `user_tables.create` writes
|
| 73 |
+
#: `datetime.now().strftime('%Y-%m-%dT%H:%M:%S')`: naive LOCAL time, no offset. A browser parses
|
| 74 |
+
#: that string as its OWN local time, so on a UTC host read by a non-UTC reader "opened 30
|
| 75 |
+
#: minutes ago" renders as "opened 7 hours ago" and the Today / Past-7-days buckets misfile β
|
| 76 |
+
#: with nothing to go red, because both ends are internally consistent. An integer instant has
|
| 77 |
+
#: no such reading. (D-18 made the same correction for notification stamps AFTER the defect
|
| 78 |
+
#: shipped; this is that lesson applied before it.)
|
| 79 |
+
def _now() -> int:
|
| 80 |
+
return int(time.time())
|
| 81 |
+
|
| 82 |
|
| 83 |
def _clean_nav_prefs(raw, page_keys):
|
| 84 |
"""Validated wholesale replacement, the `clean_folders` posture: prune, never invent.
|
|
|
|
| 160 |
return out
|
| 161 |
|
| 162 |
|
| 163 |
+
def _read_recents(runtime, uname, allowed):
|
| 164 |
+
"""This user's recents, newest first, PRUNED to what they may currently see.
|
| 165 |
+
|
| 166 |
+
β PRUNED ON READ, never on write, and both halves of that are deliberate:
|
| 167 |
+
|
| 168 |
+
Β· the WRITE happens on every route open β it is the one hot path this file has β so it
|
| 169 |
+
must not build the whole nav to validate one key;
|
| 170 |
+
Β· a key whose grant was REVOKED must stop being offered without anyone running a
|
| 171 |
+
migration, and must come back if the grant does. That is `_clean_nav_prefs`' own
|
| 172 |
+
prune-never-invent posture, applied to a second bucket for the same reason.
|
| 173 |
+
|
| 174 |
+
A key that is not in `allowed` therefore reaches the store and never reaches a screen β
|
| 175 |
+
which also means a stuffed key cannot be used to discover what exists: it comes back only
|
| 176 |
+
if the session could already see it.
|
| 177 |
+
"""
|
| 178 |
+
try:
|
| 179 |
+
stored = (runtime.get(_NAV_RECENTS_KEY) or {}).get(uname) or {}
|
| 180 |
+
except Exception:
|
| 181 |
+
return [] # a store blip must not take the nav down with it
|
| 182 |
+
if not isinstance(stored, dict):
|
| 183 |
+
return []
|
| 184 |
+
out = []
|
| 185 |
+
for key, at in stored.items():
|
| 186 |
+
key = str(key)
|
| 187 |
+
if allowed is not None and key not in allowed:
|
| 188 |
+
continue
|
| 189 |
+
try:
|
| 190 |
+
at = int(at)
|
| 191 |
+
except (TypeError, ValueError):
|
| 192 |
+
continue # a stamp this build cannot read is not a stamp
|
| 193 |
+
if at <= 0:
|
| 194 |
+
continue
|
| 195 |
+
out.append({"key": key, "at": at})
|
| 196 |
+
out.sort(key=lambda r: r["at"], reverse=True)
|
| 197 |
+
return out[:_MAX_RECENTS]
|
| 198 |
+
|
| 199 |
+
|
| 200 |
@router.get("/nav")
|
| 201 |
def nav(session: Session = Depends(require_session)):
|
| 202 |
"""`{pages: [{key,label,source?,chrome}], landing}`.
|
|
|
|
| 286 |
landing = perms.landing_page(session.user)
|
| 287 |
if landing and not any(p.get("key") == landing for p in pages):
|
| 288 |
landing = pages[0].get("key")
|
| 289 |
+
# WAVE 23 (C10 / R7) β the Home landing's recents, on the payload the client already asks
|
| 290 |
+
# for. A second round trip for a list this short, computed from a bucket this route is
|
| 291 |
+
# already holding the store open for, would be a request per page load for no gain.
|
| 292 |
+
#
|
| 293 |
+
# β `allowed` IS THE ASSEMBLED PAGE LIST, not `_visible_top_keys`. The latter reads
|
| 294 |
+
# `perms.nav_pages` alone, which does NOT include this tenant's `ut_*` databases β those are
|
| 295 |
+
# merged into `pages` above, by this route, after the permission wall. Pruning against the
|
| 296 |
+
# narrower set would have silently dropped every user database from Home's recents: the
|
| 297 |
+
# exact surface R7 is about, invisible, with every gate green.
|
| 298 |
+
recents = _read_recents(session.runtime, session.uname,
|
| 299 |
+
{str(p.get("key", "")) for p in pages})
|
| 300 |
+
return {"pages": pages, "landing": landing, "recents": recents}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
@router.post("/nav/opened")
|
| 304 |
+
def nav_opened(body: dict = Body(default=None),
|
| 305 |
+
session: Session = Depends(require_session)):
|
| 306 |
+
"""WAVE 23 (C10) β stamp a page as JUST OPENED. Fire-and-forget from the client.
|
| 307 |
+
|
| 308 |
+
The client calls this on every route commit, so this is the only write in this file on a
|
| 309 |
+
hot path, and three things follow from that:
|
| 310 |
+
|
| 311 |
+
Β· `flush='async'` β the coalescing mode ([[store-async-flush]]). A blocking upload per
|
| 312 |
+
page open against an HF-Dataset-backed store would put a network round trip inside every
|
| 313 |
+
navigation. `nav_prefs`/`nav_meta` stay `sync` because a folder rename is not a hot path;
|
| 314 |
+
this is.
|
| 315 |
+
Β· NO VALIDATION OF THE KEY against the nav. Building the page list to check one string
|
| 316 |
+
would make the stamp cost more than the navigation that triggered it β and it would buy
|
| 317 |
+
nothing, because the READ prunes to what the session may currently see. An unknown or
|
| 318 |
+
revoked key is stored and never served back.
|
| 319 |
+
Β· THE MAP IS CAPPED HERE TOO. Read-side capping alone would let a hostile or buggy client
|
| 320 |
+
grow one user's document without bound; `_MAX_RECENTS` entries survive, oldest first to
|
| 321 |
+
go, which is the same rule the read applies.
|
| 322 |
+
"""
|
| 323 |
+
body = body if isinstance(body, dict) else {}
|
| 324 |
+
key = str(body.get("key") or "").strip()[:60]
|
| 325 |
+
if not key:
|
| 326 |
+
raise err(400, "bad_request", "no page was named")
|
| 327 |
+
if not session.runtime.available():
|
| 328 |
+
raise err(503, "store_unavailable",
|
| 329 |
+
"the tenant store is unavailable β nothing was recorded")
|
| 330 |
+
stamp, uname = _now(), session.uname
|
| 331 |
+
|
| 332 |
+
def _up(data):
|
| 333 |
+
data = data if isinstance(data, dict) else {}
|
| 334 |
+
mine = dict(data.get(uname) or {}) if isinstance(data.get(uname), dict) else {}
|
| 335 |
+
mine[key] = stamp
|
| 336 |
+
if len(mine) > _MAX_RECENTS:
|
| 337 |
+
# Oldest first. `int(v)` guarded: a stamp an older build wrote in another shape
|
| 338 |
+
# sorts as 0 and is the first thing evicted, which is the right answer for a value
|
| 339 |
+
# this route can no longer read.
|
| 340 |
+
def _at(item):
|
| 341 |
+
try:
|
| 342 |
+
return int(item[1])
|
| 343 |
+
except (TypeError, ValueError):
|
| 344 |
+
return 0
|
| 345 |
+
mine = dict(sorted(mine.items(), key=_at, reverse=True)[:_MAX_RECENTS])
|
| 346 |
+
data[uname] = mine
|
| 347 |
+
return data
|
| 348 |
+
|
| 349 |
+
try:
|
| 350 |
+
session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async')
|
| 351 |
+
except Exception:
|
| 352 |
+
raise err(503, "store_unavailable",
|
| 353 |
+
"the tenant store refused the write β nothing was recorded")
|
| 354 |
+
return {"key": key, "at": stamp}
|
| 355 |
|
| 356 |
|
| 357 |
@router.post("/nav/meta")
|
api/routes_tables.py
CHANGED
|
@@ -375,13 +375,37 @@ def add_field(table_key: str, body: dict = Body(default=None),
|
|
| 375 |
ut = _ut()
|
| 376 |
field = ut.add_field(table_key, body or {}, st=session.runtime)
|
| 377 |
if not field:
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
return {"field": field}
|
| 383 |
|
| 384 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
@router.patch("/tables/{table_key}/fields/{fkey}")
|
| 386 |
def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
| 387 |
session: Session = Depends(require_session)):
|
|
|
|
| 375 |
ut = _ut()
|
| 376 |
field = ut.add_field(table_key, body or {}, st=session.runtime)
|
| 377 |
if not field:
|
| 378 |
+
# β D-46 CLOSED (wave 23) β the C8 flow law gets its OWN sentence. `add_field` answers
|
| 379 |
+
# None for every refusal, so this route said "check the name and type" to somebody whose
|
| 380 |
+
# name and type were fine and whose automation column named a flow that does not exist.
|
| 381 |
+
# A refusal that misdirects is worse than a bare 400: it sends the reader to look at the
|
| 382 |
+
# one thing that was never wrong. Checked HERE, in the route's own words, because the
|
| 383 |
+
# law itself stays enforced in `user_tables.flow_bound` β this narrates it, never
|
| 384 |
+
# re-implements it (a second copy of the rule is how two doors start disagreeing).
|
| 385 |
+
raise err(400, "refused", _refusal_sentence(ut, session, body or {}))
|
| 386 |
return {"field": field}
|
| 387 |
|
| 388 |
|
| 389 |
+
def _refusal_sentence(ut, session, body):
|
| 390 |
+
"""Why was this column refused? The specific reason when we can name one, the general list
|
| 391 |
+
otherwise β never a specific-sounding guess."""
|
| 392 |
+
bag = (body or {}).get("automation")
|
| 393 |
+
if isinstance(bag, dict):
|
| 394 |
+
flow = str(bag.get("flowId") or "").strip()
|
| 395 |
+
if not flow:
|
| 396 |
+
return ("an automation column has to name the automation that fills it β pick a "
|
| 397 |
+
"flow, or make this an ordinary column")
|
| 398 |
+
if not ut.flow_bound(bag, st=session.runtime):
|
| 399 |
+
return (f"this column names automation {flow!r}, which does not exist in this "
|
| 400 |
+
f"workspace β it may have been deleted; pick a flow that is still there")
|
| 401 |
+
kind = str((body or {}).get("type") or "").strip()
|
| 402 |
+
if kind and kind not in ut.UT_FIELD_TYPES:
|
| 403 |
+
return (f"{kind!r} is not a column type here (types: "
|
| 404 |
+
f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 405 |
+
return (f"the column was refused β check the name and type, or the table may be at its "
|
| 406 |
+
f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 407 |
+
|
| 408 |
+
|
| 409 |
@router.patch("/tables/{table_key}/fields/{fkey}")
|
| 410 |
def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
| 411 |
session: Session = Depends(require_session)):
|
api/routes_templates.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_templates.py β WAVE 23 item 7 (ruling R10, contract C12): the template doors.
|
| 2 |
+
|
| 3 |
+
GET /api/v1/templates?table=<page key> what can be applied HERE
|
| 4 |
+
POST /api/v1/templates/{key}/apply {table} apply it, to the CALLING USER's own views
|
| 5 |
+
|
| 6 |
+
Both session-gated. Neither is public and neither is admin-only: applying a template writes the
|
| 7 |
+
caller's PERSONAL saved views, which is something every user does to their own workspace all day
|
| 8 |
+
(`table_store.save_view(..., shared=False)`). The registry itself is platform-curated code
|
| 9 |
+
(`platform/core/view_templates.py`) β R10 explicitly rules out an end-user authoring UI this
|
| 10 |
+
wave, so there is no write door for the registry at all.
|
| 11 |
+
|
| 12 |
+
β THE WALL IS THE SAME PREDICATE THE NAV USES, per topic, and it is applied before anything is
|
| 13 |
+
read or written:
|
| 14 |
+
Β· `ut_*` β `user_tables.may_open` (creator, admin, or a share grant), exactly as
|
| 15 |
+
`routes_tables._defn_or_refuse` does. `session.require` would 403 every ut key, because a
|
| 16 |
+
user table is deliberately not a module (the split `/nav/schema/{key}` already makes).
|
| 17 |
+
Β· a built-in topic β `session.require(key)`, the module grant.
|
| 18 |
+
A key that is neither is a 404 with the same sentence for both: this route is not a directory of
|
| 19 |
+
what exists.
|
| 20 |
+
|
| 21 |
+
β REFUSE, NEVER PARTIALLY APPLY. `view_templates.missing_columns` runs against the TARGET's live
|
| 22 |
+
contract, and a template naming a column the target does not have comes back 400 with the
|
| 23 |
+
columns listed. The alternative β writing the views anyway β is the `_seed_wave17` failure
|
| 24 |
+
verbatim: `clean_filter_tree` DROPS a leaf on an unknown column, and a view whose only condition
|
| 25 |
+
was dropped shows EVERY row under a name that promises a shortlist.
|
| 26 |
+
"""
|
| 27 |
+
import uuid
|
| 28 |
+
|
| 29 |
+
from fastapi import APIRouter, Body, Depends
|
| 30 |
+
|
| 31 |
+
from deps import Session, err, perms, require_session
|
| 32 |
+
|
| 33 |
+
router = APIRouter(prefix="/api/v1")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _templates():
|
| 37 |
+
import core.view_templates as view_templates
|
| 38 |
+
return view_templates
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _target_or_refuse(session, table_key):
|
| 42 |
+
"""`(field_keys, source_label)` for a table this session may open β or a refusal.
|
| 43 |
+
|
| 44 |
+
The field keys are the LIVE contract, read the same way each topic's own reader reads it, so
|
| 45 |
+
a template can never be offered against a column list this end assembled by hand.
|
| 46 |
+
"""
|
| 47 |
+
key = str(table_key or '').strip()
|
| 48 |
+
if not key:
|
| 49 |
+
raise err(400, "bad_request", "no database was named")
|
| 50 |
+
if key.startswith('ut_'):
|
| 51 |
+
import core.user_tables as user_tables
|
| 52 |
+
defn = user_tables.get(key, st=session.runtime)
|
| 53 |
+
if not defn:
|
| 54 |
+
raise err(404, "unknown_table", "that database does not exist")
|
| 55 |
+
if not user_tables.may_open(key, session.uname, session.admin, st=session.runtime):
|
| 56 |
+
raise err(403, "forbidden", "that database belongs to another user")
|
| 57 |
+
fields = [str(f.get('key')) for f in (defn.get('fields') or []) if f.get('key')]
|
| 58 |
+
# A user table has no fixed topic. Its SOURCE label is left blank so `offer` gates on
|
| 59 |
+
# columns alone β which is the honest answer for a table whose shape its owner decides,
|
| 60 |
+
# and is exactly why the column test is the real contract (view_templates' own note).
|
| 61 |
+
return fields, ''
|
| 62 |
+
if key not in ('customer_data', 'product_data'):
|
| 63 |
+
raise err(404, "unknown_table", "that database does not exist")
|
| 64 |
+
session.require(key)
|
| 65 |
+
import aios_grid
|
| 66 |
+
if key == 'product_data':
|
| 67 |
+
return [f['key'] for f in aios_grid.product_fields()], 'odoo_product'
|
| 68 |
+
return [f['key'] for f in aios_grid.FIELDS], 'odoo_customer'
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
@router.get("/templates")
|
| 72 |
+
def list_templates(table: str = "", session: Session = Depends(require_session)):
|
| 73 |
+
"""What can be applied to THIS database. `{table, templates: [...]}`.
|
| 74 |
+
|
| 75 |
+
Filtered by the target's real columns, so the picker cannot offer something the apply door
|
| 76 |
+
would refuse β one predicate, two callers (`view_templates.offer` wraps
|
| 77 |
+
`missing_columns`, which is the same function the POST below re-runs).
|
| 78 |
+
"""
|
| 79 |
+
fields, source = _target_or_refuse(session, table)
|
| 80 |
+
vt = _templates()
|
| 81 |
+
return {"table": table, "templates": vt.offer(fields, source or None)}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/templates/{key}/apply")
|
| 85 |
+
def apply_template(key: str, body: dict = Body(default=None),
|
| 86 |
+
session: Session = Depends(require_session)):
|
| 87 |
+
"""Apply a template to a database, as the CALLING USER's own saved views.
|
| 88 |
+
|
| 89 |
+
IDEMPOTENT BY PINNED VIEW ID (`tpl_<template>_<suffix>`): a second click updates the same
|
| 90 |
+
views rather than minting "Past due 2". β That matters more than it sounds β `save_view`
|
| 91 |
+
de-duplicates NAMES by appending a number, so an unpinned id would make every re-apply a
|
| 92 |
+
fresh copy and the store would fill with numbered near-duplicates nobody asked for.
|
| 93 |
+
"""
|
| 94 |
+
body = body if isinstance(body, dict) else {}
|
| 95 |
+
fields, _source = _target_or_refuse(session, body.get("table"))
|
| 96 |
+
vt = _templates()
|
| 97 |
+
tpl = vt.get(key)
|
| 98 |
+
if not tpl:
|
| 99 |
+
raise err(404, "unknown_template", "that template does not exist")
|
| 100 |
+
missing = vt.missing_columns(tpl, fields)
|
| 101 |
+
if missing:
|
| 102 |
+
# NAMED, not counted. "3 columns are missing" is a sentence the reader cannot act on;
|
| 103 |
+
# the column keys are what tells them this is the wrong database for this template.
|
| 104 |
+
raise err(400, "missing_columns",
|
| 105 |
+
"this database does not have the columns that template needs: "
|
| 106 |
+
+ ", ".join(missing))
|
| 107 |
+
ws_key = vt.workspace_key(body.get("table"))
|
| 108 |
+
if not ws_key:
|
| 109 |
+
raise err(404, "unknown_table", "that database does not exist")
|
| 110 |
+
if not session.runtime.available():
|
| 111 |
+
raise err(503, "store_unavailable",
|
| 112 |
+
"the tenant store is unavailable β nothing was applied")
|
| 113 |
+
import core.table_store as table_store
|
| 114 |
+
ops = table_store.make(ws_key, st=session.runtime)
|
| 115 |
+
applied = []
|
| 116 |
+
for view in tpl.get('views') or ():
|
| 117 |
+
# β A COPY PER APPLY, and `createdBy` STAMPED HERE. The registry's dicts are module-level
|
| 118 |
+
# β mutating one would write this caller's username into the template every other tenant
|
| 119 |
+
# then reads, which is a cross-tenant leak with no symptom until two people apply the
|
| 120 |
+
# same template. Same invariant `routes_nav`'s in-place merge documents from the other
|
| 121 |
+
# side.
|
| 122 |
+
payload = dict(view)
|
| 123 |
+
payload['config'] = dict(view.get('config') or {})
|
| 124 |
+
payload['createdBy'] = session.uname
|
| 125 |
+
saved = ops.save_view(session.uname, payload, shared=False)
|
| 126 |
+
applied.append({"id": payload['id'], "name": saved.get('name') or payload['name']})
|
| 127 |
+
|
| 128 |
+
alert_id = None
|
| 129 |
+
if tpl.get('alert') and applied:
|
| 130 |
+
# The template's own view, watched. `core.alerts.create` stores the view BY ID rather
|
| 131 |
+
# than a copy of its filter tree, so an alert made here keeps meaning "tell me about
|
| 132 |
+
# this view" even after its owner edits it.
|
| 133 |
+
topic = ('product' if body.get("table") == 'product_data'
|
| 134 |
+
else 'customer' if body.get("table") == 'customer_data'
|
| 135 |
+
else str(body.get("table")))
|
| 136 |
+
try:
|
| 137 |
+
import core.alerts as alerts
|
| 138 |
+
alert_id = f"al_{uuid.uuid4().hex[:12]}"
|
| 139 |
+
alerts.create(alert_id, view_id=applied[0]["id"], topic=topic,
|
| 140 |
+
owner=session.uname, label=applied[0]["name"],
|
| 141 |
+
st=session.runtime)
|
| 142 |
+
except Exception:
|
| 143 |
+
# β THE VIEWS ARE ALREADY WRITTEN AND THAT IS THE POINT: a failed alert must not
|
| 144 |
+
# un-apply a template that worked. The response says which half landed rather than
|
| 145 |
+
# reporting a total failure over a partial success.
|
| 146 |
+
alert_id = None
|
| 147 |
+
return {"key": key, "table": body.get("table"), "views": applied,
|
| 148 |
+
"alert": alert_id, "alerted": bool(alert_id)}
|
platform/aios_grid.py
CHANGED
|
@@ -109,9 +109,15 @@ def _round(v):
|
|
| 109 |
#: megabyte-per-row tax on the whole store. Editable by NATURE (deliberately NOT in
|
| 110 |
#: `READONLY_CUSTOM_TYPES`): the ref is what the upload endpoint hands back, and the client PATCHes
|
| 111 |
#: it through the ordinary overlay wall rather than the asset route writing cells behind it.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| 113 |
"checkbox", "phone", "email", "url", "rating", "created_time", "formula",
|
| 114 |
-
"automation", "image"}
|
| 115 |
#: User-created types whose CELLS are read-only: their values are computed (formula β client
|
| 116 |
#: side, any error degrades to BLANK) or system-owned (created_time = the row's `_created`).
|
| 117 |
#: Emitted with the cohort column's read-only mechanism β `source: 'odoo'` + `derived` β so
|
|
|
|
| 109 |
#: megabyte-per-row tax on the whole store. Editable by NATURE (deliberately NOT in
|
| 110 |
#: `READONLY_CUSTOM_TYPES`): the ref is what the upload endpoint hands back, and the client PATCHes
|
| 111 |
#: it through the ordinary overlay wall rather than the asset route writing cells behind it.
|
| 112 |
+
#: β WAVE 23 (C7) β `json` joined: a cell holding a whole DOCUMENT (an Instagram comment thread,
|
| 113 |
+
#: a webhook payload, a scraped blob) that opens in its own viewer instead of being flattened
|
| 114 |
+
#: into one unreadable line. It is EDITABLE by nature, like `image`: the value is still a plain
|
| 115 |
+
#: string on the wire, so it rides the ordinary overlay wall β what makes it a json field is that
|
| 116 |
+
#: `grid_events` REFUSES a write that does not parse (a column promising structure must not
|
| 117 |
+
#: silently hold something that isn't).
|
| 118 |
CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| 119 |
"checkbox", "phone", "email", "url", "rating", "created_time", "formula",
|
| 120 |
+
"automation", "image", "json"}
|
| 121 |
#: User-created types whose CELLS are read-only: their values are computed (formula β client
|
| 122 |
#: side, any error degrades to BLANK) or system-owned (created_time = the row's `_created`).
|
| 123 |
#: Emitted with the cohort column's read-only mechanism β `source: 'odoo'` + `derived` β so
|
platform/core/alerts.py
CHANGED
|
@@ -209,6 +209,44 @@ def _queue(rec, pids, labels, st=None):
|
|
| 209 |
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
|
| 210 |
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
def inbox(user, st=None, limit=100):
|
| 213 |
"""`{'unread': int, 'items': [...]}` β newest first, for the badge and the pane."""
|
| 214 |
try:
|
|
|
|
| 209 |
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
|
| 210 |
|
| 211 |
|
| 212 |
+
def notify(owner, label, *, topic='automation', key='', row_id='', detail='', st=None):
|
| 213 |
+
"""β WAVE 23 (C6) β put ONE notification in a named user's inbox, for a producer that is not
|
| 214 |
+
a view-alert evaluation.
|
| 215 |
+
|
| 216 |
+
The automation engine needs the bell to ring when records arrive at a review gate, and that
|
| 217 |
+
is not an alert over a view: there is no filter tree, no `matched` set, no entrant diff. It
|
| 218 |
+
reached for `_queue` directly during the wave, which would have made a private function a
|
| 219 |
+
cross-module contract β so this is the public door instead, and `_queue` stays the internal
|
| 220 |
+
half of `evaluate()`.
|
| 221 |
+
|
| 222 |
+
The inbox shape is UNCHANGED, deliberately: `AlertsPane` reads one list, and a second row
|
| 223 |
+
shape would mean a client that must branch on where a notification came from. `alertId`
|
| 224 |
+
carries the producer's key so a click-through can route (W23-W5).
|
| 225 |
+
"""
|
| 226 |
+
owner = str(owner or '').strip()
|
| 227 |
+
if not owner or not label:
|
| 228 |
+
return None
|
| 229 |
+
at = _now_iso()
|
| 230 |
+
item = {'id': f"{key or topic}:{row_id or 'n'}:{at}",
|
| 231 |
+
'alertId': str(key or ''), 'viewId': '', 'topic': str(topic),
|
| 232 |
+
'rowId': str(row_id or ''), 'label': str(detail or label),
|
| 233 |
+
'alertLabel': str(label), 'at': at, 'read': False}
|
| 234 |
+
|
| 235 |
+
def _apply(data):
|
| 236 |
+
box = list(data.get(owner) or [])
|
| 237 |
+
box.append(item)
|
| 238 |
+
if len(box) > MAX_NOTIFICATIONS:
|
| 239 |
+
unread = [n for n in box if not n.get('read')]
|
| 240 |
+
read = [n for n in box if n.get('read')]
|
| 241 |
+
keep_read = read[max(0, len(unread) + len(read) - MAX_NOTIFICATIONS):]
|
| 242 |
+
box = sorted(unread + keep_read, key=lambda n: str(n.get('at') or ''))
|
| 243 |
+
data[owner] = box
|
| 244 |
+
return data
|
| 245 |
+
|
| 246 |
+
_st(st).update(NOTIFICATIONS_KEY, _apply, flush='async')
|
| 247 |
+
return item
|
| 248 |
+
|
| 249 |
+
|
| 250 |
def inbox(user, st=None, limit=100):
|
| 251 |
"""`{'unread': int, 'items': [...]}` β newest first, for the badge and the pane."""
|
| 252 |
try:
|
platform/core/grid_events.py
CHANGED
|
@@ -38,11 +38,13 @@ so the import stays and the violation is RECORDED rather than hidden. It is the
|
|
| 38 |
down, and this note is the marker for that work.
|
| 39 |
"""
|
| 40 |
import datetime as dt
|
|
|
|
| 41 |
from dataclasses import dataclass, field
|
| 42 |
from typing import MutableMapping, Optional
|
| 43 |
|
| 44 |
import core.store as store
|
| 45 |
import core.table_store as _tstore # wave-9 I17: the shared-view authorisation predicates
|
|
|
|
| 46 |
import core.users as users
|
| 47 |
import harness.telemetry as _tel # _tel.error() = THE error sink (all caught errors log)
|
| 48 |
import modules.cohort as cohort_mod
|
|
@@ -1703,6 +1705,26 @@ def handle_one(event, ctx):
|
|
| 1703 |
else:
|
| 1704 |
refused = True
|
| 1705 |
continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1706 |
clean = str(value)[:10000]
|
| 1707 |
if isinstance(value, str) and clean != value:
|
| 1708 |
# Truncated: the stored truth is shorter than the client's copy. (An
|
|
|
|
| 38 |
down, and this note is the marker for that work.
|
| 39 |
"""
|
| 40 |
import datetime as dt
|
| 41 |
+
import json as _json_ev # wave 23 (C7): the json field's parse wall
|
| 42 |
from dataclasses import dataclass, field
|
| 43 |
from typing import MutableMapping, Optional
|
| 44 |
|
| 45 |
import core.store as store
|
| 46 |
import core.table_store as _tstore # wave-9 I17: the shared-view authorisation predicates
|
| 47 |
+
import core.user_tables as _ut_limits # wave 23 (C7): MAX_JSON_CELL β one ceiling, one definer
|
| 48 |
import core.users as users
|
| 49 |
import harness.telemetry as _tel # _tel.error() = THE error sink (all caught errors log)
|
| 50 |
import modules.cohort as cohort_mod
|
|
|
|
| 1705 |
else:
|
| 1706 |
refused = True
|
| 1707 |
continue
|
| 1708 |
+
# β WAVE 23 (C7) β the json wall, BEFORE the generic 10 000-char truncation, which
|
| 1709 |
+
# would otherwise cut a document in half and store the halves as valid text. A json
|
| 1710 |
+
# column promises its readers a parseable document; a write that is not one is
|
| 1711 |
+
# REFUSED with the cell unchanged rather than silently kept as a string nothing can
|
| 1712 |
+
# open. Blank stays legal β an empty cell is "no document", not a malformed one.
|
| 1713 |
+
if (field_by_key.get(key) or {}).get('type') == 'json':
|
| 1714 |
+
raw_j = value if isinstance(value, str) else _json_ev.dumps(value)
|
| 1715 |
+
if not str(raw_j).strip():
|
| 1716 |
+
updates[key] = ''
|
| 1717 |
+
continue
|
| 1718 |
+
if len(str(raw_j)) > _ut_limits.MAX_JSON_CELL:
|
| 1719 |
+
refused = True
|
| 1720 |
+
continue
|
| 1721 |
+
try:
|
| 1722 |
+
_json_ev.loads(raw_j)
|
| 1723 |
+
except (ValueError, TypeError):
|
| 1724 |
+
refused = True
|
| 1725 |
+
continue
|
| 1726 |
+
updates[key] = str(raw_j)
|
| 1727 |
+
continue
|
| 1728 |
clean = str(value)[:10000]
|
| 1729 |
if isinstance(value, str) and clean != value:
|
| 1730 |
# Truncated: the stored truth is shorter than the client's copy. (An
|
platform/core/keychain.py
CHANGED
|
@@ -27,7 +27,13 @@ import os
|
|
| 27 |
import secrets as _secrets
|
| 28 |
|
| 29 |
KEY = 'keychain'
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
#: which stored field feeds the masked preview, per type (first present wins)
|
| 32 |
_PREVIEW_FIELDS = ('api_key', 'value', 'password', 'token')
|
| 33 |
MAX_ENTRIES = 40
|
|
|
|
| 27 |
import secrets as _secrets
|
| 28 |
|
| 29 |
KEY = 'keychain'
|
| 30 |
+
#: β WAVE 23 (C11/R8) β `stripe` and `shopify` joined. They are TOKEN connectors, not OAuth
|
| 31 |
+
#: ones, and that is the whole reason they could ship in this wave: both issue a long-lived
|
| 32 |
+
#: restricted key / custom-app token an admin pastes in, so they need no OAuth client, no
|
| 33 |
+
#: consent screen and no app review β the three things that make every other provider on the
|
| 34 |
+
#: standing queue a multi-week errand. A type here is what lets the connectors directory show
|
| 35 |
+
#: them as CONNECTABLE rather than as another faded to-do.
|
| 36 |
+
ENTRY_TYPES = {'odoo', 'generic', 'stripe', 'shopify'}
|
| 37 |
#: which stored field feeds the masked preview, per type (first present wins)
|
| 38 |
_PREVIEW_FIELDS = ('api_key', 'value', 'password', 'token')
|
| 39 |
MAX_ENTRIES = 40
|
platform/core/user_tables.py
CHANGED
|
@@ -58,8 +58,15 @@ KEY_PREFIX = 'ut_'
|
|
| 58 |
#: landed in the per-user workspace stratum and the picker could not see them β the owner's
|
| 59 |
#: "Choose a column..." stays empty. Local literal rather than an aios_grid import so this
|
| 60 |
#: module stays dependency-light for the API's boot path.
|
|
|
|
|
|
|
|
|
|
| 61 |
UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
|
| 62 |
-
'checkbox', 'phone', 'email', 'url', 'rating', 'automation'}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
def _st(st):
|
|
@@ -186,8 +193,8 @@ def delete(table_key, st=None):
|
|
| 186 |
is no longer optional. Cleaned here, in one place: definition (`user_tables`) Β· the
|
| 187 |
workspace bucket (`<key>_table_workspace`, every stratum incl. `__shared__` β views,
|
| 188 |
custom fields, overlays, folders) Β· cohorts (`<key>_cohorts`) Β· record comments
|
| 189 |
-
(`<key>_record_comments`) Β· docs metadata (`<key>_docs`
|
| 190 |
-
|
| 191 |
`nav_meta[<key>]` Β· share grants (the database grant plus a view grant per view that
|
| 192 |
lived in this bucket, via `shares.drop_objects`) Β· alert definitions on this topic.
|
| 193 |
Bound AUTOMATIONS are the API layer's to disable (platform must not import the engine).
|
|
@@ -210,11 +217,34 @@ def delete(table_key, st=None):
|
|
| 210 |
view_ids |= set((ws.get('views') or {}).keys())
|
| 211 |
except Exception:
|
| 212 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
for b in (ws_key, f'{key}_cohorts', f'{key}_record_comments', f'{key}_docs'):
|
| 214 |
try:
|
| 215 |
s.update(b, lambda cur: {}, flush='async')
|
| 216 |
except Exception:
|
| 217 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
def _drop_meta(cur):
|
| 220 |
if isinstance(cur, dict):
|
|
|
|
| 58 |
#: landed in the per-user workspace stratum and the picker could not see them β the owner's
|
| 59 |
#: "Choose a column..." stays empty. Local literal rather than an aios_grid import so this
|
| 60 |
#: module stays dependency-light for the API's boot path.
|
| 61 |
+
#: β `json` JOINED IN WAVE 23 (C7) β and it belongs here rather than staying grid-only the way
|
| 62 |
+
#: `image` did, because the databases that need it are exactly these: a scrape lands a comment
|
| 63 |
+
#: thread or a webhook payload against a row, and the automation engine writes those cells.
|
| 64 |
UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
|
| 65 |
+
'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json'}
|
| 66 |
+
#: C7's ceiling. A cell is still a scalar on the wire (`Row` values are strings/numbers) β what
|
| 67 |
+
#: this bounds is the DOCUMENT inside it. 32 KB holds a long comment thread and refuses a payload
|
| 68 |
+
#: somebody meant to store as a file.
|
| 69 |
+
MAX_JSON_CELL = 32 * 1024
|
| 70 |
|
| 71 |
|
| 72 |
def _st(st):
|
|
|
|
| 193 |
is no longer optional. Cleaned here, in one place: definition (`user_tables`) Β· the
|
| 194 |
workspace bucket (`<key>_table_workspace`, every stratum incl. `__shared__` β views,
|
| 195 |
custom fields, overlays, folders) Β· cohorts (`<key>_cohorts`) Β· record comments
|
| 196 |
+
(`<key>_record_comments`) Β· docs metadata (`<key>_docs`) AND the dataset BYTES each
|
| 197 |
+
metadata row names (D-36, closed wave 23 β wave 21 left those unreachable-but-present) Β·
|
| 198 |
`nav_meta[<key>]` Β· share grants (the database grant plus a view grant per view that
|
| 199 |
lived in this bucket, via `shares.drop_objects`) Β· alert definitions on this topic.
|
| 200 |
Bound AUTOMATIONS are the API layer's to disable (platform must not import the engine).
|
|
|
|
| 217 |
view_ids |= set((ws.get('views') or {}).keys())
|
| 218 |
except Exception:
|
| 219 |
pass
|
| 220 |
+
# β D-36 CLOSED (wave 23) β the document BYTES, not just their metadata. Wave 21 cleaned
|
| 221 |
+
# `<key>_docs` and disclosed that the blobs under `docs/<key>/β¦` were left on the dataset;
|
| 222 |
+
# "unreachable" is not "deleted", and for a tenant asking us to delete a database the
|
| 223 |
+
# difference is the whole promise. Read the metadata FIRST (it carries each blob's exact
|
| 224 |
+
# stored `path`, so nothing is guessed from a naming convention that could drift), then
|
| 225 |
+
# clear the bucket, then delete the blobs. Best-effort per file: a storage blip must not
|
| 226 |
+
# resurrect the table or abort the rest of the sweep β an undeleted blob is residue we can
|
| 227 |
+
# sweep again, an aborted delete is a table the user asked to be gone.
|
| 228 |
+
doc_paths = []
|
| 229 |
+
try:
|
| 230 |
+
for _pid, _rows in (s.get(f'{key}_docs') or {}).items():
|
| 231 |
+
for _doc in (_rows or []):
|
| 232 |
+
p = str((_doc or {}).get('path') or '')
|
| 233 |
+
if p:
|
| 234 |
+
doc_paths.append(p)
|
| 235 |
+
except Exception:
|
| 236 |
+
pass
|
| 237 |
for b in (ws_key, f'{key}_cohorts', f'{key}_record_comments', f'{key}_docs'):
|
| 238 |
try:
|
| 239 |
s.update(b, lambda cur: {}, flush='async')
|
| 240 |
except Exception:
|
| 241 |
pass
|
| 242 |
+
for p in doc_paths:
|
| 243 |
+
try:
|
| 244 |
+
import core.store as _store_bytes
|
| 245 |
+
_store_bytes.delete_path(p)
|
| 246 |
+
except Exception:
|
| 247 |
+
pass
|
| 248 |
|
| 249 |
def _drop_meta(cur):
|
| 250 |
if isinstance(cur, dict):
|
platform/core/view_templates.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""view_templates.py β WAVE 23 item 7 (ruling R10, contract C12): the PLATFORM-CURATED
|
| 2 |
+
template registry.
|
| 3 |
+
|
| 4 |
+
WHAT A TEMPLATE IS, precisely, because the word is overloaded: a named bundle of SAVED VIEWS
|
| 5 |
+
that a user applies to a database they already have. It does NOT create a table, it does not add
|
| 6 |
+
columns, and it does not connect anything. It is the answer to "this database has 30 columns and
|
| 7 |
+
I do not know which twelve matter for chasing money" β a starting layout, authored once by the
|
| 8 |
+
platform, applied by whoever wants it.
|
| 9 |
+
|
| 10 |
+
β THE ONE RULE THAT DECIDES EVERY DESIGN CHOICE HERE β `_seed_wave17.py`'s :9-20 law:
|
| 11 |
+
**only VIEWS are shared; `fields` and `overlays` are strictly per-user.** So a SHARED view that
|
| 12 |
+
filtered on a user-created column would, for every other account, name a column that does not
|
| 13 |
+
exist β and an unknown column is an INACTIVE leaf in the tri-state filter engine, which is
|
| 14 |
+
IGNORED, which WIDENS. A "Collections focus" that quietly showed the whole customer book to
|
| 15 |
+
everyone but its author, with nothing going red.
|
| 16 |
+
|
| 17 |
+
Two consequences, both load-bearing:
|
| 18 |
+
|
| 19 |
+
1. **Applying writes the CALLING USER's own views** (`save_view(..., shared=False)`), never
|
| 20 |
+
shared ones. A template is a convenience, not an administrative act, and the moment it
|
| 21 |
+
wrote into the shared bucket it would need an admin wall and a name-collision policy across
|
| 22 |
+
the tenant.
|
| 23 |
+
2. **Every column a template names is checked against the TARGET's live contract before
|
| 24 |
+
anything is written**, and a template whose columns are missing is REFUSED with them named
|
| 25 |
+
β never applied partially, never applied with the offending leaf dropped. That check is
|
| 26 |
+
also what makes eligibility honest: `source` below is a label for grouping, the COLUMNS are
|
| 27 |
+
the gate, and the two cannot disagree because only one of them is consulted.
|
| 28 |
+
|
| 29 |
+
IDEMPOTENT BY PINNED ID. Every view carries `tpl_<template>_<suffix>`, so re-applying updates
|
| 30 |
+
the same views instead of minting "Collections focus 2". `save_view` renames on collision, which
|
| 31 |
+
would otherwise turn a second click into a second copy.
|
| 32 |
+
|
| 33 |
+
Pure-ish by construction: `TEMPLATES` and every builder are data, so `verify_home.py` can assert
|
| 34 |
+
their shape without a store.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
#: The topic a template is FOR. A coarse grouping label used to filter the picker's list; the
|
| 38 |
+
#: real gate is `missing_columns` below (a table whose contract has the columns can take the
|
| 39 |
+
#: template, whatever we called its source).
|
| 40 |
+
SOURCES = ('odoo_customer', 'odoo_product', 'instagram', 'any')
|
| 41 |
+
|
| 42 |
+
#: The workspace bucket a topic's views live in. `<key>_table_workspace` for user tables
|
| 43 |
+
#: (`core/user_tables.py:211` builds the same string when it deletes them), and the two built-ins
|
| 44 |
+
#: keep the names their modules have always used.
|
| 45 |
+
_WS_KEYS = {
|
| 46 |
+
'customer_data': 'customer_table_workspace',
|
| 47 |
+
'product_data': 'product_table_workspace',
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def workspace_key(table_key):
|
| 52 |
+
"""The `core.table_store` key for a page key, or None if it is not a table at all."""
|
| 53 |
+
key = str(table_key or '')
|
| 54 |
+
if key in _WS_KEYS:
|
| 55 |
+
return _WS_KEYS[key]
|
| 56 |
+
if key.startswith('ut_'):
|
| 57 |
+
return f'{key}_table_workspace'
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _view(view_id, name, note, visible, *, mode='grid', stack_field=None, sorts=(),
|
| 62 |
+
filters=(), group_by=None, conj='and'):
|
| 63 |
+
"""One saved view, in the shape `table_store.save_view` stores and the grid reads.
|
| 64 |
+
|
| 65 |
+
β THE SHAPE IS COPIED FROM `_seed_wave17._view` ON PURPOSE, field for field, including the
|
| 66 |
+
keys that look inert (`widths`, `memberPids`, `rowHeightMode`). The grid's own cleaner fills
|
| 67 |
+
defaults for what is missing, but a view assembled from a DIFFERENT skeleton is a second
|
| 68 |
+
definition of what a view is β and the two drift on the day one of them gains a key.
|
| 69 |
+
|
| 70 |
+
`permissions.edit` is absent: `table_store.is_shared` reads it, and a view with no
|
| 71 |
+
permissions block is PERSONAL, which is the only thing this file is allowed to write.
|
| 72 |
+
|
| 73 |
+
β THE MODE LIVES AT `config.display.mode`, AND THE FIRST DRAFT PUT IT AT
|
| 74 |
+
`config.displayMode` β A KEY NOTHING READS. The gate caught nothing, because it asserted the
|
| 75 |
+
VALUE against `aios_grid.DISPLAY_MODES` and never the PATH: `"kanban" in DISPLAY_MODES` is
|
| 76 |
+
true wherever you happen to have written it. The Instagram "Review board" would have been
|
| 77 |
+
created as a plain GRID under a name promising a board β the view exists, the filter works,
|
| 78 |
+
the kanban never happens, and nothing goes red. Exactly the silent-drop class as a dropped
|
| 79 |
+
filter leaf, one field over. Corrected against `aios_grid._clean_display`, which is now RUN
|
| 80 |
+
over every template in `verify_home` rather than consulted for a vocabulary.
|
| 81 |
+
|
| 82 |
+
β AND `grid` STORES NOTHING. `_clean_display` returns None for it by design ("grid is the
|
| 83 |
+
absent default, so storing it would be a second way to say nothing"), so writing
|
| 84 |
+
`display: {'mode': 'grid'}` would be a key the cleaner strips on the next save β a value that
|
| 85 |
+
exists until something touches it.
|
| 86 |
+
"""
|
| 87 |
+
display = None
|
| 88 |
+
if mode != 'grid':
|
| 89 |
+
display = {'mode': mode}
|
| 90 |
+
if stack_field:
|
| 91 |
+
# The kanban lane column. `_clean_display` accepts any ref that NAMES a real field
|
| 92 |
+
# and leaves what the mode means to the client, which degrades a wrong-typed ref to
|
| 93 |
+
# its own default rather than erroring.
|
| 94 |
+
display['stackField'] = stack_field
|
| 95 |
+
return {
|
| 96 |
+
'id': view_id, 'name': name, 'kind': 'custom', 'locked': False, 'note': note,
|
| 97 |
+
'config': {
|
| 98 |
+
'filters': list(filters), 'filterConj': conj, 'sorts': list(sorts),
|
| 99 |
+
'groupBy': group_by, 'colorBy': None, 'rowHeightMode': 'short',
|
| 100 |
+
'order': list(visible), 'visible': list(visible), 'widths': {},
|
| 101 |
+
'memberPids': [], **({'display': display} if display else {}),
|
| 102 |
+
},
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ββ THE REGISTRY ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 107 |
+
#
|
| 108 |
+
# β FIRST BATCH (C12). Every column named below was checked against the live contract when this
|
| 109 |
+
# file was written β `aios_grid_fields.json` for customer, `aios_grid.product_fields()` for
|
| 110 |
+
# product, `automation_engine.CANDIDATE_FIELDS` for the Instagram candidate pool β but the check
|
| 111 |
+
# that MATTERS runs at apply time, against the target the user actually picked, because a
|
| 112 |
+
# contract can move and a ut_ table's columns are whatever its owner made.
|
| 113 |
+
|
| 114 |
+
TEMPLATES = [
|
| 115 |
+
{
|
| 116 |
+
'key': 'collections_focus',
|
| 117 |
+
'label': 'Collections focus',
|
| 118 |
+
'desc': 'Everyone with money past due, largest first, with the aging buckets beside it.',
|
| 119 |
+
'source': 'odoo_customer',
|
| 120 |
+
'views': [
|
| 121 |
+
_view(
|
| 122 |
+
'tpl_collections_focus_overdue', 'Past due',
|
| 123 |
+
'Customers with an overdue balance, largest first. The four aging columns sum '
|
| 124 |
+
'to AR overdue; a balance inside the grace period counts as open, not overdue.',
|
| 125 |
+
['customer', 'agent', 'ar_overdue', 'ar_aged_1_30', 'ar_aged_31_60',
|
| 126 |
+
'ar_aged_61_90', 'ar_aged_90_plus', 'ar_open', 'days_to_pay',
|
| 127 |
+
'payment_terms', 'last_order'],
|
| 128 |
+
sorts=[{'colId': 'ar_overdue', 'dir': 'desc'}],
|
| 129 |
+
filters=[{'colId': 'ar_overdue', 'op': 'gt', 'value': '0'}],
|
| 130 |
+
),
|
| 131 |
+
_view(
|
| 132 |
+
'tpl_collections_focus_worst', 'Over 90 days',
|
| 133 |
+
'The part of the book that is no longer a payment-terms conversation.',
|
| 134 |
+
['customer', 'agent', 'ar_aged_90_plus', 'ar_overdue', 'ar_exposure',
|
| 135 |
+
'days_to_pay', 'last_order'],
|
| 136 |
+
sorts=[{'colId': 'ar_aged_90_plus', 'dir': 'desc'}],
|
| 137 |
+
filters=[{'colId': 'ar_aged_90_plus', 'op': 'gt', 'value': '0'}],
|
| 138 |
+
),
|
| 139 |
+
],
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
'key': 'dba_missing',
|
| 143 |
+
'label': 'DBA is empty',
|
| 144 |
+
# The owner's own example (C12). One view, one job, and it is a DATA-QUALITY view: the
|
| 145 |
+
# brand a customer buys from decides which BU-scoped account can see them at all, so a
|
| 146 |
+
# blank DBA is a customer nobody's book contains.
|
| 147 |
+
'desc': 'Customers with no brand recorded β they fall outside every scoped book.',
|
| 148 |
+
'source': 'odoo_customer',
|
| 149 |
+
'alert': True,
|
| 150 |
+
'views': [
|
| 151 |
+
_view(
|
| 152 |
+
'tpl_dba_missing_blank', 'DBA is empty',
|
| 153 |
+
'Customers whose DBA is blank. The brand decides which scoped account sees a '
|
| 154 |
+
'customer, so a blank one is invisible to every book except an unscoped view.',
|
| 155 |
+
# β SORTED BY OPEN RECEIVABLE, NOT BY SALES. This view first named `sales_ltm`,
|
| 156 |
+
# which DOES NOT EXIST β the customer contract carries no LTM sales column (the
|
| 157 |
+
# 32 keys in `aios_grid_fields.json`; `verify_home` caught it against the live
|
| 158 |
+
# contract on the first run). `ar_open` is the nearest real column that answers
|
| 159 |
+
# the same question β "which of these matters most" β and it answers it in
|
| 160 |
+
# money that is actually outstanding rather than in history.
|
| 161 |
+
['customer', 'dba', 'agent', 'ar_open', 'last_order', 'days_since'],
|
| 162 |
+
sorts=[{'colId': 'ar_open', 'dir': 'desc'}],
|
| 163 |
+
# β `isEmpty`, and it is the one op that is safe here. A `select` column's blank
|
| 164 |
+
# is not the empty string in every row shape, and `eq ''` would miss the nulls β
|
| 165 |
+
# `isEmpty`/`isNotEmpty` are evaluated type-independently BEFORE any coercion
|
| 166 |
+
# (the same reason `_seed_wave17`'s buy list leans on them).
|
| 167 |
+
filters=[{'colId': 'dba', 'op': 'isEmpty'}],
|
| 168 |
+
),
|
| 169 |
+
],
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
'key': 'buy_list_companion',
|
| 173 |
+
'label': 'Buy-list companion',
|
| 174 |
+
'desc': 'What is running out, and what it costs to bring in β beside the buy list.',
|
| 175 |
+
'source': 'odoo_product',
|
| 176 |
+
'views': [
|
| 177 |
+
_view(
|
| 178 |
+
'tpl_buy_list_companion_cover', 'Cover gap',
|
| 179 |
+
'SKUs whose days of supply is already shorter than their supplier lead time. '
|
| 180 |
+
'Stock columns are consolidated across the one physical warehouse, so this is '
|
| 181 |
+
'empty for a BU-scoped account.',
|
| 182 |
+
['code', 'product', 'cover_gap_d', 'dos', 'lead_days', 'supplier', 'on_hand',
|
| 183 |
+
'qty_ltm', 'first_cost', 'origin_country'],
|
| 184 |
+
sorts=[{'colId': 'cover_gap_d', 'dir': 'asc'}],
|
| 185 |
+
# The same four leaves the wave-17 seed uses, and for the same reason: a bare
|
| 186 |
+
# `dos < lead_days` reads a SKU with NO days-of-supply as `0 < 30` (the client
|
| 187 |
+
# engine's `toNum(null)` is 0) and puts every never-selling product on the list.
|
| 188 |
+
filters=[{'colId': 'dos', 'op': 'isNotEmpty'},
|
| 189 |
+
{'colId': 'lead_days', 'op': 'isNotEmpty'},
|
| 190 |
+
{'colId': 'lead_days', 'op': 'gt', 'value': '0'},
|
| 191 |
+
{'colId': 'dos', 'op': 'lt',
|
| 192 |
+
'rhs': {'kind': 'field', 'colId': 'lead_days'}}],
|
| 193 |
+
),
|
| 194 |
+
_view(
|
| 195 |
+
'tpl_buy_list_companion_nosupplier', 'No supplier on file',
|
| 196 |
+
'SKUs we cannot reorder because nobody is recorded as selling them to us.',
|
| 197 |
+
['code', 'product', 'supplier', 'on_hand', 'qty_ltm', 'first_cost'],
|
| 198 |
+
sorts=[{'colId': 'qty_ltm', 'dir': 'desc'}],
|
| 199 |
+
filters=[{'colId': 'supplier', 'op': 'isEmpty'}],
|
| 200 |
+
),
|
| 201 |
+
],
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
'key': 'ig_candidates',
|
| 205 |
+
'label': 'Candidates review',
|
| 206 |
+
'desc': 'Discovered profiles as a review board, plus the ones worth reading first.',
|
| 207 |
+
'source': 'instagram',
|
| 208 |
+
'views': [
|
| 209 |
+
_view(
|
| 210 |
+
'tpl_ig_candidates_board', 'Review board',
|
| 211 |
+
'Discovered profiles grouped by category. Tick "Tracked" on the ones worth '
|
| 212 |
+
'keeping β that column is written by a person, never by the automation.',
|
| 213 |
+
['handle', 'full_name', 'followers', 'avg_engagement', 'verified', 'category',
|
| 214 |
+
'tracked', 'profile_url'],
|
| 215 |
+
# `stackField`, NOT `groupBy` β the kanban lane column is a `display` ref, while
|
| 216 |
+
# `groupBy` is the GRID's row grouping. They are two different features that both
|
| 217 |
+
# sound like "group by", and only one of them makes lanes.
|
| 218 |
+
mode='kanban', stack_field='category',
|
| 219 |
+
sorts=[{'colId': 'followers', 'dir': 'desc'}],
|
| 220 |
+
),
|
| 221 |
+
_view(
|
| 222 |
+
'tpl_ig_candidates_engaged', 'High engagement',
|
| 223 |
+
'Profiles whose audience actually responds, biggest first. Engagement, not '
|
| 224 |
+
'follower count, is what a small account can be good at.',
|
| 225 |
+
['handle', 'full_name', 'avg_engagement', 'followers', 'category', 'bio',
|
| 226 |
+
'external_url', 'tracked'],
|
| 227 |
+
sorts=[{'colId': 'avg_engagement', 'dir': 'desc'}],
|
| 228 |
+
filters=[{'colId': 'avg_engagement', 'op': 'gt', 'value': '0'}],
|
| 229 |
+
),
|
| 230 |
+
],
|
| 231 |
+
},
|
| 232 |
+
]
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
def get(key):
|
| 236 |
+
"""One template by key, or None."""
|
| 237 |
+
return next((t for t in TEMPLATES if t['key'] == str(key)), None)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def columns_named(template):
|
| 241 |
+
"""Every column key a template's views reference β visible, filtered, sorted, grouped, and
|
| 242 |
+
the right-hand side of a field-vs-field comparison.
|
| 243 |
+
|
| 244 |
+
β THE `rhs` IS THE ONE PEOPLE FORGET, and `_seed_wave17` learned it the expensive way:
|
| 245 |
+
`clean_filter_tree` KEEPS a leaf whose rhs names an unknown column β it drops the rhs and
|
| 246 |
+
leaves the rule β so `dos < lead_days` silently becomes `dos < ""`. The leaf count is
|
| 247 |
+
unchanged, so a check that counted leaves would pass while the view answered a different
|
| 248 |
+
question. Naming the rhs here is what makes the eligibility test see it.
|
| 249 |
+
"""
|
| 250 |
+
out = set()
|
| 251 |
+
for view in template.get('views') or ():
|
| 252 |
+
cfg = view.get('config') or {}
|
| 253 |
+
out.update(cfg.get('visible') or ())
|
| 254 |
+
for s in cfg.get('sorts') or ():
|
| 255 |
+
if isinstance(s, dict) and s.get('colId'):
|
| 256 |
+
out.add(s['colId'])
|
| 257 |
+
if cfg.get('groupBy'):
|
| 258 |
+
out.add(cfg['groupBy'])
|
| 259 |
+
for f in cfg.get('filters') or ():
|
| 260 |
+
if not isinstance(f, dict):
|
| 261 |
+
continue
|
| 262 |
+
if f.get('colId'):
|
| 263 |
+
out.add(f['colId'])
|
| 264 |
+
rhs = f.get('rhs')
|
| 265 |
+
if isinstance(rhs, dict) and rhs.get('colId'):
|
| 266 |
+
out.add(rhs['colId'])
|
| 267 |
+
return out
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def missing_columns(template, field_keys):
|
| 271 |
+
"""The columns this template needs that the target does not have. Empty β it can be applied.
|
| 272 |
+
|
| 273 |
+
THE ELIGIBILITY TEST AND THE REFUSAL TEST ARE THE SAME FUNCTION, deliberately: a picker that
|
| 274 |
+
offered a template the apply door would then refuse is a control that lies, and two separate
|
| 275 |
+
predicates is how those two answers drift apart.
|
| 276 |
+
"""
|
| 277 |
+
return sorted(columns_named(template) - set(field_keys or ()))
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def offer(field_keys, source=None):
|
| 281 |
+
"""Every template that CAN be applied to a target with these columns.
|
| 282 |
+
|
| 283 |
+
`source` narrows further when the caller knows it (the picker passes the target's own), but
|
| 284 |
+
a template whose columns are all present is offered regardless of label β the columns are
|
| 285 |
+
the contract and the label is a grouping.
|
| 286 |
+
"""
|
| 287 |
+
out = []
|
| 288 |
+
for t in TEMPLATES:
|
| 289 |
+
if missing_columns(t, field_keys):
|
| 290 |
+
continue
|
| 291 |
+
if source and t['source'] not in ('any', source):
|
| 292 |
+
continue
|
| 293 |
+
out.append({'key': t['key'], 'label': t['label'], 'desc': t['desc'],
|
| 294 |
+
'source': t['source'], 'views': len(t.get('views') or ()),
|
| 295 |
+
'alert': bool(t.get('alert'))})
|
| 296 |
+
return out
|
web/src/alerts/AlertsPane.tsx
CHANGED
|
@@ -19,7 +19,14 @@ import {
|
|
| 19 |
markRead,
|
| 20 |
runAlert,
|
| 21 |
} from "./alertsApi";
|
| 22 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
import type { Alert, Inbox, Notification } from "./alertsModel";
|
| 24 |
|
| 25 |
/** The clock face this pane uses for its one glyph β drawn, never an emoji. */
|
|
@@ -35,12 +42,23 @@ function BellIcon() {
|
|
| 35 |
export default function AlertsPane({
|
| 36 |
onClose,
|
| 37 |
onOpenView,
|
|
|
|
| 38 |
onInbox,
|
| 39 |
onToast,
|
| 40 |
}: {
|
| 41 |
onClose: () => void;
|
| 42 |
/** Navigate to the alert's view. The FRAME owns routing; this pane owns the row. */
|
| 43 |
onOpenView: (topic: string, viewId: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
/** Hand the freshly-read inbox back so the nav badge and the pane agree. */
|
| 45 |
onInbox: (inbox: Inbox) => void;
|
| 46 |
onToast: (message: string) => void;
|
|
@@ -114,6 +132,16 @@ export default function AlertsPane({
|
|
| 114 |
publish(applyRead(inbox, [n.id], true));
|
| 115 |
void markRead([n.id], true);
|
| 116 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
// A route this client cannot resolve opens NOTHING and says so β alerts
|
| 118 |
// outlive the surfaces they were made from.
|
| 119 |
if (!routeForTopic(n.topic)) {
|
|
@@ -123,7 +151,7 @@ export default function AlertsPane({
|
|
| 123 |
onOpenView(n.topic, n.viewId);
|
| 124 |
onClose();
|
| 125 |
},
|
| 126 |
-
[inbox, publish, onOpenView, onClose, onToast]
|
| 127 |
);
|
| 128 |
|
| 129 |
const rows = inboxOrder(inbox.items);
|
|
@@ -166,7 +194,14 @@ export default function AlertsPane({
|
|
| 166 |
type="button"
|
| 167 |
className="alerts-row-main"
|
| 168 |
onClick={() => open(n)}
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
>
|
| 171 |
<span className="alerts-row-label">{n.label}</span>
|
| 172 |
<span className="alerts-row-meta">
|
|
|
|
| 19 |
markRead,
|
| 20 |
runAlert,
|
| 21 |
} from "./alertsApi";
|
| 22 |
+
import {
|
| 23 |
+
EMPTY_INBOX,
|
| 24 |
+
applyRead,
|
| 25 |
+
inboxOrder,
|
| 26 |
+
isAutomationReview,
|
| 27 |
+
routeForTopic,
|
| 28 |
+
stampText,
|
| 29 |
+
} from "./alertsModel";
|
| 30 |
import type { Alert, Inbox, Notification } from "./alertsModel";
|
| 31 |
|
| 32 |
/** The clock face this pane uses for its one glyph β drawn, never an emoji. */
|
|
|
|
| 42 |
export default function AlertsPane({
|
| 43 |
onClose,
|
| 44 |
onOpenView,
|
| 45 |
+
onOpenAutomation,
|
| 46 |
onInbox,
|
| 47 |
onToast,
|
| 48 |
}: {
|
| 49 |
onClose: () => void;
|
| 50 |
/** Navigate to the alert's view. The FRAME owns routing; this pane owns the row. */
|
| 51 |
onOpenView: (topic: string, viewId: string) => void;
|
| 52 |
+
/**
|
| 53 |
+
* WAVE 23 (C6, wiring W23-W5) β the same division for a REVIEW notification: the frame routes
|
| 54 |
+
* to `#/automation` and asks the surface to select this automation.
|
| 55 |
+
*
|
| 56 |
+
* β REQUIRED, NOT OPTIONAL, and that is the wave-20 lesson written into a type. An optional
|
| 57 |
+
* callback the frame forgot to pass degrades to "clicking a review notification does nothing",
|
| 58 |
+
* which is indistinguishable from "the feature was never built" and goes red in no gate. A
|
| 59 |
+
* required prop fails `tsc` the moment it is unmounted.
|
| 60 |
+
*/
|
| 61 |
+
onOpenAutomation: (autoId: string, stageId?: string) => void;
|
| 62 |
/** Hand the freshly-read inbox back so the nav badge and the pane agree. */
|
| 63 |
onInbox: (inbox: Inbox) => void;
|
| 64 |
onToast: (message: string) => void;
|
|
|
|
| 132 |
publish(applyRead(inbox, [n.id], true));
|
| 133 |
void markRead([n.id], true);
|
| 134 |
}
|
| 135 |
+
// β WAVE 23 C6 β THE KIND BRANCH COMES FIRST, AND ITS ORDER IS THE WHOLE FIX. A review
|
| 136 |
+
// notification has no `topic` a grid could render, so the view guard below would have
|
| 137 |
+
// caught every one of them and told the reader their table was "no longer available" β
|
| 138 |
+
// a confidently wrong sentence about a feature that is working. The destination decides
|
| 139 |
+
// which guard applies, so the destination is decided first.
|
| 140 |
+
if (isAutomationReview(n)) {
|
| 141 |
+
onOpenAutomation(n.autoId!, n.stageId);
|
| 142 |
+
onClose();
|
| 143 |
+
return;
|
| 144 |
+
}
|
| 145 |
// A route this client cannot resolve opens NOTHING and says so β alerts
|
| 146 |
// outlive the surfaces they were made from.
|
| 147 |
if (!routeForTopic(n.topic)) {
|
|
|
|
| 151 |
onOpenView(n.topic, n.viewId);
|
| 152 |
onClose();
|
| 153 |
},
|
| 154 |
+
[inbox, publish, onOpenView, onOpenAutomation, onClose, onToast]
|
| 155 |
);
|
| 156 |
|
| 157 |
const rows = inboxOrder(inbox.items);
|
|
|
|
| 194 |
type="button"
|
| 195 |
className="alerts-row-main"
|
| 196 |
onClick={() => open(n)}
|
| 197 |
+
// The hover names the DESTINATION, which is now two different places.
|
| 198 |
+
// "Open the alert's view" over a review notification described a journey
|
| 199 |
+
// the click does not take.
|
| 200 |
+
title={
|
| 201 |
+
isAutomationReview(n)
|
| 202 |
+
? "Open the automation"
|
| 203 |
+
: `Open ${n.alertLabel || "the alert's view"}`
|
| 204 |
+
}
|
| 205 |
>
|
| 206 |
<span className="alerts-row-label">{n.label}</span>
|
| 207 |
<span className="alerts-row-meta">
|
web/src/alerts/alertsModel.ts
CHANGED
|
@@ -31,6 +31,29 @@ export interface Notification {
|
|
| 31 |
* would re-introduce the browser-clock drift the offset exists to remove. */
|
| 32 |
at: string;
|
| 33 |
read: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
|
| 36 |
/** One alert, as `GET /api/v1/alerts` sends it. */
|
|
@@ -87,6 +110,14 @@ export function parseInbox(body: unknown): Inbox {
|
|
| 87 |
alertLabel: str(n.alertLabel),
|
| 88 |
at: str(n.at),
|
| 89 |
read: n.read === true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
});
|
| 91 |
}
|
| 92 |
return { unread: Math.max(0, num(b.unread)), items };
|
|
@@ -134,6 +165,24 @@ export function routeForTopic(topic: string): string | null {
|
|
| 134 |
return null;
|
| 135 |
}
|
| 136 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
/** Newest first, and unread before read at the same instant β the inbox order.
|
| 138 |
* Stable: two notifications from one write keep the server's order. */
|
| 139 |
export function inboxOrder(items: Notification[]): Notification[] {
|
|
|
|
| 31 |
* would re-introduce the browser-clock drift the offset exists to remove. */
|
| 32 |
at: string;
|
| 33 |
read: boolean;
|
| 34 |
+
/**
|
| 35 |
+
* WAVE 23 (contract C6) β WHAT KIND of notification this is.
|
| 36 |
+
*
|
| 37 |
+
* Absent (and every notification written before this wave) means the original one: a record
|
| 38 |
+
* ENTERED a watched view, routed by `topic` + `viewId`. `"automation_review"` means a card
|
| 39 |
+
* arrived at a review stage and routes by `autoId` instead β a different destination reached
|
| 40 |
+
* from the same list.
|
| 41 |
+
*
|
| 42 |
+
* β A STRING, NEVER A UNION, and that is the wave-9 law rather than laziness: the vocabulary
|
| 43 |
+
* is the SERVER's, and a client union over it turns "the server grew a kind" into "the client
|
| 44 |
+
* silently drops the row". Unknown kinds fall through to the view route, which is exactly what
|
| 45 |
+
* they did before this field existed.
|
| 46 |
+
*/
|
| 47 |
+
kind?: string;
|
| 48 |
+
/** `automation_review` only: the automation whose review stage a card reached. Absent on
|
| 49 |
+
* every other kind β and an `automation_review` row that arrives WITHOUT one opens nothing
|
| 50 |
+
* rather than guessing, the same posture `viewId` gets. */
|
| 51 |
+
autoId?: string;
|
| 52 |
+
/** Advisory. A surface that does not scroll to a stage simply selects the automation. */
|
| 53 |
+
stageId?: string;
|
| 54 |
+
/** How many cards arrived in the batch. C6 queues ONE notification per run naming the count,
|
| 55 |
+
* never one per record β so this is the number the row's own text is built from. */
|
| 56 |
+
count?: number;
|
| 57 |
}
|
| 58 |
|
| 59 |
/** One alert, as `GET /api/v1/alerts` sends it. */
|
|
|
|
| 110 |
alertLabel: str(n.alertLabel),
|
| 111 |
at: str(n.at),
|
| 112 |
read: n.read === true,
|
| 113 |
+
// WAVE 23 C6 β ADDITIVE and spread-conditional, exactly like `NavPage`'s flags: a payload
|
| 114 |
+
// that predates this wave keeps today's shape rather than gaining four `undefined` keys,
|
| 115 |
+
// and an `automation_review` row missing its `autoId` is left WITHOUT one rather than
|
| 116 |
+
// with an empty string that would render as a real destination.
|
| 117 |
+
...(str(n.kind) ? { kind: str(n.kind) } : {}),
|
| 118 |
+
...(str(n.autoId) ? { autoId: str(n.autoId) } : {}),
|
| 119 |
+
...(str(n.stageId) ? { stageId: str(n.stageId) } : {}),
|
| 120 |
+
...(num(n.count) > 0 ? { count: num(n.count) } : {}),
|
| 121 |
});
|
| 122 |
}
|
| 123 |
return { unread: Math.max(0, num(b.unread)), items };
|
|
|
|
| 165 |
return null;
|
| 166 |
}
|
| 167 |
|
| 168 |
+
/** WAVE 23 C6 β the kind literal the server queues for a card reaching a review stage.
|
| 169 |
+
* ONE place, so the pane's branch and the gate's fixture cannot disagree about the word. */
|
| 170 |
+
export const AUTOMATION_REVIEW_KIND = "automation_review";
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* Does this notification route to an AUTOMATION rather than to a view?
|
| 174 |
+
*
|
| 175 |
+
* β BOTH HALVES ARE REQUIRED, and the second is the one that matters. A row whose kind says
|
| 176 |
+
* `automation_review` but whose `autoId` never arrived (a server mid-deploy, a truncated
|
| 177 |
+
* payload) would otherwise take the automation branch and dispatch an open request naming
|
| 178 |
+
* nothing β a click that appears to work and silently does not. Failing this test sends it down
|
| 179 |
+
* the view branch, where `routeForTopic` refuses out loud. Fail-closed in the direction that
|
| 180 |
+
* still says something.
|
| 181 |
+
*/
|
| 182 |
+
export function isAutomationReview(n: Notification): boolean {
|
| 183 |
+
return n.kind === AUTOMATION_REVIEW_KIND && !!str(n.autoId).trim();
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
/** Newest first, and unread before read at the same instant β the inbox order.
|
| 187 |
* Stable: two notifications from one write keep the server's order. */
|
| 188 |
export function inboxOrder(items: Notification[]): Notification[] {
|
web/src/apiContract.ts
CHANGED
|
@@ -101,3 +101,49 @@ export interface ViewOpenDetail {
|
|
| 101 |
topic: string;
|
| 102 |
viewId: string;
|
| 103 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
topic: string;
|
| 102 |
viewId: string;
|
| 103 |
}
|
| 104 |
+
|
| 105 |
+
/**
|
| 106 |
+
* WAVE 23 (contract C6, wiring W23-W5) β the shellβautomation channel for a review
|
| 107 |
+
* notification's click-through.
|
| 108 |
+
*
|
| 109 |
+
* β THE SAME SHAPE AS `VIEW_OPEN_EVENT` ABOVE, FOR THE SAME REASON, and wave 20 is why both
|
| 110 |
+
* exist as constants in this leaf rather than as a string typed twice. A card arriving at a
|
| 111 |
+
* review stage queues an `automation_review` notification naming `{autoId, stageId, count}`;
|
| 112 |
+
* clicking it has to do TWO things that live on opposite sides of an ownership fence β route to
|
| 113 |
+
* `#/automation` (the SHELL's hash) and select that automation in the rail (`AutomationSurface`'s
|
| 114 |
+
* own `activeId`, which the shell cannot see and must not learn). So the frame navigates and
|
| 115 |
+
* then ASKS, and the surface answers if it can.
|
| 116 |
+
*
|
| 117 |
+
* β THE LISTENER IS THE HALF THAT CAN BE ABSENT. Wave 20 shipped item 25's click-through with
|
| 118 |
+
* this exact shape and NO listener β the event was dispatched into nothing, every gate green,
|
| 119 |
+
* the notification landing the reader on the right table and doing nothing else. Declared here
|
| 120 |
+
* on the wave's first day precisely so the surface can wire the listener while it is being
|
| 121 |
+
* built rather than at close-out; the wiring row (W23-W5) asserts both ends.
|
| 122 |
+
*
|
| 123 |
+
* An automation the reader can no longer open must do NOTHING rather than throw β the listener
|
| 124 |
+
* checks its own list first, exactly as the grid does for a view it cannot see.
|
| 125 |
+
*/
|
| 126 |
+
export const AUTOMATION_OPEN_EVENT = "aios:automation-open";
|
| 127 |
+
|
| 128 |
+
/** `detail` of {@link AUTOMATION_OPEN_EVENT}. `stageId` is advisory β a surface that does not
|
| 129 |
+
* scroll to a stage simply selects the automation. */
|
| 130 |
+
export interface AutomationOpenDetail {
|
| 131 |
+
autoId: string;
|
| 132 |
+
stageId?: string;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* WAVE 23 (contracts C2 + C10) β "create an AUTOMATED database", asked from outside the
|
| 137 |
+
* automation surface.
|
| 138 |
+
*
|
| 139 |
+
* R7 puts an "Automated database" door on Home and in the Database flyout, and C2 makes the
|
| 140 |
+
* automation create wizard the thing behind it β because an automated database is created BY
|
| 141 |
+
* the automation that fills it, not by a name box that then needs one. The shell cannot open
|
| 142 |
+
* that wizard directly (it is the surface's own state), so it routes to `#/automation` and
|
| 143 |
+
* raises this; the surface opens its create flow.
|
| 144 |
+
*
|
| 145 |
+
* β NO DETAIL, deliberately. "Open the create wizard" is the whole message. Anything more β
|
| 146 |
+
* a preselected kind, a name β would be the shell deciding what the wizard's first step says,
|
| 147 |
+
* which is the surface's contract (C2's three cards), not the frame's.
|
| 148 |
+
*/
|
| 149 |
+
export const AUTOMATION_CREATE_EVENT = "aios:automation-create";
|
web/src/automation/AutomationBoard.tsx
CHANGED
|
@@ -176,7 +176,25 @@ export default function AutomationBoard({
|
|
| 176 |
}, 0);
|
| 177 |
}}
|
| 178 |
>
|
| 179 |
-
<span className="autob-card-title">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
{card.sub ? <span className="autob-card-sub">{card.sub}</span> : null}
|
| 181 |
{card.movedAt ? (
|
| 182 |
<span className="autob-card-when">{whenText(card.movedAt)}</span>
|
|
|
|
| 176 |
}, 0);
|
| 177 |
}}
|
| 178 |
>
|
| 179 |
+
<span className="autob-card-title">
|
| 180 |
+
{card.title}
|
| 181 |
+
{/*
|
| 182 |
+
β C5's CYCLE BADGE. A record that has been round the flow more than once is a
|
| 183 |
+
different fact from a record on its first pass β on a reset-mode automation it is
|
| 184 |
+
the difference between "working" and "looping" β so the card says so.
|
| 185 |
+
β FROM 2 UPWARDS ONLY, and that is what makes an ABSENT count safe: every card
|
| 186 |
+
would otherwise wear "x1", which is noise on the common case and, worse, a claim
|
| 187 |
+
about a number the server has not sent yet (see `BoardCard.cycles`).
|
| 188 |
+
*/}
|
| 189 |
+
{typeof card.cycles === "number" && card.cycles >= 2 ? (
|
| 190 |
+
<span
|
| 191 |
+
className="autob-card-cycles"
|
| 192 |
+
title={`This record has been round this flow ${card.cycles} times`}
|
| 193 |
+
>
|
| 194 |
+
×{card.cycles}
|
| 195 |
+
</span>
|
| 196 |
+
) : null}
|
| 197 |
+
</span>
|
| 198 |
{card.sub ? <span className="autob-card-sub">{card.sub}</span> : null}
|
| 199 |
{card.movedAt ? (
|
| 200 |
<span className="autob-card-when">{whenText(card.movedAt)}</span>
|
web/src/automation/AutomationBuilder.tsx
ADDED
|
@@ -0,0 +1,1558 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// automation/AutomationBuilder.tsx β the BUILDER (owner item 3b, contracts
|
| 3 |
+
// C1/C2/C4, ruling R1: "exact layout, Loopable skin").
|
| 4 |
+
//
|
| 5 |
+
// THE ANATOMY IS AIRTABLE'S, 1:1, and it is the owner's reference (images 1-9):
|
| 6 |
+
// a centre column of TRIGGER β ACTIONS with a spine down the left carrying each
|
| 7 |
+
// step's status chip, dashed add-boxes at the end of each section, and a
|
| 8 |
+
// Properties panel on the right whose sections are Trigger details /
|
| 9 |
+
// Configuration / Test step. Every colour, size and weight is OURS (R1,
|
| 10 |
+
// DESIGN.md) β no Airtable blue, no caps micro-labels (R6: "TRIGGER" is
|
| 11 |
+
// "Trigger" here, and de-capping takes the letter-spacing with it).
|
| 12 |
+
//
|
| 13 |
+
// β IT RENDERS SYNCHRONOUSLY FROM WHAT IT WAS HANDED (C14 leg 1). No fetch, no
|
| 14 |
+
// effect, no second frame: the trigger comes from `automation.trigger`, the
|
| 15 |
+
// actions from `automation.flow`, the machine steps from `automation.graph` β
|
| 16 |
+
// all three already on the list payload the rail used to draw the row you
|
| 17 |
+
// clicked. THIS IS THE GHOST FIX. The old detail rendered Steps, then swapped to
|
| 18 |
+
// the Board when `/board` answered, so switching automations painted the
|
| 19 |
+
// PREVIOUS one's shape inside the new one's frame for as long as a fetch takes.
|
| 20 |
+
// A view that cannot be in two shapes cannot show you the wrong one.
|
| 21 |
+
//
|
| 22 |
+
// β NO CLIENT UNION OVER A SERVER VOCABULARY, anywhere. Trigger keys, action
|
| 23 |
+
// kinds, comparison operators, ending modes, review deciders and every ceiling
|
| 24 |
+
// ride `GET /automations`. What this file owns is pixels and wording.
|
| 25 |
+
//
|
| 26 |
+
// WHAT PERSISTS WHEN. A discrete choice writes immediately β picking a trigger,
|
| 27 |
+
// adding an action, flipping a switch β because a control you have to remember
|
| 28 |
+
// to Save is not a control (R9). FREE TEXT does not: a value, a prompt or a name
|
| 29 |
+
// commits on BLUR, or every keystroke would PATCH a half-typed condition and the
|
| 30 |
+
// server would refuse most of them out loud (the cron-string precedent in
|
| 31 |
+
// AutomationTrigger).
|
| 32 |
+
// ---------------------------------------------------------------------------
|
| 33 |
+
import type { ReactNode } from "react";
|
| 34 |
+
import { useState } from "react";
|
| 35 |
+
|
| 36 |
+
import type {
|
| 37 |
+
Action,
|
| 38 |
+
ActionCatalogRow,
|
| 39 |
+
Automation,
|
| 40 |
+
Cond,
|
| 41 |
+
FlowVocab,
|
| 42 |
+
GraphNode,
|
| 43 |
+
OAuthStatus,
|
| 44 |
+
TriggerOption,
|
| 45 |
+
UserTable,
|
| 46 |
+
} from "./automationApi";
|
| 47 |
+
import CondBuilder, { condComplete } from "./CondBuilder";
|
| 48 |
+
|
| 49 |
+
interface Props {
|
| 50 |
+
automation: Automation;
|
| 51 |
+
/** The server's trigger vocabulary (C3). Absent is a state this file states, never fills in. */
|
| 52 |
+
triggers?: TriggerOption[];
|
| 53 |
+
catalog: ActionCatalogRow[];
|
| 54 |
+
vocab?: FlowVocab;
|
| 55 |
+
tables: UserTable[];
|
| 56 |
+
oauth: OAuthStatus | null;
|
| 57 |
+
/** True while a write is in flight β drives the "All changes saved" stamp and disables edits. */
|
| 58 |
+
busy: boolean;
|
| 59 |
+
/** Write a partial definition. The caller owns the request and prints the refusal verbatim. */
|
| 60 |
+
onPatch: (body: Record<string, unknown>) => void;
|
| 61 |
+
onToggleNode: (nodeId: string) => void;
|
| 62 |
+
/**
|
| 63 |
+
* β CHOOSING A TRIGGER IS NOT A `{trigger:{key}}` PATCH, and getting that wrong is invisible.
|
| 64 |
+
* `schedule` is what today's engine actually READS, so a pick has to write BOTH halves or
|
| 65 |
+
* "At a scheduled time" would store a key and leave the cron switched off β a trigger that
|
| 66 |
+
* says it is scheduled and never fires. The detail already owns that two-write shape
|
| 67 |
+
* (`pickTrigger`); the builder calls it rather than composing a second copy of it.
|
| 68 |
+
*/
|
| 69 |
+
onPickTrigger: (key: string) => void;
|
| 70 |
+
onRunNow: () => void;
|
| 71 |
+
/**
|
| 72 |
+
* The MACHINE steps' config bodies (source / columns / capture / find / write). They live in
|
| 73 |
+
* `AutomationDetail` and are unchanged by this wave, so they are passed IN rather than moved:
|
| 74 |
+
* a builder that re-implemented them would be a second copy of five panels whose only job is
|
| 75 |
+
* to agree with the engine.
|
| 76 |
+
*/
|
| 77 |
+
renderNodePanel: (node: GraphNode) => ReactNode;
|
| 78 |
+
/**
|
| 79 |
+
* The SCHEDULE half of the trigger face (`AutomationTrigger` with its picker hidden). Passed
|
| 80 |
+
* in for the same reason the node panels are: the cron round-trip, the server's preset list
|
| 81 |
+
* and the tick-honesty note exist exactly once, and both this view and the board render the
|
| 82 |
+
* same element rather than two copies of three things that go wrong invisibly.
|
| 83 |
+
*/
|
| 84 |
+
scheduleFace: ReactNode;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
type Sel = { kind: "trigger" | "action" | "node"; id: string };
|
| 88 |
+
|
| 89 |
+
/** Airtable phrases the empty state's shortcuts; ours come from the SERVER's own list (image 1). */
|
| 90 |
+
const SUGGESTED = 6;
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* A trigger's icon, PER KIND. Drawn, never an emoji (owner constant).
|
| 94 |
+
*
|
| 95 |
+
* β THE FIRST VERSION DREW ONE GLYPH FOR ALL ELEVEN, and reading the suggested list settled it:
|
| 96 |
+
* six identical arrows down the left edge is decoration, not information β the eye learns
|
| 97 |
+
* nothing and the column just gets wider. Airtable differentiates them (images 1/2) for the
|
| 98 |
+
* same reason. Unknown keys keep the neutral arrow, so a trigger the engine adds tomorrow
|
| 99 |
+
* renders plainly rather than not at all (the silent-drop rule, applied to icons).
|
| 100 |
+
*/
|
| 101 |
+
function TriggerMark({ kind = "" }: { kind?: string }) {
|
| 102 |
+
const common = {
|
| 103 |
+
width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
|
| 104 |
+
strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
|
| 105 |
+
"aria-hidden": true,
|
| 106 |
+
};
|
| 107 |
+
if (kind === "schedule")
|
| 108 |
+
return (
|
| 109 |
+
<svg {...common}>
|
| 110 |
+
<circle cx="8" cy="8" r="5.6" />
|
| 111 |
+
<path d="M8 5.2V8l2 1.4" />
|
| 112 |
+
</svg>
|
| 113 |
+
);
|
| 114 |
+
if (kind === "event_field")
|
| 115 |
+
return (
|
| 116 |
+
<svg {...common}>
|
| 117 |
+
<path d="M2.8 3.4h10.4l-4 4.6v4.2l-2.4 1.2V8z" />
|
| 118 |
+
</svg>
|
| 119 |
+
);
|
| 120 |
+
if (kind === "record_created")
|
| 121 |
+
return (
|
| 122 |
+
<svg {...common}>
|
| 123 |
+
<rect x="3" y="2.6" width="10" height="10.8" rx="1.8" />
|
| 124 |
+
<path d="M8 5.8v4.4M5.8 8h4.4" />
|
| 125 |
+
</svg>
|
| 126 |
+
);
|
| 127 |
+
if (kind === "record_updated")
|
| 128 |
+
return (
|
| 129 |
+
<svg {...common}>
|
| 130 |
+
<rect x="3" y="2.6" width="10" height="10.8" rx="1.8" />
|
| 131 |
+
<path d="M5.8 6.4h4.4M5.8 9.2h2.6" />
|
| 132 |
+
</svg>
|
| 133 |
+
);
|
| 134 |
+
if (kind === "enters_view")
|
| 135 |
+
return (
|
| 136 |
+
<svg {...common}>
|
| 137 |
+
<path d="M1.8 8s2.4-4 6.2-4 6.2 4 6.2 4-2.4 4-6.2 4-6.2-4-6.2-4z" />
|
| 138 |
+
<circle cx="8" cy="8" r="1.7" />
|
| 139 |
+
</svg>
|
| 140 |
+
);
|
| 141 |
+
if (kind === "webhook")
|
| 142 |
+
return (
|
| 143 |
+
<svg {...common}>
|
| 144 |
+
<path d="M6.6 9.4 4.9 11a2.6 2.6 0 1 1-1.5-4.4" />
|
| 145 |
+
<path d="M9.4 6.6 11.1 5a2.6 2.6 0 1 1 1.5 4.4" />
|
| 146 |
+
<path d="M6.2 8h3.6" />
|
| 147 |
+
</svg>
|
| 148 |
+
);
|
| 149 |
+
if (kind === "email")
|
| 150 |
+
return (
|
| 151 |
+
<svg {...common}>
|
| 152 |
+
<rect x="2.2" y="3.6" width="11.6" height="8.8" rx="1.6" />
|
| 153 |
+
<path d="m2.6 4.6 5.4 3.8 5.4-3.8" />
|
| 154 |
+
</svg>
|
| 155 |
+
);
|
| 156 |
+
if (kind === "form_submitted")
|
| 157 |
+
return (
|
| 158 |
+
<svg {...common}>
|
| 159 |
+
<rect x="3" y="2.6" width="10" height="10.8" rx="1.8" />
|
| 160 |
+
<path d="M5.8 6h4.4M5.8 8.6h2.2M9 11l1.2 1.2L12.6 9.8" />
|
| 161 |
+
</svg>
|
| 162 |
+
);
|
| 163 |
+
if (kind === "button_clicked")
|
| 164 |
+
return (
|
| 165 |
+
<svg {...common}>
|
| 166 |
+
<path d="M6 3.2v6.2l1.6-1.4 1.4 3 1.6-.8-1.4-2.8 2.2-.2z" />
|
| 167 |
+
</svg>
|
| 168 |
+
);
|
| 169 |
+
if (kind === "comment_added")
|
| 170 |
+
return (
|
| 171 |
+
<svg {...common}>
|
| 172 |
+
<path d="M13.4 9.4a1.6 1.6 0 0 1-1.6 1.6H5.4L2.6 13.4V4.2a1.6 1.6 0 0 1 1.6-1.6h7.6a1.6 1.6 0 0 1 1.6 1.6z" />
|
| 173 |
+
</svg>
|
| 174 |
+
);
|
| 175 |
+
return (
|
| 176 |
+
<svg {...common}>
|
| 177 |
+
<path d="M3 3v6.2a2 2 0 0 0 2 2h7.4" />
|
| 178 |
+
<path d="M10.2 9.2 12.8 11.2 10.2 13.2" />
|
| 179 |
+
</svg>
|
| 180 |
+
);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function ActionMark({ kind }: { kind: string }) {
|
| 184 |
+
const common = {
|
| 185 |
+
width: 15, height: 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor",
|
| 186 |
+
strokeWidth: 1.4, strokeLinecap: "round" as const, strokeLinejoin: "round" as const,
|
| 187 |
+
"aria-hidden": true,
|
| 188 |
+
};
|
| 189 |
+
if (kind === "group")
|
| 190 |
+
return (
|
| 191 |
+
<svg {...common}>
|
| 192 |
+
<path d="M2.6 8h3l2.6-3.6h5M8.2 11.6H5.6m2.6 0h5" />
|
| 193 |
+
<path d="M11.6 2.6 13.4 4.4 11.6 6.2M11.6 9.8l1.8 1.8-1.8 1.8" />
|
| 194 |
+
</svg>
|
| 195 |
+
);
|
| 196 |
+
if (kind === "review")
|
| 197 |
+
return (
|
| 198 |
+
<svg {...common}>
|
| 199 |
+
<circle cx="8" cy="5.6" r="2.6" />
|
| 200 |
+
<path d="M3 13.4c.7-2.3 2.6-3.6 5-3.6s4.3 1.3 5 3.6" />
|
| 201 |
+
</svg>
|
| 202 |
+
);
|
| 203 |
+
if (kind === "create_record")
|
| 204 |
+
return (
|
| 205 |
+
<svg {...common}>
|
| 206 |
+
<rect x="2.6" y="2.6" width="10.8" height="10.8" rx="2" />
|
| 207 |
+
<path d="M8 5.6v4.8M5.6 8h4.8" />
|
| 208 |
+
</svg>
|
| 209 |
+
);
|
| 210 |
+
if (kind === "find_records")
|
| 211 |
+
return (
|
| 212 |
+
<svg {...common}>
|
| 213 |
+
<circle cx="7.2" cy="7.2" r="4" />
|
| 214 |
+
<path d="m10.2 10.2 3.2 3.2" />
|
| 215 |
+
</svg>
|
| 216 |
+
);
|
| 217 |
+
/*
|
| 218 |
+
β THE MACHINE KINDS ARE HERE TOO, and leaving them out was a real defect for one render:
|
| 219 |
+
the engine's nodes are `source | capture | branch | write`, none of which matched above, so
|
| 220 |
+
"Write to the database" and "Fetch the page" both drew the PENCIL β an edit glyph on a step
|
| 221 |
+
that reads a web page and a step that writes a database. Caught by reading the screenshot,
|
| 222 |
+
not by a gate ([[ui-invisible-to-assertions]]): the icon was present, legible and wrong.
|
| 223 |
+
These are the shapes the retired `AutomationSteps.KindMark` used, kept with their meanings.
|
| 224 |
+
*/
|
| 225 |
+
if (kind === "source")
|
| 226 |
+
return (
|
| 227 |
+
<svg {...common}>
|
| 228 |
+
<circle cx="8" cy="8" r="5.6" />
|
| 229 |
+
<path d="M2.6 8h10.8M8 2.4c1.5 1.7 2.2 3.6 2.2 5.6S9.5 12.3 8 13.6C6.5 12.3 5.8 10 5.8 8s.7-3.9 2.2-5.6z" />
|
| 230 |
+
</svg>
|
| 231 |
+
);
|
| 232 |
+
if (kind === "write")
|
| 233 |
+
return (
|
| 234 |
+
<svg {...common}>
|
| 235 |
+
<ellipse cx="8" cy="4.2" rx="4.8" ry="1.9" />
|
| 236 |
+
<path d="M3.2 4.2v7.6c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9V4.2M3.2 8c0 1 2.1 1.9 4.8 1.9s4.8-.9 4.8-1.9" />
|
| 237 |
+
</svg>
|
| 238 |
+
);
|
| 239 |
+
if (kind === "capture")
|
| 240 |
+
return (
|
| 241 |
+
<svg {...common}>
|
| 242 |
+
<path d="M8 2.6v7.2M5.2 7l2.8 2.8L10.8 7M3 12.4h10" />
|
| 243 |
+
</svg>
|
| 244 |
+
);
|
| 245 |
+
if (kind === "branch")
|
| 246 |
+
return (
|
| 247 |
+
<svg {...common}>
|
| 248 |
+
<path d="M2.6 8h3.2l2.6-3.6h5M8.4 11.6H5.8m2.6 0h5" />
|
| 249 |
+
<path d="M11.6 2.6 13.4 4.4 11.6 6.2M11.6 9.8l1.8 1.8-1.8 1.8" />
|
| 250 |
+
</svg>
|
| 251 |
+
);
|
| 252 |
+
if (kind === "update_record")
|
| 253 |
+
return (
|
| 254 |
+
<svg {...common}>
|
| 255 |
+
<path d="M11.4 2.8 13.2 4.6 6 11.8l-2.6.8.8-2.6z" />
|
| 256 |
+
<path d="M3 13.6h10" />
|
| 257 |
+
</svg>
|
| 258 |
+
);
|
| 259 |
+
if (kind === "send_email")
|
| 260 |
+
return (
|
| 261 |
+
<svg {...common}>
|
| 262 |
+
<rect x="2.2" y="3.6" width="11.6" height="8.8" rx="1.6" />
|
| 263 |
+
<path d="m2.6 4.6 5.4 3.8 5.4-3.8" />
|
| 264 |
+
</svg>
|
| 265 |
+
);
|
| 266 |
+
if (kind === "slack")
|
| 267 |
+
return (
|
| 268 |
+
<svg {...common}>
|
| 269 |
+
<path d="M13.4 9.4a1.6 1.6 0 0 1-1.6 1.6H5.4L2.6 13.4V4.2a1.6 1.6 0 0 1 1.6-1.6h7.6a1.6 1.6 0 0 1 1.6 1.6z" />
|
| 270 |
+
</svg>
|
| 271 |
+
);
|
| 272 |
+
if (kind === "run_script")
|
| 273 |
+
return (
|
| 274 |
+
<svg {...common}>
|
| 275 |
+
<path d="M5.6 5.4 3 8l2.6 2.6M10.4 5.4 13 8l-2.6 2.6M9 3.4 7 12.6" />
|
| 276 |
+
</svg>
|
| 277 |
+
);
|
| 278 |
+
if (kind === "generate_ai")
|
| 279 |
+
return (
|
| 280 |
+
<svg {...common}>
|
| 281 |
+
<path d="M8 2.4l1.5 3.4 3.4 1.5-3.4 1.5L8 12.2 6.5 8.8 3.1 7.3l3.4-1.5z" />
|
| 282 |
+
</svg>
|
| 283 |
+
);
|
| 284 |
+
if (kind === "repeating_group")
|
| 285 |
+
return (
|
| 286 |
+
<svg {...common}>
|
| 287 |
+
<path d="M3 8a5 5 0 0 1 5-5c2 0 3.4 1 4.3 2.4M13 8a5 5 0 0 1-5 5c-2 0-3.4-1-4.3-2.4" />
|
| 288 |
+
<path d="M12.4 2.6v2.9h-2.9M3.6 13.4v-2.9h2.9" />
|
| 289 |
+
</svg>
|
| 290 |
+
);
|
| 291 |
+
/*
|
| 292 |
+
β THE FALLBACK IS NEUTRAL, and it was not the first time round. The default used to BE the
|
| 293 |
+
pencil β a real member of the set (`update_record`) β so every kind with no arm silently
|
| 294 |
+
borrowed "edit": `send_email`, `run_script` and `repeating_group` all drew it in the action
|
| 295 |
+
menu, which reads as three actions that modify a record. A fallback that is a real member
|
| 296 |
+
cannot be told apart from a match; this one can.
|
| 297 |
+
*/
|
| 298 |
+
return (
|
| 299 |
+
<svg {...common}>
|
| 300 |
+
<rect x="3.2" y="3.2" width="9.6" height="9.6" rx="2.2" />
|
| 301 |
+
<circle cx="8" cy="8" r="1.4" />
|
| 302 |
+
</svg>
|
| 303 |
+
);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/** The label a table key reads as. A raw `ut_9f3aβ¦` on a card is a key, not a subtitle. */
|
| 307 |
+
function tableLabel(tables: UserTable[], key: string): string {
|
| 308 |
+
if (!key) return "";
|
| 309 |
+
return tables.find((t) => t.key === key)?.label || key;
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
export default function AutomationBuilder({
|
| 313 |
+
automation,
|
| 314 |
+
triggers,
|
| 315 |
+
catalog,
|
| 316 |
+
vocab,
|
| 317 |
+
tables,
|
| 318 |
+
oauth,
|
| 319 |
+
busy,
|
| 320 |
+
onPatch,
|
| 321 |
+
onToggleNode,
|
| 322 |
+
onPickTrigger,
|
| 323 |
+
onRunNow,
|
| 324 |
+
renderNodePanel,
|
| 325 |
+
scheduleFace,
|
| 326 |
+
}: Props) {
|
| 327 |
+
const [sel, setSel] = useState<Sel>({ kind: "trigger", id: "" });
|
| 328 |
+
const [picking, setPicking] = useState(false);
|
| 329 |
+
const [adding, setAdding] = useState(false);
|
| 330 |
+
/** The trigger key awaiting confirmation (image 6) β a change that can invalidate config. */
|
| 331 |
+
const [confirmKey, setConfirmKey] = useState("");
|
| 332 |
+
|
| 333 |
+
const trigger = automation.trigger || null;
|
| 334 |
+
const triggerKey = trigger?.key || (automation.schedule?.enabled ? "schedule" : "manual");
|
| 335 |
+
const options = triggers || [];
|
| 336 |
+
const picked = options.find((t) => t.key === triggerKey) || null;
|
| 337 |
+
const actions = automation.flow?.actions || [];
|
| 338 |
+
const nodes = (automation.graph?.nodes || []).filter((n) => n.kind !== "trigger");
|
| 339 |
+
const ops = vocab?.condOps || [];
|
| 340 |
+
const nullaryOps = vocab?.nullaryCondOps || [];
|
| 341 |
+
const table = tables.find((t) => t.key === (trigger?.table || "")) || null;
|
| 342 |
+
|
| 343 |
+
/**
|
| 344 |
+
* β HAS ANYTHING BEEN CHOSEN? β and the first version of this line was wrong in a way only
|
| 345 |
+
* a screenshot showed.
|
| 346 |
+
*
|
| 347 |
+
* It read `!!trigger || key === "schedule" || key === "manual"`, and `triggerKey` DERIVES
|
| 348 |
+
* "manual" whenever nothing is stored β so it was true for every automation ever, and the
|
| 349 |
+
* Airtable empty state (image 1) was unreachable code that every gate was happy with.
|
| 350 |
+
*
|
| 351 |
+
* The honest test is what the DEFINITION holds, not what the picker displays: the engine
|
| 352 |
+
* deliberately stores no trigger for manual/schedule ("`schedule` already owns the cron"), so
|
| 353 |
+
* `trigger === null && !schedule.enabled` IS "nobody has decided yet". Manual is what that
|
| 354 |
+
* state DOES, not a thing you pick out of it β which is why the suggested list below omits
|
| 355 |
+
* it: an option whose selection stores nothing would bounce straight back to this state on
|
| 356 |
+
* the next reload, and a control that undoes itself is worse than no control.
|
| 357 |
+
*/
|
| 358 |
+
const chosen = !!trigger || !!automation.schedule?.enabled;
|
| 359 |
+
const configured = !trigger || trigger.configured !== false;
|
| 360 |
+
|
| 361 |
+
/** Write one key of the trigger, keeping the rest β the engine merges against `previous`. */
|
| 362 |
+
const patchTrigger = (patch: Record<string, unknown>) =>
|
| 363 |
+
onPatch({ trigger: { key: triggerKey, ...patch } });
|
| 364 |
+
|
| 365 |
+
const patchFlow = (next: Action[]) =>
|
| 366 |
+
onPatch({ flow: { actions: next, ending: automation.flow?.ending || { mode: "terminal" } } });
|
| 367 |
+
|
| 368 |
+
/** Replace one action anywhere in the tree, groups included β ids are stable server-side. */
|
| 369 |
+
const editAction = (id: string, change: (a: Action) => Action) => {
|
| 370 |
+
const walk = (list: Action[]): Action[] =>
|
| 371 |
+
list.map((a) => {
|
| 372 |
+
if (a.id === id) return change(a);
|
| 373 |
+
const kids = (a.config as { actions?: Action[] })?.actions;
|
| 374 |
+
if (a.kind === "group" && Array.isArray(kids))
|
| 375 |
+
return { ...a, config: { ...a.config, actions: walk(kids) } };
|
| 376 |
+
return a;
|
| 377 |
+
});
|
| 378 |
+
patchFlow(walk(actions));
|
| 379 |
+
};
|
| 380 |
+
|
| 381 |
+
const dropAction = (id: string) => {
|
| 382 |
+
const walk = (list: Action[]): Action[] =>
|
| 383 |
+
list
|
| 384 |
+
.filter((a) => a.id !== id)
|
| 385 |
+
.map((a) => {
|
| 386 |
+
const kids = (a.config as { actions?: Action[] })?.actions;
|
| 387 |
+
return a.kind === "group" && Array.isArray(kids)
|
| 388 |
+
? { ...a, config: { ...a.config, actions: walk(kids) } }
|
| 389 |
+
: a;
|
| 390 |
+
});
|
| 391 |
+
patchFlow(walk(actions));
|
| 392 |
+
if (sel.kind === "action" && sel.id === id) setSel({ kind: "trigger", id: "" });
|
| 393 |
+
};
|
| 394 |
+
|
| 395 |
+
/**
|
| 396 |
+
* β A NEW ACTION'S CONFIG MUST BE ONE THE SERVER ACCEPTS β and the first version of this
|
| 397 |
+
* function got it wrong for FOUR of the five kinds, which is worth writing down because every
|
| 398 |
+
* gate was green and the harness could not see it (SSR renders no click).
|
| 399 |
+
*
|
| 400 |
+
* MEASURED against `clean_actions` itself (`python -c` over the engine module, no server):
|
| 401 |
+
* `{values:{"":""}}` -> "a value is written to a field with no name"
|
| 402 |
+
* `{table:"",values:β¦}` -> the same, twice
|
| 403 |
+
* `{table:""}` (find) -> "the find records action names no database"
|
| 404 |
+
* `{cond:β¦,actions:[]}` -> "a conditional group with no actions inside it does nothing"
|
| 405 |
+
* So clicking "Add β Update record" would have produced a red banner and NO CARD. This is the
|
| 406 |
+
* wave-22 discovery-predicate scar reproduced exactly ([[cg-condition-builder-items]] β every
|
| 407 |
+
* default must be one the server takes), in a file whose own comment cites it.
|
| 408 |
+
*
|
| 409 |
+
* The seeds below are built from data this component already holds, and `seedFor` returns
|
| 410 |
+
* NULL when it cannot build a legal one. A kind with no legal seed is offered DISABLED with
|
| 411 |
+
* the reason, rather than offered and refused: the two states look identical to a user and
|
| 412 |
+
* only one of them is honest.
|
| 413 |
+
*/
|
| 414 |
+
const walkFields = table?.fields || [];
|
| 415 |
+
const firstField = walkFields[0]?.key || "";
|
| 416 |
+
const firstTable = tables[0] || null;
|
| 417 |
+
const firstTableField = firstTable?.fields?.[0]?.key || "";
|
| 418 |
+
|
| 419 |
+
const seedFor = (kind: string): Record<string, unknown> | null => {
|
| 420 |
+
if (kind === "review")
|
| 421 |
+
return { decidedBy: "user", label: "Review", next: ["Approved", "Declined"], prompt: "" };
|
| 422 |
+
if (kind === "update_record")
|
| 423 |
+
return firstField ? { values: { [firstField]: "" } } : null;
|
| 424 |
+
if (kind === "create_record")
|
| 425 |
+
return firstTable && firstTableField
|
| 426 |
+
? { table: firstTable.key, values: { [firstTableField]: "" } }
|
| 427 |
+
: null;
|
| 428 |
+
if (kind === "find_records")
|
| 429 |
+
return firstTable ? { table: firstTable.key, cond: null, limit: 25 } : null;
|
| 430 |
+
if (kind === "group")
|
| 431 |
+
// A group with no children "does nothing" and is refused, so it is born with one β the
|
| 432 |
+
// cheapest legal child, not a review, because a review would also mint a board stage that
|
| 433 |
+
// nobody asked for by clicking "Conditional logic".
|
| 434 |
+
return firstField
|
| 435 |
+
? { cond: null,
|
| 436 |
+
actions: [{ id: "", kind: "update_record", enabled: true, when: null,
|
| 437 |
+
config: { values: { [firstField]: "" } } }] }
|
| 438 |
+
: null;
|
| 439 |
+
return null;
|
| 440 |
+
};
|
| 441 |
+
|
| 442 |
+
/** Why a kind cannot be added yet β the server's own precondition, said before the click. */
|
| 443 |
+
const seedBlock = (kind: string): string => {
|
| 444 |
+
if (kind === "update_record" || kind === "group")
|
| 445 |
+
return "Name the trigger's database first β this needs a column to write.";
|
| 446 |
+
if (kind === "create_record" || kind === "find_records")
|
| 447 |
+
return "There is no blank database to point at yet.";
|
| 448 |
+
return "";
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
+
const addAction = (kind: string, into?: string) => {
|
| 452 |
+
const seed = seedFor(kind);
|
| 453 |
+
if (!seed) return;
|
| 454 |
+
const fresh: Action = { id: "", kind, enabled: true, when: null, config: seed };
|
| 455 |
+
if (!into) {
|
| 456 |
+
patchFlow([...actions, fresh]);
|
| 457 |
+
} else {
|
| 458 |
+
editAction(into, (a) => ({
|
| 459 |
+
...a,
|
| 460 |
+
config: {
|
| 461 |
+
...a.config,
|
| 462 |
+
actions: [...(((a.config as { actions?: Action[] }).actions) || []), fresh],
|
| 463 |
+
},
|
| 464 |
+
}));
|
| 465 |
+
}
|
| 466 |
+
setAdding(false);
|
| 467 |
+
};
|
| 468 |
+
|
| 469 |
+
// ββ the centre column ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 470 |
+
const chip = () => {
|
| 471 |
+
if (!chosen) return null;
|
| 472 |
+
if (!configured)
|
| 473 |
+
return (
|
| 474 |
+
<span className="autox-chip is-warn" title="This trigger cannot fire until it is finished">
|
| 475 |
+
Finish configuration
|
| 476 |
+
</span>
|
| 477 |
+
);
|
| 478 |
+
if (trigger?.paused)
|
| 479 |
+
return <span className="autox-chip is-warn">Paused</span>;
|
| 480 |
+
if (picked && picked.ready === false)
|
| 481 |
+
return <span className="autox-chip is-warn">Not set up</span>;
|
| 482 |
+
const last = automation.runs?.[0];
|
| 483 |
+
if (last)
|
| 484 |
+
return (
|
| 485 |
+
<span className="autox-chip is-ok" title={last.summary}>
|
| 486 |
+
{last.ok ? "Last run succeeded" : "Last run failed"}
|
| 487 |
+
</span>
|
| 488 |
+
);
|
| 489 |
+
return null;
|
| 490 |
+
};
|
| 491 |
+
|
| 492 |
+
const actionCard = (a: Action, depth: number): ReactNode => {
|
| 493 |
+
const row = catalog.find((c) => c.kind === a.kind);
|
| 494 |
+
const kids = ((a.config as { actions?: Action[] }).actions) || [];
|
| 495 |
+
const isGroup = a.kind === "group";
|
| 496 |
+
return (
|
| 497 |
+
<div className="autox-actionwrap" key={a.id}>
|
| 498 |
+
<div
|
| 499 |
+
className={
|
| 500 |
+
"autox-card" +
|
| 501 |
+
(sel.kind === "action" && sel.id === a.id ? " is-selected" : "") +
|
| 502 |
+
(a.enabled ? "" : " is-off") +
|
| 503 |
+
(isGroup ? " is-group" : "")
|
| 504 |
+
}
|
| 505 |
+
>
|
| 506 |
+
<button
|
| 507 |
+
type="button"
|
| 508 |
+
className="autox-card-hit"
|
| 509 |
+
aria-pressed={sel.kind === "action" && sel.id === a.id}
|
| 510 |
+
onClick={() => setSel({ kind: "action", id: a.id })}
|
| 511 |
+
>
|
| 512 |
+
<span className="autox-card-mark">
|
| 513 |
+
<ActionMark kind={a.kind} />
|
| 514 |
+
</span>
|
| 515 |
+
<span className="autox-card-text">
|
| 516 |
+
<span className="autox-card-title">
|
| 517 |
+
{isGroup ? "If conditions are met" : row?.label || a.kind}
|
| 518 |
+
</span>
|
| 519 |
+
{isGroup ? null : (
|
| 520 |
+
<span className="autox-card-sub">{actionSub(a, tables)}</span>
|
| 521 |
+
)}
|
| 522 |
+
</span>
|
| 523 |
+
</button>
|
| 524 |
+
<button
|
| 525 |
+
type="button"
|
| 526 |
+
className="autox-card-drop"
|
| 527 |
+
disabled={busy}
|
| 528 |
+
aria-label={`Remove ${row?.label || a.kind}`}
|
| 529 |
+
title={`Remove ${row?.label || a.kind}`}
|
| 530 |
+
onClick={() => dropAction(a.id)}
|
| 531 |
+
>
|
| 532 |
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 533 |
+
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| 534 |
+
strokeLinecap="round" />
|
| 535 |
+
</svg>
|
| 536 |
+
</button>
|
| 537 |
+
</div>
|
| 538 |
+
|
| 539 |
+
{/* A GROUP HOLDS ITS OWN COLUMN (image 9) β nested cards and their own dashed add. The
|
| 540 |
+
nesting ceiling is the SERVER's `maxGroupDepth`, so this affordance disappears at
|
| 541 |
+
exactly the depth `clean_actions` refuses. */}
|
| 542 |
+
{isGroup ? (
|
| 543 |
+
<div className="autox-nest">
|
| 544 |
+
{kids.map((k) => actionCard(k, depth + 1))}
|
| 545 |
+
{depth + 1 < (vocab?.maxGroupDepth ?? 2) || !kids.length ? (
|
| 546 |
+
<button
|
| 547 |
+
type="button"
|
| 548 |
+
className="autox-add is-nested"
|
| 549 |
+
disabled={busy || !seedFor("update_record")}
|
| 550 |
+
title={seedFor("update_record") ? undefined : seedBlock("update_record")}
|
| 551 |
+
onClick={() => addAction("update_record", a.id)}
|
| 552 |
+
>
|
| 553 |
+
+ Add an action in this group
|
| 554 |
+
</button>
|
| 555 |
+
) : null}
|
| 556 |
+
</div>
|
| 557 |
+
) : null}
|
| 558 |
+
</div>
|
| 559 |
+
);
|
| 560 |
+
};
|
| 561 |
+
|
| 562 |
+
return (
|
| 563 |
+
<>
|
| 564 |
+
<div className="autox-flow">
|
| 565 |
+
{/* ββ TRIGGER βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 566 |
+
<div className="autox-step">
|
| 567 |
+
<div className="autox-side">
|
| 568 |
+
<span className="autox-tag">Trigger</span>
|
| 569 |
+
{chip()}
|
| 570 |
+
</div>
|
| 571 |
+
<div className="autox-main">
|
| 572 |
+
{chosen && options.length ? (
|
| 573 |
+
<div
|
| 574 |
+
className={
|
| 575 |
+
"autox-card is-trigger" + (sel.kind === "trigger" ? " is-selected" : "")
|
| 576 |
+
}
|
| 577 |
+
>
|
| 578 |
+
<button
|
| 579 |
+
type="button"
|
| 580 |
+
className="autox-card-hit"
|
| 581 |
+
aria-pressed={sel.kind === "trigger"}
|
| 582 |
+
onClick={() => setSel({ kind: "trigger", id: "" })}
|
| 583 |
+
>
|
| 584 |
+
<span className="autox-card-mark">
|
| 585 |
+
<TriggerMark kind={triggerKey} />
|
| 586 |
+
</span>
|
| 587 |
+
<span className="autox-card-text">
|
| 588 |
+
<span className="autox-card-title">
|
| 589 |
+
{picked?.label || triggerKey}
|
| 590 |
+
</span>
|
| 591 |
+
{trigger?.table ? (
|
| 592 |
+
<span className="autox-card-sub">
|
| 593 |
+
In {tableLabel(tables, trigger.table)}
|
| 594 |
+
</span>
|
| 595 |
+
) : null}
|
| 596 |
+
</span>
|
| 597 |
+
</button>
|
| 598 |
+
</div>
|
| 599 |
+
) : (
|
| 600 |
+
/*
|
| 601 |
+
THE EMPTY STATE (image 1): a dashed add-box and the server's own suggested
|
| 602 |
+
triggers. One line of chrome, no tour (R13) β the list IS the explanation, and
|
| 603 |
+
it is the server's list so it cannot describe a trigger we removed.
|
| 604 |
+
*/
|
| 605 |
+
<>
|
| 606 |
+
<button
|
| 607 |
+
type="button"
|
| 608 |
+
className="autox-add is-empty"
|
| 609 |
+
disabled={busy || !options.length}
|
| 610 |
+
onClick={() => setPicking(true)}
|
| 611 |
+
>
|
| 612 |
+
+ Add trigger
|
| 613 |
+
</button>
|
| 614 |
+
{options.length ? (
|
| 615 |
+
<div className="autox-suggest">
|
| 616 |
+
<p className="autox-suggest-head">Suggested triggers</p>
|
| 617 |
+
{options
|
| 618 |
+
.filter((t) => t.ready !== false && !t.planned && t.key !== "manual")
|
| 619 |
+
.slice(0, SUGGESTED)
|
| 620 |
+
.map((t) => (
|
| 621 |
+
<button
|
| 622 |
+
key={t.key}
|
| 623 |
+
type="button"
|
| 624 |
+
className="autox-suggest-row"
|
| 625 |
+
disabled={busy}
|
| 626 |
+
onClick={() => onPickTrigger(t.key)}
|
| 627 |
+
>
|
| 628 |
+
<span className="autox-card-mark">
|
| 629 |
+
<TriggerMark kind={t.key} />
|
| 630 |
+
</span>
|
| 631 |
+
{t.label}
|
| 632 |
+
</button>
|
| 633 |
+
))}
|
| 634 |
+
</div>
|
| 635 |
+
) : (
|
| 636 |
+
<p className="auto-note">This server did not offer a trigger list.</p>
|
| 637 |
+
)}
|
| 638 |
+
{/* ONE LINE (R13), and it is what is TRUE of this state rather than a caption
|
| 639 |
+
for the box above it: with nothing chosen, Run now is the only thing that
|
| 640 |
+
starts this automation. Airtable's empty state means "it cannot run"; ours
|
| 641 |
+
does not, and saying so is the difference between a screen that is honest
|
| 642 |
+
and one that merely looks the same. */}
|
| 643 |
+
<p className="auto-hint">Until then, only Run now starts it.</p>
|
| 644 |
+
</>
|
| 645 |
+
)}
|
| 646 |
+
</div>
|
| 647 |
+
</div>
|
| 648 |
+
|
| 649 |
+
{/* ββ ACTIONS βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 650 |
+
<div className="autox-step">
|
| 651 |
+
<div className="autox-side">
|
| 652 |
+
<span className="autox-tag">Actions</span>
|
| 653 |
+
</div>
|
| 654 |
+
<div className="autox-main">
|
| 655 |
+
{actions.map((a) => actionCard(a, 0))}
|
| 656 |
+
|
| 657 |
+
{/* THE MACHINE STEPS (C1) β engine-derived, read-only cards. They are what this
|
| 658 |
+
automation IS (fetch a page, write a database); the flow's actions are what the
|
| 659 |
+
owner adds on top. Rendering them from `graph()` is the same law the numbered
|
| 660 |
+
steps were built on: the surface cannot show a step the engine stopped running. */}
|
| 661 |
+
{nodes.map((n) => (
|
| 662 |
+
<div
|
| 663 |
+
key={n.id}
|
| 664 |
+
className={
|
| 665 |
+
"autox-card is-machine" +
|
| 666 |
+
(sel.kind === "node" && sel.id === n.id ? " is-selected" : "") +
|
| 667 |
+
(n.enabled ? "" : " is-off")
|
| 668 |
+
}
|
| 669 |
+
>
|
| 670 |
+
<button
|
| 671 |
+
type="button"
|
| 672 |
+
className="autox-card-hit"
|
| 673 |
+
aria-pressed={sel.kind === "node" && sel.id === n.id}
|
| 674 |
+
title={n.detail || n.subtitle}
|
| 675 |
+
onClick={() => setSel({ kind: "node", id: n.id })}
|
| 676 |
+
>
|
| 677 |
+
<span className="autox-card-mark">
|
| 678 |
+
<ActionMark kind={n.kind} />
|
| 679 |
+
</span>
|
| 680 |
+
<span className="autox-card-text">
|
| 681 |
+
<span className="autox-card-title">{n.title}</span>
|
| 682 |
+
<span className="autox-card-sub">{n.subtitle}</span>
|
| 683 |
+
</span>
|
| 684 |
+
<span className={`auto-dot is-${n.status}`} aria-label={`Last run: ${n.status}`} />
|
| 685 |
+
</button>
|
| 686 |
+
{n.toggle ? (
|
| 687 |
+
<button
|
| 688 |
+
type="button"
|
| 689 |
+
className={"auto-step-switch" + (n.enabled ? " is-on" : "")}
|
| 690 |
+
disabled={busy}
|
| 691 |
+
aria-pressed={n.enabled}
|
| 692 |
+
aria-label={`${n.enabled ? "Turn off" : "Turn on"} ${n.title}`}
|
| 693 |
+
title={`${n.enabled ? "Turn off" : "Turn on"} ${n.title}`}
|
| 694 |
+
onClick={() => onToggleNode(n.id)}
|
| 695 |
+
>
|
| 696 |
+
<span className="auto-step-switch-knob" />
|
| 697 |
+
</button>
|
| 698 |
+
) : null}
|
| 699 |
+
</div>
|
| 700 |
+
))}
|
| 701 |
+
|
| 702 |
+
<button
|
| 703 |
+
type="button"
|
| 704 |
+
className="autox-add"
|
| 705 |
+
disabled={busy || !catalog.length}
|
| 706 |
+
onClick={() => setAdding((v) => !v)}
|
| 707 |
+
>
|
| 708 |
+
+ Add advanced logic or action
|
| 709 |
+
</button>
|
| 710 |
+
|
| 711 |
+
{/* THE ACTION MENU (images 7/8): the server's catalog, grouped by its own `group`
|
| 712 |
+
key, with `ready:false` rows faded and carrying the server's reason. A shorter
|
| 713 |
+
menu would imply those actions do not exist β the owner asked to see all of
|
| 714 |
+
them, and `clean_actions` refuses the unready ones at the door, so faded is a
|
| 715 |
+
wall rather than a decoration. */}
|
| 716 |
+
{adding ? (
|
| 717 |
+
<div className="autox-menu" role="menu">
|
| 718 |
+
{[...new Set(catalog.map((c) => c.group))].map((group) => (
|
| 719 |
+
<div className="autox-menu-group" key={group}>
|
| 720 |
+
<p className="autox-menu-head">{group}</p>
|
| 721 |
+
{catalog
|
| 722 |
+
.filter((c) => c.group === group)
|
| 723 |
+
.map((c) => (
|
| 724 |
+
<button
|
| 725 |
+
key={c.kind}
|
| 726 |
+
type="button"
|
| 727 |
+
className={
|
| 728 |
+
"autox-menu-row" +
|
| 729 |
+
(c.ready && seedFor(c.kind) ? "" : " is-planned")
|
| 730 |
+
}
|
| 731 |
+
disabled={!c.ready || busy || !seedFor(c.kind)}
|
| 732 |
+
title={
|
| 733 |
+
c.ready && !seedFor(c.kind) ? seedBlock(c.kind) : c.detail
|
| 734 |
+
}
|
| 735 |
+
onClick={() => addAction(c.kind)}
|
| 736 |
+
>
|
| 737 |
+
<span className="autox-card-mark">
|
| 738 |
+
<ActionMark kind={c.kind} />
|
| 739 |
+
</span>
|
| 740 |
+
<span className="autox-menu-text">
|
| 741 |
+
<span className="autox-menu-label">
|
| 742 |
+
{c.label}
|
| 743 |
+
{!c.ready ? (
|
| 744 |
+
<span className="autox-soon">Coming soon</span>
|
| 745 |
+
) : !seedFor(c.kind) ? (
|
| 746 |
+
/* NOT the same state as "coming soon", so not the same word:
|
| 747 |
+
this one is built and waiting on THIS automation, and the
|
| 748 |
+
title says exactly what is missing. */
|
| 749 |
+
<span className="autox-soon">Needs a database</span>
|
| 750 |
+
) : null}
|
| 751 |
+
</span>
|
| 752 |
+
<span className="autox-menu-detail">{c.detail}</span>
|
| 753 |
+
</span>
|
| 754 |
+
</button>
|
| 755 |
+
))}
|
| 756 |
+
</div>
|
| 757 |
+
))}
|
| 758 |
+
</div>
|
| 759 |
+
) : null}
|
| 760 |
+
</div>
|
| 761 |
+
</div>
|
| 762 |
+
</div>
|
| 763 |
+
|
| 764 |
+
{/* ββ PROPERTIES ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 765 |
+
<aside className="auto-panel autox-props" aria-label="Properties">
|
| 766 |
+
<div className="auto-panel-head">
|
| 767 |
+
<h2>Properties</h2>
|
| 768 |
+
<span className={"autox-saved" + (busy ? " is-busy" : "")}>
|
| 769 |
+
{busy ? "Savingβ¦" : "All changes saved"}
|
| 770 |
+
</span>
|
| 771 |
+
</div>
|
| 772 |
+
|
| 773 |
+
{sel.kind === "trigger" ? (
|
| 774 |
+
<TriggerProps
|
| 775 |
+
automation={automation}
|
| 776 |
+
triggerKey={triggerKey}
|
| 777 |
+
options={options}
|
| 778 |
+
picked={picked}
|
| 779 |
+
table={table}
|
| 780 |
+
tables={tables}
|
| 781 |
+
ops={ops}
|
| 782 |
+
nullaryOps={nullaryOps}
|
| 783 |
+
vocab={vocab}
|
| 784 |
+
oauth={oauth}
|
| 785 |
+
busy={busy}
|
| 786 |
+
onAskChange={setConfirmKey}
|
| 787 |
+
onPatchTrigger={patchTrigger}
|
| 788 |
+
onPickTrigger={onPickTrigger}
|
| 789 |
+
onRunNow={onRunNow}
|
| 790 |
+
scheduleFace={scheduleFace}
|
| 791 |
+
/>
|
| 792 |
+
) : sel.kind === "action" ? (
|
| 793 |
+
<ActionProps
|
| 794 |
+
action={findAction(actions, sel.id)}
|
| 795 |
+
catalog={catalog}
|
| 796 |
+
tables={tables}
|
| 797 |
+
walkFields={walkFields}
|
| 798 |
+
ops={ops}
|
| 799 |
+
nullaryOps={nullaryOps}
|
| 800 |
+
vocab={vocab}
|
| 801 |
+
busy={busy}
|
| 802 |
+
onEdit={(change) => editAction(sel.id, change)}
|
| 803 |
+
/>
|
| 804 |
+
) : (
|
| 805 |
+
nodePanel(nodes, sel.id, renderNodePanel)
|
| 806 |
+
)}
|
| 807 |
+
</aside>
|
| 808 |
+
|
| 809 |
+
{/* THE CONFIRM (image 6). A trigger change can invalidate the configuration under it, so
|
| 810 |
+
it ASKS β and it says what it will cost rather than "are you sure". */}
|
| 811 |
+
{confirmKey ? (
|
| 812 |
+
<div className="autox-confirm" role="dialog" aria-label="Change the trigger">
|
| 813 |
+
<p className="autox-confirm-title">Change the trigger?</p>
|
| 814 |
+
<p className="autox-confirm-body">
|
| 815 |
+
Anything configured for {picked?.label || triggerKey} is dropped.
|
| 816 |
+
</p>
|
| 817 |
+
<div className="autox-confirm-acts">
|
| 818 |
+
<button type="button" className="auto-btn" onClick={() => setConfirmKey("")}>
|
| 819 |
+
Cancel
|
| 820 |
+
</button>
|
| 821 |
+
<button
|
| 822 |
+
type="button"
|
| 823 |
+
className="auto-btn is-danger"
|
| 824 |
+
onClick={() => {
|
| 825 |
+
onPickTrigger(confirmKey);
|
| 826 |
+
setConfirmKey("");
|
| 827 |
+
}}
|
| 828 |
+
>
|
| 829 |
+
Change trigger
|
| 830 |
+
</button>
|
| 831 |
+
</div>
|
| 832 |
+
</div>
|
| 833 |
+
) : null}
|
| 834 |
+
|
| 835 |
+
{/* The picker, opened from the empty state's dashed box (image 2). */}
|
| 836 |
+
{picking ? (
|
| 837 |
+
<div className="autox-menu is-trigger" role="menu">
|
| 838 |
+
{options.map((t) => (
|
| 839 |
+
<button
|
| 840 |
+
key={t.key}
|
| 841 |
+
type="button"
|
| 842 |
+
className={"autox-menu-row" + (t.planned ? " is-planned" : "")}
|
| 843 |
+
disabled={!!t.planned || busy}
|
| 844 |
+
onClick={() => {
|
| 845 |
+
onPickTrigger(t.key);
|
| 846 |
+
setPicking(false);
|
| 847 |
+
}}
|
| 848 |
+
>
|
| 849 |
+
<span className="autox-card-mark">
|
| 850 |
+
<TriggerMark kind={t.key} />
|
| 851 |
+
</span>
|
| 852 |
+
<span className="autox-menu-text">
|
| 853 |
+
<span className="autox-menu-label">
|
| 854 |
+
{t.label}
|
| 855 |
+
{t.planned ? <span className="autox-soon">Coming soon</span> : null}
|
| 856 |
+
{!t.planned && t.ready === false ? (
|
| 857 |
+
<span className="autox-soon">Needs setting up</span>
|
| 858 |
+
) : null}
|
| 859 |
+
</span>
|
| 860 |
+
</span>
|
| 861 |
+
</button>
|
| 862 |
+
))}
|
| 863 |
+
</div>
|
| 864 |
+
) : null}
|
| 865 |
+
</>
|
| 866 |
+
);
|
| 867 |
+
}
|
| 868 |
+
|
| 869 |
+
/** One action's subtitle β what it will DO, composed from its own config. */
|
| 870 |
+
function actionSub(a: Action, tables: UserTable[]): string {
|
| 871 |
+
const cfg = a.config || {};
|
| 872 |
+
if (a.kind === "update_record") {
|
| 873 |
+
const keys = Object.keys((cfg as { values?: Record<string, unknown> }).values || {})
|
| 874 |
+
.filter(Boolean);
|
| 875 |
+
return keys.length ? `Sets ${keys.join(", ")}` : "No values yet";
|
| 876 |
+
}
|
| 877 |
+
if (a.kind === "create_record") {
|
| 878 |
+
const t = String((cfg as { table?: string }).table || "");
|
| 879 |
+
return t ? `Into ${tableLabel(tables, t)}` : "No database yet";
|
| 880 |
+
}
|
| 881 |
+
if (a.kind === "find_records") {
|
| 882 |
+
const t = String((cfg as { table?: string }).table || "");
|
| 883 |
+
return t ? `In ${tableLabel(tables, t)}` : "No database yet";
|
| 884 |
+
}
|
| 885 |
+
if (a.kind === "review") {
|
| 886 |
+
const by = String((cfg as { decidedBy?: string }).decidedBy || "user");
|
| 887 |
+
return by === "ai" ? "Decided by AI" : "Decided by a person";
|
| 888 |
+
}
|
| 889 |
+
return "";
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
function findAction(list: Action[], id: string): Action | null {
|
| 893 |
+
for (const a of list) {
|
| 894 |
+
if (a.id === id) return a;
|
| 895 |
+
const kids = (a.config as { actions?: Action[] })?.actions;
|
| 896 |
+
if (a.kind === "group" && Array.isArray(kids)) {
|
| 897 |
+
const hit = findAction(kids, id);
|
| 898 |
+
if (hit) return hit;
|
| 899 |
+
}
|
| 900 |
+
}
|
| 901 |
+
return null;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
function nodePanel(
|
| 905 |
+
nodes: GraphNode[],
|
| 906 |
+
id: string,
|
| 907 |
+
render: (node: GraphNode) => ReactNode
|
| 908 |
+
): ReactNode {
|
| 909 |
+
const node = nodes.find((n) => n.id === id);
|
| 910 |
+
if (!node) return <p className="auto-note">That step is no longer part of this automation.</p>;
|
| 911 |
+
return (
|
| 912 |
+
<>
|
| 913 |
+
<h3>{node.title}</h3>
|
| 914 |
+
{node.detail ? <p className="auto-hint">{node.detail}</p> : null}
|
| 915 |
+
{render(node)}
|
| 916 |
+
</>
|
| 917 |
+
);
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
// ββ the Properties panel's two faces βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 921 |
+
|
| 922 |
+
function TriggerProps({
|
| 923 |
+
automation,
|
| 924 |
+
triggerKey,
|
| 925 |
+
options,
|
| 926 |
+
picked,
|
| 927 |
+
table,
|
| 928 |
+
tables,
|
| 929 |
+
ops,
|
| 930 |
+
nullaryOps,
|
| 931 |
+
vocab,
|
| 932 |
+
oauth,
|
| 933 |
+
busy,
|
| 934 |
+
onAskChange,
|
| 935 |
+
onPatchTrigger,
|
| 936 |
+
onPickTrigger,
|
| 937 |
+
onRunNow,
|
| 938 |
+
scheduleFace,
|
| 939 |
+
}: {
|
| 940 |
+
automation: Automation;
|
| 941 |
+
triggerKey: string;
|
| 942 |
+
options: TriggerOption[];
|
| 943 |
+
picked: TriggerOption | null;
|
| 944 |
+
table: UserTable | null;
|
| 945 |
+
tables: UserTable[];
|
| 946 |
+
ops: string[];
|
| 947 |
+
nullaryOps: string[];
|
| 948 |
+
vocab?: FlowVocab;
|
| 949 |
+
oauth: OAuthStatus | null;
|
| 950 |
+
busy: boolean;
|
| 951 |
+
/** Ask before a trigger change that can invalidate the config under it (image 6). */
|
| 952 |
+
onAskChange: (key: string) => void;
|
| 953 |
+
onPatchTrigger: (patch: Record<string, unknown>) => void;
|
| 954 |
+
onPickTrigger: (key: string) => void;
|
| 955 |
+
onRunNow: () => void;
|
| 956 |
+
scheduleFace: ReactNode;
|
| 957 |
+
}) {
|
| 958 |
+
const trigger = automation.trigger || null;
|
| 959 |
+
const provider = picked?.connect?.provider || "";
|
| 960 |
+
const connected = !!(provider && oauth?.[provider]?.connected);
|
| 961 |
+
const startUrl = picked?.connect?.startUrl || "";
|
| 962 |
+
const needsTable = !!trigger && "table" in trigger;
|
| 963 |
+
const last = automation.runs?.[0];
|
| 964 |
+
|
| 965 |
+
return (
|
| 966 |
+
<>
|
| 967 |
+
<h3>Trigger details</h3>
|
| 968 |
+
<div className="auto-field">
|
| 969 |
+
<label htmlFor="autox-tkind">Trigger type</label>
|
| 970 |
+
<select
|
| 971 |
+
id="autox-tkind"
|
| 972 |
+
className="auto-input"
|
| 973 |
+
value={triggerKey}
|
| 974 |
+
disabled={busy}
|
| 975 |
+
onChange={(e) => {
|
| 976 |
+
const next = e.target.value;
|
| 977 |
+
if (next === triggerKey) return;
|
| 978 |
+
// A change that can invalidate the configuration under it ASKS FIRST (image 6);
|
| 979 |
+
// one that cannot β there is nothing configured yet β just happens.
|
| 980 |
+
if (trigger && trigger.configured !== false) onAskChange(next);
|
| 981 |
+
else onPickTrigger(next);
|
| 982 |
+
}}
|
| 983 |
+
>
|
| 984 |
+
{triggerKey && !options.some((t) => t.key === triggerKey) ? (
|
| 985 |
+
<option value={triggerKey}>{triggerKey} (not offered here)</option>
|
| 986 |
+
) : null}
|
| 987 |
+
{options.map((t) => (
|
| 988 |
+
<option key={t.key} value={t.key} disabled={!!t.planned}>
|
| 989 |
+
{t.planned
|
| 990 |
+
? `${t.label} β coming soon`
|
| 991 |
+
: t.ready === false
|
| 992 |
+
? `${t.label} β needs setting up`
|
| 993 |
+
: t.label}
|
| 994 |
+
</option>
|
| 995 |
+
))}
|
| 996 |
+
</select>
|
| 997 |
+
</div>
|
| 998 |
+
{/*
|
| 999 |
+
β NO DESCRIPTION PARAGRAPH HERE, and its absence is deliberate. Airtable prints two
|
| 1000 |
+
sentences under this select (image 3). Ours would be a CLIENT paraphrase of a server
|
| 1001 |
+
vocabulary β the thing DESIGN.md 4 calls a hand copy of a registry that goes stale in
|
| 1002 |
+
silence the day the engine changes what a trigger does. A one-line `detail` on the
|
| 1003 |
+
server's own `TriggerOption` is asked for in the wave mailbox; when it rides, it renders
|
| 1004 |
+
here and cannot disagree with the engine.
|
| 1005 |
+
*/}
|
| 1006 |
+
{picked && picked.ready === false && !picked.planned ? (
|
| 1007 |
+
<div className="autob-needs">
|
| 1008 |
+
<span className="autob-needs-text">
|
| 1009 |
+
{connected
|
| 1010 |
+
? "Connected β this trigger is still being switched on for this deployment."
|
| 1011 |
+
: "This trigger is not set up yet."}
|
| 1012 |
+
</span>
|
| 1013 |
+
{!connected && startUrl ? (
|
| 1014 |
+
<a className="auto-btn is-primary autob-connect" href={startUrl}>
|
| 1015 |
+
Connect
|
| 1016 |
+
</a>
|
| 1017 |
+
) : null}
|
| 1018 |
+
</div>
|
| 1019 |
+
) : null}
|
| 1020 |
+
|
| 1021 |
+
<h3>Configuration</h3>
|
| 1022 |
+
{needsTable ? (
|
| 1023 |
+
<div className="auto-field">
|
| 1024 |
+
<label htmlFor="autox-ttable">
|
| 1025 |
+
<span className="autox-req">*</span> Database
|
| 1026 |
+
</label>
|
| 1027 |
+
<select
|
| 1028 |
+
id="autox-ttable"
|
| 1029 |
+
className="auto-input"
|
| 1030 |
+
value={trigger?.table || ""}
|
| 1031 |
+
disabled={busy}
|
| 1032 |
+
onChange={(e) => onPatchTrigger({ table: e.target.value })}
|
| 1033 |
+
>
|
| 1034 |
+
<option value="">Select a databaseβ¦</option>
|
| 1035 |
+
{trigger?.table && !tables.some((t) => t.key === trigger.table) ? (
|
| 1036 |
+
<option value={trigger.table}>{trigger.table} (not visible to you)</option>
|
| 1037 |
+
) : null}
|
| 1038 |
+
{tables.map((t) => (
|
| 1039 |
+
<option key={t.key} value={t.key}>
|
| 1040 |
+
{t.label}
|
| 1041 |
+
</option>
|
| 1042 |
+
))}
|
| 1043 |
+
</select>
|
| 1044 |
+
</div>
|
| 1045 |
+
) : null}
|
| 1046 |
+
|
| 1047 |
+
{triggerKey === "event_field" ? (
|
| 1048 |
+
<>
|
| 1049 |
+
<div className="auto-field">
|
| 1050 |
+
<label htmlFor="autox-tfield">Watched column</label>
|
| 1051 |
+
<select
|
| 1052 |
+
id="autox-tfield"
|
| 1053 |
+
className="auto-input"
|
| 1054 |
+
value={trigger?.field || ""}
|
| 1055 |
+
disabled={busy}
|
| 1056 |
+
onChange={(e) => onPatchTrigger({ field: e.target.value })}
|
| 1057 |
+
>
|
| 1058 |
+
{/* EMPTY IS A REAL CHOICE, not a missing one: unnamed means "any column", which is
|
| 1059 |
+
Airtable's plain condition trigger and the engine's own default. */}
|
| 1060 |
+
<option value="">Any column</option>
|
| 1061 |
+
{(table?.fields || []).map((f) => (
|
| 1062 |
+
<option key={f.key} value={f.key}>
|
| 1063 |
+
{f.label}
|
| 1064 |
+
</option>
|
| 1065 |
+
))}
|
| 1066 |
+
</select>
|
| 1067 |
+
</div>
|
| 1068 |
+
<p className="auto-field-label">
|
| 1069 |
+
<span className="autox-req">*</span> Conditions
|
| 1070 |
+
</p>
|
| 1071 |
+
<CondBuilder
|
| 1072 |
+
cond={trigger?.when || null}
|
| 1073 |
+
onChange={(next) => onPatchTrigger({ when: next })}
|
| 1074 |
+
fields={table?.fields || []}
|
| 1075 |
+
ops={ops}
|
| 1076 |
+
nullaryOps={nullaryOps}
|
| 1077 |
+
maxDepth={vocab?.maxCondDepth ?? 3}
|
| 1078 |
+
maxChildren={vocab?.maxCondChildren ?? 12}
|
| 1079 |
+
disabled={busy}
|
| 1080 |
+
/>
|
| 1081 |
+
</>
|
| 1082 |
+
) : null}
|
| 1083 |
+
|
| 1084 |
+
{triggerKey === "record_updated" ? (
|
| 1085 |
+
<div className="auto-field">
|
| 1086 |
+
<label htmlFor="autox-twatch">Watched columns</label>
|
| 1087 |
+
<select
|
| 1088 |
+
id="autox-twatch"
|
| 1089 |
+
className="auto-input"
|
| 1090 |
+
multiple
|
| 1091 |
+
size={5}
|
| 1092 |
+
value={trigger?.fields || []}
|
| 1093 |
+
disabled={busy}
|
| 1094 |
+
onChange={(e) =>
|
| 1095 |
+
onPatchTrigger({
|
| 1096 |
+
fields: Array.from(e.target.selectedOptions).map((o) => o.value),
|
| 1097 |
+
})
|
| 1098 |
+
}
|
| 1099 |
+
>
|
| 1100 |
+
{(table?.fields || []).map((f) => (
|
| 1101 |
+
<option key={f.key} value={f.key}>
|
| 1102 |
+
{f.label}
|
| 1103 |
+
</option>
|
| 1104 |
+
))}
|
| 1105 |
+
</select>
|
| 1106 |
+
<p className="auto-hint">Select none to fire on any column.</p>
|
| 1107 |
+
</div>
|
| 1108 |
+
) : null}
|
| 1109 |
+
|
| 1110 |
+
{triggerKey === "enters_view" ? (
|
| 1111 |
+
<div className="auto-field">
|
| 1112 |
+
<label htmlFor="autox-tview">
|
| 1113 |
+
<span className="autox-req">*</span> View
|
| 1114 |
+
</label>
|
| 1115 |
+
{table?.views?.length ? (
|
| 1116 |
+
<select
|
| 1117 |
+
id="autox-tview"
|
| 1118 |
+
className="auto-input"
|
| 1119 |
+
value={trigger?.viewId || ""}
|
| 1120 |
+
disabled={busy}
|
| 1121 |
+
onChange={(e) => onPatchTrigger({ viewId: e.target.value })}
|
| 1122 |
+
>
|
| 1123 |
+
<option value="">Select a viewβ¦</option>
|
| 1124 |
+
{table.views.map((v) => (
|
| 1125 |
+
<option key={v.id} value={v.id}>
|
| 1126 |
+
{v.label}
|
| 1127 |
+
</option>
|
| 1128 |
+
))}
|
| 1129 |
+
</select>
|
| 1130 |
+
) : (
|
| 1131 |
+
/*
|
| 1132 |
+
β ABSENT IS A STATE, NOT AN EMPTY PICKER. `GET /automations/tables` does not carry
|
| 1133 |
+
views yet (asked of the engine session in the wave mailbox). An empty dropdown here
|
| 1134 |
+
would read as "this database has no views", which is a claim nobody measured β so
|
| 1135 |
+
the surface says what is actually true and the control lights up the day the list
|
| 1136 |
+
rides, with no change on this side.
|
| 1137 |
+
*/
|
| 1138 |
+
<p className="auto-note">
|
| 1139 |
+
{table
|
| 1140 |
+
? "This server did not offer a view list for that database yet."
|
| 1141 |
+
: "Choose a database first."}
|
| 1142 |
+
</p>
|
| 1143 |
+
)}
|
| 1144 |
+
</div>
|
| 1145 |
+
) : null}
|
| 1146 |
+
|
| 1147 |
+
{triggerKey === "email" ? (
|
| 1148 |
+
<div className="auto-field">
|
| 1149 |
+
<label htmlFor="autox-tquery">Gmail search</label>
|
| 1150 |
+
<input
|
| 1151 |
+
id="autox-tquery"
|
| 1152 |
+
className="auto-input"
|
| 1153 |
+
defaultValue={trigger?.query || ""}
|
| 1154 |
+
disabled={busy}
|
| 1155 |
+
placeholder="from:orders@example.com"
|
| 1156 |
+
// FREE TEXT COMMITS ON BLUR β per-keystroke would PATCH a half-typed query.
|
| 1157 |
+
onBlur={(e) => onPatchTrigger({ query: e.target.value })}
|
| 1158 |
+
/>
|
| 1159 |
+
</div>
|
| 1160 |
+
) : null}
|
| 1161 |
+
|
| 1162 |
+
{triggerKey === "webhook" && trigger?.token ? (
|
| 1163 |
+
<div className="auto-field">
|
| 1164 |
+
<label htmlFor="autox-thook">Hook URL</label>
|
| 1165 |
+
<input
|
| 1166 |
+
id="autox-thook"
|
| 1167 |
+
className="auto-input is-mono"
|
| 1168 |
+
readOnly
|
| 1169 |
+
value={`/api/v1/automations/hook/${trigger.token}`}
|
| 1170 |
+
/>
|
| 1171 |
+
<p className="auto-hint">Minted once. It survives every other change to this trigger.</p>
|
| 1172 |
+
</div>
|
| 1173 |
+
) : null}
|
| 1174 |
+
|
| 1175 |
+
{/* THE SCHEDULE, rendered by the one component that owns the cron round-trip. */}
|
| 1176 |
+
{triggerKey === "schedule" ? scheduleFace : null}
|
| 1177 |
+
{triggerKey === "manual" ? (
|
| 1178 |
+
<p className="auto-hint">
|
| 1179 |
+
It runs when you press Run once now, and nothing else starts it.
|
| 1180 |
+
</p>
|
| 1181 |
+
) : null}
|
| 1182 |
+
|
| 1183 |
+
<h3>Test step</h3>
|
| 1184 |
+
{/*
|
| 1185 |
+
β HONEST TO OUR SEMANTICS, not to Airtable's. Airtable's "Test step" replays one step
|
| 1186 |
+
against a chosen record. Ours has no step-replay: the engine runs a whole automation. So
|
| 1187 |
+
the control says what it actually does β RUN IT β and the results below are the last
|
| 1188 |
+
real run's, from the run log, rather than a rehearsal nobody performed.
|
| 1189 |
+
*/}
|
| 1190 |
+
<button type="button" className="auto-btn" disabled={busy || !!automation.running}
|
| 1191 |
+
onClick={onRunNow}>
|
| 1192 |
+
{automation.running ? "Runningβ¦" : "Run once now"}
|
| 1193 |
+
</button>
|
| 1194 |
+
{last ? (
|
| 1195 |
+
<>
|
| 1196 |
+
<p className={"autox-result is-" + (last.ok ? "ok" : "bad")}>
|
| 1197 |
+
{last.ok ? "Last run succeeded" : "Last run failed"}
|
| 1198 |
+
</p>
|
| 1199 |
+
<p className="auto-hint">
|
| 1200 |
+
{last.ts.replace("T", " ").slice(0, 16)} β {last.summary}
|
| 1201 |
+
</p>
|
| 1202 |
+
</>
|
| 1203 |
+
) : (
|
| 1204 |
+
<p className="auto-hint">It has not run yet.</p>
|
| 1205 |
+
)}
|
| 1206 |
+
</>
|
| 1207 |
+
);
|
| 1208 |
+
}
|
| 1209 |
+
|
| 1210 |
+
function ActionProps({
|
| 1211 |
+
action,
|
| 1212 |
+
catalog,
|
| 1213 |
+
tables,
|
| 1214 |
+
walkFields,
|
| 1215 |
+
ops,
|
| 1216 |
+
nullaryOps,
|
| 1217 |
+
vocab,
|
| 1218 |
+
busy,
|
| 1219 |
+
onEdit,
|
| 1220 |
+
}: {
|
| 1221 |
+
action: Action | null;
|
| 1222 |
+
catalog: ActionCatalogRow[];
|
| 1223 |
+
tables: UserTable[];
|
| 1224 |
+
/**
|
| 1225 |
+
* β THE WALKING RECORD'S COLUMNS β the trigger's database, resolved by the builder.
|
| 1226 |
+
*
|
| 1227 |
+
* These three surfaces were built with `fields={[]}` and it made R3's headline feature
|
| 1228 |
+
* unauthorable: a conditional group's condition, an action's `when`, and `update_record`'s
|
| 1229 |
+
* column picker all offered "Choose a fieldβ¦" and nothing else. An empty list is not a
|
| 1230 |
+
* neutral default here β it is a picker with no options, which reads as "this database has
|
| 1231 |
+
* no columns".
|
| 1232 |
+
*/
|
| 1233 |
+
walkFields: { key: string; label: string; type: string }[];
|
| 1234 |
+
ops: string[];
|
| 1235 |
+
nullaryOps: string[];
|
| 1236 |
+
vocab?: FlowVocab;
|
| 1237 |
+
busy: boolean;
|
| 1238 |
+
onEdit: (change: (a: Action) => Action) => void;
|
| 1239 |
+
}) {
|
| 1240 |
+
if (!action) return <p className="auto-note">That action is no longer part of this flow.</p>;
|
| 1241 |
+
const row = catalog.find((c) => c.kind === action.kind);
|
| 1242 |
+
const cfg = action.config || {};
|
| 1243 |
+
const setCfg = (patch: Record<string, unknown>) =>
|
| 1244 |
+
onEdit((a) => ({ ...a, config: { ...a.config, ...patch } }));
|
| 1245 |
+
const values = (cfg as { values?: Record<string, string | number> }).values || {};
|
| 1246 |
+
const target = tables.find((t) => t.key === String((cfg as { table?: string }).table || ""));
|
| 1247 |
+
const valueFields = action.kind === "create_record" ? target?.fields || [] : walkFields;
|
| 1248 |
+
|
| 1249 |
+
return (
|
| 1250 |
+
<>
|
| 1251 |
+
<h3>{row?.label || action.kind}</h3>
|
| 1252 |
+
{row?.detail ? <p className="auto-hint">{row.detail}</p> : null}
|
| 1253 |
+
|
| 1254 |
+
<h3>Configuration</h3>
|
| 1255 |
+
|
| 1256 |
+
{action.kind === "group" ? (
|
| 1257 |
+
<>
|
| 1258 |
+
<p className="auto-field-label">Run these actions ifβ¦</p>
|
| 1259 |
+
<CondBuilder
|
| 1260 |
+
cond={(cfg as { cond?: Cond | null }).cond || null}
|
| 1261 |
+
onChange={(next) => setCfg({ cond: next })}
|
| 1262 |
+
fields={walkFields}
|
| 1263 |
+
ops={ops}
|
| 1264 |
+
nullaryOps={nullaryOps}
|
| 1265 |
+
maxDepth={vocab?.maxCondDepth ?? 3}
|
| 1266 |
+
maxChildren={vocab?.maxCondChildren ?? 12}
|
| 1267 |
+
lead=""
|
| 1268 |
+
disabled={busy}
|
| 1269 |
+
/>
|
| 1270 |
+
{/* β THE FIELD LIST IS THE WALKING RECORD'S, and this panel does not know which
|
| 1271 |
+
database that is β the trigger decides it and a group can sit under any of them.
|
| 1272 |
+
So the field picker offers the stored key rather than a list it would have to
|
| 1273 |
+
guess at; a wrong list is worse than a typed key, because it looks authoritative.
|
| 1274 |
+
Booked for the wave that gives an action its own resolved table. */}
|
| 1275 |
+
</>
|
| 1276 |
+
) : null}
|
| 1277 |
+
|
| 1278 |
+
{action.kind === "create_record" ? (
|
| 1279 |
+
<div className="auto-field">
|
| 1280 |
+
<label htmlFor="autox-atable">
|
| 1281 |
+
<span className="autox-req">*</span> Database
|
| 1282 |
+
</label>
|
| 1283 |
+
<select
|
| 1284 |
+
id="autox-atable"
|
| 1285 |
+
className="auto-input"
|
| 1286 |
+
value={String((cfg as { table?: string }).table || "")}
|
| 1287 |
+
disabled={busy}
|
| 1288 |
+
onChange={(e) => setCfg({ table: e.target.value })}
|
| 1289 |
+
>
|
| 1290 |
+
<option value="">Select a databaseβ¦</option>
|
| 1291 |
+
{tables.map((t) => (
|
| 1292 |
+
<option key={t.key} value={t.key}>
|
| 1293 |
+
{t.label}
|
| 1294 |
+
</option>
|
| 1295 |
+
))}
|
| 1296 |
+
</select>
|
| 1297 |
+
</div>
|
| 1298 |
+
) : null}
|
| 1299 |
+
|
| 1300 |
+
{action.kind === "update_record" || action.kind === "create_record" ? (
|
| 1301 |
+
<>
|
| 1302 |
+
<p className="auto-field-label">
|
| 1303 |
+
<span className="autox-req">*</span> Values
|
| 1304 |
+
</p>
|
| 1305 |
+
{Object.entries(values).map(([key, val], i) => (
|
| 1306 |
+
<div className="autoc-row" key={i}>
|
| 1307 |
+
<button
|
| 1308 |
+
type="button"
|
| 1309 |
+
className="autoc-drop"
|
| 1310 |
+
disabled={busy}
|
| 1311 |
+
aria-label="Remove this value"
|
| 1312 |
+
onClick={() => {
|
| 1313 |
+
const next = { ...values };
|
| 1314 |
+
delete next[key];
|
| 1315 |
+
setCfg({ values: next });
|
| 1316 |
+
}}
|
| 1317 |
+
>
|
| 1318 |
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 1319 |
+
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| 1320 |
+
strokeLinecap="round" />
|
| 1321 |
+
</svg>
|
| 1322 |
+
</button>
|
| 1323 |
+
<select
|
| 1324 |
+
className="auto-input is-tiny"
|
| 1325 |
+
value={key}
|
| 1326 |
+
disabled={busy}
|
| 1327 |
+
aria-label="Column"
|
| 1328 |
+
onChange={(e) => {
|
| 1329 |
+
const next: Record<string, string | number> = {};
|
| 1330 |
+
for (const [k, v] of Object.entries(values))
|
| 1331 |
+
next[k === key ? e.target.value : k] = v;
|
| 1332 |
+
setCfg({ values: next });
|
| 1333 |
+
}}
|
| 1334 |
+
>
|
| 1335 |
+
<option value="">Choose a columnβ¦</option>
|
| 1336 |
+
{/* β WHICH RECORD'S COLUMNS depends on the action: `create_record` writes into
|
| 1337 |
+
the database it NAMES, `update_record` writes onto the record walking the
|
| 1338 |
+
flow β the trigger's. Offering the target's columns for both would list the
|
| 1339 |
+
wrong database's columns for every update action. */}
|
| 1340 |
+
{key && !valueFields.some((f) => f.key === key) ? (
|
| 1341 |
+
<option value={key}>{key}</option>
|
| 1342 |
+
) : null}
|
| 1343 |
+
{valueFields.map((f) => (
|
| 1344 |
+
<option key={f.key} value={f.key}>
|
| 1345 |
+
{f.label}
|
| 1346 |
+
</option>
|
| 1347 |
+
))}
|
| 1348 |
+
</select>
|
| 1349 |
+
<input
|
| 1350 |
+
className="auto-input is-tiny autoc-value"
|
| 1351 |
+
defaultValue={String(val ?? "")}
|
| 1352 |
+
disabled={busy}
|
| 1353 |
+
aria-label="Value"
|
| 1354 |
+
placeholder="Value or {{column}}"
|
| 1355 |
+
onBlur={(e) => setCfg({ values: { ...values, [key]: e.target.value } })}
|
| 1356 |
+
/>
|
| 1357 |
+
</div>
|
| 1358 |
+
))}
|
| 1359 |
+
<button
|
| 1360 |
+
type="button"
|
| 1361 |
+
className="autoc-add"
|
| 1362 |
+
disabled={busy}
|
| 1363 |
+
onClick={() => setCfg({ values: { ...values, "": "" } })}
|
| 1364 |
+
>
|
| 1365 |
+
+ Add a value
|
| 1366 |
+
</button>
|
| 1367 |
+
<p className="auto-hint">
|
| 1368 |
+
<code>{"{{column_key}}"}</code> reads that column off the record walking the flow.
|
| 1369 |
+
</p>
|
| 1370 |
+
</>
|
| 1371 |
+
) : null}
|
| 1372 |
+
|
| 1373 |
+
{action.kind === "find_records" ? (
|
| 1374 |
+
<>
|
| 1375 |
+
<div className="auto-field">
|
| 1376 |
+
<label htmlFor="autox-ftable">
|
| 1377 |
+
<span className="autox-req">*</span> Database
|
| 1378 |
+
</label>
|
| 1379 |
+
<select
|
| 1380 |
+
id="autox-ftable"
|
| 1381 |
+
className="auto-input"
|
| 1382 |
+
value={String((cfg as { table?: string }).table || "")}
|
| 1383 |
+
disabled={busy}
|
| 1384 |
+
onChange={(e) => setCfg({ table: e.target.value })}
|
| 1385 |
+
>
|
| 1386 |
+
<option value="">Select a databaseβ¦</option>
|
| 1387 |
+
{tables.map((t) => (
|
| 1388 |
+
<option key={t.key} value={t.key}>
|
| 1389 |
+
{t.label}
|
| 1390 |
+
</option>
|
| 1391 |
+
))}
|
| 1392 |
+
</select>
|
| 1393 |
+
</div>
|
| 1394 |
+
<p className="auto-field-label">Conditions</p>
|
| 1395 |
+
<CondBuilder
|
| 1396 |
+
cond={(cfg as { cond?: Cond | null }).cond || null}
|
| 1397 |
+
onChange={(next) => setCfg({ cond: next })}
|
| 1398 |
+
fields={target?.fields || []}
|
| 1399 |
+
ops={ops}
|
| 1400 |
+
nullaryOps={nullaryOps}
|
| 1401 |
+
maxDepth={vocab?.maxCondDepth ?? 3}
|
| 1402 |
+
maxChildren={vocab?.maxCondChildren ?? 12}
|
| 1403 |
+
disabled={busy}
|
| 1404 |
+
/>
|
| 1405 |
+
<div className="auto-field">
|
| 1406 |
+
<label htmlFor="autox-flimit">How many at most</label>
|
| 1407 |
+
<input
|
| 1408 |
+
id="autox-flimit"
|
| 1409 |
+
className="auto-input"
|
| 1410 |
+
type="number"
|
| 1411 |
+
min={1}
|
| 1412 |
+
defaultValue={Number((cfg as { limit?: number }).limit || 25)}
|
| 1413 |
+
disabled={busy}
|
| 1414 |
+
onBlur={(e) => setCfg({ limit: Number(e.target.value) || 25 })}
|
| 1415 |
+
/>
|
| 1416 |
+
</div>
|
| 1417 |
+
<p className="auto-hint">
|
| 1418 |
+
What it found opens from the run log. Piping the rows into a later step is not built
|
| 1419 |
+
yet.
|
| 1420 |
+
</p>
|
| 1421 |
+
</>
|
| 1422 |
+
) : null}
|
| 1423 |
+
|
| 1424 |
+
{action.kind === "review" ? (
|
| 1425 |
+
<ReviewProps
|
| 1426 |
+
cfg={cfg as Record<string, unknown>}
|
| 1427 |
+
vocab={vocab}
|
| 1428 |
+
busy={busy}
|
| 1429 |
+
setCfg={setCfg}
|
| 1430 |
+
/>
|
| 1431 |
+
) : null}
|
| 1432 |
+
|
| 1433 |
+
{/* EVERY action can be conditional, not just a group β `Action.when` is the engine's own
|
| 1434 |
+
field and a card with no way to set it would be a stored value the UI cannot reach. */}
|
| 1435 |
+
<h3>Run this only when</h3>
|
| 1436 |
+
<CondBuilder
|
| 1437 |
+
cond={action.when || null}
|
| 1438 |
+
onChange={(next) => onEdit((a) => ({ ...a, when: next }))}
|
| 1439 |
+
fields={walkFields}
|
| 1440 |
+
ops={ops}
|
| 1441 |
+
nullaryOps={nullaryOps}
|
| 1442 |
+
maxDepth={vocab?.maxCondDepth ?? 3}
|
| 1443 |
+
maxChildren={vocab?.maxCondChildren ?? 12}
|
| 1444 |
+
disabled={busy}
|
| 1445 |
+
/>
|
| 1446 |
+
{!condComplete(action.when || null, nullaryOps) ? null : (
|
| 1447 |
+
<p className="auto-hint">Leave empty to run it every time.</p>
|
| 1448 |
+
)}
|
| 1449 |
+
</>
|
| 1450 |
+
);
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
function ReviewProps({
|
| 1454 |
+
cfg,
|
| 1455 |
+
vocab,
|
| 1456 |
+
busy,
|
| 1457 |
+
setCfg,
|
| 1458 |
+
}: {
|
| 1459 |
+
cfg: Record<string, unknown>;
|
| 1460 |
+
vocab?: FlowVocab;
|
| 1461 |
+
busy: boolean;
|
| 1462 |
+
setCfg: (patch: Record<string, unknown>) => void;
|
| 1463 |
+
}) {
|
| 1464 |
+
const by = String(cfg.decidedBy || "user");
|
| 1465 |
+
const next = Array.isArray(cfg.next) ? (cfg.next as string[]) : [];
|
| 1466 |
+
const aiReady = vocab?.aiReady !== false;
|
| 1467 |
+
return (
|
| 1468 |
+
<>
|
| 1469 |
+
<div className="auto-field">
|
| 1470 |
+
<label htmlFor="autox-rby">Decided by</label>
|
| 1471 |
+
<select
|
| 1472 |
+
id="autox-rby"
|
| 1473 |
+
className="auto-input"
|
| 1474 |
+
value={by}
|
| 1475 |
+
disabled={busy}
|
| 1476 |
+
onChange={(e) => setCfg({ decidedBy: e.target.value })}
|
| 1477 |
+
>
|
| 1478 |
+
{(vocab?.reviewDeciders || ["user"]).map((d) => (
|
| 1479 |
+
<option key={d} value={d}>
|
| 1480 |
+
{d === "ai" ? "AI" : "A person"}
|
| 1481 |
+
</option>
|
| 1482 |
+
))}
|
| 1483 |
+
</select>
|
| 1484 |
+
</div>
|
| 1485 |
+
{/*
|
| 1486 |
+
β `aiReady` IS A MEASUREMENT OF THIS DEPLOYMENT, and it is stated rather than styled
|
| 1487 |
+
around. With no LLM key the engine holds every card for a human β so an AI review that
|
| 1488 |
+
looked configured would promise a decision nothing will make (C6's fail-closed path).
|
| 1489 |
+
*/}
|
| 1490 |
+
{by === "ai" && !aiReady ? (
|
| 1491 |
+
<p className="auto-note">
|
| 1492 |
+
No AI provider is configured here, so these cards wait for a person.
|
| 1493 |
+
</p>
|
| 1494 |
+
) : null}
|
| 1495 |
+
{by === "ai" ? (
|
| 1496 |
+
<div className="auto-field">
|
| 1497 |
+
<label htmlFor="autox-rprompt">What should it decide?</label>
|
| 1498 |
+
<textarea
|
| 1499 |
+
id="autox-rprompt"
|
| 1500 |
+
className="auto-input"
|
| 1501 |
+
rows={3}
|
| 1502 |
+
defaultValue={String(cfg.prompt || "")}
|
| 1503 |
+
disabled={busy}
|
| 1504 |
+
placeholder="Approve suppliers with a UK address and more than 20 reviews."
|
| 1505 |
+
onBlur={(e) => setCfg({ prompt: e.target.value })}
|
| 1506 |
+
/>
|
| 1507 |
+
</div>
|
| 1508 |
+
) : null}
|
| 1509 |
+
<div className="auto-field">
|
| 1510 |
+
<label htmlFor="autox-rlabel">Stage name</label>
|
| 1511 |
+
<input
|
| 1512 |
+
id="autox-rlabel"
|
| 1513 |
+
className="auto-input"
|
| 1514 |
+
defaultValue={String(cfg.label || "Review")}
|
| 1515 |
+
disabled={busy}
|
| 1516 |
+
onBlur={(e) => setCfg({ label: e.target.value })}
|
| 1517 |
+
/>
|
| 1518 |
+
</div>
|
| 1519 |
+
<p className="auto-field-label">Where it can go next</p>
|
| 1520 |
+
{next.map((n, i) => (
|
| 1521 |
+
<div className="autoc-row" key={i}>
|
| 1522 |
+
<button
|
| 1523 |
+
type="button"
|
| 1524 |
+
className="autoc-drop"
|
| 1525 |
+
disabled={busy}
|
| 1526 |
+
aria-label={`Remove ${n}`}
|
| 1527 |
+
onClick={() => setCfg({ next: next.filter((_x, j) => j !== i) })}
|
| 1528 |
+
>
|
| 1529 |
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 1530 |
+
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.5"
|
| 1531 |
+
strokeLinecap="round" />
|
| 1532 |
+
</svg>
|
| 1533 |
+
</button>
|
| 1534 |
+
<input
|
| 1535 |
+
className="auto-input is-tiny"
|
| 1536 |
+
defaultValue={n}
|
| 1537 |
+
disabled={busy}
|
| 1538 |
+
aria-label="Stage this review can send a record to"
|
| 1539 |
+
onBlur={(e) =>
|
| 1540 |
+
setCfg({ next: next.map((x, j) => (j === i ? e.target.value : x)) })
|
| 1541 |
+
}
|
| 1542 |
+
/>
|
| 1543 |
+
</div>
|
| 1544 |
+
))}
|
| 1545 |
+
<button
|
| 1546 |
+
type="button"
|
| 1547 |
+
className="autoc-add"
|
| 1548 |
+
disabled={busy}
|
| 1549 |
+
onClick={() => setCfg({ next: [...next, ""] })}
|
| 1550 |
+
>
|
| 1551 |
+
+ Add an exit
|
| 1552 |
+
</button>
|
| 1553 |
+
<p className="auto-hint">
|
| 1554 |
+
Each exit is a lane on the board. A card waits here until it is moved to one of them.
|
| 1555 |
+
</p>
|
| 1556 |
+
</>
|
| 1557 |
+
);
|
| 1558 |
+
}
|
web/src/automation/AutomationCreate.tsx
CHANGED
|
@@ -34,7 +34,35 @@ interface ColumnPlan {
|
|
| 34 |
key: string;
|
| 35 |
}
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
export default function AutomationCreate({ kinds, onCreated, onCancel }: Props) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
const [name, setName] = useState("");
|
| 39 |
const [kind, setKind] = useState<AutomationKind>("scrape_db");
|
| 40 |
const [problem, setProblem] = useState("");
|
|
@@ -100,6 +128,16 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 100 |
const res = await createAutomation({
|
| 101 |
name,
|
| 102 |
kind,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
schedule: { cron: "0 6 * * *", enabled: false },
|
| 104 |
config:
|
| 105 |
kind === "scrape_db"
|
|
@@ -133,19 +171,95 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 133 |
|
| 134 |
const canCreate =
|
| 135 |
!!name.trim() &&
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
(kind === "scrape_db"
|
| 137 |
? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
|
| 138 |
: kind === "discover_instagram"
|
| 139 |
? true
|
| 140 |
: !!targetTable && !!fieldKey);
|
| 141 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
return (
|
| 143 |
<div className="auto-create-pane">
|
| 144 |
<div className="auto-create-card">
|
| 145 |
<h1>New automation</h1>
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
{problem ? (
|
| 151 |
<div className="auto-banner is-error" role="alert">
|
|
@@ -340,6 +454,11 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 340 |
) : (
|
| 341 |
<>
|
| 342 |
<div className="auto-field-row">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
<div className="auto-field">
|
| 344 |
<label htmlFor="auto-new-table">Database</label>
|
| 345 |
<select
|
|
@@ -360,6 +479,7 @@ export default function AutomationCreate({ kinds, onCreated, onCancel }: Props)
|
|
| 360 |
))}
|
| 361 |
</select>
|
| 362 |
</div>
|
|
|
|
| 363 |
<div className="auto-field">
|
| 364 |
<label htmlFor="auto-new-fieldkey">Automation column</label>
|
| 365 |
<select
|
|
|
|
| 34 |
key: string;
|
| 35 |
}
|
| 36 |
|
| 37 |
+
/**
|
| 38 |
+
* C2's THREE CARDS β the wizard's first question, asked before the kind.
|
| 39 |
+
*
|
| 40 |
+
* β ONE LINE EACH (R13). The temptation here is to explain what a "machine database" is; the
|
| 41 |
+
* person reading has already decided to make an automation and every extra word is spent on
|
| 42 |
+
* someone who is leaving. What each mode DOES is visible one screen later, where the choice
|
| 43 |
+
* has consequences.
|
| 44 |
+
*/
|
| 45 |
+
const TARGET_MODES: { mode: string; label: string; line: string }[] = [
|
| 46 |
+
{ mode: "existing", label: "Use an existing database",
|
| 47 |
+
line: "It works on rows that are already there." },
|
| 48 |
+
{ mode: "new", label: "Create a new database",
|
| 49 |
+
line: "An empty one, made now, named by you." },
|
| 50 |
+
{ mode: "automated", label: "Create an automated database",
|
| 51 |
+
line: "The automation makes it on its first run." },
|
| 52 |
+
];
|
| 53 |
+
|
| 54 |
export default function AutomationCreate({ kinds, onCreated, onCancel }: Props) {
|
| 55 |
+
/**
|
| 56 |
+
* β TWO STEPS, NOT A WIZARD WITH A BACK STACK. The first answers "what does it work on", the
|
| 57 |
+
* second "what does it do" β and the second is the form this file has always been, because
|
| 58 |
+
* the engine still requires one of its three machine KINDS (`clean_definition` refuses an
|
| 59 |
+
* automation without one). C2 anticipates trigger-and-actions-only automations for the
|
| 60 |
+
* `existing`/`new` modes; there is no server kind for that yet, so the kind question stays
|
| 61 |
+
* and the wave mailbox carries the ask. When a plain kind exists, step 2 collapses for those
|
| 62 |
+
* two modes and nothing else here changes.
|
| 63 |
+
*/
|
| 64 |
+
const [mode, setMode] = useState("");
|
| 65 |
+
const [newLabel, setNewLabel] = useState("");
|
| 66 |
const [name, setName] = useState("");
|
| 67 |
const [kind, setKind] = useState<AutomationKind>("scrape_db");
|
| 68 |
const [problem, setProblem] = useState("");
|
|
|
|
| 128 |
const res = await createAutomation({
|
| 129 |
name,
|
| 130 |
kind,
|
| 131 |
+
// C2's target, resolved SERVER-SIDE in the same call that creates the automation
|
| 132 |
+
// (`resolve_target` runs before validation), so a `new` database is never a
|
| 133 |
+
// create-then-patch this client has to sequence β and an automation is never saved
|
| 134 |
+
// pointing at a table that does not exist yet.
|
| 135 |
+
target:
|
| 136 |
+
mode === "existing"
|
| 137 |
+
? { mode, table: targetTable }
|
| 138 |
+
: mode === "new"
|
| 139 |
+
? { mode, label: newLabel }
|
| 140 |
+
: { mode: "automated", label: targetLabel },
|
| 141 |
schedule: { cron: "0 6 * * *", enabled: false },
|
| 142 |
config:
|
| 143 |
kind === "scrape_db"
|
|
|
|
| 171 |
|
| 172 |
const canCreate =
|
| 173 |
!!name.trim() &&
|
| 174 |
+
// The MODE's own question has to be answered too β `resolve_target` refuses a blank one
|
| 175 |
+
// with its own sentence ("choose the database this automation works on"), and offering a
|
| 176 |
+
// Create button that can only produce that refusal is a control that lies.
|
| 177 |
+
(mode === "existing" ? !!targetTable : mode === "new" ? !!newLabel.trim() : true) &&
|
| 178 |
(kind === "scrape_db"
|
| 179 |
? !!url.trim() && plan.some((c) => c.include && c.key === keyField)
|
| 180 |
: kind === "discover_instagram"
|
| 181 |
? true
|
| 182 |
: !!targetTable && !!fieldKey);
|
| 183 |
|
| 184 |
+
/* ββ STEP 1: what does it work on? (C2, images 1-3's front door) βββββββββββββββββββββββββ */
|
| 185 |
+
if (!mode) {
|
| 186 |
+
return (
|
| 187 |
+
<div className="auto-create-pane">
|
| 188 |
+
<div className="auto-create-card">
|
| 189 |
+
<h1>New automation</h1>
|
| 190 |
+
<div className="autox-modes">
|
| 191 |
+
{TARGET_MODES.map((m) => (
|
| 192 |
+
<button
|
| 193 |
+
key={m.mode}
|
| 194 |
+
type="button"
|
| 195 |
+
className="autox-mode"
|
| 196 |
+
onClick={() => setMode(m.mode)}
|
| 197 |
+
>
|
| 198 |
+
<span className="autox-mode-label">{m.label}</span>
|
| 199 |
+
<span className="autox-mode-line">{m.line}</span>
|
| 200 |
+
</button>
|
| 201 |
+
))}
|
| 202 |
+
</div>
|
| 203 |
+
<div className="auto-head-actions">
|
| 204 |
+
<button type="button" className="auto-btn" onClick={onCancel}>
|
| 205 |
+
Cancel
|
| 206 |
+
</button>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
</div>
|
| 210 |
+
);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
return (
|
| 214 |
<div className="auto-create-pane">
|
| 215 |
<div className="auto-create-card">
|
| 216 |
<h1>New automation</h1>
|
| 217 |
+
|
| 218 |
+
{/* STEP 2's answer, and a way back to change it. A chosen mode that cannot be unchosen
|
| 219 |
+
is a dead end one click deep. */}
|
| 220 |
+
<div className="autox-modeback">
|
| 221 |
+
<span className="autox-mode-label">
|
| 222 |
+
{TARGET_MODES.find((m) => m.mode === mode)?.label || mode}
|
| 223 |
+
</span>
|
| 224 |
+
<button type="button" className="autoc-add" onClick={() => setMode("")}>
|
| 225 |
+
Change
|
| 226 |
+
</button>
|
| 227 |
+
</div>
|
| 228 |
+
|
| 229 |
+
{mode === "existing" ? (
|
| 230 |
+
<div className="auto-field">
|
| 231 |
+
<label htmlFor="auto-new-target">Database</label>
|
| 232 |
+
<select
|
| 233 |
+
id="auto-new-target"
|
| 234 |
+
className="auto-input"
|
| 235 |
+
value={targetTable}
|
| 236 |
+
onChange={(e) => {
|
| 237 |
+
setTargetTable(e.target.value);
|
| 238 |
+
setFieldKey("");
|
| 239 |
+
setUrlField("");
|
| 240 |
+
}}
|
| 241 |
+
>
|
| 242 |
+
<option value="">Choose a databaseβ¦</option>
|
| 243 |
+
{tables.map((t) => (
|
| 244 |
+
<option key={t.key} value={t.key}>
|
| 245 |
+
{t.label} ({t.rowCount} {t.rowCount === 1 ? "row" : "rows"})
|
| 246 |
+
</option>
|
| 247 |
+
))}
|
| 248 |
+
</select>
|
| 249 |
+
</div>
|
| 250 |
+
) : mode === "new" ? (
|
| 251 |
+
<div className="auto-field">
|
| 252 |
+
<label htmlFor="auto-new-dblabel">Name the new database</label>
|
| 253 |
+
<input
|
| 254 |
+
id="auto-new-dblabel"
|
| 255 |
+
className="auto-input"
|
| 256 |
+
value={newLabel}
|
| 257 |
+
placeholder="Supplier leads"
|
| 258 |
+
onChange={(e) => setNewLabel(e.target.value)}
|
| 259 |
+
/>
|
| 260 |
+
<p className="auto-hint">It is created empty, with this automation.</p>
|
| 261 |
+
</div>
|
| 262 |
+
) : null}
|
| 263 |
|
| 264 |
{problem ? (
|
| 265 |
<div className="auto-banner is-error" role="alert">
|
|
|
|
| 454 |
) : (
|
| 455 |
<>
|
| 456 |
<div className="auto-field-row">
|
| 457 |
+
{/* β ONE CONTROL PER FACT. Step 2 already asked which database for the
|
| 458 |
+
`existing`/`new` modes and binds the SAME state β two selects for one value
|
| 459 |
+
is how a form starts disagreeing with itself. Only the `automated` mode,
|
| 460 |
+
which asks nothing up front, needs this one. */}
|
| 461 |
+
{mode === "automated" ? (
|
| 462 |
<div className="auto-field">
|
| 463 |
<label htmlFor="auto-new-table">Database</label>
|
| 464 |
<select
|
|
|
|
| 479 |
))}
|
| 480 |
</select>
|
| 481 |
</div>
|
| 482 |
+
) : null}
|
| 483 |
<div className="auto-field">
|
| 484 |
<label htmlFor="auto-new-fieldkey">Automation column</label>
|
| 485 |
<select
|
web/src/automation/AutomationDetail.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/automation/AutomationSurface.tsx
CHANGED
|
@@ -16,6 +16,8 @@
|
|
| 16 |
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
| 17 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 18 |
|
|
|
|
|
|
|
| 19 |
import AutomationCreate from "./AutomationCreate";
|
| 20 |
import AutomationDetail from "./AutomationDetail";
|
| 21 |
import type { Automation, AutomationList } from "./automationApi";
|
|
@@ -55,6 +57,39 @@ function railSubtitle(a: Automation): string {
|
|
| 55 |
return last ? `Last run ${last}` : "Manual only";
|
| 56 |
}
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
export default function AutomationSurface() {
|
| 59 |
const [data, setData] = useState<AutomationList | null>(null);
|
| 60 |
const [error, setError] = useState("");
|
|
@@ -62,21 +97,66 @@ export default function AutomationSurface() {
|
|
| 62 |
const [creating, setCreating] = useState(false);
|
| 63 |
const [railShut, setRailShut] = useState(false);
|
| 64 |
const [busy, setBusy] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
const listRef = useRef<HTMLDivElement | null>(null);
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
const load = useCallback(async (abort?: AbortSignal) => {
|
|
|
|
| 68 |
try {
|
| 69 |
const next = await listAutomations(abort);
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
| 72 |
return next;
|
| 73 |
} catch (e) {
|
| 74 |
if ((e as Error)?.name === "AbortError") return null;
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
return null;
|
| 81 |
}
|
| 82 |
}, []);
|
|
@@ -91,13 +171,57 @@ export default function AutomationSurface() {
|
|
| 91 |
// finished. Polling only while something is actually running keeps an idle surface
|
| 92 |
// silent β a fixed interval would be a request every few seconds forever, for a page
|
| 93 |
// whose contents change a handful of times a day.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
const anyRunning = (data?.automations || []).some((a) => a.running);
|
| 95 |
useEffect(() => {
|
| 96 |
if (!anyRunning) return undefined;
|
| 97 |
-
const
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}, [anyRunning, load]);
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
const items = data?.automations || [];
|
| 102 |
const active = items.find((a) => a.id === activeId) || null;
|
| 103 |
|
|
@@ -162,7 +286,7 @@ export default function AutomationSurface() {
|
|
| 162 |
className="auto-create-btn"
|
| 163 |
onClick={() => {
|
| 164 |
setCreating(true);
|
| 165 |
-
|
| 166 |
}}
|
| 167 |
>
|
| 168 |
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
@@ -189,7 +313,7 @@ export default function AutomationSurface() {
|
|
| 189 |
type="button"
|
| 190 |
className="auto-row-main"
|
| 191 |
onClick={() => {
|
| 192 |
-
|
| 193 |
setCreating(false);
|
| 194 |
}}
|
| 195 |
>
|
|
@@ -248,8 +372,11 @@ export default function AutomationSurface() {
|
|
| 248 |
kinds={data?.kinds || []}
|
| 249 |
onCreated={async (id) => {
|
| 250 |
setCreating(false);
|
|
|
|
|
|
|
|
|
|
| 251 |
const next = await load();
|
| 252 |
-
if (id && next)
|
| 253 |
}}
|
| 254 |
onCancel={() => setCreating(false)}
|
| 255 |
/>
|
|
@@ -265,16 +392,42 @@ export default function AutomationSurface() {
|
|
| 265 |
// trigger face can tell "the server offered nothing" from "the server offered
|
| 266 |
// an empty list" rather than collapsing both into a picker with no options.
|
| 267 |
triggers={data?.triggers}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
// `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
|
| 269 |
// not say", which Step 1 prints as its own sentence. Defaulting it here would
|
| 270 |
// turn a missing field into a claim about production (C6 amendment #1).
|
| 271 |
tick={data?.tick ?? null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
onSaved={async (id) => {
|
| 273 |
const next = await load();
|
| 274 |
-
if (id && next)
|
| 275 |
}}
|
| 276 |
onDeleted={async () => {
|
| 277 |
-
|
| 278 |
await load();
|
| 279 |
}}
|
| 280 |
/>
|
|
@@ -296,7 +449,16 @@ export default function AutomationSurface() {
|
|
| 296 |
* paragraph, spent differently.
|
| 297 |
*/
|
| 298 |
<div className="autob-empty">
|
| 299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
<p className="autob-empty-line">
|
| 301 |
A job this workspace runs for you, on demand or on a schedule.
|
| 302 |
</p>
|
|
@@ -305,7 +467,7 @@ export default function AutomationSurface() {
|
|
| 305 |
className="auto-btn is-primary"
|
| 306 |
onClick={() => {
|
| 307 |
setCreating(true);
|
| 308 |
-
|
| 309 |
}}
|
| 310 |
>
|
| 311 |
New automation
|
|
|
|
| 16 |
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
| 17 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 18 |
|
| 19 |
+
import type { AutomationOpenDetail } from "../apiContract";
|
| 20 |
+
import { AUTOMATION_OPEN_EVENT } from "../apiContract";
|
| 21 |
import AutomationCreate from "./AutomationCreate";
|
| 22 |
import AutomationDetail from "./AutomationDetail";
|
| 23 |
import type { Automation, AutomationList } from "./automationApi";
|
|
|
|
| 57 |
return last ? `Last run ${last}` : "Manual only";
|
| 58 |
}
|
| 59 |
|
| 60 |
+
/*
|
| 61 |
+
* β W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β not a stylistic choice.
|
| 62 |
+
*
|
| 63 |
+
* The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
|
| 64 |
+
* VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
|
| 65 |
+
* hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
|
| 66 |
+
* router state then updates, React renders, and only THEN does a component effect subscribe.
|
| 67 |
+
* A listener registered in `useEffect` therefore misses every click that arrives from another
|
| 68 |
+
* page β which is every click, since a reader looking at Alerts is by definition not already
|
| 69 |
+
* on this surface. The event would dispatch into nothing and every gate would stay green:
|
| 70 |
+
* exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
|
| 71 |
+
* down.
|
| 72 |
+
*
|
| 73 |
+
* This module is imported statically by the shell, so this listener exists from app start. It
|
| 74 |
+
* LATCHES the request; the component consumes the latch when it mounts and hears live events
|
| 75 |
+
* while it is mounted. A request nobody claims is dropped on the next one β the latch is a
|
| 76 |
+
* one-slot mailbox, never a queue.
|
| 77 |
+
*/
|
| 78 |
+
let pendingOpen: AutomationOpenDetail | null = null;
|
| 79 |
+
const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
|
| 80 |
+
|
| 81 |
+
if (typeof window !== "undefined") {
|
| 82 |
+
window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
|
| 83 |
+
const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
|
| 84 |
+
if (!detail?.autoId) return;
|
| 85 |
+
if (openSubscribers.size) {
|
| 86 |
+
for (const notify of openSubscribers) notify(detail);
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
pendingOpen = detail;
|
| 90 |
+
});
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
export default function AutomationSurface() {
|
| 94 |
const [data, setData] = useState<AutomationList | null>(null);
|
| 95 |
const [error, setError] = useState("");
|
|
|
|
| 97 |
const [creating, setCreating] = useState(false);
|
| 98 |
const [railShut, setRailShut] = useState(false);
|
| 99 |
const [busy, setBusy] = useState("");
|
| 100 |
+
/** W23-W5's advisory half: which stage's panel the click-through asked for, if any. */
|
| 101 |
+
const [openStage, setOpenStage] = useState("");
|
| 102 |
+
/** An open request waiting for the list to arrive (see the resolver below). */
|
| 103 |
+
const [openRequest, setOpenRequest] = useState<AutomationOpenDetail | null>(null);
|
| 104 |
const listRef = useRef<HTMLDivElement | null>(null);
|
| 105 |
|
| 106 |
+
/**
|
| 107 |
+
* β C14 LEG 2 β THE ID THE USER LAST ASKED FOR, and it is a ref because it has to be
|
| 108 |
+
* written SYNCHRONOUSLY, inside the click, before any promise that was already in flight
|
| 109 |
+
* can resolve. State would not do: a resolution racing React's commit would read the
|
| 110 |
+
* previous value, which is the exact window this guard exists to close.
|
| 111 |
+
*/
|
| 112 |
+
const wantedId = useRef("");
|
| 113 |
+
|
| 114 |
+
/**
|
| 115 |
+
* β THE ONLY PLACE THE RAIL'S SELECTION MOVES ON PURPOSE. Every deliberate change of
|
| 116 |
+
* automation β a rail click, a create, a delete β goes through here, so "what the user
|
| 117 |
+
* asked for" and "what is on screen" are written in one statement and cannot drift apart.
|
| 118 |
+
* A resolution that wants to steer the rail compares itself against `wantedId` instead.
|
| 119 |
+
*/
|
| 120 |
+
const select = useCallback((id: string, stage = "") => {
|
| 121 |
+
wantedId.current = id;
|
| 122 |
+
setActiveId(id);
|
| 123 |
+
// The stage hint belongs to THIS selection and no other. Every ordinary caller passes
|
| 124 |
+
// nothing, which clears it β so a review notification cannot leave a stage open on the
|
| 125 |
+
// next automation the reader clicks by hand.
|
| 126 |
+
setOpenStage(stage);
|
| 127 |
+
}, []);
|
| 128 |
+
|
| 129 |
+
/**
|
| 130 |
+
* β C14 LEG 3 (the paint half) β A STALE READ MAY NOT REPAINT.
|
| 131 |
+
*
|
| 132 |
+
* Every call takes the next generation; only the newest one is allowed to `setData`. The
|
| 133 |
+
* defect this closes is not cosmetic: a poll issued BEFORE a delete resolves AFTER it, and
|
| 134 |
+
* a list that still contains the deleted automation puts a row back in the rail that the
|
| 135 |
+
* user has just watched disappear. Same for a toggle, and same for a create.
|
| 136 |
+
*
|
| 137 |
+
* β THE RETURN VALUE IS NOT GUARDED, deliberately. The caller asked a question and gets
|
| 138 |
+
* its own answer β coupling the two would mean a create whose `load()` was overtaken by a
|
| 139 |
+
* poll silently failed to select the automation it had just made.
|
| 140 |
+
*/
|
| 141 |
+
const gen = useRef(0);
|
| 142 |
+
|
| 143 |
const load = useCallback(async (abort?: AbortSignal) => {
|
| 144 |
+
const mine = ++gen.current;
|
| 145 |
try {
|
| 146 |
const next = await listAutomations(abort);
|
| 147 |
+
if (mine === gen.current) {
|
| 148 |
+
setData(next);
|
| 149 |
+
setError("");
|
| 150 |
+
}
|
| 151 |
return next;
|
| 152 |
} catch (e) {
|
| 153 |
if ((e as Error)?.name === "AbortError") return null;
|
| 154 |
+
if (mine === gen.current)
|
| 155 |
+
setError(
|
| 156 |
+
e instanceof AutomationError
|
| 157 |
+
? e.message
|
| 158 |
+
: "The automation service did not answer."
|
| 159 |
+
);
|
| 160 |
return null;
|
| 161 |
}
|
| 162 |
}, []);
|
|
|
|
| 171 |
// finished. Polling only while something is actually running keeps an idle surface
|
| 172 |
// silent β a fixed interval would be a request every few seconds forever, for a page
|
| 173 |
// whose contents change a handful of times a day.
|
| 174 |
+
//
|
| 175 |
+
// β C14 LEG 3 (the abort half). It used to call `load()` bare β no signal, nothing to
|
| 176 |
+
// cancel β so a request the interval had already issued kept going after the effect that
|
| 177 |
+
// owned it was gone. One controller per effect run, aborted with the interval, means a poll
|
| 178 |
+
// cannot outlive the condition that justified it. The generation guard inside `load` covers
|
| 179 |
+
// the rest: a response that survives the abort still cannot repaint over a newer one.
|
| 180 |
const anyRunning = (data?.automations || []).some((a) => a.running);
|
| 181 |
useEffect(() => {
|
| 182 |
if (!anyRunning) return undefined;
|
| 183 |
+
const ac = new AbortController();
|
| 184 |
+
const t = window.setInterval(() => void load(ac.signal), 2500);
|
| 185 |
+
return () => {
|
| 186 |
+
window.clearInterval(t);
|
| 187 |
+
ac.abort();
|
| 188 |
+
};
|
| 189 |
}, [anyRunning, load]);
|
| 190 |
|
| 191 |
+
// ββ W23-W5, the surface's half: hear the request, then answer it when we CAN ββββββββββ
|
| 192 |
+
useEffect(() => {
|
| 193 |
+
const notify = (detail: AutomationOpenDetail) => setOpenRequest(detail);
|
| 194 |
+
openSubscribers.add(notify);
|
| 195 |
+
if (pendingOpen) {
|
| 196 |
+
const latched = pendingOpen;
|
| 197 |
+
pendingOpen = null;
|
| 198 |
+
setOpenRequest(latched);
|
| 199 |
+
}
|
| 200 |
+
return () => {
|
| 201 |
+
openSubscribers.delete(notify);
|
| 202 |
+
};
|
| 203 |
+
}, []);
|
| 204 |
+
|
| 205 |
+
/**
|
| 206 |
+
* β THE REQUEST OUTLIVES THE FETCH, and it has to. The reader clicks a notification from
|
| 207 |
+
* another page, so this surface is mounting WITH AN EMPTY LIST β "select it if it is in the
|
| 208 |
+
* list" would drop every real click and keep only the one case where the reader was already
|
| 209 |
+
* here. So the request is held until `data` exists, and only then answered.
|
| 210 |
+
*
|
| 211 |
+
* An automation the reader can no longer open does NOTHING (the contract's own words): the
|
| 212 |
+
* request is cleared either way, so a stale id cannot sit here re-firing against every
|
| 213 |
+
* subsequent list.
|
| 214 |
+
*/
|
| 215 |
+
useEffect(() => {
|
| 216 |
+
if (!openRequest || !data) return;
|
| 217 |
+
const found = (data.automations || []).some((a) => a.id === openRequest.autoId);
|
| 218 |
+
if (found) {
|
| 219 |
+
setCreating(false);
|
| 220 |
+
select(openRequest.autoId, openRequest.stageId || "");
|
| 221 |
+
}
|
| 222 |
+
setOpenRequest(null);
|
| 223 |
+
}, [openRequest, data, select]);
|
| 224 |
+
|
| 225 |
const items = data?.automations || [];
|
| 226 |
const active = items.find((a) => a.id === activeId) || null;
|
| 227 |
|
|
|
|
| 286 |
className="auto-create-btn"
|
| 287 |
onClick={() => {
|
| 288 |
setCreating(true);
|
| 289 |
+
select("");
|
| 290 |
}}
|
| 291 |
>
|
| 292 |
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
|
|
| 313 |
type="button"
|
| 314 |
className="auto-row-main"
|
| 315 |
onClick={() => {
|
| 316 |
+
select(a.id);
|
| 317 |
setCreating(false);
|
| 318 |
}}
|
| 319 |
>
|
|
|
|
| 372 |
kinds={data?.kinds || []}
|
| 373 |
onCreated={async (id) => {
|
| 374 |
setCreating(false);
|
| 375 |
+
// THE CREATE FLOW IS THE ONE CALLER ALLOWED TO NAME A DIFFERENT ID (C14 leg 2),
|
| 376 |
+
// and it is a different PROP for exactly that reason β the guard below does not
|
| 377 |
+
// have to make an exception it could get wrong.
|
| 378 |
const next = await load();
|
| 379 |
+
if (id && next) select(id);
|
| 380 |
}}
|
| 381 |
onCancel={() => setCreating(false)}
|
| 382 |
/>
|
|
|
|
| 392 |
// trigger face can tell "the server offered nothing" from "the server offered
|
| 393 |
// an empty list" rather than collapsing both into a picker with no options.
|
| 394 |
triggers={data?.triggers}
|
| 395 |
+
// C4's action menu + the builder's ceilings. Forwarded as-is for the same reason
|
| 396 |
+
// `triggers` is: absent must stay absent, so the builder can tell "the server
|
| 397 |
+
// offered nothing" from "the server offered an empty list".
|
| 398 |
+
catalog={data?.actionsCatalog}
|
| 399 |
+
vocab={data?.flow}
|
| 400 |
// `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
|
| 401 |
// not say", which Step 1 prints as its own sentence. Defaulting it here would
|
| 402 |
// turn a missing field into a claim about production (C6 amendment #1).
|
| 403 |
tick={data?.tick ?? null}
|
| 404 |
+
// W23-W5's advisory half. A review notification names the stage its cards are
|
| 405 |
+
// waiting at, and landing on the automation with that stage's panel already open
|
| 406 |
+
// is the difference between "here is the thing" and "here is the thing you were
|
| 407 |
+
// told about". Absent for every other way of arriving.
|
| 408 |
+
initialStage={openStage}
|
| 409 |
+
/*
|
| 410 |
+
* β C14 LEG 2 β THE STALE-ID WRITE-BACK, and this is the ghost's second cause.
|
| 411 |
+
*
|
| 412 |
+
* The detail calls `onSaved(automation.id)` from six places (save, the trigger
|
| 413 |
+
* picker, a schedule change, a node switch, a card move, a run). Each closure
|
| 414 |
+
* captures the automation it was mounted for, so a save that resolves AFTER the
|
| 415 |
+
* user has clicked a different row used to call `setActiveId(the OLD id)` β the
|
| 416 |
+
* rail jumped back, the detail remounted, and what the user saw was the previous
|
| 417 |
+
* automation reappearing over the one they had just opened. It looked like a
|
| 418 |
+
* rendering bug; it was a resolution steering the selection.
|
| 419 |
+
*
|
| 420 |
+
* A resolution may no longer steer anything. It reloads the list β that part was
|
| 421 |
+
* always right β and it re-asserts the selection ONLY when its id is still the one
|
| 422 |
+
* the user asked for, which makes the write a no-op in the good case and nothing
|
| 423 |
+
* at all in the bad one.
|
| 424 |
+
*/
|
| 425 |
onSaved={async (id) => {
|
| 426 |
const next = await load();
|
| 427 |
+
if (id && next && id === wantedId.current) select(id);
|
| 428 |
}}
|
| 429 |
onDeleted={async () => {
|
| 430 |
+
select("");
|
| 431 |
await load();
|
| 432 |
}}
|
| 433 |
/>
|
|
|
|
| 449 |
* paragraph, spent differently.
|
| 450 |
*/
|
| 451 |
<div className="autob-empty">
|
| 452 |
+
{/*
|
| 453 |
+
β NO TITLE HERE ANY MORE (C13, owner item 2). This pane carried an `h1`
|
| 454 |
+
reading "Automations" at 20px/600 β a THIRD title treatment on a page that
|
| 455 |
+
also had the header's editable 16px/700 input, against every database page's
|
| 456 |
+
single 16px/650 `shell-db-name`. The shell now wraps this branch in the same
|
| 457 |
+
`shell-db-frame` + `DbHead` a database gets (wiring W23-W1), so the page's name
|
| 458 |
+
is drawn once, by the one component that draws every other page's name. A
|
| 459 |
+
stand-in restyled to match would have been a second copy of the same fact,
|
| 460 |
+
free to drift the day the header moves.
|
| 461 |
+
*/}
|
| 462 |
<p className="autob-empty-line">
|
| 463 |
A job this workspace runs for you, on demand or on a schedule.
|
| 464 |
</p>
|
|
|
|
| 467 |
className="auto-btn is-primary"
|
| 468 |
onClick={() => {
|
| 469 |
setCreating(true);
|
| 470 |
+
select("");
|
| 471 |
}}
|
| 472 |
>
|
| 473 |
New automation
|
web/src/automation/AutomationTrigger.tsx
CHANGED
|
@@ -24,7 +24,6 @@
|
|
| 24 |
// nothing and gets asked again.
|
| 25 |
// ---------------------------------------------------------------------------
|
| 26 |
import type { OAuthStatus, TickState, TriggerOption } from "./automationApi";
|
| 27 |
-
import { oauthProviderFor, oauthStartUrlFor } from "./automationApi";
|
| 28 |
import { WEEKDAYS, readCron, writeCron } from "./steps";
|
| 29 |
|
| 30 |
interface Props {
|
|
@@ -54,20 +53,27 @@ interface Props {
|
|
| 54 |
/** True when the raw cron face is open β a VIEW choice, not a schedule change. */
|
| 55 |
showCron: boolean;
|
| 56 |
onShowCron: (v: boolean) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
}
|
| 58 |
|
| 59 |
-
/*
|
| 60 |
-
*
|
| 61 |
-
*
|
| 62 |
-
*
|
| 63 |
-
*
|
| 64 |
-
*
|
| 65 |
-
*
|
|
|
|
|
|
|
|
|
|
| 66 |
*/
|
| 67 |
-
const FALLBACK_TRIGGERS: TriggerOption[] = [
|
| 68 |
-
{ key: "manual", label: "Manual only", ready: true },
|
| 69 |
-
{ key: "schedule", label: "On a schedule", ready: true },
|
| 70 |
-
];
|
| 71 |
|
| 72 |
/** The one-line next step a not-ready trigger carries. Server words where there are any. */
|
| 73 |
function needsLabel(needs: string): string {
|
|
@@ -77,6 +83,16 @@ function needsLabel(needs: string): string {
|
|
| 77 |
return needs.replace(/_/g, " ");
|
| 78 |
}
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
/**
|
| 81 |
* What the deployment will actually do with a schedule.
|
| 82 |
*
|
|
@@ -128,20 +144,23 @@ export default function AutomationTrigger({
|
|
| 128 |
oauth,
|
| 129 |
showCron,
|
| 130 |
onShowCron,
|
|
|
|
| 131 |
}: Props) {
|
| 132 |
-
|
|
|
|
|
|
|
| 133 |
const picked = options.find((t) => t.key === current) || null;
|
| 134 |
const isSchedule = current === "schedule";
|
| 135 |
const shape = readCron(cron);
|
| 136 |
const note = tickNote(tick);
|
| 137 |
const every = showCron ? "custom" : shape.every;
|
| 138 |
-
// The connector this trigger is waiting on,
|
| 139 |
-
//
|
| 140 |
-
// block below states in words instead of drawing a button that goes nowhere.
|
| 141 |
-
const waiting = !!picked && picked.ready === false && !!picked.needs;
|
| 142 |
-
const provider =
|
| 143 |
const connected = !!(provider && oauth?.[provider]?.connected);
|
| 144 |
-
const startUrl =
|
| 145 |
|
| 146 |
const pickEvery = (next: string) => {
|
| 147 |
onShowCron(next === "custom");
|
|
@@ -152,6 +171,10 @@ export default function AutomationTrigger({
|
|
| 152 |
return (
|
| 153 |
<>
|
| 154 |
<div className="auto-field-row">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
<div className="auto-field">
|
| 156 |
<label htmlFor="auto-trigger-kind">When it runs</label>
|
| 157 |
<select
|
|
@@ -171,14 +194,22 @@ export default function AutomationTrigger({
|
|
| 171 |
<option value={current}>{current} (not offered here)</option>
|
| 172 |
) : null}
|
| 173 |
{options.map((t) => (
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
</option>
|
| 179 |
))}
|
| 180 |
</select>
|
| 181 |
</div>
|
|
|
|
| 182 |
|
| 183 |
{isSchedule ? (
|
| 184 |
<>
|
|
@@ -269,6 +300,16 @@ export default function AutomationTrigger({
|
|
| 269 |
) : null}
|
| 270 |
</div>
|
| 271 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
{/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */}
|
| 273 |
{waiting ? (
|
| 274 |
<div className="autob-needs">
|
|
|
|
| 24 |
// nothing and gets asked again.
|
| 25 |
// ---------------------------------------------------------------------------
|
| 26 |
import type { OAuthStatus, TickState, TriggerOption } from "./automationApi";
|
|
|
|
| 27 |
import { WEEKDAYS, readCron, writeCron } from "./steps";
|
| 28 |
|
| 29 |
interface Props {
|
|
|
|
| 53 |
/** True when the raw cron face is open β a VIEW choice, not a schedule change. */
|
| 54 |
showCron: boolean;
|
| 55 |
onShowCron: (v: boolean) => void;
|
| 56 |
+
/**
|
| 57 |
+
* WAVE 23 β the BUILDER supplies its own "Trigger type" select (Properties β Trigger details),
|
| 58 |
+
* so this face drops its picker and contributes only the schedule half. It is a prop rather
|
| 59 |
+
* than a second component because the thing worth having exactly once is what sits BELOW the
|
| 60 |
+
* picker: the cron round-trip, the preset list and the tick-honesty note, three things that go
|
| 61 |
+
* wrong invisibly. Two pickers for one fact is the defect this file's header forbids.
|
| 62 |
+
*/
|
| 63 |
+
hidePicker?: boolean;
|
| 64 |
}
|
| 65 |
|
| 66 |
+
/*
|
| 67 |
+
* β `FALLBACK_TRIGGERS` IS GONE (D-43, wave 23). It described the only two shapes a stored
|
| 68 |
+
* definition could express before C3 shipped (manual / on a schedule), and its own note set
|
| 69 |
+
* the deletion condition: "NEVER consulted once the list arrives; when C3 lands, deleting this
|
| 70 |
+
* constant changes nothing on screen." C3 has been live for two releases and the vocabulary is
|
| 71 |
+
* now nine keys plus two planned, so the fallback could only ever have been WRONG β a server
|
| 72 |
+
* hiccup would have painted a two-option picker that looks authoritative.
|
| 73 |
+
*
|
| 74 |
+
* The absent case is a STATE now, not a substitute list: no `triggers` means the surface says
|
| 75 |
+
* so in one line (below) rather than quietly offering a vocabulary of its own.
|
| 76 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
/** The one-line next step a not-ready trigger carries. Server words where there are any. */
|
| 79 |
function needsLabel(needs: string): string {
|
|
|
|
| 83 |
return needs.replace(/_/g, " ");
|
| 84 |
}
|
| 85 |
|
| 86 |
+
/**
|
| 87 |
+
* What an option says about itself. THREE states, and they are not the same fact:
|
| 88 |
+
* ready (nothing), waiting on a connection the USER can make ("needs setting up"), and
|
| 89 |
+
* declared-but-not-built ("coming soon", R2) which is waiting on us.
|
| 90 |
+
*/
|
| 91 |
+
function optionLabel(t: TriggerOption): string {
|
| 92 |
+
if (t.planned) return `${t.label} β coming soon`;
|
| 93 |
+
return t.ready === false ? `${t.label} β needs setting up` : t.label;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
/**
|
| 97 |
* What the deployment will actually do with a schedule.
|
| 98 |
*
|
|
|
|
| 144 |
oauth,
|
| 145 |
showCron,
|
| 146 |
onShowCron,
|
| 147 |
+
hidePicker,
|
| 148 |
}: Props) {
|
| 149 |
+
// β THE SERVER'S LIST OR NOTHING (D-43). An empty array is not a fallback trigger to
|
| 150 |
+
// invent β it is the one honest thing to say, and it is said below.
|
| 151 |
+
const options = triggers || [];
|
| 152 |
const picked = options.find((t) => t.key === current) || null;
|
| 153 |
const isSchedule = current === "schedule";
|
| 154 |
const shape = readCron(cron);
|
| 155 |
const note = tickNote(tick);
|
| 156 |
const every = showCron ? "custom" : shape.every;
|
| 157 |
+
// The connector this trigger is waiting on, and the route to it, BOTH COMPOSED BY THE
|
| 158 |
+
// SERVER (`TriggerOption.connect`). No `connect` = we cannot name a route, which is a state
|
| 159 |
+
// the block below states in words instead of drawing a button that goes nowhere.
|
| 160 |
+
const waiting = !!picked && picked.ready === false && !picked.planned && !!picked.needs;
|
| 161 |
+
const provider = picked?.connect?.provider || "";
|
| 162 |
const connected = !!(provider && oauth?.[provider]?.connected);
|
| 163 |
+
const startUrl = picked?.connect?.startUrl || "";
|
| 164 |
|
| 165 |
const pickEvery = (next: string) => {
|
| 166 |
onShowCron(next === "custom");
|
|
|
|
| 171 |
return (
|
| 172 |
<>
|
| 173 |
<div className="auto-field-row">
|
| 174 |
+
{/* β CONDITIONALLY RENDERED, never `hidden` β `.auto-field` sets `display: flex`, which
|
| 175 |
+
beats the `hidden` attribute's UA `display: none`, so the attribute would have left
|
| 176 |
+
a second trigger picker on screen looking exactly as if it had been asked for. */}
|
| 177 |
+
{hidePicker ? null : (
|
| 178 |
<div className="auto-field">
|
| 179 |
<label htmlFor="auto-trigger-kind">When it runs</label>
|
| 180 |
<select
|
|
|
|
| 194 |
<option value={current}>{current} (not offered here)</option>
|
| 195 |
) : null}
|
| 196 |
{options.map((t) => (
|
| 197 |
+
/*
|
| 198 |
+
Not-ready is stated ON the option rather than by disabling it: the option is
|
| 199 |
+
pickable, and picking it shows what it is waiting for.
|
| 200 |
+
β PLANNED IS THE ONE EXCEPTION, and it is not the same fact (R2). The server
|
| 201 |
+
REFUSES a planned key at the door, so leaving it pickable would offer a
|
| 202 |
+
control whose only possible outcome is a refusal. It still renders β the
|
| 203 |
+
question "can it run when a button is clicked" has an answer β it just is not
|
| 204 |
+
an offer.
|
| 205 |
+
*/
|
| 206 |
+
<option key={t.key} value={t.key} disabled={!!t.planned}>
|
| 207 |
+
{optionLabel(t)}
|
| 208 |
</option>
|
| 209 |
))}
|
| 210 |
</select>
|
| 211 |
</div>
|
| 212 |
+
)}
|
| 213 |
|
| 214 |
{isSchedule ? (
|
| 215 |
<>
|
|
|
|
| 300 |
) : null}
|
| 301 |
</div>
|
| 302 |
|
| 303 |
+
{/*
|
| 304 |
+
THE ABSENT LIST, SAID OUT LOUD (D-43). With the fallback deleted there is no
|
| 305 |
+
client-side vocabulary left to fall back TO, and that is the point: a picker that
|
| 306 |
+
quietly offers two options because a payload was short is a surface stating something
|
| 307 |
+
nobody measured. One line, no paragraph (R13).
|
| 308 |
+
*/}
|
| 309 |
+
{!options.length && !hidePicker ? (
|
| 310 |
+
<p className="auto-note">This server did not offer a trigger list.</p>
|
| 311 |
+
) : null}
|
| 312 |
+
|
| 313 |
{/* NOT CONFIGURED IS A STATE WITH A NEXT STEP, never a dead control (C3). */}
|
| 314 |
{waiting ? (
|
| 315 |
<div className="autob-needs">
|
web/src/automation/CondBuilder.tsx
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// automation/CondBuilder.tsx β the condition TREE editor (contract C4).
|
| 3 |
+
//
|
| 4 |
+
// ONE editor, three callers: the trigger's "when a record matches conditions"
|
| 5 |
+
// (image 4), a conditional group's "Run actions in this group ifβ¦" (image 9),
|
| 6 |
+
// and a Find records step. They are the same shape (`Cond`) evaluated by the
|
| 7 |
+
// same engine function, so a second implementation would be a second set of
|
| 8 |
+
// bugs β and the one that matters is silent: a tree the editor can BUILD but
|
| 9 |
+
// the server refuses reads to the user as "it just doesn't save".
|
| 10 |
+
//
|
| 11 |
+
// β THE VOCABULARY IS THE SERVER'S. The operators are `flow.condOps`, the
|
| 12 |
+
// value-free ones are `flow.nullaryCondOps`, and the depth/breadth ceilings are
|
| 13 |
+
// `flow.maxCondDepth`/`maxCondChildren` β all off `GET /automations`. This file
|
| 14 |
+
// holds NO list of operators. It holds a WORDING for the ones it recognises and
|
| 15 |
+
// falls through to the raw key for anything it does not, which is visible and
|
| 16 |
+
// therefore reportable; a `switch` with no arm for a new op would render the row
|
| 17 |
+
// as nothing at all (the wave-9 silent-drop class).
|
| 18 |
+
//
|
| 19 |
+
// β IT NEVER PREDICTS A REFUSAL. `clean_cond` refuses an empty group, an
|
| 20 |
+
// unknown op, a valueless compare and an over-deep tree, each with a sentence.
|
| 21 |
+
// This editor SAYS a condition is incomplete β the red line Airtable shows β and
|
| 22 |
+
// still lets the save happen, so the authority on legality stays in one place.
|
| 23 |
+
// A client that blocked Save on its own arithmetic can block a tree the server
|
| 24 |
+
// would have taken, and the user cannot tell which of the two is wrong
|
| 25 |
+
// (`DiscoverGuard`'s note in automationApi states the same rule for the corpus
|
| 26 |
+
// filter, and it is the same rule).
|
| 27 |
+
// ---------------------------------------------------------------------------
|
| 28 |
+
import type { Cond } from "./automationApi";
|
| 29 |
+
import { groupParts, isCondGroup } from "./automationApi";
|
| 30 |
+
|
| 31 |
+
interface Field {
|
| 32 |
+
key: string;
|
| 33 |
+
label: string;
|
| 34 |
+
type?: string;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
interface Props {
|
| 38 |
+
/** The stored tree. `null` = no conditions yet, which is a legal state, not an empty one. */
|
| 39 |
+
cond: Cond | null;
|
| 40 |
+
onChange: (next: Cond | null) => void;
|
| 41 |
+
fields: Field[];
|
| 42 |
+
/** `flow.condOps` β the comparisons the engine can answer. */
|
| 43 |
+
ops: string[];
|
| 44 |
+
/** `flow.nullaryCondOps` β the ones that take no value. */
|
| 45 |
+
nullaryOps: string[];
|
| 46 |
+
maxDepth: number;
|
| 47 |
+
maxChildren: number;
|
| 48 |
+
/** Prefix on the first row. Airtable says "When" at a trigger and nothing inside a group. */
|
| 49 |
+
lead?: string;
|
| 50 |
+
disabled?: boolean;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/**
|
| 54 |
+
* The WORDING of an operator, never the LIST of them.
|
| 55 |
+
*
|
| 56 |
+
* β The keys are the engine's `LANE_OPS` and the fallback is the key itself: an operator this
|
| 57 |
+
* table has not heard of renders as `>=` rather than as a blank cell. Ugly beats invisible β
|
| 58 |
+
* a blank is indistinguishable from a bug and nobody reports the row they cannot see.
|
| 59 |
+
* (Booked in the B mailbox as a small ask: `condOpLabels` on the wire would delete this map.)
|
| 60 |
+
*/
|
| 61 |
+
const OP_WORDS: Record<string, string> = {
|
| 62 |
+
"=": "is",
|
| 63 |
+
"!=": "is not",
|
| 64 |
+
">": "is greater than",
|
| 65 |
+
">=": "is at least",
|
| 66 |
+
"<": "is less than",
|
| 67 |
+
"<=": "is at most",
|
| 68 |
+
includes: "contains",
|
| 69 |
+
not_includes: "does not contain",
|
| 70 |
+
is_empty: "is empty",
|
| 71 |
+
is_not_empty: "is not empty",
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
export function opWord(op: string): string {
|
| 75 |
+
return OP_WORDS[op] || op;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/** A fresh leaf, using the FIRST operator the server offered rather than a hard-coded "=". */
|
| 79 |
+
function newLeaf(ops: string[]): Cond {
|
| 80 |
+
return { field: "", op: ops[0] || "", value: "" };
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
/**
|
| 84 |
+
* β ONE CHILD IS STORED AS A BARE LEAF, not as a group of one.
|
| 85 |
+
*
|
| 86 |
+
* Both are legal (`cond_match` dispatches on shape), and the engine's own note says a stored
|
| 87 |
+
* leaf is never rewritten at rest. Emitting the leaf keeps a one-condition trigger byte-identical
|
| 88 |
+
* to what wave 22 wrote, so opening an old automation in this editor and saving it unchanged
|
| 89 |
+
* does not produce a diff β a save that silently reshapes stored data is how "I only looked at
|
| 90 |
+
* it" turns into a migration nobody reviewed.
|
| 91 |
+
*/
|
| 92 |
+
function pack(join: "all" | "any", children: Cond[]): Cond | null {
|
| 93 |
+
if (!children.length) return null;
|
| 94 |
+
if (children.length === 1 && !isCondGroup(children[0])) return children[0];
|
| 95 |
+
return join === "all" ? { all: children } : { any: children };
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** Is every leaf in this tree answerable? Drives the red line, never the Save button. */
|
| 99 |
+
export function condComplete(cond: Cond | null | undefined, nullaryOps: string[]): boolean {
|
| 100 |
+
if (!cond) return true;
|
| 101 |
+
if (isCondGroup(cond)) {
|
| 102 |
+
const { children } = groupParts(cond);
|
| 103 |
+
return children.length > 0 && children.every((c) => condComplete(c, nullaryOps));
|
| 104 |
+
}
|
| 105 |
+
const leaf = cond as { field: string; op: string; value?: string | number };
|
| 106 |
+
if (!leaf.field || !leaf.op) return false;
|
| 107 |
+
if (nullaryOps.includes(leaf.op)) return true;
|
| 108 |
+
return leaf.value !== undefined && String(leaf.value).trim() !== "";
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
export default function CondBuilder({
|
| 112 |
+
cond,
|
| 113 |
+
onChange,
|
| 114 |
+
fields,
|
| 115 |
+
ops,
|
| 116 |
+
nullaryOps,
|
| 117 |
+
maxDepth,
|
| 118 |
+
maxChildren,
|
| 119 |
+
lead = "When",
|
| 120 |
+
disabled,
|
| 121 |
+
}: Props) {
|
| 122 |
+
// The editor always works on a GROUP even when one leaf is stored β a list of one is still a
|
| 123 |
+
// list, and `pack` puts it back the way it was found.
|
| 124 |
+
const { join, children } = isCondGroup(cond)
|
| 125 |
+
? groupParts(cond)
|
| 126 |
+
: { join: "all" as const, children: cond ? [cond] : [] };
|
| 127 |
+
|
| 128 |
+
const emit = (nextJoin: "all" | "any", next: Cond[]) => onChange(pack(nextJoin, next));
|
| 129 |
+
const replace = (i: number, next: Cond) =>
|
| 130 |
+
emit(join, children.map((c, j) => (j === i ? next : c)));
|
| 131 |
+
|
| 132 |
+
return (
|
| 133 |
+
<div className="autoc-tree">
|
| 134 |
+
{children.map((child, i) => (
|
| 135 |
+
/*
|
| 136 |
+
β THE KEY CARRIES THE ROW COUNT, and that is what makes the uncontrolled value input
|
| 137 |
+
above safe. Keyed by index alone, removing row 0 would REUSE row 0's DOM node for what
|
| 138 |
+
used to be row 1 β and an uncontrolled input keeps its own DOM value, so the reader
|
| 139 |
+
would be looking at the deleted row's text under the surviving row's field. Changing
|
| 140 |
+
the count remounts the whole list, which discards every stale DOM value at exactly the
|
| 141 |
+
moment the structure changes.
|
| 142 |
+
*/
|
| 143 |
+
<div className="autoc-row" key={`${children.length}:${i}`}>
|
| 144 |
+
<button
|
| 145 |
+
type="button"
|
| 146 |
+
className="autoc-drop"
|
| 147 |
+
disabled={disabled}
|
| 148 |
+
aria-label="Remove this condition"
|
| 149 |
+
title="Remove this condition"
|
| 150 |
+
onClick={() => emit(join, children.filter((_c, j) => j !== i))}
|
| 151 |
+
>
|
| 152 |
+
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 153 |
+
<path
|
| 154 |
+
d="M4 4l8 8M12 4l-8 8"
|
| 155 |
+
stroke="currentColor"
|
| 156 |
+
strokeWidth="1.5"
|
| 157 |
+
strokeLinecap="round"
|
| 158 |
+
/>
|
| 159 |
+
</svg>
|
| 160 |
+
</button>
|
| 161 |
+
|
| 162 |
+
{/*
|
| 163 |
+
THE JOIN IS THE GROUP'S, AND IT IS EDITED IN EXACTLY ONE PLACE. Row 2 carries the
|
| 164 |
+
dropdown; every later row prints the word it chose. Giving each row its own
|
| 165 |
+
and/or select would offer a mixed tree the shape cannot express β `{all: [β¦]}` has
|
| 166 |
+
one join β and the user would discover that only when their third row silently
|
| 167 |
+
behaved like the second's.
|
| 168 |
+
*/}
|
| 169 |
+
<span className="autoc-join">
|
| 170 |
+
{i === 0 ? (
|
| 171 |
+
lead
|
| 172 |
+
) : i === 1 ? (
|
| 173 |
+
<select
|
| 174 |
+
className="auto-input is-tiny"
|
| 175 |
+
value={join}
|
| 176 |
+
disabled={disabled}
|
| 177 |
+
aria-label="Match all or any of these conditions"
|
| 178 |
+
onChange={(e) => emit(e.target.value === "any" ? "any" : "all", children)}
|
| 179 |
+
>
|
| 180 |
+
<option value="all">and</option>
|
| 181 |
+
<option value="any">or</option>
|
| 182 |
+
</select>
|
| 183 |
+
) : (
|
| 184 |
+
<span className="autoc-join-word">{join === "all" ? "and" : "or"}</span>
|
| 185 |
+
)}
|
| 186 |
+
</span>
|
| 187 |
+
|
| 188 |
+
{isCondGroup(child) ? (
|
| 189 |
+
<div className="autoc-nest">
|
| 190 |
+
<CondBuilder
|
| 191 |
+
cond={child}
|
| 192 |
+
onChange={(next) =>
|
| 193 |
+
next
|
| 194 |
+
? replace(i, next)
|
| 195 |
+
: emit(join, children.filter((_c, j) => j !== i))
|
| 196 |
+
}
|
| 197 |
+
fields={fields}
|
| 198 |
+
ops={ops}
|
| 199 |
+
nullaryOps={nullaryOps}
|
| 200 |
+
maxDepth={maxDepth - 1}
|
| 201 |
+
maxChildren={maxChildren}
|
| 202 |
+
lead=""
|
| 203 |
+
{...(disabled ? { disabled: true } : {})}
|
| 204 |
+
/>
|
| 205 |
+
</div>
|
| 206 |
+
) : (
|
| 207 |
+
<Leaf
|
| 208 |
+
leaf={child as { field: string; op: string; value?: string | number }}
|
| 209 |
+
fields={fields}
|
| 210 |
+
ops={ops}
|
| 211 |
+
nullaryOps={nullaryOps}
|
| 212 |
+
{...(disabled ? { disabled: true } : {})}
|
| 213 |
+
onChange={(next) => replace(i, next)}
|
| 214 |
+
/>
|
| 215 |
+
)}
|
| 216 |
+
</div>
|
| 217 |
+
))}
|
| 218 |
+
|
| 219 |
+
<div className="autoc-adds">
|
| 220 |
+
<button
|
| 221 |
+
type="button"
|
| 222 |
+
className="autoc-add"
|
| 223 |
+
disabled={disabled || children.length >= maxChildren}
|
| 224 |
+
title={
|
| 225 |
+
children.length >= maxChildren
|
| 226 |
+
? `This group holds at most ${maxChildren} conditions.`
|
| 227 |
+
: undefined
|
| 228 |
+
}
|
| 229 |
+
onClick={() => emit(join, [...children, newLeaf(ops)])}
|
| 230 |
+
>
|
| 231 |
+
+ Add condition
|
| 232 |
+
</button>
|
| 233 |
+
{/* Nesting is offered only while the SERVER's depth allows it β the ceiling rides the
|
| 234 |
+
payload, so this control disappears at the same depth `clean_cond` refuses. */}
|
| 235 |
+
{maxDepth > 1 ? (
|
| 236 |
+
<button
|
| 237 |
+
type="button"
|
| 238 |
+
className="autoc-add"
|
| 239 |
+
disabled={disabled || children.length >= maxChildren}
|
| 240 |
+
onClick={() => emit(join, [...children, { all: [newLeaf(ops)] }])}
|
| 241 |
+
>
|
| 242 |
+
+ Add condition group
|
| 243 |
+
</button>
|
| 244 |
+
) : null}
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
{/* THE RED LINE (image 4). A statement about the tree, not a wall in front of Save. */}
|
| 248 |
+
{!condComplete(pack(join, children), nullaryOps) ? (
|
| 249 |
+
<p className="autoc-bad">A condition is incomplete or invalid.</p>
|
| 250 |
+
) : null}
|
| 251 |
+
</div>
|
| 252 |
+
);
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function Leaf({
|
| 256 |
+
leaf,
|
| 257 |
+
fields,
|
| 258 |
+
ops,
|
| 259 |
+
nullaryOps,
|
| 260 |
+
disabled,
|
| 261 |
+
onChange,
|
| 262 |
+
}: {
|
| 263 |
+
leaf: { field: string; op: string; value?: string | number };
|
| 264 |
+
fields: Field[];
|
| 265 |
+
ops: string[];
|
| 266 |
+
nullaryOps: string[];
|
| 267 |
+
disabled?: boolean;
|
| 268 |
+
onChange: (next: Cond) => void;
|
| 269 |
+
}) {
|
| 270 |
+
const nullary = nullaryOps.includes(leaf.op);
|
| 271 |
+
return (
|
| 272 |
+
<>
|
| 273 |
+
<select
|
| 274 |
+
className="auto-input is-tiny"
|
| 275 |
+
value={leaf.field}
|
| 276 |
+
disabled={disabled}
|
| 277 |
+
aria-label="Field"
|
| 278 |
+
onChange={(e) => onChange({ ...leaf, field: e.target.value })}
|
| 279 |
+
>
|
| 280 |
+
<option value="">Choose a fieldβ¦</option>
|
| 281 |
+
{/*
|
| 282 |
+
β THE STORED VALUE IS ALWAYS AN OPTION. A <select> whose `value` matches no <option>
|
| 283 |
+
renders the FIRST one, so a condition on a column this list has not caught up with
|
| 284 |
+
would LOOK like a condition on a different column β and the next Save would write that
|
| 285 |
+
different column without anybody choosing it. This repo has paid for that twice
|
| 286 |
+
([[cg-condition-builder-items]]).
|
| 287 |
+
*/}
|
| 288 |
+
{leaf.field && !fields.some((f) => f.key === leaf.field) ? (
|
| 289 |
+
<option value={leaf.field}>{leaf.field} (not in this database)</option>
|
| 290 |
+
) : null}
|
| 291 |
+
{fields.map((f) => (
|
| 292 |
+
<option key={f.key} value={f.key}>
|
| 293 |
+
{f.label}
|
| 294 |
+
</option>
|
| 295 |
+
))}
|
| 296 |
+
</select>
|
| 297 |
+
|
| 298 |
+
<select
|
| 299 |
+
className="auto-input is-tiny"
|
| 300 |
+
value={leaf.op}
|
| 301 |
+
disabled={disabled}
|
| 302 |
+
aria-label="Comparison"
|
| 303 |
+
onChange={(e) => {
|
| 304 |
+
const op = e.target.value;
|
| 305 |
+
// Moving to a value-free comparison DROPS the value rather than keeping it out of
|
| 306 |
+
// sight: a stored `value` under `is_empty` is a fact the sentence does not show and
|
| 307 |
+
// the next operator change would silently resurrect.
|
| 308 |
+
onChange(nullaryOps.includes(op) ? { field: leaf.field, op } : { ...leaf, op });
|
| 309 |
+
}}
|
| 310 |
+
>
|
| 311 |
+
{leaf.op && !ops.includes(leaf.op) ? (
|
| 312 |
+
<option value={leaf.op}>{opWord(leaf.op)} (not offered here)</option>
|
| 313 |
+
) : null}
|
| 314 |
+
{ops.map((op) => (
|
| 315 |
+
<option key={op} value={op}>
|
| 316 |
+
{opWord(op)}
|
| 317 |
+
</option>
|
| 318 |
+
))}
|
| 319 |
+
</select>
|
| 320 |
+
|
| 321 |
+
{nullary ? null : (
|
| 322 |
+
/*
|
| 323 |
+
β FREE TEXT COMMITS ON BLUR, like every other free-text field in this builder β and
|
| 324 |
+
the controlled version this replaced was worse than merely chatty. `onChange` reached
|
| 325 |
+
`patchTrigger` β PATCH, so typing "New" fired THREE requests; the displayed value
|
| 326 |
+
could not advance until each round-trip landed; and `disabled={busy}` disabled the
|
| 327 |
+
input under the typist's fingers. The two selects beside it stay controlled because
|
| 328 |
+
each is ONE discrete decision, which is exactly the distinction the cron field made
|
| 329 |
+
(AutomationTrigger's note) and the reason this input is not one.
|
| 330 |
+
*/
|
| 331 |
+
<input
|
| 332 |
+
className="auto-input is-tiny autoc-value"
|
| 333 |
+
defaultValue={leaf.value === undefined ? "" : String(leaf.value)}
|
| 334 |
+
disabled={disabled}
|
| 335 |
+
aria-label="Value"
|
| 336 |
+
placeholder="Value"
|
| 337 |
+
onBlur={(e) => {
|
| 338 |
+
if (e.target.value !== String(leaf.value ?? "")) onChange({ ...leaf, value: e.target.value });
|
| 339 |
+
}}
|
| 340 |
+
/>
|
| 341 |
+
)}
|
| 342 |
+
</>
|
| 343 |
+
);
|
| 344 |
+
}
|
web/src/automation/automationApi.ts
CHANGED
|
@@ -100,7 +100,31 @@ export interface Automation {
|
|
| 100 |
* definition can express are `schedule.enabled` true/false, and the picker
|
| 101 |
* derives those rather than inventing a third answer.
|
| 102 |
*/
|
| 103 |
-
trigger?: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
/**
|
| 105 |
* The review gate's decisions, newest first (<=100). A promotion is a person's
|
| 106 |
* judgement written into a record, so it says WHO β the same reason every count in
|
|
@@ -158,6 +182,13 @@ export interface AutomationList {
|
|
| 158 |
* controls that already exist rather than rendering an empty dropdown.
|
| 159 |
*/
|
| 160 |
triggers?: TriggerOption[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
storeAvailable: boolean;
|
| 162 |
}
|
| 163 |
|
|
@@ -173,6 +204,111 @@ export interface TriggerOption {
|
|
| 173 |
ready: boolean;
|
| 174 |
/** What it is waiting for, e.g. `"connect_gmail"`. Rendered as a next step, never as an error. */
|
| 175 |
needs?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
}
|
| 177 |
|
| 178 |
export interface OAuthConnection {
|
|
@@ -190,28 +326,19 @@ export interface OAuthConnection {
|
|
| 190 |
*/
|
| 191 |
export type OAuthStatus = Record<string, OAuthConnection | undefined>;
|
| 192 |
|
| 193 |
-
/*
|
| 194 |
-
*
|
| 195 |
*
|
| 196 |
-
*
|
| 197 |
-
*
|
| 198 |
-
*
|
| 199 |
-
*
|
| 200 |
-
*
|
|
|
|
| 201 |
*
|
| 202 |
-
*
|
| 203 |
-
*
|
| 204 |
*/
|
| 205 |
-
const CONNECT_PROVIDERS: Record<string, string> = { connect_gmail: "google" };
|
| 206 |
-
|
| 207 |
-
export function oauthProviderFor(needs: string): string {
|
| 208 |
-
return CONNECT_PROVIDERS[String(needs || "")] || "";
|
| 209 |
-
}
|
| 210 |
-
|
| 211 |
-
export function oauthStartUrlFor(needs: string): string {
|
| 212 |
-
const provider = oauthProviderFor(needs);
|
| 213 |
-
return provider ? `${API_V1}/oauth/${provider}/start` : "";
|
| 214 |
-
}
|
| 215 |
|
| 216 |
export interface TickState {
|
| 217 |
enabled: boolean;
|
|
@@ -312,6 +439,13 @@ export interface UserTable {
|
|
| 312 |
source: string;
|
| 313 |
rowCount: number;
|
| 314 |
fields: { key: string; label: string; type: string; automation?: unknown }[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
}
|
| 316 |
|
| 317 |
export interface SourcePreview {
|
|
@@ -422,6 +556,16 @@ export interface BoardCard {
|
|
| 422 |
/** The SERVER's answer to "may this move", not a permission the client derives. */
|
| 423 |
canAdvance: boolean;
|
| 424 |
next: string[];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
}
|
| 426 |
|
| 427 |
export interface Board {
|
|
@@ -444,13 +588,25 @@ export interface Board {
|
|
| 444 |
truncated?: string[];
|
| 445 |
}
|
| 446 |
|
| 447 |
-
/**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
export interface ReviewEntry {
|
| 449 |
ts: string;
|
| 450 |
user: string;
|
| 451 |
rowId: string;
|
| 452 |
from: string;
|
| 453 |
to: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
}
|
| 455 |
|
| 456 |
export class AutomationError extends Error {
|
|
|
|
| 100 |
* definition can express are `schedule.enabled` true/false, and the picker
|
| 101 |
* derives those rather than inventing a third answer.
|
| 102 |
*/
|
| 103 |
+
trigger?: {
|
| 104 |
+
key: string;
|
| 105 |
+
ready?: boolean;
|
| 106 |
+
needs?: string;
|
| 107 |
+
/** β STORED INERT, NOT REFUSED (A3): the picker writes `{key}` first and the table/field
|
| 108 |
+
* after, so an unfinished trigger is a STATE the builder finishes β never a snap-back to
|
| 109 |
+
* Manual. It cannot fire while incomplete, which is the fail-closed direction. */
|
| 110 |
+
configured?: boolean;
|
| 111 |
+
paused?: boolean;
|
| 112 |
+
enabled?: boolean;
|
| 113 |
+
table?: string;
|
| 114 |
+
field?: string;
|
| 115 |
+
fields?: string[];
|
| 116 |
+
viewId?: string;
|
| 117 |
+
query?: string;
|
| 118 |
+
token?: string;
|
| 119 |
+
formToken?: string;
|
| 120 |
+
when?: Cond | null;
|
| 121 |
+
};
|
| 122 |
+
/**
|
| 123 |
+
* WAVE 23 / C4 β the ACTIONS this automation runs, and how it ends (C5). Defaulted
|
| 124 |
+
* server-side to `{actions: [], ending: {mode: "terminal"}}`, so an automation written before
|
| 125 |
+
* this wave reads as "no actions, stops at the end" rather than as a missing key.
|
| 126 |
+
*/
|
| 127 |
+
flow?: Flow;
|
| 128 |
/**
|
| 129 |
* The review gate's decisions, newest first (<=100). A promotion is a person's
|
| 130 |
* judgement written into a record, so it says WHO β the same reason every count in
|
|
|
|
| 182 |
* controls that already exist rather than rendering an empty dropdown.
|
| 183 |
*/
|
| 184 |
triggers?: TriggerOption[];
|
| 185 |
+
/**
|
| 186 |
+
* WAVE 23 / C4 β the action menu and the builder's ceilings. OPTIONAL for the reason every
|
| 187 |
+
* other server vocabulary here is: absent is a third state (a server older than this client),
|
| 188 |
+
* and the builder prints what it has rather than inventing a menu.
|
| 189 |
+
*/
|
| 190 |
+
actionsCatalog?: ActionCatalogRow[];
|
| 191 |
+
flow?: FlowVocab;
|
| 192 |
storeAvailable: boolean;
|
| 193 |
}
|
| 194 |
|
|
|
|
| 204 |
ready: boolean;
|
| 205 |
/** What it is waiting for, e.g. `"connect_gmail"`. Rendered as a next step, never as an error. */
|
| 206 |
needs?: string;
|
| 207 |
+
/**
|
| 208 |
+
* WAVE 23 / R2 β DECLARED BUT NOT BUILT (`button_clicked`, `comment_added`). Distinct from
|
| 209 |
+
* `ready:false`, and the difference is what the user can DO about it: a not-ready trigger is
|
| 210 |
+
* waiting on a connection THEY can make, a planned one is waiting on us. Both render; only
|
| 211 |
+
* the first offers an action. The server refuses a planned key at the door
|
| 212 |
+
* (`clean_trigger`), so the faded state is enforced rather than merely displayed.
|
| 213 |
+
*/
|
| 214 |
+
planned?: boolean;
|
| 215 |
+
/**
|
| 216 |
+
* β THE CONNECT AFFORDANCE, SERVER-COMPOSED (A3(3), wave 23). This replaced a client table
|
| 217 |
+
* that mapped a `needs` token to a provider and hand-built the start URL β two facts the
|
| 218 |
+
* OAuth registry already owns, kept in a second place that could disagree with it. `null`
|
| 219 |
+
* (or absent) means there is no route to offer, which the trigger face states in words
|
| 220 |
+
* instead of drawing a button that goes nowhere.
|
| 221 |
+
*/
|
| 222 |
+
connect?: { provider: string; startUrl: string } | null;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
// ββ WAVE 23 / contracts C4 + C5: CONDITION TREES, ACTIONS, ENDINGS βββββββββββββββββββββββββββ
|
| 226 |
+
//
|
| 227 |
+
// β EVERY VOCABULARY BELOW IS `string`, for the reason C1 and C3 already give: a client union
|
| 228 |
+
// over a server enumeration is a list that can go stale, and it goes stale SILENTLY. The engine
|
| 229 |
+
// ships `flow.condOps`, `flow.endingModes`, `flow.reviewDeciders` and `actionsCatalog` on
|
| 230 |
+
// `GET /automations`; the builder renders whatever rides and never a list of its own.
|
| 231 |
+
|
| 232 |
+
/**
|
| 233 |
+
* ONE condition, as a TREE (C4). A leaf is wave 22's lane condition unchanged β which is why
|
| 234 |
+
* nothing migrated: a stored leaf IS a valid tree, and the engine dispatches on shape.
|
| 235 |
+
* Depth and breadth are the SERVER's numbers (`flow.maxCondDepth` / `maxCondChildren`), read
|
| 236 |
+
* from the payload rather than repeated here.
|
| 237 |
+
*/
|
| 238 |
+
export type Cond =
|
| 239 |
+
| { field: string; op: string; value?: string | number }
|
| 240 |
+
| { all: Cond[] }
|
| 241 |
+
| { any: Cond[] };
|
| 242 |
+
|
| 243 |
+
export function isCondGroup(c: Cond | null | undefined): c is { all: Cond[] } | { any: Cond[] } {
|
| 244 |
+
return !!c && (Array.isArray((c as { all?: Cond[] }).all)
|
| 245 |
+
|| Array.isArray((c as { any?: Cond[] }).any));
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
/** A group's join word and its children, without the caller re-testing both keys every time. */
|
| 249 |
+
export function groupParts(c: Cond): { join: "all" | "any"; children: Cond[] } {
|
| 250 |
+
const all = (c as { all?: Cond[] }).all;
|
| 251 |
+
return Array.isArray(all) ? { join: "all", children: all }
|
| 252 |
+
: { join: "any", children: (c as { any: Cond[] }).any || [] };
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
/**
|
| 256 |
+
* One step of a flow (C4). `config` is per-kind and deliberately loose here β the engine
|
| 257 |
+
* validates it and REFUSES rather than coercing, so a client-side shape would be a second
|
| 258 |
+
* validator free to disagree with the one that decides.
|
| 259 |
+
*/
|
| 260 |
+
export interface Action {
|
| 261 |
+
id: string;
|
| 262 |
+
kind: string;
|
| 263 |
+
enabled: boolean;
|
| 264 |
+
/** Run this action only when the walking record matches. `null` = always. */
|
| 265 |
+
when?: Cond | null;
|
| 266 |
+
config: Record<string, unknown>;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/** `flow.ending` (C5) β what happens to a record that reaches the end. */
|
| 270 |
+
export interface FlowEnding {
|
| 271 |
+
mode: string;
|
| 272 |
+
hours?: number;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
export interface Flow {
|
| 276 |
+
actions: Action[];
|
| 277 |
+
ending: FlowEnding;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
/**
|
| 281 |
+
* A row of the ACTION MENU (image 7/8's grouped list). `group` is the section heading and
|
| 282 |
+
* `ready:false` paints the card faded WITH the server's own reason β the owner asked for the
|
| 283 |
+
* full menu, and a shorter one would imply the missing actions do not exist. `clean_actions`
|
| 284 |
+
* refuses an unready kind, so faded is a wall rather than a styling choice.
|
| 285 |
+
*/
|
| 286 |
+
export interface ActionCatalogRow {
|
| 287 |
+
kind: string;
|
| 288 |
+
label: string;
|
| 289 |
+
group: string;
|
| 290 |
+
ready: boolean;
|
| 291 |
+
detail: string;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/** The builder's own ceilings, server-declared so the client never hard-codes the same numbers. */
|
| 295 |
+
export interface FlowVocab {
|
| 296 |
+
condOps: string[];
|
| 297 |
+
nullaryCondOps: string[];
|
| 298 |
+
maxCondDepth: number;
|
| 299 |
+
maxCondChildren: number;
|
| 300 |
+
maxGroupDepth: number;
|
| 301 |
+
maxActions: number;
|
| 302 |
+
endingModes: string[];
|
| 303 |
+
maxResetHours: number;
|
| 304 |
+
reviewDeciders: string[];
|
| 305 |
+
/**
|
| 306 |
+
* β PER-DEPLOYMENT, AND HONEST RATHER THAN DECORATIVE. With no LLM key configured the engine
|
| 307 |
+
* holds every card for a human β so an enabled-looking "decided by AI" control would promise
|
| 308 |
+
* a decision nothing will make. False renders it as not-configured (C6's fail-closed path,
|
| 309 |
+
* made visible).
|
| 310 |
+
*/
|
| 311 |
+
aiReady: boolean;
|
| 312 |
}
|
| 313 |
|
| 314 |
export interface OAuthConnection {
|
|
|
|
| 326 |
*/
|
| 327 |
export type OAuthStatus = Record<string, OAuthConnection | undefined>;
|
| 328 |
|
| 329 |
+
/*
|
| 330 |
+
* β `CONNECT_PROVIDERS` / `oauthProviderFor` / `oauthStartUrlFor` ARE GONE (D-43, wave 23).
|
| 331 |
*
|
| 332 |
+
* They were a client table mapping a server `needs` token ("connect_gmail") to a provider
|
| 333 |
+
* ("google") and composing `/oauth/google/start` by hand β a second copy of the OAuth
|
| 334 |
+
* registry, booked as debt on the day it shipped with the resolution written into it: "this
|
| 335 |
+
* table disappears the day a `TriggerOption` carries its own provider or start URL." It does
|
| 336 |
+
* now (`TriggerOption.connect`, composed by `routes_automation._triggers_vocab`), so the copy
|
| 337 |
+
* is deleted rather than left to drift the first time a second provider lands.
|
| 338 |
*
|
| 339 |
+
* The FAIL-QUIET-BUT-VISIBLE behaviour it was built for is unchanged and now belongs to the
|
| 340 |
+
* server: no `connect` means no button, and the not-configured state is still said in words.
|
| 341 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
export interface TickState {
|
| 344 |
enabled: boolean;
|
|
|
|
| 439 |
source: string;
|
| 440 |
rowCount: number;
|
| 441 |
fields: { key: string; label: string; type: string; automation?: unknown }[];
|
| 442 |
+
/**
|
| 443 |
+
* The table's saved views β what `enters_view` names (C3). ABSENT until the server sends it,
|
| 444 |
+
* and absent is a third state, not an empty list: the trigger's view control says the list was
|
| 445 |
+
* not offered rather than rendering an empty picker that looks like "this table has no views".
|
| 446 |
+
* (Asked of session A in the wave mailbox; the control reads this key the day it rides.)
|
| 447 |
+
*/
|
| 448 |
+
views?: { id: string; label: string }[];
|
| 449 |
}
|
| 450 |
|
| 451 |
export interface SourcePreview {
|
|
|
|
| 556 |
/** The SERVER's answer to "may this move", not a permission the client derives. */
|
| 557 |
canAdvance: boolean;
|
| 558 |
next: string[];
|
| 559 |
+
/**
|
| 560 |
+
* WAVE 23 / C5 β how many times this record has been round the flow. The count lives in the
|
| 561 |
+
* machine cell `stage_<autoId>_cycles` and the engine increments it on every reset.
|
| 562 |
+
*
|
| 563 |
+
* β NOT ON THE CARD WIRE YET (asked of session A in the wave mailbox). Absent means "not
|
| 564 |
+
* reported", NOT "once": the badge only draws from 2 upwards, so a missing key renders
|
| 565 |
+
* nothing at all rather than a claim that every record is on its first pass β which is
|
| 566 |
+
* exactly the measurement nobody made. It lights up the day the key rides.
|
| 567 |
+
*/
|
| 568 |
+
cycles?: number;
|
| 569 |
}
|
| 570 |
|
| 571 |
export interface Board {
|
|
|
|
| 588 |
truncated?: string[];
|
| 589 |
}
|
| 590 |
|
| 591 |
+
/**
|
| 592 |
+
* One decision at a review gate β R7's approval posture, audited (A2(5)).
|
| 593 |
+
*
|
| 594 |
+
* β WAVE 23 / C6 (R4/R14): a decision may now be made by a MODEL, and the audit says so. One
|
| 595 |
+
* log, not two β a reader asking "who decided this" gets one answer whether it was a person or
|
| 596 |
+
* a provider, which is the only version that can be trusted at all.
|
| 597 |
+
*/
|
| 598 |
export interface ReviewEntry {
|
| 599 |
ts: string;
|
| 600 |
user: string;
|
| 601 |
rowId: string;
|
| 602 |
from: string;
|
| 603 |
to: string;
|
| 604 |
+
/** `"user"` | `"ai"`. Absent on entries written before this wave β render as a person. */
|
| 605 |
+
by?: string;
|
| 606 |
+
/** The one-line reason. On an AI decision it is the model's own; never invented here. */
|
| 607 |
+
note?: string;
|
| 608 |
+
/** Which model decided. Present only on AI entries, and shown so the reason has provenance. */
|
| 609 |
+
model?: string;
|
| 610 |
}
|
| 611 |
|
| 612 |
export class AutomationError extends Error {
|
web/src/connectors/ConnectorsPage.tsx
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// connectors/ConnectorsPage.tsx β WAVE 23 item 10 (ruling R8, contract C11).
|
| 3 |
+
//
|
| 4 |
+
// The Claude-style directory: a search box and a card grid, laid out from
|
| 5 |
+
// `reference/Claude Connectors.png` and painted in OUR tokens. Every card is a
|
| 6 |
+
// row of `GET /api/v1/connectors/directory` (SESSION A's route) β this page
|
| 7 |
+
// invents nothing, which is what lets `#/connectors` be a CHROME route without
|
| 8 |
+
// touching the shell's "an undeclared surface is denied" law (nav.ts'
|
| 9 |
+
// `CHROME_ROUTES` note carries the full argument).
|
| 10 |
+
//
|
| 11 |
+
// β NO THIRD-PARTY LOGOS AND NO EMOJIS (R8 + DESIGN.md Β§4). A connector's mark is
|
| 12 |
+
// a two-letter tile in the chip family every database already wears, so the
|
| 13 |
+
// directory reads as part of this product rather than as a wall of other
|
| 14 |
+
// companies' branding β and nobody has to ship, license or update an icon set.
|
| 15 |
+
//
|
| 16 |
+
// β NO CLIENT UNION OVER `state` OR `kind`. They are the server's vocabulary and
|
| 17 |
+
// arrive as strings (the wave-9 law): a client `type State = "connected" | β¦`
|
| 18 |
+
// turns "the server added a state" into "the client drops the card".
|
| 19 |
+
// ---------------------------------------------------------------------------
|
| 20 |
+
|
| 21 |
+
import { useEffect, useState } from "react";
|
| 22 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 23 |
+
|
| 24 |
+
/** One row of the directory. Everything optional but `key` + `label` is genuinely optional on
|
| 25 |
+
* the wire β a builtin has no provider, a planned connector has no `connectedAs`. */
|
| 26 |
+
export interface ConnectorRow {
|
| 27 |
+
key: string;
|
| 28 |
+
label: string;
|
| 29 |
+
desc: string;
|
| 30 |
+
/** `connected` | `available` | `planned` β read as a STRING, never a union. */
|
| 31 |
+
state: string;
|
| 32 |
+
/** `builtin` | `oauth` | `token`. */
|
| 33 |
+
kind: string;
|
| 34 |
+
provider?: string;
|
| 35 |
+
/** What is missing before it can connect β shown on the card, never invented here. */
|
| 36 |
+
needs?: string;
|
| 37 |
+
/** The account the connection is made as, when the server chooses to say. */
|
| 38 |
+
connectedAs?: string;
|
| 39 |
+
/**
|
| 40 |
+
* β A DESTINATION WORD, NOT A URL β `"keychain" | "oauth" | "automation" | ""`.
|
| 41 |
+
*
|
| 42 |
+
* This page was first written against C11's prose, which reads as though `manage` were a link,
|
| 43 |
+
* and it rendered `<a href={row.manage}>` β i.e. `href="keychain"`, a relative link to a page
|
| 44 |
+
* that does not exist, on every Odoo and token card. It compiled, it looked right, and it went
|
| 45 |
+
* nowhere. Corrected against the real payload (`routes_connectors.directory`): the server
|
| 46 |
+
* names WHERE the action lives and this page decides how to open it, which is the right split β
|
| 47 |
+
* the door for a keychain entry is a modal the shell owns and no URL could express it.
|
| 48 |
+
*
|
| 49 |
+
* Empty for a NON-ADMIN, deliberately (the route's own note): the card renders without an
|
| 50 |
+
* action rather than offering a door that would 403.
|
| 51 |
+
*/
|
| 52 |
+
manage?: string;
|
| 53 |
+
/** OAuth only: the URL that starts the flow. This IS a link, and it is a different field. */
|
| 54 |
+
startUrl?: string;
|
| 55 |
+
/** A token connector's honest one-liner: the key is stored, the data arrives later (R13). */
|
| 56 |
+
note?: string;
|
| 57 |
+
/** What the admin will be asked for β shown where the decision is made, not in a tour. */
|
| 58 |
+
hint?: string;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
type Load =
|
| 62 |
+
| { phase: "loading" }
|
| 63 |
+
| { phase: "ready"; rows: ConnectorRow[] }
|
| 64 |
+
| { phase: "error"; message: string };
|
| 65 |
+
|
| 66 |
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
| 67 |
+
|
| 68 |
+
export function parseDirectory(body: unknown): ConnectorRow[] {
|
| 69 |
+
const raw = (body as { connectors?: unknown } | null)?.connectors;
|
| 70 |
+
if (!Array.isArray(raw)) return [];
|
| 71 |
+
const out: ConnectorRow[] = [];
|
| 72 |
+
for (const item of raw) {
|
| 73 |
+
if (!item || typeof item !== "object") continue;
|
| 74 |
+
const c = item as Record<string, unknown>;
|
| 75 |
+
const key = str(c.key).trim();
|
| 76 |
+
const label = str(c.label).trim();
|
| 77 |
+
// A card with no name is a tile with no sign on it β dropped rather than rendered from its
|
| 78 |
+
// key, which would put an internal identifier on screen (`parsePages`' own rule).
|
| 79 |
+
if (!key || !label) continue;
|
| 80 |
+
out.push({
|
| 81 |
+
key,
|
| 82 |
+
label,
|
| 83 |
+
desc: str(c.desc),
|
| 84 |
+
state: str(c.state) || "available",
|
| 85 |
+
kind: str(c.kind),
|
| 86 |
+
...(str(c.provider) ? { provider: str(c.provider) } : {}),
|
| 87 |
+
...(str(c.needs) ? { needs: str(c.needs) } : {}),
|
| 88 |
+
...(str(c.connectedAs) ? { connectedAs: str(c.connectedAs) } : {}),
|
| 89 |
+
...(str(c.manage) ? { manage: str(c.manage) } : {}),
|
| 90 |
+
...(str(c.startUrl) ? { startUrl: str(c.startUrl) } : {}),
|
| 91 |
+
...(str(c.note) ? { note: str(c.note) } : {}),
|
| 92 |
+
...(str(c.hint) ? { hint: str(c.hint) } : {}),
|
| 93 |
+
});
|
| 94 |
+
}
|
| 95 |
+
return out;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
/** The card's mark: two letters from the connector's own name, in the database chip family.
|
| 99 |
+
* Unicode-aware for the same reason the account monogram is (`Shell.tsx:299`). */
|
| 100 |
+
export function tileText(label: string): string {
|
| 101 |
+
const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
|
| 102 |
+
return chars.slice(0, 2).join("").toUpperCase();
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
/** Search over the two things a person actually reads. Case-folded substring: this list is a
|
| 106 |
+
* couple of dozen rows, and a ranking function over a set you can see all of is noise. */
|
| 107 |
+
export function matches(row: ConnectorRow, query: string): boolean {
|
| 108 |
+
const q = query.trim().toLowerCase();
|
| 109 |
+
if (!q) return true;
|
| 110 |
+
return (
|
| 111 |
+
row.label.toLowerCase().includes(q) ||
|
| 112 |
+
row.desc.toLowerCase().includes(q) ||
|
| 113 |
+
row.key.toLowerCase().includes(q)
|
| 114 |
+
);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* The chip's tone for a state.
|
| 119 |
+
*
|
| 120 |
+
* β FIVE STATES ARRIVE, NOT THREE. C11's prose names `connected | available | planned`; the real
|
| 121 |
+
* payload also sends `reconnect` (the token expired) and `unconfigured` (nobody registered the
|
| 122 |
+
* client on this deployment) β a distinction the route's own comment calls the point of the
|
| 123 |
+
* change. Read as strings and mapped here, so a SIXTH state renders in the neutral tone rather
|
| 124 |
+
* than dropping the card (the wave-9 law: no client union over a server vocabulary).
|
| 125 |
+
*/
|
| 126 |
+
export function stateTone(state: string): string {
|
| 127 |
+
if (state === "connected") return " is-on";
|
| 128 |
+
if (state === "available" || state === "reconnect") return " is-open";
|
| 129 |
+
return ""; // planned, unconfigured, and anything new
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
export default function ConnectorsPage({
|
| 133 |
+
onKeychain,
|
| 134 |
+
}: {
|
| 135 |
+
/**
|
| 136 |
+
* `manage: "keychain"` β open Settings on the Keychains tab.
|
| 137 |
+
*
|
| 138 |
+
* β A CALLBACK, NOT A LINK, because the destination is a MODAL the shell owns and no URL can
|
| 139 |
+
* express it. REQUIRED rather than optional: an optional handler the frame forgot to pass
|
| 140 |
+
* degrades to "the Connect button does nothing", which is indistinguishable from a feature
|
| 141 |
+
* that was never built and goes red in no gate (this repo's most expensive lesson).
|
| 142 |
+
*/
|
| 143 |
+
onKeychain: () => void;
|
| 144 |
+
}) {
|
| 145 |
+
const [load, setLoad] = useState<Load>({ phase: "loading" });
|
| 146 |
+
const [query, setQuery] = useState("");
|
| 147 |
+
|
| 148 |
+
useEffect(() => {
|
| 149 |
+
let dead = false;
|
| 150 |
+
void (async () => {
|
| 151 |
+
try {
|
| 152 |
+
const res = await fetch(`${API_V1}/connectors/directory`, {
|
| 153 |
+
credentials: CREDENTIALS,
|
| 154 |
+
});
|
| 155 |
+
const body = (await res.json().catch(() => null)) as
|
| 156 |
+
| { error?: { message?: string } }
|
| 157 |
+
| null;
|
| 158 |
+
if (dead) return;
|
| 159 |
+
if (!res.ok) {
|
| 160 |
+
setLoad({
|
| 161 |
+
phase: "error",
|
| 162 |
+
message: body?.error?.message || `The server answered ${res.status}.`,
|
| 163 |
+
});
|
| 164 |
+
return;
|
| 165 |
+
}
|
| 166 |
+
setLoad({ phase: "ready", rows: parseDirectory(body) });
|
| 167 |
+
} catch {
|
| 168 |
+
if (!dead) setLoad({ phase: "error", message: "Cannot reach the server." });
|
| 169 |
+
}
|
| 170 |
+
})();
|
| 171 |
+
return () => {
|
| 172 |
+
dead = true;
|
| 173 |
+
};
|
| 174 |
+
}, []);
|
| 175 |
+
|
| 176 |
+
const rows = load.phase === "ready" ? load.rows.filter((r) => matches(r, query)) : [];
|
| 177 |
+
|
| 178 |
+
return (
|
| 179 |
+
<div className="shell-conn">
|
| 180 |
+
<h1 className="conn-title">Connectors</h1>
|
| 181 |
+
|
| 182 |
+
<input
|
| 183 |
+
className="conn-search"
|
| 184 |
+
placeholder="Search connectors"
|
| 185 |
+
value={query}
|
| 186 |
+
maxLength={60}
|
| 187 |
+
onChange={(e) => setQuery(e.target.value)}
|
| 188 |
+
/>
|
| 189 |
+
|
| 190 |
+
{load.phase === "loading" ? (
|
| 191 |
+
<div className="shell-loading">
|
| 192 |
+
<span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
|
| 193 |
+
</div>
|
| 194 |
+
) : null}
|
| 195 |
+
{load.phase === "error" ? (
|
| 196 |
+
// Honest, and it names the retry. Never a hard-coded list of connectors as a fallback:
|
| 197 |
+
// that would put sources on screen this workspace may not have (the nav's own rule,
|
| 198 |
+
// applied to a second server-composed payload).
|
| 199 |
+
<p className="conn-note">{load.message}</p>
|
| 200 |
+
) : null}
|
| 201 |
+
{load.phase === "ready" && rows.length === 0 ? (
|
| 202 |
+
<p className="conn-note">
|
| 203 |
+
{query ? "No connector matches that." : "No connectors are available yet."}
|
| 204 |
+
</p>
|
| 205 |
+
) : null}
|
| 206 |
+
|
| 207 |
+
<div className="conn-grid">
|
| 208 |
+
{rows.map((row) => {
|
| 209 |
+
const planned = row.state === "planned";
|
| 210 |
+
const connected = row.state === "connected";
|
| 211 |
+
return (
|
| 212 |
+
<div
|
| 213 |
+
key={row.key}
|
| 214 |
+
className={"conn-card" + (planned ? " is-planned" : "")}
|
| 215 |
+
// β `aria-disabled`, NOT `inert`/`pointer-events` alone. A planned card is
|
| 216 |
+
// non-interactive by R8, and a card that merely LOOKS faded while still taking a
|
| 217 |
+
// click is the painted-but-unclickable failure in reverse
|
| 218 |
+
// ([[ui-invisible-to-assertions]]). It carries no controls at all, so there is
|
| 219 |
+
// nothing to disable β the attribute is what says so to a screen reader.
|
| 220 |
+
{...(planned ? { "aria-disabled": true } : {})}
|
| 221 |
+
>
|
| 222 |
+
<div className="conn-card-head">
|
| 223 |
+
<span className="shell-db-chip conn-tile" aria-hidden="true">
|
| 224 |
+
{tileText(row.label)}
|
| 225 |
+
</span>
|
| 226 |
+
<span className="conn-card-name">{row.label}</span>
|
| 227 |
+
<span className={"conn-state" + stateTone(row.state)}>
|
| 228 |
+
{/* The server's own word, sentence-cased by CSS never by `toUpperCase` β R6
|
| 229 |
+
bans caps in app chrome, and a chip is chrome. */}
|
| 230 |
+
{row.state}
|
| 231 |
+
</span>
|
| 232 |
+
</div>
|
| 233 |
+
<p className="conn-card-desc">{row.desc}</p>
|
| 234 |
+
{connected && row.connectedAs ? (
|
| 235 |
+
<p className="conn-card-meta">Connected as {row.connectedAs}</p>
|
| 236 |
+
) : null}
|
| 237 |
+
{/* β `needs` IS SHOWN WHATEVER THE STATE, and that is a correction: gating it on
|
| 238 |
+
"not connected" hid the one line that explains an `unconfigured` row, which is
|
| 239 |
+
the state it exists for ("the owner registers the client"). Only `planned`
|
| 240 |
+
suppresses it, because "not built yet" beside a card already marked planned is
|
| 241 |
+
the same sentence twice. */}
|
| 242 |
+
{!planned && row.needs ? (
|
| 243 |
+
<p className="conn-card-meta">{row.needs}</p>
|
| 244 |
+
) : null}
|
| 245 |
+
{/* R13's honesty line for a token connector: the key is stored, the data arrives
|
| 246 |
+
later. Shown ON the card where the decision is, never in a paragraph. */}
|
| 247 |
+
{!planned && row.note ? <p className="conn-card-meta">{row.note}</p> : null}
|
| 248 |
+
{/* ββ the ACTION. `manage` names WHERE it lives; this decides how to open it.
|
| 249 |
+
Every door here already existed β an OAuth start URL, the keychain dialog,
|
| 250 |
+
the automation surface β which is exactly why this page can be chrome: it
|
| 251 |
+
mints no new credential path (C11). An empty `manage` (a non-admin) renders
|
| 252 |
+
NO control rather than one that would 403. */}
|
| 253 |
+
{!planned && row.manage === "oauth" && row.startUrl ? (
|
| 254 |
+
<a className="conn-card-go" href={row.startUrl}>
|
| 255 |
+
{row.state === "reconnect" ? "Reconnect" : "Connect"}
|
| 256 |
+
</a>
|
| 257 |
+
) : null}
|
| 258 |
+
{!planned && row.manage === "keychain" ? (
|
| 259 |
+
<button type="button" className="conn-card-go" onClick={onKeychain}>
|
| 260 |
+
{connected ? "Manage keys" : "Add keys"}
|
| 261 |
+
</button>
|
| 262 |
+
) : null}
|
| 263 |
+
{!planned && row.manage === "automation" ? (
|
| 264 |
+
<a className="conn-card-go" href="#/automation">
|
| 265 |
+
Open automations
|
| 266 |
+
</a>
|
| 267 |
+
) : null}
|
| 268 |
+
</div>
|
| 269 |
+
);
|
| 270 |
+
})}
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
);
|
| 274 |
+
}
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -3,8 +3,8 @@ import { AnchoredOverlay } from "./OverlaySurface";
|
|
| 3 |
import type { AnchorRect } from "./OverlaySurface";
|
| 4 |
import { FieldTypeIcon, MenuLabel } from "./icons";
|
| 5 |
import { FieldSelectButton } from "./FieldSelect";
|
| 6 |
-
import { CREATABLE_TYPES, choiceOptions, choiceRenames, directionLabel,
|
| 7 |
-
from "./types";
|
| 8 |
import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
|
| 9 |
import type { WindowSpec } from "./windows";
|
| 10 |
import { normalizeWindow, windowLabel } from "./windows";
|
|
@@ -1235,7 +1235,16 @@ export default function ColumnMenu({
|
|
| 1235 |
// Item 8c β the period draft is valid when it normalizes and differs from what the field
|
| 1236 |
// already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
|
| 1237 |
// read as the same window.
|
| 1238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1239 |
? "Metric, read-only"
|
| 1240 |
: field.type === "formula"
|
| 1241 |
? "Formula field, computed"
|
|
|
|
| 3 |
import type { AnchorRect } from "./OverlaySurface";
|
| 4 |
import { FieldTypeIcon, MenuLabel } from "./icons";
|
| 5 |
import { FieldSelectButton } from "./FieldSelect";
|
| 6 |
+
import { CREATABLE_TYPES, choiceOptions, choiceRenames, directionLabel, isMachineOwned,
|
| 7 |
+
parseOptions, ratingMax } from "./types";
|
| 8 |
import type { Field, FieldFormat, FieldScope, FieldType, Measure, Viewer } from "./types";
|
| 9 |
import type { WindowSpec } from "./windows";
|
| 10 |
import { normalizeWindow, windowLabel } from "./windows";
|
|
|
|
| 1235 |
// Item 8c β the period draft is valid when it normalizes and differs from what the field
|
| 1236 |
// already has. Compared through normalizeWindow so `{kind:'ltm'}` and `{kind:'ltm', n:undefined}`
|
| 1237 |
// read as the same window.
|
| 1238 |
+
// β Wave-23 C8 (owner item 4 / R9) β FIRST in the chain, because it is the most specific true
|
| 1239 |
+
// thing about the column and the branches below it were saying something false. An
|
| 1240 |
+
// `automation` or `metric` column, and every column an automation SPAWNED (the `automation`
|
| 1241 |
+
// tag), is overlay-sourced and non-derived, so it fell through to "Editable overlay field" β
|
| 1242 |
+
// which is what the wash now visibly contradicts. ONE line, no second sentence about what an
|
| 1243 |
+
// automation is (R13 / DESIGN.md Β§4): the person reading a column menu is deciding whether to
|
| 1244 |
+
// type in it, and "filled by automation" is the whole answer.
|
| 1245 |
+
const typeLineBase = isMachineOwned(field)
|
| 1246 |
+
? "Filled by automation"
|
| 1247 |
+
: field.measure
|
| 1248 |
? "Metric, read-only"
|
| 1249 |
: field.type === "formula"
|
| 1250 |
? "Formula field, computed"
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import type { MeasureSets } from "./useVisibleRows";
|
|
| 33 |
import { useGetCellContent } from "./useGetCellContent";
|
| 34 |
import { useGridSelection } from "./useGridSelection";
|
| 35 |
import Toolbar from "./Toolbar";
|
|
|
|
| 36 |
import RecordDetail from "./RecordDetail";
|
| 37 |
import ViewSidebar from "./ViewSidebar";
|
| 38 |
import ColumnMenu from "./ColumnMenu";
|
|
@@ -537,6 +538,14 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 537 |
setPicker(null);
|
| 538 |
requestAnimationFrame(() => gridRef.current?.focus());
|
| 539 |
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
/** WAVE 21 item 11 (R10) β is "Select records from a list" open? Opened from the view
|
| 541 |
* rail's "β¦" and closed by the dialog; the SELECTION it produces outlives it. */
|
| 542 |
const [selectFromFile, setSelectFromFile] = useState(false);
|
|
@@ -1910,6 +1919,18 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 1910 |
});
|
| 1911 |
return;
|
| 1912 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1913 |
// Wave-5 item 11 β a URL cell opens its link on click (scheme-guarded: http/https only,
|
| 1914 |
// a bare domain gets https://). Editing stays with glide's overlay (Enter/double-click).
|
| 1915 |
if (row.kind === "data" && field?.type === "url") {
|
|
@@ -3640,6 +3661,17 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 3640 |
onCalendarMetrics={setCalendarMetrics}
|
| 3641 |
/>
|
| 3642 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3643 |
const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined;
|
| 3644 |
// A `user` field's choices come from the HOST's real user list, a `select`'s from its own
|
| 3645 |
// definition β so an assignee is always someone who can log in, and a status is always one of
|
|
@@ -4691,6 +4723,21 @@ function CustomerGrid({ scope = "customer" }: { scope?: SurfaceScope } = {}) {
|
|
| 4691 |
</AnchoredOverlay>
|
| 4692 |
)}
|
| 4693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4694 |
{pickerField && picker && (
|
| 4695 |
<AnchoredOverlay
|
| 4696 |
anchor={picker.anchor}
|
|
|
|
| 33 |
import { useGetCellContent } from "./useGetCellContent";
|
| 34 |
import { useGridSelection } from "./useGridSelection";
|
| 35 |
import Toolbar from "./Toolbar";
|
| 36 |
+
import JsonViewer from "./JsonViewer";
|
| 37 |
import RecordDetail from "./RecordDetail";
|
| 38 |
import ViewSidebar from "./ViewSidebar";
|
| 39 |
import ColumnMenu from "./ColumnMenu";
|
|
|
|
| 538 |
setPicker(null);
|
| 539 |
requestAnimationFrame(() => gridRef.current?.focus());
|
| 540 |
}, []);
|
| 541 |
+
/** β Wave-23 C7 β which `json` cell the big viewer is open on. NOT anchored like the picker
|
| 542 |
+
* above: a document is not a choice list, so it opens as a centred modal (the record drawer's
|
| 543 |
+
* surface) rather than a popover the size of the cell it came from. */
|
| 544 |
+
const [jsonAt, setJsonAt] = useState<{ pid: number; fieldKey: string } | null>(null);
|
| 545 |
+
const closeJson = useCallback(() => {
|
| 546 |
+
setJsonAt(null);
|
| 547 |
+
requestAnimationFrame(() => gridRef.current?.focus());
|
| 548 |
+
}, []);
|
| 549 |
/** WAVE 21 item 11 (R10) β is "Select records from a list" open? Opened from the view
|
| 550 |
* rail's "β¦" and closed by the dialog; the SELECTION it produces outlives it. */
|
| 551 |
const [selectFromFile, setSelectFromFile] = useState(false);
|
|
|
|
| 1919 |
});
|
| 1920 |
return;
|
| 1921 |
}
|
| 1922 |
+
// β Wave-23 C7 β a JSON cell opens the big viewer. It is the ONLY door: the cell carries
|
| 1923 |
+
// `allowOverlay:false`, because glide's overlay is a one-line box and one keystroke in the
|
| 1924 |
+
// wrong place inside a 32 KB document turns a well-formed payload into an unparseable one,
|
| 1925 |
+
// saved. Opened for EVERY reader (a document you may not edit is still one you must be
|
| 1926 |
+
// able to read) β the viewer takes `onSave` only when the permission verdict allows it,
|
| 1927 |
+
// and the host's write wall is the real one either way.
|
| 1928 |
+
if (row.kind === "data" && field?.type === "json") {
|
| 1929 |
+
event.preventDefault();
|
| 1930 |
+
setActiveCell(cell[0], cell[1]);
|
| 1931 |
+
setJsonAt({ pid: row.record.pid, fieldKey: field.key });
|
| 1932 |
+
return;
|
| 1933 |
+
}
|
| 1934 |
// Wave-5 item 11 β a URL cell opens its link on click (scheme-guarded: http/https only,
|
| 1935 |
// a bare domain gets https://). Editing stays with glide's overlay (Enter/double-click).
|
| 1936 |
if (row.kind === "data" && field?.type === "url") {
|
|
|
|
| 3661 |
onCalendarMetrics={setCalendarMetrics}
|
| 3662 |
/>
|
| 3663 |
);
|
| 3664 |
+
// β Wave-23 C7 β the open document, resolved exactly like the picker's value below it:
|
| 3665 |
+
// the OPTIMISTIC edit wins over the raw record, so a save the server has not echoed yet is
|
| 3666 |
+
// what re-opening the cell shows (the echo-suppression law β reading the raw row here would
|
| 3667 |
+
// make a just-saved document appear to revert).
|
| 3668 |
+
const jsonField = jsonAt ? fieldByKey.get(jsonAt.fieldKey) : undefined;
|
| 3669 |
+
const jsonRow = jsonAt ? rawRows.find((r) => r.pid === jsonAt.pid) : undefined;
|
| 3670 |
+
const jsonValue = String(
|
| 3671 |
+
(jsonAt && overlayEdits[jsonAt.pid]?.[jsonAt.fieldKey])
|
| 3672 |
+
?? (jsonField && jsonRow ? jsonRow[jsonField.key] : "")
|
| 3673 |
+
?? ""
|
| 3674 |
+
);
|
| 3675 |
const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined;
|
| 3676 |
// A `user` field's choices come from the HOST's real user list, a `select`'s from its own
|
| 3677 |
// definition β so an assignee is always someone who can log in, and a status is always one of
|
|
|
|
| 4723 |
</AnchoredOverlay>
|
| 4724 |
)}
|
| 4725 |
|
| 4726 |
+
{/* β Wave-23 C7 β the document viewer. `onSave` is OMITTED, not disabled, when the reader
|
| 4727 |
+
may not edit: an absent handler is what makes the raw tab a `<pre>` instead of a
|
| 4728 |
+
textarea, so there is no editor to be refused by. */}
|
| 4729 |
+
{jsonField && jsonAt && (
|
| 4730 |
+
<JsonViewer
|
| 4731 |
+
label={jsonField.label}
|
| 4732 |
+
value={jsonValue}
|
| 4733 |
+
onSave={
|
| 4734 |
+
canEditField(jsonField)
|
| 4735 |
+
? (next) => patchAndRecord(jsonAt.pid, { [jsonField.key]: next }, "a document")
|
| 4736 |
+
: undefined
|
| 4737 |
+
}
|
| 4738 |
+
onClose={closeJson}
|
| 4739 |
+
/>
|
| 4740 |
+
)}
|
| 4741 |
{pickerField && picker && (
|
| 4742 |
<AnchoredOverlay
|
| 4743 |
anchor={picker.anchor}
|
web/src/customer-grid/JsonViewer.tsx
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / JsonViewer.tsx β β wave-23 C7 (owner item 5 / ruling R5).
|
| 3 |
+
//
|
| 4 |
+
// THE BIG VIEWER a json cell opens on click. One component, reached from two
|
| 5 |
+
// places (the grid cell's click path and the record drawer's value slot), for
|
| 6 |
+
// the reason `assetUrl` is one resolver: two readers of the same document that
|
| 7 |
+
// disagree about how it folds is a bug nobody can see in either one alone.
|
| 8 |
+
//
|
| 9 |
+
// The document is a STRING at rest (the scalar Row contract β see the `json`
|
| 10 |
+
// note in types.ts), so everything here is text in and text out. Nothing in
|
| 11 |
+
// this file ever writes a parsed OBJECT back.
|
| 12 |
+
//
|
| 13 |
+
// THREE decisions worth stating, because each has a plausible alternative:
|
| 14 |
+
//
|
| 15 |
+
// 1. THE RAW TAB IS THE EDITOR, and there is no field-by-field one. A form
|
| 16 |
+
// over a document whose shape nobody declared would have to invent that
|
| 17 |
+
// shape from the current value β so adding a key would mean editing the
|
| 18 |
+
// form's idea of the schema, and the first machine write with a different
|
| 19 |
+
// shape would silently drop what it did not recognise.
|
| 20 |
+
// 2. PARSE ON SAVE, REFUSE WITH A SENTENCE. Never repair, never re-indent on
|
| 21 |
+
// the way in: `{a:1}` is not JSON and quietly "fixing" it to `{"a":1}`
|
| 22 |
+
// teaches a person their typo was fine, until the day a value cannot be
|
| 23 |
+
// guessed. The host applies the same rule at the write wall (C7), so a
|
| 24 |
+
// refusal here says the same thing the server would.
|
| 25 |
+
// 3. THE TREE IS READ-ONLY and collapses by DEPTH, not by hand-kept state per
|
| 26 |
+
// node beyond what the reader opens. A 200-key payload that arrives fully
|
| 27 |
+
// expanded is a scroll bar, not a document.
|
| 28 |
+
// ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
| 31 |
+
import type { ReactNode } from "react";
|
| 32 |
+
import { BodyPortal, useOverlayLayer } from "./OverlaySurface";
|
| 33 |
+
import { jsonParse, jsonPretty, MAX_JSON_BYTES } from "./display";
|
| 34 |
+
|
| 35 |
+
/** Bytes, not characters β the host's cap is on the stored UTF-8, and a
|
| 36 |
+
* document of 20,000 emoji is 80 KB. Counting `length` would let a value
|
| 37 |
+
* through here that the server then refuses, which is the one refusal a user
|
| 38 |
+
* cannot act on (nothing on screen said it was too big). */
|
| 39 |
+
export function jsonByteLength(text: string): number {
|
| 40 |
+
return typeof TextEncoder === "undefined"
|
| 41 |
+
? text.length
|
| 42 |
+
: new TextEncoder().encode(text).length;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/** The one-line summary in the modal's header: what this document IS. */
|
| 46 |
+
export function jsonSummary(text: string): string {
|
| 47 |
+
const parsed = jsonParse(text);
|
| 48 |
+
const bytes = jsonByteLength(text);
|
| 49 |
+
const size = bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} KB`;
|
| 50 |
+
if (String(text ?? "").trim() === "") return "Empty";
|
| 51 |
+
if (!parsed.ok) return `Not valid JSON Β· ${size}`;
|
| 52 |
+
const v = parsed.value;
|
| 53 |
+
if (Array.isArray(v)) return `${v.length} item${v.length === 1 ? "" : "s"} Β· ${size}`;
|
| 54 |
+
if (v !== null && typeof v === "object") {
|
| 55 |
+
const n = Object.keys(v as object).length;
|
| 56 |
+
return `${n} key${n === 1 ? "" : "s"} Β· ${size}`;
|
| 57 |
+
}
|
| 58 |
+
return `${v === null ? "null" : typeof v} Β· ${size}`;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// --- the tree ---------------------------------------------------------------
|
| 62 |
+
|
| 63 |
+
/** How deep a node is open when the viewer mounts. Two levels shows the shape
|
| 64 |
+
* of every payload this product actually receives (an object of objects) and
|
| 65 |
+
* stops before the leaf spam of a scraped array. */
|
| 66 |
+
const OPEN_TO_DEPTH = 2;
|
| 67 |
+
|
| 68 |
+
function isBranch(v: unknown): v is Record<string, unknown> | unknown[] {
|
| 69 |
+
return v !== null && typeof v === "object";
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
/** The value's own text, in its own ink class. Strings keep their quotes: a
|
| 73 |
+
* bare `12` and a `"12"` are different documents and the tree is where that
|
| 74 |
+
* difference has to be visible. */
|
| 75 |
+
function Leaf({ v }: { v: unknown }): ReactNode {
|
| 76 |
+
if (v === null) return <span className="cg-json-null">null</span>;
|
| 77 |
+
if (typeof v === "string") return <span className="cg-json-str">"{v}"</span>;
|
| 78 |
+
if (typeof v === "number") return <span className="cg-json-num">{String(v)}</span>;
|
| 79 |
+
if (typeof v === "boolean") return <span className="cg-json-bool">{String(v)}</span>;
|
| 80 |
+
return <span className="cg-json-str">{String(v)}</span>;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function Node({
|
| 84 |
+
name,
|
| 85 |
+
value,
|
| 86 |
+
depth,
|
| 87 |
+
last,
|
| 88 |
+
}: {
|
| 89 |
+
name: string | null;
|
| 90 |
+
value: unknown;
|
| 91 |
+
depth: number;
|
| 92 |
+
last: boolean;
|
| 93 |
+
}) {
|
| 94 |
+
const [open, setOpen] = useState(depth < OPEN_TO_DEPTH);
|
| 95 |
+
if (!isBranch(value))
|
| 96 |
+
return (
|
| 97 |
+
<div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
|
| 98 |
+
{name !== null && <span className="cg-json-key">{name}</span>}
|
| 99 |
+
{name !== null && <span className="cg-json-punct">: </span>}
|
| 100 |
+
<Leaf v={value} />
|
| 101 |
+
{!last && <span className="cg-json-punct">,</span>}
|
| 102 |
+
</div>
|
| 103 |
+
);
|
| 104 |
+
|
| 105 |
+
const array = Array.isArray(value);
|
| 106 |
+
const entries: [string, unknown][] = array
|
| 107 |
+
? (value as unknown[]).map((v, i) => [String(i), v])
|
| 108 |
+
: Object.entries(value as Record<string, unknown>);
|
| 109 |
+
const openMark = array ? "[" : "{";
|
| 110 |
+
const closeMark = array ? "]" : "}";
|
| 111 |
+
|
| 112 |
+
return (
|
| 113 |
+
<>
|
| 114 |
+
<div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
|
| 115 |
+
<button
|
| 116 |
+
type="button"
|
| 117 |
+
className={"cg-json-twist" + (open ? " is-open" : "")}
|
| 118 |
+
onClick={() => setOpen((o) => !o)}
|
| 119 |
+
aria-expanded={open}
|
| 120 |
+
aria-label={`${open ? "Collapse" : "Expand"} ${name ?? "the document"}`}
|
| 121 |
+
>
|
| 122 |
+
<svg viewBox="0 0 16 16" width="10" height="10" aria-hidden>
|
| 123 |
+
<path
|
| 124 |
+
d="M6 4l4 4-4 4"
|
| 125 |
+
fill="none"
|
| 126 |
+
stroke="currentColor"
|
| 127 |
+
strokeWidth="1.6"
|
| 128 |
+
strokeLinecap="round"
|
| 129 |
+
strokeLinejoin="round"
|
| 130 |
+
/>
|
| 131 |
+
</svg>
|
| 132 |
+
</button>
|
| 133 |
+
{name !== null && <span className="cg-json-key">{name}</span>}
|
| 134 |
+
{name !== null && <span className="cg-json-punct">: </span>}
|
| 135 |
+
<span className="cg-json-punct">{openMark}</span>
|
| 136 |
+
{/* Collapsed, the count IS the content β a bare `{β¦}` tells a reader
|
| 137 |
+
nothing about whether opening it is worth the click. */}
|
| 138 |
+
{!open && (
|
| 139 |
+
<span className="cg-json-count">
|
| 140 |
+
{entries.length} {array ? (entries.length === 1 ? "item" : "items")
|
| 141 |
+
: entries.length === 1 ? "key" : "keys"}
|
| 142 |
+
</span>
|
| 143 |
+
)}
|
| 144 |
+
{!open && <span className="cg-json-punct">{closeMark}{last ? "" : ","}</span>}
|
| 145 |
+
</div>
|
| 146 |
+
{open &&
|
| 147 |
+
entries.map(([k, v], i) => (
|
| 148 |
+
<Node
|
| 149 |
+
key={k}
|
| 150 |
+
name={array ? null : k}
|
| 151 |
+
value={v}
|
| 152 |
+
depth={depth + 1}
|
| 153 |
+
last={i === entries.length - 1}
|
| 154 |
+
/>
|
| 155 |
+
))}
|
| 156 |
+
{open && (
|
| 157 |
+
<div className="cg-json-row" style={{ paddingLeft: depth * 14 }}>
|
| 158 |
+
<span className="cg-json-punct">
|
| 159 |
+
{closeMark}
|
| 160 |
+
{last ? "" : ","}
|
| 161 |
+
</span>
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
+
</>
|
| 165 |
+
);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
// --- the modal --------------------------------------------------------------
|
| 169 |
+
|
| 170 |
+
export interface JsonViewerProps {
|
| 171 |
+
/** The column's own label β the modal's title. */
|
| 172 |
+
label: string;
|
| 173 |
+
/** The stored document, as a string. */
|
| 174 |
+
value: string;
|
| 175 |
+
/** Absent = read-only (the field is machine-owned, Odoo-sourced, or this
|
| 176 |
+
* viewer permits the reader nothing). Present = the raw tab is an editor. */
|
| 177 |
+
onSave?: (next: string) => void;
|
| 178 |
+
onClose: () => void;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
export default function JsonViewer({ label, value, onSave, onClose }: JsonViewerProps) {
|
| 182 |
+
const panelRef = useRef<HTMLDivElement>(null);
|
| 183 |
+
const titleId = useId();
|
| 184 |
+
const tabsId = useId();
|
| 185 |
+
const [tab, setTab] = useState<"tree" | "raw">("tree");
|
| 186 |
+
const [draft, setDraft] = useState(value);
|
| 187 |
+
const [error, setError] = useState("");
|
| 188 |
+
const [copied, setCopied] = useState(false);
|
| 189 |
+
|
| 190 |
+
// β The draft re-seeds when the CELL changes underneath (a machine write
|
| 191 |
+
// landing while the viewer is open), and only then β keying on `value` alone
|
| 192 |
+
// would also stamp on every keystroke's re-render if a parent echoed it back.
|
| 193 |
+
useEffect(() => {
|
| 194 |
+
setDraft(value);
|
| 195 |
+
setError("");
|
| 196 |
+
}, [value]);
|
| 197 |
+
|
| 198 |
+
useOverlayLayer({
|
| 199 |
+
panelRef,
|
| 200 |
+
onDismiss: onClose,
|
| 201 |
+
dismissOnOutside: true,
|
| 202 |
+
initialFocus: "[data-overlay-autofocus]",
|
| 203 |
+
trapFocus: true,
|
| 204 |
+
});
|
| 205 |
+
|
| 206 |
+
const parsed = useMemo(() => jsonParse(draft), [draft]);
|
| 207 |
+
const dirty = draft !== value;
|
| 208 |
+
|
| 209 |
+
const save = () => {
|
| 210 |
+
const text = draft.trim();
|
| 211 |
+
// Blank is a legal edit: it clears the cell, which is how a document is
|
| 212 |
+
// removed. Everything else must parse before it is allowed to leave here.
|
| 213 |
+
if (text !== "") {
|
| 214 |
+
if (!jsonParse(text).ok) {
|
| 215 |
+
setError("That is not valid JSON β nothing was saved. Check for a trailing comma, a "
|
| 216 |
+
+ "single quote, or an unquoted key.");
|
| 217 |
+
return;
|
| 218 |
+
}
|
| 219 |
+
const bytes = jsonByteLength(text);
|
| 220 |
+
if (bytes > MAX_JSON_BYTES) {
|
| 221 |
+
setError(`That document is ${(bytes / 1024).toFixed(1)} KB and the limit is 32 KB β `
|
| 222 |
+
+ "nothing was saved.");
|
| 223 |
+
return;
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
setError("");
|
| 227 |
+
onSave?.(text);
|
| 228 |
+
onClose();
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
const copy = () => {
|
| 232 |
+
void navigator.clipboard?.writeText(draft).then(
|
| 233 |
+
() => {
|
| 234 |
+
setCopied(true);
|
| 235 |
+
window.setTimeout(() => setCopied(false), 1400);
|
| 236 |
+
},
|
| 237 |
+
() => setError("The browser refused clipboard access β select the raw text and copy it.")
|
| 238 |
+
);
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
return (
|
| 242 |
+
<BodyPortal>
|
| 243 |
+
<div className="cg-record-backdrop">
|
| 244 |
+
<div
|
| 245 |
+
className="cg-json-modal"
|
| 246 |
+
ref={panelRef}
|
| 247 |
+
role="dialog"
|
| 248 |
+
aria-modal="true"
|
| 249 |
+
aria-labelledby={titleId}
|
| 250 |
+
data-overlay-kind="json-viewer"
|
| 251 |
+
tabIndex={-1}
|
| 252 |
+
>
|
| 253 |
+
<div className="cg-json-head">
|
| 254 |
+
<div>
|
| 255 |
+
<div className="cg-json-title" id={titleId}>{label}</div>
|
| 256 |
+
{/* The summary is the ONE explanatory line (DESIGN.md Β§4): what
|
| 257 |
+
this document is, not what a JSON field is for. */}
|
| 258 |
+
<div className="cg-json-sub">{jsonSummary(draft)}</div>
|
| 259 |
+
</div>
|
| 260 |
+
<div className="cg-json-headacts">
|
| 261 |
+
<button type="button" className="cg-json-btn" onClick={copy}>
|
| 262 |
+
{copied ? "Copied" : "Copy"}
|
| 263 |
+
</button>
|
| 264 |
+
<button
|
| 265 |
+
type="button"
|
| 266 |
+
className="cg-icon-btn"
|
| 267 |
+
onClick={onClose}
|
| 268 |
+
aria-label="Close viewer"
|
| 269 |
+
>
|
| 270 |
+
Γ
|
| 271 |
+
</button>
|
| 272 |
+
</div>
|
| 273 |
+
</div>
|
| 274 |
+
|
| 275 |
+
<div className="cg-record-tabs" role="tablist" aria-label="Document view">
|
| 276 |
+
{(["tree", "raw"] as const).map((k) => (
|
| 277 |
+
<button
|
| 278 |
+
key={k}
|
| 279 |
+
type="button"
|
| 280 |
+
role="tab"
|
| 281 |
+
id={`${tabsId}-${k}`}
|
| 282 |
+
aria-selected={tab === k}
|
| 283 |
+
aria-controls={`${tabsId}-panel`}
|
| 284 |
+
className={"cg-record-tab" + (tab === k ? " is-on" : "")}
|
| 285 |
+
onClick={() => setTab(k)}
|
| 286 |
+
data-overlay-autofocus={k === "tree" ? true : undefined}
|
| 287 |
+
>
|
| 288 |
+
{k === "tree" ? "Tree" : "Raw"}
|
| 289 |
+
</button>
|
| 290 |
+
))}
|
| 291 |
+
</div>
|
| 292 |
+
|
| 293 |
+
<div
|
| 294 |
+
className="cg-json-body"
|
| 295 |
+
id={`${tabsId}-panel`}
|
| 296 |
+
role="tabpanel"
|
| 297 |
+
aria-labelledby={`${tabsId}-${tab}`}
|
| 298 |
+
>
|
| 299 |
+
{tab === "tree" ? (
|
| 300 |
+
parsed.ok ? (
|
| 301 |
+
<div className="cg-json-tree">
|
| 302 |
+
<Node name={null} value={parsed.value} depth={0} last />
|
| 303 |
+
</div>
|
| 304 |
+
) : (
|
| 305 |
+
// β NOT an empty tree and NOT a repaired one. A document the app
|
| 306 |
+
// cannot open is a fact about the value, and the raw tab is where
|
| 307 |
+
// it gets read β so the empty state POINTS THERE rather than
|
| 308 |
+
// describing what JSON is.
|
| 309 |
+
<p className="cg-json-empty">
|
| 310 |
+
{draft.trim() === ""
|
| 311 |
+
? "Nothing here yet."
|
| 312 |
+
: "This value is not valid JSON β read it on the Raw tab."}
|
| 313 |
+
</p>
|
| 314 |
+
)
|
| 315 |
+
) : onSave ? (
|
| 316 |
+
<textarea
|
| 317 |
+
className="cg-json-raw"
|
| 318 |
+
value={draft}
|
| 319 |
+
spellCheck={false}
|
| 320 |
+
onChange={(e) => {
|
| 321 |
+
setDraft(e.target.value);
|
| 322 |
+
if (error) setError("");
|
| 323 |
+
}}
|
| 324 |
+
aria-label={`${label} raw JSON`}
|
| 325 |
+
/>
|
| 326 |
+
) : (
|
| 327 |
+
<pre className="cg-json-raw cg-json-raw--ro">{jsonPretty(draft)}</pre>
|
| 328 |
+
)}
|
| 329 |
+
</div>
|
| 330 |
+
|
| 331 |
+
{(error || (onSave && dirty)) && (
|
| 332 |
+
<div className="cg-json-foot">
|
| 333 |
+
{error ? (
|
| 334 |
+
<span className="cg-json-err">{error}</span>
|
| 335 |
+
) : (
|
| 336 |
+
<span className="cg-json-hint">Unsaved changes</span>
|
| 337 |
+
)}
|
| 338 |
+
{onSave && (
|
| 339 |
+
<span className="cg-json-footacts">
|
| 340 |
+
<button
|
| 341 |
+
type="button"
|
| 342 |
+
className="cg-json-btn"
|
| 343 |
+
onClick={() => {
|
| 344 |
+
setDraft(value);
|
| 345 |
+
setError("");
|
| 346 |
+
}}
|
| 347 |
+
>
|
| 348 |
+
Revert
|
| 349 |
+
</button>
|
| 350 |
+
<button type="button" className="cg-json-btn cg-json-btn--primary" onClick={save}>
|
| 351 |
+
Save
|
| 352 |
+
</button>
|
| 353 |
+
</span>
|
| 354 |
+
)}
|
| 355 |
+
</div>
|
| 356 |
+
)}
|
| 357 |
+
</div>
|
| 358 |
+
</div>
|
| 359 |
+
</BodyPortal>
|
| 360 |
+
);
|
| 361 |
+
}
|
web/src/customer-grid/RecordDetail.css
CHANGED
|
@@ -146,6 +146,25 @@
|
|
| 146 |
line-height: 1.45;
|
| 147 |
}
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
/* A rating's marks are 18px inline SVG; on the text baseline they hang their
|
| 150 |
descender space below the line box and grow the slot past every neighbour. */
|
| 151 |
.cg-record-main .cg-detail-value .cg-stars {
|
|
@@ -451,6 +470,33 @@
|
|
| 451 |
font-size: var(--lp-fs-2xs);
|
| 452 |
}
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
/* Rating: the stars ARE the picker. Clicking the lit star clears the value β
|
| 455 |
without it a rating could be changed here but never removed. */
|
| 456 |
.cg-record-main .cg-detail-stars {
|
|
|
|
| 146 |
line-height: 1.45;
|
| 147 |
}
|
| 148 |
|
| 149 |
+
/* β Wave-23 C8 (owner item 4 / ruling R9) β a MACHINE-OWNED field's value slot.
|
| 150 |
+
|
| 151 |
+
`--lp-machine-tint` is EXACTLY what the canvas wash (`theme.MACHINE_CELL`,
|
| 152 |
+
`washOf(LP_MACHINE_DEEP, 0.12)`) composites to over white, so the drawer slot
|
| 153 |
+
and the grid cell are one grey rather than two that were each picked to look
|
| 154 |
+
right on their own β the sibling-panel rule (DESIGN.md Β§2) applied across two
|
| 155 |
+
rendering technologies instead of two panes.
|
| 156 |
+
|
| 157 |
+
THE VALUE KEEPS ITS INK, deliberately, on both surfaces. The wash is the whole
|
| 158 |
+
signal; dimming the number as well would say the figure matters less, and an
|
| 159 |
+
automation-written follower count is the entire reason its column exists.
|
| 160 |
+
|
| 161 |
+
Background only β no border change, no second marker. The column menu already
|
| 162 |
+
says "Filled by automation" once, and DESIGN.md Β§4 is that the app does not
|
| 163 |
+
narrate itself twice. */
|
| 164 |
+
.cg-record-main .cg-detail-field.is-machine .cg-detail-value {
|
| 165 |
+
background: var(--lp-machine-tint);
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
/* A rating's marks are 18px inline SVG; on the text baseline they hang their
|
| 169 |
descender space below the line box and grow the slot past every neighbour. */
|
| 170 |
.cg-record-main .cg-detail-value .cg-stars {
|
|
|
|
| 470 |
font-size: var(--lp-fs-2xs);
|
| 471 |
}
|
| 472 |
|
| 473 |
+
/* β Wave-23 C7 (owner item 5) β a `json` field's row: the same compact preview the grid cell
|
| 474 |
+
paints, plus the door to the viewer. Reuses `.cg-detail-imagebtn` rather than minting a
|
| 475 |
+
second quiet-button shape (DESIGN.md Β§4) β the two rows are doing the same job, showing a
|
| 476 |
+
value too big for a slot and offering the surface that owns it. */
|
| 477 |
+
.cg-record-main .cg-detail-jsonslot {
|
| 478 |
+
display: flex;
|
| 479 |
+
align-items: center;
|
| 480 |
+
justify-content: space-between;
|
| 481 |
+
gap: 10px;
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
/* β `min-width: 0` is what lets the ellipsis happen at all: a flex item's default
|
| 485 |
+
`min-width: auto` refuses to shrink below its content, so the preview would push the Open
|
| 486 |
+
button out of the panel instead of truncating. Monospace because the preview is a fragment
|
| 487 |
+
of a document β `{handle: "royalimports"}` reads as code, and setting it in Inter beside the
|
| 488 |
+
viewer's monospace tree would make one value look like two different things. */
|
| 489 |
+
.cg-record-main .cg-detail-jsonpreview {
|
| 490 |
+
flex: 1 1 auto;
|
| 491 |
+
min-width: 0;
|
| 492 |
+
overflow: hidden;
|
| 493 |
+
text-overflow: ellipsis;
|
| 494 |
+
white-space: nowrap;
|
| 495 |
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
| 496 |
+
font-size: var(--lp-fs-2xs);
|
| 497 |
+
color: var(--lp-ink);
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
/* Rating: the stars ARE the picker. Clicking the lit star clears the value β
|
| 501 |
without it a rating could be changed here but never removed. */
|
| 502 |
.cg-record-main .cg-detail-stars {
|
web/src/customer-grid/RecordDetail.tsx
CHANGED
|
@@ -7,9 +7,11 @@ import {
|
|
| 7 |
automationStateLabel,
|
| 8 |
checkboxOn,
|
| 9 |
formatDisplay,
|
|
|
|
| 10 |
} from "./cells";
|
|
|
|
| 11 |
import { StarRow, StarIcon } from "./Stars";
|
| 12 |
-
import { isPickType, mayEditField, ratingMax } from "./types";
|
| 13 |
import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
|
| 14 |
// Item 9 (wave 14) β the pure value round-trip. Everything that could silently
|
| 15 |
// LOSE a stored value moving it into an editor and back lives there, gated by
|
|
@@ -269,6 +271,10 @@ export default function RecordDetail({
|
|
| 269 |
// reachable second case.
|
| 270 |
const [imageBusy, setImageBusy] = useState("");
|
| 271 |
const [imageError, setImageError] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
const overlayFields = fields.filter((field) => field.source === "overlay");
|
| 273 |
// β These five buckets are the DEFAULT ORDER of the interleaved list (C-LAYOUT) and NOTHING
|
| 274 |
// ELSE. They used to double as the editability split β picked/rating fields rendered
|
|
@@ -475,12 +481,24 @@ export default function RecordDetail({
|
|
| 475 |
// somebody to type `rec:` by hand at a picture that does not exist. Its slot is a
|
| 476 |
// thumbnail plus an upload control, so it joins the constrained-control family.
|
| 477 |
field.type !== "image" &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
field.type !== "pct";
|
| 479 |
const isControl = editable && !isEdit;
|
| 480 |
const cls =
|
| 481 |
"cg-detail-field" +
|
| 482 |
(isEdit ? " cg-detail-field--edit" : "") +
|
| 483 |
(isControl ? " cg-detail-field--control" : "") +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
(dropKey === field.key ? " is-dropzone" : "") +
|
| 485 |
(dragKey === field.key ? " is-dragging" : "");
|
| 486 |
|
|
@@ -802,6 +820,28 @@ export default function RecordDetail({
|
|
| 802 |
<StarRow value={ratingValue(field, record[field.key])} max={ratingMax(field)} />
|
| 803 |
</span>
|
| 804 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 805 |
} else if (field.type === "automation") {
|
| 806 |
// C5-AUTOFIELD (wave 18). It has to be its OWN branch and not the read-only fallback
|
| 807 |
// below, because that branch explains read-only-ness as a PERMISSION ("You do not have
|
|
@@ -1042,6 +1082,25 @@ export default function RecordDetail({
|
|
| 1042 |
</div>
|
| 1043 |
</div>
|
| 1044 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1045 |
</BodyPortal>
|
| 1046 |
);
|
| 1047 |
}
|
|
|
|
| 7 |
automationStateLabel,
|
| 8 |
checkboxOn,
|
| 9 |
formatDisplay,
|
| 10 |
+
jsonPreview,
|
| 11 |
} from "./cells";
|
| 12 |
+
import JsonViewer from "./JsonViewer";
|
| 13 |
import { StarRow, StarIcon } from "./Stars";
|
| 14 |
+
import { isMachineOwned, isPickType, mayEditField, ratingMax } from "./types";
|
| 15 |
import type { CustomerDoc, DisplaySpec, Field, Row, Viewer } from "./types";
|
| 16 |
// Item 9 (wave 14) β the pure value round-trip. Everything that could silently
|
| 17 |
// LOSE a stored value moving it into an editor and back lives there, gated by
|
|
|
|
| 271 |
// reachable second case.
|
| 272 |
const [imageBusy, setImageBusy] = useState("");
|
| 273 |
const [imageError, setImageError] = useState("");
|
| 274 |
+
/** β Wave-23 C7 β which json field's viewer is open, by KEY. One at a time for the same
|
| 275 |
+
* reason `imageBusy` is one pair: a record has a handful of document columns and only one
|
| 276 |
+
* modal can be on screen. */
|
| 277 |
+
const [jsonKey, setJsonKey] = useState<string | null>(null);
|
| 278 |
const overlayFields = fields.filter((field) => field.source === "overlay");
|
| 279 |
// β These five buckets are the DEFAULT ORDER of the interleaved list (C-LAYOUT) and NOTHING
|
| 280 |
// ELSE. They used to double as the editability split β picked/rating fields rendered
|
|
|
|
| 481 |
// somebody to type `rec:` by hand at a picture that does not exist. Its slot is a
|
| 482 |
// thumbnail plus an upload control, so it joins the constrained-control family.
|
| 483 |
field.type !== "image" &&
|
| 484 |
+
// β Wave-23 C7 β a `json` value is NEVER edited through a free textarea, here or in the
|
| 485 |
+
// grid. `AutoTextarea` commits on blur straight through `onNotesCommit`, so this branch
|
| 486 |
+
// would be a door that saves an unparseable document with no check at all β the exact
|
| 487 |
+
// thing the viewer's parse-on-save exists to prevent, sitting one panel away from it.
|
| 488 |
+
// It joins the constrained-control family: preview + "Open", and the viewer owns the edit.
|
| 489 |
+
field.type !== "json" &&
|
| 490 |
field.type !== "pct";
|
| 491 |
const isControl = editable && !isEdit;
|
| 492 |
const cls =
|
| 493 |
"cg-detail-field" +
|
| 494 |
(isEdit ? " cg-detail-field--edit" : "") +
|
| 495 |
(isControl ? " cg-detail-field--control" : "") +
|
| 496 |
+
// β Wave-23 C8 (R9) β the SAME claim the grid cell makes, on the DOM side. A machine-owned
|
| 497 |
+
// column is grey in the table; opening the record must not present the identical value in
|
| 498 |
+
// an ordinary editable-looking slot, because the drawer is where somebody would try to type
|
| 499 |
+
// in it. `RecordDetail.css` paints the pair (`--lp-machine-tint` background, the measured
|
| 500 |
+
// `--lp-machine-deep` ink) at the value slot, not the row, so the label keeps its rhythm.
|
| 501 |
+
(isMachineOwned(field) ? " is-machine" : "") +
|
| 502 |
(dropKey === field.key ? " is-dropzone" : "") +
|
| 503 |
(dragKey === field.key ? " is-dragging" : "");
|
| 504 |
|
|
|
|
| 820 |
<StarRow value={ratingValue(field, record[field.key])} max={ratingMax(field)} />
|
| 821 |
</span>
|
| 822 |
);
|
| 823 |
+
} else if (field.type === "json") {
|
| 824 |
+
// β Wave-23 C7 β the drawer shows the same compact preview the grid cell does and hands
|
| 825 |
+
// the document to the SAME viewer, because two readers of one document that fold it
|
| 826 |
+
// differently is a disagreement nobody can see in either one alone. The button is the
|
| 827 |
+
// whole affordance: a `<pre>` of 32 KB inside a field row would push every column below
|
| 828 |
+
// it off the panel.
|
| 829 |
+
const raw = rawText(record[field.key]);
|
| 830 |
+
slot = (
|
| 831 |
+
<span className="cg-detail-value cg-detail-jsonslot">
|
| 832 |
+
<span className="cg-detail-jsonpreview">{jsonPreview(raw) || "β"}</span>
|
| 833 |
+
<button
|
| 834 |
+
type="button"
|
| 835 |
+
className="cg-detail-imagebtn"
|
| 836 |
+
onClick={() => setJsonKey(field.key)}
|
| 837 |
+
>
|
| 838 |
+
{/* "Open" for a reader, "Open" for an editor β the viewer decides what it offers
|
| 839 |
+
once it is open, and a button that says "Edit" to somebody who then cannot would
|
| 840 |
+
be the painted-but-inert control the standing rule is about. */}
|
| 841 |
+
Open
|
| 842 |
+
</button>
|
| 843 |
+
</span>
|
| 844 |
+
);
|
| 845 |
} else if (field.type === "automation") {
|
| 846 |
// C5-AUTOFIELD (wave 18). It has to be its OWN branch and not the read-only fallback
|
| 847 |
// below, because that branch explains read-only-ness as a PERMISSION ("You do not have
|
|
|
|
| 1082 |
</div>
|
| 1083 |
</div>
|
| 1084 |
</div>
|
| 1085 |
+
{/* β Wave-23 C7 β the document viewer, INSIDE this portal and therefore stacked above
|
| 1086 |
+
this modal. `useOverlayLayer`'s stack is what makes that work: it registers on top, so
|
| 1087 |
+
Escape closes the viewer and leaves the record open rather than dismissing both. */}
|
| 1088 |
+
{jsonKey && (() => {
|
| 1089 |
+
const field = fields.find((f) => f.key === jsonKey);
|
| 1090 |
+
if (!field) return null;
|
| 1091 |
+
return (
|
| 1092 |
+
<JsonViewer
|
| 1093 |
+
label={field.label}
|
| 1094 |
+
value={rawText(record[field.key])}
|
| 1095 |
+
onSave={
|
| 1096 |
+
mayEditField(field, viewer)
|
| 1097 |
+
? (next) => onNotesCommit(field.key, next)
|
| 1098 |
+
: undefined
|
| 1099 |
+
}
|
| 1100 |
+
onClose={() => setJsonKey(null)}
|
| 1101 |
+
/>
|
| 1102 |
+
);
|
| 1103 |
+
})()}
|
| 1104 |
</BodyPortal>
|
| 1105 |
);
|
| 1106 |
}
|
web/src/customer-grid/cells.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type {
|
|
| 34 |
} from "@glideapps/glide-data-grid";
|
| 35 |
import { assetUrl } from "./catalogData";
|
| 36 |
import type { Field } from "./types";
|
| 37 |
-
import { ratingMax } from "./types";
|
| 38 |
import {
|
| 39 |
LP_BLUE_TEXT,
|
| 40 |
LP_BLUE_TINT,
|
|
@@ -44,6 +44,7 @@ import {
|
|
| 44 |
LP_RED_TINT,
|
| 45 |
LP_YELLOW_DEEP,
|
| 46 |
LP_YELLOW_TINT,
|
|
|
|
| 47 |
STATUS_BUBBLE,
|
| 48 |
} from "./theme";
|
| 49 |
// β ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
|
|
@@ -51,7 +52,7 @@ import {
|
|
| 51 |
// distinguishable at the call site rather than one silently shadowing the other.
|
| 52 |
import { optionTint, pickTint as choicePickTint } from "./choiceColors";
|
| 53 |
import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
|
| 54 |
-
formulaIsText, num, numberText, userCellPayload } from "./display";
|
| 55 |
import type { AutomationState, CellValue, UserCellData } from "./display";
|
| 56 |
|
| 57 |
// Wave-7 (item W2): the pure display-string half moved to display.ts so the
|
|
@@ -66,6 +67,9 @@ export type { UserCellData } from "./display";
|
|
| 66 |
// gate can hold it); only the canvas tint table below needs this module.
|
| 67 |
export { automationDetail, automationState, automationStateLabel } from "./display";
|
| 68 |
export type { AutomationState } from "./display";
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
/**
|
| 71 |
* Wave-18 C5-AUTOFIELD β the cell tint per automation state.
|
|
@@ -393,6 +397,37 @@ export function makeCell(
|
|
| 393 |
/** C-AVATAR β username β data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
|
| 394 |
* username absent from it, paints the initials fallback. */
|
| 395 |
userAvatars?: Record<string, string>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
): GridCell {
|
| 397 |
const ro = { allowOverlay: editable, readonly: !editable };
|
| 398 |
const blank = v == null || v === "";
|
|
@@ -508,6 +543,33 @@ export function makeCell(
|
|
| 508 |
allowOverlay: false,
|
| 509 |
};
|
| 510 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 511 |
case "rating": {
|
| 512 |
const max = ratingMax(field);
|
| 513 |
const n = Math.max(0, Math.min(max, Math.round(num(v))));
|
|
|
|
| 34 |
} from "@glideapps/glide-data-grid";
|
| 35 |
import { assetUrl } from "./catalogData";
|
| 36 |
import type { Field } from "./types";
|
| 37 |
+
import { isMachineOwned, ratingMax } from "./types";
|
| 38 |
import {
|
| 39 |
LP_BLUE_TEXT,
|
| 40 |
LP_BLUE_TINT,
|
|
|
|
| 44 |
LP_RED_TINT,
|
| 45 |
LP_YELLOW_DEEP,
|
| 46 |
LP_YELLOW_TINT,
|
| 47 |
+
machineCellTheme,
|
| 48 |
STATUS_BUBBLE,
|
| 49 |
} from "./theme";
|
| 50 |
// β ALIASED: this module has its own `pickTint` (the bubble table above) and choiceColors has
|
|
|
|
| 52 |
// distinguishable at the call site rather than one silently shadowing the other.
|
| 53 |
import { optionTint, pickTint as choicePickTint } from "./choiceColors";
|
| 54 |
import { automationState, avatarInitials, avatarSize, checkboxOn, dateTimeText, formulaIsBlank,
|
| 55 |
+
formulaIsText, jsonPreview, num, numberText, userCellPayload } from "./display";
|
| 56 |
import type { AutomationState, CellValue, UserCellData } from "./display";
|
| 57 |
|
| 58 |
// Wave-7 (item W2): the pure display-string half moved to display.ts so the
|
|
|
|
| 67 |
// gate can hold it); only the canvas tint table below needs this module.
|
| 68 |
export { automationDetail, automationState, automationStateLabel } from "./display";
|
| 69 |
export type { AutomationState } from "./display";
|
| 70 |
+
// Wave-23 C7 β same arrangement again: the parse, the preview and the pretty-printer are pure
|
| 71 |
+
// (`verify_grid_ux` drives them under node); only the canvas cell below needs this module.
|
| 72 |
+
export { jsonParse, jsonPretty, jsonPreview, MAX_JSON_BYTES } from "./display";
|
| 73 |
|
| 74 |
/**
|
| 75 |
* Wave-18 C5-AUTOFIELD β the cell tint per automation state.
|
|
|
|
| 397 |
/** C-AVATAR β username β data URL, straight off `GridWorkspace.userAvatars`. Absent, or a
|
| 398 |
* username absent from it, paints the initials fallback. */
|
| 399 |
userAvatars?: Record<string, string>
|
| 400 |
+
): GridCell {
|
| 401 |
+
const cell = baseCell(field, v, editable, userAvatars);
|
| 402 |
+
return isMachineOwned(field) ? withMachineWash(cell) : cell;
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
/**
|
| 406 |
+
* β Wave-23 C8 (owner item 4 / R9) β the grey wash on a MACHINE-OWNED cell.
|
| 407 |
+
*
|
| 408 |
+
* A per-CELL override rather than a per-column one (`useGridColumns.columnTheme`), following the
|
| 409 |
+
* `AUTOMATION_TINT` precedent directly below: the column path returns one of three SHARED
|
| 410 |
+
* `COLUMN_TONE_THEME` references, so forking it per column would both break that sharing and put
|
| 411 |
+
* the wash on the HEADER, which R9 does not ask for β an involved column's filter/sort/group tone
|
| 412 |
+
* keeps winning its header.
|
| 413 |
+
*
|
| 414 |
+
* The PRECEDENCE (state tint over wash, bubble tint alongside it) is `theme.machineCellTheme` β
|
| 415 |
+
* it lives there because this module imports glide and therefore cannot be driven by a gate,
|
| 416 |
+
* and precedence is the half of this that can be silently wrong. Same split as `display.ts`.
|
| 417 |
+
*
|
| 418 |
+
* β Re-built rather than mutated: glide declares every cell key `readonly`, and `baseCell`
|
| 419 |
+
* returns a fresh literal on every branch anyway. The `as GridCell` is the union-spread widening,
|
| 420 |
+
* not a claim about the shape β the object is byte-identical to the branch's own plus one key.
|
| 421 |
+
*/
|
| 422 |
+
function withMachineWash(cell: GridCell): GridCell {
|
| 423 |
+
return { ...cell, themeOverride: machineCellTheme(cell.themeOverride) } as GridCell;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
function baseCell(
|
| 427 |
+
field: Field,
|
| 428 |
+
v: CellValue,
|
| 429 |
+
editable: boolean,
|
| 430 |
+
userAvatars?: Record<string, string>
|
| 431 |
): GridCell {
|
| 432 |
const ro = { allowOverlay: editable, readonly: !editable };
|
| 433 |
const blank = v == null || v === "";
|
|
|
|
| 543 |
allowOverlay: false,
|
| 544 |
};
|
| 545 |
}
|
| 546 |
+
case "json": {
|
| 547 |
+
// β Wave-23 C7 (owner item 5) β the compact preview; the DOCUMENT lives in the viewer.
|
| 548 |
+
//
|
| 549 |
+
// `allowOverlay: false` for the same reason `select` and `image` carry it: glide's text
|
| 550 |
+
// overlay is a one-line box, and a one-line box over a 32 KB document is an editor that
|
| 551 |
+
// can only damage the value β one keystroke in the wrong place and a well-formed payload
|
| 552 |
+
// becomes unparseable, saved. CustomerGrid opens the viewer on click instead (the same
|
| 553 |
+
// `onCellClicked` path the pickers use), and THAT is where the raw text is editable, with
|
| 554 |
+
// parse-on-save.
|
| 555 |
+
//
|
| 556 |
+
// β `readonly` is NOT set, and the difference matters: `allowOverlay:false` means "no
|
| 557 |
+
// inline editor", while `readonly` would tell glide the CELL cannot change β which would
|
| 558 |
+
// also block the paste path that legitimately writes a whole document into it.
|
| 559 |
+
//
|
| 560 |
+
// β `copyData` carries the RAW document, never the preview (the lesson the image cell
|
| 561 |
+
// booked in wave 19). Copy a json column and you get the payload; copy the preview and
|
| 562 |
+
// you get the sentence "{β¦} 5 keys", which is not data and cannot be pasted back.
|
| 563 |
+
const raw = String(v ?? "");
|
| 564 |
+
const text = jsonPreview(raw);
|
| 565 |
+
return {
|
| 566 |
+
kind: GridCellKind.Text,
|
| 567 |
+
data: text,
|
| 568 |
+
displayData: text,
|
| 569 |
+
copyData: raw,
|
| 570 |
+
allowOverlay: false,
|
| 571 |
+
};
|
| 572 |
+
}
|
| 573 |
case "rating": {
|
| 574 |
const max = ratingMax(field);
|
| 575 |
const n = Math.max(0, Math.min(max, Math.round(num(v))));
|
web/src/customer-grid/display.ts
CHANGED
|
@@ -228,6 +228,92 @@ export function automationStateLabel(v: CellValue): string {
|
|
| 228 |
return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
|
| 229 |
}
|
| 230 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
export function formatDisplay(field: Field, v: CellValue): string {
|
| 232 |
switch (field.type) {
|
| 233 |
case "currency":
|
|
@@ -261,6 +347,12 @@ export function formatDisplay(field: Field, v: CellValue): string {
|
|
| 261 |
const n = num(v);
|
| 262 |
return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
|
| 263 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
case "automation":
|
| 265 |
// The machine-written line, verbatim. Explicit rather than left to the `default` branch
|
| 266 |
// below: `formula` fell through a default once and printed every text result as `0` for
|
|
|
|
| 228 |
return s === "none" ? "Not run yet" : s[0].toUpperCase() + s.slice(1);
|
| 229 |
}
|
| 230 |
|
| 231 |
+
/**
|
| 232 |
+
* β Wave-23 C7 (owner item 5) β THE JSON PREVIEW: what a 200px cell says about a document.
|
| 233 |
+
*
|
| 234 |
+
* `MAX_JSON_BYTES` is the contract's ceiling, mirrored from the host's write validation so the
|
| 235 |
+
* viewer can refuse a paste with the same number the server would (a client that lets you type
|
| 236 |
+
* 40 KB and then shows you a server refusal has wasted the edit).
|
| 237 |
+
*
|
| 238 |
+
* The four cases, and each one is a decision rather than a formatting preference:
|
| 239 |
+
* Β· **a single-pair object shows THE PAIR.** `{handle: "royalimports"}` is more useful than
|
| 240 |
+
* "1 key" and it is the shape most machine writes actually have. Past one pair the pairs
|
| 241 |
+
* stop fitting and the honest answer is the count.
|
| 242 |
+
* Β· **many keys / many items β `{β¦} N keys` / `[β¦] N items`.** Showing the FIRST pair of a
|
| 243 |
+
* twelve-key object would let a reader take one arbitrary value β whichever key the writer's
|
| 244 |
+
* serializer happened to emit first β for the cell's content.
|
| 245 |
+
* Β· **a bare scalar renders as itself.** `12`, `"ok"`, `true` and `null` are all valid JSON
|
| 246 |
+
* documents, and wrapping them in braces would describe a shape they do not have.
|
| 247 |
+
* Β· β **text that does not parse renders AS ITSELF, never as a shape.** The host validates on
|
| 248 |
+
* write, so this only happens to a value that predates the validation or arrived another
|
| 249 |
+
* way β and the one thing the preview must never do is claim a document is well-formed. The
|
| 250 |
+
* viewer's raw tab is where such a value gets read and repaired.
|
| 251 |
+
*
|
| 252 |
+
* Blank stays blank: an empty json cell is a document nobody has written, and `{}` is a document
|
| 253 |
+
* somebody wrote that is empty. Two different facts, two different cells.
|
| 254 |
+
*/
|
| 255 |
+
export const MAX_JSON_BYTES = 32 * 1024;
|
| 256 |
+
|
| 257 |
+
/** Does this text parse as JSON? The ONE test, shared by the preview, the cell and the viewer's
|
| 258 |
+
* save β three copies of a try/catch is how they end up disagreeing about `""` or `NaN`. */
|
| 259 |
+
export function jsonParse(v: CellValue): { ok: boolean; value?: unknown } {
|
| 260 |
+
const s = String(v ?? "").trim();
|
| 261 |
+
if (s === "") return { ok: false };
|
| 262 |
+
try {
|
| 263 |
+
return { ok: true, value: JSON.parse(s) as unknown };
|
| 264 |
+
} catch {
|
| 265 |
+
return { ok: false };
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
/** One compact line for a json cell. See the note above for why each case reads as it does. */
|
| 270 |
+
export function jsonPreview(v: CellValue): string {
|
| 271 |
+
const raw = String(v ?? "").trim();
|
| 272 |
+
if (raw === "") return "";
|
| 273 |
+
const parsed = jsonParse(raw);
|
| 274 |
+
// β First line only, and clipped: an unparseable value is often a whole pasted response, and
|
| 275 |
+
// a cell is not where a 4 KB blob gets read. It is shown rather than hidden because the
|
| 276 |
+
// reader has to be able to see that the column holds something the app could not open.
|
| 277 |
+
if (!parsed.ok) return clip(raw.split("\n")[0], 60);
|
| 278 |
+
const value = parsed.value;
|
| 279 |
+
if (Array.isArray(value))
|
| 280 |
+
return value.length === 0 ? "[]" : `[β¦] ${value.length} item${value.length === 1 ? "" : "s"}`;
|
| 281 |
+
if (value !== null && typeof value === "object") {
|
| 282 |
+
const keys = Object.keys(value as Record<string, unknown>);
|
| 283 |
+
if (keys.length === 0) return "{}";
|
| 284 |
+
if (keys.length === 1)
|
| 285 |
+
return clip(`{${keys[0]}: ${scalarText((value as Record<string, unknown>)[keys[0]])}}`, 60);
|
| 286 |
+
return `{β¦} ${keys.length} keys`;
|
| 287 |
+
}
|
| 288 |
+
return clip(scalarText(value), 60);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
/** A nested value, small enough to sit inside a one-pair preview. Objects and arrays collapse
|
| 292 |
+
* to their own marks rather than recursing β a preview that unfolds is not a preview. */
|
| 293 |
+
function scalarText(v: unknown): string {
|
| 294 |
+
if (v === null) return "null";
|
| 295 |
+
if (Array.isArray(v)) return `[β¦] ${v.length}`;
|
| 296 |
+
if (typeof v === "object") return `{β¦} ${Object.keys(v as object).length}`;
|
| 297 |
+
return typeof v === "string" ? v : String(v);
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/** β An ellipsis CHARACTER, not three dots: the grid's canvas measures text and three periods
|
| 301 |
+
* are three glyphs wide. Same mark the group-bar fitter uses. */
|
| 302 |
+
function clip(s: string, n: number): string {
|
| 303 |
+
return s.length <= n ? s : s.slice(0, n - 1) + "β¦";
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
/**
|
| 307 |
+
* The document, indented for the viewer's pretty tab. Returns the RAW TEXT UNCHANGED when it
|
| 308 |
+
* does not parse β re-indenting is not repair, and handing a reader a "prettified" version of
|
| 309 |
+
* something the app could not read would hide the only thing they need to see.
|
| 310 |
+
*/
|
| 311 |
+
export function jsonPretty(v: CellValue): string {
|
| 312 |
+
const raw = String(v ?? "");
|
| 313 |
+
const parsed = jsonParse(raw);
|
| 314 |
+
return parsed.ok ? JSON.stringify(parsed.value, null, 2) : raw;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
export function formatDisplay(field: Field, v: CellValue): string {
|
| 318 |
switch (field.type) {
|
| 319 |
case "currency":
|
|
|
|
| 347 |
const n = num(v);
|
| 348 |
return n >= 1 ? `${Math.round(n)} of ${ratingMax(field)}` : "";
|
| 349 |
}
|
| 350 |
+
case "json":
|
| 351 |
+
// Wave-23 C7 β the SAME compact line the canvas cell paints. Explicit here rather than
|
| 352 |
+
// left to `default` for the reason the `formula` note above records: this function feeds
|
| 353 |
+
// ListView, KanbanView, the calendar, the record panel and all four EXPORT formats, and
|
| 354 |
+
// falling through would dump a whole 32 KB document into a CSV cell.
|
| 355 |
+
return jsonPreview(v);
|
| 356 |
case "automation":
|
| 357 |
// The machine-written line, verbatim. Explicit rather than left to the `default` branch
|
| 358 |
// below: `formula` fell through a default once and printed every text result as `0` for
|
web/src/customer-grid/iconShapes.ts
CHANGED
|
@@ -1,489 +1,524 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// customer-grid / iconShapes.ts
|
| 3 |
-
// Wave-8 items I18 + I20 β ONE geometry source for the grid's icon vocabulary,
|
| 4 |
-
// rendered by TWO very different painters:
|
| 5 |
-
//
|
| 6 |
-
// - React <FieldTypeIcon> / <ModeIcon> β DOM svg in panels, popovers, menus
|
| 7 |
-
// - glide headerIcons sprites β canvas, drawn from an SVG *string*
|
| 8 |
-
//
|
| 9 |
-
// Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
|
| 10 |
-
// never be a React component. Keeping the paths as DATA (IconShape[]) and giving
|
| 11 |
-
// each painter its own thin renderer is what stops the two from drifting β the
|
| 12 |
-
// alternative (hand-copying every path into a template literal) guarantees the
|
| 13 |
-
// header and the panel eventually disagree about what a "date" looks like.
|
| 14 |
-
//
|
| 15 |
-
// Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
|
| 16 |
-
// currentColor. Vector paths only β NEVER emoji (owner constant).
|
| 17 |
-
// ---------------------------------------------------------------------------
|
| 18 |
-
|
| 19 |
-
import type { DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
|
| 20 |
-
import {
|
| 21 |
-
LP_BLUE,
|
| 22 |
-
LP_BLUE_DEEP,
|
| 23 |
-
LP_GREEN,
|
| 24 |
-
LP_GREEN_DEEP,
|
| 25 |
-
LP_LINE,
|
| 26 |
-
LP_MUTED,
|
| 27 |
-
LP_RED,
|
| 28 |
-
LP_RED_DEEP,
|
| 29 |
-
LP_YELLOW,
|
| 30 |
-
LP_YELLOW_DEEP,
|
| 31 |
-
} from "./theme";
|
| 32 |
-
|
| 33 |
-
/** One drawing primitive. `fill: true` fills the path instead of stroking it
|
| 34 |
-
* (the rating star is the only shape that reads better solid). */
|
| 35 |
-
export type IconShape = { d: string; fill?: boolean };
|
| 36 |
-
|
| 37 |
-
/** A circle as a path β two half-arcs. Sprites are SVG *source*, so every shape
|
| 38 |
-
* has to survive being serialized into a string; paths do, <circle> elements
|
| 39 |
-
* would need a second serializer branch for no benefit. */
|
| 40 |
-
const circle = (cx: number, cy: number, r: number): string =>
|
| 41 |
-
`M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
|
| 42 |
-
|
| 43 |
-
const CALENDAR: IconShape[] = [
|
| 44 |
-
{ d: "M3.2 4.6h9.6v8.2H3.2z" },
|
| 45 |
-
{ d: "M3.2 7.2h9.6" },
|
| 46 |
-
{ d: "M5.8 3v3.2" },
|
| 47 |
-
{ d: "M10.2 3v3.2" },
|
| 48 |
-
];
|
| 49 |
-
|
| 50 |
-
/**
|
| 51 |
-
* Field type β icon geometry. A TOTAL record on purpose: adding a FieldType
|
| 52 |
-
* without an icon is a compile error, not a silently blank header.
|
| 53 |
-
*/
|
| 54 |
-
export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
|
| 55 |
-
text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
|
| 56 |
-
status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
|
| 57 |
-
currency: [
|
| 58 |
-
{ d: "M8 2.8v10.4" },
|
| 59 |
-
{ d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
|
| 60 |
-
],
|
| 61 |
-
int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
|
| 62 |
-
date: CALENDAR,
|
| 63 |
-
pct: [
|
| 64 |
-
{ d: circle(4.6, 4.6, 1.6) },
|
| 65 |
-
{ d: circle(11.4, 11.4, 1.6) },
|
| 66 |
-
{ d: "M12.2 3.9L3.8 12.3" },
|
| 67 |
-
],
|
| 68 |
-
select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
|
| 69 |
-
user: [
|
| 70 |
-
{ d: circle(8, 6, 2.4) },
|
| 71 |
-
{ d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
|
| 72 |
-
],
|
| 73 |
-
multiselect: [
|
| 74 |
-
{ d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
|
| 75 |
-
{ d: "M7.2 5.7h6M7.2 10.3h6" },
|
| 76 |
-
],
|
| 77 |
-
checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
|
| 78 |
-
phone: [
|
| 79 |
-
{ d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
|
| 80 |
-
],
|
| 81 |
-
email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
|
| 82 |
-
url: [
|
| 83 |
-
{ d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
|
| 84 |
-
{ d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
|
| 85 |
-
{ d: "M6.2 9.8l3.6-3.6" },
|
| 86 |
-
],
|
| 87 |
-
rating: [
|
| 88 |
-
{
|
| 89 |
-
d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
|
| 90 |
-
fill: true,
|
| 91 |
-
},
|
| 92 |
-
],
|
| 93 |
-
created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
|
| 94 |
-
formula: [
|
| 95 |
-
{ d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
|
| 96 |
-
{ d: "M4.2 7.6h4.6" },
|
| 97 |
-
{ d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
|
| 98 |
-
],
|
| 99 |
-
// Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
|
| 100 |
-
// with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
|
| 101 |
-
// Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
|
| 102 |
-
// (`created_time` owns the closed rim + hands).
|
| 103 |
-
automation: [
|
| 104 |
-
{ d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
|
| 105 |
-
{ d: "M4.2 6L5.3 4.1 3.1 4.5" },
|
| 106 |
-
{ d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
|
| 107 |
-
],
|
| 108 |
-
// Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
|
| 109 |
-
// series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
|
| 110 |
-
// the formula fx (that computes over the ROW) and not a bare number (int owns ##).
|
| 111 |
-
metric: [
|
| 112 |
-
{ d: "M3.2 3.2v9.6h9.6" },
|
| 113 |
-
{ d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
|
| 114 |
-
],
|
| 115 |
-
// Wave-
|
| 116 |
-
//
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
}
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
{ d:
|
| 131 |
-
{ d: "M2.6
|
| 132 |
-
],
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
//
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
//
|
| 164 |
-
//
|
| 165 |
-
//
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
{ d: "
|
| 169 |
-
{ d: "
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
/
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
{ d: "
|
| 190 |
-
{ d:
|
| 191 |
-
{ d:
|
| 192 |
-
],
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
}
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
table:
|
| 226 |
-
}
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
}
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
*
|
| 337 |
-
|
| 338 |
-
*
|
| 339 |
-
*
|
| 340 |
-
*
|
| 341 |
-
*
|
| 342 |
-
*
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
//
|
| 347 |
-
//
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
//
|
| 356 |
-
//
|
| 357 |
-
//
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
/
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
//
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
//
|
| 418 |
-
|
| 419 |
-
//
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
};
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
*
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
/
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
/**
|
| 463 |
-
*
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / iconShapes.ts
|
| 3 |
+
// Wave-8 items I18 + I20 β ONE geometry source for the grid's icon vocabulary,
|
| 4 |
+
// rendered by TWO very different painters:
|
| 5 |
+
//
|
| 6 |
+
// - React <FieldTypeIcon> / <ModeIcon> β DOM svg in panels, popovers, menus
|
| 7 |
+
// - glide headerIcons sprites β canvas, drawn from an SVG *string*
|
| 8 |
+
//
|
| 9 |
+
// Glide's sprite API takes a function returning SVG SOURCE, so a header icon can
|
| 10 |
+
// never be a React component. Keeping the paths as DATA (IconShape[]) and giving
|
| 11 |
+
// each painter its own thin renderer is what stops the two from drifting β the
|
| 12 |
+
// alternative (hand-copying every path into a template literal) guarantees the
|
| 13 |
+
// header and the panel eventually disagree about what a "date" looks like.
|
| 14 |
+
//
|
| 15 |
+
// Geometry rules: 16x16 viewBox, stroke-based, 1.35 stroke, round caps/joins,
|
| 16 |
+
// currentColor. Vector paths only β NEVER emoji (owner constant).
|
| 17 |
+
// ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
import type { DisplayMode, FieldType, FolderShape, FolderTone } from "./types";
|
| 20 |
+
import {
|
| 21 |
+
LP_BLUE,
|
| 22 |
+
LP_BLUE_DEEP,
|
| 23 |
+
LP_GREEN,
|
| 24 |
+
LP_GREEN_DEEP,
|
| 25 |
+
LP_LINE,
|
| 26 |
+
LP_MUTED,
|
| 27 |
+
LP_RED,
|
| 28 |
+
LP_RED_DEEP,
|
| 29 |
+
LP_YELLOW,
|
| 30 |
+
LP_YELLOW_DEEP,
|
| 31 |
+
} from "./theme";
|
| 32 |
+
|
| 33 |
+
/** One drawing primitive. `fill: true` fills the path instead of stroking it
|
| 34 |
+
* (the rating star is the only shape that reads better solid). */
|
| 35 |
+
export type IconShape = { d: string; fill?: boolean };
|
| 36 |
+
|
| 37 |
+
/** A circle as a path β two half-arcs. Sprites are SVG *source*, so every shape
|
| 38 |
+
* has to survive being serialized into a string; paths do, <circle> elements
|
| 39 |
+
* would need a second serializer branch for no benefit. */
|
| 40 |
+
const circle = (cx: number, cy: number, r: number): string =>
|
| 41 |
+
`M${cx - r} ${cy}a${r} ${r} 0 1 0 ${r * 2} 0a${r} ${r} 0 1 0 ${-r * 2} 0`;
|
| 42 |
+
|
| 43 |
+
const CALENDAR: IconShape[] = [
|
| 44 |
+
{ d: "M3.2 4.6h9.6v8.2H3.2z" },
|
| 45 |
+
{ d: "M3.2 7.2h9.6" },
|
| 46 |
+
{ d: "M5.8 3v3.2" },
|
| 47 |
+
{ d: "M10.2 3v3.2" },
|
| 48 |
+
];
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Field type β icon geometry. A TOTAL record on purpose: adding a FieldType
|
| 52 |
+
* without an icon is a compile error, not a silently blank header.
|
| 53 |
+
*/
|
| 54 |
+
export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
|
| 55 |
+
text: [{ d: "M3 5h10M3 8h10M3 11h6" }],
|
| 56 |
+
status: [{ d: "M4 13V3.5h7.6L10.1 6l1.5 2.5H4" }],
|
| 57 |
+
currency: [
|
| 58 |
+
{ d: "M8 2.8v10.4" },
|
| 59 |
+
{ d: "M10.6 5.4A2.6 2.6 0 0 0 8.2 4.2H7.4a2 2 0 0 0 0 4h1.2a2 2 0 0 1 0 4H7.8a2.6 2.6 0 0 1-2.4-1.4" },
|
| 60 |
+
],
|
| 61 |
+
int: [{ d: "M6.2 3L4.8 13M11.2 3l-1.4 10M3.4 6.2h9.2M2.9 9.8h9.2" }],
|
| 62 |
+
date: CALENDAR,
|
| 63 |
+
pct: [
|
| 64 |
+
{ d: circle(4.6, 4.6, 1.6) },
|
| 65 |
+
{ d: circle(11.4, 11.4, 1.6) },
|
| 66 |
+
{ d: "M12.2 3.9L3.8 12.3" },
|
| 67 |
+
],
|
| 68 |
+
select: [{ d: "M3.2 3.8h9.6v8.4H3.2z" }, { d: "M6.2 7.2l1.8 1.8 1.8-1.8" }],
|
| 69 |
+
user: [
|
| 70 |
+
{ d: circle(8, 6, 2.4) },
|
| 71 |
+
{ d: "M3.6 13c0-2.4 2-3.8 4.4-3.8s4.4 1.4 4.4 3.8" },
|
| 72 |
+
],
|
| 73 |
+
multiselect: [
|
| 74 |
+
{ d: "M3 4.6h2.2v2.2H3zM3 9.2h2.2v2.2H3z" },
|
| 75 |
+
{ d: "M7.2 5.7h6M7.2 10.3h6" },
|
| 76 |
+
],
|
| 77 |
+
checkbox: [{ d: "M3.4 3.4h9.2v9.2H3.4z" }, { d: "M5.8 8.1l1.8 1.9 3.4-3.9" }],
|
| 78 |
+
phone: [
|
| 79 |
+
{ d: "M5.1 3.2L7 5.1 5.6 7a7.2 7.2 0 0 0 3.4 3.4l1.9-1.4 1.9 1.9-1.5 1.6c-3 .5-8.3-4.8-7.8-7.8z" },
|
| 80 |
+
],
|
| 81 |
+
email: [{ d: "M3 4.4h10v7.2H3z" }, { d: "M3 4.9l5 3.9 5-3.9" }],
|
| 82 |
+
url: [
|
| 83 |
+
{ d: "M7 5.4L8.4 4a2.6 2.6 0 0 1 3.7 3.7L10.7 9" },
|
| 84 |
+
{ d: "M9 10.6L7.6 12a2.6 2.6 0 0 1-3.7-3.7L5.3 7" },
|
| 85 |
+
{ d: "M6.2 9.8l3.6-3.6" },
|
| 86 |
+
],
|
| 87 |
+
rating: [
|
| 88 |
+
{
|
| 89 |
+
d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z",
|
| 90 |
+
fill: true,
|
| 91 |
+
},
|
| 92 |
+
],
|
| 93 |
+
created_time: [{ d: circle(8, 8, 5.2) }, { d: "M8 4.9v3.4l2.3 1.4" }],
|
| 94 |
+
formula: [
|
| 95 |
+
{ d: "M5.6 12.8V5.4a2 2 0 0 1 3.2-1.6" },
|
| 96 |
+
{ d: "M4.2 7.6h4.6" },
|
| 97 |
+
{ d: "M10.2 8.4l3 3.4M13.2 8.4l-3 3.4" },
|
| 98 |
+
],
|
| 99 |
+
// Wave-18 C5-AUTOFIELD (D's spec, applied by C as client-vocab registrar). A 290Β° cycle ring
|
| 100 |
+
// with an arrowhead, wrapped around a solid run-triangle: a job that runs, repeatedly.
|
| 101 |
+
// Deliberately NOT a bolt (`FOLDER_SHAPE_PATHS.bolt` already means "Priority") and not a clock
|
| 102 |
+
// (`created_time` owns the closed rim + hands).
|
| 103 |
+
automation: [
|
| 104 |
+
{ d: "M10.8 4.1A4.8 4.8 0 1 1 5.3 4.1" },
|
| 105 |
+
{ d: "M4.2 6L5.3 4.1 3.1 4.5" },
|
| 106 |
+
{ d: "M6.9 6.1L9.8 8 6.9 9.9z", fill: true },
|
| 107 |
+
],
|
| 108 |
+
// Wave-22 C7 (added by C as client-vocab registrar, the W18 automation precedent). A rising
|
| 109 |
+
// series on an axis: a measure OVER TIME, which is what a metric field is. Deliberately not
|
| 110 |
+
// the formula fx (that computes over the ROW) and not a bare number (int owns ##).
|
| 111 |
+
metric: [
|
| 112 |
+
{ d: "M3.2 3.2v9.6h9.6" },
|
| 113 |
+
{ d: "M5 10.4l2.4-2.6 1.9 1.5 3.1-3.9" },
|
| 114 |
+
],
|
| 115 |
+
// Wave-23 C7 β TWO BRACES facing each other with a dot between them: the universal mark for
|
| 116 |
+
// "a structured document", and the one glyph in this table that draws its own SYNTAX rather
|
| 117 |
+
// than a picture of what the value means. Deliberately not a document page (nothing here owns
|
| 118 |
+
// that yet, but a page reads as a file/attachment, which a json cell is not) and not a tree
|
| 119 |
+
// of nodes (too fine to survive 16px). The centre dot is what keeps the two braces from
|
| 120 |
+
// reading as parentheses at small sizes.
|
| 121 |
+
json: [
|
| 122 |
+
{ d: "M6.4 3.2c-1.5 0-1.5 3.4-1.5 3.4S4.8 8 3.4 8s1.5 1.4 1.5 1.4 0 3.4 1.5 3.4" },
|
| 123 |
+
{ d: "M9.6 3.2c1.5 0 1.5 3.4 1.5 3.4s.1 1.4 1.5 1.4-1.5 1.4-1.5 1.4 0 3.4-1.5 3.4" },
|
| 124 |
+
{ d: circle(8, 8, 0.85), fill: true },
|
| 125 |
+
],
|
| 126 |
+
// Wave-19 R7 β a framed picture: the mount, a sun, and the hill line every photo glyph
|
| 127 |
+
// resolves to at 16px. Drawn on the same 16-unit grid as its neighbours.
|
| 128 |
+
image: [
|
| 129 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 130 |
+
{ d: circle(6, 6.3, 1.1) },
|
| 131 |
+
{ d: "M2.6 10.6L6.1 7.6l2.5 2.1 2.2-1.8 2.6 2.2" },
|
| 132 |
+
],
|
| 133 |
+
};
|
| 134 |
+
|
| 135 |
+
/**
|
| 136 |
+
* Display mode β icon geometry (I18). Also total: the wave-8 Dashboard mode
|
| 137 |
+
* cannot land in DISPLAY_MODES without the compiler demanding its icon here.
|
| 138 |
+
*/
|
| 139 |
+
export const MODE_SHAPES: Record<DisplayMode, IconShape[]> = {
|
| 140 |
+
grid: [
|
| 141 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 142 |
+
{ d: "M2.6 6.5h10.8M2.6 9.5h10.8M6.4 3.4v9.2M10 3.4v9.2" },
|
| 143 |
+
],
|
| 144 |
+
list: [{ d: "M3 4.6h1.4M6.4 4.6h6.6M3 8h1.4M6.4 8h6.6M3 11.4h1.4M6.4 11.4h6.6" }],
|
| 145 |
+
// I19c β a framed set of bars: "several charts", not "one chart".
|
| 146 |
+
chart: [
|
| 147 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 148 |
+
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 149 |
+
],
|
| 150 |
+
// I10 (C2) β the LEGACY key. Kept so this record stays total over DisplayMode, which is
|
| 151 |
+
// what makes "accept 'dashboard' on read forever" a compile-time guarantee rather than a
|
| 152 |
+
// promise. Same drawing: a stored 'dashboard' IS a chart.
|
| 153 |
+
dashboard: [
|
| 154 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 155 |
+
{ d: "M5.4 10.6V7.2M8 10.6V5.4M10.6 10.6V8.6" },
|
| 156 |
+
],
|
| 157 |
+
calendar: CALENDAR,
|
| 158 |
+
kanban: [{ d: "M2.8 3.4h3.1v9.2H2.8zM6.5 3.4h3.1v6.2H6.5zM10.2 3.4h3.1v7.6h-3.1z" }],
|
| 159 |
+
map: [
|
| 160 |
+
{ d: "M8 2.6a3.6 3.6 0 0 1 3.6 3.6c0 2.7-3.6 7.2-3.6 7.2S4.4 8.9 4.4 6.2A3.6 3.6 0 0 1 8 2.6z" },
|
| 161 |
+
{ d: circle(8, 6.1, 1.3) },
|
| 162 |
+
],
|
| 163 |
+
// 2026-08-02 item 7 β the time-series view. Drawn as a framed grid with a trend running
|
| 164 |
+
// through it, because that is literally what the panel is: metric ROWS x bucket COLUMNS with
|
| 165 |
+
// a per-row line toggle. Deliberately not the plain `line` chart mark β a chart view and a
|
| 166 |
+
// time-series table must not be the same picture in the same rail.
|
| 167 |
+
timeseries: [
|
| 168 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 169 |
+
{ d: "M2.6 6.6h10.8M6.2 3.4v9.2" },
|
| 170 |
+
{ d: "M7.2 10.8l2-2.4 1.6 1.2 1.8-2.6" },
|
| 171 |
+
],
|
| 172 |
+
// Wave-18 C6-CATALOG β an OPEN BOOK: two facing leaves with a spine between them, and a
|
| 173 |
+
// product block sitting on the left one. Every other mode in this table draws a way of
|
| 174 |
+
// arranging RECORDS; this one has to read as a printed artifact, so it is the only mark here
|
| 175 |
+
// with a spine and a gutter. Deliberately not a page-with-lines (`list` owns that reading) and
|
| 176 |
+
// not a framed grid (`grid`/`chart`/`timeseries` share the frame).
|
| 177 |
+
catalog: [
|
| 178 |
+
{ d: "M2.4 4.2h4.6a1.6 1.6 0 0 1 1 .4l0 8a1.6 1.6 0 0 0-1-.4H2.4z" },
|
| 179 |
+
{ d: "M13.6 4.2H9a1.6 1.6 0 0 0-1 .4l0 8a1.6 1.6 0 0 1 1-.4h4.6z" },
|
| 180 |
+
{ d: "M3.9 6.4h2.3v2.8H3.9z" },
|
| 181 |
+
],
|
| 182 |
+
// Wave-23 C9 β a SHEET WITH A WRITING LINE: two filled answer bars and an empty rule beneath
|
| 183 |
+
// them. Every other mode here draws a way of ARRANGING records that already exist; this one
|
| 184 |
+
// has to read as a record being MADE, so it is the only mark whose bottom line is open.
|
| 185 |
+
// Deliberately not a clipboard (nothing else here has a frame with a tab) and not a pencil
|
| 186 |
+
// (an edit affordance means something else app-wide).
|
| 187 |
+
form: [
|
| 188 |
+
{ d: "M3.4 2.8h9.2v10.4H3.4z" },
|
| 189 |
+
{ d: "M5.6 5.6h4.8" },
|
| 190 |
+
{ d: "M5.6 8h4.8" },
|
| 191 |
+
{ d: "M5.6 10.6h2.6" },
|
| 192 |
+
],
|
| 193 |
+
};
|
| 194 |
+
|
| 195 |
+
/**
|
| 196 |
+
* Wave-9 I16 β one mark per CHART KIND. Total over `ChartKind`, so a kind added to C2's
|
| 197 |
+
* vocabulary cannot ship without a drawing.
|
| 198 |
+
*
|
| 199 |
+
* β The owner asked for an icon on every chart-type option, and the wave-8 ruling stands:
|
| 200 |
+
* an `<option>` cannot render SVG and unicode glyphs are gate-banned, so the chart-type
|
| 201 |
+
* picker is NOT a native `<select>` β it is a radio-row list like the mode switcher, which
|
| 202 |
+
* is the only shape that can carry a real mark.
|
| 203 |
+
*
|
| 204 |
+
* The key type is a string union declared here rather than imported from chartData.ts: this
|
| 205 |
+
* module is a leaf (types + theme only) and chartData imports IT, not the reverse.
|
| 206 |
+
*/
|
| 207 |
+
export type ChartKindKey = "bar" | "line" | "area" | "donut" | "kpi" | "table";
|
| 208 |
+
export const CHART_KIND_SHAPES: Record<ChartKindKey, IconShape[]> = {
|
| 209 |
+
bar: [{ d: "M3.2 12.8V7.4M6.4 12.8V4.2M9.6 12.8V8.8M12.8 12.8V5.8" }],
|
| 210 |
+
line: [
|
| 211 |
+
{ d: "M2.6 11.2l3.2-3.4 2.6 2 4.9-5.2" },
|
| 212 |
+
{ d: circle(5.8, 7.8, 0.9) },
|
| 213 |
+
{ d: circle(8.4, 9.8, 0.9) },
|
| 214 |
+
],
|
| 215 |
+
area: [
|
| 216 |
+
{ d: "M2.6 12.4V9.2l3.2-3.2 2.6 2 4.9-4.6v9z" },
|
| 217 |
+
{ d: "M2.6 9.2l3.2-3.2 2.6 2 4.9-4.6" },
|
| 218 |
+
],
|
| 219 |
+
donut: [{ d: circle(8, 8, 5) }, { d: circle(8, 8, 2.1) }],
|
| 220 |
+
// A single big number: the KPI card. Drawn as a framed value rather than a glyph, so it
|
| 221 |
+
// reads as "one number" beside four marks that all read as "a distribution".
|
| 222 |
+
kpi: [{ d: "M2.6 3.8h10.8v8.4H2.6z" }, { d: "M5.4 9.6V6.4l1.9 3.2V6.4M9.4 6.4v3.2h1.8" }],
|
| 223 |
+
// Wave-16 C-CHARTCAP: a group-by aggregate table. Framed like the KPI (it is a card of
|
| 224 |
+
// values, not a distribution), with a header band and a column rule.
|
| 225 |
+
table: [
|
| 226 |
+
{ d: "M2.6 3.4h10.8v9.2H2.6z" },
|
| 227 |
+
{ d: "M2.6 6.2h10.8M7.2 6.2v6.4M2.6 9.4h10.8" },
|
| 228 |
+
],
|
| 229 |
+
};
|
| 230 |
+
|
| 231 |
+
export const CHART_KIND_LABELS: Record<ChartKindKey, string> = {
|
| 232 |
+
bar: "Bar",
|
| 233 |
+
line: "Line",
|
| 234 |
+
area: "Area",
|
| 235 |
+
donut: "Donut",
|
| 236 |
+
kpi: "Single value",
|
| 237 |
+
table: "Table",
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
/** I16 β the pastel each chart kind wears, same family rule as MODE_TONE. */
|
| 241 |
+
export const CHART_KIND_TONE: Record<ChartKindKey, FolderTone> = {
|
| 242 |
+
bar: "blue",
|
| 243 |
+
line: "green",
|
| 244 |
+
area: "green",
|
| 245 |
+
donut: "yellow",
|
| 246 |
+
kpi: "neutral",
|
| 247 |
+
table: "neutral",
|
| 248 |
+
};
|
| 249 |
+
|
| 250 |
+
/**
|
| 251 |
+
* Wave-9 contract C5 (I15) β folder icon geometry. TOTAL over `FolderShape`, so a shape key
|
| 252 |
+
* added to the wire contract in types.ts cannot ship without a drawing.
|
| 253 |
+
*
|
| 254 |
+
* Same 16x16 stroke vocabulary as everything above: these have to sit beside a mode icon in
|
| 255 |
+
* the same rail and read as one family. `folder` is first because it is the default every
|
| 256 |
+
* pre-wave-9 folder falls back to (I14: "existing folders get the folder icon").
|
| 257 |
+
*/
|
| 258 |
+
export const FOLDER_SHAPE_PATHS: Record<FolderShape, IconShape[]> = {
|
| 259 |
+
folder: [{ d: "M2.4 12.6V4.2a.6.6 0 0 1 .6-.6h3.2l1.5 1.7h5.3a.6.6 0 0 1 .6.6v6.7a.6.6 0 0 1-.6.6H3a.6.6 0 0 1-.6-.6z" }],
|
| 260 |
+
star: [
|
| 261 |
+
{ d: "M8 2.9l1.63 3.3 3.64.53-2.63 2.57.62 3.63L8 11.24 4.74 12.93l.62-3.63L2.73 6.73l3.64-.53z" },
|
| 262 |
+
],
|
| 263 |
+
flag: [
|
| 264 |
+
{ d: "M4.2 13.4V2.9" },
|
| 265 |
+
{ d: "M4.2 3.4h7.6l-1.5 2.6 1.5 2.6H4.2z" },
|
| 266 |
+
],
|
| 267 |
+
tag: [
|
| 268 |
+
{ d: "M2.9 8.2V3.5a.6.6 0 0 1 .6-.6h4.7l5 5-5.3 5.3z" },
|
| 269 |
+
{ d: circle(5.6, 5.6, 1) },
|
| 270 |
+
],
|
| 271 |
+
bookmark: [{ d: "M4.4 2.9h7.2v10.4L8 10.7l-3.6 2.6z" }],
|
| 272 |
+
// Four of the host's shapes ALREADY exist in this file as a mode or a field-type mark.
|
| 273 |
+
// Reusing the geometry rather than drawing a second "chart" is the whole point of the
|
| 274 |
+
// one-source rule: a folder labelled Chart and the Chart view must not be two pictures.
|
| 275 |
+
grid: MODE_SHAPES.grid,
|
| 276 |
+
chart: MODE_SHAPES.dashboard,
|
| 277 |
+
map: MODE_SHAPES.map,
|
| 278 |
+
users: TYPE_SHAPES.user,
|
| 279 |
+
clock: TYPE_SHAPES.created_time,
|
| 280 |
+
heart: [{ d: "M8 13.1S2.7 9.8 2.7 6.4a2.9 2.9 0 0 1 5.3-1.6 2.9 2.9 0 0 1 5.3 1.6c0 3.4-5.3 6.7-5.3 6.7z" }],
|
| 281 |
+
bolt: [{ d: "M9.1 2.4L4.2 9.1h3.3l-.6 4.5 4.9-6.7H8.5z" }],
|
| 282 |
+
};
|
| 283 |
+
|
| 284 |
+
/**
|
| 285 |
+
* Tone key β the pastel it FILLS with, and the -d weight it STROKES with.
|
| 286 |
+
*
|
| 287 |
+
* Both, not one: a folder mark is a ~14px glyph, and [[loopable-brand-palette]] is explicit
|
| 288 |
+
* that a base pastel at that size smudges β LP_BLUE measures 1.88:1 on white. So the pastel
|
| 289 |
+
* is the fill (a tinted body reads as "coloured") and the measured -deep variant carries the
|
| 290 |
+
* outline (an outline that reads at all).
|
| 291 |
+
*
|
| 292 |
+
* The default tone is `neutral` β HOST's C5 key, not "grey". The whitelist is SHARED between
|
| 293 |
+
* the two ends, so the name matters more than the word: a tone the host does not recognise
|
| 294 |
+
* degrades to the default and the user's choice silently disappears on reload.
|
| 295 |
+
*/
|
| 296 |
+
export const FOLDER_TONE_PAINT: Record<FolderTone, { fill: string; stroke: string }> = {
|
| 297 |
+
neutral: { fill: LP_LINE, stroke: LP_MUTED },
|
| 298 |
+
blue: { fill: LP_BLUE, stroke: LP_BLUE_DEEP },
|
| 299 |
+
green: { fill: LP_GREEN, stroke: LP_GREEN_DEEP },
|
| 300 |
+
yellow: { fill: LP_YELLOW, stroke: LP_YELLOW_DEEP },
|
| 301 |
+
red: { fill: LP_RED, stroke: LP_RED_DEEP },
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
export const FOLDER_TONE_LABELS: Record<FolderTone, string> = {
|
| 305 |
+
neutral: "Neutral",
|
| 306 |
+
blue: "Blue",
|
| 307 |
+
green: "Green",
|
| 308 |
+
yellow: "Yellow",
|
| 309 |
+
red: "Red",
|
| 310 |
+
};
|
| 311 |
+
|
| 312 |
+
export const FOLDER_SHAPE_LABELS: Record<FolderShape, string> = {
|
| 313 |
+
folder: "Folder",
|
| 314 |
+
star: "Star",
|
| 315 |
+
flag: "Flag",
|
| 316 |
+
tag: "Tag",
|
| 317 |
+
bookmark: "Bookmark",
|
| 318 |
+
grid: "Table",
|
| 319 |
+
chart: "Chart",
|
| 320 |
+
map: "Map",
|
| 321 |
+
users: "People",
|
| 322 |
+
clock: "Clock",
|
| 323 |
+
heart: "Heart",
|
| 324 |
+
bolt: "Priority",
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
/**
|
| 328 |
+
* Wave-9 I14 β the tone each CREATABLE view type wears in the "+ Create newβ¦" flyout.
|
| 329 |
+
*
|
| 330 |
+
* The owner asked for "pastel-coloured icons", and a flyout where every row is the same grey
|
| 331 |
+
* is a list you read rather than scan. Assigned by family, not by rotation: the two
|
| 332 |
+
* record-shaped modes (grid/list) share blue, the two time-shaped ones (calendar/kanban)
|
| 333 |
+
* share yellow, chart is green because it is the analytical one, map is red because it is
|
| 334 |
+
* the geographic one. Folder is grey β it is not a view, and the flyout's last row should
|
| 335 |
+
* not compete with the six above it.
|
| 336 |
+
*/
|
| 337 |
+
/**
|
| 338 |
+
* Human labels for every display mode. Moved here from viewModes.tsx in wave 9 so the label
|
| 339 |
+
* sits beside the geometry, the way TYPE_LABELS does β the mode switcher, the create flyout
|
| 340 |
+
* and the create prompt now read ONE table instead of three. C2's "Dashboard" β "Chart"
|
| 341 |
+
* rename is a single line here as a direct result.
|
| 342 |
+
*/
|
| 343 |
+
export const MODE_LABELS: Record<DisplayMode, string> = {
|
| 344 |
+
grid: "Grid",
|
| 345 |
+
chart: "Chart",
|
| 346 |
+
// Legacy: never OFFERED (it is not in CREATABLE_MODES) but still labelled, because a view
|
| 347 |
+
// read before normalisation must never render a blank switcher chip.
|
| 348 |
+
dashboard: "Chart",
|
| 349 |
+
list: "List",
|
| 350 |
+
calendar: "Calendar",
|
| 351 |
+
kanban: "Kanban",
|
| 352 |
+
map: "Map",
|
| 353 |
+
timeseries: "Time series",
|
| 354 |
+
catalog: "Catalog",
|
| 355 |
+
// Wave-23 C9 β the mode that COLLECTS records. "Form", the word the whole product uses for
|
| 356 |
+
// it (the public page, the share panel, the `form_submitted` trigger); a synonym here would
|
| 357 |
+
// be the one surface calling it something else.
|
| 358 |
+
form: "Form",
|
| 359 |
+
};
|
| 360 |
+
|
| 361 |
+
/**
|
| 362 |
+
* I14 β the view types the "+ Create newβ¦" flyout OFFERS, in the order it lists them.
|
| 363 |
+
*
|
| 364 |
+
* Deliberately NOT `DISPLAY_MODES`, and deliberately here rather than inside ViewSidebar.tsx:
|
| 365 |
+
* C2 makes `'dashboard'` a mode that stays READABLE forever (every view saved before the
|
| 366 |
+
* rename sits in it) while ceasing to be OFFERABLE once `'chart'` exists β one list cannot
|
| 367 |
+
* express both. Living in this pure data module means the gate can assert the offered set
|
| 368 |
+
* without importing a React component, and I10 becomes a one-line edit in one file.
|
| 369 |
+
*/
|
| 370 |
+
// 2026-08-02 item 7 β `timeseries` was deliberately held OUT of this list until the host
|
| 371 |
+
// accepted the name, because a mode may be READABLE before it is OFFERABLE (the same split C2
|
| 372 |
+
// wrote for 'dashboard', running forwards): `aios_grid._clean_display` drops a mode it does
|
| 373 |
+
// not know, so offering it early would let a user create a view that silently reverts to a
|
| 374 |
+
// grid on the next read with nothing going red. HOST posted "ACCEPTANCE LANDED" with
|
| 375 |
+
// `DISPLAY_MODES += timeseries`, so it is offerable now.
|
| 376 |
+
//
|
| 377 |
+
// wave17 GRID, owner item 10 β THE ORDER BELOW IS THE OWNER'S, stated verbatim:
|
| 378 |
+
// Grid Β· Chart Β· Calendar Β· Kanban Β· Time series Β· Map Β· List.
|
| 379 |
+
//
|
| 380 |
+
// β It supersedes the two orderings this list has carried before it, and the reasoning that
|
| 381 |
+
// produced them is now WRONG rather than merely outranked, so it is not left here to be
|
| 382 |
+
// re-applied: `timeseries` was "listed last⦠it belongs beside Chart", and `list` sat second
|
| 383 |
+
// as the other record-shaped mode. The owner put Time series FIFTH and List LAST. An order is
|
| 384 |
+
// a product decision, so it is asserted in `verify_icons` rather than left to a comment β
|
| 385 |
+
// nothing else on screen would go red if a future edit re-sorted it "sensibly".
|
| 386 |
+
// Wave-18 C6-CATALOG β `catalog` was held out of this list until `aios_grid.DISPLAY_MODES`
|
| 387 |
+
// accepted the name, the same hold `timeseries` and `chart` served before it. SESSION A posted
|
| 388 |
+
// "C6 HOST MIRROR APPLIED β you may flip CREATABLE_MODES now" (2026-08-03), so it is offerable.
|
| 389 |
+
// It lands LAST by contract: the owner's seven-mode order above is a product decision and the
|
| 390 |
+
// new mode joins the end of it rather than being sorted into it.
|
| 391 |
+
export const CREATABLE_MODES: DisplayMode[] = [
|
| 392 |
+
"grid",
|
| 393 |
+
"chart",
|
| 394 |
+
"calendar",
|
| 395 |
+
"kanban",
|
| 396 |
+
"timeseries",
|
| 397 |
+
"map",
|
| 398 |
+
"list",
|
| 399 |
+
"catalog",
|
| 400 |
+
];
|
| 401 |
+
|
| 402 |
+
export const MODE_TONE: Record<DisplayMode, FolderTone> = {
|
| 403 |
+
grid: "blue",
|
| 404 |
+
list: "blue",
|
| 405 |
+
chart: "green",
|
| 406 |
+
dashboard: "green",
|
| 407 |
+
calendar: "yellow",
|
| 408 |
+
kanban: "yellow",
|
| 409 |
+
map: "red",
|
| 410 |
+
// Green with `chart`: it is the other analytical mode, and the two belong to one family.
|
| 411 |
+
timeseries: "green",
|
| 412 |
+
// Wave-18 C6-CATALOG β NEUTRAL, and it is the honest pick rather than the leftover one. The
|
| 413 |
+
// four colour tones each name a family of ways to arrange records (blue = tabular, green =
|
| 414 |
+
// analytical, yellow = board/date, red = spatial); a catalog arranges nothing β it is a
|
| 415 |
+
// published artifact. Giving it a colour would file it under a family it is not in.
|
| 416 |
+
catalog: "neutral",
|
| 417 |
+
// Wave-23 C9 β NEUTRAL, and for `catalog`'s reason rather than by elimination: the four tones
|
| 418 |
+
// name families of ways to ARRANGE records (blue tabular, green analytical, yellow
|
| 419 |
+
// board/date, red spatial). A form arranges nothing β it is a door records come in through β
|
| 420 |
+
// so giving it a colour would file it under a family it is not in.
|
| 421 |
+
form: "neutral",
|
| 422 |
+
};
|
| 423 |
+
|
| 424 |
+
/**
|
| 425 |
+
* Human labels for every field type. Lives here beside the icons so the two
|
| 426 |
+
* halves of "how a field type presents itself" stay in one file (ColumnMenu
|
| 427 |
+
* imports it rather than keeping a second copy).
|
| 428 |
+
*/
|
| 429 |
+
export const TYPE_LABELS: Record<FieldType, string> = {
|
| 430 |
+
text: "Single line text",
|
| 431 |
+
select: "Single select",
|
| 432 |
+
multiselect: "Multi select",
|
| 433 |
+
user: "Assignee",
|
| 434 |
+
int: "Number",
|
| 435 |
+
currency: "Currency",
|
| 436 |
+
pct: "Percent",
|
| 437 |
+
date: "Date",
|
| 438 |
+
checkbox: "Checkbox",
|
| 439 |
+
phone: "Phone number",
|
| 440 |
+
email: "Email",
|
| 441 |
+
url: "URL",
|
| 442 |
+
rating: "Rating",
|
| 443 |
+
created_time: "Created time",
|
| 444 |
+
formula: "Formula",
|
| 445 |
+
// Wave-18 C5-AUTOFIELD (D's spec, applied by C).
|
| 446 |
+
automation: "Automation",
|
| 447 |
+
// Wave-22 C7 β spawned by automations (not in CREATABLE_TYPES), so this label mostly shows
|
| 448 |
+
// on headers and the field gear, not the create menu.
|
| 449 |
+
metric: "Metric",
|
| 450 |
+
// Wave-19 R7 β the picture column.
|
| 451 |
+
image: "Image",
|
| 452 |
+
// Wave-23 C7 β the structured-document column. "JSON" rather than "Structured data": it is
|
| 453 |
+
// the word on the wire, in the viewer's raw tab and in every error the server can return, and
|
| 454 |
+
// a friendlier synonym would be the only place in the product using a different one.
|
| 455 |
+
json: "JSON",
|
| 456 |
+
status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
|
| 457 |
+
};
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
// ------------------------------------------------------------ glide sprites
|
| 461 |
+
|
| 462 |
+
/** Serialize one shape to SVG source in an explicit colour (canvas sprites get
|
| 463 |
+
* no `currentColor` β glide hands the painter the theme colours directly). */
|
| 464 |
+
function shapeSource(s: IconShape, color: string): string {
|
| 465 |
+
return s.fill
|
| 466 |
+
? `<path d="${s.d}" fill="${color}"/>`
|
| 467 |
+
: `<path d="${s.d}" fill="none" stroke="${color}" stroke-width="1.35" ` +
|
| 468 |
+
`stroke-linecap="round" stroke-linejoin="round"/>`;
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
function sprite(shapes: IconShape[]) {
|
| 472 |
+
return ({ fgColor }: { fgColor: string }) =>
|
| 473 |
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
| 474 |
+
shapes.map((s) => shapeSource(s, fgColor)).join("") +
|
| 475 |
+
`</svg>`;
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
/** Glide header-icon NAME for a field type β the `icon` a GridColumn asks for. */
|
| 479 |
+
export function typeIconName(type: FieldType): string {
|
| 480 |
+
return `t_${type}`;
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
/**
|
| 484 |
+
* The sprite map handed to <DataEditor headerIcons>. One entry per field type
|
| 485 |
+
* (I20 draws the type mark in every column header), built from the same shapes
|
| 486 |
+
* the React icons use.
|
| 487 |
+
*
|
| 488 |
+
* Colour: glide's "normal" variant paints with `theme.fgIconHeader`, which is
|
| 489 |
+
* why theme.ts must set it β the library default is #FFFFFF, i.e. invisible on
|
| 490 |
+
* our header (that was I21's actual bug, not a too-pale hex of ours).
|
| 491 |
+
*/
|
| 492 |
+
export const TYPE_SPRITES: Record<string, ({ fgColor }: { fgColor: string }) => string> =
|
| 493 |
+
Object.fromEntries(
|
| 494 |
+
(Object.keys(TYPE_SHAPES) as FieldType[]).map((t) => [typeIconName(t), sprite(TYPE_SHAPES[t])])
|
| 495 |
+
);
|
| 496 |
+
|
| 497 |
+
/**
|
| 498 |
+
* The header sprite map handed to <DataEditor headerIcons>. Two families:
|
| 499 |
+
*
|
| 500 |
+
* t_<type> wave-8 I20 - the field-TYPE mark, drawn in EVERY column header,
|
| 501 |
+
* from the same shapes the React icons use. Painted by glide in
|
| 502 |
+
* `theme.fgIconHeader`.
|
| 503 |
+
* aiosInfo wave-5 item 6, restyled by wave-9 I3 - the description (i).
|
| 504 |
+
* OUTLINE ONLY: a dark-grey ring with a transparent interior, per
|
| 505 |
+
* the owner. It still deliberately IGNORES the colours glide hands
|
| 506 |
+
* it, for the reason wave-8 recorded - glide's "special" variant is
|
| 507 |
+
* accentColor behind bgHeader, which under the C1 pastels is a pale
|
| 508 |
+
* glyph on a pale disc, i.e. I21 in a new costume.
|
| 509 |
+
* β It is NO LONGER a column `overlayIcon`. Glide draws an overlay
|
| 510 |
+
* at a hard-coded offset from the TYPE mark on the far LEFT of the
|
| 511 |
+
* header (drawHeaderInner: `drawX + 9`), and I3 wants it RIGHT-
|
| 512 |
+
* aligned. It is now painted by CustomerGrid's `drawHeader`
|
| 513 |
+
* callback at `infoMarkRect()` - see overlayPlacement.ts.
|
| 514 |
+
*/
|
| 515 |
+
export const HEADER_ICONS: Record<string, (c: { fgColor: string; bgColor: string }) => string> = {
|
| 516 |
+
...TYPE_SPRITES,
|
| 517 |
+
aiosInfo: () =>
|
| 518 |
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">` +
|
| 519 |
+
`<circle cx="8" cy="8" r="6.1" fill="none" stroke="${LP_MUTED}" stroke-width="1.25"/>` +
|
| 520 |
+
`<path d="M8 7.4v3.5" fill="none" stroke="${LP_MUTED}" stroke-width="1.4" ` +
|
| 521 |
+
`stroke-linecap="round"/>` +
|
| 522 |
+
`<circle cx="8" cy="5.1" r="0.85" fill="${LP_MUTED}"/>` +
|
| 523 |
+
`</svg>`,
|
| 524 |
+
};
|
web/src/customer-grid/theme.ts
CHANGED
|
@@ -73,6 +73,30 @@ export const LP_PURPLE_DEEP = "#6B57A8"; // 5.17:1 on its own tint
|
|
| 73 |
*/
|
| 74 |
export const LP_VOID = "#F6F8FC";
|
| 75 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
export const LP_BLUE_DEEP = "#768FB6"; // 3.29:1 on white β rings, control borders
|
| 77 |
/* THE PRODUCT PRIMARY β the same #5B8FD9 the host's `.streamlit/config.toml`
|
| 78 |
paints on every primary button, so embed and standalone stop disagreeing
|
|
@@ -294,6 +318,57 @@ export const COLUMN_TONE_THEME: Record<ControlTone, Partial<Theme>> = {
|
|
| 294 |
},
|
| 295 |
};
|
| 296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
/* wave17 GRID β item 2 / owner R5: **THE ALTERNATING ROW BANDS ARE DEAD.**
|
| 298 |
*
|
| 299 |
* `CONTROL_BAND_THEME` stood here: a faint wash on every other data row while any control was
|
|
|
|
| 73 |
*/
|
| 74 |
export const LP_VOID = "#F6F8FC";
|
| 75 |
|
| 76 |
+
/**
|
| 77 |
+
* β Wave-23 C8 (owner item 4 / ruling R9) β **the MACHINE-OWNED column pair.**
|
| 78 |
+
*
|
| 79 |
+
* A sixth family, and neutral rather than a hue on purpose: the four C1 pastels each mean a
|
| 80 |
+
* STATE (green positive, red negative, yellow attention, blue interactive) and purple means
|
| 81 |
+
* GROUP. "A robot fills this column" is none of those β it is a property of the column's
|
| 82 |
+
* OWNERSHIP, so it takes the grey the palette had no name for. Deliberately NOT `LP_MUTED`
|
| 83 |
+
* (#6B7280): that is chrome ink, it is warmer, and re-using it would make "muted text" and
|
| 84 |
+
* "machine-owned" one token that two unrelated rules could then drag apart.
|
| 85 |
+
*
|
| 86 |
+
* `LP_MACHINE_TINT` the flat wash the DOM surfaces paint (`RecordDetail.css`'s
|
| 87 |
+
* `.cg-detail-field.is-machine .cg-detail-value`). It is exactly what the
|
| 88 |
+
* canvas wash below COMPOSITES TO over white, so the drawer's value slot
|
| 89 |
+
* and the grid cell are the same grey rather than two greys that were each
|
| 90 |
+
* picked to look right alone.
|
| 91 |
+
* `LP_MACHINE_DEEP` its ink β 5.35:1 on the tint, 6.32:1 on white (measured, not picked) β
|
| 92 |
+
* and the hue `MACHINE_CELL` derives the canvas wash from.
|
| 93 |
+
*
|
| 94 |
+
* Mirrors `--lp-machine-tint` / `--lp-machine-deep` in index.css; `verify_icons`' I7c parity
|
| 95 |
+
* diffs the two files, which is why both are plain hex strings here and neither is computed.
|
| 96 |
+
*/
|
| 97 |
+
export const LP_MACHINE_TINT = "#EBECEE";
|
| 98 |
+
export const LP_MACHINE_DEEP = "#59606E";
|
| 99 |
+
|
| 100 |
export const LP_BLUE_DEEP = "#768FB6"; // 3.29:1 on white β rings, control borders
|
| 101 |
/* THE PRODUCT PRIMARY β the same #5B8FD9 the host's `.streamlit/config.toml`
|
| 102 |
paints on every primary button, so embed and standalone stop disagreeing
|
|
|
|
| 318 |
},
|
| 319 |
};
|
| 320 |
|
| 321 |
+
/**
|
| 322 |
+
* β Wave-23 C8 β the wash a MACHINE-OWNED cell paints (`isMachineOwned`, types.ts).
|
| 323 |
+
*
|
| 324 |
+
* **ONE SHARED REFERENCE, and that is not a micro-optimisation.** glide has no theme-merge
|
| 325 |
+
* cache, so a fresh `{bgCell}` per cell costs a ~35-key spread on every cell of every frame β
|
| 326 |
+
* the same reasoning `STATUS_ROW_THEME` and `COLUMN_TONE_THEME` state, and it bites harder here
|
| 327 |
+
* because this override is returned for a whole COLUMN of cells rather than for the few rows
|
| 328 |
+
* that carry a status.
|
| 329 |
+
*
|
| 330 |
+
* **`rgba`, NOT a hex, and it is load-bearing** (the wave-15 R5 lesson, one surface down):
|
| 331 |
+
* `mergeAndRealizeTheme` alpha-BLENDS `bgCell` and hard-replaces every other key, so an opaque
|
| 332 |
+
* grey here would ERASE whatever it landed on β a colour-by row's status wash and the
|
| 333 |
+
* hover/active row wash both, leaving a machine column as a permanent gap through every tinted
|
| 334 |
+
* row. Blending means a hovered machine cell is darker than either alone, which is exactly the
|
| 335 |
+
* reading a person expects.
|
| 336 |
+
*
|
| 337 |
+
* β **0.12, chosen against a COLLISION rather than by eye.** `ROW_HIGHLIGHT_NEUTRAL` (the
|
| 338 |
+
* hover/active row) composites to β#F2F2F3 β a neutral grey wash already means something on
|
| 339 |
+
* this canvas. At 0.12 this composites to `LP_MACHINE_TINT` (#EBECEE), which is visibly the
|
| 340 |
+
* stronger of the two, so "a whole column is grey" and "the row under my pointer is grey" stay
|
| 341 |
+
* two different readings. Raising it is the one knob if a machine column reads as nothing; do
|
| 342 |
+
* not switch it to a hue (R9 is explicit that this is not a state).
|
| 343 |
+
*/
|
| 344 |
+
export const MACHINE_CELL: Partial<Theme> = { bgCell: washOf(LP_MACHINE_DEEP, 0.12) };
|
| 345 |
+
|
| 346 |
+
/**
|
| 347 |
+
* β Wave-23 C8 β a machine-owned cell's FINAL theme override, given whatever the cell already
|
| 348 |
+
* carried. **THE PRECEDENCE IS THE WHOLE FUNCTION**, so it lives here (node-reachable, under
|
| 349 |
+
* `verify_grid_ux`'s mutation harness) rather than inline in `cells.ts`, which imports glide and
|
| 350 |
+
* therefore cannot be driven by a gate at all.
|
| 351 |
+
*
|
| 352 |
+
* MERGED, with the CELL's own keys landing last β not "keep whichever exists", which was the
|
| 353 |
+
* first shape and is wrong in both directions:
|
| 354 |
+
* Β· an `automation` cell that HAS run carries `AUTOMATION_TINT[state]` β a green/red/yellow
|
| 355 |
+
* `bgCell` plus its ink. That is a STATE and it says something the wash does not, so it must
|
| 356 |
+
* win. (A cell that has never run carries no override at all β `AUTOMATION_TINT.none` is
|
| 357 |
+
* deliberately `undefined` β and takes the wash, which is the honest reading: machine-owned,
|
| 358 |
+
* nothing written yet.)
|
| 359 |
+
* Β· a machine-TAGGED `status`/`select`/`multiselect` cell β an automation-spawned column is an
|
| 360 |
+
* ordinary field TYPE wearing the tag β carries a BUBBLE tint (`bgBubble`/`textBubble`),
|
| 361 |
+
* which does not touch `bgCell` at all. "Keep whichever exists" would have silently cost
|
| 362 |
+
* every one of those columns its wash, and a column of pills is exactly where a reader is
|
| 363 |
+
* least able to tell who typed the value.
|
| 364 |
+
*
|
| 365 |
+
* The no-override case returns the SHARED `MACHINE_CELL` reference (see its note above); only
|
| 366 |
+
* the two merge cases allocate, and both already allocated a fresh override upstream.
|
| 367 |
+
*/
|
| 368 |
+
export function machineCellTheme(own: Partial<Theme> | undefined): Partial<Theme> {
|
| 369 |
+
return own ? { ...MACHINE_CELL, ...own } : MACHINE_CELL;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
/* wave17 GRID β item 2 / owner R5: **THE ALTERNATING ROW BANDS ARE DEAD.**
|
| 373 |
*
|
| 374 |
* `CONTROL_BAND_THEME` stood here: a faint wash on every other data row while any control was
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -44,6 +44,14 @@ export type FieldType =
|
|
| 44 |
// menu (deliberately absent from CREATABLE_TYPES: the menu has no measure/window face yet β
|
| 45 |
// offering the type without its config would mint permanently blank columns).
|
| 46 |
| "metric"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
// β Wave-19 R7 / C5 β `image` is a PICTURE on a record. The cell holds a STRING REFERENCE,
|
| 48 |
// never bytes (see `imageRef`): a SKU code, an `ed:` editorial slug, or a `rec:` upload. That
|
| 49 |
// is what lets Royal's 1,142 existing SKU masters appear with nothing uploaded, and it keeps
|
|
@@ -56,10 +64,26 @@ export type FieldSource = "odoo" | "overlay";
|
|
| 56 |
/** The types a user may create from the column menu. Odoo-sourced fields are not in this list β
|
| 57 |
* their type comes from the model, and `status` in particular is Odoo's lifecycle, not a
|
| 58 |
* user-defined set. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
export const CREATABLE_TYPES: readonly FieldType[] = [
|
| 60 |
"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| 61 |
"checkbox", "phone", "email", "url", "rating", "created_time", "formula", "automation",
|
| 62 |
"image",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
];
|
| 64 |
|
| 65 |
/**
|
|
@@ -237,8 +261,16 @@ export type FieldScope = "cohort" | "global";
|
|
| 237 |
// held OUT of CREATABLE_MODES until `aios_grid.DISPLAY_MODES` accepts the name, because
|
| 238 |
// `_clean_display` drops an unknown mode and offering it first would let a user build a whole
|
| 239 |
// catalogue that silently reverts to a grid on the next read with nothing going red.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
export const DISPLAY_MODES = [
|
| 241 |
"grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
|
|
|
|
| 242 |
] as const;
|
| 243 |
export type DisplayMode = (typeof DISPLAY_MODES)[number];
|
| 244 |
|
|
@@ -946,6 +978,35 @@ export function mayEditField(field: Field, viewer: Viewer | undefined): boolean
|
|
| 946 |
return edit === "creator" && field.createdBy === viewer.name;
|
| 947 |
}
|
| 948 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 949 |
/**
|
| 950 |
* The NUMERIC and DATE families, for UI dispatch (operator lists, input kinds, sort-direction
|
| 951 |
* wording). ONE definition here; the ENGINE (useVisibleRows) keeps its own deliberately β
|
|
@@ -1081,16 +1142,32 @@ export interface Field {
|
|
| 1081 |
* Wave-18 C5-AUTOFIELD β what this column's automation DOES. Absent = unconfigured (the cells
|
| 1082 |
* render "Not configured" and no run ever targets the column). Written through the field's
|
| 1083 |
* gear; validated host-side (`aios_grid._clean_automation`) exactly as `options`/`formula` are.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1084 |
*/
|
| 1085 |
automation?: {
|
| 1086 |
-
/** v1 vocabulary: 'instagram_profile'. Host-whitelisted; an unknown kind is dropped.
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
|
|
|
|
|
|
| 1090 |
/** the row field holding the profile URL when source = 'record_url_field'. */
|
| 1091 |
urlField?: string;
|
| 1092 |
/** kind-specific knobs. Scalars only β the host clamps keys and value lengths. */
|
| 1093 |
settings?: Record<string, string | number | boolean>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1094 |
};
|
| 1095 |
/**
|
| 1096 |
* Wave-22 C7 β a METRIC field's config bag, mirrored from `user_tables._clean_metric` (the
|
|
|
|
| 44 |
// menu (deliberately absent from CREATABLE_TYPES: the menu has no measure/window face yet β
|
| 45 |
// offering the type without its config would mint permanently blank columns).
|
| 46 |
| "metric"
|
| 47 |
+
// β Wave-23 C7 (owner item 5 / ruling R5) β `json` is a STRUCTURED DOCUMENT on a record: an
|
| 48 |
+
// API response, a webhook body, a scraped payload. The cell holds a STRING that must parse as
|
| 49 |
+
// JSON, β€ 32 KB, validated host-side on write (refuse with a sentence, never truncate) β so
|
| 50 |
+
// the scalar `Row` contract (types.ts's Row, and `grid_events.py:1619`'s non-scalar refusal)
|
| 51 |
+
// is untouched. The grid shows a compact preview and the big viewer modal owns the document.
|
| 52 |
+
// It is NOT in `READONLY_CELL_TYPES`: the value is editable, just never through a 200px cell
|
| 53 |
+
// β the same "picked, not typed" reasoning `select` and `image` carry.
|
| 54 |
+
| "json"
|
| 55 |
// β Wave-19 R7 / C5 β `image` is a PICTURE on a record. The cell holds a STRING REFERENCE,
|
| 56 |
// never bytes (see `imageRef`): a SKU code, an `ed:` editorial slug, or a `rec:` upload. That
|
| 57 |
// is what lets Royal's 1,142 existing SKU masters appear with nothing uploaded, and it keeps
|
|
|
|
| 64 |
/** The types a user may create from the column menu. Odoo-sourced fields are not in this list β
|
| 65 |
* their type comes from the model, and `status` in particular is Odoo's lifecycle, not a
|
| 66 |
* user-defined set. */
|
| 67 |
+
// β Wave-23 C7 β `json` is deliberately ABSENT from this list until SESSION A posts that
|
| 68 |
+
// `aios_grid.CUSTOM_FIELD_TYPES` and `core.user_tables.UT_FIELD_TYPES` accept the name. It is
|
| 69 |
+
// the field-kind twin of the CREATABLE_MODES hold (iconShapes.ts:344-364) and the failure is
|
| 70 |
+
// worse here, because it is not a downgrade: `user_tables._clean_field` (:389) RETURNS NONE for
|
| 71 |
+
// an unknown type, so a column created before the server knows the word simply does not exist
|
| 72 |
+
// on the next read β created, named, configured, gone, with nothing anywhere going red. Every
|
| 73 |
+
// OTHER json table below lands now, because reading a json field the server already sends is a
|
| 74 |
+
// different question from offering to create one.
|
| 75 |
export const CREATABLE_TYPES: readonly FieldType[] = [
|
| 76 |
"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| 77 |
"checkbox", "phone", "email", "url", "rating", "created_time", "formula", "automation",
|
| 78 |
"image",
|
| 79 |
+
// β Wave-23 C7 (owner item 5) β ADDED AT THE CLOSE-OUT AUDIT, and its absence is the exact
|
| 80 |
+
// half-landing the fields contract exists to catch. `json` reached the `FieldType` union, the
|
| 81 |
+
// shape/label tables and the server's `CUSTOM_FIELD_TYPES`, but not this list β so the server
|
| 82 |
+
// would have accepted a json field that the client offered no way to create. The type would
|
| 83 |
+
// have shipped real, documented and unreachable.
|
| 84 |
+
// `verify_fields_contract` caught it by diffing the two lists; nothing else could have, because
|
| 85 |
+
// every individual file was internally consistent.
|
| 86 |
+
"json",
|
| 87 |
];
|
| 88 |
|
| 89 |
/**
|
|
|
|
| 261 |
// held OUT of CREATABLE_MODES until `aios_grid.DISPLAY_MODES` accepts the name, because
|
| 262 |
// `_clean_display` drops an unknown mode and offering it first would let a user build a whole
|
| 263 |
// catalogue that silently reverts to a grid on the next read with nothing going red.
|
| 264 |
+
// β Wave-23 C9 (owner item 8): `form` joins the union β a view that COLLECTS records instead of
|
| 265 |
+
// arranging them, published at a public `#/form/<token>` link. Landed here (so the type, the
|
| 266 |
+
// icon table, the label table and the tone table are total over it, and a stored `form` view
|
| 267 |
+
// renders rather than blanking) and held OUT of `CREATABLE_MODES` until `aios_grid.DISPLAY_MODES`
|
| 268 |
+
// accepts the name β the identical hold `timeseries` and `catalog` served, for the identical
|
| 269 |
+
// reason: `_clean_display` DROPS an unknown mode, so offering it early would let somebody build
|
| 270 |
+
// a whole form, share its link, and have the view silently revert to a grid on the next read.
|
| 271 |
export const DISPLAY_MODES = [
|
| 272 |
"grid", "chart", "dashboard", "list", "calendar", "kanban", "map", "timeseries", "catalog",
|
| 273 |
+
"form",
|
| 274 |
] as const;
|
| 275 |
export type DisplayMode = (typeof DISPLAY_MODES)[number];
|
| 276 |
|
|
|
|
| 978 |
return edit === "creator" && field.createdBy === viewer.name;
|
| 979 |
}
|
| 980 |
|
| 981 |
+
/**
|
| 982 |
+
* β Wave-23 C8 (owner item 4 / ruling R9) β **is this column filled by a MACHINE?**
|
| 983 |
+
*
|
| 984 |
+
* Two clauses, and R9 is what makes it exactly two:
|
| 985 |
+
* Β· `field.automation` present β the TAG the engine stamps on everything it spawns or writes:
|
| 986 |
+
* the IG preset columns (`{flowId}`, automation_engine.py:757), the `stage_<autoId>` board
|
| 987 |
+
* column and its cycles counter (`{flowId, stageField}`, :3600), and a user-configured
|
| 988 |
+
* `automation` column's own gear bag. Ordinary field TYPES, machine ownership, one marker.
|
| 989 |
+
* Β· `type β {automation, metric}` β the two kinds that are machine-written BY DEFINITION even
|
| 990 |
+
* when no tag reached the client (an `automation` column created before the flow that fills
|
| 991 |
+
* it; a `metric`, which is computed server-side from the master series and never typed).
|
| 992 |
+
*
|
| 993 |
+
* β R9 DRAWS THE LINE HERE ON PURPOSE, and the two exclusions are the whole ruling: an
|
| 994 |
+
* ODOO-sourced column and a `formula`/`created_time` column **keep their current look**. All
|
| 995 |
+
* four are read-only, so "read-only" is NOT the predicate β a formula is written by the reader's
|
| 996 |
+
* own arithmetic and an Odoo column by the business, and greying either would say a robot owns
|
| 997 |
+
* a number the reader owns. This is why it is a separate function from `mayEditField` and not a
|
| 998 |
+
* branch of it.
|
| 999 |
+
*
|
| 1000 |
+
* β **PAINT, NEVER PERMISSION.** `mayEditField` above stays the one edit wall (client courtesy;
|
| 1001 |
+
* `grid_events.py:1608-1650` is the real one). A column can be machine-owned and editable
|
| 1002 |
+
* (nothing is, today) or editable and machine-owned tomorrow, and this predicate must not be
|
| 1003 |
+
* the reason a cell locks β two functions that agree today are exactly how one silently becomes
|
| 1004 |
+
* the other's enforcement.
|
| 1005 |
+
*/
|
| 1006 |
+
export function isMachineOwned(field: Field): boolean {
|
| 1007 |
+
return !!field.automation || field.type === "automation" || field.type === "metric";
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
/**
|
| 1011 |
* The NUMERIC and DATE families, for UI dispatch (operator lists, input kinds, sort-direction
|
| 1012 |
* wording). ONE definition here; the ENGINE (useVisibleRows) keeps its own deliberately β
|
|
|
|
| 1142 |
* Wave-18 C5-AUTOFIELD β what this column's automation DOES. Absent = unconfigured (the cells
|
| 1143 |
* render "Not configured" and no run ever targets the column). Written through the field's
|
| 1144 |
* gear; validated host-side (`aios_grid._clean_automation`) exactly as `options`/`formula` are.
|
| 1145 |
+
*
|
| 1146 |
+
* β WIDENED wave-23 C8, because this interface described only ONE of the three writers and
|
| 1147 |
+
* the other two have been shipping for a wave. `kind`/`source` are what the field GEAR writes
|
| 1148 |
+
* (`_clean_automation`); the engine stamps two more bags that never carry either of them:
|
| 1149 |
+
* Β· a SPAWNED preset column β `{flowId}` alone (`automation_engine.py:757`);
|
| 1150 |
+
* Β· a STAGE column β `{flowId, stageField: true, β¦}` (`automation_engine.py:3600`).
|
| 1151 |
+
* Nothing went red, because the wire arrives untyped: `field.automation.kind` on a stage
|
| 1152 |
+
* column is a runtime `undefined` under a green `tsc`. C8 makes the TAG ITSELF load-bearing
|
| 1153 |
+
* ("present β machine-owned", `isMachineOwned` above), so the shape it actually has is
|
| 1154 |
+
* declared here rather than left to the one writer that happened to be typed first.
|
| 1155 |
*/
|
| 1156 |
automation?: {
|
| 1157 |
+
/** v1 vocabulary: 'instagram_profile'. Host-whitelisted; an unknown kind is dropped.
|
| 1158 |
+
* Absent on engine-stamped bags (spawned presets, stage columns). */
|
| 1159 |
+
kind?: string;
|
| 1160 |
+
/** where the run gets its subject. 'record_url_field' reads `urlField` off the row.
|
| 1161 |
+
* Absent on engine-stamped bags. */
|
| 1162 |
+
source?: "record_url_field" | "self";
|
| 1163 |
/** the row field holding the profile URL when source = 'record_url_field'. */
|
| 1164 |
urlField?: string;
|
| 1165 |
/** kind-specific knobs. Scalars only β the host clamps keys and value lengths. */
|
| 1166 |
settings?: Record<string, string | number | boolean>;
|
| 1167 |
+
/** the automation definition that owns this column (engine-stamped; the machine marker). */
|
| 1168 |
+
flowId?: string;
|
| 1169 |
+
/** true on the `stage_<autoId>` column an automation board walks its records through. */
|
| 1170 |
+
stageField?: boolean;
|
| 1171 |
};
|
| 1172 |
/**
|
| 1173 |
* Wave-22 C7 β a METRIC field's config bag, mirrored from `user_tables._clean_metric` (the
|
web/src/filter-kit/ops.ts
CHANGED
|
@@ -83,6 +83,20 @@ export function opsForType(t: FieldType): FilterOp[] {
|
|
| 83 |
// by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
|
| 84 |
if (t === "multiselect")
|
| 85 |
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
// Wave-18 C5-AUTOFIELD β `automation` lands HERE, on the text ops, and that is the whole
|
| 87 |
// v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
|
| 88 |
// `contains ok` and `contains error` are the two questions anyone actually asks of the
|
|
|
|
| 83 |
// by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
|
| 84 |
if (t === "multiselect")
|
| 85 |
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
|
| 86 |
+
// β Wave-23 C7 β `json` gets EXACTLY the two value-free ops, and the omissions are the
|
| 87 |
+
// decision. `contains` over a serialized document is a substring test against punctuation and
|
| 88 |
+
// key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
|
| 89 |
+
// fragment of 5120, and every one of those reads as a working filter. `eq` is worse β two
|
| 90 |
+
// documents that mean the same thing differ by key order and whitespace, so "is" would answer
|
| 91 |
+
// false for a record that plainly matches. "Has anything been captured here yet" is the
|
| 92 |
+
// question a json column can answer soundly, and it is the one people actually ask.
|
| 93 |
+
//
|
| 94 |
+
// β NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
|
| 95 |
+
// `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
|
| 96 |
+
// are TYPE-INDEPENDENT β they test blankness before any type dispatch β so the lock-step is
|
| 97 |
+
// untouched and nothing on the server had to move for this line. (The contract's prose spells
|
| 98 |
+
// them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
|
| 99 |
+
if (t === "json") return ["isNotEmpty", "isEmpty"];
|
| 100 |
// Wave-18 C5-AUTOFIELD β `automation` lands HERE, on the text ops, and that is the whole
|
| 101 |
// v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
|
| 102 |
// `contains ok` and `contains error` are the two questions anyone actually asks of the
|
web/src/forms/FormPublic.tsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// forms / FormPublic.tsx β β wave-23 item 8 (contract W23-C9).
|
| 3 |
+
//
|
| 4 |
+
// The page an ANONYMOUS person lands on at `#/form/<token>`. E mounts it in the
|
| 5 |
+
// Shell BEFORE the auth wall (wiring W23-W2); everything below assumes there is
|
| 6 |
+
// no session, no workspace, no nav and no viewer.
|
| 7 |
+
//
|
| 8 |
+
// β IT IMPORTS NOTHING FROM `customer-grid/**`, AND THAT IS A REQUIREMENT, NOT
|
| 9 |
+
// A PREFERENCE. The grid bundle drags glide-data-grid β hundreds of kilobytes
|
| 10 |
+
// of canvas table β into whatever chunk touches it, and this page is the one
|
| 11 |
+
// surface in the product that a stranger loads cold, once, probably on a phone,
|
| 12 |
+
// probably on mobile data, to type four answers. One convenience import from
|
| 13 |
+
// `types.ts` would put the whole spreadsheet engine on that wire. So the field
|
| 14 |
+
// vocabulary below is DECLARED HERE as plain strings rather than imported: the
|
| 15 |
+
// server sends `type` as a string and this page renders a control per string,
|
| 16 |
+
// which is the same "no client union over a server vocabulary" rule the rest of
|
| 17 |
+
// the wave follows, arriving here for a second reason.
|
| 18 |
+
//
|
| 19 |
+
// β Styling reuses `.lpf-*` rules in the D REGION of index.css β the shell's
|
| 20 |
+
// stylesheet is one file and already loaded; a second stylesheet for one page
|
| 21 |
+
// would be a second copy of the tokens.
|
| 22 |
+
// ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
import { useEffect, useState } from "react";
|
| 25 |
+
|
| 26 |
+
/** One field, exactly as `routes_forms._public_form` builds it. Nothing here is
|
| 27 |
+
* optional-because-maybe: every key below is one the server always sends, and
|
| 28 |
+
* the three that are conditional say so. */
|
| 29 |
+
interface FormField {
|
| 30 |
+
key: string;
|
| 31 |
+
label: string;
|
| 32 |
+
/** The server's own word. Deliberately `string`, not a union β an unknown
|
| 33 |
+
* type lands on the text input, which is the safe direction: a person can
|
| 34 |
+
* always type an answer, and a control this page has never heard of would
|
| 35 |
+
* otherwise render as nothing at all. */
|
| 36 |
+
type: string;
|
| 37 |
+
required: boolean;
|
| 38 |
+
options?: string[];
|
| 39 |
+
max?: number;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface FormSpec {
|
| 43 |
+
title: string;
|
| 44 |
+
desc: string;
|
| 45 |
+
submitLabel: string;
|
| 46 |
+
fields: FormField[];
|
| 47 |
+
/** The honeypot's field name β planted, never shown. Server-named so the two
|
| 48 |
+
* halves cannot drift into a trap nobody checks. */
|
| 49 |
+
honeypot: string;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
type Phase = "loading" | "ready" | "sent" | "gone";
|
| 53 |
+
|
| 54 |
+
const LONG_TEXT_MIN = 0; // every text field gets a textarea when it is the only one
|
| 55 |
+
|
| 56 |
+
export default function FormPublic({ token }: { token: string }) {
|
| 57 |
+
const [spec, setSpec] = useState<FormSpec | null>(null);
|
| 58 |
+
const [phase, setPhase] = useState<Phase>("loading");
|
| 59 |
+
const [values, setValues] = useState<Record<string, string>>({});
|
| 60 |
+
const [error, setError] = useState("");
|
| 61 |
+
const [busy, setBusy] = useState(false);
|
| 62 |
+
|
| 63 |
+
useEffect(() => {
|
| 64 |
+
let live = true;
|
| 65 |
+
setPhase("loading");
|
| 66 |
+
fetch(`/api/v1/forms/${encodeURIComponent(token)}`)
|
| 67 |
+
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
| 68 |
+
.then((data: FormSpec) => {
|
| 69 |
+
if (!live) return;
|
| 70 |
+
setSpec(data);
|
| 71 |
+
setPhase("ready");
|
| 72 |
+
})
|
| 73 |
+
// β ONE dead-end state for every failure, mirroring the server's uniform 403. Telling a
|
| 74 |
+
// visitor "this form is disabled" versus "no such form" would hand an enumerator the
|
| 75 |
+
// distinction the whole server-side refusal exists to withhold.
|
| 76 |
+
.catch(() => live && setPhase("gone"));
|
| 77 |
+
return () => {
|
| 78 |
+
live = false;
|
| 79 |
+
};
|
| 80 |
+
}, [token]);
|
| 81 |
+
|
| 82 |
+
const set = (key: string, v: string) =>
|
| 83 |
+
setValues((prev) => ({ ...prev, [key]: v }));
|
| 84 |
+
|
| 85 |
+
const submit = (event: React.FormEvent) => {
|
| 86 |
+
event.preventDefault();
|
| 87 |
+
if (busy) return;
|
| 88 |
+
setBusy(true);
|
| 89 |
+
setError("");
|
| 90 |
+
fetch(`/api/v1/forms/${encodeURIComponent(token)}`, {
|
| 91 |
+
method: "POST",
|
| 92 |
+
headers: { "Content-Type": "application/json" },
|
| 93 |
+
body: JSON.stringify({ values }),
|
| 94 |
+
})
|
| 95 |
+
.then(async (r) => {
|
| 96 |
+
if (r.ok) {
|
| 97 |
+
setPhase("sent");
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
// The server's sentence, verbatim β it names the field by the label THIS PAGE showed,
|
| 101 |
+
// which is the only name the person has. A generic "something went wrong" here would
|
| 102 |
+
// discard the one useful thing the refusal carried.
|
| 103 |
+
const body = await r.json().catch(() => null);
|
| 104 |
+
setError(
|
| 105 |
+
String(body?.error?.message || "That could not be sent β check your answers and try again.")
|
| 106 |
+
);
|
| 107 |
+
})
|
| 108 |
+
.catch(() =>
|
| 109 |
+
setError("That could not be sent β check your connection and try again.")
|
| 110 |
+
)
|
| 111 |
+
.finally(() => setBusy(false));
|
| 112 |
+
};
|
| 113 |
+
|
| 114 |
+
if (phase === "loading")
|
| 115 |
+
return (
|
| 116 |
+
<div className="lpf-page">
|
| 117 |
+
<div className="lpf-card lpf-card--quiet" />
|
| 118 |
+
</div>
|
| 119 |
+
);
|
| 120 |
+
|
| 121 |
+
if (phase === "gone")
|
| 122 |
+
return (
|
| 123 |
+
<div className="lpf-page">
|
| 124 |
+
<div className="lpf-card">
|
| 125 |
+
<h1 className="lpf-title">This form is not available</h1>
|
| 126 |
+
{/* ONE line (DESIGN.md Β§4). There is nothing this person can do about it and no
|
| 127 |
+
detail we may safely give, so the page does not pretend otherwise. */}
|
| 128 |
+
<p className="lpf-desc">The link may have expired or been turned off.</p>
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
);
|
| 132 |
+
|
| 133 |
+
if (phase === "sent")
|
| 134 |
+
return (
|
| 135 |
+
<div className="lpf-page">
|
| 136 |
+
<div className="lpf-card">
|
| 137 |
+
<h1 className="lpf-title">Thank you</h1>
|
| 138 |
+
<p className="lpf-desc">Your response has been recorded.</p>
|
| 139 |
+
<button
|
| 140 |
+
type="button"
|
| 141 |
+
className="lpf-btn"
|
| 142 |
+
onClick={() => {
|
| 143 |
+
setValues({});
|
| 144 |
+
setPhase("ready");
|
| 145 |
+
}}
|
| 146 |
+
>
|
| 147 |
+
Submit another
|
| 148 |
+
</button>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
);
|
| 152 |
+
|
| 153 |
+
if (!spec) return null;
|
| 154 |
+
|
| 155 |
+
return (
|
| 156 |
+
<div className="lpf-page">
|
| 157 |
+
<form className="lpf-card" onSubmit={submit}>
|
| 158 |
+
<h1 className="lpf-title">{spec.title || "Form"}</h1>
|
| 159 |
+
{spec.desc && <p className="lpf-desc">{spec.desc}</p>}
|
| 160 |
+
|
| 161 |
+
{spec.fields.map((f) => (
|
| 162 |
+
<label key={f.key} className="lpf-field">
|
| 163 |
+
<span className="lpf-label">
|
| 164 |
+
{f.label}
|
| 165 |
+
{f.required && <span className="lpf-req" aria-hidden> *</span>}
|
| 166 |
+
</span>
|
| 167 |
+
<FieldControl field={f} value={values[f.key] ?? ""} onChange={set} />
|
| 168 |
+
</label>
|
| 169 |
+
))}
|
| 170 |
+
|
| 171 |
+
{/* β THE HONEYPOT. Off-screen rather than `display:none` β several bot frameworks skip
|
| 172 |
+
hidden inputs on purpose, and a trap they know to skip is not a trap. `tabIndex={-1}`
|
| 173 |
+
and `aria-hidden` keep it away from a keyboard user and a screen reader alike, and
|
| 174 |
+
`autoComplete="off"` stops a browser filling it for a real person, which would
|
| 175 |
+
silently drop their submission. */}
|
| 176 |
+
<input
|
| 177 |
+
className="lpf-hp"
|
| 178 |
+
type="text"
|
| 179 |
+
name={spec.honeypot}
|
| 180 |
+
value={values[spec.honeypot] ?? ""}
|
| 181 |
+
onChange={(e) => set(spec.honeypot, e.target.value)}
|
| 182 |
+
tabIndex={-1}
|
| 183 |
+
autoComplete="off"
|
| 184 |
+
aria-hidden
|
| 185 |
+
/>
|
| 186 |
+
|
| 187 |
+
{error && <p className="lpf-err">{error}</p>}
|
| 188 |
+
<button type="submit" className="lpf-btn lpf-btn--primary" disabled={busy}>
|
| 189 |
+
{busy ? "Sendingβ¦" : spec.submitLabel || "Submit"}
|
| 190 |
+
</button>
|
| 191 |
+
<p className="lpf-brand">Loopable</p>
|
| 192 |
+
</form>
|
| 193 |
+
</div>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/** One control per server type string. An unrecognised type falls through to text β see the
|
| 198 |
+
* `FormField.type` note: a stranger can always type an answer, and a blank where a control
|
| 199 |
+
* should be is the one failure they cannot work around. */
|
| 200 |
+
function FieldControl({
|
| 201 |
+
field,
|
| 202 |
+
value,
|
| 203 |
+
onChange,
|
| 204 |
+
}: {
|
| 205 |
+
field: FormField;
|
| 206 |
+
value: string;
|
| 207 |
+
onChange: (key: string, v: string) => void;
|
| 208 |
+
}) {
|
| 209 |
+
const common = {
|
| 210 |
+
className: "lpf-input",
|
| 211 |
+
value,
|
| 212 |
+
required: field.required,
|
| 213 |
+
onChange: (e: { target: { value: string } }) => onChange(field.key, e.target.value),
|
| 214 |
+
};
|
| 215 |
+
switch (field.type) {
|
| 216 |
+
case "checkbox":
|
| 217 |
+
return (
|
| 218 |
+
<input
|
| 219 |
+
type="checkbox"
|
| 220 |
+
className="lpf-check"
|
| 221 |
+
checked={value === "1"}
|
| 222 |
+
onChange={(e) => onChange(field.key, e.target.checked ? "1" : "")}
|
| 223 |
+
/>
|
| 224 |
+
);
|
| 225 |
+
case "select":
|
| 226 |
+
case "status":
|
| 227 |
+
return (
|
| 228 |
+
<select {...common} className="lpf-input lpf-select">
|
| 229 |
+
{/* An explicit empty option, always. A `<select>` with no blank member has its first
|
| 230 |
+
choice pre-selected, so an untouched optional field silently submits a value the
|
| 231 |
+
person never picked. */}
|
| 232 |
+
<option value="">{field.required ? "Chooseβ¦" : "β"}</option>
|
| 233 |
+
{(field.options ?? []).map((o) => (
|
| 234 |
+
<option key={o} value={o}>{o}</option>
|
| 235 |
+
))}
|
| 236 |
+
</select>
|
| 237 |
+
);
|
| 238 |
+
case "int":
|
| 239 |
+
case "currency":
|
| 240 |
+
case "pct":
|
| 241 |
+
case "rating":
|
| 242 |
+
return <input {...common} type="number" inputMode="decimal" />;
|
| 243 |
+
case "date":
|
| 244 |
+
// β `type="date"` submits ISO (`YYYY-MM-DD`) whatever the browser DISPLAYS, which is
|
| 245 |
+
// exactly what the server's refuse-never-coerce date check demands. A text input here
|
| 246 |
+
// would send whatever the locale suggested and be refused on half the planet.
|
| 247 |
+
return <input {...common} type="date" />;
|
| 248 |
+
case "email":
|
| 249 |
+
return <input {...common} type="email" inputMode="email" />;
|
| 250 |
+
case "phone":
|
| 251 |
+
return <input {...common} type="tel" inputMode="tel" />;
|
| 252 |
+
case "url":
|
| 253 |
+
return <input {...common} type="url" inputMode="url" />;
|
| 254 |
+
case "text":
|
| 255 |
+
return (
|
| 256 |
+
<textarea
|
| 257 |
+
{...common}
|
| 258 |
+
className="lpf-input lpf-textarea"
|
| 259 |
+
rows={value.length > 60 ? 4 : 2}
|
| 260 |
+
minLength={LONG_TEXT_MIN}
|
| 261 |
+
/>
|
| 262 |
+
);
|
| 263 |
+
default:
|
| 264 |
+
return <input {...common} type="text" />;
|
| 265 |
+
}
|
| 266 |
+
}
|
web/src/home/HomePage.tsx
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// home/HomePage.tsx β WAVE 23 item 9 (ruling R7, contract C10): the landing.
|
| 3 |
+
//
|
| 4 |
+
// The layout is `reference/Airtable Home.png`, in OUR tokens: an `h1`, a row of
|
| 5 |
+
// quick-start cards, then the recently-opened databases under Today / Past 7
|
| 6 |
+
// days / Older, with a list/grid toggle. Nothing is copied but the ANATOMY β
|
| 7 |
+
// every colour, size and weight is the Loopable palette (R1's rule for the
|
| 8 |
+
// builder, applied here for the same reason).
|
| 9 |
+
//
|
| 10 |
+
// β THIS PAGE IS CHROME, NOT A GRANTED SURFACE, and the distinction is the one
|
| 11 |
+
// the shell's oldest law turns on (`Shell.tsx` :1039 β "the nav is
|
| 12 |
+
// server-filtered and an undeclared surface is denied"). Home does not violate
|
| 13 |
+
// that law because it cannot: every database it names is resolved from the
|
| 14 |
+
// `entries` the server returned (`homeModel.groupRecents` drops a recent whose
|
| 15 |
+
// key is not in them), and the four cards open doors the rail already offered.
|
| 16 |
+
// There is no payload here that `GET /nav` did not send.
|
| 17 |
+
//
|
| 18 |
+
// It holds NO state of its own beyond the layout toggle. The recents come down
|
| 19 |
+
// with the nav; the four actions are the frame's, because the frame owns the
|
| 20 |
+
// dialogs and the hash.
|
| 21 |
+
// ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
import { useEffect, useState } from "react";
|
| 24 |
+
import { FolderMark } from "../customer-grid/icons";
|
| 25 |
+
import { dbChipClass } from "../shell/nav";
|
| 26 |
+
import type { NavEntry, Recent } from "../shell/nav";
|
| 27 |
+
import {
|
| 28 |
+
HOME_LAYOUT_KEY,
|
| 29 |
+
groupRecents,
|
| 30 |
+
parseLayout,
|
| 31 |
+
} from "./homeModel";
|
| 32 |
+
import type { HomeLayout } from "./homeModel";
|
| 33 |
+
|
| 34 |
+
// ββ the four quick-start marks βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 35 |
+
//
|
| 36 |
+
// Drawn here, in the same 16x16 stroke vocabulary as the rail's `DbIcon`/`AutoIcon`/`BellIcon`
|
| 37 |
+
// β the established pattern in this tree (the shell and the alerts pane each draw their own
|
| 38 |
+
// bell rather than sharing an icon module). DESIGN.md's rule is "extend the set, don't import a
|
| 39 |
+
// new icon language", and these are the same language: 16x16, `currentColor` strokes, no fills,
|
| 40 |
+
// no emoji.
|
| 41 |
+
|
| 42 |
+
/** Templates β stacked sheets, the thing a template gives you. */
|
| 43 |
+
function TemplateIcon() {
|
| 44 |
+
return (
|
| 45 |
+
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 46 |
+
<rect x="2.2" y="4.4" width="11.6" height="9.2" rx="1.4" />
|
| 47 |
+
<path d="M4.4 4.4V2.6h9.4v9.2h-1.8M2.2 7.6h11.6" />
|
| 48 |
+
</svg>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
/** A blank database β the rail's cylinder with the plus that makes one. */
|
| 53 |
+
function NewDbIcon() {
|
| 54 |
+
return (
|
| 55 |
+
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 56 |
+
<ellipse cx="6.6" cy="3.7" rx="4.4" ry="1.8" />
|
| 57 |
+
<path d="M2.2 3.7v6.6c0 1 2 1.8 4.4 1.8" />
|
| 58 |
+
<path d="M11 3.7v3.1" />
|
| 59 |
+
<path d="M11.6 10.4v4M9.6 12.4h4" />
|
| 60 |
+
</svg>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/** An automated database β two nodes and the edge between them, the rail's `AutoIcon` family. */
|
| 65 |
+
function AutomatedIcon() {
|
| 66 |
+
return (
|
| 67 |
+
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 68 |
+
<rect x="1.9" y="5.4" width="4.6" height="5.2" rx="1.1" />
|
| 69 |
+
<rect x="9.5" y="5.4" width="4.6" height="5.2" rx="1.1" />
|
| 70 |
+
<path d="M6.5 8h3" />
|
| 71 |
+
</svg>
|
| 72 |
+
);
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
/** Connect a source β two links of a chain, which is what a connector is. */
|
| 76 |
+
function ConnectIcon() {
|
| 77 |
+
return (
|
| 78 |
+
<svg className="home-card-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 79 |
+
<path d="M6.7 9.3 9.3 6.7" />
|
| 80 |
+
<path d="M7.6 4.6 9.1 3.1a2.7 2.7 0 0 1 3.8 3.8l-1.5 1.5" />
|
| 81 |
+
<path d="M8.4 11.4l-1.5 1.5a2.7 2.7 0 0 1-3.8-3.8l1.5-1.5" />
|
| 82 |
+
</svg>
|
| 83 |
+
);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/** The two layout marks: rows, and a 2x2 of tiles. The reference pair, in our stroke weight. */
|
| 87 |
+
function ListIcon() {
|
| 88 |
+
return (
|
| 89 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" className="home-layout-icon">
|
| 90 |
+
<path d="M2.6 4.4h10.8M2.6 8h10.8M2.6 11.6h10.8" />
|
| 91 |
+
</svg>
|
| 92 |
+
);
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
function GridIcon() {
|
| 96 |
+
return (
|
| 97 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" className="home-layout-icon">
|
| 98 |
+
<rect x="2.5" y="2.5" width="4.6" height="4.6" rx="1" />
|
| 99 |
+
<rect x="8.9" y="2.5" width="4.6" height="4.6" rx="1" />
|
| 100 |
+
<rect x="2.5" y="8.9" width="4.6" height="4.6" rx="1" />
|
| 101 |
+
<rect x="8.9" y="8.9" width="4.6" height="4.6" rx="1" />
|
| 102 |
+
</svg>
|
| 103 |
+
);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* One quick-start card. A `<button>` β every one of these opens a dialog or a wizard rather
|
| 108 |
+
* than navigating, and a link that does not link is a lie the browser's status bar tells.
|
| 109 |
+
*
|
| 110 |
+
* β `type="button"` and the UA reset ride the CLASS (DESIGN.md 4's button rule: a styled
|
| 111 |
+
* `<button>` sets `border/background/font/color` in its base rule, or it leaks the user agent's
|
| 112 |
+
* chrome β the wave-19 item-3 "mystery outer lines and tinted fill").
|
| 113 |
+
*/
|
| 114 |
+
function QuickCard({
|
| 115 |
+
icon,
|
| 116 |
+
title,
|
| 117 |
+
detail,
|
| 118 |
+
onClick,
|
| 119 |
+
}: {
|
| 120 |
+
icon: React.ReactNode;
|
| 121 |
+
title: string;
|
| 122 |
+
/** ONE line. R13: the app does not narrate itself, and this is a passing-through surface. */
|
| 123 |
+
detail: string;
|
| 124 |
+
onClick: () => void;
|
| 125 |
+
}) {
|
| 126 |
+
return (
|
| 127 |
+
<button type="button" className="home-card" onClick={onClick}>
|
| 128 |
+
<span className="home-card-head">
|
| 129 |
+
{icon}
|
| 130 |
+
<span className="home-card-title">{title}</span>
|
| 131 |
+
</span>
|
| 132 |
+
<span className="home-card-detail">{detail}</span>
|
| 133 |
+
</button>
|
| 134 |
+
);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
export default function HomePage({
|
| 138 |
+
entries,
|
| 139 |
+
recents,
|
| 140 |
+
onTemplates,
|
| 141 |
+
onNewDatabase,
|
| 142 |
+
onAutomated,
|
| 143 |
+
onConnectors,
|
| 144 |
+
}: {
|
| 145 |
+
/** The SHAPED, server-filtered nav. The only thing a tile may be drawn from. */
|
| 146 |
+
entries: NavEntry[];
|
| 147 |
+
recents: Recent[];
|
| 148 |
+
onTemplates: () => void;
|
| 149 |
+
onNewDatabase: () => void;
|
| 150 |
+
onAutomated: () => void;
|
| 151 |
+
onConnectors: () => void;
|
| 152 |
+
}) {
|
| 153 |
+
const [layout, setLayout] = useState<HomeLayout>(() => {
|
| 154 |
+
try {
|
| 155 |
+
return parseLayout(localStorage.getItem(HOME_LAYOUT_KEY));
|
| 156 |
+
} catch {
|
| 157 |
+
return "grid";
|
| 158 |
+
}
|
| 159 |
+
});
|
| 160 |
+
useEffect(() => {
|
| 161 |
+
try {
|
| 162 |
+
localStorage.setItem(HOME_LAYOUT_KEY, layout);
|
| 163 |
+
} catch {
|
| 164 |
+
// storage can be blocked; the toggle still works for this session
|
| 165 |
+
}
|
| 166 |
+
}, [layout]);
|
| 167 |
+
|
| 168 |
+
// β THE CLOCK IS READ ONCE, HERE, AND HANDED IN AS NUMBERS. `homeModel` is pure so a gate can
|
| 169 |
+
// assert the boundaries (23:59 vs 00:01, exactly seven days) rather than whatever today is.
|
| 170 |
+
// `todayStart` is the reader's LOCAL midnight β "Today" means the reader's today, which is the
|
| 171 |
+
// one clock this feature is allowed to consult (see the model's header note).
|
| 172 |
+
const now = Math.floor(Date.now() / 1000);
|
| 173 |
+
const midnight = new Date();
|
| 174 |
+
midnight.setHours(0, 0, 0, 0);
|
| 175 |
+
const sections = groupRecents(recents, entries, now, Math.floor(midnight.getTime() / 1000));
|
| 176 |
+
|
| 177 |
+
return (
|
| 178 |
+
<div className="shell-home">
|
| 179 |
+
<h1 className="home-title">Home</h1>
|
| 180 |
+
|
| 181 |
+
<div className="home-cards">
|
| 182 |
+
{/* β THE DETAIL LINES ARE BYTE-IDENTICAL TO THE DIALOG'S ROWS, and that is deliberate
|
| 183 |
+
rather than lazy. Each of these cards opens the same door as a row in the
|
| 184 |
+
New-database dialog, and the first draft described the same two things in four
|
| 185 |
+
slightly different sentences ("fills and keeps up to date" vs "creates and keeps up
|
| 186 |
+
to date"; "ready-made views" vs "a curated set of views"). Two words for one thing is
|
| 187 |
+
how a reader concludes they are two things β DESIGN.md 1's "variety is a defect".
|
| 188 |
+
The TITLES do differ, and that is a register difference the reference makes too: a
|
| 189 |
+
card is an invitation ("Start with templates"), a menu row is a choice ("From a
|
| 190 |
+
template"). */}
|
| 191 |
+
<QuickCard
|
| 192 |
+
icon={<TemplateIcon />}
|
| 193 |
+
title="Start with templates"
|
| 194 |
+
detail="Add a curated set of views to a database you already have."
|
| 195 |
+
onClick={onTemplates}
|
| 196 |
+
/>
|
| 197 |
+
<QuickCard
|
| 198 |
+
icon={<NewDbIcon />}
|
| 199 |
+
title="New database"
|
| 200 |
+
detail="A blank database in this workspace."
|
| 201 |
+
onClick={onNewDatabase}
|
| 202 |
+
/>
|
| 203 |
+
<QuickCard
|
| 204 |
+
icon={<AutomatedIcon />}
|
| 205 |
+
title="Automated database"
|
| 206 |
+
detail="A database an automation creates and keeps up to date."
|
| 207 |
+
onClick={onAutomated}
|
| 208 |
+
/>
|
| 209 |
+
<QuickCard
|
| 210 |
+
icon={<ConnectIcon />}
|
| 211 |
+
title="Connect a source"
|
| 212 |
+
detail="Browse the connectors this workspace can use."
|
| 213 |
+
onClick={onConnectors}
|
| 214 |
+
/>
|
| 215 |
+
</div>
|
| 216 |
+
|
| 217 |
+
<div className="home-recents-bar">
|
| 218 |
+
{/* The reference puts an "Opened anytime" filter here. We do not have one and do not
|
| 219 |
+
pretend to: a control that filters nothing is worse than no control. The toggle is
|
| 220 |
+
the real affordance, so it sits alone. */}
|
| 221 |
+
<div className="home-layout-toggle" role="group" aria-label="Layout">
|
| 222 |
+
<button
|
| 223 |
+
type="button"
|
| 224 |
+
className={"home-layout-btn" + (layout === "list" ? " is-on" : "")}
|
| 225 |
+
aria-pressed={layout === "list"}
|
| 226 |
+
aria-label="List"
|
| 227 |
+
title="List"
|
| 228 |
+
onClick={() => setLayout("list")}
|
| 229 |
+
>
|
| 230 |
+
<ListIcon />
|
| 231 |
+
</button>
|
| 232 |
+
<button
|
| 233 |
+
type="button"
|
| 234 |
+
className={"home-layout-btn" + (layout === "grid" ? " is-on" : "")}
|
| 235 |
+
aria-pressed={layout === "grid"}
|
| 236 |
+
aria-label="Grid"
|
| 237 |
+
title="Grid"
|
| 238 |
+
onClick={() => setLayout("grid")}
|
| 239 |
+
>
|
| 240 |
+
<GridIcon />
|
| 241 |
+
</button>
|
| 242 |
+
</div>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{sections.length === 0 ? (
|
| 246 |
+
// β ONE LINE, AND IT NAMES THE DOOR THAT FILLS IT (DESIGN.md 4 / R13). This is also
|
| 247 |
+
// where wave 18's tenant hero went: a workspace with no databases lands here, and the
|
| 248 |
+
// cards above ARE the "create your first database" affordance the hero carried. Two
|
| 249 |
+
// welcome messages on one screen is the bug Shell.tsx:1321-1326 already documents.
|
| 250 |
+
<p className="home-empty">
|
| 251 |
+
{entries.length === 0
|
| 252 |
+
? "This workspace has no databases yet β create one above, or connect a source."
|
| 253 |
+
: "Nothing opened yet. The databases you open appear here."}
|
| 254 |
+
</p>
|
| 255 |
+
) : (
|
| 256 |
+
sections.map((section) => (
|
| 257 |
+
<section key={section.id} className="home-section">
|
| 258 |
+
<h2 className="home-section-title">{section.title}</h2>
|
| 259 |
+
<div className={"home-tiles is-" + layout}>
|
| 260 |
+
{section.tiles.map((tile) => (
|
| 261 |
+
<a
|
| 262 |
+
key={tile.key}
|
| 263 |
+
className="home-tile"
|
| 264 |
+
href={tile.href}
|
| 265 |
+
{...(tile.external ? { target: "_blank", rel: "noreferrer" } : {})}
|
| 266 |
+
>
|
| 267 |
+
{/* The SAME chip family as the database header and the rail row
|
| 268 |
+
(`dbChipClass` β `.shell-db-chip` + its tone), so a database looks like
|
| 269 |
+
itself everywhere. DESIGN.md 4: new elements join the existing rhythm,
|
| 270 |
+
never a new control shape. The chosen mark wins when there is one; two
|
| 271 |
+
letters otherwise, which is what the reference tiles show.
|
| 272 |
+
|
| 273 |
+
β AND THOSE TWO BRANCHES CARRY A CONTRAST RULE β DO NOT "SIMPLIFY" THEM
|
| 274 |
+
APART. `dbChipClass` returns a TONE class only when there IS an icon, and
|
| 275 |
+
an icon is exactly when we draw a GLYPH. That matters because the tones are
|
| 276 |
+
measured for a graphical object, not for text: white on `--lp-blue-deep` is
|
| 277 |
+
3.29:1 (the stylesheet publishes every pairing beside its declaration) β
|
| 278 |
+
over the 3:1 bar a 16px glyph must clear, UNDER the 4.5:1 bar 12px letters
|
| 279 |
+
must. The fallback branch has no icon, so it takes the BASE chip
|
| 280 |
+
(`--lp-primary`, 7.71:1) and that is where the letters go. Forcing a tone
|
| 281 |
+
onto the initials, or letters onto a toned chip, would put 12px text at
|
| 282 |
+
3.29:1 on screen. The two branches agree by construction; keep them
|
| 283 |
+
agreeing. */}
|
| 284 |
+
<span className={dbChipClass(tile.icon) + " home-tile-chip"} aria-hidden="true">
|
| 285 |
+
{tile.icon ? <FolderMark icon={tile.icon} size={16} /> : tile.initials}
|
| 286 |
+
</span>
|
| 287 |
+
<span className="home-tile-text">
|
| 288 |
+
<span className="home-tile-name">{tile.label}</span>
|
| 289 |
+
<span className="home-tile-ago">{tile.ago}</span>
|
| 290 |
+
</span>
|
| 291 |
+
</a>
|
| 292 |
+
))}
|
| 293 |
+
</div>
|
| 294 |
+
</section>
|
| 295 |
+
))
|
| 296 |
+
)}
|
| 297 |
+
</div>
|
| 298 |
+
);
|
| 299 |
+
}
|
web/src/home/TemplatePicker.tsx
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// home/TemplatePicker.tsx β WAVE 23 item 7 (R10, contract C12, wiring W23-W6).
|
| 3 |
+
//
|
| 4 |
+
// A template is a bundle of SAVED VIEWS applied to a database you already have β
|
| 5 |
+
// it does not create a table. So the picker asks two questions in the order a
|
| 6 |
+
// person actually answers them: WHICH DATABASE, then WHICH TEMPLATE. The second
|
| 7 |
+
// list is a function of the first, because the server filters templates by the
|
| 8 |
+
// target's real columns (`view_templates.missing_columns`), and offering one the
|
| 9 |
+
// apply door would refuse is a control that lies.
|
| 10 |
+
//
|
| 11 |
+
// It renders inside the New-database dialog's "From a template" mode rather than
|
| 12 |
+
// as a third modal: one dialog, several doors (DESIGN.md Β§4 β new elements join
|
| 13 |
+
// the existing rhythm).
|
| 14 |
+
// ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
import { useEffect, useState } from "react";
|
| 17 |
+
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 18 |
+
import type { NavEntry } from "../shell/nav";
|
| 19 |
+
|
| 20 |
+
/** One row of `GET /templates?table=` β the server's vocabulary, read as strings (the wave-9
|
| 21 |
+
* law: no client union over a server list). */
|
| 22 |
+
interface TemplateRow {
|
| 23 |
+
key: string;
|
| 24 |
+
label: string;
|
| 25 |
+
desc: string;
|
| 26 |
+
source: string;
|
| 27 |
+
views: number;
|
| 28 |
+
alert: boolean;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
type Load =
|
| 32 |
+
| { phase: "idle" }
|
| 33 |
+
| { phase: "loading" }
|
| 34 |
+
| { phase: "ready"; rows: TemplateRow[] }
|
| 35 |
+
| { phase: "error"; message: string };
|
| 36 |
+
|
| 37 |
+
function parseRows(body: unknown): TemplateRow[] {
|
| 38 |
+
const raw = (body as { templates?: unknown } | null)?.templates;
|
| 39 |
+
if (!Array.isArray(raw)) return [];
|
| 40 |
+
const out: TemplateRow[] = [];
|
| 41 |
+
for (const item of raw) {
|
| 42 |
+
if (!item || typeof item !== "object") continue;
|
| 43 |
+
const t = item as Record<string, unknown>;
|
| 44 |
+
const key = typeof t.key === "string" ? t.key : "";
|
| 45 |
+
const label = typeof t.label === "string" ? t.label : "";
|
| 46 |
+
if (!key || !label) continue;
|
| 47 |
+
out.push({
|
| 48 |
+
key,
|
| 49 |
+
label,
|
| 50 |
+
desc: typeof t.desc === "string" ? t.desc : "",
|
| 51 |
+
source: typeof t.source === "string" ? t.source : "",
|
| 52 |
+
views: typeof t.views === "number" ? t.views : 0,
|
| 53 |
+
alert: t.alert === true,
|
| 54 |
+
});
|
| 55 |
+
}
|
| 56 |
+
return out;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export default function TemplatePicker({
|
| 60 |
+
entries,
|
| 61 |
+
onToast,
|
| 62 |
+
onDone,
|
| 63 |
+
}: {
|
| 64 |
+
/** The databases this session may open β the shaped, server-filtered nav, minus Automation. */
|
| 65 |
+
entries: NavEntry[];
|
| 66 |
+
onToast: (message: string) => void;
|
| 67 |
+
/** Applied: close the dialog and open the database, where the new views now are. */
|
| 68 |
+
onDone: (tableKey: string) => void;
|
| 69 |
+
}) {
|
| 70 |
+
// Only real destinations: a group head is a folder label with no table behind it.
|
| 71 |
+
const tables = entries.filter((e) => e.kind === "native");
|
| 72 |
+
const [table, setTable] = useState(tables[0]?.key ?? "");
|
| 73 |
+
const [load, setLoad] = useState<Load>({ phase: "idle" });
|
| 74 |
+
const [busy, setBusy] = useState("");
|
| 75 |
+
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
if (!table) {
|
| 78 |
+
setLoad({ phase: "ready", rows: [] });
|
| 79 |
+
return;
|
| 80 |
+
}
|
| 81 |
+
let dead = false;
|
| 82 |
+
setLoad({ phase: "loading" });
|
| 83 |
+
void (async () => {
|
| 84 |
+
try {
|
| 85 |
+
const res = await fetch(
|
| 86 |
+
`${API_V1}/templates?table=${encodeURIComponent(table)}`,
|
| 87 |
+
{ credentials: CREDENTIALS }
|
| 88 |
+
);
|
| 89 |
+
const body = (await res.json().catch(() => null)) as
|
| 90 |
+
| { error?: { message?: string } }
|
| 91 |
+
| null;
|
| 92 |
+
if (dead) return;
|
| 93 |
+
if (!res.ok) {
|
| 94 |
+
setLoad({
|
| 95 |
+
phase: "error",
|
| 96 |
+
message: body?.error?.message || `The server answered ${res.status}.`,
|
| 97 |
+
});
|
| 98 |
+
return;
|
| 99 |
+
}
|
| 100 |
+
setLoad({ phase: "ready", rows: parseRows(body) });
|
| 101 |
+
} catch {
|
| 102 |
+
if (!dead) setLoad({ phase: "error", message: "Cannot reach the server." });
|
| 103 |
+
}
|
| 104 |
+
})();
|
| 105 |
+
return () => {
|
| 106 |
+
dead = true;
|
| 107 |
+
};
|
| 108 |
+
}, [table]);
|
| 109 |
+
|
| 110 |
+
const apply = (key: string) => {
|
| 111 |
+
setBusy(key);
|
| 112 |
+
void (async () => {
|
| 113 |
+
try {
|
| 114 |
+
const res = await fetch(
|
| 115 |
+
`${API_V1}/templates/${encodeURIComponent(key)}/apply`,
|
| 116 |
+
{
|
| 117 |
+
method: "POST",
|
| 118 |
+
credentials: CREDENTIALS,
|
| 119 |
+
headers: { "Content-Type": "application/json" },
|
| 120 |
+
body: JSON.stringify({ table }),
|
| 121 |
+
}
|
| 122 |
+
);
|
| 123 |
+
const body = (await res.json().catch(() => null)) as
|
| 124 |
+
| { views?: { name?: string }[]; alerted?: boolean; error?: { message?: string } }
|
| 125 |
+
| null;
|
| 126 |
+
setBusy("");
|
| 127 |
+
if (!res.ok) {
|
| 128 |
+
// β THE SERVER'S SENTENCE, VERBATIM. A refusal here names the COLUMNS the target is
|
| 129 |
+
// missing, which is the only thing that tells the reader they picked the wrong
|
| 130 |
+
// database β replacing it with "could not apply" would throw that away.
|
| 131 |
+
onToast(body?.error?.message || `The server answered ${res.status}.`);
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
const n = (body?.views ?? []).length;
|
| 135 |
+
onToast(
|
| 136 |
+
`${n} view${n === 1 ? "" : "s"} added.` +
|
| 137 |
+
(body?.alerted ? " An alert is now watching the first one." : "")
|
| 138 |
+
);
|
| 139 |
+
onDone(table);
|
| 140 |
+
} catch {
|
| 141 |
+
setBusy("");
|
| 142 |
+
onToast("Cannot reach the server.");
|
| 143 |
+
}
|
| 144 |
+
})();
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
// β A WORKSPACE WITH NO DATABASES IS NOT "NO TEMPLATE FITS". Without this branch the select
|
| 148 |
+
// renders empty, `table` is "", the fetch is skipped, and the reader is told
|
| 149 |
+
// "No template fits this database's columns." about a database they do not have β a false
|
| 150 |
+
// sentence, on the FIRST thing a brand-new workspace can click (Home's "Start with templates"
|
| 151 |
+
// card sits on exactly the empty state the tenant hero used to own). The distinction is the
|
| 152 |
+
// same one the Database flyout already makes between a search that matched nothing and a
|
| 153 |
+
// workspace that holds nothing; this branch is the third place it has to be made.
|
| 154 |
+
if (tables.length === 0) {
|
| 155 |
+
return (
|
| 156 |
+
<p className="home-tpl-note">
|
| 157 |
+
A template adds views to a database. Create one first β "Blank" above.
|
| 158 |
+
</p>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
return (
|
| 163 |
+
<div className="home-tpl">
|
| 164 |
+
<label className="home-tpl-target">
|
| 165 |
+
<span className="home-tpl-label">Apply to</span>
|
| 166 |
+
{/* β A `<select>` MUST CARRY ITS VALUE. A select rendered without one shows its FIRST
|
| 167 |
+
option while the state says something else β the wave-22 scar ([[cg-condition-builder-items]]);
|
| 168 |
+
the control then reads as "customer_data" and applies to whatever `table` happens to
|
| 169 |
+
hold. Bound, not defaulted. */}
|
| 170 |
+
<select
|
| 171 |
+
className="home-tpl-select"
|
| 172 |
+
value={table}
|
| 173 |
+
onChange={(e) => setTable(e.target.value)}
|
| 174 |
+
>
|
| 175 |
+
{tables.map((t) => (
|
| 176 |
+
<option key={t.key} value={t.key}>
|
| 177 |
+
{t.label}
|
| 178 |
+
</option>
|
| 179 |
+
))}
|
| 180 |
+
</select>
|
| 181 |
+
</label>
|
| 182 |
+
|
| 183 |
+
{load.phase === "loading" ? (
|
| 184 |
+
<div className="home-tpl-note">
|
| 185 |
+
<span className="lp-spin" role="status" aria-label="Loading" />
|
| 186 |
+
</div>
|
| 187 |
+
) : null}
|
| 188 |
+
{load.phase === "error" ? <p className="shell-newdb-err">{load.message}</p> : null}
|
| 189 |
+
{load.phase === "ready" && load.rows.length === 0 ? (
|
| 190 |
+
// ONE line (R13). And it says the RIGHT thing: nothing fits THIS database, which is not
|
| 191 |
+
// the same as "there are no templates".
|
| 192 |
+
<p className="home-tpl-note">No template fits this database's columns.</p>
|
| 193 |
+
) : null}
|
| 194 |
+
|
| 195 |
+
{load.phase === "ready" && load.rows.length > 0 ? (
|
| 196 |
+
<ul className="home-tpl-list">
|
| 197 |
+
{load.rows.map((row) => (
|
| 198 |
+
<li key={row.key} className="home-tpl-row">
|
| 199 |
+
<span className="home-tpl-text">
|
| 200 |
+
<span className="home-tpl-name">{row.label}</span>
|
| 201 |
+
<span className="home-tpl-desc">{row.desc}</span>
|
| 202 |
+
</span>
|
| 203 |
+
<button
|
| 204 |
+
type="button"
|
| 205 |
+
className="login-submit home-tpl-apply"
|
| 206 |
+
disabled={!!busy}
|
| 207 |
+
onClick={() => apply(row.key)}
|
| 208 |
+
>
|
| 209 |
+
{busy === row.key ? "Addingβ¦" : `Add ${row.views}`}
|
| 210 |
+
</button>
|
| 211 |
+
</li>
|
| 212 |
+
))}
|
| 213 |
+
</ul>
|
| 214 |
+
) : null}
|
| 215 |
+
</div>
|
| 216 |
+
);
|
| 217 |
+
}
|
web/src/home/homeModel.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// home/homeModel.ts β WAVE 23 item 9 (ruling R7, contract C10): the Home
|
| 3 |
+
// landing's PURE half. React-free and fetch-free, so `verify_home.py` can run it
|
| 4 |
+
// under node the way `verify_alerts.py` runs the inbox model.
|
| 5 |
+
//
|
| 6 |
+
// Everything here is a function of values that were passed in β including the
|
| 7 |
+
// clock. `todayStart` and `now` are PARAMETERS, never `Date.now()` calls, for two
|
| 8 |
+
// reasons that pull the same way:
|
| 9 |
+
//
|
| 10 |
+
// Β· a gate can then assert the boundary cases (23:59 vs 00:01, exactly seven
|
| 11 |
+
// days) instead of asserting whatever today happens to be;
|
| 12 |
+
// Β· [[date-window-vocabulary]]'s standing rule β `today` is a parameter β was
|
| 13 |
+
// written for the server's window vocabulary and holds just as well here.
|
| 14 |
+
//
|
| 15 |
+
// β THE ONE CLOCK THIS FEATURE MAY READ IS THE READER'S OWN, and that is not a
|
| 16 |
+
// contradiction of `alertsModel.stampText`'s "never touch a clock". Two different
|
| 17 |
+
// statements: a notification re-states WHEN A SERVER EVENT HAPPENED (re-deriving
|
| 18 |
+
// it in the browser is how a tenant a day ahead gets told an event happened
|
| 19 |
+
// tomorrow), while "Opened 30 minutes ago" states HOW LONG AGO THE READER
|
| 20 |
+
// THEMSELVES DID SOMETHING, and the Today bucket means the reader's today. The
|
| 21 |
+
// component reads the clock once and hands the numbers in here.
|
| 22 |
+
// ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
import type { NavEntry, Recent } from "../shell/nav";
|
| 25 |
+
import type { FolderIcon } from "../customer-grid/types";
|
| 26 |
+
|
| 27 |
+
/** One rendered recents tile: the RESOLVED database, never the raw stamp. */
|
| 28 |
+
export interface RecentTile {
|
| 29 |
+
key: string;
|
| 30 |
+
label: string;
|
| 31 |
+
icon?: FolderIcon;
|
| 32 |
+
/** Epoch seconds, as stored. Kept so a tile can be re-sorted without re-fetching. */
|
| 33 |
+
at: number;
|
| 34 |
+
/** "Opened 30 minutes ago" β already assembled, so the component holds no clock logic. */
|
| 35 |
+
ago: string;
|
| 36 |
+
/** The chip's two letters. */
|
| 37 |
+
initials: string;
|
| 38 |
+
/**
|
| 39 |
+
* The entry's OWN href, carried through rather than rebuilt as `#/${key}`.
|
| 40 |
+
*
|
| 41 |
+
* β A TILE THAT REBUILDS ITS OWN LINK IS A SECOND ROUTER. `shapeNav` already decided what a
|
| 42 |
+
* key resolves to β `#/key` for a native surface, the current application's deep link for a
|
| 43 |
+
* hand-off β and a `#/${key}` assembled here would send a hand-off row to a hash this shell
|
| 44 |
+
* cannot render. Today only native keys are ever stamped (the frame stamps `active.kind ===
|
| 45 |
+
* "native"` alone), so this is defence rather than a fix; it costs one field and removes the
|
| 46 |
+
* class.
|
| 47 |
+
*/
|
| 48 |
+
href: string;
|
| 49 |
+
/** A hand-off opens the current application in a new tab, and the tile says so. */
|
| 50 |
+
external: boolean;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
/** One section of the recents list. `title` is the heading; an EMPTY section is never emitted. */
|
| 54 |
+
export interface RecentSection {
|
| 55 |
+
id: "today" | "week" | "older";
|
| 56 |
+
title: string;
|
| 57 |
+
tiles: RecentTile[];
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const SECOND = 1;
|
| 61 |
+
const MINUTE = 60 * SECOND;
|
| 62 |
+
const HOUR = 60 * MINUTE;
|
| 63 |
+
export const DAY = 24 * HOUR;
|
| 64 |
+
|
| 65 |
+
/** "Past 7 days" means the seven days BEFORE today β today has its own section above it. */
|
| 66 |
+
export const WEEK_DAYS = 7;
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* The chip's two letters.
|
| 70 |
+
*
|
| 71 |
+
* β `\p{L}\p{N}` AND NOT `[A-Za-z0-9]`, and this shell has already paid for that once: the
|
| 72 |
+
* account monogram at `Shell.tsx:299` carries the same note, because the Streamlit host's
|
| 73 |
+
* `_account_css` uses Python's `str.isalnum()` and an ASCII-only class here would give a
|
| 74 |
+
* non-Latin name an initial in one shell and a blank circle in the other. A database called
|
| 75 |
+
* "ΠΠ»ΠΈΠ΅Π½ΡΡ" gets "ΠΠ", not an empty tile.
|
| 76 |
+
*
|
| 77 |
+
* Two letters, not one: the reference tiles read "Un" / "Aa", and a single letter over a 26px
|
| 78 |
+
* chip is a bullet point rather than a name.
|
| 79 |
+
*/
|
| 80 |
+
export function initials(label: string): string {
|
| 81 |
+
const chars = String(label ?? "").match(/[\p{L}\p{N}]/gu) ?? [];
|
| 82 |
+
return chars.slice(0, 2).join("").toUpperCase();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* "Opened N ago", in the largest unit that is still true.
|
| 87 |
+
*
|
| 88 |
+
* Rounds DOWN throughout (`Math.floor`), so a tile never claims more time has passed than has:
|
| 89 |
+
* at 119 minutes this says "1 hour ago", never "2 hours ago". A stamp in the FUTURE β a clock
|
| 90 |
+
* skew between the browser and the host β clamps to "just now" rather than rendering a negative
|
| 91 |
+
* count, which is the only honest thing a relative label can say about it.
|
| 92 |
+
*/
|
| 93 |
+
export function agoText(at: number, now: number): string {
|
| 94 |
+
const d = Math.max(0, Math.floor(now) - Math.floor(at));
|
| 95 |
+
if (d < MINUTE) return "Opened just now";
|
| 96 |
+
const unit = (n: number, one: string) => `Opened ${n} ${one}${n === 1 ? "" : "s"} ago`;
|
| 97 |
+
if (d < HOUR) return unit(Math.floor(d / MINUTE), "minute");
|
| 98 |
+
if (d < DAY) return unit(Math.floor(d / HOUR), "hour");
|
| 99 |
+
return unit(Math.floor(d / DAY), "day");
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
/**
|
| 103 |
+
* Which section a stamp belongs to.
|
| 104 |
+
*
|
| 105 |
+
* `todayStart` is the epoch second of the reader's LOCAL midnight β computed once by the
|
| 106 |
+
* component, so the boundary is the reader's calendar day rather than UTC's. Anything at or
|
| 107 |
+
* after it is Today; the seven days before that are the week; everything else is Older.
|
| 108 |
+
*/
|
| 109 |
+
export function bucketOf(at: number, todayStart: number): RecentSection["id"] {
|
| 110 |
+
if (at >= todayStart) return "today";
|
| 111 |
+
if (at >= todayStart - WEEK_DAYS * DAY) return "week";
|
| 112 |
+
return "older";
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const TITLES: Record<RecentSection["id"], string> = {
|
| 116 |
+
today: "Today",
|
| 117 |
+
week: "Past 7 days",
|
| 118 |
+
older: "Older",
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
/**
|
| 122 |
+
* β THE C10 LAW, AS CODE: Home renders ONLY what `/nav` already granted.
|
| 123 |
+
*
|
| 124 |
+
* A recent is a KEY and a TIME. Everything a tile shows β its name, its chosen mark, whether it
|
| 125 |
+
* exists at all β is resolved from `entries`, which is the shaped, server-filtered nav payload.
|
| 126 |
+
* So a database that was revoked, deleted, or never belonged to this account cannot appear on
|
| 127 |
+
* Home even though its stamp is still in the reader's own recents bucket: there is nothing here
|
| 128 |
+
* to draw it FROM.
|
| 129 |
+
*
|
| 130 |
+
* That is what makes `#/home` chrome rather than a surface. The server prunes on read as well
|
| 131 |
+
* (`routes_nav._read_recents`), so this is the second of two walls rather than the only one β
|
| 132 |
+
* but it is the one that would still hold if the payload ever widened.
|
| 133 |
+
*
|
| 134 |
+
* β GROUP HEADS ARE EXCLUDED. A `kind: "group"` entry is a folder label with no destination;
|
| 135 |
+
* a tile for one would be a card you cannot open. `defaultRoute` makes the same distinction.
|
| 136 |
+
*/
|
| 137 |
+
export function groupRecents(
|
| 138 |
+
recents: Recent[],
|
| 139 |
+
entries: NavEntry[],
|
| 140 |
+
now: number,
|
| 141 |
+
todayStart: number
|
| 142 |
+
): RecentSection[] {
|
| 143 |
+
const byKey = new Map(entries.filter((e) => e.kind !== "group").map((e) => [e.key, e]));
|
| 144 |
+
const buckets: Record<RecentSection["id"], RecentTile[]> = { today: [], week: [], older: [] };
|
| 145 |
+
for (const r of [...recents].sort((a, b) => b.at - a.at)) {
|
| 146 |
+
const entry = byKey.get(r.key);
|
| 147 |
+
// No entry, or an entry with nowhere to go: not a tile. `href` is absent only on a group
|
| 148 |
+
// head, which the map above already excluded β this is the type-level restatement of that,
|
| 149 |
+
// and it means a tile always has a destination rather than being a card that does nothing.
|
| 150 |
+
if (!entry || !entry.href) continue;
|
| 151 |
+
buckets[bucketOf(r.at, todayStart)].push({
|
| 152 |
+
key: entry.key,
|
| 153 |
+
label: entry.label,
|
| 154 |
+
...(entry.icon ? { icon: entry.icon } : {}),
|
| 155 |
+
at: r.at,
|
| 156 |
+
ago: agoText(r.at, now),
|
| 157 |
+
initials: initials(entry.label),
|
| 158 |
+
href: entry.href,
|
| 159 |
+
external: entry.kind === "handoff",
|
| 160 |
+
});
|
| 161 |
+
}
|
| 162 |
+
const out: RecentSection[] = [];
|
| 163 |
+
for (const id of ["today", "week", "older"] as const) {
|
| 164 |
+
// An empty section is not rendered. A heading with nothing under it says "there should be
|
| 165 |
+
// something here", which is a different (and false) statement from "you have not opened
|
| 166 |
+
// anything today".
|
| 167 |
+
if (buckets[id].length) out.push({ id, title: TITLES[id], tiles: buckets[id] });
|
| 168 |
+
}
|
| 169 |
+
return out;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/**
|
| 173 |
+
* The server's recents, plus the stamps THIS SESSION made since the nav was fetched.
|
| 174 |
+
*
|
| 175 |
+
* β WITHOUT THIS, HOME IS ALWAYS ONE NAVIGATION STALE β and it is the wave's headline feature
|
| 176 |
+
* that would be stale. `recents` rides the `GET /nav` payload (one round trip per sign-in, per
|
| 177 |
+
* `navEpoch` bump), while the stamp is a fire-and-forget POST that changes nothing the client
|
| 178 |
+
* holds. So: open a database, go Home, and the database you just opened is not in Today. The
|
| 179 |
+
* alternative β refetching the whole nav on every route change to pick up one integer β is a
|
| 180 |
+
* request per navigation for a decoration.
|
| 181 |
+
*
|
| 182 |
+
* LOCAL WINS ON A KEY, always: it is strictly newer by construction (it happened after the
|
| 183 |
+
* fetch). And it is deliberately NOT persisted anywhere β a reload re-asks the server, which is
|
| 184 |
+
* the only end that actually knows.
|
| 185 |
+
*/
|
| 186 |
+
export function mergeRecents(server: Recent[], local: Record<string, number>): Recent[] {
|
| 187 |
+
const byKey = new Map<string, number>();
|
| 188 |
+
for (const r of server) byKey.set(r.key, r.at);
|
| 189 |
+
for (const [key, at] of Object.entries(local)) {
|
| 190 |
+
const cur = byKey.get(key);
|
| 191 |
+
if (cur === undefined || at > cur) byKey.set(key, at);
|
| 192 |
+
}
|
| 193 |
+
return [...byKey.entries()]
|
| 194 |
+
.map(([key, at]) => ({ key, at }))
|
| 195 |
+
.sort((a, b) => b.at - a.at);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
// ββ the list/grid toggle ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 199 |
+
|
| 200 |
+
export type HomeLayout = "grid" | "list";
|
| 201 |
+
|
| 202 |
+
/** Per-browser, like the nav's collapsed state β a display preference, not account state. */
|
| 203 |
+
export const HOME_LAYOUT_KEY = "aios-home-layout";
|
| 204 |
+
|
| 205 |
+
/** Anything unrecognised reads as `grid`, which is the reference layout and the denser one. */
|
| 206 |
+
export function parseLayout(raw: unknown): HomeLayout {
|
| 207 |
+
return raw === "list" ? "list" : "grid";
|
| 208 |
+
}
|
web/src/index.css
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/settings/permsModel.ts
CHANGED
|
@@ -152,6 +152,10 @@ const FIELD_TYPE_TABLE: Record<FieldType, true> = {
|
|
| 152 |
// names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of
|
| 153 |
// this file.
|
| 154 |
image: true,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
};
|
| 156 |
|
| 157 |
export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
|
|
|
|
| 152 |
// names is a listing here. Nowhere near `SettingsSection` / the rail, which is B's half of
|
| 153 |
// this file.
|
| 154 |
image: true,
|
| 155 |
+
// Wave-23 C7 (added by session D β `settings/**` is frozen this wave and this ONE key is the
|
| 156 |
+
// exception the freeze cannot cover: the alarm four lines up is a COMPILE error, so the union
|
| 157 |
+
// and this listing cannot land in two different changes. Posted in D's mailbox for C.)
|
| 158 |
+
json: true,
|
| 159 |
};
|
| 160 |
|
| 161 |
export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
|
web/src/shell/Shell.tsx
CHANGED
|
@@ -31,21 +31,30 @@
|
|
| 31 |
// ---------------------------------------------------------------------------
|
| 32 |
|
| 33 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 34 |
-
import
|
|
|
|
| 35 |
import AutomationSurface from "../automation/AutomationSurface";
|
| 36 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 37 |
import { clearCustomersCache } from "../customer-grid/apiBridge";
|
| 38 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 39 |
// ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows,
|
| 40 |
// so it no longer has to tell the grid that it did.
|
| 41 |
-
import { API_V1, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT } from "../apiContract";
|
| 42 |
import { PageSurface } from "../pages/PageSurface";
|
| 43 |
import { SettingsModal } from "../settings/SettingsModal";
|
| 44 |
import type { SettingsSection } from "../settings/SettingsModal";
|
| 45 |
import { Brand } from "./Brand";
|
| 46 |
import LoginPage from "./LoginPage";
|
| 47 |
-
import { EMPTY_NAV_PREFS, ENVELOPE_KEYS, MAX_NAV_FOLDERS, appLink, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| 48 |
-
import type { NavEntry, NavMetaPatch, NavPage, NavPrefs } from "./nav";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
|
| 50 |
import AlertsPane from "../alerts/AlertsPane";
|
| 51 |
import { createAlert, fetchInbox } from "../alerts/alertsApi";
|
|
@@ -83,6 +92,71 @@ const NAV_DRAG_TYPE = "application/x-loopable-nav";
|
|
| 83 |
/** A stable identity for "no nav yet" β see its use below. */
|
| 84 |
const NO_ENTRIES: NavEntry[] = [];
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
type Session =
|
| 87 |
| { phase: "checking" }
|
| 88 |
| { phase: "anon" }
|
|
@@ -91,10 +165,19 @@ type Session =
|
|
| 91 |
type Nav =
|
| 92 |
| { phase: "idle" }
|
| 93 |
| { phase: "loading" }
|
| 94 |
-
| {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
| { phase: "error" };
|
| 96 |
|
| 97 |
const NO_UTILITY: NavPage[] = [];
|
|
|
|
|
|
|
| 98 |
|
| 99 |
function useHashRoute(): string {
|
| 100 |
const read = () => window.location.hash.replace(/^#\/?/, "");
|
|
@@ -173,7 +256,7 @@ function ExtIcon() {
|
|
| 173 |
* The chip is decorative and says so: the name beside it is real text, so a
|
| 174 |
* second announcement of the same fact is noise to a screen reader.
|
| 175 |
*/
|
| 176 |
-
function DbHead({ label, icon }: { label: string; icon?: FolderIcon }) {
|
| 177 |
return (
|
| 178 |
<div className="shell-db-head">
|
| 179 |
<span className={dbChipClass(icon)} aria-hidden="true">
|
|
@@ -181,8 +264,15 @@ function DbHead({ label, icon }: { label: string; icon?: FolderIcon }) {
|
|
| 181 |
the same pair the rail row draws, so the header and the nav agree. Both
|
| 182 |
paint in the chip's ink (the stylesheet's two overrides): `FolderMark`
|
| 183 |
would otherwise stroke its pastel `-deep`, which is measured against the
|
| 184 |
-
WHITE rail and disappears on its own tone.
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</span>
|
| 187 |
{/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself,
|
| 188 |
and the name of the thing you are looking at is what a heading is for. Every other
|
|
@@ -242,6 +332,50 @@ function BellIcon() {
|
|
| 242 |
);
|
| 243 |
}
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
/** Owner item 11 β the rail toggle: three horizontal bars. One glyph for both rails (the
|
| 246 |
* views rail draws the same geometry), so "minimize a panel" reads as one idea. */
|
| 247 |
function RailToggleIcon() {
|
|
@@ -435,6 +569,10 @@ export default function Shell() {
|
|
| 435 |
const [alertsOpen, setAlertsOpen] = useState(false);
|
| 436 |
const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
|
| 437 |
const route = useHashRoute();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
// Owner items 10/11 β the navigation folds to a slim strip: by the toggle in the rail head,
|
| 439 |
// or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
|
| 440 |
// grid). Remembered per browser; expanding is always one click on the same toggle.
|
|
@@ -490,6 +628,75 @@ export default function Shell() {
|
|
| 490 |
setNavCollapsed(false);
|
| 491 |
}, []);
|
| 492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 493 |
// The window signals the data layer raises (apiContract.ts). It talks to the
|
| 494 |
// frame this way because `customer-grid/**` is host-neutral β the same tree
|
| 495 |
// the Streamlit embed ships β and must not import a shell.
|
|
@@ -589,6 +796,7 @@ export default function Shell() {
|
|
| 589 |
// the host does (`core.perms.nav_pages`' own contract).
|
| 590 |
const { main, utility } = splitChrome(r.pages);
|
| 591 |
setNav({ phase: "ready", entries: shapeNav(main, APP_BASE), utility,
|
|
|
|
| 592 |
...(r.empty ? { empty: r.empty } : {}) });
|
| 593 |
}
|
| 594 |
// 401 is not "the nav is broken", it is "the session died under us" β
|
|
@@ -687,6 +895,10 @@ export default function Shell() {
|
|
| 687 |
// scar); `dropTarget` is a folder id or "__root__" (drag out = drop on the list itself).
|
| 688 |
const [dragKey, setDragKey] = useState<string | null>(null);
|
| 689 |
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
const createFolder = useCallback(
|
| 691 |
(name: string) => {
|
| 692 |
const cur = navPrefsRef.current;
|
|
@@ -696,6 +908,14 @@ export default function Shell() {
|
|
| 696 |
}
|
| 697 |
const id = `nf_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
| 698 |
commitPrefs({ folders: [...cur.folders, { id, name }], placement: cur.placement });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
},
|
| 700 |
[commitPrefs]
|
| 701 |
);
|
|
@@ -809,12 +1029,59 @@ export default function Shell() {
|
|
| 809 |
|
| 810 |
// Keep the URL honest in both directions: an anonymous shell sits on
|
| 811 |
// `#/login`, and a signed-in one never does.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 812 |
useEffect(() => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 813 |
if (session.phase === "anon" && route !== LOGIN_ROUTE) {
|
| 814 |
window.location.hash = `#/${LOGIN_ROUTE}`;
|
| 815 |
-
} else if (session.phase === "authed" && route === LOGIN_ROUTE
|
| 816 |
window.location.hash = `#/${defaultRoute(entries)}`;
|
| 817 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 818 |
}, [session.phase, route, entries]);
|
| 819 |
|
| 820 |
const signOut = useCallback(() => {
|
|
@@ -849,26 +1116,55 @@ export default function Shell() {
|
|
| 849 |
return () => window.removeEventListener("keydown", onKey);
|
| 850 |
});
|
| 851 |
// (C3-UT): "+ New database" + the user-table Add-record bar.
|
| 852 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 853 |
const newDbOpenRef = useRef(false);
|
| 854 |
newDbOpenRef.current = newDb !== null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
const createDb = useCallback(async () => {
|
| 856 |
setNewDb((cur) => {
|
| 857 |
if (!cur || cur.busy || !cur.name.trim()) return cur;
|
| 858 |
const name = cur.name;
|
| 859 |
void (async () => {
|
| 860 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 861 |
const res = await fetch(`${API_V1}/tables`, {
|
| 862 |
method: "POST",
|
| 863 |
credentials: CREDENTIALS,
|
| 864 |
headers: { "Content-Type": "application/json" },
|
| 865 |
-
body: JSON.stringify({ label: name.trim() }),
|
| 866 |
});
|
| 867 |
const body = (await res.json().catch(() => null)) as
|
| 868 |
| { key?: string; error?: { message?: string } }
|
| 869 |
| null;
|
| 870 |
if (!res.ok || !body?.key) {
|
| 871 |
-
setNewDb({ name, busy: false,
|
| 872 |
err: body?.error?.message || `The server answered ${res.status}.` });
|
| 873 |
return;
|
| 874 |
}
|
|
@@ -876,7 +1172,7 @@ export default function Shell() {
|
|
| 876 |
setNavEpoch((e) => e + 1);
|
| 877 |
window.location.hash = `#/${body.key}`;
|
| 878 |
} catch {
|
| 879 |
-
setNewDb({ name, busy: false, err: "Cannot reach the server." });
|
| 880 |
}
|
| 881 |
})();
|
| 882 |
return { ...cur, busy: true, err: "" };
|
|
@@ -889,6 +1185,34 @@ export default function Shell() {
|
|
| 889 |
// trailing "+" row (S3's half), where the row it adds is the row you are looking at, so
|
| 890 |
// the shell stops being a second writer of table data.
|
| 891 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 892 |
if (session.phase === "checking") {
|
| 893 |
// Deliberately wordless. A "Loadingβ¦" line here would flash for one round
|
| 894 |
// trip on every load, and the mark says everything the moment needs.
|
|
@@ -935,6 +1259,20 @@ export default function Shell() {
|
|
| 935 |
// only granted surface is Automation that it has no surfaces at all.
|
| 936 |
const automation = entries.find((e) => e.key === "automation" && e.kind === "native");
|
| 937 |
const dbEntries = automation ? entries.filter((e) => e.key !== "automation") : entries;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 938 |
|
| 939 |
return (
|
| 940 |
<div className="shell-root">
|
|
@@ -977,6 +1315,20 @@ export default function Shell() {
|
|
| 977 |
</div>
|
| 978 |
|
| 979 |
<nav className="shell-nav">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
{/* The Analyst slot, above the database list β the host's own IA
|
| 981 |
(app.py:8166 pins "AI assistant" over the nav tree, same label,
|
| 982 |
same sparkle). Wave 18 (owner item 3): the hand-off into Streamlit
|
|
@@ -1065,7 +1417,97 @@ export default function Shell() {
|
|
| 1065 |
) : null}
|
| 1066 |
</button>
|
| 1067 |
|
| 1068 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
className={
|
| 1070 |
"shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
|
| 1071 |
}
|
|
@@ -1088,10 +1530,13 @@ export default function Shell() {
|
|
| 1088 |
movePage(key, null);
|
| 1089 |
}}
|
| 1090 |
>
|
| 1091 |
-
{/* C-SCHEMA: the list folds under the user's folders.
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
|
|
|
|
|
|
|
|
|
| 1095 |
(row) => {
|
| 1096 |
if (row.kind === "folder") {
|
| 1097 |
return (
|
|
@@ -1100,7 +1545,9 @@ export default function Shell() {
|
|
| 1100 |
folder={row.folder}
|
| 1101 |
count={row.count}
|
| 1102 |
open={row.open}
|
| 1103 |
-
|
|
|
|
|
|
|
| 1104 |
onToggle={() => toggleFolder(row.folder.id)}
|
| 1105 |
onRename={(name) => renameFolder(row.folder.id, name)}
|
| 1106 |
onDelete={() => deleteFolder(row.folder.id)}
|
|
@@ -1171,16 +1618,18 @@ export default function Shell() {
|
|
| 1171 |
</div>
|
| 1172 |
);
|
| 1173 |
}
|
| 1174 |
-
//
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
|
|
|
|
|
|
| 1181 |
const link =
|
| 1182 |
item.kind === "native" ? (
|
| 1183 |
-
<a className={cls} href={item.href} {
|
| 1184 |
{inner}
|
| 1185 |
</a>
|
| 1186 |
) : (
|
|
@@ -1189,7 +1638,7 @@ export default function Shell() {
|
|
| 1189 |
href={item.href}
|
| 1190 |
target="_blank"
|
| 1191 |
rel="noreferrer"
|
| 1192 |
-
{
|
| 1193 |
>
|
| 1194 |
{inner}
|
| 1195 |
</a>
|
|
@@ -1206,7 +1655,13 @@ export default function Shell() {
|
|
| 1206 |
(row.folderId ? " is-foldered" : "") +
|
| 1207 |
(dragKey === item.key ? " is-dragging" : "")
|
| 1208 |
}
|
| 1209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1210 |
onDragStart={(e) => {
|
| 1211 |
e.dataTransfer.setData(NAV_DRAG_TYPE, item.key);
|
| 1212 |
e.dataTransfer.effectAllowed = "move";
|
|
@@ -1218,7 +1673,7 @@ export default function Shell() {
|
|
| 1218 |
}}
|
| 1219 |
>
|
| 1220 |
{link}
|
| 1221 |
-
{
|
| 1222 |
<RowMenu
|
| 1223 |
entryLabel={item.label}
|
| 1224 |
canSchema
|
|
@@ -1287,24 +1742,60 @@ export default function Shell() {
|
|
| 1287 |
);
|
| 1288 |
}
|
| 1289 |
)}
|
| 1290 |
-
|
| 1291 |
-
|
| 1292 |
-
|
| 1293 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
|
| 1301 |
-
|
| 1302 |
-
|
| 1303 |
-
|
| 1304 |
-
|
| 1305 |
-
|
| 1306 |
-
|
| 1307 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1308 |
{/* Wave 17 item 3 (R6) β the mark, never the word. "Loadingβ¦" under an
|
| 1309 |
empty rail told the reader what they could already see, and it read
|
| 1310 |
as a nav ITEM for the beat before it vanished. */}
|
|
@@ -1320,22 +1811,13 @@ export default function Shell() {
|
|
| 1320 |
) : null}
|
| 1321 |
{/* β Wave 18: this line is for a MISCONFIGURED ACCOUNT β one whose grants give it
|
| 1322 |
nothing β and it must not fire for a freshly provisioned TENANT, which has no
|
| 1323 |
-
modules by design and is being welcomed
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
|
| 1327 |
{nav.phase === "ready" && entries.length === 0 && !tenantEmptyState ? (
|
| 1328 |
<div className="shell-nav-note">No surfaces are available to this account.</div>
|
| 1329 |
) : null}
|
| 1330 |
-
{/* β THE STANDALONE "+ New database" BUTTON WAS HERE, AND WAVE 19 R11 FOLDED
|
| 1331 |
-
IT INTO "+ Create newβ¦" at the bottom of the database list above. It was the
|
| 1332 |
-
louder half of the owner's complaint about this rail: a `<button>` wearing the
|
| 1333 |
-
user agent's border and grey fill (this class never reset either β see the
|
| 1334 |
-
index.css note), sitting BELOW the list's own quiet "+ New folder" and offering
|
| 1335 |
-
a sibling action in a completely different visual register. One row, one place,
|
| 1336 |
-
two things to create. The collapsed-rail behaviour it carried survives inside
|
| 1337 |
-
`CreateNewRow`: from the 56px strip the "+" opens the rail rather than a
|
| 1338 |
-
popover that would read as coming from nowhere. */}
|
| 1339 |
</nav>
|
| 1340 |
|
| 1341 |
<div
|
|
@@ -1397,7 +1879,39 @@ export default function Shell() {
|
|
| 1397 |
) : active?.kind === "native" && active.key === "automation" ? (
|
| 1398 |
// Wave 18 (C-AUTONAV): the Automation surface β its OWN secondary rail + editor,
|
| 1399 |
// props-free by contract (it fetches /api/v1/automations itself). SESSION D's tree.
|
| 1400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1401 |
) : active?.kind === "native" ? (
|
| 1402 |
// The native grid surfaces β ONE component, topic decided by the route. Wave 16:
|
| 1403 |
// the `#/cohort` route left with the cohort registry row (cohorts are LOCKED VIEWS
|
|
@@ -1432,42 +1946,54 @@ export default function Shell() {
|
|
| 1432 |
</div>
|
| 1433 |
) : active ? (
|
| 1434 |
<StranglerPage entry={active} />
|
| 1435 |
-
) :
|
| 1436 |
-
//
|
| 1437 |
-
//
|
| 1438 |
-
//
|
| 1439 |
-
//
|
| 1440 |
-
//
|
| 1441 |
-
|
| 1442 |
-
|
| 1443 |
-
//
|
| 1444 |
-
|
| 1445 |
-
|
| 1446 |
-
|
| 1447 |
-
|
| 1448 |
-
|
| 1449 |
-
|
| 1450 |
-
|
| 1451 |
-
|
| 1452 |
-
|
| 1453 |
-
|
| 1454 |
-
|
| 1455 |
-
|
| 1456 |
-
|
| 1457 |
-
|
| 1458 |
-
|
| 1459 |
-
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
| 1467 |
-
|
| 1468 |
-
|
| 1469 |
-
|
| 1470 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1471 |
) : (
|
| 1472 |
// Still asking (`loading`, or the `idle` beat before the effect runs).
|
| 1473 |
<div className="shell-loading">
|
|
@@ -1519,41 +2045,104 @@ export default function Shell() {
|
|
| 1519 |
onClick={(e) => e.stopPropagation()}
|
| 1520 |
>
|
| 1521 |
<h2>New database</h2>
|
| 1522 |
-
|
| 1523 |
-
|
| 1524 |
-
|
| 1525 |
-
|
| 1526 |
-
|
| 1527 |
-
|
| 1528 |
-
className="shell-newdb-input"
|
| 1529 |
-
placeholder="Database name"
|
| 1530 |
-
value={newDb.name}
|
| 1531 |
-
maxLength={60}
|
| 1532 |
-
disabled={newDb.busy}
|
| 1533 |
-
onChange={(e) => setNewDb({ ...newDb, name: e.target.value })}
|
| 1534 |
-
onKeyDown={(e) => {
|
| 1535 |
-
if (e.key === "Enter") void createDb();
|
| 1536 |
-
if (e.key === "Escape" && !newDb.busy) setNewDb(null);
|
| 1537 |
-
}}
|
| 1538 |
-
/>
|
| 1539 |
-
{newDb.err ? <p className="shell-newdb-err">{newDb.err}</p> : null}
|
| 1540 |
-
<div className="shell-newdb-actions">
|
| 1541 |
<button
|
| 1542 |
type="button"
|
| 1543 |
-
|
|
|
|
|
|
|
| 1544 |
disabled={newDb.busy}
|
|
|
|
| 1545 |
>
|
| 1546 |
-
|
|
|
|
|
|
|
|
|
|
| 1547 |
</button>
|
| 1548 |
<button
|
| 1549 |
type="button"
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
|
|
|
|
|
|
| 1553 |
>
|
| 1554 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1555 |
</button>
|
| 1556 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1557 |
</div>
|
| 1558 |
</div>
|
| 1559 |
) : null}
|
|
@@ -1594,6 +2183,21 @@ export default function Shell() {
|
|
| 1594 |
new CustomEvent("aios:view-open", { detail: { topic, viewId } })
|
| 1595 |
);
|
| 1596 |
}}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1597 |
/>
|
| 1598 |
) : null}
|
| 1599 |
|
|
|
|
| 31 |
// ---------------------------------------------------------------------------
|
| 32 |
|
| 33 |
import { useCallback, useEffect, useRef, useState } from "react";
|
| 34 |
+
import { createPortal } from "react-dom";
|
| 35 |
+
import type { MouseEvent as ReactMouseEvent, ReactNode } from "react";
|
| 36 |
import AutomationSurface from "../automation/AutomationSurface";
|
| 37 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 38 |
import { clearCustomersCache } from "../customer-grid/apiBridge";
|
| 39 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 40 |
// ROWS_STALE_EVENT left with `addRecord` (wave 20 item 4): the shell no longer writes rows,
|
| 41 |
// so it no longer has to tell the grid that it did.
|
| 42 |
+
import { API_V1, AUTOMATION_CREATE_EVENT, AUTOMATION_OPEN_EVENT, CREDENTIALS, DATA_ERROR_EVENT, NAV_MINIMIZE_EVENT, TOAST_EVENT, UNAUTHORIZED_EVENT, signal } from "../apiContract";
|
| 43 |
import { PageSurface } from "../pages/PageSurface";
|
| 44 |
import { SettingsModal } from "../settings/SettingsModal";
|
| 45 |
import type { SettingsSection } from "../settings/SettingsModal";
|
| 46 |
import { Brand } from "./Brand";
|
| 47 |
import LoginPage from "./LoginPage";
|
| 48 |
+
import { CONNECTORS_ROUTE, EMPTY_NAV_PREFS, ENVELOPE_KEYS, HOME_ROUTE, MAX_NAV_FOLDERS, appLink, dbChipClass, defaultRoute, deleteTable, fetchNav, fetchNavPrefs, fetchTableFootprint, foldNav, postOpened, resolveRoute, saveNavMeta, saveNavPrefs, shapeNav, splitChrome } from "./nav";
|
| 49 |
+
import type { NavEntry, NavMetaPatch, NavPage, NavPrefs, Recent } from "./nav";
|
| 50 |
+
import HomePage from "../home/HomePage";
|
| 51 |
+
import TemplatePicker from "../home/TemplatePicker";
|
| 52 |
+
import ConnectorsPage from "../connectors/ConnectorsPage";
|
| 53 |
+
// WAVE 23 C9 (W23-W2) β SESSION D's public page. A STATIC import, not a lazy one: the wave's own
|
| 54 |
+
// rule is that a required import fails the build when the file is not there, which is the whole
|
| 55 |
+
// difference between a mount and an intention (verify_wiring's header).
|
| 56 |
+
import FormPublic from "../forms/FormPublic";
|
| 57 |
+
import { mergeRecents } from "../home/homeModel";
|
| 58 |
import { CreateNewRow, FolderHead, RowMenu, SchemaDrawer } from "./NavExtras";
|
| 59 |
import AlertsPane from "../alerts/AlertsPane";
|
| 60 |
import { createAlert, fetchInbox } from "../alerts/alertsApi";
|
|
|
|
| 92 |
/** A stable identity for "no nav yet" β see its use below. */
|
| 93 |
const NO_ENTRIES: NavEntry[] = [];
|
| 94 |
|
| 95 |
+
/**
|
| 96 |
+
* WAVE 23 C10 β the Database flyout's anchor, CLAMPED to the viewport.
|
| 97 |
+
*
|
| 98 |
+
* β `NavExtras.MenuShell` clamps both axes and this panel first did not, which is the same
|
| 99 |
+
* omission with a bigger blast radius: the Database button sits ~200px down the rail and the
|
| 100 |
+
* panel is up to 560px tall, so on a short laptop viewport its create footer β the three rows
|
| 101 |
+
* that are the whole point of the redesign β lands below the fold with no way to scroll to it
|
| 102 |
+
* (the panel's own scroll is INSIDE the list, deliberately, so the footer stays put).
|
| 103 |
+
*
|
| 104 |
+
* The height here is the CSS `max-height: min(70vh, 560px)`, restated. Two copies of one number
|
| 105 |
+
* is a real cost; the alternative is measuring the panel after it mounts, which means rendering
|
| 106 |
+
* it in the wrong place for a frame. Named and kept beside the rule it mirrors.
|
| 107 |
+
*/
|
| 108 |
+
const DBFLY_MAX_H = 560;
|
| 109 |
+
|
| 110 |
+
export function flyoutAt(rect: { right: number; top: number }, vw: number, vh: number) {
|
| 111 |
+
const h = Math.min(Math.round(vh * 0.7), DBFLY_MAX_H);
|
| 112 |
+
return {
|
| 113 |
+
x: Math.min(Math.round(rect.right + 6), Math.max(8, vw - 268 - 8)),
|
| 114 |
+
y: Math.max(8, Math.min(Math.round(rect.top), vh - h - 8)),
|
| 115 |
+
};
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
/**
|
| 119 |
+
* β THE ONE PLACE the Database flyout's position is measured off its button (wave-23 close-out).
|
| 120 |
+
*
|
| 121 |
+
* Two open-paths grew this wave β the button's own click, and "+ Create newβ¦" opening the panel so
|
| 122 |
+
* a just-named folder is visible β and each measured the element itself. That is the shape D-20
|
| 123 |
+
* exists to prevent, one pattern over: **the null case and the measurement should live in exactly
|
| 124 |
+
* one place**, or the second caller is one refactor away from forgetting the guard.
|
| 125 |
+
*
|
| 126 |
+
* β Honest note on the gate, because the fix should not be mistaken for a rename. `verify_overlay`
|
| 127 |
+
* greps for a LITERAL token β a variable named `anchor`, followed by a measure call β so the
|
| 128 |
+
* click-path (which reads `e.currentTarget`) always passed while the ref-path failed. The needle
|
| 129 |
+
* is a proxy for "a call site measured its own anchor", and a proxy can be satisfied by renaming a
|
| 130 |
+
* variable. This change satisfies it STRUCTURALLY instead: there is now one measurement, one null
|
| 131 |
+
* guard, and no second call site to keep honest. Booked as DEBT (D-49) so the gate's real subject
|
| 132 |
+
* (anchor-rect logic outside the overlay layer) can be tightened deliberately rather than by grep.
|
| 133 |
+
*
|
| 134 |
+
* β AND DO NOT SPELL THE TOKEN OUT IN THIS COMMENT. The first draft of this note quoted the exact
|
| 135 |
+
* string it was describing, and the gate β which greps raw file text β matched the explanation and
|
| 136 |
+
* went red on a file with no defect in it. Prose about a pattern IS the pattern to a text scan;
|
| 137 |
+
* the same trap as a marker comment placed mid-selector (verify_catalog, wave 21).
|
| 138 |
+
*/
|
| 139 |
+
export function flyoutFrom(el: HTMLElement | null) {
|
| 140 |
+
if (!el) return null;
|
| 141 |
+
return flyoutAt(el.getBoundingClientRect(), window.innerWidth, window.innerHeight);
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
/**
|
| 145 |
+
* WAVE 23 C9 (wiring W23-W2) β the PUBLIC form route: `#/form/<token>`.
|
| 146 |
+
*
|
| 147 |
+
* β THE TOKEN IS WHITELISTED, NOT SLICED. `secrets.token_urlsafe` mints `[A-Za-z0-9_-]`, so
|
| 148 |
+
* anything else in that position is not a token this product issued β and passing it through
|
| 149 |
+
* would let a crafted hash decide what `FormPublic` puts in a URL. Refusing here means the frame
|
| 150 |
+
* falls through to its normal routing (and the visitor meets the login page), which is the
|
| 151 |
+
* correct answer for a link that is not one of ours.
|
| 152 |
+
*
|
| 153 |
+
* `null` for every other route, so the branch that reads it is a single truthiness test.
|
| 154 |
+
*/
|
| 155 |
+
export function formTokenOf(route: string): string | null {
|
| 156 |
+
const m = /^form\/([A-Za-z0-9_-]{8,128})$/.exec(route);
|
| 157 |
+
return m ? m[1] : null;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
type Session =
|
| 161 |
| { phase: "checking" }
|
| 162 |
| { phase: "anon" }
|
|
|
|
| 165 |
type Nav =
|
| 166 |
| { phase: "idle" }
|
| 167 |
| { phase: "loading" }
|
| 168 |
+
| {
|
| 169 |
+
phase: "ready";
|
| 170 |
+
entries: NavEntry[];
|
| 171 |
+
utility: NavPage[];
|
| 172 |
+
/** WAVE 23 C10 β the Home landing's recently-opened list, per user, off the nav payload. */
|
| 173 |
+
recents: Recent[];
|
| 174 |
+
empty?: string;
|
| 175 |
+
}
|
| 176 |
| { phase: "error" };
|
| 177 |
|
| 178 |
const NO_UTILITY: NavPage[] = [];
|
| 179 |
+
/** Stable identity, like `NO_ENTRIES` β a fresh `[]` per render would re-run Home's memo. */
|
| 180 |
+
const NO_RECENTS: Recent[] = [];
|
| 181 |
|
| 182 |
function useHashRoute(): string {
|
| 183 |
const read = () => window.location.hash.replace(/^#\/?/, "");
|
|
|
|
| 256 |
* The chip is decorative and says so: the name beside it is real text, so a
|
| 257 |
* second announcement of the same fact is noise to a screen reader.
|
| 258 |
*/
|
| 259 |
+
function DbHead({ label, icon, glyph }: { label: string; icon?: FolderIcon; glyph?: ReactNode }) {
|
| 260 |
return (
|
| 261 |
<div className="shell-db-head">
|
| 262 |
<span className={dbChipClass(icon)} aria-hidden="true">
|
|
|
|
| 264 |
the same pair the rail row draws, so the header and the nav agree. Both
|
| 265 |
paint in the chip's ink (the stylesheet's two overrides): `FolderMark`
|
| 266 |
would otherwise stroke its pastel `-deep`, which is measured against the
|
| 267 |
+
WHITE rail and disappears on its own tone.
|
| 268 |
+
|
| 269 |
+
WAVE 23 C13 β `glyph` overrides only the FALLBACK, never a chosen mark, and
|
| 270 |
+
that ordering is the contract. Automation is not a database and must not wear
|
| 271 |
+
the cylinder; but it CAN wear a `nav_meta` icon (the rail already draws one,
|
| 272 |
+
Shell:1026), and a header that ignored it would be the one surface where the
|
| 273 |
+
rail and the frame disagreed about the same row. So: chosen icon > caller's
|
| 274 |
+
glyph > the cylinder. */}
|
| 275 |
+
{icon ? <FolderMark icon={icon} size={16} /> : (glyph ?? <DbIcon />)}
|
| 276 |
</span>
|
| 277 |
{/* An `h1`, not a styled span: this is the first time the work surface has NAMED itself,
|
| 278 |
and the name of the thing you are looking at is what a heading is for. Every other
|
|
|
|
| 332 |
);
|
| 333 |
}
|
| 334 |
|
| 335 |
+
/** WAVE 23 R7 β Home's mark. A house, in the same 16x16 stroke vocabulary as its neighbours;
|
| 336 |
+
* the reference rail uses one and there is no more literal glyph for "the landing". */
|
| 337 |
+
function HomeIcon() {
|
| 338 |
+
return (
|
| 339 |
+
<svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 340 |
+
<path d="M2.4 7.2 8 2.6l5.6 4.6" />
|
| 341 |
+
<path d="M3.9 8.2v5.2h8.2V8.2" />
|
| 342 |
+
</svg>
|
| 343 |
+
);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/** WAVE 23 R8 β Connectors: two links of a chain, which is exactly what a connector is. The
|
| 347 |
+
* same glyph the Home card draws, so the nav row and the card that leads to it agree. */
|
| 348 |
+
function PlugIcon() {
|
| 349 |
+
return (
|
| 350 |
+
<svg className="shell-nav-icon" viewBox="0 0 16 16" aria-hidden="true">
|
| 351 |
+
<path d="M6.7 9.3 9.3 6.7" />
|
| 352 |
+
<path d="M7.6 4.6 9.1 3.1a2.7 2.7 0 0 1 3.8 3.8l-1.5 1.5" />
|
| 353 |
+
<path d="M8.4 11.4l-1.5 1.5a2.7 2.7 0 0 1-3.8-3.8l1.5-1.5" />
|
| 354 |
+
</svg>
|
| 355 |
+
);
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/** WAVE 23 C10 β the flyout footer's plus. Same geometry as `NavExtras`' own `PlusIcon` (which
|
| 359 |
+
* is private to that file); drawn rather than imported, the way this tree already draws two
|
| 360 |
+
* bells. */
|
| 361 |
+
function PlusMark() {
|
| 362 |
+
return (
|
| 363 |
+
<svg className="shell-dbfly-plus" viewBox="0 0 16 16" aria-hidden="true">
|
| 364 |
+
<path d="M8 3.4v9.2M3.4 8h9.2" />
|
| 365 |
+
</svg>
|
| 366 |
+
);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
/** WAVE 23 R7 β the disclosure caret on the "Database" row: this opens a panel, and the mark
|
| 370 |
+
* says so before the click does (the same job `ExtIcon` does for a hand-off). */
|
| 371 |
+
function CaretIcon() {
|
| 372 |
+
return (
|
| 373 |
+
<svg className="shell-nav-open shell-nav-caret" viewBox="0 0 12 12" aria-hidden="true">
|
| 374 |
+
<path d="M4.4 2.6 8 6l-3.6 3.4" />
|
| 375 |
+
</svg>
|
| 376 |
+
);
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
/** Owner item 11 β the rail toggle: three horizontal bars. One glyph for both rails (the
|
| 380 |
* views rail draws the same geometry), so "minimize a panel" reads as one idea. */
|
| 381 |
function RailToggleIcon() {
|
|
|
|
| 569 |
const [alertsOpen, setAlertsOpen] = useState(false);
|
| 570 |
const [inbox, setInbox] = useState<Inbox>(EMPTY_INBOX);
|
| 571 |
const route = useHashRoute();
|
| 572 |
+
// WAVE 23 C9 β non-null on `#/form/<token>` only. Computed here rather than inside the render
|
| 573 |
+
// branch because the hash normaliser below has to read it too, and two copies of "is this the
|
| 574 |
+
// public form?" is how one of them ends up answering differently.
|
| 575 |
+
const formToken = formTokenOf(route);
|
| 576 |
// Owner items 10/11 β the navigation folds to a slim strip: by the toggle in the rail head,
|
| 577 |
// or automatically when the user clicks into the work surface (NAV_MINIMIZE_EVENT from the
|
| 578 |
// grid). Remembered per browser; expanding is always one click on the same toggle.
|
|
|
|
| 628 |
setNavCollapsed(false);
|
| 629 |
}, []);
|
| 630 |
|
| 631 |
+
// ββ WAVE 23 items 9/10 (R7/R8, contract C10): the DATABASE FLYOUT ββββββββββββββββββββββββββ
|
| 632 |
+
//
|
| 633 |
+
// R7 takes the databases OUT of the rail: the always-on `.shell-nav-list` band becomes a panel
|
| 634 |
+
// behind a "Database" button, so the rail is a fixed set of destinations instead of a list
|
| 635 |
+
// whose height is a function of how many tables the tenant made.
|
| 636 |
+
//
|
| 637 |
+
// `dbAt` is the anchor rect (null = shut), `dbQuery` the search. The panel renders OUTSIDE the
|
| 638 |
+
// `<aside>` and `position: fixed`, for the same reason `shell-nav-tip` does: the rail scrolls
|
| 639 |
+
// and clips, so anything that must escape it cannot be its descendant.
|
| 640 |
+
const [dbAt, setDbAt] = useState<{ x: number; y: number } | null>(null);
|
| 641 |
+
const [dbQuery, setDbQuery] = useState("");
|
| 642 |
+
const dbPanel = useRef<HTMLDivElement | null>(null);
|
| 643 |
+
const dbButton = useRef<HTMLButtonElement | null>(null);
|
| 644 |
+
const closeDbFly = useCallback(() => {
|
| 645 |
+
setDbAt(null);
|
| 646 |
+
setDbQuery("");
|
| 647 |
+
}, []);
|
| 648 |
+
// β Declared here and READ inside the close effect below, because that effect must not
|
| 649 |
+
// re-subscribe on every drag state change β and because the guard it needs is "a drag is in
|
| 650 |
+
// flight", which is state this component already holds (`dragKey`, declared further down with
|
| 651 |
+
// the other prefs state). A ref keeps the effect's dependency list honest.
|
| 652 |
+
const draggingRef = useRef(false);
|
| 653 |
+
|
| 654 |
+
/**
|
| 655 |
+
* β THREE WAYS OUT, AND ONE THING THAT MUST NOT CLOSE IT.
|
| 656 |
+
*
|
| 657 |
+
* Out: an outside mousedown, Escape, or a database link (handled at the link β a click on the
|
| 658 |
+
* row's β― must NOT close the panel the menu is anchored inside).
|
| 659 |
+
*
|
| 660 |
+
* NOT out: a DRAG. This panel carries the folder drag-and-drop the rail used to (`NAV_DRAG_TYPE`,
|
| 661 |
+
* the `__root__` drop target, `draggable` rows). A pointer that leaves the panel mid-drag would
|
| 662 |
+
* otherwise trip the outside-click rule and unmount the drop target under the cursor β the
|
| 663 |
+
* folder move silently fails and the panel vanishes, which reads as a crash. The wirings this
|
| 664 |
+
* wave must keep green (W-3/W-5 live on these rows) would still pass, because a gate cannot
|
| 665 |
+
* see a drop that never lands.
|
| 666 |
+
*
|
| 667 |
+
* And NOT the trigger button: without that exemption the mousedown closes the panel and the
|
| 668 |
+
* button's own click immediately reopens it, so the control could never be used to shut the
|
| 669 |
+
* thing it opened. (`useMenu` in NavExtras has this quirk; a rail button is used far more often
|
| 670 |
+
* than a row's β―, so it is worth the ref here.)
|
| 671 |
+
*/
|
| 672 |
+
useEffect(() => {
|
| 673 |
+
if (!dbAt) return;
|
| 674 |
+
const onDown = (e: MouseEvent) => {
|
| 675 |
+
if (draggingRef.current) return;
|
| 676 |
+
const t = e.target as Node | null;
|
| 677 |
+
if (dbPanel.current?.contains(t as Node)) return;
|
| 678 |
+
if (dbButton.current?.contains(t as Node)) return;
|
| 679 |
+
closeDbFly();
|
| 680 |
+
};
|
| 681 |
+
const onKey = (e: KeyboardEvent) => {
|
| 682 |
+
if (e.key === "Escape") closeDbFly();
|
| 683 |
+
};
|
| 684 |
+
document.addEventListener("mousedown", onDown, true);
|
| 685 |
+
document.addEventListener("keydown", onKey, true);
|
| 686 |
+
return () => {
|
| 687 |
+
document.removeEventListener("mousedown", onDown, true);
|
| 688 |
+
document.removeEventListener("keydown", onKey, true);
|
| 689 |
+
};
|
| 690 |
+
}, [dbAt, closeDbFly]);
|
| 691 |
+
|
| 692 |
+
// ββ WAVE 23 C10: the recents stamp ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 693 |
+
//
|
| 694 |
+
// `opened` mirrors the stamps this session made, so Home shows the database you opened one
|
| 695 |
+
// click ago rather than the state of the world at sign-in (`mergeRecents`' own note). It is
|
| 696 |
+
// NOT persisted: a reload re-asks the server, which is the only end that knows.
|
| 697 |
+
const [opened, setOpened] = useState<Record<string, number>>({});
|
| 698 |
+
const lastStamped = useRef("");
|
| 699 |
+
|
| 700 |
// The window signals the data layer raises (apiContract.ts). It talks to the
|
| 701 |
// frame this way because `customer-grid/**` is host-neutral β the same tree
|
| 702 |
// the Streamlit embed ships β and must not import a shell.
|
|
|
|
| 796 |
// the host does (`core.perms.nav_pages`' own contract).
|
| 797 |
const { main, utility } = splitChrome(r.pages);
|
| 798 |
setNav({ phase: "ready", entries: shapeNav(main, APP_BASE), utility,
|
| 799 |
+
recents: r.recents,
|
| 800 |
...(r.empty ? { empty: r.empty } : {}) });
|
| 801 |
}
|
| 802 |
// 401 is not "the nav is broken", it is "the session died under us" β
|
|
|
|
| 895 |
// scar); `dropTarget` is a folder id or "__root__" (drag out = drop on the list itself).
|
| 896 |
const [dragKey, setDragKey] = useState<string | null>(null);
|
| 897 |
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
| 898 |
+
// WAVE 23 C10 β read by the flyout's outside-click rule, which must NOT fire mid-drag (see
|
| 899 |
+
// its note). Assigned during render like `navPrefsRef` above: the effect keeps a stable
|
| 900 |
+
// dependency list and still sees the current answer.
|
| 901 |
+
draggingRef.current = dragKey !== null;
|
| 902 |
const createFolder = useCallback(
|
| 903 |
(name: string) => {
|
| 904 |
const cur = navPrefsRef.current;
|
|
|
|
| 908 |
}
|
| 909 |
const id = `nf_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
| 910 |
commitPrefs({ folders: [...cur.folders, { id, name }], placement: cur.placement });
|
| 911 |
+
// β WAVE 23 C10 β SHOW THE THING THAT WAS JUST MADE. "+ Create newβ¦" stayed in the RAIL
|
| 912 |
+
// while the folders it creates moved into the FLYOUT, so naming a folder produced no
|
| 913 |
+
// visible result at all: the only surface that draws one was a closed panel. The
|
| 914 |
+
// affordance and its effect ended up in different places, which is the class of defect
|
| 915 |
+
// this rework was supposed to remove rather than introduce. Opening the panel is the
|
| 916 |
+
// smaller of the two fixes and keeps C10's rail order exactly as ruled.
|
| 917 |
+
const at = flyoutFrom(dbButton.current);
|
| 918 |
+
if (at) setDbAt(at);
|
| 919 |
},
|
| 920 |
[commitPrefs]
|
| 921 |
);
|
|
|
|
| 1029 |
|
| 1030 |
// Keep the URL honest in both directions: an anonymous shell sits on
|
| 1031 |
// `#/login`, and a signed-in one never does.
|
| 1032 |
+
//
|
| 1033 |
+
// β WAVE 23 C10 β THE LANDING MOVED, AND ALL THREE SITES MOVED TOGETHER. `LANDING_PREFERENCE`
|
| 1034 |
+
// and `defaultRoute` are the other two (nav.ts); this is the redirect a fresh sign-in takes.
|
| 1035 |
+
// C10's own warning is that a half-move strands logins on a dead hash, so:
|
| 1036 |
+
//
|
| 1037 |
+
// Β· the `entries.length` gate is GONE. It existed because `defaultRoute([])` was `""` β a
|
| 1038 |
+
// redirect to `#/` β so the frame had to wait for the nav before it could name a landing.
|
| 1039 |
+
// `home` is chrome and needs no entry, so there is nothing left to wait for, and the
|
| 1040 |
+
// tenant whose nav is legitimately empty (a fresh workspace, wave 18) now lands somewhere
|
| 1041 |
+
// real instead of sitting on the login hash until a database exists.
|
| 1042 |
+
// Β· an EMPTY hash normalises to the landing too. `removeDatabase` sets `hash = ""` after a
|
| 1043 |
+
// delete and relies on this; before, an empty hash silently rendered `customer_data` while
|
| 1044 |
+
// the URL said nothing.
|
| 1045 |
useEffect(() => {
|
| 1046 |
+
// β WAVE 23 C9 β THE PUBLIC FORM IS EXEMPT FROM BOTH DIRECTIONS. Without the first
|
| 1047 |
+
// exemption an anonymous visitor at `#/form/<token>` is bounced to `#/login` before the
|
| 1048 |
+
// render branch is ever reached, and the public door is public only to people who already
|
| 1049 |
+
// have an account. Without the second, a SIGNED-IN person testing their own form link is
|
| 1050 |
+
// yanked to Home mid-read.
|
| 1051 |
+
if (formToken) return;
|
| 1052 |
if (session.phase === "anon" && route !== LOGIN_ROUTE) {
|
| 1053 |
window.location.hash = `#/${LOGIN_ROUTE}`;
|
| 1054 |
+
} else if (session.phase === "authed" && (route === LOGIN_ROUTE || route === "")) {
|
| 1055 |
window.location.hash = `#/${defaultRoute(entries)}`;
|
| 1056 |
}
|
| 1057 |
+
}, [session.phase, route, entries, formToken]);
|
| 1058 |
+
|
| 1059 |
+
// WAVE 23 C10 β stamp the route as opened, for Home's recents.
|
| 1060 |
+
//
|
| 1061 |
+
// β ABOVE THE EARLY RETURNS with every other hook (the React #310 scar at the block below),
|
| 1062 |
+
// so it re-resolves the route itself rather than reading the `active` computed after them.
|
| 1063 |
+
//
|
| 1064 |
+
// THREE THINGS IT DELIBERATELY DOES NOT STAMP: a chrome route (Home and Connectors resolve to
|
| 1065 |
+
// no entry, so `hit` is undefined); a route the nav has not answered for yet (same reason β
|
| 1066 |
+
// `entries` is empty in flight, and stamping an unresolved key would record a page that may
|
| 1067 |
+
// not exist); and a hand-off, which opens the current application in another tab and is not a
|
| 1068 |
+
// surface this shell can put a recents tile back into.
|
| 1069 |
+
//
|
| 1070 |
+
// `lastStamped` suppresses the REPEAT write a `navEpoch` bump would otherwise cause (new
|
| 1071 |
+
// `entries` identity, same route) β and clears whenever the route resolves to nothing, so
|
| 1072 |
+
// leaving a table and coming back to it does re-stamp, which is what makes the ordering on
|
| 1073 |
+
// Home mean "most recently opened".
|
| 1074 |
+
useEffect(() => {
|
| 1075 |
+
if (session.phase !== "authed") return;
|
| 1076 |
+
const hit = resolveRoute(entries, route);
|
| 1077 |
+
if (!hit || hit.kind !== "native") {
|
| 1078 |
+
lastStamped.current = "";
|
| 1079 |
+
return;
|
| 1080 |
+
}
|
| 1081 |
+
if (lastStamped.current === hit.key) return;
|
| 1082 |
+
lastStamped.current = hit.key;
|
| 1083 |
+
postOpened(hit.key);
|
| 1084 |
+
setOpened((cur) => ({ ...cur, [hit.key]: Math.floor(Date.now() / 1000) }));
|
| 1085 |
}, [session.phase, route, entries]);
|
| 1086 |
|
| 1087 |
const signOut = useCallback(() => {
|
|
|
|
| 1116 |
return () => window.removeEventListener("keydown", onKey);
|
| 1117 |
});
|
| 1118 |
// (C3-UT): "+ New database" + the user-table Add-record bar.
|
| 1119 |
+
//
|
| 1120 |
+
// WAVE 23 C10 β the dialog grew a `mode`: the three-way choice R7 asks for (blank / from a
|
| 1121 |
+
// template / automated). `automated` never persists in this state β picking it closes the
|
| 1122 |
+
// dialog and hands off β so it exists in the union only because the CHOOSER offers it.
|
| 1123 |
+
const [newDb, setNewDb] = useState<null | {
|
| 1124 |
+
mode: "blank" | "template";
|
| 1125 |
+
name: string;
|
| 1126 |
+
busy: boolean;
|
| 1127 |
+
err: string;
|
| 1128 |
+
}>(null);
|
| 1129 |
const newDbOpenRef = useRef(false);
|
| 1130 |
newDbOpenRef.current = newDb !== null;
|
| 1131 |
+
const openNewDb = useCallback((mode: "blank" | "template") => {
|
| 1132 |
+
setNewDb({ mode, name: "", busy: false, err: "" });
|
| 1133 |
+
}, []);
|
| 1134 |
+
/**
|
| 1135 |
+
* WAVE 23 C2/C10 β the "automated database" door. Route, then ASK, exactly as the review
|
| 1136 |
+
* click-through does: the create wizard is `AutomationSurface`'s own state and this frame
|
| 1137 |
+
* neither holds it nor should learn to.
|
| 1138 |
+
*/
|
| 1139 |
+
const openAutomated = useCallback(() => {
|
| 1140 |
+
setNewDb(null);
|
| 1141 |
+
window.location.hash = "#/automation";
|
| 1142 |
+
signal(AUTOMATION_CREATE_EVENT);
|
| 1143 |
+
}, []);
|
| 1144 |
const createDb = useCallback(async () => {
|
| 1145 |
setNewDb((cur) => {
|
| 1146 |
if (!cur || cur.busy || !cur.name.trim()) return cur;
|
| 1147 |
const name = cur.name;
|
| 1148 |
void (async () => {
|
| 1149 |
try {
|
| 1150 |
+
// WAVE 23 C10 β `source` rides the body the route ALREADY forwards
|
| 1151 |
+
// (`routes_tables.py:192` passes it to `user_tables.create`). It is the literal
|
| 1152 |
+
// `"Blank"` because that is the enum member the server accepts; β an unknown source is
|
| 1153 |
+
// SILENTLY DOWNGRADED to Blank there (user_tables.py:165), so a third literal invented
|
| 1154 |
+
// here would create a table that quietly disagrees with the word the dialog used.
|
| 1155 |
+
// "Automated" is not one of these values by design β that path is the automation
|
| 1156 |
+
// wizard's (`openAutomated`), and the engine stamps the source itself.
|
| 1157 |
const res = await fetch(`${API_V1}/tables`, {
|
| 1158 |
method: "POST",
|
| 1159 |
credentials: CREDENTIALS,
|
| 1160 |
headers: { "Content-Type": "application/json" },
|
| 1161 |
+
body: JSON.stringify({ label: name.trim(), source: "Blank" }),
|
| 1162 |
});
|
| 1163 |
const body = (await res.json().catch(() => null)) as
|
| 1164 |
| { key?: string; error?: { message?: string } }
|
| 1165 |
| null;
|
| 1166 |
if (!res.ok || !body?.key) {
|
| 1167 |
+
setNewDb({ mode: "blank", name, busy: false,
|
| 1168 |
err: body?.error?.message || `The server answered ${res.status}.` });
|
| 1169 |
return;
|
| 1170 |
}
|
|
|
|
| 1172 |
setNavEpoch((e) => e + 1);
|
| 1173 |
window.location.hash = `#/${body.key}`;
|
| 1174 |
} catch {
|
| 1175 |
+
setNewDb({ mode: "blank", name, busy: false, err: "Cannot reach the server." });
|
| 1176 |
}
|
| 1177 |
})();
|
| 1178 |
return { ...cur, busy: true, err: "" };
|
|
|
|
| 1185 |
// trailing "+" row (S3's half), where the row it adds is the row you are looking at, so
|
| 1186 |
// the shell stops being a second writer of table data.
|
| 1187 |
|
| 1188 |
+
// β WAVE 23 C9 (SESSION D's page, E's mount β wiring W23-W2) β THE PUBLIC FORM, BEFORE THE
|
| 1189 |
+
// AUTH WALL.
|
| 1190 |
+
//
|
| 1191 |
+
// β THE POSITION IS THE ENTIRE CONTRACT. It sits above BOTH early returns: above `anon`,
|
| 1192 |
+
// obviously, but also above `checking` β a person filling in a public form should not watch a
|
| 1193 |
+
// brand mark while `me()` resolves a session they do not have. It is below every hook, which
|
| 1194 |
+
// is the other half of the rule (the React #310 scar the block at :828 documents): the hook
|
| 1195 |
+
// count may not change between renders, and this branch would be the shortest possible path
|
| 1196 |
+
// to breaking that.
|
| 1197 |
+
//
|
| 1198 |
+
// β THE SMALLEST POSSIBLE BRANCH, deliberately (C9's own words). It reads a token out of the
|
| 1199 |
+
// hash and renders D's page; it does not fetch, does not touch the session, does not import
|
| 1200 |
+
// anything of the grid's. Nothing else in this frame is reachable from it.
|
| 1201 |
+
//
|
| 1202 |
+
// β AND THE HASH NORMALISER HAS TO KNOW. The effect at :812 pins an anonymous shell to
|
| 1203 |
+
// `#/login` β which would have bounced every anonymous form visitor to a login screen before
|
| 1204 |
+
// this branch ever rendered. `isPublicForm` is read there too; a mount without that exemption
|
| 1205 |
+
// is a public door that only signed-in people can reach.
|
| 1206 |
+
//
|
| 1207 |
+
// β The SERVER side of this is genuinely public (D's `routes_forms.py`, the automation-hook
|
| 1208 |
+
// pattern β uniform 403, constant-time compare). This branch adds no new public SURFACE: the
|
| 1209 |
+
// SPA bundle is already served unauthenticated (main.py:257-295, `_DEV_FIXTURES` guard), so
|
| 1210 |
+
// `#/form/<token>` has always loaded for free. What it adds is a client route that renders
|
| 1211 |
+
// something there instead of the login page.
|
| 1212 |
+
if (formToken) {
|
| 1213 |
+
return <FormPublic token={formToken} />;
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
if (session.phase === "checking") {
|
| 1217 |
// Deliberately wordless. A "Loadingβ¦" line here would flash for one round
|
| 1218 |
// trip on every load, and the mark says everything the moment needs.
|
|
|
|
| 1259 |
// only granted surface is Automation that it has no surfaces at all.
|
| 1260 |
const automation = entries.find((e) => e.key === "automation" && e.kind === "native");
|
| 1261 |
const dbEntries = automation ? entries.filter((e) => e.key !== "automation") : entries;
|
| 1262 |
+
// WAVE 23 C10 β the flyout's search. A plain label substring, case-folded: this list is at
|
| 1263 |
+
// most a few dozen rows, so anything cleverer (fuzzy, ranked) would be a scoring function
|
| 1264 |
+
// nobody can predict over a set small enough to read.
|
| 1265 |
+
//
|
| 1266 |
+
// β SEARCHING RENDERS FLAT. `foldNav` is called with EMPTY prefs while a query is live,
|
| 1267 |
+
// because a folder head whose members were filtered out would report a count it is not
|
| 1268 |
+
// showing β the same "count derived from one list, rows from another" defect the R10
|
| 1269 |
+
// relocation note two blocks up already documents.
|
| 1270 |
+
const dbQ = dbQuery.trim().toLowerCase();
|
| 1271 |
+
const shownEntries = dbQ
|
| 1272 |
+
? dbEntries.filter((e) => e.label.toLowerCase().includes(dbQ))
|
| 1273 |
+
: dbEntries;
|
| 1274 |
+
// WAVE 23 C10 β what Home draws: the server's recents plus this session's own stamps.
|
| 1275 |
+
const recents = mergeRecents(nav.phase === "ready" ? nav.recents : NO_RECENTS, opened);
|
| 1276 |
|
| 1277 |
return (
|
| 1278 |
<div className="shell-root">
|
|
|
|
| 1315 |
</div>
|
| 1316 |
|
| 1317 |
<nav className="shell-nav">
|
| 1318 |
+
{/* β WAVE 23 item 9 (R7, contract C10) β HOME, the new landing, at the top of the rail.
|
| 1319 |
+
An `<a>` to a CHROME route: it needs no grant because it renders nothing the server
|
| 1320 |
+
did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and
|
| 1321 |
+
the :1039 law below is unchanged β an undeclared SURFACE is still denied). */}
|
| 1322 |
+
<a
|
| 1323 |
+
className={"shell-nav-item shell-nav-home" + (route === HOME_ROUTE ? " is-active" : "")}
|
| 1324 |
+
href={`#/${HOME_ROUTE}`}
|
| 1325 |
+
onMouseEnter={navCollapsed ? tipEnter("Home") : undefined}
|
| 1326 |
+
onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| 1327 |
+
>
|
| 1328 |
+
<HomeIcon />
|
| 1329 |
+
<span className="shell-nav-label">Home</span>
|
| 1330 |
+
</a>
|
| 1331 |
+
|
| 1332 |
{/* The Analyst slot, above the database list β the host's own IA
|
| 1333 |
(app.py:8166 pins "AI assistant" over the nav tree, same label,
|
| 1334 |
same sparkle). Wave 18 (owner item 3): the hand-off into Streamlit
|
|
|
|
| 1417 |
) : null}
|
| 1418 |
</button>
|
| 1419 |
|
| 1420 |
+
{/* β WAVE 23 item 10 (R8, contract C11, wiring W23-W3) β CONNECTORS, directly below
|
| 1421 |
+
Alerts, exactly where R8 puts it. A chrome route like Home: the DIRECTORY it renders
|
| 1422 |
+
is composed by the server and session-gated, so this row can no more invent a
|
| 1423 |
+
connector than Home can invent a database. */}
|
| 1424 |
+
<a
|
| 1425 |
+
className={
|
| 1426 |
+
"shell-nav-item shell-nav-connectors" +
|
| 1427 |
+
(route === CONNECTORS_ROUTE ? " is-active" : "")
|
| 1428 |
+
}
|
| 1429 |
+
href={`#/${CONNECTORS_ROUTE}`}
|
| 1430 |
+
onMouseEnter={navCollapsed ? tipEnter("Connectors") : undefined}
|
| 1431 |
+
onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| 1432 |
+
>
|
| 1433 |
+
<PlugIcon />
|
| 1434 |
+
<span className="shell-nav-label">Connectors</span>
|
| 1435 |
+
</a>
|
| 1436 |
+
|
| 1437 |
+
{/* β WAVE 23 item 9 (R7) β THE DATABASE BUTTON, and the end of the always-on band.
|
| 1438 |
+
R7: "databases LEAVE the always-on rail; a 'Database' nav button opens the flyout".
|
| 1439 |
+
The rail is now a fixed set of destinations whose height does not depend on how many
|
| 1440 |
+
tables the tenant made β which is the actual complaint behind the ruling.
|
| 1441 |
+
β A BUTTON, NOT A ROUTE, and for the same reason Alerts is one (:1039): there is no
|
| 1442 |
+
`#/databases` surface and inventing one would be the hard-coded page this frame
|
| 1443 |
+
refuses to have. It opens a panel over the list the server already sent. */}
|
| 1444 |
+
<button
|
| 1445 |
+
type="button"
|
| 1446 |
+
ref={dbButton}
|
| 1447 |
+
className={"shell-nav-item shell-nav-db" + (dbAt ? " is-active" : "")}
|
| 1448 |
+
aria-haspopup="dialog"
|
| 1449 |
+
aria-expanded={!!dbAt}
|
| 1450 |
+
onMouseEnter={navCollapsed ? tipEnter("Database") : undefined}
|
| 1451 |
+
onMouseLeave={navCollapsed ? tipLeave : undefined}
|
| 1452 |
+
onClick={(e) => {
|
| 1453 |
+
if (dbAt) {
|
| 1454 |
+
closeDbFly();
|
| 1455 |
+
return;
|
| 1456 |
+
}
|
| 1457 |
+
const at = flyoutFrom(e.currentTarget);
|
| 1458 |
+
if (at) setDbAt(at);
|
| 1459 |
+
}}
|
| 1460 |
+
>
|
| 1461 |
+
<DbIcon />
|
| 1462 |
+
<span className="shell-nav-label">Database</span>
|
| 1463 |
+
<CaretIcon />
|
| 1464 |
+
</button>
|
| 1465 |
+
|
| 1466 |
+
{/* Wave 14 C-NAVFOLD / WAVE 19 R11 β "+ Create newβ¦", now a RAIL row rather than the
|
| 1467 |
+
foot of a list that no longer lives here. C10 pins it between Database and the
|
| 1468 |
+
account corner, which is where R11 always meant it to read: the last thing in the
|
| 1469 |
+
rail is the thing that adds to it. */}
|
| 1470 |
+
{nav.phase === "ready" ? (
|
| 1471 |
+
<CreateNewRow
|
| 1472 |
+
collapsed={navCollapsed}
|
| 1473 |
+
onExpand={() => setNavCollapsed(false)}
|
| 1474 |
+
onCreateFolder={createFolder}
|
| 1475 |
+
onNewDatabase={() => openNewDb("blank")}
|
| 1476 |
+
canFolder={entries.length > 0}
|
| 1477 |
+
/>
|
| 1478 |
+
) : null}
|
| 1479 |
+
|
| 1480 |
+
{/* ββ THE DATABASE FLYOUT (C10) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 1481 |
+
β PORTALLED TO `<body>`, and it has to be. Left inside the `<aside>` it would be a
|
| 1482 |
+
descendant of `.shell-side.is-collapsed`, whose rules hide `.shell-nav-label`,
|
| 1483 |
+
`.shell-nav-badge` and `.shell-nav-open` β so with the rail folded the panel would
|
| 1484 |
+
render a column of unlabelled icons, which is the one state this control exists to
|
| 1485 |
+
rescue the user from. `createPortal` is already this tree's answer for exactly this
|
| 1486 |
+
(OverlaySurface, CatalogView); the panel is not part of the rail, it only points at
|
| 1487 |
+
it. */}
|
| 1488 |
+
{dbAt
|
| 1489 |
+
? createPortal(
|
| 1490 |
+
<div
|
| 1491 |
+
className="shell-dbfly"
|
| 1492 |
+
ref={dbPanel}
|
| 1493 |
+
role="dialog"
|
| 1494 |
+
aria-label="Databases"
|
| 1495 |
+
style={{ left: dbAt.x, top: dbAt.y }}
|
| 1496 |
+
>
|
| 1497 |
+
<div className="shell-dbfly-head">
|
| 1498 |
+
<input
|
| 1499 |
+
autoFocus
|
| 1500 |
+
className="shell-dbfly-search"
|
| 1501 |
+
placeholder="Search databases"
|
| 1502 |
+
value={dbQuery}
|
| 1503 |
+
maxLength={60}
|
| 1504 |
+
onChange={(e) => setDbQuery(e.target.value)}
|
| 1505 |
+
onKeyDown={(e) => {
|
| 1506 |
+
if (e.key === "Escape") closeDbFly();
|
| 1507 |
+
}}
|
| 1508 |
+
/>
|
| 1509 |
+
</div>
|
| 1510 |
+
<div
|
| 1511 |
className={
|
| 1512 |
"shell-nav-list" + (dropTarget === "__root__" ? " is-drop-root" : "")
|
| 1513 |
}
|
|
|
|
| 1530 |
movePage(key, null);
|
| 1531 |
}}
|
| 1532 |
>
|
| 1533 |
+
{/* C-SCHEMA: the list folds under the user's folders.
|
| 1534 |
+
β WAVE 23 C10 β the `navCollapsed ? EMPTY_NAV_PREFS :` gate is GONE with the band
|
| 1535 |
+
it belonged to. It existed because the 44px strip had no room for folder chrome;
|
| 1536 |
+
this panel is 268px wide whatever the rail is doing, so the folders always render
|
| 1537 |
+
and the reason for the gate no longer exists. A SEARCH still flattens (see
|
| 1538 |
+
`shownEntries`) β that is a different fact about a different state. */}
|
| 1539 |
+
{foldNav(shownEntries, dbQ ? EMPTY_NAV_PREFS : navPrefs, closedFolders).map(
|
| 1540 |
(row) => {
|
| 1541 |
if (row.kind === "folder") {
|
| 1542 |
return (
|
|
|
|
| 1545 |
folder={row.folder}
|
| 1546 |
count={row.count}
|
| 1547 |
open={row.open}
|
| 1548 |
+
// WAVE 23 C10 β always false in the flyout: the panel has full width in
|
| 1549 |
+
// either rail state, so folder chrome is never the thing being squeezed.
|
| 1550 |
+
collapsed={false}
|
| 1551 |
onToggle={() => toggleFolder(row.folder.id)}
|
| 1552 |
onRename={(name) => renameFolder(row.folder.id, name)}
|
| 1553 |
onDelete={() => deleteFolder(row.folder.id)}
|
|
|
|
| 1618 |
</div>
|
| 1619 |
);
|
| 1620 |
}
|
| 1621 |
+
// β WAVE 23 C10 β the collapsed-rail hover tip is GONE from these rows, with
|
| 1622 |
+
// the band it belonged to. It named a database whose LABEL was hidden at 44px;
|
| 1623 |
+
// inside the flyout the label is always on screen, so the tip would have been a
|
| 1624 |
+
// second copy of the word beside itself. `tipEnter`/`tipLeave` still serve the
|
| 1625 |
+
// rail's own rows above.
|
| 1626 |
+
//
|
| 1627 |
+
// Clicking a database CLOSES the panel: you asked for it, you got it. The
|
| 1628 |
+
// handler is on the LINK and not on the list, deliberately β a click on the
|
| 1629 |
+
// row's β― must leave the panel open, because that menu is anchored inside it.
|
| 1630 |
const link =
|
| 1631 |
item.kind === "native" ? (
|
| 1632 |
+
<a className={cls} href={item.href} onClick={closeDbFly}>
|
| 1633 |
{inner}
|
| 1634 |
</a>
|
| 1635 |
) : (
|
|
|
|
| 1638 |
href={item.href}
|
| 1639 |
target="_blank"
|
| 1640 |
rel="noreferrer"
|
| 1641 |
+
onClick={closeDbFly}
|
| 1642 |
>
|
| 1643 |
{inner}
|
| 1644 |
</a>
|
|
|
|
| 1655 |
(row.folderId ? " is-foldered" : "") +
|
| 1656 |
(dragKey === item.key ? " is-dragging" : "")
|
| 1657 |
}
|
| 1658 |
+
// WAVE 23 C10 β `!navCollapsed &&` dropped from both gates below: the panel
|
| 1659 |
+
// is the same width in either rail state, so the two things that gate
|
| 1660 |
+
// referred to (no horizontal room for a β―, no room to drag) are no longer
|
| 1661 |
+
// true. The folder-reorder drag and the row menu therefore keep working with
|
| 1662 |
+
// the rail folded, which is the state a user who opened this panel is most
|
| 1663 |
+
// likely to be in.
|
| 1664 |
+
draggable={item.depth === 0}
|
| 1665 |
onDragStart={(e) => {
|
| 1666 |
e.dataTransfer.setData(NAV_DRAG_TYPE, item.key);
|
| 1667 |
e.dataTransfer.effectAllowed = "move";
|
|
|
|
| 1673 |
}}
|
| 1674 |
>
|
| 1675 |
{link}
|
| 1676 |
+
{item.depth === 0 && (
|
| 1677 |
<RowMenu
|
| 1678 |
entryLabel={item.label}
|
| 1679 |
canSchema
|
|
|
|
| 1742 |
);
|
| 1743 |
}
|
| 1744 |
)}
|
| 1745 |
+
{/* The panel's own empty state: ONE line, and it is different from "this
|
| 1746 |
+
workspace has none" β a search that matched nothing is not a workspace
|
| 1747 |
+
with nothing in it, and saying the second when the first is true is how a
|
| 1748 |
+
reader concludes their data is gone. */}
|
| 1749 |
+
{shownEntries.length === 0 ? (
|
| 1750 |
+
<p className="shell-dbfly-empty">
|
| 1751 |
+
{dbQuery ? "No database matches that." : "No databases yet."}
|
| 1752 |
+
</p>
|
| 1753 |
+
) : null}
|
| 1754 |
+
</div>
|
| 1755 |
+
{/* ββ the create footer (C10) β three doors, ONE dialog ββββββββββββββββββ
|
| 1756 |
+
Blank and template open the same New-database dialog with the choice
|
| 1757 |
+
pre-selected; automated hands off to the automation wizard, because an
|
| 1758 |
+
automated database is CREATED BY the automation that fills it (C2's
|
| 1759 |
+
`target.mode`), not by a name box that then needs one. */}
|
| 1760 |
+
<div className="shell-dbfly-foot">
|
| 1761 |
+
<button
|
| 1762 |
+
type="button"
|
| 1763 |
+
className="shell-dbfly-make"
|
| 1764 |
+
onClick={() => {
|
| 1765 |
+
closeDbFly();
|
| 1766 |
+
openNewDb("blank");
|
| 1767 |
+
}}
|
| 1768 |
+
>
|
| 1769 |
+
<PlusMark />
|
| 1770 |
+
Blank database
|
| 1771 |
+
</button>
|
| 1772 |
+
<button
|
| 1773 |
+
type="button"
|
| 1774 |
+
className="shell-dbfly-make"
|
| 1775 |
+
onClick={() => {
|
| 1776 |
+
closeDbFly();
|
| 1777 |
+
openNewDb("template");
|
| 1778 |
+
}}
|
| 1779 |
+
>
|
| 1780 |
+
<PlusMark />
|
| 1781 |
+
From a template
|
| 1782 |
+
</button>
|
| 1783 |
+
<button
|
| 1784 |
+
type="button"
|
| 1785 |
+
className="shell-dbfly-make"
|
| 1786 |
+
onClick={() => {
|
| 1787 |
+
closeDbFly();
|
| 1788 |
+
openAutomated();
|
| 1789 |
+
}}
|
| 1790 |
+
>
|
| 1791 |
+
<PlusMark />
|
| 1792 |
+
Automated database
|
| 1793 |
+
</button>
|
| 1794 |
+
</div>
|
| 1795 |
+
</div>,
|
| 1796 |
+
document.body
|
| 1797 |
+
)
|
| 1798 |
+
: null}
|
| 1799 |
{/* Wave 17 item 3 (R6) β the mark, never the word. "Loadingβ¦" under an
|
| 1800 |
empty rail told the reader what they could already see, and it read
|
| 1801 |
as a nav ITEM for the beat before it vanished. */}
|
|
|
|
| 1811 |
) : null}
|
| 1812 |
{/* β Wave 18: this line is for a MISCONFIGURED ACCOUNT β one whose grants give it
|
| 1813 |
nothing β and it must not fire for a freshly provisioned TENANT, which has no
|
| 1814 |
+
modules by design and is being welcomed on Home. Both at once said "something is
|
| 1815 |
+
wrong here" and "welcome, start here" in one screen (caught in the close-out
|
| 1816 |
+
visual pass, not by any gate). "+ Create newβ¦" above is the honest affordance in
|
| 1817 |
+
the tenant case. */}
|
| 1818 |
{nav.phase === "ready" && entries.length === 0 && !tenantEmptyState ? (
|
| 1819 |
<div className="shell-nav-note">No surfaces are available to this account.</div>
|
| 1820 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1821 |
</nav>
|
| 1822 |
|
| 1823 |
<div
|
|
|
|
| 1879 |
) : active?.kind === "native" && active.key === "automation" ? (
|
| 1880 |
// Wave 18 (C-AUTONAV): the Automation surface β its OWN secondary rail + editor,
|
| 1881 |
// props-free by contract (it fetches /api/v1/automations itself). SESSION D's tree.
|
| 1882 |
+
//
|
| 1883 |
+
// β WAVE 23 C13 (owner item 2), wiring W23-W1 β THE SAME FRAME AS EVERY DATABASE.
|
| 1884 |
+
//
|
| 1885 |
+
// This branch used to mount the surface BARE: no `shell-db-frame`, no `DbHead`. So
|
| 1886 |
+
// the one page in the product that is not a database was also the one page whose
|
| 1887 |
+
// name was drawn by its own component, at its own size and weight β three title
|
| 1888 |
+
// treatments (the surface's 16px/700 editable input, its 20px/600 empty-state h1,
|
| 1889 |
+
// and the frame's 16px/650 `shell-db-name`) against the grid's one. The fix is not
|
| 1890 |
+
// to restyle the stand-in but to delete the reason it exists: one title system,
|
| 1891 |
+
// owned by the shell, mounted here.
|
| 1892 |
+
//
|
| 1893 |
+
// β THE LABEL COMES OFF THE PAYLOAD, exactly as the rail's does (:1006). C13 words
|
| 1894 |
+
// it "label 'Automation'" and that IS `active.label` β the registry's literal, or a
|
| 1895 |
+
// tenant's `nav_meta` override of it. Hard-coding the word here would put the same
|
| 1896 |
+
// surface's name in two places and let them drift on the day someone renames it.
|
| 1897 |
+
//
|
| 1898 |
+
// β THE HEIGHT CHAIN. `.auto-surface` is `height: 100%`, so dropping it straight
|
| 1899 |
+
// into the frame's flex column would size it against the WHOLE frame and push its
|
| 1900 |
+
// rail a header's height below the fold. `.shell-auto-host` is the same `flex: 1 1
|
| 1901 |
+
// auto; min-height: 0` link `.shell-grid-host` is for glide β and it is the shell's
|
| 1902 |
+
// OWN class rather than a `.shell-db-frame > .auto-surface` rule, because
|
| 1903 |
+
// `.auto-*` is SESSION B's CSS region this wave and a frame has no business
|
| 1904 |
+
// reaching into its child's namespace to make itself fit.
|
| 1905 |
+
<div className="shell-db-frame">
|
| 1906 |
+
<DbHead
|
| 1907 |
+
label={active.label}
|
| 1908 |
+
glyph={<AutoIcon />}
|
| 1909 |
+
{...(active.icon ? { icon: active.icon } : {})}
|
| 1910 |
+
/>
|
| 1911 |
+
<div className="shell-auto-host">
|
| 1912 |
+
<AutomationSurface />
|
| 1913 |
+
</div>
|
| 1914 |
+
</div>
|
| 1915 |
) : active?.kind === "native" ? (
|
| 1916 |
// The native grid surfaces β ONE component, topic decided by the route. Wave 16:
|
| 1917 |
// the `#/cohort` route left with the cohort registry row (cohorts are LOCKED VIEWS
|
|
|
|
| 1946 |
</div>
|
| 1947 |
) : active ? (
|
| 1948 |
<StranglerPage entry={active} />
|
| 1949 |
+
) : route === CONNECTORS_ROUTE ? (
|
| 1950 |
+
// β WAVE 23 item 10 (R8, C11, wiring W23-W3) β the connectors directory.
|
| 1951 |
+
//
|
| 1952 |
+
// The one action the page cannot perform itself: a `manage: "keychain"` row opens
|
| 1953 |
+
// Settings on the Keychains tab, and the Settings MODAL is the frame's. Same division
|
| 1954 |
+
// as every other panel here β the page knows what it wants, the frame owns the door.
|
| 1955 |
+
<ConnectorsPage onKeychain={() => setSettings("keychains")} />
|
| 1956 |
+
) : nav.phase === "ready" ? (
|
| 1957 |
+
// β WAVE 23 item 9 (R7, C10) β HOME, AND IT IS THE FALLBACK RATHER THAN A ROUTE MATCH.
|
| 1958 |
+
//
|
| 1959 |
+
// β WHY `!active` AND NOT `route === HOME_ROUTE`. Three states have to land here and
|
| 1960 |
+
// only one of them is the literal hash: `#/home`, an EMPTY hash, and a route that no
|
| 1961 |
+
// longer resolves (a deleted table, a stale bookmark, a hand-typed key). `resolveRoute`
|
| 1962 |
+
// already funnels all three to "no entry" now that `defaultRoute` returns a chrome
|
| 1963 |
+
// route, so testing the literal would have left the other two on the "Nothing to show"
|
| 1964 |
+
// branch β a shell that looks broken as the reward for deleting a database, which is
|
| 1965 |
+
// the exact failure `removeDatabase`'s own note warns about from the other side.
|
| 1966 |
+
//
|
| 1967 |
+
// β AND THIS BRANCH ABSORBED WAVE 18's TENANT HERO (R4), which used to live below.
|
| 1968 |
+
// That hero said "Welcome β this workspace has no databases yet" plus one create
|
| 1969 |
+
// button, on precisely the state Home now owns: a ready nav with zero entries. Leaving
|
| 1970 |
+
// both reachable would put two welcome messages on one screen, which is the defect the
|
| 1971 |
+
// rail's own note at the empty-nav line already documents. Home's four cards ARE the
|
| 1972 |
+
// hero's create door, and its empty line carries the hero's sentence (HomePage's
|
| 1973 |
+
// `home-empty`, which branches on `entries.length === 0` for exactly this reason).
|
| 1974 |
+
// Booked as a dated amendment in the wave doc, since it moves an R4 surface.
|
| 1975 |
+
<HomePage
|
| 1976 |
+
entries={entries}
|
| 1977 |
+
recents={recents}
|
| 1978 |
+
onTemplates={() => openNewDb("template")}
|
| 1979 |
+
onNewDatabase={() => openNewDb("blank")}
|
| 1980 |
+
onAutomated={openAutomated}
|
| 1981 |
+
onConnectors={() => {
|
| 1982 |
+
window.location.hash = `#/${CONNECTORS_ROUTE}`;
|
| 1983 |
+
}}
|
| 1984 |
+
/>
|
| 1985 |
+
) : nav.phase === "error" ? (
|
| 1986 |
+
// β WAVE 17 ITEM 4 (R6) β THE ERROR AND THE EMPTY ARE DIFFERENT SENTENCES, and this
|
| 1987 |
+
// branch is what keeps them apart. `active` resolves from `entries`, which is empty
|
| 1988 |
+
// while the nav is IN FLIGHT, so every first login used to land on a pane stating the
|
| 1989 |
+
// single worst thing this frame could say to a new user for as long as a round trip
|
| 1990 |
+
// took. WAVE 23 keeps that separation and simplifies its shape: `ready` now goes to
|
| 1991 |
+
// Home above (which welcomes an empty workspace properly), so this is only ever the
|
| 1992 |
+
// nav that FAILED, and the nested ternary that used to sort the two is gone.
|
| 1993 |
+
<div className="shell-placeholder">
|
| 1994 |
+
<h1>Nothing to show</h1>
|
| 1995 |
+
<p>The navigation service did not answer. Reload to retry.</p>
|
| 1996 |
+
</div>
|
| 1997 |
) : (
|
| 1998 |
// Still asking (`loading`, or the `idle` beat before the effect runs).
|
| 1999 |
<div className="shell-loading">
|
|
|
|
| 2045 |
onClick={(e) => e.stopPropagation()}
|
| 2046 |
>
|
| 2047 |
<h2>New database</h2>
|
| 2048 |
+
{/* β WAVE 23 C10 β THE THREE-WAY CHOICE. Three one-line rows, not three paragraphs:
|
| 2049 |
+
the explanation lives where the DECISION is made and stops there (R13). The
|
| 2050 |
+
dialog's old sub-line ("a blank database⦠nothing here connects to a source")
|
| 2051 |
+
said one of these three things as if it were the only one, so it moved onto the
|
| 2052 |
+
row it actually describes. */}
|
| 2053 |
+
<div className="shell-newdb-kinds" role="radiogroup" aria-label="What to create">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2054 |
<button
|
| 2055 |
type="button"
|
| 2056 |
+
role="radio"
|
| 2057 |
+
aria-checked={newDb.mode === "blank"}
|
| 2058 |
+
className={"shell-newdb-kind" + (newDb.mode === "blank" ? " is-on" : "")}
|
| 2059 |
disabled={newDb.busy}
|
| 2060 |
+
onClick={() => setNewDb({ ...newDb, mode: "blank", err: "" })}
|
| 2061 |
>
|
| 2062 |
+
<span className="shell-newdb-kind-name">Blank</span>
|
| 2063 |
+
<span className="shell-newdb-kind-detail">
|
| 2064 |
+
An empty database β add fields and records once it opens.
|
| 2065 |
+
</span>
|
| 2066 |
</button>
|
| 2067 |
<button
|
| 2068 |
type="button"
|
| 2069 |
+
role="radio"
|
| 2070 |
+
aria-checked={newDb.mode === "template"}
|
| 2071 |
+
className={"shell-newdb-kind" + (newDb.mode === "template" ? " is-on" : "")}
|
| 2072 |
+
disabled={newDb.busy}
|
| 2073 |
+
onClick={() => setNewDb({ ...newDb, mode: "template", err: "" })}
|
| 2074 |
>
|
| 2075 |
+
<span className="shell-newdb-kind-name">From a template</span>
|
| 2076 |
+
<span className="shell-newdb-kind-detail">
|
| 2077 |
+
Add a curated set of views to a database you already have.
|
| 2078 |
+
</span>
|
| 2079 |
+
</button>
|
| 2080 |
+
{/* β NOT A RADIO β it LEAVES. Picking it hands off to the automation wizard
|
| 2081 |
+
rather than selecting a state in this dialog, so it must not sit in the
|
| 2082 |
+
radiogroup pretending to be a third selectable option (`role="radio"` with
|
| 2083 |
+
`aria-checked` it can never wear is a control that lies to a screen reader). */}
|
| 2084 |
+
<button
|
| 2085 |
+
type="button"
|
| 2086 |
+
className="shell-newdb-kind"
|
| 2087 |
+
disabled={newDb.busy}
|
| 2088 |
+
onClick={openAutomated}
|
| 2089 |
+
>
|
| 2090 |
+
<span className="shell-newdb-kind-name">
|
| 2091 |
+
Automated
|
| 2092 |
+
<ExtIcon />
|
| 2093 |
+
</span>
|
| 2094 |
+
<span className="shell-newdb-kind-detail">
|
| 2095 |
+
A database an automation creates and keeps up to date.
|
| 2096 |
+
</span>
|
| 2097 |
</button>
|
| 2098 |
</div>
|
| 2099 |
+
{newDb.mode === "template" ? (
|
| 2100 |
+
// WAVE 23 C12 (wiring W23-W6) β the picker. It applies views to an EXISTING
|
| 2101 |
+
// database rather than making one, which is what a platform-curated template is:
|
| 2102 |
+
// a set of saved views, not a table (R10).
|
| 2103 |
+
<TemplatePicker
|
| 2104 |
+
entries={dbEntries}
|
| 2105 |
+
onToast={setToast}
|
| 2106 |
+
onDone={(tableKey) => {
|
| 2107 |
+
setNewDb(null);
|
| 2108 |
+
window.location.hash = `#/${tableKey}`;
|
| 2109 |
+
}}
|
| 2110 |
+
/>
|
| 2111 |
+
) : (
|
| 2112 |
+
<>
|
| 2113 |
+
<input
|
| 2114 |
+
autoFocus
|
| 2115 |
+
className="shell-newdb-input"
|
| 2116 |
+
placeholder="Database name"
|
| 2117 |
+
value={newDb.name}
|
| 2118 |
+
maxLength={60}
|
| 2119 |
+
disabled={newDb.busy}
|
| 2120 |
+
onChange={(e) => setNewDb({ ...newDb, name: e.target.value })}
|
| 2121 |
+
onKeyDown={(e) => {
|
| 2122 |
+
if (e.key === "Enter") void createDb();
|
| 2123 |
+
if (e.key === "Escape" && !newDb.busy) setNewDb(null);
|
| 2124 |
+
}}
|
| 2125 |
+
/>
|
| 2126 |
+
{newDb.err ? <p className="shell-newdb-err">{newDb.err}</p> : null}
|
| 2127 |
+
<div className="shell-newdb-actions">
|
| 2128 |
+
<button
|
| 2129 |
+
type="button"
|
| 2130 |
+
onClick={() => setNewDb(null)}
|
| 2131 |
+
disabled={newDb.busy}
|
| 2132 |
+
>
|
| 2133 |
+
Cancel
|
| 2134 |
+
</button>
|
| 2135 |
+
<button
|
| 2136 |
+
type="button"
|
| 2137 |
+
className="login-submit"
|
| 2138 |
+
onClick={() => void createDb()}
|
| 2139 |
+
disabled={newDb.busy || !newDb.name.trim()}
|
| 2140 |
+
>
|
| 2141 |
+
{newDb.busy ? "Creatingβ¦" : "Create database"}
|
| 2142 |
+
</button>
|
| 2143 |
+
</div>
|
| 2144 |
+
</>
|
| 2145 |
+
)}
|
| 2146 |
</div>
|
| 2147 |
</div>
|
| 2148 |
) : null}
|
|
|
|
| 2183 |
new CustomEvent("aios:view-open", { detail: { topic, viewId } })
|
| 2184 |
);
|
| 2185 |
}}
|
| 2186 |
+
// β WAVE 23 C6 (wiring W23-W5) β the review notification's click-through.
|
| 2187 |
+
//
|
| 2188 |
+
// THE SAME TWO-PART MOVE the view click-through makes, and for the same reason: the
|
| 2189 |
+
// frame owns the hash, the automation SURFACE owns which automation is selected
|
| 2190 |
+
// (`activeId` is its own state, seeded from its own list β this frame cannot see it
|
| 2191 |
+
// and must not learn to). So: route, then ASK.
|
| 2192 |
+
//
|
| 2193 |
+
// β THE ORDER IS LOAD-BEARING. The hash is set FIRST so the surface is mounting (or
|
| 2194 |
+
// already mounted) when the event arrives; dispatching first would fire into a route
|
| 2195 |
+
// that does not exist yet. `signal` is `apiContract`'s helper β the same channel the
|
| 2196 |
+
// grid's own listeners use, rather than a second hand-rolled `dispatchEvent` here.
|
| 2197 |
+
onOpenAutomation={(autoId, stageId) => {
|
| 2198 |
+
window.location.hash = "#/automation";
|
| 2199 |
+
signal(AUTOMATION_OPEN_EVENT, { autoId, ...(stageId ? { stageId } : {}) });
|
| 2200 |
+
}}
|
| 2201 |
/>
|
| 2202 |
) : null}
|
| 2203 |
|
web/src/shell/nav.ts
CHANGED
|
@@ -188,15 +188,70 @@ export function splitChrome(pages: NavPage[]): { main: NavPage[]; utility: NavPa
|
|
| 188 |
return { main, utility };
|
| 189 |
}
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
/** `empty` (wave 18): the SERVER's reason for an empty page list. `"no_databases"` means a
|
| 192 |
* freshly provisioned tenant with no modules and no databases YET β a legitimate starting
|
| 193 |
* state, not a broken account. Absent means "no reason given", which the shell reads as the
|
| 194 |
* misconfigured-account case it always did. Distinguishing them is what stops the rail saying
|
| 195 |
* "no surfaces are available" beside a hero saying "welcome, create your first database". */
|
| 196 |
export type NavResult =
|
| 197 |
-
| { ok: true; pages: NavPage[]; empty?: string }
|
| 198 |
| { ok: false; status: number };
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
/**
|
| 201 |
* X2 `GET /api/v1/nav`. `status` rides the failure so the caller can tell the
|
| 202 |
* two apart that must not be conflated: **401 means the session is gone** (drop
|
|
@@ -210,7 +265,12 @@ export async function fetchNav(): Promise<NavResult> {
|
|
| 210 |
if (!res.ok) return { ok: false, status: res.status };
|
| 211 |
const body = await res.json();
|
| 212 |
const empty = typeof body?.empty === "string" ? body.empty : undefined;
|
| 213 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
} catch {
|
| 215 |
return { ok: false, status: 0 };
|
| 216 |
}
|
|
@@ -702,10 +762,62 @@ export function shapeNav(pages: NavPage[], appBase: string): NavEntry[] {
|
|
| 702 |
* can read, not in whatever the registry happens to order first.
|
| 703 |
*/
|
| 704 |
// (`cohort` left the list with its NATIVE_KEYS exit, wave 16 β a landing must be drawable.)
|
| 705 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 706 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 707 |
export function defaultRoute(entries: NavEntry[]): string {
|
| 708 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 709 |
const hit = entries.find((e) => e.key === key && e.kind === "native");
|
| 710 |
if (hit) return hit.key;
|
| 711 |
}
|
|
@@ -725,6 +837,13 @@ export function defaultRoute(entries: NavEntry[]): string {
|
|
| 725 |
* member"). An unknown route falls to the default. Both fall back rather than
|
| 726 |
* erroring, because a URL is something a person can type or a bookmark can
|
| 727 |
* outlive.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
*/
|
| 729 |
export function resolveRoute(entries: NavEntry[], route: string): NavEntry | undefined {
|
| 730 |
const i = entries.findIndex((e) => e.key === route);
|
|
|
|
| 188 |
return { main, utility };
|
| 189 |
}
|
| 190 |
|
| 191 |
+
/**
|
| 192 |
+
* WAVE 23 (C10 / R7) β one entry of the Home landing's recents.
|
| 193 |
+
*
|
| 194 |
+
* `at` is EPOCH SECONDS, and the type says so because the alternative bit this product once:
|
| 195 |
+
* a formatted naive-local stamp read by a browser in another zone (D-18). An integer instant
|
| 196 |
+
* has one reading everywhere, and the two things Home renders from it β "Opened N minutes ago"
|
| 197 |
+
* and the Today / Past 7 days / Older bucket β are both statements about THE READER'S OWN
|
| 198 |
+
* CLOCK, which is the one case where using it is correct rather than forbidden (contrast
|
| 199 |
+
* `alertsModel.stampText`, which must NOT touch a clock because it re-states a server event).
|
| 200 |
+
*/
|
| 201 |
+
export interface Recent {
|
| 202 |
+
key: string;
|
| 203 |
+
at: number;
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
/** `empty` (wave 18): the SERVER's reason for an empty page list. `"no_databases"` means a
|
| 207 |
* freshly provisioned tenant with no modules and no databases YET β a legitimate starting
|
| 208 |
* state, not a broken account. Absent means "no reason given", which the shell reads as the
|
| 209 |
* misconfigured-account case it always did. Distinguishing them is what stops the rail saying
|
| 210 |
* "no surfaces are available" beside a hero saying "welcome, create your first database". */
|
| 211 |
export type NavResult =
|
| 212 |
+
| { ok: true; pages: NavPage[]; recents: Recent[]; empty?: string }
|
| 213 |
| { ok: false; status: number };
|
| 214 |
|
| 215 |
+
/**
|
| 216 |
+
* `{recents:[β¦]}` β the list, fail-closed. An entry with no key or an unreadable stamp is
|
| 217 |
+
* DROPPED rather than rendered at the epoch, which would file it under "Older" for ever and
|
| 218 |
+
* put a tile on Home for something nobody opened.
|
| 219 |
+
*/
|
| 220 |
+
export function parseRecents(body: unknown): Recent[] {
|
| 221 |
+
const raw = (body as { recents?: unknown } | null)?.recents;
|
| 222 |
+
if (!Array.isArray(raw)) return [];
|
| 223 |
+
const out: Recent[] = [];
|
| 224 |
+
for (const item of raw) {
|
| 225 |
+
if (!item || typeof item !== "object") continue;
|
| 226 |
+
const r = item as Record<string, unknown>;
|
| 227 |
+
const key = typeof r.key === "string" ? r.key.trim() : "";
|
| 228 |
+
const at = typeof r.at === "number" && isFinite(r.at) ? Math.floor(r.at) : 0;
|
| 229 |
+
if (!key || at <= 0) continue;
|
| 230 |
+
out.push({ key, at });
|
| 231 |
+
}
|
| 232 |
+
return out;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/**
|
| 236 |
+
* WAVE 23 (C10) β stamp a page as opened. FIRE AND FORGET, by design.
|
| 237 |
+
*
|
| 238 |
+
* β IT RETURNS `void` AND SWALLOWS EVERYTHING. A recents stamp is the least important write in
|
| 239 |
+
* the product: it decorates a landing page. Nothing the user is doing may wait on it, and no
|
| 240 |
+
* failure of it may reach a screen β a toast saying "we could not record that you opened this
|
| 241 |
+
* table" is noise about a feature nobody asked for, printed over the table they successfully
|
| 242 |
+
* opened. The server answers 503 honestly when the store is down; this end simply does not
|
| 243 |
+
* care, which is the only correct posture for telemetry-shaped state.
|
| 244 |
+
*/
|
| 245 |
+
export function postOpened(key: string): void {
|
| 246 |
+
if (!key) return;
|
| 247 |
+
void fetch(`${API_V1}/nav/opened`, {
|
| 248 |
+
method: "POST",
|
| 249 |
+
credentials: CREDENTIALS,
|
| 250 |
+
headers: { "Content-Type": "application/json" },
|
| 251 |
+
body: JSON.stringify({ key }),
|
| 252 |
+
}).catch(() => {});
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
/**
|
| 256 |
* X2 `GET /api/v1/nav`. `status` rides the failure so the caller can tell the
|
| 257 |
* two apart that must not be conflated: **401 means the session is gone** (drop
|
|
|
|
| 265 |
if (!res.ok) return { ok: false, status: res.status };
|
| 266 |
const body = await res.json();
|
| 267 |
const empty = typeof body?.empty === "string" ? body.empty : undefined;
|
| 268 |
+
return {
|
| 269 |
+
ok: true,
|
| 270 |
+
pages: parsePages(body),
|
| 271 |
+
recents: parseRecents(body),
|
| 272 |
+
...(empty ? { empty } : {}),
|
| 273 |
+
};
|
| 274 |
} catch {
|
| 275 |
return { ok: false, status: 0 };
|
| 276 |
}
|
|
|
|
| 762 |
* can read, not in whatever the registry happens to order first.
|
| 763 |
*/
|
| 764 |
// (`cohort` left the list with its NATIVE_KEYS exit, wave 16 β a landing must be drawable.)
|
| 765 |
+
//
|
| 766 |
+
// β WAVE 23 (contract C10, ruling R7) β THE LANDING IS NOW `#/home`.
|
| 767 |
+
//
|
| 768 |
+
// β CHROME ROUTES ARE NOT GRANTED PAGES, AND THE :1039 LAW SURVIVES INTACT. This shell's
|
| 769 |
+
// oldest rule is that the nav is server-filtered and an undeclared surface is denied β which is
|
| 770 |
+
// why Alerts is a PANEL and not a route. Home and Connectors are routes, and they do not
|
| 771 |
+
// violate that rule for one reason that has to be true of every future member of this set:
|
| 772 |
+
//
|
| 773 |
+
// A CHROME ROUTE RENDERS NOTHING THE SERVER DID NOT ALREADY GRANT.
|
| 774 |
+
//
|
| 775 |
+
// Home shows the entries `/nav` returned (its recents are RESOLVED against those entries and a
|
| 776 |
+
// key absent from them is dropped, `homeModel.groupRecents`) plus create affordances that were
|
| 777 |
+
// already in the rail. Connectors renders a SERVER-COMPOSED directory (`GET /connectors/
|
| 778 |
+
// directory`, session-gated) β it is a window onto a payload, not a surface with its own data.
|
| 779 |
+
// Neither can show a database, a module or a connector this session may not see; adding a route
|
| 780 |
+
// that could would be the hard-coded surface the frame refuses to have.
|
| 781 |
+
export const HOME_ROUTE = "home";
|
| 782 |
+
export const CONNECTORS_ROUTE = "connectors";
|
| 783 |
+
export const CHROME_ROUTES: ReadonlySet<string> = new Set([HOME_ROUTE, CONNECTORS_ROUTE]);
|
| 784 |
+
|
| 785 |
+
export const LANDING_PREFERENCE: readonly string[] = [HOME_ROUTE];
|
| 786 |
|
| 787 |
+
/**
|
| 788 |
+
* The surface the shell lands on.
|
| 789 |
+
*
|
| 790 |
+
* β THE THREE LANDING SITES MOVE TOGETHER OR LOGINS STRAND (C10's own warning): this function,
|
| 791 |
+
* `LANDING_PREFERENCE` above, and the post-login redirect in `Shell.tsx`. Half a move leaves a
|
| 792 |
+
* signed-in user on a hash nothing renders.
|
| 793 |
+
*
|
| 794 |
+
* A chrome route needs no entry to be reachable β that is what makes it chrome β so the loop
|
| 795 |
+
* below returns it without consulting `entries`, and the old preference chain survives beneath
|
| 796 |
+
* it for the day a granted page becomes the landing again. `entries` stays a parameter for the
|
| 797 |
+
* same reason: the fallback is still a real answer about a real payload.
|
| 798 |
+
*/
|
| 799 |
export function defaultRoute(entries: NavEntry[]): string {
|
| 800 |
+
return firstDestination(entries, LANDING_PREFERENCE);
|
| 801 |
+
}
|
| 802 |
+
|
| 803 |
+
/**
|
| 804 |
+
* The landing rule itself, over an arbitrary preference list.
|
| 805 |
+
*
|
| 806 |
+
* β EXTRACTED (wave 23) SO THE FALLBACK CHAIN STAYS UNDER TEST. Before C10 the chain WAS
|
| 807 |
+
* `defaultRoute`, so `shell.test.ts` asserted it by calling that. With `home` at the head of the
|
| 808 |
+
* preference the function now returns on its first line, and the whole "preferred native entry β
|
| 809 |
+
* any native entry β any destination" ladder became unreachable from the outside β three checks
|
| 810 |
+
* would have had to be deleted rather than retargeted, which is how a gate quietly stops
|
| 811 |
+
* asserting the thing it was written for ([[gate-can-report-green-on-nothing]]). It is one
|
| 812 |
+
* function called two ways instead.
|
| 813 |
+
*/
|
| 814 |
+
export function firstDestination(
|
| 815 |
+
entries: NavEntry[],
|
| 816 |
+
preference: readonly string[]
|
| 817 |
+
): string {
|
| 818 |
+
for (const key of preference) {
|
| 819 |
+
// A chrome route needs no entry to be reachable β that is what makes it chrome.
|
| 820 |
+
if (CHROME_ROUTES.has(key)) return key;
|
| 821 |
const hit = entries.find((e) => e.key === key && e.kind === "native");
|
| 822 |
if (hit) return hit.key;
|
| 823 |
}
|
|
|
|
| 837 |
* member"). An unknown route falls to the default. Both fall back rather than
|
| 838 |
* erroring, because a URL is something a person can type or a bookmark can
|
| 839 |
* outlive.
|
| 840 |
+
*
|
| 841 |
+
* β WAVE 23 (C10): the fallback is now `#/home`, which is CHROME and therefore matches no
|
| 842 |
+
* entry β so this returns `undefined` for an unknown route, and the frame reads "no entry" as
|
| 843 |
+
* "render Home". One behaviour change, stated plainly: `#/deleted_table` used to open the
|
| 844 |
+
* Customer grid and now opens Home. That is the better answer (the reader asked for something
|
| 845 |
+
* that is gone; the landing is where you choose again) and it is the SAME answer an empty hash
|
| 846 |
+
* gets β which is what "Home is the default" has to mean in order to be true.
|
| 847 |
*/
|
| 848 |
export function resolveRoute(entries: NavEntry[], route: string): NavEntry | undefined {
|
| 849 |
const i = entries.findIndex((e) => e.key === route);
|
web/src/viz/seriesData.ts
CHANGED
|
@@ -40,6 +40,11 @@ export const VIZ_FIELD_TYPES: ReadonlySet<string> = new Set([
|
|
| 40 |
// report the column as `text`, and the one thing a viz field type decides is what the surface
|
| 41 |
// is allowed to claim about the column.
|
| 42 |
"image",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
]);
|
| 44 |
|
| 45 |
/**
|
|
|
|
| 40 |
// report the column as `text`, and the one thing a viz field type decides is what the surface
|
| 41 |
// is allowed to claim about the column.
|
| 42 |
"image",
|
| 43 |
+
// Wave 23 C7 β `json`. THE BATCH LINE the union's own comment names: a `Set` is not a
|
| 44 |
+
// `Record`, so the compiler stays green when this goes stale and `asFields` silently reports
|
| 45 |
+
// a json column as `text`. Same reasoning as `image` β the type is categorical either way,
|
| 46 |
+
// and what listing it buys is that the surface stops mis-naming the column.
|
| 47 |
+
"json",
|
| 48 |
]);
|
| 49 |
|
| 50 |
/**
|
web/src/viz/types.ts
CHANGED
|
@@ -47,7 +47,15 @@ export type FieldType =
|
|
| 47 |
// picture (it is a reference string, so `isNumericFieldType` leaves it categorical and the
|
| 48 |
// shared formatter prints the ref) β the vocabulary has to know the word regardless, or the
|
| 49 |
// grid cannot hand its own fields to `<DashboardView>` at all.
|
| 50 |
-
| "image"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
export type FieldSource = "odoo" | "overlay";
|
| 53 |
|
|
|
|
| 47 |
// picture (it is a reference string, so `isNumericFieldType` leaves it categorical and the
|
| 48 |
// shared formatter prints the ref) β the vocabulary has to know the word regardless, or the
|
| 49 |
// grid cannot hand its own fields to `<DashboardView>` at all.
|
| 50 |
+
| "image"
|
| 51 |
+
// Wave-23 C7 β `json`, and it is COERCED TO TEXT for charting by construction rather than by
|
| 52 |
+
// a branch: `isNumericFieldType` does not name it, so the engine groups it as a category and
|
| 53 |
+
// the shared formatter prints the stored string. Grouping a table by a whole document is a
|
| 54 |
+
// question nobody asks, but the vocabulary has to know the word or the grid cannot hand its
|
| 55 |
+
// own fields to DashboardView at all β which is the alarm two rules below going off. KEEP
|
| 56 |
+
// THIS COMMENT FREE OF THE STATEMENT-TERMINATOR CHARACTER, exactly as the metric note above
|
| 57 |
+
// says: verify_ui parses this union up to the first one it meets
|
| 58 |
+
| "json";
|
| 59 |
|
| 60 |
export type FieldSource = "odoo" | "overlay";
|
| 61 |
|