diff --git a/.gitignore b/.gitignore index 1e2d268f9..aabdcf151 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ # ingore rvm gemset files .ruby-gemset +# local docker compose overrides +docker-compose.override.yml + diff --git a/app/assets/javascripts/caesar_rule_builder.js b/app/assets/javascripts/caesar_rule_builder.js new file mode 100644 index 000000000..cbef436eb --- /dev/null +++ b/app/assets/javascripts/caesar_rule_builder.js @@ -0,0 +1,227 @@ +// Caesar Rule Builder — embedded version for Rails asset pipeline +// Adapted from https://github.com/zooniverse/caesar-rules-ui +// Zero dependencies beyond the DOM. Renders into a mount point, syncs JSON to a hidden field. + +(function(window) { + 'use strict'; + + var CONNECTIVES = [ + { type: 'and', type2: 'conjunction', js: '&&' }, + { type: 'or', type2: 'conjunction', js: '||' }, + { type: 'lt', type2: 'comparator', js: '<' }, + { type: 'gt', type2: 'comparator', js: '>' }, + { type: 'lte', type2: 'comparator', js: '<=' }, + { type: 'gte', type2: 'comparator', js: '>=' }, + { type: 'eq', type2: 'comparator', js: '==' } + ]; + + var DROPDOWN_ITEMS = CONNECTIVES.filter(function(c) { + return c.type2 === 'comparator' || c.type2 === 'conjunction'; + }); + + // --- DOM helpers --- + + function el(tag, attrs) { + var e = document.createElement(tag); + var children = Array.prototype.slice.call(arguments, 2); + if (typeof attrs === 'string') { e.textContent = attrs; return e; } + if (attrs) { + var keys = Object.keys(attrs); + for (var i = 0; i < keys.length; i++) { + var k = keys[i], v = attrs[k]; + if (k === 'class') e.className = v; + else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); + else e.setAttribute(k, v); + } + } + for (var j = 0; j < children.length; j++) { + var c = children[j]; + if (typeof c === 'string') e.appendChild(document.createTextNode(c)); + else if (c) e.appendChild(c); + } + return e; + } + + function btn(text, onclick) { + return el('span', { class: 'crb-btn', onclick: onclick }, text); + } + + // --- Builder state (per instance) --- + + function createBuilder(container, opts) { + opts = opts || {}; + var rules = opts.initialRules || []; + var onChange = opts.onChange || function() {}; + + function fireChange() { + onChange(rules); + } + + function render() { + container.innerHTML = ''; + container.appendChild(renderRules()); + container.appendChild(renderOutput()); + fireChange(); + } + + function renderOutput() { + var div = el('div', { class: 'crb-output' }); + div.appendChild(el('strong', 'JSON Output:')); + div.appendChild(el('pre', { class: 'crb-json' }, JSON.stringify(rules, null, 2))); + return div; + } + + function renderOutputOnly() { + var pre = container.querySelector('.crb-json'); + if (pre) pre.textContent = JSON.stringify(rules, null, 2); + fireChange(); + } + + function renderRules() { + var div = el('div', { class: 'crb-rules' }); + for (var i = 0; i < rules.length; i++) { + div.appendChild(renderRule(rules[i], rules, i)); + } + div.appendChild(btn('+ Add Rule', function() { rules.push([]); render(); })); + return div; + } + + function renderRule(rule, parent, parentIndex) { + var connective = null; + for (var i = 0; i < CONNECTIVES.length; i++) { + if (CONNECTIVES[i].type === rule[0]) { connective = CONNECTIVES[i]; break; } + } + + var div = el('div', { class: 'crb-rule' }); + + // Header row: condition dropdown + delete + var row = el('div', { class: 'crb-row' }); + row.appendChild(renderCondition(connective, rule)); + row.appendChild(btn('Delete', function() { parent.splice(parentIndex, 1); render(); })); + div.appendChild(row); + + // Body based on connective type + if (connective && connective.type2 === 'conjunction') { + div.appendChild(renderConjunction(rule)); + } else if (connective && connective.type2 === 'comparator') { + div.appendChild(renderComparator(connective, rule)); + } + + return div; + } + + function renderCondition(connective, rule) { + var select = el('select', { + class: 'form-control crb-select', + onchange: function(e) { + var val = e.target.value; + var newConn = null; + for (var i = 0; i < CONNECTIVES.length; i++) { + if (CONNECTIVES[i].type === val) { newConn = CONNECTIVES[i]; break; } + } + var oldType2 = connective ? connective.type2 : null; + var newType2 = newConn ? newConn.type2 : null; + if (oldType2 !== newType2) { + rule.length = 0; + rule.push(val); + } else { + rule[0] = val; + } + render(); + } + }); + + select.appendChild(el('option', { value: '' }, 'Select Condition')); + for (var i = 0; i < DROPDOWN_ITEMS.length; i++) { + var item = DROPDOWN_ITEMS[i]; + var opt = el('option', { value: item.type }, item.js + ' (' + item.type + ')'); + if (item.type === rule[0]) opt.selected = true; + select.appendChild(opt); + } + return select; + } + + function renderComparator(connective, rule) { + var values = rule.slice(1); + var div = el('div', { class: 'crb-comparator' }); + div.appendChild(el('p', { class: 'crb-label' }, 'Value is ' + connective.js)); + + for (var i = 0; i < values.length; i++) { + if (values[i][0] === 'lookup') { + div.appendChild(renderLookup(values[i], rule, i)); + } else { + div.appendChild(renderConst(values[i], rule, i)); + } + } + + var addSelect = el('select', { + class: 'form-control crb-select crb-add-value', + onchange: function(e) { + if (!e.target.value) return; + rule.push([e.target.value]); + render(); + } + }); + addSelect.appendChild(el('option', { value: '' }, 'Add Value...')); + addSelect.appendChild(el('option', { value: 'lookup' }, 'lookup')); + addSelect.appendChild(el('option', { value: 'const' }, 'const')); + div.appendChild(addSelect); + return div; + } + + function renderConst(value, rule, valueIndex) { + var div = el('div', { class: 'crb-value' }); + div.appendChild(el('span', { class: 'label label-info' }, 'const')); + div.appendChild(el('input', { + type: 'text', class: 'form-control crb-input', + placeholder: 'value', value: value[1] || '', + oninput: function(e) { value[1] = e.target.value; renderOutputOnly(); } + })); + div.appendChild(btn('Remove', function() { rule.splice(valueIndex + 1, 1); render(); })); + return div; + } + + function renderLookup(value, rule, valueIndex) { + var div = el('div', { class: 'crb-value' }); + div.appendChild(el('span', { class: 'label label-primary' }, 'lookup')); + div.appendChild(el('input', { + type: 'text', class: 'form-control crb-input', + placeholder: 'lookup.variable.path', value: value[1] || '', + oninput: function(e) { value[1] = e.target.value; renderOutputOnly(); } + })); + div.appendChild(el('input', { + type: 'text', class: 'form-control crb-input', + placeholder: 'default value', value: value[2] || '', + oninput: function(e) { value[2] = e.target.value; renderOutputOnly(); } + })); + div.appendChild(btn('Remove', function() { rule.splice(valueIndex + 1, 1); render(); })); + return div; + } + + function renderConjunction(rule) { + var childRules = rule.slice(1); + var div = el('div', { class: 'crb-conjunction' }); + for (var i = 0; i < childRules.length; i++) { + div.appendChild(renderRule(childRules[i], rule, i + 1)); + } + div.appendChild(btn('+ Add Sub-Rule', function() { rule.push([]); render(); })); + return div; + } + + // Initial render + render(); + } + + // --- Public API --- + + window.CaesarRuleBuilder = { + mount: function(container, opts) { + if (!container) { + console.error('CaesarRuleBuilder.mount: container element not found'); + return; + } + createBuilder(container, opts); + } + }; + +})(window); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index af6dcaf5a..371b436f2 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -167,4 +167,108 @@ h4.modal-title { .rules-pane td { padding-top: 1em !important; padding-bottom: 1em !important; +} + +// Caesar Rule Builder (embedded from caesar-rules-ui) +.crb-container { + margin-top: 10px; +} + +.crb-rules { + background: lighten($zooniverse-light-teal, 20%); + border: 1px solid $zooniverse-light-teal; + border-radius: 4px; + padding: 15px; +} + +.crb-rule { + background: $white; + border: 1px solid $zooniverse-mid-grey; + border-radius: 4px; + margin-bottom: 10px; + padding: 12px; +} + +.crb-row { + align-items: center; + display: flex; + gap: 8px; +} + +.crb-select { + display: inline-block; + max-width: 250px; + width: auto; +} + +.crb-add-value { + margin-top: 8px; + max-width: 200px; +} + +.crb-comparator { + background: $zooniverse-light-grey; + border: 1px solid lighten($zooniverse-mid-grey, 15%); + border-radius: 4px; + margin-top: 8px; + padding: 10px; +} + +.crb-conjunction { + border-left: 3px solid $zooniverse-teal; + margin-left: 20px; + margin-top: 8px; + padding: 10px; +} + +.crb-value { + align-items: center; + display: flex; + gap: 8px; + padding: 6px 0; + + .label { + min-width: 55px; + text-align: center; + } +} + +.crb-input { + display: inline-block; + max-width: 200px; + width: auto; +} + +.crb-label { + color: $zooniverse-dark-teal; + font-weight: bold; + margin-bottom: 6px; +} + +.crb-btn { + background: $zooniverse-teal; + border-radius: 3px; + color: $white; + cursor: pointer; + display: inline-block; + font-size: 13px; + padding: 4px 10px; + white-space: nowrap; + + &:hover { + background: $zooniverse-dark-teal; + } +} + +.crb-output { + margin-top: 12px; + + pre { + background: lighten($zooniverse-light-grey, 5%); + border: 1px solid $zooniverse-mid-grey; + font-size: 12px; + max-height: 200px; + overflow: auto; + padding: 10px; + } } \ No newline at end of file diff --git a/app/views/subject_rules/_form.html.erb b/app/views/subject_rules/_form.html.erb index 74bf8cb81..c25ad50a8 100644 --- a/app/views/subject_rules/_form.html.erb +++ b/app/views/subject_rules/_form.html.erb @@ -2,11 +2,37 @@ <%= f.error_notification %>