From 817d3bbb68ab977d58cf548587b45139903f9f14 Mon Sep 17 00:00:00 2001 From: Jon Bergli Heier Date: Mon, 23 Nov 2020 17:51:04 +0100 Subject: Add frontend implementation --- frontend/src/app.css | 12 ++ frontend/src/app.js | 582 +++++++++++++++++++++++++++++++++++++++++++++++++- frontend/src/auth.js | 17 +- frontend/src/index.js | 40 +++- 4 files changed, 636 insertions(+), 15 deletions(-) create mode 100644 frontend/src/app.css (limited to 'frontend/src') 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 = Updated {updated_at.toRelative()}; + } else { + const created_at = DateTime.fromISO(node.created_at).toLocal(); + change_datetime = Created {created_at.toRelative()}; + } + let parents = []; + parents.push({node.name}); + //parents.push(
  • + // {node.name} + //
  • ); + for (let current = node.parent; current; current = current.parent) { + parents.push(
  • + {current.name} +
  • ); + } + if (parents.length > 1) { + parents.reverse(); + parents = {parents}; + } else { + parents = ''; + } + let children = ''; + if (node.children && node.children.length > 0) { + children = node.children.map(item => + + {item.name} + {item.children ? '+' : ''} + + ); + } + let fields = node.fields.map((field, index) => + + {field.name} + {field.value} + + ); + let buttons = ''; + if (props.editable) { + // Using Button here throws errors because of some navigation attribute, so just set the class names manually + buttons = <> + Edit + Delete + ; + } + return + + {change_datetime} + {buttons} + + + {parents} + {node.name} + + {fields} + + + + {children} + {props.editable ? Add : ''} + + ; +} + +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 ; + } + if (this.state.loading) { + return + Loading… + ; + } + const that = this; + return
    +

    {this.title}

    +
    + + Name + + {this.state.errors.name} + + + Parent + true} + labelKey="name" + isLoading={this.state.parents_searching} + getSuggestionValue={value => value} + renderMenuItemChildren={(option, props) => (
    {option.name}
    )} + /> + {this.state.errors.parent} +
    + + Fields + {this.state.fields.map(function(field, index) { + return + + + {that.state.errors[`field-${index}-name`]} + + + + {that.state.errors[`field-${index}-value`]} + + + + + ; + })} + + + + + +
    +
    ; + } + 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 + Loading… + ; + } + return ; + } + 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 + Loading… + ; + } + return + {this.state.nodes.map(item => )} + ; + } } function Main() { @@ -12,9 +384,209 @@ function Main() { return ; } return -

    Inventory

    +

    Unmess

    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.

    } -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 ; + } + return
    + +

    Delete {this.state.node.name}

    +
    + + + + +
    ; + } + 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 + Loading… + ; + } + if (this.state.results.length === 0) { + return
    + No results were found for {this.state.query}. +
    ; + } + return + {this.state.results.map(item => )} + ; + } + 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
    Redirecting to {this.state.url}
    ; } - return
    Logging in…
    ; + return + Logging in… + ; } 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 ; } - return
    Logging out…
    ; + return + Logging out… + ; } } @@ -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 ; } - return 'Logging in…'; + return + Logging in… + ; } } @@ -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 Login } return <> + Add Logout [{props.value.username}] ; } +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
    + + + ; +} + function Navigation() { return {theme => ( @@ -29,6 +51,9 @@ function Navigation() { {value => } + + {value => } + )} @@ -39,11 +64,16 @@ function Router() { return - + + + + + + -- cgit v1.2.3