From 2139f3d9dab12ec4197a25663577c0b9d16fa42a Mon Sep 17 00:00:00 2001
From: Bram Wiepjes <bramw@protonmail.com>
Date: Mon, 15 Jul 2019 20:34:29 +0200
Subject: [PATCH] created base layout, reuseable context and logging off

---
 .../assets/scss/abstracts/_variables.scss     |   7 +
 web-frontend/assets/scss/base/_base.scss      |   4 +
 web-frontend/assets/scss/base/_helpers.scss   |   4 +
 .../assets/scss/components/_context.scss      |   2 +-
 web-frontend/components/.gitkeep              |   0
 web-frontend/components/Context.vue           | 158 ++++++++++
 web-frontend/config/nuxt.config.base.js       |   6 +-
 web-frontend/directives/clickOutside.js       |  19 ++
 web-frontend/directives/moveToBody.js         |  18 ++
 web-frontend/layouts/app.vue                  | 278 ++++++++++++++++++
 web-frontend/layouts/default.vue              |   5 -
 web-frontend/pages/app/index.vue              |   2 +-
 web-frontend/plugins/directives.js            |   7 +
 web-frontend/store/auth.js                    |  21 +-
 web-frontend/store/sidebar.js                 |  24 ++
 web-frontend/utils/dom.js                     |   9 +
 16 files changed, 554 insertions(+), 10 deletions(-)
 delete mode 100644 web-frontend/components/.gitkeep
 create mode 100644 web-frontend/components/Context.vue
 create mode 100644 web-frontend/directives/clickOutside.js
 create mode 100644 web-frontend/directives/moveToBody.js
 create mode 100644 web-frontend/layouts/app.vue
 delete mode 100644 web-frontend/layouts/default.vue
 create mode 100644 web-frontend/plugins/directives.js
 create mode 100644 web-frontend/store/sidebar.js
 create mode 100644 web-frontend/utils/dom.js

diff --git a/web-frontend/assets/scss/abstracts/_variables.scss b/web-frontend/assets/scss/abstracts/_variables.scss
index e1812d75d..01ca4e9ac 100644
--- a/web-frontend/assets/scss/abstracts/_variables.scss
+++ b/web-frontend/assets/scss/abstracts/_variables.scss
@@ -64,3 +64,10 @@ $z-index-layout-col-3: 1;
 $z-index-layout-col-3-1: 5;
 $z-index-layout-col-3-2: 4;
 $z-index-modal: 6;
+
+// The z-index of the context must always be the highest because they can open in a
+// modal.
+$z-index-context: 7;
+
+// normalize overrides
+$base-font-family: $text-font-stack;
diff --git a/web-frontend/assets/scss/base/_base.scss b/web-frontend/assets/scss/base/_base.scss
index 52b9444b5..1501f6d02 100644
--- a/web-frontend/assets/scss/base/_base.scss
+++ b/web-frontend/assets/scss/base/_base.scss
@@ -11,6 +11,10 @@ body {
   background-color: $color-neutral-100;
 }
 
+a {
+  cursor: pointer;
+}
+
 *,
 *::before,
 *::after {
diff --git a/web-frontend/assets/scss/base/_helpers.scss b/web-frontend/assets/scss/base/_helpers.scss
index 8e74fb1e2..878ef3703 100644
--- a/web-frontend/assets/scss/base/_helpers.scss
+++ b/web-frontend/assets/scss/base/_helpers.scss
@@ -8,6 +8,10 @@
   display: none;
 }
 
+.visibility-hidden {
+  visibility: hidden;
+}
+
 .align-right {
   text-align: right;
 }
