diff --git a/src/components/metrics/card/token/style.css b/src/components/metrics/card/token/style.css index f1fedd6bd..c06613ce4 100644 --- a/src/components/metrics/card/token/style.css +++ b/src/components/metrics/card/token/style.css @@ -7,7 +7,7 @@ .MetricToken .ui.tiny.label { font-size: 0.85rem; - background-color: #25B530 !important; + background-color: $green-1 !important; border-radius: 2px; padding: 0.4em 0.45em 0.35em; margin-top: 2px; diff --git a/src/components/organizations/show/httpRequestSelector.js b/src/components/organizations/show/httpRequestSelector.js new file mode 100644 index 000000000..748d08c6f --- /dev/null +++ b/src/components/organizations/show/httpRequestSelector.js @@ -0,0 +1,33 @@ +import { createSelector } from 'reselect'; + +const specificHttpRequestSelector = state => state.httpRequests +const organizationIdSelector = (state, props) => props.organizationId + +function isExistingMember(request) { + return !!_.get(request, 'response.hasUser') +} + +export const httpRequestSelector = createSelector( + specificHttpRequestSelector, + organizationIdSelector, + (httpRequests, organizationId) => { + + let relevantRequests = httpRequests.filter(r => ( + (r.metadata.organizationId === organizationId) && + (r.entity === 'userOrganizationMembershipCreate') + )) + + let formattedRequests = relevantRequests.map(r => ({ + id: r.id, + busy: r.busy, + success: r.success, + email: r.metadata.email, + created_at: r.created_at, + isExistingMember: isExistingMember(r) + })) + + return { + httpRequests: formattedRequests + } + } +); diff --git a/src/components/organizations/show/index.js b/src/components/organizations/show/index.js index 28617ed92..93601f25a 100644 --- a/src/components/organizations/show/index.js +++ b/src/components/organizations/show/index.js @@ -1,12 +1,22 @@ import React, {Component, PropTypes} from 'react' -import { connect } from 'react-redux'; +import { connect } from 'react-redux' + +import ReactDOM from 'react-dom' +import Icon from 'react-fa' + import SpaceList from 'gComponents/spaces/list' import * as spaceActions from 'gModules/spaces/actions' import * as organizationActions from 'gModules/organizations/actions' -import { organizationSpaceSelector } from './organizationSpaceSelector.js'; -import { organizationMemberSelector } from './organizationMemberSelector.js'; -import Container from 'gComponents/utility/container/Container.js' +import * as userOrganizationMembershipActions from 'gModules/userOrganizationMemberships/actions' +import { organizationSpaceSelector } from './organizationSpaceSelector' +import { organizationMemberSelector } from './organizationMemberSelector' +import { httpRequestSelector } from './httpRequestSelector' +import SpaceCards from 'gComponents/spaces/cards' +import Container from 'gComponents/utility/container/Container' import e from 'gEngine/engine' + +import * as modalActions from 'gModules/modal/actions' + import './style.css' function mapStateToProps(state) { @@ -16,20 +26,17 @@ function mapStateToProps(state) { } } -const Member = ({user}) => ( -
- -
-) - @connect(mapStateToProps) @connect(organizationSpaceSelector) @connect(organizationMemberSelector) +@connect(httpRequestSelector) export default class OrganizationShow extends Component{ displayName: 'OrganizationShow' state = { - attemptedFetch: false + attemptedFetch: false, + openTab: 'MODELS', + subMembersTab: 'INDEX' } componentWillMount() { @@ -48,45 +55,322 @@ export default class OrganizationShow extends Component{ } } + changeTab(tab) { + this.setState({ + openTab: tab, + subMembersTab: 'INDEX' + }) + } + + destroyMembership(membershipId) { + this.props.dispatch(userOrganizationMembershipActions.destroy(membershipId)) + } + + addUser(email) { + this.props.dispatch(userOrganizationMembershipActions.createWithEmail(this.props.organizationId, email)) + } + + onRemove(member) { + this.confirmRemove(member) + } + + confirmRemove({email, name, membershipId}) { + const removeCallback = () => { + this.destroyMembership(membershipId) + this.props.dispatch(modalActions.close()) + } + + const message = `Are you sure you want to remove ${name} from this organization?` + + this.props.dispatch(modalActions.openConfirmation({onConfirm: removeCallback, message})) + } + + render () { - const {organizationId, organizations, members} = this.props + const {organizationId, organizations, members, memberships, invitations} = this.props + const unjoinedInvitees = invitations.filter(i => !_.some(memberships, m => m.invitation_id === i.id)) + const {openTab} = this.state const spaces = _.orderBy(this.props.organizationSpaces.asMutable(), ['updated_at'], ['desc']) const organization = organizations.find(u => u.id.toString() === organizationId.toString()) + const meIsAdmin = !!organization && (organization.admin_id === this.props.me.id) + const meIsMember = meIsAdmin || !!(members.find(m => m.id === this.props.me.id)) return ( -
-
-
-
- {organization && -
-
- -
-

- {organization.name} -

-
- {members && members.map(m => { - return () - })} -
-
- } -
-
+
-
- {spaces && - - } -
+ + + {meIsMember && + + } + +
+ {(openTab === 'MODELS' || !meIsMember) && spaces && + + } + + {(openTab === 'MEMBERS') && meIsMember && members && organization && + {this.setState({subMembersTab: name})}} + httpRequests={this.props.httpRequests} + meIsAdmin={meIsAdmin} + /> + }
) } } + +const OrganizationHeader = ({organization}) => ( +
+
+
+ {organization && +
+
+ +

+ {organization.name} +

+
+
+ } +
+
+) + +const OrganizationTabButtons = ({tabs, openTab, changeTab}) => ( +
+
+
+ { tabs.map( e => { + const className = `item ${(openTab === e.key) ? 'active' : ''}` + return ( + {changeTab(e.key)}}> {e.name} + ) + })} +
+
+
+) + +const MembersTab = ({subTab, members, invitations, admin_id, onRemove, addUser, onChangeSubTab, httpRequests, meIsAdmin}) => ( +
+ {subTab === 'ADD' && + + } + {subTab === 'INDEX' && + + } +
+) + +const MembersIndexSubTab = ({subTab, members, invitations, admin_id, onChangeSubTab, onRemove, meIsAdmin}) => ( +
+
+ {subTab === 'INDEX' && meIsAdmin && +
{onChangeSubTab('ADD')}}> + Add Members +
+ } +
+
+ {subTab === 'INDEX' && +
+
+ {members.map(m => { + return ( + {onRemove(m)}} + meIsAdmin={meIsAdmin} + /> + ) + })} + {meIsAdmin && invitations.map(i => { + return ( + + ) + })} +
+
+ } +
+
+) + +const Invitee = ({email}) => ( +
+
+
+
+
{email}
+
+
+
invited
+
+
+
+) + +const Member = ({user, isAdmin, onRemove, meIsAdmin}) => ( +
+ {meIsAdmin && +
+
+ + {user.name} +
+
+ {isAdmin ? 'Admin' : 'Editor'} +
+
joined
+
+ {user.membershipId && !isAdmin && + + } +
+
+ } + {!meIsAdmin && +
+
+ + {user.name} +
+
+ } +
+) + +function validateEmail(email) { + var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/; + return re.test(email); +} + +class MembersAddSubTab extends Component{ + state = { value: '' } + + componentDidMount() { + this.refs.input.focus() + } + + _submit() { + this.props.addUser(this.state.value) + this.setState({value: ''}) + } + + _onChange(e) { + this.setState({value: e.target.value}) + } + + _onKeyDown(e) { + if (e.keyCode === 13 && validateEmail(this.state.value)) { + this._submit(); + } + } + + render() { + const {value} = this.state + const isValid = validateEmail(value) + const isEmpty = _.isEmpty(value) + const buttonColor = (isValid || isEmpty) ? 'green' : 'grey' + + const requests = _.orderBy(_.cloneDeep(this.props.httpRequests), ['created_at'], ['desc']) + return( +
+
+
{this.props.onChangeSubTab('INDEX')}}> + Member List +
+
+
+

Invite New Members

+

Members have viewing & editing access to all organization models.
If you are on a plan, your pricing will be adjusted within 24 hours.

+
+
+ + +
+
+ Invite User +
+
+
+
+ {_.map(requests, (request) => { + return( + + ) + })} +
+
+ ) + } +} + +const InvitationHttpRequest = ({busy, success, email, isExistingMember}) => { + let status = httpStatus(busy, success) + + if (status === 'sending'){ return ( +
+ Sending... +
+ )} else if (status === 'failure'){ return ( +
+ Invitation to {email} failed. This could be because they are already part of the organization or because of a server problem. If it continues, please let us know. +
+ )} else if (isExistingMember){ return ( +
+ {email} was added to your organization. +
+ )} else { return( +
+ {email} was sent an email invitation to join your organization. +
+ )} +} + +function httpStatus(busy, success) { + if (busy) { return 'sending' } + else if (success) { return 'success' } + else { return 'failure' } +} diff --git a/src/components/organizations/show/organizationMemberSelector.js b/src/components/organizations/show/organizationMemberSelector.js index b8464fc15..4ebf73576 100644 --- a/src/components/organizations/show/organizationMemberSelector.js +++ b/src/components/organizations/show/organizationMemberSelector.js @@ -2,16 +2,20 @@ import { createSelector } from 'reselect'; import e from 'gEngine/engine' const userOrganizationMembershipSelector = state => state.userOrganizationMemberships +const userOrganizationInvitationSelector = state => state.userOrganizationInvitations const userSelector = state => state.users const organizationIdSelector = (state, props) => props.organizationId export const organizationMemberSelector = createSelector( userOrganizationMembershipSelector, + userOrganizationInvitationSelector, userSelector, organizationIdSelector, - (memberships, users, organizationId) => { + (memberships, invitations, users, organizationId) => { return { - members: e.organization.organizationUsers(organizationId, users, memberships) + members: e.organization.organizationUsers(organizationId, users, memberships), + memberships: e.organization.organizationMemberships(organizationId, memberships), + invitations: e.organization.organizationInvitations(organizationId, invitations), } } ); diff --git a/src/components/organizations/show/style.css b/src/components/organizations/show/style.css index 032933816..bd8e72a30 100644 --- a/src/components/organizations/show/style.css +++ b/src/components/organizations/show/style.css @@ -1,28 +1,134 @@ -.organizationShow .main-organization-tag { - text-align: center; - margin-right: 0.5em; - margin-top: 0.25em; -} +@import './styles/variables.css'; + +.OrganizationShow { + .main-section { + margin-top: 2em; + } + + .ui.menu .item.active{ + background: #CACACA; + } + + .ui.tabular.menu.reversed .active.item{ + background: none #F7F5F5; + border-radius: 0.15rem 0.15rem 0 0 !important; + } + + .OrganizationHeader { + h1 { + margin-bottom: 0; + margin-top: 0.5rem; + } -.organizationShow .main-organization-tag img{ - max-width: 10em; - max-height: 8em; - border-radius: 0.5em; - margin-bottom: 10px; + .center-display { + text-align: center; + margin-right: 0.5em; + margin-top: 0.25em; + } + + img { + max-width: 4em; + max-height: 5em; + border-radius: 0.5em; + margin-bottom: 2px; + } + } + + .OrganizationTabButtons { + .ui.menu { + float: right; + margin-top: 0; + } + + .ui.menu .item{ + font-weight: 800; + font-size: 1.2em; + color: #444; + } + } } -.organizationShow .members { - margin-top: 30px; - float: left; +.OrganizationShow .MembersAddSubTab .ui.form{ + font-size: 1.2em; + margin-top: 4em; + margin-bottom: 4em; } -.organizationShow .member { - float: left; - margin-right: 10px; - margin-bottom: 10px; +.OrganizationShow .MembersAddSubTab p{ + color: #666; + font-size: 1.2em; + line-height: 1.7em; } -.organizationShow .member img{ - width: 40px; - border-radius: 3em; +.OrganizationShow .MembersTab { + margin-top: 7em; + + .ui.button.green{ + background-color: $green-2; + } + + .members { + width: 100%; + background-color: white; + border-radius: 3px; + padding: 0 1em; + } + + .Member { + padding: 1em 0.2em; + border-bottom: 1px solid #eee; + + .role{ + margin-top: 0.6em; + color: $grey-2; + font-weight: bold; + font-size: 1.2em; + } + + .button.remove { + margin-top: 0.2em; + float: right; + font-size: 1.2em !important; + padding: 7px 10px !important; + } + + .button.remove i{ + margin: 0 !important; + } + + .name { + float: left; + font-size: 1.3em; + font-weight: 800; + color: $black-3; + margin-top: 0.5em; + } + + .invitation-status { + font-size: 1.2em; + color: #666; + margin-top: 0.6em; + } + + .avatar { + float: left; + width: 40px; + border-radius: 3px; + margin-right: 1em; + font-size: 2.9em; + margin-left: 0.3em; + color: $blue-5; + text-align: center; + } + + .avatar .fa{ + font-size: 0.6em; + margin-top: 5px; + opacity: 0.7; + } + + &:last-child { + border-bottom: none; + } + } } diff --git a/src/components/spaces/cards/style.css b/src/components/spaces/cards/style.css index 413c33826..46cd36d48 100644 --- a/src/components/spaces/cards/style.css +++ b/src/components/spaces/cards/style.css @@ -27,7 +27,7 @@ .SpaceCard .image { min-height: 11em; position: relative; - background-color: #D8DADC; + background-color: #D9DEE2; float: left; flex: 0 0 auto; } @@ -51,9 +51,9 @@ } .SpaceCard .image .snapshot.blank img{ - width: 31%; - max-width: 13em; - opacity: 0.3; + height: 67%; + width: auto; + opacity: 0.23; margin-bottom: 1em; } diff --git a/src/lib/engine/organization.js b/src/lib/engine/organization.js index cf71b5625..3bb6afc57 100644 --- a/src/lib/engine/organization.js +++ b/src/lib/engine/organization.js @@ -1,5 +1,9 @@ import * as _userOrganizationMemberships from './userOrganizationMemberships' +function sameId(first, second) { + return (parseInt(first) === parseInt(second)) +} + export function url(organization) { return (!!organization) ? urlById(organization.id) : '' } @@ -9,5 +13,18 @@ export function urlById(id) { } export function organizationUsers(organizationId, users, memberships) { - return _.filter(users, u => _userOrganizationMemberships.isMember(organizationId, u.id, memberships)) + let filteredMemberships = organizationMemberships(organizationId, memberships) + let filteredUsers = _.filter(users, u => _userOrganizationMemberships.isMember(organizationId, u.id, filteredMemberships)) + return filteredUsers.map(e => { + let membership = filteredMemberships.find(m => sameId(m.user_id, e.id)) + return {...e, membershipId: (membership && membership.id)} + }) +} + +export function organizationMemberships(organizationId, memberships) { + return _.filter(memberships, e => sameId(e.organization_id, organizationId)) +} + +export function organizationInvitations(organizationId, invitations) { + return _.filter(invitations, e => sameId(e.organization_id, organizationId)) } diff --git a/src/lib/guesstimate_api/AbstractResource.js b/src/lib/guesstimate_api/AbstractResource.js index 18d41031d..232c7b8bb 100644 --- a/src/lib/guesstimate_api/AbstractResource.js +++ b/src/lib/guesstimate_api/AbstractResource.js @@ -27,7 +27,7 @@ export default class AbstractResource { const params = this.guesstimateRequest({url, method, data}) const request = $.ajax(params) request.done((response) => {callback(null, response)}) - request.fail((jqXHR, textStatus, errorThrown) => {callback(errorThrown, null)}) + request.fail((jqXHR, textStatus, errorThrown) => {callback({jqXHR, textStatus, errorThrown}, null)}) } } } diff --git a/src/lib/guesstimate_api/index.js b/src/lib/guesstimate_api/index.js index 1420f7612..4bc104c55 100644 --- a/src/lib/guesstimate_api/index.js +++ b/src/lib/guesstimate_api/index.js @@ -3,6 +3,7 @@ import Organizations from './resources/Organizations.js' import Users from './resources/Users.js' import Accounts from './resources/Accounts.js' import Copies from './resources/Copies.js' +import UserOrganizationMemberships from './resources/UserOrganizationMemberships.js' export default class GuesstimateApi { @@ -14,5 +15,6 @@ export default class GuesstimateApi { this.organizations = new Organizations(this) this.copies = new Copies(this) this.accounts = new Accounts(this) + this.userOrganizationMemberships = new UserOrganizationMemberships(this) } } diff --git a/src/lib/guesstimate_api/resources/Organizations.js b/src/lib/guesstimate_api/resources/Organizations.js index a84ac0660..0c4748f41 100644 --- a/src/lib/guesstimate_api/resources/Organizations.js +++ b/src/lib/guesstimate_api/resources/Organizations.js @@ -14,4 +14,19 @@ export default class Organizations extends AbstractResource { this.guesstimateMethod({url, method})(callback) } + + addMember({organizationId, email}, callback) { + const url = `organizations/${organizationId}/members` + const method = 'POST' + const data = {email} + + this.guesstimateMethod({url, method, data})(callback) + } + + getInvitations({organizationId}, callback) { + const url = `organizations/${organizationId}/invitees` + const method = 'GET' + + this.guesstimateMethod({url, method})(callback) + } } diff --git a/src/lib/guesstimate_api/resources/UserOrganizationMemberships.js b/src/lib/guesstimate_api/resources/UserOrganizationMemberships.js new file mode 100644 index 000000000..f4848b565 --- /dev/null +++ b/src/lib/guesstimate_api/resources/UserOrganizationMemberships.js @@ -0,0 +1,9 @@ +import AbstractResource from '../AbstractResource.js' + +export default class UserOrganizationMemberships extends AbstractResource { + destroy({userOrganizationMembershipId}, callback) { + const url = `user_organization_memberships/${userOrganizationMembershipId}` + const method = 'DELETE' + this.guesstimateMethod({url, method})(callback) + } +} diff --git a/src/modules/httpRequests/actions.js b/src/modules/httpRequests/actions.js new file mode 100644 index 000000000..64c5a9cd5 --- /dev/null +++ b/src/modules/httpRequests/actions.js @@ -0,0 +1,11 @@ +export const start = ({id, entity, metadata}) => { + return { type: 'HTTP_REQUEST_START', id, entity, metadata} +} + +export const success = ({id, response}) => { + return { type: 'HTTP_REQUEST_SUCCESS', id, response} +} + +export const failure = ({id, error}) => { + return { type: 'HTTP_REQUEST_FAILURE', id, error} +} diff --git a/src/modules/httpRequests/reducer.js b/src/modules/httpRequests/reducer.js new file mode 100644 index 000000000..55115e7cd --- /dev/null +++ b/src/modules/httpRequests/reducer.js @@ -0,0 +1,47 @@ +function findIndex(state, id) { return state.findIndex(y => y.id === id) } + +export const httpRequestsR = (state = [], action) => { + let id, request, i + + switch (action.type) { + case 'HTTP_REQUEST_START': + request = { + id: action.id, + created_at: Date.now(), + entity: action.entity, + metadata: action.metadata, + busy: true, + success: false + } + return [...state, request] + case 'HTTP_REQUEST_SUCCESS': + id = action.id + i = findIndex(state, id) + + if (i !== -1) { + request = { + ...state[i], + busy: false, + success: true, + response: action.response + } + return [ ...state.slice(0, i), request, ...state.slice(i+1, state.length) ]; + } + case 'HTTP_REQUEST_FAILURE': + id = action.id + i = findIndex(state, id) + + if (i !== -1) { + request = { + ...state[i], + busy: false, + success: false, + error: action.error + } + return [ ...state.slice(0, i), request, ...state.slice(i+1, state.length) ]; + } + default: + return state + } +} + diff --git a/src/modules/modal/actions.js b/src/modules/modal/actions.js index 1c555a567..f478739eb 100644 --- a/src/modules/modal/actions.js +++ b/src/modules/modal/actions.js @@ -6,6 +6,10 @@ export function openFirstSubscription(planId) { return { type: 'MODAL_CHANGE', componentName: 'firstSubscription', props: {planId} }; } +export function openConfirmation(props) { + return { type: 'MODAL_CHANGE', componentName: 'confirmation', props }; +} + export function change({componentName, props}) { return { type: 'MODAL_CHANGE', componentName, props }; } diff --git a/src/modules/modal/routes.js b/src/modules/modal/routes.js index efd70983c..68d135861 100644 --- a/src/modules/modal/routes.js +++ b/src/modules/modal/routes.js @@ -7,9 +7,22 @@ import NavHelper from 'gComponents/utility/NavHelper/index.js'; import FirstSubscriptionContainer from 'gComponents/subscriptions/FirstSubscription/container.js' import * as modalActions from 'gModules/modal/actions.js' +export default class Confirmation extends Component{ + render() { + return ( +
+

{this.props.message}

+ + +
+ ) + } +} + const routes = [ {name: 'settings', component: SettingsContainer}, - {name: 'firstSubscription', component: FirstSubscriptionContainer} + {name: 'firstSubscription', component: FirstSubscriptionContainer}, + {name: 'confirmation', component: Confirmation} ] function getComponent(componentName) { @@ -38,7 +51,7 @@ export default class ModalRouter extends Component{ bottom: 'inherit', background: 'none', border: 'none', - padding: '0', + padding: 0, marginRight: 'auto', marginLeft: 'auto', marginTop: '14%' diff --git a/src/modules/organizations/actions.js b/src/modules/organizations/actions.js index 1e62842b1..758e9cda6 100644 --- a/src/modules/organizations/actions.js +++ b/src/modules/organizations/actions.js @@ -1,9 +1,11 @@ import {actionCreatorsFor} from 'redux-crud' +import cuid from 'cuid' import * as displayErrorsActions from 'gModules/displayErrors/actions.js' import * as membershipActions from 'gModules/userOrganizationMemberships/actions.js' import {captureApiError} from 'lib/errors/index.js' import {setupGuesstimateApi} from 'servers/guesstimate-api/constants.js' import * as userOrganizationMembershipActions from 'gModules/userOrganizationMemberships/actions.js' +import * as userOrganizationInvitationActions from 'gModules/userOrganizationInvitations/actions.js' let sActions = actionCreatorsFor('organizations') @@ -23,11 +25,11 @@ export function fetchById(organizationId) { } else if (organization) { dispatch(sActions.fetchSuccess([organization])) - const members = !!organization.members ? organization.members : [] + const memberships = !!organization.memberships ? organization.memberships : [] const invitations = !!organization.invitations ? organization.invitations : [] - const formatted = members.map(m => _.pick(m, ['id', 'user_id', 'organization_id'])) - dispatch(userOrganizationMembershipActions.sActions.fetchSuccess(formatted)) + dispatch(userOrganizationMembershipActions.fetchSuccess(memberships)) + dispatch(userOrganizationInvitationActions.fetchSuccess(invitations)) } }) } @@ -35,7 +37,26 @@ export function fetchById(organizationId) { export function fetchSuccess(organizations) { return (dispatch) => { - const formatted = organizations.map(o => _.pick(o, ['id', 'name', 'picture'])) + const formatted = organizations.map(o => _.pick(o, ['id', 'name', 'picture', 'admin_id'])) dispatch(sActions.fetchSuccess(formatted)) } } + +export function addMember(organizationId, email) { + return (dispatch, getState) => { + const cid = cuid() + let object = {id: cid, organization_id: organizationId, user_id: 4} + + const action = sActions.createStart(object); + + api(getState()).organizations.addMember({organizationId, email}, (err, membership) => { + if (err) { + dispatch(sActions.createError(err, object)) + } + else if (membership) { + dispatch(userActions.fetchSuccess([membership._embedded.user])) + dispatch(membershipActions.createSuccess([membership])) + } + }) + } +} diff --git a/src/modules/reducers.js b/src/modules/reducers.js index 91719e892..a450360fe 100644 --- a/src/modules/reducers.js +++ b/src/modules/reducers.js @@ -16,6 +16,7 @@ import firstSubscriptionsR from './first_subscription/reducer' import modalR from './modal/reducer' import {copiedR} from './copied/reducer' import {checkpointsR} from './checkpoints/reducer' +import {httpRequestsR} from './httpRequests/reducer.js' export function changeSelect(location) { return { type: 'CHANGE_SELECT', location }; @@ -33,6 +34,7 @@ const rootReducer = function app(state = {}, action){ users: SI(reduxCrud.reducersFor('users')(state.users, action)), organizations: SI(reduxCrud.reducersFor('organizations')(state.organizations, action)), userOrganizationMemberships: SI(reduxCrud.reducersFor('userOrganizationMemberships')(state.userOrganizationMemberships, action)), + userOrganizationInvitations: SI(reduxCrud.reducersFor('userOrganizationInvitations')(state.userOrganizationInvitations, action)), me: SI(meR(state.me, action)), canvasState: SI(canvasStateR(state.canvasState, action)), searchSpaces: SI(searchSpacesR(state.searchSpaces, action)), @@ -40,6 +42,7 @@ const rootReducer = function app(state = {}, action){ modal: SI(modalR(state.modal, action)), copied: SI(copiedR(state.copied, action)), checkpoints: SI(checkpointsR(state.checkpoints, action)), + httpRequests: SI(httpRequestsR(state.httpRequests, action)) } } diff --git a/src/modules/userOrganizationInvitations/actions.js b/src/modules/userOrganizationInvitations/actions.js new file mode 100644 index 000000000..06ada6809 --- /dev/null +++ b/src/modules/userOrganizationInvitations/actions.js @@ -0,0 +1,45 @@ +import {actionCreatorsFor} from 'redux-crud' + +import cuid from 'cuid' + +import * as displayErrorsActions from 'gModules/displayErrors/actions' +import * as userActions from 'gModules/users/actions' +import * as membershipActions from 'gModules/userOrganizationMemberships/actions' + +import {captureApiError} from 'lib/errors/index' + +import {setupGuesstimateApi} from 'servers/guesstimate-api/constants' + +let sActions = actionCreatorsFor('userOrganizationInvitations') +let relevantAttributes = ['id', 'email', 'organization_id'] + +function api(state) { + function getToken(state) { + return _.get(state, 'me.token') + } + return setupGuesstimateApi(getToken(state)) +} + +export function fetchByOrganizationId(organizationId) { + return (dispatch, getState) => { + api(getState()).organizations.getInvitations({organizationId}, (err, invitations) => { + if (err) { + dispatch(displayErrorsActions.newError()) + captureApiError('OrganizationsInvitationsFetch', null, null, err, {url: 'fetchMembers'}) + } else if (invitations) { + dispatch(fetchSuccess(invitations.items)) + + const memberships = invitations.items.map(i => _.get(i, '_embedded.membership')).filter(m => !!m) + dispatch(membershipActions.fetchSuccess(memberships)) + + const users = memberships.map(m => _.get(m, '_embedded.user')).filter(u => !!u) + dispatch(userActions.fetchSuccess(users)) + } + }) + } +} + +export function fetchSuccess(invitations) { + const formatted = invitations.map(m => _.pick(m, relevantAttributes)) + return sActions.fetchSuccess(formatted) +} diff --git a/src/modules/userOrganizationMemberships/actions.js b/src/modules/userOrganizationMemberships/actions.js index a54aa7e56..f0a4baa05 100644 --- a/src/modules/userOrganizationMemberships/actions.js +++ b/src/modules/userOrganizationMemberships/actions.js @@ -1,12 +1,19 @@ import {actionCreatorsFor} from 'redux-crud' -import * as displayErrorsActions from 'gModules/displayErrors/actions.js' -import * as userActions from 'gModules/users/actions.js' -import * as organizationActions from 'gModules/organizations/actions.js' -import {rootUrl} from 'servers/guesstimate-api/constants.js' -import {captureApiError} from 'lib/errors/index.js' -import {setupGuesstimateApi} from 'servers/guesstimate-api/constants.js' -export const sActions = actionCreatorsFor('userOrganizationMemberships') +import cuid from 'cuid' + +import * as displayErrorsActions from 'gModules/displayErrors/actions' +import * as userActions from 'gModules/users/actions' +import * as invitationActions from 'gModules/userOrganizationInvitations/actions' +import * as organizationActions from 'gModules/organizations/actions' +import * as httpRequestActions from 'gModules/httpRequests/actions' + +import {captureApiError} from 'lib/errors/index' + +import {setupGuesstimateApi} from 'servers/guesstimate-api/constants' + +const sActions = actionCreatorsFor('userOrganizationMemberships') +const relevantAttributes = ['id', 'user_id', 'organization_id', 'invitation_id'] function api(state) { function getToken(state) { @@ -17,16 +24,12 @@ function api(state) { export function fetchByOrganizationId(organizationId) { return (dispatch, getState) => { - api(getState()).organizations.getMembers({organizationId}, (err, members) => { + api(getState()).organizations.getMembers({organizationId}, (err, memberships) => { if (err) { dispatch(displayErrorsActions.newError()) captureApiError('OrganizationsMemberFetch', null, null, err, {url: 'fetchMembers'}) - } else if (members) { - const formatted = members.items.map(m => _.pick(m, ['id', 'user_id', 'organization_id'])) - dispatch(sActions.fetchSuccess(formatted)) - - const users = members.items.map(m => _.get(m, '_embedded.user')).filter(u => !!u) - dispatch(userActions.fetchSuccess(users)) + } else if (memberships) { + dispatch(fetchSuccess(memberships.items)) } }) } @@ -39,8 +42,7 @@ export function fetchByUserId(userId) { dispatch(displayErrorsActions.newError()) captureApiError('OrganizationsMemberFetch', null, null, err, {url: 'fetchMembers'}) } else if (memberships) { - const formatted = memberships.items.map(m => _.pick(m, ['id', 'user_id', 'organization_id'])) - dispatch(sActions.fetchSuccess(formatted)) + dispatch(fetchSuccess(memberships.items)) const organizations = memberships.items.map(m => _.get(m, '_embedded.organization')).filter(o => !!o) dispatch(organizationActions.fetchSuccess(organizations)) @@ -48,3 +50,52 @@ export function fetchByUserId(userId) { }) } } + +export function fetchSuccess(memberships) { + return (dispatch, getState) => { + const formatted = memberships.map(m => _.pick(m, relevantAttributes)) + dispatch(sActions.fetchSuccess(formatted)) + + const users = memberships.map(m => _.get(m, '_embedded.user')).filter(u => !!u) + dispatch(userActions.fetchSuccess(users)) + } +} + +export function destroy(id) { + return (dispatch, getState) => { + dispatch(sActions.deleteStart({id})); + api(getState()).userOrganizationMemberships.destroy({userOrganizationMembershipId: id}, (err, value) => { + if (err) { + captureApiError('OrganizationsMemberDestroy', null, null, err, {url: 'destroyOrganizationMember'}) + } else { + dispatch(sActions.deleteSuccess({id})) + } + }) + } +} + +export function createWithEmail(organizationId, email) { + return (dispatch, getState) => { + const cid = cuid() + let object = {id: cid, organization_id: organizationId} + + dispatch(httpRequestActions.start({id: cid, entity: 'userOrganizationMembershipCreate', metadata: {organizationId, email}})) + api(getState()).organizations.addMember({organizationId, email}, (err, invitation) => { + if (err) { + dispatch(sActions.createError(err, object)) + dispatch(httpRequestActions.failure({id: cid, error: err})) + } + else if (invitation) { + dispatch(invitationActions.fetchSuccess([invitation])) + + const membership = _.get(invitation, '_embedded.membership') + const user = _.get(membership, '_embedded.user') + + if (membership) { dispatch(sActions.createSuccess(membership)) } + if (user) { dispatch(userActions.fetchSuccess([user]))} + + dispatch(httpRequestActions.success({id: cid, response: {hasUser: (!!user)}})) + } + }) + } +} diff --git a/src/modules/users/actions.js b/src/modules/users/actions.js index 6fe9de727..072760aaa 100644 --- a/src/modules/users/actions.js +++ b/src/modules/users/actions.js @@ -54,7 +54,7 @@ export function fetchById(userId) { } function formatUsers(unformatted) { - return unformatted.map(u => _.pick(u, ['auth0_id', 'id', 'name', 'picture'])) + return unformatted.map(u => _.pick(u, ['auth0_id', 'id', 'name', 'picture', 'sign_in_count'])) } export function fromSearch(spaces) { diff --git a/src/routes/main.css b/src/routes/main.css index 12541667f..ac1446c0a 100644 --- a/src/routes/main.css +++ b/src/routes/main.css @@ -25,3 +25,23 @@ body { body.remove-elev #elevio-widget { display: none!important; } + +.MainConfirmation { + background: white; + padding: 3em 4em; + border-radius: 2px; + text-align: center; + min-width: 12em; + max-width: 600px; +} + +.MainConfirmation .ui.button { + margin-right: 1em; +} + +.MainConfirmation h1{ + margin-bottom: 2em; + font-size: 1.6em; + color: #333; + line-height: 1.8em; +} diff --git a/src/routes/router.js b/src/routes/router.js index 363259ccc..83f930d65 100644 --- a/src/routes/router.js +++ b/src/routes/router.js @@ -77,6 +77,6 @@ export default Router.extend({ faq() { this.render() }, subscribe(id) { this.render() }, userShow(id) { this.render(, {backgroundColor: 'GREY'}) }, - organizationShow(id) { this.render() }, + organizationShow(id) { this.render(, {backgroundColor: 'GREY'}) }, pricing() { this.render(, {backgroundColor: 'BLUE'}) }, }) diff --git a/src/styles/variables.css b/src/styles/variables.css index d2f203d3a..e12c02724 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -16,6 +16,9 @@ $grey-666: #666; $grey-999: #999; $grey-eee: #eee; +$green-1: #25B530; +$green-2: #1EB128; + $purple-1: #7970A9; $purple-2: #6C5A8C; $purple-3: #EDE4F9;