Deep watcher for calendar attributes. Closes #761.

<script>
import addDays from 'date-fns/addDays';
import addMonths from 'date-fns/addMonths';
import addYears from 'date-fns/addYears';
import Popover from './Popover';
import PopoverRow from './PopoverRow';
import CalendarNav from './CalendarNav';
import CalendarPane from './CalendarPane';
import CustomTransition from './CustomTransition';
import SvgIcon from './SvgIcon';
import AttributeStore from '../utils/attributeStore';
import { rootMixin, safeScopedSlotMixin } from '../utils/mixins';
import { addHorizontalSwipeHandler } from '../utils/touch';
import {
  addPages,
  pageIsValid,
  pageIsEqualToPage,
  pageIsBeforePage,
  pageIsAfterPage,
  pageIsBetweenPages,
  pageRangeToArray,
  createGuid,
  arrayHasItems,
  onSpaceOrEnter,
} from '../utils/helpers';
import {
  isNumber,
  isDate,
  isObject,
  hasAny,
  omit,
  head,
  last,
} from '../utils/_';
import '../styles/base.css';

export default {
  name: 'Calendar',
  render(h) {
    // Renderer for calendar panes
    const panes = this.pages.map((page, i) => {
      const position = i + 1;
      const row = Math.ceil((i + 1) / this.columns);
      const rowFromEnd = this.rows - row + 1;
      const column = position % this.columns || this.columns;
      const columnFromEnd = this.columns - column + 1;
      return h(CalendarPane, {
        attrs: {
          ...this.$attrs,
          attributes: this.store,
        },
        props: {
          page,
          position,
          row,
          rowFromEnd,
          column,
          columnFromEnd,
          titlePosition: this.titlePosition_,
        },
        on: {
          ...this.$listeners,
          dayfocusin: e => {
            this.lastFocusedDay = e;
            this.$emit('dayfocusin', e);
          },
          dayfocusout: e => {
            this.lastFocusedDay = null;
            this.$emit('dayfocusout', e);
          },
        },
        scopedSlots: this.$scopedSlots,
        key: page.key,
        ref: 'pages',
        refInFor: true,
      });
    });
    // Renderer for calendar arrows
    const getArrowButton = isPrev => {
      const click = () => this.move(isPrev ? -this.step_ : this.step_);
      const keydown = e => onSpaceOrEnter(e, click);
      const isDisabled = isPrev ? !this.canMovePrev : !this.canMoveNext;
      return h(
        'div',
        {
          class: [
            'vc-arrow',
            `is-${isPrev ? 'left' : 'right'}`,
            { 'is-disabled': isDisabled },
          ],
          attrs: {
            role: 'button',
          },
          on: {
            click,
            keydown,
          },
        },
        [
          (isPrev
            ? this.safeScopedSlot('header-left-button', { click })
            : this.safeScopedSlot('header-right-button', { click })) ||
            h(SvgIcon, {
              props: {
                name: isPrev ? 'left-arrow' : 'right-arrow',
              },
            }),
        ],
      );
    };

    // Nav popover
    const getNavPopover = () =>
      h(Popover, {
        props: {
          id: this.sharedState.navPopoverId,
          contentClass: 'vc-nav-popover-container',
        },
        ref: 'navPopover',
        scopedSlots: {
          default: ({ data }) => {
            const { position, page } = data;
            return h(CalendarNav, {
              props: {
                value: page,
                position,
                validator: e => this.canMove(e, { position }),
              },
              on: {
                input: e => this.move(e, { position }),
              },
              scopedSlots: this.$scopedSlots,
            });
          },
        },
      });

    // Day popover
    const getDayPopover = () =>
      h(Popover, {
        props: {
          id: this.sharedState.dayPopoverId,
          contentClass: 'vc-day-popover-container',
        },
        scopedSlots: {
          default: ({ data: day, updateLayout, hide }) => {
            const attributes = Object.values(day.attributes).filter(
              a => a.popover,
            );
            const masks = this.$locale.masks;
            const format = this.formatDate;
            const dayTitle = format(day.date, masks.dayPopover);
            return (
              this.safeScopedSlot('day-popover', {
                day,
                attributes,
                masks,
                format,
                dayTitle,
                updateLayout,
                hide,
              }) ||
              h('div', [
                // Show popover header only if format is defined
                masks.dayPopover &&
                  h(
                    'div',
                    {
                      class: ['vc-day-popover-header'],
                    },
                    [dayTitle],
                  ),
                attributes.map(attribute =>
                  h(PopoverRow, {
                    key: attribute.key,
                    props: {
                      attribute,
                    },
                  }),
                ),
              ])
            );
          },
        },
      });

    // Renderer for calendar container
    return h(
      'div',
      {
        attrs: {
          'data-helptext':
            'Press the arrow keys to navigate by day, Home and End to navigate to week ends, PageUp and PageDown to navigate by month, Alt+PageUp and Alt+PageDown to navigate by year',
        },
        class: [
          'vc-container',
          `vc-${this.$theme.color}`,
          {
            'vc-is-expanded': this.isExpanded,
            'vc-is-dark': this.$theme.isDark,
          },
        ],
        on: {
          keydown: this.handleKeydown,
          mouseup: e => e.preventDefault(),
        },
        ref: 'container',
      },
      [
        getNavPopover(),
        h(
          'div',
          {
            class: [
              'vc-pane-container',
              { 'in-transition': this.inTransition },
            ],
          },
          [
            h(
              CustomTransition,
              {
                props: {
                  name: this.transitionName,
                },
                on: {
                  beforeEnter: () => {
                    this.inTransition = true;
                  },
                  afterEnter: () => {
                    this.inTransition = false;
                  },
                },
              },
              [
                h(
                  'div',
                  {
                    class: 'vc-pane-layout',
                    style: {
                      gridTemplateColumns: `repeat(${this.columns}, 1fr)`,
                    },
                    attrs: {
                      ...this.$attrs,
                    },
                    key: arrayHasItems(this.pages) ? this.pages[0].key : '',
                  },
                  panes,
                ),
              ],
            ),
            h(
              'div',
              {
                class: [`vc-arrows-container title-${this.titlePosition_}`],
              },
              [getArrowButton(true), getArrowButton(false)],
            ),
            this.$scopedSlots.footer && this.$scopedSlots.footer(),
          ],
        ),
        getDayPopover(),
      ],
    );
  },
  mixins: [rootMixin, safeScopedSlotMixin],
  provide() {
    return {
      sharedState: this.sharedState,
    };
  },
  props: {
    rows: {
      type: Number,
      default: 1,
    },
    columns: {
      type: Number,
      default: 1,
    },
    step: Number,
    titlePosition: String,
    isExpanded: Boolean,
    fromDate: Date,
    toDate: Date,
    fromPage: Object,
    toPage: Object,
    minPage: Object,
    maxPage: Object,
    transition: String,
    attributes: [Object, Array],
    trimWeeks: Boolean,
    disablePageSwipe: Boolean,
  },
  data() {
    return {
      pages: [],
      store: null,
      lastFocusedDay: null,
      focusableDay: new Date().getDate(),
      transitionName: '',
      inTransition: false,
      sharedState: {
        navPopoverId: createGuid(),
        dayPopoverId: createGuid(),
        theme: {},
        masks: {},
        locale: {},
      },
    };
  },
  computed: {
    titlePosition_() {
      return this.propOrDefault('titlePosition', 'titlePosition');
    },
    firstPage() {
      return head(this.pages);
    },
    lastPage() {
      return last(this.pages);
    },
    minPage_() {
      return this.minPage || this.pageForDate(this.minDate);
    },
    maxPage_() {
      return this.maxPage || this.pageForDate(this.maxDate);
    },
    count() {
      return this.rows * this.columns;
    },
    step_() {
      return this.step || this.count;
    },
    canMovePrev() {
      return this.canMove(-this.step_);
    },
    canMoveNext() {
      return this.canMove(this.step_);
    },
  },
  watch: {
    $locale() {
      this.refreshLocale();
      this.refreshPages({ page: this.firstPage, ignoreCache: true });
      this.initStore();
    },
    $theme() {
      this.refreshTheme();
      this.initStore();
    },
    fromDate() {
      this.refreshPages();
    },
    fromPage(val) {
      const firstPage = this.pages && this.pages[0];
      if (pageIsEqualToPage(val, firstPage)) return;
      this.refreshPages();
    },
    toPage(val) {
      const lastPage = this.pages && this.pages[this.pages.length - 1];
      if (pageIsEqualToPage(val, lastPage)) return;
      this.refreshPages();
    },
    count() {
      this.refreshPages();
    },
    attributes: {
      handler(val) {
        const { adds, deletes } = this.store.refresh(val);
        this.refreshAttrs(this.pages, adds, deletes);
      },
      deep: true,
    },
    pages(val) {
      this.refreshAttrs(val, this.store.list, null, true);
    },
    disabledAttribute() {
      this.refreshDisabledDays();
    },
    lastFocusedDay(val) {
      if (val) {
        this.focusableDay = val.day;
        this.refreshFocusableDays();
      }
    },
    inTransition(val) {
      if (val) {
        this.$emit('transition-start');
      } else {
        this.$emit('transition-end');
        if (this.transitionPromise) {
          this.transitionPromise.resolve(true);
          this.transitionPromise = null;
        }
      }
    },
  },
  created() {
    this.refreshLocale();
    this.refreshTheme();
    this.initStore();
    this.refreshPages();
  },
  mounted() {
    if (!this.disablePageSwipe) {
      // Add swipe handler to move to next and previous pages
      const removeHandlers = addHorizontalSwipeHandler(
        this.$refs.container,
        ({ toLeft, toRight }) => {
          if (toLeft) {
            this.moveNext();
          } else if (toRight) {
            this.movePrev();
          }
        },
        this.$defaults.touch,
      );
      // Clean up on destroy
      this.$once('beforeDestroy', () => removeHandlers());
    }
  },
  methods: {
    refreshLocale() {
      this.sharedState.locale = this.$locale;
      this.sharedState.masks = this.$locale.masks;
    },
    refreshTheme() {
      this.sharedState.theme = this.$theme;
    },
    canMove(arg, opts = {}) {
      const page = this.$locale.toPage(arg, this.firstPage);
      let { position } = opts;
      // Pin position if arg is number
      if (isNumber(arg)) position = 1;
      // Reject unresolved pages
      if (!page) {
        return Promise.reject(new Error(`Invalid argument provided: ${arg}`));
      }
      // Set position if unspecified and out of current bounds
      if (!position) {
        if (pageIsBeforePage(page, this.firstPage)) {
          position = -1;
        } else if (pageIsAfterPage(page, this.lastPage)) {
          position = 1;
        } else {
          // Page already displayed with no specified position, so we're done
          return Promise.resolve(true);
        }
      }
      // Calculate new page range without adjusting to min/max
      Object.assign(
        opts,
        this.getTargetPageRange(page, {
          position,
          force: true,
        }),
      );
      // Verify we can to move to any pages in the target range
      return pageRangeToArray(opts.fromPage, opts.toPage).some(p =>
        pageIsBetweenPages(p, this.minPage_, this.maxPage_),
      );
    },
    movePrev(opts) {
      return this.move(-this.step_, opts);
    },
    moveNext(opts) {
      return this.move(this.step_, opts);
    },
    move(arg, opts = {}) {
      // Reject if we can't move to this page
      const canMove = this.canMove(arg, opts);
      if (!opts.force && !canMove) {
        return Promise.reject(
          new Error(`Move target is disabled: ${JSON.stringify(opts)}`),
        );
      }
      // Hide nav popover for good measure
      this.$refs.navPopover.hide({ hideDelay: 0 });
      // Move to new `fromPage` if it's different from the current one
      if (opts.fromPage && !pageIsEqualToPage(opts.fromPage, this.firstPage)) {
        return this.refreshPages({
          ...opts,
          page: opts.fromPage,
          position: 1,
          force: true,
        });
      }
      return Promise.resolve(true);
    },
    focusDate(date, opts = {}) {
      // Move to the given date
      return this.move(date, opts).then(() => {
        // Set focus on the element for the date
        const focusableEl = this.$el.querySelector(
          `.id-${this.$locale.getDayId(date)}.in-month .vc-focusable`,
        );
        if (focusableEl) {
          focusableEl.focus();
          return Promise.resolve(true);
        }
        return Promise.resolve(false);
      });
    },
    showPageRange(range, opts) {
      let fromPage;
      let toPage;
      if (isDate(range)) {
        fromPage = this.pageForDate(range);
      } else if (isObject(range)) {
        const { month, year } = range;
        const { from, to } = range;
        if (isNumber(month) && isNumber(year)) {
          fromPage = range;
        } else if (from || to) {
          fromPage = isDate(from) ? this.pageForDate(from) : from;
          toPage = isDate(to) ? this.pageForDate(to) : to;
        }
      } else {
        return Promise.reject(new Error('Invalid page range provided.'));
      }
      const lastPage = this.lastPage;
      let page = fromPage;
      // Offset page from the desired `toPage`
      if (pageIsAfterPage(toPage, lastPage)) {
        page = addPages(toPage, -(this.pages.length - 1));
      }
      // But no earlier than the desired `fromPage`
      if (pageIsBeforePage(page, fromPage)) {
        page = fromPage;
      }
      return this.refreshPages({ ...opts, page });
    },
    getTargetPageRange(page, { position, force } = {}) {
      let fromPage = null;
      let toPage = null;
      if (pageIsValid(page)) {
        let pagesToAdd = 0;
        position = +position;
        if (!isNaN(position)) {
          pagesToAdd = position > 0 ? 1 - position : -(this.count + position);
        }
        fromPage = addPages(page, pagesToAdd);
      } else {
        fromPage = this.getDefaultInitialPage();
      }
      toPage = addPages(fromPage, this.count - 1);
      // Adjust range for min/max if not forced
      if (!force) {
        if (pageIsBeforePage(fromPage, this.minPage_)) {
          fromPage = this.minPage_;
        } else if (pageIsAfterPage(toPage, this.maxPage_)) {
          fromPage = addPages(this.maxPage_, 1 - this.count);
        }
        toPage = addPages(fromPage, this.count - 1);
      }
      return { fromPage, toPage };
    },
    getDefaultInitialPage() {
      // 1. Try the fromPage prop
      let page = this.fromPage || this.pageForDate(this.fromDate);
      if (!pageIsValid(page)) {
        // 2. Try the toPage prop
        const toPage = this.toPage || this.pageForDate(this.toPage);
        if (pageIsValid(toPage)) {
          page = addPages(toPage, 1 - this.count);
        }
      }
      // 3. Try the first attribute
      if (!pageIsValid(page)) {
        page = this.getPageForAttributes();
      }
      // 4. Use today's page
      if (!pageIsValid(page)) {
        page = this.pageForThisMonth();
      }
      return page;
    },
    refreshPages({ page, position = 1, force, transition, ignoreCache } = {}) {
      return new Promise((resolve, reject) => {
        const { fromPage, toPage } = this.getTargetPageRange(page, {
          position,
          force,
        });
        // Create the new pages
        const pages = [];
        for (let i = 0; i < this.count; i++) {
          pages.push(this.buildPage(addPages(fromPage, i), ignoreCache));
        }
        // Refresh disabled days for new pages
        this.refreshDisabledDays(pages);
        // Refresh focusable days for new pages
        this.refreshFocusableDays(pages);
        // Assign the transition
        this.transitionName = this.getPageTransition(
          this.pages[0],
          pages[0],
          transition,
        );
        // Assign the new pages
        this.pages = pages;
        // Emit page update events
        this.$emit('update:from-page', fromPage);
        this.$emit('update:to-page', toPage);
        if (this.transitionName && this.transitionName !== 'none') {
          this.transitionPromise = {
            resolve,
            reject,
          };
        } else {
          resolve(true);
        }
      });
    },
    refreshDisabledDays(pages) {
      this.getPageDays(pages).forEach(d => {
        d.isDisabled =
          !!this.disabledAttribute && this.disabledAttribute.intersectsDay(d);
      });
    },
    refreshFocusableDays(pages) {
      this.getPageDays(pages).forEach(d => {
        d.isFocusable = d.inMonth && d.day === this.focusableDay;
      });
    },
    getPageDays(pages = this.pages) {
      return pages.reduce((prev, curr) => prev.concat(curr.days), []);
    },
    getPageTransition(oldPage, newPage, transition = this.transition) {
      if (transition === 'none') return transition;
      if (
        transition === 'fade' ||
        (!transition && this.count > 1) ||
        !pageIsValid(oldPage) ||
        !pageIsValid(newPage)
      ) {
        return 'fade';
      }
      // Moving to a previous page
      const movePrev = pageIsBeforePage(newPage, oldPage);
      // Vertical slide
      if (transition === 'slide-v') {
        return movePrev ? 'slide-down' : 'slide-up';
      }
      // Horizontal slide
      return movePrev ? 'slide-right' : 'slide-left';
    },
    getPageForAttributes() {
      let page = null;
      const attr = this.store.pinAttr;
      if (attr && attr.hasDates) {
        let [date] = attr.dates;
        date = date.start || date.date;
        page = this.pageForDate(date);
      }
      return page;
    },
    buildPage({ month, year }, ignoreCache) {
      const key = `${year.toString()}-${month.toString()}`;
      let page = this.pages.find(p => p.key === key);
      if (!page || ignoreCache) {
        const date = new Date(year, month - 1, 15);
        const monthComps = this.$locale.getMonthComps(month, year);
        const prevMonthComps = this.$locale.getPrevMonthComps(month, year);
        const nextMonthComps = this.$locale.getNextMonthComps(month, year);
        page = {
          key,
          month,
          year,
          weeks: this.trimWeeks ? monthComps.weeks : 6,
          title: this.$locale.format(date, this.$locale.masks.title),
          shortMonthLabel: this.$locale.format(date, 'MMM'),
          monthLabel: this.$locale.format(date, 'MMMM'),
          shortYearLabel: year.toString().substring(2),
          yearLabel: year.toString(),
          monthComps,
          prevMonthComps,
          nextMonthComps,
          canMove: pg => this.canMove(pg),
          move: pg => this.move(pg),
          moveThisMonth: () => this.moveThisMonth(),
          movePrevMonth: () => this.move(prevMonthComps),
          moveNextMonth: () => this.move(nextMonthComps),
          refresh: true,
        };
        // Assign day info
        page.days = this.$locale.getCalendarDays(page);
      }
      return page;
    },
    initStore() {
      // Create a new attribute store
      this.store = new AttributeStore(
        this.$theme,
        this.$locale,
        this.attributes,
      );
      // Refresh attributes for existing pages
      this.refreshAttrs(this.pages, this.store.list, [], true);
    },
    refreshAttrs(pages = [], adds = [], deletes = [], reset) {
      if (!arrayHasItems(pages)) return;
      // For each page...
      pages.forEach(p => {
        // For each day...
        p.days.forEach(d => {
          let map = {};
          // If resetting...
          if (reset) {
            d.refresh = true;
          } else if (hasAny(d.attributesMap, deletes)) {
            // Delete attributes from the delete list
            map = omit(d.attributesMap, deletes);
            // Flag day for refresh
            d.refresh = true;
          } else {
            // Get the existing attributes
            map = d.attributesMap || {};
          }
          // For each attribute to add...
          adds.forEach(attr => {
            // Add it if it includes the current day
            const targetDate = attr.intersectsDay(d);
            if (targetDate) {
              const newAttr = {
                ...attr,
                targetDate,
              };
              map[attr.key] = newAttr;
              // Flag day for refresh
              d.refresh = true;
            }
          });
          // Reassign day attributes
          if (d.refresh) {
            d.attributesMap = map;
          }
        });
      });
      // Refresh pages
      this.$nextTick(() => {
        this.$refs.pages.forEach(p => p.refresh());
      });
    },
    handleKeydown(e) {
      const day = this.lastFocusedDay;
      if (day != null) {
        day.event = e;
        this.handleDayKeydown(day);
      }
    },
    handleDayKeydown(day) {
      const { dateFromTime, event } = day;
      // Set to noon to offset any daylight savings time offset
      const date = dateFromTime(12);
      let newDate = null;
      switch (event.key) {
        case 'ArrowLeft': {
          // Move to previous day
          newDate = addDays(date, -1);
          break;
        }
        case 'ArrowRight': {
          // Move to next day
          newDate = addDays(date, 1);
          break;
        }
        case 'ArrowUp': {
          // Move to previous week
          newDate = addDays(date, -7);
          break;
        }
        case 'ArrowDown': {
          // Move to next week
          newDate = addDays(date, 7);
          break;
        }
        case 'Home': {
          // Move to first weekday position
          newDate = addDays(date, -day.weekdayPosition + 1);
          break;
        }
        case 'End': {
          // Move to last weekday position
          newDate = addDays(date, day.weekdayPositionFromEnd);
          break;
        }
        case 'PageUp': {
          if (event.altKey) {
            // Move to previous year w/ Alt/Option key
            newDate = addYears(date, -1);
          } else {
            // Move to previous month
            newDate = addMonths(date, -1);
          }
          break;
        }
        case 'PageDown': {
          if (event.altKey) {
            // Move to next year w/ Alt/Option key
            newDate = addYears(date, 1);
          } else {
            // Move to next month
            newDate = addMonths(date, 1);
          }
          break;
        }
      }
      if (newDate) {
        event.preventDefault();
        this.focusDate(newDate).catch(() => {});
      }
    },
  },
};
</script>

