Commit 4edcb02f authored by Dennis Tang's avatar Dennis Tang Committed by Phil Hughes

Resolve "Add status message from within user menu"

parent 18777ec7
......@@ -22,6 +22,7 @@ const Api = {
dockerfilePath: '/api/:version/templates/dockerfiles/:key',
issuableTemplatePath: '/:namespace_path/:project_path/templates/:type/:key',
usersPath: '/api/:version/users.json',
userStatusPath: '/api/:version/user/status',
commitPath: '/api/:version/projects/:id/repository/commits',
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
......@@ -266,6 +267,15 @@ const Api = {
});
},
postUserStatus({ emoji, message }) {
const url = Api.buildUrl(this.userStatusPath);
return axios.put(url, {
emoji,
message,
});
},
templates(key, params = {}) {
const url = Api.buildUrl(this.templatesPath).replace(':key', key);
......
......@@ -42,10 +42,11 @@ export class AwardsHandler {
}
bindEvents() {
const $parentEl = this.targetContainerEl ? $(this.targetContainerEl) : $(document);
// If the user shows intent let's pre-build the menu
this.registerEventListener(
'one',
$(document),
$parentEl,
'mouseenter focus',
this.toggleButtonSelector,
'mouseenter focus',
......@@ -58,7 +59,7 @@ export class AwardsHandler {
}
},
);
this.registerEventListener('on', $(document), 'click', this.toggleButtonSelector, e => {
this.registerEventListener('on', $parentEl, 'click', this.toggleButtonSelector, e => {
e.stopPropagation();
e.preventDefault();
this.showEmojiMenu($(e.currentTarget));
......@@ -76,7 +77,7 @@ export class AwardsHandler {
});
const emojiButtonSelector = `.js-awards-block .js-emoji-btn, .${this.menuClass} .js-emoji-btn`;
this.registerEventListener('on', $(document), 'click', emojiButtonSelector, e => {
this.registerEventListener('on', $parentEl, 'click', emojiButtonSelector, e => {
e.preventDefault();
const $target = $(e.currentTarget);
const $glEmojiElement = $target.find('gl-emoji');
......@@ -168,7 +169,8 @@ export class AwardsHandler {
</div>
`;
document.body.insertAdjacentHTML('beforeend', emojiMenuMarkup);
const targetEl = this.targetContainerEl ? this.targetContainerEl : document.body;
targetEl.insertAdjacentHTML('beforeend', emojiMenuMarkup);
this.addRemainingEmojiMenuCategories();
this.setupSearch();
......@@ -250,6 +252,12 @@ export class AwardsHandler {
}
positionMenu($menu, $addBtn) {
if (this.targetContainerEl) {
return $menu.css({
top: `${$addBtn.outerHeight()}px`,
});
}
const position = $addBtn.data('position');
// The menu could potentially be off-screen or in a hidden overflow element
// So we position the element absolute in the body
......@@ -424,9 +432,7 @@ export class AwardsHandler {
users = origTitle.trim().split(FROM_SENTENCE_REGEX);
}
users.unshift('You');
return awardBlock
.attr('title', this.toSentence(users))
.tooltip('_fixTitle');
return awardBlock.attr('title', this.toSentence(users)).tooltip('_fixTitle');
}
createAwardButtonForVotesBlock(votesBlock, emojiName) {
......@@ -609,13 +615,11 @@ export class AwardsHandler {
let awardsHandlerPromise = null;
export default function loadAwardsHandler(reload = false) {
if (!awardsHandlerPromise || reload) {
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(
Emoji => {
const awardsHandler = new AwardsHandler(Emoji);
awardsHandler.bindEvents();
return awardsHandler;
},
);
awardsHandlerPromise = import(/* webpackChunkName: 'emoji' */ './emoji').then(Emoji => {
const awardsHandler = new AwardsHandler(Emoji);
awardsHandler.bindEvents();
return awardsHandler;
});
}
return awardsHandlerPromise;
}
import $ from 'jquery';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { highCountTrim } from '~/lib/utils/text_utility';
import SetStatusModalTrigger from './set_status_modal/set_status_modal_trigger.vue';
import SetStatusModalWrapper from './set_status_modal/set_status_modal_wrapper.vue';
/**
* Updates todo counter when todos are toggled.
......@@ -17,3 +21,54 @@ export default function initTodoToggle() {
$todoPendingCount.toggleClass('hidden', parsedCount === 0);
});
}
document.addEventListener('DOMContentLoaded', () => {
const setStatusModalTriggerEl = document.querySelector('.js-set-status-modal-trigger');
const setStatusModalWrapperEl = document.querySelector('.js-set-status-modal-wrapper');
if (setStatusModalTriggerEl || setStatusModalWrapperEl) {
Vue.use(Translate);
// eslint-disable-next-line no-new
new Vue({
el: setStatusModalTriggerEl,
data() {
const { hasStatus } = this.$options.el.dataset;
return {
hasStatus: hasStatus === 'true',
};
},
render(createElement) {
return createElement(SetStatusModalTrigger, {
props: {
hasStatus: this.hasStatus,
},
});
},
});
// eslint-disable-next-line no-new
new Vue({
el: setStatusModalWrapperEl,
data() {
const { currentEmoji, currentMessage } = this.$options.el.dataset;
return {
currentEmoji,
currentMessage,
};
},
render(createElement) {
const { currentEmoji, currentMessage } = this;
return createElement(SetStatusModalWrapper, {
props: {
currentEmoji,
currentMessage,
},
});
},
});
}
});
......@@ -11,7 +11,7 @@ document.addEventListener('DOMContentLoaded', () => {
const statusEmojiField = document.getElementById('js-status-emoji-field');
const statusMessageField = document.getElementById('js-status-message-field');
const toggleNoEmojiPlaceholder = (isVisible) => {
const toggleNoEmojiPlaceholder = isVisible => {
const placeholderElement = document.getElementById('js-no-emoji-placeholder');
placeholderElement.classList.toggle('hidden', !isVisible);
};
......@@ -69,5 +69,5 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
})
.catch(() => createFlash('Failed to load emoji list!'));
.catch(() => createFlash('Failed to load emoji list.'));
});
import { AwardsHandler } from '~/awards_handler';
class EmojiMenuInModal extends AwardsHandler {
constructor(emoji, toggleButtonSelector, menuClass, selectEmojiCallback, targetContainerEl) {
super(emoji);
this.selectEmojiCallback = selectEmojiCallback;
this.toggleButtonSelector = toggleButtonSelector;
this.menuClass = menuClass;
this.targetContainerEl = targetContainerEl;
this.bindEvents();
}
postEmoji($emojiButton, awardUrl, selectedEmoji, callback) {
this.selectEmojiCallback(selectedEmoji, this.emoji.glEmojiTag(selectedEmoji));
callback();
}
}
export default EmojiMenuInModal;
import Vue from 'vue';
export default new Vue();
<script>
import { s__ } from '~/locale';
import eventHub from './event_hub';
export default {
props: {
hasStatus: {
type: Boolean,
required: true,
},
},
computed: {
buttonText() {
return this.hasStatus ? s__('SetStatusModal|Edit status') : s__('SetStatusModal|Set status');
},
},
methods: {
openModal() {
eventHub.$emit('openModal');
},
},
};
</script>
<template>
<button
type="button"
class="btn menu-item"
@click="openModal"
>
{{ buttonText }}
</button>
</template>
<script>
import $ from 'jquery';
import createFlash from '~/flash';
import Icon from '~/vue_shared/components/icon.vue';
import GfmAutoComplete from '~/gfm_auto_complete';
import { __, s__ } from '~/locale';
import Api from '~/api';
import eventHub from './event_hub';
import EmojiMenuInModal from './emoji_menu_in_modal';
const emojiMenuClass = 'js-modal-status-emoji-menu';
export default {
components: {
Icon,
},
props: {
currentEmoji: {
type: String,
required: true,
},
currentMessage: {
type: String,
required: true,
},
},
data() {
return {
defaultEmojiTag: '',
emoji: this.currentEmoji,
emojiMenu: null,
emojiTag: '',
isEmojiMenuVisible: false,
message: this.currentMessage,
modalId: 'set-user-status-modal',
noEmoji: true,
};
},
computed: {
isDirty() {
return this.message.length || this.emoji.length;
},
},
mounted() {
eventHub.$on('openModal', this.openModal);
},
beforeDestroy() {
this.emojiMenu.destroy();
},
methods: {
openModal() {
this.$root.$emit('bv::show::modal', this.modalId);
},
closeModal() {
this.$root.$emit('bv::hide::modal', this.modalId);
},
setupEmojiListAndAutocomplete() {
const toggleEmojiMenuButtonSelector = '#set-user-status-modal .js-toggle-emoji-menu';
const emojiAutocomplete = new GfmAutoComplete();
emojiAutocomplete.setup($(this.$refs.statusMessageField), { emojis: true });
import(/* webpackChunkName: 'emoji' */ '~/emoji')
.then(Emoji => {
if (this.emoji) {
this.emojiTag = Emoji.glEmojiTag(this.emoji);
}
this.noEmoji = this.emoji === '';
this.defaultEmojiTag = Emoji.glEmojiTag('speech_balloon');
this.emojiMenu = new EmojiMenuInModal(
Emoji,
toggleEmojiMenuButtonSelector,
emojiMenuClass,
this.setEmoji,
this.$refs.userStatusForm,
);
})
.catch(() => createFlash(__('Failed to load emoji list.')));
},
showEmojiMenu() {
this.isEmojiMenuVisible = true;
this.emojiMenu.showEmojiMenu($(this.$refs.toggleEmojiMenuButton));
},
hideEmojiMenu() {
if (!this.isEmojiMenuVisible) {
return;
}
this.isEmojiMenuVisible = false;
this.emojiMenu.hideMenuElement($(`.${emojiMenuClass}`));
},
setDefaultEmoji() {
const { emojiTag } = this;
const hasStatusMessage = this.message;
if (hasStatusMessage && emojiTag) {
return;
}
if (hasStatusMessage) {
this.noEmoji = false;
this.emojiTag = this.defaultEmojiTag;
} else if (emojiTag === this.defaultEmojiTag) {
this.noEmoji = true;
this.clearEmoji();
}
},
setEmoji(emoji, emojiTag) {
this.emoji = emoji;
this.noEmoji = false;
this.clearEmoji();
this.emojiTag = emojiTag;
},
clearEmoji() {
if (this.emojiTag) {
this.emojiTag = '';
}
},
clearStatusInputs() {
this.emoji = '';
this.message = '';
this.noEmoji = true;
this.clearEmoji();
this.hideEmojiMenu();
},
removeStatus() {
this.clearStatusInputs();
this.setStatus();
},
setStatus() {
const { emoji, message } = this;
Api.postUserStatus({
emoji,
message,
})
.then(this.onUpdateSuccess)
.catch(this.onUpdateFail);
},
onUpdateSuccess() {
this.closeModal();
window.location.reload();
},
onUpdateFail() {
createFlash(
s__("SetStatusModal|Sorry, we weren't able to set your status. Please try again later."),
);
this.closeModal();
},
},
};
</script>
<template>
<gl-ui-modal
:title="s__('SetStatusModal|Set a status')"
:modal-id="modalId"
:ok-title="s__('SetStatusModal|Set status')"
:cancel-title="s__('SetStatusModal|Remove status')"
ok-variant="success"
class="set-user-status-modal"
@shown="setupEmojiListAndAutocomplete"
@hide="hideEmojiMenu"
@ok="setStatus"
@cancel="removeStatus"
>
<div>
<input
v-model="emoji"
class="js-status-emoji-field"
type="hidden"
name="user[status][emoji]"
/>
<div
ref="userStatusForm"
class="form-group position-relative m-0"
>
<div class="input-group">
<span class="input-group-btn">
<button
ref="toggleEmojiMenuButton"
v-gl-tooltip.bottom
:title="s__('SetStatusModal|Add status emoji')"
:aria-label="s__('SetStatusModal|Add status emoji')"
name="button"
type="button"
class="js-toggle-emoji-menu emoji-menu-toggle-button btn"
@click="showEmojiMenu"
>
<span v-html="emojiTag"></span>
<span
v-show="noEmoji"
class="js-no-emoji-placeholder no-emoji-placeholder position-relative"
>
<icon
name="emoji_slightly_smiling_face"
css-classes="award-control-icon-neutral"
/>
<icon
name="emoji_smiley"
css-classes="award-control-icon-positive"
/>
<icon
name="emoji_smile"
css-classes="award-control-icon-super-positive"
/>
</span>
</button>
</span>
<input
ref="statusMessageField"
v-model="message"
:placeholder="s__('SetStatusModal|What\'s your status?')"
type="text"
class="form-control form-control input-lg js-status-message-field"
name="user[status][message]"
@keyup="setDefaultEmoji"
@keyup.enter.prevent
@click="hideEmojiMenu"
/>
<span
v-show="isDirty"
class="input-group-btn"
>
<button
v-gl-tooltip.bottom
:title="s__('SetStatusModal|Clear status')"
:aria-label="s__('SetStatusModal|Clear status')"
name="button"
type="button"
class="js-clear-user-status-button clear-user-status btn"
@click="clearStatusInputs()"
>
<icon name="close" />
</button>
</span>
</div>
</div>
</div>
</gl-ui-modal>
</template>
......@@ -529,9 +529,10 @@
}
.header-user {
.dropdown-menu {
&.show .dropdown-menu {
width: auto;
min-width: unset;
max-height: 323px;
margin-top: 4px;
color: $gl-text-color;
left: auto;
......@@ -542,6 +543,18 @@
.user-name {
display: block;
}
.user-status-emoji {
margin-right: 0;
display: block;
vertical-align: text-top;
max-width: 148px;
font-size: 12px;
gl-emoji {
font-size: $gl-font-size;
}
}
}
svg {
......@@ -573,3 +586,24 @@
}
}
}
.set-user-status-modal {
.modal-body {
min-height: unset;
}
.input-lg {
max-width: unset;
}
.no-emoji-placeholder,
.clear-user-status {
svg {
fill: $gl-text-color-secondary;
}
}
.emoji-menu-toggle-button {
@include emoji-menu-toggle-button;
}
}
......@@ -266,3 +266,59 @@
border-radius: 50%;
}
}
@mixin emoji-menu-toggle-button {
line-height: 1;
padding: 0;
min-width: 16px;
color: $gray-darkest;
fill: $gray-darkest;
.fa {
position: relative;
font-size: 16px;
}
svg {
@include btn-svg;
margin: 0;
}
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
&:hover,
&.is-active {
.danger-highlight {
color: $red-500;
}
.link-highlight {
color: $blue-600;
fill: $blue-600;
}
.award-control-icon-neutral {
opacity: 0;
}
.award-control-icon-positive {
opacity: 1;
}
}
&.is-active {
.award-control-icon-positive {
opacity: 0;
}
.award-control-icon-super-positive {
opacity: 1;
}
}
}
......@@ -314,7 +314,8 @@ $diff-jagged-border-gradient-color: darken($white-normal, 8%);
$monospace-font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono',
'Courier New', 'andale mono', 'lucida console', monospace;
$regular-font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen-Sans, Ubuntu, Cantarell,
'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
'Helvetica Neue', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
/*
* Dropdowns
......@@ -634,5 +635,4 @@ Modals
*/
$modal-body-height: 134px;
$priority-label-empty-state-width: 114px;
......@@ -519,59 +519,7 @@ ul.notes {
}
.note-action-button {
line-height: 1;
padding: 0;
min-width: 16px;
color: $gray-darkest;
fill: $gray-darkest;
.fa {
position: relative;
font-size: 16px;
}
svg {
@include btn-svg;
margin: 0;
}
.award-control-icon-positive,
.award-control-icon-super-positive {
position: absolute;
top: 0;
left: 0;
opacity: 0;
}
&:hover,
&.is-active {
.danger-highlight {
color: $red-500;
}
.link-highlight {
color: $blue-600;
fill: $blue-600;
}
.award-control-icon-neutral {
opacity: 0;
}
.award-control-icon-positive {
opacity: 1;
}
}
&.is-active {
.award-control-icon-positive {
opacity: 0;
}
.award-control-icon-super-positive {
opacity: 1;
}
}
@include emoji-menu-toggle-button;
}
.discussion-toggle-button {
......
......@@ -81,14 +81,14 @@
// Middle dot divider between each element in a list of items.
.middle-dot-divider {
&::after {
content: "\00B7"; // Middle Dot
content: '\00B7'; // Middle Dot
padding: 0 6px;
font-weight: $gl-font-weight-bold;
}
&:last-child {
&::after {
content: "";
content: '';
padding: 0;
}
}
......@@ -191,7 +191,6 @@
@include media-breakpoint-down(xs) {
width: auto;
}
}
.profile-crop-image-container {
......@@ -215,7 +214,6 @@
}
}
.user-profile {
.cover-controls a {
margin-left: 5px;
......@@ -418,7 +416,7 @@ table.u2f-registrations {