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

Support for Dynamic Content Projection #8563

Open
mhevery opened this issue May 10, 2016 · 50 comments
Open

Support for Dynamic Content Projection #8563

mhevery opened this issue May 10, 2016 · 50 comments
Assignees
Labels
area: core Issues related to the framework runtime core: content projection feature: under consideration Feature request for which voting has completed and the request is now under consideration feature Issue that requests a new feature state: Needs Design
Milestone

Comments

@mhevery
Copy link
Contributor

mhevery commented May 10, 2016

Use Case

<ng-content> allows for static (compile time resolution) projection of content. There are cases where the projection needs to be controlled programmatically. A common example is when the parent component would like to wrap/decorate the child components during projection.

Mental Model

The mental model would be that <ng-content> is for fast static projection, but for dynamic projection one would use a query to get a hold of the child components in the form of ViewRefs which could then be inserted programmatically at any location in the tree.

Possible Solution

  • ContentViewRef extends ViewRef
  • @Content(selector) Provides a way to select direct children content elements using CSS selectors. (This would be after directives such as ngFor would unroll the content, and it would be in the correct order.)
  • This would allow selection of elements rather than directives. These ContentViewRefs could then be reinserted to any ViewContainerRef
  • We would need ngViewOutlet (similar to ngTemplateOutlet).
@Component({
  template: `
  <div *ngFor="let view of allViews">
    <template ngViewOutlet="view"></template>
  </div>
  <ng-content><!-- project non selected nodes, such as text nodes --></ng-content>
`
})
class MySelect {
  @Content('*') allViews: ContentViewRef[];

  // another example
  @Content('my-option') optionViews: ContentViewRef[];
}
@Component({
  selector: 'app',
  template: `
<my-select>
     Option
    <my-option>header</my-option>
    <my-option *ngFor="let item in items">unrolled items {{item}}</my-option>
    <my-option-group>
        <my-option>
            foo <b>bar</b>
            <other-directive />
</my-option>
        <my-separator></my-separator>
        <my-option></my-option>
</my-option-group>
</my-select>
`
})
class App {}
@mhevery
Copy link
Contributor Author

mhevery commented May 10, 2016

@tbosch looking for your input on how feasible this is.
@pkozlowski-opensource & @Mlaval looking for your input if this fits your use case.
/cc @vsavkin

@pkozlowski-opensource
Copy link
Member

@mhevery thnx for opening this one. After reading through the proposal it looks like it would cover use-cases we've discussed yesterday plus solve some other ones I've bumped into previously. So it is all good from this perspective.

There would be much more details to figure out and APIs to design, but IMO we can start working off your initial proposal. It would be great to have @tbosch input and see how we can move forward on this item.

@tbosch
Copy link
Contributor

tbosch commented May 11, 2016

Technically this would work.
However, I don't like calling these projected elements view as:

  • these ContentViews consist of always only one element, not potentially multiple ones.
  • we explain to our users that a "View" is an instance of a template, which is either the content of the template property in the @Component annotation or an embedded template via <template> or <...*...>. These ContentViews are none of this.
  • Using a ViewRef would require using an AppView internally to keep track of the elements. However, a AppView class contains a lot of logic that is not needed for this use case (e.g. change detectors, debug information, ...) that is not needed here.

@tbosch
Copy link
Contributor

tbosch commented May 11, 2016

Under the cover, we would do the following:

  • for each property annotation with @Content, collect the list of matching elements from the content area of the component. Note that this needs to to already recurse into projected ViewContainerRefs (e.g. from projected ngFors). I.e. the property needs to be updated whenever a projected ngFor unrolls.
  • have an API to insert these nodes / ViewContainerRefs after/before other nodes.

Regarding how / when to update the property: Use a QueryList as value for the property annotated with @Content as well.

  • i.e. use the same algorithm we already use for marking QueryLists as dirty when e.g. an ngFor unrolls
  • i.e. update the QueryList after the content has been checked only if the query is dirty.

@tbosch
Copy link
Contributor

tbosch commented May 11, 2016

Inserting the nodes requires a container that keeps track of them:

Given the following example:

<template [ngIf]>
  <template [ngIf]></template>
</template>

Here, the nodes of the nested ngIf template are siblings to the nodes of the parent ngIf template in the DOM. When the parent ngIf becomes false, it also needs to detach the nodes of the nested ngIf template. For this to work, the root nodes of a view are represented as a list that contains either plain nodes or AppElements. When a view needs to be attached/detached, it loops over these nodes and recursed into the root AppElements ViewContainerRef as well.

For ContentViewRefs we need a similar mechanism. I.e. if nodes are inserted at the top level of a View, we need to keep track of the inserted nodes on the AppElement so that we can remove them when needed. It could be a ViewContainerRef or a different container.

