This commit is contained in:
Markus Thielen 2023-03-15 17:57:12 +01:00
parent dc3fb83819
commit 3e2ac648c2
Signed by: markus
GPG Key ID: 3D4980D3EC9C8E26
19 changed files with 828 additions and 797 deletions

View File

@ -1,77 +1,140 @@
[resolvers.deleteTask]
operation_name = "deleteTask"
[resolvers.createTask]
operation_name = "createTask"
[resolvers.deleteTask.resolver]
command_type = "SQLDelete"
[resolvers.createTask.resolver]
command_type = "SQLInsert"
[resolvers.createTask.resolver.command]
table = "Task"
columns = []
tables = []
where_clauses = [["Task", "id", "= '$id'"]]
join_clauses = []
modify_table = ["Task", ""]
modify_values = []
aggregate_final_json_result = true
where_clauses = []
aggregate_result = true
[[resolvers.createTask.resolver.command.modify_values]]
column = "title"
value = "'$title'"
[[resolvers.createTask.resolver.command.modify_values]]
column = "description"
value = "'$description'"
[[resolvers.createTask.resolver.command.modify_values]]
column = "completed"
value = "$completed"
[[resolvers.createTask.resolver.command.modify_values]]
column = "list_id"
value = "'$list.$id'"
[[resolvers.createTask.resolver.command.modify_values]]
column = "user_username"
value = "'$user.$username'"
[resolvers.updateTask]
operation_name = "updateTask"
[resolvers.updateTask.resolver]
command_type = "SQLUpdate"
[resolvers.updateTask.resolver.command]
table = "Task"
columns = []
tables = []
where_clauses = [["Task", "id", "= '$id'"]]
join_clauses = []
modify_table = ["Task", ""]
modify_values = [["title", "'$title'"], ["description", "'$description'"], ["completed", "$completed"], ["list_id", "'$list.$id'"]]
aggregate_final_json_result = true
aggregate_result = true
[resolvers.createUser]
operation_name = "createUser"
[[resolvers.updateTask.resolver.command.modify_values]]
column = "title"
value = "'$title'"
[resolvers.createUser.resolver]
command_type = "SQLInsert"
columns = []
tables = []
where_clauses = []
join_clauses = []
modify_table = ["User", ""]
modify_values = [["username", "'$username'"], ["name", "'$name'"]]
aggregate_final_json_result = true
[[resolvers.updateTask.resolver.command.modify_values]]
column = "description"
value = "'$description'"
[resolvers.createList]
operation_name = "createList"
[[resolvers.updateTask.resolver.command.modify_values]]
column = "completed"
value = "$completed"
[resolvers.createList.resolver]
command_type = "SQLInsert"
columns = []
tables = []
where_clauses = []
join_clauses = []
modify_table = ["List", ""]
modify_values = [["title", "'$title'"], ["user_username", "'$user.$username'"]]
aggregate_final_json_result = true
[[resolvers.updateTask.resolver.command.modify_values]]
column = "list_id"
value = "'$list.$id'"
[resolvers.createTask]
operation_name = "createTask"
[resolvers.createTask.resolver]
command_type = "SQLInsert"
columns = []
tables = []
where_clauses = []
join_clauses = []
modify_table = ["Task", ""]
modify_values = [["title", "'$title'"], ["description", "'$description'"], ["completed", "$completed"], ["list_id", "'$list.$id'"], ["user_username", "'$user.$username'"]]
aggregate_final_json_result = true
[[resolvers.updateTask.resolver.command.where_clauses]]
table = "Task"
column = "id"
condition_str = "= '$id'"
index = ""
[resolvers.getUser]
operation_name = "getUser"
[resolvers.getUser.resolver]
command_type = "SQLSelect"
[resolvers.getUser.resolver.command]
table = "User"
columns = []
tables = [["User", ""]]
where_clauses = [["User", "username", "= '$username'"]]
join_clauses = []
modify_table = ["", ""]
modify_values = []
aggregate_final_json_result = true
aggregate_result = true
[[resolvers.getUser.resolver.command.where_clauses]]
table = "User"
column = "username"
condition_str = "= '$username'"
index = ""
[resolvers.deleteTask]
operation_name = "deleteTask"
[resolvers.deleteTask.resolver]
command_type = "SQLDelete"
[resolvers.deleteTask.resolver.command]
table = "Task"
columns = []
modify_values = []
aggregate_result = true
[[resolvers.deleteTask.resolver.command.where_clauses]]
table = "Task"
column = "id"
condition_str = "= '$id'"
index = ""
[resolvers.createUser]
operation_name = "createUser"
[resolvers.createUser.resolver]
command_type = "SQLInsert"
[resolvers.createUser.resolver.command]
table = "User"
columns = []
where_clauses = []
aggregate_result = true
[[resolvers.createUser.resolver.command.modify_values]]
column = "username"
value = "'$username'"
[[resolvers.createUser.resolver.command.modify_values]]
column = "name"
value = "'$name'"
[resolvers.createList]
operation_name = "createList"
[resolvers.createList.resolver]
command_type = "SQLInsert"
[resolvers.createList.resolver.command]
table = "List"
columns = []
where_clauses = []
aggregate_result = true
[[resolvers.createList.resolver.command.modify_values]]
column = "title"
value = "'$title'"
[[resolvers.createList.resolver.command.modify_values]]
column = "user_username"
value = "'$user.$username'"

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">

