File

src/lib/angular-firestore-deep.ts

Description

Used as a wrapper for adding a document, either doc or path must be specified, helps when adding multiple

Index

Properties

Properties

data
data: A
Type : A

The data to be added

docFs
docFs: AngularFirestoreDocument
Type : AngularFirestoreDocument
Optional

AngularFirestoreDocument of the Document

path
path: string
Type : string
Optional

The path to the Document

import {combineLatest, from, Observable, of} from 'rxjs';
import {
  Action,
  AngularFirestore,
  AngularFirestoreDocument, DocumentChangeAction,
  DocumentChangeType,
  DocumentReference,
  DocumentSnapshotExists
} from '@angular/fire/firestore';
import {CollectionReference, DocumentSnapshot} from '@angular/fire/firestore/interfaces';
import {catchError, filter, map, mergeMap, switchMap, take, tap} from 'rxjs/operators';
import {AngularFirestoreCollection} from '@angular/fire/firestore/collection/collection';
import {moveItemInArray, transferArrayItem} from '@angular/cdk/drag-drop';

import firebase from 'firebase';
import WriteBatch = firebase.firestore.WriteBatch;
import FirestoreError = firebase.firestore.FirestoreError;
import DocumentData = firebase.firestore.DocumentData;
import {FirestoreItem, FirestoreItemFullWithIndex} from './models/firestoreItem';
import {SubCollectionQuery} from './sub-collection-query';
import {SubCollectionWriter} from './sub-collection-writer';

import {
  getRefFromPath,
  getCollectionFsFromPath,
  getDocFsFromPath,
  getParentDocFsFromPath,
  convertTimestampToDate,
  addCreatedDate,
  addModifiedDate
} from './angularfirestore-helpers';


/** Used as a wrapper for adding a document, either doc or path must be specified, helps when adding multiple */
export interface AddDocumentWrapper<A> {
  /** The data to be added */
  data: A;

  /** AngularFirestoreDocument of the Document */
  docFs?: AngularFirestoreDocument;

  /** The path to the Document */
  path?: string;
}


export enum DocNotExistAction {
  /** returns a null object */
  RETURN_NULL,

  /** return all the extras such as ref, path and so on but no data, kinda just ignores that the doc isn't there */
  RETURN_ALL_BUT_DATA,

  /** do not return at all until it does exist */
  FILTER,

  /** return doc not found error 'doc_not_found' */
  THROW_DOC_NOT_FOUND,
}


/** Used internally */
interface CurrentDocSubCollectionSplit {
  currentDoc: FirestoreItem;
  subCollections: {[index: string]: any};
}


/**
 * Main Wrapper.
 *
 *
 *
 */
export class AngularFirestoreDeep {

  /**
   * Constructor for AngularFirestoreWrapper
   *
   * @param ngFirestore The AngularFirestore injected from an Angular Class
   * @param defaultDocId The default name of any Document when no name is given
   */
  constructor(private ngFirestore: AngularFirestore,
              private defaultDocId: string = 'data') {}

  /* ----------  LISTEN -------------- */

  // listenForChangesInBaseCollection$(): Observable<T[]> {
  //   return this.listenForCollection$(this.baseCollectionFs);
  // }

  // protected listenForDocInBaseCollectionById<A>(id: string, actionIfNotExist: DocNotExistAction = DocNotExistAction.RETURN_ALL_BUT_DATA):
  //   Observable<A> {
  //   return this.listenForDoc$(
  //     this.baseCollectionFs.doc(id),
  //     actionIfNotExist
  //   );
  // }

  protected listenForDoc$<A extends FirestoreItem>(docFs: AngularFirestoreDocument<FirestoreItem>,
                                                   actionIfNotExist: DocNotExistAction = DocNotExistAction.RETURN_ALL_BUT_DATA):
    Observable<A> {
    /**
     * Returns an observable that will emit whenever the ref changes in any way.
     * Also adds the id and ref to the object.
     */
    const res$ = docFs.snapshotChanges().pipe(
      // mergeMap((action) => {
      //   if (!action.payload.exists) { return throwError('Document Does not exists'); }
      //   else { return of(action); }
      // }),

      tap((action: Action<DocumentSnapshotExists<A>>) => {
        if (!action.payload.exists && actionIfNotExist === DocNotExistAction.THROW_DOC_NOT_FOUND) {
          throw new Error('doc_not_found');
        }
      }),

      filter((action: Action<DocumentSnapshotExists<A>>) => {
        return !(!action.payload.exists && actionIfNotExist === DocNotExistAction.FILTER);
      }),
      map((action: Action<DocumentSnapshotExists<A>>) => {

        if (action.payload.exists || actionIfNotExist === DocNotExistAction.RETURN_ALL_BUT_DATA) {
          const data = action.payload.data() as A;
          // const payload: DocumentSnapshotExists<DocumentData> = action.payload as DocumentSnapshotExists<DocumentData>;
          // @ts-ignore
          const id = action.payload.id;
          // @ts-ignore
          const docRef = action.payload.ref;
          const path = docRef.path;
          const _docFs = this.ngFirestore.doc(path);
          const isExists: boolean = action.payload.exists;

          // const afDocRef = this.fireStore.doc<A>(docRef.path) as AngularFirestoreDocument;  // TODO make sure this also copies the query
          return { ...data, id, path, ref: docRef, docFs: _docFs, isExists };
        } else if (actionIfNotExist === DocNotExistAction.RETURN_NULL) { /* doc doesn't exist */
          return null;
        }
      }),
      map((data) => {
        if (data != null) {
          return convertTimestampToDate(data);
        } else {
          return data;
        }
      }),

    ) as Observable<A>;

    return this.handleRes$<A>(res$, 'listenForDoc$', {docFs});
  }

