From bf8ffbea9da0886a00a3d4130a21f44ee9d26c7e Mon Sep 17 00:00:00 2001 From: Mario Lurig Date: Tue, 9 Dec 2025 00:09:08 -0700 Subject: [PATCH] Initial File Commit --- index.html | 46 + trmnl-form-builder.js | 2148 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 2194 insertions(+) create mode 100644 index.html create mode 100644 trmnl-form-builder.js diff --git a/index.html b/index.html new file mode 100644 index 0000000..0690f85 --- /dev/null +++ b/index.html @@ -0,0 +1,46 @@ + + + + + + TRMNL YML Form Builder - Web Component Test + + + + + + + + + + + + + + + diff --git a/trmnl-form-builder.js b/trmnl-form-builder.js new file mode 100644 index 0000000..86dcd89 --- /dev/null +++ b/trmnl-form-builder.js @@ -0,0 +1,2148 @@ +/** + * TRMNL YML Form Builder Web Component + * + * A standalone, dependency-free Web Component for building TRMNL custom plugin forms. + * Requires only Tailwind CSS for styling. + * + * Usage: + * + * + * Attributes: + * - accent-color: Hex color for accent elements (default: #F8654B) + * - theme: 'light' or 'dark' (default: 'light') + */ + +class TRMLYamlForm extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + + // State management + this.fields = []; + this.selectedFieldId = null; + this.draggedFieldId = null; + this.draggedFromIndex = null; + this.nextFieldId = 1; + + // Configuration + this.accentColor = this.getAttribute('accent-color') || '#F8654B'; + this.theme = this.getAttribute('theme') || 'light'; + + // Categories cache + this.categories = []; + this.loadCategories(); + + // supported locales (based on trmnl-i18n) + this.locales = [ + { code: 'de', label: 'Deutsch' }, + { code: 'es-ES', label: 'Español' }, + { code: 'fr', label: 'Français' }, + { code: 'it', label: 'Italiano' }, + { code: 'nl', label: 'Nederlands' }, + { code: 'pt-BR', label: 'Português' }, + { code: 'uk', label: 'український' } + ]; + + // --- Property Definitions (How to render each setting) --- + this.propertyDefinitions = { + // Common + keyname: { label: 'Keyname', type: 'text', help: 'Lowercase alphanumeric + underscores only' }, + name: { label: 'Name', type: 'text', placeholder: 'About This Plugin' }, + description: { label: 'Description', type: 'textarea', placeholder: 'Main description (HTML-friendly)' }, + help_text: { label: 'Help Text', type: 'textarea', placeholder: 'Sub-label / hint (HTML-friendly)' }, + optional: { label: 'Optional field', type: 'checkbox' }, + + // Inputs + default: { label: 'Default Value', type: 'text', placeholder: 'Pre-selected value' }, + placeholder: { label: 'Placeholder', type: 'text', placeholder: 'Example value' }, + min: { label: 'Minimum Value', type: 'number', placeholder: '0' }, + max: { label: 'Maximum Value', type: 'number', placeholder: '100' }, + maxlength: { label: 'Maximum Length', type: 'number', placeholder: '12' }, + rows: { label: 'Rows', type: 'number', placeholder: '10' }, + + // Selection + options: { label: 'Options (one per line)', type: 'textarea', placeholder: 'Option 1\nOption 2', help: 'Enter one option per line' }, + multiple: { label: 'Allow multiple selection', type: 'checkbox' }, + endpoint: { label: 'Endpoint *', type: 'text', placeholder: 'https://api.example.com/options' }, + http_verb: { + label: 'HTTP Method', + type: 'select', + default: 'GET', + options: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT'], + placeholder: 'GET' + }, + + // Special + value: { label: 'Value', type: 'text', placeholder: 'sk_live_abc123xyz' }, + + // Author Bio Specifics + categories: { label: 'Categories (Max 2)', type: 'custom_categories' }, + github_url: { label: 'GitHub URL', type: 'url', placeholder: 'https://github.com/username' }, + email_address: { label: 'Email Address', type: 'text', placeholder: 'user@example.com' }, + learn_more_url: { label: 'Learn More URL', type: 'url', placeholder: 'https://thesatanictemple.com/' }, + youtube_url: { label: 'YouTube URL', type: 'url', placeholder: 'https://www.youtube.com/@useTRMNL' }, + + // The Translation Manager + i18n_descriptions: { label: 'Description Translations', type: 'i18n_map', help: 'Add translated descriptions for specific locales.' }, + + // Conditional Logic + conditional_validation: { label: 'Conditional Logic', type: 'conditional_builder', help: 'Hide other fields based on this field\'s value' } + }; + + // --- Field Types (Added 'properties' array to each) --- + this.fieldTypes = { + // Text Input + author_bio: { + category: 'TEXT INPUT', name: 'Author Bio', description: 'Plugin information', + properties: [{ + key: 'name', + default: 'About This Plugin' + }, 'categories', 'github_url', 'email_address', 'learn_more_url', 'youtube_url'] // Note: Common props are handled automatically + }, + url: { + category: 'TEXT INPUT', name: 'Url', description: 'URL input', + properties: ['default', 'placeholder', 'optional'] + }, + string: { + category: 'TEXT INPUT', name: 'String', description: 'Single-line text', + properties: ['default', 'placeholder', 'maxlength', 'optional', 'conditional_validation'] + }, + multi_string: { + category: 'TEXT INPUT', name: 'Multi String', description: 'Comma-separated', + properties: ['default', 'placeholder', 'optional'] + }, + text: { + category: 'TEXT INPUT', name: 'Text', description: 'Multi-line textarea', + properties: ['default', 'placeholder', 'rows', 'optional', 'conditional_validation'] + }, + code: { + category: 'TEXT INPUT', name: 'Code', description: 'Monospace code', + properties: ['default', 'placeholder', 'rows', 'optional'] + }, + password: { + category: 'TEXT INPUT', name: 'Password', description: 'Masked input', + properties: ['default', 'placeholder', 'optional'] + }, + + // Numeric + number: { + category: 'NUMERIC', name: 'Number', description: 'Numeric input', + properties: ['default', 'placeholder', 'min', 'max', 'optional', 'conditional_validation'] + }, + + // Date & Time + date: { + category: 'DATE & TIME', name: 'Date', description: 'Date picker', + // We use an object to override the generic 'default' definition + properties: [ + { + key: 'default', // The property key we are targeting + placeholder: 'YYYY-MM-DD', + help: 'Can also be set to "today" to use the current date automatically.' + }, + 'optional' + ] + }, + time: { category: 'DATE & TIME', name: 'Time', description: 'Time picker', properties: ['default', 'optional'] }, + timezone: { category: 'DATE & TIME', name: 'Time Zone', description: 'Timezone selector', properties: ['default', 'optional'] }, + + // Selection + select: { + category: 'SELECTION', name: 'Select', description: 'Dropdown', + properties: ['options', 'default', 'multiple', 'optional', 'conditional_validation'] + }, + xhrSelect: { + category: 'SELECTION', name: 'XhrSelect', description: 'Dynamic dropdown', + properties: ['endpoint', 'http_verb', 'multiple', 'optional'] + }, + xhrSelectSearch: { + category: 'SELECTION', name: 'XhrSelectSearch', description: 'Searchable dynamic', + properties: ['endpoint', 'http_verb', 'multiple', 'optional'] + }, + + // Special + copyable: { category: 'SPECIAL', name: 'Copyable', description: 'Click-to-copy', properties: ['value'] }, + copyable_webhook_url: { category: 'SPECIAL', name: 'Copyable Webhook', description: 'Click-to-copy webhook', properties: ['value'] }, + }; + } + + connectedCallback() { + this.render(); + this.setupEventListeners(); + } + + async loadCategories() { + try { + const response = await fetch('https://usetrmnl.com/api/categories'); + if (response.ok) { + const data = await response.json(); + // Handle both array and object with data property + this.categories = Array.isArray(data) ? data : (data.data || this.getFallbackCategories()); + // Extract just the names if they're objects + if (this.categories.length > 0 && typeof this.categories[0] === 'object') { + this.categories = this.categories.map(cat => cat.name || cat.title || cat); + } + } else { + this.categories = this.getFallbackCategories(); + } + } catch (error) { + console.warn('Failed to load categories from API, using fallback:', error); + this.categories = this.getFallbackCategories(); + } + } + + getFallbackCategories() { + return [ + "analytics", + "art", + "calendar", + "comics", + "crm", + "custom", + "discovery", + "ecommerce", + "education", + "email", + "entertainment", + "environment", + "finance", + "games", + "humor", + "images", + "kpi", + "life", + "marketing", + "nature", + "news", + "personal", + "productivity", + "programming", + "sales", + "sports", + "travel" + ]; + } + + render() { + const html = ` + + +
+
+
+

