aureliaaurelia-templating

Aurelia dynamically created custom elements without compose


I understand the advantages and disadvantages of Aurelia's custom elements vs. <compose>; Jeremy Danyow's blog post helps. But, I would like to have my cake and eat it too.

I would like to create custom elements that I can also compose dynamically. Since <compose> requires a different instantiation, to use it would mean that I would need to create two parallel versions of each element -- one for <compose> and one for static calls. For example, consider the following use case:

<template>
  <h1>Welcome to the Data Entry Screen</h1>

  <!-- Static controls -->
  <my-textbox label="Your name:" value.bind="entry_name"></my-textbox>
  <my-datepicker label="Current date:" value.bind="entry_date"></my-datepicker>

  <!-- Loop through dynamic form controls -->
  <div class="form-group" repeat.for="control of controls" if.bind="control.type !== 'hidden'">
    <label class="control-label">${control.label}</label>
    <div>
      <compose containerless class="form-control"
        view-model="resources/elements/${control.type}/${control.type}" 
        model.bind="{'control': control, 'model': model, 'readonly': readonly}">
        </compose>
    </div>
  </div>
</template>

With the following controls data:

controls = [
  {label: 'Entry Date', type: 'my-datepicker', bind: 'acc_entry_date'},
  {label: 'Code', type: 'my-textbox', bind: 'acc_entry_code'},
  {label: 'Ref', type: 'my-textbox', bind: 'acc_entry_ref'},
  {label: 'Description', type: 'my-textarea', rows: '3', bind: 'acc_entry_description'},
  {label: 'Status', type: 'my-dropdown', bind: 'acc_entry_status', enum: 'AccountEntryStatus'},
  {type: 'hidden', bind: 'acc_entry_period_id'}];

As you can see, I would like to use <my-textbox> and <my-datepicker> both statically and dynamically. Custom elements definitely seem like the best approach. However, I don't see how to accomplish this without creating two parallel components -- one designed as a custom element and one designed as a composable view/viewmodel.


Solution

  • How about this for a solution? In my solution, both controls basically are the same, but in a real solution, they would have different behavior, but this is a nice starting point.

    Here's an example: https://gist.run?id=e6e980a88d7e33aba130ef91f55df9dd

    app.html

    <template>
      <require from="./text-box"></require>
      <require from="./date-picker"></require>
    
      <div>
        Text Box
        <text-box value.bind="text"></text-box>
      </div>
      <div>
        Date Picker
        <date-picker value.bind="date"></date-picker>
      </div>
    
      <button click.trigger="reset()">Reset controls</button>
    
      <div>
        Dynamic controls:
        <div repeat.for="control of controls">
          ${control.label}
          <compose view-model="./${control.type}" model.bind="control.model" ></compose>
          <div>
            control.model.value = ${control.model.value}
          </div>
        </div>
      </div>
    
      <button click.trigger="changeModelDotValueOnTextBox()">Change model.value on text box</button>
      <button click.trigger="changeModelOnTextBox()">Change model.value on text box and then make a copy of the model</button>
    </template>
    

    app.js

    export class App {
      text = 'This is some text';
      date = '2017-02-28';
    
      controls = getDefaultControls();
    
      reset() {
        this.controls = getDefaultControls();
      }
    
      changeModelOnTextBox() {
        this.controls[1].model = {
          value: 'I changed the model to something else!'
        };
      }
    
      changeModelDotValueOnTextBox() {
        this.controls[1].model.value = 'I changed the model!';
      }
    }
    
     function getDefaultControls(){
       return[
         {label: 'Entry Date', type: 'date-picker', model: { value: '2017-01-01' }},
         {label: 'Code', type: 'text-box', model: { value: 'This is some other text'}}
       ];
     }
    

    date-picker.html

    <template>
      <input type="date" value.bind="value" />
    </template>
    

    date-picker.js

    import { inject, bindable, bindingMode, TaskQueue } from 'aurelia-framework';
    import { ObserverLocator } from 'aurelia-binding'; 
    
    @inject(Element, TaskQueue, ObserverLocator)
    export class DatePicker {
      @bindable({ defaultBindingMode: bindingMode.twoWay }) value;
      model = null;
      observerSubscription = null;
    
      constructor(el, taskQueue, observerLocator) {
        this.el = el;
        this.taskQueue = taskQueue;
        this.observerLocator = observerLocator;
      }
    
      activate(model) {
        if(this.observerSubscription) {
          this.observerSubscription.dispose();
        }
    
        this.model = model;
    
        this.observerSubscription = this.observerLocator.getObserver(this.model, 'value')
                                        .subscribe(() => this.modelValueChanged());
        this.hasModel = true;
    
        this.modelValueChanged();
      }
    
      detached() {
        if(this.observerSubscription) {
          this.observerSubscription.dispose();
        }
      }
    
      modelValueChanged() {
        this.guard = true;
    
        this.value = this.model.value;
    
        this.taskQueue.queueMicroTask(() => this.guard = false)
      }
    
      valueChanged() {
    
        if(this.guard == false && this.hasModel) {
          this.model.value = this.value;
        }
      }
    }
    

    text-box.html

    <template>
      <input type="text" value.bind="value" />
    </template>
    

    text-box.js

    import { inject, bindable, bindingMode, TaskQueue } from 'aurelia-framework';
    import { ObserverLocator } from 'aurelia-binding'; 
    
    @inject(Element, TaskQueue, ObserverLocator)
    export class TextBox {
      @bindable({ defaultBindingMode: bindingMode.twoWay }) value;
      model = null;
      observerSubscription = null;
    
      constructor(el, taskQueue, observerLocator) {
        this.el = el;
        this.taskQueue = taskQueue;
        this.observerLocator = observerLocator;
      }
    
      activate(model) {
        if(this.observerSubscription) {
          this.observerSubscription.dispose();
        }
    
        this.model = model;
    
        this.observerSubscription = this.observerLocator.getObserver(this.model, 'value')
                                        .subscribe(() => this.modelValueChanged());
        this.hasModel = true;
    
        this.modelValueChanged();
      }
    
      detached() {
        if(this.observerSubscription) {
          this.observerSubscription.dispose();
        }
      }
    
      modelValueChanged() {
        this.guard = true;
    
        this.value = this.model.value;
    
        this.taskQueue.queueMicroTask(() => this.guard = false)
      }
    
      valueChanged() {
    
        if(this.guard == false && this.hasModel) {
          this.model.value = this.value;
        }
      }
    }