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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import { Link, NavLink, withRouter } from "react-router-dom"
import React from "react"
import { NavLink, withRouter } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import { PrefTable, PrefSwitch, PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const ClientListEntry = ({ client }) => {
const ClientListEntry = ({ entry }) => {
const classes = ["client", "entry"]
if (!client.enabled) {
if (!entry.enabled) {
classes.push("disabled")
} else if (!client.started) {
} else if (!entry.started) {
classes.push("stopped")
}
return (
<NavLink className={classes.join(" ")} to={`/client/${client.id}`}>
<img className="avatar" src={api.getAvatarURL(client.id)} alt=""/>
<span className="displayname">{client.displayname || client.id}</span>
<NavLink className={classes.join(" ")} to={`/client/${entry.id}`}>
<img className="avatar" src={api.getAvatarURL(entry.id)} alt=""/>
<span className="displayname">{entry.displayname || entry.id}</span>
<ChevronRight/>
</NavLink>
)
}
class Client extends Component {
class Client extends BaseMainView {
static ListEntry = ClientListEntry
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.client)
this.deleteFunc = api.deleteClient
}
get initialState() {
@ -78,26 +79,6 @@ class Client extends Component {
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 => {
const file = event.target.files[0]
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 () => {
this.setState({ startingOrStopping: true })
const resp = await api.putClient({
id: this.props.client.id,
started: !this.props.client.started,
id: this.props.entry.id,
started: !this.props.entry.started,
})
if (resp.id) {
this.props.onChange(resp)
@ -158,10 +125,6 @@ class Client extends Component {
return this.state.saving || this.state.startingOrStopping || this.state.deleting
}
get isNew() {
return !Boolean(this.props.client)
}
renderSidebar = () => !this.isNew && (
<div className="sidebar">
<div className={`avatar-container ${this.state.avatar_url ? "" : "no-avatar"}
@ -175,17 +138,17 @@ class Client extends Component {
{this.state.uploadingAvatar && <Spinner/>}
</div>
<div className="started-container">
<span className={`started ${this.props.client.started}
${this.props.client.enabled ? "" : "disabled"}`}/>
<span className={`started ${this.props.entry.started}
${this.props.entry.enabled ? "" : "disabled"}`}/>
<span className="text">
{this.props.client.started ? "Started" :
(this.props.client.enabled ? "Stopped" : "Disabled")}
{this.props.entry.started ? "Started" :
(this.props.entry.enabled ? "Stopped" : "Disabled")}
</span>
</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}>
{this.state.startingOrStopping ? <Spinner/>
: (this.props.client.started ? "Stop" : "Start")}
: (this.props.entry.started ? "Stop" : "Start")}
</button>
)}
</div>
@ -236,21 +199,6 @@ class Client extends Component {
<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() {
return <div className="client">
{this.renderSidebar()}

View file

@ -13,7 +13,7 @@
//
// 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/>.
import React, { Component } from "react"
import React from "react"
import { NavLink, withRouter } from "react-router-dom"
import AceEditor from "react-ace"
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 api from "../../api"
import Spinner from "../../components/Spinner"
import BaseMainView from "./BaseMainView"
const InstanceListEntry = ({ instance }) => (
<NavLink className="instance entry" to={`/instance/${instance.id}`}>
<span className="id">{instance.id}</span>
const InstanceListEntry = ({ entry }) => (
<NavLink className="instance entry" to={`/instance/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight/>
</NavLink>
)
class Instance extends Component {
class Instance extends BaseMainView {
static ListEntry = InstanceListEntry
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.instance)
this.deleteFunc = api.deleteInstance
this.updateClientOptions()
}
@ -63,7 +64,7 @@ class Instance extends Component {
}
componentWillReceiveProps(nextProps) {
this.setState(Object.assign(this.initialState, nextProps.instance))
super.componentWillReceiveProps(nextProps)
this.updateClientOptions()
}
@ -82,22 +83,15 @@ class Instance extends Component {
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 () => {
this.setState({ saving: true })
const resp = await api.putInstance(this.instanceInState, this.props.instance
? this.props.instance.id : undefined)
const resp = await api.putInstance(this.instanceInState, this.props.entry
? this.props.entry.id : undefined)
if (resp.id) {
if (this.isNew) {
this.props.history.push(`/instance/${resp.id}`)
} else {
if (resp.id !== this.props.instance.id) {
if (resp.id !== this.props.entry.id) {
this.props.history.replace(`/instance/${resp.id}`)
}
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() {
return 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
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import React, { Component } from "react"
import { NavLink, Link } from "react-router-dom"
import React from "react"
import { NavLink } from "react-router-dom"
import { ReactComponent as ChevronRight } from "../../res/chevron-right.svg"
import { ReactComponent as UploadButton } from "../../res/upload.svg"
import PrefTable, { PrefInput } from "../../components/PreferenceTable"
import Spinner from "../../components/Spinner"
import api from "../../api"
import BaseMainView from "./BaseMainView"
const PluginListEntry = ({ plugin }) => (
<NavLink className="plugin entry" to={`/plugin/${plugin.id}`}>
<span className="id">{plugin.id}</span>
const PluginListEntry = ({ entry }) => (
<NavLink className="plugin entry" to={`/plugin/${entry.id}`}>
<span className="id">{entry.id}</span>
<ChevronRight/>
</NavLink>
)
class Plugin extends Component {
class Plugin extends BaseMainView {
static ListEntry = PluginListEntry
constructor(props) {
super(props)
this.state = Object.assign(this.initialState, props.plugin)
}
get initialState() {
return {
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 => {
const file = event.target.files[0]
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() {
return <div className="plugin">
<div className={`upload-box ${this.state.uploading ? "uploading" : ""}`}>

View file

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

View file

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