|
/** |
|
useBindFormAttributes - A utility function, inspired by AngularJS templates, to synchronize a JavaScript object with form elements |
|
in a Stimulus controller. |
|
This function automatically updates form elements based on the object's properties and updates the object when form inputs change. |
|
It also allows for dynamic binding of visibility, class, state, and conditional rendering based on custom data attributes. |
|
|
|
Usage: |
|
1. Stimulus Controller: |
|
|
|
In your Stimulus controller, use this function to bind an object to the form elements. |
|
This enables automatic synchronization between the form and the JavaScript object. |
|
|
|
Example: |
|
|
|
import { Controller } from 'stimulus' |
|
import { useBindFormAttributes } from './path/to/useBindFormAttributes' |
|
|
|
export default class extends Controller { |
|
static values = { formObject: Object } // Optional: Set the initial form object value from the view |
|
|
|
connect() { |
|
// Bind the form with the passed-in formObjectValue |
|
this.album = useBindFormAttributes(this, this.formObjectValue) |
|
|
|
// OR use the data already set in the form |
|
this.album = useBindFormAttributes(this) |
|
} |
|
|
|
submit(event) { |
|
console.log("Form submitted with data:", this.album) |
|
} |
|
} |
|
|
|
2. HTML: |
|
|
|
In the corresponding ERB view, set up the form fields to bind to the object's properties. |
|
You can use the following custom data attributes for various bindings: |
|
- `data-st-model`: Binds the form element to the object's property (required for synchronization) |
|
- `data-st-shown`: Conditionally shows/hides the element based on the expression (expression should evaluate to a boolean) |
|
- `data-st-hidden`: Opposite of `data-st-shown`; hides the element when the expression evaluates to true |
|
- `data-st-enabled`: Enables/disables the element based on the expression |
|
- `data-st-disabled`: Opposite of `data-st-enabled`; disables the element when the expression evaluates to true |
|
- `data-st-class`: Dynamically sets the element's class based on the expression result |
|
- `data-st-checked`: Checks or unchecks a checkbox based on the expression result |
|
- `data-st-if`: Conditionally includes or removes the element from the DOM based on the expression |
|
|
|
Example: |
|
|
|
<%= form_with(scope: :album, data: { controller: 'album-form', album_form_form_object_value: { title: "Initial Title" } }) do |f| %> |
|
<p class="alert alert-danger" data-st-shown="title.length <= 0">Title is required</p> |
|
<%= f.text_field :title, data: { st_model: 'title', st_class: "(title.length <= 0) ? 'error':''" } %> |
|
|
|
<%= f.check_box :password_input_show, class: 'form-check-input', data: { st_model: 'password_input_show', st_disabled: 'unlisted', st_checked: 'password_input_show && !unlisted' } %> |
|
<%= f.label :password_input_show, class: 'form-check-label' %> |
|
|
|
<%= f.text_field :password, data: { st_model: 'password', st_if: 'password_input_show && !unlisted' } %> |
|
|
|
<%= f.check_box :unlisted, class: 'form-check-input', data: { st_model: 'unlisted' } %> |
|
<%= f.label :unlisted, class: 'form-check-label' %> |
|
<%= f.submit "Update", data: { action: 'album-form#submit:prevent', st_disabled: 'title.length <= 0' } %> |
|
<% end %> |
|
*/ |
|
|
|
export const useBindFormAttributes = (controller, object) => { |
|
const form = controller.element |
|
const commandPrefix = 'data-st' // Prefix for all custom commands |
|
const modelAttributeSelector = `${commandPrefix}-model` // Selector for data-bound model attributes |
|
const removedElementsMap = new Map() // Track removed elements for potential re-attachment |
|
|
|
// If no formObjectValue is passed, generate the object from form data |
|
if (!object) { |
|
object = {} |
|
form.querySelectorAll(`[${modelAttributeSelector}]`).forEach(input => { |
|
const key = input.getAttribute(`${modelAttributeSelector}`) |
|
object[key] = input.type === 'checkbox' ? input.checked : input.value |
|
}) |
|
} |
|
|
|
// Function to safely evaluate expressions within the context of the object |
|
const evaluateExpression = (expression, obj) => { |
|
try { |
|
const func = new Function(...Object.keys(obj), `return ${expression}`) |
|
return func(...Object.values(obj)) |
|
} catch (error) { |
|
console.error(`Error evaluating expression: ${expression}`, error) |
|
return false |
|
} |
|
} |
|
|
|
// Update the form inputs when object properties change |
|
const syncFormWithObject = () => { |
|
Object.keys(object).forEach(key => { |
|
const input = form.querySelector(`[${modelAttributeSelector}="${key}"]`) |
|
if (!input) return |
|
|
|
input.type === 'checkbox' ? (input.checked = object[key]) : (input.value = object[key] || '') |
|
}) |
|
updateAllBindings() |
|
} |
|
|
|
// Bind form fields to object properties |
|
form.querySelectorAll(`[${modelAttributeSelector}]`).forEach(input => { |
|
const key = input.getAttribute(`${modelAttributeSelector}`) |
|
if (!key) return |
|
|
|
input.type === 'checkbox' ? (input.checked = object[key] || false) : (input.value = object[key] || '') |
|
|
|
input.addEventListener('input', () => { |
|
object[key] = input.type === 'checkbox' ? input.checked : input.value |
|
updateAllBindings() |
|
}) |
|
}) |
|
|
|
// Update visibility and state for elements |
|
const updateVisibilityAndState = () => { |
|
form.querySelectorAll(`[${commandPrefix}-shown]`).forEach(el => { |
|
const expression = el.getAttribute(`${commandPrefix}-shown`) |
|
const shouldShow = evaluateExpression(expression, object) |
|
el.style.setProperty('display', shouldShow ? '' : 'none', 'important') |
|
}) |
|
|
|
form.querySelectorAll(`[${commandPrefix}-hidden]`).forEach(el => { |
|
const expression = el.getAttribute(`${commandPrefix}-hidden`) |
|
const shouldHide = evaluateExpression(expression, object) |
|
el.style.setProperty('display', shouldHide ? 'none' : '', 'important') |
|
}) |
|
|
|
form.querySelectorAll(`[${commandPrefix}-enabled]`).forEach(el => { |
|
const expression = el.getAttribute(`${commandPrefix}-enabled`) |
|
const shouldEnable = evaluateExpression(expression, object) |
|
el.disabled = !shouldEnable |
|
}) |
|
|
|
form.querySelectorAll(`[${commandPrefix}-disabled]`).forEach(el => { |
|
const expression = el.getAttribute(`${commandPrefix}-disabled`) |
|
const shouldDisable = evaluateExpression(expression, object) |
|
el.disabled = shouldDisable |
|
}) |
|
} |
|
|
|
// Update class bindings |
|
const updateClassBindings = () => { |
|
form.querySelectorAll(`[${commandPrefix}-class]`).forEach(el => { |
|
const expression = el.getAttribute(`${commandPrefix}-class`) |
|
const className = evaluateExpression(expression, object) |
|
if (typeof className === 'string') { |
|
el.className = className |
|
} else { |
|
console.error(`Invalid className expression result: ${className}`) |
|
} |
|
}) |
|
} |
|
|
|
// Update checked state for checkboxes |
|
const updateCheckedState = () => { |
|
form.querySelectorAll(`[${commandPrefix}-checked]`).forEach(input => { |
|
const expression = input.getAttribute(`${commandPrefix}-checked`) |
|
input.checked = evaluateExpression(expression, object) |
|
}) |
|
} |
|
|
|
// Handle if-condition updates |
|
const updateIfConditions = () => { |
|
form.querySelectorAll(`[${commandPrefix}-if]`).forEach(el => { |
|
const expression = el.getAttribute(`${commandPrefix}-if`) |
|
const shouldKeep = evaluateExpression(expression, object) |
|
|
|
if (shouldKeep) { |
|
if (removedElementsMap.has(el)) { |
|
const { parent, nextSibling } = removedElementsMap.get(el) |
|
parent.insertBefore(el, nextSibling) |
|
removedElementsMap.delete(el) |
|
} |
|
} else if (!removedElementsMap.has(el)) { |
|
removedElementsMap.set(el, { parent: el.parentNode, nextSibling: el.nextSibling }) |
|
el.remove() |
|
} |
|
}) |
|
|
|
// Reevaluate all stored elements periodically to check if conditions have changed |
|
removedElementsMap.forEach((value, el) => { |
|
const expression = el.getAttribute(`${commandPrefix}-if`) |
|
const shouldKeep = evaluateExpression(expression, object) |
|
if (shouldKeep) { |
|
const { parent, nextSibling } = value |
|
parent.insertBefore(el, nextSibling) |
|
removedElementsMap.delete(el) |
|
} |
|
}) |
|
} |
|
|
|
// Run all updates in sequence |
|
const updateAllBindings = () => { |
|
updateVisibilityAndState() |
|
updateClassBindings() |
|
updateCheckedState() |
|
updateIfConditions() |
|
} |
|
|
|
// Initial run of all updates |
|
updateAllBindings() |
|
|
|
// Return a proxy to keep the object in sync with the form |
|
return new Proxy(object, { |
|
set(target, prop, value) { |
|
target[prop] = value |
|
syncFormWithObject() |
|
return true |
|
} |
|
}) |
|
} |