Render modal from theme client-side plugin API

Is there any way to render a modal from the theme javascript?

I was looking forward to render a modal similar to the one that shows up when changing avatar.

Example:

<script type="text/discourse-plugin" version="0.8">
  api.onPageChange((url, title) => {
    if (condition) {
     renderModal;
    }
  });
</script>

@Johani can you help out here?

3 Likes

What you need is showModal() and you use it like so

First you require show-modal

const showModal = require("discourse/lib/show-modal").default;

And then use

showModal("modalName") // camelCase

Then all you have to do is decide how to trigger it.

One way to do it is to create a widget and render the modal when it’s clicked like so

<script type="text/discourse-plugin" version="0.8.18">
const showModal = require("discourse/lib/show-modal").default;

api.createWidget("modal-button", {
  tagName: "button.btn.btn-primary",

  html() {
    return "Open Modal";
  },

  click() {
    showModal("avatarSelector");
  }
});
</script>

This creates a button. When that button is clicked, the avatar selector modal will be displayed.

You’d still need to insert that widget somewhere and you have a few options here

So, for example this

<script type="text/x-handlebars" data-template-name="/connectors/topic-above-post-stream/modal-button">
{{mount-widget widget="modal-button"}}
</script>  

Would add the button above topic titles like so

While this would open the modal and everything would display nicely, you’d still need to add a few more things to make sure the functionality - like changing the avatar - works as well. I did not include those bits here because this is an example and because I’m not sure I understand what you want to achieve.

Can you please share a bit more about what you’re trying to achieve?

7 Likes

Hey Joe,

I’m interested by this feature too. I’ve been able to build most of the script using your example but I don’t know how to build a specific modal ?

As far as I understand, the showModal thing is loading a template ?

Here is what I did :

<script type="text/discourse-plugin" version="0.8.24">
    const showModal = require("discourse/lib/show-modal").default;
    
    // EvE Online Magic SDE
    api.decorateCooked(function(cooked){
        SDD.LoadMeta()
        .then(function(arg){
            return arg.source.GetTable('invTypes').Load();
        })
        .then(function(arg){
            cooked.find('a[href="#evesde"]').each(function(){
                var typeName = $(this).text();
                
                results = _.filter(arg.table.data, function(entry) {
                    return entry[arg.table.c.typeName] === typeName;
                });
                
                if (results.length > 0) {
                    $(this).attr('data-typeid', results[0][arg.table.c.typeID]);
                    $(this).on('click', function(){
                        console.debug('show modal');
                        showModal('eveSdeModal');
                    });
                }
            });
        });
    });
</script>
1 Like

Thanks for sharing this example. In this specific scenario, I went with a jQuery solution that proved to be enough. It inspired me for other changes we wanted to do, though.

1 Like

Hey warlof,

As far as I can understand, showModal renders a template from this folder:
https://github.com/discourse/discourse/tree/master/app/assets/javascripts/discourse/templates/modal

And the name you provide is in CamelCase while the file name is separated by dashes, reason being this:
https://github.com/discourse/discourse/blob/master/app/assets/javascripts/discourse/lib/show-modal.js.es6#L44

I hope that helps.

1 Like

Hum, that’s annoying without getting a plugin but using only the frontend modification.

I get that Discourse was using bootstrap as main framework. I’ve been able to use standard modal but I don’t know if it’s the best way to do it (without making a plugin, apparently it is).

<div role="dialog" id="evesde-modal" class="modal d-modal fixed-modal ember-view in" style="display: none; padding-right: 0px;">
    <div class="modal-outer-container" role="document">
        <div class="modal-middle-container">
            <div class="modal-inner-container">
                <div class="modal-header">
                    <div class="modal-close">
                        <a class="close" data-dismiss="modal">
                            <i class="fa fa-times d-icon d-icon-times"></i>
                        </a>
                    </div>
                    <div class="title">
                        <h3 id="evesde-modal-title"></h3>
                    </div>
                </div>
                <div id="modal-alert" style="display: none;"></div>
                <div id="evesde-modal-description" class="avatar-selector modal-body ember-view"></div>
            </div>
        </div>
    </div>
</div>

At least it is working (almost - clicking somewhere else out of the modal isn’t closing it for some reason that I can’t get).

For the close I went with a simple jQuery event listener:

$("body").on("click", "#badge-modal-close", function() {
  $(".badge-modal").fadeOut(600);
});

Notice that the selector is body because in my example I needed to add html dynamically and this way I don’t need to rebind/add listeners only after.

