add rollup/vue for frontend assets, add wip new object form (needs success/error handling)

master
Ondřej Hruška 3 years ago
parent 780ad36696
commit b5a4900209
Signed by: MightyPork
GPG Key ID: 2C5FD5035250423D
  1. 1
      yopa-web/resources/.gitignore
  2. 64
      yopa-web/resources/index.tmp
  3. 31
      yopa-web/resources/package.json
  4. 55
      yopa-web/resources/rollup.config.js
  5. 31
      yopa-web/resources/src/components/BooleanValue.vue
  6. 31
      yopa-web/resources/src/components/DecimalValue.vue
  7. 31
      yopa-web/resources/src/components/IntegerValue.vue
  8. 141
      yopa-web/resources/src/components/NewObjectForm.vue
  9. 141
      yopa-web/resources/src/components/NewRelationForm.vue
  10. 59
      yopa-web/resources/src/components/PropertyField.vue
  11. 31
      yopa-web/resources/src/components/StringValue.vue
  12. 36
      yopa-web/resources/src/main.js
  13. 222
      yopa-web/resources/src/style/_common.scss
  14. 10
      yopa-web/resources/src/style/app.scss
  15. 26
      yopa-web/resources/src/utils.js
  16. 2
      yopa-web/resources/static/bundle.js
  17. 1
      yopa-web/resources/static/bundle.js.map
  18. 159
      yopa-web/resources/static/style.css
  19. 3
      yopa-web/resources/templates/_layout.html.tera
  20. 0
      yopa-web/resources/templates/models/_schema_macros.html.tera
  21. 0
      yopa-web/resources/templates/models/model_create.html.tera
  22. 0
      yopa-web/resources/templates/models/model_update.html.tera
  23. 0
      yopa-web/resources/templates/models/property_create.html.tera
  24. 0
      yopa-web/resources/templates/models/property_update.html.tera
  25. 0
      yopa-web/resources/templates/models/relation_create.html.tera
  26. 0
      yopa-web/resources/templates/models/relation_update.html.tera
  27. 10
      yopa-web/resources/templates/models/schema.html.tera
  28. 22
      yopa-web/resources/templates/objects/object_create.html.tera
  29. 2514
      yopa-web/resources/yarn.lock
  30. 5
      yopa-web/src/main.rs
  31. 17
      yopa-web/src/routes.rs
  32. 102
      yopa-web/src/routes/object.rs
  33. 4
      yopa-web/src/routes/object_model.rs
  34. 4
      yopa-web/src/routes/property_model.rs
  35. 4
      yopa-web/src/routes/relation_model.rs
  36. 2
      yopa/src/data.rs
  37. 4
      yopa/src/insert.rs
  38. 17
      yopa/src/lib.rs

@ -0,0 +1 @@
node_modules/

@ -1,64 +0,0 @@
{% extends "_layout" %}
{% block title -%}
Index
{%- endblock %}
{% block nav -%}
<a href="/">Home</a>
{%- endblock %}
{% block content -%}
<h1>Welcome to tera on actix</h1>
<a href="/model/object/create">New model</a>
<h2>Defined models:</h2>
<ul>
{% for model in models %}
<li>
<b>{{model.name}}</b><br>
{#
{% if !model.properties.is_empty() %}
Properties:
<ul>
{% for prop in model.properties %}
<li>{{prop.name}}, {{prop.data_type}}, def: {{prop.default}}, opt: {{prop.optional}}, mult: {{prop.multiple}}</li>
{% endfor %}
</ul>
{% endif %}
<a href="/model/property/create/{{model.id}}">New property</a>
{% if !model.relations.is_empty() %}
Relations:
<ul>
{% for rel in model.relations %}
<li>
{{rel.name}} -> {{rel.related_name}}
{% if !rel.properties.is_empty() %}
Properties:
<ul>
{% for prop in rel.properties %}
<li>{{prop.name}}, {{prop.data_type}}, def: {{prop.default}}, opt: {{prop.optional}}, mult: {{prop.multiple}}</li>
{% endfor %}
</ul>
{% endif %}
</li>
{% endfor %}
</ul>
{% endif %}
<a href="/model/relation/create/{{model.id}}">New relation</a>
#}
</li>
{% endfor %}
</ul>
{%- endblock %}

@ -0,0 +1,31 @@
{
"name": "rollup-test",
"version": "1.0.0",
"main": "index.js",
"license": "proprietary",
"devDependencies": {
"@rollup/plugin-alias": "^2.2.0",
"@rollup/plugin-commonjs": "^17.0.0",
"@rollup/plugin-node-resolve": "^11.1.0",
"@rollup/plugin-replace": "^2.2.0",
"@vue/compiler-sfc": "^3.0.5",
"npm-run-all": "^4.1.5",
"rollup": "^2.36.2",
"rollup-plugin-terser": "^7.0.2",
"rollup-plugin-vue": "^6.0.0-beta.10",
"@rollup/plugin-json": "4.1",
"serve": "^11.3.2"
},
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w",
"dev": "npm-run-all --parallel start watch",
"start": "serve public"
},
"dependencies": {
"axios": "^0.21.1",
"lodash-es": "^4.17.20",
"rollup-plugin-scss": "^2.6.1",
"vue": "^3.0.5"
}
}