<style lang="postcss">
.vc-pane-container {
  width: 100%;
  position: relative;
  &.in-transition {
    overflow: hidden;
  }
}

.vc-pane-layout {
  display: grid;
}

.vc-arrow {
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  user-select: none;
  pointer-events: auto;
  color: var(--gray-600);
  border-width: 2px;
  border-style: solid;
  border-radius: var(--rounded);
  border-color: transparent;
  &:hover {
    background: var(--gray-200);
  }
  &:focus {
    border-color: var(--gray-300);
  }

  &.is-disabled {
    opacity: 0.25;
    pointer-events: none;
    cursor: not-allowed;
  }
}

.vc-day-popover-container {
  color: var(--white);
  background-color: var(--gray-800);
  border: 1px solid;
  border-color: var(--gray-700);
  border-radius: var(--rounded);
  font-size: var(--text-xs);
  font-weight: var(--font-medium);
  padding: 4px 8px;
  box-shadow: var(--shadow);
}

.vc-day-popover-header {
  font-size: var(--text-xs);
  color: var(--gray-300);
  font-weight: var(--font-semibold);
  text-align: center;
}

.vc-arrows-container {
  width: 100%;
  position: absolute;
  top: 0;
  display: flex;
  justify-content: space-between;
  padding: 8px 10px;
  pointer-events: none;
  &.title-left {
    justify-content: flex-end;
  }
  &.title-right {
    justify-content: flex-start;
  }
}