  // TODO make listentoTypes and addExtras a single attribute called options
  protected listenForCollection$<A extends FirestoreItem>(collectionFs: AngularFirestoreCollection<FirestoreItem>,
                                                          listenToTypes?: DocumentChangeType[]): Observable<A[]> {
    /**
     * Returns an observable that will emit whenever the ref changes in any way.
     * Also adds the id and ref to the object.
     */

    if (listenToTypes === undefined || listenToTypes === null || listenToTypes.length <= 0) { listenToTypes = ['added', 'removed', 'modified']; }

    const res$ = collectionFs.snapshotChanges().pipe(

      map((actions: DocumentChangeAction<FirestoreItem>[]) => actions.filter(a => {
        return listenToTypes.includes(a.type);
      })),
      map((actions: DocumentChangeAction<FirestoreItem>[]) => actions.map(a => {
        const data = a.payload.doc.data() as A;

        const id = a.payload.doc.id;
        const docRef = a.payload.doc.ref;
        const path = docRef.path;
        const _docFs = this.ngFirestore.doc(path);
        // const afDocRef = this.fireStore.doc<A>(docRef.path) as AngularFirestoreDocument; // TODO make sure this also copies the query
        return { ...data, id, path, ref: docRef, docFs: _docFs };

      })),
      map((datas: A[]) => datas.map(data => {
        return convertTimestampToDate(data);
      }))
    ) as Observable<A[]>;

    return this.handleRes$<A[]>(res$, 'listenForCollection$', {collectionFs, listenToTypes});
  }


  /**
   * Wrapper for listenForDocDeepRecursiveHelper$ so that we can cast the return to the correct type
   * All logic is in listenForDocDeepRecursiveHelper$.
   *
   * @link SubCollectionQuery
   *
   * E.x:
   *      const subCollectionQueries: SubCollectionQuery[] = [
   *         { name: 'data' },
   *         { name: 'secure' },
   *         { name: 'variants' },
   *         { name: 'images',
   *           queryFn: ref => ref.orderBy('index'),
   *           collectionWithNames: [
   *             { name: 'secure'}
   *           ]
   *         },
   *     ];
   *
   *     this.listenForDocAndSubCollections<Product>(docFs, collections)
   *
   * @param docFs - a docFs with potential queryFn
   * @param subCollectionQueries - see example
   */
  public listenForDocDeep$<A extends FirestoreItem>(docFs: AngularFirestoreDocument,
                                                    subCollectionQueries: SubCollectionQuery[]): Observable<A> {

    const res$ = this.listenForDocDeepRecursiveHelper$(docFs, subCollectionQueries).pipe(
      map(data => data as A)
    );

    return this.handleRes$(res$, 'listenForDocDeep$', {docFs, subCollectionQueries}, null, 'Failed To read doc');
  }

