Skip to content

Commit

Permalink
feat(collaboration): share notes with friends via email (#32)
Browse files Browse the repository at this point in the history
  • Loading branch information
juarezpaf committed Oct 23, 2018
1 parent 810ba63 commit 3e15cd1
Show file tree
Hide file tree
Showing 10 changed files with 191 additions and 48 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,5 @@ Thumbs.db

# firebase
firebase-adminsdk-credentials.json
functions/lib
functions/lib
.firebase
4 changes: 2 additions & 2 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ service cloud.firestore {
function isNoteValid() {
return incomingRequestData().title is string &&
incomingRequestData().title.size() > 0 &&
incomingRequestData().owner.id == request.auth.uid
incomingRequestData().owner == request.auth.uid
}

function incomingRequestData() {
return request.resource.data
}

function isNoteOwner() {
return resource.data.owner.id == request.auth.uid
return resource.data.owner == request.auth.uid
}
}
}
55 changes: 36 additions & 19 deletions src/app/notes/components/note-add/note-add.component.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,32 @@
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material';

import * as firebase from 'firebase/app';
import { AngularFirestore, AngularFirestoreCollection } from 'angularfire2/firestore';
import { AuthService } from '../../../core/auth.service';

import { Note } from '../../models/note.model';
import { AuthService } from '../../../core/auth.service';

