Remove code duplication and add better 404 handler

This commit is contained in:
Tulir Asokan 2018-11-10 20:50:09 +02:00
parent f97b39f4e3
commit bc97df7de8
6 changed files with 130 additions and 177 deletions

View file

@ -0,0 +1,68 @@
import React, { Component } from "react"
import { Link } from "react-router-dom"
class BaseMainView extends Component {
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.entry)
}
componentWillReceiveProps(nextProps) {
this.setState(Object.assign(this.initialState, nextProps.entry))
}
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await this.deleteFunc(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
get initialState() {
throw Error("Not implemented")
}
get hasInstances() {
return this.state.instances && this.state.instances.length > 0
}
get isNew() {
return !this.props.entry
}
inputChange = event => {
if (!event.target.name) {
return
}
this.setState({ [event.target.name]: event.target.value })
}
async readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = evt => resolve(evt.target.result)
reader.onerror = err => reject(err)
})
}
renderInstances = () => !this.isNew && (
<div className="instances">
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
{this.state.instances.map(instance => (
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
{instance.id}
</Link>
))}
</div>
)
}
export default BaseMainView

View file

@ -13,36 +13,37 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React from "react"
import { Link, NavLink, withRouter } from "react-router-dom" import { NavLink, withRouter } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg" import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg" import { ReactComponent as UploadButton } from "../../res/upload.svg"
import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable" import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner" import Spinner from "../../components/Spinner"
import api from "../../api" import api from "../../api"
import BaseMainView from "./BaseMainView"
const ClientListEntry = ({ client }) => { const ClientListEntry = ({ entry }) => {
const classes = ["client", "entry"] const classes = ["client", "entry"]
if (!client.enabled) { if (!entry.enabled) {
classes.push("disabled") classes.push("disabled")
} else if (!client.started) { } else if (!entry.started) {
classes.push("stopped") classes.push("stopped")
} }
return ( return (
<NavLink className={classes.join(" ")} to={`/client/${client.id}`}> <NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
<img className="avatar" src={api.getAvatarURL(client.id)} alt=""/> <img className="avatar" src={api.getAvatarURL(entry.id)} alt=""/>
<span className="displayname">{client.displayname || client.id}</span> <span className="displayname">{entry.displayname || entry.id}</span>
<ChevronRight/> <ChevronRight/>
</NavLink> </NavLink>
) )
} }
class Client extends Component { class Client extends BaseMainView {
static ListEntry = ClientListEntry static ListEntry = ClientListEntry
constructor(props) { constructor(props) {
super(props) super(props)
this.state = Object.assign(this.initialState, props.client) this.deleteFunc = api.deleteClient
} }
get initialState() { get initialState() {
@ -78,26 +79,6 @@ class Client extends Component {
return client return client
} }
componentWillReceiveProps(nextProps) {
this.setState(Object.assign(this.initialState, nextProps.client))
}
inputChange = event => {
if (!event.target.name) {
return
}
this.setState({ [event.target.name]: event.target.value })
}
async readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = evt => resolve(evt.target.result)
reader.onerror = err => reject(err)
})
}
avatarUpload = async event => { avatarUpload = async event => {
const file = event.target.files[0] const file = event.target.files[0]
this.setState({ this.setState({
@ -126,25 +107,11 @@ class Client extends Component {
} }
} }
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await api.deleteClient(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
startOrStop = async () => { startOrStop = async () => {
this.setState({ startingOrStopping: true }) this.setState({ startingOrStopping: true })
const resp = await api.putClient({ const resp = await api.putClient({
id: this.props.client.id, id: this.props.entry.id,
started: !this.props.client.started, started: !this.props.entry.started,
}) })
if (resp.id) { if (resp.id) {
this.props.onChange(resp) this.props.onChange(resp)
@ -158,10 +125,6 @@ class Client extends Component {
return this.state.saving || this.state.startingOrStopping || this.state.deleting return this.state.saving || this.state.startingOrStopping || this.state.deleting
} }
get isNew() {
return !Boolean(this.props.client)
}
renderSidebar = () => !this.isNew && ( renderSidebar = () => !this.isNew && (
<div className="sidebar"> <div className="sidebar">
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"} <div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
@ -175,17 +138,17 @@ class Client extends Component {
{this.state.uploadingAvatar && <Spinner/>} {this.state.uploadingAvatar && <Spinner/>}
</div> </div>
<div className="started-container"> <div className="started-container">
<span className={`started ${this.props.client.started} <span className={`started ${this.props.entry.started}
${this.props.client.enabled ? "" : "disabled"}`}/> ${this.props.entry.enabled ? "" : "disabled"}`}/>
<span className="text"> <span className="text">
{this.props.client.started ? "Started" : {this.props.entry.started ? "Started" :
(this.props.client.enabled ? "Stopped" : "Disabled")} (this.props.entry.enabled ? "Stopped" : "Disabled")}
</span> </span>
</div> </div>
{(this.props.client.started || this.props.client.enabled) && ( {(this.props.entry.started || this.props.entry.enabled) && (
<button className="save" onClick={this.startOrStop} disabled={this.loading}> <button className="save" onClick={this.startOrStop} disabled={this.loading}>
{this.state.startingOrStopping ? <Spinner/> {this.state.startingOrStopping ? <Spinner/>
: (this.props.client.started ? "Stop" : "Start")} : (this.props.entry.started ? "Stop" : "Start")}
</button> </button>
)} )}
</div> </div>
@ -236,21 +199,6 @@ class Client extends Component {
<div className="error">{this.state.error}</div> <div className="error">{this.state.error}</div>
</> </>
get hasInstances() {
return this.state.instances.length > 0
}
renderInstances = () => !this.isNew && (
<div className="instances">
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
{this.state.instances.map(instance => (
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
{instance.id}
</Link>
))}
</div>
)
render() { render() {
return <div className="client"> return <div className="client">
{this.renderSidebar()} {this.renderSidebar()}

View file

@ -13,7 +13,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React from "react"
import { NavLink, withRouter } from "react-router-dom" import { NavLink, withRouter } from "react-router-dom"
import AceEditor from "react-ace" import AceEditor from "react-ace"
import "brace/mode/yaml" import "brace/mode/yaml"
@ -22,20 +22,21 @@ import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable" import PrefTable, { PrefInput, PrefSelect, PrefSwitch } from "../../components/PreferenceTable"
import api from "../../api" import api from "../../api"
import Spinner from "../../components/Spinner" import Spinner from "../../components/Spinner"
import BaseMainView from "./BaseMainView"
const InstanceListEntry = ({ instance }) => ( const InstanceListEntry = ({ entry }) => (
<NavLink className="instance entry" to={`/instance/${instance.id}`}> <NavLink className="instance entry" to={`/instance/${entry.id}`}>
<span className="id">{instance.id}</span> <span className="id">{entry.id}</span>
<ChevronRight/> <ChevronRight/>
</NavLink> </NavLink>
) )
class Instance extends Component { class Instance extends BaseMainView {
static ListEntry = InstanceListEntry static ListEntry = InstanceListEntry
constructor(props) { constructor(props) {
super(props) super(props)
this.state = Object.assign(this.initialState, props.instance) this.deleteFunc = api.deleteInstance
this.updateClientOptions() this.updateClientOptions()
} }
@ -63,7 +64,7 @@ class Instance extends Component {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
this.setState(Object.assign(this.initialState, nextProps.instance)) super.componentWillReceiveProps(nextProps)
this.updateClientOptions() this.updateClientOptions()
} }
@ -82,22 +83,15 @@ class Instance extends Component {
this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry) this.clientOptions = Object.values(this.props.ctx.clients).map(this.clientSelectEntry)
} }
inputChange = event => {
if (!event.target.name) {
return
}
this.setState({ [event.target.name]: event.target.value })
}
save = async () => { save = async () => {
this.setState({ saving: true }) this.setState({ saving: true })
const resp = await api.putInstance(this.instanceInState, this.props.instance const resp = await api.putInstance(this.instanceInState, this.props.entry
? this.props.instance.id : undefined) ? this.props.entry.id : undefined)
if (resp.id) { if (resp.id) {
if (this.isNew) { if (this.isNew) {
this.props.history.push(`/instance/${resp.id}`) this.props.history.push(`/instance/${resp.id}`)
} else { } else {
if (resp.id !== this.props.instance.id) { if (resp.id !== this.props.entry.id) {
this.props.history.replace(`/instance/${resp.id}`) this.props.history.replace(`/instance/${resp.id}`)
} }
this.setState({ saving: false, error: "" }) this.setState({ saving: false, error: "" })
@ -108,24 +102,6 @@ class Instance extends Component {
} }
} }
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await api.deleteInstance(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
get isNew() {
return !Boolean(this.props.instance)
}
get selectedClientEntry() { get selectedClientEntry() {
return this.state.primary_user return this.state.primary_user
? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user]) ? this.clientSelectEntry(this.props.ctx.clients[this.state.primary_user])

View file

@ -13,30 +13,26 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react" import React from "react"
import { NavLink, Link } from "react-router-dom" import { NavLink } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg" import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg" import { ReactComponent as UploadButton } from "../../res/upload.svg"
import PrefTable, { PrefInput } from "../../components/PreferenceTable" import PrefTable, { PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner" import Spinner from "../../components/Spinner"
import api from "../../api" import api from "../../api"
import BaseMainView from "./BaseMainView"
const PluginListEntry = ({ plugin }) => ( const PluginListEntry = ({ entry }) => (
<NavLink className="plugin entry" to={`/plugin/${plugin.id}`}> <NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
<span className="id">{plugin.id}</span> <span className="id">{entry.id}</span>
<ChevronRight/> <ChevronRight/>
</NavLink> </NavLink>
) )
class Plugin extends Component { class Plugin extends BaseMainView {
static ListEntry = PluginListEntry static ListEntry = PluginListEntry
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.plugin)
}
get initialState() { get initialState() {
return { return {
id: "", id: "",
@ -50,18 +46,6 @@ class Plugin extends Component {
} }
} }
componentWillReceiveProps(nextProps) {
this.setState(Object.assign(this.initialState, nextProps.plugin))
}
async readFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.onload = evt => resolve(evt.target.result)
reader.onerror = err => reject(err)
})
}
upload = async event => { upload = async event => {
const file = event.target.files[0] const file = event.target.files[0]
this.setState({ this.setState({
@ -81,39 +65,6 @@ class Plugin extends Component {
} }
} }
delete = async () => {
if (!window.confirm(`Are you sure you want to delete ${this.state.id}?`)) {
return
}
this.setState({ deleting: true })
const resp = await api.deletePlugin(this.state.id)
if (resp.success) {
this.props.history.push("/")
this.props.onDelete()
} else {
this.setState({ deleting: false, error: resp.error })
}
}
get isNew() {
return !Boolean(this.props.plugin)
}
get hasInstances() {
return this.state.instances.length > 0
}
renderInstances = () => !this.isNew && (
<div className="instances">
<h3>{this.hasInstances ? "Instances" : "No instances :("}</h3>
{this.state.instances.map(instance => (
<Link className="instance" key={instance.id} to={`/instance/${instance.id}`}>
{instance.id}
</Link>
))}
</div>
)
render() { render() {
return <div className="plugin"> return <div className="plugin">
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}> <div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>

View file

@ -29,7 +29,7 @@ class Dashboard extends Component {
clients: {}, clients: {},
plugins: {}, plugins: {},
} }
global.maubot = this window.maubot = this
} }
async componentWillMount() { async componentWillMount() {
@ -51,8 +51,8 @@ class Dashboard extends Component {
} }
renderList(field, type) { renderList(field, type) {
return Object.values(this.state[field + "s"]).map(entry => return this.state[field] && Object.values(this.state[field]).map(entry =>
React.createElement(type, { key: entry.id, [field]: entry })) React.createElement(type, { key: entry.id, entry }))
} }
delete(stateField, id) { delete(stateField, id) {
@ -71,19 +71,24 @@ class Dashboard extends Component {
} }
renderView(field, type, id) { renderView(field, type, id) {
const stateField = field + "s" const entry = this.state[field][id]
const entry = this.state[stateField][id]
if (!entry) { if (!entry) {
return "Not found :(" return this.renderNotFound(field.slice(0, -1))
} }
return React.createElement(type, { return React.createElement(type, {
[field]: entry, entry,
onDelete: () => this.delete(stateField, id), onDelete: () => this.delete(field, id),
onChange: newEntry => this.add(stateField, newEntry, id), onChange: newEntry => this.add(field, newEntry, id),
ctx: this.state, ctx: this.state,
}) })
} }
renderNotFound = (thing = "path") => (
<div className="not-found">
Oops! I'm afraid that {thing} couldn't be found.
</div>
)
render() { render() {
return <div className="dashboard"> return <div className="dashboard">
<Link to="/" className="title"> <Link to="/" className="title">
@ -100,21 +105,21 @@ class Dashboard extends Component {
<h2>Instances</h2> <h2>Instances</h2>
<Link to="/new/instance"><Plus/></Link> <Link to="/new/instance"><Plus/></Link>
</div> </div>
{this.renderList("instance", Instance.ListEntry)} {this.renderList("instances", Instance.ListEntry)}
</div> </div>
<div className="clients list"> <div className="clients list">
<div className="title"> <div className="title">
<h2>Clients</h2> <h2>Clients</h2>
<Link to="/new/client"><Plus/></Link> <Link to="/new/client"><Plus/></Link>
</div> </div>
{this.renderList("client", Client.ListEntry)} {this.renderList("clients", Client.ListEntry)}
</div> </div>
<div className="plugins list"> <div className="plugins list">
<div className="title"> <div className="title">
<h2>Plugins</h2> <h2>Plugins</h2>
<Link to="/new/plugin"><Plus/></Link> <Link to="/new/plugin"><Plus/></Link>
</div> </div>
{this.renderList("plugin", Plugin.ListEntry)} {this.renderList("plugins", Plugin.ListEntry)}
</div> </div>
</nav> </nav>
<main className="view"> <main className="view">
@ -128,12 +133,12 @@ class Dashboard extends Component {
<Route path="/new/plugin" render={() => <Plugin <Route path="/new/plugin" render={() => <Plugin
onChange={newEntry => this.add("plugins", newEntry)}/>}/> onChange={newEntry => this.add("plugins", newEntry)}/>}/>
<Route path="/instance/:id" render={({ match }) => <Route path="/instance/:id" render={({ match }) =>
this.renderView("instance", Instance, match.params.id)}/> this.renderView("instances", Instance, match.params.id)}/>
<Route path="/client/:id" render={({ match }) => <Route path="/client/:id" render={({ match }) =>
this.renderView("client", Client, match.params.id)}/> this.renderView("clients", Client, match.params.id)}/>
<Route path="/plugin/:id" render={({ match }) => <Route path="/plugin/:id" render={({ match }) =>
this.renderView("plugin", Plugin, match.params.id)}/> this.renderView("plugin", Plugin, match.params.id)}/>
<Route render={() => "Not found :("}/> <Route render={() => this.renderNotFound()}/>
</Switch> </Switch>
</main> </main>
</div> </div>

View file

@ -69,6 +69,11 @@
@import instance @import instance
@import plugin @import plugin
> .not-found
text-align: center
margin-top: 5rem
font-size: 1.5rem
div.buttons div.buttons
+button-group +button-group
display: flex display: flex