Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDennis Tang <dennis@dennistang.net>2018-07-06 16:40:11 +0300
committerPhil Hughes <me@iamphill.com>2018-07-06 16:40:11 +0300
commit3892b022e3173851f418e4bd8469f0dcdde2ebef (patch)
tree4379c1214ca409902e0d858551282e2dd0c262aa /app/assets/javascripts/frequent_items/components
parentb14b31b819f0f09d73e001a80acd528aad913dc9 (diff)
Resolve "Add dropdown to Groups link in top bar"
Diffstat (limited to 'app/assets/javascripts/frequent_items/components')
-rw-r--r--app/assets/javascripts/frequent_items/components/app.vue122
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list.vue78
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_list_item.vue117
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_mixin.js23
-rw-r--r--app/assets/javascripts/frequent_items/components/frequent_items_search_input.vue55
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>