@tbosch
Copy link
Contributor

tbosch commented May 11, 2016

Given the above comments / analysis, I think we should do the following:

class MySelect {
  // query for nodes:
  @ContentChildren('my-option') optionEls: QueryList<ElementRef>;

  // inserting nodes:
  someAction() {
    this.someViewContainer.insertElements(this.optionEls);
  }
}

As the optionEls could contain an unrolled ngFor, ViewContainerRef.insertElements needs to subscribe to changes of the QueryList and append new nodes / detach old nodes when the QueryList changes.

Also, we should change the semantics of @ContentChildren / @ViewChildren as follows:

  • when given a string, always do a css selector for this element / elements. Don't special case elements that have references on them any more. Right now, @ContentChildren('someString') only selects elements that have a reference someString, e.g. <div #someString>.
  • if no read property is given in @ContentChildren / @ViewChildren, always return the ElementRefs. If a user needs a special directive / component, he should use the read property. Right now, @ContentChildren(...) returns the component instance if there is one present on that element and otherwise an ElementRef.

Finally, we should rename ViewContainerRef and its properties:

  • ViewContainerRef -> ContainerRef
  • ViewContainerRef.views -> ContainerRef.entries: Array<QueryList | ViewRef>

@tbosch
Copy link
Contributor

tbosch commented May 11, 2016

Applied to the use case of wrapping projected elements:

@Component({
  template: `
  <div *ngFor="let contentEl of allContentEls">
    <template [ngViewOutlet]="contentEl"></template>
  </div>
`
})
class MySelect {
  @ContentChildren('*') allContentEls: QueryList<ElementRef>;
}

I.e. ngViewOutlet will support taking a element directly, wrap it into a QueryList that just contains that single element and call ViewContainerRef.insertElements on its ViewContainerRef.

@pkozlowski-opensource
Copy link
Member

@tbosch the latest proposal looks really great! I still need to go over all the use-cases I've got on my mind, but looks like a very promising start.

One question for now, though: would I be able to get directives instances present on a given Element so I can make decisions based on this? If so, what would be the proposed API?

@tbosch
Copy link
Contributor

tbosch commented May 11, 2016

To get directives, you have to query for them as well:

class MySelect {
  @ContentChildren('*', read: MyOption) allContentOptions: QueryList<MyOption>;
}

We could change this to allow an array in read:

class MySelect {
  @ContentChildren('*', read: [MyOption, MyOptionGroup]) allContentOptions: QueryList<MyOption | MyOptionGroup>;
}

Or if you need the combination of ElementRef and MyOption:

class MySelect {
  @ContentChildren('*', readTuple: [ElementRef, MyOption, MyOptionGroup]) allContentOptions: QueryList<Array<ElementRef | MyOption | MyOptionGroup>>;
}

The QueryList would always contain an array with 3 entries with the indices:

  • 0: ElementRef instance
  • 1: MyOption instance if existing or null
  • 2: MyOptionGroup instance if existing or null

@lacolaco
Copy link
Contributor

Can does this feature allow us to do processing like $compile ?

@Component({
  selector: 'my-cmp',
  template: `
    <template [ngViewOutlet]="dynamicTemplate"></template>
`
})
class MyComponent {
  dynamicTemplate = `<p>Hello</p>`; 
}

renderer HTML

<my-cmp>
  <p>Hello</p>
</my-cmp>

@mhevery
Copy link
Contributor Author

mhevery commented May 13, 2016

@laco0416 sorry this has nothing to do with $compile it is more of a custom transclude

@tbosch
Copy link
Contributor

tbosch commented May 16, 2016

@pkozlowski-opensource @mhevery I changed my proposal above to use ElementRefs instead of plain DOM elements. This more aligned to how we give users access to elements in DI.

Also, I think we should only support this API for elements, not for text nodes (which would only be selected by * anyways). Text nodes are just too brittle when it comes to how many there are / looping over them (e.g. a user just adding whitespace between elements results in extra entries).

@mhevery
Copy link
Contributor Author

mhevery commented May 16, 2016

@tbosch agreed.

@tbosch
Copy link
Contributor

tbosch commented Jun 13, 2016

Talked with @mhevery a bit more how we can prevent breaking changes. We came up with the following:

  1. Introduce a new ElementContainerRef which has a addElements(els: QueryList<ElementRef>) -> no need to change ViewContainerRef
  2. Introduce special selector prefix css:... (e.g. @ContentChildren('css:my-conent')). With that, Angular will use css selectors to find elements. Also, the result would always be ElementRefs (which could still be overwritten via the read property) -> no breaking changes needed for queries either.

@pkozlowski-opensource
Copy link
Member

@tbosch is this new plan a temporary measure just to avoid breaking changes? Or a proposal for the final solution?