  /**
   * DO NOT CALL THIS METHOD, meant to be used solely by listenForDocAndSubCollections$
   */
  protected listenForDocDeepRecursiveHelper$<A extends FirestoreItem>(
    docFs: AngularFirestoreDocument,
    subCollectionQueries: SubCollectionQuery[],
    actionIfNotExist: DocNotExistAction = DocNotExistAction.RETURN_NULL): Observable<FirestoreItem> {

    /* Listen for the docFs*/
    return this.listenForDoc$(docFs, actionIfNotExist).pipe(
      //
      // catchError(err => {
      //   if (err === 'Document Does not exists') { return of(null); } /* if a doc is deleted we just skip it */
      //   else { throw err; }
      // }),

      filter(doc => doc !== null),

      mergeMap((item: FirestoreItem) => {

        if (item === null) { return of(item); }
        if (subCollectionQueries.length <= 0) { return of(item); }

        const collectionListeners: Array<Observable<any>> = [];

        /* Iterate over each sub collection we have given and create collection listeners*/
        subCollectionQueries.forEach(subCollectionQuery => {

          // console.log(subCollectionQuery);

          const collectionFs = docFs.collection(subCollectionQuery.name, subCollectionQuery.queryFn);
          // const collectionFs = this.firestore.docFs(item.path).subCollectionQuery(subCollectionQuery.name, subCollectionQuery.queryFn);

          const collectionListener = this.listenForCollection$(collectionFs, null).pipe(
            // tap(d => console.log(d)),
            // filter(docs => docs.length > 0), // skip empty collections or if the subCollectionQuery doesnt exist
            /* Uncomment to see data on each update */
            // tap(d => console.log(d)),

            /* Listen For and Add any Potential Sub Docs*/
            mergeMap((docs: FirestoreItem[]) => {
              if (!subCollectionQuery.subCollectionQueries) { return of(docs); }

              const docListeners: Array<Observable<any>> = [];

              docs.forEach(d => {
                const subDocFs = this.ngFirestore.doc(d.path);
                const subDocAndCollections$ = this.listenForDocDeepRecursiveHelper$(subDocFs,
                  subCollectionQuery.subCollectionQueries).pipe(
                  // tap(subDoc => console.log(subDoc)),
                  // filter(subDoc => subDoc !== null),
                  map((subDoc: FirestoreItem) => {
                    return {...d, ...subDoc } as FirestoreItem;
                  }),
                  // tap(val => console.log(val))
                );
                docListeners.push(subDocAndCollections$);
              });

              // console.log(docListeners);

              if (docListeners.length <= 0) { return of([]); } /* subCollectionQuery is empty or doesnt exist */

              return combineLatest(docListeners).pipe(
                // tap(val => console.log(val))
              );
            }), /* End of Listening for sub docs */
            // tap(val => console.log(val)),

            /* If docs.length === 1 and the id is defaultDocId or the given docId it means we are in a sub subCollectionQuery
            and we only care about the data. So we remove the array and just make it one object with the
            subCollectionQuery name as key and docs[0] as value */
            map((docs: FirestoreItem[]) => {
              const docId = subCollectionQuery.docId !== undefined ? subCollectionQuery.docId : this.defaultDocId;

              if (docs.length === 1 && docs[0].id === docId) { return {[subCollectionQuery.name]: docs[0]}; }
              else { return {[subCollectionQuery.name]: docs}; }
            }),
            // tap(d => console.log(d)),
          );

          collectionListeners.push(collectionListener);
        });

        /* Finally return the combined collection listeners*/
        return combineLatest(collectionListeners).pipe(
          map((datas: Array<any>) => {
            const datasMap = {};

            datas.forEach((collection) => {

              for (const [key, value] of Object.entries(collection)) {
                datasMap[key] = value;
              }
            });
            return datasMap;
          }),

          map((data: DocumentData) => {
            return {...item, ...data};
          }),
          // tap(data => console.log(data)),
        );
      })
    );
  }

  protected listenForCollectionRecursively<A extends FirestoreItem>(path: string,
                                                                    collectionKey: string,
                                                                    orderKey?: string): Observable<any> {

    /**
     * Listens for collections inside collections with the same name to an unlimited depth and returns all of it as an array.
     */
    let ref;

    if (orderKey != null) {
      ref = this.ngFirestore.collection(path, r => r.orderBy(orderKey));
    } else {
      ref = this.ngFirestore.collection(path);
    }

    return this.listenForCollection$<A>(ref, null).pipe(
      mergeMap((items: A[]) => {

        if (items.length <= 0) { return of([]); } // TODO  perhaps make this throw an error so that we can skip it

        // if (items.length <= 0) { throwError('No more '); }

        const nextLevelObs: Array<Observable<A>> = [];

        for (const item of items) {


          const nextLevelPath = this.ngFirestore.doc(item.path).collection(collectionKey).ref.path;  // one level deeper

          const nextLevelItems$ = this.listenForCollectionRecursively(nextLevelPath, collectionKey, orderKey).pipe(
            map((nextLevelItems: A[]) => {
              if (nextLevelItems.length > 0) { return {...item, [collectionKey]: nextLevelItems } as A; }
              else {  return {...item} as A; }  // dont include an empty array
            }),
          );
          nextLevelObs.push(nextLevelItems$);
        }

        return combineLatest(nextLevelObs).pipe(
          tap(val => console.log(val))
        );
      }),

    );
  }

  /* ----------  ADD -------------- */

  // protected addToBaseCollection$(data: T, id?: string): Observable<T> {
  //   return this.add$<T>(data, this.baseCollectionFs, id);
  // }

  protected add$<A>(data: A, collectionFs: AngularFirestoreCollection, id?: string): Observable<A> {
    return of(null).pipe(
      mergeMap(() => {
        let res$: Observable<any>;

        data = addCreatedDate(data);
        data = addModifiedDate(data);

        if (id !== undefined) { res$ = from(collectionFs.doc(id).set(data)); }
        else { res$ = from(collectionFs.add(data)); }

        res$ = res$.pipe(
          // tap(() => this.snackBar.open('Success', 'Added', {duration: 1000})),
          tap(ref => console.log(ref)),
          tap(() => console.log(data)),
          map((ref: DocumentReference) => {
            if (id === undefined) {
              return {...data, id: ref.id, path: ref.path, ref, docFs: this.ngFirestore.doc(ref.path) };
            } else {
              const path = collectionFs.ref.path + '/' + id;
              return {...data, id, path, docFs: this.ngFirestore.doc(path) };
            }
          }),
        );

        return this.handleRes$(res$, 'add$', {data, collectionFs, id}, 'Document Added Successfully', 'Document failed to add').pipe(
          take(1) /* No need to unsub */
        );
      })
    );
  }

