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": { "react-form-binder": {
"version": "1.2.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-form-binder/-/react-form-binder-1.2.0.tgz", "resolved": "https://registry.npmjs.org/react-form-binder/-/react-form-binder-2.0.0.tgz",
"integrity": "sha512-VFeiB5nCe01WU5aVJILMw7GLgOPsYJvdJEL9WRz7qecKDZx30sKA5bLDOWHsWQDZhediIr3KLpFkPxj0u89tDg==", "integrity": "sha512-ihqbA3sp8eOOvjN2cSWOC7pfK+ukuRW5+dgpbrDJKnH/wgJ0LSMaJg2d/lX8bc0XO7+KxRJi7mBdizvCT1qhgQ==",
"requires": { "requires": {
"eventemitter3": "^2.0.3", "eventemitter3": "^2.0.3",
"prop-types": "^15.5.10", "prop-types": "^15.5.10",

View File

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

View File

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

View File

@@ -1,44 +1,15 @@
import passport from "passport" import passport from "passport"
import createError from "http-errors" import createError from "http-errors"
import autobind from "autobind-decorator" import autobind from "autobind-decorator"
import { catchAll, TeamRoutes } from "." import { catchAll, TeamRoutes, BaseRoutes } from "."
@autobind @autobind
export class ActivityRoutes { export class ActivityRoutes extends BaseRoutes {
constructor(container) { constructor(container) {
super(container, container.db.Activity)
const app = container.app 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 app
.route("/activities/all") .route("/activities/all")
.delete( .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) { async deleteAllActivities(req, res, next) {
const Activity = this.db.Activity const Activity = this.db.Activity
const Team = this.db.Team const Team = this.db.Team

View File

@@ -11,19 +11,19 @@ import B64 from "b64"
import { PassThrough } from "stream" import { PassThrough } from "stream"
import { catchAll } from "." import { catchAll } from "."
function pipeToGridFS(readable, gfsWriteable, decoder) { function pipeToGridFS(readable, writable, decoder) {
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
readable.on("error", (error) => { readable.on("error", (error) => {
reject(error) reject(error)
}) })
gfsWriteable.on("error", (error) => { writeable.on("error", (error) => {
reject(error) reject(error)
}) })
gfsWriteable.on("close", (file) => { writeable.on("finish", (file) => {
resolve(file) resolve(file)
}) })
}) })
readable.pipe(decoder).pipe(gfsWriteable) readable.pipe(decoder).pipe(writeable)
return promise return promise
} }
@@ -73,12 +73,13 @@ export class AssetRoutes {
assetId = assetId.slice(0, extIndex) 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`) throw createError.NotFound(`Asset ${assetId} was not found`)
} }
const file = cursor.next()
const ifNoneMatch = req.get("If-None-Match") const ifNoneMatch = req.get("If-None-Match")
if (ifNoneMatch && ifNoneMatch === file.md5) { if (ifNoneMatch && ifNoneMatch === file.md5) {
@@ -98,13 +99,13 @@ export class AssetRoutes {
ETag: file.md5, 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) { async deleteAsset(req, res, next) {
const assetId = req.params._id const assetId = req.params._id
await this.db.gridfs.removeAsync({ _id: assetId }) await this.db.gridfs.delete(assetId)
res.json({}) res.json({})
} }
@@ -235,11 +236,11 @@ export class AssetRoutes {
if (uploadedChunks >= uploadData.numberOfChunks) { if (uploadedChunks >= uploadData.numberOfChunks) {
let readable = redisReadStream(this.rs.client, uploadDataId) let readable = redisReadStream(this.rs.client, uploadDataId)
let writeable = this.db.gridfs.createWriteStream({ let writeable = this.db.gridfs.openUploadStreamWithId(
_id: uploadId, uploadId,
filename: uploadData.fileName, uploadData.fileName,
content_type: uploadData.contentType, { contentType: uploadData.contentType }
}) )
const decoder = const decoder =
uploadData.chunkContentType === "application/base64" 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 autobind from "autobind-decorator"
import zlib from "zlib" import zlib from "zlib"
import { Readable } from "stream" import { Readable } from "stream"
import { catchAll } from "." import { catchAll, BaseRoutes } from "."
@autobind @autobind
export class TeamRoutes { export class TeamRoutes extends BaseRoutes {
constructor(container) { constructor(container) {
const app = container.app super(container, container.db.Team)
this.log = container.log container.app
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
.route("/teams/status") .route("/teams/status")
.get( .get(
passport.authenticate("bearer", { session: false }), 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) { async getTeamStatus(req, res, next) {
const Team = this.db.Team const Team = this.db.Team
const Activity = this.db.Activity const Activity = this.db.Activity

View File

@@ -2,33 +2,14 @@ import passport from "passport"
import createError from "http-errors" import createError from "http-errors"
import autobind from "autobind-decorator" import autobind from "autobind-decorator"
import merge from "deepmerge" import merge from "deepmerge"
import { catchAll } from "." import { catchAll, BaseRoutes } from "."
@autobind @autobind
export class WorkItemRoutes { export class WorkItemRoutes extends BaseRoutes {
constructor(container) { constructor(container) {
super(container, container.db.WorkItem)
const app = container.app 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 app
.route("/workitems/activities") .route("/workitems/activities")
.get( .get(
@@ -36,17 +17,6 @@ export class WorkItemRoutes {
catchAll(this.listWorkItemActivities) 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 app
.route("/workitems/all") .route("/workitems/all")
.delete( .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) { async listWorkItemActivities(req, res, next) {
const WorkItem = this.db.WorkItem const WorkItem = this.db.WorkItem
const aggregate = WorkItem.aggregate() const aggregate = WorkItem.aggregate()
@@ -117,83 +53,6 @@ export class WorkItemRoutes {
res.json({ items }) 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) { async deleteAllWorkItems(req, res, next) {
const Activity = this.db.Activity const Activity = this.db.Activity
const WorkItem = this.db.WorkItem const WorkItem = this.db.WorkItem

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,7 @@
"radium": "^0.22.0", "radium": "^0.22.0",
"react": "^16.2.0", "react": "^16.2.0",
"react-dom": "^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", "react-router-dom": "^4.1.1",
"regexp-pattern": "^1.0.4", "regexp-pattern": "^1.0.4",
"socket.io-client": "^2.0.3" "socket.io-client": "^2.0.3"

View File

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

View File

@@ -1,4 +1,4 @@
import React, { Component, Fragment } from "react" import React, { Component } from "react"
import { import {
Login, Login,
Logout, Logout,
@@ -13,160 +13,67 @@ import { Profile } from "./Profile"
import { Users } from "./Users" import { Users } from "./Users"
import { Teams } from "./Teams" import { Teams } from "./Teams"
import { System } from "./System" import { System } from "./System"
import { HeaderButton, HeaderText, Column, Row, Text, Box } from "ui" import { Header, Column, Footer } from "ui"
import { BrowserRouter as Router, Route, Switch } from "react-router-dom" import { BrowserRouter, Route, Switch } from "react-router-dom"
import logoImage from "images/logo.png" import { sizeInfo } from "ui/style"
import { versionInfo } from "./version"
import { sizeInfo, colorInfo } from "ui/style"
import { api } from "src/API"
import PropTypes from "prop-types" import PropTypes from "prop-types"
import autobind from "autobind-decorator" import { versionInfo } from "./version"
export class App extends Component { export class App extends Component {
static propTypes = { static propTypes = {
history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), 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() { 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 ( return (
<Router basename="/"> <BrowserRouter>
<Column minHeight="100vh"> <Column minHeight="100vh">
<Column.Item <Route
height={sizeInfo.headerHeight - sizeInfo.headerBorderWidth}> path="/app"
<Box render={(props) => (
background={colorInfo.headerButtonBackground} <Column.Item height={sizeInfo.headerHeight}>
borderBottom={{ <Header
width: sizeInfo.headerBorderWidth, {...props}
color: colorInfo.headerBorder, left={[
}} { image: require("images/badge.png"), path: "/app/home" },
style={{ boxSizing: "content" }}> { text: "Teams", path: "/app/teams" },
<Row minWidth="100vw"> { text: "Users", path: "/app/users" },
<Row.Item>{headerButtonsLeft}</Row.Item> ]}
<Row.Item grow /> right={[
<Row.Item>{headerButtonsRight}</Row.Item> { icon: "profile", path: "/app/profile" },
</Row> { icon: "logout", path: "/logout" },
</Box> ]}
</Column.Item> />
</Column.Item>
)}
/>
<Switch> <Switch>
<Route exact path="/login" component={Login} /> <Route exact path="/login" component={Login} />
<Route exact path="/logout" component={Logout} /> <Route exact path="/logout" component={Logout} />
<Route exact path="/confirm-email" component={ConfirmEmail} /> <Route exact path="/confirm-email" component={ConfirmEmail} />
<Route exact path="/reset-password" component={ResetPassword} /> <Route exact path="/reset-password" component={ResetPassword} />
<Route exact path="/forgot-password" component={ForgotPassword} /> <Route exact path="/forgot-password" component={ForgotPassword} />
<ProtectedRoute <ProtectedRoute exact path="/app/profile" component={Profile} />
exact <ProtectedRoute exact admin path="/app/home" component={Home} />
path="/profile" <ProtectedRoute exact admin path="/app/teams" component={Teams} />
render={(props) => ( <ProtectedRoute exact admin path="/system" component={System} />
<Profile {...props} changeTitle={this.handleChangeTitle} /> <ProtectedRoute exact admin path="/app/users" component={Users} />
)} <DefaultRoute redirect="/app/home" />
/>
<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 />
</Switch> </Switch>
<Column.Item> <Route
<Box path="/app"
background={colorInfo.headerButtonBackground} render={() => (
borderTop={{ <Column.Item>
width: sizeInfo.headerBorderWidth, <Footer
color: colorInfo.headerBorder, text={
}}> "v" + versionInfo.fullVersion + " " + versionInfo.copyright
<Text color="dimmed" margin={sizeInfo.footerTextMargin}> }
{"v" + versionInfo.fullVersion} {versionInfo.copyright} />
</Text> </Column.Item>
</Box> )}
</Column.Item> />
</Column> </Column>
</Router> </BrowserRouter>
) )
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,11 @@
import React, { Component, Fragment } from 'react' import React, { Component, Fragment } from "react"
import PropTypes from 'prop-types' import PropTypes from "prop-types"
import { Row, Column, PanelButton } from 'ui' import { Row, Column, PanelButton } from "ui"
import { sizeInfo } from 'ui/style' import { sizeInfo } from "ui/style"
export class Home extends Component { export class Home extends Component {
static propTypes = { static propTypes = {
history: PropTypes.object, history: PropTypes.object,
changeTitle: PropTypes.func.isRequired,
}
componentDidMount() {
this.props.changeTitle('Home')
}
componentWillUnmount() {
this.props.changeTitle('')
} }
render() { render() {
@@ -25,15 +16,27 @@ export class Home extends Component {
<Row> <Row>
<Row.Item grow /> <Row.Item grow />
<Row.Item> <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>
<Row.Item width={sizeInfo.panelButtonSpacing} /> <Row.Item width={sizeInfo.panelButtonSpacing} />
<Row.Item> <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>
<Row.Item width={sizeInfo.panelButtonSpacing} /> <Row.Item width={sizeInfo.panelButtonSpacing} />
<Row.Item> <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>
<Row.Item grow /> <Row.Item grow />
</Row> </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 { Box, Image, Column, Row, Button, Link } from "ui"
import { MessageModal, WaitModal, YesNoMessageModal } from "../Modal" import { MessageModal, WaitModal, YesNoMessageModal } from "../Modal"
import { sizeInfo, colorInfo } from "ui/style" import { sizeInfo, colorInfo } from "ui/style"
import headerLogo from "images/deighton.png" import headerLogo from "images/logo.png"
import autobind from "autobind-decorator" import autobind from "autobind-decorator"
import { api } from "../API" import { api } from "../API"

View File

@@ -9,7 +9,7 @@ import { api } from "src/API"
export class TeamForm extends React.Component { export class TeamForm extends React.Component {
static propTypes = { static propTypes = {
team: PropTypes.object, item: PropTypes.object,
onSave: PropTypes.func, onSave: PropTypes.func,
onRemove: PropTypes.func, onRemove: PropTypes.func,
onModifiedChanged: PropTypes.func, onModifiedChanged: PropTypes.func,
@@ -21,6 +21,9 @@ export class TeamForm extends React.Component {
}, },
start: { start: {
isValid: (r, v) => v === "" || moment(v).isValid(), isValid: (r, v) => v === "" || moment(v).isValid(),
initValue: "",
pre: (v) => (v === null ? "" : v),
post: (v) => (v === "" ? null : v),
}, },
remove: { remove: {
noValue: true, noValue: true,
@@ -43,14 +46,14 @@ export class TeamForm extends React.Component {
this.state = { this.state = {
binder: new FormBinder( binder: new FormBinder(
props.team, props.item,
TeamForm.bindings, TeamForm.bindings,
this.props.onModifiedChanged this.props.onModifiedChanged
), ),
users: [], users: [],
} }
this.getUsersForTeam(props.team._id) this.getUsersForTeam(props.item._id)
} }
@autobind @autobind
@@ -69,16 +72,20 @@ export class TeamForm extends React.Component {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (nextProps.team !== this.props.team) { if (nextProps.item !== this.props.item) {
this.setState({ this.setState({
binder: new FormBinder( binder: new FormBinder(
nextProps.team, nextProps.item,
TeamForm.bindings, TeamForm.bindings,
nextProps.onModifiedChanged 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 @autobind
handleReset() { handleReset() {
const { team, onModifiedChanged } = this.props const { item, onModifiedChanged } = this.props
this.setState({ this.setState({
binder: new FormBinder(team, TeamForm.bindings, onModifiedChanged), binder: new FormBinder(item, TeamForm.bindings, onModifiedChanged),
}) })
if (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 React, { Component } from "react"
import PropTypes from "prop-types"
import autobind from "autobind-decorator"
import { TeamList } from "./TeamList"
import { TeamForm } from "./TeamForm" import { TeamForm } from "./TeamForm"
import { TeamFormPlaceholder } from "./TeamFormPlaceholder"
import { api } from "src/API" import { api } from "src/API"
import { Row, Column, Box } from "ui" import { MasterDetail } from "../MasterDetail"
import { import PropTypes from "prop-types"
YesNoMessageModal,
MessageModal,
ChangeEmailModal,
WaitModal,
} from "../Modal"
import { sizeInfo, colorInfo } from "ui/style"
export class Teams extends Component { export class Teams extends Component {
static propTypes = { static propTypes = {
changeTitle: PropTypes.func.isRequired, history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
}
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 })
}
} }
render() { render() {
const { messageModal, yesNoModal, changeEmailModal } = this.state
return ( return (
<Fragment> <MasterDetail
<Column.Item height={sizeInfo.formColumnSpacing} /> history={this.props.history}
<Column.Item grow> ref={(ref) => (this.masterDetail = ref)}
<Row fillParent> name="team"
<Row.Item width={sizeInfo.formRowSpacing} /> form={TeamForm}
<Row.Item width="25vw"> listItems={api.listTeams}
<TeamList updateItem={api.updateTeam}
teams={this.state.teams} createItem={api.createTeam}
selectedTeam={this.state.selectedTeam} deleteItem={api.deleteTeam}
selectionModified={this.state.modified} sort={(a, b) => a.name.localeCompare(b.name)}
onTeamListClick={this.handleTeamListClick} listData={(team) => ({
onAddNewTeam={this.handleAddNewTeam} 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 { export class UserForm extends React.Component {
static propTypes = { static propTypes = {
user: PropTypes.object, item: PropTypes.object,
onSave: PropTypes.func, onSave: PropTypes.func,
onRemove: PropTypes.func, onRemove: PropTypes.func,
onModifiedChanged: PropTypes.func, onModifiedChanged: PropTypes.func,
@@ -83,7 +83,7 @@ export class UserForm extends React.Component {
super(props) super(props)
this.state = { this.state = {
binder: new FormBinder( binder: new FormBinder(
props.user, props.item,
UserForm.bindings, UserForm.bindings,
props.onModifiedChanged props.onModifiedChanged
), ),
@@ -111,10 +111,10 @@ export class UserForm extends React.Component {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
if (nextProps.user !== this.props.user) { if (nextProps.item !== this.props.item) {
this.setState({ this.setState({
binder: new FormBinder( binder: new FormBinder(
nextProps.user, nextProps.item,
UserForm.bindings, UserForm.bindings,
nextProps.onModifiedChanged nextProps.onModifiedChanged
), ),
@@ -137,10 +137,10 @@ export class UserForm extends React.Component {
@autobind @autobind
handleReset() { handleReset() {
const { user, onModifiedChanged } = this.props const { item, onModifiedChanged } = this.props
this.setState({ this.setState({
binder: new FormBinder(user, UserForm.bindings, onModifiedChanged), binder: new FormBinder(item, UserForm.bindings, onModifiedChanged),
}) })
if (onModifiedChanged) { if (onModifiedChanged) {
@@ -179,7 +179,7 @@ export class UserForm extends React.Component {
return ( return (
<form <form
style={{ width: "100%", height: "100%", overflow: "scroll" }} style={{ width: "100%", height: "100%", overflow: "scroll" }}
id="userForm" id="UserForm"
onSubmit={this.handleSubmit}> onSubmit={this.handleSubmit}>
<Column> <Column>
<Column.Item height={sizeInfo.formColumnSpacing} /> <Column.Item height={sizeInfo.formColumnSpacing} />
@@ -303,7 +303,7 @@ export class UserForm extends React.Component {
<Row.Item width={sizeInfo.formRowSpacing} /> <Row.Item width={sizeInfo.formRowSpacing} />
<Row.Item> <Row.Item>
<BoundButton <BoundButton
submit="userForm" submit="UserForm"
text={binder._id ? "Save" : "Add"} text={binder._id ? "Save" : "Add"}
name="submit" name="submit"
binder={binder} 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 React, { Component } from "react"
import PropTypes from "prop-types"
import autobind from "autobind-decorator" import autobind from "autobind-decorator"
import { UserList } from "./UserList"
import { UserForm } from "./UserForm" import { UserForm } from "./UserForm"
import { UserFormPlaceholder } from "./UserFormPlaceholder"
import { api } from "src/API" import { api } from "src/API"
import { Row, Column, Box } from "ui" import { ChangeEmailModal } from "../Modal"
import { import { MasterDetail } from "../MasterDetail"
YesNoMessageModal, import PropTypes from "prop-types"
MessageModal,
ChangeEmailModal,
WaitModal,
} from "../Modal"
import { sizeInfo, colorInfo } from "ui/style"
export class Users extends Component { export class Users extends Component {
static propTypes = { static propTypes = {
changeTitle: PropTypes.func.isRequired, history: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
} }
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
modified: false,
selectedUser: null,
users: [],
yesNoModal: null,
messageModal: null,
waitModal: null,
changeEmailModal: 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 @autobind
handleChangeEmail() { handleChangeEmail() {
this.setState({ this.setState({
@@ -154,57 +31,41 @@ export class Users extends Component {
api api
.sendResetPassword(this.state.selectedUser.email) .sendResetPassword(this.state.selectedUser.email)
.then(() => { .then(() => {
this.setState({ this.masterDetail.hideWait()
waitModal: null, this.masterDetail.showMessage(
messageModal: { `An email has been sent to '${
icon: "thumb", this.masterDetail.selectedItem.email
message: `An email has been sent to '${ }' with instructions on how to reset their password`
this.state.selectedUser.email )
}' with instructions on how to reset their password`,
},
})
}) })
.catch((error) => { .catch((error) => {
this.setState({ this.masterDetail.hideWait()
error: true, this.masterDetail.showErrorMessage(
waitModal: null, "Unable to request password reset.",
messageModal: { error.message
icon: "hand", )
message: "Unable to request password reset.",
detail: error.message,
},
})
}) })
} }
@autobind @autobind
handleResendEmail() { handleResendEmail() {
this.setState({ this.masterDetail.showWait("Resending Email...")
waitModal: { message: "Resending Email..." },
})
api api
.sendConfirmEmail({ existingEmail: this.state.selectedUser.email }) .sendConfirmEmail({ existingEmail: this.masterDetail.selectedItem.email })
.then(() => { .then(() => {
this.setState({ this.masterDetail.hideWait()
waitModal: null, this.masterDetail.showMessage(
messageModal: { `An email has been sent to '${
icon: "thumb", this.masterDetail.selectedItem.email
message: `An email has been sent to '${ }' with further instructions.`
this.state.selectedUser.email )
}' with further instructions.`,
},
})
}) })
.catch((error) => { .catch((error) => {
this.setState({ this.masterDetail.hideWait()
error: true, this.masterDetail.showErrorMessage(
waitModal: null, "Unable to request email change.",
messageModal: { error.message
icon: "hand", )
message: "Unable to request email change.",
detail: error.message,
},
})
}) })
} }
@@ -224,187 +85,48 @@ export class Users extends Component {
newEmail, newEmail,
}) })
.then(() => { .then(() => {
this.setState({ this.masterDetail.showMessage(
waitModal: null, `An email has been sent to '${newEmail}' to confirm this email.`
messageModal: { )
icon: "hand",
message: `An email has been sent to '${newEmail}' to confirm this email.`,
},
})
}) })
.catch((error) => { .catch((error) => {
this.setState({ this.masterDetail.showErrorMessage(
error: true, "Unable to request email change.",
waitModal: null, error.message
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
)
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.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() { render() {
const { messageModal, yesNoModal, changeEmailModal } = this.state const { changeEmailModal } = this.state
return ( return (
<Fragment> <MasterDetail
<Column.Item height={sizeInfo.formColumnSpacing} /> history={this.props.history}
<Column.Item grow> ref={(ref) => (this.masterDetail = ref)}
<Row fillParent> name="user"
<Row.Item width={sizeInfo.formRowSpacing} /> form={UserForm}
<Row.Item width="25vw"> listItems={api.listUsers}
<UserList updateItem={api.updateUser}
users={this.state.users} createItem={api.createUser}
selectedUser={this.state.selectedUser} deleteItem={api.deleteUser}
selectionModified={this.state.modified} detailCallbacks={{
onUserListClick={this.handleUserListClick} onChangeEmail: this.handleChangeEmail,
onAddNewUser={this.handleAddNewUser} onResendEmail: this.handleResendEmail,
/> onResetPassword: this.handleSendPasswordReset,
</Row.Item> }}
<Row.Item width={sizeInfo.formRowSpacing} /> sort={(a, b) => 0}
<Row.Item grow> listData={(user) => ({
<Box icon: user.administrator ? "admin" : "profile",
border={{ text: user.firstName + " " + user.lastName,
width: sizeInfo.headerBorderWidth, })}>
color: colorInfo.headerBorder, <ChangeEmailModal
}} open={!!changeEmailModal}
radius={sizeInfo.formBoxRadius}> oldEmail={changeEmailModal && changeEmailModal.oldEmail}
{this.state.selectedUser ? ( onDismiss={this.handleChangeEmailDismiss}
<UserForm />
user={this.state.selectedUser} </MasterDetail>
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}>
<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>
) )
} }
} }

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 = { static svgs = {
logout: require("icons/logout.svg"), admin: require("./icons/admin.svg"),
thumb: require("icons/thumb.svg"), blank: require("./icons/blank.svg"),
profile: require("icons/profile.svg"), clock: require("./icons/clock.svg"),
admin: require("icons/admin.svg"), confirmed: require("./icons/confirmed.svg"),
hand: require("icons/hand.svg"), edit: require("./icons/edit.svg"),
users: require("icons/users.svg"), hand: require("./icons/hand.svg"),
team: require("icons/team.svg"), help: require("./icons/help.svg"),
teams: require("icons/teams.svg"), logout: require("./icons/logout.svg"),
system: require("icons/system.svg"), profile: require("./icons/profile.svg"),
confirmed: require("icons/confirmed.svg"), placeholder: require("./icons/placeholder.svg"),
help: require("icons/help.svg"), system: require("./icons/system.svg"),
warning: require("icons/warning.svg"), thumb: require("./icons/thumb.svg"),
edit: require("icons/edit.svg"), team: require("./icons/team.svg"),
placeholder: require("icons/placeholder.svg"), teams: require("./icons/teams.svg"),
clock: require("icons/clock.svg"), users: require("./icons/users.svg"),
warning: require("./icons/warning.svg"),
} }
render() { 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 { Box } from "./Box"
export { Button } from "./Button" export { Button } from "./Button"
export { FormIconButton } from "./FormIconButton" export { FormIconButton } from "./FormIconButton"
export { HeaderButton } from "./HeaderButton" export { Header } from "./Header"
export { HeaderText } from "./HeaderText" export { Footer } from "./Footer"
export { PanelButton } from "./PanelButton" export { PanelButton } from "./PanelButton"
export { Checkbox } from "./Checkbox" export { Checkbox } from "./Checkbox"
export { Input } from "./Input" export { Input } from "./Input"