@mhevery
Copy link
Contributor Author

mhevery commented Jun 13, 2016

@pkozlowski-opensource the new plan is the actual solution. (since we don't see any benefits of adding breaking changes)

@choeller
Copy link
Contributor

@tbosch Is the css: prefix supposed to land in 2.0? We are currently implementing a component-library that could really benefit from this, as we are now adding dozens of dumb directives just to make a selector "queryable" (also this destroys our tslint statistics, because of wrong selectors for Directives :D). Would be cool to know if this is planned to land in near future! thanks in advance!

@patrickmichalina
Copy link

I am unsure if I've read this issue correctly. Is there currently no other way to insert a component into another component (i.e. nested) programmatically? ViewContainerRef.createComponent() adds the new component instance as a sibling! This is strange behavior and not sure if it is intended.

@Mutmansky
Copy link

Mutmansky commented Aug 31, 2016

@patrickmichalina I've done a bunch of reading on this issue and I believe the behavior of ViewContainerRef.createComponent() is expected. Furthermore, I thought I had found a way to create the component as a child using the ComponentResolver and getting a ComponentFactory and calling create() on that factory. However, that does not seem to work as expected (see issue: 10523).

@tolemac
Copy link
Contributor

tolemac commented Sep 2, 2016

I was thinking about post a new Feature Request Issue, but I would like that someone here check out that before.
I think this issue is related.

That is about ViewContainerRef.createEmbeddedView and @ContentChildren or @ViewChildren, the QueryList property don't detect when a child is added using createEmbeddedView.
I have a SO question here and a plunkr that reproduce it: http://plnkr.co/edit/JH7acOXvOOnml53WoIk6?p=preview

I think this feature covert my problem, am I right?

@pkozlowski-opensource
Copy link
Member

Another excellent use case showing limitations of the current content projection + query system: #20810 (comment)

@AshMcConnell
Copy link

My use case is, I'm creating a grid component with sensible defaults for header / filter and content layout.

For the filter I need to use ContentChildren to get a list of Filters (in order to subscribe to their changes).

Unfortunately ContentChildren doesn't find the FilterComponents when I use the default layout ng-template inside the grid component.

It would be great to find a solution for this.

@BenRacicot
Copy link

BenRacicot commented Dec 18, 2019

Hopefully I'm caught up but my use case is for a stepper that automatically picks up [step]s for content. looking for dynamic content projection for this:

<stepper>
    <section step></section>
    <section step></section>
    <section step></section>
</stepper>

stepper.component uses ng-content to transclude the steps but @ContentChildren comes up empty...
@ViewChild.nativeElement.querySelectorAll('section') at least gets the sections to work with...
Screen Shot 2019-12-17 at 7 47 16 PM

@waterplea
Copy link
Contributor

@BenRacicot in your case step should be a structural directive that caries TemplateRef with it and inside stepper you would have ContentChildren and template outlet to instantiate correct template. This issue is more about dynamically moving things around instead of static select and project as.

@vthinkxie

This comment has been minimized.

@rocifier
Copy link

It would be nice if this solution could also make use of base class components as generic types.

@dlebee

This comment has been minimized.

@ayslanjohnson
Copy link

I Used ng-content with select on component. And on html ngFor directly with button to create buttons list


@Component({
    selector: 'button-actions',
    template: `
        <ng-content select="[mat-mini-fab]"></ng-content>`,
})


<button-actions>
    <button mat-mini-fab *ngFor="let btn of buttonList" (click)="btn.action()">
        <mat-icon>{{btn.icon}}</mat-icon>
    </button>
</button-actions>

@rocifier
Copy link

rocifier commented Oct 20, 2020

@ayslanjohnson but how do you reference those buttons in your component to allow a developer to use any component type they want inside a nested structure? When doing something like the following:

@ContentChildren(ButtonComponent) buttons: QueryList<ButtonInterface>;

You are tied to only working with ButtonComponent classes and not their inheritance or even their abstract interface because you need to specify a component class in @ContentChildren (breaks Dependency Inversion principle).

@davidpanic
Copy link

This is quite an old issue with no update, could someone with the knowledge comment on the state of this?

It's been a big headache for me as I'm trying to dynamically create a MatTabGroup, but it uses @ContentChildren which I haven't been able to inject in any way that I've tried. I've tried using appendChild on the elements, using projectableNodes, setting the variable directly on the instance and none of them worked.

@alexdecoder
Copy link

I am looking to do something very similar to extend the functionality of some generic components I have. This functionality would be very useful for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area: core Issues related to the framework runtime core: content projection feature: under consideration Feature request for which voting has completed and the request is now under consideration feature Issue that requests a new feature state: Needs Design
Projects
None yet
Development

No branches or pull requests