RestCollectionView Firestore

このサンプルは、RestCollectionViewクラスを拡張して、Firestoreソースと通信するカスタムRestCollectionViewFirestoreクラスを作成する方法を示しています。

RestCollectionViewFirestoreクラスは、Wijmoに付属しているwijmo.cloud.Collectionクラスに似ています。 CRUD操作だけでなく、サーバーベースの並べ替え、フィルタリング、ページネーションもサポートしています。

Firestoreは合計アイテム数を提供しないため、使用可能なページ数を計算できないことに注意してください。 このため、このサンプルのCollectionViewNavigatorには現在のページのみが表示され、合計数は表示されません。

また、Firestoreフィルタリングには特定の制限があることに注意してください。 特定のフィルター条件はサポートされていません(「含む」、「含まない」、「で終わる」)。 また、特定の条件では追加のインデックスを作成する必要があり、サーバー側の並べ替えと競合する場合もあります。 これらの理由から、クライアントにのみフィルターを適用することをお勧めします。 FlexGridFilterクラスを使用するのではなく、カスタムコードを使用してサーバー側フィルターを設定します。

import 'bootstrap.css'; import '@grapecity/wijmo.styles/wijmo.css'; import './styles.css'; // import { RestCollectionViewFirestore } from './rest-collection-view-firestore'; import { isNumber } from '@grapecity/wijmo'; import { CollectionViewNavigator } from '@grapecity/wijmo.input'; import { FlexGrid } from '@grapecity/wijmo.grid'; import { FlexGridFilter } from '@grapecity/wijmo.grid.filter'; import { OAuth2 } from '@grapecity/wijmo.cloud'; // // Firestore info const PROJECT_ID = 'wijmo-867c8'; const API_KEY = 'AIzaSyDh6pPihWP4gpnlvhuL_4DrRhqRSBhKRS4'; const CLIENT_ID = '431930459834-vrvi6175h6n47jdo3m84f1hgio01ok3u.apps.googleusercontent.com'; // // field info for Customers table const fields = 'ProductID,ProductName,SupplierID,CategoryID,QuantityPerUnit'.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, 'Products', { fields: fields, pageSize: 8, sortDescriptions: ['ProductID'], }), columns: [ { binding: 'ProductID', header: '品番', width: 80 }, { binding: 'ProductName', header: '商品名', width: 200 }, { binding: 'SupplierID', header: '取引先', width: 180, }, { binding: 'CategoryID', header: '分類', width: 120, }, { binding: 'QuantityPerUnit', header: '単位', width: 120 }, ], autoGenerateColumns: false, }); // 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 ? 'サインアウト' : 'サインイン'; // 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 ? 'サインインしているので、グリッドを編集できます。' : 'サインインしていないので、グリッドを編集できません。'; }); } // // 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>GrapeCity RestCollectionView Firestore</title> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <!-- SystemJS --> <script src="node_modules/systemjs/dist/system.src.js"></script> <script src="systemjs.config.js"></script> <script> System.import('./src/app'); </script> </head> <body> <div class="container-fluid"> <label> ページング <input id="paging" type="checkbox" checked> </label> <button id="auth-btn" class="btn btn-primary" style="float:right"> サインイン </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) { 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) : new P(function (resolve) { resolve(result.value); }).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; import { RestCollectionView } from '@grapecity/wijmo.rest'; import { DataType, httpRequest, assert, copy, getType, changeType, asString, isArray, isObject, isString } from '@grapecity/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', '@grapecity/wijmo': 'npm:@grapecity/wijmo/index.js', '@grapecity/wijmo.input': 'npm:@grapecity/wijmo.input/index.js', '@grapecity/wijmo.styles': 'npm:@grapecity/wijmo.styles', '@grapecity/wijmo.cultures': 'npm:@grapecity/wijmo.cultures', '@grapecity/wijmo.chart': 'npm:@grapecity/wijmo.chart/index.js', '@grapecity/wijmo.chart.analytics': 'npm:@grapecity/wijmo.chart.analytics/index.js', '@grapecity/wijmo.chart.animation': 'npm:@grapecity/wijmo.chart.animation/index.js', '@grapecity/wijmo.chart.annotation': 'npm:@grapecity/wijmo.chart.annotation/index.js', '@grapecity/wijmo.chart.finance': 'npm:@grapecity/wijmo.chart.finance/index.js', '@grapecity/wijmo.chart.finance.analytics': 'npm:@grapecity/wijmo.chart.finance.analytics/index.js', '@grapecity/wijmo.chart.hierarchical': 'npm:@grapecity/wijmo.chart.hierarchical/index.js', '@grapecity/wijmo.chart.interaction': 'npm:@grapecity/wijmo.chart.interaction/index.js', '@grapecity/wijmo.chart.radar': 'npm:@grapecity/wijmo.chart.radar/index.js', '@grapecity/wijmo.chart.render': 'npm:@grapecity/wijmo.chart.render/index.js', '@grapecity/wijmo.chart.webgl': 'npm:@grapecity/wijmo.chart.webgl/index.js', '@grapecity/wijmo.chart.map': 'npm:@grapecity/wijmo.chart.map/index.js', '@grapecity/wijmo.gauge': 'npm:@grapecity/wijmo.gauge/index.js', '@grapecity/wijmo.grid': 'npm:@grapecity/wijmo.grid/index.js', '@grapecity/wijmo.grid.detail': 'npm:@grapecity/wijmo.grid.detail/index.js', '@grapecity/wijmo.grid.filter': 'npm:@grapecity/wijmo.grid.filter/index.js', '@grapecity/wijmo.grid.search': 'npm:@grapecity/wijmo.grid.search/index.js', '@grapecity/wijmo.grid.grouppanel': 'npm:@grapecity/wijmo.grid.grouppanel/index.js', '@grapecity/wijmo.grid.multirow': 'npm:@grapecity/wijmo.grid.multirow/index.js', '@grapecity/wijmo.grid.transposed': 'npm:@grapecity/wijmo.grid.transposed/index.js', '@grapecity/wijmo.grid.transposedmultirow': 'npm:@grapecity/wijmo.grid.transposedmultirow/index.js', '@grapecity/wijmo.grid.pdf': 'npm:@grapecity/wijmo.grid.pdf/index.js', '@grapecity/wijmo.grid.sheet': 'npm:@grapecity/wijmo.grid.sheet/index.js', '@grapecity/wijmo.grid.xlsx': 'npm:@grapecity/wijmo.grid.xlsx/index.js', '@grapecity/wijmo.grid.selector': 'npm:@grapecity/wijmo.grid.selector/index.js', '@grapecity/wijmo.grid.cellmaker': 'npm:@grapecity/wijmo.grid.cellmaker/index.js', '@grapecity/wijmo.nav': 'npm:@grapecity/wijmo.nav/index.js', '@grapecity/wijmo.odata': 'npm:@grapecity/wijmo.odata/index.js', '@grapecity/wijmo.olap': 'npm:@grapecity/wijmo.olap/index.js', '@grapecity/wijmo.rest': 'npm:@grapecity/wijmo.rest/index.js', '@grapecity/wijmo.pdf': 'npm:@grapecity/wijmo.pdf/index.js', '@grapecity/wijmo.pdf.security': 'npm:@grapecity/wijmo.pdf.security/index.js', '@grapecity/wijmo.viewer': 'npm:@grapecity/wijmo.viewer/index.js', '@grapecity/wijmo.xlsx': 'npm:@grapecity/wijmo.xlsx/index.js', '@grapecity/wijmo.undo': 'npm:@grapecity/wijmo.undo/index.js', '@grapecity/wijmo.interop.grid': 'npm:@grapecity/wijmo.interop.grid/index.js', '@grapecity/wijmo.touch': 'npm:@grapecity/wijmo.touch/index.js', '@grapecity/wijmo.cloud': 'npm:@grapecity/wijmo.cloud/index.js', '@grapecity/wijmo.barcode': 'npm:@grapecity/wijmo.barcode/index.js', '@grapecity/wijmo.barcode.common': 'npm:@grapecity/wijmo.barcode.common/index.js', '@grapecity/wijmo.barcode.composite': 'npm:@grapecity/wijmo.barcode.composite/index.js', '@grapecity/wijmo.barcode.specialized': 'npm:@grapecity/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);