ui changes

ui changes

<template>

  <div v-el:videowrapperwrapper class="videowrapperwrapper">
    <loading-spinner v-if="loading"></loading-spinner>
    <div v-el:videowrapper v-show="!loading" class="videowrapper">
      <video v-el:video class="video-js vjs-default-skin" @seeking="handleSeek" @timeupdate="updateTime">
        <template v-for="video in videoSources">
          <source :src="video.storage_url" :type="'video/' + video.extension">
        </template>
        <template v-for="track in trackSources">
          <track kind="captions" :src="track.storage_url" :srclang="track.lang" :label="getLangName(track.lang)">
        </template>
      </video>
    </div>
  </div>

</template>

<script>

  const videojs = require('video.js');
  const langcodes = require('./langcodes.json');
  const customButtons = require('./videojs-replay-forward-btns');
  const debounce = require('vue').util.debounce;

  module.exports = {

    $trNameSpace: 'videoRender',

    $trs: {
      replay: 'Replay',
      forward: 'Forward',
    },

    props: ['files'],

    data: () => ({
      videoWidth: 0,
      videoHeight: 0,
      dummyTime: 0,
      progressStartingPoint: 0,
      lastUpdateTime: 0,
      loading: true,
    }),

    computed: {
      posterSource() {
        const posterFileExtensions = ['png', 'jpg'];
        const posterArray = this.files.filter(
          (file) => posterFileExtensions.some((ext) => ext === file.extension)
        );
        if (posterArray.length === 0) {
          return '';
        }
        return posterArray[0].storage_url;
      },

      videoSources() {
        const videoFileExtensions = ['mp4', 'webm', 'ogg'];
        return this.files.filter(
          (file) => videoFileExtensions.some((ext) => ext === file.extension)
        );
      },

      trackSources() {
        const trackFileExtensions = ['vtt'];
        return this.files.filter(
          (file) => trackFileExtensions.some((ext) => ext === file.extension)
        );
      },

      aspectRatio() {
        return this.videoWidth / this.videoHeight;
      },

    },

    methods: {
      getLangName(langCode) {
        return langcodes.filter(
            (lang) => lang.code === langCode
        )[0].lang;
      },

      setPlayState(state) {
        this.recordProgress();
        if (state === true) {
          this.$emit('startTracking');
        } else {
          this.$emit('stopTracking');
        }
      },

      loadedMetaData() {
        this.videoWidth = this.videoPlayer.videoWidth();
        this.videoHeight = this.videoPlayer.videoHeight();
        this.resizeVideo();
        this.videoPlayerIsReady();
        this.loading = false;
      },

      resizeVideo() {
        const wrapperWrapperWidth = this.$els.videowrapperwrapper.clientWidth;
        const wrapperWrapperHeight = this.$els.videowrapperwrapper.clientHeight;

        const neededHeightGivenWidth = wrapperWrapperWidth * (1 / this.aspectRatio);
        const neededWidthGivenHeight = wrapperWrapperHeight * this.aspectRatio;

        let newWidth = 0;
        let newHeight = 0;

        if (neededHeightGivenWidth <= wrapperWrapperHeight) {
          newWidth = wrapperWrapperWidth;
          newHeight = neededHeightGivenWidth;
        } else {
          newWidth = neededWidthGivenHeight;
          newHeight = wrapperWrapperHeight;
        }

        this.$els.videowrapper.setAttribute('style', `width:${newWidth}px;height:${newHeight}px`);
      },

      get debouncedResizeVideo() {
        return debounce(this.resizeVideo, 300);
      },

      videoPlayerIsReady() {
        videojs(this.$els.video).on('play', () => {
          this.setPlayState(true);
        });

        videojs(this.$els.video).on('pause', () => {
          this.setPlayState(false);
        });

        videojs(this.$els.video).on('ended', () => {
          this.setPlayState(false);
        });
      },

      updateTime() {
        this.dummyTime = this.videoPlayer.currentTime();
        if (this.dummyTime - this.lastUpdateTime >= 5) {
          this.recordProgress();
          this.lastUpdateTime = this.dummyTime;
        }
      },

      /* Catches when a user jumps around/skips while playing the video */
      handleSeek() {
        /* Record any progress up to this point */
        this.recordProgress();
        /* Set last check to be where player is at now */
        this.dummyTime = this.videoPlayer.currentTime();
        this.lastUpdateTime = this.dummyTime;
      },

      recordProgress() {
        this.$emit('progressUpdate', Math.max(0,
          (this.dummyTime - this.progressStartingPoint) /
          Math.floor(this.videoPlayer.duration())));
        this.progressStartingPoint = this.videoPlayer.currentTime();
      },
    },

    created() {
      customButtons.ReplayButton.prototype.controlText_ = this.$tr('replay');
      customButtons.ForwardButton.prototype.controlText_ = this.$tr('forward');
      videojs.registerComponent('ReplayButton', customButtons.ReplayButton);
      videojs.registerComponent('ForwardButton', customButtons.ForwardButton);
    },

    ready() {
      this.videoPlayer = videojs(this.$els.video, {
        fluid: true,
        autoplay: false,
        controls: true,
        textTrackDisplay: true,
        bigPlayButton: true,
        inactivityTimeout: 1000,
        preload: 'metadata',
        // poster: this.posterSource,
        playbackRates: [0.5, 1.0, 1.25, 1.5, 2.0],
        controlBar: {
          children: [
            { name: 'progressControl' },
            { name: 'currentTimeDisplay' },
            { name: 'durationDisplay' },
            {
              name: 'VolumeMenuButton',
              inline: false,
              vertical: true,
            },
            { name: 'playbackRateMenuButton' },
            { name: 'ReplayButton' },
            { name: 'playToggle' },
            { name: 'ForwardButton' },
            { name: 'captionsButton' },
            { name: 'fullscreenToggle' },
          ],
        },
      });

      this.videoPlayer.on('loadedmetadata', this.loadedMetaData);
      global.addEventListener('resize', this.debouncedResizeVideo);
    },
    beforeDestroy() {
      this.recordProgress();
      this.$emit('stopTracking');
      global.removeEventListener('resize', this.debouncedResizeVideo);
      this.videoPlayer.dispose();
    },
  };

</script>

<style lang="stylus">

  // Default videojs stylesheet
  // Unable to reference the videojs using require since videojs doesn't have good webpack support
  @import '../../../node_modules/video.js/dist/video-js.css'

  $video-player-color = black
  $video-player-accent-color = indigo

  .videowrapperwrapper
    width: 100%
    height: 100%
    background-color: rgba(0, 0, 0, 0)
    position: relative

  .videowrapper
    top: 50%
    left: 50%
    transform: translate(-50%, -50%)
    position: relative
    height: 100%
    background-color: black

  .video-js
    font-size: 1em

  .video-js
    .vjs-control-bar,
    .vjs-menu-button .vjs-menu-content
      font-size: 1em

  .video-js
    color: white

    .vjs-button
      width: 48px
      height: 48px

    &:hover
      .vjs-big-play-button
        background-color: $video-player-color

    .vjs-big-play-button
      position: absolute
      top: 50%
      left: 50%
      transform: translate(-50%, -50%)
      height: 2.5em
      width: 2.5em
      font-size: 2.5em
      line-height: 2.5em
      border-radius: 50%
      border: none
      background-color: $video-player-color

    .vjs-slider
      background-color: grey

    .vjs-progress-control
      position: absolute
      left: 0
      right: 0
      width: auto
      height: 2em
      top: -1em
      visibility: inherit
      opacity: inherit

    .vjs-progress-holder
      margin: 0

    .vjs-load-progress
      background: darkgrey

    .vjs-play-progress
      background-color: $video-player-accent-color

    .vjs-progress-control
      &:hover
        .vjs-progress-holder
          font-size: 1em

    .vjs-control-bar,
    .vjs-menu-button .vjs-menu-content
      background-color: $video-player-color

    .vjs-control-bar
      justify-content: center

    .vjs-current-time,
    .vjs-duration
      position: absolute
      visibility: inherit
      opacity: inherit
      top: 0
      height: 0.5em

    .vjs-current-time
      display: block
      left: 0
      width: 48px
      height: 48px

    .vjs-duration
      display: block
      right: 0

    .vjs-menu
      li
        &:focus,
        &:hover
          background-color: grey

    .vjs-volume-menu-button
      .vjs-slider
        background-color: grey

    .videoreplay
      background: url('../icons/replay.svg')
      background-repeat: no-repeat
      background-position: center
      background-size: 45%
      bottom: 0

    .videoforward
      background: url('../icons/forward.svg')
      background-repeat: no-repeat
      background-position: center
      background-size: 45%
      bottom: 0

    .vjs-captions-button
      display: block

</style>

<template>

  <div v-el:videowrapperwrapper class="videowrapperwrapper">
    <loading-spinner v-if="loading"></loading-spinner>
    <div v-el:videowrapper v-show="!loading" class="videowrapper">
      <video v-el:video class="video-js vjs-default-skin" @seeking="handleSeek" @timeupdate="updateTime">
        <template v-for="video in videoSources">
          <source :src="video.storage_url" :type="'video/' + video.extension">
        </template>
        <template v-for="track in trackSources">
          <track kind="captions" :src="track.storage_url" :srclang="track.lang" :label="getLangName(track.lang)">
        </template>
      </video>
    </div>
  </div>

</template>

<script>

  const videojs = require('video.js');
  const langcodes = require('./langcodes.json');
  const customButtons = require('./videojs-replay-forward-btns');
  const debounce = require('vue').util.debounce;

  module.exports = {

    $trNameSpace: 'videoRender',

    $trs: {
      replay: 'Replay',
      forward: 'Forward',
    },

    props: ['files'],

    data: () => ({
      videoWidth: 0,
      videoHeight: 0,
      dummyTime: 0,
      progressStartingPoint: 0,
      lastUpdateTime: 0,
      loading: true,
    }),

    computed: {
      posterSource() {
        const posterFileExtensions = ['png', 'jpg'];
        const posterArray = this.files.filter(
          (file) => posterFileExtensions.some((ext) => ext === file.extension)
        );
        if (posterArray.length === 0) {
          return '';
        }
        return posterArray[0].storage_url;
      },

      videoSources() {
        const videoFileExtensions = ['mp4', 'webm', 'ogg'];
        return this.files.filter(
          (file) => videoFileExtensions.some((ext) => ext === file.extension)
        );
      },

      trackSources() {
        const trackFileExtensions = ['vtt'];
        return this.files.filter(
          (file) => trackFileExtensions.some((ext) => ext === file.extension)
        );
      },

      aspectRatio() {
        return this.videoWidth / this.videoHeight;
      },

    },

    methods: {
      getLangName(langCode) {
        return langcodes.filter(
            (lang) => lang.code === langCode
        )[0].lang;
      },

      setPlayState(state) {
        this.recordProgress();
        if (state === true) {
          this.$emit('startTracking');
        } else {
          this.$emit('stopTracking');
        }
      },

      loadedMetaData() {
        this.videoWidth = this.videoPlayer.videoWidth();
        this.videoHeight = this.videoPlayer.videoHeight();
        this.resizeVideo();
        this.videoPlayerIsReady();
        this.loading = false;
      },

      resizeVideo() {
        const wrapperWrapperWidth = this.$els.videowrapperwrapper.clientWidth;
        const wrapperWrapperHeight = this.$els.videowrapperwrapper.clientHeight;

        const neededHeightGivenWidth = wrapperWrapperWidth * (1 / this.aspectRatio);
        const neededWidthGivenHeight = wrapperWrapperHeight * this.aspectRatio;

        let newWidth = 0;
        let newHeight = 0;

        if (neededHeightGivenWidth <= wrapperWrapperHeight) {
          newWidth = wrapperWrapperWidth;
          newHeight = neededHeightGivenWidth;
        } else {
          newWidth = neededWidthGivenHeight;
          newHeight = wrapperWrapperHeight;
        }

        this.$els.videowrapper.setAttribute('style', `width:${newWidth}px;height:${newHeight}px`);
      },

      get debouncedResizeVideo() {
        return debounce(this.resizeVideo, 300);
      },

      videoPlayerIsReady() {
        videojs(this.$els.video).on('play', () => {
          this.setPlayState(true);
        });

        videojs(this.$els.video).on('pause', () => {
          this.setPlayState(false);
        });

        videojs(this.$els.video).on('ended', () => {
          this.setPlayState(false);
        });
      },

      updateTime() {
        this.dummyTime = this.videoPlayer.currentTime();
        if (this.dummyTime - this.lastUpdateTime >= 5) {
          this.recordProgress();
          this.lastUpdateTime = this.dummyTime;
        }
      },

      /* Catches when a user jumps around/skips while playing the video */
      handleSeek() {
        /* Record any progress up to this point */
        this.recordProgress();
        /* Set last check to be where player is at now */
        this.dummyTime = this.videoPlayer.currentTime();
        this.lastUpdateTime = this.dummyTime;
      },

      recordProgress() {
        this.$emit('progressUpdate', Math.max(0,
          (this.dummyTime - this.progressStartingPoint) /
          Math.floor(this.videoPlayer.duration())));
        this.progressStartingPoint = this.videoPlayer.currentTime();
      },
    },

    created() {
      customButtons.ReplayButton.prototype.controlText_ = this.$tr('replay');
      customButtons.ForwardButton.prototype.controlText_ = this.$tr('forward');
      videojs.registerComponent('ReplayButton', customButtons.ReplayButton);
      videojs.registerComponent('ForwardButton', customButtons.ForwardButton);
    },

    ready() {
      this.videoPlayer = videojs(this.$els.video, {
        fluid: true,
        autoplay: false,
        controls: true,
        textTrackDisplay: true,
        bigPlayButton: true,
        inactivityTimeout: 1000,
        preload: 'metadata',
        // poster: this.posterSource,
        playbackRates: [0.5, 1.0, 1.25, 1.5, 2.0],
        controlBar: {
          children: [
            { name: 'progressControl' },
            { name: 'currentTimeDisplay' },
            { name: 'durationDisplay' },
            {
              name: 'VolumeMenuButton',
              inline: false,
              vertical: true,
            },
            { name: 'playbackRateMenuButton' },
            { name: 'ReplayButton' },
            { name: 'playToggle' },
            { name: 'ForwardButton' },
            { name: 'captionsButton' },
            { name: 'fullscreenToggle' },
          ],
        },
      });

      this.videoPlayer.on('loadedmetadata', this.loadedMetaData);
      global.addEventListener('resize', this.debouncedResizeVideo);
    },
    beforeDestroy() {
      this.recordProgress();
      this.$emit('stopTracking');
      global.removeEventListener('resize', this.debouncedResizeVideo);
      this.videoPlayer.dispose();
    },
  };

</script>

<style lang="stylus">

  // Default videojs stylesheet
  // Unable to reference the videojs using require since videojs doesn't have good webpack support
  @import '../../../node_modules/video.js/dist/video-js.css'

  $video-player-color = black
  $video-player-accent-color = indigo

  .videowrapperwrapper
    width: 100%
    height: 100%
    background-color: rgba(0, 0, 0, 0)
    position: relative

  .videowrapper
    top: 50%
    left: 50%
    transform: translate(-50%, -50%)
    position: relative
    height: 100%
    background-color: black

  @media screen and (max-width: 840px)
    .video-js
      font-size: 0.8em

  @media screen and (min-width: 841px)
    .video-js
      font-size: 1em

  @media screen and (max-width: 841px)
    .video-js
      .vjs-control-bar,
      .vjs-menu-button .vjs-menu-content
        font-size: 0.8em

  @media screen and (min-width: 841px)
    .video-js
      .vjs-control-bar,
      .vjs-menu-button .vjs-menu-content
        font-size: 1em

  .video-js
    color: white

    &:hover
      .vjs-big-play-button
        background-color: $video-player-color

    .vjs-big-play-button
      position: absolute
      top: 50%
      left: 50%
      transform: translate(-50%, -50%)
      height: 2.5em
      width: 2.5em
      font-size: 2.5em
      line-height: 2.5em
      border-radius: 50%
      border: none
      background-color: $video-player-color

    .vjs-slider
      background-color: grey

    .vjs-progress-control
      position: absolute
      left: 0
      right: 0
      width: auto
      height: 2em
      top: -1em
      visibility: inherit
      opacity: inherit

    .vjs-progress-holder
      margin: 0

    .vjs-load-progress
      background: darkgrey

    .vjs-play-progress,
      background-color: $video-player-accent-color

    .vjs-progress-control
      &:hover
        .vjs-progress-holder
          font-size: 1em

    .vjs-control-bar,
    .vjs-menu-button .vjs-menu-content
      background-color: $video-player-color

    .vjs-control-bar
      justify-content: center

    .vjs-current-time,
    .vjs-duration,
    .vjs-volume-menu-button,
    .vjs-playback-rate,
    .vjs-fullscreen-control
      position: absolute
      visibility: inherit
      opacity: inherit
      bottom: 0

    .vjs-current-time
      display: block
      left: 0
      width: 48px
      height: 48px

    .vjs-duration
      display: block
      right: 0

    .vjs-menu
      li
        &:focus,
        &:hover
          background-color: grey

    .vjs-volume-menu-button
      right: 6em
      .vjs-slider
        background-color: grey

    .vjs-playback-rate
      right: 3em

    .vjs-fullscreen-control
      right: 0

    .videoreplay
      background: url('../icons/replay.svg')
      background-repeat: no-repeat
      background-position: center
      background-size: 45%
      bottom: 0

    .videoforward
      background: url('../icons/forward.svg')
      background-repeat: no-repeat
      background-position: center
      background-size: 45%
      bottom: 0

    .vjs-button
      width: 48px
      height: 48px

    .vjs-control-bar
      height: 60px

</style>


Updated included mixin name in component.

<template>

Are you sure to remove {{ articleToRemove.slug }}?

    <button
      class="popup__button"
      @click.prevent="removeArticle"
    >
      Yes
    </button>
    <button
      class="popup__button"
      @click.prevent="dismissPopup"
    >
      No
    </button>

</template>


Add pagination

Add pagination

<template>
  <div id="characters">
    <vue-headful :title="title" />

    <v-breadcrumbs :items="breadcrumbs">
      <v-icon slot="divider">chevron_right</v-icon>
    </v-breadcrumbs>

    <template v-if="loading">
      <v-progress-linear :indeterminate="true"></v-progress-linear>
    </template>

    <template v-if="loaded">
      <v-pagination v-model="current_page" :length="total_pages"></v-pagination>

      <v-card>
        <v-container fluid grid-list-lg>
          <v-layout row wrap>
            <character-item v-for="character in characters"
                            v-bind:key="character.id"
                            v-bind="character">
            </character-item>
          </v-layout>
        </v-container>
      </v-card>

      <v-pagination v-model="current_page" :length="total_pages"></v-pagination>
    </template>

    <template v-if="error">
      We're sorry, but something went wrong. Please, try again later.
    </template>
  </div>
</template>

<script>
  import { mapActions } from 'vuex';
  import CharacterItem from './character-item.vue';

  export default {
    data () {
      return {
        title: 'Characters | EveMonk: EveOnline management suite',
        loading: true,
        loaded: false,
        error: false,
        characters: [],
        current_page: 1,
        total_count: null,
        total_pages: null,
        breadcrumbs: [
          {
            text: 'Home',
            to: { name: 'welcome' },
            exact: true
          },
          {
            text: 'Characters',
            to: { name: 'universe_characters' },
            exact: true,
            disabled: true
          }
        ]
      }
    },

    components: {
      'character-item': CharacterItem,
    },

    created () {
      let page = this.$route.query.page;

      if (page !== undefined) {
        this.current_page = parseInt(page);
      }

      this.fetchUniverseCharacters(this.current_page).then(response => {
        if (response.status === 200) {
          this.total_count = response.data.total_count;
          this.total_pages = response.data.total_pages;
          this.characters = response.data.characters;
          this.loaded = true;
          this.loading = false;
        } else {
          this.loading = false;
          this.loaded = false;
          this.error = true;
        }
      });
    },

    watch: {
      current_page: function (page) {
        this.loaded = false;
        this.loading = true;

        this.fetchUniverseCharacters(page).then(response => {
          if (response.status === 200) {
            this.total_count = response.data.total_count;
            this.total_pages = response.data.total_pages;
            this.characters = response.data.characters;

            this.loaded = true;
            this.loading = false;

            this.$router.push({ name: 'universe_characters', query: { page: page } });
          }
        });
      },
    },

    methods: {
      ...mapActions([
        'fetchUniverseCharacters'
      ])
    }
  }
</script>

<template>
  <div id="characters">
    <vue-headful :title="title" />

    <v-breadcrumbs :items="breadcrumbs">
      <v-icon slot="divider">chevron_right</v-icon>
    </v-breadcrumbs>

    <template v-if="loading">
      <v-progress-linear :indeterminate="true"></v-progress-linear>
    </template>

    <template v-if="loaded">
      <v-pagination v-model="current_page" :length="total_pages"></v-pagination>

      <v-card>
        <v-container fluid grid-list-lg>
          <v-layout row wrap>
            <character-item v-for="character in characters"
                            v-bind:key="character.id"
                            v-bind="character">
            </character-item>
          </v-layout>
        </v-container>
      </v-card>

      <v-pagination v-model="current_page" :length="total_pages"></v-pagination>
    </template>

    <template v-if="error">
      We're sorry, but something went wrong. Please, try again later.
    </template>
  </div>
</template>

<script>
  import { mapActions } from 'vuex';
  import CharacterItem from './character-item.vue';

  export default {
    data () {
      return {
        title: 'Characters | EveMonk: EveOnline management suite',
        loading: true,
        loaded: false,
        error: false,
        characters: [],
        current_page: 1,
        total_count: null,
        total_pages: null,
        breadcrumbs: [
          {
            text: 'Home',
            to: { name: 'welcome' },
            exact: true
          },
          {
            text: 'Characters',
            to: { name: 'universe_characters' },
            exact: true,
            disabled: true
          }
        ]
      }
    },

    components: {
      'character-item': CharacterItem,
    },

    created () {
      let page = this.$route.query.page;

      if (page !== undefined) {
        this.current_page = parseInt(page);
      }

      this.fetchUniverseCharacters(this.current_page).then(response => {
        if (response.status === 200) {
          this.total_count = response.data.total_count;
          this.total_pages = response.data.total_pages;
          this.characters = response.data.characters;
          this.loaded = true;
          this.loading = false;
        } else {
          this.loading = false;
          this.loaded = false;
          this.error = true;
        }
      });
    },

    methods: {
      ...mapActions([
        'fetchUniverseCharacters'
      ])
    }
  }
</script>


Start refactor EditorSelection (WIP)

Start refactor EditorSelection (WIP)

<template>
  <v-card>
    <v-overlay v-if="modelInstances.length" :absolute="true" :opacity="0.85">
      <v-icon x-large>mdi-folder-lock</v-icon>

        Locked because there are model instances

    </v-overlay>

    <v-card-title>
      Edit project {{ gdslProject.name }}
    </v-card-title>

    <v-divider class="ma-0"></v-divider>

    <v-card-text>
      <v-stepper v-model="currentStep">
        <v-stepper-header>
          <v-stepper-step :complete="currentStep > 1" step="1">Concept
Editor</v-stepper-step>
          <v-divider></v-divider>
          <v-stepper-step :complete="currentStep > 2" step="2">Shape</v-stepper-step>
          <v-divider></v-divider>
          <v-stepper-step :complete="currentStep > 3" step="3">Style</v-stepper-step>
          <v-divider></v-divider>
          <v-stepper-step :complete="currentStep === 4" step="4">Diagram</v-stepper-step>
        </v-stepper-header>
      </v-stepper>

      <v-dialog v-model="editProjectDialog" fullscreen hide-overlay transition="dialog-bottom-transition">
        <template v-slot:activator="{ on, attrs }">

            <v-btn v-on:click="showStepElement(currentStep)" class="mt-4" color="primary" depressed v-bind="attrs"
                   v-on="on">
              Edit project
            </v-btn>

        </template>

        <v-card>
          <v-toolbar dark color="primary" class="rounded-0">
            <v-btn icon dark @click="editProjectDialog = false">
              <v-icon>mdi-close</v-icon>
            </v-btn>
            <v-toolbar-title>Edit project</v-toolbar-title>
            <v-spacer></v-spacer>

            <v-toolbar-items>
              <v-stepper v-model="currentStep" class="elevation-0 rounded-0">
                <v-stepper-header>
                  <v-stepper-step @click="showStepElement(currentStep=1)" :complete="conceptStepCompleted" step="1">
                    {{ step1 }}
                  </v-stepper-step>
                  <v-divider></v-divider>
                  <v-stepper-step @click="showStepElement(currentStep=2)" :complete="shapeStepCompleted" step="2">
                    {{ step2 }}
                  </v-stepper-step>
                  <v-divider></v-divider>
                  <v-stepper-step @click="showStepElement(currentStep=3)" :complete="styleStepCompleted" step="3">
                    {{ step3 }}
                  </v-stepper-step>
                  <v-divider></v-divider>
                  <v-stepper-step @click="showStepElement(currentStep=4)" :complete="diagramStepCompleted" step="4">
                    {{ step4 }}
                  </v-stepper-step>
                </v-stepper-header>
              </v-stepper>

              <v-btn :disabled="continueBtnIsHidden" text
                     v-on:click="initializeEditor(), currentStep++, showStepElement(currentStep)">Continue
              </v-btn>
            </v-toolbar-items>
          </v-toolbar>

          <GraphEditor v-if="!step1IsHidden" ref="GraphComponent"></GraphEditor>

          <v-container fluid class="py-0">
            <v-row v-show="step1IsHidden">
              <v-col md="3">

              </v-col>

              <v-col md="3" class="code-editor" :data-meta-model-id="gdslProject.id" :data-dsl-type="step2">
                <v-card>
                  <v-card-title>
                    {{ step2 }}
                    <v-spacer></v-spacer>

                      <v-chip class="js-save-successful" style="display: none" color="green" text-color="white">
                        Saving succeed
                      </v-chip>
                      <v-chip class="js-save-failed" style="display: none" color="red" text-color="white">
                        Saving failed
                      </v-chip>
                      <v-btn class="js-save" icon color="primary">
                        <v-icon>mdi-content-save</v-icon>
                      </v-btn>

                  </v-card-title>

                  <v-card-text>

                  </v-card-text>
                </v-card>
              </v-col>

              <v-col md="3" class="code-editor" :data-meta-model-id="gdslProject.id" :data-dsl-type="step3">
                <v-card>
                  <v-card-title>
                    {{ step3 }}
                    <v-spacer></v-spacer>

                      <v-chip class="js-save-successful" style="display: none" color="green" text-color="white">
                        Saving succeed
                      </v-chip>
                      <v-chip class="js-save-failed" style="display: none" color="red" text-color="white">
                        Saving failed
                      </v-chip>
                      <v-btn :disabled="step3IsHidden" class="js-save" icon color="primary">
                        <v-icon>mdi-content-save</v-icon>
                      </v-btn>

                  </v-card-title>

                  <v-card-text>

                  </v-card-text>
                </v-card>
              </v-col>

              <v-col md="3" class="code-editor" :data-meta-model-id="gdslProject.id" :data-dsl-type="step4">
                <v-card>
                  <v-card-title>
                    {{ step4 }}
                    <v-spacer></v-spacer>

                      <v-chip class="js-save-successful" style="display: none" color="green" text-color="white">
                        Saving succeed
                      </v-chip>
                      <v-chip class="js-save-failed" style="display: none" color="red" text-color="white">
                        Saving failed
                      </v-chip>
                      <v-btn :disabled="step4IsHidden" class="js-save" icon color="primary">
                        <v-icon>mdi-content-save</v-icon>
                      </v-btn>

                  </v-card-title>

                  <v-card-text>

                  </v-card-text>
                </v-card>
              </v-col>
            </v-row>
          </v-container>
        </v-card>
      </v-dialog>
    </v-card-text>

    <v-divider class="ma-0"></v-divider>

    <v-card-actions>
      <v-menu offset-y>
        <template v-slot:activator="{ on }">
          <v-btn color="secondary" outlined v-on="on">
            Validator
            <v-icon class="ml-1">mdi-chevron-down</v-icon>
          </v-btn>
        </template>
        <v-list>
          <v-list-item v-on:click="validatorGenerate" id="validatorGenerate">
            <v-list-item-title>Generate / Update Validator</v-list-item-title>
          </v-list-item>
          <v-list-item v-on:click="validatorShow" id="validatorShow">
            <v-list-item-title>Show Validation Rules</v-list-item-title>
          </v-list-item>
        </v-list>
      </v-menu>
    </v-card-actions>
  </v-card>
</template>

<template>
  <v-card>
    <v-overlay v-if="modelInstances.length" :absolute="true" :opacity="0.85">
      <v-icon x-large>mdi-folder-lock</v-icon>

        Locked because there are model instances

    </v-overlay>

    <v-card-title>
      Edit project {{ gdslProject.name }}
    </v-card-title>

    <v-divider class="ma-0"></v-divider>

    <v-card-text>
      <v-stepper v-model="stepCounter">
        <v-stepper-header>
          <v-stepper-step :complete="stepCounter > 1" step="1">Concept
Editor</v-stepper-step>
          <v-divider></v-divider>
          <v-stepper-step :complete="stepCounter > 2" step="2">Shape</v-stepper-step>
          <v-divider></v-divider>
          <v-stepper-step :complete="stepCounter > 3" step="3">Style</v-stepper-step>
          <v-divider></v-divider>
          <v-stepper-step :complete="stepCounter === 4" step="4">Diagram</v-stepper-step>
        </v-stepper-header>
      </v-stepper>

      <v-dialog v-model="editProjectDialog" fullscreen hide-overlay transition="dialog-bottom-transition">
        <template v-slot:activator="{ on, attrs }">

            <v-btn v-on:click="showStepElement(stepCounter)" class="mt-4" color="primary" depressed v-bind="attrs"
                   v-on="on">
              Edit project
            </v-btn>

        </template>

        <v-card>
          <v-toolbar dark color="primary" class="rounded-0">
            <v-btn icon dark @click="editProjectDialog = false">
              <v-icon>mdi-close</v-icon>
            </v-btn>
            <v-toolbar-title>Edit project</v-toolbar-title>
            <v-spacer></v-spacer>

            <v-toolbar-items>
              <v-stepper v-model="stepCounter" class="elevation-0 rounded-0">
                <v-stepper-header>
                  <v-stepper-step @click="showStepElement(stepCounter=1)" :complete="stepCounter > 1" step="1">
                    {{ step1 }}
                  </v-stepper-step>
                  <v-divider></v-divider>
                  <v-stepper-step @click="showStepElement(stepCounter=2)" :complete="stepCounter > 2" step="2">
                    {{ step2 }}
                  </v-stepper-step>
                  <v-divider></v-divider>
                  <v-stepper-step @click="showStepElement(stepCounter=3)" :complete="stepCounter > 3" step="3">
                    {{ step3 }}
                  </v-stepper-step>
                  <v-divider></v-divider>
                  <v-stepper-step @click="showStepElement(stepCounter=4)" :complete="stepCounter === 4" step="4">
                    {{ step4 }}
                  </v-stepper-step>
                </v-stepper-header>
              </v-stepper>

              <v-btn :disabled="continueBtnIsHidden" text
                     v-on:click="initializeEditor(), stepCounter++, showStepElement(stepCounter)">Continue
              </v-btn>
            </v-toolbar-items>
          </v-toolbar>

          <GraphEditor v-if="!step1IsHidden" ref="GraphComponent"></GraphEditor>

          <v-container fluid class="py-0">
            <v-row v-show="step1IsHidden">
              <v-col md="3">

              </v-col>

              <v-col md="3" class="code-editor" :data-meta-model-id="gdslProject.id" :data-dsl-type="step2">
                <v-card>
                  <v-card-title>
                    {{ step2 }}
                    <v-spacer></v-spacer>

                      <v-chip class="js-save-successful" style="display: none" color="green" text-color="white">
                        Saving succeed
                      </v-chip>
                      <v-chip class="js-save-failed" style="display: none" color="red" text-color="white">
                        Saving failed
                      </v-chip>
                      <v-btn class="js-save" icon color="primary">
                        <v-icon>mdi-content-save</v-icon>
                      </v-btn>

                  </v-card-title>

                  <v-card-text>

                  </v-card-text>
                </v-card>
              </v-col>

              <v-col md="3" class="code-editor" :data-meta-model-id="gdslProject.id" :data-dsl-type="step3">
                <v-card>
                  <v-card-title>
                    {{ step3 }}
                    <v-spacer></v-spacer>

                      <v-chip class="js-save-successful" style="display: none" color="green" text-color="white">
                        Saving succeed
                      </v-chip>
                      <v-chip class="js-save-failed" style="display: none" color="red" text-color="white">
                        Saving failed
                      </v-chip>
                      <v-btn :disabled="step3IsHidden" class="js-save" icon color="primary">
                        <v-icon>mdi-content-save</v-icon>
                      </v-btn>

                  </v-card-title>

                  <v-card-text>

                  </v-card-text>
                </v-card>
              </v-col>

              <v-col md="3" class="code-editor" :data-meta-model-id="gdslProject.id" :data-dsl-type="step4">
                <v-card>
                  <v-card-title>
                    {{ step4 }}
                    <v-spacer></v-spacer>

                      <v-chip class="js-save-successful" style="display: none" color="green" text-color="white">
                        Saving succeed
                      </v-chip>
                      <v-chip class="js-save-failed" style="display: none" color="red" text-color="white">
                        Saving failed
                      </v-chip>
                      <v-btn :disabled="step4IsHidden" class="js-save" icon color="primary">
                        <v-icon>mdi-content-save</v-icon>
                      </v-btn>

                  </v-card-title>

                  <v-card-text>

                  </v-card-text>
                </v-card>
              </v-col>
            </v-row>
          </v-container>
        </v-card>
      </v-dialog>
    </v-card-text>

    <v-divider class="ma-0"></v-divider>

    <v-card-actions>
      <v-menu offset-y>
        <template v-slot:activator="{ on }">
          <v-btn color="secondary" outlined v-on="on">
            Validator
            <v-icon class="ml-1">mdi-chevron-down</v-icon>
          </v-btn>
        </template>
        <v-list>
          <v-list-item v-on:click="validatorGenerate" id="validatorGenerate">
            <v-list-item-title>Generate / Update Validator</v-list-item-title>
          </v-list-item>
          <v-list-item v-on:click="validatorShow" id="validatorShow">
            <v-list-item-title>Show Validation Rules</v-list-item-title>
          </v-list-item>
        </v-list>
      </v-menu>
    </v-card-actions>
  </v-card>
</template>


Cancel all debounced ‘updateTitle’ calls before being destroyed

<template>

  <div :style="{ maxHeight: `${maxHeight - 16}px` }" :class="{ truncated: !shaveDone }">
    <div v-if="viewAllText">
      {{ text }}
    </div>
    <div v-else ref="shaveEl" class="truncated">
      {{ text }}
    </div>
    <div
      v-if="showViewMore && (textIsTruncated || viewAllText)"
      class="show-more"
    >
      <KButton
        appearance="basic-link"
        :text="viewAllText ? $tr('viewLessButtonPrompt') : coreString('viewMoreAction')"
        @click.stop.prevent="viewAllText = !viewAllText"
      />
    </div>
  </div>

</template>

<script>

  import shave from 'shave';
  import debounce from 'lodash/debounce';
  import responsiveElementMixin from 'kolibri.coreVue.mixins.responsiveElementMixin';
  import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings';

  export default {
    name: 'TextTruncator',
    mixins: [commonCoreStrings, responsiveElementMixin],
    props: {
      text: {
        type: String,
        required: true,
      },
      maxHeight: {
        type: Number,
        required: true,
        validator(value) {
          return value > 0;
        },
      },
      showViewMore: {
        type: Boolean,
        required: false,
        default: false,
      },
    },
    data() {
      return {
        textIsTruncated: false,
        shaveDone: false,
        viewAllText: false,
      };
    },
    computed: {
      currentDimensions() {
        return {
          elementWidth: this.elementWidth,
          elementHeight: this.elementHeight,
        };
      },
      debouncedHandleUpdate() {
        return debounce(this.handleUpdate, 50);
      },
    },
    watch: {
      currentDimensions() {
        this.debouncedHandleUpdate();
      },
    },
    beforeDestroy() {
      this.debouncedHandleUpdate.cancel();
    },
    methods: {
      titleIsShaved() {
        return Boolean(this.$el.querySelector('.js-shave'));
      },
      titleIsOverflowing() {
        // This checks to see if shave.js did not work, but the text is still
        // overflowing. This can happen if `text` prop is one long string.
        const $shaveEl = this.$refs.shaveEl;
        if (!$shaveEl) {
          return false;
        } else {
          return $shaveEl.clientWidth < $shaveEl.scrollWidth;
        }
      },
      updateTitle() {
        // Set title attribute as full text if the visible text is truncated
        if (this.textIsTruncated && !this.$refs.shaveEl.title) {
          this.$refs.shaveEl.setAttribute('title', this.text);
        } else if (!this.textIsTruncated && this.$refs.shaveEl.title) {
          // Remove if text is fully visible after a resize
          this.$refs.shaveEl.removeAttribute('title');
        }
      },
      handleUpdate() {
        // TODO make "View Less" disappear when user expands window
        // and text isn't truncated anymore.
        shave(this.$refs.shaveEl, this.maxHeight, { spaces: false });
        this.$nextTick().then(() => {
          this.textIsTruncated = this.titleIsShaved() || this.titleIsOverflowing();
          this.updateTitle();
          // Removes temporary truncated styling from main div
          this.shaveDone = true;
        });
      },
    },
    $trs: {
      viewLessButtonPrompt: 'View less',
    },
  };

</script>

<style lang="scss" scoped>

  .show-more {
    margin-top: 8px;
    text-align: right;
  }

  // If the text is a long single word (and not shortened by shave.js),
  // then apply this CSS instead
  .truncated {
    overflow: hidden;
    text-overflow: ellipsis;
  }

</style>

<template>

  <div :style="{ maxHeight: `${maxHeight - 16}px` }" :class="{ truncated: !shaveDone }">
    <div v-if="viewAllText">
      {{ text }}
    </div>
    <div v-else ref="shaveEl" class="truncated">
      {{ text }}
    </div>
    <div
      v-if="showViewMore && (textIsTruncated || viewAllText)"
      class="show-more"
    >
      <KButton
        appearance="basic-link"
        :text="viewAllText ? $tr('viewLessButtonPrompt') : coreString('viewMoreAction')"
        @click.stop.prevent="viewAllText = !viewAllText"
      />
    </div>
  </div>

</template>

<script>

  import shave from 'shave';
  import debounce from 'lodash/debounce';
  import responsiveElementMixin from 'kolibri.coreVue.mixins.responsiveElementMixin';
  import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings';

  export default {
    name: 'TextTruncator',
    mixins: [commonCoreStrings, responsiveElementMixin],
    props: {
      text: {
        type: String,
        required: true,
      },
      maxHeight: {
        type: Number,
        required: true,
        validator(value) {
          return value > 0;
        },
      },
      showViewMore: {
        type: Boolean,
        required: false,
        default: false,
      },
    },
    data() {
      return {
        textIsTruncated: false,
        shaveDone: false,
        viewAllText: false,
      };
    },
    computed: {
      currentDimensions() {
        return {
          elementWidth: this.elementWidth,
          elementHeight: this.elementHeight,
        };
      },
      debouncedHandleUpdate() {
        return debounce(this.handleUpdate, 50);
      },
    },
    watch: {
      currentDimensions() {
        this.debouncedHandleUpdate();
      },
    },
    methods: {
      titleIsShaved() {
        return Boolean(this.$el.querySelector('.js-shave'));
      },
      titleIsOverflowing() {
        // This checks to see if shave.js did not work, but the text is still
        // overflowing. This can happen if `text` prop is one long string.
        const $shaveEl = this.$refs.shaveEl;
        if (!$shaveEl) {
          return false;
        } else {
          return $shaveEl.clientWidth < $shaveEl.scrollWidth;
        }
      },
      updateTitle() {
        // Set title attribute as full text if the visible text is truncated
        if (this.textIsTruncated && !this.$refs.shaveEl.title) {
          this.$refs.shaveEl.setAttribute('title', this.text);
        } else if (!this.textIsTruncated && this.$refs.shaveEl.title) {
          // Remove if text is fully visible after a resize
          this.$refs.shaveEl.removeAttribute('title');
        }
      },
      handleUpdate() {
        // TODO make "View Less" disappear when user expands window
        // and text isn't truncated anymore.
        shave(this.$refs.shaveEl, this.maxHeight, { spaces: false });
        this.$nextTick().then(() => {
          this.textIsTruncated = this.titleIsShaved() || this.titleIsOverflowing();
          this.updateTitle();
          // Removes temporary truncated styling from main div
          this.shaveDone = true;
        });
      },
    },
    $trs: {
      viewLessButtonPrompt: 'View less',
    },
  };

</script>

<style lang="scss" scoped>

  .show-more {
    margin-top: 8px;
    text-align: right;
  }

  // If the text is a long single word (and not shortened by shave.js),
  // then apply this CSS instead
  .truncated {
    overflow: hidden;
    text-overflow: ellipsis;
  }

</style>


Formatting; Use Ryan's custom radio button component to solve all of my problems <3; Add validation for RSVP buttons; Remove unnecessary updateRadio function

<template>

            <form id="eventCreateForm" v-on:submit.prevent="submit">

Event Details

                    Event Name*

                        <input v-model="event.name" type="text" class="form-control" :class="{ 'is-invalid': $v.event.name.$error }" id="event-name" @blur="$v.event.name.$touch()">

                    Organizer

                        <input v-model="event.organizer" type="text" class="form-control" id="event-organizer" placeholder="Defaults to you">

                    Start Time

                        <flat-pickr
                                id="event-starttime"
                                v-model="event.start_time"
                                placeholder="Select start time"
                                :required="true"
                                :config="dateTimeConfig"
                                input-class="form-control">
                        </flat-pickr>

                    End Time

                        <flat-pickr
                                id="event-endtime"
                                v-model="event.end_time"
                                placeholder="Select start time"
                                :required="true"
                                :config="dateTimeConfig"
                                input-class="form-control">
                        </flat-pickr>

                    Location

                        <input v-model="event.location" type="text" class="form-control" id="event-location">

                    Anonymous RSVP*

                        <custom-radio-buttons
                                v-model="event.allow_anonymous_rsvp"
                                :options="rsvpOptions"
                                id="event-anonymousrsvp-buttons"
                                :is-error="$v.event.allow_anonymous_rsvp.$error"
                                @input="$v.event.allow_anonymous_rsvp.$touch()">
                        </custom-radio-buttons>

                    Cost

                            $
                            <input
                                    v-model="event.cost"
                                    type="text"
                                    class="form-control"
                                    :class="{ 'is-invalid': $v.event.cost.$error }"
                                    id="event-cost"
                                    placeholder="Enter a decimal (10.00)"
                                    aria-describedby="prepend-dollar"
                                    @blur="$v.event.cost.$touch()">

                    <button type="submit" class="btn btn-primary">Create</button>
                    Cancel
                     {{feedback}}

            </form>

</template>

<template>

            <form id="eventCreateForm" v-on:submit.prevent="submit">

Event Details

                    Event Name*

                        <input
                                v-model="event.name"
                                type="text"
                                class="form-control"
                                :class="{ 'is-invalid': $v.event.name.$error }"
                                id="event-name"
                                @blur="$v.event.name.$touch()">

                    Organizer

                        <input v-model="event.organizer" type="text" class="form-control" id="event-organizer" placeholder="Defaults to you">

                    Start Time

                        <flat-pickr
                                id="event-starttime"
                                v-model="event.start_time"
                                placeholder="Select start time"
                                :required="true"
                                :config="dateTimeConfig"
                                input-class="form-control">
                        </flat-pickr>

                    End Time

                        <flat-pickr
                                id="event-endtime"
                                v-model="event.end_time"
                                placeholder="Select start time"
                                :required="true"
                                :config="dateTimeConfig"
                                input-class="form-control">
                        </flat-pickr>

                    Location

                        <input v-model="event.location" type="text" class="form-control" id="event-location">

                    Allow Anonymous RSVP*

                                <input v-model="event.allow_anonymous_rsvp" type="radio" name="shirt_size" value="false" autocomplete="off"> No

                                <input v-model="event.allow_anonymous_rsvp" type="radio" name="shirt_size" value="true" autocomplete="off"> Yes

                    Cost

                            $
                            <input
                                    v-model="event.cost"
                                    type="text"
                                    class="form-control"
                                    :class="{ 'is-invalid': $v.event.cost.$error }"
                                    id="event-cost"
                                    placeholder="Enter a decimal (10.00)"
                                    aria-describedby="prepend-dollar"
                                    @blur="$v.event.cost.$touch()">

                    <button type="submit" class="btn btn-primary">Create</button>
                    Cancel
                     {{feedback}}

            </form>

</template>


Updated label on user edit form

Updated label on user edit form

<template>

      <form id="userEditForm" v-on:submit.prevent="submit">

GT Directory Info

Information obtained via GT Single Sign-On. Update at Passport.

          Name

            <input v-model="user.full_name" type="text" readonly class="form-control" id="user-name">

          GT Username

            <input v-model="user.uid" type="text" readonly class="form-control" id="user-uid">

          GT Email

            <input v-model="user.gt_email" type="text" readonly class="form-control" id="user-gtemail">

Additional Information

          Preferred First Name

            <input
              v-model="user.preferred_name"
              type="text"
              class="form-control"
              id="user-preferredname"
              :class="{ 'is-invalid': $v.user.preferred_name.$error }"
              @input="$v.user.preferred_name.$touch()">

          Personal Email

            <input
              v-model="user.personal_email"
              type="email"
              class="form-control"
              id="user-personalemail"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.personal_email.$error }"
              @input="$v.user.personal_email.$touch()">

              Personal email cannot be a GT email address

              Must be a valid email address

          Phone Number

            <input
              v-model="user.phone"
              type="tel"
              class="form-control"
              id="user-phone"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.phone.$error }"
              @input="$v.user.phone.$touch()">

                Must be a valid phone number with no punctuation

          Shirt Size

            <custom-radio-buttons
              v-model="user.shirt_size"
              :options="shirtSizeOptions"
              id="user-shirtsize"
              :is-error="$v.user.shirt_size.$error"
              @input="$v.user.shirt_size.$touch()">
            </custom-radio-buttons>

          Polo Size

            <custom-radio-buttons
              v-model="user.polo_size"
              :options="shirtSizeOptions"
              id="user-polosize"
              :is-error="$v.user.polo_size.$error"
              @input="$v.user.polo_size.$touch()">
            </custom-radio-buttons>

Emergency Contacts

          Contact Name

            <input
              v-model="user.emergency_contact_name"
              type="text"
              class="form-control"
              id="user-emergencyname"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.emergency_contact_name.$error }"
              @input="$v.user.emergency_contact_name.$touch()">

          Contact Phone Number

            <input
              v-model="user.emergency_contact_phone"
              type="tel"
              class="form-control"
              id="user-emergencyphone"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.emergency_contact_phone.$error }"
              @input="$v.user.emergency_contact_phone.$touch()">

                Must be a valid phone number with no punctuation

          <button type="submit" class="btn btn-primary">Save Changes</button>
           {{feedback}}

      </form>

</template>

<template>

      <form id="userEditForm" v-on:submit.prevent="submit">

GT Directory Info

Information obtained via GT Single Sign-On. Update at Passport.

          Name

            <input v-model="user.full_name" type="text" readonly class="form-control" id="user-name">

          GT Username

            <input v-model="user.uid" type="text" readonly class="form-control" id="user-uid">

          GT Email

            <input v-model="user.gt_email" type="text" readonly class="form-control" id="user-gtemail">

Additional Information

          Preferred Name

            <input
              v-model="user.preferred_name"
              type="text"
              class="form-control"
              id="user-preferredname"
              :class="{ 'is-invalid': $v.user.preferred_name.$error }"
              @input="$v.user.preferred_name.$touch()">

          Personal Email

            <input
              v-model="user.personal_email"
              type="email"
              class="form-control"
              id="user-personalemail"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.personal_email.$error }"
              @input="$v.user.personal_email.$touch()">

              Personal email cannot be a GT email address

              Must be a valid email address

          Phone Number

            <input
              v-model="user.phone"
              type="tel"
              class="form-control"
              id="user-phone"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.phone.$error }"
              @input="$v.user.phone.$touch()">

                Must be a valid phone number with no punctuation

          Shirt Size

            <custom-radio-buttons
              v-model="user.shirt_size"
              :options="shirtSizeOptions"
              id="user-shirtsize"
              :is-error="$v.user.shirt_size.$error"
              @input="$v.user.shirt_size.$touch()">
            </custom-radio-buttons>

          Polo Size

            <custom-radio-buttons
              v-model="user.polo_size"
              :options="shirtSizeOptions"
              id="user-polosize"
              :is-error="$v.user.polo_size.$error"
              @input="$v.user.polo_size.$touch()">
            </custom-radio-buttons>

Emergency Contacts

          Contact Name

            <input
              v-model="user.emergency_contact_name"
              type="text"
              class="form-control"
              id="user-emergencyname"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.emergency_contact_name.$error }"
              @input="$v.user.emergency_contact_name.$touch()">

          Contact Phone Number

            <input
              v-model="user.emergency_contact_phone"
              type="tel"
              class="form-control"
              id="user-emergencyphone"
              placeholder="None on record"
              :class="{ 'is-invalid': $v.user.emergency_contact_phone.$error }"
              @input="$v.user.emergency_contact_phone.$touch()">

                Must be a valid phone number with no punctuation

          <button type="submit" class="btn btn-primary">Save Changes</button>
           {{feedback}}

      </form>

</template>


fixing $tr()

fixing $tr()

<template>

        <breadcrumb :linkobject="learnRootLink" :text="$tr('learn')"></breadcrumb>

      <template v-if="pageName === PageNames.EXPLORE_TOPIC">

          <breadcrumb :showarrow='false' :linkobject="exploreRootLink" :text="$tr('explore')"></breadcrumb>

          <breadcrumb :linkobject="parentExploreLink"></breadcrumb>

          {{ crumb.title }}

      </template>

        <breadcrumb :linkobject="parentExploreLink" :text="$tr('back')"></breadcrumb>

</template>

<template>

        <breadcrumb :linkobject="learnRootLink" :text="$tr('learn')"></breadcrumb>

      <template v-if="pageName === PageNames.EXPLORE_TOPIC">

          <breadcrumb :showarrow='false' :linkobject="exploreRootLink" :text="$tr('explore')"></breadcrumb>

          <breadcrumb :linkobject="parentExploreLink"></breadcrumb>

          {{ crumb.title }}

      </template>

        <breadcrumb :linkobject="parentExploreLink" :text="('Back')" ></breadcrumb>

</template>


use kolibriBranding object for scrim

use kolibriBranding object for scrim

<template>

    <FacilityModal
      v-if="facilityModalVisible"
      @cancel="closeFacilityModal"
      @submit="closeFacilityModal"
    />

            <CoreLogo
              v-if="$kolibriBranding.signIn.topLogo"
              class="logo"
              :src="$kolibriBranding.signIn.topLogo.src"
              :alt="$kolibriBranding.signIn.topLogo.alt"
              :style="$kolibriBranding.signIn.topLogo.style"
            />

              {{ logoText }}

              <KButton
                v-if="oidcProviderFlow"
                :text="$tr('poweredByKolibri')"
                appearance="basic-link"
                @click="whatsThisModalVisible = true"
              />
              <KExternalLink
                v-else
                :text="$tr('poweredByKolibri')"
                :primary="true"
                href="https://learningequality.org/r/powered_by_kolibri"
                target="_blank"
                appearance="basic-link"
              />

            <form ref="form" class="login-form" @submit.prevent="signIn">
              <UiAlert
                v-if="invalidCredentials"
                type="error"
                :dismissible="false"
              >
                {{ $tr('signInError') }}
              </UiAlert>
              <transition name="textbox">
                <KTextbox
                  id="username"
                  ref="username"
                  v-model="username"
                  autocomplete="username"
                  :autofocus="!hasMultipleFacilities"
                  :label="coreString('usernameLabel')"
                  :invalid="usernameIsInvalid"
                  :invalidText="usernameIsInvalidText"
                  @blur="handleUsernameBlur"
                  @input="showDropdown = true"
                  @keydown="handleKeyboardNav"
                />
              </transition>
              <transition name="list">

                    <UiAutocompleteSuggestion
                      v-for="(suggestion, i) in suggestions"
                      :key="i"
                      :suggestion="suggestion"
                      :style="suggestionStyle(i)"
                      @mousedown.native="fillUsername(suggestion)"
                    />

              </transition>
              <transition name="textbox">
                <KTextbox
                  v-if="needPasswordField"
                  id="password"
                  ref="password"
                  v-model="password"
                  type="password"
                  autocomplete="current-password"
                  :label="coreString('passwordLabel')"
                  :autofocus="simpleSignIn"
                  :invalid="passwordIsInvalid"
                  :invalidText="passwordIsInvalidText"
                  :floatingLabel="!autoFilledByChromeAndNotEdited"
                  @blur="passwordBlurred = true"
                  @input="handlePasswordChanged"
                />
              </transition>

                <KButton
                  class="login-btn"
                  type="submit"
                  :text="coreString('signInLabel')"
                  :primary="true"
                  :disabled="busy"
                />

            </form>

              <KRouterLink
                v-if="canSignUp"
                :text="$tr('createAccountAction')"
                :to="signUpPage"
                :primary="true"
                appearance="flat-button"
              />

              <component :is="component" v-for="component in loginOptions" :key="component.name" />

              <KExternalLink
                :text="$tr('accessAsGuest')"
                :href="guestURL"
                :primary="true"
                appearance="basic-link"
              />

          <LanguageSwitcherFooter />

              {{ versionMsg }}

            <CoreLogo
              v-if="this.$kolibriBranding.signIn.showKolibriFooterLogo"
              class="footer-logo"
            />
             •
            <KButton
              :text="coreString('usageAndPrivacyLabel')"
              appearance="basic-link"
              @click="privacyModalVisible = true"
            />

    <PrivacyInfoModal
      v-if="privacyModalVisible"
      @submit="privacyModalVisible = false"
      @cancel="privacyModalVisible = false"
    />

    <KModal
      v-if="whatsThisModalVisible"
      :title="$tr('whatsThis')"
      :submitText="coreString('closeAction')"
      @submit="whatsThisModalVisible = false"
      @cancel="whatsThisModalVisible = false"
    >

