json-object-editor
Version:
JOE the Json Object Editor | Platform Edition
679 lines (621 loc) • 38.9 kB
JavaScript
/**
* JOE React Form Renderer
* Vanilla JS form renderer using React from CDN (no build step)
*
* Usage:
* 1. Include React from CDN in your page
* 2. Load this script
* 3. Call: joeReactForm.init({ rootId: 'react-form-root', formDefinitionUrl: '/_include/{json_include_id}', formId: '{joe_form_id}' })
*/
(function(window) {
'use strict';
// Wait for React/ReactDOM to load if not immediately available
function waitForReact(callback, maxAttempts) {
maxAttempts = maxAttempts || 50; // 50 attempts = ~2.5 seconds if 50ms intervals
var attempts = 0;
function check() {
if (typeof window.React !== 'undefined' && typeof window.ReactDOM !== 'undefined') {
callback();
} else {
attempts++;
if (attempts < maxAttempts) {
setTimeout(check, 50);
} else {
console.error('JOE React Form: React and ReactDOM failed to load after ' + (maxAttempts * 50) + 'ms. Make sure React CDN scripts are included before this script.');
}
}
}
check();
}
// Test data generators for auto-fill
function generateTestValue(field, allFields) {
switch (field.type) {
case 'text':
if (field.id.toLowerCase().includes('name')) {
return field.id.toLowerCase().includes('first') ? 'John' : 'Doe';
}
if (field.id.toLowerCase().includes('email')) {
return 'test@example.com';
}
if (field.id.toLowerCase().includes('phone')) {
return '(555) 555-5555';
}
return 'Test ' + field.label;
case 'email':
return 'test@example.com';
case 'phone':
return '(555) 555-5555';
case 'number':
return field.min !== undefined ? field.min : 0;
case 'textarea':
return 'Test content for ' + field.label + '. This is sample text to fill the field.';
case 'select':
if (field.options && field.options.length > 0) {
// Prefer first non-blank option, or first option if all have values
var firstOption = field.options[0];
return firstOption.value || (field.options.length > 1 ? field.options[1].value : '');
}
return '';
case 'boolean':
return false; // Default to false for booleans
case 'scale':
var min = field.min !== undefined ? field.min : 0;
var max = field.max !== undefined ? field.max : 5;
return Math.floor((min + max) / 2); // Middle value
default:
return 'test';
}
}
// Create the public API immediately - this ensures joeReactForm exists even if React isn't loaded yet
window.joeReactForm = {
init: function(options) {
var rootId = options.rootId || 'react-form-root';
var formDefinitionUrl = options.formDefinitionUrl;
var formId = options.formId;
if (!formDefinitionUrl) {
console.error('JOE React Form: formDefinitionUrl is required');
return;
}
if (!formId) {
console.error('JOE React Form: formId is required');
return;
}
// Wait for React if needed, then initialize
function doInit() {
if (typeof window.React === 'undefined' || typeof window.ReactDOM === 'undefined') {
waitForReact(doInit);
return;
}
// React is loaded - proceed with initialization
var React = window.React;
var ReactDOM = window.ReactDOM;
var createElement = React.createElement;
var useState = React.useState;
var useMemo = React.useMemo;
// Helper function for className
function clsx() {
var classes = [];
for (var i = 0; i < arguments.length; i++) {
if (arguments[i]) classes.push(arguments[i]);
}
return classes.join(' ');
}
// Condition evaluation
function evalCondition(cond, values) {
var v = values && values[cond.field];
switch (cond.op) {
case 'eq':
return v === cond.value;
case 'neq':
return v !== cond.value;
case 'contains':
return typeof v === 'string' ? v.toLowerCase().indexOf(String(cond.value).toLowerCase()) !== -1 : false;
case 'gt':
return Number(v) > Number(cond.value);
case 'gte':
return Number(v) >= Number(cond.value);
case 'lt':
return Number(v) < Number(cond.value);
case 'lte':
return Number(v) <= Number(cond.value);
case 'truthy':
return Boolean(v);
default:
return false;
}
}
// Visibility check
function isVisible(field, values) {
var vis = field.visibility;
if (!vis) return true;
if (vis.whenAll) {
return vis.whenAll.every(function(c) { return evalCondition(c, values); });
}
if (vis.whenAny) {
return vis.whenAny.some(function(c) { return evalCondition(c, values); });
}
return true;
}
// Field component
function Field(props) {
var field = props.field;
var value = props.value;
var onChange = props.onChange;
var error = props.error;
var commonProps = {
id: field.id,
name: field.id,
className: 'w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm focus:border-slate-400 focus:outline-none focus:ring-2 focus:ring-slate-200',
value: value !== undefined && value !== null ? value : '',
onChange: function(e) { onChange(field.id, e.target.value); }
};
var label = createElement('label', {
htmlFor: field.id,
className: 'text-sm font-medium text-slate-900'
}, field.label, field.required ? createElement('span', { className: 'ml-1 text-rose-600' }, '*') : null);
// Support comment, description, help, info, or tooltip for field help text
var fieldComment = field.comment || field.description || field.help || field.info || field.tooltip;
var commentElement = fieldComment ? createElement('p', {
className: 'text-xs text-slate-500 mt-0.5'
}, fieldComment) : null;
var inputElement;
if (field.type === 'textarea') {
inputElement = createElement('textarea', Object.assign({}, commonProps, {
rows: 4,
placeholder: field.placeholder || ''
}));
} else if (field.type === 'select') {
var options = (field.options || []).map(function(o) {
return createElement('option', { key: o.value, value: o.value }, o.label);
});
inputElement = createElement('select', Object.assign({}, commonProps, {
value: value !== undefined && value !== null ? value : '',
onChange: function(e) { onChange(field.id, e.target.value); }
}), createElement('option', { value: '' }, 'Select…'), options);
} else if (field.type === 'boolean') {
inputElement = createElement('div', { className: 'flex items-center gap-3' },
createElement('label', { className: 'inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm' },
createElement('input', {
type: 'radio',
name: field.id,
checked: value === true,
onChange: function() { onChange(field.id, true); }
}),
'Yes'
),
createElement('label', { className: 'inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm shadow-sm' },
createElement('input', {
type: 'radio',
name: field.id,
checked: value === false,
onChange: function() { onChange(field.id, false); }
}),
'No'
)
);
} else if (field.type === 'scale') {
var min = field.min !== undefined ? field.min : 0;
var max = field.max !== undefined ? field.max : 5;
// Use value if set, otherwise use default, otherwise use min
var currentValue = value !== undefined && value !== null ? value : (field.default !== undefined && field.default !== null ? field.default : (field.defaultValue !== undefined && field.defaultValue !== null ? field.defaultValue : min));
inputElement = createElement('div', { className: 'space-y-2' },
createElement('input', {
type: 'range',
min: min,
max: max,
step: 1,
value: currentValue,
onChange: function(e) { onChange(field.id, Number(e.target.value)); },
className: 'w-full'
}),
createElement('div', { className: 'flex items-center justify-between text-xs text-slate-500' },
createElement('span', null, field.minLabel !== undefined ? field.minLabel : min),
createElement('span', { className: 'rounded-lg bg-slate-100 px-2 py-1 text-slate-700' }, String(currentValue)),
createElement('span', null, field.maxLabel !== undefined ? field.maxLabel : max)
)
);
} else {
var inputType = field.type === 'number' ? 'number' : (field.type === 'email' ? 'email' : 'text');
inputElement = createElement('input', Object.assign({}, commonProps, {
type: inputType,
min: field.min,
max: field.max,
step: field.step,
placeholder: field.placeholder || ''
}));
}
return createElement('div', { className: 'space-y-1' },
createElement('div', { className: 'flex items-start justify-between gap-3' }, label),
commentElement,
inputElement,
error ? createElement('p', { className: 'text-xs text-rose-600' }, error) : null
);
}
// Validation
function validateSection(section, values) {
var errors = {};
for (var i = 0; i < section.fields.length; i++) {
var f = section.fields[i];
if (!isVisible(f, values)) continue;
if (!f.required) continue;
var v = values[f.id];
var empty = v === undefined || v === null || v === '';
if (f.type === 'boolean') {
if (v !== true && v !== false) {
errors[f.id] = 'Please select Yes or No.';
}
} else if (f.type === 'scale') {
if (v === undefined || v === null) {
errors[f.id] = 'Please choose a value.';
}
} else if (empty) {
errors[f.id] = 'This field is required.';
}
}
return errors;
}
// Check for test mode from query string
var testMode = false;
var skipValidation = false;
if (typeof window !== 'undefined' && window.location) {
var params = new URLSearchParams(window.location.search);
testMode = params.get('test') === '1' || params.get('test') === 'true';
skipValidation = params.get('skipValidation') === '1' || params.get('skipValidation') === 'true';
}
// Main App component
function App(props) {
var formDefinition = props.formDefinition;
var formId = props.formId;
if (!formDefinition || !formDefinition.sections || formDefinition.sections.length === 0) {
return createElement('div', { style: { padding: '20px' } }, 'Loading form...');
}
var sections = formDefinition.sections;
var stepState = useState(0);
var step = stepState[0];
var setStep = stepState[1];
// Initialize default values from field definitions
var initialValues = {};
sections.forEach(function(section) {
if (section.fields) {
section.fields.forEach(function(field) {
// Check if field has explicit default
if (field.default !== undefined && field.default !== null) {
initialValues[field.id] = field.default;
} else if (field.defaultValue !== undefined && field.defaultValue !== null) {
initialValues[field.id] = field.defaultValue;
} else {
// Generate sensible defaults based on field type
switch (field.type) {
case 'scale':
var min = field.min !== undefined ? field.min : 0;
var max = field.max !== undefined ? field.max : 5;
// Default to middle value for scales
initialValues[field.id] = Math.floor((min + max) / 2);
break;
case 'select':
// Don't auto-select - leave empty so user must choose unless default is explicitly set
// Only set if default/defaultValue was already handled above
break;
case 'boolean':
// Default booleans to false (can be changed to true if needed)
// Note: We only set if field is not required, or if explicitly defaulted
if (!field.required) {
initialValues[field.id] = false;
}
break;
case 'number':
// Default to min value or 0
initialValues[field.id] = field.min !== undefined ? field.min : 0;
break;
// For text, email, phone, textarea - leave undefined (empty string)
}
}
});
}
});
var valuesState = useState(initialValues);
var values = valuesState[0];
var setValues = valuesState[1];
var errorsState = useState({});
var errors = errorsState[0];
var setErrors = errorsState[1];
var showReviewState = useState(false);
var showReview = showReviewState[0];
var setShowReview = showReviewState[1];
var current = sections[step];
var visibleFields = useMemo(function() {
if (!current) return [];
return current.fields.filter(function(f) { return isVisible(f, values); });
}, [current, values]);
// Collect all fields from all sections for auto-fill
var allFields = useMemo(function() {
var fields = [];
sections.forEach(function(section) {
if (section.fields) {
section.fields.forEach(function(f) {
fields.push(f);
});
}
});
return fields;
}, [sections]);
// Auto-fill function for testing
function autoFillForm() {
var newValues = {};
allFields.forEach(function(field) {
if (isVisible(field, newValues)) {
var testValue = generateTestValue(field, allFields);
if (testValue !== null && testValue !== undefined) {
newValues[field.id] = testValue;
}
}
});
setValues(newValues);
setErrors({});
console.log('JOE React Form: Auto-filled form with test data', newValues);
}
// Force submission bypassing validation
function forceSubmit() {
submitForm();
}
// Expose testing functions to window in test mode
if (testMode && typeof window !== 'undefined') {
window.__joeReactFormTest = {
autoFill: autoFillForm,
forceSubmit: forceSubmit,
skip: skip,
next: next,
getValues: function() { return values; },
setValues: setValues,
submit: submitForm,
setStep: setStep,
goToReview: function() { setShowReview(true); },
formDefinition: formDefinition
};
console.log('JOE React Form: Test mode enabled. Use window.__joeReactFormTest for testing.');
console.log('Available functions:', Object.keys(window.__joeReactFormTest));
}
function setValue(id, v) {
setValues(function(prev) {
var next = Object.assign({}, prev);
next[id] = v;
return next;
});
setErrors(function(prev) {
if (!prev[id]) return prev;
var next = Object.assign({}, prev);
delete next[id];
return next;
});
}
function next() {
if (!current) return;
var e = validateSection(current, values);
setErrors(e);
// Skip validation check if in skipValidation mode
if (!skipValidation && Object.keys(e).length) return;
if (step < sections.length - 1) {
setStep(step + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
setShowReview(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
// Skip to next section without validation (test mode only)
function skip() {
if (!current) return;
// Clear any errors since we're skipping validation
setErrors({});
if (step < sections.length - 1) {
setStep(step + 1);
window.scrollTo({ top: 0, behavior: 'smooth' });
} else {
setShowReview(true);
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}
function back() {
if (showReview) {
setShowReview(false);
return;
}
setStep(Math.max(0, step - 1));
window.scrollTo({ top: 0, behavior: 'smooth' });
}
function submitForm() {
// Submit to JOE
var submissionData = {
formid: formId,
submission: values
};
fetch('/API/plugin/formBuilder/submission', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(submissionData)
})
.then(function(response) { return response.json(); })
.then(function(data) {
if (data.errors) {
alert('Submission error: ' + (typeof data.errors === 'string' ? data.errors : JSON.stringify(data.errors)));
return;
}
alert('Form submitted successfully!');
// Could redirect or show success message
})
.catch(function(error) {
console.error('Submission error:', error);
alert('Error submitting form: ' + error.message);
});
}
// Already checked formDefinition above, just check current
if (!current) {
return createElement('div', { style: { padding: '20px' } }, 'Loading form...');
}
var progressPercent = showReview ? 100 : ((step + 1) / sections.length) * 100;
return createElement('div', { className: 'min-h-screen bg-gradient-to-b from-slate-50 to-white' },
createElement('div', { className: 'mx-auto max-w-3xl px-4 py-8' },
createElement('header', { className: 'mb-6' },
createElement('div', { className: 'flex flex-col gap-2' },
createElement('h1', { className: 'text-2xl font-semibold text-slate-900' }, formDefinition.formName || 'Form'),
createElement('p', { className: 'text-sm text-slate-600' },
'Version ' + (formDefinition.version || '1.0') + ' • Interactive form'
)
),
createElement('div', { className: 'mt-4 h-2 w-full overflow-hidden rounded-full bg-slate-100' },
createElement('div', {
className: 'h-full rounded-full bg-slate-900 transition-all',
style: { width: progressPercent + '%' }
})
),
createElement('div', { className: 'mt-2 flex items-center justify-between text-xs text-slate-500' },
createElement('span', null, showReview ? 'Review' : 'Section ' + (step + 1) + ' of ' + sections.length),
createElement('span', null, showReview ? 'Ready' : current.title)
)
),
createElement('main', { className: 'rounded-2xl border border-slate-200 bg-white p-5 shadow-sm' },
!showReview ? createElement('div', null,
createElement('div', { className: 'mb-4' },
createElement('h2', { className: 'text-lg font-semibold text-slate-900' }, current.title),
current.description ? createElement('p', { className: 'mt-1 text-sm text-slate-600' }, current.description) : null
),
createElement('div', { className: 'grid gap-4' },
visibleFields.map(function(f) {
return createElement(Field, {
key: f.id,
field: f,
value: values[f.id],
onChange: setValue,
error: errors[f.id]
});
})
),
createElement('div', { className: 'mt-6 flex items-center justify-between' },
createElement('div', { className: 'flex items-center gap-2' },
createElement('button', {
onClick: back,
disabled: step === 0,
className: clsx(
'rounded-xl px-4 py-2 text-sm font-medium',
step === 0
? 'cursor-not-allowed bg-slate-100 text-slate-400'
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
)
}, 'Back'),
testMode ? createElement('button', {
onClick: skip,
className: 'rounded-xl bg-blue-500 px-3 py-2 text-xs font-medium text-white hover:bg-blue-600',
title: 'Skip to next section (bypass validation)'
}, '⏭️ Skip') : null,
testMode ? createElement('button', {
onClick: autoFillForm,
className: 'rounded-xl bg-amber-500 px-3 py-2 text-xs font-medium text-white hover:bg-amber-600',
title: 'Auto-fill all fields with test data'
}, '🧪 Auto-fill') : null
),
createElement('button', {
onClick: next,
className: 'rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800'
}, step === sections.length - 1 ? 'Review' : 'Next')
)
) : createElement('div', null,
createElement('div', { className: 'mb-4' },
createElement('h2', { className: 'text-lg font-semibold text-slate-900' }, 'Review & Submit'),
createElement('p', { className: 'mt-1 text-sm text-slate-600' },
'Please review your answers before submitting.'
)
),
createElement('div', { className: 'rounded-xl border border-slate-200 bg-slate-50 p-3' },
createElement('pre', { className: 'max-h-[55vh] overflow-auto text-xs text-slate-800' },
JSON.stringify(values, null, 2)
)
),
createElement('div', { className: 'mt-6 flex items-center justify-between' },
createElement('div', { className: 'flex items-center gap-2' },
createElement('button', {
onClick: back,
className: 'rounded-xl bg-slate-100 px-4 py-2 text-sm font-medium text-slate-800 hover:bg-slate-200'
}, 'Back'),
testMode ? createElement('button', {
onClick: forceSubmit,
className: 'rounded-xl bg-red-500 px-3 py-2 text-xs font-medium text-white hover:bg-red-600',
title: 'Force submit (bypass validation)'
}, '🚀 Force Submit') : null
),
createElement('button', {
onClick: submitForm,
className: 'rounded-xl bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800'
}, 'Submit')
)
)
)
)
);
}
// Fetch form definition and render
console.log('JOE React Form: Fetching form definition from:', formDefinitionUrl);
fetch(formDefinitionUrl)
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
return response.json();
})
.then(function(responseData) {
console.log('JOE React Form: Raw response received:', responseData);
console.log('JOE React Form: Response type:', typeof responseData);
console.log('JOE React Form: Has sections?', responseData && responseData.sections);
// Handle potential response wrapping (some APIs wrap in {data: {...}})
var formDefinition = responseData;
if (responseData && responseData.data && responseData.data.sections) {
formDefinition = responseData.data;
console.log('JOE React Form: Unwrapped response.data');
}
// If response has errors, show them
if (responseData && (responseData.errors || responseData.error)) {
console.error('JOE React Form: API returned error:', responseData.errors || responseData.error);
var root = document.getElementById(rootId);
if (root) {
var errorMsg = responseData.errors || responseData.error;
root.innerHTML = '<div style="padding: 20px; color: red;">Error loading form: ' +
(typeof errorMsg === 'string' ? errorMsg : JSON.stringify(errorMsg)) + '</div>';
}
return;
}
console.log('JOE React Form: Processing form definition:', formDefinition);
console.log('JOE React Form: Sections count:', formDefinition && formDefinition.sections ? formDefinition.sections.length : 'N/A');
if (!formDefinition || !formDefinition.sections || formDefinition.sections.length === 0) {
console.error('JOE React Form: Invalid form definition');
console.error('JOE React Form: formDefinition:', formDefinition);
console.error('JOE React Form: formDefinition.sections:', formDefinition && formDefinition.sections);
var root = document.getElementById(rootId);
if (root) {
root.innerHTML = '<div style="padding: 20px; color: red;">Error: Form definition is invalid or has no sections.<br><pre style="font-size:11px; overflow:auto; max-height:200px;">' +
JSON.stringify(formDefinition, null, 2) + '</pre></div>';
}
return;
}
var root = document.getElementById(rootId);
if (!root) {
console.error('JOE React Form: root element #' + rootId + ' not found');
return;
}
console.log('JOE React Form: Rendering form with', formDefinition.sections.length, 'sections');
ReactDOM.render(
createElement(App, { formDefinition: formDefinition, formId: formId }),
root
);
})
.catch(function(error) {
console.error('JOE React Form: Error loading form definition:', error);
var root = document.getElementById(rootId);
if (root) {
root.innerHTML = '<div style="padding: 20px; color: red;">Error loading form: ' + error.message + '</div>';
}
});
}
// Start initialization (will wait for React if needed)
doInit();
}
};
})(window);