.vc-is-dark {
  & .vc-arrow {
    color: var(--white);
    &:hover {
      background: var(--gray-800);
    }
    &:focus {
      border-color: var(--gray-700);
    }
  }
  & .vc-day-popover-container {
    color: var(--gray-800);
    background-color: var(--white);
    border-color: var(--gray-100);
  }
  & .vc-day-popover-header {
    color: var(--gray-700);
  }
}
</style>

<script>
import addDays from 'date-fns/addDays';
import addMonths from 'date-fns/addMonths';
import addYears from 'date-fns/addYears';
import Popover from './Popover';
import PopoverRow from './PopoverRow';
import CalendarNav from './CalendarNav';
import CalendarPane from './CalendarPane';
import CustomTransition from './CustomTransition';
import SvgIcon from './SvgIcon';
import AttributeStore from '../utils/attributeStore';
import { rootMixin, safeScopedSlotMixin } from '../utils/mixins';
import { addHorizontalSwipeHandler } from '../utils/touch';
import {
  addPages,
  pageIsValid,
  pageIsEqualToPage,
  pageIsBeforePage,
  pageIsAfterPage,
  pageIsBetweenPages,
  pageRangeToArray,
  createGuid,
  arrayHasItems,
  onSpaceOrEnter,
} from '../utils/helpers';
import {
  isNumber,
  isDate,
  isObject,
  hasAny,
  omit,
  head,
  last,
} from '../utils/_';
import '../styles/base.css';

export default {
  name: 'Calendar',
  render(h) {
    // Renderer for calendar panes
    const panes = this.pages.map((page, i) => {
      const position = i + 1;
      const row = Math.ceil((i + 1) / this.columns);
      const rowFromEnd = this.rows - row + 1;
      const column = position % this.columns || this.columns;
      const columnFromEnd = this.columns - column + 1;
      return h(CalendarPane, {
        attrs: {
          ...this.$attrs,
          attributes: this.store,
        },
        props: {
          page,
          position,
          row,
          rowFromEnd,
          column,
          columnFromEnd,
          titlePosition: this.titlePosition_,
        },
        on: {
          ...this.$listeners,
          dayfocusin: e => {
            this.lastFocusedDay = e;
            this.$emit('dayfocusin', e);
          },
          dayfocusout: e => {
            this.lastFocusedDay = null;
            this.$emit('dayfocusout', e);
          },
        },
        scopedSlots: this.$scopedSlots,
        key: page.key,
        ref: 'pages',
        refInFor: true,
      });
    });
    // Renderer for calendar arrows
    const getArrowButton = isPrev => {
      const click = () => this.move(isPrev ? -this.step_ : this.step_);
      const keydown = e => onSpaceOrEnter(e, click);
      const isDisabled = isPrev ? !this.canMovePrev : !this.canMoveNext;
      return h(
        'div',
        {
          class: [
            'vc-arrow',
            `is-${isPrev ? 'left' : 'right'}`,
            { 'is-disabled': isDisabled },
          ],
          attrs: {
            role: 'button',
          },
          on: {
            click,
            keydown,
          },
        },
        [
          (isPrev
            ? this.safeScopedSlot('header-left-button', { click })
            : this.safeScopedSlot('header-right-button', { click })) ||
            h(SvgIcon, {
              props: {
                name: isPrev ? 'left-arrow' : 'right-arrow',
              },
            }),
        ],
      );
    };

    // Nav popover
    const getNavPopover = () =>
      h(Popover, {
        props: {
          id: this.sharedState.navPopoverId,
          contentClass: 'vc-nav-popover-container',
        },
        ref: 'navPopover',
        scopedSlots: {
          default: ({ data }) => {
            const { position, page } = data;
            return h(CalendarNav, {
              props: {
                value: page,
                position,
                validator: e => this.canMove(e, { position }),
              },
              on: {
                input: e => this.move(e, { position }),
              },
              scopedSlots: this.$scopedSlots,
            });
          },
        },
      });

    // Day popover
    const getDayPopover = () =>
      h(Popover, {
        props: {
          id: this.sharedState.dayPopoverId,
          contentClass: 'vc-day-popover-container',
        },
        scopedSlots: {
          default: ({ data: day, updateLayout, hide }) => {
            const attributes = Object.values(day.attributes).filter(
              a => a.popover,
            );
            const masks = this.$locale.masks;
            const format = this.formatDate;
            const dayTitle = format(day.date, masks.dayPopover);
            return (
              this.safeScopedSlot('day-popover', {
                day,
                attributes,
                masks,
                format,
                dayTitle,
                updateLayout,
                hide,
              }) ||
              h('div', [
                // Show popover header only if format is defined
                masks.dayPopover &&
                  h(
                    'div',
                    {
                      class: ['vc-day-popover-header'],
                    },
                    [dayTitle],
                  ),
                attributes.map(attribute =>
                  h(PopoverRow, {
                    key: attribute.key,
                    props: {
                      attribute,
                    },
                  }),
                ),
              ])
            );
          },
        },
      });

    // Renderer for calendar container
    return h(
      'div',
      {
        attrs: {
          'data-helptext':
            'Press the arrow keys to navigate by day, Home and End to navigate to week ends, PageUp and PageDown to navigate by month, Alt+PageUp and Alt+PageDown to navigate by year',
        },
        class: [
          'vc-container',
          `vc-${this.$theme.color}`,
          {
            'vc-is-expanded': this.isExpanded,
            'vc-is-dark': this.$theme.isDark,
          },
        ],
        on: {
          keydown: this.handleKeydown,
          mouseup: e => e.preventDefault(),
        },
        ref: 'container',
      },
      [
        getNavPopover(),
        h(
          'div',
          {
            class: [
              'vc-pane-container',
              { 'in-transition': this.inTransition },
            ],
          },
          [
            h(
              CustomTransition,
              {
                props: {
                  name: this.transitionName,
                },
                on: {
                  beforeEnter: () => {
                    this.inTransition = true;
                  },
                  afterEnter: () => {
                    this.inTransition = false;
                  },
                },
              },
              [
                h(
                  'div',
                  {
                    class: 'vc-pane-layout',
                    style: {
                      gridTemplateColumns: `repeat(${this.columns}, 1fr)`,
                    },
                    attrs: {
                      ...this.$attrs,
                    },
                    key: arrayHasItems(this.pages) ? this.pages[0].key : '',
                  },
                  panes,
                ),
              ],
            ),
            h(
              'div',
              {
                class: [`vc-arrows-container title-${this.titlePosition_}`],
              },
              [getArrowButton(true), getArrowButton(false)],
            ),
            this.$scopedSlots.footer && this.$scopedSlots.footer(),
          ],
        ),
        getDayPopover(),
      ],
    );
  },
  mixins: [rootMixin, safeScopedSlotMixin],
  provide() {
    return {
      sharedState: this.sharedState,
    };
  },
  props: {
    rows: {
      type: Number,
      default: 1,
    },
    columns: {
      type: Number,
      default: 1,
    },
    step: Number,
    titlePosition: String,
    isExpanded: Boolean,
    fromDate: Date,
    toDate: Date,
    fromPage: Object,
    toPage: Object,
    minPage: Object,
    maxPage: Object,
    transition: String,
    attributes: [Object, Array],
    trimWeeks: Boolean,
    disablePageSwipe: Boolean,
  },
  data() {
    return {
      pages: [],
      store: null,
      lastFocusedDay: null,
      focusableDay: new Date().getDate(),
      transitionName: '',
      inTransition: false,
      sharedState: {
        navPopoverId: createGuid(),
        dayPopoverId: createGuid(),
        theme: {},
        masks: {},
        locale: {},
      },
    };
  },
  computed: {
    titlePosition_() {
      return this.propOrDefault('titlePosition', 'titlePosition');
    },
    firstPage() {
      return head(this.pages);
    },
    lastPage() {
      return last(this.pages);
    },
    minPage_() {
      return this.minPage || this.pageForDate(this.minDate);
    },
    maxPage_() {
      return this.maxPage || this.pageForDate(this.maxDate);
    },
    count() {
      return this.rows * this.columns;
    },
    step_() {
      return this.step || this.count;
    },
    canMovePrev() {
      return this.canMove(-this.step_);
    },
    canMoveNext() {
      return this.canMove(this.step_);
    },
  },
  watch: {
    $locale() {
      this.refreshLocale();
      this.refreshPages({ page: this.firstPage, ignoreCache: true });
      this.initStore();
    },
    $theme() {
      this.refreshTheme();
      this.initStore();
    },
    fromDate() {
      this.refreshPages();
    },
    fromPage(val) {
      const firstPage = this.pages && this.pages[0];
      if (pageIsEqualToPage(val, firstPage)) return;
      this.refreshPages();
    },
    toPage(val) {
      const lastPage = this.pages && this.pages[this.pages.length - 1];
      if (pageIsEqualToPage(val, lastPage)) return;
      this.refreshPages();
    },
    count() {
      this.refreshPages();
    },
    attributes(val) {
      const { adds, deletes } = this.store.refresh(val);
      this.refreshAttrs(this.pages, adds, deletes);
    },
    pages(val) {
      this.refreshAttrs(val, this.store.list, null, true);
    },
    disabledAttribute() {
      this.refreshDisabledDays();
    },
    lastFocusedDay(val) {
      if (val) {
        this.focusableDay = val.day;
        this.refreshFocusableDays();
      }
    },
    inTransition(val) {
      if (val) {
        this.$emit('transition-start');
      } else {
        this.$emit('transition-end');
        if (this.transitionPromise) {
          this.transitionPromise.resolve(true);
          this.transitionPromise = null;
        }
      }
    },
  },
  created() {
    this.refreshLocale();
    this.refreshTheme();
    this.initStore();
    this.refreshPages();
  },
  mounted() {
    if (!this.disablePageSwipe) {
      // Add swipe handler to move to next and previous pages
      const removeHandlers = addHorizontalSwipeHandler(
        this.$refs.container,
        ({ toLeft, toRight }) => {
          if (toLeft) {
            this.moveNext();
          } else if (toRight) {
            this.movePrev();
          }
        },
        this.$defaults.touch,
      );
      // Clean up on destroy
      this.$once('beforeDestroy', () => removeHandlers());
    }
  },
  methods: {
    refreshLocale() {
      this.sharedState.locale = this.$locale;
      this.sharedState.masks = this.$locale.masks;
    },
    refreshTheme() {
      this.sharedState.theme = this.$theme;
    },
    canMove(arg, opts = {}) {
      const page = this.$locale.toPage(arg, this.firstPage);
      let { position } = opts;
      // Pin position if arg is number
      if (isNumber(arg)) position = 1;
      // Reject unresolved pages
      if (!page) {
        return Promise.reject(new Error(`Invalid argument provided: ${arg}`));
      }
      // Set position if unspecified and out of current bounds
      if (!position) {
        if (pageIsBeforePage(page, this.firstPage)) {
          position = -1;
        } else if (pageIsAfterPage(page, this.lastPage)) {
          position = 1;
        } else {
          // Page already displayed with no specified position, so we're done
          return Promise.resolve(true);
        }
      }
      // Calculate new page range without adjusting to min/max
      Object.assign(
        opts,
        this.getTargetPageRange(page, {
          position,
          force: true,
        }),
      );
      // Verify we can to move to any pages in the target range
      return pageRangeToArray(opts.fromPage, opts.toPage).some(p =>
        pageIsBetweenPages(p, this.minPage_, this.maxPage_),
      );
    },
    movePrev(opts) {
      return this.move(-this.step_, opts);
    },
    moveNext(opts) {
      return this.move(this.step_, opts);
    },
    move(arg, opts = {}) {
      // Reject if we can't move to this page
      const canMove = this.canMove(arg, opts);
      if (!opts.force && !canMove) {
        return Promise.reject(
          new Error(`Move target is disabled: ${JSON.stringify(opts)}`),
        );
      }
      // Hide nav popover for good measure
      this.$refs.navPopover.hide({ hideDelay: 0 });
      // Move to new `fromPage` if it's different from the current one
      if (opts.fromPage && !pageIsEqualToPage(opts.fromPage, this.firstPage)) {
        return this.refreshPages({
          ...opts,
          page: opts.fromPage,
          position: 1,
          force: true,
        });
      }
      return Promise.resolve(true);
    },
    focusDate(date, opts = {}) {
      // Move to the given date
      return this.move(date, opts).then(() => {
        // Set focus on the element for the date
        const focusableEl = this.$el.querySelector(
          `.id-${this.$locale.getDayId(date)}.in-month .vc-focusable`,
        );
        if (focusableEl) {
          focusableEl.focus();
          return Promise.resolve(true);
        }
        return Promise.resolve(false);
      });
    },
    showPageRange(range, opts) {
      let fromPage;
      let toPage;
      if (isDate(range)) {
        fromPage = this.pageForDate(range);
      } else if (isObject(range)) {
        const { month, year } = range;
        const { from, to } = range;
        if (isNumber(month) && isNumber(year)) {
          fromPage = range;
        } else if (from || to) {
          fromPage = isDate(from) ? this.pageForDate(from) : from;
          toPage = isDate(to) ? this.pageForDate(to) : to;
        }
      } else {
        return Promise.reject(new Error('Invalid page range provided.'));
      }
      const lastPage = this.lastPage;
      let page = fromPage;
      // Offset page from the desired `toPage`
      if (pageIsAfterPage(toPage, lastPage)) {
        page = addPages(toPage, -(this.pages.length - 1));
      }
      // But no earlier than the desired `fromPage`
      if (pageIsBeforePage(page, fromPage)) {
        page = fromPage;
      }
      return this.refreshPages({ ...opts, page });
    },
    getTargetPageRange(page, { position, force } = {}) {
      let fromPage = null;
      let toPage = null;
      if (pageIsValid(page)) {
        let pagesToAdd = 0;
        position = +position;
        if (!isNaN(position)) {
          pagesToAdd = position > 0 ? 1 - position : -(this.count + position);
        }
        fromPage = addPages(page, pagesToAdd);
      } else {
        fromPage = this.getDefaultInitialPage();
      }
      toPage = addPages(fromPage, this.count - 1);
      // Adjust range for min/max if not forced
      if (!force) {
        if (pageIsBeforePage(fromPage, this.minPage_)) {
          fromPage = this.minPage_;
        } else if (pageIsAfterPage(toPage, this.maxPage_)) {
          fromPage = addPages(this.maxPage_, 1 - this.count);
        }
        toPage = addPages(fromPage, this.count - 1);
      }
      return { fromPage, toPage };
    },
    getDefaultInitialPage() {
      // 1. Try the fromPage prop
      let page = this.fromPage || this.pageForDate(this.fromDate);
      if (!pageIsValid(page)) {
        // 2. Try the toPage prop
        const toPage = this.toPage || this.pageForDate(this.toPage);
        if (pageIsValid(toPage)) {
          page = addPages(toPage, 1 - this.count);
        }
      }
      // 3. Try the first attribute
      if (!pageIsValid(page)) {
        page = this.getPageForAttributes();
      }
      // 4. Use today's page
      if (!pageIsValid(page)) {
        page = this.pageForThisMonth();
      }
      return page;
    },
    refreshPages({ page, position = 1, force, transition, ignoreCache } = {}) {
      return new Promise((resolve, reject) => {
        const { fromPage, toPage } = this.getTargetPageRange(page, {
          position,
          force,
        });
        // Create the new pages
        const pages = [];
        for (let i = 0; i < this.count; i++) {
          pages.push(this.buildPage(addPages(fromPage, i), ignoreCache));
        }
        // Refresh disabled days for new pages
        this.refreshDisabledDays(pages);
        // Refresh focusable days for new pages
        this.refreshFocusableDays(pages);
        // Assign the transition
        this.transitionName = this.getPageTransition(
          this.pages[0],
          pages[0],
          transition,
        );
        // Assign the new pages
        this.pages = pages;
        // Emit page update events
        this.$emit('update:from-page', fromPage);
        this.$emit('update:to-page', toPage);
        if (this.transitionName && this.transitionName !== 'none') {
          this.transitionPromise = {
            resolve,
            reject,
          };
        } else {
          resolve(true);
        }
      });
    },
    refreshDisabledDays(pages) {
      this.getPageDays(pages).forEach(d => {
        d.isDisabled =
          !!this.disabledAttribute && this.disabledAttribute.intersectsDay(d);
      });
    },
    refreshFocusableDays(pages) {
      this.getPageDays(pages).forEach(d => {
        d.isFocusable = d.inMonth && d.day === this.focusableDay;
      });
    },
    getPageDays(pages = this.pages) {
      return pages.reduce((prev, curr) => prev.concat(curr.days), []);
    },
    getPageTransition(oldPage, newPage, transition = this.transition) {
      if (transition === 'none') return transition;
      if (
        transition === 'fade' ||
        (!transition && this.count > 1) ||
        !pageIsValid(oldPage) ||
        !pageIsValid(newPage)
      ) {
        return 'fade';
      }
      // Moving to a previous page
      const movePrev = pageIsBeforePage(newPage, oldPage);
      // Vertical slide
      if (transition === 'slide-v') {
        return movePrev ? 'slide-down' : 'slide-up';
      }
      // Horizontal slide
      return movePrev ? 'slide-right' : 'slide-left';
    },
    getPageForAttributes() {
      let page = null;
      const attr = this.store.pinAttr;
      if (attr && attr.hasDates) {
        let [date] = attr.dates;
        date = date.start || date.date;
        page = this.pageForDate(date);
      }
      return page;
    },
    buildPage({ month, year }, ignoreCache) {
      const key = `${year.toString()}-${month.toString()}`;
      let page = this.pages.find(p => p.key === key);
      if (!page || ignoreCache) {
        const date = new Date(year, month - 1, 15);
        const monthComps = this.$locale.getMonthComps(month, year);
        const prevMonthComps = this.$locale.getPrevMonthComps(month, year);
        const nextMonthComps = this.$locale.getNextMonthComps(month, year);
        page = {
          key,
          month,
          year,
          weeks: this.trimWeeks ? monthComps.weeks : 6,
          title: this.$locale.format(date, this.$locale.masks.title),
          shortMonthLabel: this.$locale.format(date, 'MMM'),
          monthLabel: this.$locale.format(date, 'MMMM'),
          shortYearLabel: year.toString().substring(2),
          yearLabel: year.toString(),
          monthComps,
          prevMonthComps,
          nextMonthComps,
          canMove: pg => this.canMove(pg),
          move: pg => this.move(pg),
          moveThisMonth: () => this.moveThisMonth(),
          movePrevMonth: () => this.move(prevMonthComps),
          moveNextMonth: () => this.move(nextMonthComps),
          refresh: true,
        };
        // Assign day info
        page.days = this.$locale.getCalendarDays(page);
      }
      return page;
    },
    initStore() {
      // Create a new attribute store
      this.store = new AttributeStore(
        this.$theme,
        this.$locale,
        this.attributes,
      );
      // Refresh attributes for existing pages
      this.refreshAttrs(this.pages, this.store.list, [], true);
    },
    refreshAttrs(pages = [], adds = [], deletes = [], reset) {
      if (!arrayHasItems(pages)) return;
      // For each page...
      pages.forEach(p => {
        // For each day...
        p.days.forEach(d => {
          let map = {};
          // If resetting...
          if (reset) {
            d.refresh = true;
          } else if (hasAny(d.attributesMap, deletes)) {
            // Delete attributes from the delete list
            map = omit(d.attributesMap, deletes);
            // Flag day for refresh
            d.refresh = true;
          } else {
            // Get the existing attributes
            map = d.attributesMap || {};
          }
          // For each attribute to add...
          adds.forEach(attr => {
            // Add it if it includes the current day
            const targetDate = attr.intersectsDay(d);
            if (targetDate) {
              const newAttr = {
                ...attr,
                targetDate,
              };
              map[attr.key] = newAttr;
              // Flag day for refresh
              d.refresh = true;
            }
          });
          // Reassign day attributes
          if (d.refresh) {
            d.attributesMap = map;
          }
        });
      });
      // Refresh pages
      this.$nextTick(() => {
        this.$refs.pages.forEach(p => p.refresh());
      });
    },
    handleKeydown(e) {
      const day = this.lastFocusedDay;
      if (day != null) {
        day.event = e;
        this.handleDayKeydown(day);
      }
    },
    handleDayKeydown(day) {
      const { dateFromTime, event } = day;
      // Set to noon to offset any daylight savings time offset
      const date = dateFromTime(12);
      let newDate = null;
      switch (event.key) {
        case 'ArrowLeft': {
          // Move to previous day
          newDate = addDays(date, -1);
          break;
        }
        case 'ArrowRight': {
          // Move to next day
          newDate = addDays(date, 1);
          break;
        }
        case 'ArrowUp': {
          // Move to previous week
          newDate = addDays(date, -7);
          break;
        }
        case 'ArrowDown': {
          // Move to next week
          newDate = addDays(date, 7);
          break;
        }
        case 'Home': {
          // Move to first weekday position
          newDate = addDays(date, -day.weekdayPosition + 1);
          break;
        }
        case 'End': {
          // Move to last weekday position
          newDate = addDays(date, day.weekdayPositionFromEnd);
          break;
        }
        case 'PageUp': {
          if (event.altKey) {
            // Move to previous year w/ Alt/Option key
            newDate = addYears(date, -1);
          } else {
            // Move to previous month
            newDate = addMonths(date, -1);
          }
          break;
        }
        case 'PageDown': {
          if (event.altKey) {
            // Move to next year w/ Alt/Option key
            newDate = addYears(date, 1);
          } else {
            // Move to next month
            newDate = addMonths(date, 1);
          }
          break;
        }
      }
      if (newDate) {
        event.preventDefault();
        this.focusDate(newDate).catch(() => {});
      }
    },
  },
};
</script>

<style lang="postcss">
.vc-pane-container {
  width: 100%;
  position: relative;
  &.in-transition {
    overflow: hidden;
  }
}

.vc-pane-layout {
  display: grid;
}

.vc-arrow {
  display: flex;
  justify-content: center;
  align-items: center;
  cursor: pointer;
  user-select: none;
  pointer-events: auto;
  color: var(--gray-600);
  border-width: 2px;
  border-style: solid;
  border-radius: var(--rounded);
  border-color: transparent;
  &:hover {
    background: var(--gray-200);
  }
  &:focus {
    border-color: var(--gray-300);
  }

  &.is-disabled {
    opacity: 0.25;
    pointer-events: none;
    cursor: not-allowed;
  }
}

.vc-day-popover-container {
  color: var(--white);
  background-color: var(--gray-800);
  border: 1px solid;
  border-color: var(--gray-700);
  border-radius: var(--rounded);
  font-size: var(--text-xs);
  font-weight: var(--font-medium);
  padding: 4px 8px;
  box-shadow: var(--shadow);
}

.vc-day-popover-header {
  font-size: var(--text-xs);
  color: var(--gray-300);
  font-weight: var(--font-semibold);
  text-align: center;
}