Creating a new modal involves 4 steps.

1. creating the template for the modal

You can create a new template like so:

<script type="text/x-handlebars" data-template-name="modal/custom-modal">
{{#d-modal-body title="custom_modal_title" class="custom-modal"}}
  Your content goes here!
{{/d-modal-body}}
</script>

A few quick notes about this:

  • The path you set in data-template-name must start with modal/ followed by your template name
  • {{d-modal-body}} is required
  • The title attribute on {{d-modal-body}} is the title that show at the top of the modal
  • it’s a good idea to add unique classes to your modal

2. Adding a title for the modal

If you notice above, the title for {{d-modal-body}} is set to “custom_modal_title” so we need to define what that is and you can do it like so

let currentLocale = I18n.currentLocale();
I18n.translations[currentLocale].js.custom_modal_title = "My custom modal";

3. creating a trigger for the modal

For this I’m going to be reusing the method I described in my previous post. Create a widget and show the modal when the widget is clicked but anything equivalent is fine as well. All you need is to get showModal() to fire somehow depending on what you have to work with.

const showModal = require("discourse/lib/show-modal").default;

api.createWidget("modal-button", {
 tagName: "button.btn.btn-primary",

 html() {
   return "Open Modal";
 },

 click() {
   showModal("customModal"); // name is the same you use for the new modal template in camelCase
 }
});

4. Add the widget to a template somewhere

Again, I’ll go back to my previous example and use

<script type="text/x-handlebars" data-template-name="/connectors/topic-above-post-stream/modal-button">
  {{mount-widget widget="modal-button"}}
</script> 

which would add the widget (button) right under the topic title on topic pages.

Now all you have to do is put all of this together and add it to the header section of your theme / theme component

<script type="text/x-handlebars" data-template-name="modal/custom-modal">
{{#d-modal-body title="custom_modal_title" class="custom-modal"}}
  <h1>Hello World!</h1>
{{/d-modal-body}}
</script>


<script type="text/discourse-plugin" version="0.8.18">
let currentLocale = I18n.currentLocale();
I18n.translations[currentLocale].js.custom_modal_title = "My custom modal";

const showModal = require("discourse/lib/show-modal").default;

api.createWidget("modal-button", {
  tagName: "button.btn.btn-primary",

  html() {
    return "Open Modal";
  },

  click() {
    showModal("customModal");
  }
});
</script>

<script type="text/x-handlebars" data-template-name="/connectors/topic-above-post-stream/modal-button">
{{mount-widget widget="modal-button"}}
</script> 

and you will have created a custom modal. :+1:

I’ve put together a small demo on theme-creator. Just visit any topic page and click on the “open modal” button below the title.

9 Likes

Oh, I figure I had to create files into Discourse to handle the modal templating.

Actually I’m doing this by inserting raw bootstrap modal definition :

$('#evesde-modal-title').text(data.name)
    .prepend(
        $('<img>').attr('src', `//image.eveonline.com/Type/${typeID}_32.png`)
            .attr('alt', data.name)
            .css('margin-right', '10px'));
$('#evesde-modal-description').html(content);
$('#evesde-modal').modal('toggle');

After few tests, I assume the transformation have to be applied this way :
$('#evesde-modal-title').text(data.name) is becoming I18n.translations[currentLocale].js.eve_sde_modal_title = data.name;
$('#evesde-modal').modal('toggle'); is becoming showModal('eveSdeModal');

I don’t know how to update the body dynamically (which have to be raw html) though. I tried to use jquery in order to retrieve the body and update it, but it doesn’t do anything.

Also, I have an image to put before the text of the modal header. I don’t figure how to handle this properly neither.

The reason it doesn’t work is because the modal itself is dynamically loaded. So when your script fires, the element you’re targeting is not in the DOM, and once it is a part of the DOM (when you trigger it with showModal) your script will have already fired.

If you want to fire a script when the modal is loaded, you can use the modal:body-shown app event.

Here’s a commented example:

<script type="text/discourse-plugin" version="0.8">
// when the modal:body-shown event fires, let's try to do some work. we pass the 
// msg object to try and target a specific modal
api.onAppEvent("modal:body-shown", msg => {
  // let's get the title of the modal from the msg object
  let title = msg.title;
  // and compare the current modal's title to see if it has the same title as 
  // the one we want to target. This is the same string you use for the title 
  // attribute you define for the d-modal-body component in your custom modal 
  // template
  if (title != "custom_modal_title") {
    // not the modal you want to target
    return;
  } else {
    // this is the one we want, so do your work here
    $(".modal-body").html("Hey there!");
    $('.modal-header').prepend('<h1>Above modal title</h1>')
  }
});
</script>

just a quick note, when you prepend things to modal-header they will not display correctly right away. This happens because modal-header is set to display: flex.

So, you have to play around with its styles a little bit, and even though I don’t understand why you’d want to add an image there it should be possible to prepend an image to the modal header using the same method.

9 Likes

Hi Johani,

Thanks a lot for your valuable inputs.
About the image, actually, it is must more a design assets than a plain raw image.

The final render could be one of those two :

I’ll play around with your events things which should allow me to definitively fix this modal display thing properly.

1 Like

Well, just discover how model was passed to the showModal function and it unlocked a tons of things in my head :stuck_out_tongue:

Now I’m quite happy with what I have and assume it’s properly implemented according to core guidelines.

<script type="text/javascript" src="https://cf.eve-oj.com/js/EVEoj-0.3.x.min.js"></script>
<script type="text/javascript">
    var SDEVer = 201611140,
        SDD = EVEoj.SDD.Create('json', {'path': 'https://cf.eve-oj.com/sdd/' + SDEVer});
</script>
<script type="text/x-handlebars" data-template-name="modal/eve-sde-modal">
    {{#d-modal-body title="eve_sde_modal_title" class="eve-sde-modal"}}
    <p data-typeid="{{ model.type_id }}">{{{ model.description }}}</p>
    {{#if model.attributes.shield }}
    <h4>Shield</h4>
    <table>
        <tbody>
            <tr>
                {{#each model.attributes.shield as |attribute|}}
                <td>
                    <img src="{{ attribute.img.src }}" alt="{{ attribute.img.alt }}" /> {{ attribute.value }}
                </td>
                {{/each}}
            </tr>
        </tbody>
    </table>
    {{/if}}
    {{#if model.attributes.shield }}
    <h4>Armor</h4>
    <table>
        <tbody>
            <tr>
                {{#each model.attributes.armor as |attribute|}}
                <td>
                    <img src="{{ attribute.img.src }}" alt="{{ attribute.img.alt }}" /> {{ attribute.value }}
                </td>
                {{/each}}
            </tr>
        </tbody>
    </table>
    {{/if}}
    {{#if model.attributes.shield }}
    <h4>Hull</h4>
    <table>
        <tbody>
            <tr>
                {{#each model.attributes.hull as |attribute|}}
                <td>
                    <img src="{{ attribute.img.src }}" alt="{{ attribute.img.alt }}" /> {{ attribute.value }}
                </td>
                {{/each}}
            </tr>
        </tbody>
    </table>
    {{/if}}
    {{/d-modal-body}}
</script>
<script type="text/discourse-plugin" version="0.8.24">
    const ESI_BASE_URI = 'https://esi.evetech.net';
    const RESISTS = [109, 110, 111, 113, 267, 268, 269, 270, 271, 272, 273, 274];
    const showModal = require('discourse/lib/show-modal').default;
    let currentLocale = I18n.currentLocale();

    // EvE Online Magic SDE
    api.onAppEvent('modal:body-shown', msg => {
        if (msg.title !== 'eve_sde_modal_title')
            return;
            
        var typeID = $('.modal-body p').attr('data-typeid');
        $('.modal-header .title h3 img').remove();
        $('.modal-header .title h3').prepend(
            $('<img>').attr('src', `//image.eveonline.com/Type/${typeID}_32.png`)
                      .attr('alt', 'Icon')
                      .css('margin-right', '10px'));
    });
    
    api.decorateCooked(function(cooked) {
        SDD.LoadMeta()
            .then(function(arg) {
                return arg.source.GetTable('invTypes').Load();
            })
            .then(function(arg) {
                cooked.find('a[href="#evesde"]').each(function() {
                    var typeName = $(this).text();
                    results = _.filter(arg.table.data, function(entry) {
                        return entry[arg.table.c.typeName] === typeName;
                    });
                
                    if (results.length > 0) {
                        $(this).attr('data-typeid', results[0][arg.table.c.typeID]);
                        $(this).on('click', function() {
                            var typeID  = $(this).attr('data-typeid');
                            var typeUri = ESI_BASE_URI + '/latest/universe/types/' + typeID + '/';
                        
                            $.ajax({
                                data : {
                                    language: 'fr'
                                },
                                dataType: 'json',
                                headers: {
                                    'Accept-Language': 'fr',
                                    'X-User-Agent': 'discourse/1.0.0 (Warlof Tutsimo - Daerie Inc. - Get Off My Lawn)'
                                },
                                type: 'GET',
                                url: typeUri
                            }).done(function(data) {
                                SDD.LoadMeta()
                                    .then(function(arg) {
                                        return arg.source.GetTable('dgmAttributeTypes').Load();
                                    })
                                    .then(function(arg) {
                                        var sdeModel = {
                                            'type_id': 0,
                                            'description': '',
                                            'attributes': {
                                                'shield': [],
                                                'armor': [],
                                                'hull': []
                                            }
                                        };
                                        
                                        _.each(data.dogma_attributes, function(dgmAttribute) {
                                            if (RESISTS.indexOf(dgmAttribute.attribute_id) >= 0) {
                                                results = _.filter(arg.table.data, function(entry) {
                                                    return entry[arg.table.c.attributeID] === dgmAttribute.attribute_id;
                                                });
                                                
                                                if (results.length > 0) {
                                                    var attribute = {
                                                        'id': 0,
                                                        'name': '',
                                                        'value': '',
                                                        'percent': 0,
                                                        'img': {
                                                            'alt': '',
                                                            'src': ''
                                                        }
                                                    };
                                                    
                                                    switch (results[0][arg.table.c.iconID]) {
                                                        case 1393:
                                                            attribute.img.src = 'https://forums.clandaerie.com/uploads/default/original/1X/a948a8f3d0ac5a1628c110071aceef976db0fae5.png';
                                                            break;
                                                        case 1394:
                                                            attribute.img.src = 'https://forums.clandaerie.com/uploads/default/original/1X/4d8ef4c39d6baa27845ff8931c63fc4c08cc7bbb.png';
                                                            break;
                                                        case 1395:
                                                            attribute.img.src = 'https://forums.clandaerie.com/uploads/default/original/1X/281dbfadd89530cc1142a4b93fdc7ada671297eb.png';
                                                            break;
                                                        case 1396:
                                                            attribute.img.src = 'https://forums.clandaerie.com/uploads/default/original/1X/b3f1e97f6144ec32c601294bfe2a2abc7cd9adfd.png';
                                                            break;
                                                    }
                                                    
                                                    attribute.id = dgmAttribute.attribute_id;
                                                    attribute.name = results[0][arg.table.c.displayName];
                                                    attribute.value = dgmAttribute.value;
                                                    attribute.percent = Math.round((1 - attribute.value) * 100);
                                                    attribute.img.alt = attribute.name.replace(/Armor|Shield|Structure/gi, '').trim();
                                                    
                                                    switch (results[0][arg.table.c.categoryID]) {
                                                        // Shield
                                                        case 2:
                                                            sdeModel.attributes.shield.push(attribute);
                                                            break;
                                                        // Armor
                                                        case 3:
                                                            sdeModel.attributes.armor.push(attribute);
                                                            break;
                                                        // Structure
                                                        case 4:
                                                            sdeModel.attributes.hull.push(attribute);
                                                            break;
                                                    }
                                                }
                                            }
                                        });
                                        
                                        sdeModel.type_id = typeID;
                                        sdeModel.description = data.description
                                            .replace(/\r\n/gi, '<br/>')
                                            .replace(/\n\n/gi, '<br/>')
                                            .replace(/\n/gi, '<br/>')
                                            .replace(/<color='0xff([a-f0-9]{6})'>([a-z0-9,.']+)<\/color>/gmi, function(match, p1, p2, offset, string) {
                                                return `<span style="color: #${p1};">${p2}</span>`;
                                            })
                                            .replace(/<a href=showinfo:([0-9]+)>([éèàçù \w]+)<\/a>/gmi, function(match, p1, p2, offset, string) {
                                                return p2;
                                            });
                                            
                                        I18n.translations[currentLocale].js.eve_sde_modal_title = data.name;
                                        
                                        showModal('eveSdeModal', {
                                            'model': sdeModel
                                        });
                                    });
                            });
                        });
                    }
                });
            });
    }, {onlyStream: true});
</script>
5 Likes

thank you for the tutorial to build a customized modal.
However I’d like to know how to bind an event (like onclick) in the template?

Hi,
I’m showing a modal when we open a forum. Is there a simple way to prevent the modal from closing when we click outside?