- Using Firebase with Angular and AngularFire
- Start a new project
- CREATE data in Firestore with
add()
- CREATE data in Firestore with
set()
- READ data from Firestore
- OBSERVE a collection listener in the controller
- DELETE data from Firestore
- UPDATE data in Firestore
- Stuff I don't understand
- Complete finished code
This tutorial will make a simple Angular CRUD (CREATE, READ, UPDATE, DELETE) app that uses Google's Firebase Firestore cloud database, plus we'll use OBSERVE to display realtime updates.
This project uses Angular 15, AngularFire 7.5, and Firestore Web version 9 (modular).
I assume that you know the basics of Angular (nothing advanced is required). No Material or CSS is used, to make the code easier to understand.
I assume that you know the basics of Firebase's Firestore cloud database. In particular, this tutorial talks about collections
and documents
. This is essential. A collection
is pretty much an array and a document
is pretty much an object, just like in JavaScript. Firestore is structured with collections of documents. A document can contain a collection (a.k.a. a sub-collection), which contains further documents. This collection
-document
-collection
-document
pattern is maintained.
You'll see that some of the CRUD operations come in pairs: one for a collection and one for a document. You can READ a collection with getDoc()
or READ a collection with getDocs()
. You can OBSERVE a collection or OBSERVE a document. The other three CRUD operations (CREATE, UPDATE, DELETE) are only for documents. You, as admin, can CREATE and DELETE collections from the Firebase console but users can't do these operations from a web app.
I expect that you'll read this tutorial with a second window open to the Firebase documentation and the AngularFire documentation. I'll try to let you know which page of the Firebase documentation to open for each section of this tutorial. This stuff changes, especially AngularFire. Make a pull request if something in this tutorial is out of date.
Here is the data (documents) we will use:
Charles Babbage, born 1791: Built first computer
Ada Lovelace, born 1815: Wrote first software
Howard Aiken, born 1900: Built IBM's Harvard Mark I electromechanical computer
John von Neumann, born 1903: Built first general-purpose computer with memory and instructions
Grace Hopper, born 1906: Devised the first machine-independent programming language.
Alan Turing, born 1912: First theorized computers with memory and instructions, i.e., general-purpose computers
Donald Knuth, born 1938: Father of algorithm analysis
Lynn Ann Conway, born 1938: Invented generalized dynamic instruction handling
Shafi Goldwasser, born 1958: Crypography and blockchain
Jeff Dean, born 1968: Google's smartest computer scientist
In your terminal:
npm install -g @angular/cli
ng new GreatestComputerScientists
cd GreatestComputerScientists
The Angular CLI's new
command will set up the latest Angular build in a new project structure. Accept the defaults (no routing, CSS). Start the server:
ng serve -o
Your browser should open to localhost:4200
. You should see the Angular default homepage.
Open another tab in your terminal and install AngularFire and Firebase from npm
in your project directory.
ng add @angular/fire
Deselect ng deploy -- hosting
and select Firestore
. This project won't use any other Firebase features.
It will ask you for your email address associated with your Firebase account. Then it will ask you to associate a Firebase project. Select [CREATE NEW PROJECT]
. Call it Greatest Computer Scientists
.
If this doesn't work, open your Firebase console and make a new project. Call it Greatest Computer Scientists
. Skip the Google Analytics.
Create your Firestore database.
Open the Firestore Get started section.
In your Firebase Console, under Get started by adding Firebase to your app
select the web app </>
icon. Register your app, again calling it Greatest Computer Scientists
. You won't need Firebase Hosting
, we'll just run the app locally.
Under Add Firebase SDK
select Use npm
.
Return to your terminal and install Firebase in your new project.
npm install firebase
Then copy and paste the config values provided to your environment.ts
file:
export const environment = {
production: false,
firebaseConfig: {
apiKey: '<your-key>',
authDomain: '<your-project-authdomain>',
projectId: '<your-project-id>',
storageBucket: '<your-storage-bucket>',
messagingSenderId: '<your-messaging-sender-id>',
appId: '<your-app-id>',
measurementId: '<your-measurement-id>'
}
};
Don't copy and paste the whole window that the Firebase console provides. Check that it looks like the above code. A =
needs to be changed to :
and a ;
needs to be dropped. Then check that your browser is still showing the demo app.
Add Firebase SDK
also tells you to do several other things:
// Import the functions you need from the SDKs you need
import { initializeApp } from "firebase/app";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Initialize Firebase
const app = initializeApp(firebaseConfig);
We'll use AngularFire instead of these items. Click Continue to console
.
Open the AngularFire documentation for this section.
Open /src/app/app.module.ts
, import the environment
module and the FormsModule
module. Then import a bunch of AngularFire modules. Lastly, import the FormsModule and two AngularFire functions to initialize Firebase and get Firestore.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { environment } from '../environments/environment'; // access firebaseConfig
// Angular
import { FormsModule } from '@angular/forms';
// AngularFire
import { provideFirebaseApp, initializeApp } from '@angular/fire/app';
import { provideFirestore, getFirestore } from '@angular/fire/firestore';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule,
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideFirestore(() => getFirestore()),
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Keep an eye on the browser. If the homepage crashes, go back and see what's wrong.
Note the line with initializeApp()
. This is the key to using Firebase and this line belongs in app.module.ts
. It doesn't belong in your components or anywhere else.
Open /src/app/app.component.ts
and import three AngularFire modules.
import { Component } from '@angular/core';
import { Firestore } from '@angular/fire/firestore';
// AngularFire
import { collection, addDoc } from '@angular/fire/firestore';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'GreatestComputerScientists';
constructor(public firestore: Firestore) {}
}
Now we'll make the view in app.component.html
. Replace the placeholder view with:
<h2>Greatest Computer Scientists</h2>
<h3>Create</h3>
<form (ngSubmit)="onCreate()">
<input type="text" [(ngModel)]="name" name="name" placeholder="Name" required>
<input type="text" [(ngModel)]="born" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishment" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Submit">Submit</button>
</form>
We're using an HTML form and the Angular FormsModule
. The form is within the <form></form>
directive.
<form (ngSubmit)="onCreate()">
</form>
The parentheses around ngSubmit
creates one-way data binding from the view app.component.html
to the controller app.component.ts
. When the Submit
button is clicked the function onCreate()
executes in app.component.ts
. We'll make this function next.
Inside the form we have three text fields and a Submit
button. The first text field has two-way data binding (parenthesis and brackets) using ngModel
to the variable name
in the controller. The second and third text fields bind to the variables born
and accomplishment
.
Clicking the button executes ngSubmit
and the function onCreate()
.
Open the Add Data section of the Firestore documentation.
Now we'll add a handler function to write the data to database.
async onCreate() {
try {
const docRef = await addDoc(collection(this.firestore, 'Scientists'), {
name: this.name,
born: this.born,
accomplishment: this.accomplishment
});
console.log("Document written with ID: ", docRef.id);
} catch (error) {
console.error(error);
}
}
Enter this data in the HTML form and click Submit
:
name [string]: Charles Babbage
born [number]: 1791
accomplishment [string]: Built first computer
Look in your Firestore console and you should see your data. Yay! Firebase is talking to your Angular app.
Notice that Charles Babbage is still in your HTML form fields. Lets's clear that data so that the forms are ready for another entry.
async onCreate() {
try {
const docRef = await addDoc(collection(this.firestore, 'Scientists'), {
name: this.name,
born: this.born,
accomplishment: this.accomplishment
});
console.log("Document written with ID: ", docRef.id);
this.name = null;
this.born = null;
this.accomplishment = null;
} catch (error) {
console.error(error);
}
}
Open the Add data section of the Firestore documentation.
Note that the document identifier (ID) is a string of letters and numbers. add()
automatically generates an ID string. If you want to make the document identifier yourself you use set()
.
Let's make a second Submit
button for set()
.
<h2>Greatest Computer Scientists</h2>
<h3>Create (add)</h3>
<form (ngSubmit)="onCreate()">
<input type="text" [(ngModel)]="name" name="name" placeholder="Name" required>
<input type="text" [(ngModel)]="born" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishment" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Submit">Submit</button>
</form>
<h3>Create (set)</h3>
<form (ngSubmit)="onSet()">
<input type="text" [(ngModel)]="name" name="name" placeholder="Name" required>
<input type="text" [(ngModel)]="born" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishment" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Submit">Submit</button>
</form>
Import the setDoc
and doc
modules to the controller.
import { Firestore, addDoc, setDoc, doc, getDocs, collectionData, collection } from '@angular/fire/firestore';
We'll make a new set of variables for set()
.
nameSet: string = '';
bornSet: number | null = null;
accomplishmentSet: string | null = null;
Add the handler function in the controller. Note the third parameter of setDoc(collection())
.
async onSet() {
try {
await setDoc(doc(this.firestore, 'Scientists', this.nameSet), {
name: this.nameSet,
born: this.bornSet,
accomplishment: this.accomplishmentSet
});
this.nameSet = '';
this.bornSet = null;
this.accomplishmentSet = null;
} catch (error) {
console.error(error);
}
}
A difference between add()
and set()
is that set()
can update or overwrite a record. Use Create (set)
to enter a new record:
Howard Aiken, 1900, Built IBM's Harvard Mark I electromechanical computer
I like this quote from Howard Aiken:
Don't worry about people stealing an idea. If it's original, you will have to ram it down their throats.
Now enter this data in both Create (add)
and Create (set)
:
Howard Aiken, 2000, First baby raised speaking only JavaScript
You should see different results. add()
created a new record for Howard Aiken's millenial great-grandchild. set()
updated the original Howard Aiken record.
- Import
doc
andsetDoc
modules. - Third parameter in
setDoc(doc())
for document identifier. - Document identifier can't be null. Note that we initialized
nameSet
with an empty string''
, notnull
.
set()
will overwrite a document or create it if it doesn't exist yet. Another option is to add the {merge: true}
:
set({data: 12345}, {merge: true})
This will update fields in the document or create it if it doesn't exists. In contrast, update()
will update fields but will fail if the document doesn't exist. We'll go over this again in the update()
section.
You, as admin, can create collections in the Firebase console or the CLI. Your users can't create collections from a web app.
Open the Get data once section of the Firestore documentation.
In app.component.html
add a button that allows you to select a computer scientist by name and then click a button to display their birthyear and accomplishment. Don't try to understand how the list of computer scientists gets into the <select><option>
, we'll get to that in the OBSERVE
section.
<h2>Greatest Computer Scientists</h2>
<h3>Create</h3>
<form (ngSubmit)="onCreate()">
<input type="text" [(ngModel)]="name" name="name" placeholder="Name" required>
<input type="text" [(ngModel)]="born" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishment" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Submit">Submit</button>
</form>
<h3>Read (one document, once)</h3>
<form (ngSubmit)="getDocument()">
<select name="scientist" [(ngModel)]="documentID">
<option *ngFor="let scientist of scientist$ | async" [ngValue]="scientist.name">
{{ scientist.name }}
</option>
</select>
<button type="submit" value="Submit">Get Document</button>
</form>
<div *ngIf="singleDoc.name">{{ singleDoc.name }}, born {{ singleDoc.born }}: {{ singleDoc.accomplishment }}</div>
We're using *ngIf
to hide the result before the user selects a scientist.
Import the doc
and getDoc
modules and the DocumentSnapshot
TypeScript type.
import { doc, DocumentSnapshot, getDoc } from '@angular/fire/firestore';
Make some variables.
documentID: string = '';
docSnap: DocumentSnapshot;
docSnapName: string = '';
docSnapBorn: number | null = null;
docSnapAccomplishment: string = '';
Make the handler function.
async getDocument() {
this.docSnap = await getDoc(doc(this.firestore, 'Scientists', this.documentID));
this.docSnapName = this.docSnap.data().name; // this.docSnapName = this.docSnap.data()?.['name'];
this.docSnapBorn = this.docSnap.data().born;
this.documentID = this.docSnap.data().accomplishment;
}
The unusual syntax is because we're using the DocumentSnapshot
type instead of any
.
Couldn't be simpler! Well, it could. Let's make a variable to hold the data:
singleDoc: Scientist = {
name: null,
born: null,
accomplishment: null
}
Simplfy the handler function:
async getDocument() {
this.docSnap = await getDoc(doc(this.firestore, 'Scientists', this.documentID));
this.singleDoc = this.docSnap.data();
}
And display the data:
{{ singleDoc.name }}, born {{ singleDoc.born }}: {{ singleDoc.accomplishment }}
In your Firebase console, make a new document in your Scientists
collection. Call it nest
, with one field name: nest
. In the nest
document make a sub-collection Nested
. Note that we're using Uppercase for collections and lowercase for documents with subcollections.
Add one document to your Nested
subcollection from the Firebase console.
Add some variables to your controller:
nestedScientist$: Observable<Scientist[]>;
nestedDocumentID: string = '';
nestedDoc: Scientist = {
name: null,
born: null,
accomplishment: null
}
Add a handler function to your controller:
async getNested() {
try {
this.docSnap = await getDoc(doc(this.firestore, 'Scientists/nest/Nested', this.nestedDocumentID));
this.nestedDoc = this.docSnap.data();
} catch (error) {
console.error(error);
}
}
Put the nested document in the view:
<h3 ng>Read (nested document, once)</h3>
<form (ngSubmit)="getNested()">
<select name="nested" [(ngModel)]="nestedDocumentID">
<option *ngFor="let scientist of nestedScientist$ | async" [ngValue]="scientist.name">
{{ scientist.name }}
</option>
</select>
<button type="submit" value="Submit">Get Nested</button>
</form>
<div *ngIf="nestedDoc.name">{{ nestedDoc.name }}, born {{ nestedDoc.born }}: {{ nestedDoc.accomplishment }}</div>
You should be able to select a nested computer scientist and then see the results in the view. We won't make a CREATE, UPDATE, or DELETE for the nested data.
Add a variable
`querySnapshot: any;`
and a handler function:
async getData() {
console.log("Getting data!");
this.querySnapshot = await getDocs(collection(this.firestore, 'Scientists'));
this.querySnapshot.forEach((document: any) => {
console.log(`${document.id} => ${document.data().name}`);
});
}
The first line clears any old data in the view. This should display the names of your favorite computer scientists in your console.
Let's add query where
to filter the results
Let's display this data in the HTML form. Make an array and push the scientists into the array:
async getData() {
this.querySnapshot = await getDocs(collection(this.firestore, 'Scientists'));
this.querySnapshot.forEach((document: any) => {
console.log(`${document.id} => ${document.data().name}`);
this.scientists.push(document.data());
});
}
Display the data in the HTML view:
<h3>Read (once)</h3>
<form (ngSubmit)="getData()">
<button type="submit" value="getData">Get Data</button>
</form>
<ul>
<li *ngFor="let scientist of scientists">
{{scientist.name}}, born {{scientist.born}}: {{scientist.accomplishment}}
</li>
</ul>
OK, that works...but needs improvement. First, the TypseScript gods hate any
. Let's make an interface
(or a type
, your choice).
interface Scientist {
name?: string,
born?: number,
accomplishment?: string
};
Note that all the properties are optional. This prevents throwing an error that gets inherited from the DocumentData
object.
Now make an array of scientists. Initialize it as an empty array.
scientists: Scientist[] = [];
Now we can push objects into the array:
this.scientists.push(doc.data());
Get rid of the initial empty array:
this.scientists = [];
querySnapshot
has to remain type any
. It seems to be type QuerySnapshot<DocumentData>
. I have no idea how to call that as a type.
Also, let's clear any old data from the view:
this.scientists = []; // clear view
Make a variable:
whenBorn: string = '1700';
This should be a number but it only works as a string. Sometimes TypeScript is baffling.
And make a query in the handler function:
async getData() {
this.scientists = []; // clear view
this.q = query(collection(this.firestore, 'Scientists'), where('born', '>=', this.whenBorn));
this.querySnapshot = await getDocs(this.q);
this.querySnapshot.forEach((docElement: any) => {
this.scientists.push(docElement.data());
});
}
The user can now filter results to show only computer scientists born after 1700, 1800, or 1900.
By default the data is ordered by the document identifier. You can order your data differently or limit the number of documents returned.
I can't get the Firestore data converter to work. It should convert downloaded documents into custom objects, e.g., Scientist
, or convert objects to be uploaded to Firestore into a specific collection.
Open the Listen for realtime updates section of the Firestore documentation.
Let's get rid of that Get Data
button. This is 2022, we're not using SQL!
Import Observable
from rxjs
. Make an instantiation of the Observable
class, call it scientist$
, and set the type as an array of Scientist
elements:
scientist$: Observable<Scientist[]>;
The observer is one line in the constructor
:
constructor(public firestore: Firestore) {
this.scientist$ = collectionData(collection(firestore, 'Scientists'));
}
That's it. Now scientist$
will always mirror the database. We have the observer in the constructor
so that it starts when the page loads (ngOnInit
would do more or less the same thing). An observer could instead go in a function to start after an event.
In the HTML view, repeat the *ngFor
data display, with three changes. First, no button. Second, change scientists
to scientist$
. Third, add the pipe | async
.
<h3>Observe</h3>
<ul>
<li *ngFor="let scientist of scientist$ | async">
{{scientist.name}}, born {{scientist.born}}: {{scientist.accomplishment}}
</li>
</ul>
You should now see the data without clicking the button, and then the same data when you click the button. Add another record and watch it change in real time. Try to make MongoDB do that!
We can also observe a single document. In the controller, make some variables:
charle$: Scientist = {
name: null,
born: null,
accomplishment: null
};
unsubCharle$: any;
Then make the listener in the constructor
:
constructor(public firestore: Firestore) {
this.unsubCharle$ = onSnapshot(doc(firestore, 'Scientists', 'Charles Babbage'), (snapshot: any) => { // document listener
this.charle$.name = snapshot.data().name;
this.charle$.born = snapshot.data().born;
this.charle$.accomplishment = snapshot.data().accomplishment;
});
}
This makes two observers, the collection listener and the document listener.
<h3>Observe (single document, 'Charles Babbage')</h3>
<div *ngIf="charle$.name">{{ charle$.name }}, born {{ charle$.born }}: {{ charle$.accomplishment}}</div>
When you no longer need to observe a collection, detach the listener to reduce your bandwidth.
Detaching the document listener can be easy:
<form (ngSubmit)="detachListener()">
<button type="submit" value="detachListener">Detach Listener</button>
</form>
async detachListener() {
console.log("Detaching listener.");
this.unsubCharle$();
}
What's hard is to detach the listener programmatically, e.g., after an API returns data.
A typical use for a listener is when you make a request to an API, then you have to wait for the data to come back before you use the data. Make the listener first, then make the API request. You could run into two problems.
First, you can't put an async API request inside a listener. You'll get this error:
'await' expressions are only allowed within async functions and at the top levels of modules.ts(1308)
Nesting async calls is possible but tricky. What works for me is to make the listener first, then call the API, and hope that the listener starts before the API data comes back. This generally works but is a smelly solution.
Second, you'll want to detach the listener after the API returns your data. But you can't put this.unsubCharle$();
inside this.unsubCharle$();
, that would be recursive. In other words, when I put this.unsubCharle$();
anywhere in the listener, the listener is detached before the data comes back. The only solution I've found is to set a timer:
setTimeout(() => {
this.unsubCharle$();
}, 60000); // delayed for one minute
That's a smelly solution. What if a user has a slow Internet connection and needs more than a minute to get the data? What if your users are on broadband and get their data back in a fraction of a second? 99% of the listeners' lives will be wasted, costing you speed and money.
The firestore.collection().onSnapshot()
function returns an unsubscribe function. You just call it to detach a collection listener:
unsubscribe = firestore.collection("collection_name")
.onSnapshot(function(querySnapshot) {
querySnapshot.forEach(function(doc) {
if (doc && doc.exists) {
const myData = doc.data();
// DO SOMETHING
}
});
});
unsubscribe(); // detaches the collection listener
I couldn't get this code to work.
One of my coding bootcamp classmates insisted "D" was for "DESTROY".
Now we'll add the Delete service to app.component.html
:
<h3>Delete</h3>
<form (ngSubmit)="onDelete()">
<select name="scientist" [(ngModel)]="selection">
<option *ngFor="let scientist of scientist$ | async" [ngValue]="scientist.name">
{{ scientist.name }}
</option>
</select>
<button type="submit" value="Submit">Delete</button>
</form>
This form has a <select><option>
dropdown menu for selecting a computer scientist to delete. In this we use Angular's *ngFor
again. Unlike the READ service we must include [ngValue]="scientist.name"
in the Delete service. Without this your selection isn't passed back to the controller.
First, import the deleteDoc
module.
import { Firestore, addDoc, doc, setDoc, getDocs, collectionData, collection, deleteDoc } from '@angular/fire/firestore';
Add a variable for the selection.
selection: string = '';
Then make a handler function.
async onDelete() {
await deleteDoc(doc(this.firestore, 'Scientists', this.selection));
this.selection = '';
}
Works great...on the records that we entered with set()
, where the document identifier is the same as the name
field. That's a lesson in data structure: use set()
, not add()
, for records that may need to be deleted, and make a name
or identifier
field with the same document's identifier.
Open Perform simple and compound queries in Cloud Firestore in the Firestore documentation.
For documents with auto-generated document identifiers we'll have to do a query to find the document identifier.
Import the collection
, query
, and where
modules.
import { Firestore, addDoc, doc, setDoc, getDocs, collectionData, collection, deleteDoc, query, where } from '@angular/fire/firestore';
We'll need these variables:
q: any;
querySnapshot: any;
These variables are type any
because they will handle collections returned from Firestore. I don't know what type a collection is.
Now we'll make the handler function. Let's make a smelly function first:
async onDelete() {
console.log(this.selection);
this.q = query(collection(this.firestore, 'Scientists'), where('name', '==', this.selection));
this.querySnapshot = await getDocs(this.q);
this.querySnapshot.forEach((doc: any) => {
console.log(doc.id, ' => ', doc.data());
deleteDoc(doc(this.firestore, 'Scientists', doc.id));
});
}
Do you see the problem? Look at this line:
deleteDoc(doc(this.firestore, 'Scientists', doc.id));
doc
is both a Firestore module and the elements in the array being iterated. We can use an alias for the module doc
:
import { doc as whatsUpDoc } from '@angular/fire/firestore';
but I can't think of a better name for the module than doc
. We can't use document
because that's a JavaScript keyword. I suspect that the Firebase team had a discussion about this.
We'll have change the name of the elements in the array from doc
to docElement
.
async onDelete() {
console.log(this.selection);
this.q = query(collection(this.firestore, 'Scientists'), where('name', '==', this.selection));
this.querySnapshot = await getDocs(this.q);
this.querySnapshot.forEach((docElement: any) => {
console.log(docElement.id, ' => ', docElement.data());
deleteDoc(doc(this.firestore, 'Scientists', document.id));
});
}
This will delete all documents with the selected name. My database had several Charles Babbage
documents. Now they're all gone. :-)
More about filtering queries with where().
Our DELETE
functions deletes documents.
To delete fields in documents use deleteField().
If a field in a document is a collection, i.e., a subcollection, the documents in those subcollections won't be deleted with the parent document. You'll have to search for and delete each document in a subcollection, or delete the subcollection from the Firebase Console.
You, as admin, can delete collections in the Firebase console or the CLI. Your users can't delete collections from a web app.
UPDATE
changes existing records without creating new records.
<h3>Update</h3>
<form (ngSubmit)="onUpdate()">
<select (change)="onSelect($event)">
<option>Select scientist</option>
<option *ngFor="let scientist of scientist$ | async" [ngValue]="scientist.name">
{{ scientist.name }}
</option>
</select>
<input type="text" [(ngModel)]="bornUpdate" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishmentUpdate" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Update">Update</button>
</form>
The <select><option>
menu uses Angular event binding. (change)
looks for a change in the selected option, from Select scientist
to a computer scientist. This fires the onSelect()
handler function in the controller. $event
passes the selected value to the handler function.
The *ngFor
iterates through the observer's data scientist$
to display a realtime view of the computer scientists in the database.
Two text input
fields allow changing the computer scientist's year of birth and accomplishment (not their name).
The button fires a second handler function onUpdate()
to update the database.
Open Update a document in the Firestore documentation.
Import the updateDoc
module.
import { Firestore, addDoc, doc, setDoc, getDocs, collectionData, collection, deleteDoc, query, where, updateDoc } from '@angular/fire/firestore';
Make some more variables:
nameUpdate: string = '';
bornUpdate: number | null = null;
accomplishmentUpdate: string | null = null;
We can't collapse these three variables into a Scientist
type object because the name
property can't be null
.
Add the handler functions:
// UPDATE
async onSelect(event: any) { // type Event doesn't work despite https://angular.io/guide/event-binding-concepts
this.querySnapshot = await getDocs(query(collection(this.firestore, 'Scientists'), where('name', '==', event.target.value))); // query the database
this.querySnapshot.forEach((docElement: any) => { // itereate through the collection
this.nameUpdate = docElement.data().name; // transfer to local variables
this.bornUpdate = docElement.data().born;
this.accomplishmentUpdate = docElement.data().accomplishment;
});
}
// UPDATE
async onUpdate() {
this.querySnapshot = await getDocs(query(collection(this.firestore, 'Scientists'), where('name', '==', this.nameUpdate))); // find the document by the name property instead of the document identifier because we're using both autogenerated document identifiers and custom document identifiers
this.querySnapshot.forEach((docElement: any) => { // iterate through the collection to find the document identifier for the selected document
this.nameUpdate = docElement.id; // put the document identifier in a local variable
});
await updateDoc(doc(this.firestore, 'Scientists', this.nameUpdate), { // update the database
born: this.bornUpdate,
accomplishment: this.accomplishmentUpdate,
});
this.bornUpdate = null; // clear form field
this.accomplishmentUpdate = null; // clear form field
}
We have two handler functions, making UPDATE more complex than the other CRUD features. onSelect()
fires when the user selects a compputer scientist, then quieries the database to fill in the year and the accomplishment. The user then updates one or both fields and clicks the button that fires onUpdate()
. This handler fucntion queries the database to find the document by the name
property and return the document identifier. We could eliminate this code if we used a data structure in which the document identifier always matched the name
property.
When we have the document identifier we then run updateDoc()
to update the database.
As we learned earlier, set()
will overwrite a document or create it if it doesn't exist yet. But we can append {merge: true}
to set()
:
set({data: 12345}, {merge: true})
This will update fields in the document or create it if it doesn't exists. In contrast, update()
will update fields but will fail if the document doesn't exist. You might call this "updateOrCreate()".
Looking through code that I wrote a few years ago I found this:
if (doc.exists) {
return admin.firestore().collection('Dictionaries').doc(longLanguage).update({ tokenDownloadURLs: downloadURLs })
.then() // do nothing
.catch(error => console.error(error));
} else {
return admin.firestore().collection('Dictionaries').doc(longLanguage).set({ tokenDownloadURLs: downloadURLs })
.then() // do nothing
.catch(error => console.error(error));
} // close else
})
In other words, if the document exists, run update()
; else run set()
. Now I can write the same code
Can't do that from anywhere.
There's a few things that I either don't understand or Firebase can't do.
The TypeScript gods hate it when we use any
but collections and documents returned from Firestore are type any
, as far as I know. You send data to Firestore as a Scientist
custom type but it comes back as a document in which the data is now document.data
, i.e., Firestore puts a container around your data. The returned container includes metadata, $event
, and snapshot
. These also have to be typed any
. (I tried typing $event
as Event
but this threw an error.)
Maybe Firestore could provide a module with interfaces or types, Collection
and Document
, that we could use instead of any
?
Firestore has a data converter feature to make custom objects but I don't see how this will get rid of any
.
As noted above, I couldn't get unsubscribe()
from collections listener to work.
Please send pull requests if you find mistakes.
This Angular app has four modified files.
export const environment = {
production: false,
firebaseConfig: {
apiKey: "abc123",
authDomain: "myCRUDyApp.firebaseapp.com",
projectId: "myCRUDyApp",
storageBucket: "myCRUDyApp.appspot.com",
messagingSenderId: "12345",
appId: "1:12345:web:abcde"
}
};
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { environment } from '../environments/environment'; // access firebaseConfig
// Angular
import { FormsModule } from '@angular/forms';
// AngularFire
import { provideFirebaseApp, initializeApp } from '@angular/fire/app';
import { provideFirestore, getFirestore } from '@angular/fire/firestore';
@NgModule({
declarations: [
AppComponent
],
imports: [
BrowserModule,
FormsModule,
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideFirestore(() => getFirestore()),
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
<h2>Greatest Computer Scientists</h2>
<h3>Create document (add)</h3>
<form (ngSubmit)="onCreate()">
<input type="text" [(ngModel)]="name" name="name" placeholder="Name" required>
<input type="text" [(ngModel)]="born" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishment" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Submit">Submit</button>
</form>
<h3>Create document (set)</h3>
<form (ngSubmit)="onSet()">
<input type="text" [(ngModel)]="nameSet" name="name" placeholder="Name" required>
<input type="text" [(ngModel)]="bornSet" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishmentSet" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Submit">Submit</button>
</form>
<h3>Read (document, once)</h3>
<form (ngSubmit)="getDocument()">
<select name="scientist" [(ngModel)]="documentID">
<option *ngFor="let scientist of scientist$ | async" [ngValue]="scientist.name">
{{ scientist.name }}
</option>
</select>
<button type="submit" value="Submit">Get Document</button>
</form>
<div *ngIf="singleDoc.name">{{ singleDoc.name }}, born {{ singleDoc.born }}: {{ singleDoc.accomplishment }}</div>
<h3>Read (collection, once)</h3>
<form (ngSubmit)="getData()">
<button type="submit" value="getData">Get Collection</button>
</form>
<ul>
<li *ngFor="let scientist of scientists">
{{scientist.name}}, born {{scientist.born}}: {{scientist.accomplishment}}
</li>
</ul>
Show only computer scientists born after:
<select name="whenBorn" [(ngModel)]="whenBorn">
<option>1700</option>
<option>1800</option>
<option>1900</option>
</select> (filter documents with <code>query</code> and <code>where</code>)
<h3>Observe (collection)</h3>
<ul>
<li *ngFor="let scientist of scientist$ | async">
{{scientist.name}}, born {{scientist.born}}: {{scientist.accomplishment}}
</li>
</ul>
<form (ngSubmit)="detachListener()">
<button type="submit" value="detachListener">Detach Listener</button>
</form>
<h3>Observe (document, 'Charles Babbage')</h3>
<div *ngIf="charle$.name">{{ charle$.name }}, born {{ charle$.born }}: {{ charle$.accomplishment}}</div>
<h3>Update document</h3>
<form (ngSubmit)="onUpdate()">
<select (change)="onSelect($event)">
<option>Select scientist</option>
<option *ngFor="let scientist of scientist$ | async" [ngValue]="scientist.name">
{{ scientist.name }}
</option>
</select>
<input type="text" [(ngModel)]="bornUpdate" name="born" placeholder="Year born">
<input type="text" [(ngModel)]="accomplishmentUpdate" name="accomplishment" placeholder="Accomplishment">
<button type="submit" value="Update">Update</button>
</form>
<h3>Delete document</h3>
<form (ngSubmit)="onDelete()">
<select name="deleteScientist" [(ngModel)]="selection">
<option *ngFor="let scientist of scientist$ | async" [ngValue]="scientist.name">
{{scientist.name}}
</option>
</select>
<button type="submit" value="Submit">Delete</button>
</form>
import { Component, OnInit } from '@angular/core';
import { Observable } from 'rxjs';
// Firebase
import { Firestore, doc, addDoc, setDoc, getDoc, getDocs, collectionData, collection, deleteDoc, query, where, updateDoc, onSnapshot } from '@angular/fire/firestore';
interface Scientist {
name?: string | null,
born?: number | null,
accomplishment?: string | null
};
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
styleUrls: ['./app.component.css']
})
export class AppComponent {
title = 'GreatestComputerScientistsLite';
//CREATE add()
name: string | null = null;
born: number | null = null;
accomplishment: string | null = null;
// CREATE set()
nameSet: string = '';
bornSet: number | null = null;
accomplishmentSet: string | null = null;
// UPDATE
nameUpdate: string = '';
bornUpdate: number | null = null;
accomplishmentUpdate: string | null = null;
querySnapshot: any;
// READ collecion
scientists: Scientist[] = [];
// OBSERVE
scientist$: Observable<Scientist[]>;
charle$: Scientist = {
name: null,
born: null,
accomplishment: null
};
unsubCharle$: any;
selection: string = '';
documentID: string = '';
docSnap: any;
// READ document
singleDoc: Scientist = {
name: null,
born: null,
accomplishment: null
}
setDoc: Scientist = {
name: null,
born: null,
accomplishment: null
}
deleteID: string = '';
deleteIDarray: string[] = [];
whenBorn: string = '1700';
constructor(public firestore: Firestore) {
// OBSERVER
this.scientist$ = collectionData(collection(firestore, 'Scientists')); // collection listener
this.unsubCharle$ = onSnapshot(doc(firestore, 'Scientists', 'Charles Babbage'), (snapshot: any) => { // document listener
this.charle$.name = snapshot.data().name;
this.charle$.born = snapshot.data().born;
this.charle$.accomplishment = snapshot.data().accomplishment;
});
}
// OBSERVER document listener
detachListener() {
this.unsubCharle$();
}
// CREATE add()
async onCreate() {
try {
const docRef = await addDoc(collection(this.firestore, 'Scientists'), {
name: this.name,
born: this.born,
accomplishment: this.accomplishment
});
this.name = null;
this.born = null;
this.accomplishment = null;
} catch (error) {
console.error(error);
}
}
// CREATE set()
async onSet() {
try {
await setDoc(doc(this.firestore, 'Scientists', this.nameSet), {
name: this.nameSet,
born: this.bornSet,
accomplishment: this.accomplishmentSet
});
this.nameSet = '';
this.bornSet = null;
this.accomplishmentSet = null;
} catch (error) {
console.error(error);
}
}
// READ document
async getDocument() {
try {
this.docSnap = await getDoc(doc(this.firestore, 'Scientists', this.documentID));
this.singleDoc = this.docSnap.data();
} catch (error) {
console.error(error);
}
}
// READ collection
async getData() {
try {
this.scientists = []; // clear view
this.querySnapshot = await getDocs(query(collection(this.firestore, 'Scientists'), where('born', '>=', this.whenBorn)));
this.querySnapshot.forEach((docElement: any) => {
this.scientists.push(docElement.data());
});
} catch (error) {
console.error(error);
}
}
// UPDATE
async onSelect(event: any) { // type Event doesn't work despite https://angular.io/guide/event-binding-concepts
try {
this.querySnapshot = await getDocs(query(collection(this.firestore, 'Scientists'), where('name', '==', event.target.value))); // query the database
this.querySnapshot.forEach((docElement: any) => { // itereate through the collection
this.nameUpdate = docElement.data().name; // transfer to local variables
this.bornUpdate = docElement.data().born;
this.accomplishmentUpdate = docElement.data().accomplishment;
});
} catch (error) {
console.error(error);
}
}
// UPDATE
async onUpdate() {
try {
this.querySnapshot = await getDocs(query(collection(this.firestore, 'Scientists'), where('name', '==', this.nameUpdate))); // find the document by the name property instead of the document identifier because we're using both autogenerated document identifiers and custom document identifiers
this.querySnapshot.forEach((docElement: any) => { // iterate through the collection to find the document identifier for the selected document
this.nameUpdate = docElement.id; // put the document identifier in a local variable
});
await updateDoc(doc(this.firestore, 'Scientists', this.nameUpdate), { // update the database
born: this.bornUpdate,
accomplishment: this.accomplishmentUpdate,
});
this.bornUpdate = null; // clear form field
this.accomplishmentUpdate = null; // clear form field
} catch (error) {
console.error(error);
}
}
// DELETE
async onDelete() {
try {
this.querySnapshot = await getDocs(query(collection(this.firestore, 'Scientists'), where('name', '==', this.selection))); // get a collection of documents filtered by the query
this.querySnapshot.forEach((docElement: any) => { // iterate through the collection
deleteDoc(doc(this.firestore, 'Scientists', docElement.id)); // delete all documents that match the query
});
} catch (error) {
console.error(error);
}
}
}