Integrated master/detail, refactor Icon, add base router

This commit is contained in:
John Lyon-Smith
2018-05-12 12:36:39 -07:00
parent 84babf0e4b
commit 6fae5ef5d6
61 changed files with 1203 additions and 1620 deletions

View File

@@ -5104,9 +5104,9 @@
}
},
"react-form-binder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-form-binder/-/react-form-binder-1.2.0.tgz",
"integrity": "sha512-VFeiB5nCe01WU5aVJILMw7GLgOPsYJvdJEL9WRz7qecKDZx30sKA5bLDOWHsWQDZhediIr3KLpFkPxj0u89tDg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-form-binder/-/react-form-binder-2.0.0.tgz",
"integrity": "sha512-ihqbA3sp8eOOvjN2cSWOC7pfK+ukuRW5+dgpbrDJKnH/wgJ0LSMaJg2d/lX8bc0XO7+KxRJi7mBdizvCT1qhgQ==",
"requires": {
"eventemitter3": "^2.0.3",
"prop-types": "^15.5.10",

View File

@@ -23,7 +23,7 @@
"eventemitter3": "^3.1.0",
"moment": "^2.22.1",
"react": "^16.3.2",
"react-form-binder": "^1.2.0",
"react-form-binder": "^2.0.0",
"react-native": "^0.55.4",
"react-native-image-picker": "^0.26.7",
"react-native-iphone-x-helper": "^1.0.3",

View File

@@ -22,7 +22,7 @@ import {
BoundPhotoPanel,
FormStaticInput,
} from "../ui"
import { MessageModal, WaitModal } from "../Modal"
import { MessageModal, WaitModal, ProgressModal } from "../Modal"
import autobind from "autobind-decorator"
import KeyboardSpacer from "react-native-keyboard-spacer"
import { isIphoneX } from "react-native-iphone-x-helper"
@@ -88,6 +88,7 @@ export class Activity extends React.Component {
binder: new FormBinder({}, Activity.bindings),
waitModal: null,
messageModal: null,
progressModal: null,
}
const { search } = this.props.location
@@ -105,7 +106,7 @@ export class Activity extends React.Component {
this.setState({
binder: new FormBinder(
{
...this.state.binder.getOriginalFieldValues(),
...this.state.binder.originalObj,
workItem: workItem._id,
team: api.loggedInUser.team,
},
@@ -225,12 +226,35 @@ export class Activity extends React.Component {
@autobind
handleUploadStarted() {
this.setState({ waitModal: { message: "Uploading Photo..." } })
this.setState({
progressModal: { message: "Uploading Photo..." },
uploadPercent: 0,
})
}
@autobind
handleUploadEnded() {
this.setState({ waitModal: null })
handleUploadProgress(uploadData) {
console.log(uploadData)
if (this.state.progressModal) {
this.setState({
uploadPercent: Math.round(
uploadData.uploadedChunks / uploadData.numberOfChunks * 100
),
})
return true
} else {
return false
}
}
@autobind
handleUploadEnded(successful, uploadData) {
this.setState({ progressModal: null })
}
@autobind
handleUploadCanceled() {
this.setState({ progressModal: null })
}
render() {
@@ -313,11 +337,18 @@ export class Activity extends React.Component {
name="photos"
binder={binder}
onUploadStarted={this.handleUploadStarted}
onUploadProgress={this.handleUploadProgress}
onUploadEnded={this.handleUploadEnded}
/>
</View>
{isIphoneX ? <View style={{ height: 30, width: "100%" }} /> : null}
</ScrollView>
<ProgressModal
open={!!progressModal}
message={progressModal ? progressModal.message : ""}
percent={uploadPercent}
onCancel={this.handleUploadCanceled}
/>
<WaitModal
open={!!waitModal}
message={waitModal ? waitModal.message : ""}

View File

@@ -1,44 +1,15 @@
import passport from "passport"
import createError from "http-errors"
import autobind from "autobind-decorator"
import { catchAll, TeamRoutes } from "."
import { catchAll, TeamRoutes, BaseRoutes } from "."
@autobind
export class ActivityRoutes {
export class ActivityRoutes extends BaseRoutes {
constructor(container) {
super(container, container.db.Activity)
const app = container.app
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
app
.route("/activities")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.listActivities)
)
.post(
passport.authenticate("bearer", { session: false }),
catchAll(this.createActivity)
)
.put(
passport.authenticate("bearer", { session: false }),
catchAll(this.updateActivity)
)
app
.route("/activities/:_id([a-f0-9]{24})")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.getActivity)
)
.delete(
passport.authenticate("bearer", { session: false }),
catchAll(this.deleteActivity)
)
app
.route("/activities/all")
.delete(
@@ -47,117 +18,6 @@ export class ActivityRoutes {
)
}
async listActivities(req, res, next) {
const Activity = this.db.Activity
const limit = req.query.limit || 20
const skip = req.query.skip || 0
const partial = !!req.query.partial
let query = {}
const total = await Activity.count({})
let Activities = []
let cursor = Activity.find(query)
.limit(limit)
.skip(skip)
.cursor()
.map((doc) => {
return doc.toClient(partial)
})
cursor.on("data", (doc) => {
Activities.push(doc)
})
cursor.on("end", () => {
res.json({
total: total,
offset: skip,
count: activities.length,
items: activities,
})
})
cursor.on("error", (err) => {
throw createError.InternalServerError(err.message)
})
}
async createActivity(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
// Create a new Activity template then assign it to a value in the req.body
const Activity = this.db.Activity
let activity = new Activity(req.body)
// Save the activity (with promise) - If it doesnt, catch and throw error
const newActivity = await activity.save()
res.json(newActivity.toClient())
}
async updateActivity(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
throw createError.BadRequest("No _id given in body")
}
let Activity = this.db.Activity
let activity = await Activity.findById(req.body._id)
if (!activity) {
return next(
createError.NotFound(`Activity with _id ${req.body_id} was not found`)
)
}
let activityUpdates = new Activity(req.body)
// Strip off all BSON types
activity.merge(activityUpdates)
const savedActivity = await activity.save()
res.json(savedActivity.toClient())
}
async getActivity(req, res, next) {
const Activity = this.db.Activity
const _id = req.params._id
const activity = await Activity.findById(_id)
if (!activity) {
throw createError.NotFound(`Activity with _id ${_id} not found`)
}
res.json(activity.toClient())
}
async deleteActivity(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
const Activity = this.db.Activity
const _id = req.params._id
const activity = await Activity.remove({ _id })
if (!activity) {
throw createError.NotFound(`Activity with _id ${_id} not found`)
}
res.json({})
}
async deleteAllActivities(req, res, next) {
const Activity = this.db.Activity
const Team = this.db.Team

View File

@@ -11,19 +11,19 @@ import B64 from "b64"
import { PassThrough } from "stream"
import { catchAll } from "."
function pipeToGridFS(readable, gfsWriteable, decoder) {
function pipeToGridFS(readable, writable, decoder) {
const promise = new Promise((resolve, reject) => {
readable.on("error", (error) => {
reject(error)
})
gfsWriteable.on("error", (error) => {
writeable.on("error", (error) => {
reject(error)
})
gfsWriteable.on("close", (file) => {
writeable.on("finish", (file) => {
resolve(file)
})
})
readable.pipe(decoder).pipe(gfsWriteable)
readable.pipe(decoder).pipe(writeable)
return promise
}
@@ -73,12 +73,13 @@ export class AssetRoutes {
assetId = assetId.slice(0, extIndex)
}
const file = await this.db.gridfs.findOneAsync({ _id: assetId })
const cursor = await this.db.gridfs.findOne({ _id: assetId })
if (!file) {
if (!cursor) {
throw createError.NotFound(`Asset ${assetId} was not found`)
}
const file = cursor.next()
const ifNoneMatch = req.get("If-None-Match")
if (ifNoneMatch && ifNoneMatch === file.md5) {
@@ -98,13 +99,13 @@ export class AssetRoutes {
ETag: file.md5,
})
this.db.gridfs.createReadStream({ _id: file._id }).pipe(res)
this.db.gridfs.openDownloadStream(file._id).pipe(res)
}
async deleteAsset(req, res, next) {
const assetId = req.params._id
await this.db.gridfs.removeAsync({ _id: assetId })
await this.db.gridfs.delete(assetId)
res.json({})
}
@@ -235,11 +236,11 @@ export class AssetRoutes {
if (uploadedChunks >= uploadData.numberOfChunks) {
let readable = redisReadStream(this.rs.client, uploadDataId)
let writeable = this.db.gridfs.createWriteStream({
_id: uploadId,
filename: uploadData.fileName,
content_type: uploadData.contentType,
})
let writeable = this.db.gridfs.openUploadStreamWithId(
uploadId,
uploadData.fileName,
{ contentType: uploadData.contentType }
)
const decoder =
uploadData.chunkContentType === "application/base64"

View File

@@ -0,0 +1,147 @@
import passport from "passport"
import createError from "http-errors"
import autobind from "autobind-decorator"
import { catchAll } from "."
@autobind
export class BaseRoutes {
constructor(container, model) {
this.model = model
this.log = container.log
this.db = container.db
const basePath = "/" + model.collection.collectionName
const app = container.app
app
.route(basePath)
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.listItems)
)
.post(
passport.authenticate("bearer", { session: false }),
catchAll(this.createItem)
)
.put(
passport.authenticate("bearer", { session: false }),
catchAll(this.updateItem)
)
app
.route(basePath + "/:_id([a-f0-9]{24})")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.getItem)
)
.delete(
passport.authenticate("bearer", { session: false }),
catchAll(this.deleteItem)
)
}
async listItems(req, res, next) {
const ItemModel = this.model
const limit = req.query.limit || 20
const skip = req.query.skip || 0
const partial = !!req.query.partial
let query = {}
const total = await ItemModel.count({})
let items = []
let cursor = ItemModel.find(query)
.limit(limit)
.skip(skip)
.cursor()
.map((doc) => {
return doc.toClient(partial)
})
cursor.on("data", (doc) => {
items.push(doc)
})
cursor.on("end", () => {
res.json({
total: total,
offset: skip,
count: items.length,
items: items,
})
})
cursor.on("error", (err) => {
throw createError.InternalServerError(err.message)
})
}
async createItem(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
const ItemModel = this.model
let item = new ItemModel(req.body)
const newItem = await item.save()
res.json(newItem.toClient())
}
async updateItem(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
throw createError.BadRequest("No _id given in body")
}
let ItemModel = this.model
let item = await ItemModel.findById(req.body._id)
if (!item) {
return next(createError.NotFound(`Item with _id ${_id} was not found`))
}
item.merge(new ItemModel(req.body))
const savedItem = await item.save()
res.json(savedItem.toClient())
}
async getItem(req, res, next) {
const ItemModel = this.model
const _id = req.params._id
const item = await ItemModel.findById(_id)
if (!item) {
throw createError.NotFound(`Item with _id ${_id} not found`)
}
res.json(item.toClient())
}
async deleteItem(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
const ItemModel = this.model
const _id = req.params._id
const item = await ItemModel.remove({ _id })
if (!item) {
throw createError.NotFound(`Item with _id ${_id} not found`)
}
res.json({})
}
}

View File

@@ -3,45 +3,14 @@ import createError from "http-errors"
import autobind from "autobind-decorator"
import zlib from "zlib"
import { Readable } from "stream"
import { catchAll } from "."
import { catchAll, BaseRoutes } from "."
@autobind
export class TeamRoutes {
export class TeamRoutes extends BaseRoutes {
constructor(container) {
const app = container.app
super(container, container.db.Team)
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
app
.route("/teams")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.listTeams)
)
.post(
passport.authenticate("bearer", { session: false }),
catchAll(this.createTeam)
)
.put(
passport.authenticate("bearer", { session: false }),
catchAll(this.updateTeam)
)
app
.route("/teams/:_id([a-f0-9]{24})")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.getTeam)
)
.delete(
passport.authenticate("bearer", { session: false }),
catchAll(this.deleteTeam)
)
app
container.app
.route("/teams/status")
.get(
passport.authenticate("bearer", { session: false }),
@@ -49,109 +18,6 @@ export class TeamRoutes {
)
}
async listTeams(req, res, next) {
const Team = this.db.Team
let limit = req.query.limit || 20
let skip = req.query.skip || 0
let partial = !!req.query.partial
let query = {}
const total = await Team.count({})
let teams = []
let cursor = Team.find(query)
.limit(limit)
.skip(skip)
.cursor()
.map((doc) => {
return doc.toClient(partial)
})
cursor.on("data", (doc) => {
teams.push(doc)
})
cursor.on("end", () => {
res.json({
total: total,
offset: skip,
count: teams.length,
items: teams,
})
})
cursor.on("error", (err) => {
throw err
})
}
async createTeam(req, res, next) {
if (!req.user.administrator) {
throw createError.Forbidden()
}
// Create a new Team template then assign it to a value in the req.body
const Team = this.db.Team
let team = new Team(req.body)
const newTeam = await team.save()
res.json(newTeam.toClient())
}
async updateTeam(req, res, next) {
if (!req.user.administrator) {
throw createError.Forbidden()
}
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
throw createError.BadRequest("No _id given in body")
}
let Team = this.db.Team
let team = await Team.findById(req.body._id)
if (!team) {
throw createError.NotFound(`Team with _id ${req.body_id} was not found`)
}
let teamUpdates = new Team(req.body)
team.merge(teamUpdates)
const savedTeam = await team.save()
res.json(savedTeam.toClient())
}
async getTeam(req, res, next) {
const Team = this.db.Team
const _id = req.params._id
const team = await Team.findById(_id)
if (!team) {
throw createError.NotFound(`Team with _id ${_id} not found`)
}
res.json(team.toClient())
}
async deleteTeam(req, res, next) {
if (!req.user.administrator) {
throw createError.Forbidden()
}
const Team = this.db.Team
const _id = req.params._id
const removedTeam = await Team.remove({ _id })
if (!removedTeam) {
throw createError.NotFound(`Team with _id ${_id} not found`)
}
res.json({})
}
async getTeamStatus(req, res, next) {
const Team = this.db.Team
const Activity = this.db.Activity

View File

@@ -2,33 +2,14 @@ import passport from "passport"
import createError from "http-errors"
import autobind from "autobind-decorator"
import merge from "deepmerge"
import { catchAll } from "."
import { catchAll, BaseRoutes } from "."
@autobind
export class WorkItemRoutes {
export class WorkItemRoutes extends BaseRoutes {
constructor(container) {
super(container, container.db.WorkItem)
const app = container.app
this.log = container.log
this.db = container.db
this.mq = container.mq
this.ws = container.ws
app
.route("/workitems")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.listWorkItems)
)
.post(
passport.authenticate("bearer", { session: false }),
catchAll(this.createWorkItem)
)
.put(
passport.authenticate("bearer", { session: false }),
catchAll(this.updateWorkItem)
)
app
.route("/workitems/activities")
.get(
@@ -36,17 +17,6 @@ export class WorkItemRoutes {
catchAll(this.listWorkItemActivities)
)
app
.route("/workitems/:_id([a-f0-9]{24})")
.get(
passport.authenticate("bearer", { session: false }),
catchAll(this.getWorkItem)
)
.delete(
passport.authenticate("bearer", { session: false }),
catchAll(this.deleteWorkItem)
)
app
.route("/workitems/all")
.delete(
@@ -55,40 +25,6 @@ export class WorkItemRoutes {
)
}
async listWorkItems(req, res, next) {
const WorkItem = this.db.WorkItem
const limit = req.query.limit || 20
const skip = req.query.skip || 0
const partial = !!req.query.partial
let query = {}
const total = await WorkItem.count({})
let workItems = []
let cursor = WorkItem.find(query)
.limit(limit)
.skip(skip)
.cursor()
.map((doc) => {
return doc.toClient(partial)
})
cursor.on("data", (doc) => {
workItems.push(doc)
})
cursor.on("end", () => {
res.json({
total: total,
offset: skip,
count: workItems.length,
items: workItems,
})
})
cursor.on("error", (err) => {
throw createError.InternalServerError(err.message)
})
}
async listWorkItemActivities(req, res, next) {
const WorkItem = this.db.WorkItem
const aggregate = WorkItem.aggregate()
@@ -117,83 +53,6 @@ export class WorkItemRoutes {
res.json({ items })
}
async createWorkItem(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
// Create a new WorkItem template then assign it to a value in the req.body
const WorkItem = this.db.WorkItem
let workItem = new WorkItem(req.body)
// Save the workItem (with promise) - If it doesnt, catch and throw error
const newWorkItem = await workItem.save()
res.json(newWorkItem.toClient())
}
async updateWorkItem(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
// Do this here because Mongoose will add it automatically otherwise
if (!req.body._id) {
throw createError.BadRequest("No _id given in body")
}
let WorkItem = this.db.WorkItem
let workItem = await WorkItem.findById(req.body._id)
if (!workItem) {
return next(
createError.NotFound(`WorkItem with _id ${req.body_id} was not found`)
)
}
const workItemUpdates = new WorkItem(req.body)
workItem.merge(workItemUpdates)
const savedWorkItem = await workItem.save()
res.json(savedWorkItem.toClient())
}
async getWorkItem(req, res, next) {
const WorkItem = this.db.WorkItem
const _id = req.params._id
const workItem = await WorkItem.findById(_id)
if (!workItem) {
throw createError.NotFound(`WorkItem with _id ${_id} not found`)
}
res.json(workItem.toClient())
}
async deleteWorkItem(req, res, next) {
const isAdmin = req.user.administrator
if (!isAdmin) {
return new createError.Forbidden()
}
const WorkItem = this.db.WorkItem
const _id = req.params._id
const workItem = await WorkItem.remove({ _id })
if (!workItem) {
throw createError.NotFound(`WorkItem with _id ${_id} not found`)
}
res.json({})
}
async deleteAllWorkItems(req, res, next) {
const Activity = this.db.Activity
const WorkItem = this.db.WorkItem

View File

@@ -1,3 +1,4 @@
export { BaseRoutes } from "./BaseRoutes"
export { AuthRoutes } from "./AuthRoutes"
export { AssetRoutes } from "./AssetRoutes"
export { UserRoutes } from "./UserRoutes"

View File

@@ -1,6 +1,6 @@
import mongoose from "mongoose"
import mongodb from "mongodb"
import Grid from "gridfs-stream"
import { GridFSBucket } from "mongodb"
import mergePlugin from "mongoose-doc-merge"
import autobind from "autobind-decorator"
import * as Schemas from "./schemas"
@@ -18,9 +18,7 @@ export class DB {
autoIndex: !isProduction,
})
this.gridfs = Grid(connection.db, mongoose.mongo)
this.gridfs.findOneAsync = util.promisify(this.gridfs.findOne)
this.gridfs.removeAsync = util.promisify(this.gridfs.remove)
this.gridfs = new GridFSBucket(connection.db)
this.User = connection.model("User", Schemas.userSchema)
this.WorkItem = connection.model("WorkItem", Schemas.workItemSchema)

View File

@@ -24,6 +24,7 @@
"space-before-function-paren": ["error", "never"],
"comma-dangle": ["error", "only-multiline"],
"jsx-quotes": "off",
"quotes": "off"
"quotes": "off",
"indent": 0
}
}

View File

@@ -14167,9 +14167,9 @@
}
},
"react-form-binder": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/react-form-binder/-/react-form-binder-1.2.0.tgz",
"integrity": "sha512-VFeiB5nCe01WU5aVJILMw7GLgOPsYJvdJEL9WRz7qecKDZx30sKA5bLDOWHsWQDZhediIr3KLpFkPxj0u89tDg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-form-binder/-/react-form-binder-2.0.0.tgz",
"integrity": "sha512-ihqbA3sp8eOOvjN2cSWOC7pfK+ukuRW5+dgpbrDJKnH/wgJ0LSMaJg2d/lX8bc0XO7+KxRJi7mBdizvCT1qhgQ==",
"requires": {
"eventemitter3": "^2.0.3",
"prop-types": "^15.5.10",

View File

@@ -12,7 +12,7 @@
"radium": "^0.22.0",
"react": "^16.2.0",
"react-dom": "^16.2.0",
"react-form-binder": "^1.2.0",
"react-form-binder": "^2.0.0",
"react-router-dom": "^4.1.1",
"regexp-pattern": "^1.0.4",
"socket.io-client": "^2.0.3"

View File

@@ -2,7 +2,7 @@ import EventEmitter from "eventemitter3"
import io from "socket.io-client"
import autobind from "autobind-decorator"
const authTokenName = "AuthToken"
const authTokenKeyName = "AuthToken"
class NetworkError extends Error {
constructor(message) {
@@ -33,30 +33,31 @@ class APIError extends Error {
class API extends EventEmitter {
constructor() {
super()
this.user = null
let token =
localStorage.getItem(authTokenName) ||
sessionStorage.getItem(authTokenName)
localStorage.getItem(authTokenKeyName) ||
sessionStorage.getItem(authTokenKeyName)
if (token) {
this.token = token
this.user = { pending: true }
this._token = token
this._user = { pending: true }
this.who()
.then((user) => {
this.user = user
this._user = user
this.connectSocket()
this.emit("login")
})
.catch(() => {
localStorage.removeItem(authTokenName)
sessionStorage.removeItem(authTokenName)
this.token = null
this.user = null
localStorage.removeItem(authTokenKeyName)
sessionStorage.removeItem(authTokenKeyName)
this._token = null
this._user = {}
this.socket = null
this.emit("logout")
})
} else {
this._user = {}
}
}
@@ -64,7 +65,7 @@ class API extends EventEmitter {
this.socket = io(window.location.origin, {
path: "/api/socketio",
query: {
auth_token: this.token,
auth_token: this._token,
},
})
this.socket.on("disconnect", (reason) => {
@@ -77,10 +78,10 @@ class API extends EventEmitter {
// Filter the few massages that affect our cached user data to avoid a server round trip
switch (eventName) {
case "newThumbnailImage":
this.user.thumbnailImageId = eventData.imageId
this._user.thumbnailImageId = eventData.imageId
break
case "newProfileImage":
this.user.imageId = eventData.imageId
this._user.imageId = eventData.imageId
break
default:
// Nothing to see here...
@@ -99,15 +100,15 @@ class API extends EventEmitter {
}
get loggedInUser() {
return this.user
return this._user
}
makeImageUrl(id, size) {
if (id) {
return "/api/assets/" + id + "?access_token=" + this.token
return "/api/assets/" + id + "?access_token=" + this._token
} else if (size && size.width && size.height) {
return `/api/placeholders/${size.width}x${size.height}?access_token=${
this.token
this._token
}`
} else {
return null
@@ -115,11 +116,11 @@ class API extends EventEmitter {
}
makeAssetUrl(id) {
return id ? "/api/assets/" + id + "?access_token=" + this.token : null
return id ? "/api/assets/" + id + "?access_token=" + this._token : null
}
makeTeamStatusUrl() {
return `/api/teams/status?access_token=${this.token}`
return `/api/teams/status?access_token=${this._token}`
}
static makeParams(params) {
@@ -140,8 +141,8 @@ class API extends EventEmitter {
cache: "no-store",
}
let headers = new Headers()
if (this.token) {
headers.set("Authorization", "Bearer " + this.token)
if (this._token) {
headers.set("Authorization", "Bearer " + this._token)
}
if (method === "POST" || method === "PUT") {
if (requestOptions.binary) {
@@ -207,12 +208,12 @@ class API extends EventEmitter {
}
if (remember) {
localStorage.setItem(authTokenName, token)
localStorage.setItem(authTokenKeyName, token)
} else {
sessionStorage.setItem(authTokenName, token)
sessionStorage.setItem(authTokenKeyName, token)
}
this.token = token
this.user = response.body
this._token = token
this._user = response.body
this.connectSocket()
this.emit("login")
resolve(response.body)
@@ -225,10 +226,10 @@ class API extends EventEmitter {
logout() {
let cb = () => {
// Regardless of response, always logout in the client
localStorage.removeItem(authTokenName)
sessionStorage.removeItem(authTokenName)
this.token = null
this.user = null
localStorage.removeItem(authTokenKeyName)
sessionStorage.removeItem(authTokenKeyName)
this._token = null
this._user = {}
this.disconnectSocket()
this.emit("logout")
}
@@ -256,6 +257,8 @@ class API extends EventEmitter {
return this.post("/auth/password/reset", passwords)
}
// Users
getUser(id) {
return this.get("/users/" + id)
}
@@ -271,16 +274,16 @@ class API extends EventEmitter {
updateUser(user) {
return new Promise((resolve, reject) => {
this.put("/users", user)
.then((user) => {
.then((updatedUser) => {
// If we just updated ourselves, update the internal cached copy
if (user._id === this.user._id) {
this.user = user
if (updatedUser._id === this._user._id) {
this._user = updatedUser
this.emit("login")
}
resolve(user)
resolve(updatedUser)
})
.catch((reason) => {
reject(reason)
.catch((error) => {
reject(error)
})
})
}

View File

@@ -1,4 +1,4 @@
import React, { Component, Fragment } from "react"
import React, { Component } from "react"
import {
Login,
Logout,
@@ -13,160 +13,67 @@ import { Profile } from "./Profile"
import { Users } from "./Users"
import { Teams } from "./Teams"
import { System } from "./System"
import { HeaderButton, HeaderText, Column, Row, Text, Box } from "ui"
import { BrowserRouter as Router, Route, Switch } from "react-router-dom"
import logoImage from "images/logo.png"
import { versionInfo } from "./version"
import { sizeInfo, colorInfo } from "ui/style"
import { api } from "src/API"
import { Header, Column, Footer } from "ui"
import { BrowserRouter, Route, Switch } from "react-router-dom"
import { sizeInfo } from "ui/style"
import PropTypes from "prop-types"
import autobind from "autobind-decorator"
import { versionInfo } from "./version"
export class App extends Component {
static propTypes = {
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
}
constructor(props) {
super(props)
this.state = {
loggedInUser: api.loggedInUser,
}
}
componentDidMount() {
api.addListener("login", this.handleUpdate)
api.addListener("logout", this.handleUpdate)
}
componentWillUnmount() {
api.removeListener("login", this.handleUpdate)
api.removeListener("logout", this.handleUpdate)
}
@autobind
handleUpdate() {
this.setState({ loggedInUser: api.loggedInUser })
}
handleLogout() {
// We have to use window here because App does not have history in it's props
window.location.replace("/logout")
}
handleHome() {
window.location.replace("/")
}
handleProfile() {
window.location.replace("/profile")
}
@autobind
handleChangeTitle(title) {
this.setState({ title })
}
render() {
const { loggedInUser } = this.state
let headerButtonsRight = null
let headerButtonsLeft = null
if (loggedInUser) {
headerButtonsLeft = (
<Fragment>
<HeaderButton image={logoImage} onClick={this.handleHome} />
<HeaderText text={this.state.title} />
</Fragment>
)
headerButtonsRight = (
<Fragment>
<HeaderButton icon="profile" onClick={this.handleProfile} />
<HeaderButton icon="logout" onClick={this.handleLogout} />
</Fragment>
)
}
return (
<Router basename="/">
<BrowserRouter>
<Column minHeight="100vh">
<Column.Item
height={sizeInfo.headerHeight - sizeInfo.headerBorderWidth}>
<Box
background={colorInfo.headerButtonBackground}
borderBottom={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}
style={{ boxSizing: "content" }}>
<Row minWidth="100vw">
<Row.Item>{headerButtonsLeft}</Row.Item>
<Row.Item grow />
<Row.Item>{headerButtonsRight}</Row.Item>
</Row>
</Box>
<Route
path="/app"
render={(props) => (
<Column.Item height={sizeInfo.headerHeight}>
<Header
{...props}
left={[
{ image: require("images/badge.png"), path: "/app/home" },
{ text: "Teams", path: "/app/teams" },
{ text: "Users", path: "/app/users" },
]}
right={[
{ icon: "profile", path: "/app/profile" },
{ icon: "logout", path: "/logout" },
]}
/>
</Column.Item>
)}
/>
<Switch>
<Route exact path="/login" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/confirm-email" component={ConfirmEmail} />
<Route exact path="/reset-password" component={ResetPassword} />
<Route exact path="/forgot-password" component={ForgotPassword} />
<ProtectedRoute
exact
path="/profile"
render={(props) => (
<Profile {...props} changeTitle={this.handleChangeTitle} />
)}
/>
<ProtectedRoute
exact
admin
path="/users"
render={(props) => (
<Users {...props} changeTitle={this.handleChangeTitle} />
)}
/>
<ProtectedRoute
exact
admin
path="/teams"
render={(props) => (
<Teams {...props} changeTitle={this.handleChangeTitle} />
)}
/>
<ProtectedRoute
exact
admin
path="/system"
render={(props) => (
<System {...props} changeTitle={this.handleChangeTitle} />
)}
/>
<ProtectedRoute
exact
admin
path="/home"
render={(props) => (
<Home {...props} changeTitle={this.handleChangeTitle} />
)}
/>
<DefaultRoute />
<ProtectedRoute exact path="/app/profile" component={Profile} />
<ProtectedRoute exact admin path="/app/home" component={Home} />
<ProtectedRoute exact admin path="/app/teams" component={Teams} />
<ProtectedRoute exact admin path="/system" component={System} />
<ProtectedRoute exact admin path="/app/users" component={Users} />
<DefaultRoute redirect="/app/home" />
</Switch>
<Route
path="/app"
render={() => (
<Column.Item>
<Box
background={colorInfo.headerButtonBackground}
borderTop={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}>
<Text color="dimmed" margin={sizeInfo.footerTextMargin}>
{"v" + versionInfo.fullVersion} {versionInfo.copyright}
</Text>
</Box>
<Footer
text={
"v" + versionInfo.fullVersion + " " + versionInfo.copyright
}
/>
</Column.Item>
)}
/>
</Column>
</Router>
</BrowserRouter>
)
}
}

View File

@@ -1,47 +1,14 @@
import React, { Fragment, Component } from 'react'
import { api } from 'src/API'
import { Route, Redirect } from 'react-router-dom'
import { Column } from 'ui'
import autobind from 'autobind-decorator'
import React, { Component } from "react"
import { Route, Redirect } from "react-router-dom"
import PropTypes from "prop-types"
export class DefaultRoute extends Component {
@autobind
updateComponent() {
this.forceUpdate()
}
componentDidMount() {
api.addListener('login', this.updateComponent)
}
componentWillUnmount() {
api.removeListener('login', this.updateComponent)
static propTypes = {
redirect: PropTypes.string,
}
render() {
const user = api.loggedInUser
let path = null
if (user) {
if (!user.pending) {
path = user.administrator ? '/home' : '/profile'
}
} else {
path = '/login'
}
return (
<Route
path='/'
render={() => {
return (
<Fragment>
<Column.Item grow />
{path ? <Redirect to={path} /> : null}
</Fragment>
)
}}
/>
)
// NOTE: When working on the site, Redirect to the page you are working on
return <Route render={() => <Redirect to={this.props.redirect} />} />
}
}

View File

@@ -1,28 +1,28 @@
import React, { Component, Fragment } from 'react'
import PropTypes from 'prop-types'
import { regExpPattern } from 'regexp-pattern'
import { Image, Text, Column, Row, BoundInput, BoundButton, Box } from 'ui'
import { MessageModal, WaitModal } from '../Modal'
import { api } from 'src/API'
import { FormBinder } from 'react-form-binder'
import headerLogo from 'images/deighton.png'
import { sizeInfo, colorInfo } from 'ui/style'
import autobind from 'autobind-decorator'
import React, { Component, Fragment } from "react"
import PropTypes from "prop-types"
import { regExpPattern } from "regexp-pattern"
import { Image, Text, Column, Row, BoundInput, BoundButton, Box } from "ui"
import { MessageModal, WaitModal } from "../Modal"
import { api } from "src/API"
import { FormBinder } from "react-form-binder"
import headerLogo from "images/badge.png"
import { sizeInfo, colorInfo } from "ui/style"
import autobind from "autobind-decorator"
export class ForgotPassword extends Component {
static propTypes = {
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object])
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
}
static bindings = {
email: {
alwaysGet: true,
isValid: (r, v) => (regExpPattern.email.test(v))
isValid: (r, v) => regExpPattern.email.test(v),
},
submit: {
noValue: true,
isDisabled: (r) => (!r.anyModified || !r.allValid)
}
isDisabled: (r) => !r.anyModified || !r.allValid,
},
}
constructor(props) {
@@ -30,7 +30,7 @@ export class ForgotPassword extends Component {
this.state = {
binder: new FormBinder({}, ForgotPassword.bindings),
messageModal: null,
waitModal: null
waitModal: null,
}
}
@@ -45,16 +45,18 @@ export class ForgotPassword extends Component {
const obj = this.state.binder.getModifiedFieldValues()
this.setState({ waitModal: { message: 'Requesting Reset Email' } })
this.setState({ waitModal: { message: "Requesting Reset Email" } })
const cb = (res) => {
this.setState({
waitModal: null,
messageModal: {
icon: 'thumb',
title: 'Password Reset Requested',
message: `If everything checks out, an email will be sent to '${obj.email}' with a reset link. Please click on it to finish resetting the password.`
}
icon: "thumb",
title: "Password Reset Requested",
message: `If everything checks out, an email will be sent to '${
obj.email
}' with a reset link. Please click on it to finish resetting the password.`,
},
})
}
@@ -63,7 +65,7 @@ export class ForgotPassword extends Component {
@autobind
handleMessageModalDismiss() {
this.props.history.replace('/')
this.props.history.replace("/")
}
render() {
@@ -77,8 +79,13 @@ export class ForgotPassword extends Component {
<Row.Item grow />
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item width={sizeInfo.modalWidth}>
<form id='forgotPasswordForm' onSubmit={this.handleSubmit}>
<Box border={{ width: sizeInfo.headerBorderWidth, color: colorInfo.headerBorder }} radius={sizeInfo.formBoxRadius}>
<form id="forgotPasswordForm" onSubmit={this.handleSubmit}>
<Box
border={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}
radius={sizeInfo.formBoxRadius}>
<Row>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item>
@@ -88,31 +95,46 @@ export class ForgotPassword extends Component {
<Row>
<Row.Item grow />
<Row.Item>
<Image source={headerLogo} width={sizeInfo.loginLogoWidth} />
<Image
source={headerLogo}
width={sizeInfo.loginLogoWidth}
/>
</Row.Item>
<Row.Item grow />
</Row>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item>
<Text size='large'>Forgotten Password</Text>
<Text size="large">Forgotten Password</Text>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item>
<BoundInput label='Email' name='email'
placeholder='example@xyz.com' binder={this.state.binder}
message='A valid email address' />
<BoundInput
label="Email"
name="email"
placeholder="example@xyz.com"
binder={this.state.binder}
message="A valid email address"
/>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item>
<Text>The email address of an existing user to send the password reset link to.</Text>
<Text>
The email address of an existing user to send the
password reset link to.
</Text>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item minHeight={sizeInfo.buttonHeight}>
<Row>
<Row.Item grow />
<Row.Item>
<BoundButton text='Submit' name='submit' submit='forgotPasswordForm' binder={binder} />
<BoundButton
text="Submit"
name="submit"
submit="forgotPasswordForm"
binder={binder}
/>
</Row.Item>
</Row>
</Column.Item>
@@ -128,15 +150,18 @@ export class ForgotPassword extends Component {
</Row>
</Column.Item>
<Column.Item grow>
<WaitModal active={!!waitModal}
message={waitModal ? waitModal.message : ''} />
<WaitModal
active={!!waitModal}
message={waitModal ? waitModal.message : ""}
/>
<MessageModal
open={!!messageModal}
icon={messageModal ? messageModal.icon : ''}
message={messageModal ? messageModal.message : ''}
detail={messageModal ? messageModal.detail : ''}
onDismiss={this.handleMessageModalDismiss} />
icon={messageModal ? messageModal.icon : ""}
message={messageModal ? messageModal.message : ""}
detail={messageModal ? messageModal.detail : ""}
onDismiss={this.handleMessageModalDismiss}
/>
</Column.Item>
</Fragment>
)

View File

@@ -14,7 +14,7 @@ import {
BoundCheckbox,
BoundButton,
} from "ui"
import headerLogo from "images/deighton.png"
import headerLogo from "images/logo.png"
import { versionInfo } from "../version"
import { FormBinder } from "react-form-binder"
import autobind from "autobind-decorator"

View File

@@ -6,7 +6,10 @@ import autobind from 'autobind-decorator'
export class ProtectedRoute extends React.Component {
static propTypes = {
location: PropTypes.shape({ pathname: PropTypes.string, search: PropTypes.string }),
location: PropTypes.shape({
pathname: PropTypes.string,
search: PropTypes.string,
}),
admin: PropTypes.bool,
}
@@ -16,26 +19,32 @@ export class ProtectedRoute extends React.Component {
}
componentDidMount() {
api.addListener('login', this.updateComponent)
api.addListener("login", this.updateComponent)
api.addListener("logout", this.updateComponent)
}
componentWillUnmount() {
api.removeListener('login', this.updateComponent)
api.removeListener("login", this.updateComponent)
api.removeListener("logout", this.updateComponent)
}
render(props) {
const user = api.loggedInUser
if (user) {
if (user.pending) {
// The API might be in the middle of fetching the user information
// Return something and wait for login evint to fire to re-render
return <div />
} else if (!this.props.admin || (this.props.admin && user.administrator)) {
return null
} else {
if (!user._id || (this.props.admin && !user.administrator)) {
return (
<Redirect
to={`/login?redirect=${this.props.location.pathname}${
this.props.location.search
}`}
/>
)
} else {
return <Route {...this.props} />
}
}
return <Redirect to={`/login?redirect=${this.props.location.pathname}${this.props.location.search}`} />
}
}

View File

@@ -6,7 +6,7 @@ import { api } from "src/API"
import { FormBinder } from "react-form-binder"
import { sizeInfo, colorInfo } from "ui/style"
import autobind from "autobind-decorator"
import headerLogo from "images/deighton.png"
import headerLogo from "images/logo.png"
export class ResetPassword extends Component {
static propTypes = {

View File

@@ -1,20 +1,11 @@
import React, { Component, Fragment } from 'react'
import PropTypes from 'prop-types'
import { Row, Column, PanelButton } from 'ui'
import { sizeInfo } from 'ui/style'
import React, { Component, Fragment } from "react"
import PropTypes from "prop-types"
import { Row, Column, PanelButton } from "ui"
import { sizeInfo } from "ui/style"
export class Home extends Component {
static propTypes = {
history: PropTypes.object,
changeTitle: PropTypes.func.isRequired,
}
componentDidMount() {
this.props.changeTitle('Home')
}
componentWillUnmount() {
this.props.changeTitle('')
}
render() {
@@ -25,15 +16,27 @@ export class Home extends Component {
<Row>
<Row.Item grow />
<Row.Item>
<PanelButton icon='users' text='Users' onClick={() => (this.props.history.push('/users'))} />
<PanelButton
icon="users"
text="Users"
onClick={() => this.props.history.push("/users")}
/>
</Row.Item>
<Row.Item width={sizeInfo.panelButtonSpacing} />
<Row.Item>
<PanelButton icon='teams' text='Teams' onClick={() => (this.props.history.push('/teams'))} />
<PanelButton
icon="teams"
text="Teams"
onClick={() => this.props.history.push("/teams")}
/>
</Row.Item>
<Row.Item width={sizeInfo.panelButtonSpacing} />
<Row.Item>
<PanelButton icon='system' text='System' onClick={() => (this.props.history.push('/system'))} />
<PanelButton
icon="system"
text="System"
onClick={() => this.props.history.push("/system")}
/>
</Row.Item>
<Row.Item grow />
</Row>

View File

@@ -0,0 +1,30 @@
import React, { Component } from "react"
import PropTypes from "prop-types"
import { Column, Text } from "ui"
export class DetailPlaceholder extends Component {
static propTypes = {
name: PropTypes.string,
}
render() {
const { name } = this.props
const capitalizedName = name.charAt(0).toUpperCase() + name.substr(1)
return (
<Column fillParent>
<Column.Item grow />
<Column.Item>
<Text size="large" align="center" width="100%">
{`Select a ${name} to view details here`}
</Text>
<br />
<Text size="small" align="center" width="100%">
{`Or 'Add New ${capitalizedName}'`}
</Text>
</Column.Item>
<Column.Item grow />
</Column>
)
}
}

View File

@@ -0,0 +1,356 @@
import React, { Component, Fragment } from "react"
import PropTypes from "prop-types"
import autobind from "autobind-decorator"
import { Row, Column, Box } from "ui"
import { YesNoMessageModal, MessageModal, WaitModal } from "../Modal"
import { sizeInfo, colorInfo } from "ui/style"
import { DetailPlaceholder, MasterList } from "."
import pluralize from "pluralize"
export class MasterDetail extends Component {
static propTypes = {
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
name: PropTypes.string,
form: PropTypes.func.isRequired,
listItems: PropTypes.func.isRequired,
updateItem: PropTypes.func.isRequired,
createItem: PropTypes.func.isRequired,
deleteItem: PropTypes.func.isRequired,
sort: PropTypes.func.isRequired,
detailCallbacks: PropTypes.object,
listData: PropTypes.func,
children: PropTypes.element,
}
constructor(props) {
super(props)
this.state = {
modified: false,
selectedItem: null,
items: [],
yesNoModal: null,
messageModal: null,
waitModal: null,
changeEmailModal: null,
}
const { name } = this.props
this.capitalizedName = name.charAt(0).toUpperCase() + name.substr(1)
this.pluralizedName = pluralize(name)
}
componentDidMount() {
this.props
.listItems()
.then((list) => {
this.setState({ items: list.items })
const { history } = this.props
const search = new URLSearchParams(history.location.search)
const id = search.get("id")
if (id) {
const item = list.items.find((item) => item._id === id)
if (item) {
this.setState({ selectedItem: item })
} else {
history.replace(history.pathname)
}
}
})
.catch((error) => {
this.showErrorMessage(
`Unable to get the list of ${this.pluralizedName}.`,
error.message
)
})
}
get selectedItem() {
return this.state.selectedItem
}
@autobind
showWait(message) {
this.setState({
waitModal: {
message,
},
})
}
@autobind
hideWait() {
this.setState({
waitModal: null,
})
}
@autobind
showMessage(message, detail) {
this.setState({
icon: "thumb",
message,
detail,
})
}
@autobind
showErrorMessage(message, detail) {
this.setState({
icon: "hand",
message,
detail,
})
}
@autobind
showYesNo(message, onDismiss) {
this.setState({
yesNoModal: {
question: message,
onDismiss,
},
})
}
@autobind
removeUnfinishedNewItem() {
let items = this.state.items
if (items.length > 0 && !items[0]._id) {
this.setState({ items: this.state.items.slice(1) })
}
}
@autobind
handleItemListClick(e, index) {
let item = this.state.items[index]
const { history } = this.props
if (this.state.modified) {
this.nextSelectedItem = item
this.showYesNo(
`This ${
this.props.name
} has been modified. Are you sure you would like to navigate away?`,
this.handleModifiedModalDismiss
)
} else {
this.setState({ selectedItem: item })
this.removeUnfinishedNewItem()
history.replace(`${history.location.pathname}?id=${item._id}`)
}
}
@autobind
handleSave(item) {
if (item._id) {
this.showWait(`Updating ${this.capitalizedName}`)
this.props
.updateItem(item)
.then((updatedItem) => {
this.hideWait()
this.setState({
items: this.state.items.map(
(item) => (item._id === updatedItem._id ? updatedItem : item)
),
modified: false,
selectedItem: updatedItem,
})
})
.catch((error) => {
this.hideWait()
this.showErrorMessage(
"Unable to save the item changes",
error.message
)
})
} else {
this.showWait(`Creating ${this.capitalizedName}`)
this.props
.createItem(item)
.then((createdItem) => {
this.hideWait()
this.setState({
items: this.state.items
.map((item) => (!item._id ? createdItem : item))
.sort(this.props.sort),
modified: false,
selectedItem: createdItem,
})
})
.catch((error) => {
this.hideWait()
this.showErrorMessage("Unable to create the item.", error.message)
})
}
}
@autobind
handleRemove() {
this.showYesNo(
`Are you sure you want to remove this ${this.props.name}?`,
this.handleRemoveModalDismiss
)
}
@autobind
handleRemoveModalDismiss(yes) {
if (yes) {
const selectedItemId = this.state.selectedItem._id
const selectedIndex = this.state.items.findIndex(
(item) => item._id === selectedItemId
)
if (selectedIndex >= 0) {
this.showWait(`Removing ${this.capitalizedName}`)
this.props
.deleteItem(selectedItemId)
.then(() => {
this.hideWait()
this.setState({
items: [
...this.state.items.slice(0, selectedIndex),
...this.state.items.slice(selectedIndex + 1),
],
selectedItem: null,
})
})
.catch((error) => {
this.hideWait()
this.showErrorMessage(
`Unable to remove the ${this.props.name}.`,
error.message
)
})
}
}
this.setState({
yesNoModal: null,
})
}
@autobind
handleModifiedModalDismiss(yes) {
if (yes) {
this.setState({
selectedItem: this.nextSelectedItem,
modified: false,
})
this.removeUnfinishedNewItem()
delete this.nextSelectedItem
}
this.setState({
yesNoModal: null,
})
}
@autobind
handleMessageModalDismiss() {
this.setState({ messageModal: null })
}
@autobind
handleModifiedChanged(modified) {
this.setState({ modified: modified })
}
@autobind
handleAddNewItem() {
let items = this.state.items
if (items.length > 0 && !items[0]._id) {
// Already adding a new item
return
}
let newItem = {}
let newItems = [newItem].concat(this.state.items)
this.setState({ items: newItems, selectedItem: newItem })
}
render() {
const {
messageModal,
yesNoModal,
waitModal,
items,
selectedItem,
modified,
} = this.state
const { name } = this.props
return (
<Fragment>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item grow>
<Row fillParent>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item width="25vw">
<MasterList
capitalizedName={this.capitalizedName}
items={items}
selectedItem={selectedItem}
selectionModified={modified}
onItemListClick={this.handleItemListClick}
onAddNewItem={this.handleAddNewItem}
listData={this.props.listData}
/>
</Row.Item>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item grow>
<Box
border={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}
radius={sizeInfo.formBoxRadius}>
{this.state.selectedItem ? (
React.createElement(this.props.form, {
item: selectedItem,
onSave: this.handleSave,
onRemove: this.handleRemove,
onModifiedChanged: this.handleModifiedChanged,
...this.props.detailCallbacks,
})
) : (
<DetailPlaceholder name={name} />
)}
</Box>
</Row.Item>
<Row.Item width={sizeInfo.formRowSpacing} />
</Row>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing}>
<YesNoMessageModal
open={!!yesNoModal}
question={yesNoModal ? yesNoModal.question : ""}
onDismiss={yesNoModal && yesNoModal.onDismiss}
/>
<MessageModal
open={!!messageModal}
icon={messageModal ? messageModal.icon : ""}
message={messageModal ? messageModal.message : ""}
detail={messageModal && messageModal.detail}
onDismiss={this.handleMessageModalDismiss}
/>
<WaitModal
active={!!waitModal}
message={waitModal ? waitModal.message : ""}
/>
{this.props.children}
</Column.Item>
</Fragment>
)
}
}

View File

@@ -0,0 +1,73 @@
import React from "react"
import PropTypes from "prop-types"
import { Column, List, Button } from "ui"
import { sizeInfo } from "ui/style"
export class MasterList extends React.Component {
static propTypes = {
capitalizedName: PropTypes.string,
items: PropTypes.array,
listData: PropTypes.func,
onItemListClick: PropTypes.func,
selectedItem: PropTypes.object,
selectionModified: PropTypes.bool,
onAddNewItem: PropTypes.func,
}
constructor(props) {
super(props)
this.state = {
items: null,
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.items !== this.props.items) {
this.setState({ items: nextProps.items })
}
}
render() {
const { selectedItem, selectionModified, capitalizedName } = this.props
const { items } = this.state
return (
<Column fillParent>
<Column.Item grow>
<List
items={items}
render={(item, index) => {
const data = item._id
? this.props.listData(item)
: {
icon: "blank",
text: `[New ${capitalizedName}]`,
}
return (
<List.Item
key={item._id || "0"}
onClick={(e) => this.props.onItemListClick(e, index)}
active={item === this.props.selectedItem}>
<List.Icon name={data.icon} size={sizeInfo.listIcon} />
<List.Text>{data.text}</List.Text>
{item === selectedItem && selectionModified ? (
<List.Icon name="edit" size={sizeInfo.listIcon} />
) : null}
</List.Item>
)
}}
/>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item height={sizeInfo.buttonHeight}>
<Button
width="100%"
color="inverse"
onClick={this.props.onAddNewItem}
text={`Add New ${capitalizedName}`}
/>
</Column.Item>
</Column>
)
}
}

View File

@@ -0,0 +1,3 @@
export { MasterDetail } from "./MasterDetail"
export { DetailPlaceholder } from "./DetailPlaceholder"
export { MasterList } from "./MasterList"

View File

@@ -3,7 +3,7 @@ import PropTypes from "prop-types"
import { Box, Image, Column, Row, Button, Link } from "ui"
import { MessageModal, WaitModal, YesNoMessageModal } from "../Modal"
import { sizeInfo, colorInfo } from "ui/style"
import headerLogo from "images/deighton.png"
import headerLogo from "images/logo.png"
import autobind from "autobind-decorator"
import { api } from "../API"

View File

@@ -9,7 +9,7 @@ import { api } from "src/API"
export class TeamForm extends React.Component {
static propTypes = {
team: PropTypes.object,
item: PropTypes.object,
onSave: PropTypes.func,
onRemove: PropTypes.func,
onModifiedChanged: PropTypes.func,
@@ -21,6 +21,9 @@ export class TeamForm extends React.Component {
},
start: {
isValid: (r, v) => v === "" || moment(v).isValid(),
initValue: "",
pre: (v) => (v === null ? "" : v),
post: (v) => (v === "" ? null : v),
},
remove: {
noValue: true,
@@ -43,14 +46,14 @@ export class TeamForm extends React.Component {
this.state = {
binder: new FormBinder(
props.team,
props.item,
TeamForm.bindings,
this.props.onModifiedChanged
),
users: [],
}
this.getUsersForTeam(props.team._id)
this.getUsersForTeam(props.item._id)
}
@autobind
@@ -69,16 +72,20 @@ export class TeamForm extends React.Component {
}
componentWillReceiveProps(nextProps) {
if (nextProps.team !== this.props.team) {
if (nextProps.item !== this.props.item) {
this.setState({
binder: new FormBinder(
nextProps.team,
nextProps.item,
TeamForm.bindings,
nextProps.onModifiedChanged
),
})
this.getUsersForTeam(nextProps.team._id)
if (nextProps.item._id) {
this.getUsersForTeam(nextProps.item._id)
} else {
this.setState({ users: [] })
}
}
}
@@ -95,10 +102,10 @@ export class TeamForm extends React.Component {
@autobind
handleReset() {
const { team, onModifiedChanged } = this.props
const { item, onModifiedChanged } = this.props
this.setState({
binder: new FormBinder(team, TeamForm.bindings, onModifiedChanged),
binder: new FormBinder(item, TeamForm.bindings, onModifiedChanged),
})
if (onModifiedChanged) {

View File

@@ -1,14 +0,0 @@
import React from 'react'
import { Column, Text } from 'ui'
export const TeamFormPlaceholder = () => (
<Column fillParent>
<Column.Item grow />
<Column.Item>
<Text size='large' align='center' width='100%'>Select a team to view details here</Text>
<br />
<Text size='small' align='center' width='100%'>Or 'Add New Team'</Text>
</Column.Item>
<Column.Item grow />
</Column>
)

View File

@@ -1,55 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Column, List, Button } from 'ui'
import { sizeInfo } from 'ui/style'
export class TeamList extends React.Component {
static propTypes = {
teams: PropTypes.array,
onTeamListClick: PropTypes.func,
selectedTeam: PropTypes.object,
selectionModified: PropTypes.bool,
onAddNewTeam: PropTypes.func
}
constructor(props) {
super(props)
this.state = {
teams: null
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.teams !== this.props.teams) {
this.setState({ teams: nextProps.teams })
}
}
render() {
const { selectedTeam, selectionModified } = this.props
const { teams } = this.state
return (
<Column fillParent>
<Column.Item grow>
<List items={teams} render={(team, index) => {
return (
<List.Item key={team._id || '0'} onClick={(e) => (this.props.onTeamListClick(e, index))}
active={team === selectedTeam}>
<List.Icon name='team' size={sizeInfo.listIcon} />
<List.Text>
{ team._id ? team.name : '[New Team]' }
</List.Text>
{ team === selectedTeam && selectionModified ? <List.Icon name='edit' size={sizeInfo.listIcon} /> : null }
</List.Item>
)
}} />
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item height={sizeInfo.buttonHeight}>
<Button width='100%' color='inverse' onClick={this.props.onAddNewTeam} text='Add New Team' />
</Column.Item>
</Column>
)
}
}

View File

@@ -1,306 +1,31 @@
import React, { Component, Fragment } from "react"
import PropTypes from "prop-types"
import autobind from "autobind-decorator"
import { TeamList } from "./TeamList"
import React, { Component } from "react"
import { TeamForm } from "./TeamForm"
import { TeamFormPlaceholder } from "./TeamFormPlaceholder"
import { api } from "src/API"
import { Row, Column, Box } from "ui"
import {
YesNoMessageModal,
MessageModal,
ChangeEmailModal,
WaitModal,
} from "../Modal"
import { sizeInfo, colorInfo } from "ui/style"
import { MasterDetail } from "../MasterDetail"
import PropTypes from "prop-types"
export class Teams extends Component {
static propTypes = {
changeTitle: PropTypes.func.isRequired,
}
constructor(props) {
super(props)
this.state = {
modified: false,
selectedTeam: null,
teams: [],
yesNoModal: null,
messageModal: null,
waitModal: null,
changeEmailModal: null,
}
}
componentDidMount() {
this.props.changeTitle("Teams")
api
.listTeams()
.then((list) => {
list.items.sort((teamA, teamB) => teamA.name.localeCompare(teamB.name))
this.setState({ teams: list.items })
})
.catch((error) => {
this.setState({
messageModal: {
icon: "hand",
message: "Unable to get the list of teams.",
detail: error.message,
},
})
})
}
componentWillUnmount() {
this.props.changeTitle("")
}
removeUnfinishedNewTeam() {
let teams = this.state.teams
if (teams.length > 0 && !teams[0]._id) {
this.setState({ teams: this.state.teams.slice(1) })
}
}
@autobind
handleTeamListClick(e, index) {
let team = this.state.teams[index]
if (this.state.modified) {
this.nextSelectedTeam = team
this.setState({
yesNoModal: {
question:
"This team has been modified. Are you sure you would like to navigate away?",
onDismiss: this.handleModifiedModalDismiss,
},
})
} else {
this.setState({ selectedTeam: team })
this.removeUnfinishedNewTeam()
}
}
@autobind
handleSave(team) {
if (team._id) {
this.setState({ waitModal: { message: "Updating Team" } })
api
.updateTeam(team)
.then((updatedTeam) => {
this.setState({
waitModal: null,
teams: this.state.teams.map(
(team) => (team._id === updatedTeam._id ? updatedTeam : team)
),
modified: false,
selectedTeam: updatedTeam,
})
})
.catch((error) => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to save the team changes",
detail: error.message,
},
})
})
} else {
this.setState({ waitModal: { message: "Creating Team" } })
api
.createTeam(team)
.then((createdTeam) => {
this.setState({
waitModal: false,
teams: this.state.teams
.map((team) => (!team._id ? createdTeam : team))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)),
modified: false,
selectedTeam: createdTeam,
})
})
.catch((error) => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to create the team.",
detail: error.message,
},
})
})
}
}
@autobind
handleChangeEmail() {
this.setState({
changeEmailModal: { oldEmail: this.state.selectedTeam.email },
})
}
@autobind
handleRemove() {
this.setState({
yesNoModal: {
question:
"Are you sure you want to remove this team? This will also remove them from any teams they belong to.",
onDismiss: this.handleRemoveModalDismiss,
},
})
}
@autobind
handleRemoveModalDismiss(yes) {
if (yes) {
const selectedTeamId = this.state.selectedTeam._id
const selectedIndex = this.state.teams.findIndex(
(team) => team._id === selectedTeamId
)
if (selectedIndex >= 0) {
this.setState({ waitModal: { message: "Removing Team" } })
api
.deleteTeam(selectedTeamId)
.then(() => {
this.setState({
waitModal: null,
teams: [
...this.state.teams.slice(0, selectedIndex),
...this.state.teams.slice(selectedIndex + 1),
],
selectedTeam: null,
})
})
.catch((error) => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to remove the team.",
detail: error.message,
},
})
})
}
}
this.setState({
yesNoModal: null,
})
}
@autobind
handleModifiedModalDismiss(yes) {
if (yes) {
this.setState({
selectedTeam: this.nextSelectedTeam,
modified: false,
})
this.removeUnfinishedNewTeam()
delete this.nextSelectedTeam
}
this.setState({
yesNoModal: null,
})
}
@autobind
handleMessageModalDismiss() {
this.setState({ messageModal: null })
}
@autobind
handleModifiedChanged(modified) {
this.setState({ modified: modified })
}
@autobind
handleAddNewTeam() {
let teams = this.state.teams
if (teams.length > 0 && !!teams[0]._id) {
let newTeam = {}
let newTeams = [newTeam].concat(this.state.teams)
this.setState({ teams: newTeams, selectedTeam: newTeam })
}
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
}
render() {
const { messageModal, yesNoModal, changeEmailModal } = this.state
return (
<Fragment>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item grow>
<Row fillParent>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item width="25vw">
<TeamList
teams={this.state.teams}
selectedTeam={this.state.selectedTeam}
selectionModified={this.state.modified}
onTeamListClick={this.handleTeamListClick}
onAddNewTeam={this.handleAddNewTeam}
<MasterDetail
history={this.props.history}
ref={(ref) => (this.masterDetail = ref)}
name="team"
form={TeamForm}
listItems={api.listTeams}
updateItem={api.updateTeam}
createItem={api.createTeam}
deleteItem={api.deleteTeam}
sort={(a, b) => a.name.localeCompare(b.name)}
listData={(team) => ({
icon: "team",
text: team.name,
})}
/>
</Row.Item>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item grow>
<Box
border={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}
radius={sizeInfo.formBoxRadius}>
{this.state.selectedTeam ? (
<TeamForm
team={this.state.selectedTeam}
onSave={this.handleSave}
onRemove={this.handleRemove}
onModifiedChanged={this.handleModifiedChanged}
onChangeEmail={this.handleChangeEmail}
onResendEmail={this.handleResendEmail}
/>
) : (
<TeamFormPlaceholder />
)}
</Box>
</Row.Item>
<Row.Item width={sizeInfo.formRowSpacing} />
</Row>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing}>
<ChangeEmailModal
open={!!changeEmailModal}
oldEmail={changeEmailModal && changeEmailModal.oldEmail}
onDismiss={this.handleChangeEmailDismiss}
/>
<YesNoMessageModal
open={!!yesNoModal}
question={yesNoModal ? yesNoModal.question : ""}
onDismiss={yesNoModal && yesNoModal.onDismiss}
/>
<MessageModal
open={!!messageModal}
icon={messageModal ? messageModal.icon : ""}
message={messageModal ? messageModal.message : ""}
detail={messageModal && messageModal.detail}
onDismiss={this.handleMessageModalDismiss}
/>
<WaitModal
active={!!this.state.waitModal}
message={this.state.waitModal ? this.state.waitModal.message : ""}
/>
</Column.Item>
</Fragment>
)
}
}

View File

@@ -17,7 +17,7 @@ import { sizeInfo } from "ui/style"
export class UserForm extends React.Component {
static propTypes = {
user: PropTypes.object,
item: PropTypes.object,
onSave: PropTypes.func,
onRemove: PropTypes.func,
onModifiedChanged: PropTypes.func,
@@ -83,7 +83,7 @@ export class UserForm extends React.Component {
super(props)
this.state = {
binder: new FormBinder(
props.user,
props.item,
UserForm.bindings,
props.onModifiedChanged
),
@@ -111,10 +111,10 @@ export class UserForm extends React.Component {
}
componentWillReceiveProps(nextProps) {
if (nextProps.user !== this.props.user) {
if (nextProps.item !== this.props.item) {
this.setState({
binder: new FormBinder(
nextProps.user,
nextProps.item,
UserForm.bindings,
nextProps.onModifiedChanged
),
@@ -137,10 +137,10 @@ export class UserForm extends React.Component {
@autobind
handleReset() {
const { user, onModifiedChanged } = this.props
const { item, onModifiedChanged } = this.props
this.setState({
binder: new FormBinder(user, UserForm.bindings, onModifiedChanged),
binder: new FormBinder(item, UserForm.bindings, onModifiedChanged),
})
if (onModifiedChanged) {
@@ -179,7 +179,7 @@ export class UserForm extends React.Component {
return (
<form
style={{ width: "100%", height: "100%", overflow: "scroll" }}
id="userForm"
id="UserForm"
onSubmit={this.handleSubmit}>
<Column>
<Column.Item height={sizeInfo.formColumnSpacing} />
@@ -303,7 +303,7 @@ export class UserForm extends React.Component {
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item>
<BoundButton
submit="userForm"
submit="UserForm"
text={binder._id ? "Save" : "Add"}
name="submit"
binder={binder}

View File

@@ -1,14 +0,0 @@
import React from 'react'
import { Column, Text } from 'ui'
export const UserFormPlaceholder = () => (
<Column fillParent>
<Column.Item grow />
<Column.Item>
<Text size='large' align='center' width='100%'>Select a registered user to view details here</Text>
<br />
<Text size='small' align='center' width='100%'>Or 'Add New User'</Text>
</Column.Item>
<Column.Item grow />
</Column>
)

View File

@@ -1,55 +0,0 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Column, List, Button } from 'ui'
import { sizeInfo } from 'ui/style'
export class UserList extends React.Component {
static propTypes = {
users: PropTypes.array,
onUserListClick: PropTypes.func,
selectedUser: PropTypes.object,
selectionModified: PropTypes.bool,
onAddNewUser: PropTypes.func
}
constructor(props) {
super(props)
this.state = {
users: null
}
}
componentWillReceiveProps(nextProps) {
if (nextProps.users !== this.props.users) {
this.setState({ users: nextProps.users })
}
}
render() {
const { selectedUser, selectionModified } = this.props
const { users } = this.state
return (
<Column fillParent>
<Column.Item grow>
<List items={users} render={(user, index) => {
return (
<List.Item key={user._id || '0'} onClick={(e) => (this.props.onUserListClick(e, index))}
active={user === this.props.selectedUser}>
<List.Icon name={user.administrator ? 'admin' : 'profile'} size={sizeInfo.listIcon} />
<List.Text>
{ user._id ? user.firstName + ' ' + user.lastName : '[New User]' }
</List.Text>
{ user === selectedUser && selectionModified ? <List.Icon name='edit' size={sizeInfo.listIcon} /> : null }
</List.Item>
)
}} />
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item height={sizeInfo.buttonHeight}>
<Button width='100%' color='inverse' onClick={this.props.onAddNewUser} text='Add New User' />
</Column.Item>
</Column>
)
}
}

View File

@@ -1,146 +1,23 @@
import React, { Component, Fragment } from "react"
import PropTypes from "prop-types"
import React, { Component } from "react"
import autobind from "autobind-decorator"
import { UserList } from "./UserList"
import { UserForm } from "./UserForm"
import { UserFormPlaceholder } from "./UserFormPlaceholder"
import { api } from "src/API"
import { Row, Column, Box } from "ui"
import {
YesNoMessageModal,
MessageModal,
ChangeEmailModal,
WaitModal,
} from "../Modal"
import { sizeInfo, colorInfo } from "ui/style"
import { ChangeEmailModal } from "../Modal"
import { MasterDetail } from "../MasterDetail"
import PropTypes from "prop-types"
export class Users extends Component {
static propTypes = {
changeTitle: PropTypes.func.isRequired,
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
}
constructor(props) {
super(props)
this.state = {
modified: false,
selectedUser: null,
users: [],
yesNoModal: null,
messageModal: null,
waitModal: null,
changeEmailModal: null,
}
}
componentDidMount() {
this.props.changeTitle("Users")
api
.listUsers()
.then((list) => {
list.items.sort((userA, userB) =>
userA.lastName.localeCompare(userB.lastName)
)
this.setState({ users: list.items })
})
.catch((error) => {
this.setState({
messageModal: {
icon: "hand",
message: "Unable to get the list of users.",
detail: error.message,
},
})
})
}
componentWillUnmount() {
this.props.changeTitle("")
}
removeUnfinishedNewUser() {
let users = this.state.users
if (users.length > 0 && !users[0]._id) {
this.setState({ users: this.state.users.slice(1) })
}
}
@autobind
handleUserListClick(e, index) {
let user = this.state.users[index]
if (this.state.modified) {
this.nextSelectedUser = user
this.setState({
yesNoModal: {
question:
"This user has been modified. Are you sure you would like to navigate away?",
onDismiss: this.handleModifiedModalDismiss,
},
})
} else {
this.setState({ selectedUser: user })
this.removeUnfinishedNewUser()
}
}
@autobind
handleSave(user) {
if (user._id) {
this.setState({ waitModal: { message: "Updating User" } })
api
.updateUser(user)
.then((updatedUser) => {
this.setState({
waitModal: null,
users: this.state.users.map(
(user) => (user._id === updatedUser._id ? updatedUser : user)
),
modified: false,
selectedUser: updatedUser,
})
})
.catch((error) => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to save the user changes",
detail: error.message,
},
})
})
} else {
this.setState({ waitModal: { message: "Creating User" } })
api
.createUser(user)
.then((createdUser) => {
this.setState({
waitModal: false,
users: this.state.users
.map((user) => (!user._id ? createdUser : user))
.sort(
(a, b) =>
((a.lastName < b.lastName ? -1 : a.lastName > b.lastName): 0)
),
modified: false,
selectedUser: createdUser,
})
})
.catch((error) => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to create the user.",
detail: error.message,
},
})
})
}
}
@autobind
handleChangeEmail() {
this.setState({
@@ -154,57 +31,41 @@ export class Users extends Component {
api
.sendResetPassword(this.state.selectedUser.email)
.then(() => {
this.setState({
waitModal: null,
messageModal: {
icon: "thumb",
message: `An email has been sent to '${
this.state.selectedUser.email
}' with instructions on how to reset their password`,
},
})
this.masterDetail.hideWait()
this.masterDetail.showMessage(
`An email has been sent to '${
this.masterDetail.selectedItem.email
}' with instructions on how to reset their password`
)
})
.catch((error) => {
this.setState({
error: true,
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to request password reset.",
detail: error.message,
},
})
this.masterDetail.hideWait()
this.masterDetail.showErrorMessage(
"Unable to request password reset.",
error.message
)
})
}
@autobind
handleResendEmail() {
this.setState({
waitModal: { message: "Resending Email..." },
})
this.masterDetail.showWait("Resending Email...")
api
.sendConfirmEmail({ existingEmail: this.state.selectedUser.email })
.sendConfirmEmail({ existingEmail: this.masterDetail.selectedItem.email })
.then(() => {
this.setState({
waitModal: null,
messageModal: {
icon: "thumb",
message: `An email has been sent to '${
this.state.selectedUser.email
}' with further instructions.`,
},
})
this.masterDetail.hideWait()
this.masterDetail.showMessage(
`An email has been sent to '${
this.masterDetail.selectedItem.email
}' with further instructions.`
)
})
.catch((error) => {
this.setState({
error: true,
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to request email change.",
detail: error.message,
},
})
this.masterDetail.hideWait()
this.masterDetail.showErrorMessage(
"Unable to request email change.",
error.message
)
})
}
@@ -224,187 +85,48 @@ export class Users extends Component {
newEmail,
})
.then(() => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: `An email has been sent to '${newEmail}' to confirm this email.`,
},
})
})
.catch((error) => {
this.setState({
error: true,
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to request email change.",
detail: error.message,
},
})
})
}
}
@autobind
handleRemove() {
this.setState({
yesNoModal: {
question:
"Are you sure you want to remove this user? This will also remove them from any teams they belong to.",
onDismiss: this.handleRemoveModalDismiss,
},
})
}
@autobind
handleRemoveModalDismiss(yes) {
if (yes) {
const selectedUserId = this.state.selectedUser._id
const selectedIndex = this.state.users.findIndex(
(user) => user._id === selectedUserId
this.masterDetail.showMessage(
`An email has been sent to '${newEmail}' to confirm this email.`
)
if (selectedIndex >= 0) {
this.setState({ waitModal: { message: "Removing User" } })
api
.deleteUser(selectedUserId)
.then(() => {
this.setState({
waitModal: null,
users: [
...this.state.users.slice(0, selectedIndex),
...this.state.users.slice(selectedIndex + 1),
],
selectedUser: null,
})
})
.catch((error) => {
this.setState({
waitModal: null,
messageModal: {
icon: "hand",
message: "Unable to remove the user.",
detail: error.message,
},
this.masterDetail.showErrorMessage(
"Unable to request email change.",
error.message
)
})
})
}
}
this.setState({
yesNoModal: null,
})
}
@autobind
handleModifiedModalDismiss(yes) {
if (yes) {
this.setState({
selectedUser: this.nextSelectedUser,
modified: false,
})
this.removeUnfinishedNewUser()
delete this.nextSelectedUser
}
this.setState({
yesNoModal: null,
})
}
@autobind
handleMessageModalDismiss() {
this.setState({ messageModal: null })
}
@autobind
handleModifiedChanged(modified) {
this.setState({ modified: modified })
}
@autobind
handleAddNewUser() {
let users = this.state.users
if (users.length > 0 && !!users[0]._id) {
let newUser = {}
let newUsers = [newUser].concat(users)
this.setState({ users: newUsers, selectedUser: newUser })
}
}
render() {
const { messageModal, yesNoModal, changeEmailModal } = this.state
const { changeEmailModal } = this.state
return (
<Fragment>
<Column.Item height={sizeInfo.formColumnSpacing} />
<Column.Item grow>
<Row fillParent>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item width="25vw">
<UserList
users={this.state.users}
selectedUser={this.state.selectedUser}
selectionModified={this.state.modified}
onUserListClick={this.handleUserListClick}
onAddNewUser={this.handleAddNewUser}
/>
</Row.Item>
<Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item grow>
<Box
border={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
<MasterDetail
history={this.props.history}
ref={(ref) => (this.masterDetail = ref)}
name="user"
form={UserForm}
listItems={api.listUsers}
updateItem={api.updateUser}
createItem={api.createUser}
deleteItem={api.deleteUser}
detailCallbacks={{
onChangeEmail: this.handleChangeEmail,
onResendEmail: this.handleResendEmail,
onResetPassword: this.handleSendPasswordReset,
}}
radius={sizeInfo.formBoxRadius}>
{this.state.selectedUser ? (
<UserForm
user={this.state.selectedUser}
onSave={this.handleSave}
onRemove={this.handleRemove}
onModifiedChanged={this.handleModifiedChanged}
onChangeEmail={this.handleChangeEmail}
onResendEmail={this.handleResendEmail}
onResetPassword={this.handleSendPasswordReset}
/>
) : (
<UserFormPlaceholder />
)}
</Box>
</Row.Item>
<Row.Item width={sizeInfo.formRowSpacing} />
</Row>
</Column.Item>
<Column.Item height={sizeInfo.formColumnSpacing}>
sort={(a, b) => 0}
listData={(user) => ({
icon: user.administrator ? "admin" : "profile",
text: user.firstName + " " + user.lastName,
})}>
<ChangeEmailModal
open={!!changeEmailModal}
oldEmail={changeEmailModal && changeEmailModal.oldEmail}
onDismiss={this.handleChangeEmailDismiss}
/>
<YesNoMessageModal
open={!!yesNoModal}
question={yesNoModal ? yesNoModal.question : ""}
onDismiss={yesNoModal && yesNoModal.onDismiss}
/>
<MessageModal
open={!!messageModal}
icon={messageModal ? messageModal.icon : ""}
message={messageModal ? messageModal.message : ""}
detail={messageModal && messageModal.detail}
onDismiss={this.handleMessageModalDismiss}
/>
<WaitModal
active={!!this.state.waitModal}
message={this.state.waitModal ? this.state.waitModal.message : ""}
/>
</Column.Item>
</Fragment>
</MasterDetail>
)
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

25
website/src/ui/Footer.js Normal file
View File

@@ -0,0 +1,25 @@
import React, { Component } from "react"
import { Box, Text } from "."
import PropTypes from "prop-types"
import { colorInfo, sizeInfo } from "./style"
export class Footer extends Component {
static propTypes = {
text: PropTypes.string,
}
render() {
return (
<Box
background={colorInfo.headerButtonBackground}
borderTop={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}>
<Text color="dimmed" margin={sizeInfo.footerTextMargin}>
{this.props.text}
</Text>
</Box>
)
}
}

169
website/src/ui/Header.js Normal file
View File

@@ -0,0 +1,169 @@
import React, { Component } from "react"
import Radium from "radium"
import PropTypes from "prop-types"
import { Icon, Image, Box, Row } from "."
import { colorInfo, sizeInfo, fontInfo } from "./style"
export class Header extends Component {
static propTypes = {
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
location: PropTypes.object,
left: PropTypes.arrayOf(PropTypes.object),
right: PropTypes.arrayOf(PropTypes.object),
}
render() {
const { location } = this.props
const renderHeaderitem = (item, index) => {
if (item.image) {
return (
<Header.Button
key={index}
image={item.image}
onClick={() => this.props.history.push(item.path)}
/>
)
} else if (item.icon) {
return (
<Header.Button
key={index}
icon={item.icon}
onClick={() => this.props.history.push(item.path)}
/>
)
} else if (item.text) {
return (
<Header.TextButton
key={index}
active={location.pathname.endsWith(item.path)}
text={item.text}
onClick={() => this.props.history.push(item.path)}
/>
)
}
}
return (
<Box
background={colorInfo.headerButtonBackground}
borderBottom={{
width: sizeInfo.headerBorderWidth,
color: colorInfo.headerBorder,
}}
style={{ boxSizing: "content" }}>
<Row fillParent>
<Row.Item>{this.props.left.map(renderHeaderitem)}</Row.Item>
<Row.Item grow />
<Row.Item>{this.props.right.map(renderHeaderitem)}</Row.Item>
</Row>
</Box>
)
}
}
Header.Button = Radium(
class HeaderButton extends Component {
static propTypes = {
onClick: PropTypes.func,
icon: PropTypes.string,
image: PropTypes.string,
}
static style = {
background: colorInfo.headerButtonBackground,
verticalAlign: "middle",
borderWidth: 0,
padding: "0 0 0 0",
outline: "none",
":hover": {
background: colorInfo.headerButtonBackgroundHover,
},
":active": {
background: colorInfo.headerButtonBackgroundActive,
},
}
render() {
// Times two to account for zooming
const size = sizeInfo.headerHeight - sizeInfo.headerBorderWidth
const { onClick, icon, image } = this.props
let content = null
if (image) {
content = (
<Image
source={image}
width={size}
height={size}
margin={sizeInfo.headerButtonMargin}
/>
)
} else if (icon) {
content = (
<Icon name={icon} size={size} margin={sizeInfo.headerButtonMargin} />
)
}
return (
<button
type="button"
style={[{ height: size, width: size }, HeaderButton.style]}
onClick={onClick}>
{content}
</button>
)
}
}
)
Header.TextButton = Radium(
class HeaderTextButton extends Component {
static propTypes = {
active: PropTypes.bool,
text: PropTypes.string,
onClick: PropTypes.func,
}
static style = {
display: "inline-block",
fontSize: fontInfo.size.header,
fontFamily: fontInfo.family,
color: fontInfo.color.normal,
textAlign: "left",
outline: "none",
background: "transparent",
verticalAlign: "middle",
borderWidth: 0,
paddingLeft: sizeInfo.headerSpacing,
paddingRight: sizeInfo.headerSpacing,
paddingTop: 0,
paddingBottom: 0,
":hover": {
background: colorInfo.headerButtonBackgroundHover,
},
":active": {
background: colorInfo.headerButtonBackgroundActive,
},
}
render() {
const height = sizeInfo.headerHeight - sizeInfo.headerBorderWidth
const { text, active, onClick } = this.props
return (
<button
type="button"
style={[{ height }, HeaderTextButton.style]}
onClick={onClick}>
<div
style={{
textDecoration: active ? "underline" : "initial",
}}>
{text}
</div>
</button>
)
}
}
)

View File

@@ -1,46 +0,0 @@
import Radium from 'radium'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { Icon, Image } from '.'
import { colorInfo, sizeInfo } from 'ui/style'
@Radium
export class HeaderButton extends Component {
static propTypes = {
onClick: PropTypes.func,
icon: PropTypes.string,
image: PropTypes.string,
}
static style = {
background: colorInfo.headerButtonBackground,
verticalAlign: 'middle',
borderWidth: 0,
padding: '0 0 0 0',
outline: 'none',
':hover': {
background: colorInfo.headerButtonBackgroundHover,
},
':active': {
background: colorInfo.headerButtonBackgroundActive,
}
}
render() {
const size = sizeInfo.headerHeight - 2 * sizeInfo.headerBorderWidth // Times two to account for zooming
const { onClick, icon, image } = this.props
let content = null
if (image) {
content = (<Image source={image} width={size} height={size} margin={sizeInfo.headerButtonMargin} />)
} else if (icon) {
content = (<Icon name={icon} size={size} margin={sizeInfo.headerButtonMargin} />)
}
return (
<button type='button' style={[{ height: size, width: size }, HeaderButton.style]} onClick={onClick}>
{content}
</button>
)
}
}

View File

@@ -1,34 +0,0 @@
import Radium from 'radium'
import PropTypes from 'prop-types'
import React, { Component } from 'react'
import { sizeInfo, fontInfo } from 'ui/style'
@Radium
export class HeaderText extends Component {
static propTypes = {
text: PropTypes.string,
}
static style = {
position: 'relative',
top: sizeInfo.headerTextOffset,
display: 'inline-block',
fontSize: fontInfo.size.header,
fontFamily: fontInfo.family,
color: fontInfo.color.normal,
textAlign: 'left',
background: 'transparent',
verticalAlign: 'middle',
borderWidth: 0,
paddingLeft: sizeInfo.headerPaddingLeft,
}
render() {
const height = sizeInfo.headerHeight - sizeInfo.headerBorderWidth
const { text } = this.props
return (
<div style={[{ height }, HeaderText.style]}>{text}</div>
)
}
}

View File

@@ -21,21 +21,22 @@ export class Icon extends Component {
}
static svgs = {
logout: require("icons/logout.svg"),
thumb: require("icons/thumb.svg"),
profile: require("icons/profile.svg"),
admin: require("icons/admin.svg"),
hand: require("icons/hand.svg"),
users: require("icons/users.svg"),
team: require("icons/team.svg"),
teams: require("icons/teams.svg"),
system: require("icons/system.svg"),
confirmed: require("icons/confirmed.svg"),
help: require("icons/help.svg"),
warning: require("icons/warning.svg"),
edit: require("icons/edit.svg"),
placeholder: require("icons/placeholder.svg"),
clock: require("icons/clock.svg"),
admin: require("./icons/admin.svg"),
blank: require("./icons/blank.svg"),
clock: require("./icons/clock.svg"),
confirmed: require("./icons/confirmed.svg"),
edit: require("./icons/edit.svg"),
hand: require("./icons/hand.svg"),
help: require("./icons/help.svg"),
logout: require("./icons/logout.svg"),
profile: require("./icons/profile.svg"),
placeholder: require("./icons/placeholder.svg"),
system: require("./icons/system.svg"),
thumb: require("./icons/thumb.svg"),
team: require("./icons/team.svg"),
teams: require("./icons/teams.svg"),
users: require("./icons/users.svg"),
warning: require("./icons/warning.svg"),
}
render() {

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="79px" height="79px" viewBox="0 0 79 79" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
<title>blank</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Artboard" transform="translate(-542.000000, -375.000000)">
<g id="blank" transform="translate(542.000000, 375.000000)"></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 610 B

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 965 B

After

Width:  |  Height:  |  Size: 965 B

View File

Before

Width:  |  Height:  |  Size: 918 B

After

Width:  |  Height:  |  Size: 918 B

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@@ -2,8 +2,8 @@ export { Anime } from "./Anime"
export { Box } from "./Box"
export { Button } from "./Button"
export { FormIconButton } from "./FormIconButton"
export { HeaderButton } from "./HeaderButton"
export { HeaderText } from "./HeaderText"
export { Header } from "./Header"
export { Footer } from "./Footer"
export { PanelButton } from "./PanelButton"
export { Checkbox } from "./Checkbox"
export { Input } from "./Input"