/** * Created by rburson on 3/18/16. */ /** * Warning! This component required 'wrapping' a non-React style, traditional javascript grid component (ag-grid) * The wrapped component is very stateful and requires interaction and control with a very 'imperative' javascript api, * thus this component is not a good example the stateless, functional components that React normally promotes. * There has been much work done here to 'work around' the issues created by the grid component and it's requirements. * i.e. the grid component does not gracefully survive React component updates. * */ import * as React from 'react' import {AgGridReact} from 'ag-grid-react' import Clipboard = require('clipboard') import { CvAction, CvState, CvProps, CvBaseMixin, CvListPane, CvRecordList, CvNavigationResult, CvEvent, CvRecord, CvContext, CvQueryPaneCallback, CvValueListener, CvValueAdapter, CvStateChangeResult, CvActionFiredResult, CvActionCallback, CvValueProvider, CvActionHandlerParams, CvNavigation, CvForm, CvSearchPane, CvSearchPaneCallback, CvSortDirection, CvResultCallback, CvMessage, CvEventType, CvMessageType, CvInitMode, CvEventRegistry, CvStateChangeType, UIUtil } from 'catreact' import { CvDataAnno, CvDataAnnoStyle, CvHtmlProp } from './catreact-html' import { FormContext, ListContext, Prop, ColumnDef, EntityRec, ArrayUtil, ObjUtil, QueryContext, Try, Log, EntityRecDef, PropDef, DialogException } from 'catavolt-sdk' const CV_GRID_PAGE_SIZE:number = 200; const CV_GRID_ROW_OVERFLOW_SIZE = 1; const CV_GRID_ROW_HEIGHT = 25; const CV_GRID_ROW_HEIGHT_IMAGES = 50; export enum CvGridEventType { SELECTION_EVENT, TOTAL_ROWS_LOADED_EVENT, FIRST_VISIBLE_ROW_CHANGE_EVENT } export interface CvGridEvent { type:CvGridEventType; eventObj:T; } export interface CvGridPanelState extends CvState { searchNavEvent:CvEvent; quickFilterNavResult:CvNavigationResult; complexSearchNavResult:CvNavigationResult; numInitialRows:number; initialFirstVisibleRow:number; } export interface CvGridPanelProps extends CvProps { paneRef?:number; formContext?:FormContext; listContext?:ListContext; navigationListeners?:Array<(event:CvEvent)=>void>; actionListeners?:Array<(event:CvEvent)=>void> gridListener?:CvValueListener>; stateChangeListeners?:Array<(event:CvEvent)=>void>; actionProvider?:CvValueProvider; searchable?:boolean; initialSelectedItems?:Array; numInitialRows?:number; initialFirstVisibleRow?:number; gridHeight?:string; } //the Grid control doesn't allow periods in column key names, so we switch back and forth const CvGridPanel_DOT_REPL = '_d_o_t_'; /* *************************************************** * Render a ListContext *************************************************** */ export var CvGridPanel = React.createClass({ mixins: [CvBaseMixin], componentWillMount: function() { //set up unmanaged, init values const numInitialRows = this.props.numInitialRows ? this.props.numInitialRows : CV_GRID_PAGE_SIZE; this.setState({numInitialRows: numInitialRows}); }, componentDidMount: function () { /* Allow for copying cell content */ new Clipboard('.cv-clipboard-target', { text: function (trigger) { return trigger.innerText; } }); /* register to receive events from the 'popup search' editor model the list pane will refresh itself when the search is submitted but we need to reset the data source before that happens */ (this.eventRegistry() as CvEventRegistry) .subscribe(this._dataChangeListener, CvEventType.STATE_CHANGE); }, componentWillUnmount: function() { (this.eventRegistry() as CvEventRegistry).unsubscribe(this._dataChangeListener); }, componentWillReceiveProps(props, state) { //Log.debug('GridPanel will receive props:'); //Log.debug(props); }, componentWillUpdate(props, state) { //Log.debug('GridPanel will update: '); //Log.debug(props); }, getDefaultProps: function () { return { paneRef: null, formContext: null, listContext: null, navigationListeners: [], actionListeners: [], gridListener: null, stateChangeListeners: [], actionProvider: null, searchable: false, initialSelectedItems: null, numInitialRows: CV_GRID_PAGE_SIZE, initialFirstVisibleRow: null, gridHeight: '100%' } }, getInitialState: function () { return {searchNavEvent: null, quickFilterNavResult: null, complexSearchNavResult: null, numInitialRows: null, initialFirstVisibleRow: null } }, render: function () { /* We can't do ES7 style rest destructuring in Typescript yet, so we do this... */ const listPaneProps = { paneRef: this.props.paneRef, formContext: this.props.formContext, queryContext: this.props.listContext, stateChangeListeners: this.props.stateChangeListeners, actionListeners: this.props.actionListeners, actionProvider: this.props.actionProvider } return ( { const listContext:ListContext = cvContext.scopeCtx.scopeObj; return(
{(()=>{ return this.props.searchable ? {}} paneContext={listContext} navigationListeners={[(event:CvEvent)=>{ //hold on to the nav event so we can pass it along later to the complex search popup let newState = {complexSearchNavResult: event.eventObj, searchNavEvent: event}; if(this._unifiedSearchSupport()) { newState["quickFilterNavResult"] = event.eventObj; } this.setState(newState); }]}/> : null; })()} {(()=>{ //older server versions need to use the deprecated 'searchQuery' action for keyword search return this.props.searchable && !this._unifiedSearchSupport() ? {}} paneContext={listContext} navigationListeners={[(event:CvEvent)=>{ this.setState({quickFilterNavResult: event.eventObj}); }]}/> : null; })()}
{if(this.table){this.table.quickFilter(filterValue)} }} ref={(me)=>this.quickFilter=me}/> {/* */} {(()=>{ /* The 'search' button fires a typical navigation which opens a search pane, which shows in a modal popup We're passing along the previously obtained 'search navigation' result */ return this.props.searchable && this.state.searchNavEvent ? { return }}/> : null; })()}
{(()=>{ /* make sure that the search actions have been opened */ return this._areSearchHandlesReady() ? this.table = table}/> }/> : null; })()}
); }}/> ); }, shouldComponentUpdate(nextProps, nextState) { /* NOTE: initialSelectedItems, numInitialRows, initialFirstVisibleRow are all unmanaged properties, meaning that when they change, they are not synchronized with component state (we ignore them) Here they are simply changing URL params to be retained only for navigation or a refresh */ /* This is an 'exclusive' change list - updates happen by default unless included here Props changes that should not fire updates, should be added here */ //Don't update for selection changes as this is NOT a managed param - the Grid control handles selections internally //determine if this was a selection change //if so avoid a component update const initialSelectedItems = this.props.initialSelectedItems; const nextSelectedItems = nextProps.initialSelectedItems; //do we already have selected items //if so have we simply added or removed a selection if(initialSelectedItems != null) { if (nextSelectedItems && (initialSelectedItems.length != nextSelectedItems.length)) { return false; } } else { //we don't have a selection but now, but having an incoming selection if(nextSelectedItems != null) { return false; } } //Don't update for changes in original num rows - also handled internally by Grid control //this is NOT a managed parameter if(nextProps.numInitialRows != this.props.numInitialRows) { return false; } //Don't update for changes in initialFirstVisibleRow //this is NOT a managed parameter if(nextProps.initialFirstVisibleRow != this.props.initialFirstVisibleRow) { return false; } //otherwise we'll continue to update the grid... return true; }, _dataChangeListener: function (dataChangeResult:CvEvent) { const eventSource = dataChangeResult.eventObj.source; const eventType = dataChangeResult.eventObj.type; const complexSearchNavResult:CvNavigationResult = this.state.complexSearchNavResult; //listen for the modal search submission if (eventType === CvStateChangeType.DATA_CHANGE) { if(eventSource && complexSearchNavResult && complexSearchNavResult.navRequest) { if (eventSource.parentContext == complexSearchNavResult.navRequest) { this.table.resetDataSource(); } } } }, _areSearchHandlesReady: function() { return this.state.quickFilterNavResult && this.state.searchNavEvent; }, _unifiedSearchSupport: function() { return this.catavolt().isFeatureSetAvailable("Unified_Search"); } }); export var CvQuickFilter = React.createClass<{filterValueListener:CvValueListener}, {searchText:string, isSet:boolean}>({ getDefaultProps: function () { return {filterValueListener: null} }, getInitialState: function () { return {searchText: "", isSet:false} }, isSet: function() { return this.state.searchText; }, render: function () { return }, resetText() { this.setState({searchText: ""}); const listener:CvValueListener = this.props.filterValueListener; if (listener) { listener(""); } }, _filterKeyUp(e:any) { if (e.keyCode === 13) { const listener:CvValueListener = this.props.filterValueListener; if (listener) { listener(this.state.searchText); } } }, _onFilterTextChange(e:any) { const value = e.target.value ? e.target.value : ""; this.setState({searchText: value}); } }); export interface CvGridTableState extends CvState { numInitialRows:number; initialFirstVisibleRow:number; initialSelectedItems:Array; forceAutoSize:boolean; } export interface CvGridTableProps extends CvProps { listContext:ListContext; lastRefreshTime:Date; initialSelectedItems?:Array; numInitialRows?:number; gridHeight?:string; initialFirstVisibleRow?:number; queryPaneCallback:CvQueryPaneCallback; searchNavResult:CvNavigationResult; quickFilterCallback:CvSearchPaneCallback; eventRegistry:CvEventRegistry; navigationListeners?:Array<(event:CvEvent)=>void>; actionListeners?:Array<(event:CvEvent)=>void> gridListener?:CvValueListener>; stateChangeListeners?:Array<(event:CvEvent)=>void>; } export var CvGridTable = React.createClass({ autoSize: function () { const columns = this.columnApi.getAllColumns().filter(col=>col.visible).map(col=>col.colDef.field); this.columnApi.autoSizeColumns(columns); }, autoFit: function () { this.gridApi.sizeColumnsToFit(); }, componentWillMount: function() { //set up unmanaged, init values const numInitialRows = this.props.numInitialRows ? this.props.numInitialRows : CV_GRID_PAGE_SIZE; this.setState({numInitialRows: numInitialRows}); const initialFirstVisibleRow = this.props.initialFirstVisibleRow ? this.props.initialFirstVisibleRow : 0; this.setState({initialFirstVisibleRow: initialFirstVisibleRow}); this.setState({initialSelectedItems:this.props.initialSelectedItems}); }, componentDidMount() { }, componentWillReceiveProps(props, state) { //Log.debug('GridTable will receive props:'); //Log.debug(props); }, componentWillUpdate(props, state) { //Log.debug('GridTable will update:'); //Log.debug(props); }, componentDidUpdate(prevProps, prevState) { //Log.debug('GridTable component did update'); //make sure there's not another update in progress, as this breaks the gridApi call here if(this.gridApi && !this.props.queryPaneCallback.isInProgress()){ //Log.debug('calling refreshInfintePageCache'); this.gridApi.refreshInfinitePageCache(); } }, getDefaultProps: function () { return { listContext: null, lastRefreshTime: null, initialSelectedItems: null, numInitialRows: CV_GRID_PAGE_SIZE, gridHeight: '100%', initialFirstVisibleRow: null, queryPaneCallback: null, searchNavResult: null, quickFilterCallback: null, eventRegistry: null, navigationListeners: [], actionListeners: [], gridListener: null, stateChangeListeners: [] } }, getInitialState: function () { return {numInitialRows: null, initialFirstVisibleRow: null, initialSelectedItems:null, forceAutoSize:false} }, quickFilter(filterText:string) { //submit the quick search const quickFilterCallback:CvSearchPaneCallback = this.props.quickFilterCallback; const queryPaneCallback:CvQueryPaneCallback = this.props.queryPaneCallback; if (quickFilterCallback) { quickFilterCallback.reopenSearch((success, error)=> { if (error) { this.props.eventRegistry.publishError('quickFilter reopen failed with ' + ObjUtil.formatRecAttr(error)); } else { quickFilterCallback.clearSearchValues(); quickFilterCallback.setPropValue('keyword', filterText); //this is key - ag-grid must rebuild the grid instance this.resetDataSource(); //this.setState({forceAutoSize:true}); quickFilterCallback.submitSearch((success, error)=> { if (error) { queryPaneCallback.refresh(); this.props.eventRegistry.publishError('quickFilter error ' + ObjUtil.formatRecAttr(error)); } }); } }); } }, render: function () { const {listContext, queryPaneCallback, searchNavResult, navigationListeners, actionListeners, stateChangeListeners, eventRegistry} = this.props; const entityRecs:Array = ArrayUtil.copy(listContext.scroller.buffer); const hasBinaries = this._anyBinaries(listContext, entityRecs); const rowHeight = hasBinaries ? CV_GRID_ROW_HEIGHT_IMAGES : CV_GRID_ROW_HEIGHT; return ( { const columnDefs = listContext.listDef.activeColumnDefs.map((columnDef:ColumnDef)=> { //@TODO - fully integrate custom filtering //filter: this._getFilterType(columnDef.propertyDef), //filterParams: {apply:true}, //the grid doesn't allow '.' in key names return { headerName: columnDef.heading, field: columnDef.name.replace('.', CvGridPanel_DOT_REPL), width: this._getColumnWidth(columnDef), cellRendererFramework: CvGridTableCell }; }); columnDefs[0].pinned = 'left'; columnDefs[0].cellRendererFramework = CvGridTableCellColumn0; if(!this.dataSource) { this.dataSource = new CvGridDataSource(-1, listContext, searchCallback, queryPaneCallback, navigationListeners, actionListeners, stateChangeListeners, eventRegistry); } else { this.dataSource.update(listContext, searchCallback, queryPaneCallback, navigationListeners, actionListeners, stateChangeListeners, eventRegistry); } return (
);} }/>
); }, resetDataSource: function() { this.dataSource = null; }, shouldComponentUpdate(nextProps, nextState) { /* NOTE: initialSelectedItems, numInitialRows, initialFirstVisibleRow are all unmanaged properties, meaning that when they change, they are not synchronized with component state (we ignore them) They are simply changing URL params to be retained only for navigation or a refresh */ //don't update if we've change the forceAutoSize value if(this.state.forceAutoSize != this.state.forceAutoSize) { return false; } //update only if we have new list data if(this.props.queryPaneCallback && !this.props.queryPaneCallback.isInProgress()) { if (nextProps.lastRefreshTime) { if (this.props.lastRefreshTime) { if (nextProps.lastRefreshTime.getTime() > this.props.lastRefreshTime.getTime()) { return true; } } else { return true; } } } return false; }, _anyBinaries: function (listContext:ListContext, entityRecs:Array):boolean { return listContext.entityRecDef.propertyDefs.some(propDef=>propDef.isBinaryType) || entityRecs.some(entityRec=>!!entityRec.imageName || entityRec.props.some(prop=>!!prop.imageName)); }, _onBodyScroll: function(rowHeight, event) { /* 1) Report change in 'first visible row' (unmanaged parameter) */ //seems to be the only option for approximating the first visible row. really need a better solution const vpr = this.gridApi.getVerticalPixelRange(); const rowNum = Math.round(vpr.bottom / rowHeight) - 1; if(this.props.gridListener) this.props.gridListener({type: CvGridEventType.FIRST_VISIBLE_ROW_CHANGE_EVENT, eventObj:rowNum}); }, _onGridReady: function (params:any) { this.gridApi = params.api; this.columnApi = params.columnApi; //relying on this event to tell us when more pages have been loaded and add to the scroll buffer this.gridApi.addEventListener('paginationChanged', this._onPaginationChanged) //relying on this event firing when brand new data is inserted this.gridApi.addEventListener('columnEverythingChanged', this._onColumnEverythingChanged) //relying on this event firing after rows are loaded and inserted (this seems to be the only indication) this.gridApi.addEventListener('scrollVisibilityChanged', this._onRowsInitialized); //uncomment this to log all the ag-grid events to the console for debugging //this.gridApi.addGlobalListener((event, p1, p2, p3)=>{ const e = event; Log.debug(e); }); }, _onPaginationChanged: function(event) { /* * 1) Report change in total rows loaded (unmanaged param) */ const model = this.gridApi.getModel(); if(model) { const rowsLoaded = this.props.listContext.scroller.buffer.length; //don't update if rowsLoaded hasn't actually changed (i.e. on the first load) if(this.state.numInitialRows != rowsLoaded) { if(this.props.gridListener && (rowsLoaded > CV_GRID_PAGE_SIZE)) { this.props.gridListener({type: CvGridEventType.TOTAL_ROWS_LOADED_EVENT, eventObj: rowsLoaded}); } } } }, _onColumnEverythingChanged: function(event) { //if(this.state.forceAutoSize) { //The grid doesn't report an event when all rows are rendered, so we're //left with the horrible option of trying to time the autoSize. somebody help. setTimeout(this.autoSize, 500); //this.setState({forceAutoSize:false}); //} }, _onRowDoubleClicked: function (event) { event.node.data.rowCallback.fireAction(); }, _onRowSelected: function (event) { /* 1) Report change in selections (unmanaged parameter) */ //get the oids of the selected rows and notify the gridListeners fo the selection if (event.node.data && event.node.data.entityRec) { const selectedItems = this.gridApi.getSelectedRows().map(data => data.entityRec.objectId); if (this.props.gridListener) this.props.gridListener({type: CvGridEventType.SELECTION_EVENT, eventObj:selectedItems}); } }, _onRowsInitialized: function(event) { //set the first visible row if specified if(this.state.initialFirstVisibleRow) { this.gridApi.ensureIndexVisible(this.state.initialFirstVisibleRow); } //select initial rows based on the supplied selectedItems oids const selectedItems = this.state.initialSelectedItems; if(selectedItems && selectedItems.length > 0){ selectedItems.forEach(selectedItem=>{ this.gridApi.forEachNode(node=>{ if (node.data && node.data.entityRec && (node.data.entityRec.objectId === selectedItem)) { node.setSelected(true); } }); }); } //autosize the columns this.autoSize(); //remove this listener as we only want this to fire on the first load this.gridApi.removeEventListener('scrollVisibilityChanged', this._onRowsInitialized); }, _getFilterType: function (propDef:PropDef) { if (propDef.isDateType || propDef.isDateTimeType) { return 'date'; } else if (propDef.isNumericType) { return 'number' } else { return 'text'; } }, _getRowStyle: function (params) { if (params.data && params.data.entityRec) { const styleInfo:CvDataAnnoStyle = (CvDataAnno as any).generateStyleInfo(params.data.entityRec); return styleInfo.cssStyle; } else { return null; } }, _getRowNodeId: function (data) { return data.entityRec.objectId; }, _getColumnWidth: function (columnDef:ColumnDef) { return columnDef.propertyDef.presLength < 200 || columnDef.propertyDef.presLength > 2000 ? 250 : columnDef.propertyDef.presLength; }, }); class CvGridDataSource { private inProgress:boolean = false; constructor(public rowCount:number, private listContext:ListContext, private searchCallback:CvSearchPaneCallback, private queryPaneCallback:CvQueryPaneCallback, private navigationListeners:Array<(event:CvEvent)=>void> = [], private actionListeners:Array<(event:CvEvent)=>void> = [], private stateChangeListeners:Array<(event:CvEvent)=>void> = [], private eventRegistry:CvEventRegistry) { this.init(); } public update(listContext:ListContext, searchCallback:CvSearchPaneCallback, queryPaneCallback:CvQueryPaneCallback, navigationListeners:Array<(event:CvEvent)=>void> = [], actionListeners:Array<(event:CvEvent)=>void> = [], stateChangeListeners:Array<(event:CvEvent)=>void> = [], eventRegistry:CvEventRegistry) { this.listContext = listContext; this.searchCallback = searchCallback; this.queryPaneCallback = queryPaneCallback; this.navigationListeners = navigationListeners; this.actionListeners = actionListeners; this.stateChangeListeners = stateChangeListeners; this.eventRegistry = eventRegistry; this.init(); } private init() { this.searchCallback.suppressEvents(true); this.queryPaneCallback.suppressEvents(true); } public getRows(params:any) { const {sortModel} = params; if (sortModel && sortModel.length > 0) { this._handleSort(sortModel, this.searchCallback, (success, error)=> { if (error) { this.eventRegistry.publishError('CvGridPanel._handleSoft failed with ' + ObjUtil.formatRecAttr(error)); } else if (success) { //refresh the list this.listContext.scroller.refresh().onComplete(entityRecTry=> { if (entityRecTry.isFailure) { this.eventRegistry.publishError('CvGridPanel::getRows queryContext.scroller.refresh failed with ' + ObjUtil.formatRecAttr(error)); } else { this._getRows(params); } }); } else { this._getRows(params); } }); } else { this._getRows(params); } } private _getRows(params:any) { const {startRow, endRow, successCallback, failCallback} = params; let rowData = []; const currentRows:Array = this.listContext.scroller.buffer; //any rows loaded yet? if (currentRows.length > 0) { this.pageForwardToRange((success, error)=>{ if(error) { this.eventRegistry.publishError(ObjUtil.formatRecAttr(error)); failCallback(); } else { const currentRows:Array = this.listContext.scroller.buffer; let lastIndex = -1; lastIndex = (currentRows.length > endRow || this.queryPaneCallback.hasMoreForward()) ? -1 : (endRow > currentRows.length) ? currentRows.length : endRow; rowData = this._buildRows(currentRows.slice(startRow, endRow)); successCallback(rowData, lastIndex); } }, this.queryPaneCallback, startRow, endRow); } else { successCallback(rowData, 0); } } /* @TODO - fully integrate custom filtering! Resolve to true if the filter value has changed, otherwise false. private _handleFilters(filterModel:any, searchCallback:CvSearchPaneCallback, resultCallback:CvResultCallback) { Object.keys(filterModel).forEach((key:string)=>{ const colName = key; const pFilterValue = filterModel[key].filter; const pOperator = filterModel[key].type; }); } */ /* Resolve to true if the sort value has changed, otherwise false. */ private _handleSort(sortModel:Array, searchCallback:CvSearchPaneCallback, resultCallback:CvResultCallback) { const colName = sortModel[0].colId.replace(CvGridPanel_DOT_REPL, '.'); const isDesc = sortModel[0] && sortModel[0].sort.indexOf('d') === 0; let sortDirection:CvSortDirection = null; if ((isDesc && !searchCallback.isDescending(colName))) { sortDirection = 'DSC'; } else if (!isDesc && !searchCallback.isAscending(colName)) { sortDirection = 'ASC'; } if (sortDirection) { searchCallback.reopenSearch((success, error)=> { if(error) { resultCallback(false, error); } else { searchCallback.clearSortValues(); searchCallback.setSortValue(colName, sortDirection, 0); searchCallback.submitSearch((success, error)=> { resultCallback(!error, error); }); } }) } else { resultCallback(false); } } private _buildRows(newPage:Array):Array { const entityRecs:Array = ArrayUtil.copy(newPage); return entityRecs.map((record:EntityRec)=> { const row = {entityRec: record}; this.listContext.listDef.activeColumnDefs.forEach((colDef:ColumnDef)=> { const prop:Prop = record.propAtName(colDef.name); const styleInfo:CvDataAnnoStyle = (CvDataAnno as any).generateStyleInfo(prop); const cellStyle = 'cv-grid-cell cv-target'; //select "this record" so that the action can find the target via the selectionProvider const selectionAdapter:CvValueAdapter> = new CvValueAdapter>(); selectionAdapter.createValueListener()([record.objectId]); //the grid doesn't allow '.' in key names row[colDef.name.replace('.', CvGridPanel_DOT_REPL)] = { row['rowCallback'] = callback; return (
);} }/> }); return row; }); } private pageForwardToRange(resultCallback:CvResultCallback, queryPaneCallback:CvQueryPaneCallback, startRow:number, endRow:number):void { let currentRows:Array = this.listContext.scroller.buffer; if (startRow >= currentRows.length || endRow > currentRows.length) { if (queryPaneCallback.hasMoreForward()) { queryPaneCallback.pageForward((num, error) => { const currentRows = this.listContext.scroller.buffer; if (error) { resultCallback(false, error); } else if (startRow >= currentRows.length) { //still haven't reached start row, try to page again this.pageForwardToRange(resultCallback, queryPaneCallback, startRow, endRow); } else { //we've got the startRow, check the endRow if(endRow > currentRows.length) { //still haven't reached the endRow, try to page again this.pageForwardToRange(resultCallback, queryPaneCallback, startRow, endRow); } else { resultCallback(true); } } }); } else { if(startRow >= currentRows.length) { resultCallback(false, new Error('Requested startRow is greater than total number of rows')); } else { //no more pages, we've got the start row but no the end row resultCallback(true); } } } else { //already loaded resultCallback(true); } } } export var CvGridTableCellColumn0 = React.createClass<{},{}>({ render: function () { if (this.props.data) { return this.props.value || null; } else { return ; } } }); export var CvGridTableCell = React.createClass<{},{}>({ render: function () { return this.props.value || null; } });