{
: (No results to show)
}
+
+ {this.messages('perPage')}{' '}
+
+ {// Render users per page options.
+ [10, 25, 50, 100].map(val =>
+
+ {val}
+
+ )
+ }
+
+
{
this.toggleExpansion()
}
+ _getOrgLabel = (permissions: UserPermissions) => {
+ const {creatingUser, organizations} = this.props
+ if (!organizations) return null
+ const userOrganization = organizations.find(o => o.id === permissions.getOrganizationId())
+ const creatorIsApplicationAdmin = creatingUser.permissions &&
+ creatingUser.permissions.isApplicationAdmin()
+ return userOrganization && creatorIsApplicationAdmin
+ ? {userOrganization.name}
+ : null
+ }
+
+ /**
+ * Constructs label indicating user authorization level (e.g., app/org admin)
+ * or listing the projects the user has access to.
+ */
+ _getUserPermissionLabel = (permissions: UserPermissions) => {
+ const {projects} = this.props
+ // Default label to no projects found.
+ let labelText = this.messages('noProjectsFound')
+ let missingProjectCount = 0
+ let labelStyle, title
+ if (permissions.isApplicationAdmin()) {
+ labelStyle = 'danger'
+ labelText = this.messages('appAdmin')
+ } else if (permissions.canAdministerAnOrganization()) {
+ labelStyle = 'warning'
+ labelText = this.messages('orgAdmin')
+ } else {
+ const missingProjectIds = []
+ // Find project names for any projects that exist.
+ const projectNames = Object.keys(permissions.projectLookup)
+ .map(id => {
+ const project = projects.find(p => p.id === id)
+ // Use name of project for label (or track missing project with uuid).
+ // A missing project can occur when the same Auth0 tenant is used for
+ // multiple instances of Data Tools or if a project is deleted (but
+ // the permission is still attached to the user).
+ if (project) return project.name
+ missingProjectCount++
+ missingProjectIds.push(id)
+ return MISSING_PROJECT_VALUE
+ })
+ .filter(name => name)
+ const uniqueProjectNames = Array.from(new Set(projectNames))
+ // Build message based on number of projects.
+ if (uniqueProjectNames.length > 0) {
+ if (missingProjectCount > 0) {
+ // If any missing project ids, use warning label and show in title.
+ labelStyle = 'warning'
+ title = `${this.messages('missingProject')}: ${missingProjectIds.join(', ')}`
+ } else {
+ labelStyle = 'info'
+ }
+ labelText = uniqueProjectNames
+ // Replace uuid with missing project count message.
+ .map(name => name === MISSING_PROJECT_VALUE
+ ? `${missingProjectCount} ${this.messages('missingProject')}`
+ : name
+ )
+ .join(', ')
+ }
+ }
+ return {labelText}
+ }
+
save = () => {
const settings = this.refs.userSettings.getSettings()
this.props.updateUserData(this.props.user, settings)
@@ -69,7 +138,6 @@ export default class UserRow extends Component {
const permissions = new UserPermissions(user.app_metadata && user.app_metadata.datatools)
const creatorIsApplicationAdmin = creatingUser.permissions &&
creatingUser.permissions.isApplicationAdmin()
- const userOrganization = organizations.find(o => o.id === permissions.getOrganizationId())
const creatorDoesNotHaveOrg = !creatingUser.permissions ||
// $FlowFixMe
!creatingUser.permissions.hasOrganization(permissions.getOrganizationId())
@@ -92,16 +160,9 @@ export default class UserRow extends Component {
{user.email}{' '}
- {permissions.isApplicationAdmin()
- ? {this.messages('appAdmin')}
- : permissions.canAdministerAnOrganization()
- ? {this.messages('orgAdmin')}
- : null
- }{' '}
- {userOrganization && creatorIsApplicationAdmin
- ? {userOrganization.name}
- : null
- }
+ {this._getUserPermissionLabel(permissions)}
+ {' '}
+ {this._getOrgLabel(permissions)}
diff --git a/lib/admin/reducers/servers.js b/lib/admin/reducers/servers.js
index cb5f43ed2..225a52e6e 100644
--- a/lib/admin/reducers/servers.js
+++ b/lib/admin/reducers/servers.js
@@ -19,6 +19,18 @@ const servers = (state: AdminServersState = defaultState, action: Action): Admin
isFetching: { $set: false },
data: { $set: action.payload }
})
+ case 'RECEIVE_SERVER':
+ const serverData = action.payload
+ if (state.data) {
+ const serverIdx = state.data.findIndex(
+ server => server.id === serverData.id
+ )
+ return update(state, {
+ isFetching: { $set: false },
+ data: { [serverIdx]: { $set: action.payload } }
+ })
+ }
+ return state
case 'CREATED_SERVER':
if (state.data) {
return update(state, { data: { $push: [action.payload] } })
diff --git a/lib/admin/reducers/users.js b/lib/admin/reducers/users.js
index af031d6b5..6e12c9eab 100644
--- a/lib/admin/reducers/users.js
+++ b/lib/admin/reducers/users.js
@@ -6,11 +6,11 @@ import type {Action} from '../../types/actions'
import type {AdminUsersState} from '../../types/reducers'
export const defaultState = {
- isFetching: false,
data: null,
- userCount: 0,
+ isFetching: false,
page: 0,
perPage: 10,
+ userCount: 0,
userQueryString: null
}
@@ -32,6 +32,8 @@ const users = (state: AdminUsersState = defaultState, action: Action): AdminUser
return state
case 'SET_USER_PAGE':
return update(state, {page: { $set: action.payload }})
+ case 'SET_USER_PER_PAGE':
+ return update(state, {perPage: { $set: action.payload }})
case 'SET_USER_QUERY_STRING':
return update(state, {userQueryString: { $set: action.payload }})
default:
diff --git a/lib/alerts/components/AlertEditor.js b/lib/alerts/components/AlertEditor.js
index 0e1e26fd2..0fe7ffe6a 100644
--- a/lib/alerts/components/AlertEditor.js
+++ b/lib/alerts/components/AlertEditor.js
@@ -70,7 +70,7 @@ export default class AlertEditor extends Component {
validateAndSave = () => {
const {alert, saveAlert} = this.props
- const {affectedEntities, description, end, start, title} = alert
+ const {affectedEntities, end, start, title} = alert
const momentEnd = moment(end)
const momentStart = moment(start)
@@ -78,13 +78,6 @@ export default class AlertEditor extends Component {
if (!title.trim()) {
return window.alert('You must specify an alert title')
}
- // alert title/description must meet character limits (for display purposes)
- if (title.length > ALERT_TITLE_CHAR_LIMIT) {
- return window.alert(`Alert title must be ${ALERT_TITLE_CHAR_LIMIT} characters or less`)
- }
- if (description && description.length > ALERT_DESCRIPTION_CHAR_LIMIT) {
- return window.alert(`Alert description must be ${ALERT_DESCRIPTION_CHAR_LIMIT} characters or less`)
- }
if (!end || !start || !momentEnd.isValid() || !momentStart.isValid()) {
return window.alert('Alert must have a valid start and end date')
}
@@ -146,7 +139,14 @@ export default class AlertEditor extends Component {
const descriptionCharactersRemaining = alert.description
? ALERT_DESCRIPTION_CHAR_LIMIT - alert.description.length
: ALERT_DESCRIPTION_CHAR_LIMIT
- const canPublish = alert.affectedEntities.length &&
+ const titleCharacterCount = alert.title
+ ? alert.title.length
+ : 0
+ const descriptionCharactersCount = alert.description
+ ? alert.description.length
+ : 0
+ const canPublish =
+ alert.affectedEntities.length &&
checkEntitiesForFeeds(alert.affectedEntities, publishableFeeds)
const canEdit = checkEntitiesForFeeds(alert.affectedEntities, editableFeeds)
const editingIsDisabled = alert.published && !canPublish ? true : !canEdit
@@ -221,10 +221,17 @@ export default class AlertEditor extends Component {
: 'text-danger'
}
style={{fontWeight: 400}}>
- {titleCharactersRemaining}
+ {titleCharacterCount}
+ {titleCharacterCount > ALERT_TITLE_CHAR_LIMIT &&
+ (
+
+ WARNING: Alert title longer than {ALERT_TITLE_CHAR_LIMIT} characters may get truncated in some dissemination channels.
+
+ )
+ }
Note: alert title serves as text for eTID alerts. Use
descriptive language so it can serve as a standalone
alert.
@@ -290,7 +297,7 @@ export default class AlertEditor extends Component {
-
+
Description
@@ -302,18 +309,28 @@ export default class AlertEditor extends Component {
: 'text-danger'
}
style={{fontWeight: 400}}>
- {descriptionCharactersRemaining}
+ {descriptionCharactersCount}
+ {descriptionCharactersCount > ALERT_DESCRIPTION_CHAR_LIMIT &&
+ (
+
+
+ WARNING: Alert description longer than {ALERT_DESCRIPTION_CHAR_LIMIT} characters may get truncated in some dissemination channels.
+
+
+ )
+ }
+ onChange={this._onChange}
+ style={{ minHeight: '89px' }} />
-
+
URL
= 500) {
+ errorMessage = `Network error (${status})!\n\n(${method} request on ${url})`
+ }
+ return errorMessage
+}
+
export function createVoidPayloadAction (type: string) {
return () => ({ type })
}
-export function secureFetch (url: string, method: string = 'get', payload?: any, raw: boolean = false, isJSON: boolean = true, actionOnFail?: string): any {
+/**
+ * Shorthand fetch call to pass a file as formData on a POST request to the
+ * specified URL.
+ */
+export function postFormData (url: string, file: File, customHeaders?: {[string]: string}) {
return function (dispatch: dispatchFn, getState: getStateFn) {
- function consoleError (message) {
- console.error(`Error making ${method} request to ${url}: `, message)
+ if (!file) {
+ alert('No file to upload!')
+ return
}
+ const data = new window.FormData()
+ data.append('file', file)
+ return dispatch(secureFetch(url, 'post', data, false, false, undefined, customHeaders))
+ }
+}
+export function secureFetch (
+ url: string,
+ method: string = 'get',
+ payload?: any,
+ raw: boolean = false,
+ isJSON: boolean = true,
+ actionOnFail?: string,
+ customHeaders?: {[string]: string}
+): any {
+ return function (dispatch: dispatchFn, getState: getStateFn) {
// if running in a test environment, fetch will complain when using relative
// urls, so prefix all urls with http://localhost:4000.
if (process.env.NODE_ENV === 'test') {
@@ -31,7 +63,8 @@ export function secureFetch (url: string, method: string = 'get', payload?: any,
}
const headers: {[string]: string} = {
'Authorization': `Bearer ${token}`,
- 'Accept': 'application/json'
+ 'Accept': 'application/json',
+ ...customHeaders
}
if (isJSON) {
headers['Content-Type'] = 'application/json'
@@ -42,43 +75,60 @@ export function secureFetch (url: string, method: string = 'get', payload?: any,
return fetch(url, {method, headers, body})
// Catch basic error during fetch
.catch(err => {
- const message = `Error making request: (${err})`
- consoleError(message)
+ const message = getErrorMessage(method, url)
+ console.error(message, err)
return dispatch(setErrorMessage({message}))
})
- .then(res => {
- // if raw response is requested
- if (raw) return res
- let action, message
- // check for errors
- const {status} = res
- if (status >= 500) {
- action = 'RELOAD'
- message = `Network error!\n\n(${method} request on ${url})`
- consoleError(message)
- dispatch(setErrorMessage({message, action}))
- return null
- } else if (status >= 400) {
- action = status === 401
- ? 'LOG_IN'
- : actionOnFail
- res.json()
- .then(json => {
- const {detail, message} = json
- const unknown = `Unknown (${status}) error occurred while making request`
- consoleError(message || JSON.stringify(json) || unknown)
- dispatch(setErrorMessage({
- message: message || JSON.stringify(json) || unknown,
- action,
- detail
- }))
- })
- return null
- } else {
- return res
- }
- })
+ .then(res => dispatch(handleResponse(method, url, res, raw, actionOnFail)))
+ }
+}
+
+function handleResponse (method, url, res, raw, actionOnFail) {
+ return async function (dispatch: dispatchFn, getState: getStateFn) {
+ // if raw response is requested
+ if (raw) return res
+ const {status} = res
+ // Return response with no further action if there are no errors.
+ if (status < 400) return res
+ // check for errors
+ let json
+ try {
+ json = await res.json()
+ } catch (e) {
+ console.warn('Could not parse JSON from error response')
+ }
+ const errorMessage = getErrorMessageFromJson(json, status, method, url, actionOnFail)
+ dispatch(setErrorMessage(errorMessage))
+ return null
+ }
+}
+
+function getErrorMessageFromJson (
+ json: ?ErrorResponse,
+ status,
+ method,
+ url,
+ actionOnFail
+) {
+ let action = 'RELOAD'
+ let detail
+ let errorMessage = getErrorMessage(method, url, status)
+ if (status < 500) {
+ action = status === 401
+ ? 'LOG_IN'
+ : actionOnFail
+ }
+ if (json) {
+ detail = json.detail
+ // if status >= 500 and detail is being overrode, add original network error in small text
+ if (status >= 500) {
+ detail = detail ? detail + errorMessage : errorMessage
+ }
+ // re-assign error message after it gets used in the detail.
+ errorMessage = json.message || JSON.stringify(json)
}
+ console.error(errorMessage)
+ return { action, detail, message: errorMessage }
}
function graphQLErrorsToString (errors: Array<{locations: any, message: string}>): Array {
diff --git a/lib/common/components/EditableTextField.js b/lib/common/components/EditableTextField.js
index 2cb4e269a..1dcc2db6b 100644
--- a/lib/common/components/EditableTextField.js
+++ b/lib/common/components/EditableTextField.js
@@ -94,8 +94,8 @@ export default class EditableTextField extends Component {
handleKeyDown = (e: SyntheticKeyboardEvent) => {
switch (e.keyCode) {
- case 9: // [Enter]
- case 13: // [Tab]
+ case 9: // [Tab]
+ case 13: // [Enter]
e.preventDefault()
if (this.state.isEditing) {
this._save(e)
diff --git a/lib/common/components/FeedLabel.js b/lib/common/components/FeedLabel.js
new file mode 100644
index 000000000..9936513d5
--- /dev/null
+++ b/lib/common/components/FeedLabel.js
@@ -0,0 +1,158 @@
+// @flow
+import React from 'react'
+import tinycolor from 'tinycolor2'
+import Icon from '@conveyal/woonerf/components/icon'
+import { connect } from 'react-redux'
+
+import { deleteLabel } from '../../manager/actions/labels'
+import type { Label } from '../../types'
+import type {ManagerUserState} from '../../types/reducers'
+import ConfirmModal from '../../common/components/ConfirmModal'
+import LabelEditorModal from '../../manager/components/LabelEditorModal'
+
+export type Props = {
+ checked?: boolean,
+ deleteLabel: Function,
+ editable?: boolean,
+ label: Label,
+ onClick?: Function,
+ user?: ManagerUserState
+}
+export type State = {}
+
+/**
+ * Generate lightened/darkened versions of a color for use in text and border rendering
+ * @param {string} cssHex The css hex value to modify
+ * @param {number} strength The amount to lighten or darken by
+ * @returns String with lightened/darkened css hex value
+ */
+const getComplementaryColor = (cssHex, strength) => {
+ const color = tinycolor(cssHex)
+
+ const complementary = color.isDark()
+ ? color.lighten(strength)
+ : color.darken(strength + 10)
+ return complementary.toHexString()
+}
+
+/**
+ * Renders a feed label, either large or small and optionally renders a checkbox or edit/delete
+ * buttons alongside the label
+ */
+class FeedLabel extends React.Component {
+ _onConfirmDelete = () => {
+ this.props.deleteLabel && this.props.deleteLabel(this.props.label)
+ }
+
+ _onClickDelete = () => {
+ this.refs.deleteModal.open()
+ }
+
+ _getWrapperClasses = () => {
+ const classes = ['feedLabelWrapper']
+ if (this._isEditable()) classes.push('withButtons')
+ return classes.join(' ')
+ }
+
+ _getLabelClasses = () => {
+ const classes = ['feedLabel']
+ classes.push('smaller')
+ if (this._isCheckable()) classes.push('clickable')
+ return classes.join(' ')
+ }
+
+ _isCheckable = () => this.props.checked !== undefined
+
+ _onClickEdit = () => {
+ this.refs.editModal.open()
+ }
+
+ _onClick = () => {
+ const {label, onClick} = this.props
+ onClick && onClick(label.id)
+ }
+
+ _isEditable = () => {
+ const {editable, label, user} = this.props
+ if (!editable) return false
+ const projectAdmin =
+ user &&
+ user.permissions &&
+ user.permissions.isProjectAdmin(label.projectId)
+ return projectAdmin
+ }
+
+ render () {
+ const {label, checked = false} = this.props
+
+ // Used to avoid collision when label is rendered multiple times
+ const uniqueId = `${label.id}-${Date.now().toString(36)}`
+
+ return (
+
+ {this._isCheckable() && (
+
+
+
+ )}
+
+
+ {label.adminOnly && }
+ {label.name}
+
+
+ {this._isEditable() && (
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+ }
+}
+
+const mapStateToProps = (state, ownProps) => {
+ return {
+ user: state.user
+ }
+}
+
+const mapDispatchToProps = {
+ deleteLabel
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(FeedLabel)
diff --git a/lib/common/components/Loading.js b/lib/common/components/Loading.js
index 0eae019f5..f7ffd80c4 100644
--- a/lib/common/components/Loading.js
+++ b/lib/common/components/Loading.js
@@ -4,10 +4,12 @@ import Icon from '@conveyal/woonerf/components/icon'
import React, {Component} from 'react'
import { Row, Col } from 'react-bootstrap'
+import type {Style} from '../../types'
+
type Props = {
inline?: boolean,
small?: boolean,
- style?: {[string]: string | number}
+ style?: Style
}
export default class Loading extends Component {
diff --git a/lib/common/components/MenuItem.js b/lib/common/components/MenuItem.js
new file mode 100644
index 000000000..a0d3eb342
--- /dev/null
+++ b/lib/common/components/MenuItem.js
@@ -0,0 +1,28 @@
+// @flow
+
+import Icon from '@conveyal/woonerf/components/icon'
+import * as React from 'react'
+import {MenuItem as BsMenuItem} from 'react-bootstrap'
+
+/**
+ * Simple wrapper around Bootstrap's menu item to inject a checkmark if the item
+ * is selected.
+ */
+const MenuItem = ({children, selected, ...menuItemProps}: {children?: React.Node, selected?: boolean}) => (
+
+ {selected
+ ?
+ : null
+ }
+ {children}
+
+)
+
+export default MenuItem
diff --git a/lib/common/components/SelectFileModal.js b/lib/common/components/SelectFileModal.js
index 4cf59efc8..d83e6c29c 100644
--- a/lib/common/components/SelectFileModal.js
+++ b/lib/common/components/SelectFileModal.js
@@ -8,21 +8,23 @@ type Props = {
body?: string,
errorMessage?: string,
onClose?: () => void,
- onConfirm?: (any) => boolean,
+ onConfirm?: (any) => Promise | boolean,
title?: string
}
type State = {
body?: string,
+ disabled?: boolean,
errorMessage?: string,
onClose?: () => void,
- onConfirm?: (any) => boolean,
+ onConfirm?: (any) => Promise | boolean,
showModal: boolean,
title?: string
}
export default class SelectFileModal extends Component {
state = {
+ disabled: false,
errorMessage: '',
onConfirm: (args: any) => false,
showModal: false
@@ -39,6 +41,7 @@ export default class SelectFileModal extends Component {
if (props) {
this.setState({
body: props.body,
+ disabled: false,
errorMessage: props.errorMessage,
onClose: props.onClose,
onConfirm: props.onConfirm,
@@ -46,13 +49,17 @@ export default class SelectFileModal extends Component {
title: props.title
})
} else {
- this.setState({ showModal: true })
+ this.setState({ disabled: false, showModal: true })
}
}
- ok = () => {
+ ok = async () => {
const {errorMessage: propsErrorMessage, onConfirm: propsConfirm} = this.props
const {errorMessage: stateErrorMessage, onConfirm: stateConfirm} = this.state
+
+ // disable buttons while "loading" response
+ this.setState({disabled: true})
+
if (!propsConfirm && !stateConfirm) {
return this.close()
}
@@ -61,23 +68,23 @@ export default class SelectFileModal extends Component {
const files = (node && node.files) ? node.files : []
if (propsConfirm) {
- if (propsConfirm(files)) {
+ if (await propsConfirm(files)) {
this.close()
} else {
- this.setState({errorMessage: propsErrorMessage || stateErrorMessage})
+ this.setState({disabled: false, errorMessage: propsErrorMessage || stateErrorMessage})
}
} else if (stateConfirm) {
if (stateConfirm(files)) {
this.close()
} else {
- this.setState({errorMessage: propsErrorMessage || stateErrorMessage})
+ this.setState({disabled: false, errorMessage: propsErrorMessage || stateErrorMessage})
}
}
}
render () {
const {Body, Footer, Header, Title} = Modal
- const {errorMessage} = this.state
+ const {disabled, errorMessage} = this.state
return (
@@ -94,8 +101,8 @@ export default class SelectFileModal extends Component {