diff options
Diffstat (limited to 'frontend/src')
-rw-r--r-- | frontend/src/app.css | 12 | ||||
-rw-r--r-- | frontend/src/app.js | 582 | ||||
-rw-r--r-- | frontend/src/auth.js | 17 | ||||
-rw-r--r-- | frontend/src/index.js | 40 |
4 files changed, 636 insertions, 15 deletions
diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..78aca4a --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,12 @@ +.card .breadcrumb { + padding: 0em; + background-color: inherit; +} + +@media (min-width: 576px) { + #root .card-deck .card, + #root .card-columns .card, + #root .card-group .card { + min-width: 20rem; + } +} diff --git a/frontend/src/app.js b/frontend/src/app.js index d445faa..d9be619 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,9 +1,381 @@ import React, { useContext } from 'react'; -import { Jumbotron } from 'react-bootstrap'; +import { Breadcrumb, Spinner, CardColumns, Card, Jumbotron, Row, Col, Form, Button } from 'react-bootstrap'; +import { Redirect, Link } from 'react-router-dom'; import { UserContext } from './auth.js'; +import { DateTime } from 'luxon'; +import { AsyncTypeahead } from 'react-bootstrap-typeahead'; +import './app.css'; -function UserHome() { - return 'user logged in'; +// Set parent on all node children +function set_children_parents(node) { + if (node.children) { + node.children.forEach(function(item) { + item.parent = node; + set_children_parents(item); + }); + } + return node; +} + +function Node(props) { + const node = props.node; + let change_datetime = null; + if (node.updated_at) { + const updated_at = node.updated_at ? DateTime.fromISO(node.updated_at).toLocal() : null; + change_datetime = <span title={updated_at.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}>Updated {updated_at.toRelative()}</span>; + } else { + const created_at = DateTime.fromISO(node.created_at).toLocal(); + change_datetime = <span title={created_at.toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS)}>Created {created_at.toRelative()}</span>; + } + let parents = []; + parents.push(<Breadcrumb.Item key={node.id} active>{node.name}</Breadcrumb.Item>); + //parents.push(<li className="breadcrumb-item active" key={node.id}> + // <Link to={`/node/${node.id}`} key={node.id}>{node.name}</Link> + //</li>); + for (let current = node.parent; current; current = current.parent) { + parents.push(<li className="breadcrumb-item" key={current.id}> + <Link to={{pathname: `/node/${current.id}`, state: {node: current}}}>{current.name}</Link> + </li>); + } + if (parents.length > 1) { + parents.reverse(); + parents = <Breadcrumb>{parents}</Breadcrumb>; + } else { + parents = ''; + } + let children = ''; + if (node.children && node.children.length > 0) { + children = node.children.map(item => + <React.Fragment key={item.id}> + <Link to={{pathname: `/node/${item.id}`, state: {node: item}}} className="card-link">{item.name}</Link> + {item.children ? '+' : ''} + </React.Fragment> + ); + } + let fields = node.fields.map((field, index) => + <Row as={'dl'} key={`${node.id}-field-${index}`}> + <Col as={'dt'} sm={3} className="text-sm-right">{field.name}</Col> + <Col as={'dd'} sm={9}>{field.value}</Col> + </Row> + ); + let buttons = ''; + if (props.editable) { + // Using Button here throws errors because of some navigation attribute, so just set the class names manually + buttons = <> + <Link to={{pathname: `/node/${node.id}/edit`, state: {node: node}}} + className="float-right btn btn-sm btn-info">Edit</Link> + <Link to={{pathname: `/node/${node.id}/delete`, state: {node: node}}} + className="float-right btn btn-sm btn-danger">Delete</Link> + </>; + } + return <Card> + <Card.Header> + {change_datetime} + {buttons} + </Card.Header> + <Card.Body> + {parents} + <Card.Title><Link to={{pathname: `/node/${node.id}`, state: {node: node}}}>{node.name}</Link></Card.Title> + <Card.Text className="node-fields" as="div"> + {fields} + </Card.Text> + </Card.Body> + <Card.Footer> + {children} + {props.editable ? <Link to={{pathname: '/add', state: {parent: node.name, parent_id: node.id}}} className="btn btn-sm btn-primary float-right">Add</Link> : ''} + </Card.Footer> + </Card>; +} + +class NodeEditor extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + this.state = { + loading: false, + name: '', + errors: {}, + parent: '', + parent_id: null, + validated: false, + fields: [], + parents_searching: false, + parents: [], + redirect_id: null, + }; + if (props.location.state !== undefined) { + const node = props.location.state.node; + if (node !== undefined) { + this.state.id = node.id; + this.state.name = node.name; + this.state.parent = node.parent ? node.parent.name : ''; + this.state.parent_id = node.parent_id; + this.state.fields = [...node.fields]; + } + const parent = props.location.state.parent; + const parent_id = props.location.state.parent_id; + if (parent !== undefined && parent_id !== undefined) { + this.state.parent = parent; + this.state.parent_id = parent_id; + } + } + this.on_name_change = this.on_name_change.bind(this); + this.on_parent_change = this.on_parent_change.bind(this); + this.add_field = this.add_field.bind(this); + this.remove_field = this.remove_field.bind(this); + this.on_field_change = this.on_field_change.bind(this); + this.on_parents_search = this.on_parents_search.bind(this); + this.save = this.save.bind(this); + } + render() { + if (this.state.redirect_id !== null) { + return <Redirect to={`/node/${this.state.redirect_id}`} />; + } + if (this.state.loading) { + return <Spinner animation="border"> + <span className="sr-only">Loading…</span> + </Spinner>; + } + const that = this; + return <div> + <h2>{this.title}</h2> + <Form noValidate validated={this.state.validated} id="form"> + <Form.Group controlId="name" className={this.state.errors.name === undefined ? 'is-valid' : 'is-invalid'}> + <Form.Label>Name</Form.Label> + <Form.Control type="text" placeholder="Name" aria-label="Name" + value={this.state.name} onChange={this.on_name_change} required /> + <Form.Control.Feedback type="invalid">{this.state.errors.name}</Form.Control.Feedback> + </Form.Group> + <Form.Group controlId="parent" className={this.state.errors.parent === undefined ? 'is-valid' : 'is-invalid'}> + <Form.Label>Parent</Form.Label> + <AsyncTypeahead id="parent-options" placeholder="Parent" inputProps={{id: "parent"}} onChange={this.on_parent_change} + defaultSelected={[{name: this.state.parent, id: this.state.parent_id}]} + options={this.state.parents} onSearch={this.on_parents_search} + filterBy={value => true} + labelKey="name" + isLoading={this.state.parents_searching} + getSuggestionValue={value => value} + renderMenuItemChildren={(option, props) => (<div>{option.name}</div>)} + /> + <Form.Control.Feedback type="invalid">{this.state.errors.parent}</Form.Control.Feedback> + </Form.Group> + <Form.Group> + <Form.Label>Fields</Form.Label> + {this.state.fields.map(function(field, index) { + return <Form.Row key={index}> + <Form.Group controlId={`field-${index}-name`} as={Col} md="5"> + <Form.Control placeholder="Name" aria-label="Name" value={field.name} + onChange={that.on_field_change} data-index={index} data-field="name" /> + <Form.Control.Feedback type="invalid">{that.state.errors[`field-${index}-name`]}</Form.Control.Feedback> + </Form.Group> + <Form.Group controlId={`field-${index}-value`} as={Col} md="6"> + <Form.Control placeholder="Value" aria-label="Value" value={field.value} + onChange={that.on_field_change} data-index={index} data-field="value" /> + <Form.Control.Feedback type="invalid">{that.state.errors[`field-${index}-value`]}</Form.Control.Feedback> + </Form.Group> + <Col md="1"> + <Button variant="danger" onClick={that.remove_field} data-index={index}>Remove</Button> + </Col> + </Form.Row>; + })} + </Form.Group> + <Form.Group> + <Button variant="secondary" onClick={this.add_field}>Add field</Button> + </Form.Group> + <Button variant="primary" onClick={this.save}>Save</Button> + </Form> + </div>; + } + on_name_change(event) { + this.setState({name: event.target.value}); + } + on_parent_change(items) { + let item = items[0] || {}; + this.setState({parent: item.name || '', parent_id: item.id || null}); + } + add_field(event) { + event.preventDefault(); + this.setState({fields: [...this.state.fields, {name: '', value: ''}], validated: false}); + } + remove_field(event) { + let fields = [...this.state.fields]; + fields.splice(event.target.dataset.index, 1); + this.setState({fields: fields, validated: false}); + } + on_field_change(event) { + const index = event.target.dataset.index; + let fields = [...this.state.fields]; + let field = {...this.state.fields[index]}; + field[event.target.dataset.field] = event.target.value; + fields[index] = field; + this.setState({fields: fields, validated: false}); + } + async on_parents_search(q) { + this.setState({parents_searching: true}); + let token = await this.context.get_token(); + let response = await fetch('/api/search', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'q=' + encodeURIComponent(q), + }); + let data = await response.json(); + this.setState({parents: data, parents_searching: false}); + } + validate() { + let errors = {}; + if (this.state.name === '') { + errors.name = 'Name must be specified'; + } + if (this.state.parent !== '' && this.state.parent_id === null) { + errors.parent = 'Parent must be empty or set to a valid value'; + } + this.state.fields.forEach(function(field, index) { + if (field.name === '') { + errors[`field-${index}-name`] = 'Name must be specified'; + } + if (field.value === '') { + errors[`field-${index}-value`] = 'Value must be specified'; + } + }); + this.setState({validated: true, errors: errors}); + // Clear existing errors + document.querySelectorAll('form :invalid').forEach(e => e.setCustomValidity('')); + for (let key in errors) { + document.getElementById(key).setCustomValidity(errors[key]); + } + if (errors.length > 0) { + return false; + } + return true; + } +} + +class ViewNode extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + let node = null; + if (props.location.state !== undefined) { + node = props.location.state.node; + } + this.state = { + ready: node !== null, + node: node, + }; + this.mounted = false; + this.loading_id = null; + this.load_abort = null; + this.load_signal = null; + } + async componentDidMount() { + this.mounted = true; + try { + await this.load(); + } catch (error) { + if (!(error instanceof DOMException && error.code === 20)) { + throw error; + } + } + } + componentWillUnmount() { + this.mounted = false; + } + async componentDidUpdate() { + // Set and reload if we have received a new node in the location state + if (this.state.node !== null && this.props.location.state !== undefined && this.props.location.state.node !== null + && this.state.node.id !== this.props.location.state.node.id) { + this.setState({node: this.props.location.state.node}); + try { + await this.load(); + } catch (error) { + if (!(error instanceof DOMException && error.code === 20)) { + throw error; + } + } + } + // Load if the current node is not set, or if the node's id does not match the param id + if (this.state.node === null || this.props.match.params.id !== this.state.node.id) { + try { + await this.load(); + } catch (error) { + if (!(error instanceof DOMException && error.code === 20)) { + throw error; + } + } + } + } + render() { + if (!this.state.ready) { + return <Spinner animation="border"> + <span className="sr-only">Loading…</span> + </Spinner>; + } + return <Node node={this.state.node} editable={true} />; + } + async load() { + if (!this.mounted || this.loading_id === this.props.match.params.id) { + return; + } + if (this.load_abort !== null && !this.load_signal.aborted) { + this.load_abort.abort(); + } + let abort = this.load_abort = new AbortController(); + let signal = this.load_signal = abort.signal; + this.loading_id = this.props.match.params.id; + if (this.state.node === null && this.state.ready) { + this.setState({ready: false}); + } + const token = await this.context.get_token(); + let response = await fetch(`/api/nodes/${this.props.match.params.id}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + signal: signal, + }); + const node = await response.json(); + if (signal.aborted) { + return; + } + set_children_parents(node); + if (this.mounted && node.id === this.props.match.params.id) { + this.setState({node: node, ready: true}); + } + } +} + +class UserHome extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + this.state = { + ready: false, + nodes: [], + }; + } + async componentDidMount() { + const token = await this.context.get_token() + let response = await fetch('/api/nodes', { + headers: { + Authorization: `Bearer ${token}`, + } + }); + const nodes = await response.json(); + nodes.forEach(set_children_parents); + this.setState({nodes: nodes, ready: true}); + } + render() { + if (!this.state.ready) { + return <Spinner animation="border"> + <span className="sr-only">Loading…</span> + </Spinner>; + } + return <CardColumns> + {this.state.nodes.map(item => <Node key={item.id} node={item} />)} + </CardColumns>; + } } function Main() { @@ -12,9 +384,209 @@ function Main() { return <UserHome />; } return <Jumbotron> - <h1>Inventory</h1> + <h1>Unmess</h1> <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nulla pharetra fringilla dolor, sed tempor est. Maecenas malesuada feugiat nisl ut hendrerit. Donec faucibus augue quis mi fermentum, ac tempus neque dignissim. Nullam egestas bibendum enim, at feugiat lorem consectetur eu. Ut faucibus, dolor gravida ultricies malesuada, leo ex volutpat turpis, at placerat lectus mauris ut tortor. Phasellus sit amet lorem laoreet nunc sollicitudin pharetra. Pellentesque laoreet est lacinia velit porta aliquam ut quis sem. Sed vel convallis purus. Nunc elementum fermentum leo sit amet elementum.</p> </Jumbotron> } -export default Main; +class AddNode extends NodeEditor { + componentDidMount() { + this.title = 'Add new item'; + } + async save(event) { + if (!this.validate()) { + return; + } + let node = { + name: this.state.name, + parent_id: this.state.parent_id, + fields: this.state.fields, + } + let token = await this.context.get_token(); + let response = await fetch('/api/nodes', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(node), + }); + let data = await response.json(); + if (!response.ok) { + window.flash({header: 'Add node', text: 'Adding node failed'}); + return; + } + this.setState({redirect_id: data.id}); + } +} + +class EditNode extends NodeEditor { + async componentDidMount() { + this.title = 'Edit item'; + if (this.state.name === '') { + this.setState({loading: true}); + this.load(); + } + } + async load() { + let token = await this.context.get_token(); + let response = await fetch(`/api/nodes/${this.props.match.params.id}`, { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + token, + }, + }); + if (!response.ok) { + window.flash({header: 'Edit node', text: 'Failed to load node'}); + return; + } + let node = await response.json(); + this.setState({ + loading: false, + id: node.id, + name: node.name, + parent: node.parent ? node.parent.name : '', + parent_id: node.parent_id, + fields: [...node.fields], + }); + } + async save(event) { + if (!this.validate()) { + return; + } + let node = { + name: this.state.name, + parent_id: this.state.parent_id, + fields: this.state.fields, + }; + let token = await this.context.get_token(); + let response = await fetch(`/api/nodes/${this.state.id}`, { + method: 'PUT', + headers: { + 'Authorization': 'Bearer ' + token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(node), + }); + if (!response.ok) { + window.flash({header: 'Edit node', text: 'Saving node failed'}); + return; + } + this.setState({redirect_id: this.state.id}); + } +} + +class DeleteNode extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + let node = null; + if (props.location.state !== undefined) { + node = props.location.state.node; + } + this.state = { + node: node, + redirect: null, + }; + this.on_delete = this.on_delete.bind(this); + this.on_cancel = this.on_cancel.bind(this); + } + render() { + if (this.state.redirect) { + return <Redirect to={this.state.redirect} />; + } + return <div> + <Row> + <h3>Delete {this.state.node.name}</h3> + </Row> + <Row> + <Button variant="danger" onClick={this.on_delete}>Delete</Button> + <Button variant="primary" onClick={this.on_cancel}>Cancel</Button> + </Row> + </div>; + } + async on_delete() { + let path = '/'; + if (this.state.node.parent != null) { + path = `/node/${this.state.node.parent.id}`; + } + let token = await this.context.get_token(); + let response = await fetch(`/api/nodes/${this.state.node.id}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + window.flash({header: `Delete ${this.state.node.name}`, text: 'Deletion failed'}); + return; + } + window.flash({header: `Delete ${this.state.node.name}`, text: `${this.state.node.name} has been deleted`}); + this.setState({redirect: path}); + } + on_cancel() { + this.setState({redirect: `/node/${this.state.node.id}`}); + } +} + +class SearchNodes extends React.Component { + static contextType = UserContext; + constructor(props) { + super(props); + let params = new URLSearchParams(props.location.search); + this.state = { + query: params.get('q'), + loading: true, + results: [], + }; + this.last_query = null; + } + componentDidMount() { + this.search(); + } + componentDidUpdate() { + let params = new URLSearchParams(this.props.location.search); + let new_query = params.get('q'); + if (new_query !== this.state.query) { + this.setState({query: new_query}); + } + if (this.last_query !== this.state.query) { + this.search(); + } + } + render() { + if (this.state.loading) { + return <Spinner animation="border"> + <span className="sr-only">Loading…</span> + </Spinner>; + } + if (this.state.results.length === 0) { + return <div> + No results were found for <mark>{this.state.query}</mark>. + </div>; + } + return <CardColumns> + {this.state.results.map(item => <Node key={item.id} node={item} />)} + </CardColumns>; + } + async search() { + this.last_query = this.state.query; + this.setState({loading: true}); + let token = await this.context.get_token(); + let response = await fetch(`/api/search`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'q=' + encodeURIComponent(this.state.query), + }); + let results = await response.json(); + this.setState({ + loading: false, + results: results, + }); + } +} + +export { Main, AddNode, EditNode, DeleteNode, ViewNode, SearchNodes }; diff --git a/frontend/src/auth.js b/frontend/src/auth.js index 0ed1e1a..2f32c33 100644 --- a/frontend/src/auth.js +++ b/frontend/src/auth.js @@ -1,5 +1,6 @@ import React from 'react'; import { Redirect, generatePath } from 'react-router-dom'; +import { Spinner } from 'react-bootstrap'; import { isExpired } from 'react-jwt'; const UserContext = React.createContext(); @@ -18,7 +19,9 @@ class Login extends React.Component { if (this.state.url) { return <div>Redirecting to <a href={this.state.url}>{this.state.url}</a></div>; } - return <div>Logging in…</div>; + return <Spinner animation="border"> + <span className="sr-only">Logging in…</span> + </Spinner>; } async get_oauth_url() { let redirect_uri = window.location.origin + generatePath('/oauth-callback'); @@ -50,7 +53,9 @@ class Logout extends React.Component { if (this.state.done) { return <Redirect to='/' />; } - return <div>Logging out…</div>; + return <Spinner animation="border"> + <span className="sr-only">Logging out…</span> + </Spinner>; } } @@ -88,7 +93,7 @@ class OauthCallback extends React.Component { const user = await this.context.get_user(); if (user) { this.setState({error: null, done: true}); - window.flash({header: 'Logged in', text: 'You are now logged in as ' + user.username + '.'}); + window.flash({header: 'Logged in', text: `You are now logged in as ${user.username}.`}); } else { window.flash({header: 'Login failed', text: 'Something failed during authentication.'}); } @@ -100,7 +105,9 @@ class OauthCallback extends React.Component { if (this.state.done) { return <Redirect to='/' />; } - return 'Logging in…'; + return <Spinner animation="border"> + <span className="sr-only">Logging in…</span> + </Spinner>; } } @@ -146,7 +153,7 @@ class AuthenticationProvider extends React.Component { } let response = await fetch('/api/user',{ headers: { - Authorization: 'Bearer ' + token, + Authorization: `Bearer ${token}`, } }); if (response.ok) { diff --git a/frontend/src/index.js b/frontend/src/index.js index c2a2b74..75cdf35 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -1,21 +1,43 @@ -import React from 'react'; +import React, { useState } from 'react'; import ReactDOM from 'react-dom'; -import { BrowserRouter, Link, Switch, Route } from 'react-router-dom'; -import { Navbar, Nav, Container } from 'react-bootstrap'; +import { BrowserRouter, Link, Switch, Route, useHistory } from 'react-router-dom'; +import { Navbar, Nav, Container, Form, Button } from 'react-bootstrap'; import { UserContext, Login, Logout, OauthCallback, AuthenticationProvider } from './auth.js'; import { Flash } from './flash'; import { ThemeContext, ThemeProvider, ThemeSwitcher } from './theme.js'; -import Main from './app'; +import { AddNode, EditNode, DeleteNode, ViewNode, SearchNodes, Main } from './app'; function LoginNavigation(props) { if (!props.value.is_logged_in) { return <Nav.Link as={Link} to="/login">Login</Nav.Link> } return <> + <Nav.Link as={Link} to="/add">Add</Nav.Link> <Nav.Link as={Link} to="/logout">Logout [{props.value.username}]</Nav.Link> </>; } +function SearchBox(props) { + const [query, setQuery] = useState(''); + let history = useHistory(); + function on_search_change(event) { + setQuery(event.target.value); + } + function on_submit(event) { + event.preventDefault(); + //this.setState({search: true}); + //console.log('this', this); + history.push('/search?q=' + encodeURIComponent(query)); + } + if (!props.value.is_logged_in) { + return ''; + } + return <Form inline onSubmit={on_submit} action="/search"> + <Form.Control onChange={on_search_change} name="q" /> + <Button variant="secondary">Search</Button> + </Form>; +} + function Navigation() { return <ThemeContext.Consumer> {theme => ( @@ -29,6 +51,9 @@ function Navigation() { {value => <LoginNavigation value={value} />} </UserContext.Consumer> </Nav> + <UserContext.Consumer> + {value => <SearchBox value={value} />} + </UserContext.Consumer> <ThemeSwitcher /> </Navbar.Collapse> </Navbar>)} @@ -39,11 +64,16 @@ function Router() { return <BrowserRouter> <Navigation /> <Flash /> - <Container fluid> + <Container> <Switch> <Route path="/login" component={Login} /> <Route path="/logout" component={Logout} /> <Route path="/oauth-callback" component={OauthCallback} /> + <Route path="/add" component={AddNode} /> + <Route path="/node/:id/edit" component={EditNode} /> + <Route path="/node/:id/delete" component={DeleteNode} /> + <Route path="/node/:id" component={ViewNode} /> + <Route path="/search" component={SearchNodes} /> <Route path="/" component={Main} /> </Switch> </Container> |