diff options
author | Dennis Tang <dennis@dennistang.net> | 2018-07-06 16:40:11 +0300 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2018-07-06 16:40:11 +0300 |
commit | 3892b022e3173851f418e4bd8469f0dcdde2ebef (patch) | |
tree | 4379c1214ca409902e0d858551282e2dd0c262aa /app/assets/javascripts/frequent_items/components | |
parent | b14b31b819f0f09d73e001a80acd528aad913dc9 (diff) |
Resolve "Add dropdown to Groups link in top bar"
Diffstat (limited to 'app/assets/javascripts/frequent_items/components')
5 files changed, 395 insertions, 0 deletions
diff --git a/app/assets/javascripts/frequent_items/components/app.vue b/app/assets/javascripts/frequent_items/components/app.vue new file mode 100644 index 00000000000..2f030de8967 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/app.vue @@ -0,0 +1,122 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import AccessorUtilities from '~/lib/utils/accessor'; +import eventHub from '../event_hub'; +import store from '../store/'; +import { FREQUENT_ITEMS, STORAGE_KEY } from '../constants'; +import { isMobile, updateExistingFrequentItem } from '../utils'; +import FrequentItemsSearchInput from './frequent_items_search_input.vue'; +import FrequentItemsList from './frequent_items_list.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + store, + components: { + LoadingIcon, + FrequentItemsSearchInput, + FrequentItemsList, + }, + mixins: [frequentItemsMixin], + props: { + currentUserName: { + type: String, + required: true, + }, + currentItem: { + type: Object, + required: true, + }, + }, + computed: { + ...mapState(['searchQuery', 'isLoadingItems', 'isFetchFailed', 'items']), + ...mapGetters(['hasSearchQuery']), + translations() { + return this.getTranslations(['loadingMessage', 'header']); + }, + }, + created() { + const { namespace, currentUserName, currentItem } = this; + const storageKey = `${currentUserName}/${STORAGE_KEY[namespace]}`; + + this.setNamespace(namespace); + this.setStorageKey(storageKey); + + if (currentItem.id) { + this.logItemAccess(storageKey, currentItem); + } + + eventHub.$on(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.dropdownOpenHandler); + }, + methods: { + ...mapActions(['setNamespace', 'setStorageKey', 'fetchFrequentItems']), + dropdownOpenHandler() { + if (this.searchQuery === '' || isMobile()) { + this.fetchFrequentItems(); + } + }, + logItemAccess(storageKey, item) { + if (!AccessorUtilities.isLocalStorageAccessSafe()) { + return false; + } + + // Check if there's any frequent items list set + const storedRawItems = localStorage.getItem(storageKey); + const storedFrequentItems = storedRawItems + ? JSON.parse(storedRawItems) + : [{ ...item, frequency: 1 }]; // No frequent items list set, set one up. + + // Check if item already exists in list + const itemMatchIndex = storedFrequentItems.findIndex( + frequentItem => frequentItem.id === item.id, + ); + + if (itemMatchIndex > -1) { + storedFrequentItems[itemMatchIndex] = updateExistingFrequentItem( + storedFrequentItems[itemMatchIndex], + item, + ); + } else { + if (storedFrequentItems.length === FREQUENT_ITEMS.MAX_COUNT) { + storedFrequentItems.shift(); + } + + storedFrequentItems.push({ ...item, frequency: 1 }); + } + + return localStorage.setItem(storageKey, JSON.stringify(storedFrequentItems)); + }, + }, +}; +</script> + +<template> + <div> + <frequent-items-search-input + :namespace="namespace" + /> + <loading-icon + v-if="isLoadingItems" + :label="translations.loadingMessage" + class="loading-animation prepend-top-20" + size="2" + /> + <div + v-if="!isLoadingItems && !hasSearchQuery" + class="section-header" + > + {{ translations.header }} + </div> + <frequent-items-list + v-if="!isLoadingItems" + :items="items" + :namespace="namespace" + :has-search-query="hasSearchQuery" + :is-fetch-failed="isFetchFailed" + :matcher="searchQuery" + /> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue new file mode 100644 index 00000000000..8e511aa2a36 --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list.vue @@ -0,0 +1,78 @@ +<script> +import FrequentItemsListItem from './frequent_items_list_item.vue'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + components: { + FrequentItemsListItem, + }, + mixins: [frequentItemsMixin], + props: { + items: { + type: Array, + required: true, + }, + hasSearchQuery: { + type: Boolean, + required: true, + }, + isFetchFailed: { + type: Boolean, + required: true, + }, + matcher: { + type: String, + required: true, + }, + }, + computed: { + translations() { + return this.getTranslations([ + 'itemListEmptyMessage', + 'itemListErrorMessage', + 'searchListEmptyMessage', + 'searchListErrorMessage', + ]); + }, + isListEmpty() { + return this.items.length === 0; + }, + listEmptyMessage() { + if (this.hasSearchQuery) { + return this.isFetchFailed + ? this.translations.searchListErrorMessage + : this.translations.searchListEmptyMessage; + } + + return this.isFetchFailed + ? this.translations.itemListErrorMessage + : this.translations.itemListEmptyMessage; + }, + }, +}; +</script> + +<template> + <div class="frequent-items-list-container"> + <ul class="list-unstyled"> + <li + v-if="isListEmpty" + :class="{ 'section-failure': isFetchFailed }" + class="section-empty" + > + {{ listEmptyMessage }} + </li> + <frequent-items-list-item + v-for="item in items" + v-else + :key="item.id" + :item-id="item.id" + :item-name="item.name" + :namespace="item.namespace" + :web-url="item.webUrl" + :avatar-url="item.avatarUrl" + :matcher="matcher" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue new file mode 100644 index 00000000000..1f1665ff7fe --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue @@ -0,0 +1,117 @@ +<script> +/* eslint-disable vue/require-default-prop, vue/require-prop-types */ +import Identicon from '../../vue_shared/components/identicon.vue'; + +export default { + components: { + Identicon, + }, + props: { + matcher: { + type: String, + required: false, + }, + itemId: { + type: Number, + required: true, + }, + itemName: { + type: String, + required: true, + }, + namespace: { + type: String, + required: false, + }, + webUrl: { + type: String, + required: true, + }, + avatarUrl: { + required: true, + validator(value) { + return value === null || typeof value === 'string'; + }, + }, + }, + computed: { + hasAvatar() { + return this.avatarUrl !== null; + }, + highlightedItemName() { + if (this.matcher) { + const matcherRegEx = new RegExp(this.matcher, 'gi'); + const matches = this.itemName.match(matcherRegEx); + + if (matches && matches.length > 0) { + return this.itemName.replace(matches[0], `<b>${matches[0]}</b>`); + } + } + return this.itemName; + }, + /** + * Smartly truncates item namespace by doing two things; + * 1. Only include Group names in path by removing item name + * 2. Only include first and last group names in the path + * when namespace has more than 2 groups present + * + * First part (removal of item name from namespace) can be + * done from backend but doing so involves migration of + * existing item namespaces which is not wise thing to do. + */ + truncatedNamespace() { + if (!this.namespace) { + return null; + } + const namespaceArr = this.namespace.split(' / '); + + namespaceArr.splice(-1, 1); + let namespace = namespaceArr.join(' / '); + + if (namespaceArr.length > 2) { + namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; + } + + return namespace; + }, + }, +}; +</script> + +<template> + <li class="frequent-items-list-item-container"> + <a + :href="webUrl" + class="clearfix" + > + <div class="frequent-items-item-avatar-container"> + <img + v-if="hasAvatar" + :src="avatarUrl" + class="avatar s32" + /> + <identicon + v-else + :entity-id="itemId" + :entity-name="itemName" + size-class="s32" + /> + </div> + <div class="frequent-items-item-metadata-container"> + <div + :title="itemName" + class="frequent-items-item-title" + v-html="highlightedItemName" + > + </div> + <div + v-if="truncatedNamespace" + :title="namespace" + class="frequent-items-item-namespace" + > + {{ truncatedNamespace }} + </div> + </div> + </a> + </li> +</template> diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js new file mode 100644 index 00000000000..704dc83ca8e --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_mixin.js @@ -0,0 +1,23 @@ +import { TRANSLATION_KEYS } from '../constants'; + +export default { + props: { + namespace: { + type: String, + required: true, + }, + }, + methods: { + getTranslations(keys) { + const translationStrings = keys.reduce( + (acc, key) => ({ + ...acc, + [key]: TRANSLATION_KEYS[this.namespace][key], + }), + {}, + ); + + return translationStrings; + }, + }, +}; diff --git a/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue new file mode 100644 index 00000000000..a6a265eb3fd --- /dev/null +++ b/app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue @@ -0,0 +1,55 @@ +<script> +import _ from 'underscore'; +import { mapActions } from 'vuex'; +import eventHub from '../event_hub'; +import frequentItemsMixin from './frequent_items_mixin'; + +export default { + mixins: [frequentItemsMixin], + data() { + return { + searchQuery: '', + }; + }, + computed: { + translations() { + return this.getTranslations(['searchInputPlaceholder']); + }, + }, + watch: { + searchQuery: _.debounce(function debounceSearchQuery() { + this.setSearchQuery(this.searchQuery); + }, 500), + }, + mounted() { + eventHub.$on(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + beforeDestroy() { + eventHub.$off(`${this.namespace}-dropdownOpen`, this.setFocus); + }, + methods: { + ...mapActions(['setSearchQuery']), + setFocus() { + this.$refs.search.focus(); + }, + }, +}; +</script> + +<template> + <div class="search-input-container d-none d-sm-block"> + <input + ref="search" + v-model="searchQuery" + :placeholder="translations.searchInputPlaceholder" + type="search" + class="form-control" + /> + <i + v-if="!searchQuery" + class="search-icon fa fa-fw fa-search" + aria-hidden="true" + > + </i> + </div> +</template> |