From febc562dd442df52677304bc5948fc64b112b9e3 Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Mon, 10 Nov 2025 09:55:13 +0100 Subject: [PATCH 1/7] Initial hardcoded webview --- src/extension.ts | 121 +++++++++ src/manage/webviews/css/editRequirements.css | 230 ++++++++++++++++++ .../webviews/html/editRequirements.html | 44 ++++ .../webviewScripts/editRequirements.js | 160 ++++++++++++ 4 files changed, 555 insertions(+) create mode 100644 src/manage/webviews/css/editRequirements.css create mode 100644 src/manage/webviews/html/editRequirements.html create mode 100644 src/manage/webviews/webviewScripts/editRequirements.js diff --git a/src/extension.ts b/src/extension.ts index 4762892f..3e6f4211 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2389,6 +2389,127 @@ async function installPreActivationEventHandlers( return html; } + + const editRequirementsCmd = vscode.commands.registerCommand( + "vectorcastTestExplorer.editRequirements", + async () => { + const baseDir = resolveWebviewBase(context); + const panel = vscode.window.createWebviewPanel( + "editRequirements", + "Edit Requirements", + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(baseDir)], + } + ); + + // Example merged JSON + const mergedJson = { + "DataBase::DeleteRecord.1": { + title: "", + description: "", + id: "DataBase::DeleteRecord.1", + last_modified: "tbd", + unit: "database", + function: "DataBase::DeleteRecord", + lines: null, + }, + "DataBase::DeleteRecord.2": { + title: "", + description: "", + id: "DataBase::DeleteRecord.1", + last_modified: "tbd", + unit: "database", + function: "DataBase::DeleteRecord", + lines: null, + }, + "DataBase::DeleteRecord.3": { + title: "", + description: "", + id: "DataBase::DeleteRecord.1", + last_modified: "tbd", + unit: "database", + function: "DataBase::DeleteRecord", + lines: null, + }, + }; + + panel.webview.html = await getEditRequirementsWebviewContent( + context, + panel, + mergedJson + ); + + // Handle messages + const dispatch: Record void> = { + saveJson: (msg) => { + vscode.window.showInformationMessage("Requirements JSON saved!"); + console.log("Saved JSON:", msg.data); + }, + cancel: () => panel.dispose(), + }; + + panel.webview.onDidReceiveMessage( + (msg) => dispatch[msg.command]?.(msg), + undefined, + context.subscriptions + ); + } + ); + + context.subscriptions.push(editRequirementsCmd); + + async function getEditRequirementsWebviewContent( + context: vscode.ExtensionContext, + panel: vscode.WebviewPanel, + mergedJson: any + ): Promise { + const base = resolveWebviewBase(context); + const cssOnDisk = vscode.Uri.file( + path.join(base, "css", "editRequirements.css") + ); + const scriptOnDisk = vscode.Uri.file( + path.join(base, "webviewScripts", "editRequirements.js") + ); + const htmlPath = path.join(base, "html", "editRequirements.html"); + + const cssUri = panel.webview.asWebviewUri(cssOnDisk); + const scriptUri = panel.webview.asWebviewUri(scriptOnDisk); + + let html = fs.readFileSync(htmlPath, "utf8"); + const nonce = getNonce(); + + // Inject CSP and CSS + const csp = ` + + `; + html = html.replace( + //, + `${csp}` + ); + + // Inject initial JSON and script + html = html.replace( + "{{ scriptUri }}", + ` + ` + ); + + // Replace CSS placeholder + html = html.replace( + "{{ cssUri }}", + `` + ); + + return html; + } } // this method is called when your extension is deactivated diff --git a/src/manage/webviews/css/editRequirements.css b/src/manage/webviews/css/editRequirements.css new file mode 100644 index 00000000..c76fabe5 --- /dev/null +++ b/src/manage/webviews/css/editRequirements.css @@ -0,0 +1,230 @@ +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + background-color: #1e1e1e; + color: #d4d4d4; + display: flex; + align-items: center; + justify-content: center; + height: 100vh; +} + +.modal { + width: 95%; + max-width: 1200px; + max-height: 90vh; + background-color: #252526; + padding: 25px; + border-radius: 8px; + box-shadow: 0 0 10px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; + overflow: hidden; +} + +h2 { + text-align: center; + margin-bottom: 20px; + font-size: 24px; +} + +/* Filter bar styling */ +.filter-title { + text-align: center; + font-size: 16px; + margin-bottom: 8px; + font-weight: bold; +} + +.filter-bar { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: center; /* center the filters */ + margin-bottom: 15px; + border-bottom: 1px solid #555; + padding-bottom: 10px; +} + +.filter-bar label { + font-size: 14px; + display: flex; + align-items: center; + cursor: pointer; +} + +.filter-bar input[type="checkbox"] { + margin-right: 5px; +} + +/* JSON objects */ +.table-container { + overflow: auto; /* make it scrollable */ + flex: 1 1 auto; + padding-top: 10px; +} + +.json-object { + border: 1px solid #555; + border-radius: 6px; + padding: 10px; + margin-bottom: 12px; + background-color: #1e1e1e; +} + +.json-object:hover { + background-color: #2a2a2a; +} + +.json-object-key { + font-weight: bold; + margin-bottom: 6px; + font-size: 16px; +} + +/* key-value rows */ +.key-value { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 4px; +} + +.key-value div { + flex: 0 0 auto; + min-width: 120px; + font-weight: bold; +} + +/* Make all inputs same length */ +.key-value input { + flex: 1; + background-color: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + border-radius: 4px; + padding: 6px 8px; + font-family: monospace; + font-size: 16px; +} + +/* Buttons */ +.button-container { + display: flex; + justify-content: flex-end; + margin-top: 10px; + gap: 10px; +} + +.primary-button { background-color: #007acc; color: white; padding: 10px 15px; } +.primary-button:hover { background-color: #005f99; } +.cancel-button { background-color: #cc4444; color: white; padding: 10px 15px; } +.cancel-button:hover { background-color: #992222; } + +#addSection { + margin-top: 18px; + padding: 14px; + border: 1px solid #666; + border-radius: 6px; + background-color: #232323; + } + + #addSection h3 { + text-align: center; + margin-bottom: 12px; + font-size: 18px; + } + + .add-rows .row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; + } + + .add-rows .row label { + min-width: 140px; + text-align: right; + font-weight: bold; + } + + .add-rows .row input { + flex: 1; + background-color: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + border-radius: 4px; + padding: 6px 8px; + font-family: monospace; + font-size: 16px; + } + + .json-object { + border: 1px solid #444; + border-radius: 6px; + padding: 12px; + margin-bottom: 14px; + background-color: #212121; + transition: background-color 0.2s; + } + + .json-object:hover { + background-color: #292929; + } + + .add-section { + border: 1px solid #666; + border-radius: 6px; + padding: 14px; + margin-top: 18px; + background-color: #252525; + } + + .add-section h3 { + margin-bottom: 10px; + text-align: center; + } + + .add-button { + margin-top: 12px; + padding: 10px; + width: 100%; + background-color: #007acc; + border-radius: 6px; + color: white; + border: none; + cursor: pointer; + } + + .add-button:hover { + background-color: #005f99; + } + + #jsonContainer { + overflow-y: auto; + flex: 1; + padding-right: 4px; + } + + .add-section-title { + text-align: center; + font-size: 20px; + font-weight: bold; + margin-bottom: 14px; + color: #ffffff; + } + + /* Ensure placeholder text is readable */ + #addSection input::placeholder { + color: #aaaaaa; + opacity: 1; + } + + /* Highlight required fields when empty */ + .input-error { + border-color: #ff4444 !important; + background-color: #3b1f1f !important; + } + + \ No newline at end of file diff --git a/src/manage/webviews/html/editRequirements.html b/src/manage/webviews/html/editRequirements.html new file mode 100644 index 00000000..b648349e --- /dev/null +++ b/src/manage/webviews/html/editRequirements.html @@ -0,0 +1,44 @@ + + + + + + Edit Requirements + {{ cssUri }} + + + + + {{ scriptUri }} + + diff --git a/src/manage/webviews/webviewScripts/editRequirements.js b/src/manage/webviews/webviewScripts/editRequirements.js new file mode 100644 index 00000000..c156e3c8 --- /dev/null +++ b/src/manage/webviews/webviewScripts/editRequirements.js @@ -0,0 +1,160 @@ +const vscode = acquireVsCodeApi(); +let jsonData = window.initialJson; +let undoStack = []; +let collapsedKeys = new Set(); + +const jsonContainer = document.getElementById("jsonContainer"); +const filterBar = document.getElementById("filterBar"); +const addSection = document.getElementById("addSection"); +const addRows = document.getElementById("addRows"); + +// Buttons +const showAddFormBtn = document.getElementById("showAddForm"); +const addCancelBtn = document.getElementById("btnAddCancel"); +const addConfirmBtn = document.getElementById("btnAddConfirm"); +const btnSave = document.getElementById("btnSave"); +const btnCancel = document.getElementById("btnCancel"); + +// Initial state +addSection.style.display = "none"; // << Ensure hidden on start + +pushUndo(); +renderFilters(); +renderObjects(); + +// Undo logic remains for Ctrl+Z +function pushUndo() { undoStack.push(JSON.stringify(jsonData)); } + +document.addEventListener("keydown", e => { + if ((e.ctrlKey || e.metaKey) && e.key === "z") { + if (!undoStack.length) return; + jsonData = JSON.parse(undoStack.pop()); + renderObjects(); + } +}); + +// Save / Cancel +btnSave.addEventListener("click", () => + vscode.postMessage({ command: "saveJson", data: jsonData }) +); +btnCancel.addEventListener("click", () => + vscode.postMessage({ command: "cancel" }) +); + +// Filters +function renderFilters() { + filterBar.innerHTML = ""; + const keys = Object.keys(jsonData[Object.keys(jsonData)[0]] || {}); + keys.forEach(k => { + const label = document.createElement("label"); + label.innerHTML = ` ${k}`; + filterBar.appendChild(label); + label.querySelector("input").addEventListener("change", e => { + const key = e.target.dataset.key; + if (e.target.checked) collapsedKeys.delete(key); + else collapsedKeys.add(key); + renderObjects(); + }); + }); +} + +// Objects Display +function renderObjects() { + jsonContainer.innerHTML = ""; + const keys = Object.keys(jsonData[Object.keys(jsonData)[0]] || {}); + Object.entries(jsonData).forEach(([objKey, objVal]) => { + const div = document.createElement("div"); + div.className = "json-object"; + div.innerHTML = `
${objKey}
`; + keys.forEach(k => { + if (!collapsedKeys.has(k)) { + const val = objVal[k] ?? ""; + const kv = document.createElement("div"); + kv.className = "key-value"; + kv.innerHTML = `
${k}:
`; + div.appendChild(kv); + } + }); + jsonContainer.appendChild(div); + }); + + jsonContainer.querySelectorAll("input").forEach(input => { + input.addEventListener("input", e => { + const k = e.target.dataset.key; + const parent = e.target.dataset.parent; + jsonData[parent][k] = e.target.value; + pushUndo(); + }); + }); +} + +// --------------------- +// ADD NEW REQUIREMENT UI +// --------------------- + +showAddFormBtn.addEventListener("click", () => { + buildAddForm(); + addSection.style.display = "block"; + showAddFormBtn.style.display = "none"; +}); + +// Build empty fields matching structure +function buildAddForm() { + addRows.innerHTML = ""; + const sample = jsonData[Object.keys(jsonData)[0]] || {}; + const keys = Object.keys(sample); + + keys.forEach(k => { + const wrapper = document.createElement("div"); + wrapper.className = "key-value"; + + // REQUIRED placeholder only for key, unit, function + const isRequired = (k === "id" || k === "unit" || k === "function"); + const placeholder = isRequired ? "(required)" : ""; + + wrapper.innerHTML = ` +
${k}:
+ + `; + addRows.appendChild(wrapper); + }); +} + + +// Cancel add requirement +addCancelBtn.addEventListener("click", () => { + addSection.style.display = "none"; + showAddFormBtn.style.display = "block"; +}); + +addConfirmBtn.addEventListener("click", () => { + const newEntry = {}; + const inputs = addRows.querySelectorAll("input"); + + // Clear previous error states + inputs.forEach(inp => inp.classList.remove("input-error")); + + inputs.forEach(inp => newEntry[inp.dataset.key] = inp.value.trim()); + + // Validate required fields + let valid = true; + ["unit", "function", "id"].forEach(requiredKey => { + const field = addRows.querySelector(`input[data-key="${requiredKey}"]`); + if (!newEntry[requiredKey]) { + valid = false; + field.classList.add("input-error"); + } + }); + + if (!valid) return; // Stop, show red highlight + + jsonData[newEntry.id] = newEntry; + + pushUndo(); + renderObjects(); + + // Hide form again + addSection.style.display = "none"; + showAddFormBtn.style.display = "block"; +}); + From 23d0403d4d205b293aba74021ac34f3edeea2020 Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Mon, 10 Nov 2025 10:44:52 +0100 Subject: [PATCH 2/7] Loading correct files --- src/extension.ts | 309 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 247 insertions(+), 62 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 3e6f4211..5647ebb5 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2392,75 +2392,259 @@ async function installPreActivationEventHandlers( const editRequirementsCmd = vscode.commands.registerCommand( "vectorcastTestExplorer.editRequirements", - async () => { - const baseDir = resolveWebviewBase(context); - const panel = vscode.window.createWebviewPanel( - "editRequirements", - "Edit Requirements", - vscode.ViewColumn.Active, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.file(baseDir)], + async (args: any) => { + try { + // args must contain the test node id just like your showRequirements command + if (!args) { + vscode.window.showErrorMessage("No test node argument provided."); + return; } - ); - // Example merged JSON - const mergedJson = { - "DataBase::DeleteRecord.1": { - title: "", - description: "", - id: "DataBase::DeleteRecord.1", - last_modified: "tbd", - unit: "database", - function: "DataBase::DeleteRecord", - lines: null, - }, - "DataBase::DeleteRecord.2": { - title: "", - description: "", - id: "DataBase::DeleteRecord.1", - last_modified: "tbd", - unit: "database", - function: "DataBase::DeleteRecord", - lines: null, - }, - "DataBase::DeleteRecord.3": { - title: "", - description: "", - id: "DataBase::DeleteRecord.1", - last_modified: "tbd", - unit: "database", - function: "DataBase::DeleteRecord", - lines: null, - }, - }; + const testNode: testNodeType = getTestNode(args.id); + if (!testNode) { + vscode.window.showErrorMessage("Test node not found."); + return; + } - panel.webview.html = await getEditRequirementsWebviewContent( - context, - panel, - mergedJson - ); + const enviroPath = testNode.enviroPath; + const parentDir = path.dirname(enviroPath); + const enviroNameWithExt = path.basename(enviroPath); + const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); + const envReqsFolderPath = path.join( + parentDir, + `reqs-${enviroNameWithoutExt}` + ); - // Handle messages - const dispatch: Record void> = { - saveJson: (msg) => { - vscode.window.showInformationMessage("Requirements JSON saved!"); - console.log("Saved JSON:", msg.data); - }, - cancel: () => panel.dispose(), - }; + // target folder containing generated_requirement_repository/requirements_gateway + const gatewayFolder = path.join( + envReqsFolderPath, + "generated_requirement_repository", + "requirements_gateway" + ); + const requirementsJsonPath = path.join( + gatewayFolder, + "requirements.json" + ); + const traceabilityJsonPath = path.join( + gatewayFolder, + "traceability.json" + ); - panel.webview.onDidReceiveMessage( - (msg) => dispatch[msg.command]?.(msg), - undefined, - context.subscriptions - ); + if (!fs.existsSync(gatewayFolder)) { + vscode.window.showErrorMessage( + `Requirements folder not found: ${gatewayFolder}` + ); + return; + } + + if ( + !fs.existsSync(requirementsJsonPath) && + !fs.existsSync(traceabilityJsonPath) + ) { + vscode.window.showErrorMessage( + "No requirements.json or traceability.json found in requirements_gateway." + ); + return; + } + + // Read files if they exist + let requirementsRaw: any = null; + let traceabilityRaw: any = null; + try { + if (fs.existsSync(requirementsJsonPath)) { + const txt = fs.readFileSync(requirementsJsonPath, "utf8"); + requirementsRaw = JSON.parse(txt || "{}"); + } else { + requirementsRaw = {}; + } + } catch (err) { + vscode.window.showErrorMessage( + `Failed to read/parse requirements.json: ${err}` + ); + return; + } + + try { + if (fs.existsSync(traceabilityJsonPath)) { + const txt = fs.readFileSync(traceabilityJsonPath, "utf8"); + traceabilityRaw = JSON.parse(txt || "{}"); + } else { + traceabilityRaw = {}; + } + } catch (err) { + vscode.window.showErrorMessage( + `Failed to read/parse traceability.json: ${err}` + ); + return; + } + + // Merge logic: + // - requirementsRaw may be grouped (like { "[CSV] [/tmp/..]": { id: {...}, ... } }) OR flat { id: {...}, ... } + // - traceabilityRaw is expected to be flat { id: { unit, function, lines }, ... } + const merged: Record = {}; + + // Helper: extract id->obj mapping from requirementsRaw regardless of grouping + function flattenRequirements(reqsRoot: any): Record { + const out: Record = {}; + if (!reqsRoot || typeof reqsRoot !== "object") return out; + const topKeys = Object.keys(reqsRoot); + // detect grouped: if top-level values are objects and their values are objects containing id fields + let isGrouped = false; + for (const k of topKeys) { + const v = reqsRoot[k]; + if (v && typeof v === "object") { + const maybeInnerKeys = Object.keys(v); + if (maybeInnerKeys.length > 0) { + // check if inner values look like requirement objects + const sampleInner = v[maybeInnerKeys[0]]; + if ( + sampleInner && + typeof sampleInner === "object" && + ("id" in sampleInner || "title" in sampleInner) + ) { + isGrouped = true; + break; + } + } + } + } + if (isGrouped) { + // merge all inner objects from groups + for (const groupKey of topKeys) { + const group = reqsRoot[groupKey]; + if (group && typeof group === "object") { + for (const idKey of Object.keys(group)) { + out[idKey] = group[idKey]; + } + } + } + } else { + // flat top-level: treat each top-level key as id -> obj + for (const k of topKeys) { + const v = reqsRoot[k]; + if (v && typeof v === "object") out[k] = v; + } + } + return out; + } + + const reqsFlat = flattenRequirements(requirementsRaw); + const traceFlat = + traceabilityRaw && typeof traceabilityRaw === "object" + ? traceabilityRaw + : {}; + + // Build merged: start from union of ids in both + const allIds = new Set([ + ...Object.keys(reqsFlat), + ...Object.keys(traceFlat), + ]); + allIds.forEach((id) => { + const r = reqsFlat[id] || {}; + const t = traceFlat[id] || {}; + // Merge: prefer r fields and then t fields ('unit','function','lines') + const mergedObj: any = {}; + // copy all reqs fields + for (const k of Object.keys(r)) mergedObj[k] = r[k]; + // ensure id key exists + mergedObj.id = mergedObj.id || id; + // copy traceability fields + if ("unit" in t) mergedObj.unit = t.unit; + if ("function" in t) mergedObj.function = t.function; + if ("lines" in t) mergedObj.lines = t.lines; + merged[id] = mergedObj; + }); + + // Create panel & webview content (inject merged) + const baseDir = resolveWebviewBase(context); + const panel = vscode.window.createWebviewPanel( + "editRequirements", + "Edit Requirements", + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(baseDir)], + } + ); + + panel.webview.html = await getEditRequirementsWebviewContent( + context, + panel, + merged + ); + + panel.webview.onDidReceiveMessage( + async (msg) => { + switch (msg.command) { + case "saveJson": { + try { + const mergedData = msg.data; // key → merged object + const requirementOutput: Record = {}; + const traceOutput: Record = {}; + + // Split merged data back to each file + for (const [id, obj] of Object.entries( + mergedData as Record + )) { + // Requirement JSON fields + requirementOutput[id] = { + title: obj.title ?? "", + description: obj.description ?? "", + id: obj.id ?? id, + last_modified: obj.last_modified ?? "tbd", + }; + + // Traceability JSON fields + traceOutput[id] = { + unit: obj.unit ?? "", + function: obj.function ?? "", + lines: obj.lines ?? null, + }; + } + + // Write files back to disk + await fs.promises.writeFile( + requirementsJsonPath, + JSON.stringify(requirementOutput, null, 2), + "utf8" + ); + await fs.promises.writeFile( + traceabilityJsonPath, + JSON.stringify(traceOutput, null, 2), + "utf8" + ); + + vscode.window.showInformationMessage( + "Requirements saved successfully." + ); + } catch (err: any) { + vscode.window.showErrorMessage( + `Failed to save requirements: ${err.message}` + ); + } + break; + } + + case "cancel": + panel.dispose(); + break; + } + }, + undefined, + context.subscriptions + ); + } catch (err) { + vscode.window.showErrorMessage( + `editRequirements failed: ${String(err)}` + ); + } } ); context.subscriptions.push(editRequirementsCmd); + // Update getEditRequirementsWebviewContent to accept mergedJson and inject it (if you already have similar function, replace accordingly) async function getEditRequirementsWebviewContent( context: vscode.ExtensionContext, panel: vscode.WebviewPanel, @@ -2488,12 +2672,13 @@ async function installPreActivationEventHandlers( style-src ${panel.webview.cspSource}; script-src 'nonce-${nonce}' ${panel.webview.cspSource};"> `; + // Replace with CSP + css link html = html.replace( //, `${csp}` ); - // Inject initial JSON and script + // Inject initialJson and the script tag (with nonce) html = html.replace( "{{ scriptUri }}", `` ); - // Replace CSS placeholder + // also replace css placeholder if present html = html.replace( - "{{ cssUri }}", + /{{\s*cssUri\s*}}/g, `` ); From 4076e1c153f38eb8402b3f0459a1caa32d1fe3a3 Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Wed, 12 Nov 2025 16:52:47 +0100 Subject: [PATCH 3/7] Adding package.json --- package.json | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/package.json b/package.json index d023a15a..29fd71f4 100644 --- a/package.json +++ b/package.json @@ -314,6 +314,11 @@ "category": "VectorCAST Test Explorer", "title": "Show Requirements" }, + { + "command": "vectorcastTestExplorer.editRequirements", + "category": "VectorCAST Test Explorer", + "title": "Edit Requirements" + }, { "command": "vectorcastTestExplorer.removeRequirements", "category": "VectorCAST Test Explorer", @@ -811,6 +816,10 @@ "command": "vectorcastTestExplorer.showRequirements", "when": "never" }, + { + "command": "vectorcastTestExplorer.editRequirements", + "when": "never" + }, { "command": "vectorcastTestExplorer.removeRequirements", "when": "never" @@ -1082,6 +1091,11 @@ "group": "vcast@9", "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" }, + { + "command": "vectorcastTestExplorer.editRequirements", + "group": "vcast@9", + "when": "testId =~ /^vcast:.*$/ && testId in vectorcastTestExplorer.vcastEnviroList && vectorcastTestExplorer.reqs2xFeatureEnabled && testId in vectorcastTestExplorer.vcastRequirementsAvailable && vectorcastTestExplorer.generateRequirementsEnabled" + }, { "command": "vectorcastTestExplorer.generateTestsFromRequirements", "group": "vcast@10", From de0cdf0da05e65af2620ac2fe91dbecb025e0244 Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Fri, 14 Nov 2025 11:14:36 +0100 Subject: [PATCH 4/7] Enhanced webview features --- python/vTestInterface.py | 28 ++ python/vcastDataServerTypes.py | 1 + src-common/vcastServer.ts | 1 + src/extension.ts | 12 +- src/manage/webviews/css/editRequirements.css | 21 +- .../webviewScripts/editRequirements.js | 281 +++++++++++++++--- src/vcastAdapter.ts | 11 + 7 files changed, 307 insertions(+), 48 deletions(-) diff --git a/python/vTestInterface.py b/python/vTestInterface.py index be667f9e..fafe5e7d 100644 --- a/python/vTestInterface.py +++ b/python/vTestInterface.py @@ -802,6 +802,34 @@ def processCommandLogic(mode, clicast, pathToUse, testString="", options=""): returnObject = topLevel + elif mode == "requirementsWebview": + try: + api = UnitTestApi(pathToUse) + except Exception as err: + raise UsageError(err) + + # getTestDataVCAST returns a list of nodes: + # - Compound Tests + # - Initialization Tests + # - Unit nodes: { "name": unitName, "functions": [ {name:...}, ... ] } + testData = getTestDataVCAST(api, pathToUse) + + unitFunctionMap = {} + + for node in testData: + # Skip compound + init test groups + if "functions" not in node: + continue + + unitName = node.get("name") + functionNames = [fn.get("name") for fn in node["functions"]] + + unitFunctionMap[unitName] = functionNames + + api.close() + returnObject = unitFunctionMap + + elif mode == "getEnviroData": topLevel = dict() diff --git a/python/vcastDataServerTypes.py b/python/vcastDataServerTypes.py index 16d87cbe..7e67087e 100644 --- a/python/vcastDataServerTypes.py +++ b/python/vcastDataServerTypes.py @@ -37,6 +37,7 @@ class commandType(str, Enum): choiceListCT = "choiceList-ct" mcdcReport = "mcdcReport" mcdcLines = "mcdcLines" + requirementsWebview = "requirementsWebview" class clientRequest: diff --git a/src-common/vcastServer.ts b/src-common/vcastServer.ts index b2dee22a..5b8a198c 100644 --- a/src-common/vcastServer.ts +++ b/src-common/vcastServer.ts @@ -26,6 +26,7 @@ export enum vcastCommandType { mcdcReport = "mcdcReport", mcdcLines = "mcdcLines", getWorkspaceEnviroData = "getWorkspaceEnviroData", + requirementsWebview = "requirementsWebview", } export interface mcdcClientRequestType extends clientRequestType { diff --git a/src/extension.ts b/src/extension.ts index 5647ebb5..e9613e4a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -93,6 +93,7 @@ import { rebuildEnvironment, openProjectInVcast, deleteLevel, + getRequirementsWebviewDataFromPython, } from "./vcastAdapter"; import { @@ -2407,6 +2408,10 @@ async function installPreActivationEventHandlers( } const enviroPath = testNode.enviroPath; + + const webviewDropdownData = + getRequirementsWebviewDataFromPython(enviroPath); + const parentDir = path.dirname(enviroPath); const enviroNameWithExt = path.basename(enviroPath); const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); @@ -2571,7 +2576,8 @@ async function installPreActivationEventHandlers( panel.webview.html = await getEditRequirementsWebviewContent( context, panel, - merged + merged, + webviewDropdownData ); panel.webview.onDidReceiveMessage( @@ -2648,7 +2654,8 @@ async function installPreActivationEventHandlers( async function getEditRequirementsWebviewContent( context: vscode.ExtensionContext, panel: vscode.WebviewPanel, - mergedJson: any + mergedJson: any, + webviewDropdownData: any ): Promise { const base = resolveWebviewBase(context); const cssOnDisk = vscode.Uri.file( @@ -2683,6 +2690,7 @@ async function installPreActivationEventHandlers( "{{ scriptUri }}", ` ` ); diff --git a/src/manage/webviews/css/editRequirements.css b/src/manage/webviews/css/editRequirements.css index c76fabe5..806212a2 100644 --- a/src/manage/webviews/css/editRequirements.css +++ b/src/manage/webviews/css/editRequirements.css @@ -227,4 +227,23 @@ h2 { background-color: #3b1f1f !important; } - \ No newline at end of file + /* Style dropdowns to match inputs */ +.key-value select { + flex: 1; + background-color: #3c3c3c; + color: #d4d4d4; + border: 1px solid #555; + border-radius: 4px; + padding: 6px 8px; + font-family: monospace; + font-size: 16px; + appearance: none; /* removes default arrow on some browsers */ + -moz-appearance: none; + -webkit-appearance: none; + cursor: pointer; +} + +/* Optional: Add a little padding for the arrow space if you want a custom arrow */ +.key-value select::-ms-expand { + display: none; /* Remove default arrow on IE/Edge */ +} \ No newline at end of file diff --git a/src/manage/webviews/webviewScripts/editRequirements.js b/src/manage/webviews/webviewScripts/editRequirements.js index c156e3c8..d501a2c8 100644 --- a/src/manage/webviews/webviewScripts/editRequirements.js +++ b/src/manage/webviews/webviewScripts/editRequirements.js @@ -2,6 +2,7 @@ const vscode = acquireVsCodeApi(); let jsonData = window.initialJson; let undoStack = []; let collapsedKeys = new Set(); +const dropdownData = window.webviewDropdownData; // {unit1:[f1,f2], unit2:[f3,f4]} const jsonContainer = document.getElementById("jsonContainer"); const filterBar = document.getElementById("filterBar"); @@ -15,14 +16,15 @@ const addConfirmBtn = document.getElementById("btnAddConfirm"); const btnSave = document.getElementById("btnSave"); const btnCancel = document.getElementById("btnCancel"); -// Initial state -addSection.style.display = "none"; // << Ensure hidden on start +addSection.style.display = "none"; // hide add form initially pushUndo(); renderFilters(); renderObjects(); -// Undo logic remains for Ctrl+Z +// ---------------------- +// UNDO STACK +// ---------------------- function pushUndo() { undoStack.push(JSON.stringify(jsonData)); } document.addEventListener("keydown", e => { @@ -33,15 +35,45 @@ document.addEventListener("keydown", e => { } }); -// Save / Cancel -btnSave.addEventListener("click", () => - vscode.postMessage({ command: "saveJson", data: jsonData }) -); +// ---------------------- +// SAVE / CANCEL +// ---------------------- +btnSave.addEventListener("click", () => { + let valid = true; + + // Validate all requirements before saving + Object.entries(jsonData).forEach(([reqId, reqObj]) => { + ["id", "unit", "function"].forEach(key => { + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="${key}"]` + ); + if (!reqObj[key] || reqObj[key].trim() === "") { + valid = false; + if (input) input.classList.add("input-error"); + } else { + if (input) input.classList.remove("input-error"); + } + }); + }); + + if (!valid) { + vscode.window.showWarningMessage( + "Please fill in all required fields (id, unit, function) before saving." + ); + return; + } + + // All required fields filled, send to extension + vscode.postMessage({ command: "saveJson", data: jsonData }); +}); + btnCancel.addEventListener("click", () => vscode.postMessage({ command: "cancel" }) ); -// Filters +// ---------------------- +// FILTER BAR +// ---------------------- function renderFilters() { filterBar.innerHTML = ""; const keys = Object.keys(jsonData[Object.keys(jsonData)[0]] || {}); @@ -49,6 +81,7 @@ function renderFilters() { const label = document.createElement("label"); label.innerHTML = ` ${k}`; filterBar.appendChild(label); + label.querySelector("input").addEventListener("change", e => { const key = e.target.dataset.key; if (e.target.checked) collapsedKeys.delete(key); @@ -58,70 +91,238 @@ function renderFilters() { }); } -// Objects Display +// ---------------------- +// RENDER REQUIREMENTS +// ---------------------- function renderObjects() { jsonContainer.innerHTML = ""; const keys = Object.keys(jsonData[Object.keys(jsonData)[0]] || {}); + Object.entries(jsonData).forEach(([objKey, objVal]) => { const div = document.createElement("div"); div.className = "json-object"; + div.dataset.objKey = objKey; // Track current object key div.innerHTML = `
${objKey}
`; + keys.forEach(k => { if (!collapsedKeys.has(k)) { - const val = objVal[k] ?? ""; const kv = document.createElement("div"); kv.className = "key-value"; - kv.innerHTML = `
${k}:
`; + kv.innerHTML = `
${k}:
`; + + // ID / regular input + if (k === "id" || (k !== "unit" && k !== "function")) { + const input = document.createElement("input"); + input.dataset.parent = objKey; + input.dataset.key = k; + input.value = objVal[k] ?? ""; + + input.addEventListener("input", e => { + const parent = e.target.dataset.parent; + const key = e.target.dataset.key; + const value = e.target.value; + + // If the ID changes, rename the key in jsonData + if (key === "id" && value !== parent) { + jsonData[value] = { ...jsonData[parent], id: value }; + delete jsonData[parent]; + + // Update dataset.parent for all inputs/selects in this row + div.querySelectorAll("input, select").forEach(el => { + el.dataset.parent = value; + }); + + // Update div header + div.querySelector(".json-object-key").textContent = value; + div.dataset.objKey = value; + } else { + jsonData[parent][key] = value; + } + + pushUndo(); + }); + + kv.appendChild(input); + } + + // UNIT DROPDOWN + else if (k === "unit") { + const select = document.createElement("select"); + select.dataset.parent = objKey; + select.dataset.key = k; + select.innerHTML = Object.keys(dropdownData).map(u => ``).join(""); + if (objVal.unit) select.value = objVal.unit; + select.addEventListener("change", onUnitChange); + kv.appendChild(select); + } + + // FUNCTION DROPDOWN + else if (k === "function") { + const select = document.createElement("select"); + select.dataset.parent = objKey; + select.dataset.key = k; + + const currentUnit = objVal.unit; + if (currentUnit && dropdownData[currentUnit]) { + select.innerHTML = dropdownData[currentUnit].map(f => ``).join(""); + } else { + select.innerHTML = Object.values(dropdownData).flat().map(f => ``).join(""); + } + + if (objVal.function) select.value = objVal.function; + select.addEventListener("change", onFunctionChange); + kv.appendChild(select); + } + div.appendChild(kv); } }); + jsonContainer.appendChild(div); }); +} + +// ---------------------- +// UNIT/FUNCTION LOGIC +// ---------------------- +function onUnitChange(e) { + const unit = e.target.value; + const parent = e.target.dataset.parent; + jsonData[parent].unit = unit; - jsonContainer.querySelectorAll("input").forEach(input => { - input.addEventListener("input", e => { - const k = e.target.dataset.key; - const parent = e.target.dataset.parent; - jsonData[parent][k] = e.target.value; - pushUndo(); + const functionSelect = Array.from( + jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="function"]`) + )[0]; + + functionSelect.innerHTML = ``; + if (unit && dropdownData[unit]) { + dropdownData[unit].forEach(f => { + functionSelect.innerHTML += ``; }); - }); + + if (!dropdownData[unit].includes(jsonData[parent].function)) { + jsonData[parent].function = ""; + functionSelect.value = ""; + } + } else { + Object.values(dropdownData).flat().forEach(f => { + functionSelect.innerHTML += ``; + }); + } + + pushUndo(); } -// --------------------- -// ADD NEW REQUIREMENT UI -// --------------------- +function onFunctionChange(e) { + const func = e.target.value; + const parent = e.target.dataset.parent; + jsonData[parent].function = func; + + // Autocomplete unit if empty + if (!jsonData[parent].unit && func) { + for (const [unit, funcs] of Object.entries(dropdownData)) { + if (funcs.includes(func)) { + jsonData[parent].unit = unit; + const unitSelect = Array.from( + jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="unit"]`) + )[0]; + unitSelect.value = unit; + + // trigger unit change to filter functions correctly + const event = new Event('change'); + unitSelect.dispatchEvent(event); + + // restore function selection + const funcSelect = Array.from( + jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="function"]`) + )[0]; + funcSelect.value = func; + + break; + } + } + } + + pushUndo(); +} + +// ---------------------- +// ADD NEW REQUIREMENT FORM +// ---------------------- showAddFormBtn.addEventListener("click", () => { buildAddForm(); addSection.style.display = "block"; showAddFormBtn.style.display = "none"; }); -// Build empty fields matching structure function buildAddForm() { addRows.innerHTML = ""; const sample = jsonData[Object.keys(jsonData)[0]] || {}; - const keys = Object.keys(sample); - keys.forEach(k => { + Object.keys(sample).forEach(k => { const wrapper = document.createElement("div"); wrapper.className = "key-value"; - // REQUIRED placeholder only for key, unit, function const isRequired = (k === "id" || k === "unit" || k === "function"); const placeholder = isRequired ? "(required)" : ""; - wrapper.innerHTML = ` -
${k}:
- - `; + wrapper.innerHTML = `
${k}:
`; + + if (k === "unit") { + const select = document.createElement("select"); + select.dataset.key = k; + select.innerHTML = `` + Object.keys(dropdownData).map(u => ``).join(""); + wrapper.appendChild(select); + } else if (k === "function") { + const select = document.createElement("select"); + select.dataset.key = k; + // Initial: all functions + select.innerHTML = `` + Object.values(dropdownData).flat().map(f => ``).join(""); + wrapper.appendChild(select); + } else { + const input = document.createElement("input"); + input.dataset.key = k; + input.placeholder = placeholder; + wrapper.appendChild(input); + } + addRows.appendChild(wrapper); }); -} + const unitSelect = addRows.querySelector(`select[data-key="unit"]`); + const funcSelect = addRows.querySelector(`select[data-key="function"]`); + + // Unit -> filter functions + unitSelect.addEventListener("change", e => { + const unit = e.target.value; + funcSelect.innerHTML = ``; + if (unit && dropdownData[unit]) { + dropdownData[unit].forEach(f => funcSelect.innerHTML += ``); + if (!dropdownData[unit].includes(funcSelect.value)) funcSelect.value = ""; + } else { + Object.values(dropdownData).flat().forEach(f => funcSelect.innerHTML += ``); + } + }); + + // Function -> auto-select unit if unit empty + funcSelect.addEventListener("change", e => { + const func = e.target.value; + if (!unitSelect.value && func) { + for (const [unit, funcs] of Object.entries(dropdownData)) { + if (funcs.includes(func)) { + const previousFunc = func; + unitSelect.value = unit; + const event = new Event('change'); + unitSelect.dispatchEvent(event); + funcSelect.value = previousFunc; + break; + } + } + } + }); +} -// Cancel add requirement addCancelBtn.addEventListener("click", () => { addSection.style.display = "none"; showAddFormBtn.style.display = "block"; @@ -129,32 +330,22 @@ addCancelBtn.addEventListener("click", () => { addConfirmBtn.addEventListener("click", () => { const newEntry = {}; - const inputs = addRows.querySelectorAll("input"); + const inputs = addRows.querySelectorAll("input, select"); - // Clear previous error states inputs.forEach(inp => inp.classList.remove("input-error")); - inputs.forEach(inp => newEntry[inp.dataset.key] = inp.value.trim()); - // Validate required fields let valid = true; ["unit", "function", "id"].forEach(requiredKey => { - const field = addRows.querySelector(`input[data-key="${requiredKey}"]`); - if (!newEntry[requiredKey]) { - valid = false; - field.classList.add("input-error"); - } + const field = addRows.querySelector(`[data-key="${requiredKey}"]`); + if (!newEntry[requiredKey]) { valid = false; field.classList.add("input-error"); } }); - - if (!valid) return; // Stop, show red highlight + if (!valid) return; jsonData[newEntry.id] = newEntry; - pushUndo(); renderObjects(); - // Hide form again addSection.style.display = "none"; showAddFormBtn.style.display = "block"; }); - diff --git a/src/vcastAdapter.ts b/src/vcastAdapter.ts index 8b69ac55..cd85cbc2 100644 --- a/src/vcastAdapter.ts +++ b/src/vcastAdapter.ts @@ -634,6 +634,17 @@ function getProjectDataFromPython(projectDirectoryPath: string): any { return jsonData; } +export function getRequirementsWebviewDataFromPython(envPath: string): any { + // This function will return the environment data for a single directory + // by calling vpython with the appropriate command + const commandToRun = getVcastInterfaceCommand( + vcastCommandType.requirementsWebview, + envPath + ); + let jsonData = getJsonDataFromTestInterface(commandToRun, envPath); + return jsonData; +} + // Get Environment Data --------------------------------------------------------------- // Server logic is in a separate function below export async function getDataForEnvironment(enviroPath: string): Promise { From 9c3a028111afd0b5382e248221e1934d5911a97e Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Fri, 14 Nov 2025 14:26:03 +0100 Subject: [PATCH 5/7] prettier --- src/extension.ts | 243 +++++++++++++++++++++-------------------------- 1 file changed, 109 insertions(+), 134 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index e9613e4a..51f1825c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2391,53 +2391,57 @@ async function installPreActivationEventHandlers( return html; } - const editRequirementsCmd = vscode.commands.registerCommand( + const editRequirementsCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.editRequirements", async (args: any) => { try { - // args must contain the test node id just like your showRequirements command + // Ensure a test node argument is provided if (!args) { vscode.window.showErrorMessage("No test node argument provided."); return; } + // Retrieve the test node based on ID const testNode: testNodeType = getTestNode(args.id); if (!testNode) { vscode.window.showErrorMessage("Test node not found."); return; } - const enviroPath = testNode.enviroPath; + const environmentFilePath = testNode.enviroPath; - const webviewDropdownData = - getRequirementsWebviewDataFromPython(enviroPath); + // Fetch dropdown data for webview + const dropdownData = + getRequirementsWebviewDataFromPython(environmentFilePath); - const parentDir = path.dirname(enviroPath); - const enviroNameWithExt = path.basename(enviroPath); - const enviroNameWithoutExt = enviroNameWithExt.replace(/\.env$/, ""); - const envReqsFolderPath = path.join( - parentDir, - `reqs-${enviroNameWithoutExt}` + // Construct paths to requirements and traceability JSON files + const environmentDir = path.dirname(environmentFilePath); + const environmentFileName = path.basename(environmentFilePath); + const environmentName = environmentFileName.replace(/\.env$/, ""); + const requirementsFolderPath = path.join( + environmentDir, + `reqs-${environmentName}` ); - // target folder containing generated_requirement_repository/requirements_gateway - const gatewayFolder = path.join( - envReqsFolderPath, + const gatewayFolderPath = path.join( + requirementsFolderPath, "generated_requirement_repository", "requirements_gateway" ); + const requirementsJsonPath = path.join( - gatewayFolder, + gatewayFolderPath, "requirements.json" ); const traceabilityJsonPath = path.join( - gatewayFolder, + gatewayFolderPath, "traceability.json" ); - if (!fs.existsSync(gatewayFolder)) { + // Validate existence of target folders and files + if (!fs.existsSync(gatewayFolderPath)) { vscode.window.showErrorMessage( - `Requirements folder not found: ${gatewayFolder}` + `Requirements folder not found: ${gatewayFolderPath}` ); return; } @@ -2447,121 +2451,98 @@ async function installPreActivationEventHandlers( !fs.existsSync(traceabilityJsonPath) ) { vscode.window.showErrorMessage( - "No requirements.json or traceability.json found in requirements_gateway." + "Neither requirements.json nor traceability.json found in requirements_gateway." ); return; } - // Read files if they exist - let requirementsRaw: any = null; - let traceabilityRaw: any = null; - try { - if (fs.existsSync(requirementsJsonPath)) { - const txt = fs.readFileSync(requirementsJsonPath, "utf8"); - requirementsRaw = JSON.parse(txt || "{}"); - } else { - requirementsRaw = {}; - } - } catch (err) { - vscode.window.showErrorMessage( - `Failed to read/parse requirements.json: ${err}` - ); - return; - } - - try { - if (fs.existsSync(traceabilityJsonPath)) { - const txt = fs.readFileSync(traceabilityJsonPath, "utf8"); - traceabilityRaw = JSON.parse(txt || "{}"); - } else { - traceabilityRaw = {}; + // Load JSON files safely + const readJsonFile = (filePath: string) => { + if (!fs.existsSync(filePath)) return {}; + try { + const content = fs.readFileSync(filePath, "utf8"); + return JSON.parse(content || "{}"); + } catch (err) { + vscode.window.showErrorMessage( + `Failed to read/parse ${filePath}: ${err}` + ); + return null; } - } catch (err) { - vscode.window.showErrorMessage( - `Failed to read/parse traceability.json: ${err}` - ); - return; - } + }; - // Merge logic: - // - requirementsRaw may be grouped (like { "[CSV] [/tmp/..]": { id: {...}, ... } }) OR flat { id: {...}, ... } - // - traceabilityRaw is expected to be flat { id: { unit, function, lines }, ... } - const merged: Record = {}; - - // Helper: extract id->obj mapping from requirementsRaw regardless of grouping - function flattenRequirements(reqsRoot: any): Record { - const out: Record = {}; - if (!reqsRoot || typeof reqsRoot !== "object") return out; - const topKeys = Object.keys(reqsRoot); - // detect grouped: if top-level values are objects and their values are objects containing id fields - let isGrouped = false; - for (const k of topKeys) { - const v = reqsRoot[k]; - if (v && typeof v === "object") { - const maybeInnerKeys = Object.keys(v); - if (maybeInnerKeys.length > 0) { - // check if inner values look like requirement objects - const sampleInner = v[maybeInnerKeys[0]]; - if ( - sampleInner && - typeof sampleInner === "object" && - ("id" in sampleInner || "title" in sampleInner) - ) { - isGrouped = true; - break; - } + const requirementsData = readJsonFile(requirementsJsonPath); + if (requirementsData === null) return; + + const traceabilityData = readJsonFile(traceabilityJsonPath); + if (traceabilityData === null) return; + + // Flatten grouped or nested requirement objects + const flattenRequirements = (data: any): Record => { + if (!data || typeof data !== "object") return {}; + const flattened: Record = {}; + const topKeys = Object.keys(data); + + const isGrouped = topKeys.some((key) => { + const value = data[key]; + if (value && typeof value === "object") { + const innerKeys = Object.keys(value); + if (innerKeys.length > 0) { + const sample = value[innerKeys[0]]; + return ( + sample && + typeof sample === "object" && + ("id" in sample || "title" in sample) + ); } } - } + return false; + }); + if (isGrouped) { - // merge all inner objects from groups - for (const groupKey of topKeys) { - const group = reqsRoot[groupKey]; + topKeys.forEach((groupKey) => { + const group = data[groupKey]; if (group && typeof group === "object") { - for (const idKey of Object.keys(group)) { - out[idKey] = group[idKey]; - } + Object.keys(group).forEach((id) => { + flattened[id] = group[id]; + }); } - } + }); } else { - // flat top-level: treat each top-level key as id -> obj - for (const k of topKeys) { - const v = reqsRoot[k]; - if (v && typeof v === "object") out[k] = v; - } + topKeys.forEach((id) => { + const value = data[id]; + if (value && typeof value === "object") flattened[id] = value; + }); } - return out; - } - const reqsFlat = flattenRequirements(requirementsRaw); - const traceFlat = - traceabilityRaw && typeof traceabilityRaw === "object" - ? traceabilityRaw - : {}; + return flattened; + }; + + const flattenedRequirements = flattenRequirements(requirementsData); + const flattenedTraceability = + typeof traceabilityData === "object" ? traceabilityData : {}; - // Build merged: start from union of ids in both - const allIds = new Set([ - ...Object.keys(reqsFlat), - ...Object.keys(traceFlat), + // Merge requirements and traceability by ID + const mergedRequirements: Record = {}; + const allIds = new Set([ + ...Object.keys(flattenedRequirements), + ...Object.keys(flattenedTraceability), ]); + allIds.forEach((id) => { - const r = reqsFlat[id] || {}; - const t = traceFlat[id] || {}; - // Merge: prefer r fields and then t fields ('unit','function','lines') - const mergedObj: any = {}; - // copy all reqs fields - for (const k of Object.keys(r)) mergedObj[k] = r[k]; - // ensure id key exists - mergedObj.id = mergedObj.id || id; - // copy traceability fields - if ("unit" in t) mergedObj.unit = t.unit; - if ("function" in t) mergedObj.function = t.function; - if ("lines" in t) mergedObj.lines = t.lines; - merged[id] = mergedObj; + const requirement = flattenedRequirements[id] || {}; + const trace = flattenedTraceability[id] || {}; + + mergedRequirements[id] = { + ...requirement, + id: requirement.id || id, + unit: trace.unit ?? undefined, + function: trace.function ?? undefined, + lines: trace.lines ?? undefined, + }; }); - // Create panel & webview content (inject merged) - const baseDir = resolveWebviewBase(context); + // Create and display the webview panel + const webviewBasePath = resolveWebviewBase(context); const panel = vscode.window.createWebviewPanel( "editRequirements", "Edit Requirements", @@ -2569,39 +2550,35 @@ async function installPreActivationEventHandlers( { enableScripts: true, retainContextWhenHidden: true, - localResourceRoots: [vscode.Uri.file(baseDir)], + localResourceRoots: [vscode.Uri.file(webviewBasePath)], } ); panel.webview.html = await getEditRequirementsWebviewContent( context, panel, - merged, - webviewDropdownData + mergedRequirements, + dropdownData ); + // Handle messages from webview panel.webview.onDidReceiveMessage( - async (msg) => { - switch (msg.command) { - case "saveJson": { + async (message) => { + switch (message.command) { + case "saveJson": try { - const mergedData = msg.data; // key → merged object - const requirementOutput: Record = {}; + const mergedData = message.data as Record; + const requirementsOutput: Record = {}; const traceOutput: Record = {}; - // Split merged data back to each file - for (const [id, obj] of Object.entries( - mergedData as Record - )) { - // Requirement JSON fields - requirementOutput[id] = { + for (const [id, obj] of Object.entries(mergedData)) { + requirementsOutput[id] = { title: obj.title ?? "", description: obj.description ?? "", id: obj.id ?? id, last_modified: obj.last_modified ?? "tbd", }; - // Traceability JSON fields traceOutput[id] = { unit: obj.unit ?? "", function: obj.function ?? "", @@ -2609,10 +2586,9 @@ async function installPreActivationEventHandlers( }; } - // Write files back to disk await fs.promises.writeFile( requirementsJsonPath, - JSON.stringify(requirementOutput, null, 2), + JSON.stringify(requirementsOutput, null, 2), "utf8" ); await fs.promises.writeFile( @@ -2630,7 +2606,6 @@ async function installPreActivationEventHandlers( ); } break; - } case "cancel": panel.dispose(); @@ -2648,9 +2623,9 @@ async function installPreActivationEventHandlers( } ); - context.subscriptions.push(editRequirementsCmd); + context.subscriptions.push(editRequirementsCommand); - // Update getEditRequirementsWebviewContent to accept mergedJson and inject it (if you already have similar function, replace accordingly) + // Update getEditRequirementsWebviewContent to accept mergedJson and inject it async function getEditRequirementsWebviewContent( context: vscode.ExtensionContext, panel: vscode.WebviewPanel, From 5dff4b4c390a03816eadcf299af4d457a5e0f3ba Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Fri, 14 Nov 2025 16:05:29 +0100 Subject: [PATCH 6/7] HL reqs adaptations --- src/manage/webviews/css/editRequirements.css | 19 +- .../webviewScripts/editRequirements.js | 565 +++++++++++++----- 2 files changed, 421 insertions(+), 163 deletions(-) diff --git a/src/manage/webviews/css/editRequirements.css b/src/manage/webviews/css/editRequirements.css index 806212a2..e312c2fc 100644 --- a/src/manage/webviews/css/editRequirements.css +++ b/src/manage/webviews/css/editRequirements.css @@ -243,7 +243,24 @@ h2 { cursor: pointer; } -/* Optional: Add a little padding for the arrow space if you want a custom arrow */ +.hl-tag { + margin-left: 10px; + padding: 2px 6px; + background: #ffcc00; + color: #000; + font-weight: bold; + border-radius: 4px; + font-size: 12px; +} + +.hl-readonly { + background: #eee; + color: #555; + cursor: not-allowed; + border: 1px solid #ccc; +} + + .key-value select::-ms-expand { display: none; /* Remove default arrow on IE/Edge */ } \ No newline at end of file diff --git a/src/manage/webviews/webviewScripts/editRequirements.js b/src/manage/webviews/webviewScripts/editRequirements.js index d501a2c8..5eb2b89f 100644 --- a/src/manage/webviews/webviewScripts/editRequirements.js +++ b/src/manage/webviews/webviewScripts/editRequirements.js @@ -1,32 +1,71 @@ +// webview.js +// ------------------------------ +// Webview logic for Edit Requirements +// ------------------------------ const vscode = acquireVsCodeApi(); -let jsonData = window.initialJson; + +// State +let jsonData = window.initialJson || {}; let undoStack = []; let collapsedKeys = new Set(); -const dropdownData = window.webviewDropdownData; // {unit1:[f1,f2], unit2:[f3,f4]} +const dropdownData = window.webviewDropdownData || {}; // { unit1: [f1,f2], unit2: [...] } +// DOM refs const jsonContainer = document.getElementById("jsonContainer"); const filterBar = document.getElementById("filterBar"); const addSection = document.getElementById("addSection"); const addRows = document.getElementById("addRows"); -// Buttons const showAddFormBtn = document.getElementById("showAddForm"); const addCancelBtn = document.getElementById("btnAddCancel"); const addConfirmBtn = document.getElementById("btnAddConfirm"); const btnSave = document.getElementById("btnSave"); const btnCancel = document.getElementById("btnCancel"); -addSection.style.display = "none"; // hide add form initially +// Hide add section initially +addSection.style.display = "none"; +// Initial render pushUndo(); renderFilters(); renderObjects(); -// ---------------------- -// UNDO STACK -// ---------------------- -function pushUndo() { undoStack.push(JSON.stringify(jsonData)); } +/* ------------------------- + Utility helpers + ------------------------- */ + +/** + * Return true if a requirement is considered High-Level (HL). + * HL is detected if the object key contains "_HL." OR the object's id contains "_HL." + */ +function isHighLevel(objKey, objVal) { + try { + return ( + (typeof objKey === "string" && objKey.includes("_HL.")) || + (objVal && typeof objVal.id === "string" && objVal.id.includes("_HL.")) + ); + } catch { + return false; + } +} + +/** Push current state to undo stack (simple serialization) */ +function pushUndo() { + try { + undoStack.push(JSON.stringify(jsonData)); + } catch { + // ignore + } +} +/** Send a message to extension to show a message in VS Code (info/warning/error) */ +function showVscodeMessage(type, message) { + vscode.postMessage({ command: "showMessage", type, message }); +} + +/* ------------------------- + Keyboard / Undo handling + ------------------------- */ document.addEventListener("keydown", e => { if ((e.ctrlKey || e.metaKey) && e.key === "z") { if (!undoStack.length) return; @@ -35,48 +74,67 @@ document.addEventListener("keydown", e => { } }); -// ---------------------- -// SAVE / CANCEL -// ---------------------- +/* ------------------------- + Save / Cancel behavior + ------------------------- */ btnSave.addEventListener("click", () => { let valid = true; + // Clear previous highlights + jsonContainer.querySelectorAll(".input-error").forEach(el => el.classList.remove("input-error")); + + // Validate each requirement + for (const [reqId, reqObj] of Object.entries(jsonData)) { + // id always required + if (!reqObj.id || String(reqObj.id).trim() === "") { + valid = false; + const input = jsonContainer.querySelector(`[data-parent="${reqId}"][data-key="id"]`); + if (input) input.classList.add("input-error"); + } - // Validate all requirements before saving - Object.entries(jsonData).forEach(([reqId, reqObj]) => { - ["id", "unit", "function"].forEach(key => { - const input = jsonContainer.querySelector( - `[data-parent="${reqId}"][data-key="${key}"]` - ); - if (!reqObj[key] || reqObj[key].trim() === "") { + // unit always required + if (!reqObj.unit || String(reqObj.unit).trim() === "") { + valid = false; + const input = jsonContainer.querySelector(`[data-parent="${reqId}"][data-key="unit"]`); + if (input) input.classList.add("input-error"); + } + + // function required only for non-HL items + if (!isHighLevel(reqId, reqObj)) { + if (!reqObj.function || String(reqObj.function).trim() === "") { valid = false; + const input = jsonContainer.querySelector(`[data-parent="${reqId}"][data-key="function"]`); if (input) input.classList.add("input-error"); - } else { - if (input) input.classList.remove("input-error"); } - }); - }); + } + } if (!valid) { - vscode.window.showWarningMessage( - "Please fill in all required fields (id, unit, function) before saving." - ); + showVscodeMessage("warning", "Please fill in all required fields. Required: id, unit, and (for non-HL) function."); return; } - // All required fields filled, send to extension + // For HL requirements, ensure function is explicitly null + for (const [reqId, reqObj] of Object.entries(jsonData)) { + if (isHighLevel(reqId, reqObj)) { + reqObj.function = null; + } + } + + // All OK, instruct extension to save vscode.postMessage({ command: "saveJson", data: jsonData }); }); -btnCancel.addEventListener("click", () => - vscode.postMessage({ command: "cancel" }) -); +btnCancel.addEventListener("click", () => { + vscode.postMessage({ command: "cancel" }); +}); -// ---------------------- -// FILTER BAR -// ---------------------- +/* ------------------------- + Filters rendering + ------------------------- */ function renderFilters() { filterBar.innerHTML = ""; - const keys = Object.keys(jsonData[Object.keys(jsonData)[0]] || {}); + const sample = jsonData[Object.keys(jsonData)[0]] || {}; + const keys = Object.keys(sample); keys.forEach(k => { const label = document.createElement("label"); label.innerHTML = ` ${k}`; @@ -91,153 +149,238 @@ function renderFilters() { }); } -// ---------------------- -// RENDER REQUIREMENTS -// ---------------------- +/* ------------------------- + Main: render the requirement objects + ------------------------- */ function renderObjects() { jsonContainer.innerHTML = ""; - const keys = Object.keys(jsonData[Object.keys(jsonData)[0]] || {}); - Object.entries(jsonData).forEach(([objKey, objVal]) => { + // Defensive: if empty dataset, show hint + if (!jsonData || Object.keys(jsonData).length === 0) { + const hint = document.createElement("div"); + hint.style.opacity = "0.7"; + hint.style.fontStyle = "italic"; + hint.textContent = "No requirements to display."; + jsonContainer.appendChild(hint); + return; + } + + const sample = jsonData[Object.keys(jsonData)[0]] || {}; + const keys = Object.keys(sample); + + // Iterate in insertion order (object) — note that renaming IDs updates jsonData + for (const [objKey, objVal] of Object.entries(jsonData)) { const div = document.createElement("div"); div.className = "json-object"; - div.dataset.objKey = objKey; // Track current object key - div.innerHTML = `
${objKey}
`; - + div.dataset.objKey = objKey; + + // Header: show object key and HL tag if applicable + const hl = isHighLevel(objKey, objVal); + const headerHtml = ` +
+ ${escapeHtml(objKey)} + ${hl ? `HIGH LEVEL` : ""} +
+ `; + div.innerHTML = headerHtml; + + // Render each field (respect collapsed keys) keys.forEach(k => { - if (!collapsedKeys.has(k)) { - const kv = document.createElement("div"); - kv.className = "key-value"; - kv.innerHTML = `
${k}:
`; - - // ID / regular input - if (k === "id" || (k !== "unit" && k !== "function")) { - const input = document.createElement("input"); - input.dataset.parent = objKey; - input.dataset.key = k; - input.value = objVal[k] ?? ""; - - input.addEventListener("input", e => { - const parent = e.target.dataset.parent; - const key = e.target.dataset.key; - const value = e.target.value; - - // If the ID changes, rename the key in jsonData - if (key === "id" && value !== parent) { - jsonData[value] = { ...jsonData[parent], id: value }; - delete jsonData[parent]; - - // Update dataset.parent for all inputs/selects in this row - div.querySelectorAll("input, select").forEach(el => { - el.dataset.parent = value; - }); - - // Update div header - div.querySelector(".json-object-key").textContent = value; - div.dataset.objKey = value; - } else { - jsonData[parent][key] = value; - } - - pushUndo(); - }); - - kv.appendChild(input); + if (collapsedKeys.has(k)) return; + + const kv = document.createElement("div"); + kv.className = "key-value"; + kv.innerHTML = `
${escapeHtml(k)}:
`; + + // ID and plain inputs (title, description, last_modified, etc.) + if (k === "id" || (k !== "unit" && k !== "function")) { + const input = document.createElement("input"); + input.dataset.parent = objKey; + input.dataset.key = k; + input.value = objVal[k] ?? ""; + + // If HL and this is id field -> read-only + if (k === "id" && hl) { + input.readOnly = true; + input.classList.add("hl-readonly"); } - // UNIT DROPDOWN - else if (k === "unit") { - const select = document.createElement("select"); - select.dataset.parent = objKey; - select.dataset.key = k; - select.innerHTML = Object.keys(dropdownData).map(u => ``).join(""); - if (objVal.unit) select.value = objVal.unit; - select.addEventListener("change", onUnitChange); - kv.appendChild(select); - } + // Input listener + input.addEventListener("input", e => { + const parent = e.target.dataset.parent; + const key = e.target.dataset.key; + const value = e.target.value; + + // If it's the id field and user changed, rename the object key in jsonData + if (key === "id" && value !== parent) { + // Prevent duplicate IDs + if (jsonData[value]) { + // show error and keep old value in UI + showVscodeMessage("error", `A requirement with ID "${value}" already exists. Choose a unique ID.`); + input.classList.add("input-error"); + return; + } + + // Create new key, copy data, delete old key + jsonData[value] = { ...jsonData[parent], id: value }; + delete jsonData[parent]; - // FUNCTION DROPDOWN - else if (k === "function") { - const select = document.createElement("select"); - select.dataset.parent = objKey; - select.dataset.key = k; + // Update dataset.parent for all elements within this row (so further edits target new key) + div.querySelectorAll("input, select").forEach(el => { + el.dataset.parent = value; + }); - const currentUnit = objVal.unit; - if (currentUnit && dropdownData[currentUnit]) { - select.innerHTML = dropdownData[currentUnit].map(f => ``).join(""); + // Update header text and dataset + const headerEl = div.querySelector(".json-object-key"); + if (headerEl) headerEl.textContent = value + (hl ? " " : ""); + div.dataset.objKey = value; } else { - select.innerHTML = Object.values(dropdownData).flat().map(f => ``).join(""); + // Normal field update + jsonData[parent][key] = value; } - if (objVal.function) select.value = objVal.function; - select.addEventListener("change", onFunctionChange); - kv.appendChild(select); + pushUndo(); + }); + + kv.appendChild(input); + div.appendChild(kv); + return; + } + + // UNIT dropdown (always shown, editable even for HL) + if (k === "unit") { + const select = document.createElement("select"); + select.dataset.parent = objKey; + select.dataset.key = k; + + // Populate units + select.innerHTML = Object.keys(dropdownData).map(u => ``).join(""); + if (objVal.unit) select.value = objVal.unit; + + select.addEventListener("change", onUnitChange); + kv.appendChild(select); + div.appendChild(kv); + return; + } + + // FUNCTION: for HL items we do NOT render editable dropdown. + // Instead show a small readonly note indicating 'null' and that it's HL. + if (k === "function") { + if (hl) { + const note = document.createElement("div"); + note.style.flex = "1"; + note.style.alignSelf = "center"; + note.style.color = "#aaaaaa"; + note.textContent = "(high-level requirement — function stored as null)"; + // add a non-editable placeholder element with the same data-key so validation selectors still work (but invisible) + const hidden = document.createElement("input"); + hidden.style.display = "none"; + hidden.dataset.parent = objKey; + hidden.dataset.key = "function"; + // add both elements + kv.appendChild(note); + kv.appendChild(hidden); + div.appendChild(kv); + return; } + // Regular requirement: render function dropdown, filtered by unit if unit present + const select = document.createElement("select"); + select.dataset.parent = objKey; + select.dataset.key = k; + + const currentUnit = objVal.unit; + if (currentUnit && dropdownData[currentUnit]) { + select.innerHTML = dropdownData[currentUnit].map(f => ``).join(""); + } else { + select.innerHTML = Object.values(dropdownData).flat().map(f => ``).join(""); + } + + if (objVal.function) select.value = objVal.function; + select.addEventListener("change", onFunctionChange); + kv.appendChild(select); div.appendChild(kv); + return; } + + // fallback append (shouldn't reach) + div.appendChild(kv); }); jsonContainer.appendChild(div); - }); + } // end for each object } -// ---------------------- -// UNIT/FUNCTION LOGIC -// ---------------------- +/* ------------------------- + UNIT / FUNCTION interdependency + ------------------------- */ + +/** Called when a unit dropdown changes in the main table */ function onUnitChange(e) { const unit = e.target.value; const parent = e.target.dataset.parent; jsonData[parent].unit = unit; + // find the function select for this parent (if any) const functionSelect = Array.from( jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="function"]`) )[0]; - functionSelect.innerHTML = ``; + if (!functionSelect) { + // no function select present (HL or missing), nothing to do + pushUndo(); + return; + } + + // Repopulate functionSelect based on chosen unit (or show all if unit empty) + functionSelect.innerHTML = ""; if (unit && dropdownData[unit]) { dropdownData[unit].forEach(f => { - functionSelect.innerHTML += ``; + functionSelect.innerHTML += ``; }); - + // If previously selected function isn't in new list, clear it if (!dropdownData[unit].includes(jsonData[parent].function)) { jsonData[parent].function = ""; functionSelect.value = ""; } } else { Object.values(dropdownData).flat().forEach(f => { - functionSelect.innerHTML += ``; + functionSelect.innerHTML += ``; }); } pushUndo(); } +/** Called when a function dropdown changes in the main table */ function onFunctionChange(e) { const func = e.target.value; const parent = e.target.dataset.parent; jsonData[parent].function = func; - // Autocomplete unit if empty + // If unit is empty, autocomplete it if (!jsonData[parent].unit && func) { for (const [unit, funcs] of Object.entries(dropdownData)) { if (funcs.includes(func)) { + // Set unit in model jsonData[parent].unit = unit; + // find the unit select and update it (will trigger filtering) const unitSelect = Array.from( jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="unit"]`) )[0]; - unitSelect.value = unit; - - // trigger unit change to filter functions correctly - const event = new Event('change'); - unitSelect.dispatchEvent(event); + if (unitSelect) { + unitSelect.value = unit; + // trigger unit change to repopulate functions correctly + const event = new Event('change'); + unitSelect.dispatchEvent(event); + } // restore function selection const funcSelect = Array.from( jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="function"]`) )[0]; - funcSelect.value = func; + if (funcSelect) funcSelect.value = func; break; } @@ -247,9 +390,10 @@ function onFunctionChange(e) { pushUndo(); } -// ---------------------- -// ADD NEW REQUIREMENT FORM -// ---------------------- +/* ------------------------- + ADD NEW REQUIREMENT form + ------------------------- */ + showAddFormBtn.addEventListener("click", () => { buildAddForm(); addSection.style.display = "block"; @@ -258,71 +402,121 @@ showAddFormBtn.addEventListener("click", () => { function buildAddForm() { addRows.innerHTML = ""; - const sample = jsonData[Object.keys(jsonData)[0]] || {}; - Object.keys(sample).forEach(k => { + // Use sample keys from existing requirements if present, + // otherwise default to common fields + const sample = jsonData[Object.keys(jsonData)[0]] || { + id: "", + title: "", + description: "", + unit: "", + function: "", + last_modified: "" + }; + const keys = Object.keys(sample); + + // For later toggling, references + let unitSelect = null; + let funcSelect = null; + let idInput = null; + let funcNote = null; + + keys.forEach(k => { const wrapper = document.createElement("div"); wrapper.className = "key-value"; - - const isRequired = (k === "id" || k === "unit" || k === "function"); - const placeholder = isRequired ? "(required)" : ""; - - wrapper.innerHTML = `
${k}:
`; + wrapper.innerHTML = `
${escapeHtml(k)}:
`; if (k === "unit") { const select = document.createElement("select"); select.dataset.key = k; - select.innerHTML = `` + Object.keys(dropdownData).map(u => ``).join(""); + select.innerHTML = Object.keys(dropdownData).map(u => ``).join(""); wrapper.appendChild(select); + unitSelect = select; } else if (k === "function") { const select = document.createElement("select"); select.dataset.key = k; - // Initial: all functions - select.innerHTML = `` + Object.values(dropdownData).flat().map(f => ``).join(""); + // initial: show ALL functions + select.innerHTML = Object.values(dropdownData).flat().map(f => ``).join(""); wrapper.appendChild(select); + funcSelect = select; + + // place to show HL note when id indicates HL + funcNote = document.createElement("div"); + funcNote.style.color = "#aaaaaa"; + funcNote.style.fontStyle = "italic"; + funcNote.style.display = "none"; + funcNote.textContent = "(high-level requirement — function will be saved as null)"; + wrapper.appendChild(funcNote); } else { const input = document.createElement("input"); input.dataset.key = k; - input.placeholder = placeholder; + input.placeholder = (k === "id" || k === "unit" || k === "function") ? "(required)" : ""; wrapper.appendChild(input); + if (k === "id") idInput = input; } addRows.appendChild(wrapper); }); - const unitSelect = addRows.querySelector(`select[data-key="unit"]`); - const funcSelect = addRows.querySelector(`select[data-key="function"]`); + // If unitSelect exists, wire unit->filter functions + if (unitSelect && funcSelect) { + unitSelect.addEventListener("change", () => { + const unit = unitSelect.value; + funcSelect.innerHTML = ""; + if (unit && dropdownData[unit]) { + dropdownData[unit].forEach(f => funcSelect.innerHTML += ``); + } else { + Object.values(dropdownData).flat().forEach(f => funcSelect.innerHTML += ``); + } + }); + } - // Unit -> filter functions - unitSelect.addEventListener("change", e => { - const unit = e.target.value; - funcSelect.innerHTML = ``; - if (unit && dropdownData[unit]) { - dropdownData[unit].forEach(f => funcSelect.innerHTML += ``); - if (!dropdownData[unit].includes(funcSelect.value)) funcSelect.value = ""; - } else { - Object.values(dropdownData).flat().forEach(f => funcSelect.innerHTML += ``); + // If idInput exists, detect HL pattern and toggle function visibility + if (idInput && funcSelect && funcNote) { + function toggleForHL(idVal) { + const isHL = idVal && idVal.includes("_HL."); + if (isHL) { + funcSelect.style.display = "none"; + funcNote.style.display = "block"; + } else { + funcSelect.style.display = ""; + funcNote.style.display = "none"; + } } - }); - // Function -> auto-select unit if unit empty - funcSelect.addEventListener("change", e => { - const func = e.target.value; - if (!unitSelect.value && func) { - for (const [unit, funcs] of Object.entries(dropdownData)) { - if (funcs.includes(func)) { - const previousFunc = func; - unitSelect.value = unit; - const event = new Event('change'); - unitSelect.dispatchEvent(event); - funcSelect.value = previousFunc; - break; + // initial state + toggleForHL(idInput.value); + + // on input change, toggle + idInput.addEventListener("input", (e) => { + toggleForHL(e.target.value); + }); + } + + // Function-first behavior in add form: if function selected and unit empty, auto fill unit + if (funcSelect && unitSelect) { + funcSelect.addEventListener("change", () => { + const func = funcSelect.value; + if (!unitSelect.value && func) { + for (const [u, funcs] of Object.entries(dropdownData)) { + if (funcs.includes(func)) { + unitSelect.value = u; + // trigger unit change event + const evt = new Event('change'); + unitSelect.dispatchEvent(evt); + // restore function selection (unit change may have reset it) + funcSelect.value = func; + break; + } } } - } - }); + }); + } } +/* ------------------------- + Add-form cancel/confirm + ------------------------- */ addCancelBtn.addEventListener("click", () => { addSection.style.display = "none"; showAddFormBtn.style.display = "block"; @@ -331,21 +525,68 @@ addCancelBtn.addEventListener("click", () => { addConfirmBtn.addEventListener("click", () => { const newEntry = {}; const inputs = addRows.querySelectorAll("input, select"); - inputs.forEach(inp => inp.classList.remove("input-error")); inputs.forEach(inp => newEntry[inp.dataset.key] = inp.value.trim()); + // Validate required fields: let valid = true; - ["unit", "function", "id"].forEach(requiredKey => { - const field = addRows.querySelector(`[data-key="${requiredKey}"]`); - if (!newEntry[requiredKey]) { valid = false; field.classList.add("input-error"); } - }); - if (!valid) return; + // id and unit always required + const idField = addRows.querySelector(`[data-key="id"]`); + const unitField = addRows.querySelector(`[data-key="unit"]`); + const funcField = addRows.querySelector(`[data-key="function"]`); + + if (!newEntry.id) { valid = false; if (idField) idField.classList.add("input-error"); } + if (!newEntry.unit) { valid = false; if (unitField) unitField.classList.add("input-error"); } + + // function required only if not HL id + const isNewHL = newEntry.id && newEntry.id.includes("_HL."); + if (!isNewHL) { + if (!newEntry.function) { valid = false; if (funcField) funcField.classList.add("input-error"); } + } + if (!valid) { + showVscodeMessage("warning", "Please fill in all required fields before adding. Required: id, unit, and (for non-HL) function."); + return; + } + + // Prevent duplicate ID + if (jsonData[newEntry.id]) { + showVscodeMessage("error", `A requirement with ID "${newEntry.id}" already exists. Choose a unique ID.`); + if (idField) idField.classList.add("input-error"); + return; + } + + // For HL new requirements, ensure function is null + if (isNewHL) newEntry.function = null; + + // Save into model and re-render jsonData[newEntry.id] = newEntry; pushUndo(); renderObjects(); + // Hide add form and confirm to user addSection.style.display = "none"; showAddFormBtn.style.display = "block"; + showVscodeMessage("info", `Requirement "${newEntry.id}" added.`); }); + +/* ------------------------- + Small helpers + ------------------------- */ + +function escapeHtml(str) { + if (str === null || str === undefined) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">"); +} +function escapeHtmlAttr(str) { + if (str === null || str === undefined) return ""; + return String(str) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} From 5f0b3f459a00fe73905b9be4bfb8595fe16c5e67 Mon Sep 17 00:00:00 2001 From: Denis Moslavac Date: Mon, 17 Nov 2025 17:22:24 +0100 Subject: [PATCH 7/7] Added different tabs for normal reqs and hl reqs --- src/extension.ts | 12 +- src/manage/webviews/css/editRequirements.css | 244 ++---- .../webviews/html/editRequirements.html | 7 +- .../webviewScripts/editRequirements.js | 797 +++++++++++------- 4 files changed, 542 insertions(+), 518 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 51f1825c..2c4f58bc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2476,6 +2476,11 @@ async function installPreActivationEventHandlers( const traceabilityData = readJsonFile(traceabilityJsonPath); if (traceabilityData === null) return; + // Preserve original top-level group key + const topLevelKeys = Object.keys(requirementsData); + const mainGroupKey = + topLevelKeys.length === 1 ? topLevelKeys[0] : "Requirements"; + // Flatten grouped or nested requirement objects const flattenRequirements = (data: any): Record => { if (!data || typeof data !== "object") return {}; @@ -2586,9 +2591,14 @@ async function installPreActivationEventHandlers( }; } + // Wrap flattened requirements back in original top-level group + const wrappedRequirementsOutput = { + [mainGroupKey]: requirementsOutput, + }; + await fs.promises.writeFile( requirementsJsonPath, - JSON.stringify(requirementsOutput, null, 2), + JSON.stringify(wrappedRequirementsOutput, null, 2), "utf8" ); await fs.promises.writeFile( diff --git a/src/manage/webviews/css/editRequirements.css b/src/manage/webviews/css/editRequirements.css index e312c2fc..a5cc1df9 100644 --- a/src/manage/webviews/css/editRequirements.css +++ b/src/manage/webviews/css/editRequirements.css @@ -29,76 +29,67 @@ h2 { font-size: 24px; } -/* Filter bar styling */ +/* Tabs */ +.tabs { + display: flex; + margin-bottom: 10px; + justify-content: center; + gap: 5px; +} +.tab-button { + flex: 1; + padding: 8px 12px; + cursor: pointer; + border: none; + border-radius: 6px; + background-color: #3c3c3c; + color: #d4d4d4; + font-weight: bold; +} +.tab-button.active { + background-color: #007acc; + color: #fff; +} + +/* Filter bar */ .filter-title { text-align: center; font-size: 16px; margin-bottom: 8px; font-weight: bold; } - .filter-bar { display: flex; flex-wrap: wrap; gap: 10px; - justify-content: center; /* center the filters */ + justify-content: center; margin-bottom: 15px; border-bottom: 1px solid #555; padding-bottom: 10px; } - .filter-bar label { font-size: 14px; display: flex; align-items: center; cursor: pointer; } +.filter-bar input[type="checkbox"] { margin-right: 5px; } -.filter-bar input[type="checkbox"] { - margin-right: 5px; -} - -/* JSON objects */ -.table-container { - overflow: auto; /* make it scrollable */ - flex: 1 1 auto; - padding-top: 10px; -} - +/* JSON container */ +.table-container { overflow: auto; flex: 1 1 auto; padding-top: 10px; } .json-object { - border: 1px solid #555; + border: 1px solid #444; border-radius: 6px; - padding: 10px; - margin-bottom: 12px; - background-color: #1e1e1e; -} - -.json-object:hover { - background-color: #2a2a2a; -} - -.json-object-key { - font-weight: bold; - margin-bottom: 6px; - font-size: 16px; -} - -/* key-value rows */ -.key-value { - display: flex; - gap: 10px; - align-items: center; - margin-bottom: 4px; -} - -.key-value div { - flex: 0 0 auto; - min-width: 120px; - font-weight: bold; -} - -/* Make all inputs same length */ -.key-value input { + padding: 12px; + margin-bottom: 14px; + background-color: #212121; + transition: background-color 0.2s; +} +.json-object:hover { background-color: #292929; } +.json-object-key { font-weight: bold; margin-bottom: 6px; font-size: 16px; } +.key-value { display: flex; gap: 10px; align-items: center; margin-bottom: 4px; } +.key-value div { flex: 0 0 auto; min-width: 120px; font-weight: bold; } +.key-value input, .key-value select { flex: 1; background-color: #3c3c3c; color: #d4d4d4; @@ -107,160 +98,31 @@ h2 { padding: 6px 8px; font-family: monospace; font-size: 16px; + appearance: none; + cursor: pointer; } +.key-value select::-ms-expand { display: none; } -/* Buttons */ -.button-container { - display: flex; - justify-content: flex-end; - margin-top: 10px; - gap: 10px; -} +.hl-tag { margin-left: 10px; padding: 2px 6px; background: #ffcc00; color: #000; font-weight: bold; border-radius: 4px; font-size: 12px; } +.hl-readonly { background: #eee; color: #555; cursor: not-allowed; border: 1px solid #ccc; } +.button-container { display: flex; justify-content: flex-end; margin-top: 10px; gap: 10px; } .primary-button { background-color: #007acc; color: white; padding: 10px 15px; } .primary-button:hover { background-color: #005f99; } .cancel-button { background-color: #cc4444; color: white; padding: 10px 15px; } .cancel-button:hover { background-color: #992222; } #addSection { - margin-top: 18px; - padding: 14px; - border: 1px solid #666; - border-radius: 6px; - background-color: #232323; - } - - #addSection h3 { - text-align: center; - margin-bottom: 12px; - font-size: 18px; - } - - .add-rows .row { - display: flex; - align-items: center; - gap: 10px; - margin-bottom: 6px; - } - - .add-rows .row label { - min-width: 140px; - text-align: right; - font-weight: bold; - } - - .add-rows .row input { - flex: 1; - background-color: #3c3c3c; - color: #d4d4d4; - border: 1px solid #555; - border-radius: 4px; - padding: 6px 8px; - font-family: monospace; - font-size: 16px; - } - - .json-object { - border: 1px solid #444; - border-radius: 6px; - padding: 12px; - margin-bottom: 14px; - background-color: #212121; - transition: background-color 0.2s; - } - - .json-object:hover { - background-color: #292929; - } - - .add-section { - border: 1px solid #666; - border-radius: 6px; - padding: 14px; - margin-top: 18px; - background-color: #252525; - } - - .add-section h3 { - margin-bottom: 10px; - text-align: center; - } - - .add-button { - margin-top: 12px; - padding: 10px; - width: 100%; - background-color: #007acc; - border-radius: 6px; - color: white; - border: none; - cursor: pointer; - } - - .add-button:hover { - background-color: #005f99; - } - - #jsonContainer { - overflow-y: auto; - flex: 1; - padding-right: 4px; - } - - .add-section-title { - text-align: center; - font-size: 20px; - font-weight: bold; - margin-bottom: 14px; - color: #ffffff; - } - - /* Ensure placeholder text is readable */ - #addSection input::placeholder { - color: #aaaaaa; - opacity: 1; - } - - /* Highlight required fields when empty */ - .input-error { - border-color: #ff4444 !important; - background-color: #3b1f1f !important; - } - - /* Style dropdowns to match inputs */ -.key-value select { - flex: 1; - background-color: #3c3c3c; - color: #d4d4d4; - border: 1px solid #555; - border-radius: 4px; - padding: 6px 8px; - font-family: monospace; - font-size: 16px; - appearance: none; /* removes default arrow on some browsers */ - -moz-appearance: none; - -webkit-appearance: none; - cursor: pointer; + margin-top: 18px; padding: 14px; + border: 1px solid #666; border-radius: 6px; background-color: #232323; } +.add-section-title { text-align: center; font-size: 20px; font-weight: bold; margin-bottom: 14px; color: #ffffff; } +.input-error { border-color: #ff4444 !important; background-color: #3b1f1f !important; } -.hl-tag { - margin-left: 10px; - padding: 2px 6px; - background: #ffcc00; - color: #000; - font-weight: bold; - border-radius: 4px; - font-size: 12px; +.add-button { + margin-top: 12px; padding: 10px; width: 100%; background-color: #007acc; border-radius: 6px; color: white; border: none; cursor: pointer; } +.add-button:hover { background-color: #005f99; } -.hl-readonly { - background: #eee; - color: #555; - cursor: not-allowed; - border: 1px solid #ccc; -} - - -.key-value select::-ms-expand { - display: none; /* Remove default arrow on IE/Edge */ -} \ No newline at end of file +#jsonContainer { overflow-y: auto; flex: 1; padding-right: 4px; } +#addSection input::placeholder { color: #aaaaaa; opacity: 1; } diff --git a/src/manage/webviews/html/editRequirements.html b/src/manage/webviews/html/editRequirements.html index b648349e..4d2375df 100644 --- a/src/manage/webviews/html/editRequirements.html +++ b/src/manage/webviews/html/editRequirements.html @@ -10,6 +10,12 @@ - {{ scriptUri }} diff --git a/src/manage/webviews/webviewScripts/editRequirements.js b/src/manage/webviews/webviewScripts/editRequirements.js index 5eb2b89f..9825e89b 100644 --- a/src/manage/webviews/webviewScripts/editRequirements.js +++ b/src/manage/webviews/webviewScripts/editRequirements.js @@ -4,13 +4,18 @@ // ------------------------------ const vscode = acquireVsCodeApi(); -// State +// ------------------------------ +// State variables +// ------------------------------ let jsonData = window.initialJson || {}; let undoStack = []; let collapsedKeys = new Set(); +let currentTab = "normal"; // "normal" or "highLevel" const dropdownData = window.webviewDropdownData || {}; // { unit1: [f1,f2], unit2: [...] } -// DOM refs +// ------------------------------ +// DOM references +// ------------------------------ const jsonContainer = document.getElementById("jsonContainer"); const filterBar = document.getElementById("filterBar"); const addSection = document.getElementById("addSection"); @@ -22,7 +27,10 @@ const addConfirmBtn = document.getElementById("btnAddConfirm"); const btnSave = document.getElementById("btnSave"); const btnCancel = document.getElementById("btnCancel"); -// Hide add section initially +// Tabs +const tabButtons = document.querySelectorAll(".tab-button"); + +// Hide "add new requirement" section initially addSection.style.display = "none"; // Initial render @@ -30,97 +38,145 @@ pushUndo(); renderFilters(); renderObjects(); -/* ------------------------- - Utility helpers - ------------------------- */ +// ------------------------------ +// Utility functions +// ------------------------------ /** - * Return true if a requirement is considered High-Level (HL). - * HL is detected if the object key contains "_HL." OR the object's id contains "_HL." + * Determines whether a requirement is High-Level. + * High-Level if: + * - its ID contains "_HL." + * - OR its "function" field is explicitly null */ function isHighLevel(objKey, objVal) { try { - return ( - (typeof objKey === "string" && objKey.includes("_HL.")) || - (objVal && typeof objVal.id === "string" && objVal.id.includes("_HL.")) - ); + if (typeof objKey === "string" && objKey.includes("_HL.")) { + return true; + } + if (objVal && objVal.function === null) { + return true; + } + return false; } catch { return false; } } -/** Push current state to undo stack (simple serialization) */ +/** + * Push the current JSON state onto the undo stack. + * Uses JSON serialization for simplicity. + */ function pushUndo() { try { undoStack.push(JSON.stringify(jsonData)); - } catch { - // ignore + } catch (err) { + console.error("Failed to push undo state:", err); } } -/** Send a message to extension to show a message in VS Code (info/warning/error) */ +/** + * Show a message in VS Code via postMessage. + */ function showVscodeMessage(type, message) { vscode.postMessage({ command: "showMessage", type, message }); } -/* ------------------------- - Keyboard / Undo handling - ------------------------- */ -document.addEventListener("keydown", e => { - if ((e.ctrlKey || e.metaKey) && e.key === "z") { - if (!undoStack.length) return; +/** + * Escape HTML content to prevent injection + */ +function escapeHtml(str) { + if (str === null || str === undefined) return ""; + return String(str) + .replace(/&/g, "&") + .replace(//g, ">"); +} + +/** + * Escape string for HTML attributes + */ +function escapeHtmlAttr(str) { + if (str === null || str === undefined) return ""; + return String(str) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(//g, ">"); +} + +// ------------------------------ +// Keyboard / Undo handling +// ------------------------------ +document.addEventListener("keydown", (e) => { + const isUndo = (e.ctrlKey || e.metaKey) && e.key === "z"; + if (isUndo) { + if (undoStack.length === 0) { + return; + } jsonData = JSON.parse(undoStack.pop()); renderObjects(); } }); -/* ------------------------- - Save / Cancel behavior - ------------------------- */ +// ------------------------------ +// Save / Cancel behavior +// ------------------------------ btnSave.addEventListener("click", () => { - let valid = true; - // Clear previous highlights - jsonContainer.querySelectorAll(".input-error").forEach(el => el.classList.remove("input-error")); + let isValid = true; - // Validate each requirement + // Remove previous error highlights + jsonContainer.querySelectorAll(".input-error").forEach((el) => { + el.classList.remove("input-error"); + }); + + // Validate all requirements for (const [reqId, reqObj] of Object.entries(jsonData)) { - // id always required + // id is required if (!reqObj.id || String(reqObj.id).trim() === "") { - valid = false; - const input = jsonContainer.querySelector(`[data-parent="${reqId}"][data-key="id"]`); + isValid = false; + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="id"]` + ); if (input) input.classList.add("input-error"); } - // unit always required + // unit is required if (!reqObj.unit || String(reqObj.unit).trim() === "") { - valid = false; - const input = jsonContainer.querySelector(`[data-parent="${reqId}"][data-key="unit"]`); + isValid = false; + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="unit"]` + ); if (input) input.classList.add("input-error"); } - // function required only for non-HL items + // function is required for non-high-level requirements if (!isHighLevel(reqId, reqObj)) { if (!reqObj.function || String(reqObj.function).trim() === "") { - valid = false; - const input = jsonContainer.querySelector(`[data-parent="${reqId}"][data-key="function"]`); + isValid = false; + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="function"]` + ); if (input) input.classList.add("input-error"); } } } - if (!valid) { - showVscodeMessage("warning", "Please fill in all required fields. Required: id, unit, and (for non-HL) function."); + if (!isValid) { + showVscodeMessage( + "warning", + "Please fill in all required fields. Required: id, unit, and (for non-HL) function." + ); return; } - // For HL requirements, ensure function is explicitly null + // Ensure HL requirements have function set to null for (const [reqId, reqObj] of Object.entries(jsonData)) { if (isHighLevel(reqId, reqObj)) { reqObj.function = null; } } - // All OK, instruct extension to save vscode.postMessage({ command: "saveJson", data: jsonData }); }); @@ -128,35 +184,64 @@ btnCancel.addEventListener("click", () => { vscode.postMessage({ command: "cancel" }); }); -/* ------------------------- - Filters rendering - ------------------------- */ +// ------------------------------ +// Tab handling +// ------------------------------ +tabButtons.forEach((btn) => { + btn.addEventListener("click", () => { + // Reset all tabs + tabButtons.forEach((b) => b.classList.remove("active")); + + // Activate clicked tab + btn.classList.add("active"); + + currentTab = btn.dataset.tab; + renderFilters(); + renderObjects(); + }); +}); + +// ------------------------------ +// Filters rendering +// ------------------------------ function renderFilters() { filterBar.innerHTML = ""; - const sample = jsonData[Object.keys(jsonData)[0]] || {}; - const keys = Object.keys(sample); - keys.forEach(k => { + + const filteredData = getFilteredData(); + const allKeys = new Set(); + + Object.values(filteredData).forEach((obj) => { + if (typeof obj === "object" && obj !== null) { + Object.keys(obj).forEach((k) => allKeys.add(k)); + } + }); + + allKeys.forEach((k) => { const label = document.createElement("label"); - label.innerHTML = ` ${k}`; + const isChecked = collapsedKeys.has(k) ? "" : "checked"; + label.innerHTML = ` ${k}`; filterBar.appendChild(label); - label.querySelector("input").addEventListener("change", e => { + label.querySelector("input").addEventListener("change", (e) => { const key = e.target.dataset.key; - if (e.target.checked) collapsedKeys.delete(key); - else collapsedKeys.add(key); + if (e.target.checked) { + collapsedKeys.delete(key); + } else { + collapsedKeys.add(key); + } renderObjects(); }); }); } -/* ------------------------- - Main: render the requirement objects - ------------------------- */ +// ------------------------------ +// Main render function +// ------------------------------ function renderObjects() { jsonContainer.innerHTML = ""; + const filteredData = getFilteredData(); - // Defensive: if empty dataset, show hint - if (!jsonData || Object.keys(jsonData).length === 0) { + if (!filteredData || Object.keys(filteredData).length === 0) { const hint = document.createElement("div"); hint.style.opacity = "0.7"; hint.style.fontStyle = "italic"; @@ -165,223 +250,252 @@ function renderObjects() { return; } - const sample = jsonData[Object.keys(jsonData)[0]] || {}; - const keys = Object.keys(sample); + // Collect keys across all objects + const keys = new Set(); + Object.values(filteredData).forEach((obj) => { + if (typeof obj === "object" && obj !== null) { + Object.keys(obj).forEach((k) => keys.add(k)); + } + }); - // Iterate in insertion order (object) — note that renaming IDs updates jsonData - for (const [objKey, objVal] of Object.entries(jsonData)) { + Object.entries(filteredData).forEach(([objKey, objVal]) => { const div = document.createElement("div"); div.className = "json-object"; div.dataset.objKey = objKey; - // Header: show object key and HL tag if applicable const hl = isHighLevel(objKey, objVal); - const headerHtml = ` -
- ${escapeHtml(objKey)} - ${hl ? `HIGH LEVEL` : ""} -
- `; + const headerHtml = `
${escapeHtml( + objKey + )}${hl ? `HIGH LEVEL` : ""}
`; div.innerHTML = headerHtml; - // Render each field (respect collapsed keys) - keys.forEach(k => { - if (collapsedKeys.has(k)) return; + keys.forEach((k) => { + if (collapsedKeys.has(k)) { + return; + } const kv = document.createElement("div"); kv.className = "key-value"; - kv.innerHTML = `
${escapeHtml(k)}:
`; - - // ID and plain inputs (title, description, last_modified, etc.) - if (k === "id" || (k !== "unit" && k !== "function")) { - const input = document.createElement("input"); - input.dataset.parent = objKey; - input.dataset.key = k; - input.value = objVal[k] ?? ""; - - // If HL and this is id field -> read-only - if (k === "id" && hl) { - input.readOnly = true; - input.classList.add("hl-readonly"); - } - // Input listener - input.addEventListener("input", e => { - const parent = e.target.dataset.parent; - const key = e.target.dataset.key; - const value = e.target.value; - - // If it's the id field and user changed, rename the object key in jsonData - if (key === "id" && value !== parent) { - // Prevent duplicate IDs - if (jsonData[value]) { - // show error and keep old value in UI - showVscodeMessage("error", `A requirement with ID "${value}" already exists. Choose a unique ID.`); - input.classList.add("input-error"); - return; - } - - // Create new key, copy data, delete old key - jsonData[value] = { ...jsonData[parent], id: value }; - delete jsonData[parent]; - - // Update dataset.parent for all elements within this row (so further edits target new key) - div.querySelectorAll("input, select").forEach(el => { - el.dataset.parent = value; - }); - - // Update header text and dataset - const headerEl = div.querySelector(".json-object-key"); - if (headerEl) headerEl.textContent = value + (hl ? " " : ""); - div.dataset.objKey = value; - } else { - // Normal field update - jsonData[parent][key] = value; - } - - pushUndo(); - }); - - kv.appendChild(input); + // Key label + const keyLabel = document.createElement("div"); + keyLabel.style.width = "150px"; + keyLabel.textContent = escapeHtml(k) + ":"; + kv.appendChild(keyLabel); + + // Value input + if (k === "function" && hl) { + // HL requirement: function stored as null + const note = document.createElement("div"); + note.style.flex = "1"; + note.style.color = "#aaaaaa"; + note.textContent = + "(high-level requirement — function stored as null)"; + const hiddenInput = document.createElement("input"); + hiddenInput.style.display = "none"; + hiddenInput.dataset.parent = objKey; + hiddenInput.dataset.key = "function"; + + kv.appendChild(note); + kv.appendChild(hiddenInput); div.appendChild(kv); return; } - // UNIT dropdown (always shown, editable even for HL) if (k === "unit") { - const select = document.createElement("select"); - select.dataset.parent = objKey; - select.dataset.key = k; + renderUnitSelect(kv, objKey, objVal); + } else if (k === "function") { + renderFunctionSelect(kv, objKey, objVal); + } else { + renderTextInput(kv, objKey, k); + } - // Populate units - select.innerHTML = Object.keys(dropdownData).map(u => ``).join(""); - if (objVal.unit) select.value = objVal.unit; + div.appendChild(kv); + }); - select.addEventListener("change", onUnitChange); - kv.appendChild(select); - div.appendChild(kv); + jsonContainer.appendChild(div); + }); +} + +// ------------------------------ +// Render helpers for input types +// ------------------------------ +function renderTextInput(container, objKey, key) { + const input = document.createElement("input"); + input.dataset.parent = objKey; + input.dataset.key = key; + input.value = jsonData[objKey][key] ?? ""; + + input.addEventListener("input", (e) => { + const parent = e.target.dataset.parent; + const key = e.target.dataset.key; + const value = e.target.value; + + if (key === "id" && value !== parent) { + if (jsonData[value]) { + showVscodeMessage("error", `ID "${value}" already exists.`); + input.classList.add("input-error"); return; } - // FUNCTION: for HL items we do NOT render editable dropdown. - // Instead show a small readonly note indicating 'null' and that it's HL. - if (k === "function") { - if (hl) { - const note = document.createElement("div"); - note.style.flex = "1"; - note.style.alignSelf = "center"; - note.style.color = "#aaaaaa"; - note.textContent = "(high-level requirement — function stored as null)"; - // add a non-editable placeholder element with the same data-key so validation selectors still work (but invisible) - const hidden = document.createElement("input"); - hidden.style.display = "none"; - hidden.dataset.parent = objKey; - hidden.dataset.key = "function"; - // add both elements - kv.appendChild(note); - kv.appendChild(hidden); - div.appendChild(kv); - return; - } + jsonData[value] = { ...jsonData[parent], id: value }; + delete jsonData[parent]; - // Regular requirement: render function dropdown, filtered by unit if unit present - const select = document.createElement("select"); - select.dataset.parent = objKey; - select.dataset.key = k; + const inputs = container.parentElement.querySelectorAll("input, select"); + inputs.forEach((el) => (el.dataset.parent = value)); - const currentUnit = objVal.unit; - if (currentUnit && dropdownData[currentUnit]) { - select.innerHTML = dropdownData[currentUnit].map(f => ``).join(""); - } else { - select.innerHTML = Object.values(dropdownData).flat().map(f => ``).join(""); - } - - if (objVal.function) select.value = objVal.function; - select.addEventListener("change", onFunctionChange); - kv.appendChild(select); - div.appendChild(kv); - return; + const headerEl = container.parentElement.querySelector(".json-object-key"); + if (headerEl) { + headerEl.textContent = value; } - // fallback append (shouldn't reach) - div.appendChild(kv); + container.parentElement.dataset.objKey = value; + } else { + jsonData[parent][key] = value; + } + + pushUndo(); + }); + + container.appendChild(input); +} + +function renderUnitSelect(container, objKey, objVal) { + const select = document.createElement("select"); + select.dataset.parent = objKey; + select.dataset.key = "unit"; + + Object.keys(dropdownData).forEach((u) => { + const opt = document.createElement("option"); + opt.value = u; + opt.textContent = u; + select.appendChild(opt); + }); + + if (objVal.unit) { + select.value = objVal.unit; + } + + select.addEventListener("change", onUnitChange); + container.appendChild(select); +} + +function renderFunctionSelect(container, objKey, objVal) { + const select = document.createElement("select"); + select.dataset.parent = objKey; + select.dataset.key = "function"; + + const unit = objVal.unit; + if (unit && dropdownData[unit]) { + dropdownData[unit].forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + select.appendChild(opt); }); + } else { + Object.values(dropdownData) + .flat() + .forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + select.appendChild(opt); + }); + } - jsonContainer.appendChild(div); - } // end for each object + if (objVal.function) { + select.value = objVal.function; + } + + select.addEventListener("change", onFunctionChange); + container.appendChild(select); } -/* ------------------------- - UNIT / FUNCTION interdependency - ------------------------- */ +// ------------------------------ +// Filtered data based on tab +// ------------------------------ +function getFilteredData() { + if (currentTab === "normal") { + return Object.fromEntries( + Object.entries(jsonData).filter(([k, v]) => !isHighLevel(k, v)) + ); + } else { + return Object.fromEntries( + Object.entries(jsonData).filter(([k, v]) => isHighLevel(k, v)) + ); + } +} -/** Called when a unit dropdown changes in the main table */ +// ------------------------------ +// UNIT / FUNCTION interdependency +// ------------------------------ function onUnitChange(e) { const unit = e.target.value; const parent = e.target.dataset.parent; - jsonData[parent].unit = unit; - // find the function select for this parent (if any) - const functionSelect = Array.from( - jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="function"]`) - )[0]; + jsonData[parent].unit = unit; - if (!functionSelect) { - // no function select present (HL or missing), nothing to do - pushUndo(); + const funcSelect = jsonContainer.querySelector( + `select[data-parent="${parent}"][data-key="function"]` + ); + if (!funcSelect) { + pushUndo(); return; } - // Repopulate functionSelect based on chosen unit (or show all if unit empty) - functionSelect.innerHTML = ""; + funcSelect.innerHTML = ""; if (unit && dropdownData[unit]) { - dropdownData[unit].forEach(f => { - functionSelect.innerHTML += ``; + dropdownData[unit].forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + funcSelect.appendChild(opt); }); - // If previously selected function isn't in new list, clear it + if (!dropdownData[unit].includes(jsonData[parent].function)) { jsonData[parent].function = ""; - functionSelect.value = ""; + funcSelect.value = ""; } } else { - Object.values(dropdownData).flat().forEach(f => { - functionSelect.innerHTML += ``; - }); + Object.values(dropdownData) + .flat() + .forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + funcSelect.appendChild(opt); + }); } pushUndo(); } -/** Called when a function dropdown changes in the main table */ function onFunctionChange(e) { const func = e.target.value; const parent = e.target.dataset.parent; + jsonData[parent].function = func; - // If unit is empty, autocomplete it if (!jsonData[parent].unit && func) { for (const [unit, funcs] of Object.entries(dropdownData)) { if (funcs.includes(func)) { - // Set unit in model jsonData[parent].unit = unit; - // find the unit select and update it (will trigger filtering) - const unitSelect = Array.from( - jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="unit"]`) - )[0]; + const unitSelect = jsonContainer.querySelector( + `select[data-parent="${parent}"][data-key="unit"]` + ); if (unitSelect) { unitSelect.value = unit; - // trigger unit change to repopulate functions correctly - const event = new Event('change'); - unitSelect.dispatchEvent(event); + unitSelect.dispatchEvent(new Event("change")); } - // restore function selection - const funcSelect = Array.from( - jsonContainer.querySelectorAll(`select[data-parent="${parent}"][data-key="function"]`) - )[0]; - if (funcSelect) funcSelect.value = func; - + const funcSelect = jsonContainer.querySelector( + `select[data-parent="${parent}"][data-key="function"]` + ); + if (funcSelect) { + funcSelect.value = func; + } break; } } @@ -390,203 +504,236 @@ function onFunctionChange(e) { pushUndo(); } -/* ------------------------- - ADD NEW REQUIREMENT form - ------------------------- */ - +// ------------------------------ +// Add New Requirement Form +// ------------------------------ showAddFormBtn.addEventListener("click", () => { buildAddForm(); addSection.style.display = "block"; showAddFormBtn.style.display = "none"; }); +addCancelBtn.addEventListener("click", () => { + addSection.style.display = "none"; + showAddFormBtn.style.display = "block"; +}); + +addConfirmBtn.addEventListener("click", () => { + handleAddConfirm(); +}); + +// ------------------------------ +// Functions for adding new requirement +// ------------------------------ function buildAddForm() { addRows.innerHTML = ""; - // Use sample keys from existing requirements if present, - // otherwise default to common fields - const sample = jsonData[Object.keys(jsonData)[0]] || { - id: "", - title: "", - description: "", - unit: "", - function: "", - last_modified: "" - }; - const keys = Object.keys(sample); + const sample = + jsonData[Object.keys(jsonData)[0]] || { + id: "", + title: "", + description: "", + unit: "", + function: "", + last_modified: "", + }; - // For later toggling, references + const keys = Object.keys(sample); let unitSelect = null; let funcSelect = null; let idInput = null; let funcNote = null; - keys.forEach(k => { + keys.forEach((k) => { const wrapper = document.createElement("div"); wrapper.className = "key-value"; - wrapper.innerHTML = `
${escapeHtml(k)}:
`; + + const label = document.createElement("div"); + label.style.width = "120px"; + label.textContent = k + ":"; + wrapper.appendChild(label); if (k === "unit") { const select = document.createElement("select"); select.dataset.key = k; - select.innerHTML = Object.keys(dropdownData).map(u => ``).join(""); + Object.keys(dropdownData).forEach((u) => { + const opt = document.createElement("option"); + opt.value = u; + opt.textContent = u; + select.appendChild(opt); + }); wrapper.appendChild(select); unitSelect = select; } else if (k === "function") { const select = document.createElement("select"); select.dataset.key = k; - // initial: show ALL functions - select.innerHTML = Object.values(dropdownData).flat().map(f => ``).join(""); + + Object.values(dropdownData) + .flat() + .forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + select.appendChild(opt); + }); + wrapper.appendChild(select); funcSelect = select; - // place to show HL note when id indicates HL funcNote = document.createElement("div"); funcNote.style.color = "#aaaaaa"; funcNote.style.fontStyle = "italic"; funcNote.style.display = "none"; - funcNote.textContent = "(high-level requirement — function will be saved as null)"; + funcNote.textContent = + "(high-level requirement — function will be saved as null)"; wrapper.appendChild(funcNote); } else { const input = document.createElement("input"); input.dataset.key = k; - input.placeholder = (k === "id" || k === "unit" || k === "function") ? "(required)" : ""; + if (k === "id" || k === "unit" || k === "function") { + input.placeholder = "(required)"; + } wrapper.appendChild(input); - if (k === "id") idInput = input; + + if (k === "id") { + idInput = input; + } } addRows.appendChild(wrapper); }); - // If unitSelect exists, wire unit->filter functions if (unitSelect && funcSelect) { unitSelect.addEventListener("change", () => { - const unit = unitSelect.value; - funcSelect.innerHTML = ""; - if (unit && dropdownData[unit]) { - dropdownData[unit].forEach(f => funcSelect.innerHTML += ``); - } else { - Object.values(dropdownData).flat().forEach(f => funcSelect.innerHTML += ``); - } + updateFunctionOptions(unitSelect, funcSelect); + }); + funcSelect.addEventListener("change", () => { + syncUnitForFunction(unitSelect, funcSelect); }); } - // If idInput exists, detect HL pattern and toggle function visibility if (idInput && funcSelect && funcNote) { - function toggleForHL(idVal) { - const isHL = idVal && idVal.includes("_HL."); - if (isHL) { - funcSelect.style.display = "none"; - funcNote.style.display = "block"; - } else { - funcSelect.style.display = ""; - funcNote.style.display = "none"; - } - } - - // initial state - toggleForHL(idInput.value); + toggleHLFields(idInput.value, funcSelect, funcNote); - // on input change, toggle idInput.addEventListener("input", (e) => { - toggleForHL(e.target.value); + toggleHLFields(e.target.value, funcSelect, funcNote); }); } +} - // Function-first behavior in add form: if function selected and unit empty, auto fill unit - if (funcSelect && unitSelect) { - funcSelect.addEventListener("change", () => { - const func = funcSelect.value; - if (!unitSelect.value && func) { - for (const [u, funcs] of Object.entries(dropdownData)) { - if (funcs.includes(func)) { - unitSelect.value = u; - // trigger unit change event - const evt = new Event('change'); - unitSelect.dispatchEvent(evt); - // restore function selection (unit change may have reset it) - funcSelect.value = func; - break; - } - } - } +function toggleHLFields(idVal, funcSelect, funcNote) { + const isHL = idVal && idVal.includes("_HL."); + if (isHL) { + funcSelect.style.display = "none"; + funcNote.style.display = "block"; + } else { + funcSelect.style.display = ""; + funcNote.style.display = "none"; + } +} + +function updateFunctionOptions(unitSelect, funcSelect) { + funcSelect.innerHTML = ""; + + const unit = unitSelect.value; + if (unit && dropdownData[unit]) { + dropdownData[unit].forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + funcSelect.appendChild(opt); }); + } else { + Object.values(dropdownData) + .flat() + .forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + funcSelect.appendChild(opt); + }); } } -/* ------------------------- - Add-form cancel/confirm - ------------------------- */ -addCancelBtn.addEventListener("click", () => { - addSection.style.display = "none"; - showAddFormBtn.style.display = "block"; -}); +function syncUnitForFunction(unitSelect, funcSelect) { + const func = funcSelect.value; -addConfirmBtn.addEventListener("click", () => { + if (!unitSelect.value && func) { + for (const [unit, funcs] of Object.entries(dropdownData)) { + if (funcs.includes(func)) { + unitSelect.value = unit; + unitSelect.dispatchEvent(new Event("change")); + funcSelect.value = func; + break; + } + } + } +} + +function handleAddConfirm() { const newEntry = {}; - const inputs = addRows.querySelectorAll("input, select"); - inputs.forEach(inp => inp.classList.remove("input-error")); - inputs.forEach(inp => newEntry[inp.dataset.key] = inp.value.trim()); + addRows.querySelectorAll("input, select").forEach((el) => { + el.classList.remove("input-error"); + newEntry[el.dataset.key] = el.value.trim(); + }); + + // Validate fields + let isValid = true; - // Validate required fields: - let valid = true; - // id and unit always required const idField = addRows.querySelector(`[data-key="id"]`); const unitField = addRows.querySelector(`[data-key="unit"]`); const funcField = addRows.querySelector(`[data-key="function"]`); - if (!newEntry.id) { valid = false; if (idField) idField.classList.add("input-error"); } - if (!newEntry.unit) { valid = false; if (unitField) unitField.classList.add("input-error"); } + if (!newEntry.id) { + isValid = false; + if (idField) idField.classList.add("input-error"); + } + + if (!newEntry.unit) { + isValid = false; + if (unitField) unitField.classList.add("input-error"); + } - // function required only if not HL id - const isNewHL = newEntry.id && newEntry.id.includes("_HL."); - if (!isNewHL) { - if (!newEntry.function) { valid = false; if (funcField) funcField.classList.add("input-error"); } + const isHL = newEntry.id && newEntry.id.includes("_HL."); + if (!isHL && !newEntry.function) { + isValid = false; + if (funcField) funcField.classList.add("input-error"); } - if (!valid) { - showVscodeMessage("warning", "Please fill in all required fields before adding. Required: id, unit, and (for non-HL) function."); + if (!isValid) { + showVscodeMessage( + "warning", + "Please fill in all required fields before adding." + ); return; } - // Prevent duplicate ID if (jsonData[newEntry.id]) { - showVscodeMessage("error", `A requirement with ID "${newEntry.id}" already exists. Choose a unique ID.`); + showVscodeMessage( + "error", + `A requirement with ID "${newEntry.id}" already exists.` + ); if (idField) idField.classList.add("input-error"); return; } - // For HL new requirements, ensure function is null - if (isNewHL) newEntry.function = null; + if (isHL) { + newEntry.function = null; + } - // Save into model and re-render + // Add new entry and sort jsonData[newEntry.id] = newEntry; + jsonData = Object.keys(jsonData) + .sort() + .reduce((acc, key) => { + acc[key] = jsonData[key]; + return acc; + }, {}); + pushUndo(); renderObjects(); - // Hide add form and confirm to user addSection.style.display = "none"; showAddFormBtn.style.display = "block"; showVscodeMessage("info", `Requirement "${newEntry.id}" added.`); -}); - -/* ------------------------- - Small helpers - ------------------------- */ - -function escapeHtml(str) { - if (str === null || str === undefined) return ""; - return String(str) - .replace(/&/g, "&") - .replace(//g, ">"); -} -function escapeHtmlAttr(str) { - if (str === null || str === undefined) return ""; - return String(str) - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(//g, ">"); }