UNPKG

json-object-editor

Version:

JOE the Json Object Editor | Platform Edition

679 lines (621 loc) 38.9 kB
/** * 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);