  /**
   *
   * @param data the data to be saved
   * @param collectionFs AngularFirestoreCollection reference to where on firestore the item should be saved
   * @param subCollectionWriters see documentation for SubCollectionWriter for more details on how these are used
   * @param docId If a docId is given it will use that specific id when saving the doc, if no docId is given a ranom id will be used.
   */
  public addDeep$<A extends FirestoreItem>(data: A,
                                           collectionFs: AngularFirestoreCollection,
                                           subCollectionWriters?: SubCollectionWriter[],
                                           docId?: string): Observable<A> {

    const split = this.splitDataIntoCurrentDocAndSubCollections(data, subCollectionWriters);
    const currentDoc = split.currentDoc;
    const subCollections = split.subCollections;

    // console.log(currentDoc, subCollections);

    // console.log(Object.entries(subCollections).keys(), Object.entries(subCollections).values());

    const res$ = this.add$<A>(currentDoc as A, collectionFs, docId).pipe(
      // tap(val => console.log(val)),

      /* Add Sub/sub collections*/
      mergeMap((currentData: A) => {

        const subWriters: Array<Observable<any>> = [];

        for (const [subCollectionKey, subCollectionValue] of Object.entries(subCollections)) {
          let subSubCollectionWriters: SubCollectionWriter[]; // undefined in no subCollectionWriters
          let subDocId: string;

          if (subCollectionWriters) {
            subSubCollectionWriters = subCollectionWriters.find(subColl => subColl.name === subCollectionKey).subCollectionWriters;
            subDocId = subCollectionWriters.find(subColl => subColl.name === subCollectionKey).docId;
          }

          const subCollectionFs = this.ngFirestore.doc(currentData.path).collection(subCollectionKey);

          /* Handle array and object differently
          * For example if array and no docId is given it means we should save each entry as a separate doc.
          * If a docId is given should save it using that docId under a single doc.
          * If not an array it will always be saved as a single doc, using this.defaultDocId as the default docId if none is given */
          if (Array.isArray(subCollectionValue)) {
            if (subDocId !== undefined) { /* not undefined so save it as a single doc under that docId */

              /* the pipe only matters for the return subCollectionValue not for writing the data */
              const subWriter = this.addDeep$(subCollectionValue as FirestoreItem, subCollectionFs, subSubCollectionWriters, subDocId).pipe(
                map(item => {
                  // return {[key]: item};
                  return {key: subCollectionKey, value: item}; /* key and subCollectionValue as separate k,v properties */
                })
              );
              subWriters.push(subWriter);

            } else { /* docId is undefined so we save each object in the array separate */
              subCollectionValue.forEach((arrayValue: FirestoreItem) => {

                /* the pipe only matters for the return subCollectionValue not for writing the data */
                const subWriter = this.addDeep$(arrayValue, subCollectionFs, subSubCollectionWriters).pipe(
                  map(item => {
                    // return {[key]: [item]};
                    /* key and subCollectionValue as separate k,v properties -- subCollectionValue in an array */
                    return {key: subCollectionKey, value: [item]};
                  })
                );

                subWriters.push(subWriter);
              });
            }
          } else { /* Not an array so a single Object*/
            subDocId = subDocId !== undefined ? subDocId : this.defaultDocId;

            /* the pipe only matters for the return subCollectionValue not for writing the data */
            const subWriter = this.addDeep$(subCollectionValue, subCollectionFs, subSubCollectionWriters, subDocId).pipe(
              map(item => {
                // return {[key]: item};
                return {key: subCollectionKey, value: item}; /* key and subCollectionValue as separate k,v properties */
              })
            );

            subWriters.push(subWriter);
          }
        } /* end of iteration */

        if (subWriters.length > 0) { /* if subWriters.length > 0 it means we need to handle the subwriters */

          /* the pipe only matters for the return value not for writing the data */
          return combineLatest(subWriters).pipe(
            // tap(sub => console.log(sub)),

            // TODO super duper ugly way of joining the data together but I cannot think of a better way..also it doesnt really matter.
            // TODO The ugliness only relates to how the return object looks after we add, it has no effect on how the object is saved on
            // TODO firestore.

            map((docDatas: Array<Map<string, []>>) => { /* List of sub docs*/
              const groupedData = {};

              docDatas.forEach((doc: {[index: string]: any}) => { /* iterate over each doc */

                const key = doc.key;
                const value = doc.value;

                /* if groupedData has the key already it means that the several docs have the same key..so an array */
                if (groupedData.hasOwnProperty(key) && Array.isArray(groupedData[key])) {
                  /* groupedData[key] must be an array since it already exist..add this doc.value to the array */
                  (groupedData[key] as Array<any>).push(value[0]);
                } else {
                  groupedData[key] = value;
                }
              });

              return groupedData as object;
            }),

            // tap(groupedData => console.log(groupedData)),

            map((groupedData: A) => {
              return {...currentData, ...groupedData } as A;
            }),
            // tap(d => console.log(d)),
          );
        } else {
          return of(currentData);
        }
      })
    );

    return this.handleRes$(res$, 'addDeep$', {data, collectionFs, subCollectionWriters, docId}, 'Document Added Successfully', 'Document failed to add').pipe(
      // tap(d => console.log(d)),
      take(1) /* just to make sure it only emits once */
    );
  }


  /* ----------  EDIT -------------- */