.vc-arrows-container {
  width: 100%;
  position: absolute;
  top: 0;
  display: flex;
  justify-content: space-between;
  padding: 8px 10px;
  pointer-events: none;
  &.title-left {
    justify-content: flex-end;
  }
  &.title-right {
    justify-content: flex-start;
  }
}

.vc-is-dark {
  & .vc-arrow {
    color: var(--white);
    &:hover {
      background: var(--gray-800);
    }
    &:focus {
      border-color: var(--gray-700);
    }
  }
  & .vc-day-popover-container {
    color: var(--gray-800);
    background-color: var(--white);
    border-color: var(--gray-100);
  }
  & .vc-day-popover-header {
    color: var(--gray-700);
  }
}
</style>


Remove duplicate action button

Remove duplicate action button

Signed-off-by: Julius Härtl <bf353fa4999f2f148afcc6d8ee6cb1ee74cc07c3@bitgrid.net>

<template>

{{ board.title }}

				({{ t('deck', 'Archived cards') }})

				<Actions v-if="!isAddStackVisible" :title="t('deck', 'Add new list')">
					<ActionButton icon="icon-add" @click.stop="showAddStack" />
				</Actions>
				<form v-else @submit.prevent="addNewStack()">
					{{ t('deck', 'Add new list') }}
					<input id="new-stack-input-main"
						v-model="newStackTitle"
						v-focus
						type="text"
						class="no-close"
						:placeholder="t('deck', 'List name')"
						required>
					<input v-tooltip="t('deck', 'Add new list')"
						class="icon-confirm"
						type="submit"
						value="">
				</form>

				<Popover>
					<Actions slot="trigger" :style="filterOpacity" :title="t('deck', 'Apply filter')">
						<ActionButton icon="icon-filter" />
					</Actions>

					<template>

{{ t('deck', 'Filter by tag') }}

							{{ filter }}

								<input
									:id="label.id"
									v-model="filter.tags"
									type="checkbox"
									class="checkbox"
									:value="label.id"
									@change="setFilter">
								{{ label.title }}

{{ t('deck', 'Filter by assigned user') }}

								<input
									:id="user.uid"
									v-model="filter.users"
									type="checkbox"
									class="checkbox"
									:value="user.uid"
									@change="setFilter">
								<Avatar :user="user.uid" :size="24" :disable-menu="true" /> {{ user.displayname }}

{{ t('deck', 'Filter by due date') }}

								<input
									id="overdue"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="overdue"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Overdue') }}

								<input
									id="dueToday"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueToday"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Today') }}

								<input
									id="dueWeek"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueWeek"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 7 days') }}

								<input
									id="dueMonth"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueMonth"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 30 days') }}

								<input
									id="noDue"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="noDue"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'No due date') }}

					</template>
				</Popover>

				<Actions :style="archivedOpacity" :title="t('deck', 'Show archived cards')">
					<ActionButton
						icon="icon-archive"
						@click="toggleShowArchived" />
				</Actions>
				<Actions :title="t('deck', 'Toggle compact mode')">
					<ActionButton v-if="compactMode"
						icon="icon-toggle-compact-collapsed"
						@click="toggleCompactMode" />
					<ActionButton v-else
						icon="icon-toggle-compact-expanded"
						@click="toggleCompactMode" />
				</Actions>

				<Actions :title="t('deck', 'Details')">
					<ActionButton icon="icon-menu-sidebar" @click="toggleDetailsView" />
				</Actions>

</template>

<template>

{{ board.title }}

				({{ t('deck', 'Archived cards') }})

				<Actions v-if="!isAddStackVisible" :title="t('deck', 'Add new list')">
					<ActionButton icon="icon-add" @click.stop="showAddStack" />
				</Actions>
				<form v-else @submit.prevent="addNewStack()">
					{{ t('deck', 'Add new list') }}
					<input id="new-stack-input-main"
						v-model="newStackTitle"
						v-focus
						type="text"
						class="no-close"
						:placeholder="t('deck', 'List name')"
						required>
					<input v-tooltip="t('deck', 'Add new list')"
						class="icon-confirm"
						type="submit"
						value="">
				</form>

				<Popover>
					<Actions slot="trigger" :style="filterOpacity" :title="t('deck', 'Apply filter')">
						<ActionButton icon="icon-filter" />
					</Actions>

					<template>

{{ t('deck', 'Filter by tag') }}

							{{ filter }}

								<input
									:id="label.id"
									v-model="filter.tags"
									type="checkbox"
									class="checkbox"
									:value="label.id"
									@change="setFilter">
								{{ label.title }}

{{ t('deck', 'Filter by assigned user') }}

								<input
									:id="user.uid"
									v-model="filter.users"
									type="checkbox"
									class="checkbox"
									:value="user.uid"
									@change="setFilter">
								<Avatar :user="user.uid" :size="24" :disable-menu="true" /> {{ user.displayname }}

{{ t('deck', 'Filter by due date') }}

								<input
									id="overdue"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="overdue"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Overdue') }}

								<input
									id="dueToday"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueToday"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Today') }}

								<input
									id="dueWeek"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueWeek"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 7 days') }}

								<input
									id="dueMonth"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueMonth"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 30 days') }}

								<input
									id="noDue"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="noDue"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'No due date') }}

					</template>
				</Popover>

				<Actions :style="archivedOpacity" :title="t('deck', 'Show archived cards')">
					<ActionButton v-if="showArchived"
						icon="icon-archive"
						@click="toggleShowArchived" />
					<ActionButton v-else
						icon="icon-archive"
						@click="toggleShowArchived" />
				</Actions>
				<Actions :title="t('deck', 'Toggle compact mode')">
					<ActionButton v-if="compactMode"
						icon="icon-toggle-compact-collapsed"
						@click="toggleCompactMode" />
					<ActionButton v-else
						icon="icon-toggle-compact-expanded"
						@click="toggleCompactMode" />
				</Actions>

				<Actions :title="t('deck', 'Details')">
					<ActionButton icon="icon-menu-sidebar" @click="toggleDetailsView" />
				</Actions>

</template>


Update Controls.vue

Update Controls.vue

Add padding to filter popup.

<template>

{{ board.title }}

				({{ t('deck', 'Archived cards') }})

{{ overviewName }}

				<Actions v-if="!isAddStackVisible">
					<ActionButton icon="icon-add" @click.stop="showAddStack">
						{{ t('deck', 'Add list') }}
					</ActionButton>
				</Actions>
				<form v-else @submit.prevent="addNewStack()">
					{{ t('deck', 'Add list') }}
					<input id="new-stack-input-main"
						v-model="newStackTitle"
						v-focus
						type="text"
						class="no-close"
						:placeholder="t('deck', 'List name')"
						required>
					<input v-tooltip="t('deck', 'Add list')"
						class="icon-confirm"
						type="submit"
						value="">
				</form>

				<Popover>
					<Actions slot="trigger" :title="t('deck', 'Apply filter')">
						<ActionButton v-if="isFilterActive" icon="icon-filter_set" />
						<ActionButton v-else icon="icon-filter" />
					</Actions>

					<template>

{{ t('deck', 'Filter by tag') }}

								<input
									:id="label.id"
									v-model="filter.tags"
									type="checkbox"
									class="checkbox"
									:value="label.id"
									@change="setFilter">
								{{ label.title }}

{{ t('deck', 'Filter by assigned user') }}

								<input
									id="unassigned"
									v-model="filter.unassigned"
									type="checkbox"
									class="checkbox"
									value="unassigned"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Unassigned') }}

								<input
									:id="user.uid"
									v-model="filter.users"
									type="checkbox"
									class="checkbox"
									:value="user.uid"
									@change="setFilter">
								<Avatar :user="user.uid" :size="24" :disable-menu="true" /> {{ user.displayname }}

{{ t('deck', 'Filter by due date') }}

								<input
									id="overdue"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="overdue"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Overdue') }}

								<input
									id="dueToday"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueToday"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 24 hours') }}

								<input
									id="dueWeek"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueWeek"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 7 days') }}

								<input
									id="dueMonth"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="dueMonth"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'Next 30 days') }}

								<input
									id="noDue"
									v-model="filter.due"
									type="radio"
									class="radio"
									value="noDue"
									@change="setFilter"
									@click="beforeSetFilter">
								{{ t('deck', 'No due date') }}

							<Button :disabled="!isFilterActive" @click="clearFilter">
								{{ t('deck', 'Clear filter') }}
							</Button>

					</template>
				</Popover>

				<Actions :style="archivedOpacity">
					<ActionButton
						icon="icon-archive"
						@click="toggleShowArchived">
						{{ showArchived ? t('deck', 'Hide archived cards') : t('deck', 'Show archived cards') }}
					</ActionButton>
					<ActionButton v-if="compactMode"
						icon="icon-toggle-compact-collapsed"
						@click="toggleCompactMode">
						{{ t('deck', 'Toggle compact mode') }}
					</ActionButton>
					<ActionButton v-else
						icon="icon-toggle-compact-expanded"
						@click="toggleCompactMode">
						{{ t('deck', 'Toggle compact mode') }}
					</ActionButton>
				</Actions>

				<Actions :title="t('deck', 'Details')">
					<ActionButton icon="icon-menu-sidebar" @click="toggleDetailsView" />
				</Actions>

</template>


Add button to save fragment changes

Add button to save fragment changes

<template>

        <subtle-input :model="fragment" :placeholder="fragment['@id']||'Fatal error'"></subtle-input>
         {{fragment['@id']}}

{{fragment|json}}

      <props-list :fragmentprops.sync="fragment"></props-list>

        <button v-if="!addPropShow" class="btn-add" @click="addPropShow=1">Add</button>
        <input v-if="addPropShow" v-model="addPropSearch" placeholder="property..." @change="loadProps" @focus="addPropShow=1;loadProps()" class="inp-prop" required>
        <input v-if="addPropShow" v-model="addPropValue" placeholder="value..." @change="guessProp" @focus="addPropShow=0" required>

      <button class="btn btn-save" :class="{'btn-save':savable,'btn-soft':!savable}" @click="sync">Sync</button>

</template>

<template>

        <subtle-input :model="fragment" :placeholder="fragment['@id']||'Fatal error'"></subtle-input>
         {{fragment['@id']}}

{{fragment|json}}

      <props-list :fragmentprops.sync="fragment"></props-list>

        <button v-if="!addPropShow" class="btn-add" @click="addPropShow=1">Add</button>
        <input v-if="addPropShow" v-model="addPropSearch" placeholder="property..." @change="loadProps" @focus="addPropShow=1;loadProps()" class="inp-prop" required>
        <input v-if="addPropShow" v-model="addPropValue" placeholder="value..." @change="guessProp" @focus="addPropShow=0" required>

</template>


Nicklist user count using new buffer users structure

<template>

{{$t('person', {count: bufferUsers.length})}}

                {{userModePrefix(user)}}{{user.nick}}

</template>

<template>

{{$t('person', {count: buffer.users.length})}}

                {{userModePrefix(user)}}{{user.nick}}

</template>


disable clear button if no files added

disable clear button if no files added

<template>

        <slot name="browse-btn">
          Browse
        </slot>

{{ files[0].error }}

        <slot name="browse-btn">
          Browse
        </slot>

      <input type="file" :multiple="multiple" :accept="accept" @change="addFiles">

      <button type="button" class="vuejs-uploader__btn" @click="clear" :disabled="noFiles">
        <slot name="clear-btn">Clear</slot>
      </button>
      <button type="button" class="vuejs-uploader__btn" @click="upload" :disabled="isDisabled">
        <slot name="upload-btn">Upload</slot>
      </button>