@Component({
selector: 'app-note-add',
templateUrl: './note-add.component.html'
})
export class NoteAddComponent implements OnInit {
private notesCollection: AngularFirestoreCollection<Note>;
currentUser: any;
isLoading: boolean;
note: Note;
noteForm: FormGroup;
today: Date = new Date();
isLoading: boolean;
private notesCollection: AngularFirestoreCollection<Note>;

constructor(
private afs: AngularFirestore,
private auth: AuthService,
private fb: FormBuilder,
private router: Router
private router: Router,
private snackBar: MatSnackBar
) {
this.noteForm = fb.group({
title: ['', Validators.required ],
Expand All @@ -34,40 +38,53 @@ export class NoteAddComponent implements OnInit {

ngOnInit() {
this.notesCollection = this.afs.collection<Note>('notes');
this.auth.authState$.subscribe(user => {
this.currentUser = user;
});
}

onSubmit() {
async onSubmit() {
if (this.noteForm.valid) {
this.isLoading = true;
this.note = this.prepareToSaveNote();
this.notesCollection.add(this.note).then(doc => {
this.router.navigate([`/note/${doc.id}`]);
});
this.note = this.prepareSaveNote();
const docRef = await this.notesCollection.add(this.note);

this.redirectToNote({id: docRef.id, title: this.note.title});
}
}

private prepareToSaveNote(): Note {
const { email, photoURL, uid } = this.auth.user;
prepareSaveNote(): Note {
const userDoc = this.afs.doc(`users/${this.currentUser.uid}`);
const formModel = this.noteForm.value;

const newNote = {
completed: false,
createdAt: firebase.firestore.FieldValue.serverTimestamp(),
owner: {
id: this.auth.user.uid,
photoURL
},
createdBy: userDoc.ref,
owner: this.currentUser.uid,
photoURL: this.currentUser.photoURL,
isInvitaionFormEnabled: false,
sharedWith: [{
email,
photoURL,
uid,
email: this.currentUser.email,
photoURL: this.currentUser.photoURL,
uid: this.currentUser.uid,
owner: true
}],
collaborators: {
[this.auth.user.email.replace(/\W/g, '')]: true
[this.currentUser.email.replace(/\W/g, '')]: true
}
};

return {...formModel, ...newNote};
}

private redirectToNote(doc: any): any {
const snackBarRef = this.snackBar.open(`${doc.title} created successfully`, null, {
duration: 2000,
});

snackBarRef.afterDismissed().subscribe(() => {
this.router.navigate([`note/${doc.id}`]);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,49 @@
<mat-progress-bar mode="indeterminate" color="accent" class="app-loading-state"></mat-progress-bar>
</ng-template>

<mat-card class="app-empty-state">
<ng-template #collaboratorsBlankSlate>
<mat-card class="app-empty-state">
<mat-card-content>
<button mat-icon-button type="button" (click)="navigateBack()" class="btn-back">
<mat-icon aria-label="An icon-button with a arrow icon">keyboard_arrow_left</mat-icon>
</button>
<img src="assets/empty-collaborators.png" width="125">
<blockquote><span class="quotes"></span>A single rose can be my garden… a single friend, my world.<span class="quotes"></span><cite>Leo Buscaglia</cite></blockquote>
<button mat-raised-button type="button" (click)="enableInvitationForm()">Invite someone now</button>
</mat-card-content>
</mat-card>
</ng-template>

<mat-card *ngIf="note?.isInvitaionFormEnabled; else collaboratorsBlankSlate">
<mat-card-header>
<mat-card-title>
<button mat-icon-button (click)="navigateBack()" class="btn-back">
<mat-icon aria-label="An icon-button with a arrow icon">keyboard_arrow_left</mat-icon>
</button>
Collaborators
</mat-card-title>
</mat-card-header>

<mat-card-content>
<button mat-icon-button type="button" routerLink="/note/{{noteId}}" class="btn-back">
<mat-icon aria-label="An icon-button with a arrow icon">keyboard_arrow_left</mat-icon>
</button>
<img src="assets/empty-collaborators.png" width="125">
<blockquote><span class="quotes"></span>A single rose can be my garden… a single friend, my world.<span class="quotes"></span><cite>Leo Buscaglia</cite></blockquote>
<button mat-raised-button type="button" routerLink="/notes/add">Invite someone now</button>
<mat-list *ngIf="note?.sharedWith" class="collaborators-list">
<mat-list-item *ngFor="let collaborator of note.sharedWith">
<img matListAvatar [src]="collaborator.photoURL" alt="...">
<h3 matLine>{{collaborator.email}} <small *ngIf="collaborator.owner" class="collaborator-onwer">(Owner)</small></h3>
<button mat-icon-button (click)="deleteCollaborator($event, collaborator)" class="action-collaborator-delete"
color="warn" *ngIf="!collaborator.owner">
<mat-icon aria-label="An icon-button with a delete icon">cancel</mat-icon>
</button>
</mat-list-item>

<mat-list-item class="action-collaborator-addition">
<mat-icon mat-list-icon>group_add</mat-icon>
<div mat-line>
<mat-form-field floatLabel="never" color="accent">
<input matInput autocomplete="off" type="email" placeholder="Email to share with..." (keyup.enter)="addCollaborator($event)"
name="newCollaborator" [formControl]="emailFormControl">
</mat-form-field>
</div>
</mat-list-item>
</mat-list>
</mat-card-content>
</mat-card>
Original file line number Diff line number Diff line change
@@ -1,20 +1,96 @@
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { ActivatedRoute, Router, ActivatedRouteSnapshot } from '@angular/router';
import { AngularFirestore, AngularFirestoreDocument } from 'angularfire2/firestore';
import { Observable } from 'rxjs/Observable';
import { FormControl } from '@angular/forms';

import { Note } from '../../models/note.model';
import { Collaborator } from '../../models/collaborator.model';
import { AuthService } from '../../../core/auth.service';

@Component({
selector: 'app-note-collaborators',
templateUrl: './note-collaborators.component.html'
})
export class NoteCollaboratorsComponent implements OnInit {
noteId: any;
currentUser: any;
emailFormControl = new FormControl('');
note: Note;
note$: Observable<Note>;
private noteId = '';
private noteDoc: AngularFirestoreDocument<Note>;

constructor(
private route: ActivatedRoute
private auth: AuthService,
private afs: AngularFirestore,
private route: ActivatedRoute,
private router: Router
) {
route.params.subscribe((params: Object) => this.noteId = params['id']);
}

ngOnInit() {
async ngOnInit() {
this.noteDoc = this.afs.doc<Note>(`notes/${this.noteId}`);
this.note$ = this.noteDoc.snapshotChanges().map(item => {
const id = item.payload.id;
const data = item.payload.data();
return <Note>{ id, ...data };
});
this.note$.subscribe(noteItem => {
this.note = noteItem;
});

this.auth.authState$.subscribe(user => {
this.currentUser = user;
});
}

async addCollaborator(e) {
const email = e.target.value;
if (email.trim().length) {
const photoURL = `https://avatars.io/gravatar/${email}`;
const collaborator: Collaborator = { email, photoURL};
const collabEmailEscaped = email.replace(/\W/g, '');
const collaborators = {...this.note.collaborators, ...{[collabEmailEscaped]: true}};
const sharedWith = this.note.sharedWith.concat(collaborator);

await this.noteDoc.collection('collaborators').doc(`${collabEmailEscaped}`).set({
email,
photoURL,
invitedBy: this.currentUser.uid
});

await this.noteDoc.update({
collaborators,
sharedWith
});

this.emailFormControl.reset();
}
}

async deleteCollaborator(e, collab) {
const sharedWith = [...this.note.sharedWith.filter(item => item.email !== collab.email)];

// https://codeburst.io/use-es2015-object-rest-operator-to-omit-properties-38a3ecffe90
const collabEmailEscaped = collab.email.replace(/\W/g, '');
delete this.note.collaborators[collabEmailEscaped];
const collaborators = {...this.note.collaborators};

await this.noteDoc.collection('collaborators').doc(`${collabEmailEscaped}`).delete();
await this.noteDoc.update({
collaborators,
sharedWith
});
}

enableInvitationForm() {
this.noteDoc.update({
isInvitaionFormEnabled: true
});
}

navigateBack() {
this.router.navigate([`/note/${this.noteId}`]);
}
}
2 changes: 1 addition & 1 deletion src/app/notes/components/note/note.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
<mat-icon aria-label="An icon-button with a archive/unarchive icon">{{note?.archived ? 'una' : 'a'}}rchive</mat-icon>
</button>

<button mat-icon-button color="warn" type="button" (click)="deleteNote()">
<button [disabled]="note.owner !== currentUser?.uid" mat-icon-button color="warn" type="button" (click)="deleteNote()">
<mat-icon aria-label="An icon-button with a trash icon">delete_forever</mat-icon>
</button>

Expand Down
7 changes: 7 additions & 0 deletions src/app/notes/components/note/note.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Note } from '../../models/note.model';
import * as firebase from 'firebase/app';
import { AngularFirestore, AngularFirestoreDocument } from 'angularfire2/firestore';
import { Observable } from 'rxjs/Observable';
import { AuthService } from '../../../core/auth.service';

@Component({
selector: 'app-note',
Expand All @@ -14,12 +15,14 @@ import { Observable } from 'rxjs/Observable';
})
export class NoteComponent implements OnInit {
private noteDoc: AngularFirestoreDocument<Note>;
currentUser: any;
noteId: string;
note: Note;
note$: Observable<Note>;
minNoteDueDate = new Date();

constructor(
private auth: AuthService,
private afs: AngularFirestore,
private route: ActivatedRoute,
private router: Router,
Expand All @@ -38,6 +41,10 @@ export class NoteComponent implements OnInit {
this.note$.subscribe(noteItem => {
this.note = noteItem;
});

this.auth.authState$.subscribe(user => {
this.currentUser = user;
});
}

async deleteNote() {
Expand Down
24 changes: 13 additions & 11 deletions src/app/notes/components/notes-list/notes-list.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@

<ng-container *ngIf="notes$ | async; let notes; else loading">

<div *ngIf="notes.length > 0">
<ng-container *ngIf="notes.length > 0; else noResults">
<mat-card *ngFor="let note of notes" class="note-item" routerLink="/note/{{note.id}}">
<mat-card-header>
<img mat-card-avatar [src]="note.owner.photoURL">
<img mat-card-avatar [src]="note.photoURL">
<mat-card-title>{{note.title}}</mat-card-title>
</mat-card-header>
<mat-card-content>
Expand All @@ -27,14 +27,16 @@
<button mat-fab class="fab-bottom-right" type="button" routerLink="/notes/add">
<mat-icon aria-label="icon-button with a plus icon">library_add</mat-icon>
</button>
</div>
</ng-container>

<mat-card class="app-empty-state" *ngIf="notes.length === 0">
<mat-card-content>
<img src="assets/notes.png" width="125">
<blockquote><span class="quotes"></span>If you spend too much time thinking about a thing, you’ll never get it done.<span class="quotes"></span><cite>Bruce Lee</cite></blockquote>
<p>You haven't created any note yet</p>
<button mat-raised-button type="button" routerLink="/notes/add">Start Now</button>
</mat-card-content>
</mat-card>
<ng-template #noResults>
<mat-card class="app-empty-state">
<mat-card-content>
<img src="assets/notes.png" width="125">
<blockquote><span class="quotes"></span>If you spend too much time thinking about a thing, you’ll never get it done.<span class="quotes"></span><cite>Bruce Lee</cite></blockquote>
<p>You haven't created any note yet</p>
<button mat-raised-button type="button" routerLink="/notes/add">Start Now</button>
</mat-card-content>
</mat-card>
</ng-template>
</ng-container>
9 changes: 6 additions & 3 deletions src/app/notes/components/notes-list/notes-list.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ export class NotesListComponent implements OnInit {
) {}

ngOnInit() {
this.authService.authState$.subscribe(authUser => {
this.notesCollection = this.afs.collection<Note>('notes', ref => ref.where('owner.id', '==', authUser.uid));
this.authService.authState$.subscribe(user => {
const collabEmailEscaped = user.email.replace(/\W/g, '');
this.notesCollection = this.afs.collection<Note>('notes', ref => ref.where(`collaborators.${collabEmailEscaped}`, '==', true));

this.notes$ = this.notesCollection.snapshotChanges().map(actions => {
return actions.map(a => {
return actions.filter(item => !item.payload.doc.data().archived)
.map(a => {
const data = a.payload.doc.data() as Note;
const id = a.payload.doc.id;
return { id, ...data };
Expand Down
1 change: 1 addition & 0 deletions src/app/notes/models/note.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,5 @@ export class Note {
todos?: Todo[];
collaborators?: Object;
sharedWith?: Collaborator[];
isInvitaionFormEnabled?: boolean;
}

0 comments on commit 3e15cd1

Please sign in to comment.