2

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.

LStarky
  • 2,740
  • 1
  • 17
  • 47
  • `containerless` *shakes head and slaps LStarky on the wrist* maaaan how many times I gotta tell you don't use containerless unless it's absolutely necessary b/c of good reasons (legacy CSS for example) – Ashley Grant Feb 28 '17 at 19:56
  • 1
    If I don't use containerless, Bootstrap makes a visible box around the ``. I didn't think it would affect binding, etc. But anyway, my real interest is in well-built custom controls that can be instantiated statically AND dynamically (and that work with Aurelia validation). – LStarky Feb 28 '17 at 20:06
  • Well, there you go, you gave a valid reason to use `containerless` :-) – Ashley Grant Feb 28 '17 at 20:07

3 Answers3

3

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;
    }
  }
}
Ashley Grant
  • 10,879
  • 24
  • 36
  • I'm working through this proposal, which basically seems to be creating hybrid components that work as normal custom elements but can also be activated with compose. Am I correct that `activate(model)` will only be called when instantiated by ``? – LStarky Feb 28 '17 at 20:55
  • Yes. The only real limitation of this strategy is that these custom elements can't also be pages loaded by the router. Though even that could be worked around by checking the type of the first parameter to `activate`. – Ashley Grant Feb 28 '17 at 21:11
  • @AshleyGrant what do think about my answer? – Fabio Mar 01 '17 at 20:05
  • Argh! This is giving me a headache... Ashley's solution seems to work initially but the binding seems to be static from compose... when `control.model` is changed from outside, the new value is not reflected in the control. – LStarky Mar 01 '17 at 20:53
  • I'm thinking about inverting your suggestion and making the view work with the model properties, which seem to bind well, and having the custom element properties update the model properties when changed (which is detected well). But... what's the best way to detect whether this element was instantiated as a custom element vs. `compose`? – LStarky Mar 01 '17 at 21:29
  • I'm working on something on my side. One good way to detect is the `activate` callback I'm using. – Ashley Grant Mar 01 '17 at 21:40
  • Ok, refresh my gist.run to check out what I've done. I set up an observer on the `value` property of the model. – Ashley Grant Mar 01 '17 at 21:43
  • I mean.. sure, it's a fair amount of work, but it's work you explicitly asked for :-P – Ashley Grant Mar 01 '17 at 21:44
3

There's another strategy, not sure if it's better or not. You could create a custom-compose that behaves in the way you want. For example:

import { 
  bindable, 
  inlineView, 
  noView, 
  inject, 
  TemplatingEngine,
  bindingMode } from 'aurelia-framework';

@noView
@inject(Element, TemplatingEngine)
export class DynamicElement {

  @bindable type;
  @bindable({ defaultBindingMode: bindingMode.twoWay }) model;

  constructor(element, templatingEngine) {
    this.element = element;
    this.templatingEngine = templatingEngine;
  }

  bind(bindingContext, overrideContext) {
    this.element.innerHTML = `<${this.type} value.bind="model"></${this.type}>`;
    this.templatingEngine.enhance({ element: this.element, bindingContext: this });
  }

  detached() {
    this.element.firstChild.remove();
    this.view.detached();
    this.view.unbind();
    this.view = null;
  }
}

Usage:

<div repeat.for="control of controls">
  ${control.label}
  <dynamic-element type.bind="control.type" model.bind="control.value"></dynamic-element>
  <div>
    control.value = ${control.value}
  </div>
</div>

I'm not comfortable with bindingContext: this. There's probably a better way to do this.

Runnable example https://gist.run/?id=827c72ec2062ec61adbfb0a72b4dac7d

What do you think?

Fabio
  • 11,892
  • 1
  • 25
  • 41
  • The problem I have here is this will probably lead to memory leaks since there really isn't an API to tear down "enhanced" elements. – Ashley Grant Mar 01 '17 at 21:00
  • hmmm... that's very important. So, after the parent element is detached, will the enhanced element still be in the memory? Is there something we could do to clean it? – Fabio Mar 01 '17 at 21:47
  • I remember seeing an issue regarding us not calling "detached" on "enhanced" elements. You have to call the method yourself. I'll see if I can find it. – Ashley Grant Mar 01 '17 at 22:10
  • http://stackoverflow.com/questions/40999547/how-to-tear-down-an-enhanced-fragment there's some good info – Ashley Grant Mar 01 '17 at 22:11
  • @AshleyGrant hmm interesting... I've made some changes https://gist.run/?id=827c72ec2062ec61adbfb0a72b4dac7d . Do you think the memory leak problem is solved now? – Fabio Mar 02 '17 at 12:18
1

In order to accomplish dynamic creation of custom elements, I've implemented a meta custom element that uses if.bind to dynamically instantiate the correct custom element (general idea below).

Meta Viewmodel:

import {bindable} from 'aurelia-framework';

export class MyMetaElement {

  @bindable control;                // control definition object
  @bindable model;                  // data for binding
  @bindable readonly = false;       // flag to make controls view-only

}

Meta View:

<template>

  <my-textbox if.bind="control.type == 'my-textbox" label.bind="control.label" value.bind="model[control.bind]" readonly.bind="readonly"></my-textbox>
  <my-datepicker if.bind="control.type == 'my-datepicker" label.bind="control.label" value.bind="model[control.bind]" readonly.bind="readonly"></my-datepicker>
  <my-textarea if.bind="control.type == 'my-textarea" label.bind="control.label" value.bind="model[control.bind]" rows.bind="control.rows" readonly.bind="readonly"></my-textarea>
  <my-dropdown if.bind="control.type == 'my-dropdown" label.bind="control.label" value.bind="model[control.bind]" enum.bind="control.enum" readonly.bind="readonly"></my-dropdown>

</template>

Although this seems like a lot of extra work to dynamically create controls, it has a lot of advantages over the use of <compose>, especially because the custom element controls can also be used in a standalone setting (static instantiation).

LStarky
  • 2,740
  • 1
  • 17
  • 47