  /**
   * Goes through each level and removes DbItemExtras
   * In case you wish to save the data
   */
  public cleanExtrasFromData<A extends FirestoreItem>(data: A, subCollectionWriters: SubCollectionWriter[]): A {

    // const dataToBeCleaned = cloneDeep(data); /* clone data so we dont modify the original */
    const dataToBeCleaned = data;
    return this.removeDataExtrasRecursiveHelper(dataToBeCleaned, subCollectionWriters) as A;
  }

  protected removeDataExtrasRecursiveHelper<A extends FirestoreItem>(dbItem: A, subCollectionWriters: SubCollectionWriter[]): A {

    // tslint:disable-next-line:no-console
    console.time('removeDataExtrasRecursiveHelper');

    // const extraPropertyNames: string[] = Object.getOwnPropertyNames(new DbItemExtras());
    const extraPropertyNames: string[] = ['id', 'path', 'ref', 'docFs', 'isExists'];

    /* Current level delete */
    for (const extraPropertyName of extraPropertyNames) {
      delete dbItem[extraPropertyName];
    }

    subCollectionWriters.forEach(col => {
      if (Array.isArray(dbItem[col.name])) { /* property is array so will contain multiple docs */

        const docs: FirestoreItem[] = dbItem[col.name];
        docs.forEach(d => {

          if (col.subCollectionWriters) {
            this.removeDataExtrasRecursiveHelper(d, col.subCollectionWriters);
          } else {
            /*  */
            for (const extraPropertyName of extraPropertyNames) {
              delete dbItem[col.name][extraPropertyName];
            }
          }
        });

      } else { /* not an array so a single doc*/

        if (col.subCollectionWriters) {
          this.removeDataExtrasRecursiveHelper(dbItem[col.name], col.subCollectionWriters);
        } else {
          for (const extraPropertyName of extraPropertyNames) {
            delete dbItem[col.name][extraPropertyName];
          }
        }

      }
    });

    // tslint:disable-next-line:no-console
    console.timeEnd('removeDataExtrasRecursiveHelper');

    return dbItem;

  }

  /**
   * Firestore doesn't allow you do change the name or move a doc directly so you will have to create a new doc under the new name
   * and then delete the old doc.
   * returns the new doc once the delete is done.
   */
  public changeDocName$<A extends FirestoreItem>(docFs: AngularFirestoreDocument,
                                                 subCollectionQueries: SubCollectionQuery[],
                                                 subCollectionWriters: SubCollectionWriter[],
                                                 newName: string): Observable<any> {

    const collectionFs: AngularFirestoreCollection = this.ngFirestore.collection(docFs.ref.parent);


    const res$ = this.listenForDocDeep$(docFs, subCollectionQueries).pipe(
      take(1),
      tap(data => console.log(data)),
      map((oldData: A) => this.removeDataExtrasRecursiveHelper(oldData, subCollectionWriters)),
      tap(data => console.log(data)),
      mergeMap((oldData: A) => {

        return this.addDeep$(oldData, collectionFs, subCollectionWriters, newName).pipe( /* add the data under newName*/
          // mergeMap(newData => { /* delete the old doc */
          //   return this.deleteDeep$(docFs, subCollectionQueries).pipe(
          //     mapTo(newData) /* keep the new data */
          //   );
          // }),
          // mergeMap(newData => {
          //   const newDocFs = this.ngFirestore.doc(newData.path);
          //   return this.listenForDocDeep$<A>(newDocFs, subCollectionQueries);  /* switch to listening to the new doc*/
          // }),
        );
      }),
      catchError(err => {
        console.log('Failed to Change Doc Name: ' + err);
        throw err;
      })
    );

    return this.handleRes$(res$,
      'changeDocName$',
      {docFs, subCollectionQueries, subCollectionWriters, newName},
      'Failed to Change Doc Name'
    );
  }

  protected update$<A>(data: A, docFs: AngularFirestoreDocument): Observable<boolean> {
    /**
     * transforms the promise to an observable
     */
    return of(null).pipe(
      mergeMap(() => {
        let res$: Observable<any>;

        data = addModifiedDate(data);

        res$ = from(docFs.update(data));

        return this.handleRes$(res$, 'update$', {data, docFs}, 'Updated', 'Failed to Update Document').pipe(
          // take(1) /* no need to unsub */
        );
      }),
      take(1) /* just to make sure it only emits once */
    );
  }

  /**
   * Be careful when updating a document of any kind since we allow partial data there cannot be any type checking prior to update
   * so its possible to introduce spelling mistakes on attributes and so forth
   */
  public updateDeep$<A>(data: A,
                        docFs: AngularFirestoreDocument,
                        subCollectionWriters: SubCollectionWriter[]): Observable<any> {

    const batch = this.updateDeepToBatchHelper(data, docFs, subCollectionWriters);

    const res$ = this.batchCommit(batch);

    return this.handleRes$(res$, 'updateDeep$', {data, docFs, subCollectionWriters}, 'Updated', 'Failed to Update Document').pipe(
      take(1) /* no need to unsub */
    );
  }

  /**
   * DO NOT CALL THIS METHOD, used by update deep
   */
  protected updateDeepToBatchHelper<A>(data: A,
                                       docFs: AngularFirestoreDocument,
                                       subCollectionWriters: SubCollectionWriter[],
                                       batch?: WriteBatch): WriteBatch {

    if (batch === undefined) { batch = this.ngFirestore.firestore.batch(); }

    const split = this.splitDataIntoCurrentDocAndSubCollections(data, subCollectionWriters);
    const currentDoc = split.currentDoc;
    const subCollections = split.subCollections;

    // console.log(currentDoc, subCollections);

    batch.update(docFs.ref, currentDoc);

    for (const [subCollectionKey, subDocUpdateValue] of Object.entries(subCollections)) {

      let subSubCollectionWriters: SubCollectionWriter[]; // undefined in no subCollectionWriters
      let subDocId: string;

      if (subCollectionWriters) {
        subSubCollectionWriters = subCollectionWriters.find(subColl => subColl.name === subCollectionKey).subCollectionWriters;
        subDocId = subCollectionWriters.find(subColl => subColl.name === subCollectionKey).docId;
      }

      subDocId = subDocId !== undefined ? subDocId : this.defaultDocId; /* Set default if none given */

      const subDocFs = docFs.collection(subCollectionKey).doc(subDocId);

      batch = this.updateDeepToBatchHelper(subDocUpdateValue, subDocFs, subSubCollectionWriters, batch);
    }

    return batch;
  }


  protected updateDocByPath$<A>(path: string, data: A): Observable<any> {
    const doc = this.ngFirestore.doc(path);
    return this.update$(data, doc);
  }

  protected deleteDocByPath(path: string): Observable<any> {
    const doc = this.ngFirestore.doc(path);
    return this.delete$(doc);
  }

  protected updateMultiple$<A>(docs: AngularFirestoreDocument[], data: A): Observable<any> {
    const batch = this.ngFirestore.firestore.batch();


    docs.forEach((doc) => {
      batch.update(doc.ref, data);
    });

    return this.batchCommit(batch);
  }

  protected updateMultipleByPaths$<A>(paths: string[], data: A): Observable<any> {
    const docsFs: AngularFirestoreDocument[] = [];

    paths.forEach(path => {
      docsFs.push(this.ngFirestore.doc(path));
    });

    return this.updateMultiple$<A>(docsFs, data);
  }

  /* Move Item in Array */

  protected moveItemInArray$<A extends FirestoreItemFullWithIndex>(array: A[], fromIndex: number, toIndex: number): Observable<any> {
    console.log(fromIndex, toIndex);

    if (fromIndex == null || toIndex == null || fromIndex === toIndex || array.length <= 0) { // we didnt really move anything
      return of(null);
    }

    const batch = this.getBatchFromMoveItemInIndexedDocs(array as FirestoreItemFullWithIndex[], fromIndex, toIndex);

    return this.batchCommit(batch);
  }

  protected getBatchFromMoveItemInIndexedDocs<A extends FirestoreItemFullWithIndex>(array: Array<A>,
                                                                                    fromIndex: number,
                                                                                    toIndex: number,
                                                                                    useCopy = false): firebase.firestore.WriteBatch {
    /**
     * Moved item within the same list so we need to update the index of all items in the list;
     * Use a copy if you dont wish to update the given array, for example when you watch to just listen for the change of the db..
     * The reason to not do this is because it takes some time for the db to update and it looks better if the list updates immediately.
     */

    const lowestIndex = Math.min(fromIndex, toIndex);
    const batch = this.ngFirestore.firestore.batch();

    if (fromIndex == null || toIndex == null || fromIndex === toIndex) { // we didnt really move anything
      return batch;
    }

    let usedArray: Array<A>;

    if (useCopy) {
      usedArray = Object.assign([], array);
    } else {
      usedArray = array;
    }

    // console.log(usedArray, array);

    moveItemInArray(usedArray, fromIndex, toIndex);

    const listSliceToUpdate: A[] = usedArray.slice(lowestIndex);

    let i = lowestIndex;
    for (const item of listSliceToUpdate) {
      const ref = getRefFromPath(item.path) as DocumentReference;
      batch.update(ref, {index: i});
      i++;
    }

    return batch;
  }

  protected getBatchFromTransferItemInIndexedDocs<A extends FirestoreItemFullWithIndex>(previousArray: A[],
                                                                                        currentArray: A[],
                                                                                        previousIndex: number,
                                                                                        currentIndex: number,
                                                                                        additionalDataUpdateOnMovedItem?: {[key: string]: any},
                                                                                        useCopy = false): firebase.firestore.WriteBatch {

    /**
     * Used mainly for drag and drop scenarios where we drag an item from one array to another and the the docs have an index attribute.
     */

    const batch = this.ngFirestore.firestore.batch();

    let usedPreviousArray: Array<A>;
    let usedCurrentArray: Array<A>;
    if (useCopy) {
      usedPreviousArray = Object.assign([], previousArray);
      usedCurrentArray = Object.assign([], currentArray);
    } else {
      usedPreviousArray = previousArray;
      usedCurrentArray = currentArray;
    }

    transferArrayItem(usedPreviousArray, usedCurrentArray, previousIndex, currentIndex);

    if (additionalDataUpdateOnMovedItem !== undefined) {
      const movedItem = currentArray[currentIndex];
      const movedPartRef = getRefFromPath(movedItem.path) as DocumentReference;
      batch.update(movedPartRef, additionalDataUpdateOnMovedItem);
    }

    const currentArraySliceToUpdate: A[] = usedCurrentArray.slice(currentIndex);
    let i = currentIndex;
    for (const item of currentArraySliceToUpdate) {
      const ref = getRefFromPath(item.path) as DocumentReference;
      batch.update(ref, {index: i});
      i++;
    }

    const prevArraySliceToUpdate: A[] = usedPreviousArray.slice(previousIndex);
    i = previousIndex;
    for (const item of prevArraySliceToUpdate) {
      const ref = getRefFromPath(item.path) as DocumentReference;
      batch.update(ref, {index: i});
      i++;
    }

    return batch;
  }

  protected getBatchFromDeleteItemInIndexedDocs<A extends FirestoreItemFullWithIndex>(array: A[]): firebase.firestore.WriteBatch {

    /**
     * Run this on collections with a fixed order using an index: number attribute;
     * This will update that index with the index in the collectionData, so it should be sorted by index first.
     * Basically needs to be run after a delete
     */

    const batch = this.ngFirestore.firestore.batch();

    array.forEach((item, index) => {
      if (item.index !== index) {
        const ref = getRefFromPath(item.path) as DocumentReference;
        batch.update(ref, {index});
      }
    });

    return batch;
  }

  protected updateIndexAfterDeleteInIndexedDocs<A extends FirestoreItemFullWithIndex>(array: A[]): Observable<any> {
    const batch = this.getBatchFromDeleteItemInIndexedDocs(array);
    return this.batchCommit(batch);
  }

  /* ----------  DELETE -------------- */

  protected delete$(docFs: AngularFirestoreDocument): Observable<any> {

    return of(null).pipe(
      mergeMap(() => {
        let res$: Observable<any>;
        res$ = from(docFs.delete());

        return this.handleRes$(res$, 'delete$', {docFs}, 'Deleted', 'Failed to Delete Document').pipe(
          take(1) /* no need to unsub*/
        );
      }),
      take(1)
    );
  }

  protected deleteMultiple$(docsFs: AngularFirestoreDocument[]): Observable<any> {
    const batch = this.ngFirestore.firestore.batch();

    docsFs.forEach((doc) => {
      batch.delete(doc.ref);
    });

    const res$ = this.batchCommit(batch);

    return this.handleRes$(res$, 'deleteMultiple$', docsFs.map(doc => doc.ref.path), 'Deleted', 'Failed to Delete Multiple');
  }

  protected deleteMultipleByPaths$(paths: string[]): Observable<any> {
    const docsFs: AngularFirestoreDocument[] = [];

    paths.forEach(path => {
      docsFs.push(this.ngFirestore.doc(path));
    });

    return this.deleteMultiple$(docsFs);
  }

  protected deleteMultipleDeep$(docsFs: AngularFirestoreDocument[], subCollectionQueries: SubCollectionQuery[]): Observable<any> {

    const mainDocLists$: Array<Observable<any>> = [];

    docsFs.forEach(doc => {
      const docList$ = this.getFirestoreDocumentsDeep$(doc, subCollectionQueries);
      mainDocLists$.push(docList$);
    });

    return combineLatest(mainDocLists$).pipe(
      // tap(lists => console.log(lists)),
      map((lists: [any]) => {
        let mainDocFsList: AngularFirestoreDocument[] = [];
        lists.forEach(list => {
          mainDocFsList = mainDocFsList.concat(list);
        });
        return mainDocFsList;
      }),
      // tap(lists => console.log(lists)),
      switchMap((docFsList: AngularFirestoreDocument[]) => this.deleteMultiple$(docFsList)),
      catchError((err) => { // TODO super ugly and I dont know why this error is thrown..still works
        if (err === 'Document Does not exists') { return of(null); }
        else { throw err; }
      })
    );
  }

  protected deleteDeep$(docFs: AngularFirestoreDocument, subCollectionQueries: SubCollectionQuery[]): Observable<any> {

    console.log('deleteDeep');
    const res$ = this.getFirestoreDocumentsDeep$(docFs, subCollectionQueries).pipe(
      switchMap((docList: AngularFirestoreDocument<DocumentData>[]) => this.deleteMultiple$(docList)),
      catchError((err) => { // TODO super ugly and I dont know why this error is thrown..still works
        if (err === 'Document Does not exists') { return of(null); }
        else { throw err; }
      }),
      take(1)
    );

    return this.handleRes$(res$, 'deleteDeep$', {docFs, subCollectionQueries}, 'Deleted', 'Failed to Delete');
  }

  protected deleteDeepByItem$(item: FirestoreItem, subCollectionQueries: SubCollectionQuery[]): Observable<any> {

    console.log('deleteDeepByItem$');

    const docsFs = this.getFirestoreDocumentsFromDbItem(item, subCollectionQueries);

    const res$ = this.deleteMultiple$(docsFs).pipe(
      catchError((err) => { // TODO super ugly and I dont know why this error is thrown..still works
        if (err === 'Document Does not exists') { return of(null); }
        else { throw err; }
      }),
      take(1)
    );

    return this.handleRes$(res$,
      'deleteDeepByItem$',
      {item, subCollectionQueries},
      'Deleted',
      'Failed to Delete'
    );
  }

  /* ----------  OTHER -------------- */



  /**
   * Returns an Observable containing a list of AngularFirestoreDocument found under the given docFs using the SubCollectionQuery[]
   * Mainly used to delete a docFs and its sub docs
   * @param docFs: AngularFirestoreDocument
   * @param subCollectionQueries: SubCollectionQuery[]
   */
  private getFirestoreDocumentsDeep$<A extends FirestoreItem>(docFs: AngularFirestoreDocument,
                                                              subCollectionQueries: SubCollectionQuery[]):
    Observable<AngularFirestoreDocument[]> {

    return this.listenForDocDeep$(docFs, subCollectionQueries).pipe(
      map(item => this.getPathsFromDbItemDeepRecursiveHelper(item, subCollectionQueries)),
      // tap(pathList => console.log(pathList)),
      map((pathList: A) => {
        const docFsList: AngularFirestoreDocument[] = [];
        pathList.forEach(path => docFsList.push(this.ngFirestore.doc(path)));
        return docFsList;
      }),
      // tap(item => console.log(item)),
    );
  }

  private getFirestoreDocumentsFromDbItem<A extends FirestoreItem>(dbItem: FirestoreItem,
                                                                   subCollectionQueries: SubCollectionQuery[]): AngularFirestoreDocument[] {

    const pathList = this.getPathsFromDbItemDeepRecursiveHelper(dbItem, subCollectionQueries);

    const docFsList: AngularFirestoreDocument[] = [];
    pathList.forEach(path => docFsList.push(this.ngFirestore.doc(path)));
    return docFsList;
  }

  /**
   * DO NOT CALL THIS METHOD, its meant as a support method for getDocs$
   */
  private getPathsFromDbItemDeepRecursiveHelper<A extends FirestoreItem>(dbItem: FirestoreItem,
                                                                         subCollectionQueries: SubCollectionQuery[]): string[] {

    // console.log(dbItem, subCollectionQueries);

    let pathList: string[] = [];
    pathList.push(dbItem.path);

    subCollectionQueries.forEach(col => {
      if (Array.isArray(dbItem[col.name])) { /* property is array so will contain multiple docs */

        const docs: FirestoreItem[] = dbItem[col.name];
        docs.forEach(d => {

          if (col.subCollectionQueries) {
            pathList = pathList.concat(this.getPathsFromDbItemDeepRecursiveHelper(d, col.subCollectionQueries));
          } else {
            pathList.push(d.path);
          }
        });

      } else { /* not an array so a single doc*/

        if (col.subCollectionQueries) {
          pathList = pathList.concat(this.getPathsFromDbItemDeepRecursiveHelper(dbItem, col.subCollectionQueries));
        } else {
          const item = (dbItem[col.name] as FirestoreItem);
          if (item != null && 'path' in item) {
            pathList.push(item.path);
          }
          // const path = (dbItem[col.name] as FirestoreItem).path;
        }

      }
    });

    // console.log(pathList);

    return pathList;
  }

  /**
   * DO  NOT  CALL THIS METHOD, used in addDeep and updateDeep to split the data into currentDoc and subCollections
   * Onyl goes one sub level deep;
   */
  private splitDataIntoCurrentDocAndSubCollections<A extends FirestoreItem>(
    data: A,
    subCollectionWriters?: SubCollectionWriter[]): CurrentDocSubCollectionSplit {

    /* Split data into current doc and sub collections */
    let currentDoc: { [index: string]: any; } = {};
    const subCollections: { [index: string]: any; }  = {};

    /* Check if the key is in subcollections, if not place it in currentDoc, else subCollections*/
    for (const [key, value] of Object.entries(data)) {
      // console.log(key, value);
      if (subCollectionWriters) {
        const subCollectionWriter: SubCollectionWriter = subCollectionWriters.find(subColl => subColl.name === key);

        if (subCollectionWriter) {
          subCollections[key] = value;
        } else {
          currentDoc[key] = value;
        }
      }
      else {
        currentDoc = data;
      }
    }

    return {
      currentDoc,
      subCollections
    } as CurrentDocSubCollectionSplit;
  }



  /**
   * Turn a batch into an Observable instead of Promise.
   */
  protected batchCommit(batch: firebase.firestore.WriteBatch): Observable<any> {
    return of(null).pipe(
      mergeMap(() => {
        let res$: Observable<any>;
        res$ = from(batch.commit());

        return this.handleRes$(res$, 'batchCommit', {batch}, 'Updated',
                  'Failed to Update Documents').pipe(
          // take(1) /* no need to unsub*/
        );
      }),
      take(1)
    );
  }

  protected handleRes$<A>(result$: Observable<A>, name: string, data: {[key: string]: any}, successMessage?: string, errorMessage?: string):
    Observable<A> {
    return result$.pipe(
      // tap(val => console.log(val)),
      tap(() => {
        // if (successMessage != null) { (this.snackBar.open('Success', successMessage, {duration: 1000})); }
      }),
      // map(() => true),
      catchError((err: FirestoreError) => {
        console.error(name, data, err);
        // if (errorMessage != null) { this.snackBar.open('Error', errorMessage + '-' +  err.code, {duration: 1000}); }
        throw err;
      }),

    );
  }


}

result-matching ""

    No results matching ""