RestCollectionView Firestore

This sample shows how you can extend the RestCollectionView class to create a custom RestCollectionViewFirestore class that talks to Firestore sources.

The RestCollectionViewFirestore class is similar to the wijmo.cloud.Collection class that ships with Wijmo. It supports CRUD operations as well as server-based sorting, filtering, and pagination.

Note that Firestore does not provide a total item count, so we can't calculate how many pages are available. For this reason, the CollectionViewNavigator in this sample shows only the current page and not the total count.

Also note that Firestore filtering is subject to certain limitations. Certain filter conditions are not supported ("contains", "does not contain", and "ends with"). Also, certain conditions may require additional indexes to be built, and some may conflict with server-side sorting. For these reasons, you may prefer to apply filters only on the client, or set server-side filters using custom code rather than using the FlexGridFilter class.

Learn about FlexGrid | Loading Data Documentation | CollectionView API Reference

import 'bootstrap.css'; import '@mescius/wijmo.styles/wijmo.css'; import './styles.css'; // import { RestCollectionViewFirestore } from './rest-collection-view-firestore'; import { isNumber } from '@mescius/wijmo'; import { CollectionViewNavigator } from '@mescius/wijmo.input'; import { FlexGrid } from '@mescius/wijmo.grid'; import { FlexGridFilter } from '@mescius/wijmo.grid.filter'; import { OAuth2 } from '@mescius/wijmo.cloud'; // // Firestore info const PROJECT_ID = 'test-9c0be'; const API_KEY = 'AIzaSyBeEwDqO_h1KOMRekRrDizOZweSiTXRj2Y'; const CLIENT_ID = '4727401369-ia1ur90eb7et0udmkvugc8i7c3v7u4t4.apps.googleusercontent.com'; // // field info for Customers table const fields = 'CustomerID,CompanyName,ContactName,ContactTitle,Address,City,Region,PostalCode,Country,Phone,Fax'.split(','); // document.readyState === 'complete' ? init() : window.onload = init; // function init() { // create the grid to show the data let theGrid = new FlexGrid('#theGrid', { allowAddNew: true, allowDelete: true, showMarquee: true, selectionMode: 'MultiRange', deferResizing: true, alternatingRowStep: 0, isReadOnly: true, // create RestCollectionViewFirestore itemsSource: new RestCollectionViewFirestore(PROJECT_ID, API_KEY, 'Customers', { fields: fields, pageSize: 8, sortDescriptions: ['CustomerID'], }) }); // auto-number row headers theGrid.topLeftCells.columns[0].cellTemplate = $ => $.text || ($.row.index + 1).toString(); // add the filter new FlexGridFilter(theGrid); // add the navigator let nav = new CollectionViewNavigator('#theNavigator', { cv: theGrid.collectionView, byPage: true }); // update navigator header let cv = theGrid.collectionView; cv.collectionChanged.addHandler(() => updateHeader(cv, nav)); cv.pageChanged.addHandler((s, e) => { updateHeader(cv, nav); }); // toggle pagination document.getElementById('paging').addEventListener('click', e => { let paging = e.target.checked, view = theGrid.collectionView; view.pageSize = paging ? 8 : 0; }); // handle authorization (login/out) const SCOPES = ['https://www.googleapis.com/auth/userinfo.email']; let auth = new OAuth2(API_KEY, CLIENT_ID, SCOPES); // button to log in/out let oAuthBtn = document.getElementById('auth-btn'); // click button to log user in or out oAuthBtn.addEventListener('click', () => { if (auth.user) { auth.signOut(); } else { auth.signIn(); } }); // update button/sheet state when user changes auth.userChanged.addHandler(s => { let user = s.user; oAuthBtn.textContent = user ? 'Sign Out' : 'Sign In'; // apply OAuth id token to the RestCollectionViewFirestore let fireStoreCV = theGrid.collectionView; fireStoreCV.idToken = s.idToken; // make the grid read-only if the user is not signed in theGrid.isReadOnly = user == null; // update message document.getElementById('msg').innerHTML = user ? 'You are signed in as [<b>' + user.eMail + '</b>], so you may edit the grid (if you have permissions).' : 'You are not signed in, so you cannot edit the grid.'; }); } // // update CollectionViewNavigator header function updateHeader(cv, nav) { let tot = cv.totalItemCount; nav.headerFormat = isNumber(tot) && tot < 10000 ? '{current:n0} / {count:n0}' : '{current:n0}'; }
<!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>MESCIUS Wijmo RestCollectionView Firestore</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- SystemJS --> <script src="https://cdnjs.cloudflare.com/ajax/libs/systemjs/0.21.5/system.src.js" integrity="sha512-skZbMyvYdNoZfLmiGn5ii6KmklM82rYX2uWctBhzaXPxJgiv4XBwJnFGr5k8s+6tE1pcR1nuTKghozJHyzMcoA==" crossorigin="anonymous"></script> <script src="systemjs.config.js"></script> <script> System.import('./src/app'); </script> </head> <body> <div class="container-fluid"> <label> Paging <input id="paging" type="checkbox" checked> </label> <button id="auth-btn" class="btn btn-primary" style="float:right"> Sign In </button> <br /> <div id="theNavigator"></div> <div id="theGrid"></div> <p id="msg"></p> </div> </body> </html>
body { margin-bottom: 36pt; } .wj-flexgrid { max-height: 400px; }
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { RestCollectionView } from '@mescius/wijmo.rest'; import { DataType, httpRequest, assert, copy, getType, changeType, asString, isArray, isObject, isString } from '@mescius/wijmo'; // overshoot the number of items when paging on server // will adjust when we hit the last page const _INITIAL_ITEM_COUNT = 1e9; /** * Class that extends {@link RestCollectionView} to support Firestore data sources. */ export class RestCollectionViewFirestore extends RestCollectionView { /** * Initializes a new instance of the {@link RestCollectionViewFirestore} class. * * @param projectId ID of the Firebase app that contains the database. * @param apiKey Unique identifier used to authenticate requests associated with the app. * To generate API keys, please go to https://console.cloud.google.com/. * @param collectionName Name of the collection. * @param options JavaScript object containing initialization data (property values * and event handlers) for this {@link Collection}. */ constructor(projectId, apiKey, collectionName, options) { super(); this._projectId = asString(projectId, false); this._apiKey = asString(apiKey, false); this._name = asString(collectionName, false); this._idToken = null; this._totalItemCount = _INITIAL_ITEM_COUNT; copy(this, options); } /** * Gets the name of this collection. */ get name() { return this._name; } /** * Gets or sets a OAuth 2.0 id token used to access the database. * * You can use the {@link OAuth2} class to allow users to log in and * to obtain the {@link idToken} string. * * If you choose this authentication method, Firestore Security Rules * will be applied as usual to determine which users can read and write * to the database. * * See also the {@link accessToken} property, which bypasses Firestore * Security Rules and uses Cloud Identity and Access Management (IAM) * instead. */ get idToken() { return this._idToken; } set idToken(value) { if (value != this._idToken) { // save OAuth idToken this._idToken = asString(value); // convert OAuth idToken into Firebase idToken // https://cloud.google.com/identity-platform/docs/reference/rest/v1/accounts/signInWithIdp if (this._idToken) { let url = 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=' + this._apiKey; httpRequest(url, { method: 'POST', data: { requestUri: window.location.href, postBody: 'id_token=' + this._idToken + '&providerId=google.com', returnSecureToken: true, returnIdpCredential: true }, success: xhr => { let result = JSON.parse(xhr.responseText); this._fbToken = result.idToken; }, error: xhr => { this._fbToken = ''; } }); } else { this._fbToken = ''; } } } // ** implementation // gets a URL to the collection or to a document _getUrl(doc) { let base = 'https://firestore.googleapis.com/v1/'; // got object? use it if (isObject(doc) && doc.name) { return base + doc.name; } // doc not specified? use this collection's name if (!doc) { doc = '/' + this.name; } // get parent address let parent = 'projects/' + this._projectId + '/databases/(default)/documents'; // build and return the URL return base + parent + doc; } // gets a structuredQuery object // that specifies fields, sorting, filtering, paging _getQuery() { // collection source let q = { from: [{ collectionId: this.name }] }; // select fields to include if (this.fields && this.fields.length) { q.select = { fields: this.fields.map(field => { return { fieldPath: field }; }) }; } ; // server-side filtering let filters = null; if (this.filterOnServer && this._filterProvider) { filters = this._getQueryFilters(); } // build where clause if (filters && filters.length) { q.where = filters.length == 1 ? filters[0] : { compositeFilter: { filters: filters, op: 'AND' } }; } // sorting let orderBy = []; if (filters && filters.length) { // order by range filters (must be first) filters.forEach(filter => { let ff = filter.fieldFilter; if (ff && ff.op != 'IN' && ff.op != 'EQUAL') { // no sortBy with IN/EQUAL operators orderBy.push({ field: ff.field.fieldPath, asc: true }); } }); } if (!orderBy.length && this.sortOnServer) { // sort on server this.sortDescriptions.forEach(sd => { orderBy.push({ field: sd.property, asc: sd.ascending }); }); } // apply orderBy array if (orderBy.length) { q.orderBy = orderBy.map(ob => { return { field: { fieldPath: ob.field }, direction: ob.asc ? 'ASCENDING' : 'DESCENDING' }; }); } // paging let pageSize = this.pageSize; if (this.pageOnServer && pageSize) { q.limit = pageSize; q.offset = pageSize * this.pageIndex; } // done return { structuredQuery: q }; } // gets the filter part of a query from a filterProvider (FlexGridFilter) // https://cloud.google.com/firestore/docs/reference/rest/v1/StructuredQuery#Filter _getQueryFilters() { let filters = [], filter = this._filterProvider; if (filter) { for (let c = 0; c < filter.grid.columns.length; c++) { let col = filter.grid.columns[c], cf = filter.getColumnFilter(col, false); if (cf && cf.isActive) { if (cf.conditionFilter && cf.conditionFilter.isActive) { this._getQueryConditionFilter(filters, cf.conditionFilter); } else if (cf.valueFilter && cf.valueFilter.isActive) { this._getQueryValueFilter(filters, cf.valueFilter); } break; // cannot have multiple inequality filters on different columns } } } return filters; } _getQueryConditionFilter(filters, cf) { let path = cf.column.binding, sf1 = this._getQuerySimpleFilter(cf.condition1, path), sf2 = this._getQuerySimpleFilter(cf.condition2, path); if (sf1 && sf2 && cf.and) { filters.push({ compositeFilter: { op: cf.and ? 'AND' : 'OR', filters: [sf1, sf2] } }); } else if (sf1) { filters.push(sf1); } else if (sf2) { filters.push(sf2); } } _getQuerySimpleFilter(fc, path) { if (fc.isActive) { // beginsWith requires two conditions if (fc.operator == 6 /*OP.BW*/) { return { compositeFilter: { op: 'AND', filters: [ { fieldFilter: { field: { fieldPath: path }, op: 'GREATER_THAN_OR_EQUAL', value: this._getValueObject(fc.value) } }, { fieldFilter: { field: { fieldPath: path }, op: 'LESS_THAN', value: this._getValueObject(fc.value + '\uf8ff') } }, ] } }; } // other operators require only one condition return { fieldFilter: { field: { fieldPath: path }, op: this._getFilterOperator(fc.operator), value: this._getValueObject(fc.value) } }; } return null; } _getFilterOperator(op) { switch (op) { case 0: // OP.EQ: // equals return 'EQUAL'; case 1: // OP.NE: // not equal return 'NOT_EQUAL'; case 2: // OP.GT: // greater return 'GREATER_THAN'; case 3: // OP.GE: // greater/equal return 'GREATER_THAN_OR_EQUAL'; case 4: // OP.LT: // less return 'LESS_THAN'; case 5: // OP.LE: // less/equal return 'LESS_THAN_OR_EQUAL'; // not supported: //case OP.CT: // contains //case OP.EW: // ends with //case OP.NC: // does not contain } assert(false, op + ' operator not supported (use EQ, NE, GT, GE, LT, or LE)'); } _getQueryValueFilter(filters, vf) { let col = vf.column, map = col.dataMap, values = []; // build list of values for (let key in vf.showValues) { let value = changeType(key, col.dataType, col.format); if (map && isString(value)) { // TFS 239356 value = map.getKeyValue(value); } values.push(value); if (values.length >= 10) { break; } } // build condition if (values.length) { filters.push({ fieldFilter: { field: { fieldPath: col.binding }, op: 'IN', value: this._getValueObject(values) } }); } } // authorization _getRequestHeaders() { let rh = {}, token = this._fbToken; if (token) { rh.Authorization = 'Bearer ' + token; } return rh; } // parse the data received after a get request _parseData(docs) { let arr = []; if (isArray(docs)) { docs.forEach(doc => { let item = this._docToItem(doc); if (item) { arr.push(item); } }); } return arr; } // save Firestore document name (key) and collection into plain data items _saveDocName(doc, item) { if (doc.name && !item.$META) { item.$META = { name: doc.name }; return true; } return false; } // convert Firestore document into plain data item _docToItem(doc) { // handle documents wrapped in other items (returned from runQuery) if (!doc.name) { doc = doc.document; } // the first item returned by runQuery may not be a document // (e.g. { readTime: xx, skippedResults: yy }) if (!doc || !doc.name) { return null; } // save document name let item = {}; this._saveDocName(doc, item); // save document fields for (let fld in doc.fields) { item[fld] = this._getDocValue(doc.fields[fld]); } // done return item; } // convert Firestore value into plain data item _getDocValue(obj) { let value = null; for (let valName in obj) { value = obj[valName]; switch (valName) { case 'integerValue': // document stores integers as strings value = parseInt(value); break; case 'timestampValue': value = new Date(value); break; case 'mapValue': let obj = {}; for (let k in value.fields) { obj[k] = this._getDocValue(value.fields[k]); } value = obj; break; case 'arrayValue': value = value.values ? value.values.map((val) => this._getDocValue(val)) : []; break; } } return value; } // convert plain data item into Firestore document _itemToDoc(item) { let doc = {}, meta = item.$META, calcFields = this.calculatedFields; // save document name (key) if (meta && meta.name) { doc.name = meta.name; } // save fields doc.fields = {}; for (let fld in item) { if (!calcFields || !(fld in calcFields)) { if (fld != '$META') { doc.fields[fld] = this._getValueObject(item[fld]); } } } // document is ready return doc; } // convert value into value object // https://cloud.google.com/firestore/docs/reference/rest/v1/Value _getValueObject(value) { let valObj = {}, DT = DataType; switch (getType(value)) { case DT.String: valObj.stringValue = value; break; case DT.Boolean: valObj.booleanValue = value; break; case DT.Date: valObj.timestampValue = value.toJSON(); break; case DT.Number: if (value == Math.round(value)) { valObj.integerValue = value.toString(); } else { valObj.doubleValue = value; } break; case DT.Array: valObj.arrayValue = { values: value.map((v) => this._getValueObject(v)) }; break; case DT.Object: let fields = {}; for (let k in value) { fields[k] = this._getValueObject(value[k]); } valObj.mapValue = { fields: fields }; break; default: assert(false, 'failed to create value object.'); } return valObj; } // ** overrides // reset page count when filter changes updateFilterDefinition(filterProvider) { if (this.filterOnServer && this.pageOnServer) { this._totalItemCount = _INITIAL_ITEM_COUNT; } super.updateFilterDefinition(filterProvider); } getItems() { return __awaiter(this, void 0, void 0, function* () { // cancel any pending requests if (this._pendingRequest) { //console.log('aborting pending request'); this._pendingRequest.abort(); } return new Promise(resolve => { this._pendingRequest = httpRequest(this._getUrl(':runQuery'), { method: 'POST', data: this._getQuery(), requestHeaders: this._getRequestHeaders(), success: xhr => { // read the data let data = JSON.parse(xhr.responseText), arr = this._parseData(data); // keep track of total item count if (this.pageOnServer && this.pageSize) { if (arr.length < this.pageSize) { // not enough items? reached the end let skipped = data[0].skippedResults || 0; let cnt = skipped + arr.length; // this is the actual count if (this._totalItemCount != cnt) { this._totalItemCount = cnt; // store count if (!arr.length) { // if we're past the end, move to last page this.moveToLastPage(); } } } } // done resolve(arr); }, error: xhr => this._raiseError(xhr.responseText, false), complete: xhr => this._pendingRequest = null // no pending requests }); }); }); } addItem(item) { return new Promise(resolve => { let doc = this._itemToDoc(item); // https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/createDocument httpRequest(this._getUrl(), { method: 'POST', data: { fields: doc.fields }, requestHeaders: this._getRequestHeaders(), success: xhr => { let doc = JSON.parse(xhr.responseText); this._saveDocName(doc, item); // keep new doc's name this._totalItemCount++; resolve(item); }, error: xhr => this._raiseError(xhr, true) }); }); } patchItem(item) { return new Promise(resolve => { let doc = this._itemToDoc(item); // https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/patch httpRequest(this._getUrl(doc), { method: 'PATCH', data: { fields: doc.fields }, requestHeaders: this._getRequestHeaders(), success: xhr => resolve(item), error: xhr => this._raiseError(xhr, true) }); }); } deleteItem(item) { return new Promise(resolve => { let doc = this._itemToDoc(item); // https://firebase.google.com/docs/firestore/reference/rest/v1/projects.databases.documents/delete httpRequest(this._getUrl(doc), { method: 'DELETE', requestHeaders: this._getRequestHeaders(), success: xhr => { this._totalItemCount--; resolve(item); }, error: xhr => this._raiseError(xhr, true) }); }); } }
(function (global) { System.config({ transpiler: 'plugin-babel', babelOptions: { es2015: true }, meta: { '*.css': { loader: 'css' } }, paths: { // paths serve as alias 'npm:': 'node_modules/' }, // map tells the System loader where to look for things map: { 'jszip': 'npm:jszip/dist/jszip.js', '@mescius/wijmo': 'npm:@mescius/wijmo/index.js', '@mescius/wijmo.input': 'npm:@mescius/wijmo.input/index.js', '@mescius/wijmo.styles': 'npm:@mescius/wijmo.styles', '@mescius/wijmo.cultures': 'npm:@mescius/wijmo.cultures', '@mescius/wijmo.chart': 'npm:@mescius/wijmo.chart/index.js', '@mescius/wijmo.chart.analytics': 'npm:@mescius/wijmo.chart.analytics/index.js', '@mescius/wijmo.chart.animation': 'npm:@mescius/wijmo.chart.animation/index.js', '@mescius/wijmo.chart.annotation': 'npm:@mescius/wijmo.chart.annotation/index.js', '@mescius/wijmo.chart.finance': 'npm:@mescius/wijmo.chart.finance/index.js', '@mescius/wijmo.chart.finance.analytics': 'npm:@mescius/wijmo.chart.finance.analytics/index.js', '@mescius/wijmo.chart.hierarchical': 'npm:@mescius/wijmo.chart.hierarchical/index.js', '@mescius/wijmo.chart.interaction': 'npm:@mescius/wijmo.chart.interaction/index.js', '@mescius/wijmo.chart.radar': 'npm:@mescius/wijmo.chart.radar/index.js', '@mescius/wijmo.chart.render': 'npm:@mescius/wijmo.chart.render/index.js', '@mescius/wijmo.chart.webgl': 'npm:@mescius/wijmo.chart.webgl/index.js', '@mescius/wijmo.chart.map': 'npm:@mescius/wijmo.chart.map/index.js', '@mescius/wijmo.gauge': 'npm:@mescius/wijmo.gauge/index.js', '@mescius/wijmo.grid': 'npm:@mescius/wijmo.grid/index.js', '@mescius/wijmo.grid.detail': 'npm:@mescius/wijmo.grid.detail/index.js', '@mescius/wijmo.grid.filter': 'npm:@mescius/wijmo.grid.filter/index.js', '@mescius/wijmo.grid.search': 'npm:@mescius/wijmo.grid.search/index.js', '@mescius/wijmo.grid.style': 'npm:@mescius/wijmo.grid.style/index.js', '@mescius/wijmo.grid.grouppanel': 'npm:@mescius/wijmo.grid.grouppanel/index.js', '@mescius/wijmo.grid.multirow': 'npm:@mescius/wijmo.grid.multirow/index.js', '@mescius/wijmo.grid.transposed': 'npm:@mescius/wijmo.grid.transposed/index.js', '@mescius/wijmo.grid.transposedmultirow': 'npm:@mescius/wijmo.grid.transposedmultirow/index.js', '@mescius/wijmo.grid.pdf': 'npm:@mescius/wijmo.grid.pdf/index.js', '@mescius/wijmo.grid.sheet': 'npm:@mescius/wijmo.grid.sheet/index.js', '@mescius/wijmo.grid.xlsx': 'npm:@mescius/wijmo.grid.xlsx/index.js', '@mescius/wijmo.grid.selector': 'npm:@mescius/wijmo.grid.selector/index.js', '@mescius/wijmo.grid.cellmaker': 'npm:@mescius/wijmo.grid.cellmaker/index.js', '@mescius/wijmo.nav': 'npm:@mescius/wijmo.nav/index.js', '@mescius/wijmo.odata': 'npm:@mescius/wijmo.odata/index.js', '@mescius/wijmo.olap': 'npm:@mescius/wijmo.olap/index.js', '@mescius/wijmo.rest': 'npm:@mescius/wijmo.rest/index.js', '@mescius/wijmo.pdf': 'npm:@mescius/wijmo.pdf/index.js', '@mescius/wijmo.pdf.security': 'npm:@mescius/wijmo.pdf.security/index.js', '@mescius/wijmo.viewer': 'npm:@mescius/wijmo.viewer/index.js', '@mescius/wijmo.xlsx': 'npm:@mescius/wijmo.xlsx/index.js', '@mescius/wijmo.undo': 'npm:@mescius/wijmo.undo/index.js', '@mescius/wijmo.interop.grid': 'npm:@mescius/wijmo.interop.grid/index.js', '@mescius/wijmo.touch': 'npm:@mescius/wijmo.touch/index.js', '@mescius/wijmo.cloud': 'npm:@mescius/wijmo.cloud/index.js', '@mescius/wijmo.barcode': 'npm:@mescius/wijmo.barcode/index.js', '@mescius/wijmo.barcode.common': 'npm:@mescius/wijmo.barcode.common/index.js', '@mescius/wijmo.barcode.composite': 'npm:@mescius/wijmo.barcode.composite/index.js', '@mescius/wijmo.barcode.specialized': 'npm:@mescius/wijmo.barcode.specialized/index.js', 'jszip': 'npm:jszip/dist/jszip.js', 'bootstrap.css': 'npm:bootstrap/dist/css/bootstrap.min.css', 'css': 'npm:systemjs-plugin-css/css.js', 'plugin-babel': 'npm:systemjs-plugin-babel/plugin-babel.js', 'systemjs-babel-build':'npm:systemjs-plugin-babel/systemjs-babel-browser.js' }, // packages tells the System loader how to load when no filename and/or no extension packages: { src: { defaultExtension: 'js' }, "node_modules": { defaultExtension: 'js' }, } }); })(this);