Angular2/Typescript - Parent/Child Directive(?)
I'm new to, and very much still learning, Angular2/Typescript/javascript. As a result, I'm not entirely sure how to title my question. The basis of my app is a card game. The premise (relative to my struggle) is that the game has 2 players and each player has a hand of 5 cards. I have API calls to build/return the hand of cards.
In my app.component template, I have 2 div blocks; one for each players' hand of cards. Currently, I have it working by building two distinct arrays of cards (named p1cards and p2cards). Here is the relative code for that:
<div class="player1Cards" id="player1Cards">
<ul class="list-group">
<div draggable *ngFor="let card of p1cards" [dragData]="card"
class="list-group-item">
<img src="{{cardBluePath + card.fileName}}">
</div>
</ul>
</div>
<div class="player2Cards" id="player2Cards">
<ul class="list-group">
<div draggable *ngFor="let card of p2cards" [dragData]="card"
class="list-group-item">
<img src="{{cardBluePath + card.fileName}}">
</div>
</ul>
</div>
And here is the actual export class of the entire AppComponent:
#Injectable()
export class AppComponent implements OnInit
{
#ViewChild(ModalComponent) errorMsg: ModalComponent;
errorMessage: string;
gameBoard: GameBoard[];
name: {};
mode = 'Observable';
//we need a gameboard (maybe not)
//we need an array of players
player:Player;
players:Player[] = [];
p1cards:Card[] = [];
p2cards:Card[] = [];
droppedItems = [];
//This tells us where the card images can be found
cardBluePath = "/assets/deck/Blue/";
cardRedPath = "/assets/deck/Red/";
//The boardService will handle our API calls
boardService;
//Initialize the API service
constructor(boardService:BoardService) {
this.boardService = boardService;
}
//On load...
ngOnInit()
{
//Create the game
this.boardService.createGame()
.subscribe(
error => this.errorMessage = <any>error);
//Create the players
this.createPlayer(0);
this.createPlayer(1);
}
createPlayer(player: number)
{
var playerName;
if (player == 0) {playerName = "Player1"} else {playerName = "Player2"};
//We'll make a call to the API to build the hand of cards
this.boardService.buildHand(player)
.subscribe(
cardList =>
{
var cardData = [];
cardData = JSON.parse(cardList.toString());
var i, itemLength, card
itemLength = cardData.length;
for(i=0;i<itemLength;i++)
{
let card = new Card();
Object.assign(card,
{
"cardNum":i,
"id": cardData[i].id,
"displayName": cardData[i].displayName,
"fileName": cardData[i].fileName,
"left": cardData[i].left,
"top": cardData[i].top,
"right": cardData[i].right,
"bottom": cardData[i].bottom,
"level": cardData[i].level,
"native": cardData[i].native
});
if (player == 0) {this.p1cards.push(card)} else {this.p2cards.push(card)};
////this.cards.push(card);
}
//Now we will create the player and feed it the hand
this.player = new Player(playerName);
if (player ==0) {this.player.cardHand = this.p1cards} else {this.player.cardHand = this.p2cards};
this.players.push(this.player);
}
);
}
//When a card is dropped...
onItemDrop(e: any, slot: any)
{
e.dragData.slot = slot;
//Update the object
this.boardService.playCard(slot, e.dragData.card)
.subscribe(result => {
//If the slot is open and the card is played, physically move the item
if (result == "true" )
{
this.droppedItems.push(e.dragData);
this.removeItem(e.dragData, this.p1cards);
}
else{
window.alert("Slot already occupied.");
//this.modalWindow.show()
//this.errorMsg.showErrorMessage("Slot already occupied.");
//this.errorMsg.show();
}
});
}
//Remove the card from the hand
removeItem(item: any, list: Array<any>)
{
let index = list.map((e) => {
return e.cardNum
}).indexOf(item.cardNum);
list.splice(index, 1);
}
}
The createPlayer function is really where the question begins. Currently, it will make the API call and parse the JSON back into an array of cards. Right now, the array of cards lives locally in the AppComponent (as p1cards or p2cards).
What I want to do instead is create a player objects (component) for each player, assign their respective hand of cards, and then put those players in an array. I had that part working (pieces of the code still exist above, but not all of it), but I hit a wall in my *ngFor to display the cards. In pseudocode, I understood what I needed to do, but in practice I couldn't figure it out.
I knew that div class player1Cards needed to be something like "let player of Players where name = player1", and then I needed to iterate over the player.cardHand[] array to display each of the cards. I tried quite a few things, but nothing worked.
So then, after a few hours of Google searching, I came to the conclusion that I needed a child view for the player to handle it. I currently have the following for that:
My player.html is:
<div draggable *ngFor="let card of cardHand" [dragData]="card" class="list-group-item">
<img src="{{cardBluePath + card.fileName}}">
</div>
And my player.ts is:
import { Component, Input, OnInit } from '#angular/core';
import { Card } from './card';
#Component({
selector: 'player',
templateUrl: './player.html',
})
export class Player implements OnInit
{
public cardHand: Card[];
cardBluePath = "/assets/deck/Blue/";
constructor
(
public name: string
)
{}
ngOnInit()
{
}
}
Then in my AppComponent template, I added the block (and Imported the player.ts)
I get an error message on App.Component "inline template:69:16 caused by: No provider for String!". Of all the Google research I performed and all of the changes I tried (ViewChild, Input/Output, Reference), I could not get it to work. I don't recall exactly what I did, but at one point I was able to eliminate the error, but the card array was not getting passed to the player (I wish I had committed or stashed that code).
In my mind, I understand the task at hand, I just can't make it happen. I know I need to create the Player object and feed it the respective cardHand in order for the player html to be able to parse it. I can do that fine in AppComponent, but once I try to do it as a parent/child, I get stuck.
Can someone help get me going in the right direction?
I know I need to create the Player object and feed it the respective
cardHand in order for the player html to be able to parse it. I can do
that fine in AppComponent, but once I try to do it as a parent/child,
I get stuck.
I agree that it makes sense to create an array of player objects, each with an array of cards in their hand. Something like this:
let player1 = {name:'Player 1',hand:[]}
let player2 = {name:'Player 2',hand:[]}
this.players = [player1, player2]
player1.hand.push(this.dealCard())
...
player2.hand.push(this.dealCard())
...
You can then create a player component to show the players (and even a card component to show their hand). In your root template you'll loop through the players, creating your player component and passing in the player data, including their hands.
<player-component *ngFor="let player of players" [player]="player"></player-component>
Make sure your player component has an input to receive the player data:
export class PlayerComponent implements OnInit {
#Input() player: Player;
constructor() { }
ngOnInit() { }
}
Then in the <player-component> template loop through the player's hand and render the cards:
<p>I am {{player.name}}. My hand is:</p>
<ul>
<li *ngFor="let card of player.hand">{{card}}</li>
</ul>
Here is a plunker showing a working demo that's a simplified version of this setup:
https://plnkr.co/edit/5Hz8P7poCb9Ju5IR6MWs?p=preview
You should be able to configure it to the specific setup of your game. Good luck!
in your player component, if you want to access another component:
1. that component needs a import statement on top
2. within the #component section, you need to include it in Providers
3. also include it in the constructor
For more information, visit here: https://angular.io/guide/dependency-injection
Related
I have a side drawer where I'm showing the current cart products selected by the user. Initially, I have a <p> tag saying the cart is empty. However, I want to remove it if the cart has items inside. I'm using an OOP approach to design this page. See below the class I'm working with.
I tried to use an if statement to condition the <p> tag but this seems the wrong approach. Anyone has a better way to do this. See screenshot of the cart in the UI and code below:
class SideCartDrawer {
cartProducts = [];
constructor() {
this.productInCartEl = document.getElementById('item-cart-template');
}
addToCart(product) {
const updatedProducts = [...this.cartProducts];
updatedProducts.push(product);
this.cartProducts = updatedProducts;
this.renderCart();
}
renderCart() {
const cartListHook = document.getElementById('cart-items-list');
let cartEl = null;
if (this.cartProducts.length === 0) {
cartEl = '<h2>You Cart is Empty</h2>';
} else {
const productInCartElTemplate = document.importNode(
this.productInCartEl.content,
true
);
cartEl = productInCartElTemplate.querySelector('.cart-item');
for (let productInCart of this.cartProducts) {
cartEl.querySelector('h3').textContent = productInCart.productName;
cartEl.querySelector('p').textContent = `£ ${productInCart.price}`;
cartEl.querySelector('span').textContent = 1;
}
}
cartListHook.append(cartEl);
}
}
By the way, the <p> should reappear if the cart is back to empty :) !
With how your code is setup, you would want to reset the list on each render. You would do this by totally clearing out #cart-items-list. Here is a deletion method from this question
while (cartListHook.firstChild) {
cartListHook.removeChild(cartListHook.lastChild);
}
But you could use any method to delete the children of an HTML Node. To reiterate, you would put this right after getting the element by its id.
P.S. You probably want to put more code into your for loop, because it seems like it will only create cart-item element even if there are multiple items in this.cartProducts.
I am using ng2-dragula for drag and drop feature. I am seeing issue when I drag and drop first element(or any element) at the end and then try to add new item to the array using addNewItem button, new item is not getting added to the end. If i don't drop element to the end, new item is getting added at the end in UI.
I want new items to be displayed at the bottom in any scenario. Any help is appreciated.
This issue is not reproducible with Angular 7. I see this happening with Angular 9
JS
export class SampleComponent {
items = ['Candlestick','Dagger','Revolver','Rope','Pipe','Wrench'];
constructor(private dragulaService: DragulaService) {
dragulaService.createGroup("bag-items", {
removeOnSpill: false
});
}
public addNewItem() {
this.items.push('New Item');
}
}
HTML
<div class="container" [dragula]='"bag-items"' [(dragulaModel)]='items'>
<div *ngFor="let item of items">{{ item }}</div>
</div>
<button id="addNewItem" (click)="addNewItem()">Add New Item
I edited the stackblitz from the comment to help visualize the issue. This seems to be triggered when a unit is dragged to the bottom of the list. Updated stackblitz : https://stackblitz.com/edit/ng2-dragula-base-ykm8fz?file=src/app/app.component.html
ItemsAddedOutOfOrder
You can try to restore old item position on drop.
constructor(private dragulaService: DragulaService) {
this.subscription = this.dragulaService.drop().subscribe(({ name }) => {
this.dragulaService.find(name).drake.cancel(true);
});
}
Forked Stackblitz
Explanation
There is some difference between how Ivy and ViewEngine insert ViewRef at specific index. They relay on different beforeNode
Ivy always returns ViewContainer host(Comment node)ref if we add item to the end:
export function getBeforeNodeForView(viewIndexInContainer: number, lContainer: LContainer): RNode|
null {
const nextViewIndex = CONTAINER_HEADER_OFFSET + viewIndexInContainer + 1;
if (nextViewIndex < lContainer.length) {
const lView = lContainer[nextViewIndex] as LView;
const firstTNodeOfView = lView[TVIEW].firstChild;
if (firstTNodeOfView !== null) {
return getFirstNativeNode(lView, firstTNodeOfView);
}
}
return lContainer[NATIVE]; <============================= this one
}
ViewEngine returns last rendered node(last <li/> element)ref
function renderAttachEmbeddedView(
elementData: ElementData, prevView: ViewData|null, view: ViewData) {
const prevRenderNode =
prevView ? renderNode(prevView, prevView.def.lastRenderRootNode!) : elementData.renderElement;
...
}
The solution might be reverting the dragged element back to original container so that we can let built-in ngForOf Angular directive to do its smart diffing.
Btw, the same technique is used in Angular material DragDropModule. It remembers position of dragging element and after we drop item it inserts it at its old position in the DOM which is IMPORTANT.
Lets say I have 3 lists
list: 1 ) bus , plane
list: 2 ) [related to bus] slow , can't fly
list: 3) [related to plane] fast, can fly
In my Ionic Angular project I have successfully made the 1st ion-list. But how can I change the whole ion-list by clicking on the item inside it?
[I get it, its something to do with (click) function, but how I can affect the whole list using typescript]
Edit: I get what you want to achieve. You can do this by creating an intermediary list and using that list in your ngFor. That way you can just simply change the reference of the intermediary list to whatever list you like onClick
export class ListPage {
transportationTypes: string[] = ['bus', 'plane'];
busSpecs: string[] = ['slow', "can't fly"];
planeSpecs: string[] = ['fast', 'can fly'];
currentList: string[] = this.transportationTypes;
itemClicked(type): void {
if (type === 'bus') {
this.currentList = this.busSpecs;
} else if(type === 'plane') {
this.currentList = this.planeSpecs;
} else {
this.currentList = this.transportationTypes;
}
}
}
And in your HTML just call the itemClicked function
<ion-list *ngIf="currentList">
<ion-item *ngFor="let item of currentList" (click)="itemClicked(item)">
{{item}}
</ion-item>
</ion-list>
I'm new to rxjs, and I'm developing an angular multiselect list component that should render a long list of values (500+).
I'm rendering the list based on an UL, I'm iterating over an observable that will render the LI's.
I'm thinking about my options to avoid impacting the performance by rendering all the elements at once. But I don't know whether this is possible or not, and if it's possible what is the best operator to use.
The proposed solution:
On init I load all the data into an Observable. (src) and I'll take 100 elements from it and will put them on the target observable (The one that will be used to render the list)
Everytime that the user reaches the end of the list (the scrollEnd event fires) I'll load 100 elements more, until there are no more values in the src observable.
The emission of new values in the target observable will be triggered by the scrollEnd event.
Find my code below, I still need to implement the proposed solution, but I'm stuck at this point.
EDIT: I'm implementing #martin solution, but I'm still not able to make it work in my code. My first step was to replicate it in the code, to get the logged values, but the observable is completing immediately without producing any values.
Instead of triggering an event, I've added a subject. Everytime the scrollindEnd output emits, I will push a new value to the subject. The template has been modified to support this.
multiselect.component.ts
import { Component, AfterViewInit } from '#angular/core';
import { zip, Observable, fromEvent, range } from 'rxjs';
import { map, bufferCount, startWith, scan } from 'rxjs/operators';
import { MultiSelectService, ProductCategory } from './multiselect.service';
#Component({
selector: 'multiselect',
templateUrl: './multiselect.component.html',
styleUrls: ['./multiselect.component.scss']
})
export class MultiselectComponent implements AfterViewInit {
SLICE_SIZE = 100;
loadMore$: Observable<Event>;
numbers$ = range(450);
constructor() {}
ngAfterViewInit() {
this.loadMore$ = fromEvent(document.getElementsByTagName('button')[0], 'click');
zip(
this.numbers$.pipe(bufferCount(this.SLICE_SIZE)),
this.loadMore$.pipe(),
).pipe(
map(results => console.log(results)),
).subscribe({
next: v => console.log(v),
complete: () => console.log('complete ...'),
});
}
}
multiselect.component.html
<form action="#" class="multiselect-form">
<h3>Categories</h3>
<input type="text" placeholder="Search..." class="multiselect-form--search" tabindex="0"/>
<multiselect-list [categories]="categories$ | async" (scrollingFinished)="lazySubject.next($event)">
</multiselect-list>
<button class="btn-primary--large">Proceed</button>
</form>
multiselect-list.component.ts
import { Component, Input, Output, EventEmitter } from '#angular/core';
#Component({
selector: 'multiselect-list',
templateUrl: './multiselect-list.component.html'
})
export class MultiselectListComponent {
#Output() scrollingFinished = new EventEmitter<any>();
#Input() categories: Array<string> = [];
constructor() {}
onScrollingFinished() {
this.scrollingFinished.emit(null);
}
}
multiselect-list.component.html
<ul class="multiselect-list" (scrollingFinished)="onScrollingFinished($event)">
<li *ngFor="let category of categories; let idx=index" scrollTracker class="multiselect-list--option">
<input type="checkbox" id="{{ category }}" tabindex="{{ idx + 1 }}"/>
<label for="{{ category }}">{{ category }}</label>
</li>
</ul>
NOTE: The scrollingFinished event is being triggered by the scrollTracker directive that holds the tracking logic. I'm bubbling the event from multiselect-list to the multiselect component.
Thanks in advance!
This example generates an array of 450 items and then splits them into chunks of 100. It first dumps the first 100 items and after every button click it takes another 100 and appends it to the previous results. This chain properly completes after loading all data.
I think you should be able to take this and use to for your problem. Just instead of button clicks use a Subject that will emit every time user scrolls to the bottom:
import { fromEvent, range, zip } from 'rxjs';
import { map, bufferCount, startWith, scan } from 'rxjs/operators';
const SLICE_SIZE = 100;
const loadMore$ = fromEvent(document.getElementsByTagName('button')[0], 'click');
const data$ = range(450);
zip(
data$.pipe(bufferCount(SLICE_SIZE)),
loadMore$.pipe(startWith(0)),
).pipe(
map(results => results[0]),
scan((acc, chunk) => [...acc, ...chunk], []),
).subscribe({
next: v => console.log(v),
complete: () => console.log('complete'),
});
Live demo: https://stackblitz.com/edit/rxjs-au9pt7?file=index.ts
If you're concerned about performance you should use trackBy for *ngFor to avoid re-rendering existing DOM elements but I guess you already know that.
Here is a live demo on Stackblitz.
If your component subscribes to an observable holding the whole list to be displayed, your service will have to hold this whole list and send a new one every time an item is added. Here is an implementation using this pattern. Since lists are passed by reference, each list pushed in the observable is simply a reference and not a copy of the list, so sending a new list is not a costly operation.
For the service, use a BehaviorSubject to inject your new items in your observable. You can get an observable from it using its asObservable() method. Use another property to hold your current list. Each time loadMore() is called, push the new items in your list, and then push this list in the subject, which will push it in the observable as well, and your components will rerender.
Here I am starting with a list holding all items (allCategories), every time loadMore() is called, a block of 100 items if taken and placed on the current list using Array.splice():
#Injectable({
providedIn: 'root'
})
export class MultiSelectService {
private categoriesSubject = new BehaviorSubject<Array<string>>([]);
categories$ = this.categoriesSubject.asObservable();
categories: Array<string> = [];
allCategories: Array<string> = Array.from({ length: 1000 }, (_, i) => `item #${i}`);
constructor() {
this.getNextItems();
this.categoriesSubject.next(this.categories);
}
loadMore(): void {
if (this.getNextItems()) {
this.categoriesSubject.next(this.categories);
}
}
getNextItems(): boolean {
if (this.categories.length >= this.allCategories.length) {
return false;
}
const remainingLength = Math.min(100, this.allCategories.length - this.categories.length);
this.categories.push(...this.allCategories.slice(this.categories.length, this.categories.length + remainingLength));
return true;
}
}
Then call the loadMore() method on your service from your multiselect component when the bottom is reached:
export class MultiselectComponent {
categories$: Observable<Array<string>>;
constructor(private dataService: MultiSelectService) {
this.categories$ = dataService.categories$;
}
onScrollingFinished() {
console.log('load more');
this.dataService.loadMore();
}
}
In your multiselect-list component, place the scrollTracker directive on the containing ul and not on the li:
<ul class="multiselect-list" scrollTracker (scrollingFinished)="onScrollingFinished()">
<li *ngFor="let category of categories; let idx=index" class="multiselect-list--option">
<input type="checkbox" id="{{ category }}" tabindex="{{ idx + 1 }}"/>
<label for="{{ category }}">{{ category }}</label>
</li>
</ul>
In order to detect a scroll to bottom and fire the event only once, use this logic to implement your scrollTracker directive:
#Directive({
selector: '[scrollTracker]'
})
export class ScrollTrackerDirective {
#Output() scrollingFinished = new EventEmitter<void>();
emitted = false;
#HostListener("window:scroll", [])
onScroll(): void {
if ((window.innerHeight + window.scrollY) >= document.body.offsetHeight && !this.emitted) {
this.emitted = true;
this.scrollingFinished.emit();
} else if ((window.innerHeight + window.scrollY) < document.body.offsetHeight) {
this.emitted = false;
}
}
}
Hope that helps!
I'm building a site that uses Vue for to power the majority of the UI. The main component is a list of videos that is updated whenever a certain URL pattern is matched.
The main (video-list) component looks largely like this:
let VideoList = Vue.component( 'video-list', {
data: () => ({ singlePost: '' }),
props: ['posts', 'categorySlug'],
template: `
<div>
<transition-group tag="ul">
<li v-for="(post, index) in filterPostsByCategory( posts )">
<div #click.prevent="showPost( post )">
<img :src="post.video_cover" />
/* ... */
</div>
</li>
</transition-group>
</div>`,
methods: {
orderPostsInCategory: function ( inputArray, currentCategory ) {
let outputArray = [];
for (let i = 0; i < inputArray.length; i++) {
let currentCategoryObj = inputArray[i].video_categories.find( (category) => {
return category.slug === currentCategory;
});
let positionInCategory = currentCategoryObj.category_post_order;
outputArray[positionInCategory] = inputArray[i];
}
return outputArray;
},
filterPostsByCategory: function ( posts ) {
let categorySlug = this.categorySlug,
filteredPosts = posts.filter( (post) => {
return post.video_categories.some( (category) => {
return category.slug === categorySlug;
})
});
return this.orderPostsInCategory( filteredPosts, categorySlug );
}
}
});
The filterPostsByCategory() method does its job switching between the various possible categories, and instantly updating the list, according to the routes below:
let router = new VueRouter({
mode: 'history',
linkActiveClass: 'active',
routes: [
{ path: '/', component: VideoList, props: {categorySlug: 'home-page'} },
{ path: '/category/:categorySlug', component: VideoList, props: true }
]
});
The difficulty I'm having is transitioning the list in the way that I'd like. Ideally, when new category is selected all currently visible list items would fade out and the new list items would then fade in. I've looked at the vue transitions documentation, but haven't been able to get the effect I'm after.
The issue is that some items have more than one category, and when switching between these categories, those items are never affected by whatever transition I try to apply (I assume because Vue is just trying to be efficient and update as few nodes as possible). It's also possible that two or more categories contain the exact same list items, and in these instances enter and leave methods don't seem to fire at all.
So the question is, what would be a good way to ensure that I can target all current items (regardless of whether they're still be visible after the route change) whenever the route patterns above are matched?
Have you noticed the special key attribute in the documentation?
Vue.js is really focused on performance, because of that, when you modify lists used with v-for, vue tries to update as few DOM nodes as possible. Sometimes it only updates text content of the nodes instead of removing the whole node and then append a newly created one. Using :key you tell vue that this node is specifically related to the given key/id, and you force vue to completely update the DOM when the list/array is modified and as a result the key is changed. In your case is appropriate to bind the key attribute to some info related to the post and the category filter itself, so that whenever the list is modified or the category is changed the whole list may be rerendered and thus apply the animation on all items:
<li v-for="(post, index) in filterPostsByCategory( posts )" :key="post.id + categorySlug">
<div #click.prevent="showPost( post )">
<img :src="post.video_cover" />
/* ... */
</div>
</li>