Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can't create a CurriedComponentDefinition programmatically #17509

Closed
Herriau opened this issue Jan 23, 2019 · 1 comment
Closed

Can't create a CurriedComponentDefinition programmatically #17509

Herriau opened this issue Jan 23, 2019 · 1 comment

Comments

@Herriau
Copy link
Contributor

Herriau commented Jan 23, 2019

The ability to create curried component definitions via the (component ...) helper has proven tremendously useful in a variety of patterns. Often times however we find ourselves wanting to generate these curried definitions from the JS side (inside a component or controller class), which is not currently possible.

Examples

The list is long, but a couple of use cases that we have run into lately:

Example 1 - Yielding a collection of pre-configured component definitions

some-component/template.hbs

{{yield (hash
    componentA=(component 'my-comp-a' onAdd=(action addItem) onDelete=(action deleteItem))
    componentB=(component 'my-comp-b' onAdd=(action addItem) onDelete=(action deleteItem))
    ...
    componentZ=(component 'my-comp-z' onAdd=(action addItem) onDelete=(action deleteItem))
)}}

Given a large enough set of components to yield, this becomes painful to maintain, instead we'd prefer to programmatically generate these curried component definitions:

some-component/component.js

export default Component.extend({
    contextualComponents: computed(function() {
        const onAdd = () => this.addItem();
        const onDelete = () => this.deleteItem();

        return _.mapValues(
            {
                componentA: 'my-comp-a',
                ...
                componentZ: 'my-comp-z',
            }
            c => createCurriedComponentDefinition(c, {onAdd, onDelete})
        );
    })
});

Note that createCurriedComponentDefinition() is the missing piece here.

some-component/template.hbs

{{yield contextualComponents}}

Example 2 - Overlay container

This example is obviously simplified quite a bit, but here it is in essence:

We would like to render out-of-band overlays such as modals or notifications at the top-most level of our application. For this purpose let's create a "host" component instantiated in our application template that will house any number of overlay components we throw at it:

application.hbs

...
{{overlay-container}}
...

components/overlay-container/template.hbs

{{#each overlays as |overlay|}}{{component overlay}}{{/each}}

Our host component will read the component definitions for the overlays it's supposed to render from a globally accessible location: the overlay-central service, whose job is to do the book-keeping of which overlays are currently displayed on screen:

components/overlay-container/component.js

export default Component.extend({
    overlayCentral: inject('overlay-central'),
    overlays: reads('overlayService.overlays'),
});

services/overlay-central.js

export default Service.extend({
    overlays: computed(() => []),
    renderOverlay(componentDefinition) {
        this.overlays.pushObject(componentDefinition);
    }
});

Let's suppose we have a modal component:

components/modal/template.hbs

{{#if title}}<h1>{{title}}</h1>{{/if}}
<p>{{message}}</p>
<div class="modal__options">
    {{#each options as |option|}}
        <button {{action option.action}}>{{option.label}}</button>
    {{/each}}
</div>

Modals could be instantiated like any other components within the template of a given route, and it makes a lot of sense in a variety of scenarios, but in some other cases, modals may not be in the context of any particular route, and are rather application-wide concerns. For the purpose of handling the latter case, let's create a modal service that exposes several utility methods:

services/modal-central.js

export default Service.extend({
    overlayCentral: inject(),
    createModal(attrs) {
        this.overlayCentral.renderOverlay(
            createCurriedComponentDefinition('modal', attrs);
        );
    },
    alert(message, title = null) {
        return new RSVP.Promise(resolve => {
            this.createModal({
                title,
                message, 
                options: [
                    {label: 'Ok', action: resolve}
                ]
            });
        });
    },
    confirm(message, title = null) {
        return new RSVP.Promise(resolve => {
            this.createModal({
                title,
                message, 
                options: [
                    {label: 'Cancel', action: () => resolve(false)}
                    {label: 'Ok', action: () => resolve(true)}
                ]
            });
        });
    }
});

Note that createCurriedComponentDefinition() is the missing piece here.

Example usage from a route:

pods/some-route/route.js

export default Route.extend({
    modalCentral: inject(),
    actions: {
        async delete() {
            if (await modalCentral.confirm('Are you sure?')) {
                // Handle deletion here...
            }
        }
    }
});

Workarounds

Pass around hashes instead of curried component definitions

Piggybacking off our 2nd example here, we could have our overlay-container component feed off of a collection of objects describing what needs to be instantiated, and have it create the actual component definitions:

components/overlay-container/template.hbs

{{#each overlays as |overlay|}}
    {{#if (eq overlay.type "modal")}}
        <Modal
            @title={{overlay.title}}
            @message={{overlay.message}}
            @options={{overlay.options}}
        />
    {{else if (eq overlay.type "notification")}}
        <Notification
            @title={{overlay.title}}
            @message={{overlay.message}}
            @options={{overlay.options}}
            @timing={{overlay.timing}}
        />
    {{else if ...}}
    ...
    {{/if}}
{{/each}}

However this forces overlay-container to suddenly know an awful lot about the type of overlays that it will house, and it makes it difficult for our ember addons to leverage this overlay-container for additional types of overlay.

If HTMLBars had a spread operator (from hash to named attributes), then this would also greatly simplify this particular problem:

{{#each overlays as |overlay|}}
    {{component overlay.componentName ...overlay.attrs}}
{{/each}}

but this is as far as I know not possible.

Homemade createCurriedComponentDefinition()

We have used a custom mechanism to create what we call component closures (they are essentially curried component definitions). Here is the outline:

createCurriedComponentDefinition(componentName, attrs) {
    // 1. Resolve componentName to a component class
    // 2. Subclass the resolved class and pass `attrs` to `.extend()`
    // 3. Register this subclass with the container under a randomly generated name
    // 4. Return this randomly generated name
}

We have used this successfully since the early days of Ember, before the nested form of the component helper was even a thing, but it has the following downsides:

  1. It uses the private component-lookup:main.
  2. It's probably quite a bit less performant, although we haven't measured it.
  3. You can use (component) to further curry a curried component definition that was generated with createCurriedComponentDefinition(), but the opposite is not true.
@chancancode
Copy link
Member

I personally think this is a good feature and relates to emberjs/rfcs#432 and emberjs/rfcs#434. It will need a design proposal as an RFC. I see the main difficulty as how to pass "bound" values to this JavaScript API. Let's move this discussion into emberjs/rfcs#434.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants