File size: 8,574 Bytes
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// ---------------------------------------------------------------------------
// 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>
  );
}