52
package-lock.json generated
View File

@ -5,8 +5,12 @@
"requires": true,
"packages": {
"": {
"name": "todo",
"version": "0.0.0",
"dependencies": {
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.3.0-alpha1",
"bootstrap-icons": "^1.10.3",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},
@ -379,6 +383,15 @@
"node": ">=12"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@vitejs/plugin-vue": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
@ -521,6 +534,29 @@
"node": ">=8"
}
},
"node_modules/bootstrap": {
"version": "5.3.0-alpha1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0-alpha1.tgz",
"integrity": "sha512-ABZpKK4ObS3kKlIqH+ZVDqoy5t/bhFG0oHTAzByUdon7YIom0lpCeTqRniDzJmbtcWkNe800VVPBiJgxSYTYew==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"peerDependencies": {
"@popperjs/core": "^2.11.6"
}
},
"node_modules/bootstrap-icons": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.3.tgz",
"integrity": "sha512-7Qvj0j0idEm/DdX9Q0CpxAnJYqBCFCiUI6qzSPYfERMcokVuV9Mdm/AJiVZI8+Gawe4h/l6zFcOzvV7oXCZArw=="
},
"node_modules/braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
@ -1141,6 +1177,11 @@
"dev": true,
"optional": true
},
"@popperjs/core": {
"version": "2.11.6",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz",
"integrity": "sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw=="
},
"@vitejs/plugin-vue": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.0.0.tgz",
@ -1268,6 +1309,17 @@
"integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
"dev": true
},
"bootstrap": {
"version": "5.3.0-alpha1",
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.0-alpha1.tgz",
"integrity": "sha512-ABZpKK4ObS3kKlIqH+ZVDqoy5t/bhFG0oHTAzByUdon7YIom0lpCeTqRniDzJmbtcWkNe800VVPBiJgxSYTYew==",
"requires": {}
},
"bootstrap-icons": {
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.10.3.tgz",
"integrity": "sha512-7Qvj0j0idEm/DdX9Q0CpxAnJYqBCFCiUI6qzSPYfERMcokVuV9Mdm/AJiVZI8+Gawe4h/l6zFcOzvV7oXCZArw=="
},
"braces": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",

View File

@ -8,6 +8,9 @@
"preview": "vite preview"
},
"dependencies": {
"@popperjs/core": "^2.11.6",
"bootstrap": "^5.3.0-alpha1",
"bootstrap-icons": "^1.10.3",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},

View File

@ -3,7 +3,6 @@ import { RouterLink, RouterView } from 'vue-router'
import HelloWorld from './components/Welcome.vue'
import { store } from './store.js'
import {clearSession} from "./store.js";
import FatalError from "./components/FatalError.vue";
/**
* Logout the user.
@ -13,98 +12,64 @@ function logOut() {
location.href = "/";
}
/**
* Remove error alert from the screen.
*/
function dismissError() {
store.fatalError = "";
}
</script>
<template>
<header>
<img alt="basebox logo" class="logo" src="@/assets/img/basebox_logo.svg" />
<header class="sticky-top">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<img alt="basebox logo" class="logo" src="@/assets/img/basebox_logo.svg" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="wrapper">
<HelloWorld />
<nav>
<RouterLink to="/">Home</RouterLink>
<RouterLink to="/about">About</RouterLink>
<a v-if="store.session" href="" @click="logOut()">Log Out</a>
</nav>
<FatalError v-if="store.fatalError" :error-msg="store.fatalError"/>
</div>
<!-- navigation items are shown only if logged in -->
<div v-if="store.session.token" class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<RouterLink to="/about" class="nav-link" active-class="active">About</RouterLink>
</li>
<li class="nav-item">
<RouterLink to="/lists" class="nav-link" active-class="active">Lists</RouterLink>
</li>
</ul>
<ul class="navbar-nav mb-2 mb-lg-0">
<li class="navbar-text">Hello, {{ store.session.username }}!</li>
<li class="nav-item ms-2">
<button class="nav-link btn btn-sm btn-secondary" @click="logOut()">Logout</button>
</li>
</ul>
</div>
</div>
</nav>
</header>
<RouterView />
<main class="container">
<div class="alert alert-dismissible alert-danger mb-3 fade show" v-if="store.fatalError">
<p><strong>An error occurred:</strong></p>
<p v-html="store.fatalError"></p>
<button type="button" class="btn-close" @click="dismissError()" aria-label="Close"></button>
</div>
<RouterView />
</main>
</template>
<style lang="scss" scoped>
header {
line-height: 1.5;
max-height: 100vh;
justify-content: center;
}
.logo {
display: block;
margin: 0 auto 2rem;
max-width: 100%;
height: 32px;
}
nav {
width: 100%;
font-size: 12px;
text-align: center;
margin-top: 2rem;
@media(max-width: 1024px) {
margin-bottom: 2rem;
}
}
nav a.router-link-exact-active {
color: var(--color-text);
}
nav a.router-link-exact-active:hover {
background-color: transparent;
}
nav a {
display: inline-block;
padding: 0 1rem;
border-left: 1px solid var(--color-border);
}
nav a:first-of-type {
border: 0;
}
@media (min-width: 1024px) {
header {
display: flex;
flex-direction: column;
padding-right: calc(var(--section-gap) / 2);
text-align: center;
}
header .logo {
width: 50%;
}
.logo {
margin: 0 auto 5rem;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
justify-content: center;
}
nav {
font-size: 1rem;
padding: 1rem 0;
margin-top: 1rem;
}
}
</style>