{{ errorMessage }}

          {{ fileObj.extension }}

{{ fileObj.file.name }}

{{ fileObj.formattedFilesize }}

{{ fileObj.error }}

          <slot name="extra" :fileObj="fileObj"></slot>

          <button type="button" class="vuejs-uploader__btn vuejs-uploader__btn--delete" @click="removeFile(fileObj)">
            <slot name="remove-btn">Remove</slot>
          </button>

</template>

<template>

        <slot name="browse-btn">
          Browse
        </slot>

{{ files[0].error }}

        <slot name="browse-btn">
          Browse
        </slot>

      <input type="file" :multiple="multiple" :accept="accept" @change="addFiles">

      <button type="button" class="vuejs-uploader__btn" @click="clear">
        <slot name="clear-btn">Clear</slot>
      </button>
      <button type="button" class="vuejs-uploader__btn" @click="upload" :disabled="isDisabled">
        <slot name="upload-btn">Upload</slot>
      </button>

{{ errorMessage }}

          {{ fileObj.extension }}

{{ fileObj.file.name }}

{{ fileObj.formattedFilesize }}

{{ fileObj.error }}

          <slot name="extra" :fileObj="fileObj"></slot>

          <button type="button" class="vuejs-uploader__btn vuejs-uploader__btn--delete" @click="removeFile(fileObj)">
            <slot name="remove-btn">Remove</slot>
          </button>

</template>


move error outside of slot so it not overwritten

<template>

        <slot name="browse">
          Browse
        </slot>

{{ files[0].error }}

      Browse

      <input type="file" :multiple="multiple" @change="addFiles">

      <button type="button" class="vuejs-uploader__btn" @click="upload">Upload</button>
      <button type="button" class="vuejs-uploader__btn" @click="clear">Clear</button>

{{ errorMessage }}

{{ fileObj.file.name }}

{{ fileSize(fileObj.file.size) }}

{{ fileObj.error }}

          <button type="button" class="vuejs-uploader__btn vuejs-uploader__btn--delete" @click="removeFile(fileObj)">Remove</button>

</template>

<template>

      <slot name="browse" v-if="isSingleFileUpload">
        Browse

{{ files[0].error }}

      </slot>

      Browse

      <input type="file" :multiple="multiple" @change="addFiles">

      <button type="button" class="vuejs-uploader__btn" @click="upload">Upload</button>
      <button type="button" class="vuejs-uploader__btn" @click="clear">Clear</button>

{{ errorMessage }}

{{ fileObj.file.name }}

{{ fileSize(fileObj.file.size) }}

{{ fileObj.error }}

          <button type="button" class="vuejs-uploader__btn vuejs-uploader__btn--delete" @click="removeFile(fileObj)">Remove</button>

</template>


Adding some boxes for the related items

Adding some boxes for the related items

This is to visualize how the version diff graph will look. Maybe I need
to try pen and paper!

<template>
			  <a-entity position='5 -10 -20' id='vr_items'>

                        <a-entity position="0 4 -1.9" id='vr_file_name_2'
                                    scale="1 1 1"
									geometry="primitive: plane; width: 3.9; height: 1.9;" material="color: white;opacity: 1;"
                                    mixin="RobotoFont"
									v-bind:text='"color: black; align: left; value:  ; width: 4; "'>
						</a-entity>
						 <a-entity position="0 3.6 -1.9" id='vr_file_size_2'
                                    scale="1 1 1"
									geometry="primitive: plane; width: 3.9; height: 1.9;" material="color: white;opacity: 1;"
                                    mixin="RobotoFont"
									v-bind:text='"color: black; align: left; value:  ; width: 4; "'>
						</a-entity>
						 <a-entity position="0 3.2 -1.9" id='vr_file_saved_as'
                                    scale="1 1 1"
									geometry="primitive: plane; width: 3.9; height: 1.9;" material="color: white;opacity: 1;"
                                    mixin="RobotoFont"
									v-bind:text='"color: black; align: left; value: ; width: 4; "'>
						</a-entity>

				<a-entity position="-1.4 4.7 .6" id=vr_file_name
						  geometry="primitive: plane; width: auto; height: auto" material="color: white"
                          mixin="RobotoFont"
						  text='color: black; align: left; value: ; width: 4;  '
						  rotation='0 0 0'>
				</a-entity>

				<a-entity position="10 -10 -10" id='doc_details'>

					<a-entity v-if='can_show_full_doc()' geometry="primitive: plane; height: 5; width: 8;" material="color: white" position='0 2.5 -1' >

						<a-entity geometry="primitive: box; width:.5;height: 0.5;depth: 0.1;" material="color: red"
						v-if='get_vr_type_mouse'  position='-4 -2.5 1.1' closedoc=''>
								<a-entity 	position="1 0 0.07"
                                            mixin="AileronFont"
											text="color: white; align: left; value: Close; width: 2; height: 1; opacity: 1;">
								</a-entity>
						</a-entity>
						<a-entity  v-if='get_vr_type_mouse' geometry="primitive: box; width:.5;height: 0.5;depth: 0.1;" material="color: green"
						position='-4 -1 1.1'
									v-bind:open_file='get_viewed_query_file()?("" + get_viewed_query_file()):false ' >
							<a-entity 	position="1 0 0.07"
                                        mixin="AileronFont"
										text="color: white; align: left; value: Open; width: 2; height: 1; opacity: 1;">
							</a-entity>
						</a-entity>

						<a-entity  v-if='get_vr_type_mouse' geometry="primitive: box; width:.5;height: 0.5;depth: 0.1;" material="color: blue"
						position='-4 -.2 1.1'
									v-bind:related_files='get_viewed_query_id()?("" + get_viewed_query_id()):false '
                                    >
							<a-entity 	position=".9 0 0.07"
                                        mixin="AileronFont"
										text="color: white; align: left; value: Related; width: 2; height: 1; opacity: 1;">
							</a-entity>
						</a-entity>

						<a-entity v-if='get_vr_type_move' geometry="primitive: box; width:.6;height: 0.6;depth: 0.6;" material="color: red"
						position='-6 -3.4 .7' closedoc='' rotation='0 40 0'>
						</a-entity>
						<a-entity v-if='get_vr_type_move' 	position="-5 -3.5 -1" rotation='0 40 0'
                                    mixin="AileronFont"
									text="color: red; align: middle; value: Close; width: 8; height: 2; opacity: 1;">
						</a-entity>

					<a-entity v-if='can_show_full_doc()' v-for="(field_name,index)  in  list_of_fields"
							  v-bind:position='(index + .5) + " -1 2.5"'
							  geometry="primitive: plane; width: auto; height: auto"
							  material="color: white"
							  rotation='0 0 0'>

                            <a-entity position='-1.5 0 0.6' v-if='!is_document(get_viewed_query_id())'
                                      mixin="AileronFont"
                                      v-bind:text='"color: black; align: left; value: " + field_name + "; width: 2; "'>
                            </a-entity>

							<a-entity v-for="(a_record,rindex)  in  list_of_records" v-if='!is_document(get_viewed_query_id())'
									  v-bind:position='"-1.5 " + (-.2 - (rindex * 0.2)) + " 0.6"'
									  geometry="primitive: plane; width: 2; height: 0.2" material="color: white"
                                      mixin="SourceCodeProFont"
									  v-bind:text='"color: black; align: left; value: " + truncate(a_record[field_name]) + "; width: 2; opacity: 1;"'
									  rotation='0 0 0'>

							</a-entity>

							<a-entity v-for="(a_record,rindex)  in  list_of_records" v-if='is_document(get_viewed_query_id())'
									  v-bind:position='"1 " + (-.2 - (rindex * 0.2)) + " 0.6"'
									  geometry="primitive: plane; width: 6; height: 0.2" material="color: white"
                                      mixin="SourceCodeProFont"
									  v-bind:text='"color: black; align: left; value: " + truncate2(a_record[field_name]) + "; width: 6; opacity: 1; wrapPixels: 2000; "'
									  rotation='0 0 0'>

							</a-entity>

					</a-entity>
						</a-entity>
					</a-entity>

<a-entity   id='related_items'
            position="-200 0 0"
            >
    <a-entity
                position="-.5 2.4 0"
                text="color: black; align: left; value: Related items ; width: 4; ">
    </a-entity>
    <a-box   witdth=1 height=0.4 depth=1 color=blue position="-1 1.4 0"></a-box>
    <a-box   witdth=1 height=0.4 depth=1 color=green position="0.1 .7 0"></a-box>

</a-entity>

<a-entity id='scrollable_grid' v-bind:refresh_vr_items='get_refresh_view_counter'>

				<a-entity  v-for="(a_driver,index)  in  list_of_queries"
                    v-bind:id='a_driver.id + "_upper"'
				   v-bind:position="((is_visible(a_driver.id)?-0.8:100) + (get_x_position(get_index(a_driver.id),list_of_queries.length)*0.5))+ ' ' + (1.5 - (get_y_position(get_index(a_driver.id),list_of_queries.length)*0.6)) + ' -.1'"
				   v-bind:color="(get_index(a_driver.id) % 2 == 0)?'blue':'green'"
				   v-bind:text="'color: black; align: left; value: ' + a_driver.name.substr(a_driver.name.length - 10) + ' ; width: 2; '">
					   <a-entity    position='-0.8 .3 0'
                                    v-bind:id='a_driver.id + "_mid"'
									geometry="primitive: plane; width:.35;height: 0.35;"
							        v-bind:griditem='"x: " + get_x_position(get_index(a_driver.id),list_of_queries.length) + "; y:" + get_y_position(get_index(a_driver.id),list_of_queries.length) + ";" +
								    "   query_name: " + a_driver.name +
								    ";  query_id: " + a_driver.id +
                                    ";  query_saved_as: " + (a_driver.hash?(a_driver.hash + (a_driver.fileName?"." + a_driver.fileName.split(".").pop():"")):"") +
					    			";  query_display: " + "" + a_driver.fileName +
                                    ";  query_size: " + a_driver.size + "; " '
								mixin='gsd'
								v-bind:material2='"src: driver_icons/" + a_driver.driver + ".jpg;"'
								v-bind:material='"color: gray;"'
								v-bind:color="(get_index(a_driver.id) % 2 == 0)?'blue':'green'"
								v-bind:log='"x: " + get_x_position(index,list_of_queries.length) + "; y:" + get_y_position(index,list_of_queries.length) + ";queryFile: " + a_driver.hash + (a_driver.fileName?"." +a_driver.fileName.split(".").pop():"") +
                                ";queryId: "  + a_driver.id + ";"'
                                >
								<a-animation begin="mouseenter" attribute="rotation"
                                    v-bind:id='a_driver.id + "_anim"'
												to="0 0 5" dur="90" direction="alternate"  repeat="3"></a-animation>
						</a-entity>
			   </a-entity>
</a-entity >

			</a-entity>
</template>

<script>
import output_table             from './output_table.vue'

