Mostly working state
This commit is contained in:
@@ -1,5 +1,18 @@
|
||||
require 'yaml'
|
||||
|
||||
|
||||
## Debugging:
|
||||
# If the response should be filtered and it is not being filtered **and** you
|
||||
# are geting all the values for that column, check to make sure that you are
|
||||
# selecting from the allowed tables and columns.
|
||||
|
||||
|
||||
ALLOWED_TABLES = {
|
||||
"JointInfo" => ["Joint", "Osteokinematics"],
|
||||
"Joint" => ["Common Name", "Joint"]
|
||||
}
|
||||
|
||||
|
||||
class DashboardController < ApplicationController
|
||||
before_action :load_manifest
|
||||
|
||||
@@ -13,14 +26,28 @@ class DashboardController < ApplicationController
|
||||
|
||||
# GET /dashboard/options?table=Joint&filter_col=joint_id&value=1
|
||||
def options
|
||||
table = params[:table].constantize
|
||||
if params[:filter_col].present?
|
||||
options = table.where(params[:filter_col] => params[:value]).pluck(params[:pluck_column])
|
||||
# 1. Define what is allowed to be accessed
|
||||
|
||||
table_name = params[:table]
|
||||
col_name = params[:pluck_column]
|
||||
|
||||
# 2. Validate that the table and column exist in your allowlist
|
||||
if ALLOWED_TABLES.key?(table_name) && ALLOWED_TABLES[table_name].include?(col_name)
|
||||
table = table_name.constantize
|
||||
quoted_col = ActiveRecord::Base.connection.quote_column_name(col_name)
|
||||
|
||||
if params[:filter_column].present? && ALLOWED_TABLES[table_name].include?(params[:filter_column])
|
||||
puts "Filtering with 'filter_column'"
|
||||
options = table.select(Arel.sql(quoted_col)).where(params[:filter_column] => params[:value]).pluck(Arel.sql(quoted_col))
|
||||
else
|
||||
puts "***NO FILTERING***"
|
||||
options = table.distinct.pluck(Arel.sql(quoted_col))
|
||||
end
|
||||
|
||||
render json: options
|
||||
else
|
||||
puts table
|
||||
options = table.distinct.pluck(params[:pluck_column])
|
||||
render json: { error: "Invalid table or column requested" }, status: :bad_request
|
||||
end
|
||||
render json: options
|
||||
end
|
||||
|
||||
# GET /dashboard/data?joint=Acromioclavicular&sub_joint=X
|
||||
@@ -29,10 +56,11 @@ class DashboardController < ApplicationController
|
||||
|
||||
# Use the data_sources from YAML to aggregate data
|
||||
@manifest['data_sources'].each do |source|
|
||||
debugger
|
||||
table = source['table'].constantize
|
||||
# Find the filter value from the params that matches the filter_column
|
||||
# We assume the filter_column name in DB matches the filter ID in YAML
|
||||
filter_val = params[source['filter_column']] || params['joint'] # Fallback to joint
|
||||
filter_val = params[source['filter_column']]
|
||||
|
||||
record = table.find_by(source['filter_column'] => filter_val)
|
||||
results[source['key']] = record ? record.attributes : {}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class JointInfo < ApplicationRecord
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -1,5 +0,0 @@
|
||||
class JointsInfo < ApplicationRecord
|
||||
def readonly?
|
||||
true
|
||||
end
|
||||
end
|
||||
@@ -18,180 +18,6 @@
|
||||
.bg-pale-orange { background-color: #FCE4D6; }
|
||||
</style>
|
||||
|
||||
<!-- <script> -->
|
||||
<!-- document.addEventListener("DOMContentLoaded", () => { -->
|
||||
<!-- let manifest = {}; -->
|
||||
<!-- let currentSelections = {}; -->
|
||||
<!-- -->
|
||||
<!-- async function init() { -->
|
||||
<!-- const resp = await fetch('/dashboard'); -->
|
||||
<!-- manifest = await resp.json(); -->
|
||||
<!-- renderRootFilters(); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- function renderRootFilters() { -->
|
||||
<!-- const rootFilters = manifest.filters.filter(f => f.depends_on === null); -->
|
||||
<!-- rootFilters.forEach(f => createDropdown(f)); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- function createDropdown(config) { -->
|
||||
<!-- const container = document.createElement("div"); -->
|
||||
<!-- container.id = `container_${config.id}`; -->
|
||||
<!-- -->
|
||||
<!-- const label = document.createElement("label"); -->
|
||||
<!-- label.textContent = config.label + ": "; -->
|
||||
<!-- label.style.fontWeight = "bold"; -->
|
||||
<!-- -->
|
||||
<!-- const select = document.createElement("select"); -->
|
||||
<!-- select.id = config.id; -->
|
||||
<!-- select.innerHTML = `<option value="">Select...</option>`; -->
|
||||
<!-- -->
|
||||
<!-- select.addEventListener("change", async (e) => { -->
|
||||
<!-- const val = e.target.value; -->
|
||||
<!-- currentSelections[config.id] = val; -->
|
||||
<!-- -->
|
||||
<!-- // 1. Clear dependent filters and data -->
|
||||
<!-- clearDependents(config.id); -->
|
||||
<!-- -->
|
||||
<!-- // 2. Load options for children -->
|
||||
<!-- const children = manifest.filters.filter(f => f.depends_on === config.id); -->
|
||||
<!-- children.forEach(child => { -->
|
||||
<!-- const options = await fetchOptions(child, val); -->
|
||||
<!-- populateDropdown(child.id, options); -->
|
||||
<!-- }); -->
|
||||
<!-- -->
|
||||
<!-- // 3. Update data grid -->
|
||||
<!-- updateGrid(); -->
|
||||
<!-- }); -->
|
||||
<!-- -->
|
||||
<!-- container.appendChild(label); -->
|
||||
<!-- container.appendChild(select); -->
|
||||
<!-- document.getElementById("filter-area").appendChild(container); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- async function fetchOptions(config, value) { -->
|
||||
<!-- const params = new URLSearchParams({ -->
|
||||
<!-- table: config.table, -->
|
||||
<!-- pluck_column: config.pluck_column, -->
|
||||
<!-- filter_col: config.filter_column, -->
|
||||
<!-- value: value -->
|
||||
<!-- }); -->
|
||||
<!-- const resp = await fetch(`/dashboard/options?${params.toString()}`); -->
|
||||
<!-- return await resp.json(); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- function populateDropdown(id, options) { -->
|
||||
<!-- const select = document.getElementById(id) || createMissingDropdown(id); -->
|
||||
<!-- select.innerHTML = `<option value="">Select...</option>`; -->
|
||||
<!-- options.forEach(opt => { -->
|
||||
<!-- select.innerHTML += `<option value="${opt}">${opt}</option>`; -->
|
||||
<!-- }); -->
|
||||
<!-- // If this is the last dropdown, it should be visible now -->
|
||||
<!-- const container = document.getElementById(`container_${id}`); -->
|
||||
<!-- if(container) container.style.display = "block"; -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- function createMissingDropdown(id) { -->
|
||||
<!-- const config = manifest.filters.find(f => f.id === id); -->
|
||||
<!-- const container = document.createElement("div"); -->
|
||||
<!-- container.id = `container_${id}`; -->
|
||||
<!-- container.style.display = "none"; // Hidden until parent is selected -->
|
||||
<!-- -->
|
||||
<!-- const label = document.createElement("label"); -->
|
||||
<!-- label.textContent = config.label + ": "; -->
|
||||
<!-- -->
|
||||
<!-- const select = document.createElement("select"); -->
|
||||
<!-- select.id = id; -->
|
||||
<!-- -->
|
||||
<!-- select.addEventListener("change", async (e) => { -->
|
||||
<!-- currentSelections[id] = e.target.value; -->
|
||||
<!-- clearDependents(id); -->
|
||||
<!-- const children = manifest.filters.filter(f => f.depends_on === id); -->
|
||||
<!-- children.forEach(child => { -->
|
||||
<!-- const opts = await fetchOptions(child, e.target.value); -->
|
||||
<!-- populateDropdown(child.id, opts); -->
|
||||
<!-- }); -->
|
||||
<!-- updateGrid(); -->
|
||||
<!-- }); -->
|
||||
<!-- -->
|
||||
<!-- container.appendChild(label); -->
|
||||
<!-- container.appendChild(select); -->
|
||||
<!-- document.getElementById("filter-area").appendChild(container); -->
|
||||
<!-- return select; -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- function clearDependents(id) { -->
|
||||
<!-- // Recursively find all filters that depend on this ID and reset them -->
|
||||
<!-- manifest.filters.forEach(f => { -->
|
||||
<!-- if (f.depends_on === id) { -->
|
||||
<!-- const select = document.getElementById(f.id); -->
|
||||
<!-- if (select) select.innerHTML = `<option value="">Select...</option>`; -->
|
||||
<!-- const container = document.getElementById(`container_${f.id}`); -->
|
||||
<!-- if (container) container.style.display = "none"; -->
|
||||
<!-- delete currentSelections[f.id]; -->
|
||||
<!-- clearDependents(f.id); -->
|
||||
<!-- } -->
|
||||
<!-- }); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- async function updateGrid() { -->
|
||||
<!-- const params = new URLSearchParams(currentSelections); -->
|
||||
<!-- const resp = await fetch(`/dashboard/data?${params.toString()}`); -->
|
||||
<!-- const data = await resp.json(); -->
|
||||
<!-- renderLayout(data); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- function renderLayout(data) { -->
|
||||
<!-- const area = document.getElementById("results-area"); -->
|
||||
<!-- area.innerHTML = ""; -->
|
||||
<!-- -->
|
||||
<!-- // Group layout by grid name (defaults to 'main') -->
|
||||
<!-- const grids = {}; -->
|
||||
<!-- manifest.layout.forEach(item => { -->
|
||||
<!-- const gridId = item.grid || 'main'; -->
|
||||
<!-- if (!grids[gridId]) grids[gridId] = []; -->
|
||||
<!-- grids[gridId].push(item); -->
|
||||
<!-- }); -->
|
||||
<!-- -->
|
||||
<!-- Object.keys(grids).forEach(gridId => { -->
|
||||
<!-- const table = document.createElement("table"); -->
|
||||
<!-- table.className = "dyn-grid"; -->
|
||||
<!-- -->
|
||||
<!-- // Find max rows/cols to initialize table structure if needed, -->
|
||||
<!-- // but we can just build rows dynamically. -->
|
||||
<!-- const layoutItems = grids[gridId]; -->
|
||||
<!-- const maxRow = Math.max(...layoutItems.map(i => i.row)); -->
|
||||
<!-- -->
|
||||
<!-- for (let r = 0; r <= maxRow; r++) { -->
|
||||
<!-- const tr = document.createElement("tr"); -->
|
||||
<!-- const rowItems = layoutItems.filter(i => i.row === r); -->
|
||||
<!-- -->
|
||||
<!-- rowItems.forEach(item => { -->
|
||||
<!-- const td = document.createElement("td"); -->
|
||||
<!-- td.className = item.class; -->
|
||||
<!-- td.colSpan = item.colspan || 1; -->
|
||||
<!-- td.rowSpan = item.rowspan || 1; -->
|
||||
<!-- -->
|
||||
<!-- if (item.text) { -->
|
||||
<!-- td.textContent = item.text; -->
|
||||
<!-- } else if (item.value) { -->
|
||||
<!-- // Resolve nested JSON path (e.g., "main_info.joint") -->
|
||||
<!-- const parts = item.value.split('.'); -->
|
||||
<!-- const val = parts.reduce((obj, key) => (obj && obj[key] !== undefined) ? obj[key] : '', data); -->
|
||||
<!-- td.textContent = val; -->
|
||||
<!-- } -->
|
||||
<!-- tr.appendChild(td); -->
|
||||
<!-- }); -->
|
||||
<!-- table.appendChild(tr); -->
|
||||
<!-- } -->
|
||||
<!-- area.appendChild(table); -->
|
||||
<!-- }); -->
|
||||
<!-- } -->
|
||||
<!-- -->
|
||||
<!-- init(); -->
|
||||
<!-- }); -->
|
||||
<!-- </script> -->
|
||||
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -261,7 +87,8 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
async function fetchOptions(config, value) {
|
||||
var params
|
||||
if (config.filter_col == undefined) {
|
||||
if (config.filter_column == undefined) {
|
||||
console.log(config + "Undefined filter col");
|
||||
params = new URLSearchParams({
|
||||
table: config.table,
|
||||
pluck_column: config.pluck_column,
|
||||
@@ -270,7 +97,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
params = new URLSearchParams({
|
||||
table: config.table,
|
||||
pluck_column: config.pluck_column,
|
||||
filter_col: config.filter_column,
|
||||
filter_column: config.filter_column,
|
||||
value: value
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,36 +5,44 @@ filters:
|
||||
table: "Joint"
|
||||
pluck_column: "Joint"
|
||||
depends_on: null # Root level
|
||||
# - id: "joint_info"
|
||||
# label: "joint_info"
|
||||
# table: "joint_info" # A separate table in SQLite
|
||||
# pluck_column: "Common Joint Name"
|
||||
# depends_on: "joint" # Only appears after 'joint' is selected
|
||||
# filter_column: "Common Joint Name" # Filter SubJoint where joint_id == selected joint value
|
||||
- id: "joint_info"
|
||||
label: "Select kinematics"
|
||||
table: "JointInfo" # A separate table in SQLite
|
||||
pluck_column: "Osteokinematics"
|
||||
depends_on: "joint" # Only appears after 'joint' is selected
|
||||
filter_column: "Joint" # Filter SubJoint where joint_id == selected joint value
|
||||
|
||||
# Data Mapping: Defines which tables to hit for the final grid
|
||||
data_sources:
|
||||
- table: "Joint"
|
||||
key: "main_info" # Nested JSON key
|
||||
filter_column: "joint"
|
||||
# - table: "Ligament"
|
||||
# key: "lig_info"
|
||||
# filter_column: "joint_id"
|
||||
- table: "JointInfo" # MUST MATCH DATABASE TABLE
|
||||
key: "joint_details"
|
||||
filter_column: "Osteokinematics"
|
||||
|
||||
# Layout: Defines the visual grid
|
||||
# colors: dark_blue, light_blue, pale_yellow, pale_green, pale_orange
|
||||
layout:
|
||||
- { row: 0, col: 0, colspan: 4, rowspan: 1, text: "Joint Information", class: "bg-dark-blue" }
|
||||
- { row: 1, col: 0, colspan: 1, rowspan: 1, text: "Anatomical Name", class: "bg-light-blue" }
|
||||
- { row: 1, col: 1, colspan: 1, rowspan: 1, value: "main_info.joint", class: "bg-pale-yellow" }
|
||||
- { row: 0, col: 0, colspan: 3, rowspan: 1, text: "Joint Information", class: "bg-dark-blue" }
|
||||
- { row: 1, col: 0, colspan: 2, rowspan: 1, text: "Anatomical Name", class: "bg-light-blue" }
|
||||
- { row: 2, col: 0, colspan: 2, rowspan: 1, value: "main_info.Joint", class: "bg-pale-yellow" }
|
||||
- { row: 1, col: 2, colspan: 1, rowspan: 1, text: "Common Name", class: "bg-light-blue" }
|
||||
- { row: 1, col: 3, colspan: 1, rowspan: 1, value: "main_info.common_name", class: "bg-pale-yellow" }
|
||||
- { row: 2, col: 0, colspan: 1, rowspan: 1, text: "Joint Type", class: "bg-light-blue" }
|
||||
- { row: 2, col: 1, colspan: 1, rowspan: 1, value: "main_info.joint_type", class: "bg-pale-yellow" }
|
||||
- { row: 2, col: 2, colspan: 1, rowspan: 1, text: "Planes", class: "bg-light-blue" }
|
||||
- { row: 2, col: 3, colspan: 1, rowspan: 1, value: "main_info.planes", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 0, colspan: 4, rowspan: 1, value: "main_info.notes", class: "bg-pale-green" }
|
||||
- { row: 2, col: 2, colspan: 1, rowspan: 1, value: "main_info.Common Name", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 0, colspan: 1, rowspan: 1, text: "Diarthrodial Joint Type", class: "bg-light-blue" }
|
||||
- { row: 4, col: 0, colspan: 1, rowspan: 1, value: "main_info.Diarthrodial joint", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 1, colspan: 1, rowspan: 1, text: "Alternate Name", class: "bg-light-blue" }
|
||||
- { row: 4, col: 1, colspan: 1, rowspan: 1, value: "main_info.Alternate Name", class: "bg-pale-yellow" }
|
||||
- { row: 3, col: 2, colspan: 1, rowspan: 1, text: "Planes of Freedom", class: "bg-light-blue" }
|
||||
- { row: 4, col: 2, colspan: 1, rowspan: 1, value: "main_info.# of Plains", class: "bg-pale-yellow" }
|
||||
- { row: 5, col: 0, colspan: 1, rowspan: 1, text: "Arthrokinematic Rule", class: "bg-light-blue"}
|
||||
- { row: 6, col: 0, colspan: 1, rowspan: 1, value: "joint_details.Arthrokinematic Rule", class: "bg-pale-yellow"}
|
||||
#- { row: 5, col: 0, colspan: 4, rowspan: 1, value: "main_info.notes", class: "bg-pale-green" }
|
||||
|
||||
# Ligaments Table (separate grid)
|
||||
#- { grid: "ligaments", row: 0, col: 0, colspan: 1, rowspan: 1, text: "Ligaments", class: "bg-dark-blue" }
|
||||
#- { grid: "ligaments", row: 0, col: 1, colspan: 1, rowspan: 1, value: ".list", class: "bg-pale-orange" }
|
||||
|
||||
|
||||
# http://localhost:3000/dashboard/options?table=JointInfo&pluck_column=Osteokinematics&filter_col=undefined&value=Glenohumeral
|
||||
# http://localhost:3000/dashboard/options?table=JointInfo&pluck_column=Osteokinematics&filter_col=Common%20Joint%20Name&value=Shoulder
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
require "test_helper"
|
||||
|
||||
class DashboardControllerTest < ActionDispatch::IntegrationTest
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
test "options returns JSON" do
|
||||
get "/dashboard/options",
|
||||
params: {
|
||||
table: 'Joint',
|
||||
pluck_column: 'Joint'
|
||||
}
|
||||
|
||||
assert_response :success
|
||||
assert_equal "application/json", response.media_type
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user