View File

@ -1,89 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
--color-input-background: #fff;
--color-input: rgba(0,0,0,.85);
--color-input-border-focus: rgba(0,0,0,.2);
--color-error-text: darkred;
--color-item-background: white;
--color-item-text: rgb(33, 53, 71);
--item-box-shadow: 0 3px 12px rgba(0, 0, 0, .07), 0 1px 4px rgba(0, 0, 0, .07);
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
--color-input-background: rgba(255,255,255,.05);
--color-input: rgba(255,255,255,.8);
--color-input-border-focus: rgba(0,0,0,.4);
--color-error-text: red;
--color-item-background: #242424;
--color-item-text: white;
--item-box-shadow: 0 3px 12px rgba(0, 0, 0, .07), 0 1px 4px rgba(0, 0, 0, .07);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
position: relative;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition: color 0.5s, background-color 0.5s;
line-height: 1.6;
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -1,113 +1,74 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
ul{
padding-left: 18px;
}
a,
.primary {
text-decoration: none;
color: #3AB2FF;
transition: 0.4s;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 4rem 0 2rem;
}
}
.form-group {
margin: 25px 0;
main {
padding-top: 20px;
label {
text-transform: uppercase;
font-size: .8rem;
font-weight: 700;
display: block;
margin-bottom: 5px;
white-space: nowrap;
}
}
.toolbar {
display: flex;
background-color: var(--bs-tertiary-bg);
border: 1px solid var(--bs-border-color);
border-radius: .3em;
padding: 5px;
align-items: center;
}
.list-item {
display: flex;
background-color: var(--bs-tertiary-bg);
box-shadow: var(--item-box-shadow);
margin-bottom: 10px;
padding: 15px;
border-radius: .3em;
border: 3px solid transparent;
&.completed {
border: 3px solid #41ab57;
}
input {
border: 1px solid var(--color-border);
padding: 6px 8px;
display: block;
border-radius: 2px;
width: 100%;
background-color: var(--color-input-background);
color: var(--color-input);
appearance: none;
border: none;
border-bottom: 1px solid var(--bs-border-color);
outline: none;
margin-bottom: 15px;
color: var(--color-item-text);
background: transparent;
&:focus {
border-color: var(--color-input-border-focus);
outline: none;
}
}
.item-title {
font-size: 1.5rem;
}
}
.form-buttons {
margin-top: 25px;
padding-top: 20px;
border-top: 1px solid var(--color-border);
.item-v-container {
display: flex;
gap: 1rem;
flex-direction: column;
flex-grow: 1;
background-color: inherit;
}
.btn {
padding: 5px 20px;
border: 1px solid var(--color-border);
border-radius: 2px;
cursor: pointer;
&.btn-primary {
background-color: #1890ff;
color: white;
border-color: #1890ff;
.item-meta {
padding: 5px 5px 5px 25px;
display: flex;
flex-direction: column;
align-items: end;
}
@keyframes bounce-in {
0% {
transform: scale(0);
}
&.btn-large {
font-size: 1.25rem;
padding: 6px 25px;
100% {
transform: scale(1);
}
}
.btn-container {
margin: 30px 0;
.list-enter-active {
animation: bounce-in 0.4s;
}
.list-leave-active {
animation: bounce-in 0.4s reverse;
}
form .error {
color: var(--color-error-text);
margin: 25px 0 0 0;
}
select.form-control:not([multiple]) {
cursor: pointer;
background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAMCAYAAABiDJ37AAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAI9JREFUeNpiZACCyMjIBCDVD8SOy5cvv8BAAgDqNQBS+4G4EKh3ASPUsPlQ+Q+kGIpkmABUKJEJ6jIYAEnshyok1TAQ6AcZ6Ah1GdGG4jAM7DtGQgrQvU9ILSOxColVw0is7VA2QV8wkhA+DMQECSMJgc5AyDCcBhIwFG9aZSQxrRFM+IwkJGAGYnIRQIABACQuXCKovu2mAAAAAElFTkSuQmCC);
background-size: 1rem;
background-repeat: no-repeat;
background-position: calc(100% - 1rem) center;
padding: .5em 3em .5em 1em;
background-color: var(--color-background);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: .4rem;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}

View File

@ -1,57 +0,0 @@
<!--
Component that displays oauth/login related errors.
Part of the basebox sample Todo app.
https://basebox.tech
-->
<template>
<div id="error-message">
<h3>An error occurred...</h3>
<p>{{ errorMsg }}</p>
<p>
<a href="/" class="btn btn-primary">Start Over</a>
</p>
</div>
</template>
<script>
export default {
name: "OAuthError",
props: {
errorMsg: {
type: String,
},
}
}
</script>
<style lang="scss" scoped>
#error-message {
h3 {
font-weight: bold;
font-size: 1.5em;
}
position: absolute;
min-height: 100%;
left: 0;
top: 0;
width: 100%;
border: 1px solid #ff5050;
border-radius: .5rem;
padding: 2rem;
background: #4a1c1c;
p {
margin: 30px 0;
}
.btn {
margin-top: 20px;
display: inline-block;
}
}
</style>

117
src/components/Lists.vue Normal file
View File

@ -0,0 +1,117 @@
<!--
Component for editing task lists.
Part of the basebox sample Todo app.
https://basebox.tech
-->
<template>
<main>
<h1>
Lists
</h1>
<div class="toolbar">
<button type="button" @click="addItem()" class="btn btn-primary btn-large">New List</button>
<div class="ms-auto">
<small>{{ gStore.lists.length }} lists found.</small>
</div>
</div>
<div id="list-container">
<transition-group name="list" tag="div">
<div class="list-item" v-for="list in gStore.lists" :key="list.id">
<div class="item-v-container">
<input class="item-title" type="text" @blur="saveList(list)" v-model="list.title">
</div>
<div class="item-meta">
<div class="dropdown">
<button class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-trash"></i>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Delete this list</a></li>
</ul>
</div>
<span class="item-counter">
{{ listItemCount(list) }}
<i class="bi bi-card-checklist"></i>
</span>
</div>
</div>
<div class="alert alert-info" v-if="gStore.lists.length === 0">
<p>There are no lists, yet.</p>
<button type="button" @click="addItem()" class="btn btn-primary btn-large">Create your first list</button>
</div>
</transition-group>
</div>
</main>
</template>
<script>
import {gqlQuery} from "../util/net";
import {store} from "../store";
export default {
name: "Lists",
methods: {
/**
* Save a list to the database.
* @param list
*/
saveList(list)
{
},
/**
* Add a new list.
*/
addItem()
{
},
/**
* Return number of todo items in a list.
*/
listItemCount(list) {
return store.tasks.filter((item) => item.list.id === list.id).length;
},
},
computed: {
/**
* Add global store to the context, so we can refer to it in the template.
*/
gStore() {
return store;
},
}
}
</script>
<style lang="scss" scoped>
#list-container {
margin-top: 20px;
}
.item-counter {
font-size: .8em;
border: 1px solid var(--bs-border-color);
padding: 3px 5px;
text-align: right;
border-radius: 3px;
margin-top: 5px;
display: block;
}
</style>

286
src/components/Todo.vue Normal file
View File

@ -0,0 +1,286 @@
<!--
Root component for the Todo app that is displayed if the user is logged in.
Part of the basebox sample Todo app.
https://basebox.tech
-->
<template>
<main>
<h1>
ToDo List
</h1>
<div id="filter-form" class="toolbar">
<label class="switch">
<input type="checkbox" v-model="showCompleted">
Show completed
</label>
<button type="button" @click="addItem()" class="ms-auto btn btn-primary btn-large">Add Item</button>
<div class="ms-auto">
<div class="d-flex">
<label class="col-form-label" for="list-selector">Select List: </label>
<div class="ms-2">
<select id="list-selector" class="form-select" v-model="currentList">
<option :value="0">All</option>
<option v-for="item in gStore.lists" :key="item.id" :value="item.id">{{ item.title }}</option>
</select>
</div>
</div>
</div>
</div>
<div id="todo-container">
<transition-group name="list" tag="div">
<div :class="todoItemClass(task)" v-for="(task, i) in filteredItems" :id="'task-' + task.id" :key="task.id">
<div :class="todoCheckedClass(task)" @click="toggleCompleted(task)">
&nbsp;
</div>
<div class="item-v-container">
<input class="item-title" type="text" @blur="saveTask(task)" v-model="task.title">
<input class="todo-description" type="text" @blur="saveTask(task)" v-model="task.description">
</div>
<div class="item-meta">
<div class="dropdown">
<button class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-trash"></i>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="#">Delete this item</a></li>
</ul>
</div>
</div>
</div>
<div class="alert alert-info" v-if="filteredItems.length === 0">
</div>
</transition-group>
</div>
</main>
</template>
<script>
import {gqlQuery} from "../util/net";
import {store} from "../store";
export default {
name: "Todo",
components: [],
/** Current state of the component */
data() {
return {
/** whether to show completed items */
showCompleted: true,
/* the id of the list currently being shown */
currentList: 1,
}
},
methods: {
/**
* Save a task to the database.
* @param task the task to save; this is an object as received from the broker.
*/
saveTask(task) {
},
/**
* Add an item to the current list.
*/
addItem() {
if (this.currentList === 0) {
window.alert("Please select a list at the top right before adding an item.");
return;
}
/* fake ID of new item */
const newItemId = store.tasks.length + 1;
store.tasks.push({
id: newItemId,
completed: false,
title: "Enter task here",
description: "",
list: {
id: this.currentList,
}
});
/* wait a moment, then scroll list to the bottom */
setTimeout(function() {
document.getElementById("todo-container").scrollTo({
top: 100000,
behavior: "smooth"
});
/* select text in new item's title field */
const titleInput = document.querySelector(`#task-${newItemId} .item-title`);
titleInput.setSelectionRange(0, 1000);
titleInput.focus();
}, 100);
},
/**
* Toggle completed state of a todo item.
* @param task the todo item to toggle.
*/
toggleCompleted(task) {
task.completed = !task.completed;
},
/**
* Return class object for a todo checked symbol
*
* @param task the item
*/
todoCheckedClass(task) {
return {
"todo-checked": true,
"completed": task.completed,
"uncompleted": !task.completed,
}
},
/**
* Return class object for a todo item
*
* @param task the item
*/
todoItemClass(task) {
return {
"list-item": true,
"completed": task.completed,
}
},
},
computed: {
/**
* Add global store to the context, so we can refer to it in the template.
*/
gStore() {
return store;
},
/**
* Return array of tasks to show.
*/
filteredItems() {
return store.tasks.filter((item) => {
/* hide completed items if the option to show them is not set */
if (!this.showCompleted && item.completed) {
return false;
}
/* only return items of the current list */
if (this.currentList && item.list.id !== this.currentList) {
return false;
}
return true;
})
}
},
/**
* mounted lifecycle hook.
*/
mounted() {
if (!store.session) {
console.error("TodoRoot component must not be loaded if user is not logged in.");
return;
}
/* Load user info and his/her lists and todos */
gqlQuery(`query {
getUser(username: "${store.session.userName}") {
name
lists {
id
title
}
tasks {
id
title
description
completed
list {
id
}
}
}
}`).then(rspJson => {
}).catch(err => {
});
},
}
</script>
<style lang="scss" scoped>
#todo-container {
margin-top: 20px;
}
h1 {
margin-bottom: 25px;
}
.btn-icon {
width: 16px;
height: 16px;
cursor: pointer;
}
.todo-checked {
width: 80px;
height: 80px;
cursor: pointer;
margin-right: 20px;
min-height: 80px;
border-radius: 50%;
border: 4px solid var(--bs-border-color);
background-size: 80%;
background-position: center center;
background-repeat: no-repeat;
&.uncompleted {
&:hover {
background-image: url(../assets/img/check-gray.svg);
}
}
&.completed {
border-color: #41ab57;
background-image: url(../assets/img/check-green.svg);
}
}
.header-buttons {
float:right;
font-size: 1rem;
}
.switch {
padding-left: .5em;
input {
transform: scale(150%);
margin-right: 6px;
}
}
</style>

View File

@ -1,342 +0,0 @@
<!--
Root component for the Todo app that is displayed if the user is logged in.
Part of the basebox sample Todo app.
https://basebox.tech
-->
<template>
<main>
<h1>
ToDo List
</h1>
<div id="filter-form">
<label class="switch">
<input type="checkbox" v-model="showCompleted">
Show completed
</label>
<div>
<label for="list-selector">List: </label>
<select id="list-selector" class="form-control" v-model="currentList">
<option :value="0">All</option>
<option v-for="item in lists" :key="item.id" :value="item.id">{{ item.title }}</option>
</select>
</div>
</div>
<div id="todo-container">
<transition-group name="list" tag="div">
<div :class="todoItemClass(task)" v-for="(task, i) in filteredItems" :id="'task-' + task.id" :key="task.id">
<div :class="todoCheckedClass(task)" @click="toggleCompleted(task)">
&nbsp;
</div>
<div class="todo-v-container">
<input class="todo-title" type="text" v-model="task.title">
<input class="todo-description" type="text" v-model="task.description">
</div>
<div class="todo-meta">
<img src="@/assets/img/trash-solid.png" class="btn-icon">
</div>
</div>
</transition-group>
</div>
<div class="btn-container">
<button type="button" @click="addItem()" class="btn btn-primary btn-large">Add Item</button>
</div>
</main>
</template>
<script>
import {gqlQuery} from "../util/net";
import {store} from "../store";
export default {
name: "TodoRoot",
components: [],
/** Current state of the component */
data() {
return {
/** whether to show completed items */
showCompleted: true,
/* array of todo lists currently known */
lists: [
{ id: 1, title: "Default"},
{ id: 2, title: "House" },
{ id: 3, title: "Car" },
{ id: 4, title: "Work" },
],
/* the id of the list currently being shown */
currentList: 1,
/* known tasks */
tasks: [
{ id: 1, completed: false, title: "Go to dentist", description: "Last time is way too long ago...", list: { id: 1 } },
{ id: 2, completed: true, title: "Change engine oil", description: "Use the good one", list: { id: 3 } },
{ id: 3, completed: false, title: "Clean windows", description: "Regualar stuff", list: { id: 1 } },
{ id: 4, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 5, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 6, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 7, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 8, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 9, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 10, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 11, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 12, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 13, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 14, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 15, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 16, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 17, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
],
}
},
methods: {
/**
* Add an item to the current list.
*/
addItem() {
if (this.currentList === 0) {
window.alert("Please select a list at the top right before adding an item.");
return;
}
/* fake ID of new item */
const newItemId = this.tasks.length + 1;
this.tasks.push({
id: newItemId,
completed: false,
title: "Enter task here",
description: "",
list: {
id: this.currentList,
}
});
/* wait a moment, then scroll list to the bottom */
setTimeout(function() {
document.getElementById("todo-container").scrollTo({
top: 100000,
behavior: "smooth"
});
/* select text in new item's title field */
const titleInput = document.querySelector(`#task-${newItemId} .todo-title`);
titleInput.setSelectionRange(0, 1000);
titleInput.focus();
}, 100);
},
/**
* Toggle completed state of a todo item.
* @param task the todo item to toggle.
*/
toggleCompleted(task) {
task.completed = !task.completed;
},
/**
* Return class object for a todo checked
*
* @param task the item
*/
todoCheckedClass(task) {
return {
"todo-checked": true,
"completed": task.completed,
"uncompleted": !task.completed,
}
},
/**
* Return class object for a todo item
*
* @param task the item
*/
todoItemClass(task) {
return {
"todo-item": true,
"completed": task.completed,
}
},
},
computed: {
/**
* Return array of tasks to show.
*/
filteredItems() {
return this.tasks.filter((item) => {
/* hide completed items if the option to show them is not set */
if (!this.showCompleted && item.completed) {
return false;
}
/* only return items of the current list */
if (this.currentList && item.list.id !== this.currentList) {
return false;
}
return true;
})
}
},
/**
* mounted lifecycle hook.
*/
mounted() {
if (!store.session) {
console.error("TodoRoot component must not be loaded if user is not logged in.");
return;
}
/* Load user info and his/her lists and todos */
gqlQuery(`query {
getUser(username: "${store.session.userName}") {
name
lists {
id
title
}
tasks {
id
title
description
completed
list {
id
}
}
}
}`).then(rspJson => {
}).catch(err => {
});
},
}
</script>
<style lang="scss" scoped>
#todo-container {
height: calc(100vh - 200px);
overflow: auto;
margin-top: 20px;
}
.todo-item {
display: flex;
background-color: var(--color-item-background);
box-shadow: var(--item-box-shadow);
margin-bottom: 10px;
padding: 15px;
border-radius: .3em;
border: 3px solid transparent;
&.completed {
border: 3px solid #41ab57;
}
input {
border: none;
border-bottom: 1px solid var(--color-border);
outline: none;
margin-bottom: 15px;
color: var(--color-item-text);
background: transparent;
&:focus {
outline: none;
}
}
.todo-title {
font-size: 1.5rem;
}
}
h1 {
margin-bottom: 25px;
}
.todo-v-container {
display: flex;
flex-direction: column;
flex-grow: 1;
background-color: inherit;
}
.todo-meta {
padding: 5px 5px 5px 25px;
}
.btn-icon {
width: 16px;
height: 16px;
cursor: pointer;
}
.todo-checked {
width: 80px;
cursor: pointer;
margin-right: 20px;
min-height: 80px;
border-radius: 50%;
border: 4px solid var(--color-border);
background-size: 80%;
background-position: center center;
background-repeat: no-repeat;
&.uncompleted {
&:hover {
background-image: url(../assets/img/check-gray.svg);
}
}
&.completed {
border-color: #41ab57;
background-image: url(../assets/img/check-green.svg);
}
}
.header-buttons {
float:right;
font-size: 1rem;
}
.switch {
input {
transform: scale(150%);
margin-right: 6px;
}
}
.list-enter-active {
animation: bounce-in 0.4s;
}
.list-leave-active {
animation: bounce-in 0.4s reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0);
}
100% {
transform: scale(1);
}
}
#filter-form {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@ -7,7 +7,9 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import "bootstrap/dist/css/bootstrap.min.css"
import "bootstrap"
import 'bootstrap-icons/font/bootstrap-icons.css'
import './assets/main.scss'
const app = createApp(App)