TRMNL YML Form Builder

+

Create custom plugin configuration forms with a no-code interface

+
+
0 fields defined
+
+ + + +
+
+
+
+

No fields yet

+

Select a field type from the left panel to start building your form.

+

You can drag fields to reorder them, or click to edit properties.

+
+
+ + +
+ `; + + this.shadowRoot.innerHTML = html; + this.renderFieldTypes(); + } + + renderFieldTypes() { + const container = this.shadowRoot.getElementById('fieldTypesList'); + const grouped = {}; + + // Group field types by category + Object.entries(this.fieldTypes).forEach(([key, config]) => { + if (!grouped[config.category]) { + grouped[config.category] = []; + } + grouped[config.category].push({ key, ...config }); + }); + + let html = ''; + Object.entries(grouped).forEach(([category, fields]) => { + html += `
+
${category}
`; + + fields.forEach(field => { + html += ` + + `; + }); + + html += '
'; + }); + + container.innerHTML = html; + this.updateAuthorBioState(); // Check immediately on render + } + + renderCategoryPills(selectedCategories = []) { + if (!this.categories || this.categories.length === 0) { + return '
Loading categories...
'; + } + + let html = '
'; + + this.categories.forEach(category => { + const isSelected = selectedCategories.includes(category); + html += ` + + `; + }); + + html += '
'; + return html; + } + + setupEventListeners() { + // Field type buttons + this.shadowRoot.querySelectorAll('.field-type-button').forEach(btn => { + btn.addEventListener('dragstart', (e) => this.handleFieldTypeDragStart(e)); + btn.addEventListener('click', (e) => this.addField(e.currentTarget.dataset.fieldType)); + }); + + // Canvas drop zone + const canvas = this.shadowRoot.getElementById('canvas'); + canvas.addEventListener('dragover', (e) => this.handleCanvasDragOver(e)); + canvas.addEventListener('drop', (e) => this.handleCanvasDrop(e)); + canvas.addEventListener('dragleave', (e) => this.handleCanvasDragLeave(e)); + + // Tabs + this.shadowRoot.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', (e) => this.switchTab(e.currentTarget.dataset.tab)); + }); + + // YAML buttons + this.shadowRoot.getElementById('copyBtn').addEventListener('click', () => this.copyYaml()); + this.shadowRoot.getElementById('importBtn').addEventListener('click', () => this.importYaml()); + + // Reset button listener + const resetBtn = this.shadowRoot.getElementById('resetBtn'); + if (resetBtn) { + resetBtn.addEventListener('click', () => { + if (confirm('Are you sure you want to reset the form? All fields will be lost.')) { + this.clear(); + } + }); + } + } + + handleFieldTypeDragStart(e) { + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('fieldType', e.currentTarget.dataset.fieldType); + } + + handleCanvasDragOver(e) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + this.shadowRoot.getElementById('canvas').classList.add('drag-over'); + } + + handleCanvasDragLeave(e) { + if (e.target === this.shadowRoot.getElementById('canvas')) { + this.shadowRoot.getElementById('canvas').classList.remove('drag-over'); + } + } + + handleCanvasDrop(e) { + e.preventDefault(); + this.shadowRoot.getElementById('canvas').classList.remove('drag-over'); + const fieldType = e.dataTransfer.getData('fieldType'); + if (fieldType) { + this.addField(fieldType); + } + } + + addField(fieldType) { + if (fieldType === 'author_bio' && this.fields.some(f => f.field_type === 'author_bio')) { + this.showToast('Only one Author Bio field is allowed'); + return; + } + + const fieldKeyname = fieldType === 'author_bio' + ? fieldType + : `${fieldType}_${Date.now().toString().slice(-4)}`; + + const typeConfig = this.fieldTypes[fieldType]; + + // Create new field object with minimal defaults + const field = { + id: `field_${this.nextFieldId++}`, + keyname: fieldKeyname, + field_type: fieldType, + }; + + // Only set 'name' if there's a default specified in the type config + let hasNameDefault = false; + if (typeConfig && Array.isArray(typeConfig.properties)) { + typeConfig.properties.forEach(prop => { + if (typeof prop === 'object' && prop.key === 'name' && prop.default) { + field.name = prop.default; + hasNameDefault = true; + } + }); + } + + // Fallback to type name if no custom default was set + if (!hasNameDefault) { + field.name = this.fieldTypes[fieldType]?.name || fieldType; + } + + // Update state + this.fields.push(field); + this.selectedFieldId = field.id; + + this.switchTab('config'); + this.updateConfigPanel(); + this.updateCanvasView(); + this.updateYamlOutput(); + this.updateAuthorBioState(); + } + + updateCanvasView() { + const canvas = this.shadowRoot.getElementById('canvas'); + + if (this.fields.length === 0) { + canvas.innerHTML = ` +
+
+
+