{{ $tr('oidcGenericExplanation') }}

        <KExternalLink
          text="https://learningequality.org/kolibri"
          :primary="true"
          href="https://learningequality.org/r/powered_by_kolibri"
          target="_blank"
          appearance="basic-link"
        />

    </KModal>

</template>


🔥 Remove old Demo

🔥 Remove old Demo

Signed-off-by: Eduardo San Martin Morote <ce308eb9228381c0f1b2b85a43002c2aea519513@gmail.com>

<template>

    <Motion :value="n"
            :spring="config"
    >
      <template scope="values">

          <div class="demo"
               :style="{transform: `translate3d(${values.value}px, 0, 0)`}"
          >

      </template>
    </Motion>

    <button @click="toggle">Toggle</button>

</template>


Scroll to top when opening form

Scroll to top when opening form

<template>

To allow connection to the CARTO database from your desktop GIS applications such as QGIS, Tableau, or Power BI, among others, you'll need to add at least one IP. You can read more in our developer documentation.

        <SettingsTitle title="1. Your IPs"></SettingsTitle>

        <Toggle
          :disabled="true"
          class="dbconnection__formsection"
          label="Allow connections from any IP addresses">
        </Toggle>

        <InputList
          ref="ipInputList"
          class="dbconnection__formsection"
          title="Allowed IP addresses"
          placeholder="12.12.12.12"
          :values="ipList"
          :fieldValidator="checkIfIPIsValid"
          :addElementToState="false"
          @removeElement="onIPsChanged">
          Computer or device IP address.
          Get your IP address.
        </InputList>

      <SettingsTitle :title="`2. Your certificates (${certificatesLength} of ${certificateLimit})`">

          <button
            class="button button--small"
            :disabled="!canCreateCertificates"
            @click="goToCertificateCreation">New Certificate</button>

      </SettingsTitle>

        <template v-if="certificatesLength">
          <CertificateCard
            v-for="certificate in certificates"
            :key="certificate.id"
            :certificate="certificate"
            @revoke="onCertificateRevoked"></CertificateCard>
        </template>

          You have not generated any certificates yet.

      <SettingsTitle title="Generate your Certificate" class="dbconnection-creationform">

          <button class="button__back" style="margin-right: 20px;" @click="goToCertificateList">

          </button>

      </SettingsTitle>
      <CertificateCreation class="creation__component" @create="onCertificateCreated"></CertificateCreation>

    <CertificateDownloadModal :certificate="newCertificate" ref="certificateDownload"></CertificateDownloadModal>

</template>


Removed debug statement

Removed debug statement

<template>
  <div>
    <q-search v-if="isVisible" v-model="pattern" inverted @change="onChanged">
      <q-autocomplete :debounce="500" @search="onSearch" @selected="onSelected" />
    </q-search>
  </div>
</template>

<script>
import { QSearch, QAutocomplete } from 'quasar'

export default {
  name: 'k-search',
  components: {
    QSearch,
    QAutocomplete
  },
  computed: {
    isVisible () {
      return this.search.requests.length > 0
    }
  },
  data () {
    return {
      pattern: '',
      search: this.$store.get('search')
    }
  },
  methods: {
    onSearch (pattern, done) {
      // Perform request for partial match to all registered services
      const requests = this.search.requests.map(request => {
        const service = this.$api.getService(request.service)
        // build the query using given templet if any
        let query = Object.assign({}, request.baseQuery)
        // Then add partial match
        // We don't use set by dot here because Mongo queries on nested fields
        // require the key to contain the path and not nested objects
        //_.set(query, item.field, { $search: pattern })
        query[request.field] = { $search: pattern }
        return service.find({ query })
      })
      Promise.all(requests).then(responses => {
        let results = []
        for (let i = 0; i < responses.length; i++) {
          const response = responses[i]
          const request = this.search.requests[i]
          if (response.total > 0) {
            response.data.forEach(data => {
              let result = {
                label: _.get(data, request.field),
                value: _.get(data, request.field)
              }
              data.service = request.service
              Object.assign(result, { data: data })
              results.push(result)
            })
          }
        }
        done(results)
      })
    },
    onSelected (selection) {
      this.$store.patch('search', { pattern: selection.label, selection: selection.data })
    },
    onChanged (pattern) {
      if (this.$store.get('search.pattern') !== pattern) {
        this.$store.patch('search', { pattern: pattern, selection: null })
      }
    }
  }
}
</script>

<template>
  <div>
    <q-search v-if="isVisible" v-model="pattern" inverted @change="onChanged">
      <q-autocomplete :debounce="500" @search="onSearch" @selected="onSelected" />
    </q-search>
  </div>
</template>

<script>
import { QSearch, QAutocomplete } from 'quasar'

export default {
  name: 'k-search',
  components: {
    QSearch,
    QAutocomplete
  },
  computed: {
    isVisible () {
      return this.search.requests.length > 0
    }
  },
  data () {
    return {
      pattern: '',
      search: this.$store.get('search')
    }
  },
  methods: {
    onSearch (pattern, done) {
      // Perform request for partial match to all registered services
      const requests = this.search.requests.map(request => {
        const service = this.$api.getService(request.service)
        // build the query using given templet if any
        let query = Object.assign({}, request.baseQuery)
        // Then add partial match
        // We don't use set by dot here because Mongo queries on nested fields
        // require the key to contain the path and not nested objects
        //_.set(query, item.field, { $search: pattern })
        query[request.field] = { $search: pattern }
        return service.find({ query })
      })
      Promise.all(requests).then(responses => {
        let results = []
        for (let i = 0; i < responses.length; i++) {
          const response = responses[i]
          const request = this.search.requests[i]
          if (response.total > 0) {
            response.data.forEach(data => {
              let result = {
                label: _.get(data, request.field),
                value: _.get(data, request.field)
              }
              data.service = request.service
              Object.assign(result, { data: data })
              results.push(result)
            })
          }
        }
        done(results)
      })
    },
    onSelected (selection) {
      this.$store.patch('search', { pattern: selection.label, selection: selection.data })
    },
    onChanged (pattern) {
      console.log(pattern)
      if (this.$store.get('search.pattern') !== pattern) {
        this.$store.patch('search', { pattern: pattern, selection: null })
      }
    }
  }
}
</script>


Redirect user to root on logout

Redirect user to root on logout

<template>

        <router-link class="navbar-item" @click="menu = false" to="/">Pixelect</router-link>

        <div :class="`navbar-burger${ menu ? ' is-active' : '' }`" @click="menu = !menu">

      <div :class="`navbar-menu${ menu ? ' is-active' : '' }`">

                <button :class="button.class" @click="bus.$emit(button.action)">

                  {{ button.text }}
                </button>

              {{userName}}

                Logout

</template>


modify pagination of SongsBookmarks

modify pagination of SongsBookmarks

<template>
  <panel title="Bookmarks">
    <v-data-table
      :headers="headers"
      :items="bookmarks"
      :pagination.sync="pagination"
      class="elevation-1"
    >
      <template slot="items" scope="props">
        {{ props.item.title }}
        {{ props.item.artist }}
      </template>
    </v-data-table>
  </panel>
</template>


fix: Close plan modal when creating tasks

Bug introduced in 44003af2a6f9bf0f2f2adab632ff948b9902615d

<template>
  <ly-modal
    :title="$t('tasks.modals.planTitle')"
    @close="$emit('close')"
    width="large"
  >
    <p v-if="intro" class="text-secondary">
      {{ intro }}
    </p>

    <task-create-form
      main
      autoFocus
      :plannedAt="plannedAt"
      @success="$emit('close')"
    ></task-create-form>

    <ly-section v-if="tasks.length > 0" :title="$t('tasks.modals.orPickFromBacklog')">
      <task-select-list
        :tasks="tasks"
        @select="startTask"
      ></task-select-list>
    </ly-section>
  </ly-modal>
</template>

<script>
  import moment from 'moment'

  import { mapGetters } from 'vuex'

  import TaskCreateForm from '@/components/tasks/TaskCreateForm'
  import TaskSelectList from './TaskSelectList'

  export default {
    props: {
      intro: { type: String }
    },

    components: {
      TaskCreateForm,
      TaskSelectList,
    },

    data () {
      return {
        plannedAt: moment(),
      }
    },

    computed: {
      ...mapGetters({
        tasks: 'tasks/listBacklog',
      }),
    },

    methods: {
      startTask (task) {
        this.$store.dispatch('tasks/start', { task })
        this.$emit('close')
      },
    },
  }
</script>

<template>
  <ly-modal
    :title="$t('tasks.modals.planTitle')"
    @close="$emit('close')"
    width="large"
  >
    <p v-if="intro" class="text-secondary">
      {{ intro }}
    </p>

    <task-create-form
      main
      autoFocus
      :plannedAt="plannedAt"
      @success="this.$emit('close')"
    ></task-create-form>

    <ly-section v-if="tasks.length > 0" :title="$t('tasks.modals.orPickFromBacklog')">
      <task-select-list
        :tasks="tasks"
        @select="startTask"
      ></task-select-list>
    </ly-section>
  </ly-modal>
</template>

<script>
  import moment from 'moment'

  import { mapGetters } from 'vuex'

  import TaskCreateForm from '@/components/tasks/TaskCreateForm'
  import TaskSelectList from './TaskSelectList'

  export default {
    props: {
      intro: { type: String }
    },

    components: {
      TaskCreateForm,
      TaskSelectList,
    },

    data () {
      return {
        plannedAt: moment(),
      }
    },

    computed: {
      ...mapGetters({
        tasks: 'tasks/listBacklog',
      }),
    },

    methods: {
      startTask (task) {
        this.$store.dispatch('tasks/start', { task })
        this.$emit('close')
      },
    },
  }
</script>


给首页增加昨日和今年的金额统计

给首页增加昨日和今年的金额统计

