diff --git a/src/components/distributions/editor/TextForm/TextForm.js b/src/components/distributions/editor/TextForm/TextForm.js index eee0211dd..f733ef253 100644 --- a/src/components/distributions/editor/TextForm/TextForm.js +++ b/src/components/distributions/editor/TextForm/TextForm.js @@ -29,8 +29,9 @@ export class TextForm extends Component{ } _switchMetricClickMode() { - if (this.props.guesstimate.guesstimateType === 'FUNCTION') {this.props.onChangeClickMode('FUNCTION_INPUT_SELECT')} - this.props.onFocus() + if (this.props.guesstimate.guesstimateType === 'FUNCTION') { + this.props.onChangeClickMode('FUNCTION_INPUT_SELECT') + } } _handleBlur() { diff --git a/src/components/distributions/editor/TextForm/TextInput.js b/src/components/distributions/editor/TextForm/TextInput.js index 1f5d6c480..0bb9848a7 100644 --- a/src/components/distributions/editor/TextForm/TextInput.js +++ b/src/components/distributions/editor/TextForm/TextInput.js @@ -11,8 +11,9 @@ import {or} from 'gEngine/utils' import {isData, formatData} from 'lib/guesstimator/formatter/formatters/Data' -function findWithRegex(regex, contentBlock, callback) { +function findWithRegex(baseRegex, contentBlock, callback) { const text = contentBlock.getText() + const regex = new RegExp(baseRegex.source, 'g') let matchArr, start while ((matchArr = regex.exec(text)) !== null) { start = matchArr.index @@ -46,7 +47,10 @@ export class TextInput extends Component{ value: PropTypes.string, } - factRegex() { return this.props.canUseOrganizationFacts ? HANDLE_REGEX : GLOBALS_ONLY_REGEX } + factRegex() { + const baseRegex = this.props.canUseOrganizationFacts ? HANDLE_REGEX : GLOBALS_ONLY_REGEX + return new RegExp(baseRegex, 'g') // We always want a fresh, global regex. + } decoratorList(extraDecorators=[]) { const {validInputs, errorInputs} = this.props @@ -108,6 +112,9 @@ export class TextInput extends Component{ deleteOldSuggestion(oldSuggestion) { const freshEditorState = this.addText('', true, oldSuggestion.length) this.setState({editorState: this.stripExtraDecorators(freshEditorState)}) + + const text = this.text(freshEditorState) + if (text !== this.props.value) { this.props.onChange(text) } } addSuggestion() { @@ -179,9 +186,16 @@ export class TextInput extends Component{ } } + acceptSuggestionIfAppropriate() { + if (!_.isEmpty(this.props.suggestion) && this.nextWord() === this.props.suggestion) { + this.acceptSuggestion() + return true + } + return false + } + handleTab(e){ - if (!_.isEmpty(this.props.suggestion) && this.nextWord() === this.props.suggestion) { this.acceptSuggestion() } - else { this.props.onTab(e.shiftKey) } + if (!this.acceptSuggestionIfAppropriate()) { this.props.onTab(e.shiftKey) } e.preventDefault() } @@ -202,6 +216,11 @@ export class TextInput extends Component{ this.props.onBlur() } + handleReturn(e) { + if (!this.acceptSuggestionIfAppropriate()) { this.props.onReturn(e.shiftKey) } + return 'handled' + } + render() { const [{hasErrors, width, value, validInputs}, {editorState}] = [this.props, this.state] const className = `TextInput ${width}` + (_.isEmpty(value) && hasErrors ? ' hasErrors' : '') @@ -210,13 +229,12 @@ export class TextInput extends Component{ className={className} onClick={this.focus.bind(this)} onKeyDown={e => {e.stopPropagation()}} - onFocus={this.handleFocus.bind(this)} > this.props.onReturn(e.shiftKey)} + handleReturn={this.handleReturn.bind(this)} handlePastedText={this.handlePastedText.bind(this)} onTab={this.handleTab.bind(this)} onBlur={this.handleBlur.bind(this)} diff --git a/src/components/distributions/editor/index.js b/src/components/distributions/editor/index.js index 77f8db94d..d2816e765 100644 --- a/src/components/distributions/editor/index.js +++ b/src/components/distributions/editor/index.js @@ -22,6 +22,7 @@ export default class Guesstimate extends Component{ changeGuesstimate: PropTypes.func.isRequired, runFormSimulations: PropTypes.func.isRequired, changeMetricClickMode: PropTypes.func.isRequired, + metricClickMode: PropTypes.string, guesstimate: PropTypes.object, metricId: PropTypes.string.isRequired, metricFocus: PropTypes.func.isRequired, @@ -69,7 +70,9 @@ export default class Guesstimate extends Component{ this.changeGuesstimate({}, false, true) } - _changeMetricClickMode(newMode) { this.props.changeMetricClickMode(newMode) } + _changeMetricClickMode(newMode) { + if (this.props.metricClickMode !== newMode) { this.props.changeMetricClickMode(newMode) } + } handleReturn(shifted) { if (shifted) { @@ -120,7 +123,6 @@ export default class Guesstimate extends Component{ onEscape={this.props.metricFocus} onReturn={this.handleReturn.bind(this)} onTab={this.handleTab.bind(this)} - onFocus={this.props.onEdit} size={size} errors={errors} organizationId={organizationId} diff --git a/src/components/facts/list/container.js b/src/components/facts/list/container.js index 387a5cae0..d8ffaa505 100644 --- a/src/components/facts/list/container.js +++ b/src/components/facts/list/container.js @@ -2,32 +2,30 @@ import React, {Component, PropTypes} from 'react' import {connect} from 'react-redux' import * as organizationActions from 'gModules/organizations/actions' -import {findFacts} from 'gEngine/organization.js' +import {findFacts} from 'gEngine/organization' +import * as _collections from 'gEngine/collections' +import {orArr} from 'gEngine/utils' import {FactList} from './list.js' -function mapStateToProps(state) { - return { - organizations: state.organizations, - organizationFacts: state.facts.organizationFacts, - } -} - -@connect(mapStateToProps) +@connect(null) export class FactListContainer extends Component{ displayName: 'FactListContainer' render() { - const {organizationId, organizations, organizationFacts, isEditable} = this.props - const facts = findFacts(organizationId, organizationFacts) - const organization = organizations.find(u => u.id.toString() === organizationId.toString()) + const {facts, existingVariableNames, categories, organization, categoryId, canMakeNewFacts, spaceId, imported_fact_ids} = this.props return ( this.props.dispatch(organizationActions.deleteFact(organization, fact))} onAddFact={fact => this.props.dispatch(organizationActions.addFact(organization, fact))} - onEditFact={fact => this.props.dispatch(organizationActions.editFact(organization, fact))} + onEditFact={fact => this.props.dispatch(organizationActions.editFact(organization, fact, true))} facts={facts} - isEditable={isEditable} + existingVariableNames={existingVariableNames} + categories={orArr(categories)} + categoryId={categoryId} + canMakeNewFacts={canMakeNewFacts} + spaceId={spaceId} + imported_fact_ids={imported_fact_ids} /> ) } diff --git a/src/components/facts/list/form.js b/src/components/facts/list/form.js index 3a0e83857..9a180dff1 100644 --- a/src/components/facts/list/form.js +++ b/src/components/facts/list/form.js @@ -1,10 +1,15 @@ import React, {Component, PropTypes} from 'react' -import {simulateFact, FactPT} from 'gEngine/facts' +import {navigateFn} from 'gModules/navigation/actions' + +import {spaceUrlById} from 'gEngine/space' +import {hasRequiredProperties, isExportedFromSpace, simulateFact, FactPT} from 'gEngine/facts' +import {FactCategoryPT} from 'gEngine/fact_category' import {addStats} from 'gEngine/simulation' +import {orStr} from 'gEngine/utils' import {isData, formatData} from 'lib/guesstimator/formatter/formatters/Data' -import {getVariableNameFromName} from 'lib/generateVariableNames/nameToVariableName' +import {withVariableName} from 'lib/generateVariableNames/generateFactVariableName' export class FactForm extends Component { static defaultProps = { @@ -12,16 +17,21 @@ export class FactForm extends Component { name: '', expression: '', variable_name: '', + exported_from_id: null, + metric_id: null, + category_id: null, simulation: { sample: { values: [], errors: [], }, }, - } + }, + categories: [], } static propTypes = { + categories: PropTypes.arrayOf(FactCategoryPT).isRequired, existingVariableNames: PropTypes.arrayOf(PropTypes.string).isRequired, onSubmit: PropTypes.func.isRequired, onCancel: PropTypes.func, @@ -29,7 +39,7 @@ export class FactForm extends Component { } state = { - runningFact: this.props.startingFact, + runningFact: {...this.props.startingFact, category_id: _.get(this, 'props.startingFact.category_id') || this.props.categoryId}, variableNameManuallySet: !_.isEmpty(_.get(this.props, 'startingFact.variable_name')), currentExpressionSimulated: true, submissionPendingOnSimulation: false, @@ -41,13 +51,16 @@ export class FactForm extends Component { } } + componentDidMount() { this.refs.name.focus() } + setFactState(newFactState, otherState = {}) { this.setState({...otherState, runningFact: {...this.state.runningFact, ...newFactState}}) } onChangeName(e) { const name = _.get(e, 'target.value') this.setFactState( - this.state.variableNameManuallySet ? {name} : {name, variable_name: getVariableNameFromName(name, this.props.existingVariableNames)} + this.state.variableNameManuallySet ? {name} : withVariableName({...this.state.runningFact, name}, this.props.existingVariableNames) ) } + onSelectCategory(c) { this.setFactState({category_id: c.target.value}) } onChangeVariableName(e) { this.setFactState({variable_name: _.get(e, 'target.value')}, {variableNameManuallySet: true}) } onChangeExpression(e) { this.setFactState({expression: _.get(e, 'target.value')}, {currentExpressionSimulated: false}) } onBlurExpression() { this.simulateCurrentExpression() } @@ -58,26 +71,18 @@ export class FactForm extends Component { addStats(simulation) this.setFactState({simulation}, {currentExpressionSimulated: true}) } else { - simulateFact(this.state.runningFact).then(({values, errors}) => { - let simulation = {sample: {values, errors}} + simulateFact(this.state.runningFact).then(sample => { + let simulation = {sample} addStats(simulation) this.setFactState({simulation}, {currentExpressionSimulated: true}) }) } } - isExpressionValid() { return _.isEmpty(_.get(this, 'state.runningFact.simulation.sample.errors')) } + hasNoErrors() { return _.isEmpty(_.get(this, 'state.runningFact.simulation.sample.errors')) } isVariableNameUnique() { return !_.some(this.props.existingVariableNames, n => n === this.state.runningFact.variable_name) } - isValid() { - const requiredProperties = [ - 'variable_name', - 'expression', - 'simulation.sample.values', - 'simulation.stats', - ] - const requiredPropertiesPresent = requiredProperties.map(prop => !_.isEmpty(_.get(this.state.runningFact, prop))) - return _.every(requiredPropertiesPresent) && this.isExpressionValid() && this.isVariableNameUnique() - } + isValid() { return hasRequiredProperties(this.state.runningFact) && this.hasNoErrors() && this.isVariableNameUnique() } + onSubmit() { if (this.state.currentExpressionSimulated) { this.props.onSubmit(this.state.runningFact) @@ -86,14 +91,32 @@ export class FactForm extends Component { } } - submitIfEnter(e){ - if (e.keyCode === 13 && this.isValid()) {this.onSubmit()} + submitIfEnter(e) { if (e.keyCode === 13 && this.isValid()) {this.onSubmit()} } + + renderEditExpressionSection() { + if (isExportedFromSpace(this.state.runningFact)) { + const exported_from_url = `${spaceUrlById(_.get(this, 'state.runningFact.exported_from_id'))}?factsShown=true` + return Edit Model + } else { + return ( +
+ +
+ ) + } } render() { const { - props: {buttonText, onCancel, onDelete}, - state: {submissionPendingOnSimulation, runningFact: {expression, name, variable_name}} + props: {buttonText, onCancel, onDelete, categories}, + state: {submissionPendingOnSimulation, runningFact: {expression, name, variable_name, category_id}} } = this let buttonClasses = ['ui', 'button', 'small', 'primary'] @@ -107,15 +130,7 @@ export class FactForm extends Component {
-
- -
+ {this.renderEditExpressionSection()}
@@ -127,6 +142,7 @@ export class FactForm extends Component { value={name} onChange={this.onChangeName.bind(this)} onKeyDown={this.submitIfEnter.bind(this)} + ref='name' />
@@ -142,6 +158,18 @@ export class FactForm extends Component { />
+ {!_.isEmpty(categories) && +
+
+ +
+
+ }
{buttonText} {!!onCancel && Cancel} diff --git a/src/components/facts/list/item.js b/src/components/facts/list/item.js index aea892201..46266c183 100644 --- a/src/components/facts/list/item.js +++ b/src/components/facts/list/item.js @@ -2,43 +2,61 @@ import React, {Component} from 'react' import Icon from 'react-fa' +import {spaceUrlById} from 'gEngine/space' +import {navigateFn} from 'gModules/navigation/actions' +import {isExportedFromSpace, length, mean, adjustedConfidenceInterval} from 'gEngine/facts' import {DistributionSummary} from 'gComponents/distributions/summary/index' import Histogram from 'gComponents/simulations/histogram/index' -export const FactItem = ({fact, onEdit}) => ( -
-
-
- {_.has(fact, 'simulation.sample.values.length') && _.has(fact, 'simulation.stats.mean') && -
- -
- } - {_.has(fact, 'simulation.sample.values.length') && -
- -
- } -
-
- {fact.name} -
- # -
{fact.variable_name}
+import {allPropsPresent} from 'gEngine/utils' + +export const FactItem = ({fact, onEdit, isExportedFromSelectedSpace, size}) => { + const exported_from_url = spaceUrlById(_.get(fact, 'exported_from_id'), {factsShown: 'true'}) + return ( +
+
+
+ {allPropsPresent(fact, 'simulation.sample.values.length', 'simulation.stats.mean') && +
+
+ +
+
+ +
+
+ } +
+
+ {fact.name} + {(size !== 'SMALL') && +
+ # +
{fact.variable_name}
+
+ }
-
+ {(size !== 'SMALL') &&
Edit
+ } + + {!!isExportedFromSpace(fact) && (size !== 'SMALL') && +
+ {!isExportedFromSelectedSpace && } +
+ } +
-
-) + ) +} diff --git a/src/components/facts/list/list.js b/src/components/facts/list/list.js index c133456fa..db3862dc0 100644 --- a/src/components/facts/list/list.js +++ b/src/components/facts/list/list.js @@ -4,12 +4,16 @@ import {FactItem} from './item' import {FactForm} from './form' import {getVar} from 'gEngine/facts' +import {utils} from 'gEngine/engine' + import './style.css' +import Icon from 'react-fa' export class FactList extends Component { state = { editingFactId: null, newFactKey: 0, + showNewForm: false } componentWillUpdate(newProps) { @@ -23,22 +27,33 @@ export class FactList extends Component { this.setState({newFactKey: this.state.newFactKey + 1}) } + showEditForm(editingFactId) { this.setState({editingFactId}) } + + isExportedFromSelectedSpaceFn({exported_from_id}) { return exported_from_id === this.props.spaceId } + isImportedFromSelectedSpaceFn({id}) { return this.props.imported_fact_ids.includes(id) } + + showNewForm() { this.setState({showNewForm: true}) } + hideNewForm() { this.setState({showNewForm: false}) } + renderFactShow(fact) { return ( {this.setState({editingFactId: fact.id})}} + onEdit={this.showEditForm.bind(this, fact.id)} + isExportedFromSelectedSpace={this.isExportedFromSelectedSpaceFn(fact)} + size={'LARGE'} /> ) } renderEditForm(fact) { - const {facts, onEditFact} = this.props + const {existingVariableNames, categories, onEditFact} = this.props return v !== getVar(fact))} + existingVariableNames={existingVariableNames.filter(v => v !== getVar(fact))} + categories={categories} buttonText={'Save'} onSubmit={onEditFact} onDelete={() => {this.props.onDeleteFact(fact)}} @@ -47,33 +62,59 @@ export class FactList extends Component { } renderNewForm() { - const {facts} = this.props + const {existingVariableNames, categories, categoryId} = this.props return } - renderFacts() { - const {props: {facts}, state: {editingFactId}} = this - if (!editingFactId) { return _.map(facts, this.renderFactShow.bind(this)) } + renderSpaceFacts() { + let filteredFacts = utils.mutableCopy(this.props.facts) + const exported = _.remove(filteredFacts, this.isExportedFromSelectedSpaceFn.bind(this)) + const imported = _.remove(filteredFacts, this.isImportedFromSelectedSpaceFn.bind(this)) - const editingFactIndex = facts.findIndex(fact => fact.id === editingFactId) - return [ - ..._.map(facts.slice(0, editingFactIndex), this.renderFactShow.bind(this)), - this.renderEditForm(facts[editingFactIndex]), - ..._.map(facts.slice(editingFactIndex + 1), this.renderFactShow.bind(this)), - ] + return ( +
+ {!!exported.length &&

Model Outputs

} + {this.renderFactSublist(exported)} + {!!imported.length &&

Model Inputs

} + {this.renderFactSublist(imported)} + {!!filteredFacts.length &&

Other Facts

} + {this.renderFactSublist(filteredFacts)} +
+ ) + } + + renderFactSublist(facts) { + const {state: {editingFactId}} = this + + return _.map(facts, e => { + if (e.id === editingFactId) {return this.renderEditForm(e)} + else {return this.renderFactShow(e)} + }) } render() { return (
- {this.renderFacts()} - {this.props.isEditable && this.renderNewForm()} + {this.props.spaceId && this.renderSpaceFacts()} + {!this.props.spaceId && this.renderFactSublist(this.props.facts)} + {this.props.canMakeNewFacts && this.state.showNewForm && this.renderNewForm()} + {this.props.canMakeNewFacts && !this.state.showNewForm && }
) } } + +const NewButton = ({onClick}) => ( +
+ + New Fact +
+) diff --git a/src/components/facts/list/style.css b/src/components/facts/list/style.css index 62c6e4171..f47959ab9 100644 --- a/src/components/facts/list/style.css +++ b/src/components/facts/list/style.css @@ -4,23 +4,61 @@ $grey-a1: rgb(231, 234, 236); $grey-a2: #607580; $blue-a1: #4A6A88; +.FactsTab { + h3 { + color: #555; + float: left; + } +} + +.NewFactButton { + background-color: $grey-a1; + border-radius: 2px; + width: 100%; + float: left; + padding: .4em .7em; + transition: all .15s; + color: #767575; + font-size: 1.2em; + margin-top: .3em; + cursor: pointer; + + .fa { + margin-right: .5em; + } + + &:hover { + color: #1a5a2c; + background-color: rgba(89, 168, 114, 0.54); + transition: all .3s; + } +} + + .Fact--outer { border: 1px solid rgba(0,0,0,0); float: left; width: 100%; - margin: .2em 0; + margin: .1em 0; &:hover { border-color: #d9dee2; } + + &.SMALL { + margin: .1em .6em; + + .Fact { + flex-direction: column-reverse; + } + } } .Fact { - padding: .4em .5em .5em; background-color: white; float: left; width: 100%; - border-radius: 2px; + border-radius: 1px; display: flex; flex-direction: row; @@ -61,14 +99,15 @@ $blue-a1: #4A6A88; .Fact .section-simulation { min-width: 6em; - flex: 1; + flex: 1.4; position: relative; + padding: .3em 0 .2em .4em; .histogram { left: 10%; height: 35px; position: absolute; - bottom: -.5em; + bottom: 0; right: 0; z-index: 0; .react-d3-histogram__bar rect { @@ -81,6 +120,7 @@ $blue-a1: #4A6A88; margin-top: 2px; position: relative; z-index: 1; + font-size: .88em; .DistributionSummary .mean { color: $grey-a2; @@ -102,9 +142,10 @@ $blue-a1: #4A6A88; float: left; flex: 6; margin-top: .05em; + padding: .3em .4em 0; .fact-name { - font-size: 1.3em; + font-size: 1.2em; color: #333; float: left; width: 100%; @@ -113,7 +154,7 @@ $blue-a1: #4A6A88; .variable-name { float: left; line-height: 1.2em; - margin-top: .4em; + margin-top: .2em; &.variable-token { padding: 0 3px; @@ -155,12 +196,23 @@ $blue-a1: #4A6A88; textarea { margin-top: .3em; - font-size: 1rem; + font-size: .9rem; + } + } + + .category-select { + margin-top: .5em; + float: left; + width: 100%; + + select { + width: 50%; } } .actions { margin-top: 1em; + margin-bottom: .3em; float: left; } } @@ -168,6 +220,7 @@ $blue-a1: #4A6A88; .Fact > .section-help { float: left; min-width: 5em; + padding: .4em 0; flex: 1; .button { @@ -175,3 +228,24 @@ $blue-a1: #4A6A88; } } +.Fact > .section-exported { + padding: .4em .4em 0 .4em; + flex: .3; + background-color: #e0e3e6; + float: left; + cursor: pointer; +} + +.Fact > .section-exported .fa { + color: #a2acbb; + font-size: 1.1em; + margin-top: .1em; +} + +.Fact > .section-exported:hover { + transition: all .15s; + background-color: #bac5cf; + .fa { + color: #73849c; + } +} diff --git a/src/components/lib/FlowGrid/FlowGrid.css b/src/components/lib/FlowGrid/FlowGrid.css index 769f9ca02..e1f3816f9 100644 --- a/src/components/lib/FlowGrid/FlowGrid.css +++ b/src/components/lib/FlowGrid/FlowGrid.css @@ -47,17 +47,22 @@ .FlowGridEmptyCell { width: 100%; display: flex; - cursor: pointer; width: 100%; } -.FlowGridCell.hovered .FlowGridEmptyCell{ - background-color: rgba(79, 152, 197, 0.25); -} +.FlowGrid.isSelectable { + .FlowGridEmptyCell { + cursor: pointer; + } -.FlowGridCell.hovered.hasItem .FlowGridFilledCell{ - background-color: rgba(75, 138, 177, 0.3); - border-radius: 1px; + .FlowGridCell.hovered .FlowGridEmptyCell{ + background-color: rgba(79, 152, 197, 0.25); + } + + .FlowGridCell.hovered.hasItem .FlowGridFilledCell{ + background-color: rgba(75, 138, 177, 0.3); + border-radius: 1px; + } } .FlowGridEmptyCell:focus { diff --git a/src/components/lib/FlowGrid/FlowGrid.jsx b/src/components/lib/FlowGrid/FlowGrid.jsx index b8df76488..a785a7dfb 100644 --- a/src/components/lib/FlowGrid/FlowGrid.jsx +++ b/src/components/lib/FlowGrid/FlowGrid.jsx @@ -6,8 +6,10 @@ import HTML5Backend from 'react-dnd-html5-backend' import Cell from './cell' import {BackgroundContainer} from './background-container' +import {allPropsPresent} from 'gEngine/utils' + import {keycodeToDirection, DirectionToLocation} from './utils' -import {getBounds, isLocation, isWithinRegion, isAtLocation, PTRegion, PTLocation} from 'lib/locationUtils' +import {getBounds, isLocation, isWithinRegion, isAtLocation, PTRegion, PTLocation, boundingRegion} from 'lib/locationUtils' import './FlowGrid.css' @@ -46,10 +48,12 @@ export default class FlowGrid extends Component{ onCopy: PropTypes.func.isRequired, onPaste: PropTypes.func.isRequired, showGridLines: PropTypes.bool, + isModelingCanvas: PropTypes.bool } static defaultProps = { showGridLines: true, + isModelingCanvas: true } state = { @@ -153,8 +157,7 @@ export default class FlowGrid extends Component{ let direction = keycodeToDirection(e.keyCode) if (direction) { e.preventDefault() - const size = ({columns: this._columnCount(), rows: this._rowCount()}) - let newLocation = new DirectionToLocation(size, this.props.selectedCell)[direction]() + let newLocation = new DirectionToLocation(this._size(), this.props.selectedCell)[direction]() this.props.onSelectItem(newLocation) } else if (!e.shiftKey && (e.keyCode == '17' || e.keyCode == '224' || e.keyCode == '91' || e.keyCode == '93')) { e.preventDefault() @@ -188,25 +191,19 @@ export default class FlowGrid extends Component{ this.props.onMultipleSelect(corner1, corner2) } - size() { - const lowestItem = !this.props.items.length ? 2 : Math.max(...this.props.items.map(g => parseInt(g.location.row))) + 2 - const selected = parseInt(this.props.selectedCell.row) + 2 - const height = Math.max(3, lowestItem, selected) || 3; - return {columns: this._columnCount(), rows: height} - } - - _rowCount() { - const lowestItem = Math.max(...this.props.items.map(e => parseInt(e.location.row))) + 4 - let selectedRow = this.props.selectedCell.row || 0 - const selected = parseInt(selectedRow) + 3 - return Math.max(10, lowestItem, selected) || 6; - } + _size() { + const [_1, {row: largestRow, column: largestColumn}] = boundingRegion(this.props.items.map(e => e.location)) + let [selectedRow, selectedColumn] = [0, 0] + if (allPropsPresent(this.props, 'selectedCell.row', 'selectedCell.column')) { + selectedRow = this.props.selectedCell.row + selectedColumn = this.props.selectedCell.column + } - _columnCount() { - const lowestItem = Math.max(...this.props.items.map(e => parseInt(e.location.column))) + 4 - let selectedColumn = this.props.selectedCell.column || 0 - const selected = parseInt(selectedColumn) + 4 - return Math.max(6, lowestItem, selected) || 6; + if (this.props.isModelingCanvas) { + return {rows: Math.max(16, largestRow + 4, selectedRow + 5), columns: Math.max(10, largestColumn + 4, selectedColumn + 1)} + } else { + return {rows: Math.max(1, largestRow + 1), columns: Math.max(1, largestColumn + 1)} + } } _addIfNeededAndSelect(location, direction) { @@ -307,11 +304,11 @@ export default class FlowGrid extends Component{ } render() { - const rowCount = this._rowCount() - const columnCount = this._columnCount() + const {rows, columns} = this._size() const {edges} = this.props let className = 'FlowGrid' className += this.props.showGridLines ? ' withLines' : '' + className += this.props.isModelingCanvas ? ' isSelectable' : '' return (
{ - upto(rowCount).map((row) => { + upto(rows).map((row) => { return (
- {this._row(row, columnCount)} + {this._row(row, columns)}
) }) @@ -339,7 +336,7 @@ export default class FlowGrid extends Component{
this.props.getRowHeight(i))}) - } - - componentWillReceiveProps(nextProps) { - this.setState({rowHeights: _.map(upto(nextProps.rowCount), (r, i) => nextProps.getRowHeight(i))}) + setRowHeights(params = this.props, {rowCount, getRowHeight} = params) { + this.setState({rowHeights: _.map(upto(rowCount), (_1, i) => getRowHeight(i))}) } + componentDidMount() { this.setRowHeights() } + componentWillReceiveProps(nextProps) { this.setRowHeights(nextProps) } shouldComponentUpdate(nextProps, nextState) { return ( @@ -65,11 +63,15 @@ export class BackgroundContainer extends Component { } else { return false } } + getColumnWidth() { + return $('.FlowGridCell') && $('.FlowGridCell')[0] ? $('.FlowGridCell')[0].offsetWidth : null + } + render() { const {edges, rowCount, getRowHeight, selectedRegion, copiedRegion, autoFillRegion, analyzedRegion} = this.props const {rowHeights} = this.state - const columnWidth = $('.FlowGridCell') && $('.FlowGridCell')[0] && $('.FlowGridCell')[0].offsetWidth + const columnWidth = this.getColumnWidth() if (!columnWidth || !rowHeights.length) { return false } const containerHeight = rowHeights.reduce((a,b) => a + b) diff --git a/src/components/metrics/card/MetricCardViewSection/index.js b/src/components/metrics/card/MetricCardViewSection/index.js index 98ee01d16..ab5d27a0f 100644 --- a/src/components/metrics/card/MetricCardViewSection/index.js +++ b/src/components/metrics/card/MetricCardViewSection/index.js @@ -6,7 +6,7 @@ import Histogram from 'gComponents/simulations/histogram/index' import MetricName from 'gComponents/metrics/card/name/index' import {DistributionSummary} from 'gComponents/distributions/summary/index' import StatTable from 'gComponents/simulations/stat_table/index' -import {MetricToken} from 'gComponents/metrics/card/token/index' +import {MetricReadableId, MetricReasoningIcon, MetricSidebarToggle, MetricExportedIcon} from 'gComponents/metrics/card/token/index' import SensitivitySection from 'gComponents/metrics/card/SensitivitySection/SensitivitySection' import {INTERNAL_ERROR, INFINITE_LOOP_ERROR, INPUT_ERROR} from 'lib/errors/modelErrors' @@ -75,6 +75,29 @@ export class MetricCardViewSection extends Component { return !!inputError ? inputError : this._errors().find(e => e.type !== INTERNAL_ERROR) } + renderToken() { + const { + canvasState: {metricClickMode}, + metric: {guesstimate: {description}, readableId}, + inSelectedCell, + hovered, + exportedAsFact, + onToggleSidebar, + } = this.props + + if ((metricClickMode === 'FUNCTION_INPUT_SELECT') && !inSelectedCell) { + return + } else if (hovered) { + return + } else if (exportedAsFact) { + return + } else if (!_.isEmpty(description)) { + return + } else { + return false + } + } + render() { const { canvasState: {metricCardView, metricClickMode}, @@ -86,7 +109,7 @@ export class MetricCardViewSection extends Component { onMouseDown, showSensitivitySection, hovered, - editing, + exportedAsFact, } = this.props const errors = this._errors() @@ -102,7 +125,7 @@ export class MetricCardViewSection extends Component { let className = `MetricCardViewSection ${metricCardView}` className += (hasErrors & !inSelectedCell) ? ' hasErrors' : '' className += (anotherFunctionSelected) ? ' anotherFunctionSelected' : '' - return( + return (
{(metricCardView !== 'basic') && showSimulation && - {(hovered || anotherFunctionSelected || hasGuesstimateDescription) && - - } +
+ { this.renderToken() } +
{(!_.isEmpty(metric.name) || inSelectedCell) && diff --git a/src/components/metrics/card/index.js b/src/components/metrics/card/index.js index df9938cd9..41dfcd75c 100644 --- a/src/components/metrics/card/index.js +++ b/src/components/metrics/card/index.js @@ -2,6 +2,7 @@ import React, {Component, PropTypes} from 'react' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' +import Icon from 'react-fa' import ReactDOM from 'react-dom' import $ from 'jquery' @@ -16,60 +17,52 @@ import {hasMetricUpdated} from './updated' import {removeMetrics, changeMetric} from 'gModules/metrics/actions' import {changeGuesstimate} from 'gModules/guesstimates/actions' import {analyzeMetricId, endAnalysis} from 'gModules/canvas_state/actions' +import {createFactFromMetric} from 'gModules/facts/actions' import * as canvasStateProps from 'gModules/canvas_state/prop_type' import {PTLocation} from 'lib/locationUtils' import {withReadableId} from 'lib/generateVariableNames/generateMetricReadableId' +import {shouldTransformName} from 'lib/generateVariableNames/nameToVariableName' import {INTERMEDIATE, OUTPUT, INPUT, NOEDGE, relationshipType} from 'gEngine/graph' import {makeURLsMarkdown} from 'gEngine/utils' import './style.css' -import Icon from 'react-fa' - const relationshipClasses = {} relationshipClasses[INTERMEDIATE] = 'intermediate' relationshipClasses[OUTPUT] = 'output' relationshipClasses[INPUT] = 'input' relationshipClasses[NOEDGE] = 'noedge' -class ScatterTip extends Component { - render() { - return ( - - - - ) - } -} - -const PT = PropTypes +const ScatterTip = ({yMetric, xMetric}) => ( + +) @connect(null, dispatch => bindActionCreators({ changeMetric, changeGuesstimate, removeMetrics, analyzeMetricId, - endAnalysis + endAnalysis, + createFactFromMetric, }, dispatch)) export default class MetricCard extends Component { displayName: 'MetricCard' static propTypes = { canvasState: canvasStateProps.canvasState, - changeMetric: PT.func.isRequired, - changeGuesstimate: PT.func.isRequired, - removeMetrics: PT.func.isRequired, - gridKeyPress: PT.func.isRequired, - inSelectedCell: PT.bool.isRequired, + changeMetric: PropTypes.func.isRequired, + changeGuesstimate: PropTypes.func.isRequired, + removeMetrics: PropTypes.func.isRequired, + gridKeyPress: PropTypes.func.isRequired, + inSelectedCell: PropTypes.bool.isRequired, location: PTLocation, - metric: PT.object.isRequired + metric: PropTypes.object.isRequired } state = { modalIsOpen: false, - editing: false, sidebarIsOpen: false, } @@ -87,10 +80,6 @@ export default class MetricCard extends Component { this.props.endAnalysis() } - onEdit() { - if (!this.state.editing) { this.setState({editing: true}) } - } - focusFromDirection(dir) { if (dir === 'DOWN' || dir === 'RIGHT') { this._focusForm() } else { this.refs.MetricCardViewSection.focusName() } @@ -98,7 +87,6 @@ export default class MetricCard extends Component { componentWillUpdate(nextProps) { window.recorder.recordRenderStartEvent(this) - if (this.state.editing && !nextProps.inSelectedCell) { this.setState({editing: false}) } if (this.props.inSelectedCell && !nextProps.inSelectedCell) { this._closeSidebar() } if (this.props.hovered && !nextProps.hovered){ this._closeSidebar() } } @@ -229,10 +217,12 @@ export default class MetricCard extends Component { return (this._isSelectable(e) && (this.props.canvasState.metricClickMode === 'FUNCTION_INPUT_SELECT')) } + _relationshipType() { return relationshipType(_.get(this, 'props.metric.edges')) } + _className() { - const {inSelectedCell, metric, hovered} = this.props + const {inSelectedCell, hovered} = this.props const {canvasState: {metricCardView}} = this.props - const relationshipClass = relationshipClasses[relationshipType(metric.edges)] + const relationshipClass = relationshipClasses[this._relationshipType()] const titleView = !hovered && !inSelectedCell && this._isTitle() let className = inSelectedCell ? 'metricCard grid-item-focus' : 'metricCard' @@ -262,6 +252,10 @@ export default class MetricCard extends Component { return !!analyzedMetric && metric.id === analyzedMetric.id } + _makeFact() { + this.props.createFactFromMetric(this.props.organizationId, this.props.metric) + } + // If sidebar is expanded, we want to close it if anything else is clicked onMouseDown(e){ const isSidebarElement = (_.get(e, 'target.dataset.controlSidebar') === "true") @@ -281,11 +275,16 @@ export default class MetricCard extends Component { connectDragSource, analyzedMetric, forceFlowGridUpdate, + exportedAsFact, } = this.props - const {guesstimate} = metric + const {guesstimate, name} = metric + const {metricClickMode} = canvasState const shouldShowSensitivitySection = this._shouldShowSensitivitySection() const isAnalyzedMetric = this._isAnalyzedMetric() + const isFunction = _.get(metric, 'guesstimate.guesstimateType') === 'FUNCTION' + const canBeMadeFact = shouldTransformName(name) && isFunction && canUseOrganizationFacts + return (
} @@ -320,17 +324,18 @@ export default class MetricCard extends Component { showSensitivitySection={shouldShowSensitivitySection} heightHasChanged={forceFlowGridUpdate} hovered={hovered} - editing={this.state.editing} onEscape={this.focus.bind(this)} onReturn={this.props.onReturn} onTab={this.props.onTab} + exportedAsFact={exportedAsFact} /> - {inSelectedCell && + {inSelectedCell && !this.state.modalIsOpen &&
} @@ -357,15 +361,28 @@ export default class MetricCard extends Component { showAnalysis={this._canBeAnalyzed()} onBeginAnalysis={this._beginAnalysis.bind(this)} onEndAnalysis={this._endAnalysis.bind(this)} + canBeMadeFact={canBeMadeFact} + exportedAsFact={exportedAsFact} + onMakeFact={this._makeFact.bind(this)} isAnalyzedMetric={isAnalyzedMetric} /> }
- ); + ) } } -const MetricSidebar = ({onOpenModal, onBeginAnalysis, onEndAnalysis, onRemoveMetric, showAnalysis, isAnalyzedMetric}) => ( +const MetricSidebar = ({ + onOpenModal, + onBeginAnalysis, + onEndAnalysis, + canBeMadeFact, + exportedAsFact, + onMakeFact, + onRemoveMetric, + showAnalysis, + isAnalyzedMetric +}) => (
} @@ -387,6 +404,13 @@ const MetricSidebar = ({onOpenModal, onBeginAnalysis, onEndAnalysis, onRemoveMet onClick={onEndAnalysis} /> } + {canBeMadeFact && !exportedAsFact && + } + name={'Export'} + onClick={onMakeFact} + /> + } } name={'Delete'} diff --git a/src/components/metrics/card/style.css b/src/components/metrics/card/style.css index 34ec8839f..ae031e455 100644 --- a/src/components/metrics/card/style.css +++ b/src/components/metrics/card/style.css @@ -74,7 +74,7 @@ .MetricSidebar { position: absolute; top: 4px; - right: -8.2em; + right: -10.2em; z-index: 3; } @@ -82,7 +82,7 @@ a.MetricSidebarItem { clear: both; display: block; float: left; - width: 8em; + width: 10em; padding: .4em .7em; color: white; background: #52646f; diff --git a/src/components/metrics/card/token/index.js b/src/components/metrics/card/token/index.js index af33d7ba4..624e7e4bb 100644 --- a/src/components/metrics/card/token/index.js +++ b/src/components/metrics/card/token/index.js @@ -4,13 +4,9 @@ import Icon from 'react-fa' import './style.css' -const MetricReadableId = ({readableId}) => ( -
- {readableId} -
-) - -const MetricExpandButton = ({onToggleSidebar}) => ( +export const MetricReadableId = ({readableId}) =>
{readableId}
+export const MetricReasoningIcon = () => +export const MetricSidebarToggle = ({onToggleSidebar}) => ( ( ) - -const MetricReasoningIcon = () => ( - - - -) - -export const MetricToken = ({anotherFunctionSelected, readableId, onToggleSidebar, hasGuesstimateDescription}) => ( -
- {anotherFunctionSelected && } - {!anotherFunctionSelected && } - {!anotherFunctionSelected && hasGuesstimateDescription && } +export const MetricExportedIcon = () => ( +
+
+
+ +
) diff --git a/src/components/metrics/card/token/style.css b/src/components/metrics/card/token/style.css index c06613ce4..269984f8c 100644 --- a/src/components/metrics/card/token/style.css +++ b/src/components/metrics/card/token/style.css @@ -47,3 +47,21 @@ .hovered .MetricToken .hover-toggle { display: block } + +.MetricToken--Corner-Triangle { + height: 0; + width: 0; + border-top: 36px solid #9fabb3; + border-left: 36px solid transparent; + position: absolute; + top: 0; + right: 0; +} + +.MetricToken--Corner-Item { + position: absolute; + top: 1px; + right: 3px; + color: white; + font-size: 1.1em; +} diff --git a/src/components/metrics/card/updated.js b/src/components/metrics/card/updated.js index 15ae0c421..ec72b8a56 100644 --- a/src/components/metrics/card/updated.js +++ b/src/components/metrics/card/updated.js @@ -12,6 +12,7 @@ export function hasMetricUpdated(oldProps, newProps) { _.get(oldProps, 'metric.simulation.propagationId') !== _.get(newProps, 'metric.simulation.propagationId') || !!oldProps.metric.guesstimate !== !!newProps.metric.guesstimate || oldProps.metric.guesstimate.description !== newProps.metric.guesstimate.description || - oldProps.metric.guesstimate.guesstimateType !== newProps.metric.guesstimate.guesstimateType + oldProps.metric.guesstimate.guesstimateType !== newProps.metric.guesstimate.guesstimateType || + oldProps.exportedAsFact !== newProps.exportedAsFact ) } diff --git a/src/components/metrics/modal/index.js b/src/components/metrics/modal/index.js index 4aee587aa..e399e20e9 100644 --- a/src/components/metrics/modal/index.js +++ b/src/components/metrics/modal/index.js @@ -46,7 +46,15 @@ export class MetricModal extends Component { render() { const showSimulation = this.showSimulation() - const {closeModal, metric, errors, onChangeGuesstimateDescription} = this.props + const { + closeModal, + metric, + organizationId, + canUseOrganizationFacts, + metricClickMode, + errors, + onChangeGuesstimateDescription, + } = this.props const sortedSampleValues = _.get(metric, 'simulation.sample.sortedValues') const stats = _.get(metric, 'simulation.stats') const guesstimate = metric.guesstimate @@ -91,9 +99,14 @@ export class MetricModal extends Component {
diff --git a/src/components/organizations/show/categories/category.js b/src/components/organizations/show/categories/category.js new file mode 100644 index 000000000..96b632290 --- /dev/null +++ b/src/components/organizations/show/categories/category.js @@ -0,0 +1,96 @@ +import React, {Component, PropTypes} from 'react' +import {connect} from 'react-redux' + +import {CategoryForm} from './form' +import {FactListContainer} from 'gComponents/facts/list/container.js' + +import '../style.css' + +class CategoryHeader extends Component { + state = { + editing: false, + hovering: false, + } + + onEnter() { this.setState({hovering: true}) } + onLeave() { this.setState({hovering: false}) } + onStartEditing() { this.setState({editing: true}) } + onStopEditing() { this.setState({editing: false}) } + onSaveEdits(editedCategory) { + this.props.onEdit(editedCategory) + this.onStopEditing() + } + onDelete() { this.props.onDelete(this.props.category) } + + renderEditHeader() { + return ( + + ) + } + + renderShowHeader() { + const {category, onEditCategory} = this.props + return ( +
+

{category.name}

+
+ {!!this.state.hovering && +
+ + Edit + + + Delete + +
+ } +
+
+ ) + } + + render() { + return ( +
+ {!!this.state.editing ? this.renderEditHeader() : this.renderShowHeader() } +
+ ) + } +} + +const NullCategoryHeader = ({}) => ( +
+

Uncategorized

+
+) + +export const Category = ({category, categories, facts, onEditCategory, onDeleteCategory, organization, existingVariableNames}) => ( +
+ {!!category && + c.name)} + onEdit={onEditCategory} + onDelete={onDeleteCategory} + /> + } + {!category && } + +
+) diff --git a/src/components/organizations/show/categories/form.js b/src/components/organizations/show/categories/form.js new file mode 100644 index 000000000..1f96f8bbb --- /dev/null +++ b/src/components/organizations/show/categories/form.js @@ -0,0 +1,64 @@ +import React, {Component, PropTypes} from 'react' + +import {FactCategoryPT, isFactCategoryValid} from 'gEngine/fact_category' + +export class CategoryForm extends Component { + // TODO(matthew): We have wiring (via props) for onCancel, but no button. Either strip that code or add cancellation + // buttons. + static defaultProps = { + startingCategory: { + name: '', + }, + } + + static propTypes = { + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func, + existingCategoryNames: PropTypes.arrayOf(PropTypes.string).isRequired, + startingCategory: FactCategoryPT, + } + + state = { + runningCategory: this.props.startingCategory, + } + + setCategoryState(newCategoryState) { this.setState({runningCategory: {...this.state.runningCategory, ...newCategoryState}}) } + onChangeName(e) { this.setCategoryState({name: e.target.value}) } + isValid() { + const {props: {existingCategoryNames, startingCategory}, state: {runningCategory}} = this + return isFactCategoryValid(runningCategory, existingCategoryNames.filter(n => n !== startingCategory.name)) + } + onSubmit() { + if (!this.isValid()) { return } + + this.props.onSubmit(this.state.runningCategory) + this.setCategoryState(this.props.startingCategory) + } + + render() { + return ( +
+
+
+

+ +

+
+
+
+ + Save + +
+
+ ) + } +} diff --git a/src/components/organizations/show/facts/factGraph.js b/src/components/organizations/show/facts/factGraph.js new file mode 100644 index 000000000..1920af977 --- /dev/null +++ b/src/components/organizations/show/facts/factGraph.js @@ -0,0 +1,199 @@ +import React, {Component} from 'react' +import {FactItem} from 'gComponents/facts/list/item.js' + +import FlowGrid from 'gComponents/lib/FlowGrid/FlowGrid' + +import * as _collections from 'gEngine/collections' +import * as _utils from 'gEngine/utils' +import * as _space from 'gEngine/space' +import SpaceListItem from 'gComponents/spaces/list_item/index.js' +import {SpaceCard, NewSpaceCard} from 'gComponents/spaces/cards' + +import './style.css' + +const allParentsWithin = nodeSet => n => _.every(n.parents, p => _.some(nodeSet, ({id}) => p === id)) +const anyParentsWithin = nodeSet => n => _.some(n.parents, p => _.some(nodeSet, ({id}) => p === id)) +const anyChildrenWithin = nodeSet => n => _.some(n.children, c => _.some(nodeSet, ({id}) => c === id)) +const anyRelativesWithin = nodeSet => n => anyParentsWithin(nodeSet)(n) || anyChildrenWithin(nodeSet)(n) + +function separateIntoHeightsAndStripInfiniteLoops(nodes) { + let unprocessedNodes = _utils.mutableCopy(nodes) + let heightOrderedNodes = [] + while (!_.isEmpty(unprocessedNodes)) { + const nextLevelNodes = _.remove(unprocessedNodes, allParentsWithin(_.flatten(heightOrderedNodes))) + if (_.isEmpty(nextLevelNodes)) { break } + heightOrderedNodes.push(nextLevelNodes) + } + return heightOrderedNodes +} + +function separateIntoDisconnectedComponents(nodes) { + if (_.isEmpty(nodes)) { return [] } + let unprocessedNodes = _utils.mutableCopy(nodes) + let components = [] + let currentComponent = _.pullAt(unprocessedNodes, [0]) + while (!_.isEmpty(unprocessedNodes)) { + const newComponentNodes = _.remove(unprocessedNodes, anyRelativesWithin(currentComponent)) + if (_.isEmpty(newComponentNodes)) { + components.push(currentComponent) + currentComponent = _.pullAt(unprocessedNodes, [0]) + } else { + currentComponent.push(...newComponentNodes) + } + } + components.push(currentComponent) + return components +} + +const idToNodeId = (id, isFact) => `${isFact ? 'fact' : 'space'}:${id}` +const spaceIdToNodeId = ({id}) => idToNodeId(id, false) +const factIdToNodeId = ({id}) => idToNodeId(id, true) + +const makeFactNodeFn = spaces => fact => ({ + key: factIdToNodeId(fact), + id: factIdToNodeId(fact), + children: spaces.filter(s => _utils.orArr(s.imported_fact_ids).includes(fact.id)).map(spaceIdToNodeId), + parents: !!fact.exported_from_id ? [idToNodeId(fact.exported_from_id, false)] : [], + component: , +}) +const makeSpaceNodeFn = facts => s => ({ + key: spaceIdToNodeId(s), + id: spaceIdToNodeId(s), + parents: s.imported_fact_ids.map(id => idToNodeId(id, true)), + children: _collections.filter(facts, s.id, 'exported_from_id').map(factIdToNodeId), + component: +}) + +const addLocationsToHeightOrderedComponents = componentsHeightOrdered => { + let withFinalLocations = [] + let maxRowUsed = 0 + componentsHeightOrdered.forEach(heightOrderedComponent => { + let sortedHeightOrderedNodes = [] + let currColumn = 0 + let maxRowUsedInComponent = maxRowUsed + heightOrderedComponent.forEach(heightSet => { + const prevLayer = _utils.orArr(_.last(sortedHeightOrderedNodes)) + let newLayer = _utils.mutableCopy(heightSet) + let newLayerOrdered = [] + prevLayer.filter(n => !_.isEmpty(n.children)).forEach(n => { + const children = _.remove(newLayer, ({id}) => n.children.includes(id)) + const childrenSorted = _.sortBy(children, c => -c.children.length) + newLayerOrdered.push(...childrenSorted) + }) + const restSorted = _.sortBy(newLayer, n => -n.children.length) + newLayerOrdered.push(...restSorted) + + let currRow = maxRowUsed + const withLocations = _.map(newLayerOrdered, node => { + const withLocation = { + ...node, + location: {row: currRow, column: currColumn}, + } + if (node.children.length > 3) { + currRow += 2 + } else { + currRow += 1 + } + return withLocation + }) + maxRowUsedInComponent = Math.max(currRow, maxRowUsedInComponent) + + if (newLayerOrdered.length > 3) { + currColumn += 2 + } else { + currColumn += 1 + } + + sortedHeightOrderedNodes.push(withLocations) + }) + maxRowUsed = maxRowUsedInComponent + 1 + withFinalLocations.push(..._.flatten(sortedHeightOrderedNodes)) + }) + return {withFinalLocations, maxRowUsed} +} + +export class FactGraph extends Component { + itemsAndEdges() { + const {facts, spaces} = this.props + + let factNodes = _.map(facts, makeFactNodeFn(spaces)) + + const spacesToDisplay = _.filter(spaces, s => s.exported_facts_count > 0 || !_.isEmpty(s.imported_fact_ids)) + const spaceNodes = _.map(spacesToDisplay, makeSpaceNodeFn(facts)) + + // Here we remove some facts from the set of fact nodes, to display them separately, outside the rest of the graph. + // In particular, we remove facts that are isolated (i.e. have no parents or children) and orphaned facts, which are + // facts that are missing parents, due to missing deletions or other abnormal data setups. We don't want to + // render those facts within the main graph, as they have no sensible edges we could display, so we pull them out to + // render with the isolated nodes at the bottom of the graph. + const isolatedFactNodes = _.remove(factNodes, n => _.isEmpty(n.children) && _.isEmpty(n.parents)) + const orphanedFactNodes = _.remove(factNodes, _.negate(allParentsWithin(spaceNodes))) + + const components = separateIntoDisconnectedComponents([...factNodes, ...spaceNodes]) + const componentsHeightOrdered = _.map(components, separateIntoHeightsAndStripInfiniteLoops) + + const {withFinalLocations, maxRowUsed} = addLocationsToHeightOrderedComponents(componentsHeightOrdered) + + // Now we add locations to the isolated facts. + const width = Math.floor(Math.sqrt(isolatedFactNodes.length + orphanedFactNodes.length)) + const isolatedFactNodesWithLocations = _.map([...isolatedFactNodes, ...orphanedFactNodes], (n, i) => ({ + ...n, + location: {row: maxRowUsed + 1 + Math.floor(i/width), column: i % width}, + })) + + const items = [...isolatedFactNodesWithLocations, ...withFinalLocations] + + const locationById = id => _collections.gget(items, id, 'id', 'location') + + let edges = [] + const pathStatus = 'default' + factNodes.forEach(({id, children, parents}) => { + edges.push(...children.map(c => ({input: locationById(id), inputId: id, output: locationById(c), outputId: c, pathStatus}))) + edges.push(...parents.map(p => ({input: locationById(p), inputId: p, output: locationById(id), outputId: id, pathStatus}))) + }) + + const bad_edges = _.remove(edges, edge => !_utils.allPropsPresent(edge, 'input.row', 'input.column', 'output.row', 'output.column')) + if (!_.isEmpty(bad_edges)) { + console.warn(bad_edges.length, 'BAD EDGES ENCOUNTERED!') + console.warn(bad_edges) + } + + return { items, edges } + } + + render() { + let {items, edges} = this.itemsAndEdges() + + return ( +
+ {}} + hasItemUpdated = {() => false} + isItemEmpty = {() => false} + edges={edges} + selectedRegion={[]} + copiedRegion={[]} + selectedCell={{}} + analyzedRegion={[]} + onUndo={() => {}} + onRedo={() => {}} + onSelectItem={() => {}} + onDeSelectAll={() => {}} + onAutoFillRegion={() => {}} + onAddItem={() => {}} + onMoveItem={() => {}} + onRemoveItems={() => {}} + onCopy={() => {}} + onPaste={() => {}} + onCut={() => {}} + showGridLines={false} + canvasState={{}} + isModelingCanvas={false} + /> +
+ ) + } +} diff --git a/src/components/organizations/show/facts/style.css b/src/components/organizations/show/facts/style.css new file mode 100644 index 000000000..95fe037dd --- /dev/null +++ b/src/components/organizations/show/facts/style.css @@ -0,0 +1,32 @@ +.FactGraph { + font-size: .8em; + display: flex; + flex-align: center; + justify-content: center; +} + +.spaceNode { + padding: 1em; + color: #435870; + font-size: 1.3em; + text-align: center; + background-color: white; + margin: 4px; + width: 100%; + background-color: #acbfcf; + border-radius: 1px; + transition: background-color .5s; + &:hover { + background-color: #82a1bb; + color: #283b50; + } +} + +.FactGraph .FlowGridCell.FlowGridCell { + width: 150px; + min-width: 150px; +} + +.FactGraph .FlowGridCell.hovered .FlowGridEmptyCell { + background-color: none; +} diff --git a/src/components/organizations/show/index.js b/src/components/organizations/show/index.js index faefbcc0c..748429ef6 100644 --- a/src/components/organizations/show/index.js +++ b/src/components/organizations/show/index.js @@ -5,10 +5,12 @@ import ReactDOM from 'react-dom' import Icon from 'react-fa' import {SpaceCard, NewSpaceCard} from 'gComponents/spaces/cards' - import Container from 'gComponents/utility/container/Container' import {MembersTab} from './members' +import {Category} from './categories/category' +import {CategoryForm} from './categories/form' import {FactListContainer} from 'gComponents/facts/list/container.js' +import {FactGraph} from './facts/factGraph' import {httpRequestSelector} from './httpRequestSelector' import {organizationSpaceSelector} from './organizationSpaceSelector' @@ -27,14 +29,16 @@ import './style.css' const MODEL_TAB = 'models' const MEMBERS_TAB = 'members' const FACT_BOOK_TAB = 'facts' +const FACT_GRAPH_TAB = 'fact-graph' -const isValidTabString = tabStr => [MODEL_TAB, MEMBERS_TAB, FACT_BOOK_TAB].includes(tabStr) +const isValidTabString = tabStr => [MODEL_TAB, MEMBERS_TAB, FACT_BOOK_TAB, FACT_GRAPH_TAB].includes(tabStr) function mapStateToProps(state) { return { me: state.me, organizations: state.organizations, organizationFacts: state.facts.organizationFacts, + globalFactCategories: state.factCategories, } } @@ -58,15 +62,10 @@ export default class OrganizationShow extends Component{ this.props.dispatch(spaceActions.fetch({organizationId: this.props.organizationId})) } - url(openTab) { - const organization = this.props.organizations.find(u => u.id.toString() === this.props.organizationId.toString()) - const base = e.organization.url(organization) - if (_.isEmpty(base)) { return '' } - return `${base}/${openTab}` - } + organization() { return e.collections.get(this.props.organizations, this.props.organizationId) } changeTab(openTab) { - navigate(this.url(openTab), {trigger: false}) + navigate(`${e.organization.url(this.organization())}/${openTab}`, {trigger: false}) this.setState({openTab}) } @@ -78,6 +77,16 @@ export default class OrganizationShow extends Component{ this.props.dispatch(userOrganizationMembershipActions.destroy(membershipId)) } + onAddCategory(newCategory) { + this.props.dispatch(organizationActions.addFactCategory(this.organization(), newCategory)) + } + onEditCategory(editedCategory) { + this.props.dispatch(organizationActions.editFactCategory(this.organization(), editedCategory)) + } + onDeleteCategory(category) { + this.props.dispatch(organizationActions.deleteFactCategory(this.organization(), category)) + } + onRemove(member) { this.confirmRemove(member) } @@ -93,19 +102,29 @@ export default class OrganizationShow extends Component{ this.props.dispatch(modalActions.openConfirmation({onConfirm: removeCallback, message})) } - render () { - const {organizationId, organizations, organizationFacts, members, memberships, invitations} = this.props - const {openTab} = this.state + render() { + const { + props: {organizationId, organizations, organizationFacts, members, memberships, invitations, globalFactCategories}, + state: {openTab}, + } = this + const factCategories = e.collections.filter(globalFactCategories, organizationId, 'organization_id') const spaces = _.orderBy(this.props.organizationSpaces.asMutable(), ['updated_at'], ['desc']) - const organization = organizations.find(u => u.id.toString() === organizationId.toString()) + const organization = this.organization() const hasPrivateAccess = e.organization.hasPrivateAccess(organization) - const facts = _.get(organizationFacts.find(f => f.variable_name === `organization_${organizationId}`), 'children') || [] + const facts = e.organization.findFacts(organizationId, organizationFacts) const meIsAdmin = !!organization && (organization.admin_id === this.props.me.id) const meIsMember = meIsAdmin || !!(members.find(m => m.id === this.props.me.id)) if (!organization) { return false } let tabs = [{name: 'Models', key: MODEL_TAB}, {name: 'Members', key: MEMBERS_TAB}] - if (hasPrivateAccess) { tabs = [{name: 'Models', key: MODEL_TAB}, {name: 'Facts', key: FACT_BOOK_TAB}, {name: 'Members', key: MEMBERS_TAB}] } + if (hasPrivateAccess) { + tabs = [ + {name: 'Models', key: MODEL_TAB}, + {name: 'Facts', key: FACT_BOOK_TAB}, + {name: 'Fact Graph', key: FACT_GRAPH_TAB}, + {name: 'Members', key: MEMBERS_TAB} + ] + } const portalUrl = _.get(organization, 'account._links.payment_portal.href') if (!!portalUrl) { tabs = [...tabs, {name: 'Billing', key: 'BILLING', href: portalUrl, onMouseUp: this.refreshData.bind(this)}] } @@ -154,7 +173,22 @@ export default class OrganizationShow extends Component{ } {(openTab === FACT_BOOK_TAB) && meIsMember && !!facts && - + + } + + {(openTab === FACT_GRAPH_TAB) && meIsMember && !!facts && + }
@@ -206,19 +240,71 @@ const OrganizationTabButtons = ({tabs, openTab, changeTab}) => (
) -const FactTab = ({organizationId}) => ( -
-
-

Private Organizational Facts

-

Facts can be used in private organization models by referencing them with '#' symbols.

-
- -
-
-
- +const FactTab = ({ + organization, + facts, + factCategories, + onAddCategory, + onEditCategory, + onDeleteCategory, +}) => { + const categorySets = [ + ..._.map(factCategories, c => ({ + category: c, + facts: e.collections.filter(facts, c.id, 'category_id'), + })), + { + category: null, + facts: _.filter(facts, f => !f.category_id), + }, + ] + const existingVariableNames = facts.map(e.facts.getVar) + const existingCategoryNames = _.map(factCategories, c => c.name) + + return ( +
+
+ {_.map(categorySets, ({category, facts}) => ( +
+ +
+ ))} +
+
+
+ +
-
-) + ) +} + +class NewCategorySection extends Component{ + state = { + showForm: false + } + + onSubmit(name) { + this.setState({showForm: false}) + this.props.onSubmit(name) + } + render() { + if (this.state.showForm){ + return () + } else { + return (
this.setState({showForm: true})}> New Category
) + } + } +} diff --git a/src/components/organizations/show/style.css b/src/components/organizations/show/style.css index b20e42779..20b880670 100644 --- a/src/components/organizations/show/style.css +++ b/src/components/organizations/show/style.css @@ -58,5 +58,18 @@ font-size: 1.1em; color: #666; } + + .Category { + margin-bottom: 3em; + + h3{ + color: #888; + font-size: 1.4em; + font-style: italic; + font-weight: 200; + margin-bottom: .3em; + margin-top: .5em; + } + } } } diff --git a/src/components/spaces/canvas/index.js b/src/components/spaces/canvas/index.js index 2950a8140..8f12129cd 100644 --- a/src/components/spaces/canvas/index.js +++ b/src/components/spaces/canvas/index.js @@ -14,6 +14,8 @@ import * as canvasStateActions from 'gModules/canvas_state/actions' import {undo, redo} from 'gModules/checkpoints/actions' import {fillRegion} from 'gModules/auto_fill_region/actions' +import * as _collections from 'gEngine/collections' + import {hasMetricUpdated} from 'gComponents/metrics/card/updated' import * as canvasStateProps from 'gModules/canvas_state/prop_type' @@ -134,7 +136,7 @@ export default class Canvas extends Component{ } renderMetric(metric, analyzed) { - const {location} = metric + const {location, id} = metric const analyzedSamples = _.get(analyzed, 'simulation.sample.values') const hasAnalyzed = analyzed && metric && analyzedSamples && !_.isEmpty(analyzedSamples) @@ -146,10 +148,12 @@ export default class Canvas extends Component{ const is_private = _.get(this, 'props.denormalizedSpace.is_private') const organizationId = _.get(this, 'props.denormalizedSpace.organization_id') - const canUseOrganizationFacts = !!is_private && !!this.props.organizationHasFacts && !!organizationId + const canUseOrganizationFacts = !!_.get(this, 'props.canUseOrganizationFacts') + + const existingReadableIds = _.get(this, 'props.denormalizedSpace.metrics').map(m => m.readableId) + + const exportedAsFact = _collections.some(_.get(this, 'props.exportedFacts'), id, 'metric_id') - const metrics = _.get(this, 'props.denormalizedSpace.metrics') - const existingReadableIds = metrics.map(m => m.readableId) return ( ) diff --git a/src/components/spaces/cards/index.js b/src/components/spaces/cards/index.js index f914334f6..4181839e5 100644 --- a/src/components/spaces/cards/index.js +++ b/src/components/spaces/cards/index.js @@ -56,22 +56,24 @@ export const NewSpaceCard = ({onClick}) => (
) -export const SpaceCard = ({space, showPrivacy}) => { +export const SpaceCard = ({space, showPrivacy, size, urlParams = {}}) => { const hasName = !_.isEmpty(space.name) const hasOrg = _.has(space, 'organization.name') const owner = hasOrg ? space.organization : space.user const ownerUrl = hasOrg ? Organization.url(space.organization) : User.url(space.user) - const spaceUrl = Space.url(space) - const navigateToSpace = () => {navigationActions.navigate(spaceUrl)} + const spaceUrl = Space.spaceUrlById(space.id, urlParams) + const navigateToSpace = navigationActions.navigateFn(spaceUrl) return ( -
+

{hasName ? space.name : 'Untitled Model'}

-
Updated {formatDate(space.updated_at)}
+ {size !== 'SMALL' && +
Updated {formatDate(space.updated_at)}
+ }
@@ -84,9 +86,11 @@ export const SpaceCard = ({space, showPrivacy}) => { showPrivacy={showPrivacy} />
-
-

{formatDescription(space.description)}

-
+ {size !== 'SMALL' && +
+

{formatDescription(space.description)}

+
+ }
) diff --git a/src/components/spaces/cards/style.css b/src/components/spaces/cards/style.css index 9874fc8a4..37aa719c7 100644 --- a/src/components/spaces/cards/style.css +++ b/src/components/spaces/cards/style.css @@ -32,6 +32,31 @@ } } +.SpaceCard.SMALL { + width: 100%; + + .image { + flex: 1; + min-height: 3em; + border-radius: 0 0 1px 1px; + } + + .SpaceCard--inner { + margin: 0 .8em; + } + + .image .snapshot.blank img{ + height: 85%; + opacity: .2; + margin-bottom: 0; + max-height: 4em; + } + + .header h3 { + font-size: 1.2em; + } +} + .SpaceCard--inner { margin: 1.5em .8em; float: left; diff --git a/src/components/spaces/denormalized-space-selector.js b/src/components/spaces/denormalized-space-selector.js index c920bbad7..6103ff7e0 100644 --- a/src/components/spaces/denormalized-space-selector.js +++ b/src/components/spaces/denormalized-space-selector.js @@ -47,12 +47,11 @@ export const denormalizedSpaceSelector = createSelector( } const {organization_id} = denormalizedSpace - const organization = graph.organizations.find(o => o.id == organization_id) - const organizationHasFacts = !!organization && _.some( - organizationFacts, e.facts.byVariableName(e.organization.organizationReadableId(organization)) - ) + const facts = e.organization.findFacts(organization_id, organizationFacts) + + const exportedFacts = e.collections.filter(facts, spaceId, 'exported_from_id') window.recorder.recordSelectorStop(NAME, {denormalizedSpace}) - return { denormalizedSpace, organizationHasFacts } + return { denormalizedSpace, exportedFacts, organizationFacts: facts, organizationHasFacts: !_.isEmpty(facts) } } ) diff --git a/src/components/spaces/show/header.js b/src/components/spaces/show/header.js index 0a3dbcde5..2f0584881 100644 --- a/src/components/spaces/show/header.js +++ b/src/components/spaces/show/header.js @@ -105,7 +105,6 @@ export class SpaceHeader extends Component { {ownerName} } - {isPrivate && editableByMe && } - - {(isPrivate || editableByMe) && + {ownerIsOrg && } + {editableByMe && {privacy_header}} diff --git a/src/components/spaces/show/index.js b/src/components/spaces/show/index.js index 8e295d8b6..d0421352e 100644 --- a/src/components/spaces/show/index.js +++ b/src/components/spaces/show/index.js @@ -102,7 +102,7 @@ export default class SpacesShow extends Component { showTutorial: !!_.get(this, 'props.me.profile.needs_tutorial'), attemptedFetch: false, rightSidebar: { - type: !!this.props.showCalculatorId ? SHOW_CALCULATOR : CLOSED, + type: !!this.props.showCalculatorId ? SHOW_CALCULATOR : !!this.props.factsShown ? FACT_SIDEBAR : CLOSED, showCalculatorResults: this.props.showCalculatorResults, showCalculatorId: this.props.showCalculatorId, }, @@ -240,7 +240,7 @@ export default class SpacesShow extends Component { return parseInt(this.props.spaceId) } - canShowFactSidebar() { + canUseOrganizationFacts() { const organization = _.get(this, 'props.denormalizedSpace.organization') if (!organization) { return false } @@ -265,15 +265,16 @@ export default class SpacesShow extends Component { } makeNewCalculator() { this.openRightSidebar({type: NEW_CALCULATOR_FORM}) } toggleFactSidebar() { - if (this.canShowFactSidebar()) { - if (this.state.rightSidebar.type !== FACT_SIDEBAR){ this.openRightSidebar({type: FACT_SIDEBAR}) } - else { this.closeRightSidebar() } - } + if (this.state.rightSidebar.type !== FACT_SIDEBAR) { this.openRightSidebar({type: FACT_SIDEBAR}) } + else { this.closeRightSidebar() } } rightSidebarBody() { - const {props: {denormalizedSpace}, state: {rightSidebar: {type, showCalculatorResults, showCalculatorId, editCalculatorId}}} = this - const {editableByMe, calculators, organization} = denormalizedSpace + const { + props: {denormalizedSpace, spaceId, organizationFacts}, + state: {rightSidebar: {type, showCalculatorResults, showCalculatorId, editCalculatorId}}, + } = this + const {editableByMe, calculators, organization, imported_fact_ids} = denormalizedSpace switch (type) { case CLOSED: return {} @@ -325,7 +326,14 @@ export default class SpacesShow extends Component { ), main: (
- +
), } @@ -346,10 +354,11 @@ export default class SpacesShow extends Component { } render() { + const {exportedFacts, organizationHasFacts, me} = this.props const space = this.props.denormalizedSpace + if (!e.space.prepared(space)) { return
} - const {organizationHasFacts, me} = this.props const sidebarIsViseable = space.editableByMe || !_.isEmpty(space.description) const isLoggedIn = e.me.isLoggedIn(this.props.me) const shareableLinkUrl = e.space.urlWithToken(space) @@ -357,7 +366,13 @@ export default class SpacesShow extends Component { if (this.props.embed) { return (
- +
) } @@ -445,7 +460,7 @@ export default class SpacesShow extends Component { makeNewCalculator={this.makeNewCalculator.bind(this)} showCalculator={this.showCalculator.bind(this)} toggleFactSidebar={this.toggleFactSidebar.bind(this)} - canShowFactSidebar={this.canShowFactSidebar()} + canShowFactSidebar={this.canUseOrganizationFacts()} onOpenTutorial={this.openTutorial.bind(this)} />
@@ -463,7 +478,8 @@ export default class SpacesShow extends Component { } { describe('URL_REGEX', () => { const fullStrRegex = new RegExp(`^${URL_REGEX.source}$`, 'i') - console.log(fullStrRegex) it('correctly matches urls', () => { validURLs.forEach(url => expect(fullStrRegex.test(url), `URL: ${url} should match`).to.equal(true)) errorURLs.forEach(url => expect(fullStrRegex.test(url), `URL: ${url} should not match`).to.equal(false)) diff --git a/src/lib/engine/fact_category.js b/src/lib/engine/fact_category.js new file mode 100644 index 000000000..c33df43b0 --- /dev/null +++ b/src/lib/engine/fact_category.js @@ -0,0 +1,8 @@ +import {PropTypes} from 'react' + +export const FactCategoryPT = PropTypes.shape({ + id: PropTypes.number, + name: PropTypes.string.isRequired, +}) + +export const isFactCategoryValid = ({name}, existingNames) => !_.isEmpty(name) && !existingNames.includes(name) diff --git a/src/lib/engine/facts.js b/src/lib/engine/facts.js index c64f2298b..aab8c082a 100644 --- a/src/lib/engine/facts.js +++ b/src/lib/engine/facts.js @@ -14,7 +14,9 @@ import {sortDescending} from 'lib/dataAnalysis' export const FactPT = PropTypes.shape({ name: PropTypes.string.isRequired, variable_name: PropTypes.string.isRequired, - expression: PropTypes.string.isRequired, + expression: PropTypes.string, + exported_from_id: PropTypes.number, + metric_id: PropTypes.string, simulation: PropTypes.shape({ sample: PropTypes.shape({ values: PropTypes.arrayOf(PropTypes.number).isRequired, @@ -37,13 +39,33 @@ export const FactPT = PropTypes.shape({ export const GLOBALS_ONLY_REGEX = /@\w+(?:\.\w+)?/g export const HANDLE_REGEX = /(?:@\w+(?:\.\w+)?|#\w+)/g -export const getVar = f => _.get(f, 'variable_name') || '' +export const getVar = f => _utils.orStr(_.get(f, 'variable_name')) export const byVariableName = name => f => getVar(f) === name const namedLike = partial => f => getVar(f).startsWith(partial) -export function withSortedValues(rawFact) { - let fact = Object.assign({}, rawFact) +export const isExportedFromSpace = f => _utils.allPropsPresent(f, 'exported_from_id') + +export const length = f => _.get(f, 'simulation.sample.values.length') +export const mean = f => _.get(f, 'simulation.stats.mean') +export const adjustedConfidenceInterval = f => _.get(f, 'simulation.stats.adjustedConfidenceInterval') + +export function hasRequiredProperties(f) { + let requiredProperties = ['variable_name', 'name'] + if (!isExportedFromSpace(f)) { requiredProperties.push('expression', 'simulation.sample.values', 'simulation.stats') } + + return _utils.allPresent(requiredProperties.map(prop => _.get(f, prop))) +} + +export function withMissingStats(rawFact) { + let fact = _utils.mutableCopy(rawFact) _.set(fact, 'simulation.sample.sortedValues', sortDescending(_.get(fact, 'simulation.sample.values'))) + + const length = _.get(fact, 'simulation.stats.length') + const needsACI = _.isFinite(length) && length > 1 + const ACIlength = _.get('simulation.stats.adjustedConfidenceInterval.length') + const hasACI = _.isFinite(ACIlength) && ACIlength === 2 + if (needsACI && !hasACI) { _.set(fact, 'simulation.stats.adjustedConfidenceInterval', [null, null]) } + return fact } @@ -87,15 +109,17 @@ export const getFactsForOrg = (facts, org) => !org ? [] : _utils.orArr( ) export function getRelevantFactsAndReformatGlobals({metrics, guesstimates, simulations}, globalFacts, organizationFacts, spaceIds) { - const organizationFactsUsed = organizationFacts.filter( - f => _.some(guesstimates, g => _utils.orStr(g.expression).includes(_guesstimate.expressionSyntaxPad(f.id, false))) - ) const rawOrganizationFactsDefined = _collections.filterByInclusion(organizationFacts, 'exported_from_id', spaceIds) const organizationFactsDefined = rawOrganizationFactsDefined.map(f => ({ ...f, expression: `=${_guesstimate.expressionSyntaxPad(f.metric_id)}` })) + const organizationFactsUsed = organizationFacts.filter( + f => _.some(guesstimates, g => _utils.orStr(g.expression).includes(_guesstimate.expressionSyntaxPad(f.id, false))) + ) + const organizationFactsUsedDeDuped = organizationFactsUsed.filter(f => !_collections.some(organizationFactsDefined, f.id)) + // First we grab the top level global facts (e.g. the fact for 'Chicago') which contain as children subfacts of the // population variety. We'll next pre-resolve these into 'fake facts' momentarily. const globalFactContainersUsed = globalFacts.filter(f => _.some(guesstimates, g => _utils.orStr(g.expression).includes(f.variable_name))) @@ -105,7 +129,7 @@ export function getRelevantFactsAndReformatGlobals({metrics, guesstimates, simul variable_name: `@${f.variable_name}.population`, })) - return {organizationFactsUsed: [...organizationFactsUsed, ...organizationFactsDefined], globalFactsUsed} + return {organizationFactsUsed: [...organizationFactsUsedDeDuped, ...organizationFactsDefined], globalFactsUsed} } export function simulateFact(fact) { diff --git a/src/lib/engine/organization.js b/src/lib/engine/organization.js index ff2cae39b..3c606bc21 100644 --- a/src/lib/engine/organization.js +++ b/src/lib/engine/organization.js @@ -1,5 +1,6 @@ import * as _userOrganizationMemberships from './userOrganizationMemberships' import * as _collections from './collections' +import {orArr} from './utils' export const url = o => !!_.get(o, 'id') ? urlById(o.id) : '' export const image = o => _.isEmpty(_.get(o, 'picture')) ? '/organization-default-image.png' : o.picture @@ -10,7 +11,7 @@ export const organizationMemberships = (id, memberships) => _collections.filter( export const organizationInvitations = (id, invitations) => _collections.filter(invitations, id, 'organization_id') const ORG_FACT_READABLE_ID_PREFIX = 'organization_' -export const organizationReadableId = ({id}) => `${ORG_FACT_READABLE_ID_PREFIX}${id}` +export const organizationReadableId = o => !!o ? `${ORG_FACT_READABLE_ID_PREFIX}${o.id}` : '' export const organizationIdFromFactReadableId = str => str.slice(ORG_FACT_READABLE_ID_PREFIX.length) export function organizationUsers(organizationId, users, memberships) { @@ -24,6 +25,5 @@ export function organizationUsers(organizationId, users, memberships) { export function findFacts(organizationId, organizationFacts) { const readableId = organizationReadableId({id: organizationId}) - const containingFact = _collections.get(organizationFacts, readableId, 'variable_name') - return _.get(containingFact, 'children') || [] + return orArr(_collections.gget(organizationFacts, readableId, 'variable_name', 'children')) } diff --git a/src/lib/engine/simulation.js b/src/lib/engine/simulation.js index b4b94c8d9..c3700bc89 100644 --- a/src/lib/engine/simulation.js +++ b/src/lib/engine/simulation.js @@ -1,4 +1,5 @@ import * as _collections from './collections' +import {orArr} from './utils' import {sampleMean, sampleStdev, percentile, cutoff, sortDescending} from 'lib/dataAnalysis.js' @@ -46,4 +47,5 @@ export function addStats(simulation){ } export const hasErrors = simulation => errors(simulation).length > 0 -export const errors = simulation => _.get(simulation, 'sample.errors') || [] +export const errors = simulation => orArr(_.get(simulation, 'sample.errors')) +export const values = simulation => orArr(_.get(simulation, 'sample.values')) diff --git a/src/lib/engine/space.js b/src/lib/engine/space.js index 45ad2b14a..b02fd5121 100644 --- a/src/lib/engine/space.js +++ b/src/lib/engine/space.js @@ -10,9 +10,16 @@ import * as _facts from './facts' import * as _collections from './collections' import * as _utils from './utils' +export const spaceUrlById = (id, params = {}) => { + if (!id) { return '' } + + const paramString = _.isEmpty(params) ? '' : `?${_.toPairs(params).map(p => p.join('=')).join('&')}` + return `/models/${id}${paramString}` +} + +export const url = ({id}) => spaceUrlById(id) import {BASE_URL} from 'lib/constants' -export const url = ({id}) => (!!id) ? `/models/${id}` : '' export const urlWithToken = s => s.shareable_link_enabled ? `${BASE_URL}${url(s)}?token=${s.shareable_link_token}` : '' const TOKEN_REGEX = /token=([^&]+)/ @@ -21,7 +28,7 @@ export const extractTokenFromUrl = url => TOKEN_REGEX.test(url) ? url.match(TOKE export const withGraph = (space, graph) => ({...space, graph: subset(graph, space.id)}) export function prepared(dSpace) { - const ownerName = _utils.isPresent(_.get(dSpace, 'organization_id')) ? _.get(dSpace, 'organization.name') : _.get(dSpace, 'user.name') + const ownerName = _utils.allPropsPresent(dSpace, 'organization_id') ? _.get(dSpace, 'organization.name') : _.get(dSpace, 'user.name') return _utils.allPresent(dSpace, ownerName) } diff --git a/src/lib/engine/utils.js b/src/lib/engine/utils.js index 6a11e2cd2..0706104f1 100644 --- a/src/lib/engine/utils.js +++ b/src/lib/engine/utils.js @@ -4,15 +4,17 @@ import * as _collections from './collections' export const URL_REGEX = /(?:(?:https?|ftp):\/\/)?(?:\S+(?::\S*)?@)?(?:(?!(?:10|127)(?:\.\d{1,3}){3})(?!(?:169\.254|192\.168)(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)(?:\.(?:[a-z\u00a1-\uffff0-9]-*)*[a-z\u00a1-\uffff0-9]+)*(?:\.(?:[a-z\u00a1-\uffff]{2,}))\.?)(?::\d{2,5})?(?:[\/?#]\S*)?/i const isImmutable = e => !!_.get(e, '__immutable_invariants_hold') && _.has(e, 'asMutable') -export const mutableCopy = e => isImmutable(e) ? e.asMutable() : _.cloneDeep(e) +export const mutableCopy = (e, deep=false) => isImmutable(e) ? e.asMutable() : deep ? _.cloneDeep(e) : _.clone(e) export const typeSafeEq = (x, y) => !x ? !y : !!y && x.toString() === y.toString() export const orStr = e => e || '' export const orZero = e => e || 0 export const orArr = e => e || [] + export const isPresent = e => (!!e && !_.isEmpty(e)) || (typeof e === 'number') || (e === true) export const presentOrVal = (e, val) => isPresent(e) ? e : val export const allPresent = (...objs) => objs.reduce((running, curr) => running && isPresent(curr), true) +export const allPropsPresent = (obj, ...props) => allPresent(...props.map(p => _.get(obj, p))) const escSpecialChars = str => str.replace(/\$|\{|\}|\_/g, e => `\\${e}`) const toSource = re => re instanceof RegExp ? re.source : escSpecialChars(re) diff --git a/src/lib/generateVariableNames/generateFactVariableName.js b/src/lib/generateVariableNames/generateFactVariableName.js new file mode 100644 index 000000000..0bbca3189 --- /dev/null +++ b/src/lib/generateVariableNames/generateFactVariableName.js @@ -0,0 +1,12 @@ +import {getVariableNameFromName, shouldTransformName} from './nameToVariableName' + +// withVariableName takes a fact that needs at least a `name` parameter, and a list of existing fact names, and returns +// a copy of that object with a `variable_name` field set to a unique variable_name derived from the name parameter, if +// appropriate, otherwise returns a copy of the obect unchanged. + +export function withVariableName(fact, existingReadableIds) { + const {name} = fact + if (!shouldTransformName(name)) { return fact } + + return {...fact, variable_name: getVariableNameFromName(name, existingReadableIds, 18, 2, 18, true, false).toLowerCase()} +} diff --git a/src/lib/generateVariableNames/generateMetricReadableId.js b/src/lib/generateVariableNames/generateMetricReadableId.js index eddc53db4..ebe21b777 100644 --- a/src/lib/generateVariableNames/generateMetricReadableId.js +++ b/src/lib/generateVariableNames/generateMetricReadableId.js @@ -9,5 +9,5 @@ export function withReadableId(metric, existingReadableIds) { const shouldUpdateReadableId = shouldTransformName(name) if (!shouldUpdateReadableId) { return metric } - return {...metric, readableId: getVariableNameFromName(name, existingReadableIds, 3, 3, 3, false).toUpperCase()} + return {...metric, readableId: getVariableNameFromName(name, existingReadableIds, 3, 3, 3, false, true).toUpperCase()} } diff --git a/src/lib/generateVariableNames/nameToVariableName.js b/src/lib/generateVariableNames/nameToVariableName.js index fbfbe37cd..318a21262 100644 --- a/src/lib/generateVariableNames/nameToVariableName.js +++ b/src/lib/generateVariableNames/nameToVariableName.js @@ -1,5 +1,9 @@ +import {mutableCopy} from 'gEngine/utils' + const DIGIT_REGEX = /^\d+$/ const readableIdPartFromWord = word => DIGIT_REGEX.test(word) ? word : word[0] +const DISALLOWED_WORDS = ['my', 'your', 'her', 'their', 'our', 'his'] +const BAD_ENDING_WORDS = ['of', 'in', 'on', 'per', 'for', 'and', 'but', 'or', 'each'] function prepareName(rawName) { const name = rawName.trim().toLowerCase().replace(/[^\w\d]/g, ' ') @@ -8,38 +12,69 @@ function prepareName(rawName) { return name.slice(firstNonDigit).trim().replace(/\s/g, '_') } -function getDirectVariableNameFromName(rawName, maxOneWordLength, maxSplitWordLength, cutOffLength) { +function processWord(rawWord) { + let word = mutableCopy(rawWord) + if (word.length >= 9) { + const strippedWord = word.replace(/a|e|i|o|u/g, '') + if (strippedWord.length > 3) { word = strippedWord } + } else if (DISALLOWED_WORDS.includes(word)) { + word = '' + } + return word +} + +function getDirectVariableNameFromName(rawName, maxOneWordLength, maxSplitWordLength, cutOffLength, makeAcronym) { const name = prepareName(rawName) - const words = name.split(/[\_]/).filter(s => !_.isEmpty(s)) + const words = name.split(/[\_]/).map(processWord).filter(s => !_.isEmpty(s)) if (words.length === 1 && name.length < maxOneWordLength) { - return name + return _.trimEnd(name, '_') } else if (words.length < maxSplitWordLength) { - return name.slice(0, cutOffLength) + return _.trimEnd(name.slice(0, cutOffLength), '_') } else { - return words.map(readableIdPartFromWord).slice(0, cutOffLength).join('') + if (makeAcronym) { + return _.trimEnd(words.map(readableIdPartFromWord).slice(0, cutOffLength).join(''), '_') + } else { + let numberOfWordsUsed = 0 + let totalLength = 0 + for (; numberOfWordsUsed < words.length; numberOfWordsUsed++) { + totalLength += words[numberOfWordsUsed].length + 1 // One extra plus one for the underscores in between. + if (totalLength >= cutOffLength) { break } + } + + for (; numberOfWordsUsed > 1; numberOfWordsUsed--) { + const lastWord = words[numberOfWordsUsed] + if (!BAD_ENDING_WORDS.includes(lastWord)) { break } + } + + return _.trimEnd(words.slice(0, numberOfWordsUsed+1).join('_'), '_') + } } } export function getVariableNameFromName( rawName, existingVariableNames=[], - maxOneWordLength=30, - maxSplitWordLength=8, - totalMaxLength=maxSplitWordLength, - allowUnderscores=true, + maxOneWordLength, + maxSplitWordLength, + totalMaxLength, + allowUnderscores, + makeAcronym, ) { - let directName = getDirectVariableNameFromName(rawName, maxOneWordLength, maxSplitWordLength, totalMaxLength) + let directName = getDirectVariableNameFromName(rawName, maxOneWordLength, maxSplitWordLength, totalMaxLength, makeAcronym) if (!allowUnderscores) { directName = directName.replace(/\_/g, '') } - const nameRegex = new RegExp(`${directName}(\\d+)?`, 'i') + const nameRegex = new RegExp(`${directName}(?:_?(\\d+))?$`, 'i') const matchingNames = existingVariableNames.filter(v => nameRegex.test(v)) if (_.isEmpty(matchingNames)) { return directName } - const currentMaxSuffix = Math.max(...matchingNames.map(v => parseInt(v.match(nameRegex)[1] || '0'))) - return `${directName}${currentMaxSuffix + 1}` + const suffixes = matchingNames.map(v => parseInt(v.match(nameRegex)[1] || '0')) + if (!suffixes.includes(0)) { return directName } + + const currentMaxSuffix = Math.max(...suffixes) + return `${directName}${allowUnderscores ? '_' : ''}${currentMaxSuffix + 1}` } export const shouldTransformName = name => !(_.isEmpty(name) || _.isEmpty(name.replace(/[^a-zA-Z]/g, '').trim())) diff --git a/src/lib/generateVariableNames/tests/generateFactVariableName-test.js b/src/lib/generateVariableNames/tests/generateFactVariableName-test.js new file mode 100644 index 000000000..0b6d89d79 --- /dev/null +++ b/src/lib/generateVariableNames/tests/generateFactVariableName-test.js @@ -0,0 +1,130 @@ +import {expect} from 'chai' +import {withVariableName} from '../generateFactVariableName' + +describe('generateVariableName', () => { + const existingVariableNames = [ + 'revenue_2016', + ] + + const testCases = [ + { + description: ` + A fact with a simple name should yield a simple variable_name composed of the words of the fact joined by underscores. + `, + fact: {name: 'Value of time'}, + shouldGenerateVariableName: true, + expectedVariableName: 'value_of_time', + }, + { + description: ` + A fact with a name containing a possisive ('my', 'your') should strip the possives from the resultant variable_name. + `, + fact: {name: 'Value of my time'}, + shouldGenerateVariableName: true, + expectedVariableName: 'value_of_time', + }, + { + description: ` + A fact with a name containing a number spelled in words should strip the number from the generated variable name. + `, + fact: {name: 'Value of one hour'}, + shouldGenerateVariableName: true, + expectedVariableName: 'value_of_one_hour', + }, + { + description: ` + A fact with a name over 18 characters should truncate the resultant variable_name to under 18 characters, without ending + on an underscore. + `, + fact: {name: "This name uses 18 characters before the word 'characters'"}, + shouldGenerateVariableName: true, + expectedVariableName: 'this_name_uses_18', + }, + { + description: ` + A fact with very long words should truncate those words internally, and should not end on a bad ending word. + `, + fact: {name: 'Productive hours per each work day'}, + shouldGenerateVariableName: true, + expectedVariableName: 'prdctv_hours', + }, + { + description: ` + A fact whose acronym is present in the list should yield a variable name given by the acronym of the fact + followed by the lowest number not present in the list of variable names. + `, + fact: {name: 'Revenue 2016'}, + shouldGenerateVariableName: true, + expectedVariableName: 'revenue_2016_1', + }, + { + description: ` + A fact with no name should not yield a new variable name. + `, + fact: {}, + shouldGenerateVariableName: false, + }, + { + description: ` + A fact with an empty name should not yield a new variable name. + `, + fact: {name: ''}, + shouldGenerateVariableName: false, + }, + { + description: ` + A fact with a name with only spaces should not yield a new variable name. + `, + fact: {name: ' '}, + shouldGenerateVariableName: false, + }, + { + description: ` + A fact with a name with only spaces and digits should not yield a new variable name. + `, + fact: {name: ' 2016 '}, + shouldGenerateVariableName: false, + }, + { + description: ` + A fact with a name with only special characters should not yield a new variable name. + `, + fact: {name: ' =4*20 5%2 &91\/\\94\`\'\"()-_+!@#$,.;:><{[}]?~ '}, + shouldGenerateVariableName: false, + }, + { + description: ` + A fact with a name with only foreign characters should not yield a new variable name. + `, + fact: {name: 'Люди в Бостоне, מענטשן אין באָסטאָן, बोस्टन में लोग, مردم در بوستو.'}, + shouldGenerateVariableName: false, + }, + { + description: ` + A fact with a name with a leading number should drop the number in the variable name. + `, + fact: {name: '2016 Revenue'}, + shouldGenerateVariableName: true, + expectedVariableName: 'revenue', + }, + { + description: ` + A fact with a name with a trailing number should include the full number in the variable name. + `, + fact: {name: 'Projected Revenue 2016'}, + shouldGenerateVariableName: true, + expectedVariableName: 'prjctd_revenue_2016', + }, + ] + + testCases.forEach(({fact, description, shouldGenerateVariableName, expectedVariableName}) => { + it (description, () => { + const {variable_name} = withVariableName(fact, existingVariableNames) + if (shouldGenerateVariableName) { + expect(variable_name).to.equal(expectedVariableName) + } else { + expect(variable_name).to.not.be.ok + } + }) + }) +}) diff --git a/src/lib/guesstimate_api/resources/Organizations.js b/src/lib/guesstimate_api/resources/Organizations.js index 12ff04edf..79915a83d 100644 --- a/src/lib/guesstimate_api/resources/Organizations.js +++ b/src/lib/guesstimate_api/resources/Organizations.js @@ -46,6 +46,29 @@ export default class Organizations extends AbstractResource { this.guesstimateMethod({url, method})(callback) } + addFactCategory({id}, fact_category, callback) { + const url = `organizations/${id}/fact_categories` + const method = 'POST' + const data = {fact_category} + + this.guesstimateMethod({url, method, data})(callback) + } + + editFactCategory({id}, fact_category, callback) { + const url = `organizations/${id}/fact_categories/${fact_category.id}` + const method = 'PATCH' + const data = {fact_category} + + this.guesstimateMethod({url, method, data})(callback) + } + + deleteFactCategory({id}, fact_category, callback) { + const url = `organizations/${id}/fact_categories/${fact_category.id}` + const method = 'DELETE' + + this.guesstimateMethod({url, method})(callback) + } + getInvitations({organizationId}, callback) { const url = `organizations/${organizationId}/invitees` const method = 'GET' diff --git a/src/lib/locationUtils.js b/src/lib/locationUtils.js index 9eb8a2da0..ea801ccd8 100644 --- a/src/lib/locationUtils.js +++ b/src/lib/locationUtils.js @@ -5,6 +5,7 @@ export const PTLocation = PropTypes.shape({ row: PropTypes.number }) +// Regions are [top_left_location, bottom_right_location] export const PTRegion = PropTypes.arrayOf(PTLocation, PTLocation) export function isLocation(test) { @@ -31,12 +32,7 @@ export function isWithinRegion(test, region) { export function getBounds({start, end}) { if (!start || !end) {return []} - let leftX, topY, rightX, bottomY - leftX = Math.min(start.row, end.row) - topY = Math.max(start.column, end.column) - rightX = Math.max(start.row, end.row) - bottomY = Math.min(start.column, end.column) - return [{row: leftX, column: bottomY}, {row: rightX, column: topY}] + return boundingRegion([start, end]) } export const move = ({row, column}, direction) => ({row: row + direction.row, column: column + direction.column}) @@ -52,3 +48,11 @@ export function translate(start, end) { export function existsAtLoc(seekLoc) { return e => isAtLocation(e.location, seekLoc) } + +export function boundingRegion(locations) { + if (_.isEmpty(locations)) { return [{row: 0, column: 0}, {row: 0, column: 0}] } + return [ + {row: Math.min(...locations.map(l => l.row)), column: Math.min(...locations.map(l => l.column))}, + {row: Math.max(...locations.map(l => l.row)), column: Math.max(...locations.map(l => l.column))}, + ] +} diff --git a/src/lib/nameToVariableName.js b/src/lib/nameToVariableName.js deleted file mode 100644 index e6aed2687..000000000 --- a/src/lib/nameToVariableName.js +++ /dev/null @@ -1,42 +0,0 @@ -const DIGIT_REGEX = /^\d+$/ -const readableIdPartFromWord = word => DIGIT_REGEX.test(word) ? word : word[0] -function prepareName(rawName) { - const name = rawName.trim().toLowerCase().replace(/[^\w\d]/g, ' ') - const firstNonDigit = name.search(/[^\d\s]/) - if (firstNonDigit === -1) { return '' } - return name.slice(firstNonDigit).trim().replace(/\s/g, '_') -} - -function getDirectVariableNameFromName(rawName, maxOneWordLength, maxSplitWordLength, cutOffLength) { - const name = prepareName(rawName) - - const words = name.split(/[\_]/).filter(s => !_.isEmpty(s)) - - if (words.length === 1 && name.length < maxOneWordLength) { - return name - } else if (words.length < maxSplitWordLength) { - return name.slice(0, cutOffLength) - } else { - return words.map(readableIdPartFromWord).join('').slice(0, cutOffLength) - } -} - -export function getVariableNameFromName( - rawName, - existingVariableNames=[], - maxOneWordLength=30, - maxSplitWordLength=8, - totalMaxLength=maxSplitWordLength -) { - const directName = getDirectVariableNameFromName(rawName, maxOneWordLength, maxSplitWordLength, totalMaxLength) - - const nameRegex = new RegExp(`${directName}(?:_(\d+))?`, 'gi') - - const matchingNames = existingVariableNames.filter(v => nameRegex.test(v)) - if (_.isEmpty(matchingNames)) { return directName } - - const currentMaxSuffix = Math.max(...matchingNames.map(v => parseInt(v.match(nameRegex)[1] || '0'))) - return `${directName}${currentMaxSuffix + 1}` -} - -export const shouldTransformName = name => !(_.isEmpty(name) || _.isEmpty(name.replace(/[^\w\d]/g, '').trim())) diff --git a/src/lib/numberShower/numberShower-test.js b/src/lib/numberShower/numberShower-test.js index 3c27f8f67..be2deb69e 100644 --- a/src/lib/numberShower/numberShower-test.js +++ b/src/lib/numberShower/numberShower-test.js @@ -23,7 +23,7 @@ describe('NumberShow', () => { [10, {value: '10', symbol: undefined, power: undefined}], [10, {value: '10', symbol: undefined, power: undefined}], [100, {value: '100', symbol: undefined, power: undefined}], - [1000, {value: '1', symbol: 'K', power: undefined}], + [1000, {value: '1000', symbol: undefined, power: undefined}], [10000, {value: '10', symbol: 'K', power: undefined}], [100000, {value: '100', symbol: 'K', power: undefined}], [100001, {value: '100', symbol: 'K', power: undefined}], diff --git a/src/lib/numberShower/numberShower.js b/src/lib/numberShower/numberShower.js index b5918ee38..a0114053c 100644 --- a/src/lib/numberShower/numberShower.js +++ b/src/lib/numberShower/numberShower.js @@ -52,7 +52,7 @@ class NumberShower { const order = orderOfMagnitude(number) if (order < -2) { return {value: this.metricSystem(number, order), power: order} - } else if (order < 3) { + } else if (order < 4) { return {value: this.metricSystem(number, 0)} } else if (order < 6) { return {value: this.metricSystem(number, 3), symbol: 'K'} diff --git a/src/lib/propagation/DAG.js b/src/lib/propagation/DAG.js index 16c2d406d..e591a98d7 100644 --- a/src/lib/propagation/DAG.js +++ b/src/lib/propagation/DAG.js @@ -1,6 +1,5 @@ import * as nodeFns from './nodeFns' import {SimulationNode} from './node' -import * as constants from './constants' import * as _collections from 'gEngine/collections' import * as _utils from 'gEngine/utils' @@ -38,6 +37,10 @@ function orderNodesAndAddData(nodes) { const missingInputsNodes = _.remove(unprocessedNodes, nodeFns.hasMissingInputs(unprocessedNodes)) let graphErrorNodes = missingInputsNodes.map(nodeFns.withMissingInputError(nodes)) + + const duplicateIdNodes = _.remove(unprocessedNodes, nodeFns.hasDuplicateId(unprocessedNodes)) + graphErrorNodes.push(...duplicateIdNodes.map(nodeFns.withDuplicateIdError)) + let errorNodes = Object.assign([], graphErrorNodes) let heightOrderedNodes = [] diff --git a/src/lib/propagation/constants.js b/src/lib/propagation/constants.js index 90e174972..e2a2b9539 100644 --- a/src/lib/propagation/constants.js +++ b/src/lib/propagation/constants.js @@ -15,5 +15,6 @@ export const ERROR_SUBTYPES = { MISSING_INPUT_ERROR: 1, IN_INFINITE_LOOP: 2, INVALID_ANCESTOR_ERROR: 3, + DUPLICATE_ID_ERROR: 4, }, } diff --git a/src/lib/propagation/nodeFns.js b/src/lib/propagation/nodeFns.js index ab877aec7..d444cc3cd 100644 --- a/src/lib/propagation/nodeFns.js +++ b/src/lib/propagation/nodeFns.js @@ -7,7 +7,7 @@ const ID_REGEX = /\$\{([^\}]*)\}/g // ERRORS: const { ERROR_TYPES: {GRAPH_ERROR}, - ERROR_SUBTYPES: {GRAPH_SUBTYPES: {MISSING_INPUT_ERROR, IN_INFINITE_LOOP, INVALID_ANCESTOR_ERROR}}, + ERROR_SUBTYPES: {GRAPH_SUBTYPES: {MISSING_INPUT_ERROR, IN_INFINITE_LOOP, INVALID_ANCESTOR_ERROR, DUPLICATE_ID_ERROR}}, } = constants const addError = (type, subType, dataFn=()=>({})) => n => ({...n, errors: [...n.errors, {type, subType, ...dataFn(n)}]}) @@ -17,6 +17,9 @@ const getInvalidInputsFn = nodes => n => ({missingInputs: _.filter(n.inputs, i = export const withMissingInputError = nodes => addGraphError(MISSING_INPUT_ERROR, getInvalidInputsFn(nodes)) export const hasMissingInputs = nodes => _.negate(allInputsWithin(nodes)) +export const withDuplicateIdError = addGraphError(DUPLICATE_ID_ERROR) +export const hasDuplicateId = nodes => ({id}) => _collections.filter(nodes, id).length > 1 + export const withInfiniteLoopError = addGraphError(IN_INFINITE_LOOP) const concatErrorIds = a => (running, curr) => curr.subType === INVALID_ANCESTOR_ERROR ? [...running, ...curr.ancestors] : [...running, a] diff --git a/src/lib/propagation/tests/DAG-test.js b/src/lib/propagation/tests/DAG-test.js index 8c8f7dde9..694277c99 100644 --- a/src/lib/propagation/tests/DAG-test.js +++ b/src/lib/propagation/tests/DAG-test.js @@ -11,7 +11,7 @@ import * as _utils from 'gEngine/utils' // ERRORS: const { ERROR_TYPES: {GRAPH_ERROR}, - ERROR_SUBTYPES: {GRAPH_SUBTYPES: {MISSING_INPUT_ERROR, IN_INFINITE_LOOP, INVALID_ANCESTOR_ERROR}}, + ERROR_SUBTYPES: {GRAPH_SUBTYPES: {MISSING_INPUT_ERROR, IN_INFINITE_LOOP, INVALID_ANCESTOR_ERROR, DUPLICATE_ID_ERROR}}, } = constants @@ -52,6 +52,17 @@ describe('construction', () => { expect(subTypes).to.have.members([MISSING_INPUT_ERROR]) }) + const duplicateInputsNodeList = [utils.makeNode(1), utils.makeNode(1), utils.makeNode(3, [4]), utils.makeNode(4)] + it ('Correctly flags duplicate input errors', () => {utils.expectNodesToBe(new SimulationDAG(duplicateInputsNodeList), [4,3], [1,1])}) + it ('Produces appropriately typed errors', () => { + const DAG = new SimulationDAG(duplicateInputsNodeList) + + DAG.graphErrorNodes.forEach(n => { n.errors.forEach( e => {expect(e.type).to.equal(GRAPH_ERROR)} ) }) + + const subTypes = _.flatten(DAG.graphErrorNodes.map(n => n.errors.map(e => e.subType))) + expect(subTypes).to.have.members([DUPLICATE_ID_ERROR, DUPLICATE_ID_ERROR]) + }) + const infiniteLoopNodeList = [utils.makeNode(1, [2]), utils.makeNode(2, [1]), utils.makeNode(3), utils.makeNode(4, [3])] it ('Correctly flags missing input errors', () => {utils.expectNodesToBe(new SimulationDAG(infiniteLoopNodeList), [3,4], [1,2])}) it ('Produces appropriately typed errors', () => { diff --git a/src/lib/propagation/tests/wrapper-test.js b/src/lib/propagation/tests/wrapper-test.js index 069a67344..6b8c97acf 100644 --- a/src/lib/propagation/tests/wrapper-test.js +++ b/src/lib/propagation/tests/wrapper-test.js @@ -14,6 +14,7 @@ describe('getSubset', () => { const space3Metrics = [ {id: 5, space: 3}, {id: 6, space: 3} ] const space4Metrics = [ {id: 7, space: 4}, {id: 8, space: 4}, {id: 9, space: 4} ] const space5Metrics = [ {id: 10, space: 5} ] + const space6Metrics = [ {id: 11, space: 6} ] const space1Guesstimates = [ {metric: 1, expression: '1'}, {metric: 2, expression: '=${fact:1}'} ] const space2Guesstimates = [ {metric: 3, expression: '=${metric:4}'}, {metric: 4, expression: '=${fact:1}'} ] @@ -24,6 +25,7 @@ describe('getSubset', () => { {metric: 9, expression: '=@Chicago.population'}, ] const space5Guesstimates = [ {metric: 10, expression: '6'} ] + const space6Guesstimates = [ {metric: 11, expression: '=${fact:2}'} ] const space1Sims = [ {metric: 1, sample: {values: [], errors: []}}, {metric: 2, sample: {values: [], errors: []}} ] const space2Sims = [ {metric: 3, sample: {values: [], errors: []}}, {metric: 4, sample: {values: [], errors: []}} ] @@ -34,8 +36,12 @@ describe('getSubset', () => { {metric: 9, sample: {values: [], errors: []}} ] const space5Sims = [ {metric: 10, sample: {values: [], errors: []}} ] + const space6Sims = [ {metric: 11, sample: {values: [], errors: []}} ] const state = { + canvasState: { + editsAllowedManuallySet: false, + }, spaces: [ { id: 1, @@ -61,6 +67,12 @@ describe('getSubset', () => { imported_fact_ids: [], exported_facts_count: 1, }, + { + id: 6, + organization_id: 1, + imported_fact_ids: [2], + exported_facts_count: 1, + }, { id: 4, organization_id: 2, @@ -96,6 +108,7 @@ describe('getSubset', () => { {id: 3, metric_id: 3, exported_from_id: 2}, {id: 7, expression: '100'}, {id: 8, metric_id: 10, exported_from_id: 5}, + {id: 9, metric_id: 11, exported_from_id: 6}, ], }, { @@ -108,9 +121,12 @@ describe('getSubset', () => { }, ], }, - metrics: [...space1Metrics, ...space2Metrics, ...space3Metrics, ...space4Metrics, ...space5Metrics], - guesstimates: [ ...space1Guesstimates, ...space2Guesstimates, ...space3Guesstimates, ...space4Guesstimates, ...space5Guesstimates ], - simulations: [...space1Sims, ...space2Sims, ...space3Sims, ...space4Sims, ...space5Sims], + metrics: [...space1Metrics, ...space2Metrics, ...space3Metrics, ...space4Metrics, ...space5Metrics, ...space6Metrics], + guesstimates: [ + ...space1Guesstimates, ...space2Guesstimates, ...space3Guesstimates, + ...space4Guesstimates, ...space5Guesstimates, ...space6Guesstimates, + ], + simulations: [...space1Sims, ...space2Sims, ...space3Sims, ...space4Sims, ...space5Sims, ...space6Sims], } describe ("getSubset should correctly extract a single space's subset, through metricId or spaceId", () => { @@ -133,8 +149,78 @@ describe('getSubset', () => { expect(subset, "guesstimates match").to.have.property('guesstimates').that.deep.has.members(space1Guesstimates) expect(subset, "simulations match").to.have.property('simulations').that.deep.has.members(space1Sims) expect(relevantFacts, 'relevantFacts match').to.deep.have.members([ - {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2]}, - {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`}, + {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2], shouldBeSimulated: false}, + {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`, shouldBeSimulated: true}, + ]) + }) + }) + }) + + describe ("getSubset should correctly extract a single space's subset, through metricId or spaceId, and flags all facts as unsimulatable when canvasState forbids edits.", () => { + const testCases = [ + { + description: "Passing a single metricId should yield that metric's space's subset", + graphFilters: { metricId: 1 } + }, + { + description: "Passing a single spaceId should yield that space's subset", + graphFilters: { spaceId: 1 } + }, + ] + + const stateWithCanvasStateToFalse = { + ...state, + canvasState: { + editsAllowed: false, + editsAllowedManuallySet: true, + }, + } + + testCases.forEach( ({graphFilters, description}) => { + it (description, () => { + const {subset, relevantFacts} = getSubset(stateWithCanvasStateToFalse, graphFilters) + + expect(subset, 'metrics match').to.have.property('metrics').that.deep.has.members(space1Metrics) + expect(subset, 'guesstimates match').to.have.property('guesstimates').that.deep.has.members(space1Guesstimates) + expect(subset, 'simulations match').to.have.property('simulations').that.deep.has.members(space1Sims) + expect(relevantFacts, 'relevantFacts match').to.deep.have.members([ + {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2], shouldBeSimulated: false}, + {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`, shouldBeSimulated: false}, + ]) + }) + }) + }) + + describe ("getSubset should correctly extract a single space's subset, through metricId or spaceId, and flags facts as simulatable as appropriate when canvasState specifically allows edits.", () => { + const testCases = [ + { + description: "Passing a single metricId should yield that metric's space's subset", + graphFilters: { metricId: 1 } + }, + { + description: "Passing a single spaceId should yield that space's subset", + graphFilters: { spaceId: 1 } + }, + ] + + const stateWithCanvasStateToFalse = { + ...state, + canvasState: { + editsAllowed: true, + editsAllowedManuallySet: true, + }, + } + + testCases.forEach( ({graphFilters, description}) => { + it (description, () => { + const {subset, relevantFacts} = getSubset(stateWithCanvasStateToFalse, graphFilters) + + expect(subset, 'metrics match').to.have.property('metrics').that.deep.has.members(space1Metrics) + expect(subset, 'guesstimates match').to.have.property('guesstimates').that.deep.has.members(space1Guesstimates) + expect(subset, 'simulations match').to.have.property('simulations').that.deep.has.members(space1Sims) + expect(relevantFacts, 'relevantFacts match').to.deep.have.members([ + {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2], shouldBeSimulated: false}, + {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`, shouldBeSimulated: true}, ]) }) }) @@ -144,20 +230,62 @@ describe('getSubset', () => { const graphFilters = { factId: 1 } const {subset, relevantFacts} = getSubset(state, graphFilters) - expect(subset, "metrics match").to.have.property('metrics').that.deep.has.members([...space1Metrics, ...space2Metrics]) + expect(subset, "metrics match").to.have.property('metrics').that.deep.has.members([ + ...space1Metrics, + ...space2Metrics, + ...space6Metrics, + ]) + expect(subset, "guesstimates match").to.have.property('guesstimates').that.deep.has.members([ + ...space1Guesstimates, + ...space2Guesstimates, + ...space6Guesstimates, + ]) + expect(subset, "simulations match").to.have.property('simulations').that.deep.has.members([ + ...space1Sims, + ...space2Sims, + ...space6Sims, + ]) + + expect(relevantFacts, 'relevantFacts match').to.deep.have.members([ + {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2], shouldBeSimulated: false}, + {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`, shouldBeSimulated: true}, + {id: 3, metric_id: 3, exported_from_id: 2, expression: `=${expressionSyntaxPad(3)}`, shouldBeSimulated: true}, + {id: 9, metric_id: 11, exported_from_id: 6, expression: `=${expressionSyntaxPad(11)}`, shouldBeSimulated: true}, + ]) + }) + + it ("should correctly extract all possibly intermediate spaces' subsets from a factId and ignore the canvasState settings", () => { + const graphFilters = { factId: 1 } + const stateWithCanvasStateToFalse = { + ...state, + canvasState: { + editsAllowed: false, + editsAllowedManuallySet: true, + }, + } + const {subset, relevantFacts} = getSubset(stateWithCanvasStateToFalse, graphFilters) + + expect(subset, "metrics match").to.have.property('metrics').that.deep.has.members([ + ...space1Metrics, + ...space2Metrics, + ...space6Metrics, + ]) expect(subset, "guesstimates match").to.have.property('guesstimates').that.deep.has.members([ ...space1Guesstimates, ...space2Guesstimates, + ...space6Guesstimates, ]) expect(subset, "simulations match").to.have.property('simulations').that.deep.has.members([ ...space1Sims, - ...space2Sims + ...space2Sims, + ...space6Sims, ]) expect(relevantFacts, 'relevantFacts match').to.deep.have.members([ - {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2]}, - {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`}, - {id: 3, metric_id: 3, exported_from_id: 2, expression: `=${expressionSyntaxPad(3)}`}, + {id: 1, expression: '3', imported_to_intermediate_space_ids: [1, 2], shouldBeSimulated: false}, + {id: 2, metric_id: 1, exported_from_id: 1, expression: `=${expressionSyntaxPad(1)}`, shouldBeSimulated: true}, + {id: 3, metric_id: 3, exported_from_id: 2, expression: `=${expressionSyntaxPad(3)}`, shouldBeSimulated: true}, + {id: 9, metric_id: 11, exported_from_id: 6, expression: `=${expressionSyntaxPad(11)}`, shouldBeSimulated: true}, ]) }) diff --git a/src/lib/propagation/wrapper.js b/src/lib/propagation/wrapper.js index 6ab76c96f..a21708632 100644 --- a/src/lib/propagation/wrapper.js +++ b/src/lib/propagation/wrapper.js @@ -42,12 +42,42 @@ export function getSubset(state, graphFilters) { const {spaces, organization} = getSpacesAndOrganization(state, graphFilters) if (_.isEmpty(spaces)) { return {subset: {metrics: [], guesstimates: [], simulations: []}, relevantFacts: []} } + const spaceIds = spaces.map(s => s.id) - let subset = e.space.subset(state, ...spaces.map(s => s.id)) + let subset = e.space.subset(state, ...spaceIds) const organizationFacts = e.facts.getFactsForOrg(state.facts.organizationFacts, organization) const {organizationFactsUsed, globalFactsUsed} = e.facts.getRelevantFactsAndReformatGlobals(subset, state.facts.globalFacts, organizationFacts, spaces.map(s => s.id)) + // When should facts be simulatable? + // + // This logic is a bit dicey, and very tempermental, so it warrants an explanation. + // We only want to simulate facts if the user is in editing mode on a space; you don't want to redefine their global + // fact store if they are just arbitrarily adjusting parameters, for example. View mode or edit mode is determined by + // one of two things: either the canvasState flags edits as being allowed or not, or the space's permissions itself + // can forbid editing by a user. But, as our permission system right now prohibits the use of (or exporting of) facts + // from spaces that the user doesn't have permission to edit, this second restriction is moot; if you can't edit the + // space you can't be simulating output facts in the first place. Therefore, the only restriction that defines + // whether or not the user is in editing mode that is relevant when there are possible output facts is the canvas + // state. In particular, a user is in edit mode (and thus we should simulate facts) when the canvasState editsAllowed + // field is manually set to true. + // + // However, there is one additional caveat. If we are simulating facts as part of a downstream propagation from an + // upstream fact change, then we want to simulate facts independently of canvasState. This is indicated by whether or + // not the graphFilters object has the `factId` field set. + // + // Additionally, we never want to simulate input facts, or facts not exported by the space we're simulating. + // + // So, our final condition as to whether or not we want to simulate a given fact f is: + // ([can edit] OR [simulating fact descendants]) AND [fact is output] + const {canvasState: {editsAllowed, editsAllowedManuallySet}} = state + const allowedToSimulateOutputFact = !!graphFilters.factId || !editsAllowedManuallySet || editsAllowed + + const organizationFactsFlaggedAsSimulatable = organizationFactsUsed.map(f => ({ + ...f, + shouldBeSimulated: allowedToSimulateOutputFact && spaceIds.includes(_.get(f, 'exported_from_id')), + })) + const globalFactHandleToNodeIdMap = _.transform( globalFactsUsed, (resultMap, globalFact) => { resultMap[globalFact.variable_name] = e.guesstimate.expressionSyntaxPad(globalFact.id, false) }, @@ -59,7 +89,7 @@ export function getSubset(state, graphFilters) { expression: e.utils.replaceByMap(g.expression, globalFactHandleToNodeIdMap) })) - return {subset, relevantFacts: [...organizationFactsUsed, ...globalFactsUsed]} + return {subset, relevantFacts: [...organizationFactsFlaggedAsSimulatable, ...globalFactsUsed]} } const nodeIdToMetricId = id => id.slice(e.simulation.METRIC_ID_PREFIX.length) @@ -84,8 +114,8 @@ const metricToSimulationNodeFn = m => ({ type: guesstimateTypeToNodeType(m.guesstimate.guesstimateType), guesstimateType: m.guesstimate.guesstimateType, expression: m.guesstimate.expression, - samples: m.guesstimate.guesstimateType === 'DATA' ? e.utils.orArr(_.get(m, 'guesstimate.data')) : e.utils.orArr(_.get(m, 'simulation.sample.values')), - errors: e.utils.mutableCopy(e.utils.orArr(_.get(m, 'simulation.sample.errors')).filter(filterErrorsFn)), + samples: m.guesstimate.guesstimateType === 'DATA' ? e.utils.orArr(_.get(m, 'guesstimate.data')) : e.simulation.values(m.simulation), + errors: e.utils.mutableCopy(e.simulation.errors(m.simulation)).filter(filterErrorsFn), }) const factIdToNodeId = id => `${e.simulation.FACT_ID_PREFIX}${id}` @@ -94,9 +124,9 @@ const factToSimulationNodeFn = f => ({ expression: f.expression, type: NODE_TYPES.UNSET, // Facts are currently type-less. guesstimateType: null, // Facts are currently type-less. - samples: e.utils.orArr(_.get(f, 'simulation.sample.values')), - errors: [], - skipSimulating: !_.get(f, 'defining_space_id'), + samples: e.simulation.values(f.simulation), + errors: e.utils.mutableCopy(e.simulation.errors(f.simulation)).filter(filterErrorsFn), + skipSimulating: !_.get(f, 'shouldBeSimulated'), }) function denormalize({metrics, guesstimates, simulations}) { @@ -173,6 +203,7 @@ const getCurrPropId = state => nodeId => { export function simulate(dispatch, getState, graphFilters) { const state = getState() + const shouldTriggerDownstreamFactSimulations = !graphFilters.factId const {subset, relevantFacts} = getSubset(state, graphFilters) const denormalizedMetrics = denormalize(subset) @@ -190,22 +221,25 @@ export function simulate(dispatch, getState, graphFilters) { propagationId, sample: { values: _.isEmpty(errors) ? samples : [], - errors: errors.map(translateErrorFn(denormalizedMetrics, metric)) + errors: Object.assign([], errors.map(translateErrorFn(denormalizedMetrics, metric))), } } dispatch(addSimulation(newSimulation)) } const yieldFactSims = (nodeId, {samples, errors}) => { + // TODO(matthew): Proper error handling... + if (!_.isEmpty(errors)) { return } + const factId = nodeIdToFactId(nodeId) const newSimulation = { propagationId, sample: { values: _.isEmpty(errors) ? samples : [], - errors: errors + errors: Object.assign([], errors), } } - dispatch(addSimulationToFact(newSimulation, factId)) + dispatch(addSimulationToFact(newSimulation, factId, shouldTriggerDownstreamFactSimulations)) } const yieldSims = (nodeId, sim) => { nodeIdIsMetric(nodeId) ? yieldMetricSims(nodeId, sim) : yieldFactSims(nodeId, sim) } diff --git a/src/modules/factCategories/actions.js b/src/modules/factCategories/actions.js new file mode 100644 index 000000000..aea3a5523 --- /dev/null +++ b/src/modules/factCategories/actions.js @@ -0,0 +1,3 @@ +import {actionCreatorsFor} from 'redux-crud' + +export const factCategoryActions = actionCreatorsFor('factCategories') diff --git a/src/modules/facts/actions.js b/src/modules/facts/actions.js index efa26dd42..97e72e29e 100644 --- a/src/modules/facts/actions.js +++ b/src/modules/facts/actions.js @@ -1,10 +1,13 @@ -import {editFact} from 'gModules/organizations/actions' +import {editFact, addFact} from 'gModules/organizations/actions' -import {selectorSearch, withSortedValues} from 'gEngine/facts' +import {getVar, selectorSearch, withMissingStats} from 'gEngine/facts' import * as _collections from 'gEngine/collections' -import {organizationIdFromFactReadableId} from 'gEngine/organization' +import {orArr} from 'gEngine/utils' +import {organizationIdFromFactReadableId, organizationReadableId} from 'gEngine/organization' import {addStats} from 'gEngine/simulation' +import {withVariableName} from 'lib/generateVariableNames/generateFactVariableName' + export function getSuggestion(selector) { return (dispatch, getState) => { const {partial, suggestion} = selectorSearch(selector, [...getState().facts.globalFacts, ...getState().facts.organizationFacts]) @@ -25,24 +28,45 @@ export function loadByOrg(facts) { } export function addToOrg(organizationVariableName, fact) { - return {type: 'ADD_FACT_TO_ORG', organizationVariableName, fact: withSortedValues(fact)} + return {type: 'ADD_FACT_TO_ORG', organizationVariableName, fact: withMissingStats(fact)} } export function updateWithinOrg(organizationVariableName, fact) { - return {type: 'UPDATE_FACT_WITHIN_ORG', organizationVariableName, fact: withSortedValues(fact)} + return {type: 'UPDATE_FACT_WITHIN_ORG', organizationVariableName, fact: withMissingStats(fact)} } export function deleteFromOrg(organizationVariableName, {id}) { return {type: 'DELETE_FACT_FROM_ORG', organizationVariableName, id} } -export function addSimulationToFact(simulation, id) { +export function createFactFromMetric(organizationId, metric) { + return (dispatch, getState) => { + const {organizations, facts: {organizationFacts}} = getState() + + const organization = _collections.get(organizations, organizationId) + + const organizationVariableName = organizationReadableId(organization) + const otherFacts = orArr(_collections.gget(organizationFacts, organizationVariableName, 'variable_name', 'children')) + const existingVariableNames = otherFacts.map(getVar) + + const newFactParams = { + name: metric.name, + metric_id: metric.id, + exported_from_id: metric.space, + simulation: metric.simulation, + } + + dispatch(addFact(organization, withVariableName(newFactParams))) + } +} + +export function addSimulationToFact(simulation, id, shouldTriggerDownstreamFactSimulations) { return (dispatch, getState) => { const state = getState() const oldOrganizationFact = state.facts.organizationFacts.find(e => _collections.some(e.children, id)) if (!oldOrganizationFact) { - console.warn('Tried to add simulations to a non-existent fact!') + if (__DEV__) { console.warn('Tried to add simulations to non-existent fact!', id) } return } @@ -57,6 +81,6 @@ export function addSimulationToFact(simulation, id) { simulation: simulation, } - dispatch(editFact(organization, newFact)) + dispatch(editFact(organization, newFact, shouldTriggerDownstreamFactSimulations)) } } diff --git a/src/modules/facts/reducer.js b/src/modules/facts/reducer.js index 34cfbdd42..2602e0de9 100644 --- a/src/modules/facts/reducer.js +++ b/src/modules/facts/reducer.js @@ -1,3 +1,6 @@ +import {mutableCopy, typeSafeEq} from 'gEngine/utils' +import * as _collections from 'gEngine/collections' + import CITIES from './cities.json' const INITIAL_STATE = { @@ -82,6 +85,46 @@ export function factsR(state = INITIAL_STATE, action) { ...state, currentSuggestion: INITIAL_STATE.currentSuggestion, } + case 'FACT_CATEGORIES_DELETE_SUCCESS': { + const categoryId = _.get(action, 'record.id') + let copiedState = mutableCopy(state.organizationFacts) + const organizationContainersToModify = _.remove(copiedState, o => _collections.some(o.children, categoryId, 'category_id')) + const modifiedOrganizationContainers = _.map(organizationContainersToModify, o => { + let copiedChildren = mutableCopy(o.children) + const childrenToModify = _.remove(copiedChildren, f => typeSafeEq(_.get(f, 'category_id'), categoryId)) + return { + ...o, + children: [ + ...copiedChildren, + ..._.map(childrenToModify, c => ({...c, category_id: null})), + ], + } + }) + return { + ...state, + organizationFacts: [ + ...copiedState, + ...modifiedOrganizationContainers, + ], + } + } + case 'SPACES_DELETE_SUCCESS': { + const spaceId = _.get(action, 'record.id') + let copiedState = mutableCopy(state.organizationFacts) + const organizationContainersToModify = _.remove(copiedState, o => _collections.some(o.children, spaceId, 'exported_from_id')) + const modifiedOrganizationContainers = _.map(organizationContainersToModify, o => ({ + ...o, + children: _.filter(o.children, f => !typeSafeEq(_.get(f, 'exported_from_id'), spaceId)), + })) + + return { + ...state, + organizationFacts: [ + ...copiedState, + ...modifiedOrganizationContainers, + ], + } + } default: return state } diff --git a/src/modules/metrics/actions.js b/src/modules/metrics/actions.js index ba94e6231..c9c012b43 100644 --- a/src/modules/metrics/actions.js +++ b/src/modules/metrics/actions.js @@ -1,12 +1,11 @@ -import e from 'gEngine/engine' import * as spaceActions from 'gModules/spaces/actions' +import * as organizationActions from 'gModules/organizations/actions' + +import e from 'gEngine/engine' import {isWithinRegion} from 'lib/locationUtils.js' -function findSpaceId(getState, metricId) { - const metric = e.collections.get(getState().metrics, metricId) - return _.get(metric, 'space') -} +const findSpaceId = (getState, metricId) => e.collections.gget(getState().metrics, metricId, 'id', 'space') function registerGraphChange(dispatch, spaceId) { spaceId && dispatch(spaceActions.registerGraphChange(spaceId)) @@ -22,11 +21,23 @@ export function addMetric(item) { } } -//spaceId must be done before the metric is removed here. export function removeMetrics(ids) { return (dispatch, getState) => { if (ids.length === 0) { return } + const spaceId = findSpaceId(getState, ids[0]) + + const {organizations, spaces, facts: {organizationFacts}} = getState() + const organizationId = e.collections.gget(spaces, spaceId, 'id', 'organization_id') + if (!!organizationId) { + const organization = e.collections.get(organizations, organizationId) + const facts = e.organization.findFacts(organizationId, organizationFacts) + const factsToDelete = _.filter(facts, f => f.exported_from_id === spaceId && ids.includes(f.metric_id)) + factsToDelete.forEach(fact => { + dispatch(organizationActions.deleteFact(organization, fact)) + }) + } + dispatch({ type: 'REMOVE_METRICS', item: {ids}}); registerGraphChange(dispatch, spaceId) } diff --git a/src/modules/organizations/actions.js b/src/modules/organizations/actions.js index 66f3ba65a..4d715611a 100644 --- a/src/modules/organizations/actions.js +++ b/src/modules/organizations/actions.js @@ -7,12 +7,15 @@ import * as membershipActions from 'gModules/userOrganizationMemberships/actions import * as userOrganizationMembershipActions from 'gModules/userOrganizationMemberships/actions' import * as userOrganizationInvitationActions from 'gModules/userOrganizationInvitations/actions' import * as factActions from 'gModules/facts/actions' +import {factCategoryActions} from 'gModules/factCategories/actions' import * as spaceActions from 'gModules/spaces/actions' +import {orArr} from 'gEngine/utils' import {organizationReadableId} from 'gEngine/organization' -import {withSortedValues} from 'gEngine/facts' +import {withMissingStats} from 'gEngine/facts' import {captureApiError} from 'lib/errors/index' +import {simulate} from 'lib/propagation/wrapper' import {setupGuesstimateApi} from 'servers/guesstimate-api/constants' @@ -40,18 +43,20 @@ export function fetchById(organizationId) { } } -const toContainerFact = o => _.isEmpty(o.facts) ? {} : {variable_name: organizationReadableId(o), children: o.facts.map(withSortedValues)} +const toContainerFact = o => _.isEmpty(o.facts) ? {} : {variable_name: organizationReadableId(o), children: o.facts.map(withMissingStats)} export function fetchSuccess(organizations) { return (dispatch) => { const formatted = organizations.map(o => _.pick(o, ['id', 'name', 'picture', 'admin_id', 'account', 'plan'])) - const memberships = _.flatten(organizations.map(o => o.memberships || [])) - const invitations = _.flatten(organizations.map(o => o.invitations || [])) + const memberships = _.flatten(organizations.map(o => orArr(o.memberships))) + const invitations = _.flatten(organizations.map(o => orArr(o.invitations))) const factsByOrg = organizations.map(toContainerFact).filter(o => !_.isEmpty(o)) + const factCategories = _.flatten(organizations.map(o => orArr(o.fact_categories))) if (!_.isEmpty(memberships)) { dispatch(userOrganizationMembershipActions.fetchSuccess(memberships)) } if (!_.isEmpty(invitations)) { dispatch(userOrganizationInvitationActions.fetchSuccess(invitations)) } + if (!_.isEmpty(factCategories)) { dispatch(factCategoryActions.fetchSuccess(factCategories)) } if (!_.isEmpty(factsByOrg)) { dispatch(factActions.loadByOrg(factsByOrg)) } dispatch(oActions.fetchSuccess(formatted)) @@ -105,8 +110,7 @@ export function addFact(organization, rawFact) { } // editFact edits the passed fact, with sortedValues overwritten to null, to the organization and saves it on the server. -export function editFact(organization, rawFact) { - // TODO(matthew): Build dependency chain here? +export function editFact(organization, rawFact, simulateDependentFacts=false) { return (dispatch, getState) => { let fact = Object.assign({}, rawFact) _.set(fact, 'simulation.sample.sortedValues', null) @@ -114,6 +118,8 @@ export function editFact(organization, rawFact) { api(getState()).organizations.editFact(organization, fact, (err, serverFact) => { if (!!serverFact) { dispatch(factActions.updateWithinOrg(organizationReadableId(organization), serverFact)) + + if (simulateDependentFacts) { simulate(dispatch, getState, {factId: fact.id}) } } }) } @@ -130,3 +136,33 @@ export function deleteFact(organization, fact) { }) } } + +export function addFactCategory(organization, factCategory) { + return (dispatch, getState) => { + const cid = cuid() + api(getState()).organizations.addFactCategory(organization, factCategory, (err, serverFactCategory) => { + if (!!serverFactCategory) { dispatch(factCategoryActions.createSuccess(serverFactCategory, cid)) } + }) + + } +} + +export function editFactCategory(organization, factCategory) { + return (dispatch, getState) => { + dispatch(factCategoryActions.updateStart(factCategory)) + api(getState()).organizations.editFactCategory(organization, factCategory, (err, serverFactCategory) => { + if (!!serverFactCategory) { dispatch(factCategoryActions.updateSuccess(serverFactCategory)) } + }) + } +} + +export function deleteFactCategory(organization, factCategory) { + return (dispatch, getState) => { + dispatch(factCategoryActions.deleteStart(factCategory)) + api(getState()).organizations.deleteFactCategory(organization, factCategory, (err, _1) => { + if (!err) { + dispatch(factCategoryActions.deleteSuccess(factCategory)) + } + }) + } +} diff --git a/src/modules/reducers.js b/src/modules/reducers.js index 4a5289f3a..df864614e 100644 --- a/src/modules/reducers.js +++ b/src/modules/reducers.js @@ -52,6 +52,7 @@ const rootReducer = function app(state = {}, action){ httpRequests: SI(httpRequestsR(state.httpRequests, action)), calculators: SI(reduxCrud.reducersFor('calculators')(state.calculators, action)), facts: SI(factsR(state.facts, action)), + factCategories: SI(reduxCrud.reducersFor('factCategories')(state.factCategories, action)), } } diff --git a/src/modules/simulations/actions.js b/src/modules/simulations/actions.js index 84264a10a..f4ab65bfe 100644 --- a/src/modules/simulations/actions.js +++ b/src/modules/simulations/actions.js @@ -3,7 +3,7 @@ import {call} from 'redux-saga/effects' import e from 'gEngine/engine' -import {simulate} from '../../lib/propagation/wrapper' +import {simulate} from 'lib/propagation/wrapper' const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)) diff --git a/src/modules/spaces/actions.js b/src/modules/spaces/actions.js index 458bf5cec..1c49bca6b 100644 --- a/src/modules/spaces/actions.js +++ b/src/modules/spaces/actions.js @@ -70,9 +70,27 @@ export function destroy(object) { } } +// TODO(matthew): Maybe we can remove metric_count? +const SPACE_INDEX_ATTRIBUTES = [ + 'id', + 'name', + 'description', + 'user_id', + 'organization_id', + 'updated_at', + 'metric_count', + 'is_private', + 'screenshot', + 'big_screenshot', + 'viewcount', + 'imported_fact_ids', + 'exported_facts_count', + 'editors_by_time', +] + export function fromSearch(data) { return (dispatch) => { - const formatted = data.map(d => _.pick(d, ['id', 'name', 'description', 'user_id', 'updated_at', 'metric_count', 'is_private', 'screenshot', 'big_screenshot', 'viewcount'])) + const formatted = data.map(d => _.pick(d, SPACE_INDEX_ATTRIBUTES)) const action = sActions.fetchSuccess(formatted) dispatch(action) } @@ -101,7 +119,7 @@ export function fetch({userId, organizationId}) { if (err) { captureApiError('SpacesFetch', err.jqXHR, err.textStatus, err, {url: 'fetch'}) } else if (value) { - const formatted = value.items.map(d => _.pick(d, ['id', 'name', 'description', 'user_id', 'organization_id', 'updated_at', 'metric_count', 'is_private', 'screenshot', 'big_screenshot'])) + const formatted = value.items.map(d => _.pick(d, SPACE_INDEX_ATTRIBUTES)) dispatch(sActions.fetchSuccess(formatted)) const users = value.items.map(d => _.get(d, 'user')).filter(u => !!u) diff --git a/src/routes/router.js b/src/routes/router.js index 22f888857..93eec4848 100644 --- a/src/routes/router.js +++ b/src/routes/router.js @@ -78,6 +78,7 @@ export default Router.extend({ spaceId={parseInt(id)} showCalculatorId={parseInt(calculatorId)} showCalculatorResults={window.location.search.includes('showResults=true')} + factsShown={window.location.search.includes('factsShown=true')} shareableLinkToken={extractTokenFromUrl(window.location.search)} key={parseInt(id)} />,