diff --git a/web-frontend/assets/scss/components/_context.scss b/web-frontend/assets/scss/components/_context.scss
index b3114459b..60d93208d 100644
--- a/web-frontend/assets/scss/components/_context.scss
+++ b/web-frontend/assets/scss/components/_context.scss
@@ -1,6 +1,6 @@
 .context {
   position: absolute;
-  z-index: 1;
+  z-index: $z-index-context;
   white-space: nowrap;
   background-color: $white;
   border-radius: 6px;
diff --git a/web-frontend/components/.gitkeep b/web-frontend/components/.gitkeep
deleted file mode 100644
index e69de29bb..000000000
diff --git a/web-frontend/components/Context.vue b/web-frontend/components/Context.vue
new file mode 100644
index 000000000..f96ec1fdb
--- /dev/null
+++ b/web-frontend/components/Context.vue
@@ -0,0 +1,158 @@
+<template>
+  <div
+    v-move-to-body
+    v-click-outside="hide"
+    class="context"
+    :class="{ 'visibility-hidden': !open }"
+  >
+    <slot></slot>
+  </div>
+</template>
+
+<script>
+import { isElement } from '@/utils/dom'
+
+export default {
+  name: 'Context',
+  data() {
+    return {
+      open: false,
+      opener: null,
+      children: []
+    }
+  },
+  /**
+   * Because we don't want the parent context to close when a user clicks 'outside' that
+   * element and in the child element we need to register the child with their parent to
+   * prevent this.
+   */
+  beforeMount() {
+    let $parent = this.$parent
+    while ($parent !== undefined) {
+      if ($parent.registerContextChild) {
+        $parent.registerContextChild(this.$el)
+        break
+      }
+      $parent = $parent.$parent
+    }
+  },
+  methods: {
+    /**
+     * Toggles the open state of the context menu.
+     *
+     * @param target      The original element element that changed the state of the
+     *                    context, this will be used to calculate the correct position.
+     * @param vertical    Bottom positions the context under the target.
+     *                    Top positions the context above the target.
+     * @param horizontal  Left aligns the context with the left side of the target.
+     *                    Right aligns the context with the right side of the target.
+     * @param offset      The distance between the target element and the context.
+     * @param value       True if context must be shown, false if not and undefine
+     *                    will invert the current state.
+     */
+    toggle(
+      target,
+      vertical = 'bottom',
+      horizontal = 'left',
+      offset = 10,
+      value
+    ) {
+      if (value === undefined) {
+        value = !this.open
+      }
+
+      if (value) {
+        const css = this.calculatePosition(target, vertical, horizontal, offset)
+
+        // Set the calculated positions of the context.
+        for (const key in css) {
+          const value = css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto'
+          this.$el.style[key] = value
+        }
+      }
+
+      // If we store the element who opened the context menu we can exclude the element
+      // when clicked outside of this element.
+      this.opener = value ? target : null
+      this.open = value
+    },
+    hide(event) {
+      // Checks if the click is inside one of our children. In that code the context
+      // must stay open.
+      const isChild = this.children.some(element =>
+        isElement(element, event.target)
+      )
+
+      // Checks if the context is already opened, if the click was not on the opener
+      // because he can trigger the toggle method and if the click was not in one of
+      // our child contexts.
+      if (this.open && !isElement(this.opener, event.target) && !isChild) {
+        this.open = false
+      }
+    },
+    /**
+     * Calculates the absolute position of the context based on the original clicked
+     * element.
+     */
+    calculatePosition(target, vertical, horizontal, offset) {
+      const targetRect = target.getBoundingClientRect()
+      const contextRect = this.$el.getBoundingClientRect()
+      const positions = { top: null, right: null, bottom: null, left: null }
+
+      // Calculate if top, bottom, left and right positions are possible.
+      const canTop = targetRect.top - contextRect.height - offset > 0
+      const canBottom =
+        window.innerHeight - targetRect.bottom - contextRect.height - offset > 0
+      const canRight = targetRect.right - contextRect.width > 0
+      const canLeft =
+        window.innerWidth - targetRect.left - contextRect.width > 0
+
+      // If bottom, top, left or right doesn't fit, but their opposite does we switch to
+      // that.
+      if (vertical === 'bottom' && !canBottom && canTop) {
+        vertical = 'top'
+      }
+
+      if (vertical === 'top' && !canTop) {
+        vertical = 'bottom'
+      }
+
+      if (horizontal === 'left' && !canLeft && canRight) {
+        horizontal = 'right'
+      }
+
+      if (horizontal === 'right' && !canRight) {
+        horizontal = 'left'
+      }
+
+      // Calculate the correct positions for horizontal and vertical values.
+      if (horizontal === 'left') {
+        positions.left = targetRect.left
+      }
+
+      if (horizontal === 'right') {
+        positions.right = window.innerWidth - targetRect.right
+      }
+
+      if (vertical === 'bottom') {
+        positions.top = targetRect.bottom + offset
+      }
+
+      if (vertical === 'top') {
+        positions.bottom = window.innerHeight - targetRect.top + offset
+      }
+
+      return positions
+    },
+    /**
+     * A child context can register itself with the parent to prevent closing of the
+     * parent when clicked inside the child.
+     *
+     * @param element HTMLElement
+     */
+    registerContextChild(element) {
+      this.children.push(element)
+    }
+  }
+}
+</script>
diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js
index 5bdb09e02..f090cdcef 100644
--- a/web-frontend/config/nuxt.config.base.js
+++ b/web-frontend/config/nuxt.config.base.js
@@ -25,7 +25,11 @@ export default {
   /*
    ** Plugins to load before mounting the App
    */
-  plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }],
+  plugins: [
+    { src: '@/plugins/auth.js' },
+    { src: '@/plugins/directives.js' },
+    { src: '@/plugins/vuelidate.js' }
+  ],
 
   /*
    ** Nuxt.js modules
diff --git a/web-frontend/directives/clickOutside.js b/web-frontend/directives/clickOutside.js
new file mode 100644
index 000000000..6d1c7bf3b
--- /dev/null
+++ b/web-frontend/directives/clickOutside.js
@@ -0,0 +1,19 @@
+import { isElement } from '@/utils/dom'
+
+/**
+ * This directive calls a custom method if the user clicks outside of the
+ * element.
+ */
+export default {
+  bind: (el, binding, vnode) => {
+    el.clickOutsideEvent = event => {
+      if (!isElement(el, event.target)) {
+        vnode.context[binding.expression](event)
+      }
+    }
+    document.body.addEventListener('click', el.clickOutsideEvent)
+  },
+  unbind: el => {
+    document.body.removeEventListener('click', el.clickOutsideEvent)
+  }
+}
diff --git a/web-frontend/directives/moveToBody.js b/web-frontend/directives/moveToBody.js
new file mode 100644
index 000000000..c51404d09
--- /dev/null
+++ b/web-frontend/directives/moveToBody.js
@@ -0,0 +1,18 @@
+/**
+ * This directive moves the whole element to the document body so that it can be
+ * positioned over another element.
+ */
+export default {
+  inserted: el => {
+    const body = document.body
+
+    // The element is added as first child in the body so that child contexts
+    // are being shown on top of their parent.
+    body.insertBefore(el, body.firstChild)
+  },
+  unbind: el => {
+    if (el.parentNode) {
+      el.parentNode.removeChild(el)
+    }
+  }
+}
diff --git a/web-frontend/layouts/app.vue b/web-frontend/layouts/app.vue
new file mode 100644
index 000000000..a2c921b46
--- /dev/null
+++ b/web-frontend/layouts/app.vue
@@ -0,0 +1,278 @@
+<template>
+  <div :class="{ 'layout-collapsed': isCollapsed }" class="layout">
+    <div class="layout-col-1 menu">
+      <ul class="menu-items">
+        <li class="menu-item">
+          <nuxt-link :to="{ name: 'app' }" class="menu-link">
+            <i class="fas fa-tachometer-alt"></i>
+            <span class="menu-link-text">Dashboard</span>
+          </nuxt-link>
+        </li>
+        <li class="menu-item">
+          <a href="#" class="menu-link" data-context=".select">
+            <i class="fas fa-layer-group"></i>
+            <span class="menu-link-text">Groups</span>
+          </a>
+          <div class="select hidden">
+            <div class="select-search">
+              <i class="select-search-icon fas fa-search"></i>
+              <input
+                type="text"
+                class="select-search-input"
+                placeholder="Search views"
+              />
+            </div>
+            <ul class="select-items">
+              <li class="select-item active">
+                <a href="#" class="select-item-link">Group name 1</a>
+                <a href="#" class="select-item-options" data-context=".context">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+                <div class="context hidden">
+                  <ul class="context-menu">
+                    <li>
+                      <a href="#">
+                        <i class="context-menu-icon fas fa-fw fa-pen"></i>
+                        Rename group
+                      </a>
+                    </li>
+                    <li>
+                      <a href="#">
+                        <i class="context-menu-icon fas fa-fw fa-trash"></i>
+                        Delete group
+                      </a>
+                    </li>
+                  </ul>
+                </div>
+              </li>
+              <li class="select-item">
+                <a href="#" class="select-item-link">Group name 2</a>
+                <a href="#" class="select-item-options">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+              </li>
+              <li class="select-item">
+                <a href="#" class="select-item-link">Group name 3</a>
+                <a href="#" class="select-item-options">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+              </li>
+              <li class="select-item">
+                <a href="#" class="select-item-link">Group name 4</a>
+                <a href="#" class="select-item-options">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+              </li>
+            </ul>
+            <div class="select-footer">
+              <a href="#" class="select-footer-button">
+                <i class="fas fa-plus"></i>
+                Do something
+              </a>
+            </div>
+          </div>
+        </li>
+      </ul>
+      <ul class="menu-items">
+        <li class="menu-item layout-uncollapse">
+          <a class="menu-link" @click="toggleCollapsed()">
+            <i class="menu-item-icon fas fa-angle-double-right"></i>
+            <span class="menu-link-text">Uncollapse</span>
+          </a>
+        </li>
+        <li class="menu-item">
+          <a
+            class="menu-link menu-user-item"
+            @click="$refs.userContext.toggle($event.target)"
+          >
+            {{ nameAbbreviation }}
+            <span class="menu-link-text">{{ name }}</span>
+          </a>
+          <Context ref="userContext">
+            <div class="context-menu-title">{{ name }}</div>
+            <ul class="context-menu">
+              <li>
+                <a @click="logoff()">
+                  <i class="context-menu-icon fas fa-fw fa-sign-out-alt"></i>
+                  Logoff
+                </a>
+              </li>
+            </ul>
+          </Context>
+        </li>
+      </ul>
+    </div>
+    <div class="layout-col-2 sidebar">
+      <div class="sidebar-content-wrapper">
+        <nav class="sidebar-content">
+          <div class="sidebar-title">
+            <img src="@/static/img/logo.svg" alt="" />
+          </div>
+          <div class="sidebar-group-title">Group name 1</div>
+          <ul class="tree">
+            <li class="tree-item">
+              <div class="tree-action">
+                <a href="#" class="tree-link">
+                  <i class="tree-type fas fa-database"></i>
+                  Vehicles
+                </a>
+                <a href="#" class="tree-options" data-context=".context">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+                <div class="context hidden">
+                  <div class="context-menu-title">Vehicles</div>
+                  <ul class="context-menu">
+                    <li>
+                      <a href="#">
+                        <i class="context-menu-icon fas fa-fw fa-pen"></i>
+                        Rename database
+                      </a>
+                    </li>
+                    <li>
+                      <a href="#">
+                        <i class="context-menu-icon fas fa-fw fa-trash"></i>
+                        Delete table
+                      </a>
+                    </li>
+                  </ul>
+                </div>
+              </div>
+            </li>
+            <li class="tree-item">
+              <div class="tree-action">
+                <a href="#" class="tree-link">
+                  <i class="tree-type fas fa-angle-right"></i>
+                  Map nummer 1
+                </a>
+              </div>
+            </li>
+            <li class="tree-item active">
+              <div class="tree-action">
+                <a href="#" class="tree-link">
+                  <i class="tree-type fas fa-database"></i>
+                  Webshop
+                </a>
+                <a href="#" class="tree-options">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+              </div>
+              <ul class="tree-subs">
+                <li class="tree-sub active">
+                  <a href="#" class="tree-sub-link">Customers</a>
+                  <a href="#" class="tree-options">
+                    <i class="fas fa-ellipsis-v"></i>
+                  </a>
+                </li>
+                <li class="tree-sub">
+                  <a href="#" class="tree-sub-link">Products very long name</a>
+                  <a href="#" class="tree-options">
+                    <i class="fas fa-ellipsis-v"></i>
+                  </a>
+                </li>
+                <li class="tree-sub">
+                  <a href="#" class="tree-sub-link">Categories</a>
+                  <a href="#" class="tree-options">
+                    <i class="fas fa-ellipsis-v"></i>
+                  </a>
+                </li>
+              </ul>
+            </li>
+            <li class="tree-item">
+              <div class="tree-action">
+                <a href="#" class="tree-link">
+                  <i class="tree-type fas fa-angle-down"></i>
+                  Map nummer 1
+                </a>
+              </div>
+              <ul class="tree">
+                <li class="tree-item">
+                  <div class="tree-action">
+                    <a href="#" class="tree-link">
+                      <i class="tree-type fas fa-database"></i>
+                      Vehicles
+                    </a>
+                    <a href="#" class="tree-options">
+                      <i class="fas fa-ellipsis-v"></i>
+                    </a>
+                  </div>
+                </li>
+                <li class="tree-item">
+                  <div class="tree-action">
+                    <a href="#" class="tree-link">
+                      <i class="tree-type fas fa-database"></i>
+                      Something
+                    </a>
+                    <a href="#" class="tree-options">
+                      <i class="fas fa-ellipsis-v"></i>
+                    </a>
+                  </div>
+                </li>
+              </ul>
+            </li>
+            <li class="tree-item">
+              <div class="tree-action">
+                <a href="#" class="tree-link">
+                  <i class="tree-type fas fa-database"></i>
+                  Vehicles with very long name and that is not good.
+                </a>
+                <a href="#" class="tree-options">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+              </div>
+            </li>
+            <li class="tree-item">
+              <div class="tree-action">
+                <a href="#" class="tree-link">
+                  <i class="tree-type fas fa-database"></i>
+                  Something else
+                </a>
+                <a href="#" class="tree-options">
+                  <i class="fas fa-ellipsis-v"></i>
+                </a>
+              </div>
+            </li>
+          </ul>
+        </nav>
+      </div>
+      <div class="sidebar-footer">
+        <a class="sidebar-collapse" @click="toggleCollapsed()">
+          <i class="fas fa-angle-double-left"></i>
+          Collapse sidebar
+        </a>
+      </div>
+    </div>
+    <div class="layout-col-3">
+      <nuxt />
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex'
+
+import Context from '@/components/Context'
+
+export default {
+  layout: 'default',
+  middleware: 'authenticated',
+  components: {
+    Context
+  },
+  computed: {
+    ...mapGetters({
+      isCollapsed: 'sidebar/isCollapsed',
+      name: 'auth/getName',
+      nameAbbreviation: 'auth/getNameAbbreviation'
+    })
+  },
+  methods: {
+    logoff() {
+      this.$store.dispatch('auth/logoff')
+      this.$nuxt.$router.replace({ name: 'login' })
+    },
+    ...mapActions({
+      toggleCollapsed: 'sidebar/toggleCollapsed'
+    })
+  }
+}
+</script>
diff --git a/web-frontend/layouts/default.vue b/web-frontend/layouts/default.vue
deleted file mode 100644
index 984257528..000000000
--- a/web-frontend/layouts/default.vue
+++ /dev/null
@@ -1,5 +0,0 @@
-<template>
-  <div>
-    <nuxt />
-  </div>
-</template>
diff --git a/web-frontend/pages/app/index.vue b/web-frontend/pages/app/index.vue
index d0a7363c8..d3f9e4b01 100644
--- a/web-frontend/pages/app/index.vue
+++ b/web-frontend/pages/app/index.vue
@@ -8,7 +8,7 @@
 import { mapState } from 'vuex'
 
 export default {
-  middleware: 'authenticated',
+  layout: 'app',
   computed: {
     ...mapState({
       user: state => state.auth.user
diff --git a/web-frontend/plugins/directives.js b/web-frontend/plugins/directives.js
new file mode 100644
index 000000000..64a9c8c3f
--- /dev/null
+++ b/web-frontend/plugins/directives.js
@@ -0,0 +1,7 @@
+import Vue from 'vue'
+
+import moveToBody from '@/directives/moveToBody'
+import clickOutside from '@/directives/clickOutside'
+
+Vue.directive('move-to-body', moveToBody)
+Vue.directive('click-outside', clickOutside)
diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js
index 86dca60db..ceb43ab44 100644
--- a/web-frontend/store/auth.js
+++ b/web-frontend/store/auth.js
@@ -49,6 +49,14 @@ export const actions = {
       }
     )
   },
+  /**
+   * Logs off the user by removing the token as a cookie and clearing the user
+   * data.
+   */
+  logoff({ commit }) {
+    unsetToken(this.app.$cookies)
+    commit('CLEAR_USER_DATA')
+  },
   /**
    * Refresh the existing token. If successful commit the new token and start a
    * new refresh timeout. If unsuccessful the existing cookie and user data is
@@ -100,10 +108,19 @@ export const getters = {
   token(state) {
     return state.token
   },
+  getName(state) {
+    return state.user ? state.user.first_name : ''
+  },
+  getNameAbbreviation(state) {
+    return state.user ? state.user.first_name.split('')[0] : ''
+  },
+  getEmail(state) {
+    return state.user ? state.user.email : ''
+  },
   /**
    * Returns the amount of seconds it will take before the tokes expires.
-   * @TODO figure out what happens if the browser and server time and not very
-   *       much in sync.
+   * @TODO figure out what happens if the browser and server time are not in
+   *       sync.
    */
   tokenExpireSeconds(state) {
     const now = Math.ceil(new Date().getTime() / 1000)
diff --git a/web-frontend/store/sidebar.js b/web-frontend/store/sidebar.js
new file mode 100644
index 000000000..0f3a33f8a
--- /dev/null
+++ b/web-frontend/store/sidebar.js
@@ -0,0 +1,24 @@
+export const state = () => ({
+  collapsed: false
+})
+
+export const mutations = {
+  SET_COLLAPSED(state, collapsed) {
+    state.collapsed = collapsed
+  }
+}
+
+export const actions = {
+  toggleCollapsed({ commit, getters }, value) {
+    if (value === undefined) {
+      value = !getters.isCollapsed
+    }
+    commit('SET_COLLAPSED', value)
+  }
+}
+
+export const getters = {
+  isCollapsed(state) {
+    return !!state.collapsed
+  }
+}
diff --git a/web-frontend/utils/dom.js b/web-frontend/utils/dom.js
new file mode 100644
index 000000000..882fdc2da
--- /dev/null
+++ b/web-frontend/utils/dom.js
@@ -0,0 +1,9 @@
+/**
+ * Checks if the target is the same as the provided element of that the element
+ * contains the target. Returns true is this is the case.
+ *
+ * @returns boolean
+ */
+export const isElement = (element, target) => {
+  return element === target || element.contains(target)
+}