View File

@ -8,9 +8,9 @@
import {showError, store} from "../store";
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
import OAUthError from "../components/FatalError.vue";
import { objectToQueryString } from "../util/net";
import {createUser, oauthCallbackHandler} from "../util/oauth";
import {oauthCallbackHandler} from "../util/oauth";
import {storeInit} from "../store";
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -28,6 +28,11 @@ const router = createRouter({
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
},
{
path: '/lists',
name: 'lists',
component: () => import('../components/Lists.vue')
},
]
})
@ -43,7 +48,6 @@ router.beforeEach(async (to, from) => {
/* Pass the query string to the handler function */
try {
await oauthCallbackHandler(queryString);
/* Success; redirect to home */
} catch (err) {
const errorMsg = `Failed to get session info from basebox/finish OpenID Connect login: ${err}`;
console.error(errorMsg);
@ -51,15 +55,6 @@ router.beforeEach(async (to, from) => {
showError(errorMsg);
}
/* We have to create the logged-in user explicitly; see comment for `createUser()` */
try {
await createUser();
} catch (err) {
const errorMsg = `Failed to create user: ${err}`;
console.error(errorMsg);
showError(errorMsg);
}
return {name: 'home'};
}

View File

@ -5,6 +5,7 @@
* https://basebox.tech
*/
import { reactive } from 'vue'
import {gqlQuery} from "./util/net";
export const store = reactive({
@ -20,6 +21,34 @@ export const store = reactive({
/** Fatal error message, if any */
fatalError: "",
/* array of todo lists currently known */
lists: [
{ id: 1, title: "Default"},
{ id: 2, title: "House" },
{ id: 3, title: "Car" },
{ id: 4, title: "Work" },
],
/* known tasks */
tasks: [
{ id: 1, completed: false, title: "Go to dentist", description: "Last time is way too long ago...", list: { id: 1 } },
{ id: 2, completed: true, title: "Change engine oil", description: "Use the good one", list: { id: 3 } },
{ id: 3, completed: false, title: "Clean windows", description: "Regualar stuff", list: { id: 1 } },
{ id: 4, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 5, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 6, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 7, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 8, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 9, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 10, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 11, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 12, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 13, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 14, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 15, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 16, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
{ id: 17, completed: false, title: "Oil stove hinges", description: "They are stiff and screechy", list: { id: 2 } },
],
});
/**
@ -49,3 +78,66 @@ export function showError(message) {
export function clearError() {
store.fatalError = "";
}
/**
* Initialize data store and database.
*
* Must be called by the OAuth login completion handler as soon as session data is available.
*
* If this is the first run, we need to create the logged on user, since he/she is so far known
* only to the OpenID Connect server (Keycloak).
*
* Additionally, we also create the default list here and save everything in the store.
*
* @param session - the session info as returned by the OpenID Connect login.
*/
export async function storeInit(session) {
/* save user session in the store */
store.session = { ...session };
store.userName = session.first_name ? session.first_name : session.username;
/* Create user and default list.
* We cannot run these requests in parallel, since the user record must exist in the database
* before we can create the default list.
*/
const tasks = [];
tasks.push(
gqlQuery(`mutation {
createUser(
username: "${session.username}",
name: "${session.first_name} ${session.last_name}"
) {
username
}
}`));
tasks.push(gqlQuery(`mutation {
createList(
title: "Default",
user: "${session.username}"
) {
title
}
}`));
const allErrors = [];
for (const task of tasks) {
try {
await task;
} catch (e) {
const errStr = e.toString();
if (errStr.indexOf("already exists") === -1) {
/* this is an error */
const e = `Init task failed: ${errStr}`;
console.error(e);
allErrors.push(e);
}
}
}
if (allErrors.length) {
showError(allErrors.join("<br><br>"));
}
}

View File

@ -40,8 +40,26 @@ class GqlError extends Error {
} else if (error instanceof String) {
errorMessages.push(error);
} else {
/* assume this is a GraphQL server response (JSON) */
} else if ('errors' in error) {
/** assume this is a GraphQL server response (JSON) of the following form:
* {
* "errors": [
* {
* "message": "Cannot query field \"__typenam\" on type \"Query\".",
* "locations": [
* {
* "line": 1,
* "column": 2
* }
* ],
* "extensions": {
* "code": "GRAPHQL_VALIDATION_FAILED",
* "stacktrace": []
* }
* }
* ]
* }
*/
try {
for (const e of error.errors) {
let s = e.message;
@ -57,9 +75,12 @@ class GqlError extends Error {
errorMessages.push(s);
}
} catch(e) {
errorMessages.push("Failed to interpret error: " + e.toString());
errorMessages.push("GraphQL error response of unknown format " + e.toString());
errorMessages.push(error.toString());
}
} else {
/* ubnknown error format */
errorMessages.push(error.toString());
}
/* concat errors into a single message for the parent classes */
@ -71,20 +92,6 @@ class GqlError extends Error {
this.name = "GqlError";
}
/**
* Check if this error is an authentication error (401).
*/
is401() {
/* check if one of the server errors has a 401 extension */
for (const error of this.errors) {
if (error.extensions && error.extensions.code.toString() === "401") {
return true;
}
}
return false;
}
}
/**
@ -111,7 +118,7 @@ export function gqlQuery(query)
fetchOpt.headers["Authorization"] = `Bearer ${store.session.token}`;
}
console.info(fetchOpt);
console.info(`Sending request:\n${query}`);
return fetch(`${store.baseboxHost}/graphql`, fetchOpt).then(
/* fetch success */
@ -140,9 +147,19 @@ export function gqlQuery(query)
return new Promise((resolve, reject) => reject("Unauthorized"));
}
return new Promise((resolve, reject) => {
reject(new GqlError(response.statusText));
});
/* try to get JSON error object */
try {
const json = await response.json();
return new Promise((resolve, reject) => {
reject(new GqlError(json));
});
} catch (e) {
/* no JSON in response, fall through */
return new Promise((resolve, reject) => {
reject(new GqlError(response.statusText));
});
}
}
},

View File

@ -6,7 +6,7 @@
*/
import {store} from "../store";
import {gqlQuery} from "./net";
import {storeInit} from "../store";
/**
* Handle OAuth callback and complete login process.
@ -52,30 +52,7 @@ export async function oauthCallbackHandler(queryString) {
throw new Error("Failed to get session data: " + response.statusText);
}
/* Store session data */
/* Store session data and initialize data store. */
const rspJson = await response.json();
store.session = { ...rspJson };
store.userName = store.session.first_name ? store.session.first_name : store.session.username;
await storeInit(rspJson);
}
/**
* Create the user on the broker/todo database.
*
* Since user management is done by Keycloak, which uses its own user database, we must
* make sure that out user is known in the app database (basebox broker). After successful
* login, this function must be called to do that.
*
* @throws Error if the user creation failed.
*/
export async function createUser() {
await gqlQuery(`mutation {
createUser(
username: "${store.session.username}",
name: "${store.session.first_name} ${store.session.last_name}"
) {
username
}
}`);
}

View File

@ -10,11 +10,5 @@ import TheAbout from '../components/TheAbout.vue'
</template>
<style>
@media (min-width: 1024px) {
.about {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>

View File

@ -8,8 +8,7 @@
<script setup>
import { store } from "../store";
import {loggedIn} from "../store";
import TodoRoot from "../components/TodoRoot.vue";
import FatalError from "../components/FatalError.vue";
import TodoRoot from "../components/Todo.vue";
/**
* Perform a login.
@ -25,7 +24,9 @@ function login() {
<!-- Force user to log in before he/she can see tasks. -->
<div v-if="!loggedIn()" id="login-prompt">
<p>Your are currently not logged in.</p>
<h1>Welcome to basebox' TODO sample app!</h1>
<p class="mt-5">Your are currently not logged in.</p>
<button class="btn btn-primary" @click="login" type="button">Login</button>
</div>
@ -40,14 +41,8 @@ main {
}
#login-prompt {
margin: 5rem 0;
border: 1px solid var(--color-border);
border-radius: .5rem;
padding: 2rem;
margin: 5rem;
text-align: center;
.btn {
margin: 3rem 0 0 0;
}
}
</style>