Skip to content

Instantly share code, notes, and snippets.

@sc0ttman
Last active October 10, 2024 16:47
Show Gist options
  • Save sc0ttman/c5c96f616c0bb26b6e7ba2891684f8a5 to your computer and use it in GitHub Desktop.
Save sc0ttman/c5c96f616c0bb26b6e7ba2891684f8a5 to your computer and use it in GitHub Desktop.
Mixin POC. Bind form data to a stimulus-accessable object and then allow expressions to be run on data attributes using that data

It would be nice if there was a way to keep a javascript object in a stimulus controller in sync with all the form elements.

It would work both ways. If you updated this.user.email in stimulus controller, the form value would automatically change.

The real power would come from being able to define functionality within the HTML without the need of explicitly defining callbacks and show/hide functionality manually in the stimulus controller. Just a set of tags that allow you to run expressions against the form data and have that functionality added for you.

This would be great for simple validations and show/hide/disable/css functionality leaving you room in the Stimulus controller for more advanced features.

See data-stimulus-hidden, data-stimulus-enabled and data-stimulus-class

import { Controller } from 'stimulus'
export default class UserForm extends Controller {
  static values = { 
    formObject: Object
  }
  connect() {    
    // some way to tell Stimulus to keep the properties defined in the form in sync with this object
    // Doing this would also set the initial form values automatically
    this.user = useBindFormAttributes(this, this.formObjectValue)
    
    // Or let the form define its own initial state
    // this.user = useBindFormAttributes(this)
  }

  async submit() {
    // I know this.user is up to date. I dont need to mess with FormData.

    const response = await post('some/endpoint', {
      body: { user: this.user },
      responseKind: "json"
    }) 
  }
}
<form data-controller="user-form" data-user-form-form-object-value='{"name": "", "sendEmail": false, "email": ""}'>
  <p class="error" data-stimulus-hidden="name.length > 0">Name is required</p>
  <input type="text" data-user-form-bind-attribute="name" data-stimulus-class="(name.length <= 0) ? 'error':''">
  <input type="checkbox" data-user-form-bind-attribute="sendEmail">
                                                                    
  <p data-stimulus-hidden="sendEmail == false">Enter your email to receive updates</p>                                                                    
  <input type="email" data-user-form-bind-attribute="email" data-stimulus-enabled="sendEmail == true">
  
  <input type="submit" data-action="userForm#submit:prevent">
</form>
/**
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
}
})
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment