1

I'm migrating from KnockoutJS to Aurelia and having a hard time trying to figure out how I can swap out some HTML/JS from a view. I have some existing Knockout code as follows:

$.ajax({
    url: "/admin/configuration/settings/get-editor-ui/" + replaceAll(self.type(), ".", "-"),
    type: "GET",
    dataType: "json",
    async: false
})
.done(function (json) {

    // Clean up from previously injected html/scripts
    if (typeof cleanUp == 'function') {
        cleanUp(self);
    }

    // Remove Old Scripts
    var oldScripts = $('script[data-settings-script="true"]');

    if (oldScripts.length > 0) {
        $.each(oldScripts, function () {
            $(this).remove();
        });
    }

    var elementToBind = $("#form-section")[0];
    ko.cleanNode(elementToBind);

    var result = $(json.content);

    // Add new HTML
    var content = $(result.filter('#settings-content')[0]);
    var details = $('<div>').append(content.clone()).html();
    $("#settings-details").html(details);

    // Add new Scripts
    var scripts = result.filter('script');

    $.each(scripts, function () {
        var script = $(this);
        script.attr("data-settings-script", "true");//for some reason, .data("block-script", "true") doesn't work here
        script.appendTo('body');
    });

    // Update Bindings
    // Ensure the function exists before calling it...
    if (typeof updateModel == 'function') {
        var data = ko.toJS(ko.mapping.fromJSON(self.value()));
        updateModel(self, data);
        ko.applyBindings(self, elementToBind);
    }

    //self.validator.resetForm();
    switchSection($("#form-section"));
})
.fail(function (jqXHR, textStatus, errorThrown) {
    $.notify(self.translations.getRecordError, "error");
    console.log(textStatus + ': ' + errorThrown);
});

In the above code, the self.type() being passed to the url for an AJAX request is the name of some settings. Here is an example of some settings:

public class DateTimeSettings : ISettings
{
    public string DefaultTimeZoneId { get; set; }

    public bool AllowUsersToSetTimeZone { get; set; }

    #region ISettings Members

    public string Name => "Date/Time Settings";

    public string EditorTemplatePath => "Framework.Web.Views.Shared.EditorTemplates.DateTimeSettings.cshtml";

    #endregion ISettings Members
}

I use that EditorTemplatePath property to render that view and return it in the AJAX request. An example settings view is as follows:

@using Framework.Web
@using Framework.Web.Configuration
@inject Microsoft.Extensions.Localization.IStringLocalizer T

@model DateTimeSettings

<div id="settings-content">
    <div class="form-group">
        @Html.LabelFor(m => m.DefaultTimeZoneId)
        @Html.TextBoxFor(m => m.DefaultTimeZoneId, new { @class = "form-control", data_bind = "value: defaultTimeZoneId" })
        @Html.ValidationMessageFor(m => m.DefaultTimeZoneId)
    </div>
    <div class="checkbox">
        <label>
            @Html.CheckBoxFor(m => m.AllowUsersToSetTimeZone, new { data_bind = "checked: allowUsersToSetTimeZone" }) @T[FrameworkWebLocalizableStrings.Settings.DateTime.AllowUsersToSetTimeZone]
        </label>
    </div>
</div>

<script type="text/javascript">
    function updateModel(viewModel, data) {
        viewModel.defaultTimeZoneId = ko.observable("");
        viewModel.allowUsersToSetTimeZone = ko.observable(false);

        if (data) {
            if (data.DefaultTimeZoneId) {
                viewModel.defaultTimeZoneId(data.DefaultTimeZoneId);
            }
            if (data.AllowUsersToSetTimeZone) {
                viewModel.allowUsersToSetTimeZone(data.AllowUsersToSetTimeZone);
            }
        }
    };

    function cleanUp(viewModel) {
        delete viewModel.defaultTimeZoneId;
        delete viewModel.allowUsersToSetTimeZone;
    }

    function onBeforeSave(viewModel) {
        var data = {
            DefaultTimeZoneId: viewModel.defaultTimeZoneId(),
            AllowUsersToSetTimeZone: viewModel.allowUsersToSetTimeZone()
        };

        viewModel.value(ko.mapping.toJSON(data));
    };
</script>

Now if you go back to the AJAX request and see what I am doing there, it should make more sense. There is a <div> where I am injecting this HTML, as follows:

<div id="settings-details"></div>

I am trying to figure out how to do this in Aurelia. I see that I can use Aurelia's templatingEngine.enhance({ element: elementToBind, bindingContext: this }); instead of Knockout's ko.applyBindings(self, elementToBind); so I think that should bind the new properties to the view model. However, I don't know what to do about the scripts from the settings editor templates. I suppose I can try keeping the same logic I already have (using jQuery to add/remove scripts, etc)... but I am hoping there is a cleaner/more elegant solution to this with Aurelia. I looked at slots, but I don't think that's applicable here, though I may be wrong.

Matt
  • 6,787
  • 11
  • 65
  • 112
  • Any chance you could simplify the code examples to provide a minimal example? – Ashley Grant May 05 '18 at 01:35
  • @AshleyGrant I dont really think I can simplify it further.You can mostly ignore the 2nd code block,as its just an example of an implementation of `ISettings`.Basically,I have a Grid,and it shows one of each of these settings.When I click Edit,it hides the grid and shows a form.Since the form fields are always different for each implementation,then I need to load the HTML and JS in dynamically.The 1st code block I provided is the main logic and as for the last code block,its an example for you to see the 3 JS functions mandatory for all implementations to provide(see first code block for why) – Matt May 05 '18 at 12:42
  • 1
    Your question is soaked with project-specific fluff which is irrelevant to the core issue you're asking about. If you actually do this "I suppose I can try keeping the same logic" then we have actual problematic code to look at that we can help you improve. This is a hard to digest question which, even if I could answer it without spending an hour deciphering it, will not help a single person in the future. – Fred Kleuver May 05 '18 at 12:55
  • @FredKleuver Alright then, ignore my code altogether and please let me know how I can dynamically replace content (including adding and removing some viewmodel properties)... because that's all I want, really. Just to have a div like this: `
    ` and the HTML I get back from my server will be injected there. At which point I guess I can use `templatingEngine.enhance` to bind the new properties to my model? And then how would I remove that from the viewmodel afterwards? So I can replace with different properties when user wants to edit a different record?
    – Matt May 05 '18 at 13:08
  • Well honestly I'd refer to my answer to your previous similar question: https://stackoverflow.com/questions/50022746/how-to-refresh-bindings/50026483#50026483. Grab that whole runtime-view element and use it like this: ` `. I'm 99% sure that if you use it in the correct place and pass the correct context to it, it would solve your problem. It will automatically refresh whenever the html changes. If it doesn't work for you, could you clarify what's going wrong? – Fred Kleuver May 05 '18 at 13:25
  • @FredKleuver That looks great. I will try it soon, but I think you forgot the part about me needing to add/remove viewmodel properties as well. Currently, I extract a – Matt May 05 '18 at 13:30
  • It will completely re-compile if either the bound html property or context property changes. You bind your viewmodel to the context property - you can either change the viewmodel (context) or pass a new, modified version to it. It's probably cleanest to pass a fresh context to it (optionally with some properties of the old context copied over) because you don't need to worry about clean up then. It's efficient enough for that to be viable. I use it for an in-app code editor and it has no problems keeping the viewmodel in sync as I type out html or javascript in the editor. – Fred Kleuver May 05 '18 at 13:34
  • Ah, yes I see it now! Of course, the context doesn't have to be `$this`, so I can add some property to my main viewmodel, say `settingsDetails` and then pass `$this.settingsDetails` as the context, right? Then I just need to worry about updating `this.settingsDetails` to whatever it needs to be every time I edit a different record. Thanks! Please add as answer. It's almost midnight where I am now, so I will try this tomorrow or Monday and then mark your answer as the correct one if it works as I expect it will. – Matt May 05 '18 at 13:38
  • Precisely - you can pass it any object you like. If you pass it no object, it will look for a property named `context` in the current binding context and if that does not exist, it will simply take the current binding context (you can see this in the `bind()` method) – Fred Kleuver May 05 '18 at 13:49

1 Answers1

1

As discussed in the comments, my answer to your other question should do the trick here. Given this runtime-view element:

TypeScript

    import { bindingMode, createOverrideContext } from "aurelia-binding";
    import { Container } from "aurelia-dependency-injection";
    import { TaskQueue } from "aurelia-task-queue";
    import { bindable, customElement, inlineView, ViewCompiler, ViewResources, ViewSlot } from "aurelia-templating";
    
    @customElement("runtime-view")
    @inlineView("<template><div></div></template>")
    export class RuntimeView {
      @bindable({ defaultBindingMode: bindingMode.toView })
      public html: string;
    
      @bindable({ defaultBindingMode: bindingMode.toView })
      public context: any;
    
      public el: HTMLElement;
      public slot: ViewSlot;
      public bindingContext: any;
      public overrideContext: any;
      public isAttached: boolean;
      public isRendered: boolean;
      public needsRender: boolean;
    
      private tq: TaskQueue;
      private container: Container;
      private viewCompiler: ViewCompiler;
    
      constructor(el: Element, tq: TaskQueue, container: Container, viewCompiler: ViewCompiler) {
        this.el = el as HTMLElement;
        this.tq = tq;
        this.container = container;
        this.viewCompiler = viewCompiler;
        this.slot = this.bindingContext = this.overrideContext = null;
        this.isAttached = this.isRendered = this.needsRender = false;
      }
    
      public bind(bindingContext: any, overrideContext: any): void {
        this.bindingContext = this.context || bindingContext.context || bindingContext;
        this.overrideContext = createOverrideContext(this.bindingContext, overrideContext);
    
        this.htmlChanged();
      }
    
      public unbind(): void {
        this.bindingContext = null;
        this.overrideContext = null;
      }
    
      public attached(): void {
        this.slot = new ViewSlot(this.el.firstElementChild || this.el, true);
        this.isAttached = true;
    
        this.tq.queueMicroTask(() => {
          this.tryRender();
        });
      }
    
      public detached(): void {
        this.isAttached = false;
    
        if (this.isRendered) {
          this.cleanUp();
        }
        this.slot = null;
      }
    
      private htmlChanged(): void {
        this.tq.queueMicroTask(() => {
          this.tryRender();
        });
      }
    
      private contextChanged(): void {
        this.tq.queueMicroTask(() => {
          this.tryRender();
        });
      }
    
      private tryRender(): void {
        if (this.isAttached) {
          if (this.isRendered) {
            this.cleanUp();
          }
          try {
            this.tq.queueMicroTask(() => {
              this.render();
            });
          } catch (e) {
            this.tq.queueMicroTask(() => {
              this.render(`<template>${e.message}</template>`);
            });
          }
        }
      }
    
      private cleanUp(): void {
        try {
          this.slot.detached();
        } catch (e) {}
        try {
          this.slot.unbind();
        } catch (e) {}
        try {
          this.slot.removeAll();
        } catch (e) {}
    
        this.isRendered = false;
      }
    
      private render(message?: string): void {
        if (this.isRendered) {
          this.cleanUp();
        }
    
        const template = `<template>${message || this.html}</template>`;
        const viewResources = this.container.get(ViewResources) as ViewResources;
        const childContainer = this.container.createChild();
        const factory = this.viewCompiler.compile(template, viewResources);
        const view = factory.create(childContainer);
    
        this.slot.add(view);
        this.slot.bind(this.bindingContext, this.overrideContext);
        this.slot.attached();
    
        this.isRendered = true;
      }
    }

ES6

    import { bindingMode, createOverrideContext } from "aurelia-binding";
    import { Container } from "aurelia-dependency-injection";
    import { TaskQueue } from "aurelia-task-queue";
    import { DOM } from "aurelia-pal";
    import { bindable, customElement, inlineView, ViewCompiler, ViewResources, ViewSlot } from "aurelia-templating";
    
    @customElement("runtime-view")
    @inlineView("<template><div></div></template>")
    @inject(DOM.Element, TaskQueue, Container, ViewCompiler)
    export class RuntimeView {
      @bindable({ defaultBindingMode: bindingMode.toView }) html;
      @bindable({ defaultBindingMode: bindingMode.toView }) context;
    
      constructor(el, tq, container, viewCompiler) {
        this.el = el;
        this.tq = tq;
        this.container = container;
        this.viewCompiler = viewCompiler;
        this.slot = this.bindingContext = this.overrideContext = null;
        this.isAttached = this.isRendered = this.needsRender = false;
      }
    
      bind(bindingContext, overrideContext) {
        this.bindingContext = this.context || bindingContext.context || bindingContext;
        this.overrideContext = createOverrideContext(this.bindingContext, overrideContext);
    
        this.htmlChanged();
      }
    
      unbind() {
        this.bindingContext = null;
        this.overrideContext = null;
      }
    
      attached() {
        this.slot = new ViewSlot(this.el.firstElementChild || this.el, true);
        this.isAttached = true;
    
        this.tq.queueMicroTask(() => {
          this.tryRender();
        });
      }
    
      detached() {
        this.isAttached = false;
    
        if (this.isRendered) {
          this.cleanUp();
        }
        this.slot = null;
      }
    
      htmlChanged() {
        this.tq.queueMicroTask(() => {
          this.tryRender();
        });
      }
    
      contextChanged() {
        this.tq.queueMicroTask(() => {
          this.tryRender();
        });
      }
    
      tryRender() {
        if (this.isAttached) {
          if (this.isRendered) {
            this.cleanUp();
          }
          try {
            this.tq.queueMicroTask(() => {
              this.render();
            });
          } catch (e) {
            this.tq.queueMicroTask(() => {
              this.render(`<template>${e.message}</template>`);
            });
          }
        }
      }
    
      cleanUp() {
        try {
          this.slot.detached();
        } catch (e) {}
        try {
          this.slot.unbind();
        } catch (e) {}
        try {
          this.slot.removeAll();
        } catch (e) {}
    
        this.isRendered = false;
      }
    
      render(message) {
        if (this.isRendered) {
          this.cleanUp();
        }
    
        const template = `<template>${message || this.html}</template>`;
        const viewResources = this.container.get(ViewResources);
        const childContainer = this.container.createChild();
        const factory = this.viewCompiler.compile(template, viewResources);
        const view = factory.create(childContainer);
    
        this.slot.add(view);
        this.slot.bind(this.bindingContext, this.overrideContext);
        this.slot.attached();
    
        this.isRendered = true;
      }
    }

Here are some ways in which you can use it:

dynamicHtml is a property on the ViewModel containing arbitrary generated html with any kind of bindings, custom elements and other aurelia behaviors in it.

It will compile this html and bind to the bindingContext it receives in bind() - which will be the viewModel of the view in which you declare it.

<runtime-view html.bind="dynamicHtml">
</runtime-view>

Given a someObject in a view model:

this.someObject.foo = "bar";

And a dynamicHtml like so:

this.dynamicHtml = "<div>${foo}</div>";

This will render as you'd expect it to in a normal Aurelia view:

<runtime-view html.bind="dynamicHtml" context.bind="someObject">
</runtime-view>

Re-assigning either html or context will trigger it to re-compile. Just to give you an idea of possible use cases, I'm using this in a project with the monaco editor to dynamically create Aurelia components from within the Aurelia app itself, and this element will give a live preview (next to the editor) and also compile+render the stored html/js/json when I use it elsewhere in the application.

Fred Kleuver
  • 7,797
  • 2
  • 27
  • 38
  • Thanks and sorry to be a nuisance, but I am using ES6, not TypeScript. I have tried converting your RuntimeView to ES6, but it's not working. I got rid of the access modifiers and types, but it's not enough. I think the main issue is with passing params to the constructor. I guess I need to maybe import `inject` from `aurelia-framework` and use that, but then the first param would be confusing, because it's supposed to be the HTML element and thus should not be injected by me. Could I bother you to please add an ES6 version of your RuntimeView above? – Matt May 05 '18 at 23:10
  • It works beautifully. You should maybe see about contributing this as a plugin - it's great! Thanks! – Matt May 06 '18 at 13:18
  • Thought about that as well. I guess I'll just do it then :) glad it helps – Fred Kleuver May 06 '18 at 13:21
  • Great. Please post the GitHub link here if you do! – Matt May 06 '18 at 13:24
  • FYI - Updating the model doesn't refresh the binding - and it seems the model/context only binds when setting it in the constructor. Trying to do so in attached() or my own test() method does nothing. Example: this is in constructor and works: `this.dynamicModel.foo = "bar"` then the following in another location doesn't do anything: `this.dynamicModel.foo = "blah blah blah";`. Anyway, if you need to see my code, it may be best if I post an issue on GitHub once you put your code there. – Matt May 06 '18 at 13:56
  • Ah yes, I just caught that as well as I was converting it. Change `contextChanged()` to `contextChanged(newValue)` and add this line at the top: `this.bindingContext = context` – Fred Kleuver May 06 '18 at 14:03
  • When you say, "at the top", do you mean in the constructor? Should I replace this : `this.slot = this.bindingContext = this.overrideContext = null;` with this: `this.slot = this.bindingContext = this.overrideContext = context;` ? Maybe it's best if you update your ES6 code in the answer. Thanks in advance. – Matt May 06 '18 at 23:23
  • Reading your instruction again, I guess by "at the top" you meant at the top of the `contextChanged(newValue)` function. With that in mind, I did add `this.bindingContext = this.context;` to no avail. I added some logging to console (`console.log('this.context: ' + JSON.stringify(this.context));`) and it does clearly show the new model there, but the UI is not updated with the new value. Rather than have a ton of comments here on SO, I think it might be better to add your code to GitHub and I can open an issue if needed. – Matt May 06 '18 at 23:41
  • I published it as a plugin, not 100% sure if it works in webpack (I couldn't get it to work in a local demo folder at least). Let me know. https://github.com/aurelia-contrib/aurelia-dynamic-html – Fred Kleuver May 07 '18 at 00:11
  • Well, the one on GitHub works much better and does exactly what I need it to. There is one bug though, when the HTML contains anchor tags with an href that points to an element ID. It complains about no such route being defined.. not sure if there's anything you can do about this. Anyway, I will open an issue on GitHub for that. Thanks for all your help! – Matt May 07 '18 at 03:26