loopable / web /src /connectors /OdooConfig.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
8.57 kB
// ---------------------------------------------------------------------------
// connectors/OdooConfig.tsx β€” WAVE 32 items 6/11, contract C2, rulings R9/R10/R11.
//
// β›” WHAT THE OWNER FOUND WHEN THEY CLICKED "Manage keys" ON ODOO: a list of
// credentials. Not which server database this workspace reads, not which of the
// ten mirrored grids it wants, not how often they sync, and no way to stop. This
// panel is those four questions, on the card the question is asked from.
//
// β›”β›” THE PANEL IS A PURE COMPONENT AND THE FETCHING LIVES ABOVE IT, which is not
// a style preference β€” it is what makes the ticket's `done-when` provable. The
// page fetches on mount and `renderToStaticMarkup` does not run effects, so a
// component that loads its own data is a spinner forever in a shot, and a ticket
// whose done-when is "an admin SEES the server database and a checklist" cannot
// be evidenced by a picture of a spinner. `ConnectorCard` owns the request;
// `OdooConfigPanel` renders whatever it is handed. Same reason `ConnectorCard`
// itself was lifted out of the page in wave 30.
//
// ⚠ NO CLIENT UNION OVER `syncEvery` OR THE GRID KEYS. Both are the server's
// vocabulary (`odoo_relational.SYNC_PRESETS`, `odoo_relational.TABLES`) and
// arrive as strings β€” a client union turns "the server added a preset" into "the
// client drops the option" (the wave-9 law).
// ---------------------------------------------------------------------------
/** One materialisable grid, exactly as `GET /admin/connectors/odoo/config` sends it. */
export interface OdooGrid {
key: string;
label: string;
enabled: boolean;
}
/** The config payload. Everything optional but `grids` is genuinely optional on the wire β€”
* an older server, or a tenant with no Odoo at all, simply says less. */
export interface OdooConfig {
applicable?: boolean;
/** `keychain` | `env` | `none` β€” WHERE the credential lives, read as a string. */
source?: string;
label?: string;
/** The masked hint the keychain computed at write. Never the secret. */
preview?: string;
serverDb?: string;
serverUrl?: string;
apiUser?: string;
/** False for the deployment ENVIRONMENT: editing it here would be editing the container. */
serverDbEditable?: boolean;
grids?: OdooGrid[];
syncEvery?: string;
syncOptions?: string[];
syncFloorSeconds?: number;
frozen?: boolean;
frozenAt?: string;
canDisconnect?: boolean;
/** What the server did with what it was sent β€” a clamp, a refusal, a rewrite (W30/R6). */
notes?: string[];
}
/**
* The cadence words, in the two places a person reads them. ⚠ The MAP is presentation; the
* OPTIONS come from the server (`syncOptions`), so a preset we have no phrase for still renders
* β€” as its own key rather than as nothing.
*/
const EVERY_LABEL: Record<string, string> = {
"30m": "Every 30 minutes",
"1h": "Every hour",
"4h": "Every 4 hours",
daily: "Once a day",
manual: "Only when I ask",
};
export const everyLabel = (k: string): string => EVERY_LABEL[k] || k;
/** What the connection is, in one line, for the header of the panel. */
export function sourceLine(cfg: OdooConfig): string {
if (cfg.frozen) return "Disconnected β€” these databases are frozen as static data";
if (cfg.source === "env") return "Connected using this deployment's own credentials";
if (cfg.source === "keychain") return "Connected using a key stored in this workspace";
return "Not connected";
}
export default function OdooConfigPanel({
cfg,
busy,
onToggleGrid,
onSyncEvery,
onServerDb,
onDisconnect,
onReconnect,
}: {
cfg: OdooConfig;
busy?: boolean;
onToggleGrid: (key: string, enabled: boolean) => void;
onSyncEvery: (every: string) => void;
onServerDb: (db: string) => void;
onDisconnect: () => void;
onReconnect: () => void;
}) {
const grids = cfg.grids ?? [];
const options = cfg.syncOptions ?? [];
const on = grids.filter((g) => g.enabled).length;
return (
<div className="conn-odoo">
<p className="conn-card-meta">{sourceLine(cfg)}</p>
{/* ── the credential, as much of it as may be shown ────────────────────────────────── */}
<div className="conn-odoo-row">
<span className="conn-odoo-k">Key</span>
<span className="conn-odoo-v">
{cfg.preview ? cfg.preview : "held on this deployment"}
{cfg.apiUser ? " Β· " + cfg.apiUser : ""}
</span>
</div>
{cfg.serverUrl ? (
<div className="conn-odoo-row">
<span className="conn-odoo-k">Server</span>
<span className="conn-odoo-v">{cfg.serverUrl}</span>
</div>
) : null}
<div className="conn-odoo-row">
<span className="conn-odoo-k">Database</span>
{cfg.serverDbEditable ? (
// ⚠ A `defaultValue` + blur, not a controlled input: this component is PURE and holds
// no state, and a controlled field with no owner is a box you cannot type in.
<input
className="conn-odoo-v"
defaultValue={cfg.serverDb ?? ""}
maxLength={80}
disabled={busy || cfg.frozen}
aria-label="Odoo server database"
onBlur={(e) => {
const next = e.target.value.trim();
if (next && next !== (cfg.serverDb ?? "")) onServerDb(next);
}}
/>
) : (
<span className="conn-odoo-v">
{cfg.serverDb || "β€”"}
{/* R6's second sentence: a thing you cannot change says WHY, not nothing. */}
<em> β€” set on this deployment, not in this workspace</em>
</span>
)}
</div>
{/* ── R9's second reading: WHICH grids this workspace materialises ─────────────────── */}
<p className="conn-odoo-h">
Databases to build <span className="conn-odoo-count">{on} of {grids.length}</span>
</p>
<div className="conn-odoo-grids">
{grids.map((g) => (
<label className="conn-odoo-grid" key={g.key}>
<input
type="checkbox"
checked={g.enabled}
disabled={busy || cfg.frozen}
onChange={(e) => onToggleGrid(g.key, e.target.checked)}
/>
<span>{g.label}</span>
</label>
))}
</div>
{/* β›” SAID BEFORE IT IS DONE, not after. Unticking stops the NEXT sync from rebuilding a
database; it does not delete the one you already have. A checkbox that silently threw
away rows would be the worst control on this page. */}
<p className="conn-card-meta">
Unticking one stops it being rebuilt on the next sync. Nothing you already have is
deleted.
</p>
{/* ── R11: the cadence, from the server's own preset list, floor included ──────────── */}
<p className="conn-odoo-h">How often to sync</p>
<select
className="conn-odoo-every"
value={cfg.syncEvery ?? ""}
disabled={busy || cfg.frozen}
aria-label="How often to sync"
onChange={(e) => onSyncEvery(e.target.value)}
>
{options.map((o) => (
<option value={o} key={o}>
{everyLabel(o)}
</option>
))}
</select>
{/* ── R10: off, and back on ────────────────────────────────────────────────────────── */}
{cfg.frozen ? (
<div className="conn-odoo-off">
<p className="conn-card-meta">
Disconnected{cfg.frozenAt ? " on " + cfg.frozenAt.slice(0, 10) : ""}. Every row and
every column you had is still here and still readable β€” nothing is being refreshed.
</p>
<button type="button" className="conn-card-go" disabled={busy} onClick={onReconnect}>
Reconnect
</button>
</div>
) : cfg.canDisconnect ? (
<button type="button" className="conn-odoo-danger" disabled={busy} onClick={onDisconnect}>
Disconnect Odoo
</button>
) : null}
{/* Whatever the server did with what it was sent β€” a clamp, a rewrite, a warning. */}
{(cfg.notes ?? []).length ? (
<ul className="conn-odoo-notes">
{(cfg.notes ?? []).map((n) => (
<li key={n}>{n}</li>
))}
</ul>
) : null}
</div>
);
}