diff --git a/cmd/cqlplay/main.go b/cmd/cqlplay/main.go index 306f6f6..db19ccd 100644 --- a/cmd/cqlplay/main.go +++ b/cmd/cqlplay/main.go @@ -27,6 +27,7 @@ import ( "time" "flag" + log "github.com/golang/glog" "github.com/google/cql" "github.com/google/cql/retriever/local" @@ -107,7 +108,13 @@ func handleEvalCQL(w http.ResponseWriter, req *http.Request) { return } - elm, err := cql.Parse(req.Context(), []string{evalCQLReq.CQL, fhirHelpers}, cql.ParseConfig{DataModels: [][]byte{fhirDM}}) + // Combine main CQL with additional libraries and FHIRHelpers + cqlInputs := append([]string{evalCQLReq.CQL}, evalCQLReq.Libraries...) + // TODO: now that users can supply their own libraries, we may wish to only add FHIRHelpers if + // it's not already included. Though, our parser really only works with FHIR Helpers 4.0.1. + cqlInputs = append(cqlInputs, fhirHelpers) + + elm, err := cql.Parse(req.Context(), cqlInputs, cql.ParseConfig{DataModels: [][]byte{fhirDM}}) if err != nil { sendError(w, fmt.Errorf("failed to parse: %w", err), http.StatusInternalServerError) return @@ -147,8 +154,9 @@ func sendError(w http.ResponseWriter, err error, code int) { } type evalCQLRequest struct { - CQL string `json:"cql"` - Data string `json:"data"` + CQL string `json:"cql"` + Data string `json:"data"` + Libraries []string `json:"libraries"` } func getTerminologyProvider() (*terminology.LocalFHIRProvider, error) { diff --git a/cmd/cqlplay/main_test.go b/cmd/cqlplay/main_test.go index 18b59ba..847fd39 100644 --- a/cmd/cqlplay/main_test.go +++ b/cmd/cqlplay/main_test.go @@ -16,7 +16,6 @@ package main import ( "encoding/json" - "fmt" "io" "net/http" "net/http/httptest" @@ -32,6 +31,7 @@ func TestServerHandler(t *testing.T) { name string cql string data string + libraries []string bodyJSON string wantOutput string }{ @@ -86,6 +86,38 @@ func TestServerHandler(t *testing.T) { } ]`, }, + { + name: "CQL with multiple libraries", + cql: dedent.Dedent(` + library Main version '1.0.0' + include HelperLib version '1.0.0' called Helper + define result: Helper.ConstantValue + 10`), + libraries: []string{dedent.Dedent(` + library HelperLib version '1.0.0' + define public ConstantValue: 42`)}, + wantOutput: `[ + { + "libName": "HelperLib", + "libVersion": "1.0.0", + "expressionDefinitions": { + "ConstantValue": { + "@type": "System.Integer", + "value": 42 + } + } + }, + { + "libName": "Main", + "libVersion": "1.0.0", + "expressionDefinitions": { + "result": { + "@type": "System.Integer", + "value": 52 + } + } + } + ]`, + }, } for _, tc := range tests { @@ -97,7 +129,23 @@ func TestServerHandler(t *testing.T) { server := httptest.NewServer(h) defer server.Close() - bodyJSON := fmt.Sprintf(`{"cql": %q, "data": %q}`, tc.cql, tc.data) + // Build the request body using anonymous struct + reqBody := struct { + CQL string `json:"cql"` + Data string `json:"data"` + Libraries []string `json:"libraries"` + }{ + CQL: tc.cql, + Data: tc.data, + Libraries: tc.libraries, + } + + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + t.Fatalf("json.Marshal(%v) returned an unexpected error: %v", reqBody, err) + } + bodyJSON := string(bodyBytes) + resp, err := http.Post(server.URL+"/eval_cql", "application/json", strings.NewReader(bodyJSON)) if err != nil { t.Fatalf("http.Post(%v) with body %v returned an unexpected error: %v", server.URL, bodyJSON, err) @@ -109,7 +157,7 @@ func TestServerHandler(t *testing.T) { } got := string(body) if !cmp.Equal(normalizeJSON(t, got), normalizeJSON(t, tc.wantOutput)) { - t.Errorf("POST to /eval_cql to CQL server with body %v returned %v, want %v", tc.bodyJSON, got, tc.wantOutput) + t.Errorf("POST to /eval_cql to CQL server with body %s returned %v, want %v", bodyJSON, got, tc.wantOutput) } }) } diff --git a/cmd/cqlplay/static/cqlPlay.js b/cmd/cqlplay/static/cqlPlay.js index 802898a..bceb68f 100644 --- a/cmd/cqlplay/static/cqlPlay.js +++ b/cmd/cqlplay/static/cqlPlay.js @@ -39,6 +39,9 @@ context Patient`; let data = syntheticPatient; +// Libraries array to store additional CQL libraries +let libraries = []; + let results = ''; // Helper functions: @@ -81,6 +84,16 @@ function bindButtonActions() { .addEventListener('click', function(e) { showDataTab(); }); + document.getElementById('librariesTabButton') + .addEventListener('click', function(e) { + showLibrariesTab(); + }); + document.getElementById('addLibrary') + .addEventListener('click', function(e) { + addLibrary(); + }); + document.getElementById('uploadLibrary') + .addEventListener('change', handleFileUpload); } /** @@ -98,7 +111,15 @@ function runCQL() { }; xhr.open('POST', '/eval_cql', true); xhr.setRequestHeader('Content-Type', 'text/json'); - xhr.send(JSON.stringify({'cql': code, 'data': data})); + + // Collect library content to send with request + const libraryContents = libraries.map(lib => lib.content); + + xhr.send(JSON.stringify({ + 'cql': code, + 'data': data, + 'libraries': libraryContents + })); } /** @@ -107,9 +128,11 @@ function runCQL() { function showDataTab() { document.getElementById('cqlEntry').style.display = 'none'; document.getElementById('dataEntry').style.display = 'block'; + document.getElementById('librariesEntry').style.display = 'none'; - document.getElementById('dataTabButton').className += 'active'; + document.getElementById('dataTabButton').className = 'active'; document.getElementById('cqlTabButton').className = ''; + document.getElementById('librariesTabButton').className = ''; } /** @@ -118,9 +141,134 @@ function showDataTab() { function showCQLTab() { document.getElementById('cqlEntry').style.display = 'block'; document.getElementById('dataEntry').style.display = 'none'; + document.getElementById('librariesEntry').style.display = 'none'; - document.getElementById('cqlTabButton').className += 'active'; + document.getElementById('cqlTabButton').className = 'active'; document.getElementById('dataTabButton').className = ''; + document.getElementById('librariesTabButton').className = ''; +} + +/** + * showLibrariesTab shows the Libraries tab and hides other tabs. + */ +function showLibrariesTab() { + document.getElementById('cqlEntry').style.display = 'none'; + document.getElementById('dataEntry').style.display = 'none'; + document.getElementById('librariesEntry').style.display = 'block'; + + document.getElementById('librariesTabButton').className = 'active'; + document.getElementById('cqlTabButton').className = ''; + document.getElementById('dataTabButton').className = ''; +} + +/** + * addLibrary adds a new library to the libraries list and updates the UI. + */ +function addLibrary(name = '', content = '') { + const libraryId = Date.now(); // Unique ID for the library + + // Add to libraries array + libraries.push({ + id: libraryId, + name: name, + content: content + }); + + // Update the UI + renderLibraries(); + + // Save to localStorage + saveLibrariesToLocalStorage(); +} + +/** + * removeLibrary removes a library from the libraries list and updates the UI. + */ +function removeLibrary(libraryId) { + libraries = libraries.filter(lib => lib.id !== libraryId); + renderLibraries(); + saveLibrariesToLocalStorage(); +} + +/** + * renderLibraries updates the libraries UI with the current libraries. + */ +function renderLibraries() { + const container = document.getElementById('librariesContainer'); + container.innerHTML = ''; + + libraries.forEach(library => { + const libraryContainer = document.createElement('div'); + libraryContainer.className = 'libraryContainer'; + + const headerDiv = document.createElement('div'); + headerDiv.className = 'libraryHeader'; + + // Create label for the library name input + const nameLabel = document.createElement('div'); + nameLabel.className = 'libraryNameLabel'; + + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.value = library.name; + nameInput.placeholder = 'Library Name'; + nameInput.className = 'libraryNameInput'; + nameInput.oninput = function(e) { + library.name = e.target.value; + saveLibrariesToLocalStorage(); + }; + + nameLabel.appendChild(document.createTextNode('Library Name:')); + nameLabel.appendChild(nameInput); + + const removeButton = document.createElement('button'); + removeButton.className = 'removeLibraryButton'; + removeButton.textContent = 'Remove'; + removeButton.onclick = function() { + removeLibrary(library.id); + }; + + headerDiv.appendChild(nameLabel); + headerDiv.appendChild(removeButton); + + const editorDiv = document.createElement('div'); + editorDiv.className = 'codeInputContainer'; + + const codeInput = document.createElement('code-input'); + codeInput.setAttribute('lang', 'cql'); + codeInput.setAttribute('placeholder', 'Type CQL Library Here'); + codeInput.className = 'codeInput'; + codeInput.value = library.content; + codeInput.onchange = function(e) { + library.content = e.target.value; + saveLibrariesToLocalStorage(); + }; + + editorDiv.appendChild(codeInput); + + libraryContainer.appendChild(headerDiv); + libraryContainer.appendChild(editorDiv); + + container.appendChild(libraryContainer); + }); +} + +/** + * saveLibrariesToLocalStorage saves the libraries to localStorage. + */ +function saveLibrariesToLocalStorage() { + localStorage.setItem('cqlLibraries', JSON.stringify(libraries)); +} + +/** + * loadLibrariesFromLocalStorage loads the libraries from localStorage. + */ +function loadLibrariesFromLocalStorage() { + const storedLibraries = localStorage.getItem('cqlLibraries'); + if (storedLibraries) { + libraries = JSON.parse(storedLibraries); + renderLibraries(); + } } /** @@ -150,6 +298,33 @@ function setupPrism() { 'syntax-highlighted', codeInput.templates.prism(Prism, [])); } +/** + * handleFileUpload processes uploaded CQL library files + */ +function handleFileUpload(event) { + const fileList = event.target.files; + if (fileList.length === 0) { + return; // No file selected + } + + const file = fileList[0]; + const reader = new FileReader(); + + reader.onload = function(e) { + const content = e.target.result; + // Extract library name from filename (remove .cql extension) + const fileName = file.name.replace(/\.cql$/i, ''); + + // Add the library with the file content + addLibrary(fileName, content); + }; + + reader.readAsText(file); + + // Reset the file input so the same file can be selected again + event.target.value = ''; +} + /** * main is the entrypoint for the script. */ @@ -159,8 +334,15 @@ function main() { bindInputsOnChange(); bindButtonActions(); - // Initially hide dataEntry tab: + // Load libraries from localStorage + loadLibrariesFromLocalStorage(); + + // Initially hide non-CQL tabs document.getElementById('dataEntry').style.display = 'none'; + document.getElementById('librariesEntry').style.display = 'none'; + + // Set CQL tab as active + document.getElementById('cqlTabButton').className = 'active'; } main(); // All code actually executed when the script is loaded by the HTML. \ No newline at end of file diff --git a/cmd/cqlplay/static/index.html b/cmd/cqlplay/static/index.html index ddd8a8a..ef91255 100644 --- a/cmd/cqlplay/static/index.html +++ b/cmd/cqlplay/static/index.html @@ -43,6 +43,7 @@

⚡ CQL Playground

+
@@ -57,6 +58,23 @@

Data Editor

+
+

CQL Libraries

+

+ Add additional CQL libraries to be included in your CQL execution. + Libraries can be created directly or uploaded from files. + Each library should have a name and contain valid CQL code.
+ Our parser only supports FHIRHelpers 4.0.1, it is automatically included in all execution--you do not need to add it here. +

+
+ +
+
+ + + +
+
diff --git a/cmd/cqlplay/static/styles.css b/cmd/cqlplay/static/styles.css index ae7d88d..2e3e4f6 100644 --- a/cmd/cqlplay/static/styles.css +++ b/cmd/cqlplay/static/styles.css @@ -91,4 +91,83 @@ code-input { .submitButton { margin: 10px; + font-family: Roboto, Helvetica; + font-size: 1em; + font-weight: normal; +} + +.addButton { + margin: 10px; + background-color: #4CAF50; + color: white; + border: none; + padding: 8px 16px; + cursor: pointer; + font-family: Roboto, Helvetica; + font-size: 1em; + font-weight: normal; +} + +.uploadButton { + margin: 10px; + background-color: #2196F3; + color: white; + border: none; + padding: 8px 16px; + cursor: pointer; + display: inline-block; + font-family: Roboto, Helvetica; + font-size: 1em; + font-weight: normal; +} + +.libraryButtons { + display: flex; + flex-direction: row; +} + +.libraryContainer { + margin-bottom: 20px; + border: 1px solid #ddd; + padding: 10px; + background-color: #f9f9f9; +} + +.libraryHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.libraryNameLabel { + display: flex; + align-items: center; + font-family: Roboto, Helvetica; + font-weight: bold; +} + +.libraryNameInput { + padding: 5px; + width: 300px; + margin-left: 10px; +} + +.removeLibraryButton { + background-color: #f44336; + color: white; + border: none; + padding: 5px 10px; + cursor: pointer; + font-family: Roboto, Helvetica; + font-size: 1em; + font-weight: normal; +} + +.instructions { + font-family: Roboto, Helvetica; + font-size: 14px; + color: #555; + margin-bottom: 15px; + line-height: 1.4; } \ No newline at end of file