export default {
name: 'VR-items'
		,
			props: ['vr_type'],

  computed: {
  get_refresh_view_counter: function () {
      return this.$store.state.refresh_view_counter;
  },
    list_of_records: function () {
	if (this.$store.state.list_of_output_records) {
		var newl=new Object();
		for (var i=0; i<10;i++){
			if (this.$store.state.list_of_output_records[i]){
			     newl[i]=this.$store.state.list_of_output_records[i];
				 };
		}
		return newl;
		} else {
		return [];
	};
    },
    get_vr_type: function () {
      return this.vr_type;
    },
    get_vr_type_mouse: function () {
      return this.vr_type == 'mouse';
    },
    get_vr_type_move: function () {
      return this.vr_type == 'move';
    },
    list_of_queries: function () {
      return this.$store.getters.list_of_queries
    },
    list_of_drivers: function () {
      return this.$store.getters.list_of_drivers
    },

    add_driver_visible: function () {
      return this.$store.state.add_driver_visible
    },

    viewed_driver_id: function () {
      return this.$store.state.viewed_driver_id
    },
	    list_of_fields: function () {
      return this.$store.state.list_of_output_fields
    }

	},
	methods: {
	get_viewed_query_id: function() {
	    return this.$store.state.viewed_query_id;
	},
	get_viewed_query_file: function() {
	    return this.$store.state.viewed_query_file;
	},
    can_show_full_doc: function() {
		return this.$store.state.show_full_doc;
	},
		get_x_position: function(index, total) {
		var cols = (Math.ceil(Math.sqrt(total)));
		return index % cols;
	},
	truncate: function(txt) {
	    return (txt?txt.toString().substring(0,10):'');
	},
	truncate2: function(txt) {
	    return (txt?txt.toString().substring(0,100):'');
	},
	get_y_position: function(index, total) {
		var cols = (Math.ceil(Math.sqrt(total)));
		var rawQuotient = index / cols;
		var remainder = rawQuotient % 1;
		var quotient = rawQuotient - remainder;
		//console.log('get_y_position( ' + index + ', ' + total + ') = ' + quotient);
		return quotient ;
	},
    is_document: function (id) {
        var qq = this.$store.getters.list_of_queries;
        for (var i =0 ; i < qq.length; i++) {
            var rt = qq[i];
            if (rt.id == id) {
                //console.log("rt.driver: " + rt.type)
                if (rt.type.indexOf("DOCUMENT") != -1) {
                    return true;
                } else {
                    return false;
                }
            }
        }
        //console.log("rt.fileName  not found: ")
        return false;
    },
    is_visible: function(id) {
        var qm = this.$store.state.query_map[id];
        if (!qm) {
            return false;
        }
        //return false;
        console.log(" this.$store.state.query_map[id].visible = " + this.$store.state.query_map[id].visible);
        return this.$store.state.query_map[id].visible;
    },
    get_index: function(id) {
        var qm = this.$store.state.query_map[id];
        if (!qm) {
            return -1;
        }
        //return false;
        return this.$store.state.query_map[id].index;
    }

  },
  components: {
	  'output-table': output_table
	}

}
</script>

<style>
</style>

<template>
			  <a-entity position='5 -10 -20' id='vr_items'>

                        <a-entity position="0 4 -1.9" id='vr_file_name_2'
                                    scale="1 1 1"
									geometry="primitive: plane; width: 3.9; height: 1.9;" material="color: white;opacity: 1;"
                                    mixin="RobotoFont"
									v-bind:text='"color: black; align: left; value:  ; width: 4; "'>
						</a-entity>
						 <a-entity position="0 3.6 -1.9" id='vr_file_size_2'
                                    scale="1 1 1"
									geometry="primitive: plane; width: 3.9; height: 1.9;" material="color: white;opacity: 1;"
                                    mixin="RobotoFont"
									v-bind:text='"color: black; align: left; value:  ; width: 4; "'>
						</a-entity>
						 <a-entity position="0 3.2 -1.9" id='vr_file_saved_as'
                                    scale="1 1 1"
									geometry="primitive: plane; width: 3.9; height: 1.9;" material="color: white;opacity: 1;"
                                    mixin="RobotoFont"
									v-bind:text='"color: black; align: left; value: ; width: 4; "'>
						</a-entity>

				<a-entity position="-1.4 4.7 .6" id=vr_file_name
						  geometry="primitive: plane; width: auto; height: auto" material="color: white"
                          mixin="RobotoFont"
						  text='color: black; align: left; value: ; width: 4;  '
						  rotation='0 0 0'>
				</a-entity>

				<a-entity position="10 -10 -10" id='doc_details'>

					<a-entity v-if='can_show_full_doc()' geometry="primitive: plane; height: 5; width: 8;" material="color: white" position='0 2.5 -1' >

						<a-entity geometry="primitive: box; width:.5;height: 0.5;depth: 0.1;" material="color: red"
						v-if='get_vr_type_mouse'  position='-4 -2.5 1.1' closedoc=''>
								<a-entity 	position="1 0 0.07"
                                            mixin="AileronFont"
											text="color: white; align: left; value: Close; width: 2; height: 1; opacity: 1;">
								</a-entity>
						</a-entity>
						<a-entity  v-if='get_vr_type_mouse' geometry="primitive: box; width:.5;height: 0.5;depth: 0.1;" material="color: green"
						position='-4 -1 1.1'
									v-bind:open_file='get_viewed_query_file()?("" + get_viewed_query_file()):false ' >
							<a-entity 	position="1 0 0.07"
                                        mixin="AileronFont"
										text="color: white; align: left; value: Open; width: 2; height: 1; opacity: 1;">
							</a-entity>
						</a-entity>

						<a-entity  v-if='get_vr_type_mouse' geometry="primitive: box; width:.5;height: 0.5;depth: 0.1;" material="color: blue"
						position='-4 -.2 1.1'
									v-bind:related_files='get_viewed_query_id()?("" + get_viewed_query_id()):false '
                                    >
							<a-entity 	position=".9 0 0.07"
                                        mixin="AileronFont"
										text="color: white; align: left; value: Related; width: 2; height: 1; opacity: 1;">
							</a-entity>
						</a-entity>

						<a-entity v-if='get_vr_type_move' geometry="primitive: box; width:.6;height: 0.6;depth: 0.6;" material="color: red"
						position='-6 -3.4 .7' closedoc='' rotation='0 40 0'>
						</a-entity>
						<a-entity v-if='get_vr_type_move' 	position="-5 -3.5 -1" rotation='0 40 0'
                                    mixin="AileronFont"
									text="color: red; align: middle; value: Close; width: 8; height: 2; opacity: 1;">
						</a-entity>

					<a-entity v-if='can_show_full_doc()' v-for="(field_name,index)  in  list_of_fields"
							  v-bind:position='(index + .5) + " -1 2.5"'
							  geometry="primitive: plane; width: auto; height: auto"
							  material="color: white"
							  rotation='0 0 0'>

                            <a-entity position='-1.5 0 0.6' v-if='!is_document(get_viewed_query_id())'
                                      mixin="AileronFont"
                                      v-bind:text='"color: black; align: left; value: " + field_name + "; width: 2; "'>
                            </a-entity>

							<a-entity v-for="(a_record,rindex)  in  list_of_records" v-if='!is_document(get_viewed_query_id())'
									  v-bind:position='"-1.5 " + (-.2 - (rindex * 0.2)) + " 0.6"'
									  geometry="primitive: plane; width: 2; height: 0.2" material="color: white"
                                      mixin="SourceCodeProFont"
									  v-bind:text='"color: black; align: left; value: " + truncate(a_record[field_name]) + "; width: 2; opacity: 1;"'
									  rotation='0 0 0'>

							</a-entity>

							<a-entity v-for="(a_record,rindex)  in  list_of_records" v-if='is_document(get_viewed_query_id())'
									  v-bind:position='"1 " + (-.2 - (rindex * 0.2)) + " 0.6"'
									  geometry="primitive: plane; width: 6; height: 0.2" material="color: white"
                                      mixin="SourceCodeProFont"
									  v-bind:text='"color: black; align: left; value: " + truncate2(a_record[field_name]) + "; width: 6; opacity: 1; wrapPixels: 2000; "'
									  rotation='0 0 0'>

							</a-entity>

					</a-entity>
						</a-entity>
					</a-entity>

<a-entity   id='related_items'
            position="-200 0 0"
            >
    <a-entity
                position="-.5 2.4 0"
                text="color: black; align: left; value: Related items ; width: 4; ">
    </a-entity>
</a-entity>

<a-entity id='scrollable_grid' v-bind:refresh_vr_items='get_refresh_view_counter'>

				<a-entity  v-for="(a_driver,index)  in  list_of_queries"
                    v-bind:id='a_driver.id + "_upper"'
				   v-bind:position="((is_visible(a_driver.id)?-0.8:100) + (get_x_position(get_index(a_driver.id),list_of_queries.length)*0.5))+ ' ' + (1.5 - (get_y_position(get_index(a_driver.id),list_of_queries.length)*0.6)) + ' -.1'"
				   v-bind:color="(get_index(a_driver.id) % 2 == 0)?'blue':'green'"
				   v-bind:text="'color: black; align: left; value: ' + a_driver.name.substr(a_driver.name.length - 10) + ' ; width: 2; '">
					   <a-entity    position='-0.8 .3 0'
                                    v-bind:id='a_driver.id + "_mid"'
									geometry="primitive: plane; width:.35;height: 0.35;"
							        v-bind:griditem='"x: " + get_x_position(get_index(a_driver.id),list_of_queries.length) + "; y:" + get_y_position(get_index(a_driver.id),list_of_queries.length) + ";" +
								    "   query_name: " + a_driver.name +
								    ";  query_id: " + a_driver.id +
                                    ";  query_saved_as: " + (a_driver.hash?(a_driver.hash + (a_driver.fileName?"." + a_driver.fileName.split(".").pop():"")):"") +
					    			";  query_display: " + "" + a_driver.fileName +
                                    ";  query_size: " + a_driver.size + "; " '
								mixin='gsd'
								v-bind:material2='"src: driver_icons/" + a_driver.driver + ".jpg;"'
								v-bind:material='"color: gray;"'
								v-bind:color="(get_index(a_driver.id) % 2 == 0)?'blue':'green'"
								v-bind:log='"x: " + get_x_position(index,list_of_queries.length) + "; y:" + get_y_position(index,list_of_queries.length) + ";queryFile: " + a_driver.hash + (a_driver.fileName?"." +a_driver.fileName.split(".").pop():"") +
                                ";queryId: "  + a_driver.id + ";"'
                                >
								<a-animation begin="mouseenter" attribute="rotation"
                                    v-bind:id='a_driver.id + "_anim"'
												to="0 0 5" dur="90" direction="alternate"  repeat="3"></a-animation>
						</a-entity>
			   </a-entity>
</a-entity >

			</a-entity>
</template>

<script>
import output_table             from './output_table.vue'