<template>

        <chart-card :loading="loading" title="今日采购" data-step="1" data-title="今日采购" data-intro="统计今日采购单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="今日销售" data-step="2" data-title="今日销售" data-intro="统计今日销售单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="今日零售" data-step="3" data-title="今日零售" data-intro="统计今日零售单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="本月累计采购">

        </chart-card>

        <chart-card :loading="loading" title="本月累计销售">

        </chart-card>

        <chart-card :loading="loading" title="本月累计零售">

        </chart-card>

        <chart-card :loading="loading" title="昨日采购">

        </chart-card>

        <chart-card :loading="loading" title="昨日销售">

        </chart-card>

        <chart-card :loading="loading" title="昨日零售">

        </chart-card>

        <chart-card :loading="loading" title="今年累计采购">

        </chart-card>

        <chart-card :loading="loading" title="今年累计销售">

        </chart-card>

        <chart-card :loading="loading" title="今年累计零售">

        </chart-card>

          <bar title="采购统计" :height="barHeight" :yaxisText="yaxisText" :dataSource="buyPriceData"/>

          <bar title="销售统计" :height="barHeight" :yaxisText="yaxisText" :dataSource="salePriceData"/>

          <bar title="零售统计" :height="barHeight" :yaxisText="yaxisText" :dataSource="retailPriceData"/>

© 2015-2030 {{systemTitle}} V3.1

          试用到期：{{tenant.expireTime}}
          试用用户：{{tenant.userCurrentNum}}/{{tenant.userNumLimit}}
          服务到期：{{tenant.expireTime}}
          授权用户：{{tenant.userCurrentNum}}/{{tenant.userNumLimit}}
          立即续费

</template>

<template>

        <chart-card :loading="loading" title="今日累计采购" data-step="1" data-title="今日累计采购" data-intro="统计今日采购单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="今日累计销售" data-step="2" data-title="今日累计销售" data-intro="统计今日销售单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="今日累计零售" data-step="3" data-title="今日累计零售" data-intro="统计今日零售单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="本月累计采购" data-step="4" data-title="本月累计采购" data-intro="统计本月采购单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="本月累计销售" data-step="5" data-title="本月累计销售" data-intro="统计本月销售单据的总金额">

        </chart-card>

        <chart-card :loading="loading" title="本月累计零售" data-step="6" data-title="本月累计零售" data-intro="统计本月零售单据的总金额">

        </chart-card>

          <bar title="采购统计" :height="barHeight" :yaxisText="yaxisText" :dataSource="buyPriceData"/>

          <bar title="销售统计" :height="barHeight" :yaxisText="yaxisText" :dataSource="salePriceData"/>

          <bar title="零售统计" :height="barHeight" :yaxisText="yaxisText" :dataSource="retailPriceData"/>

© 2015-2030 {{systemTitle}} V3.1

          试用到期：{{tenant.expireTime}}
          试用用户：{{tenant.userCurrentNum}}/{{tenant.userNumLimit}}
          服务到期：{{tenant.expireTime}}
          授权用户：{{tenant.userCurrentNum}}/{{tenant.userNumLimit}}
          立即续费

</template>


