Added UI for creating link tokens, and improved component search api.

This commit is contained in:
Andrew Lalis 2022-05-20 01:12:26 +02:00
parent ed3f6bd6b9
commit e45b942f34
13 changed files with 382 additions and 32 deletions

View File

@ -48,6 +48,27 @@ export function getComponent(rs, id) {
}); });
} }
/**
* Searches through the rail system's components.
* @param {RailSystem} rs
* @param {string|null} searchQuery
* @return {Promise<Object>}
*/
export function searchComponents(rs, searchQuery) {
return new Promise((resolve, reject) => {
const params = {
page: 0,
size: 25
};
if (searchQuery) params.q = searchQuery;
axios.get(`${API_URL}/rs/${rs.id}/c/search`, {params: params})
.then(response => {
resolve(response.data);
})
.catch(reject);
});
}
export function createComponent(rs, data) { export function createComponent(rs, data) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs/${rs.id}/c`, data) axios.post(`${API_URL}/rs/${rs.id}/c`, data)

View File

@ -0,0 +1,57 @@
import {API_URL} from "./constants";
import axios from "axios";
/**
* A token that's used by components to provide real-time up and down links.
*/
export class LinkToken {
constructor(data) {
this.id = data.id;
this.label = data.label;
this.components = data.components;
}
}
/**
* Gets the list of link tokens in a rail system.
* @param {RailSystem} rs
* @return {Promise<LinkToken[]>}
*/
export function getTokens(rs) {
return new Promise((resolve, reject) => {
axios.get(`${API_URL}/rs/${rs.id}/lt`)
.then(response => {
resolve(response.data.map(obj => new LinkToken(obj)));
})
.catch(reject);
});
}
/**
* Creates a new link token.
* @param {RailSystem} rs
* @param {LinkToken} data
* @return {Promise<string>} A promise that resolves to the token that was created.
*/
export function createLinkToken(rs, data) {
return new Promise((resolve, reject) => {
axios.post(`${API_URL}/rs/${rs.id}/lt`, data)
.then(response => {
resolve(response.data.token);
})
.catch(reject);
});
}
/**
* Deletes a link token.
* @param {RailSystem} rs
* @param {Number} tokenId
*/
export function deleteToken(rs, tokenId) {
return new Promise((resolve, reject) => {
axios.delete(`${API_URL}/rs/${rs.id}/lt/${tokenId}`)
.then(resolve)
.catch(reject);
});
}

View File

@ -2,6 +2,12 @@ import axios from "axios";
import {API_URL} from "./constants"; import {API_URL} from "./constants";
import {refreshSomeComponents} from "./components"; import {refreshSomeComponents} from "./components";
/**
* Updates the connections to a path node.
* @param {RailSystem} rs The rail system to which the node belongs.
* @param {Object} node The node to update.
* @returns {Promise}
*/
export function updateConnections(rs, node) { export function updateConnections(rs, node) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
axios.patch( axios.patch(
@ -16,11 +22,25 @@ export function updateConnections(rs, node) {
}); });
} }
/**
* Adds a connection to a path node.
* @param {RailSystem} rs
* @param {Object} node
* @param {Object} other
* @returns {Promise}
*/
export function addConnection(rs, node, other) { export function addConnection(rs, node, other) {
node.connectedNodes.push(other); node.connectedNodes.push(other);
return updateConnections(rs, node); return updateConnections(rs, node);
} }
/**
* Removes a connection from a path node.
* @param {RailSystem} rs
* @param {Object} node
* @param {Object} other
* @returns {Promise}
*/
export function removeConnection(rs, node, other) { export function removeConnection(rs, node, other) {
const idx = node.connectedNodes.findIndex(n => n.id === other.id); const idx = node.connectedNodes.findIndex(n => n.id === other.id);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View File

@ -1,11 +1,26 @@
import axios from "axios"; import axios from "axios";
import {API_URL} from "./constants"; import {API_URL} from "./constants";
export class RailSystem {
constructor(data) {
this.id = data.id;
this.name = data.name;
this.segments = [];
this.components = [];
this.websocket = null;
this.selectedComponent = null;
}
}
export function refreshRailSystems(rsStore) { export function refreshRailSystems(rsStore) {
return new Promise(resolve => { return new Promise(resolve => {
axios.get(`${API_URL}/rs`) axios.get(`${API_URL}/rs`)
.then(response => { .then(response => {
rsStore.railSystems = response.data; const rsItems = response.data;
rsStore.railSystems.length = 0;
for (let i = 0; i < rsItems.length; i++) {
rsStore.railSystems.push(new RailSystem(rsItems[i]));
}
resolve(); resolve();
}) })
.catch(error => console.error(error)); .catch(error => console.error(error));

View File

@ -1,9 +1,11 @@
import {WS_URL} from "./constants"; import {WS_URL} from "./constants";
/**
* Establishes a websocket connection to the given rail system.
* @param {RailSystem} rs
*/
export function establishWebsocketConnection(rs) { export function establishWebsocketConnection(rs) {
if (rs.websocket) { closeWebsocketConnection(rs);
rs.websocket.close();
}
rs.websocket = new WebSocket(`${WS_URL}/${rs.id}`); rs.websocket = new WebSocket(`${WS_URL}/${rs.id}`);
rs.websocket.onopen = () => { rs.websocket.onopen = () => {
console.log("Opened websocket connection to rail system " + rs.id); console.log("Opened websocket connection to rail system " + rs.id);
@ -25,6 +27,10 @@ export function establishWebsocketConnection(rs) {
}; };
} }
/**
* Closes the websocket connection to a rail system, if possible.
* @param {RailSystem} rs
*/
export function closeWebsocketConnection(rs) { export function closeWebsocketConnection(rs) {
if (rs.websocket) { if (rs.websocket) {
rs.websocket.close(); rs.websocket.close();

View File

@ -15,6 +15,7 @@
id="railSystemSelect" id="railSystemSelect"
v-model="rsStore.selectedRailSystem" v-model="rsStore.selectedRailSystem"
class="form-select form-select-sm" class="form-select form-select-sm"
@change="rsStore.onSelectedRailSystemChanged()"
> >
<option v-for="rs in rsStore.railSystems" :key="rs.id" :value="rs"> <option v-for="rs in rsStore.railSystems" :key="rs.id" :value="rs">
{{rs.name}} {{rs.name}}

View File

@ -0,0 +1,115 @@
<template>
<div class="modal fade" tabindex="-1" id="createLinkTokenModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Create Link Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>
Create a <em>link token</em> to link components in this rail system
to actual devices in your system, so your world can talk to this
system. Each link token should have a unique label that can be used
to identify it, and a list of components that it's linked to.
</p>
<p>
Note that for security purposes, the raw token that's generated is
only shown once, and is never available again. If you lose the
token, you must create a new one instead and delete the old one.
</p>
<form>
<div class="mb-3">
<label for="createLinkTokenLabel" class="form-label">Label</label>
<input
id="createLinkTokenLabel"
class="form-control"
type="text"
v-model="formData.label"
required
/>
</div>
<div class="mb-3">
<label class="form-label" for="createLinkTokenComponentSelect">Select Components to Link</label>
<ComponentSelector id="createLinkTokenComponentSelect" :railSystem="railSystem" v-model="components"/>
</div>
</form>
<div v-if="warnings.length > 0">
<div v-for="msg in warnings" :key="msg" class="alert alert-danger mt-2">
{{msg}}
</div>
</div>
<div v-if="token !== null" class="alert alert-success mt-2">
Created token: {{token}}
<br>
<small>Copy this token now; it will not be shown again.</small>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" @click="reset()">Close</button>
<button
type="button"
class="btn btn-primary"
@click="formSubmitted()"
v-if="token == null"
>
Add
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import {createLinkToken} from "../../api/linkTokens";
import {RailSystem} from "../../api/railSystems";
import ComponentSelector from "./util/ComponentSelector.vue";
export default {
name: "CreateLinkTokenModal",
components: {ComponentSelector},
props: {
railSystem: {
type: RailSystem,
required: true
}
},
data() {
return {
formData: {
label: "",
componentIds: []
},
components: [],
warnings: [],
token: null
};
},
methods: {
formSubmitted() {
this.formData.componentIds = this.components.map(c => c.id);
createLinkToken(this.railSystem, this.formData)
.then(token => {
this.token = token;
})
.catch(error => {
this.warnings.length = 0;
this.warnings.push("Couldn't create token: " + error.response.data.message);
});
},
reset() {
// TODO: Fix this!! Reset doesn't work.
this.formData.label = "";
this.formData.componentIds = [];
this.components = [];
this.warnings = [];
this.token = null;
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,7 +1,7 @@
<template> <template>
<h3>Rail System: <em>{{railSystem.name}}</em></h3> <h3>Rail System: <em>{{railSystem.name}}</em></h3>
<SegmentsView :segments="railSystem.segments" v-if="railSystem.segments"/> <SegmentsView :segments="railSystem.segments" v-if="railSystem.segments"/>
<div class="dropdown"> <div class="dropdown d-inline-block me-2">
<button class="btn btn-success btn-sm dropdown-toggle" type="button" id="railSystemAddComponentsToggle" data-bs-toggle="dropdown" aria-expanded="false"> <button class="btn btn-success btn-sm dropdown-toggle" type="button" id="railSystemAddComponentsToggle" data-bs-toggle="dropdown" aria-expanded="false">
Add Component Add Component
</button> </button>
@ -48,6 +48,17 @@
</li> </li>
</ul> </ul>
</div> </div>
<div v-if="createLinkTokenAllowed()" class="d-inline-block">
<button
class="btn btn-success btn-sm"
type="button"
data-bs-toggle="modal"
data-bs-target="#createLinkTokenModal"
>
Create Link Token
</button>
<CreateLinkTokenModal :railSystem="railSystem" />
</div>
<AddSegmentModal /> <AddSegmentModal />
<AddSignalModal v-if="addSignalAllowed()" /> <AddSignalModal v-if="addSignalAllowed()" />
<AddSegmentBoundaryModal v-if="addSegmentBoundaryAllowed()" /> <AddSegmentBoundaryModal v-if="addSegmentBoundaryAllowed()" />
@ -60,10 +71,13 @@ import AddSegmentModal from "./AddSegmentModal.vue";
import AddSignalModal from "./component/AddSignalModal.vue"; import AddSignalModal from "./component/AddSignalModal.vue";
import AddSegmentBoundaryModal from "./component/AddSegmentBoundaryModal.vue"; import AddSegmentBoundaryModal from "./component/AddSegmentBoundaryModal.vue";
import AddSwitchModal from "./component/AddSwitchModal.vue"; import AddSwitchModal from "./component/AddSwitchModal.vue";
import CreateLinkTokenModal from "./CreateLinkTokenModal.vue";
import {RailSystem} from "../../api/railSystems";
export default { export default {
name: "RailSystemPropertiesView", name: "RailSystemPropertiesView",
components: { components: {
CreateLinkTokenModal,
AddSwitchModal, AddSwitchModal,
AddSegmentBoundaryModal, AddSegmentBoundaryModal,
AddSignalModal, AddSignalModal,
@ -72,19 +86,22 @@ export default {
}, },
props: { props: {
railSystem: { railSystem: {
type: Object, type: RailSystem,
required: true required: true
} }
}, },
methods: { methods: {
addSignalAllowed() { addSignalAllowed() {
return this.railSystem.segments && this.railSystem.segments.length > 0 return this.railSystem.segments && this.railSystem.segments.length > 0;
}, },
addSegmentBoundaryAllowed() { addSegmentBoundaryAllowed() {
return this.railSystem.segments && this.railSystem.segments.length > 1 return this.railSystem.segments && this.railSystem.segments.length > 1;
}, },
addSwitchAllowed() { addSwitchAllowed() {
return this.railSystem.components && this.railSystem.components.length > 1 return this.railSystem.components && this.railSystem.components.length > 1;
},
createLinkTokenAllowed() {
return this.railSystem.components && this.railSystem.components.length > 0;
} }
} }
} }

View File

@ -0,0 +1,106 @@
<template>
<div class="border p-2">
<input type="text" class="form-control mb-2" placeholder="Search for components" v-model="searchQuery" @keyup="refreshComponents()" />
<ul class="list-group list-group-flush" style="overflow: auto; max-height: 200px">
<li
class="list-group-item"
v-for="component in possibleComponents"
:key="component.id"
>
{{component.name}}
<button
type="button"
class="badge btn btn-sm btn-success float-end"
@click="selectComponent(component)"
v-if="!isComponentSelected(component)"
>
Select
</button>
<span v-if="isComponentSelected(component)" class="badge bg-secondary float-end">Selected</span>
</li>
</ul>
<div v-if="selectedComponents.length > 0" class="mt-2">
<span
class="badge bg-secondary me-1 mb-1"
v-for="component in selectedComponents"
:key="component.id"
>
{{component.name}}
<button class="badge rounded-pill bg-danger" @click="deselectComponent(component)">
X
</button>
</span>
</div>
<button
class="btn btn-sm btn-secondary"
type="button"
v-if="selectedComponents.length > 1"
@click="selectedComponents.length = 0"
>
Clear All
</button>
</div>
</template>
<script>
import {RailSystem} from "../../../api/railSystems";
import {searchComponents} from "../../../api/components";
export default {
name: "ComponentSelector",
props: {
railSystem: {
type: RailSystem,
required: true
},
modelValue: {
type: Array,
required: true
}
},
emits: ["update:modelValue"],
data() {
return {
searchQuery: null,
selectedComponents: [],
possibleComponents: []
};
},
mounted() {
this.refreshComponents();
},
methods: {
refreshComponents() {
searchComponents(this.railSystem, this.searchQuery)
.then(page => this.possibleComponents = page.content);
},
isComponentSelected(component) {
return this.selectedComponents.some(c => c.id === component.id);
},
selectComponent(component) {
if (this.isComponentSelected(component)) return;
this.selectedComponents.push(component);
this.selectedComponents.sort((a, b) => {
const nameA = a.name.toUpperCase();
const nameB = b.name.toUpperCase();
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return 0;
});
this.$emit("update:modelValue", this.selectedComponents);
},
deselectComponent(component) {
const idx = this.selectedComponents.findIndex(c => c.id === component.id);
if (idx > -1) {
this.selectedComponents.splice(idx, 1);
this.$emit("update:modelValue", this.selectedComponents);
}
}
}
}
</script>
<style scoped>
</style>

View File

@ -1,22 +1,11 @@
import { createApp } from 'vue'; import {createApp} from 'vue';
import { createPinia } from 'pinia'; import {createPinia} from 'pinia';
import App from './App.vue' import App from './App.vue'
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap"; import "bootstrap";
import {useRailSystemsStore} from "./stores/railSystemsStore";
const pinia = createPinia(); const pinia = createPinia();
const app = createApp(App); const app = createApp(App);
app.use(pinia); app.use(pinia);
// Configure rail system updates.
const rsStore = useRailSystemsStore();
rsStore.$subscribe(mutation => {
const evt = mutation.events;
if (evt.key === "selectedRailSystem" && evt.newValue !== null) {
rsStore.onSelectedRailSystemChanged();
}
});
app.mount('#app') app.mount('#app')

View File

@ -5,14 +5,12 @@ import {closeWebsocketConnection, establishWebsocketConnection} from "../api/web
export const useRailSystemsStore = defineStore('RailSystemsStore', { export const useRailSystemsStore = defineStore('RailSystemsStore', {
state: () => ({ state: () => ({
/**
* @type {RailSystem[]}
*/
railSystems: [], railSystems: [],
/** /**
* @type {{ * @type {RailSystem | null}
* segments: [Object],
* components: [Object],
* selectedComponent: Object | null,
* websocket: WebSocket | null
* } | null}
*/ */
selectedRailSystem: null selectedRailSystem: null
}), }),

View File

@ -29,11 +29,12 @@ public class ComponentsApiController {
@GetMapping(path = "/search") @GetMapping(path = "/search")
public Page<SimpleComponentResponse> searchComponents( public Page<SimpleComponentResponse> searchComponents(
@PathVariable long rsId,
@RequestParam(name = "q", required = false) String searchQuery, @RequestParam(name = "q", required = false) String searchQuery,
@PageableDefault(sort = "name") @PageableDefault(sort = "name")
Pageable pageable Pageable pageable
) { ) {
return componentService.search(searchQuery, pageable); return componentService.search(rsId, searchQuery, pageable);
} }
@GetMapping(path = "/{cId}") @GetMapping(path = "/{cId}")

View File

@ -16,6 +16,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.HashSet; import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -42,12 +44,14 @@ public class ComponentService {
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<SimpleComponentResponse> search(String searchQuery, Pageable pageable) { public Page<SimpleComponentResponse> search(long rsId, String searchQuery, Pageable pageable) {
return componentRepository.findAll((root, query, cb) -> { return componentRepository.findAll((root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>(2);
predicates.add(cb.equal(root.get("railSystem").get("id"), rsId));
if (searchQuery != null && !searchQuery.isBlank()) { if (searchQuery != null && !searchQuery.isBlank()) {
return cb.like(cb.lower(root.get("name")), '%' + searchQuery.toLowerCase() + '%'); predicates.add(cb.like(cb.lower(root.get("name")), '%' + searchQuery.toLowerCase() + '%'));
} }
return cb.and(); return cb.and(predicates.toArray(new Predicate[0]));
}, pageable).map(SimpleComponentResponse::new); }, pageable).map(SimpleComponentResponse::new);
} }