diff --git a/package.json b/package.json index 09c4c17b..f5c75852 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "typescript": "^2.4.1", "webpack": "^1.14.0", "webpack-dev-server": "^1.16.2", - "webpack-fail-plugin": "^1.0.6" + "webpack-fail-plugin": "^1.0.6", + "redux-logger": "^3.0.6" }, "dependencies": { "immutable": "^3.8.1", diff --git a/src/index.js b/src/index.js index 2ec017d4..9f42c480 100644 --- a/src/index.js +++ b/src/index.js @@ -8,13 +8,15 @@ import _ from 'lodash'; import * as dataReducers from './reducers/dataReducer'; import components from './components'; import settingsComponentObjects from './settingsComponentObjects'; -import * as selectors from './selectors/dataSelectors'; +import * as baseSelectors from './selectors/dataSelectors'; +import * as composedSelectors from './selectors/composedSelectors'; import { buildGriddleReducer, buildGriddleComponents } from './utils/compositionUtils'; import { getColumnProperties } from './utils/columnUtils'; import { getRowProperties } from './utils/rowUtils'; import { setSortProperties } from './utils/sortUtils'; import { StoreListener } from './utils/listenerUtils'; +import { composeSelectors } from './utils/selectorUtils'; import * as actions from './actions'; const defaultEvents = { @@ -98,7 +100,7 @@ class Griddle extends Component { this.events = Object.assign({}, events, ...plugins.map(p => p.events)); - this.selectors = plugins.reduce((combined, plugin) => ({ ...combined, ...plugin.selectors }), {...selectors}); + this.selectors = composeSelectors(baseSelectors, composedSelectors, plugins); const mergedStyleConfig = _.merge({}, defaultStyleConfig, ...plugins.map(p => p.styleConfig), styleConfig); diff --git a/src/module.d.ts b/src/module.d.ts index 5ae07a04..b621a212 100644 --- a/src/module.d.ts +++ b/src/module.d.ts @@ -422,6 +422,7 @@ export namespace utils { const compositionUtils: PropertyBag; const dataUtils: PropertyBag; const rowUtils: PropertyBag; + const selectorUtils: PropertyBag; const connect : typeof originalConnect; diff --git a/src/selectors/composedSelectors.js b/src/selectors/composedSelectors.js new file mode 100644 index 00000000..beaea878 --- /dev/null +++ b/src/selectors/composedSelectors.js @@ -0,0 +1,153 @@ +import Immutable from 'immutable'; +import { createSelector } from 'reselect'; +import _ from 'lodash'; +import MAX_SAFE_INTEGER from 'max-safe-integer' +import { griddleCreateSelector } from '../utils/selectorUtils'; + +export const dataLoadingSelector = griddleCreateSelector( + "dataSelector", + data => !data +); + +export const hasPreviousSelector = griddleCreateSelector( + "currentPageSelector", + (currentPage) => (currentPage > 1) +); + +export const maxPageSelector = griddleCreateSelector( + "pageSizeSelector", + "recordCountSelector", + (pageSize, recordCount) => { + const calc = recordCount / pageSize; + const result = calc > Math.floor(calc) ? Math.floor(calc) + 1 : Math.floor(calc); + return _.isFinite(result) ? result : 1; + } +); + +export const hasNextSelector = griddleCreateSelector( + "currentPageSelector", + "maxPageSelector", + (currentPage, maxPage) => { + return currentPage < maxPage; + } +); + +export const allColumnsSelector = griddleCreateSelector( + "dataSelector", + "renderPropertiesSelector", + (data, renderProperties) => { + const dataColumns = !data || data.size === 0 ? + [] : + data.get(0).keySeq().toJSON(); + + const columnPropertyColumns = (renderProperties && renderProperties.size > 0) ? + // TODO: Make this not so ugly + Object.keys(renderProperties.get('columnProperties').toJSON()) : + []; + + return _.union(dataColumns, columnPropertyColumns); + } +); + +export const sortedColumnPropertiesSelector = griddleCreateSelector( + "renderPropertiesSelector", + (renderProperties) => ( + renderProperties && renderProperties.get('columnProperties') && renderProperties.get('columnProperties').size !== 0 ? + renderProperties.get('columnProperties') + .sortBy(col => (col && col.get('order'))||MAX_SAFE_INTEGER) : + null + ) +); + +export const metaDataColumnsSelector = griddleCreateSelector( + "sortedColumnPropertiesSelector", + (sortedColumnProperties) => ( + sortedColumnProperties ? sortedColumnProperties + .filter(c => c.get('isMetadata')) + .keySeq() + .toJSON() : + [] + ) +); + +export const visibleColumnsSelector = griddleCreateSelector( + "sortedColumnPropertiesSelector", + "allColumnsSelector", + (sortedColumnProperties, allColumns) => ( + sortedColumnProperties ? sortedColumnProperties + .filter(c => { + const isVisible = c.get('visible') || c.get('visible') === undefined; + const isMetadata = c.get('isMetadata'); + return isVisible && !isMetadata; + }) + .keySeq() + .toJSON() : + allColumns + ) +); + +export const visibleColumnPropertiesSelector = griddleCreateSelector( + "visibleColumnsSelector", + "renderPropertiesSelector", +(visibleColumns=[], renderProperties) => ( + visibleColumns.map(c => { + const columnProperty = renderProperties.getIn(['columnProperties', c]); + return (columnProperty && columnProperty.toJSON()) || { id: c } + }) + ) +); + +export const hiddenColumnsSelector = griddleCreateSelector( + "visibleColumnsSelector", + "allColumnsSelector", + "metaDataColumnsSelector", +(visibleColumns, allColumns, metaDataColumns) => { + const removeColumns = [...visibleColumns, ...metaDataColumns]; + + return allColumns.filter(c => removeColumns.indexOf(c) === -1); + } +); + +export const hiddenColumnPropertiesSelector = griddleCreateSelector( + "hiddenColumnsSelector", + "renderPropertiesSelector", +(hiddenColumns=[], renderProperties) => ( + hiddenColumns.map(c => { + const columnProperty = renderProperties.getIn(['columnProperties', c]); + + return (columnProperty && columnProperty.toJSON()) || { id: c } + }) + ) +); + +export const columnIdsSelector = griddleCreateSelector( + "renderPropertiesSelector", + "visibleColumnsSelector", +(renderProperties, visibleColumns) => { + const offset = 1000; + // TODO: Make this better -- This is pretty inefficient + return visibleColumns + .map((k, index) => ({ + id: renderProperties.getIn(['columnProperties', k, 'id']) || k, + order: renderProperties.getIn(['columnProperties', k, 'order']) || offset + index + })) + .sort((first, second) => first.order - second.order) + .map(item => item.id); + } +); + +export const columnTitlesSelector = griddleCreateSelector( + "columnIdsSelector", + "renderPropertiesSelector", + (columnIds, renderProperties) => columnIds.map(k => renderProperties.getIn(['columnProperties', k, 'title']) || k) +); + +export const visibleRowIdsSelector = griddleCreateSelector( + "dataSelector", + currentPageData => currentPageData ? currentPageData.map(c => c.get('griddleKey')) : new Immutable.List() +); + +export const visibleRowCountSelector = griddleCreateSelector( + "visibleRowIdsSelector", + (visibleRowIds) => visibleRowIds.size +); diff --git a/src/utils/index.js b/src/utils/index.js index 94c5d6fa..6e0daef8 100644 --- a/src/utils/index.js +++ b/src/utils/index.js @@ -3,6 +3,7 @@ import * as compositionUtils from './compositionUtils'; import * as dataUtils from './dataUtils'; import * as rowUtils from './rowUtils'; import * as sortUtils from './sortUtils'; +import * as selectorUtils from './selectorUtils'; import { connect } from './griddleConnect'; export default { @@ -12,4 +13,5 @@ export default { rowUtils, sortUtils, connect, + selectorUtils }; diff --git a/src/utils/selectorUtils.js b/src/utils/selectorUtils.js new file mode 100644 index 00000000..c6521570 --- /dev/null +++ b/src/utils/selectorUtils.js @@ -0,0 +1,277 @@ +import { forOwn } from 'lodash'; +import { createSelector } from 'reselect' + +/* + * Wrapped 'createSelector' that allows for building the selector + * dependency tree. Takes any number of arguments, all arguments but the + * last must be dependencies, which are the string names of selectors + * this selector depends on and the last arg must be the selector function + * itself. This structure mirrors very closely what calling 'createSelector' + * looks like. + * + * const mySelector = createSelector( + * aSelector, + * anotherSelector, + * (a, b) => (someLogic....) + * ); + * + * const mySelector = griddleCreateSelector( + * "aSelector", + * "anotherSelector", + * (a, b) => (someLogic...) + * ); + * + * When the selectors are finally generated, the actual dependency selectors + * are passed to the createSelector function. + */ +export const griddleCreateSelector = (...args) => { + + // All selectors that use createSelector must have a minimum of one + // dependency and the selector function itself + if (args.length < 2) { + throw new Error("Cannot create a selector with fewer than 2 arguments, must have at least one dependency and the selector function"); + } + + // The first n - 1 args are the dependencies, they must + // all be strings. + const dependencies = args.slice(0, args.length - 1); + for (let dependency of dependencies) { + if (typeof dependency !== "string") { + throw new Error("Args 0..n-1 must be strings"); + } + } + + // The last of n args is the selector function, + // it must be a function + const selector = args[args.length - 1]; + if (typeof selector !== "function") { + throw new Error("Last argument must be a function"); + } + + return { + // the creator function is called to generate the + // selector function. It is passed the object containing all + // of the static/generated selector functions to be potentially + // used as dependencies + creator: (selectors) => { + + // extract the dependency selectors using the list + // of dependencies + const createSelectorFuncs = []; + for (let dependency of dependencies) { + createSelectorFuncs.push(selectors[dependency]); + } + + // add this selector + createSelectorFuncs.push(selector); + + // call createSelector with the final list of args + return createSelector(...createSelectorFuncs); + }, + + // the list of dependencies is needed to build the dependency + // tree + dependencies + }; +}; + + +export const composeSelectors = (baseSelectors, composedSelectors, plugins) => { + + // STEP 1 + // ========== + // + // Add all of the 'base' selectors to the list of combined selectors. + // The actuall selector functions are wrapped in an object which is used + // to keep track of all the data needed to properly build all the + // selector dependency trees + console.log("Parsing built-in selectors"); + const combinedSelectors = new Map(); + + forOwn(baseSelectors, (baseSelector, name) => { + const selector = { + name, + selector: baseSelector, + dependencies: [], + rank: 0, + traversed: false + }; + combinedSelectors.set(name, selector); + }); + + // STEP 2 + // ========== + // + // Add all of the 'composed' selectors to the list of combined selectors. + // Composed selectors use the 'createSelector' function provided by reselect + // and depend on other selectors. These new selectors are located in a + // new file named 'composedSelectors' and are now an object that looks like this: + // { + // creator: ({dependency1, dependency2, ...}) => return createSelector(dependency1, dependency2, (...) => (...)), + // dependencies: ["dependency1", "dependency2"] + // } + // 'creator' will return the selector when it is run with the dependency selectors + // 'dependencies' are the string names of the dependency selectors, these will be used to + // build the tree of selectors + forOwn(composedSelectors, (composedSelector, name) => { + const selector = { + name, + ...composedSelector, + rank: 0, + traversed: false + }; + combinedSelectors.has(name) && console.log(` Overriding existing selector named ${name}`); + combinedSelectors.set(name, selector); + }); + + // STEP 3 + // ========== + // + // Once the built-in 'base' and 'composed' selectors are added to the list, + // repeat the same process for each of the plugins. + // + // Plugins can now redefine a single existing selector without having to + // include the full list of dependency selectors since the dependencies + // are now created dynamically + for (let i in plugins) { + console.log(`Parsing selectors for plugin ${i}`); + const plugin = plugins[i]; + forOwn(plugin.selectors, (baseSelector, name) => { + const selector = { + name, + selector: baseSelector, + dependencies: [], + rank: 0, + traversed: false + }; + + // console log for demonstration purposes + combinedSelectors.has(name) && console.log(` Overriding existing selector named ${name} with base selector`); + combinedSelectors.set(name, selector); + }); + + forOwn(plugin.composedSelectors, (composedSelector, name) => { + const selector = { + name, + ...composedSelector, + rank: 0, + traversed: false + }; + + // console log for demonstration purposes + combinedSelectors.has(name) && console.log(` Overriding existing selector named ${name} with composed selector`); + combinedSelectors.set(name, selector); + }); + } + + + // RANKS + // ========== + // + // The ranks array is populated when running getDependencies + // It stores the selectors based on their 'rank' + // Rank can be defined recursively as: + // - if a selector has no dependencies, rank is 0 + // - if a selector has 1 or more dependencies, rank is max(all dependency ranks) + 1 + const ranks = []; + + // GET DEPENDENCIES + // ========== + // + // getDependencies recursively descends through the dependencies + // of a given selector doing several things: + // - creates a 'flat' list of dependencies for a given selector, + // which is a list of all of its dependencies + // - calculates the rank of each selector and fills out the above ranks list + // - determines if there are any cycles present in the dependency tree + // + // It also memoizes the results in the combinedSelectors Map by setting the + // 'traversed' flag for a given selector. If a selector has been flagged as + // 'traversed', it simply returns the previously calculated dependencies + const getDependencies = (node, parents) => { + // if this node has already been traversed + // no need to run the get dependencies logic as they + // have already been computed + // simply return its list of flattened dependencies + if (!node.traversed) { + + // if the node has dependencies, add each one to the node's + // list of flattened dependencies and recursively call + // getDependencies on each of them + if (node.dependencies.length > 0) { + + const flattenedDependencies = new Set(); + for (let dependency of node.dependencies) { + if (!combinedSelectors.has(dependency)) { + const err = `Selector ${node.name} has dependency ${dependency} but this is not in the list of dependencies! Did you misspell something?`; + throw new Error(err); + } + + // if any dependency in the recursion chain + // matches one of the parents there is a cycle throw an exception + // this is an unrecoverable runtime error + if (parents.has(dependency)) { + let err = "Dependency cycle detected! "; + for (let e of parents) { + e === dependency ? err += `[[${e}]] -> ` : err += `${e} -> `; + } + err += `[[${dependency}]]`; + console.log(err); + throw new Error(err); + } + flattenedDependencies.add(dependency); + const childParents = new Set(parents); + childParents.add(dependency); + const childsDependencies = getDependencies(combinedSelectors.get(dependency), childParents); + childsDependencies.forEach((key) => flattenedDependencies.add(key)) + const childRank = combinedSelectors.get(dependency).rank; + childRank >= node.rank && (node.rank = childRank + 1); + } + node.flattenedDependencies = flattenedDependencies; + node.traversed = true; + + } else { + + // otherwise, this is a leaf node + // - set the node's rank to 0 + // - set the nodes flattenedDependencies to an empty set + node.flattenedDependencies = new Set(); + node.traversed = true; + } + ranks[node.rank] || (ranks[node.rank] = new Array()); + ranks[node.rank].push(node); + } + return node.flattenedDependencies; + }; + + + // STEP 4 + // ========== + // + // Run getDependencies on each selector in the 'combinedSelectors' list + // This fills out the 'ranks' list for use in the next step + for (let e of combinedSelectors) { + const [name, selector] = e; + getDependencies(selector, new Set([name])); + } + + // STEP 5 + // ========== + // + // Create a flat object of just the actual selector functions + const flattenedSelectors = {}; + for (let rank of ranks) { + for (let selector of rank) { + if (selector.creator) { + const childSelectors = {}; + for (let childSelector of selector.dependencies) { + childSelectors[childSelector] = combinedSelectors.get(childSelector).selector; + } + selector.selector = selector.creator(childSelectors); + } + flattenedSelectors[selector.name] = selector.selector; + } + } + + return flattenedSelectors; +} diff --git a/stories/index.tsx b/stories/index.tsx index aba578da..e3b37954 100644 --- a/stories/index.tsx +++ b/stories/index.tsx @@ -11,12 +11,15 @@ import { Provider } from 'react-redux'; import { createStore } from 'redux'; import { createSelector } from 'reselect'; import _ from 'lodash'; +import { createLogger } from 'redux-logger'; import GenericGriddle, { actions, components, selectors, plugins, utils, ColumnDefinition, RowDefinition, GriddleProps } from '../src/module'; const { connect } = utils; const { Cell, Row, Table, TableContainer, TableBody, TableHeading, TableHeadingCell } = components; const { SettingsWrapper, SettingsToggle, Settings } = components; +const { griddleCreateSelector } = utils.selectorUtils; + const { LegacyStylePlugin, LocalPlugin, PositionPlugin } = plugins; import fakeData, { FakeData } from './fakeData'; @@ -872,6 +875,140 @@ storiesOf('Plugins', module) ); }) + .add('Overridable selectors in plugin', () => { + + const getNext = () => { + return { + type: "GRIDDLE_NEXT_PAGE" + }; + }; + + const getPrevious = () => { + return { + type: "GRIDDLE_PREVIOUS_PAGE" + }; + }; + + const setPage = (pageNumber) => { + return { + type: "GRIDDLE_SET_PAGE", + pageNumber + }; + }; + + const GRIDDLE_NEXT_PAGE = (state, action) => { + const currentPage = state.getIn(["pageProperties", "currentPage"]); + const pageSize = state.getIn(["pageProperties", "pageSize"]); + const recordCount = state.get("data").size; + const maxPage = Math.ceil(recordCount/pageSize); + + if (currentPage + 1 <= maxPage) { + return state.setIn(["pageProperties", "currentPage"], currentPage + 1); + } else { + return state; + } + }; + + const GRIDDLE_PREVIOUS_PAGE = (state, action) => { + const currentPage = state.getIn(["pageProperties", "currentPage"]); + const minPage = 1; + + if (currentPage - 1 >= minPage) { + return state.setIn(["pageProperties", "currentPage"], currentPage - 1); + } else { + return state; + } + }; + + const GRIDDLE_SET_PAGE = (state, action) => { + const pageNumber = action.pageNumber; + const pageSize = state.getIn(["pageProperties", "pageSize"]); + const recordCount = state.get("data").size; + const maxPage = Math.ceil(recordCount/pageSize); + const minPage = 1; + + if (pageNumber >= minPage && pageNumber <= maxPage) { + return state.setIn(["pageProperties", "currentPage"], pageNumber); + } else { + return state; + } + }; + + const allDataSelector = (state) => state.get("data"); + + const recordCountSelector = state => state.get("data").size; + + const dataSelector = griddleCreateSelector ( + "allDataSelector", + "pageSizeSelector", + "currentPageSelector", + "recordCountSelector", + (data, pageSize, currentPage, recordCount) => { + currentPage = currentPage - 1; + const first = currentPage * pageSize; + const last = Math.min((currentPage + 1) * pageSize, recordCount); + return data.slice(first, last); + } + ); + + const NextButtonEnhancer = OriginalComponent => compose( + connect( + null, + (dispatch, props) => { + return { + getNext: () => dispatch(getNext()) + } + } + ) + )((props) => ); + + const PageDropdownEnhancer = OriginalComponent => compose( + connect( + null, + (dispatch, props) => { + return { + setPage: (page) => dispatch(setPage(page)) + } + } + ) + )((props) => ); + + const PreviousButtonEnhancer = OriginalComponent => compose( + connect( + null, + (dispatch, props) => { + return { + getPrevious: () => dispatch(getPrevious()) + } + } + ) + )((props) => ); + + + const OverridableSelectorsPlugin = { + components: { + NextButtonEnhancer, + PageDropdownEnhancer, + PreviousButtonEnhancer + }, + reducer: { + GRIDDLE_NEXT_PAGE, + GRIDDLE_PREVIOUS_PAGE, + GRIDDLE_SET_PAGE + }, + selectors: { + allDataSelector, + recordCountSelector + }, + composedSelectors: { + dataSelector + } + }; + + return ( + + ); + }) storiesOf('Data Missing', module) .add('base (data=undefined)', () => { diff --git a/yarn.lock b/yarn.lock index 4561917b..d859aa1a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2316,6 +2316,10 @@ decamelize@^1.0.0, decamelize@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" +deep-diff@^0.3.5: + version "0.3.8" + resolved "https://registry.npmjs.intuit.net/d/deep-diff/_attachments/deep-diff-0.3.8.tgz#c01de63efb0eec9798801d40c7e0dae25b582c84" + deep-equal@^1.0.0, deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -5354,6 +5358,12 @@ reduce-function-call@^1.0.1: dependencies: balanced-match "^0.4.2" +redux-logger@^3.0.6: + version "3.0.6" + resolved "https://registry.npmjs.intuit.net/r/redux-logger/_attachments/redux-logger-3.0.6.tgz#f7555966f3098f3c88604c449cf0baf5778274bf" + dependencies: + deep-diff "^0.3.5" + redux@^3.5.2, redux@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/redux/-/redux-3.6.0.tgz#887c2b3d0b9bd86eca2be70571c27654c19e188d"