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", 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 4762892f..2c4f58bc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -93,6 +93,7 @@ import { rebuildEnvironment, openProjectInVcast, deleteLevel, + getRequirementsWebviewDataFromPython, } from "./vcastAdapter"; import { @@ -2389,6 +2390,304 @@ async function installPreActivationEventHandlers( return html; } + + const editRequirementsCommand = vscode.commands.registerCommand( + "vectorcastTestExplorer.editRequirements", + async (args: any) => { + try { + // 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 environmentFilePath = testNode.enviroPath; + + // Fetch dropdown data for webview + const dropdownData = + getRequirementsWebviewDataFromPython(environmentFilePath); + + // 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}` + ); + + const gatewayFolderPath = path.join( + requirementsFolderPath, + "generated_requirement_repository", + "requirements_gateway" + ); + + const requirementsJsonPath = path.join( + gatewayFolderPath, + "requirements.json" + ); + const traceabilityJsonPath = path.join( + gatewayFolderPath, + "traceability.json" + ); + + // Validate existence of target folders and files + if (!fs.existsSync(gatewayFolderPath)) { + vscode.window.showErrorMessage( + `Requirements folder not found: ${gatewayFolderPath}` + ); + return; + } + + if ( + !fs.existsSync(requirementsJsonPath) && + !fs.existsSync(traceabilityJsonPath) + ) { + vscode.window.showErrorMessage( + "Neither requirements.json nor traceability.json found in requirements_gateway." + ); + return; + } + + // 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; + } + }; + + const requirementsData = readJsonFile(requirementsJsonPath); + if (requirementsData === null) return; + + 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 {}; + 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) { + topKeys.forEach((groupKey) => { + const group = data[groupKey]; + if (group && typeof group === "object") { + Object.keys(group).forEach((id) => { + flattened[id] = group[id]; + }); + } + }); + } else { + topKeys.forEach((id) => { + const value = data[id]; + if (value && typeof value === "object") flattened[id] = value; + }); + } + + return flattened; + }; + + const flattenedRequirements = flattenRequirements(requirementsData); + const flattenedTraceability = + typeof traceabilityData === "object" ? traceabilityData : {}; + + // Merge requirements and traceability by ID + const mergedRequirements: Record = {}; + const allIds = new Set([ + ...Object.keys(flattenedRequirements), + ...Object.keys(flattenedTraceability), + ]); + + allIds.forEach((id) => { + 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 and display the webview panel + const webviewBasePath = resolveWebviewBase(context); + const panel = vscode.window.createWebviewPanel( + "editRequirements", + "Edit Requirements", + vscode.ViewColumn.Active, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [vscode.Uri.file(webviewBasePath)], + } + ); + + panel.webview.html = await getEditRequirementsWebviewContent( + context, + panel, + mergedRequirements, + dropdownData + ); + + // Handle messages from webview + panel.webview.onDidReceiveMessage( + async (message) => { + switch (message.command) { + case "saveJson": + try { + const mergedData = message.data as Record; + const requirementsOutput: Record = {}; + const traceOutput: Record = {}; + + 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", + }; + + traceOutput[id] = { + unit: obj.unit ?? "", + function: obj.function ?? "", + lines: obj.lines ?? null, + }; + } + + // Wrap flattened requirements back in original top-level group + const wrappedRequirementsOutput = { + [mainGroupKey]: requirementsOutput, + }; + + await fs.promises.writeFile( + requirementsJsonPath, + JSON.stringify(wrappedRequirementsOutput, 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(editRequirementsCommand); + + // Update getEditRequirementsWebviewContent to accept mergedJson and inject it + async function getEditRequirementsWebviewContent( + context: vscode.ExtensionContext, + panel: vscode.WebviewPanel, + mergedJson: any, + webviewDropdownData: 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 = ` + + `; + // Replace with CSP + css link + html = html.replace( + //, + `${csp}` + ); + + // Inject initialJson and the script tag (with nonce) + html = html.replace( + "{{ scriptUri }}", + ` + ` + ); + + // also replace css placeholder if present + html = html.replace( + /{{\s*cssUri\s*}}/g, + `` + ); + + 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..a5cc1df9 --- /dev/null +++ b/src/manage/webviews/css/editRequirements.css @@ -0,0 +1,128 @@ +* { 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; +} + +/* 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; + 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 container */ +.table-container { overflow: auto; flex: 1 1 auto; padding-top: 10px; } +.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; } +.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; + border: 1px solid #555; + border-radius: 4px; + padding: 6px 8px; + font-family: monospace; + font-size: 16px; + appearance: none; + cursor: pointer; +} +.key-value select::-ms-expand { display: none; } + +.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; +} +.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; } + +.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; } +#addSection input::placeholder { color: #aaaaaa; opacity: 1; } diff --git a/src/manage/webviews/html/editRequirements.html b/src/manage/webviews/html/editRequirements.html new file mode 100644 index 00000000..4d2375df --- /dev/null +++ b/src/manage/webviews/html/editRequirements.html @@ -0,0 +1,49 @@ + + + + + + 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..9825e89b --- /dev/null +++ b/src/manage/webviews/webviewScripts/editRequirements.js @@ -0,0 +1,739 @@ +// webview.js +// ------------------------------ +// Webview logic for Edit Requirements +// ------------------------------ +const vscode = acquireVsCodeApi(); + +// ------------------------------ +// 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 references +// ------------------------------ +const jsonContainer = document.getElementById("jsonContainer"); +const filterBar = document.getElementById("filterBar"); +const addSection = document.getElementById("addSection"); +const addRows = document.getElementById("addRows"); + +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"); + +// Tabs +const tabButtons = document.querySelectorAll(".tab-button"); + +// Hide "add new requirement" section initially +addSection.style.display = "none"; + +// Initial render +pushUndo(); +renderFilters(); +renderObjects(); + +// ------------------------------ +// Utility functions +// ------------------------------ + +/** + * 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 { + if (typeof objKey === "string" && objKey.includes("_HL.")) { + return true; + } + if (objVal && objVal.function === null) { + return true; + } + return false; + } catch { + return false; + } +} + +/** + * Push the current JSON state onto the undo stack. + * Uses JSON serialization for simplicity. + */ +function pushUndo() { + try { + undoStack.push(JSON.stringify(jsonData)); + } catch (err) { + console.error("Failed to push undo state:", err); + } +} + +/** + * Show a message in VS Code via postMessage. + */ +function showVscodeMessage(type, message) { + vscode.postMessage({ command: "showMessage", type, message }); +} + +/** + * 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 +// ------------------------------ +btnSave.addEventListener("click", () => { + let isValid = true; + + // 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 is required + if (!reqObj.id || String(reqObj.id).trim() === "") { + isValid = false; + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="id"]` + ); + if (input) input.classList.add("input-error"); + } + + // unit is required + if (!reqObj.unit || String(reqObj.unit).trim() === "") { + isValid = false; + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="unit"]` + ); + if (input) input.classList.add("input-error"); + } + + // function is required for non-high-level requirements + if (!isHighLevel(reqId, reqObj)) { + if (!reqObj.function || String(reqObj.function).trim() === "") { + isValid = false; + const input = jsonContainer.querySelector( + `[data-parent="${reqId}"][data-key="function"]` + ); + if (input) input.classList.add("input-error"); + } + } + } + + if (!isValid) { + showVscodeMessage( + "warning", + "Please fill in all required fields. Required: id, unit, and (for non-HL) function." + ); + return; + } + + // Ensure HL requirements have function set to null + for (const [reqId, reqObj] of Object.entries(jsonData)) { + if (isHighLevel(reqId, reqObj)) { + reqObj.function = null; + } + } + + vscode.postMessage({ command: "saveJson", data: jsonData }); +}); + +btnCancel.addEventListener("click", () => { + vscode.postMessage({ command: "cancel" }); +}); + +// ------------------------------ +// 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 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"); + const isChecked = collapsedKeys.has(k) ? "" : "checked"; + 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(); + }); + }); +} + +// ------------------------------ +// Main render function +// ------------------------------ +function renderObjects() { + jsonContainer.innerHTML = ""; + const filteredData = getFilteredData(); + + if (!filteredData || Object.keys(filteredData).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; + } + + // 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)); + } + }); + + Object.entries(filteredData).forEach(([objKey, objVal]) => { + const div = document.createElement("div"); + div.className = "json-object"; + div.dataset.objKey = objKey; + + const hl = isHighLevel(objKey, objVal); + const headerHtml = `
${escapeHtml( + objKey + )}${hl ? `HIGH LEVEL` : ""}
`; + div.innerHTML = headerHtml; + + keys.forEach((k) => { + if (collapsedKeys.has(k)) { + return; + } + + const kv = document.createElement("div"); + kv.className = "key-value"; + + // 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; + } + + if (k === "unit") { + renderUnitSelect(kv, objKey, objVal); + } else if (k === "function") { + renderFunctionSelect(kv, objKey, objVal); + } else { + renderTextInput(kv, objKey, k); + } + + 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; + } + + jsonData[value] = { ...jsonData[parent], id: value }; + delete jsonData[parent]; + + const inputs = container.parentElement.querySelectorAll("input, select"); + inputs.forEach((el) => (el.dataset.parent = value)); + + const headerEl = container.parentElement.querySelector(".json-object-key"); + if (headerEl) { + headerEl.textContent = value; + } + + 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); + }); + } + + if (objVal.function) { + select.value = objVal.function; + } + + select.addEventListener("change", onFunctionChange); + container.appendChild(select); +} + +// ------------------------------ +// 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)) + ); + } +} + +// ------------------------------ +// UNIT / FUNCTION interdependency +// ------------------------------ +function onUnitChange(e) { + const unit = e.target.value; + const parent = e.target.dataset.parent; + + jsonData[parent].unit = unit; + + const funcSelect = jsonContainer.querySelector( + `select[data-parent="${parent}"][data-key="function"]` + ); + if (!funcSelect) { + pushUndo(); + return; + } + + funcSelect.innerHTML = ""; + if (unit && dropdownData[unit]) { + dropdownData[unit].forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + funcSelect.appendChild(opt); + }); + + if (!dropdownData[unit].includes(jsonData[parent].function)) { + jsonData[parent].function = ""; + funcSelect.value = ""; + } + } else { + Object.values(dropdownData) + .flat() + .forEach((f) => { + const opt = document.createElement("option"); + opt.value = f; + opt.textContent = f; + funcSelect.appendChild(opt); + }); + } + + pushUndo(); +} + +function onFunctionChange(e) { + const func = e.target.value; + const parent = e.target.dataset.parent; + + jsonData[parent].function = func; + + if (!jsonData[parent].unit && func) { + for (const [unit, funcs] of Object.entries(dropdownData)) { + if (funcs.includes(func)) { + jsonData[parent].unit = unit; + + const unitSelect = jsonContainer.querySelector( + `select[data-parent="${parent}"][data-key="unit"]` + ); + if (unitSelect) { + unitSelect.value = unit; + unitSelect.dispatchEvent(new Event("change")); + } + + const funcSelect = jsonContainer.querySelector( + `select[data-parent="${parent}"][data-key="function"]` + ); + if (funcSelect) { + funcSelect.value = func; + } + break; + } + } + } + + pushUndo(); +} + +// ------------------------------ +// 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 = ""; + + const sample = + jsonData[Object.keys(jsonData)[0]] || { + id: "", + title: "", + description: "", + unit: "", + function: "", + last_modified: "", + }; + + const keys = Object.keys(sample); + 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 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; + 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; + + 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; + + 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; + if (k === "id" || k === "unit" || k === "function") { + input.placeholder = "(required)"; + } + wrapper.appendChild(input); + + if (k === "id") { + idInput = input; + } + } + + addRows.appendChild(wrapper); + }); + + if (unitSelect && funcSelect) { + unitSelect.addEventListener("change", () => { + updateFunctionOptions(unitSelect, funcSelect); + }); + funcSelect.addEventListener("change", () => { + syncUnitForFunction(unitSelect, funcSelect); + }); + } + + if (idInput && funcSelect && funcNote) { + toggleHLFields(idInput.value, funcSelect, funcNote); + + idInput.addEventListener("input", (e) => { + toggleHLFields(e.target.value, funcSelect, funcNote); + }); + } +} + +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); + }); + } +} + +function syncUnitForFunction(unitSelect, funcSelect) { + const func = funcSelect.value; + + 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 = {}; + addRows.querySelectorAll("input, select").forEach((el) => { + el.classList.remove("input-error"); + newEntry[el.dataset.key] = el.value.trim(); + }); + + // Validate fields + let isValid = true; + + const idField = addRows.querySelector(`[data-key="id"]`); + const unitField = addRows.querySelector(`[data-key="unit"]`); + const funcField = addRows.querySelector(`[data-key="function"]`); + + if (!newEntry.id) { + isValid = false; + if (idField) idField.classList.add("input-error"); + } + + if (!newEntry.unit) { + isValid = false; + if (unitField) unitField.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 (!isValid) { + showVscodeMessage( + "warning", + "Please fill in all required fields before adding." + ); + return; + } + + if (jsonData[newEntry.id]) { + showVscodeMessage( + "error", + `A requirement with ID "${newEntry.id}" already exists.` + ); + if (idField) idField.classList.add("input-error"); + return; + } + + if (isHL) { + newEntry.function = null; + } + + // 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(); + + addSection.style.display = "none"; + showAddFormBtn.style.display = "block"; + showVscodeMessage("info", `Requirement "${newEntry.id}" added.`); +} 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 {