normalized-schema-generator 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,635 @@
1
+ export function render({ model, el }) {
2
+ const root = document.createElement("div");
3
+ root.className = "nsw";
4
+ el.appendChild(root);
5
+
6
+ /* ── helpers ──────────────────────────────────────────── */
7
+ function h(tag, attrs, ...children) {
8
+ const el = document.createElement(tag);
9
+ if (attrs) {
10
+ for (const [k, v] of Object.entries(attrs)) {
11
+ if (v === undefined || v === null) continue;
12
+ if (k === "className") el.className = v;
13
+ else if (k === "textContent") el.textContent = v;
14
+ else if (k === "innerHTML") el.innerHTML = v;
15
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2).toLowerCase(), v);
16
+ else el.setAttribute(k, v);
17
+ }
18
+ }
19
+ for (const child of children.flat()) {
20
+ if (child == null) continue;
21
+ if (typeof child === "string") el.appendChild(document.createTextNode(child));
22
+ else el.appendChild(child);
23
+ }
24
+ return el;
25
+ }
26
+
27
+ function dtypeBadge(dtype) {
28
+ const known = { int: "INT", double: "FLOAT", string: "STR", timestamp: "DATE", boolean: "BOOL" };
29
+ const cls = known[dtype] ? dtype : "other";
30
+ return h("span", { className: "nsw-col-dtype " + cls, textContent: known[dtype] || dtype.toUpperCase().slice(0, 8) });
31
+ }
32
+
33
+ function toggleSwitch(checked, onChange) {
34
+ const lbl = h("label", { className: "nsw-toggle-cb" });
35
+ const inp = h("input", { type: "checkbox" });
36
+ inp.checked = checked;
37
+ inp.addEventListener("change", () => onChange(inp.checked));
38
+ lbl.appendChild(inp);
39
+ lbl.appendChild(h("span", { className: "slider" }));
40
+ return lbl;
41
+ }
42
+
43
+ function selectEl(options, value, onChange, placeholder, disabled) {
44
+ const sel = h("select", { className: "nsw-select" });
45
+ if (disabled) sel.disabled = true;
46
+ if (placeholder) {
47
+ const opt = h("option", { value: "", textContent: placeholder });
48
+ opt.disabled = true;
49
+ if (!value) opt.selected = true;
50
+ sel.appendChild(opt);
51
+ }
52
+ for (const o of options) {
53
+ const opt = h("option", { value: o.value, textContent: o.label });
54
+ if (o.value === value) opt.selected = true;
55
+ sel.appendChild(opt);
56
+ }
57
+ sel.addEventListener("change", () => onChange(sel.value));
58
+ return sel;
59
+ }
60
+
61
+ /* ── persistent state (survives rebuilds) ────────────── */
62
+ const S = {
63
+ eventsRef: { visible: false, editIndex: -1, targetTable: "", sourceCol: "", targetCol: "", keyType: "Integer", exportName: "", targetCols: [], loading: false },
64
+ casesRef: { visible: false, editIndex: -1, targetTable: "", sourceCol: "", targetCol: "", keyType: "Integer", exportName: "", targetCols: [], loading: false },
65
+ pendingRefFor: null,
66
+ outputOpen: true,
67
+ copied: false,
68
+ };
69
+
70
+ function resetRefForm(form) {
71
+ form.visible = false;
72
+ form.editIndex = -1;
73
+ form.targetTable = "";
74
+ form.sourceCol = "";
75
+ form.targetCol = "";
76
+ form.keyType = "Integer";
77
+ form.exportName = "";
78
+ form.targetCols = [];
79
+ form.loading = false;
80
+ }
81
+
82
+ /* ── rebuild UI ──────────────────────────────────────── */
83
+ function rebuild() {
84
+ root.innerHTML = "";
85
+ root.appendChild(buildHeader());
86
+ root.appendChild(buildLakehouseCard());
87
+ root.appendChild(buildEventsCard());
88
+ root.appendChild(buildCasesCard());
89
+ root.appendChild(buildOutputCard());
90
+ }
91
+
92
+ /* ── header ──────────────────────────────────────────── */
93
+ function buildHeader() {
94
+ return h("div", { className: "nsw-header" },
95
+ h("h2", { textContent: "Normalized Schema Generator" }),
96
+ h("p", { textContent: "Configure your process mining star-schema and generate the dataSource JSON." })
97
+ );
98
+ }
99
+
100
+ /* ── Section A: Lakehouse & source type ──────────────── */
101
+ function buildLakehouseCard() {
102
+ const card = h("div", { className: "nsw-card" });
103
+ const lakehouses = model.get("lakehouses") || [];
104
+ const selId = model.get("selected_lakehouse_id") || "";
105
+ const fileType = model.get("data_source_file_type");
106
+
107
+ // Lakehouse selector
108
+ const status = model.get("loading_status") || "";
109
+ const lhOpts = lakehouses.map(l => ({ value: l.id, label: l.displayName }));
110
+ const lhRow = h("div", { className: "nsw-row" },
111
+ h("span", { className: "nsw-label", textContent: "Lakehouse" }),
112
+ selectEl(lhOpts, selId, (val) => {
113
+ const lh = lakehouses.find(l => l.id === val);
114
+ model.set("selected_lakehouse_id", val);
115
+ model.set("selected_lakehouse_name", lh ? lh.displayName : "");
116
+ model.save_changes();
117
+ }, "Select a lakehouse...")
118
+ );
119
+ if (status && status.includes("tables")) {
120
+ lhRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: status }));
121
+ }
122
+ card.appendChild(lhRow);
123
+
124
+ // Data source type toggle
125
+ const toggle = h("div", { className: "nsw-toggle" });
126
+ const btnDelta = h("button", {
127
+ textContent: "Delta Tables",
128
+ className: fileType === 2 ? "active" : "",
129
+ onClick: () => { model.set("data_source_file_type", 2); model.save_changes(); }
130
+ });
131
+ const btnFiles = h("button", {
132
+ textContent: "Files",
133
+ className: fileType < 2 ? "active" : "",
134
+ onClick: () => { model.set("data_source_file_type", 0); model.save_changes(); }
135
+ });
136
+ toggle.appendChild(btnDelta);
137
+ toggle.appendChild(btnFiles);
138
+
139
+ const row2 = h("div", { className: "nsw-row" },
140
+ h("span", { className: "nsw-label", textContent: "Source type" }),
141
+ toggle
142
+ );
143
+
144
+ // File format sub-selector (only for Files mode)
145
+ if (fileType < 2) {
146
+ const fmtOpts = [{ value: "0", label: "CSV" }, { value: "1", label: "Parquet" }];
147
+ row2.appendChild(h("span", { className: "nsw-label", textContent: "Format", style: "margin-left:12px" }));
148
+ row2.appendChild(selectEl(fmtOpts, String(fileType), (v) => {
149
+ model.set("data_source_file_type", parseInt(v));
150
+ model.save_changes();
151
+ }));
152
+ }
153
+ card.appendChild(row2);
154
+
155
+ return card;
156
+ }
157
+
158
+ /* ── Column checklist ────────────────────────────────── */
159
+ function buildColumnList(columns, onChange) {
160
+ if (!columns || columns.length === 0) return h("div", { className: "nsw-empty", textContent: "No columns loaded." });
161
+ const wrap = h("div", { className: "nsw-col-list" });
162
+ columns.forEach((col, i) => {
163
+ wrap.appendChild(h("div", { className: "nsw-col-row" },
164
+ toggleSwitch(col.included, (val) => {
165
+ const updated = JSON.parse(JSON.stringify(columns));
166
+ updated[i].included = val;
167
+ onChange(updated);
168
+ }),
169
+ h("span", { className: "nsw-col-name", textContent: col.name }),
170
+ dtypeBadge(col.dtype)
171
+ ));
172
+ });
173
+ return wrap;
174
+ }
175
+
176
+ /* ── Join list ───────────────────────────────────────── */
177
+ function buildJoinList(joins, onEdit, onRemove) {
178
+ if (!joins || joins.length === 0) return h("div", { className: "nsw-empty", textContent: "No references configured." });
179
+ const wrap = h("div", null);
180
+ joins.forEach((j, i) => {
181
+ const targetColNames = (j.target_columns || []).filter(c => c.included).map(c => c.name).join(", ");
182
+ wrap.appendChild(h("div", { className: "nsw-join-item" },
183
+ h("span", { className: "join-type", textContent: j.join_key_type }),
184
+ h("span", { className: "join-desc", textContent: j.source_column + " → " + j.target_table + "." + j.target_column }),
185
+ targetColNames ? h("span", { className: "join-desc", textContent: "cols: " + targetColNames, style: "color:#666" }) : null,
186
+ h("button", { className: "nsw-btn sm", textContent: "Edit", onClick: () => onEdit(i) }),
187
+ h("button", { className: "nsw-btn danger sm", textContent: "Remove", onClick: () => onRemove(i) })
188
+ ));
189
+ });
190
+ return wrap;
191
+ }
192
+
193
+ /* ── Add Reference form ──────────────────────────────── */
194
+ function buildRefForm(datasetType) {
195
+ const form = datasetType === "events" ? S.eventsRef : S.casesRef;
196
+ if (!form.visible) return null;
197
+
198
+ const tables = model.get("available_tables") || [];
199
+ const existingJoins = model.get(datasetType + "_joins") || [];
200
+ const currentTable = model.get(datasetType + "_table_name") || "";
201
+ const currentCols = model.get(datasetType + "_columns") || [];
202
+ const usedTargets = existingJoins.map(j => j.target_table);
203
+
204
+ // Exclude current table and already-referenced tables (but allow the edited one)
205
+ const editingTarget = form.editIndex >= 0 && existingJoins[form.editIndex] ? existingJoins[form.editIndex].target_table : null;
206
+ let availTargets = tables.filter(t => t.name !== currentTable && (!usedTargets.includes(t.name) || t.name === editingTarget));
207
+ const targetOpts = availTargets.map(t => ({ value: t.name, label: t.name }));
208
+ const isEditing = form.editIndex >= 0;
209
+
210
+ const wrap = h("div", { className: "nsw-ref-form" });
211
+ const tgtRow = h("div", { className: "nsw-row" },
212
+ h("span", { className: "nsw-label", textContent: "Target table" }),
213
+ isEditing
214
+ ? h("span", { className: "nsw-col-name", textContent: form.targetTable, style: "padding:5px 0" })
215
+ : selectEl(targetOpts, form.targetTable, (val) => {
216
+ form.targetTable = val;
217
+ form.targetCol = "";
218
+ form.sourceCol = "";
219
+ form.targetCols = [];
220
+ form.loading = true;
221
+ S.pendingRefFor = datasetType;
222
+ model.set("fetch_columns_request", Date.now() + ":" + val);
223
+ model.save_changes();
224
+ rebuild();
225
+ }, "Select target table...")
226
+ );
227
+ if (form.loading) {
228
+ tgtRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "Loading columns..." }));
229
+ }
230
+ wrap.appendChild(tgtRow);
231
+
232
+ if (!form.loading && form.targetCols.length > 0) {
233
+ // FK + PK side-by-side
234
+ const srcColOpts = currentCols.map(c => ({ value: c.name, label: c.name + " (" + c.dtype + ")" }));
235
+ const tgtColOpts = form.targetCols.map(c => ({ value: c.name, label: c.name + " (" + c.dtype + ")" }));
236
+ wrap.appendChild(h("div", { className: "nsw-pair-row" },
237
+ h("div", { className: "nsw-pair" },
238
+ h("span", { className: "nsw-label sm", textContent: "FK column" }),
239
+ selectEl(srcColOpts, form.sourceCol, (v) => { form.sourceCol = v; rebuild(); }, "Source FK...")
240
+ ),
241
+ h("div", { className: "nsw-pair" },
242
+ h("span", { className: "nsw-label sm", textContent: "PK column" }),
243
+ selectEl(tgtColOpts, form.targetCol, (v) => { form.targetCol = v; rebuild(); }, "Target PK...")
244
+ )
245
+ ));
246
+
247
+ // Key type + Export name side-by-side
248
+ const keyOpts = [{ value: "Integer", label: "Integer" }, { value: "String", label: "String" }];
249
+ const expInp = h("input", {
250
+ type: "text",
251
+ className: "nsw-input",
252
+ placeholder: "Optional export name",
253
+ style: "min-width:140px;flex:1",
254
+ });
255
+ expInp.value = form.exportName;
256
+ expInp.addEventListener("input", (e) => { form.exportName = e.target.value; });
257
+ wrap.appendChild(h("div", { className: "nsw-pair-row" },
258
+ h("div", { className: "nsw-pair" },
259
+ h("span", { className: "nsw-label sm", textContent: "Key type" }),
260
+ selectEl(keyOpts, form.keyType, (v) => { form.keyType = v; rebuild(); })
261
+ ),
262
+ h("div", { className: "nsw-pair" },
263
+ h("span", { className: "nsw-label sm", textContent: "Export name" }),
264
+ expInp
265
+ )
266
+ ));
267
+
268
+ // Target columns to include
269
+ wrap.appendChild(h("div", { className: "nsw-joins-title", textContent: "Columns to include from target:" }));
270
+ const colList = h("div", { className: "nsw-col-list" });
271
+ form.targetCols.forEach((col, i) => {
272
+ colList.appendChild(h("div", { className: "nsw-col-row" },
273
+ toggleSwitch(col.included, (val) => { form.targetCols[i].included = val; rebuild(); }),
274
+ h("span", { className: "nsw-col-name", textContent: col.name }),
275
+ dtypeBadge(col.dtype)
276
+ ));
277
+ });
278
+ wrap.appendChild(colList);
279
+ }
280
+
281
+ // Buttons
282
+ const canConfirm = form.targetTable && form.sourceCol && form.targetCol && !form.loading;
283
+ const actions = h("div", { className: "form-actions" },
284
+ h("button", {
285
+ className: "nsw-btn primary",
286
+ textContent: isEditing ? "Save" : "Confirm",
287
+ disabled: canConfirm ? undefined : "disabled",
288
+ onClick: () => {
289
+ if (!canConfirm) return;
290
+ const joins = JSON.parse(JSON.stringify(model.get(datasetType + "_joins") || []));
291
+ const entry = {
292
+ target_table: form.targetTable,
293
+ source_column: form.sourceCol,
294
+ target_column: form.targetCol,
295
+ join_key_type: form.keyType,
296
+ export_name: form.exportName || "",
297
+ target_columns: form.targetCols.map(c => ({ name: c.name, dtype: c.dtype, included: c.included })),
298
+ };
299
+ if (isEditing && form.editIndex < joins.length) {
300
+ joins[form.editIndex] = entry;
301
+ } else {
302
+ joins.push(entry);
303
+ }
304
+ model.set(datasetType + "_joins", joins);
305
+ model.save_changes();
306
+ resetRefForm(form);
307
+ rebuild();
308
+ }
309
+ }),
310
+ h("button", {
311
+ className: "nsw-btn",
312
+ textContent: "Cancel",
313
+ onClick: () => { resetRefForm(form); rebuild(); }
314
+ })
315
+ );
316
+ wrap.appendChild(actions);
317
+ return wrap;
318
+ }
319
+
320
+ /* ── Section B: Events table ─────────────────────────── */
321
+ function buildEventsCard() {
322
+ const card = h("div", { className: "nsw-card events" });
323
+ card.appendChild(h("div", { className: "nsw-card-title" },
324
+ h("span", { textContent: "Events Table" }),
325
+ h("span", { className: "badge", textContent: "Required" })
326
+ ));
327
+
328
+ const tables = model.get("available_tables") || [];
329
+ const selTable = model.get("events_table_name") || "";
330
+ const columns = model.get("events_columns") || [];
331
+ const joins = model.get("events_joins") || [];
332
+ const selLH = model.get("selected_lakehouse_id") || "";
333
+
334
+ // Table selector
335
+ const loadingStatus = model.get("loading_status") || "";
336
+ const tOpts = tables.map(t => ({ value: t.name, label: t.name }));
337
+ const tblRow = h("div", { className: "nsw-row" },
338
+ h("span", { className: "nsw-label", textContent: "Table" }),
339
+ selectEl(tOpts, selTable, (val) => {
340
+ model.set("events_table_name", val);
341
+ model.set("events_joins", []);
342
+ model.save_changes();
343
+ resetRefForm(S.eventsRef);
344
+ }, selLH ? "Select events table..." : "Select a lakehouse first", !selLH)
345
+ );
346
+ if (selTable && columns.length === 0 && loadingStatus.includes("columns")) {
347
+ tblRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "Loading columns..." }));
348
+ }
349
+ card.appendChild(tblRow);
350
+
351
+ // Columns
352
+ if (selTable && columns.length > 0) {
353
+ card.appendChild(h("div", { className: "nsw-joins-title", textContent: "Columns" }));
354
+ card.appendChild(buildColumnList(columns, (updated) => {
355
+ model.set("events_columns", updated);
356
+ model.save_changes();
357
+ }));
358
+ }
359
+
360
+ // References
361
+ if (selTable) {
362
+ card.appendChild(h("hr", { className: "nsw-divider" }));
363
+ card.appendChild(h("div", { className: "nsw-joins-title", textContent: "References (Joins)" }));
364
+ card.appendChild(buildJoinList(joins, (idx) => {
365
+ const j = joins[idx];
366
+ S.eventsRef.visible = true;
367
+ S.eventsRef.editIndex = idx;
368
+ S.eventsRef.targetTable = j.target_table;
369
+ S.eventsRef.sourceCol = j.source_column;
370
+ S.eventsRef.targetCol = j.target_column;
371
+ S.eventsRef.keyType = j.join_key_type;
372
+ S.eventsRef.exportName = j.export_name || "";
373
+ S.eventsRef.targetCols = (j.target_columns || []).map(c => ({ name: c.name, dtype: c.dtype, included: c.included }));
374
+ S.eventsRef.loading = false;
375
+ rebuild();
376
+ }, (idx) => {
377
+ const updated = JSON.parse(JSON.stringify(joins));
378
+ updated.splice(idx, 1);
379
+ model.set("events_joins", updated);
380
+ model.save_changes();
381
+ }));
382
+
383
+ // Add Reference button / form
384
+ const refForm = buildRefForm("events");
385
+ if (refForm) {
386
+ card.appendChild(refForm);
387
+ } else {
388
+ card.appendChild(h("button", {
389
+ className: "nsw-btn outline",
390
+ textContent: "+ Add Reference",
391
+ style: "margin-top:6px",
392
+ onClick: () => { S.eventsRef.visible = true; rebuild(); }
393
+ }));
394
+ }
395
+ }
396
+
397
+ return card;
398
+ }
399
+
400
+ /* ── Cases: Events→Cases relationship form ─────────── */
401
+ function buildCasesRelationship() {
402
+ const evtCols = model.get("events_columns") || [];
403
+ const casesCols = model.get("cases_columns") || [];
404
+ const rel = model.get("cases_relationship") || {};
405
+ const evtTable = model.get("events_table_name") || "";
406
+
407
+ if (!evtTable) {
408
+ return h("div", { className: "nsw-rel-form" },
409
+ h("div", { className: "rel-title", textContent: "Link to Events Table" }),
410
+ h("div", { className: "nsw-empty", textContent: "Select an events table first to configure the relationship." })
411
+ );
412
+ }
413
+
414
+ const wrap = h("div", { className: "nsw-rel-form" });
415
+ wrap.appendChild(h("div", { className: "rel-title", textContent: "Link to Events Table (required)" }));
416
+
417
+ // FK + PK side-by-side
418
+ const fkOpts = evtCols.map(c => ({ value: c.name, label: c.name + " (" + c.dtype + ")" }));
419
+ const pkOpts = casesCols.map(c => ({ value: c.name, label: c.name + " (" + c.dtype + ")" }));
420
+ wrap.appendChild(h("div", { className: "nsw-pair-row" },
421
+ h("div", { className: "nsw-pair" },
422
+ h("span", { className: "nsw-label sm", textContent: "Events FK" }),
423
+ selectEl(fkOpts, rel.fk_column || "", (v) => {
424
+ const updated = Object.assign({}, rel, { fk_column: v });
425
+ model.set("cases_relationship", updated);
426
+ model.save_changes();
427
+ }, "Column on " + evtTable + "...")
428
+ ),
429
+ h("div", { className: "nsw-pair" },
430
+ h("span", { className: "nsw-label sm", textContent: "Cases PK" }),
431
+ selectEl(pkOpts, rel.pk_column || "", (v) => {
432
+ const updated = Object.assign({}, rel, { pk_column: v });
433
+ model.set("cases_relationship", updated);
434
+ model.save_changes();
435
+ }, "Column on cases...")
436
+ )
437
+ ));
438
+
439
+ // Key type + status on one row
440
+ const keyOpts = [{ value: "Integer", label: "Integer" }, { value: "String", label: "String" }];
441
+ const configured = rel.fk_column && rel.pk_column;
442
+ wrap.appendChild(h("div", { className: "nsw-row" },
443
+ h("span", { className: "nsw-label sm", textContent: "Key type" }),
444
+ selectEl(keyOpts, rel.join_key_type || "Integer", (v) => {
445
+ const updated = Object.assign({}, rel, { join_key_type: v });
446
+ model.set("cases_relationship", updated);
447
+ model.save_changes();
448
+ }),
449
+ h("span", { className: "nsw-rel-status " + (configured ? "configured" : "missing"),
450
+ textContent: configured
451
+ ? evtTable + "." + rel.fk_column + " → cases." + rel.pk_column
452
+ : "Select FK and PK columns"
453
+ })
454
+ ));
455
+
456
+ return wrap;
457
+ }
458
+
459
+ /* ── Section C: Cases table ──────────────────────────── */
460
+ function buildCasesCard() {
461
+ const enabled = model.get("cases_enabled");
462
+
463
+ if (!enabled) {
464
+ return h("div", { className: "nsw-cases-placeholder" },
465
+ h("button", {
466
+ className: "nsw-btn outline",
467
+ textContent: "+ Add Cases Table (optional)",
468
+ onClick: () => { model.set("cases_enabled", true); model.save_changes(); }
469
+ })
470
+ );
471
+ }
472
+
473
+ const card = h("div", { className: "nsw-card cases" });
474
+ card.appendChild(h("div", { className: "nsw-card-title" },
475
+ h("span", { textContent: "Cases Table" }),
476
+ h("span", { className: "badge", textContent: "Optional" }),
477
+ h("span", { style: "flex:1" }),
478
+ h("button", {
479
+ className: "nsw-btn danger",
480
+ textContent: "Remove Cases Table",
481
+ onClick: () => {
482
+ model.set("cases_enabled", false);
483
+ model.set("cases_table_name", "");
484
+ model.set("cases_columns", []);
485
+ model.set("cases_joins", []);
486
+ model.set("cases_relationship", {});
487
+ model.save_changes();
488
+ resetRefForm(S.casesRef);
489
+ }
490
+ })
491
+ ));
492
+
493
+ const tables = model.get("available_tables") || [];
494
+ const selTable = model.get("cases_table_name") || "";
495
+ const columns = model.get("cases_columns") || [];
496
+ const joins = model.get("cases_joins") || [];
497
+ const selLH = model.get("selected_lakehouse_id") || "";
498
+
499
+ const casesLoadingStatus = model.get("loading_status") || "";
500
+ const tOpts = tables.map(t => ({ value: t.name, label: t.name }));
501
+ const casesTblRow = h("div", { className: "nsw-row" },
502
+ h("span", { className: "nsw-label", textContent: "Table" }),
503
+ selectEl(tOpts, selTable, (val) => {
504
+ model.set("cases_table_name", val);
505
+ model.set("cases_joins", []);
506
+ model.set("cases_relationship", {});
507
+ model.save_changes();
508
+ resetRefForm(S.casesRef);
509
+ }, selLH ? "Select cases table..." : "Select a lakehouse first", !selLH)
510
+ );
511
+ if (selTable && columns.length === 0 && casesLoadingStatus.includes("columns")) {
512
+ casesTblRow.appendChild(h("span", { className: "nsw-loading-inline", textContent: "Loading columns..." }));
513
+ }
514
+ card.appendChild(casesTblRow);
515
+
516
+ // Events→Cases relationship (shown as soon as cases table is selected)
517
+ if (selTable && columns.length > 0) {
518
+ card.appendChild(buildCasesRelationship());
519
+ }
520
+
521
+ if (selTable && columns.length > 0) {
522
+ card.appendChild(h("div", { className: "nsw-joins-title", textContent: "Columns" }));
523
+ card.appendChild(buildColumnList(columns, (updated) => {
524
+ model.set("cases_columns", updated);
525
+ model.save_changes();
526
+ }));
527
+ }
528
+
529
+ if (selTable) {
530
+ card.appendChild(h("hr", { className: "nsw-divider" }));
531
+ card.appendChild(h("div", { className: "nsw-joins-title", textContent: "References (Join lookups only)" }));
532
+ card.appendChild(buildJoinList(joins, (idx) => {
533
+ const j = joins[idx];
534
+ S.casesRef.visible = true;
535
+ S.casesRef.editIndex = idx;
536
+ S.casesRef.targetTable = j.target_table;
537
+ S.casesRef.sourceCol = j.source_column;
538
+ S.casesRef.targetCol = j.target_column;
539
+ S.casesRef.keyType = j.join_key_type;
540
+ S.casesRef.exportName = j.export_name || "";
541
+ S.casesRef.targetCols = (j.target_columns || []).map(c => ({ name: c.name, dtype: c.dtype, included: c.included }));
542
+ S.casesRef.loading = false;
543
+ rebuild();
544
+ }, (idx) => {
545
+ const updated = JSON.parse(JSON.stringify(joins));
546
+ updated.splice(idx, 1);
547
+ model.set("cases_joins", updated);
548
+ model.save_changes();
549
+ }));
550
+
551
+ const refForm = buildRefForm("cases");
552
+ if (refForm) {
553
+ card.appendChild(refForm);
554
+ } else {
555
+ card.appendChild(h("button", {
556
+ className: "nsw-btn outline",
557
+ textContent: "+ Add Reference",
558
+ style: "margin-top:6px",
559
+ onClick: () => { S.casesRef.visible = true; rebuild(); }
560
+ }));
561
+ }
562
+ }
563
+
564
+ return card;
565
+ }
566
+
567
+ /* ── Section D: JSON output ──────────────────────────── */
568
+ function buildOutputCard() {
569
+ const card = h("div", { className: "nsw-card output" });
570
+ const json = model.get("config_json") || "";
571
+
572
+ const toggleBtn = h("button", { className: "nsw-collapse-btn", onClick: () => { S.outputOpen = !S.outputOpen; rebuild(); } },
573
+ h("span", { className: "arrow " + (S.outputOpen ? "open" : ""), textContent: "▶" }),
574
+ h("span", { textContent: "Generated JSON Config" })
575
+ );
576
+ card.appendChild(toggleBtn);
577
+
578
+ if (S.outputOpen) {
579
+ if (!json) {
580
+ card.appendChild(h("div", { className: "nsw-empty", textContent: "Select a lakehouse and events table to generate the config." }));
581
+ } else {
582
+ const wrap = h("div", { className: "nsw-json-wrap" });
583
+ wrap.appendChild(h("div", { className: "nsw-json", textContent: json }));
584
+ if (S.copied) {
585
+ wrap.appendChild(h("span", { className: "nsw-copy-btn nsw-copied", textContent: "Copied!" }));
586
+ } else {
587
+ wrap.appendChild(h("button", {
588
+ className: "nsw-btn outline nsw-copy-btn",
589
+ textContent: "Copy",
590
+ onClick: () => {
591
+ navigator.clipboard.writeText(json).then(() => {
592
+ S.copied = true;
593
+ rebuild();
594
+ setTimeout(() => { S.copied = false; rebuild(); }, 1500);
595
+ });
596
+ }
597
+ }));
598
+ }
599
+ card.appendChild(wrap);
600
+ }
601
+ }
602
+
603
+ // Error messages
604
+ const err = model.get("error_message") || "";
605
+ if (err) card.appendChild(h("div", { className: "nsw-error", textContent: err }));
606
+
607
+ return card;
608
+ }
609
+
610
+ /* ── model change listeners ──────────────────────────── */
611
+ const watchedTraits = [
612
+ "lakehouses", "selected_lakehouse_id", "data_source_file_type",
613
+ "available_tables", "events_table_name", "events_columns", "events_joins",
614
+ "cases_enabled", "cases_table_name", "cases_columns", "cases_joins", "cases_relationship",
615
+ "config_json", "loading_status", "error_message"
616
+ ];
617
+ for (const t of watchedTraits) {
618
+ model.on("change:" + t, rebuild);
619
+ }
620
+
621
+ // Handle fetch_columns_response
622
+ model.on("change:fetch_columns_response", () => {
623
+ const resp = model.get("fetch_columns_response") || [];
624
+ const target = S.pendingRefFor;
625
+ if (target) {
626
+ const form = target === "events" ? S.eventsRef : S.casesRef;
627
+ form.targetCols = resp.map(c => ({ name: c.name, dtype: c.dtype, included: true }));
628
+ form.loading = false;
629
+ S.pendingRefFor = null;
630
+ }
631
+ rebuild();
632
+ });
633
+
634
+ rebuild();
635
+ }