fix(MdFile): empty statement (#1716)

fix(MdFile): empty statement (#1716)

empty file selection should check files' length while it exists

fix #1711

<template>
  <div class="md-file">
    <md-file-icon @click.native="openPicker" />

    <input
      class="md-input"
      readonly
      v-model="model"
      v-bind="{ disabled, required, placeholder }"
      @focus="openPicker"
      @blur="onBlur">

    <input type="file" ref="inputFile" v-bind="attributes" v-on="$listeners" @change="onChange" />
  </div>
</template>

<script>
  import MdUuid from 'core/utils/MdUuid'
  import MdFileIcon from 'core/icons/MdFileIcon'
  import MdFieldMixin from '../MdFieldMixin'

  export default {
    name: 'MdFile',
    components: {
      MdFileIcon
    },
    props: {
      id: {
        type: String,
        default: () => 'md-file-' + MdUuid()
      },
      name: String
    },
    mixins: [MdFieldMixin],
    inject: ['MdField'],
    methods: {
      getMultipleName (files) {
        let names = [];

        [...files].forEach(({ name }) => names.push(name))

        return names.join(', ')
      },
      getFileName (files, target) {
        if (!files || files.length === 0) {
          return target.value.split('\\').pop()
        }

        if (files.length > 1) {
          return this.getMultipleName(files)
        }

        if (files.length === 1) {
          return files[0].name
        }

        return null
      },
      openPicker () {
        this.onFocus()
        this.$refs.inputFile.click()
      },
      onChange ($event) {
        this.onFileSelected($event)
      },
      onFileSelected ({ target, dataTransfer }) {
        const files = target.files || dataTransfer.files

        this.model = this.getFileName(files, target)
        this.$emit('md-change', files || target.value)
      }
    },
    created () {
      this.MdField.file = true
    },
    beforeDestroy () {
      this.MdField.file = false
    }
  }
</script>

<style lang="scss">
  .md-file {
    display: flex;
    flex: 1;

    input[type="file"] {
      width: 1px;
      height: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      position: absolute;
      clip: rect(0 0 0 0);
      border: 0;
    }

    .md-icon {
      cursor: pointer;
    }
  }
</style>

<template>
  <div class="md-file">
    <md-file-icon @click.native="openPicker" />

    <input
      class="md-input"
      readonly
      v-model="model"
      v-bind="{ disabled, required, placeholder }"
      @focus="openPicker"
      @blur="onBlur">

    <input type="file" ref="inputFile" v-bind="attributes" v-on="$listeners" @change="onChange" />
  </div>
</template>

<script>
  import MdUuid from 'core/utils/MdUuid'
  import MdFileIcon from 'core/icons/MdFileIcon'
  import MdFieldMixin from '../MdFieldMixin'

  export default {
    name: 'MdFile',
    components: {
      MdFileIcon
    },
    props: {
      id: {
        type: String,
        default: () => 'md-file-' + MdUuid()
      },
      name: String
    },
    mixins: [MdFieldMixin],
    inject: ['MdField'],
    methods: {
      getMultipleName (files) {
        let names = [];

        [...files].forEach(({ name }) => names.push(name))

        return names.join(', ')
      },
      getFileName (files, target) {
        if (!files) {
          return target.value.split('\\').pop()
        }

        if (files.length > 1) {
          return this.getMultipleName(files)
        }

        if (files.length === 1) {
          return files[0].name
        }

        return null
      },
      openPicker () {
        this.onFocus()
        this.$refs.inputFile.click()
      },
      onChange ($event) {
        this.onFileSelected($event)
      },
      onFileSelected ({ target, dataTransfer }) {
        const files = target.files || dataTransfer.files

        this.model = this.getFileName(files, target)
        this.$emit('md-change', files || target.value)
      }
    },
    created () {
      this.MdField.file = true
    },
    beforeDestroy () {
      this.MdField.file = false
    }
  }
</script>

<style lang="scss">
  .md-file {
    display: flex;
    flex: 1;

    input[type="file"] {
      width: 1px;
      height: 1px;
      margin: -1px;
      padding: 0;
      overflow: hidden;
      position: absolute;
      clip: rect(0 0 0 0);
      border: 0;
    }

    .md-icon {
      cursor: pointer;
    }
  }
</style>


fix: prevent toggleButton from getting called on input enter (#23)

Closes #21


Fix open production layout

Fix open production layout

Productions were no more centered.

<template>

      <spinner />

 0">

        <activity-icon />
        {{ $t('productions.home.title') }}

 4
        }"
      >

          <router-link
            :to="getPath(production)"
          >

                {{ generateAvatar(production) }}

              {{ production.name }}

          </router-link>
          <router-link
            class="secondary"
            :to="assetsPath(production)"
          >
            {{ $t('assets.title') }}
          </router-link>
          -
          <router-link
            class="secondary"
            :to="shotsPath(production)"
          >
            {{ $t('shots.title') }}
          </router-link>

          {{ $t('productions.home.create_new') }}

        {{ $t('productions.home.welcome') }}

          {{ $t('productions.home.empty') }}

          <button-simple
            class="level-item big-button"
            :text="$t('productions.home.create_new')"
            :is-responsive="false"
            @click="showNewModal"
          />

          {{ $t('productions.home.no_prod_for_client') }}

          {{ $t('productions.home.no_task') }}

    <edit-production-modal
      :active="modals.isNewDisplayed"
      :is-loading="editProduction.isLoading"
      :is-error="editProduction.isError"
      @confirm="confirmEditProduction"
      @cancel="hideNewModal"
    />

</template>


tally -> summaryTally

tally -> summaryTally

<template>

  <CoreBase
    :immersivePage="false"
    :authorized="userIsAuthorized"
    authorizedRole="adminOrCoach"
    :showSubNav="true"
  >

    <TopNavbar slot="sub-nav" />

    <KPageContainer>

      <ReportsLessonExerciseHeader />

      <KCheckbox
        :label="coachStrings.$tr('viewByGroupsLabel')"
        :checked="viewByGroups"
        @change="toggleGroupsView"
      />

      <template v-if="viewByGroups"></template>
      <template v-else>
        <p>
          <StatusSummary :tally="summaryTally" />
        </p>

        <ReportsExerciseLearners :entries="table" />
      </template>
    </KPageContainer>
  </CoreBase>

</template>

<script>

  import commonCoach from '../common';
  import { PageNames } from '../../constants';
  import ReportsLessonExerciseHeader from './ReportsLessonExerciseHeader';
  import ReportsExerciseLearners from './ReportsExerciseLearners';

  export default {
    name: 'ReportsLessonExerciseLearnerListPage',
    components: {
      ReportsLessonExerciseHeader,
      ReportsExerciseLearners,
    },
    mixins: [commonCoach],
    data() {
      return {
        viewByGroups: false,
      };
    },
    computed: {
      lesson() {
        return this.lessonMap[this.$route.params.lessonId];
      },
      recipients() {
        return this.getLearnersForGroups(this.lesson.groups);
      },
      summaryTally() {
        return this.getContentStatusTally(this.$route.params.exerciseId, this.recipients);
      },
      table() {
        const learners = this.recipients.map(learnerId => this.learnerMap[learnerId]);
        const sorted = this._.sortBy(learners, ['name']);
        const mapped = sorted.map(learner => {
          const tableRow = {
            groups: this.getGroupNamesForLearner(learner.id),
            statusObj: this.getContentStatusObjForLearner(
              this.$route.params.exerciseId,
              learner.id
            ),
            exerciseLink: this.classRoute(PageNames.REPORTS_LESSON_EXERCISE_LEARNER_PAGE_ROOT, {
              learnerId: learner.id,
            }),
          };
          Object.assign(tableRow, learner);
          return tableRow;
        });
        return mapped;
      },
    },
    methods: {
      toggleGroupsView() {
        this.viewByGroups = !this.viewByGroups;
      },
    },
  };

</script>

<style lang="scss" scoped>

  .stats {
    margin-right: 16px;
  }

</style>

<template>

  <CoreBase
    :immersivePage="false"
    :authorized="userIsAuthorized"
    authorizedRole="adminOrCoach"
    :showSubNav="true"
  >

    <TopNavbar slot="sub-nav" />

    <KPageContainer>

      <ReportsLessonExerciseHeader />

      <KCheckbox
        :label="coachStrings.$tr('viewByGroupsLabel')"
        :checked="viewByGroups"
        @change="toggleGroupsView"
      />

      <template v-if="viewByGroups"></template>
      <template v-else>
        <p>
          <StatusSummary :tally="tally" />
        </p>

        <ReportsExerciseLearners :entries="table" />
      </template>
    </KPageContainer>
  </CoreBase>

</template>

<script>

  import commonCoach from '../common';
  import { PageNames } from '../../constants';
  import ReportsLessonExerciseHeader from './ReportsLessonExerciseHeader';
  import ReportsExerciseLearners from './ReportsExerciseLearners';

  export default {
    name: 'ReportsLessonExerciseLearnerListPage',
    components: {
      ReportsLessonExerciseHeader,
      ReportsExerciseLearners,
    },
    mixins: [commonCoach],
    data() {
      return {
        viewByGroups: false,
      };
    },
    computed: {
      lesson() {
        return this.lessonMap[this.$route.params.lessonId];
      },
      recipients() {
        return this.getLearnersForGroups(this.lesson.groups);
      },
      tally() {
        return this.getContentStatusTally(this.$route.params.exerciseId, this.recipients);
      },
      table() {
        const learners = this.recipients.map(learnerId => this.learnerMap[learnerId]);
        const sorted = this._.sortBy(learners, ['name']);
        const mapped = sorted.map(learner => {
          const tableRow = {
            groups: this.getGroupNamesForLearner(learner.id),
            statusObj: this.getContentStatusObjForLearner(
              this.$route.params.exerciseId,
              learner.id
            ),
            exerciseLink: this.classRoute(PageNames.REPORTS_LESSON_EXERCISE_LEARNER_PAGE_ROOT, {
              learnerId: learner.id,
            }),
          };
          Object.assign(tableRow, learner);
          return tableRow;
        });
        return mapped;
      },
    },
    methods: {
      toggleGroupsView() {
        this.viewByGroups = !this.viewByGroups;
      },
    },
  };

</script>

<style lang="scss" scoped>

  .stats {
    margin-right: 16px;
  }

</style>


docs(SystemBar): add vueschool bf banner

<template>
  <v-system-bar
    v-if="hasPromotion"
    color="#000C19"
    app
    dark
    height="76"
  >
    <a
      class="cm-banner"
      href="https://vueschool.io/sales/blackfriday?friend=vuetify"
      rel="noopener"
      target="_blank"
      @click="onClick"
    />

    <v-btn
      fab
      small
      absolute
      right
      @click="onClose"
    >
      <v-icon class="mr-0">$clear</v-icon>
    </v-btn>
  </v-system-bar>
</template>

<script>
  // Utilities
  import { differenceInHours } from 'date-fns'
  import { get, sync } from 'vuex-pathify'

  export default {
    name: 'DefaultSystemBar',

    computed: {
      last: sync('user/last@promotion'),
      name: get('route/name'),
      hasPromotion () {
        const now = Date.now()

        return (
          differenceInHours(now, Number(this.last)) > 1
        )
      },
    },

    methods: {
      onClick () {
        this.$gtag.event('click', {
          event_category: 'vuetify-banner',
          event_label: 'vue-school-black-friday-2021',
          value: this.name.toLowerCase(),
        })
      },
      onClose () {
        this.last = Date.now()
      },
    },
  }
</script>

<style lang="sass">
  .cm-banner
    background-position: center
    background-repeat: no-repeat
    background-size: contain
    bottom: 0
    display: block
    height: inherit
    left: 0
    overflow: hidden
    position: absolute
    right: 0
    text-indent: 100%
    top: 0
    white-space: nowrap

  .cm-banner
    background-image: url(https://cdn.vuetifyjs.com/docs/images/promotions/vs-black-friday-2021/vs-black-friday-2021-mobile.png)

    @media (min-width: 660px)
      background-image: url(https://cdn.vuetifyjs.com/docs/images/promotions/vs-black-friday-2021/vs-black-friday-2021-tablet.png)

    @media (min-width: 992px)
      background-image: url(https://cdn.vuetifyjs.com/docs/images/promotions/vs-black-friday-2021/vs-black-friday-2021-desktop.png)
</style>

<template>
  <v-system-bar
    v-if="false"
    color="#1A265A"
    app
    dark
    height="76"
  >
    <a
      class="cm-banner"
      href="https://vueschool.io/sales/summer-vue?friend=vuetify"
      rel="noopener"
      target="_blank"
      @click="onClick"
    />

    <v-btn
      fab
      small
      absolute
      right
      @click="onClose"
    >
      <v-icon class="mr-0">$clear</v-icon>
    </v-btn>
  </v-system-bar>
</template>

<script>
  // Utilities
  import { differenceInHours } from 'date-fns'
  import { get, sync } from 'vuex-pathify'

  export default {
    name: 'DefaultSystemBar',

    computed: {
      last: sync('user/last@promotion'),
      name: get('route/name'),
      hasPromotion () {
        const now = Date.now()

        return (
          differenceInHours(now, Number(this.last)) > 1
        )
      },
    },

    methods: {
      onClick () {
        this.$gtag.event('click', {
          event_category: 'vuetify-banner',
          event_label: 'vue-school-summer-vue',
          value: this.name.toLowerCase(),
        })
      },
      onClose () {
        this.last = Date.now()
      },
    },
  }
</script>

<style lang="sass">
  .cm-banner
    background-position: center
    background-repeat: no-repeat
    background-size: contain
    bottom: 0
    display: block
    height: inherit
    left: 0
    overflow: hidden
    position: absolute
    right: 0
    text-indent: 100%
    top: 0
    white-space: nowrap

  .cm-banner
    background-image: url(https://cdn.vuetifyjs.com/docs/images/promotions/vs-summer-sale-2021/vs-summer-mobile.png)

    @media (min-width: 660px)
      background-image: url(https://cdn.vuetifyjs.com/docs/images/promotions/vs-summer-sale-2021/vs-summer-tablet.png)

    @media (min-width: 992px)
      background-image: url(https://cdn.vuetifyjs.com/docs/images/promotions/vs-summer-sale-2021/vs-summer-desktop.png)
</style>


Added comments to controller components

Added comments to controller components

<template>

	<li :class="getClass()">
		<a
			:data-name="controller.class"
			:data-path="controller.path"
			:data-route="controller.route"
			:title="getComment()"
			v-link="'/run/' + controller.route"
			>
			{{{ getLabel() }}}
		</a>
	</li>

</template>

<script>

import Helpers		from '../../../js/functions/helpers.js';
import settings 	from '../../../js/state/settings.js';
import state 		from '../../../js/state/state.js';

export default
{
	props:['controller'],

	methods:
	{
		getClass ()
		{
			return {
				controller: true,
				active: this.isActive(),
				error: !!this.controller.error,
				icon: !!this.controller.error
			}
		},

		getLabel ()
		{
			return Helpers.getControllerLabel(this.controller);
		},

		getComment ()
		{
			return this.controller.comment.intro.replace(/<.+?>/g, '');
		},

		isActive ()
		{
			return state.route && state.route.indexOf(this.controller.route) === 0;
		}

	}

}

</script>

<style lang="scss">

</style>

<template>

	<li :class="getClass()">
		<a
			:data-name="controller.class"
			:data-path="controller.path"
			:data-route="controller.route"
			v-link="'/run/' + controller.route"
			>
			{{{ getLabel() }}}
		</a>
	</li>

</template>

<script>

import Helpers		from '../../../js/functions/helpers.js';
import settings 	from '../../../js/state/settings.js';
import state 		from '../../../js/state/state.js';

export default
{
	props:['controller'],

	methods:
	{
		getClass ()
		{
			return {
				controller: true,
				active: this.isActive(),
				error: !!this.controller.error,
				icon: !!this.controller.error
			}
		},

		getLabel ()
		{
			return Helpers.getControllerLabel(this.controller);
		},

		isActive ()
		{
			return state.route && state.route.indexOf(this.controller.route) === 0;
		}

	}

}

</script>

<style lang="scss">

</style>


Add psuedo-zoom to image-viewer

Add psuedo-zoom to image-viewer


Table component style enhancements

Table component style enhancements

<template>
    <table
            class="mdl-data-table mdl-js-data-table"
            :class="classes"
            ref="table">
        <slot></slot>
    </table>
</template>

<script>
export default {
    name: "AppTable",
    props: {
        selectable: Boolean,
        shadow: Boolean
    },
    computed: {
        classes() {
            return {
                'mdl-shadow--2dp': this.shadow
            }
        }
    },
    mounted() {
        window.componentHandler.upgradeElement(this.$refs.table)
    }
}
</script>

<style scoped>

.mdl-data-table {
    width: 100%;
}

.mdl-data-table th {
    text-align: inherit;
}

.mdl-data-table td {
    text-align: inherit;
}

</style>

<template>
    <table
            class="mdl-data-table mdl-js-data-table"
            :class="classes"
            ref="table">
        <slot></slot>
    </table>
</template>

<script>
export default {
    name: "AppTable",
    props: {
        selectable: Boolean,
        shadow: Boolean
    },
    computed: {
        classes() {
            return {
                'mdl-shadow--2dp': this.shadow
            }
        }
    },
    mounted() {
        window.componentHandler.upgradeElement(this.$refs.table)
    }
}
</script>

<style scoped>

.mdl-data-table td {
    text-align: inherit;
}

</style>


Show real message timestamp

Show real message timestamp

Signed-off-by: Christoph Wurst <7abde52c298496fa169fb750f91f213a282142af@winzerhof-wurst.at>

<template>
	<router-link :class="{
			'app-content-list-item': true,
			'unseen': data.flags.unseen
		}" :to="{
			name: 'message',
			params: {
				accountId: this.$route.params.accountId,
				folderId: this.$route.params.folderId,
				messageId: this.data.id
			},
			exact: true}">

			<Avatar :label="sender"/>

			{{sender}}

			{{data.subject}}

			<Moment :timestamp="data.dateInt * 1000"/>

	</router-link>
</template>

<template>
	<router-link :class="{
			'app-content-list-item': true,
			'unseen': data.flags.unseen
		}" :to="{
			name: 'message',
			params: {
				accountId: this.$route.params.accountId,
				folderId: this.$route.params.folderId,
				messageId: this.data.id
			},
			exact: true}">

			<Avatar :label="sender"/>

			{{sender}}

			{{data.subject}}

			<Moment timestamp="1536048354000"/>

	</router-link>
</template>