I'm trying to make my react app as dry as possible, for common things like consuming a rest api, I've created classes that act as stores with predefined actions to make it easy to modify it.
Behold, big code:
import {autorun, action, observable} from 'mobx'
export function getResourceMethods(name) {
let lname = name.toLowerCase()
let obj = {
methods: {
plural: (lname + 's'),
add: ('add' + name),
addPlural: ('add' + name + 's'),
rename: ('rename' + name),
},
refMethods: {
add: ('add' + name + 'ByRef'),
addPlural: ('add' + name + 'sByRef'),
rename: ('rename' + name + 'ByRef'),
setRef: ('set' + name + 'Ref'),
},
fetchMethods: {
pending: (lname + 'Pending'),
fulfilled: (lname + 'Fulfilled'),
rejected: (lname + 'Rejected'),
}
}
return obj
}
class ResourceItem {
#observable data;
#observable fetched = false;
#observable stats = 'pending';
#observable error = null;
constructor(data) {
this.data = data;
}
}
class ResourceList {
#observable items = [];
#observable fetched = false;
#observable status = 'pending';
constructor(name) {
this['add' + name + 's'] = action((items) => {
items.forEach((item, iterator) => {
this.items.push(item.id)
})
})
}
}
class ResourceStore {
constructor(name, resourceItem, middleware) {
let {methods} = getResourceMethods(name)
this.middleware = middleware || []
let items = methods.plural.toLowerCase()
this[items] = observable({}) // <--------------- THIS DOES NOT WORK!
// Add resource item
this[methods.add] = action((id, resource) => {
let item = this[items][id], data;
if (item && item.fetched) {
data = item.data
} else {
data = resource || {}
}
this[items][id] = new resourceItem(data)
this.runMiddleware(this[items][id])
})
// Add several resource items
this[methods.addPlural] = action((resources) => {
resources.forEach((resource, iterator) => {
this[methods.add](resource.id, resource)
})
})
// Rename resource item
this[methods.rename] = action((oldId, newId) => {
let item = this[items][oldId]
this[items][newId] = item
if (oldId !== newId) {
delete this[items][oldId]
}
})
// Constructor ends here
}
runMiddleware(item) {
let result = item;
this.middleware.map(fn => {
result = fn(item)
})
return result
}
}
class ReferencedResourceStore extends ResourceStore {
#observable references = {}
constructor(name, resource, middleware) {
super(name, resource, middleware)
let {methods, refMethods, fetchMethods} = getResourceMethods(name)
let getReference = (reference) => {
return this.references[reference] || reference
}
this[refMethods.setRef] = action((ref, id) => {
this.references[ref] = id
})
this[refMethods.add] = action((ref, data) => {
this[methods.add](getReference(ref), data)
this[refMethods.setRef](ref, getReference(ref))
})
this[refMethods.rename] = action((ref, id) => {
this[methods.rename](getReference(ref), id)
this[refMethods.setRef](ref, id)
})
// *** Fetch *** //
// Resource pending
this[fetchMethods.pending] = action((ref) => {
this[refMethods.add](ref)
})
// Resource fulfilled
this[fetchMethods.fulfilled] = action((ref, data) => {
this[refMethods.add](ref, data)
this[refMethods.rename](ref, data.id)
let item = this[methods.plural][data.id];
item.fetched = true
item.status = 'fulfilled'
})
}
}
export {ResourceItem, ResourceList, ResourceStore, ReferencedResourceStore}
Now I'm just creating a simple user store:
class UserResource extends ResourceItem {
constructor(data) {
super(data)
}
#observable posts = new ResourceList('Posts')
#observable comments = new ResourceList('Comment')
}
// Create store
class UserStore extends ReferencedResourceStore {}
let store = new UserStore('User', UserResource)
And mobx-react connects just fine to the store, can read it as well. BUT, whenever I do any changes to the items (users in this case, the name of the property is dynamic) property, there are no reactions. I also noticed that in chrome, the object property does not have a "invoke property getter" in the tree view:
Didn't read the entire gist, but if you want to declare a new observable property on an existing object, use extendObservable, observable creates just a boxed observable, so you have an observable value now, but not yet an observable property. In other words:
this[items] = observable({}) // <--------------- THIS DOES NOT WORK!
should be:
extendObservable(this, {
[items] : {}
})
N.b. if you can't use the above ES6 syntax, it desugars to:
const newProps = {}
newProps[items] = {}
extendObservable(this, newProps)
to grok this: https://mobxjs.github.io/mobx/best/react.html
Edit: oops misread, you already did that, it is not hacky but the correct solution, just make sure the extend is done before the property is ever used :)
I found a hacky solution:
First off, use extendObservable instead (this is the correct solution) and then use a fresh version of the object and set it as the property.
let items = methods.plural.toLowerCase()
extendObservable(this, {
[items]: {}
})
// Add resource item
this[methods.add] = action((id, resource) => {
let item = this[items][id], data;
if (item && item.fetched) {
data = item.data
} else {
data = resource || {}
}
this[items][id] = new resourceItem(data)
this.runMiddleware(this[items][id])
this[items] = {...this[items]}
})
This works, not sure if there's a better solution.
Your options are using extendObservable or using an observable map.
For reference see the documentation of observable and specifically:
To create dynamically keyed objects use the asMap modifier! Only initially existing properties on an object will be made observable, although new ones can be added using extendObservable.
Related
I'm trying to make a game using React to display the UI elements and using Typescript classes to represent the state of the game.
Here are a few examples of the classes I'm using to represent my data:
export class Place extends Entity {
items: Item[];
npcs: NPC[];
location: LatLng | null;
onEnter: (...args: any[]) => any = () => {};
constructor(
name: string,
description: string,
location?: LatLng,
onEnter: (...args: any[]) => any = () => {},
items: Item[] = [],
npcs: NPC[] = []
) {
super(name, description);
this.items = items;
this.npcs = npcs;
this.location = location ? location : null;
this.onEnter = onEnter;
}
export class Item extends Entity {
url: string;
constructor(
name: string,
description: string,
actions = {},
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Question_mark_%28black%29.svg/1920px-Question_mark_%28black%29.svg.png"
) {
super(name, description);
this.url = url;
this.actions = actions;
}
}
export class NPC {
name: string;
description: string;
messages: Message[];
url: string;
timesTalkedTo = 0;
constructor(
name: string,
description: string,
url = "https://cdn.icon-icons.com/icons2/1378/PNG/512/avatardefault_92824.png",
messages: Message[] = []
) {
this.name = name;
this.description = description;
this.messages = messages;
this.url = url;
}
getMsg() {
console.log(this.messages);
if (this.messages.length > 1) {
for (var i = 1; i < this.messages.length; i++) {
const msg = this.messages[i];
if (msg["cond"] && msg["cond"]()) {
this.timesTalkedTo += 1;
return msg;
}
}
}
this.timesTalkedTo += 1;
return this.messages[0];
}
}
Later on, I store instances of these classes in hooks so I can display them using other components I've defined:
function UI() {
const [places, setPlaces] = useState({});
const [inventory, setInventory] = useState([]);
const [playerPlace, setPlayerPlace] = useState(outside);
const [playerLocation, setPlayerLocation] = useState(L.latLng([0, 0]));
...
My problem is that I wanted to define a class and functions like this inside my UI component, so I would be able to access the setState hooks and use the "drop" and "pick up" actions on any item I've defined as Droppable:
class Droppable extends Item {
dropped;
constructor(
name,
description,
actions = {},
dropped = true,
url = "https://upload.wikimedia.org/wikipedia/commons/thumb/4/46/Question_mark_%28black%29.svg/1920px-Question_mark_%28black%29.svg.png"
) {
super(name, description, actions, url);
this.dropped = dropped;
const drop = () => {
addToPlace(removeFromInventory(this));
this.dropped = true;
this.actions["pick up"] = pickUp;
delete this.actions["drop"];
};
const pickUp = () => {
addToInventory(removeFromPlace(this));
this.dropped = false;
this.actions["drop"] = drop;
delete this.actions["pick up"];
};
if (dropped) {
this.actions["pick up"] = pickUp;
} else {
this.actions["drop"] = drop;
}
}
}
const addToInventory = useCallback(
(item) => {
setInventory((inv) => [...inv, item]);
return item;
},
[setInventory]
);
const removeFromInventory = useCallback(
(item) => {
setInventory((inv) => inv.filter((i) => i !== item));
return item;
},
[setInventory]
);
const addToPlace = useCallback(
(item) => {
setPlaces((places) => {
return {
...places,
[playerPlace.name]: {
...playerPlace,
items: [...playerPlace.items, item],
},
};
});
return item;
},
[setPlaces, playerPlace]
);
const removeFromPlace = useCallback(
(item) => {
setPlaces((places) => {
const newPlace = { ...places[playerPlace.name] };
newPlace.items = newPlace.items.filter((i) => i !== item);
const newPlaces = [...places];
newPlaces[playerPlace.name] = newPlace;
return newPlaces;
});
return item;
},
[setPlaces, playerPlace]
);
However, when I try removing an item from the place it's in and adding it to the player's inventory (the "pick up" action), I find that, while it is successfully added to the inventory, it cannot be removed from the place, because the playerPlace state variable is stale. Even though setPlayerPlace had been called successfully and set the playerPlace to a place containing items, the value is still set to its initial empty Place, so there is an error when trying to access the items of that Place.
My guess is that these callbacks are not being updated properly according to the state because they are used inside the class that I defined, but I can't think of any other way to give methods inside the class easy access to the state variables.
Is it a bad idea to be using ordinary classes alongside React in this way? If so, what would be a better way to structure my app. If not, how can I give my classes access to the state inside my React components?
I would recommend moving the class outside of the component and then passing the setters and data to the class as parameters if you really want to use classes. You can also use a third-party state management library for this and then hook it together, but I don't think it's really worth it. Generally speaking, using classes for your state in react is an antipattern IMHO. Usually what I would do is just write types and then utility functions for those types if I need to encapsulate functionality. This has many benefits aside from working with react such as easily being able to serialize the data to JSON (they are now POJOs).
I have a function that when the component is loaded, it returns specific data of a certain parameter (ex: /app/items) and displays them in an information balloon, but if I access a child route (ex: /app/items/ create) or (ex: /app/items/1/view) I need to do a validation to see if there is informational data in this route to display, but if not, I need to display the information from the previous parameter (ex: /app/items) . I managed to create this function below that fulfills this, but with a request inside another, which I believe is not the most appropriate. So I'm trying to refactor so that only one request is made validating these parameters. It should also be considered that if the route has numbers, it needs to be filtered and returned an array with just the strings before doing the join('/'), as you can see there in the constructor. I'm new to the area, so I'm trying to find some background to refactor this function.
public information: InformationList | any
public isLoading:boolean = true
error: any;
showError: boolean = false
public currentPageUrl = null;
public output = null
other: any;
constructor(
public screenInfoService: ScreenInfoService,
public loaderService: LoaderService,
public router: Router
) {
this.currentPageUrl = this.router.url.split("/").filter((item: any) => {
return isNaN(item)
})
}
load() {
this.output = this.currentPageUrl.join("/");
let params = new HttpParams();
params = params.set('orderBy', 'id');
params = params.set('search', `path:${this.output}`);
params = params.set('searchFields', `path:ilike`);
params = params.set('searchJoin', 'and');
this.screenInfoService.getAll(params).subscribe(
success => {
if (success.length === 0) {
let output2 = null
output2 = this.currentPageUrl.slice(0,2).join("/");
params = params.set('orderBy', 'id');
params = params.set('search', `path:${output2}`);
params = params.set('searchFields', `path:ilike`);
params = params.set('searchJoin', 'and');
this.screenInfoService.getAll(params).subscribe(
success => {
this.information = success
}, error => {
this.error = 'MSG.T224';
this.showError = true;
}
)
} else {
this.information = success
}
}, error => {
this.error = 'MSG.T224';
this.showError = true;
});
}
ngOnInit(): void {
this.load()
}
I'm trying to send the selected data in my table row that I am selecting via a checkbox to the server but having questions about how it should be sent via a service. I have the basic skeleton but need help with getting the items to a delete REST API call. Using C# .Net Core JSON call as the server endpoint for this service call.
view.component.ts
#Component({
templateUrl: 'view.component.html'
})
export class ViewComponent implements OnInit, OnDestroy {
// User Fields
currentUser: User;
users: User[] = [];
currentUserSubscription: Subscription;
loading : boolean;
// Action Fields
viewData: any;
viewName: string;
refNumber: number;
currentActionSubscription: Subscription;
displayedColumns: string[] = [];
dataSource: any = new MatTableDataSource([]);
pageSizeOptions: number[] = [10, 20, 50];
#ViewChild(MatSort) sort: MatSort;
#ViewChild(MatPaginator) paginator: MatPaginator;
selection = new SelectionModel<TableRow>(true, []);
defaultSort: MatSortable = {
id: 'defColumnName',
start: 'asc',
disableClear: true
};
defaultPaginator: MatPaginator;
constructor(
private iconRegistry: MatIconRegistry,
private sanitizer: DomSanitizer,
private actionService: ActionService
) {
this.loading = false;
this.iconRegistry.addSvgIcon(
'thumbs-up',
this.sanitizer.bypassSecurityTrustResourceUrl(
'assets/img/examples/thumbup-icon.svg'
)
);
}
loadAction(action: any) {
this.loading = true;
// If there is already data loaded into the View, cache it in the service.
if (this.viewData) {
this.cacheAction();
}
if (this.sort) {
// If there is sorting cached, load it into the View.
if (action.sortable) {
// If the action was cached, we should hit this block.
this.sort.sort(action.sortable);
} else {
// Else apply the defaultSort.
this.sort.sort(this.defaultSort);
}
}
if (this.paginator) {
// If we've stored a pageIndex and/or pageSize, retrieve accordingly.
if (action.pageIndex) {
this.paginator.pageIndex = action.pageIndex;
} else { // Apply default pageIndex.
this.paginator.pageIndex = 0;
}
if (action.pageSize) {
this.paginator.pageSize = action.pageSize;
} else { // Apply default pageSize.
this.paginator.pageSize = 10;
}
}
// Apply the sort & paginator to the View data.
setTimeout(() => this.dataSource.sort = this.sort, 4000);
setTimeout(() => this.dataSource.paginator = this.paginator, 4000);
// Load the new action's data into the View:
this.viewData = action.action;
this.viewName = action.action.ActionName;
this.refNumber = action.refNumber;
// TODO: add uniquifiers/ids and use these as the sort for table
const displayedColumns = this.viewData.Columns.map((c: { Name: any; }) => c.Name);
displayedColumns[2] = 'Folder1';
this.displayedColumns = ['select'].concat(displayedColumns);
// tslint:disable-next-line: max-line-length
const fetchedData = this.viewData.DataRows.map((r: { slice: (arg0: number, arg1: number) => { forEach: (arg0: (d: any, i: string | number) => any) => void; }; }) => {
const row = {};
r.slice(0, 9).forEach((d: any, i: string | number) => (row[this.displayedColumns[i]] = d));
return row;
});
this.dataSource = new MatTableDataSource(fetchedData);
this.loading = false;
}
// Stores the current Action, sort, and paginator in an ActionState object to be held in the action service's stateMap.
cacheAction() {
let actionState = new ActionState(this.viewData);
// Determine the sort direction to store.
let cachedStart: SortDirection;
if (this.sort.direction == "desc") {
cachedStart = 'desc';
} else {
cachedStart = 'asc';
}
// Create a Sortable so that we can re-apply this sort.
actionState.sortable = {
id: this.sort.active,
start: cachedStart,
disableClear: this.sort.disableClear
};
// Store the current pageIndex and pageSize.
actionState.pageIndex = this.paginator.pageIndex;
actionState.pageSize = this.paginator.pageSize;
// Store the refNumber in the actionState for later retrieval.
actionState.refNumber = this.refNumber;
this.actionService.cacheAction(actionState);
}
ngOnInit() {
// Subscribes to the action service's currentAction, populating this component with View data.
this.actionService.currentAction.subscribe(action => this.loadAction(action));
}
/** Whether the number of selected elements matches the total number of rows. */
isAllSelected() {
const numSelected = this.selection.selected.length;
const numRows = this.dataSource.data.length;
return numSelected === numRows;
}
/** Selects all rows if they are not all selected; otherwise clear selection. */
masterToggle() {
this.isAllSelected()
? this.selection.clear()
: this.dataSource.data.forEach((row: TableRow) => this.selection.select(row));
}
// Delete row functionality
deleteRow() {
console.log(this.selection);
this.selection.selected.forEach(item => {
const index: number = this.dataSource.data.findIndex((d: TableRow) => d === item);
console.log(this.dataSource.data.findIndex((d: TableRow) => d === item));
this.dataSource.data.splice(index, 1);
this.dataSource = new MatTableDataSource<Element>(this.dataSource.data);
});
this.selection = new SelectionModel<TableRow>(true, []);
this.actionService.deleteRow(this.selection).subscribe((response) => {
console.log('Success!');
});
}
ngOnDestroy() {
}
}
view.service.ts
deleteRow(selection: any): Observable<{}> {
console.log('testing service');
return this.http.delete<any>(`http://localhost:15217/actions/deleteRow`);
}
There are 2 things that your code as it currently stands needs to do:
Pass the ids of the selected rows back to the server in some way (generally via the url in a DELETE request)
Subscribe to the observable to materialise it. Currently the http request won't run, because it's an observable without any subscribers. At the very least the call to the service in the component should look a little like this:
this.actionService.deleteRow(this.selection).subscribe((response) => {
console.log('Success!');
});
Edit:
With number 1, it depends on what your server method looks like. If it accepts an array of numeric ids, then view.service.ts would look something like:
deleteRow(selection: SelectionModel<TableRow>): Observable<{}> {
console.log('testing service');
// create an array of query params using the property that you use to identify a table row
const queryParams = selection.selected.map(row => `id=${row.id}`);
// add the query params to the url
const url = `http://localhost:15217/actions/deleteRow?${queryParams.join('&')}`;
return this.http.delete<any>(url);
}
I'm guessing here at how you pass information about table rows to your server. If you're still struggling with this, you will need to provide a bit of information about the DELETE endpoint.
Edit 2:
Now we know a bit more about what the objects look like...
deleteRow(selection: SelectionModel<TableRow>): Observable<{}> {
console.log('testing service');
// create an array of query params using the property that you use to identify a table row
const queryParams = [...selection._selection].map(row => `id=${row.id}`);
// add the query params to the url
const url = `http://localhost:15217/actions/deleteRow?${queryParams.join('&')}`;
return this.http.delete<any>(url);
}
I would like to update a Firestore model containing a profile name and a list of hashtags with Angular 6. The "name" is stored as the value of a document field and the "hashtags" are stored as the keys of an object. When I try to update the database entry, my program adds a new document field called "data" every time I call the update function instead of updating the existing fields.
How can I fix this?
This is how my firestore looks like before the update.
My update function adds a new "data" field instead of updating everytime I call it.
My Firestore Service:
export class MembersService {
membersCollection: AngularFirestoreCollection<Member>;
members$: Observable<Member[]>;
memberDoc: AngularFirestoreDocument<Member>;
constructor(public afs: AngularFirestore) {
this.membersCollection = afs.collection<Member>('Members');
this.members$ = this.membersCollection.snapshotChanges().pipe(
map(actions => actions.map(a => {
const data = a.payload.doc.data() as Member;
const id = a.payload.doc.id;
return { data, id };
}))
);
}
getMembers(): Observable<Member[]> {
return this.members$;
}
updateMember(member: Member) {
this.memberDoc = this.afs.doc(`Members/${member.id}`);
this.memberDoc.update(member);
}
}
My input component.ts:
export class MembersComponent implements OnInit {
members: Member[];
editState: boolean;
membertoEdit: Member;
constructor(private membersService: MembersService) {
this.editState = false;
}
ngOnInit() {
this.membersService.getMembers().subscribe(members => {
this.members = members;
});
}
editMember(member: Member) {
this.editState = true;
this.membertoEdit = member;
}
clearState() {
this.editState = false;
this.membertoEdit = null;
}
submit(member: Member, editName: string, editHashtag: string) {
if ( editName !== '' && editHashtag !== '') {
this.membertoEdit.name = editName;
const key = editHashtag;
const object = {};
object[key] = true;
this.membertoEdit.hashtag = object;
this.membersService.updateMember(this.membertoEdit);
}
this.clearState();
}
}
My component.html for the user Input:
<button *ngIf="editState == false" (click)="editMember(member)">edit</button>
<div *ngIf="editState && membertoEdit.id == member.id">
<form>
<input type="text" #editName>
<input type="text" #editHashtag>
<button (click)="submit(member, editName.value, editHashtag.value);
editName.value=''">Submit</button>
</form>>
</div>
Found a solution: Even I don´t think it is elegant. It´s possible to pass every input on its own
updateMember(member: Member, editName: string, editHashtag: object) {
this.memberDoc = this.afs.doc(`Members/${member.id}`);
console.log(this.memberDoc);
this.memberDoc.update({
name: editName,
hashtag: editHashtag
});
}
submit(member: Member, editName: string, editHashtag: string) {
if ( editName !== '' && editHashtag !== '') {
const key = editHashtag;
const object = {};
object[key] = true;
this.membersService.updateMember(member, editName, object);
}
this.clearState();
}
I am calling two API to return objects of data. Than run through for each of them and search if it has a value.
I want to check if one of these obj has a vas value matches.
getdata(slug){
this._apiService.getPages().subscribe(data =>{
this.pageobj = data
console.log('this page obj',this.pageobj)
})
this._apiService.getPosts().subscribe(data =>{
this.postsobj = data;
console.log('this post obj',this.postsobj)
})
}
this.pageobj is an object
this.postsobj
in both responses they had a property 'slug'.
I would like to check if in this.postsobj or this.pageobj has an object that contains 'slug' == 'hello-word', if so to return me object and store in var this.content
UPDATE
export class PageSingleComponent implements OnInit {
page: Page;
pageobj:any;
postsobj:any;
pageobjCheck:any
postsobjCheck:any
pageSlug:any;
content =new Array<any>();
constructor( private _apiService: apiService, private route: ActivatedRoute ) { }
getdata(slug){
this._apiService.getPages().subscribe(data =>{
this.pageobj = data
this.content.push(_.filter(this.pageobj, { 'slug':' hello-world' }));
})
this._apiService.getPosts().subscribe(data =>{
this.postsobj = data;
this.content.push(_.filter(this.postsobj, { 'slug':' hello-world' }));
})
}
ngOnInit() {
this.route.params.forEach((params: Params) => {
// Get slug from the rout
let slug = params['pageslug'];
console.log('slug is catcheds', slug)
this.pageSlug = params['pageslug'];
this.getdata(slug)
// Run functions
//
});
}
}
I think you want to use Filter function.
In your callback function passed to map function you want to check whether your response object form array has slug property which is equal to 'hello world'. Your code will like like this:
var content = response.filter(obj => obj && obj.slug === 'hello-world');
I prefer using lodash as below,
this.content =new Array<any>();
this.content.push(_.filter(this.pageobj, { 'slug':' hello-world' });
this.content.push(_.filter(this.postsobj, { 'slug':' hello-world' });
Alternatively you can handle it in the service using takeWhile operator
getPages(){
return this.http.get(...)
.takeWhile(data=>{
if(data.slug ==== 'hello-world'){
return data;
}
})
}