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);