export default {
name: 'VR-items'
		,
			props: ['vr_type'],

  computed: {
  get_refresh_view_counter: function () {
      return this.$store.state.refresh_view_counter;
  },
    list_of_records: function () {
	if (this.$store.state.list_of_output_records) {
		var newl=new Object();
		for (var i=0; i<10;i++){
			if (this.$store.state.list_of_output_records[i]){
			     newl[i]=this.$store.state.list_of_output_records[i];
				 };
		}
		return newl;
		} else {
		return [];
	};
    },
    get_vr_type: function () {
      return this.vr_type;
    },
    get_vr_type_mouse: function () {
      return this.vr_type == 'mouse';
    },
    get_vr_type_move: function () {
      return this.vr_type == 'move';
    },
    list_of_queries: function () {
      return this.$store.getters.list_of_queries
    },
    list_of_drivers: function () {
      return this.$store.getters.list_of_drivers
    },

    add_driver_visible: function () {
      return this.$store.state.add_driver_visible
    },

    viewed_driver_id: function () {
      return this.$store.state.viewed_driver_id
    },
	    list_of_fields: function () {
      return this.$store.state.list_of_output_fields
    }

	},
	methods: {
	get_viewed_query_id: function() {
	    return this.$store.state.viewed_query_id;
	},
	get_viewed_query_file: function() {
	    return this.$store.state.viewed_query_file;
	},
    can_show_full_doc: function() {
		return this.$store.state.show_full_doc;
	},
		get_x_position: function(index, total) {
		var cols = (Math.ceil(Math.sqrt(total)));
		return index % cols;
	},
	truncate: function(txt) {
	    return (txt?txt.toString().substring(0,10):'');
	},
	truncate2: function(txt) {
	    return (txt?txt.toString().substring(0,100):'');
	},
	get_y_position: function(index, total) {
		var cols = (Math.ceil(Math.sqrt(total)));
		var rawQuotient = index / cols;
		var remainder = rawQuotient % 1;
		var quotient = rawQuotient - remainder;
		//console.log('get_y_position( ' + index + ', ' + total + ') = ' + quotient);
		return quotient ;
	},
    is_document: function (id) {
        var qq = this.$store.getters.list_of_queries;
        for (var i =0 ; i < qq.length; i++) {
            var rt = qq[i];
            if (rt.id == id) {
                //console.log("rt.driver: " + rt.type)
                if (rt.type.indexOf("DOCUMENT") != -1) {
                    return true;
                } else {
                    return false;
                }
            }
        }
        //console.log("rt.fileName  not found: ")
        return false;
    },
    is_visible: function(id) {
        var qm = this.$store.state.query_map[id];
        if (!qm) {
            return false;
        }
        //return false;
        console.log(" this.$store.state.query_map[id].visible = " + this.$store.state.query_map[id].visible);
        return this.$store.state.query_map[id].visible;
    },
    get_index: function(id) {
        var qm = this.$store.state.query_map[id];
        if (!qm) {
            return -1;
        }
        //return false;
        return this.$store.state.query_map[id].index;
    }

  },
  components: {
	  'output-table': output_table
	}

}
</script>

<style>
</style>


fix transition-group breaks detail row component rendering

<template>

        <template v-for="field in tableFields">
          <template v-if="field.visible">
            <template v-if="isSpecialField(field.name)">

                <input type="checkbox" @change="toggleAllCheckboxes(field.name, $event)"
                  :checked="checkCheckboxesState(field.name)">

            </template>
            <template v-else>

            </template>
          </template>
        </template>

      <template v-for="(item, index) in tableData">

          <template v-for="field in tableFields">
            <template v-if="field.visible">
              <template v-if="isSpecialField(field.name)">

                  <input type="checkbox"
                    @change="toggleCheckbox(item, field.name, $event)"
                    :checked="rowSelected(item, field.name)">

                    <component :is="extractArgs(field.name)" :row-data="item" :row-index="index"></component>

                  <slot :name="extractArgs(field.name)" :row-data="item" :row-index="index"></slot>

              </template>
              <template v-else>

              </template>
            </template>
          </template>

        <template v-if="useDetailRow">

            <transition :name="detailRowTransition">

                <component :is="detailRowComponent" :row-data="item" :row-index="index"></component>

            </transition>

        </template>
      </template>

</template>


修复request

修复request

<template>

        <el-row>
            <el-col class="chart-container">

                    <el-date-picker
                        v-model="timeRange"
                        type="daterange"
                        placeholder="选择日期范围"
                        range-separator = ':'
                        @change="selectOther">
                    </el-date-picker>

             </el-col>
        </el-row>
        <el-row >
            <el-col  class="chart-container">

            </el-col>
        </el-row>

        <el-row >
            <el-col :xs="24" :sm="24" :md="24" :lg="24" class="chart-container">

            </el-col>
        </el-row>

        <el-row >
            <el-col :xs="24" :sm="24" :md="24" :lg="24" class="chart-container">

            </el-col>
        </el-row>
        <el-row>
             <el-col  class="chart-container">

                    <el-date-picker
                        v-model="year"
                        type="year"
                        placeholder="选择年"
                        @change="selectYearMonth">
                    </el-date-picker>

             </el-col>
        </el-row>
        <el-row>
            <el-col class="chart-container">

            </el-col>
        </el-row>

</template>


[Rubrics] Explicitly set weight as a number input

<i18n>
{
    "en": {
        "back-to-rubric": "Back to rubric",
        "close": "Close",
        "criterium": "Criterium",
        "formatting": "Formatting",
        "weight": "Weight"
    },
    "fr": {
        "back-to-rubric": "Retour à la rubrique",
        "close": "Fermer",
        "criterium": "Critère",
        "formatting": "Mise en forme",
        "weight": "Poids"
    },
    "nl": {
        "back-to-rubric": "Terug naar rubric",
        "close": "Sluiten",
        "criterium": "Criterium",
        "formatting": "Opmaakhulp",
        "weight": "Gewicht"
    }
}
</i18n>

<template>

        <transition name="border-flash" mode="out-in">

                        <button class="btn-close" :aria-label="$t('close')" :title="$t('close')" @click="$emit('close')"></button>

                            {{ $t('criterium') }}:
                            <textarea id="criterium-title" name="title" v-model="criterium.title" ref="criteriumTitleField" class="input-detail" @input="onCriteriumChange"></textarea>

                            <template v-if="rubric.useRelativeWeights">
                                {{ $t('weight') }}: <span :style="rubric.eqRestWeight < 0 && 'color: red'">{{ criterium.rel_weight === null ? rubric.eqRestWeight.toLocaleString() : criterium.rel_weight }} % <i v-if="rubric.eqRestWeight < 0" class="fa fa-exclamation-circle" style="color: red;" aria-hidden="true">
                            </template>
                            <template v-else>
                                {{ $t('weight') }}:
                                <input type="number" id="weight" v-model.number="criterium.weight" class="input-detail" @input="onWeightChange" min="0" max="100" required /> %
                            </template>

{{ $t('formatting') }}

                            <criterium-level-view :rubric="rubric" :criterium="criterium" :level="level" @input="updateHeight" @change="onChoiceChange($event, criterium, level)"></criterium-level-view>

                     {{ $t('back-to-rubric') }}

                <formatting-help v-if="showFormatting" @close="showFormatting = false"></formatting-help>

        </transition>

</template>

<i18n>
{
    "en": {
        "back-to-rubric": "Back to rubric",
        "close": "Close",
        "criterium": "Criterium",
        "formatting": "Formatting",
        "weight": "Weight"
    },
    "fr": {
        "back-to-rubric": "Retour à la rubrique",
        "close": "Fermer",
        "criterium": "Critère",
        "formatting": "Mise en forme",
        "weight": "Poids"
    },
    "nl": {
        "back-to-rubric": "Terug naar rubric",
        "close": "Sluiten",
        "criterium": "Criterium",
        "formatting": "Opmaakhulp",
        "weight": "Gewicht"
    }
}
</i18n>

<template>

        <transition name="border-flash" mode="out-in">

                        <button class="btn-close" :aria-label="$t('close')" :title="$t('close')" @click="$emit('close')"></button>

                            {{ $t('criterium') }}:
                            <textarea id="criterium-title" name="title" v-model="criterium.title" ref="criteriumTitleField" class="input-detail" @input="onCriteriumChange"></textarea>

                            <template v-if="rubric.useRelativeWeights">
                                {{ $t('weight') }}: <span :style="rubric.eqRestWeight < 0 && 'color: red'">{{ criterium.rel_weight === null ? rubric.eqRestWeight.toLocaleString() : criterium.rel_weight }} % <i v-if="rubric.eqRestWeight < 0" class="fa fa-exclamation-circle" style="color: red;" aria-hidden="true">
                            </template>
                            <template v-else>
                                {{ $t('weight') }}:
                                <input id="weight" v-model.number="criterium.weight" class="input-detail" @input="onWeightChange" min="0" max="100" required /> %
                            </template>

{{ $t('formatting') }}

                            <criterium-level-view :rubric="rubric" :criterium="criterium" :level="level" @input="updateHeight" @change="onChoiceChange($event, criterium, level)"></criterium-level-view>

                     {{ $t('back-to-rubric') }}

                <formatting-help v-if="showFormatting" @close="showFormatting = false"></formatting-help>

        </transition>

</template>


Hides more menu template code

Hides more menu template code


목록 작성자 이름이 나오도록 변경

목록 작성자 이름이 나오도록 변경

<template>

    <v-layout row justify-space-between>

Sample Board

      <v-spacer></v-spacer>
      <v-btn primary @click="goToPostForm" v-if="editable">새글 쓰기</v-btn>
    </v-layout>

    <v-data-table
      class="elevation-1"
      v-model="selected"
      select-all
      selected-key="name"
      :headers="headers"
      :items="posts"
      hide-actions
      :pagination.sync="pagination"
    >
      <template slot="headers" scope="props">

            {{ header.text }}

      </template>
      <template slot="items" scope="props">

          {{ props.item.seq }}
          {{ props.item.subject }}

              {{ props.item.creator.name }}

          {{ formatDate(props.item.createdAt) }}

      </template>
    </v-data-table>

      <v-pagination v-model="pagination.page" :length="pages"></v-pagination>

</template>

<template>

    <v-layout row justify-space-between>

Sample Board

      <v-spacer></v-spacer>
      <v-btn primary @click="goToPostForm" v-if="editable">새글 쓰기</v-btn>
    </v-layout>

    <v-data-table
      class="elevation-1"
      v-model="selected"
      select-all
      selected-key="name"
      :headers="headers"
      :items="posts"
      hide-actions
      :pagination.sync="pagination"
    >
      <template slot="headers" scope="props">

            {{ header.text }}

      </template>
      <template slot="items" scope="props">

          {{ props.item.seq }}
          {{ props.item.subject }}
          {{ props.item.creator.username }}
          {{ formatDate(props.item.createdAt) }}

      </template>
    </v-data-table>

      <v-pagination v-model="pagination.page" :length="pages"></v-pagination>

</template>


fix(webapp): clarify terms rationale (not a real terms change)

<template>

  <v-card outline tile class="pa-md-12 pa-8 elevation-4" style="overflow: hidden">

§§

    <v-container class="pa-0">
      <v-row align="center">
        <v-col class="col-md-6 col-12 py-8 triangle-fg">

Terms of Use

              We collect as little data as possible and restrict the usage of our DNS service only where necessary to
              meet legal requirements or to ensure the smooth operation of our services. In turn, we rely on the
              community to use the service responsibly, and exercise care when configuring e.g. bulk operations.

              Our <router-link :to="{name: 'privacy-policy'}">Privacy Policy</router-link> is also part of the
              agreement between deSEC and its users.

        </v-col>
      </v-row>
    </v-container>
  </v-card>
  <v-container fluid class="grey lighten-4">
    <v-container>
      <v-row class="pt-8">
        <v-col class="text-center">

Terms of Use

        </v-col>
      </v-row>
      <v-row class="pb-8">
        <v-col class="col-12 col-sm-6 d-flex" v-for="(t, idx) in terms_of_use" :key="t.title">
          <v-card>
            <v-card-title>§{{idx+1}} {{t.title}}</v-card-title>
            <v-card-text v-html="t.text" />
          </v-card>
        </v-col>
      </v-row>
    </v-container>
  </v-container>

</template>


EBEAST: b/projectshell.vue: ensure projects have a song with master bus

Signed-off-by: Tim Janik <6ec6ff229c4e1c5bbd5933ee92e6c5832ff023bb@gnu.org>

<docs>
  # B-PROJECTSHELL
  Shell for editing and display of a Bse.Project.
  ## Props:
  *project*
  : Implicit *Bse.Project*, using App.bse_project().
</docs>

<template>

      Project Shell:

</template>


Removed margin on top of display due to element style defaults

<template>

{{header}}

      <slot></slot>

</template>