No fields yet

+

Select a field type from the left panel to start building your form.

+

You can drag fields to reorder them, or click to edit properties.

+
+ `; + canvas.classList.remove('has-fields'); + return; + } + + canvas.classList.add('has-fields'); + let html = `
`; + + this.fields.forEach((field, index) => { + const isSelected = field.id === this.selectedFieldId; + html += ` +
+
+
${field.keyname}${field.field_type}
+
${field.name}
+
+
+ `; + if (field.field_type != 'author_bio'){ + html += ``; + } + html += ` + +
+
+ `; + }); + + html += '
'; + canvas.innerHTML = html; + + // Setup field card event listeners + this.shadowRoot.querySelectorAll('.field-card').forEach(card => { + card.addEventListener('click', (e) => { + if (!e.target.closest('.icon-button')) { + this.selectedFieldId = card.dataset.fieldId; + this.switchTab('config'); + this.updateConfigPanel(); + this.updateCanvasView(); + } + }); + + card.addEventListener('dragstart', (e) => { + // Explicitly set data to satisfy browser validation + e.dataTransfer.setData('text/plain', card.dataset.fieldId); + e.dataTransfer.effectAllowed = 'move'; + + this.draggedFieldId = card.dataset.fieldId; + this.draggedFromIndex = this.fields.findIndex(f => f.id === this.draggedFieldId); + card.style.opacity = '0.5'; + }); + + card.addEventListener('dragend', () => { + card.style.opacity = '1'; + this.draggedFieldId = null; + this.draggedFromIndex = null; + }); + + card.addEventListener('dragover', (e) => { + e.preventDefault(); + // Stop propagation to prevent Canvas "copy" effect from overriding "move" + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + + // Add visual cue + card.style.borderTop = '2px solid var(--accent-color)'; + }); + + card.addEventListener('dragleave', (e) => { + // Ensure we don't remove style when entering child elements + // Only remove if we are genuinely leaving the card rect + const rect = card.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + + // Check if cursor is actually outside the element bounds + if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) { + card.style.borderTop = ''; + } + }); + + card.addEventListener('drop', (e) => { + e.preventDefault(); + // Stop propagation so the canvas handler doesn't trigger + e.stopPropagation(); + + card.style.borderTop = ''; + + if (this.draggedFieldId && this.draggedFieldId !== card.dataset.fieldId) { + const toIndex = this.fields.findIndex(f => f.id === card.dataset.fieldId); + + if (this.draggedFromIndex !== -1 && toIndex !== -1) { + // Perform the move + const [draggedField] = this.fields.splice(this.draggedFromIndex, 1); + this.fields.splice(toIndex, 0, draggedField); + + // Re-render + this.updateCanvasView(); + this.updateYamlOutput(); + } + } + }); + }); + + // Setup action buttons + this.shadowRoot.querySelectorAll('.icon-button.duplicate').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.duplicateField(btn.dataset.fieldId); + }); + }); + + this.shadowRoot.querySelectorAll('.icon-button.delete').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + this.deleteField(btn.dataset.fieldId); + }); + }); + + this.updateFieldCount(); + } + + updateAuthorBioState() { + const hasAuthorBio = this.fields.some(f => f.field_type === 'author_bio'); + const btn = this.shadowRoot.querySelector('button[data-field-type="author_bio"]'); + + if (btn) { + if (hasAuthorBio) { + btn.setAttribute('disabled', 'true'); + btn.removeAttribute('draggable'); + btn.title = "Only one Author Bio allowed"; + } else { + btn.removeAttribute('disabled'); + btn.setAttribute('draggable', 'true'); + btn.removeAttribute('title'); + } + } + } + + updateConfigPanel() { + const field = this.fields.find(f => f.id === this.selectedFieldId); + if (!field) { + this.shadowRoot.getElementById('configContent').innerHTML = '
Select a field to edit its properties
'; + return; + } + + const typeConfig = this.fieldTypes[field.field_type]; + + // 1. Define Standard Properties + let visibleProps = ['keyname', 'field_type_display', 'name', 'description', 'i18n_descriptions', 'help_text']; + + // 2. Add Type-Specific Properties (These can now be strings OR objects) + if (typeConfig && Array.isArray(typeConfig.properties)) { + visibleProps = [...visibleProps, ...typeConfig.properties]; + } + + // 3. Generate HTML + let html = ''; + visibleProps.forEach(propEntry => { // propEntry is now string or object + + // Determine the base key (string or object.key) + const propKey = typeof propEntry === 'string' ? propEntry : propEntry.key; + + // Special case for static display of field type + if (propKey === 'field_type_display') { + html += ` +
+ +
${field.field_type}
+
`; + return; + } + + let propDef = this.propertyDefinitions[propKey]; + + // --- NEW LOGIC: Merge Overrides --- + if (typeof propEntry === 'object' && propDef) { + // Create a new definition by merging the base definition with the specific overrides + propDef = {...propDef, ...propEntry}; + } + + if (propDef) { + // Pass the key and the potentially merged definition to the renderer + html += this.renderPropertyInput(propKey, propDef, field); + } + }); + + this.shadowRoot.getElementById('configContent').innerHTML = html; + + // 4. Re-attach Event Listeners + this.attachConfigListeners(field); + } + + renderPropertyInput(key, def, field) { + const value = field[key] !== undefined ? field[key] : (def.default || ''); + + if (def.type === 'custom_categories') { + const selectedCategories = field.category ? field.category.split(',').map(c => c.trim()).filter(c => c) : []; + return ` +
+ +
+ ${this.renderCategoryPills(selectedCategories)} +
+
Selected: ${selectedCategories.length}/2
+
+ `; + } + + if (def.type === 'checkbox') { + return ` +
+ +
`; + } + + if (def.type === 'textarea') { + // Convert array options to string for display + let displayValue = value; + let useLabelValue = false; + + if (key === 'options' && Array.isArray(value)) { + // Check if any option is an object (label:value pair) + useLabelValue = value.some(opt => typeof opt === 'object'); + + if (useLabelValue) { + // Convert objects to "Label: value" format + displayValue = value.map(opt => { + if (typeof opt === 'object') { + const [label, val] = Object.entries(opt)[0]; + return `${label}: ${val}`; + } + return opt; + }).join('\n'); + } else { + // Simple string array + displayValue = value.join('\n'); + } + } + + // Add toggle for options field + const optionsToggle = key === 'options' ? ` + +
+ ${useLabelValue ? 'Enter one per line in format "Label: value"' : 'Enter one option per line'} +
+ ` : ''; + + return ` +
+ + ${optionsToggle} + + ${!optionsToggle && def.help ? `
${def.help}
` : ''} +
`; + } + + // --- i18n_map Renderer --- + if (def.type === 'i18n_map') { + // 1. Find existing translations in the field object + const existingTranslations = Object.keys(field) + .filter(k => k.startsWith('description-')) + .map(k => { + const code = k.replace('description-', ''); + return { code, value: field[k] }; + }); + + // 2. Build the list of existing translation inputs + let rowsHtml = existingTranslations.map(item => ` +
+
+ ${item.code} + +
+ +
+ `).join(''); + + // 3. Build the "Add New" dropdown + // Filter out locales that are already added + const availableLocales = this.locales.filter(l => !field[`description-${l.code}`]); + + let addHtml = ''; + if (availableLocales.length > 0) { + addHtml = ` +
+ + +
+ `; + } + + return ` +
+ +
+ ${rowsHtml} +
+ ${addHtml} + ${def.help ? `
${def.help}
` : ''} +
`; + } + + // --- Handle generic Select inputs (including http_verb) --- + if (def.options && Array.isArray(def.options)) { + // Determine the current value, falling back to default if not set + const currentValue = field[key] !== undefined ? field[key] : (def.default || ''); + + const optionsHtml = def.options.map(option => { + // The option value is the method name itself (GET, POST, etc.) + const selected = currentValue === option ? 'selected' : ''; + return ``; + }).join(''); + + return ` +
+ + + ${def.help ? `
${def.help}
` : ''} +
`; + } + + // --- Conditional Builder Renderer --- + if (def.type === 'conditional_builder') { + const conditions = field.conditional_validation || []; + + // Get list of other fields for checkboxes + const otherFields = this.fields.filter(f => f.id !== field.id); + + let conditionsHtml = conditions.map((condition, idx) => { + const hiddenSet = new Set(condition.hidden || []); + + const checkboxesHtml = otherFields.map(f => { + const checked = hiddenSet.has(f.keyname) ? 'checked' : ''; + return ` + + `; + }).join(''); + + return ` +
+
+
+ When equals: + +
+ +
+
Hide these fields:
+
+ ${checkboxesHtml || '
No other fields available
'} +
+
+ `; + }).join(''); + + return ` +
+ +
+ ${conditionsHtml} +
+ + ${def.help ? `
${def.help}
` : ''} +
+ `; + } + + // --- Special handling for 'default' field --- + if (key === 'default') { + const hasDefault = field.hasOwnProperty('default'); + const defaultValue = hasDefault ? (field.default || '') : ''; + + return ` +
+ + + + ${def.help ? `
${def.help}
` : ''} +
`; + } + + // Default: Text, Number, URL inputs + return ` +
+ + + ${def.help ? `
${def.help}
` : ''} +
`; + } + + + attachConfigListeners(field) { + const container = this.shadowRoot.getElementById('configContent'); + + // Generic Inputs + container.querySelectorAll('[data-prop]').forEach(input => { + input.addEventListener('input', (e) => this.handleConfigInput(e, field)); + input.addEventListener('change', (e) => this.handleConfigInput(e, field)); + }); + + // Handle Label:Value toggle for options + const labelValueToggle = container.querySelector('#use-label-value'); + if (labelValueToggle) { + labelValueToggle.addEventListener('change', (e) => { + const helpDiv = container.querySelector('#options-help'); + if (helpDiv) { + helpDiv.textContent = e.target.checked + ? 'Enter one per line in format "Label: value"' + : 'Enter one option per line'; + } + }); + } + + // Handle "Set default value" checkbox + const enableDefaultCheckbox = container.querySelector('#enable-default'); + const defaultInput = container.querySelector('#default-input'); + if (enableDefaultCheckbox && defaultInput) { + enableDefaultCheckbox.addEventListener('change', (e) => { + if (e.target.checked) { + // Enable the input and set default to empty string if not already set + defaultInput.disabled = false; + defaultInput.style.opacity = '1'; + if (!field.hasOwnProperty('default')) { + field.default = ''; + } + } else { + // Disable the input and remove the default property + defaultInput.disabled = true; + defaultInput.style.opacity = '0.5'; + delete field.default; + } + this.updateYamlOutput(); + }); + } + + // Special Case: Categories (Pills) + const pills = container.querySelectorAll('.category-pill'); + if (pills.length > 0) { + pills.forEach(pill => { + pill.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + + const category = pill.dataset.category; + // Parse current selection + let selected = field.category ? field.category.split(',').map(c => c.trim()).filter(c => c) : []; + + const index = selected.indexOf(category); + + if (index > -1) { + // Deselect + selected.splice(index, 1); + } else if (selected.length < 2) { + // Select (if under limit) + selected.push(category); + } else { + // Limit Reached Feedback + this.showToast('You can only select up to 2 categories'); + return; // Stop here so we don't re-render unnecessarily + } + + field.category = selected.join(','); + this.updateConfigPanel(); // Re-render to update pill styling + this.updateYamlOutput(); + }); + }); + } + + // --- i18n Listeners --- + // Handle Input Changes (typing in the translation box) + container.querySelectorAll('.i18n-input').forEach(input => { + input.addEventListener('input', (e) => { + const code = e.target.dataset.code; + field[`description-${code}`] = e.target.value; + this.updateYamlOutput(); + }); + }); + + // Handle "Add" Button + const addBtn = container.querySelector('#add-locale-btn'); + if (addBtn) { + addBtn.addEventListener('click', (e) => { + e.preventDefault(); + const select = container.querySelector('#new-locale-select'); + const code = select.value; + if (code) { + // Initialize the new key + field[`description-${code}`] = ''; + this.updateConfigPanel(); // Re-render to show the new input + this.updateYamlOutput(); + } + }); + } + + // Handle "Remove" Buttons + container.querySelectorAll('.remove-i18n-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const code = e.target.dataset.code; + delete field[`description-${code}`]; // Remove the key entirely + this.updateConfigPanel(); // Re-render to remove the input + this.updateYamlOutput(); + }); + }); + + // --- Conditional Logic Listeners --- + // Add Condition Button + const addConditionBtn = container.querySelector('#add-condition-btn'); + if (addConditionBtn) { + addConditionBtn.addEventListener('click', (e) => { + e.preventDefault(); + if (!field.conditional_validation) { + field.conditional_validation = []; + } + field.conditional_validation.push({ + when: '', + hidden: [] + }); + this.updateConfigPanel(); + this.updateYamlOutput(); + }); + } + + // Remove Condition Buttons + container.querySelectorAll('.remove-condition-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.preventDefault(); + const idx = parseInt(e.target.dataset.conditionIdx); + field.conditional_validation.splice(idx, 1); + if (field.conditional_validation.length === 0) { + delete field.conditional_validation; + } + this.updateConfigPanel(); + this.updateYamlOutput(); + }); + }); + + // When Value Inputs + container.querySelectorAll('.condition-when-input').forEach(input => { + input.addEventListener('input', (e) => { + const idx = parseInt(e.target.dataset.conditionIdx); + field.conditional_validation[idx].when = e.target.value; + this.updateYamlOutput(); + }); + }); + + // Hidden Field Checkboxes + container.querySelectorAll('.condition-hidden-cb').forEach(cb => { + cb.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.conditionIdx); + const fieldKeyname = e.target.dataset.fieldKeyname; + + if (!field.conditional_validation[idx].hidden) { + field.conditional_validation[idx].hidden = []; + } + + if (e.target.checked) { + if (!field.conditional_validation[idx].hidden.includes(fieldKeyname)) { + field.conditional_validation[idx].hidden.push(fieldKeyname); + } + } else { + field.conditional_validation[idx].hidden = field.conditional_validation[idx].hidden.filter(k => k !== fieldKeyname); + } + + this.updateYamlOutput(); + }); + }); + } + + handleConfigInput(e, field) { + const input = e.target; + const prop = input.dataset.prop; + let value; + const container = this.shadowRoot.getElementById('configContent'); + + // 1. Determine Value + if (input.type === 'checkbox') { + value = input.checked; + } else if (input.type === 'number') { + value = input.value === '' ? undefined : Number(input.value); + } else if (prop === 'options') { + const lines = input.value.split('\n').filter(o => o.trim()); + + // Check if we're in label:value mode + const useLabelValue = container.querySelector('#use-label-value')?.checked; + + if (useLabelValue) { + // Parse as label:value pairs + value = lines.map(line => { + const colonIndex = line.indexOf(':'); + if (colonIndex > -1) { + const label = line.substring(0, colonIndex).trim(); + const val = line.substring(colonIndex + 1).trim(); + return { [label]: val }; + } + // Fallback to simple string if no colon found + return line.trim(); + }); + } else { + // Simple string array + value = lines; + } + } else { + value = input.value; + // Special handling for 'default' - allow empty string but only if checkbox is checked + if (prop === 'default' && value === '') { + // If checkbox is checked, explicitly set to empty string + // If unchecked, this won't be called because input is disabled + field[prop] = ''; + this.updateYamlOutput(); + return; + } + } + + // 2. Save Value + field[prop] = value; + + // 3. --- NEW: Auto-populate help text for multiple select --- + if (prop === 'multiple' && value === true && field.field_type === 'select') { + // Only populate if currently empty to avoid overwriting user content + if (!field.help_text || !field.help_text.trim()) { + field.help_text = 'Use +click or ctrl+click to select and deselect multiple options.'; + + // Force a re-render so the user sees the text appear immediately in the specific field + this.updateConfigPanel(); + } + } + + // 4. Specific UI updates + if (prop === 'keyname' || prop === 'name') { + this.updateCanvasView(); // Refresh the sidebar list items + } + + this.updateYamlOutput(); + } + + + updateYamlOutput() { + const yaml = this.generateYaml(); + this.shadowRoot.getElementById('yamlOutput').textContent = yaml; + } + + generateYaml() { + if (this.fields.length === 0) { + return '# No fields defined yet'; + } + + const lines = []; + + this.fields.forEach(field => { + lines.push(`- keyname: ${field.keyname}`); + lines.push(` field_type: ${field.field_type}`); + lines.push(` name: ${this.escapeYaml(field.name)}`); + + if (field.description) { + lines.push(` description: ${this.escapeYaml(field.description)}`); + } + + if (field.help_text) { + lines.push(` help_text: ${this.escapeYaml(field.help_text)}`); + } + + if (field.placeholder) { + lines.push(` placeholder: ${this.escapeYaml(field.placeholder)}`); + } + + if (field.hasOwnProperty('default')) { + lines.push(` default: ${this.escapeYaml(field.default)}`); + } + + if (field.optional) { + lines.push(` optional: true`); + } + + if (field.min !== undefined) { + lines.push(` min: ${field.min}`); + } + + if (field.max !== undefined) { + lines.push(` max: ${field.max}`); + } + + if (field.maxlength !== undefined) { + lines.push(` maxlength: ${field.maxlength}`); + } + + if (field.rows !== undefined) { + lines.push(` rows: ${field.rows}`); + } + + if (field.options && field.options.length > 0) { + // console.log('Field options:', field.keyname, field.options); + lines.push(` options:`); + field.options.forEach(opt => { + if (typeof opt === 'object') { + // Label:Value pair - format as YAML key:value + const [label, value] = Object.entries(opt)[0]; + // Don't escape the label in the key position, only the value + lines.push(` - ${label}: ${this.escapeYaml(value)}`); + } else { + // Simple string + lines.push(` - ${this.escapeYaml(opt)}`); + } + }); + } + + if (field.multiple) { + lines.push(` multiple: true`); + } + + if (field.endpoint) { + lines.push(` endpoint: ${this.escapeYaml(field.endpoint)}`); + } + + const isXhrField = field.field_type === 'xhrSelect' || field.field_type === 'xhrSelectSearch'; + if (isXhrField) { + // Determine the verb: use the field property value, or fall back to the default 'GET' + const verb = field.http_verb || this.propertyDefinitions.http_verb.default; + + // Always print the http_verb for XHR fields, even if it's the default 'GET'. + lines.push(` http_verb: ${verb}`); + } else if (field.http_verb && field.http_verb !== 'GET') { + // Keep this secondary check for non-XHR fields that might use a non-default verb + // (though unlikely with current definitions) + lines.push(` http_verb: ${field.http_verb}`); + } + + if (field.value) { + lines.push(` value: ${this.escapeYaml(field.value)}`); + } + + if (field.category) { + lines.push(` category: ${this.escapeYaml(field.category)}`); + } + + if (field.github_url) { + lines.push(` github_url: ${this.escapeYaml(field.github_url)}`); + } + if (field.email_address) { + lines.push(` email_address: ${this.escapeYaml(field.email_address)}`); + } + if (field.learn_more_url) { + lines.push(` learn_more_url: ${this.escapeYaml(field.learn_more_url)}`); + } + if (field.youtube_url) { + lines.push(` youtube_url: ${this.escapeYaml(field.youtube_url)}`); + } + + // Conditional validation + if (field.conditional_validation && field.conditional_validation.length > 0) { + lines.push(` conditional_validation:`); + field.conditional_validation.forEach(condition => { + lines.push(` - when: ${this.escapeYaml(condition.when)}`); + if (condition.hidden && condition.hidden.length > 0) { + lines.push(` hidden:`); + condition.hidden.forEach(keyname => { + lines.push(` - ${keyname}`); + }); + } + }); + } + + // Capture any i18n description keys + Object.keys(field).forEach(k => { + if (k.startsWith('description-')) { + lines.push( ` ${k}: "${field[k]}"`); + } + }); + + }); + + return lines.join('\n'); + } + + escapeYaml(value) { + if (!value && value !== 0) return '""'; + + // Convert to string if it's a number + if (typeof value === 'number') { return value.toString(); } + + // If it's not a string at this point, return empty + if (typeof value !== 'string') return '""'; + + // Check if value needs quoting + if (/[:#\[\]\{\},&\*\|>!'"%@`\n\t]/.test(value) || /^\d+$/.test(value) || value.startsWith(' ') || value.endsWith(' ')) { + return `"${value.replace(/"/g, '\\"')}"`; + } + + return value; + } + + reorderFields(draggedId, targetId) { + const draggedIndex = this.fields.findIndex(f => f.id === draggedId); + const targetIndex = this.fields.findIndex(f => f.id === targetId); + + if (draggedIndex !== -1 && targetIndex !== -1) { + const [draggedField] = this.fields.splice(draggedIndex, 1); + this.fields.splice(targetIndex, 0, draggedField); + this.updateCanvasView(); + this.updateYamlOutput(); + } + } + + duplicateField(fieldId) { + const field = this.fields.find(f => f.id === fieldId); + + // Prevent duplicating author_bio (existing logic) + if (field && field.field_type === 'author_bio') { + this.showToast('Author Bio field cannot be duplicated'); + return; + } + + if (field) { + // 1. Create a deep copy of the original field object + const newField = JSON.parse(JSON.stringify(field)); + + // 2. Assign a new unique ID + const newFieldId = `field_${this.nextFieldId++}`; + newField.id = newFieldId; + + // 3. FIX: Generate a clean, unique keyname based on the field type + // Example: 'string' -> 'string_2' + newField.keyname = `${field.field_type}_${this.nextFieldId - 1}`; + + // 4. Insert the new field and update state + this.fields.push(newField); + this.selectedFieldId = newField.id; + + // Ensure the config panel opens for the newly duplicated field + this.updateConfigPanel(); + this.updateCanvasView(); + this.updateYamlOutput(); + this.updateAuthorBioState(); + } + } + + deleteField(fieldId) { + this.fields = this.fields.filter(f => f.id !== fieldId); + if (this.selectedFieldId === fieldId) { + this.selectedFieldId = this.fields[0]?.id || null; + } + this.updateCanvasView(); + this.updateConfigPanel(); + this.updateYamlOutput(); + this.updateAuthorBioState(); + } + + updateFieldCount() { + this.shadowRoot.querySelector('#fieldCount').textContent = this.fields.length; + } + + switchTab(tab) { + this.shadowRoot.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + this.shadowRoot.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); + + this.shadowRoot.querySelector(`[data-tab="${tab}"]`).classList.add('active'); + this.shadowRoot.getElementById(`${tab}Tab`).classList.add('active'); + } + + copyYaml() { + const yaml = this.generateYaml(); + navigator.clipboard.writeText(yaml).then(() => { + this.showToast('YAML copied to clipboard'); + }); + } + + importYaml() { + // Create modal overlay + const modal = document.createElement('div'); + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + `; + + const modalContent = document.createElement('div'); + modalContent.style.cssText = ` + background: ${this.theme === 'dark' ? '#1a1a1a' : '#ffffff'}; + border: 1px solid ${this.theme === 'dark' ? '#404040' : '#e0e0e0'}; + border-radius: 8px; + padding: 24px; + width: 600px; + max-width: 90vw; + max-height: 80vh; + display: flex; + flex-direction: column; + `; + + modalContent.innerHTML = ` +

Import YAML

+ +
+ + +
+ `; + + modal.appendChild(modalContent); + document.body.appendChild(modal); + + const textarea = modalContent.querySelector('#yamlImportTextarea'); + const cancelBtn = modalContent.querySelector('#cancelImportBtn'); + const confirmBtn = modalContent.querySelector('#confirmImportBtn'); + + // Focus textarea + textarea.focus(); + + // Cancel handler + const closeModal = () => { + document.body.removeChild(modal); + }; + + cancelBtn.addEventListener('click', closeModal); + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + // Import handler + confirmBtn.addEventListener('click', () => { + const yamlText = textarea.value; + + if (!yamlText || !yamlText.trim()) { + closeModal(); + return; + } + + try { + const fields = this.parseYaml(yamlText); + // console.log('Parsed fields:', fields); + if (fields.length === 0) { + this.showToast('No fields found in YAML'); + closeModal(); + return; + } + + // Clear existing fields and import new ones + this.fields = fields.map((fieldData, index) => { + const field = { + id: `field_${this.nextFieldId++}`, + keyname: fieldData.keyname || `field_${index}`, + field_type: fieldData.field_type || 'string', + name: fieldData.name || '', + }; + + // Copy over all other properties + Object.keys(fieldData).forEach(key => { + if (key !== 'id') { + field[key] = fieldData[key]; + } + }); + + return field; + }); + + this.selectedFieldId = this.fields[0]?.id || null; + this.updateCanvasView(); + this.updateConfigPanel(); + this.updateYamlOutput(); + this.updateAuthorBioState(); + this.switchTab('config'); + this.showToast(`Imported ${fields.length} field(s)`); + closeModal(); + } catch (error) { + alert(`Error parsing YAML: ${error.message}`); + } + }); + } + + parseYaml(yamlText) { + const fields = []; + const lines = yamlText.split('\n'); + let currentField = null; + let currentArray = null; + let currentArrayKey = null; + let currentCondition = null; + let currentNestedKey = null; // Helper to track if we are in hidden/required + + lines.forEach(line => { + // Skip comments and empty lines + if (line.trim().startsWith('#') || !line.trim()) { + return; + } + + // 1. New field (starts with - keyname:) + if (line.match(/^-\s+keyname:/)) { + if (currentField) { + fields.push(currentField); + } + currentField = {}; + currentArray = null; + currentArrayKey = null; + currentCondition = null; + currentNestedKey = null; + const value = line.split('keyname:')[1].trim(); + currentField.keyname = this.unescapeYaml(value); + } + + // 2. Field properties (2 spaces indentation) + else if (currentField && line.match(/^\s{2}[a-z_-]+:/)) { + const match = line.match(/^\s{2}([a-z_-]+):\s*(.*)$/); + if (match) { + const key = match[1]; + const value = match[2].trim(); + + // Handle array start (value is empty) + if (!value || value === '') { + currentArrayKey = key; + currentArray = []; + + if (key === 'conditional_validation') { + currentField[key] = []; + } else { + currentField[key] = currentArray; + } + } else { + // Scalar values + currentArrayKey = null; + currentArray = null; + currentField[key] = this.unescapeYaml(value); + + // Convert string booleans + if (value === 'true') currentField[key] = true; + if (value === 'false') currentField[key] = false; + + // Convert numbers (but not empty strings!) + if (!isNaN(value) && value !== '' && value !== '""') { + currentField[key] = Number(value); + } + } + } + } + + // 3. Nested properties in conditions (e.g., " hidden:") + // We check this BEFORE array items to set context + else if (currentCondition && line.match(/^\s{4}[a-z_-]+:/)) { + const match = line.match(/^\s{4}([a-z_]+):\s*(.*)$/); + if (match) { + const key = match[1]; + // Initialize the nested array + currentCondition[key] = []; + currentNestedKey = key; // Track that we are currently filling this key + } + } + + // 4. Nested Array Items (Deep indentation: 4 or more spaces + dash) + // IMPORTANT: This must come BEFORE the generic array check + else if (currentCondition && line.match(/^\s{4,}-\s+/)) { + const value = line.replace(/^\s{4,}-\s+/, '').trim(); + + // Add to the currently active nested array (hidden or required) + if (currentNestedKey && Array.isArray(currentCondition[currentNestedKey])) { + currentCondition[currentNestedKey].push(value); + } + } + + // 5. Top-Level Array items (Exactly 2 spaces + dash) + // Regex changed from {2,} to {2} to avoid eating nested items + else if (line.match(/^\s{2}-\s+/)) { + const value = line.replace(/^\s{2}-\s+/, '').trim(); + + // If we're in conditional_validation, this is a NEW condition object + if (currentArrayKey === 'conditional_validation') { + currentCondition = {}; + currentField.conditional_validation.push(currentCondition); + currentNestedKey = null; // Reset nested context for new object + + // Parse the "when" value + if (value.startsWith('when:')) { + currentCondition.when = this.unescapeYaml(value.split('when:')[1].trim()); + } + } + // Otherwise it's a regular array item (like options) + else if (currentArray) { + const colonIndex = value.indexOf(':'); + if (currentArrayKey === 'options' && colonIndex > -1) { + const label = value.substring(0, colonIndex).trim(); + const val = value.substring(colonIndex + 1).trim(); + currentArray.push({ [this.unescapeYaml(label)]: this.unescapeYaml(val) }); + } else { + currentArray.push(this.unescapeYaml(value)); + } + } + } + }); + + // Don't forget the last field + if (currentField) { + fields.push(currentField); + } + + return fields; + } + + unescapeYaml(value) { + if (!value && value !== 0) return ''; + + // If it's not a string, just return it as-is + if (typeof value !== 'string') return value; + + // Remove surrounding quotes + value = value.replace(/^["'](.*)["']$/, '$1'); + + // Unescape escaped quotes + value = value.replace(/\\"/g, '"'); + + return value; + } + + showToast(message) { + const toast = document.createElement('div'); + toast.className = 'toast'; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + padding: 12px 16px; + background: ${this.theme === 'dark' ? '#e0e0e0' : '#1a1a1a'}; + color: ${this.theme === 'dark' ? '#1a1a1a' : '#e0e0e0'}; + border-radius: 4px; + font-size: 150%; + font-weight: 500; + z-index: 1000; + animation: slideIn 0.3s ease-out; + `; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 2000); + } + + // Public API methods + getYaml() { + return this.generateYaml(); + } + + getFields() { + return JSON.parse(JSON.stringify(this.fields)); + } + + setFields(fields) { + this.fields = JSON.parse(JSON.stringify(fields)); + this.selectedFieldId = this.fields[0]?.id || null; + this.updateCanvasView(); + this.updateConfigPanel(); + this.updateYamlOutput(); + this.updateAuthorBioState(); // Check immediately on render + } + + clear() { + this.fields = []; + this.selectedFieldId = null; + this.draggedFieldId = null; + this.draggedFromIndex = null; + this.nextFieldId = 1; + this.render(); + this.setupEventListeners(); + this.updateCanvasView(); + this.updateConfigPanel(); + this.updateYamlOutput(); + this.updateAuthorBioState(); // Check immediately on render + } +} + +// Register the custom element +customElements.define('trmnl-form-builder', TRMLYamlForm);