@ -0,0 +1,55 @@
import pluginNodeResolve from '@rollup/plugin-node-resolve';
import pluginCommonJs from '@rollup/plugin-commonjs';
import { terser } from 'rollup-plugin-terser';
import rollupReplace from '@rollup/plugin-replace';
import pluginAlias from '@rollup/plugin-alias';
import pluginJson from '@rollup/plugin-json';
import pluginVue from 'rollup-plugin-vue';
import pluginScss from 'rollup-plugin-scss';
// `npm run build` -> `production` is true
// `npm run dev` -> `production` is false
const production = !process.env.ROLLUP_WATCH;
export default {
input: 'src/main.js',
output: {
file: 'static/bundle.js',
format: 'iife', // immediately-invoked function expression — suitable for <script> tags
sourcemap: true
},
plugins: [
pluginAlias({
entries: {
//'vue': __dirname + "/node_modules/vue/dist/vue.esm-bundler.js"
}
}),
pluginVue({
target: 'browser',
}),
pluginScss({
output: 'static/style.css',
}),
pluginJson(),
pluginNodeResolve({
jsnext: true,
main: true,
module: true,
browser: true,
preferBuiltins: false,
}),
pluginCommonJs({
include: 'node_modules/**',
browser: true,
preferBuiltins: false,
// ignoreGlobal: false,
sourceMap: !production
}),
rollupReplace({
'process.env.NODE_ENV': production ? '"development"': '"production"',
'__VUE_OPTIONS_API__': 'true',
'__VUE_PROD_DEVTOOLS__': 'false',
}),
production && terser(),
]
};

@ -0,0 +1,31 @@
<script>
export default {
name: "BooleanValue",
props: {
id: {
type: String,
default: '',
},
value: Object
},
methods: {
focus() {
this.$refs.input.focus();
}
},
computed: {
inputValue: {
set(value) {
this.value.Boolean = !!value;
},
get() {
return this.value.Boolean;
}
}
}
};
</script>
<template>
<input ref="input" type="checkbox" :id="id" value="true" v-model="inputValue">
</template>

@ -0,0 +1,31 @@
<script>
export default {
name: "DecimalValue",
props: {
id: {
type: String,
default: '',
},
value: Object
},
methods: {
focus() {
this.$refs.input.focus();
}
},
computed: {
inputValue: {
set(value) {
this.value.Decimal = parseFloat(value);
},
get() {
return this.value.Decimal;
}
}
}
};
</script>
<template>
<input ref="input" type="number" :id="id" step="any" v-model="inputValue">
</template>

@ -0,0 +1,31 @@
<script>
export default {
name: "IntegerValue",
props: {
id: {
type: String,
default: '',
},
value: Object
},
methods: {
focus() {
this.$refs.input.focus();
}
},
computed: {
inputValue: {
set(value) {
this.value.Integer = parseInt(value);
},
get() {
return this.value.Integer;
}
}
}
};
</script>
<template>
<input ref="input" type="number" :id="id" step="1" v-model="inputValue">
</template>

@ -0,0 +1,141 @@
<script>
import {castId, keyBy, objCopy} from "../utils";
import forEach from "lodash-es/forEach";
import isEmpty from "lodash-es/isEmpty";
import axios from "axios";
export default {
props: ['model_id', 'schema', 'objects'],
name: "NewObjectForm",
data() {
const model = this.schema.obj_models.find((m) => m.id === this.model_id);
let properties = this.schema.prop_models.filter((m) => m.object === model.id);
let relations = this.schema.rel_models.filter((m) => m.object === model.id);
properties.sort((a, b) => a.name.localeCompare(b.name));
relations.sort((a, b) => a.name.localeCompare(b.name));
let values = {};
properties.forEach((p) => {
if (p.optional) {
values[p.id] = [];
} else {
values[p.id] = [objCopy(p.default)];
}
});
properties = keyBy(properties, 'id');
relations = keyBy(relations, 'id');
let model_names = {};
this.schema.obj_models.forEach((m) => {
model_names[m.id] = m.name;
});
return {
model,
properties,
relations,
model_names,
values,
name: '',
relationRefs: [],
}
},
methods: {
/** Get values in the raw format without grouping */
collectData() {
if (isEmpty(this.name)) {
throw new Error("Name is required");
}
let values = [];
forEach(objCopy(this.values), (vv, k) => {
for (let v of vv) {
values.push({
model_id: castId(k),
value: v
})
}
})
let relations = [];
for (let rref of this.relationRefs) {
for (let r of rref.collectData()) {
relations.push(r);
}
}
return {
model_id: this.model_id, // string is fine
name: this.name,
values,
relations,
};
},
trySave() {
let data;
try {
data = this.collectData();
} catch (e) {
alert(e.message);
return;
}
console.log('Try save', data);
axios({
method: 'post',
url: '/object/create',
data: data
})
.then(function (response) {
console.log('Response', response);
})
.catch(function (error) {
console.log('Error', error);
});
},
setRelationRef(el) {
if (el) {
this.relationRefs.push(el)
}
},
},
beforeUpdate() {
this.relationRefs = []
},
mounted() {
this.$el.parentNode
.classList.add('EditForm');
}
}
</script>
<template>
<h2>New {{ model.name }}</h2>
<p><input type="button" value="Save" @click="trySave"></p>
<table>
<tr>
<th><label for="field-name">Name</label></th>
<td>
<input type="text" id="field-name" v-model="name">
</td>
</tr>
<property v-for="(property, pi) in properties" :model="property" :values="values[property.id]" :key="pi"></property>
</table>
<h3>Relations</h3>
<new-relation
v-for="relation in relations"
:ref="setRelationRef"
:model_id="relation.id"
:objects="objects"
:schema="schema"
></new-relation>
</template>

@ -0,0 +1,141 @@
<script>
import {castId, keyBy, objCopy} from "../utils";
import forEach from "lodash-es/forEach";
import isEmpty from "lodash-es/isEmpty";
export default {
props: ['model_id', 'schema', 'objects'],
name: "NewRelationForm",
data() {
const model = this.schema.rel_models.find((m) => m.id === this.model_id);
if(!model) throw Error("Relation model not exist");
let properties = this.schema.prop_models.filter((m) => m.object === model.id);
properties.sort((a, b) => a.name.localeCompare(b.name));
if (isEmpty(properties)) {
properties = null;
} else {
properties = keyBy(properties, 'id');
}
let related_model = this.schema.obj_models.find((m) => m.id === model.related);
if(!related_model) throw Error("Related model not exist");
let choices = {};
this.objects.forEach((obj) => {
if (obj.model === model.related) {
choices[obj.id] = obj.name;
}
});
let instances = [];
if (!model.optional) {
// TODO avoid duplicated code
let values = {};
forEach(this.properties, (p) => {
if (p.optional) {
values[p.id] = [];
} else {
values[p.id] = [objCopy(p.default)];
}
});
instances.push({
related: '',
values
})
}
return {
model,
related_model,
properties,
object_names: choices,
instances,
}
},
methods: {
collectData() {
let relations = [];
forEach(objCopy(this.instances), (instance) => {
if (isEmpty(instance.related)) {
if (!this.model.optional) {
throw new Error(`Relation "${this.model.name}" is required`)
}
return; // continue
}
let values = [];
forEach(instance.values, (vv, prop_model_id) => {
for (let v of vv) {
values.push({
model_id: castId(prop_model_id),
value: v
});
}
})
relations.push({
model_id: this.model.id,
related_id: castId(instance.related),
values
});
})
return relations;
},
addInstance() {
let values = {};
forEach(this.properties, (p) => {
if (p.optional) {
values[p.id] = [];
} else {
values[p.id] = [objCopy(p.default)];
}
});
this.instances.push({
related: '',
values
})
},
removeInstance(ri) {
this.instances.splice(ri, 1)
}
}
}
</script>
<style scoped>
.new-relation {
border: 1px dashed gray;
margin: 10px 0;
padding: 10px;
}
</style>
<template>
<div>
<div class="new-relation" v-for="(instance, ri) in instances" :key="ri">
<b>{{ model.name }} -&gt; {{ related_model.name }}
<select v-model="instance.related">
<option v-for="(name, id) in object_names" :value="id">{{name}}</option>
</select>
</b>
<a href="#" v-if="model.multiple || model.optional && instances.length > 0"
style="margin-left: 5px"
@click="removeInstance(ri)">X</a>
<table v-if="properties">
<property v-for="(property, id) in properties" :model="property" :values="instance.values[id]" :key="id"></property>
</table>
</div>
<a href="#" v-if="model.multiple || model.optional && instances.length==0"
@click="addInstance">Add {{ model.name }} -&gt; {{ related_model.name }}</a>
</div>
</template>

@ -0,0 +1,59 @@
<script>
import {objCopy, uniqueId} from "../utils";
import * as Vue from 'vue';
export default {
name: "PropertyField",
props: ['model', 'values'],
data() {
return {
id: uniqueId(),
fieldRefs: [],
}
},
methods: {
addValue(event) {
this.values.push(objCopy(this.model.default));
Vue.nextTick(() => {
this.fieldRefs[this.values.length-1].focus();
})
},
removeValue(vi) {
this.values.splice(vi, 1);
},
setFieldRef(el) {
if (el) {
this.fieldRefs.push(el)
}
},
},
beforeUpdate() {
this.fieldRefs = []
}
}
</script>
<template>
<tr v-if="values.length===0">
<th @click="addValue">{{model.name}}</th>
<td>
<a href="#" @click="addValue">Add</a>
</td>
</tr>
<tr v-else v-for="(value, vi) in values" :key="vi">
<th :rowspan="values.length + model.multiple" v-if="vi == 0"><label :for="id">{{model.name}}</label></th>
<td>
<string-value :ref="setFieldRef" :value="value" :id="vi===0?id:null" v-if="model.data_type==='String'"></string-value>
<integer-value :ref="setFieldRef" :value="value" :id="vi===0?id:null" v-if="model.data_type==='Integer'"></integer-value>
<decimal-value :ref="setFieldRef" :value="value" :id="vi===0?id:null" v-if="model.data_type==='Decimal'"></decimal-value>
<boolean-value :ref="setFieldRef" :value="value" :id="vi===0?id:null" v-if="model.data_type==='Boolean'"></boolean-value>
<a href="#" @click="removeValue(vi)" v-if="vi > 0 || model.optional" style="margin-left:5px">X</a>
</td>
</tr>
<tr v-if="values.length > 0 && model.multiple">
<td>
<a href="#" @click="addValue">Add</a>
</td>
</tr>
</template>

@ -0,0 +1,31 @@
<script>
export default {
name: "StringValue",
props: {
id: {
type: String,
default: '',
},
value: Object
},
methods: {
focus() {
this.$refs.input.focus();
}
},
computed: {
inputValue: {
set(value) {
this.value.String = value
},
get() {
return this.value.String;
}
}
}
};
</script>
<template>
<input ref="input" type="text" :id="id" v-model="inputValue">
</template>

@ -0,0 +1,36 @@
import * as Vue from "vue";
import './style/app.scss';
import StringValue from "./components/StringValue.vue";
import DecimalValue from "./components/DecimalValue.vue";
import BooleanValue from "./components/BooleanValue.vue";
import IntegerValue from "./components/IntegerValue.vue";
import PropertyField from "./components/PropertyField.vue";
import NewObjectForm from "./components/NewObjectForm.vue";
import NewRelationForm from "./components/NewRelationForm.vue";
function registerComponents(app) {
app.component('string-value', StringValue);
app.component('integer-value', IntegerValue);
app.component('decimal-value', DecimalValue);
app.component('boolean-value', BooleanValue);
app.component('property', PropertyField);
app.component('new-relation', NewRelationForm);
}
window.onLoad = function (callback) {
document.addEventListener('DOMContentLoaded', callback);
}
window.Yopa = {
newObjectForm(opts) {
// Opts: model_id, schema, objects (named objects for relations)
let app = window.app = Vue.createApp(NewObjectForm, opts);
registerComponents(app);
let instance = app.mount('#new-object-form');
// ...
return instance;
}
};

@ -0,0 +1,222 @@
*, *::before, *::after {
box-sizing: border-box;
}
html, textarea, select {
font-family: "IBM Plex", "DejaVu Sans", "Helvetica", sans-serif;
}
.Form {
display: block;
width: 900px;
margin: 0 auto;
}
nav.top-nav {
margin-bottom: .5rem;
border-bottom: 1px solid silver;
}
nav.top-nav, .content {
margin: 0 auto;
width: 900px;
}
a {
color: gray;
text-decoration: none;
}
a:hover {
color: black;
text-decoration: underline;
}
nav.top-nav a {
display: inline-block;
padding: .75rem;
color: gray;
text-decoration: none;
}
nav.top-nav a:hover {
color: black;
text-decoration: underline;
}
.Form .Row {
display: flex;
padding: .25rem;
}
.Form .Row.indented {
padding-left: 10.25rem;
}
input[type="text"],
input[type="number"],
textarea,
.tag-input {
border: 1px solid silver;
padding: 0.5rem;
border-radius: 5px;
font-size: 1rem;
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus,
.tag-input.active {
box-shadow: inset 0 0 0 1px #3c97ff;
border-color: #3c97ff;
outline: 0 none !important;
}
.Form label {
flex-shrink: 0;
width: 10rem;
height: 2.1rem;
line-height: 2.1rem;
vertical-align: middle;
text-align: right;
display: inline-block;
padding-right: .5rem;
align-self: flex-start;
}
.Form input[type="text"],
.Form input[type="number"],
.Form select {
height: 2.1rem;
width: 15rem;
}
.Form textarea {
flex-shrink: 1;
width: 30rem;
height: 6rem;
}
.Form label.checkbox-wrap {
width: 15rem;
padding-right: 1rem;
text-align: left !important;
}
.tag-input {
position: relative;
width: 30rem;
padding-bottom: 0rem !important;
}
.tag-input input,
.tag-input input:focus {
border: 0 transparent;
padding: 0;
margin: 0;
box-shadow: none;
outline: 0 none !important;
}
/*
.cards-table {
border-collapse: collapse;
margin: 0 auto;
margin-top: 1rem;
}
.cards-table .actions {
font-size: 90%;
}
.cards-table td,
.cards-table th {
padding: .5rem;
}
.cards-table thead th {
border-bottom: 2px solid silver;
}
.cards-table tbody td {
border-bottom: 1px solid silver;
}
.cards-table tbody tr:last-child td {
border-bottom: 2px solid silver;
}
.cards-table td.tags {
padding: .25rem;
}
.cards-table .tag {
background: #E2E1DF;
font-size: 90%;
padding: 0.25rem .45rem;
border-radius: 3px;
display: inline-block;
}
.paginate {
margin: 1rem auto;
width: 300px;
text-align: center;
white-space: nowrap;
}
.paginate span,
.paginate a {
padding: .5rem 1rem;
border-radius: .5rem;
border: 1px solid silver;
text-decoration: none;
}
.paginate span.num {
border: 1px solid #ccc;
color: gray;
}
.paginate a {
cursor: pointer;
user-select: none;
}
.paginate a:hover {
background: #ccc;
text-decoration: none;
}
.paginate .disabled {
opacity: .5;
cursor: default;
}
.paginate .disabled:hover {
color: gray;
background: transparent;
}
*/
li {
padding-bottom: .5rem;
}
.toast {
border: 1px solid black;
border-radius: 5px;
padding: .5rem;
margin: .5rem 0;
}
.toast.error {
border-color: #dc143c;
}
.toast.success {
border-color: #32cd32;
}

@ -0,0 +1,10 @@
@import "common";
.EditForm th {
text-align: left;
vertical-align: top;
}
label {
cursor: pointer;
}

@ -0,0 +1,26 @@
export function uniqueId() {
return 'f'
+ Math.random().toString(16).replace('.', '')
+ (+new Date()).toString(16);
}
export function keyBy(array, keyfunc) {
let result = {};
for(let item of array) {
if (typeof keyfunc == 'string') {
result[item[keyfunc]] = item;
} else {
result[keyfunc(item)] = item;
}
}
return result;
}
export function objCopy(object) {
return JSON.parse(JSON.stringify(object));
}
export function castId(id) {
// TODO no-op after switching to UUIDs
return +id;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

@ -1,123 +1,103 @@
*, *::before, *::after {
box-sizing: border-box;
}
box-sizing: border-box; }
html, textarea, select {
font-family: "IBM Plex", "DejaVu Sans", "Helvetica", sans-serif;
}
font-family: "IBM Plex", "DejaVu Sans", "Helvetica", sans-serif; }
.Form {
display: block;
width: 900px;
margin: 0 auto;
}
display: block;
width: 900px;
margin: 0 auto; }
nav.top-nav {
margin-bottom: .5rem;
border-bottom: 1px solid silver;
}
margin-bottom: .5rem;
border-bottom: 1px solid silver; }
nav.top-nav, .content {
margin: 0 auto;
width: 900px;
}
margin: 0 auto;
width: 900px; }
a {
color: gray;
text-decoration: none;
}
color: gray;
text-decoration: none; }
a:hover {
color: black;
text-decoration: underline;
}
color: black;
text-decoration: underline; }
nav.top-nav a {
display: inline-block;
padding: .75rem;
color: gray;
text-decoration: none;
}
display: inline-block;
padding: .75rem;
color: gray;
text-decoration: none; }
nav.top-nav a:hover {
color: black;
text-decoration: underline;
}
color: black;
text-decoration: underline; }
.Form .Row {
display: flex;
padding: .25rem;
}
display: flex;
padding: .25rem; }
.Form .Row.indented {
padding-left: 10.25rem;
}
padding-left: 10.25rem; }
input[type="text"],
input[type="number"],
textarea,
.tag-input {
border: 1px solid silver;
padding: 0.5rem;
border-radius: 5px;
font-size: 1rem;
}
border: 1px solid silver;
padding: 0.5rem;
border-radius: 5px;
font-size: 1rem; }
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus,
.tag-input.active {
box-shadow: inset 0 0 0 1px #3c97ff;
border-color: #3c97ff;
outline: 0 none !important;
}
box-shadow: inset 0 0 0 1px #3c97ff;
border-color: #3c97ff;
outline: 0 none !important; }
.Form label {
flex-shrink: 0;
width: 10rem;
height: 2.1rem;
line-height: 2.1rem;
vertical-align: middle;
text-align: right;
display: inline-block;
padding-right: .5rem;
align-self: flex-start;
}
flex-shrink: 0;
width: 10rem;
height: 2.1rem;
line-height: 2.1rem;
vertical-align: middle;
text-align: right;
display: inline-block;
padding-right: .5rem;
align-self: flex-start; }
.Form input[type="text"],
.Form input[type="number"],
.Form select {
height: 2.1rem;
width: 15rem;
}
height: 2.1rem;
width: 15rem; }
.Form textarea {
flex-shrink: 1;
width: 30rem;
height: 6rem;
}
flex-shrink: 1;
width: 30rem;
height: 6rem; }
.Form label.checkbox-wrap {
width: 15rem;
padding-right: 1rem;
text-align: left !important;
}
width: 15rem;
padding-right: 1rem;
text-align: left !important; }
.tag-input {
position: relative;
width: 30rem;
padding-bottom: 0rem !important;
}
position: relative;
width: 30rem;
padding-bottom: 0rem !important; }
.tag-input input,
.tag-input input:focus {
border: 0 transparent;
padding: 0;
margin: 0;
box-shadow: none;
outline: 0 none !important;
}
border: 0 transparent;
padding: 0;
margin: 0;
box-shadow: none;
outline: 0 none !important; }
/*
@ -201,22 +181,29 @@ textarea:focus,
}
*/
li {
padding-bottom: .5rem;
}
padding-bottom: .5rem; }
.toast {
border: 1px solid black;
border-radius: 5px;
padding: .5rem;
margin: .5rem 0;
}
border: 1px solid black;
border-radius: 5px;
padding: .5rem;
margin: .5rem 0; }
.toast.error {
border-color: #dc143c;
}
border-color: #dc143c; }
.toast.success {
border-color: #32cd32;
}
border-color: #32cd32; }
.EditForm th {
text-align: left;
vertical-align: top; }
label {
cursor: pointer; }
.new-relation[data-v-0f98a4a0] {
border: 1px dashed gray;
margin: 10px 0;
padding: 10px; }

@ -3,9 +3,8 @@
<head>
<meta charset="UTF-8">
<title>{% block title -%}{%- endblock title %} &bull; YOPA</title>
<script src="/static/bundle.js"></script>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/taggle.css">
<script src="/static/taggle.min.js"></script>
</head>
<body>
<nav class="top-nav">

@ -1,12 +1,12 @@
{% extends "_layout" %}
{% import "_macros" as macros %}
{% import "models/_schema_macros" as macros %}
{% block title -%}
Index
{%- endblock %}
{% block nav -%}
<a href="/">Home</a>
<a href="/takeout">Takeout</a>
{%- endblock %}
{% block content -%}
@ -23,6 +23,12 @@
<li>
<b title="{{model.id}}">{{model.name}}</b><br>
<!--
{{ model | json_encode(pretty=true) | safe }}
-->
<a href="/model/object/delete/{{model.id}}" onclick="return confirm('Delete model?')">Delete model</a> &middot;
<a href="/model/object/update/{{model.id}}">Edit model</a> &middot;
<a href="/model/relation/create/{{model.id}}">New relation</a> &middot;

@ -0,0 +1,22 @@
{% extends "_layout" %}
{% block title -%}
Create {{model.name}}
{%- endblock %}
{% block nav -%}
<a href="/">Home</a>
{%- endblock %}
{% block content -%}
<div id="new-object-form"></div>
<script>
onLoad(() => {
// TODO populate dynamically from the database
window.app = Yopa.newObjectForm({{ form_data | json_encode | safe }})
});
</script>
{%- endblock %}

File diff suppressed because it is too large Load Diff

@ -131,6 +131,7 @@ async fn main() -> std::io::Result<()> {
/* Routes */
.service(routes::index)
.service(routes::takeout)
//
.service(routes::object_model::create_form)
.service(routes::object_model::create)
@ -149,6 +150,10 @@ async fn main() -> std::io::Result<()> {
.service(routes::property_model::update_form)
.service(routes::property_model::update)
.service(routes::property_model::delete)
.service(routes::object::create_form)
.service(routes::object::create)
.service(static_files)
.default_service(web::to(|| HttpResponse::NotFound().body("File or endpoint not found")))
})

@ -1,5 +1,5 @@
use std::fmt::{Debug, Display};
use std::ops::DerefMut;
use std::ops::{DerefMut, Deref};
use std::str::FromStr;
use actix_session::Session;
@ -20,6 +20,8 @@ pub(crate) mod object_model;
pub(crate) mod relation_model;
pub(crate) mod property_model;
pub(crate) mod object;
#[get("/")]
pub(crate) async fn index(session: Session, store: crate::YopaStoreWrapper) -> actix_web::Result<impl Responder> {
let rg = store.read().await;
@ -79,5 +81,16 @@ pub(crate) async fn index(session: Session, store: crate::YopaStoreWrapper) -> a
ctx.insert("models", &models);
session.render_flash(&mut ctx);
TERA.build_response("index", &ctx)
TERA.build_response("models/schema", &ctx)
}
#[get("/takeout")]
pub(crate) async fn takeout(store: crate::YopaStoreWrapper) -> actix_web::Result<impl Responder> {
let rg = store.read().await;
let encoded = serde_json::to_string_pretty(rg.deref()).unwrap();
Ok(HttpResponse::Ok()
.content_type("application/json")
.body(encoded))
}

@ -0,0 +1,102 @@
use actix_session::Session;
use actix_web::{Responder, web, HttpResponse};
use crate::session_ext::SessionExt;
use crate::routes::object_model::ObjectModelForm;
use crate::TERA;
use crate::tera_ext::TeraExt;
use yopa::{ID, model};
use yopa::data::Object;
use serde::{Serialize,Deserialize};
use yopa::insert::InsertObj;
use crate::utils::redirect;
use actix_web::web::Json;
use serde_json::Value;
// we only need references here, Context serializes everything to Value.
// cloning would be a waste of cycles
#[derive(Debug, Default, Serialize, Clone)]
pub struct Schema<'a> {
pub obj_models: Vec<&'a model::ObjectModel>,
pub rel_models: Vec<&'a model::RelationModel>,
pub prop_models: Vec<&'a model::PropertyModel>,
}
#[derive(Serialize,Debug,Clone)]
pub struct ObjectCreateData<'a> {
pub model_id: ID,
pub schema: Schema<'a>,
pub objects: Vec<&'a Object>,
}
#[get("/object/create/{model_id}")]
pub(crate) async fn create_form(
model_id: web::Path<ID>,
store: crate::YopaStoreWrapper,
session: Session
) -> actix_web::Result<impl Responder> {
let mut context = tera::Context::new();
session.render_flash(&mut context);
let rg = store.read().await;
let model = rg.get_object_model(*model_id)
.ok_or_else(|| actix_web::error::ErrorNotFound("No such model"))?;
context.insert("model", model);
let relations : Vec<_> = rg.get_relation_models_for_object(model.id).collect();
let mut prop_object_ids : Vec<ID> = relations.iter().map(|r| r.id).collect();
prop_object_ids.push(model.id);
prop_object_ids.sort();
prop_object_ids.dedup();
let mut related_ids : Vec<_> = relations.iter().map(|r| r.related).collect();
related_ids.sort();
related_ids.dedup();
let form_data = ObjectCreateData {
model_id: model.id,
schema: Schema {
obj_models: rg.get_object_models().collect(), // TODO get only the ones that matter here
rel_models: relations,
prop_models: rg.get_property_models_for_parents(&prop_object_ids).collect()
},
objects: rg.get_objects_of_type(&related_ids).collect()
};
context.insert("form_data", &form_data);
TERA.build_response("objects/object_create", &context)
}
#[post("/object/create")]
pub(crate) async fn create(
form: web::Json<InsertObj>,
store: crate::YopaStoreWrapper,
session: Session,
) -> actix_web::Result<impl Responder> {
warn!("{:?}", form);
// let des : InsertObj = serde_json::from_value(form.into_inner()).unwrap();
//
// Ok(HttpResponse::Ok().finish())
//
let mut wg = store.write().await;
let form = form.into_inner();
let name = form.name.clone();
match wg.insert_object(form) {
Ok(_id) => {
debug!("Object created, redirecting to root");
session.flash_success(format!("Object \"{}\" created.", name));
Ok(HttpResponse::Ok().finish())
}
Err(e) => {
warn!("Error creating model: {}", e);
Ok(HttpResponse::BadRequest().body(e.to_string()))
}
}
}

@ -32,7 +32,7 @@ pub(crate) async fn create_form(session: Session) -> actix_web::Result<impl Resp
context.insert("old", &ObjectModelForm::default());
}
TERA.build_response("model_create", &context)
TERA.build_response("models/model_create", &context)
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
@ -88,7 +88,7 @@ pub(crate) async fn update_form(
context.insert("model", model);
}
TERA.build_response("model_update", &context)
TERA.build_response("models/model_update", &context)
}
#[post("/model/object/update/{model_id}")]

@ -57,7 +57,7 @@ pub(crate) async fn create_form(
};
context.insert("object", &object);
TERA.build_response("property_create", &context)
TERA.build_response("models/property_create", &context)
}
#[derive(Serialize,Deserialize)]
@ -220,7 +220,7 @@ pub(crate) async fn update_form(
context.insert("model", model);
}
TERA.build_response("property_update", &context)
TERA.build_response("models/property_update", &context)
}
#[post("/model/property/update/{model_id}")]

@ -55,7 +55,7 @@ pub(crate) async fn create_form(
TERA.build_response("relation_create", &context)
TERA.build_response("models/relation_create", &context)
}
#[derive(Serialize,Deserialize)]
@ -162,7 +162,7 @@ pub(crate) async fn update_form(
context.insert("model", model);
}
TERA.build_response("relation_update", &context)
TERA.build_response("models/relation_update", &context)
}
#[post("/model/relation/update/{model_id}")]

@ -176,6 +176,8 @@ pub struct Object {
pub id: ID,
/// Object template ID
pub model: ID,
/// Model name, mainly shown in lists
pub name : String,
}
/// Relation between two objects

@ -43,14 +43,16 @@ impl InsertRel {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InsertObj {
pub model_id: ID,
pub name : String,
pub values: Vec<InsertValue>,
pub relations: Vec<InsertRel>,
}
impl InsertObj {
pub fn new(model_id: ID, values: Vec<InsertValue>, relations: Vec<InsertRel>) -> Self {
pub fn new(model_id: ID, name : String, values: Vec<InsertValue>, relations: Vec<InsertRel>) -> Self {
Self {
model_id,
name,
values,
relations,
}

@ -18,6 +18,7 @@ use crate::model::{PropertyModel, RelationModel};
pub use data::{TypedValue};
pub use model::{DataType};
use crate::data::Object;
pub mod model;
pub mod data;
@ -248,6 +249,7 @@ impl Storage {
let object = data::Object {
id: object_id,
model: obj_model_id,
name: insobj.name
};
let find_values_to_insert = |values: Vec<InsertValue>, parent_id : ID, parent_model_id: ID| -> Result<Vec<data::Value>, StorageError> {
@ -360,6 +362,21 @@ impl Storage {
.into_group_map_by(|model| model.object)
}
pub fn get_relation_models_for_object(&self, model_id: ID) -> impl Iterator<Item=&RelationModel> {
self.rel_models.values()
.filter(move |model| model.object == model_id)
}
pub fn get_property_models_for_parents<'p, 'a : 'p>(&'a self, parents: &'p [ID]) -> impl Iterator<Item=&'p PropertyModel> {
self.prop_models.values()
.filter(move |model| parents.contains(&model.object))
}
pub fn get_objects_of_type<'p, 'a : 'p>(&'a self, model_ids : &'p [ID]) -> impl Iterator<Item=&'p data::Object> {
self.objects.values()
.filter(move |object| model_ids.contains(&object.model))
}
pub fn get_grouped_relation_models(&self) -> HashMap<ID, Vec<&RelationModel>> {
self.rel_models.values()
.into_group_map_by(|model| model.object)

Loading…
Cancel
Save