Commit 5c29be3a authored by Stan Hu's avatar Stan Hu

Merge branch 'master' into ce-to-ee-2018-12-07

parents 471b703d f9f8ebea
This diff is collapsed.
......@@ -22,7 +22,9 @@ const Api = {
projectTemplatePath: '/api/:version/projects/:id/templates/:type/:key',
projectTemplatesPath: '/api/:version/projects/:id/templates/:type',
usersPath: '/api/:version/users.json',
userStatusPath: '/api/:version/user/status',
userPath: '/api/:version/users/:id',
userStatusPath: '/api/:version/users/:id/status',
userPostStatusPath: '/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',
......@@ -257,6 +259,20 @@ const Api = {
});
},
user(id, options) {
const url = Api.buildUrl(this.userPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: options,
});
},
userStatus(id, options) {
const url = Api.buildUrl(this.userStatusPath).replace(':id', encodeURIComponent(id));
return axios.get(url, {
params: options,
});
},
branches(id, query = '', options = {}) {
const url = Api.buildUrl(this.createBranchPath).replace(':id', encodeURIComponent(id));
......@@ -279,7 +295,7 @@ const Api = {
},
postUserStatus({ emoji, message }) {
const url = Api.buildUrl(this.userStatusPath);
const url = Api.buildUrl(this.userPostStatusPath);
return axios.put(url, {
emoji,
......
......@@ -3,6 +3,7 @@ import syntaxHighlight from '~/syntax_highlight';
import renderMath from './render_math';
import renderMermaid from './render_mermaid';
import highlightCurrentUser from './highlight_current_user';
import initUserPopovers from '../../user_popovers';
// Render GitLab flavoured Markdown
//
......@@ -13,6 +14,7 @@ $.fn.renderGFM = function renderGFM() {
renderMath(this.find('.js-render-math'));
renderMermaid(this.find('.js-render-mermaid'));
highlightCurrentUser(this.find('.gfm-project_member').get());
initUserPopovers(this.find('.gfm-project_member').get());
return this;
};
......
......@@ -15,6 +15,16 @@ export default {
type: String,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
tooltipPlacement: {
type: String,
required: false,
default: 'bottom',
},
},
computed: {
title() {
......@@ -66,15 +76,13 @@ export default {
<template>
<span>
<span ref="issueDueDate" class="board-card-info card-number">
<icon
:class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }"
name="calendar"
/><time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
<span ref="issueDueDate" :class="cssClass" class="board-card-info card-number">
<icon :class="{ 'text-danger': isPastDue, 'board-card-info-icon': true }" name="calendar" />
<time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{
body
}}</time>
</span>
<gl-tooltip :target="() => $refs.issueDueDate" placement="bottom">
<gl-tooltip :target="() => $refs.issueDueDate" :placement="tooltipPlacement">
<span class="bold">{{ __('Due date') }}</span> <br />
<span :class="{ 'text-danger-muted': isPastDue }">{{ title }}</span>
</gl-tooltip>
......
......@@ -4,6 +4,7 @@ import _ from 'underscore';
import { __, sprintf } from '~/locale';
import createFlash from '~/flash';
import { GlLoadingIcon } from '@gitlab/ui';
import eventHub from '../../notes/event_hub';
import DiffFileHeader from './diff_file_header.vue';
import DiffContent from './diff_content.vue';
......@@ -75,6 +76,9 @@ export default {
}
},
},
created() {
eventHub.$on(`loadCollapsedDiff/${this.file.file_hash}`, this.handleLoadCollapsedDiff);
},
methods: {
...mapActions('diffs', ['loadCollapsedDiff', 'assignDiscussionsToDiff']),
handleToggle() {
......
......@@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils';
import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
import eventHub from '../../notes/event_hub';
import { getDiffPositionByLineCode, getNoteFormData } from './utils';
import * as types from './mutation_types';
import {
......@@ -53,6 +54,10 @@ export const assignDiscussionsToDiff = (
diffPositionByLineCode,
});
});
Vue.nextTick(() => {
eventHub.$emit('scrollToDiscussion');
});
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
......@@ -60,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
if (discussion) {
const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
if (file) {
if (!file.renderIt) {
commit(types.RENDER_FILE, file);
}
if (file.collapsed) {
eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
scrollToElement(document.getElementById(file.file_hash));
} else {
eventHub.$emit('scrollToDiscussion');
}
}
}
};
export const startRenderDiffsQueue = ({ state, commit }) => {
const checkItem = () =>
new Promise(resolve => {
......
......@@ -170,7 +170,7 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
file.discussions = file.discussions.concat(discussion);
file.discussions = (file.discussions || []).concat(discussion);
}
return file;
......
......@@ -192,8 +192,12 @@ export const contentTop = () => {
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
const diffFileLargeEnoughScreen =
'matchMedia' in window ? window.matchMedia('min-width: 768') : true;
const diffFileTitleBar =
(diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
return perfBar + mrTabsHeight + headerHeight + diffFilesChanged;
return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
};
export const scrollToElement = element => {
......
......@@ -22,6 +22,34 @@ class UsersCache extends Cache {
});
// missing catch is intentional, error handling depends on use case
}
retrieveById(userId) {
if (this.hasData(userId) && this.get(userId).username) {
return Promise.resolve(this.get(userId));
}
return Api.user(userId).then(({ data }) => {
this.internalStorage[userId] = data;
return data;
});
// missing catch is intentional, error handling depends on use case
}
retrieveStatusById(userId) {
if (this.hasData(userId) && this.get(userId).status) {
return Promise.resolve(this.get(userId).status);
}
return Api.userStatus(userId).then(({ data }) => {
if (!this.hasData(userId)) {
this.internalStorage[userId] = {};
}
this.internalStorage[userId].status = data;
return data;
});
// missing catch is intentional, error handling depends on use case
}
}
export default new UsersCache();
......@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
import initUserPopovers from './user_popovers';
// EE-only scripts
import 'ee/main';
......@@ -81,6 +82,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle();
initLogoAnimation();
initUsagePingConsent();
initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
......
<script>
import { GlAreaChart } from '@gitlab/ui';
import dateFormat from 'dateformat';
export default {
components: {
GlAreaChart,
},
props: {
graphData: {
type: Object,
required: true,
validator(data) {
return (
data.queries &&
Array.isArray(data.queries) &&
data.queries.filter(query => {
if (Array.isArray(query.result)) {
return (
query.result.filter(res => Array.isArray(res.values)).length === query.result.length
);
}
return false;
}).length === data.queries.length
);
},
},
},
computed: {
chartData() {
return this.graphData.queries.reduce((accumulator, query) => {
const xLabel = `${query.unit}`;
accumulator[xLabel] = {};
query.result.forEach(res =>
res.values.forEach(v => {
accumulator[xLabel][v.time.toISOString()] = v.value;
}),
);
return accumulator;
}, {});
},
chartOptions() {
return {
xAxis: {
name: 'Time',
type: 'time',
axisLabel: {
formatter: date => dateFormat(date, 'h:MMtt'),
},
nameTextStyle: {
padding: [18, 0, 0, 0],
},
},
yAxis: {
name: this.graphData.y_label,
axisLabel: {
formatter: value => value.toFixed(3),
},
nameTextStyle: {
padding: [0, 0, 36, 0],
},
},
legend: {
formatter: this.xAxisLabel,
},
};
},
xAxisLabel() {
return this.graphData.queries.map(query => query.label).join(', ');
},
},
methods: {
formatTooltipText(params) {
const [date, value] = params;
return [dateFormat(date, 'dd mmm yyyy, h:MMtt'), value.toFixed(3)];
},
onCreated(chart) {
this.$emit('created', chart);
},
},
};
</script>
<template>
<div class="prometheus-graph">
<div class="prometheus-graph-header">
<h5 class="prometheus-graph-title">{{ graphData.title }}</h5>
<div class="prometheus-graph-widgets"><slot></slot></div>
</div>
<gl-area-chart
:data="chartData"
:option="chartOptions"
:format-tooltip-text="formatTooltipText"
@created="onCreated"
/>
</div>
</template>
......@@ -7,6 +7,7 @@ import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
......@@ -15,6 +16,7 @@ import eventHub from '../event_hub';
export default {
components: {
MonitorAreaChart,
Graph,
GraphGroup,
EmptyState,
......@@ -114,6 +116,9 @@ export default {
};
},
computed: {
graphComponent() {
return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
},
forceRedraw() {
return this.elWidth;
},
......@@ -229,7 +234,8 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
<graph
<component
:is="graphComponent"
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
......@@ -259,7 +265,7 @@ export default {
:alert-data="alertData[graphData.id]"
@setAlerts="setAlerts"
/>
</graph>
</component>
</graph-group>
</div>
<empty-state
......
......@@ -39,7 +39,10 @@ export default {
<div :class="className">
{{ actionText }}
<template v-if="editedBy">
by <a :href="editedBy.path" class="js-vue-author author-link"> {{ editedBy.name }} </a>
by
<a :href="editedBy.path" :data-user-id="editedBy.id" class="js-user-link author-link">
{{ editedBy.name }}
</a>
</template>
{{ actionDetailText }}
<time-ago-tooltip :time="editedAt" tooltip-placement="bottom" />
......
......@@ -73,7 +73,14 @@ export default {
{{ __('Toggle discussion') }}
</button>
</div>
<a v-if="hasAuthor" v-once :href="author.path">
<a
v-if="hasAuthor"
v-once
:href="author.path"
class="js-user-link"
:data-user-id="author.id"
:data-username="author.username"
>
<slot name="note-header-info"></slot>
<span class="note-header-author-name">{{ author.name }}</span>
<span v-if="author.status_tooltip_html" v-html="author.status_tooltip_html"></span>
......
......@@ -91,6 +91,7 @@ export default {
'nextUnresolvedDiscussionId',
'unresolvedDiscussionsCount',
'hasUnresolvedDiscussions',
'showJumpToNextDiscussion',
]),
author() {
return this.initialDiscussion.author;
......@@ -131,6 +132,12 @@ export default {
resolvedText() {
return this.discussion.resolved_by_push ? __('Automatically resolved') : __('Resolved');
},
shouldShowJumpToNextDiscussion() {
return this.showJumpToNextDiscussion(
this.discussion.id,
this.discussionsByDiffOrder ? 'diff' : 'discussion',
);
},
shouldRenderDiffs() {
return this.discussion.diff_discussion && this.renderDiffFile;
},
......@@ -433,7 +440,7 @@ Please check your network connection and try again.`;
<icon name="issue-new" />
</a>
</div>
<div v-if="hasUnresolvedDiscussions" class="btn-group" role="group">
<div v-if="shouldShowJumpToNextDiscussion" class="btn-group" role="group">
<button
v-gl-tooltip
class="btn btn-default discussion-next-btn"
......
......@@ -12,6 +12,7 @@ import placeholderNote from '../../vue_shared/components/notes/placeholder_note.
import placeholderSystemNote from '../../vue_shared/components/notes/placeholder_system_note.vue';
import skeletonLoadingContainer from '../../vue_shared/components/notes/skeleton_note.vue';
import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user';
import initUserPopovers from '../../user_popovers';
export default {
name: 'NotesApp',
......@@ -106,7 +107,10 @@ export default {
}
},
updated() {
this.$nextTick(() => highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member')));
this.$nextTick(() => {
highlightCurrentUser(this.$el.querySelectorAll('.gfm-project_member'));
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
});
},
methods: {
...mapActions([
......
import { scrollToElement } from '~/lib/utils/common_utils';
import eventHub from '../../notes/event_hub';
export default {
methods: {
jumpToDiscussion(id) {
if (id) {
const activeTab = window.mrTabs.currentAction;
const selector =
activeTab === 'diffs'
? `ul.notes[data-discussion-id="${id}"]`
: `div.discussion[data-discussion-id="${id}"]`;
const el = document.querySelector(selector);
diffsJump(id) {
const selector = `ul.notes[data-discussion-id="${id}"]`;
if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.activateTab('show');
}
eventHub.$once('scrollToDiscussion', () => {
const el = document.querySelector(selector);
if (el) {
this.expandDiscussion({ discussionId: id });
scrollToElement(el);
return true;
}
return false;
});
this.expandDiscussion({ discussionId: id });
},
discussionJump(id) {
const selector = `div.discussion[data-discussion-id="${id}"]`;
const el = document.querySelector(selector);
this.expandDiscussion({ discussionId: id });
if (el) {
scrollToElement(el);
return true;
}
return false;
},
jumpToDiscussion(id) {
if (id) {
const activeTab = window.mrTabs.currentAction;
if (activeTab === 'diffs') {
this.diffsJump(id);
} else if (activeTab === 'commits' || activeTab === 'pipelines') {
window.mrTabs.eventHub.$once('MergeRequestTabChange', () => {
setTimeout(() => this.discussionJump(id), 0);
});
window.mrTabs.tabShown('show');
} else {
this.discussionJump(id);
}
}
},
},
};
......@@ -17,7 +17,13 @@ import { __ } from '~/locale';
let eTagPoll;
export const expandDiscussion = ({ commit }, data) => commit(types.EXPAND_DISCUSSION, data);
export const expandDiscussion = ({ commit, dispatch }, data) => {
if (data.discussionId) {
dispatch('diffs/renderFileForDiscussionId', data.discussionId, { root: true });
}
commit(types.EXPAND_DISCUSSION, data);
};
export const collapseDiscussion = ({ commit }, data) => commit(types.COLLAPSE_DISCUSSION, data);
......
......@@ -57,6 +57,17 @@ export const unresolvedDiscussionsCount = state => state.unresolvedDiscussionsCo
export const resolvableDiscussionsCount = state => state.resolvableDiscussionsCount;
export const hasUnresolvedDiscussions = state => state.hasUnresolvedDiscussions;
export const showJumpToNextDiscussion = (state, getters) => (discussionId, mode = 'discussion') => {
const orderedDiffs =
mode !== 'discussion'
? getters.unresolvedDiscussionsIdsByDiff
: getters.unresolvedDiscussionsIdsByDate;
const indexOf = orderedDiffs.indexOf(discussionId);
return indexOf !== -1 && indexOf < orderedDiffs.length - 1;
};
export const isDiscussionResolved = (state, getters) => discussionId =>
getters.resolvedDiscussionsById[discussionId] !== undefined;
......@@ -104,7 +115,7 @@ export const unresolvedDiscussionsIdsByDate = (state, getters) =>
// line numbers.
export const unresolvedDiscussionsIdsByDiff = (state, getters) =>
getters.allResolvableDiscussions
.filter(d => !d.resolved)
.filter(d => !d.resolved && d.active)
.sort((a, b) => {
if (!a.diff_file || !b.diff_file) {
return 0;
......
......@@ -22,6 +22,7 @@ export default {
if (isDiscussion && isInMRPage()) {
noteData.resolvable = note.resolvable;
noteData.resolved = false;
noteData.active = true;
noteData.resolve_path = note.resolve_path;
noteData.resolve_with_issue_path = note.resolve_with_issue_path;
noteData.diff_discussion = false;
......
import Vue from 'vue';
import UsersCache from './lib/utils/users_cache';
import UserPopover from './vue_shared/components/user_popover/user_popover.vue';
let renderedPopover;
let renderFn;
const handleUserPopoverMouseOut = event => {
const { target } = event;
target.removeEventListener('mouseleave', handleUserPopoverMouseOut);
if (renderFn) {
clearTimeout(renderFn);
}
if (renderedPopover) {
renderedPopover.$destroy();
renderedPopover = null;
}
};
/**
* Adds a UserPopover component to the body, hands over as much data as the target element has in data attributes.
* loads based on data-user-id more data about a user from the API and sets it on the popover
*/
const handleUserPopoverMouseOver = event => {
const { target } = event;
// Add listener to actually remove it again
target.addEventListener('mouseleave', handleUserPopoverMouseOut);
renderFn = setTimeout(() => {
// Helps us to use current markdown setup without maybe breaking or duplicating for now
if (target.dataset.user) {
target.dataset.userId = target.dataset.user;
// Removing titles so its not showing tooltips also
target.dataset.originalTitle = '';
target.setAttribute('title', '');
}
const { userId, username, name, avatarUrl } = target.dataset;
const user = {
userId,
username,
name,
avatarUrl,
location: null,
bio: null,
organization: null,
status: null,
};
if (userId || username) {
const UserPopoverComponent = Vue.extend(UserPopover);
renderedPopover = new UserPopoverComponent({
propsData: {
target,
user,
},
});
renderedPopover.$mount();
UsersCache.retrieveById(userId)
.then(userData => {
if (!userData) {
return;
}
Object.assign(user, {
avatarUrl: userData.avatar_url,
username: userData.username,
name: userData.name,
location: userData.location,
bio: userData.bio,
organization: userData.organization,
loaded: true,
});
UsersCache.retrieveStatusById(userId)
.then(status => {
if (!status) {
return;
}
Object.assign(user, {
status,
});
})
.catch(() => {
throw new Error(`User status for "${userId}" could not be retrieved!`);
});
})
.catch(() => {
renderedPopover.$destroy();
renderedPopover = null;
});
}
}, 200);
};
export default elements => {
let userLinks = elements;
if (!elements) {
userLinks = [...document.querySelectorAll('.js-user-link')];
}
userLinks.forEach(el => {
el.addEventListener('mouseenter', handleUserPopoverMouseOver);
});
};
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
components: {
UserAvatarLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
assignees: {
type: Array,
required: true,
},
},
data() {
return {
maxVisibleAssignees: 2,
maxAssigneeAvatars: 3,
maxAssignees: 99,
};
},
computed: {
countOverLimit() {
return this.assignees.length - this.maxVisibleAssignees;
},
assigneesToShow() {
if (this.assignees.length > this.maxAssigneeAvatars) {
return this.assignees.slice(0, this.maxVisibleAssignees);
}
return this.assignees;
},
assigneesCounterTooltip() {
const { countOverLimit, maxAssignees } = this;
const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
return sprintf(__('%{count} more assignees'), { count });
},
shouldRenderAssigneesCounter() {
const assigneesCount = this.assignees.length;
if (assigneesCount <= this.maxAssigneeAvatars) {
return false;
}
return assigneesCount > this.countOverLimit;
},
assigneeCounterLabel() {
if (this.countOverLimit > this.maxAssignees) {
return `${this.maxAssignees}+`;
}
return `+${this.countOverLimit}`;
},
},
methods: {
avatarUrlTitle(assignee) {
return sprintf(__('Avatar for %{assigneeName}'), {
assigneeName: assignee.name,
});
},
},
};
</script>
<template>
<div class="issue-assignees">
<user-avatar-link
v-for="assignee in assigneesToShow"
:key="assignee.id"
:link-href="assignee.web_url"
:img-alt="avatarUrlTitle(assignee)"
:img-src="assignee.avatar_url"
:img-size="24"
class="js-no-trigger"
tooltip-placement="bottom"
>
<span class="js-assignee-tooltip">
<span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }}
<span class="text-white-50">@{{ assignee.username }}</span>
</span>
</user-avatar-link>
<span
v-if="shouldRenderAssigneesCounter"
v-gl-tooltip
:title="assigneesCounterTooltip"
class="avatar-counter"
data-placement="bottom"
>{{ assigneeCounterLabel }}</span
>
</div>
</template>
<script>
import { GlTooltip } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import { timeFor, parsePikadayDate, dateInWords } from '~/lib/utils/datetime_utility';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
GlTooltip,
},
mixins: [timeagoMixin],
props: {
milestone: {
type: Object,
required: true,
},
},
data() {
return {
milestoneDue: this.milestone.due_date ? parsePikadayDate(this.milestone.due_date) : null,
milestoneStart: this.milestone.start_date
? parsePikadayDate(this.milestone.start_date)
: null,
};
},
computed: {
isMilestoneStarted() {
if (!this.milestoneStart) {
return false;
}
return Date.now() > this.milestoneStart;
},
isMilestonePastDue() {
if (!this.milestoneDue) {
return false;
}
return Date.now() > this.milestoneDue;
},
milestoneDatesAbsolute() {
if (this.milestoneDue) {
return `(${dateInWords(this.milestoneDue)})`;
} else if (this.milestoneStart) {
return `(${dateInWords(this.milestoneStart)})`;
}
return '';
},
milestoneDatesHuman() {
if (this.milestoneStart || this.milestoneDue) {
if (this.milestoneDue) {
return timeFor(
this.milestoneDue,
sprintf(__('Expired %{expiredOn}'), {
expiredOn: this.timeFormated(this.milestoneDue),
}),
);
}
return sprintf(
this.isMilestoneStarted ? __('Started %{startsIn}') : __('Starts %{startsIn}'),
{
startsIn: this.timeFormated(this.milestoneStart),
},
);
}
return '';
},
},
};
</script>
<template>
<div ref="milestoneDetails" class="issue-milestone-details">
<icon :size="16" class="inline icon" name="clock" />
<span class="milestone-title">{{ milestone.title }}</span>
<gl-tooltip :target="() => $refs.milestoneDetails" placement="bottom" class="js-item-milestone">
<span class="bold">{{ __('Milestone') }}</span> <br />
<span>{{ milestone.title }}</span> <br />
<span
v-if="milestoneStart || milestoneDue"
:class="{
'text-danger-muted': isMilestonePastDue,
'text-tertiary': !isMilestonePastDue,
}"
><span>{{ milestoneDatesHuman }}</span
><br /><span>{{ milestoneDatesAbsolute }}</span>
</span>
</gl-tooltip>
</div>
</template>
......@@ -67,7 +67,7 @@ export default {
// In both cases we should render the defaultAvatarUrl
sanitizedSource() {
let baseSrc = this.imgSrc === '' || this.imgSrc === null ? defaultAvatarUrl : this.imgSrc;
if (baseSrc.indexOf('?') === -1) baseSrc += `?width=${this.size}`;
if (!baseSrc.startsWith('data:') && !baseSrc.includes('?')) baseSrc += `?width=${this.size}`;
return baseSrc;
},
resultantSrcAttribute() {
......@@ -97,6 +97,7 @@ export default {
class="avatar"
/>
<gl-tooltip
v-if="tooltipText || $slots.default"
:target="() => $refs.userAvatarImage"
:placement="tooltipPlacement"
boundary="window"
......
<script>
import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
import { glEmojiTag } from '../../../emoji';
export default {
name: 'UserPopover',
components: {
GlPopover,
GlSkeletonLoading,
UserAvatarImage,
},
props: {
target: {
type: HTMLAnchorElement,
required: true,
},
user: {
type: Object,
required: true,
default: null,
},
loaded: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
jobLine() {
if (this.user.bio && this.user.organization) {
return sprintf(__('%{bio} at %{organization}'), {
bio: this.user.bio,
organization: this.user.organization,
});
} else if (this.user.bio) {
return this.user.bio;
} else if (this.user.organization) {
return this.user.organization;
}
return null;
},
statusHtml() {
if (this.user.status.emoji && this.user.status.message) {
return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`;
} else if (this.user.status.message) {
return this.user.status.message;
}
return '';
},
nameIsLoading() {
return !this.user.name;
},
jobInfoIsLoading() {
return !this.loaded && this.user.organization === null;
},
locationIsLoading() {
return !this.loaded && this.user.location === null;
},
},
};
</script>
<template>
<gl-popover :target="target" boundary="viewport" placement="top" show>
<div class="user-popover d-flex">
<div class="p-1 flex-shrink-1">
<user-avatar-image :img-src="user.avatarUrl" :size="60" css-classes="mr-2" />
</div>
<div class="p-1 w-100">
<h5 class="m-0">
{{ user.name }}
<gl-skeleton-loading
v-if="nameIsLoading"
:lines="1"
class="animation-container-small mb-1"
/>
</h5>
<div class="text-secondary mb-2">
<span v-if="user.username">@{{ user.username }}</span>
<gl-skeleton-loading v-else :lines="1" class="animation-container-small mb-1" />
</div>
<div class="text-secondary">
{{ jobLine }}
<gl-skeleton-loading
v-if="jobInfoIsLoading"
:lines="1"
class="animation-container-small mb-1"
/>
</div>
<div class="text-secondary">
{{ user.location }}
<gl-skeleton-loading
v-if="locationIsLoading"
:lines="1"
class="animation-container-small mb-1"
/>
</div>
<div v-if="user.status" class="mt-2"><span v-html="statusHtml"></span></div>
</div>
</div>
</gl-popover>
</template>
......@@ -34,6 +34,11 @@
*/
@import "pages/**/*";
/*
* Component specific styles, will be moved to gitlab-ui
*/
@import "components/**/*";
/*
* Code highlight
*/
......
......@@ -18,8 +18,10 @@ $input-border: $border-color;
$padding-base-vertical: $gl-vert-padding;
$padding-base-horizontal: $gl-padding;
html {
// Override default font size used in bs4
body,
.form-control,
.search form {
// Override default font size used in non-csslab UI
font-size: 14px;
}
......
.popover {
min-width: 300px;
.popover-body .user-popover {
padding: $gl-padding-8;
font-size: $gl-font-size-small;
line-height: $gl-line-height;
}
}
@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
......@@ -24,7 +24,7 @@
}
}
table {
&:not(.use-csslab) table {
@extend .table;
}
......
......@@ -135,7 +135,7 @@
width: 100%;
}
.md {
.md:not(.use-csslab) {
&.md-preview-holder {
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
......
......@@ -368,11 +368,11 @@ code {
* Apply Markdown typography
*
*/
.wiki {
.wiki:not(.use-csslab) {
@include md-typography;
}
.md {
.md:not(.use-csslab) {
@include md-typography;
}
......
......@@ -173,6 +173,7 @@ $theme-light-red-700: #a62e21;
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
$shadow-color: rgba($black, 0.1);
$almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor);
......
......@@ -21,3 +21,8 @@ $danger: $red-500;
$zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2);
$dropdown-divider-bg: $theme-gray-200;
$popover-max-width: 300px;
$popover-border-width: 1px;
$popover-border-color: $border-color;
$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color;
$popover-arrow-outer-color: $shadow-color;
......@@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list {
}
}
.wiki {
.wiki:not(.use-csslab) {
table {
@include markdown-table;
}
......
......@@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
before_action do
push_frontend_feature_flag(:area_chart, project)
end
prepend ::EE::Projects::EnvironmentsController
def index
......
......@@ -179,7 +179,7 @@ module IssuablesHelper
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true)
author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none")
if status = user_status(issuable.author)
......
......@@ -52,6 +52,12 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts)
data_attrs = {
user_id: author.id,
username: author.username,
name: author.name
}
return "(deleted)" unless author
author_html = []
......@@ -67,7 +73,7 @@ module ProjectsHelper
author_html = author_html.join.html_safe
if opts[:name]
link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe
link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe
......
......@@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator
ports: [],
allow_localhost: true,
allow_local_network: true,
ascii_only: false,
enforce_user: false
}
end
......
......@@ -36,6 +36,7 @@
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
= stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab)
= Gon::Base.render_data
......
......@@ -9,6 +9,6 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
%article.file-holder
%article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
.file-content.wiki
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(@blob.name, @content, legacy_render_context(params))
- else
.file-content.code.js-syntax-highlight
......
......@@ -2,5 +2,5 @@
- context = legacy_render_context(params)
- unless context[:markdown_engine] == :redcarpet
- context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
.file-content.wiki
.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
......@@ -6,7 +6,7 @@
= render 'shared/snippets/header'
.project-snippets
%article.file-holder.snippet-file-content
%article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
......
......@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
.wiki
.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render_wiki_content(@page, legacy_render_context(params))
= render 'sidebar'
---
title: Extended user centric tooltips on issue and MR page
merge_request: 23231
author:
type: added
---
title: Fix navigating by unresolved discussions on Merge Request page
merge_request: 22789
author:
type: fixed
......@@ -168,6 +168,7 @@ module Gitlab
config.assets.precompile << "locale/**/app.js"
config.assets.precompile << "emoji_sprites.css"
config.assets.precompile << "errors.css"
config.assets.precompile << "csslab.css"
# Import gitlab-svgs directly from vendored directory
config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist"
......
......@@ -592,9 +592,10 @@
in compiled/distributed product so attribution not needed.
:versions: []
:when: 2018-10-02 19:23:54.840151000 Z
- - :approve
- - :license
- echarts
- :who: Mike Greiling
- Apache 2.0
- :who: Adriel Santiago
:why: https://github.com/apache/incubator-echarts/blob/master/LICENSE
:versions: []
:when: 2018-12-05 22:12:30.550027000 Z
:when: 2018-12-07 20:46:12.421256000 Z
# Elasticsearch integration **[STARTER ONLY]**
> [Introduced][ee-109] in GitLab [Starter][ee] 8.4. Support
> for [Amazon Elasticsearch][aws-elastic] was [introduced][ee-1305] in GitLab
> [Starter][ee] 9.0.
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/109 "Elasticsearch Merge Request") in GitLab [Starter](https://about.gitlab.com/pricing/) 8.4. Support
> for [Amazon Elasticsearch](http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html) was [introduced](https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1305) in GitLab
> [Starter](https://about.gitlab.com/pricing/) 9.0.
This document describes how to set up Elasticsearch with GitLab. Once enabled,
you'll have the benefit of fast search response times and the advantage of two
......@@ -28,12 +28,12 @@ GitLab from source. Providing detailed information on installing Elasticsearch
is out of the scope of this document.
Once the data is added to the database or repository and [Elasticsearch is
enabled in the admin area](#enable-elasticsearch) the search index will be
enabled in the admin area](#enabling-elasticsearch) the search index will be
updated automatically. Elasticsearch can be installed on the same machine as
GitLab, or on a separate server, or you can use the [Amazon Elasticsearch][aws-elastic]
GitLab, or on a separate server, or you can use the [Amazon Elasticsearch](http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html)
service.
You can follow the steps as described in the [official web site][install] or
You can follow the steps as described in the [official web site](https://www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html "Elasticsearch installation documentation") or
use the packages that are available for your OS.
## Elasticsearch repository indexer (beta)
......@@ -118,7 +118,7 @@ The following Elasticsearch settings are available:
| `Use the new repository indexer (beta)` | Perform repository indexing using [GitLab Elasticsearch Indexer](https://gitlab.com/gitlab-org/gitlab-elasticsearch-indexer). |
| `Search with Elasticsearch enabled` | Enables/disables using Elasticsearch in search. |
| `URL` | The URL to use for connecting to Elasticsearch. Use a comma-separated list to support clustering (e.g., "http://host1, https://host2:9200"). If your Elasticsearch instance is password protected, pass the `username:password` in the URL (e.g., `http://<username>:<password>@<elastic_host>:9200/`). |
| `Using AWS hosted Elasticsearch with IAM credentials` | Sign your Elasticsearch requests using [AWS IAM authorization][aws-iam] or [AWS EC2 Instance Profile Credentials][aws-instance-profile]. The policies must be configured to allow `es:*` actions. |
| `Using AWS hosted Elasticsearch with IAM credentials` | Sign your Elasticsearch requests using [AWS IAM authorization](http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html) or [AWS EC2 Instance Profile Credentials](http://docs.aws.amazon.com/codedeploy/latest/userguide/getting-started-create-iam-instance-profile.html#getting-started-create-iam-instance-profile-cli). The policies must be configured to allow `es:*` actions. |
| `AWS Region` | The AWS region your Elasticsearch service is located in. |
| `AWS Access Key` | The AWS access key. |
| `AWS Secret Access Key` | The AWS secret access key. |
......@@ -313,6 +313,38 @@ curl --request POST 'http://localhost:9200/_forcemerge?max_num_segments=5'
Enable Elasticsearch search in **Admin > Settings**. That's it. Enjoy it!
## Tuning
### Deleted documents
Whenever a change or deletion is made to an indexed GitLab object (a merge request description is changed, a file is deleted from the master branch in a repository, a project is deleted, etc), a document in the index is deleted. However, since these are "soft" deletes, the overall number of "deleted documents", and therefore wasted space, increases. Elasticsearch does intelligent merging of segments in order to remove these deleted documents. However, depending on the amount and type of activity in your GitLab installation, it's possible to see as much as 50% wasted space in the index.
In general, we recommend simply letting Elasticseach merge and reclaim space automatically, with the default settings. From [Lucene's Handling of Deleted Documents](https://www.elastic.co/blog/lucenes-handling-of-deleted-documents "Lucene's Handling of Deleted Documents"), _"Overall, besides perhaps decreasing the maximum segment size, it is best to leave Lucene's defaults as-is and not fret too much about when deletes are reclaimed."_
However, some larger installations may wish to tune the merge policy settings:
- Consider reducing the `index.merge.policy.max_merged_segment` size from the default 5 GB to maybe 2 GB or 3 GB. Merging only happens when a segment has at least 50% deletions. Smaller segment sizes will allow merging to happen more frequently.
```bash
curl --request PUT http://localhost:9200/gitlab-production/_settings --data '{
"index" : {
"merge.policy.max_merged_segment": "2gb"
}
}'
```
- You can also adjust `index.merge.policy.reclaim_deletes_weight`, which controls how aggressively deletions are targetd. But this can lead to costly merge decisions, so we recommend not changing this unless you understand the tradeoffs.
```bash
curl --request PUT http://localhost:9200/gitlab-production/_settings --data '{
"index" : {
"merge.policy.reclaim_deletes_weight": "3.0"
}
}'
```
- Do not do a [force merge](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html "Force Merge") to remove deleted documents. A warning in the [documentation](https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-forcemerge.html "Force Merge") states that this can lead to very large segments that may never get reclaimed, and can also cause significant performance or availability issues.
## Troubleshooting
Here are some common pitfalls and how to overcome them:
......@@ -325,7 +357,7 @@ Here are some common pitfalls and how to overcome them:
- **I indexed all the repositories but I can't find anything**
Make sure you indexed all the database data [as stated above](#adding-gitlab-data-to-the-elasticsearch-index).
Make sure you indexed all the database data [as stated above](#adding-gitlabs-data-to-the-elasticsearch-index).
- **I indexed all the repositories but then switched elastic search servers and now I can't find anything**
......@@ -355,7 +387,7 @@ Here are some common pitfalls and how to overcome them:
- Exception `Elasticsearch::Transport::Transport::Errors::BadRequest`
If you have this exception (just like in the case above but the actual message is different) please check if you have the correct Elasticsearch version and you met the other [requirements](#requirements).
If you have this exception (just like in the case above but the actual message is different) please check if you have the correct Elasticsearch version and you met the other [requirements](#system-requirements).
There is also an easy way to check it automatically with `sudo gitlab-rake gitlab:check` command.
- Exception `Elasticsearch::Transport::Transport::Errors::RequestEntityTooLarge`
......@@ -373,13 +405,3 @@ Here are some common pitfalls and how to overcome them:
for this setting ("Maximum Size of HTTP Request Payloads"), based on the size of
the underlying instance.
[ee-1305]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1305
[aws-elastic]: http://docs.aws.amazon.com/elasticsearch-service/latest/developerguide/es-gsg.html
[aws-iam]: http://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html
[aws-instance-profile]: http://docs.aws.amazon.com/codedeploy/latest/userguide/getting-started-create-iam-instance-profile.html#getting-started-create-iam-instance-profile-cli
[ee-109]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/109 "Elasticsearch Merge Request"
[elasticsearch]: https://www.elastic.co/products/elasticsearch "Elasticsearch website"
[install]: https://www.elastic.co/guide/en/elasticsearch/reference/current/_installation.html "Elasticsearch installation documentation"
[pkg]: https://about.gitlab.com/downloads/ "Download Omnibus GitLab"
[elastic-settings]: https://www.elastic.co/guide/en/elasticsearch/reference/current/setup-configuration.html#settings "Elasticsearch configuration settings"
[ee]: https://about.gitlab.com/pricing/
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { GlTooltip } from '@gitlab/ui';
import icon from '~/vue_shared/components/icon.vue';
export default {
name: 'IssueCardWeight',
components: {
icon,
},
directives: {
GlTooltip: GlTooltipDirective,
GlTooltip,
},
props: {
weight: {
......@@ -21,16 +19,19 @@ export default {
<template>
<a
v-gl-tooltip
:title="__('Weight')"
ref="itemWeight"
class="board-card-info card-number board-card-weight"
data-container="body"
data-placement="bottom"
tabindex="1"
v-on="$listeners"
>
<icon name="weight" css-classes="board-card-info-icon" /><span class="board-card-info-text">{{
weight
}}</span>
<icon name="weight" css-classes="board-card-info-icon" />
<span class="board-card-info-text"> {{ weight }} </span>
<gl-tooltip
:target="() => $refs.itemWeight"
placement="bottom"
container="body"
class="js-item-weight"
>{{ __('Weight') }}<br /><span class="text-tertiary">{{ weight }}</span>
</gl-tooltip>
</a>
</template>
......@@ -31,6 +31,10 @@ export default {
required: false,
default: false,
},
pathIdSeparator: {
type: String,
required: true,
},
},
data() {
......@@ -135,6 +139,7 @@ export default {
:display-reference="reference"
:can-remove="true"
:is-condensed="true"
:path-id-separator="pathIdSeparator"
event-namespace="pendingIssuable"
/>
</li>
......
<script>
import { __ } from '~/locale';
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
import IssueDueDate from '~/boards/components/issue_due_date.vue';
import IssueWeight from 'ee/boards/components/issue_card_weight.vue';
import relatedIssueMixin from '../mixins/related_issues_mixin';
export default {
name: 'IssueItem',
components: {
IssueMilestone,
IssueDueDate,
IssueAssignees,
IssueWeight,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [relatedIssueMixin],
props: {
canReorder: {
......@@ -14,7 +28,14 @@ export default {
},
computed: {
stateTitle() {
return this.isOpen ? __('Open') : __('Closed');
return sprintf(
'<span class="bold">%{state}</span> %{timeInWords}<br/><span class="text-tertiary">%{timestamp}</span>',
{
state: this.isOpen ? __('Opened') : __('Closed'),
timeInWords: this.isOpen ? this.createdAtInWords : this.closedAtInWords,
timestamp: this.isOpen ? this.createdAtTimestamp : this.closedAtTimestamp,
},
);
},
},
};
......@@ -26,22 +47,70 @@ export default {
'issuable-info-container': !canReorder,
'card-body': canReorder,
}"
class="flex"
class="item-body"
>
<div class="block-truncated append-right-8 d-inline-flex">
<div class="block text-secondary append-right-default">
<div class="item-contents">
<div class="item-title d-flex align-items-center">
<icon
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="12"
:size="16"
:title="stateTitle"
:aria-label="state"
data-html="true"
/>
<icon
v-if="confidential"
v-gl-tooltip
name="eye-slash"
:size="16"
:title="__('Confidential')"
class="confidential-icon append-right-4"
:aria-label="__('Confidential')"
/>
<a :href="computedPath" class="sortable-link">{{ title }}</a>
</div>
<div class="item-meta">
<div class="d-flex align-items-center item-path-id">
<icon
v-if="hasState"
v-tooltip
:css-classes="iconClass"
:name="iconName"
:size="16"
:title="stateTitle"
:aria-label="state"
data-html="true"
/>
<span v-tooltip :title="itemPath" class="path-id-text">{{ itemPath }}</span>
{{ pathIdSeparator }}{{ itemId }}
</div>
<div class="item-meta-child d-flex align-items-center">
<issue-milestone
v-if="milestone"
:milestone="milestone"
class="d-flex align-items-center item-milestone"
/>
<issue-due-date
v-if="dueDate"
:date="dueDate"
tooltip-placement="top"
css-class="item-due-date d-flex align-items-center"
/>
<issue-weight
v-if="weight"
:weight="weight"
class="item-weight d-flex align-items-center"
/>
</div>
<issue-assignees
v-if="assignees.length"
:assignees="assignees"
class="item-assignees d-inline-flex"
/>
{{ displayReference }}
</div>
<a :href="computedPath" class="issue-token-title-text sortable-link"> {{ title }} </a>
</div>
<button
v-if="canRemove"
......@@ -49,13 +118,12 @@ export default {
v-tooltip
:disabled="removeDisabled"
type="button"
class="btn btn-default js-issue-item-remove-button issue-item-remove-button flex-align-self-center flex-right
qa-remove-issue-button"
class="btn btn-default btn-svg btn-item-remove js-issue-item-remove-button qa-remove-issue-button"
title="Remove"
aria-label="Remove"
@click="onRemoveRequest"
>
<i class="fa fa-times" aria-hidden="true"> </i>
<icon :size="16" class="btn-item-remove-icon" name="close" />
</button>
</div>
</template>
......@@ -60,6 +60,11 @@ export default {
required: false,
default: '',
},
pathIdSeparator: {
type: String,
required: false,
default: '#',
},
helpPath: {
type: String,
required: false,
......@@ -148,16 +153,13 @@ export default {
{{ title }}
<a v-if="hasHelpPath" :href="helpPath">
<i
class="related-issues-header-help-icon
fa fa-question-circle"
class="related-issues-header-help-icon fa fa-question-circle"
aria-label="Read more about related issues"
>
</i>
></i>
</a>
<div class="d-inline-flex lh-100 align-middle">
<div
class="js-related-issues-header-issue-count
related-issues-header-issue-count issue-count-badge mx-1"
class="js-related-issues-header-issue-count related-issues-header-issue-count issue-count-badge mx-1"
>
<span class="issue-count-badge-count">
<icon name="issues" class="mr-1 text-secondary" /> {{ badgeLabel }}
......@@ -167,13 +169,12 @@ fa fa-question-circle"
v-if="canAdmin"
ref="issueCountBadgeAddButton"
type="button"
class="js-issue-count-badge-add-button
issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="js-issue-count-badge-add-button issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
aria-label="Add an issue"
data-placement="top"
@click="toggleAddRelatedIssuesForm"
>
<i class="fa fa-plus" aria-hidden="true"> </i>
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
</div>
</h3>
......@@ -190,6 +191,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
:input-value="inputValue"
:pending-references="pendingReferences"
:auto-complete-sources="autoCompleteSources"
:path-id-separator="pathIdSeparator"
/>
</div>
<div
......@@ -206,7 +208,7 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
class="prepend-top-5"
/>
</div>
<ul ref="list" :class="{ 'content-list': !canReorder }" class="flex-list issuable-list">
<ul ref="list" :class="{ 'content-list': !canReorder }" class="related-items-list">
<li
v-for="issue in relatedIssues"
:key="issue.id"
......@@ -217,16 +219,24 @@ issue-count-badge-add-button btn btn-sm btn-default qa-add-issues-button"
}"
:data-key="issue.id"
:data-epic-issue-id="issue.epic_issue_id"
class="js-related-issues-token-list-item related-issues-list-item pt-0 pb-0"
class="js-related-issues-token-list-item list-item pt-0 pb-0"
>
<issue-item
:id-key="issue.id"
:display-reference="issue.reference"
:confidential="issue.confidential"
:title="issue.title"
:path="issue.path"
:state="issue.state"
:milestone="issue.milestone"
:due-date="issue.due_date"
:assignees="issue.assignees"
:weight="issue.weight"
:created-at="issue.created_at"
:closed-at="issue.closed_at"
:can-remove="canAdmin"
:can-reorder="canReorder"
:path-id-separator="pathIdSeparator"
event-namespace="relatedIssue"
/>
</li>
......
......@@ -246,6 +246,7 @@ export default {
:input-value="inputValue"
:auto-complete-sources="autoCompleteSources"
:title="title"
path-id-separator="#"
@saveReorder="saveIssueOrder"
/>
</template>
import { formatDate } from '~/lib/utils/datetime_utility';
import tooltip from '~/vue_shared/directives/tooltip';
import icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import eventHub from '../event_hub';
const mixins = {
......@@ -17,11 +19,20 @@ const mixins = {
type: String,
required: true,
},
pathIdSeparator: {
type: String,
required: true,
},
eventNamespace: {
type: String,
required: false,
default: '',
},
confidential: {
type: Boolean,
required: false,
default: false,
},
title: {
type: String,
required: false,
......@@ -37,6 +48,36 @@ const mixins = {
required: false,
default: '',
},
createdAt: {
type: String,
required: false,
default: '',
},
closedAt: {
type: String,
required: false,
default: '',
},
milestone: {
type: Object,
required: false,
default: () => ({}),
},
dueDate: {
type: String,
required: false,
default: '',
},
assignees: {
type: Array,
required: false,
default: () => [],
},
weight: {
type: Number,
required: false,
default: 0,
},
canRemove: {
type: Boolean,
required: false,
......@@ -49,6 +90,7 @@ const mixins = {
directives: {
tooltip,
},
mixins: [timeagoMixin],
computed: {
hasState() {
return this.state && this.state.length > 0;
......@@ -63,7 +105,7 @@ const mixins = {
return this.title.length > 0;
},
iconName() {
return this.isOpen ? 'issue-open' : 'issue-close';
return this.isOpen ? 'issue-open-m' : 'issue-close';
},
iconClass() {
return this.isOpen ? 'issue-token-state-icon-open' : 'issue-token-state-icon-closed';
......@@ -74,6 +116,24 @@ const mixins = {
computedPath() {
return this.path.length ? this.path : null;
},
itemPath() {
return this.displayReference.split(this.pathIdSeparator)[0];
},
itemId() {
return this.displayReference.split(this.pathIdSeparator).pop();
},
createdAtInWords() {
return this.createdAt ? this.timeFormated(this.createdAt) : '';
},
createdAtTimestamp() {
return this.createdAt ? formatDate(new Date(this.createdAt)) : '';
},
closedAtInWords() {
return this.closedAt ? this.timeFormated(this.closedAt) : '';
},
closedAtTimestamp() {
return this.closedAt ? formatDate(new Date(this.closedAt)) : '';
},
},
methods: {
onRemoveRequest() {
......
$item-path-max-width: 160px;
$item-milestone-max-width: 120px;
$item-weight-max-width: 48px;
.related-items-list {
padding: $gl-padding-4;
&,
.list-item:last-child {
margin-bottom: 0;
}
}
.item-body {
display: flex;
position: relative;
align-items: center;
padding: $gl-padding-8;
line-height: $gl-line-height;
.item-contents {
display: flex;
align-items: center;
flex-wrap: wrap;
flex-grow: 1;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed,
.confidential-icon,
.item-milestone .icon,
.item-weight .board-card-info-icon {
min-width: $gl-padding;
cursor: help;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
margin-right: $gl-padding-4;
}
.confidential-icon {
align-self: baseline;
color: $orange-600;
margin-right: $gl-padding-4;
}
.item-title {
flex-basis: 100%;
margin-bottom: $gl-padding-8;
font-size: $gl-font-size-small;
.sortable-link {
max-width: 85%;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
.item-meta {
display: flex;
flex-wrap: wrap;
flex-basis: 100%;
font-size: $gl-font-size-small;
color: $gl-text-color-secondary;
.item-meta-child {
order: 0;
display: flex;
flex-wrap: wrap;
flex-basis: 100%;
.item-due-date,
.item-weight {
margin-left: $gl-padding-8;
}
.item-milestone,
.item-weight {
cursor: help;
text-decoration: none;
}
.item-milestone {
max-width: $item-milestone-max-width;
}
.item-due-date {
margin-right: 0;
}
.item-weight {
margin-right: 0;
max-width: $item-weight-max-width;
}
}
.item-path-id .path-id-text,
.item-milestone .milestone-title,
.item-due-date,
.item-weight .board-card-info-text {
color: $gl-text-color-secondary;
display: inline-block;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.item-path-id {
order: 1;
margin-top: $gl-padding-4;
font-size: $gl-font-size-xs;
.path-id-text {
font-weight: $gl-font-weight-bold;
max-width: $item-path-max-width;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: block;
}
}
.item-milestone .ic-clock {
color: $gl-text-color-tertiary;
margin-right: $gl-padding-4;
}
.item-assignees {
order: 2;
align-self: flex-end;
align-items: center;
margin-left: auto;
.user-avatar-link {
margin-right: -$gl-padding-4;
&:nth-of-type(1) {
z-index: 2;
}
&:nth-of-type(2) {
z-index: 1;
}
&:last-child {
margin-right: 0;
}
}
.avatar {
height: $gl-padding;
width: $gl-padding;
margin-right: 0;
vertical-align: bottom;
}
.avatar-counter {
height: $gl-padding;
border: 1px solid transparent;
background-color: $gl-text-color-tertiary;
font-weight: $gl-font-weight-bold;
padding: 0 $gl-padding-4;
line-height: $gl-padding;
}
}
}
.btn-item-remove {
position: absolute;
right: 0;
top: $gl-padding-4 / 2;
padding: $gl-padding-4;
margin-right: $gl-padding-4 / 2;
line-height: 0;
border-color: transparent;
color: $gl-text-color-secondary;
&:hover {
color: $gl-text-color;
}
}
}
@include media-breakpoint-up(sm) {
.item-body {
.item-contents .item-title .sortable-link {
max-width: 90%;
}
}
}
/* Small devices (landscape phones, 768px and up) */
@include media-breakpoint-up(md) {
.item-body {
.item-contents {
min-width: 0;
.item-title {
flex-basis: unset;
// 98% because we compensate
// for remove button which is
// positioned absolutely
width: 95%;
margin-bottom: $gl-padding-4;
.sortable-link {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
}
.item-meta {
.item-path-id {
order: 0;
margin-top: 0;
}
.item-meta-child {
flex-basis: unset;
margin-left: auto;
margin-right: $gl-padding-4;
~ .item-assignees {
margin-left: $gl-padding-4;
}
}
.item-assignees {
margin-bottom: 0;
margin-left: 0;
order: 2;
}
}
}
.btn-item-remove {
order: 1;
}
}
}
/* Medium devices (desktops, 992px and up) */
@include media-breakpoint-up(lg) {
.item-body {
padding: $gl-padding;
.item-title {
font-size: $gl-font-size;
}
.item-meta .item-path-id {
font-size: inherit; // Base size given to `item-meta` is `$gl-font-size-small`
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
margin-right: $gl-padding-4;
}
}
}
/* Large devices (large desktops, 1200px and up) */
@include media-breakpoint-up(xl) {
.item-body {
padding: $gl-padding-8;
padding-left: $gl-padding;
.item-contents {
flex-wrap: nowrap;
overflow: hidden;
.item-title {
display: flex;
margin-bottom: 0;
min-width: 0;
width: auto;
flex-basis: unset;
font-weight: $gl-font-weight-normal;
.sortable-link {
display: block;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: block;
margin-right: $gl-padding-8;
}
.confidential-icon {
align-self: auto;
margin-top: 0;
}
}
.item-meta {
margin-top: 0;
justify-content: flex-end;
flex: 1;
flex-wrap: nowrap;
.item-path-id {
order: 0;
margin-top: 0;
margin-left: $gl-padding-8;
margin-right: auto;
.issue-token-state-icon-open,
.issue-token-state-icon-closed {
display: none;
}
}
.item-meta-child {
margin-left: $gl-padding-8;
flex-wrap: nowrap;
}
.item-assignees {
flex-grow: 0;
margin-top: 0;
margin-right: $gl-padding-4;
.avatar {
height: $gl-padding-24;
width: $gl-padding-24;
}
.avatar-counter {
height: $gl-padding-24;
line-height: $gl-padding-24;
border-radius: $gl-padding-24;
}
}
}
}
.btn-item-remove {
position: relative;
align-self: center;
top: initial;
right: 0;
margin-right: 0;
padding: $btn-sm-side-margin;
&:hover {
border-color: $border-color;
}
}
}
}
......@@ -33,7 +33,6 @@ $token-spacing-bottom: 0.5em;
li .issuable-info-container {
padding-left: $gl-padding;
padding-right: $gl-padding-4;
@include media-breakpoint-down(sm) {
padding-left: $gl-padding-8;
......
......@@ -15,6 +15,8 @@ class Groups::SsoController < Groups::ApplicationController
def saml
@group_path = params[:group_id]
@group_name = @unauthenticated_group.full_name
@group_saml_identity = linked_identity
@idp_url = @unauthenticated_group.saml_provider.sso_url
end
def unlink
......
......@@ -219,9 +219,10 @@ module EE
def update_project_counter_caches
end
def issues_readable_by(current_user)
def issues_readable_by(current_user, preload: nil)
related_issues = ::Issue.select('issues.*, epic_issues.id as epic_issue_id, epic_issues.relative_position')
.joins(:epic_issue)
.preload(preload)
.where("epic_issues.epic_id = #{id}")
.order('epic_issues.relative_position, epic_issues.id')
......
......@@ -5,7 +5,7 @@ class SamlProvider < ActiveRecord::Base
has_many :identities
validates :group, presence: true, top_level_group: true
validates :sso_url, presence: true, url: { protocols: %w(https) }
validates :sso_url, presence: true, url: { protocols: %w(https), ascii_only: true }
validates :certificate_fingerprint, presence: true, certificate_fingerprint: true
after_initialize :set_defaults, if: :new_record?
......
......@@ -7,7 +7,7 @@ module EpicIssues
def child_issuables
return [] unless issuable&.group&.feature_available?(:epics)
issuable.issues_readable_by(current_user)
issuable.issues_readable_by(current_user, preload: preload_for_collection)
end
def relation_path(issue)
......
......@@ -18,6 +18,10 @@ module IssuableLinks
private
def preload_for_collection
[{ project: :namespace }, :assignees]
end
def relation_path(object)
raise NotImplementedError
end
......@@ -30,15 +34,24 @@ module IssuableLinks
project_issue_path(object.project, object.iid)
end
# rubocop: disable CodeReuse/Serializer
def to_hash(object)
{
id: object.id,
confidential: object.confidential,
title: object.title,
assignees: UserSerializer.new.represent(object.assignees),
state: object.state,
milestone: MilestoneSerializer.new.represent(object.milestone),
weight: object.weight,
reference: reference(object),
path: issuable_path(object),
relation_path: relation_path(object)
relation_path: relation_path(object),
due_date: object.due_date,
created_at: object.created_at&.to_s,
closed_at: object.closed_at
}
end
# rubocop: enable CodeReuse/Serializer
end
end
......@@ -7,7 +7,7 @@ module IssueLinks
private
def child_issuables
issuable.related_issues(current_user, preload: { project: :namespace })
issuable.related_issues(current_user, preload: preload_for_collection)
end
def relation_path(issue)
......
......@@ -3,8 +3,19 @@
= render 'devise/shared/tab_single', tab_title: _('SAML SSO')
.login-box
.login-body
%h4= _("Sign in to %{group_name}") % { group_name: @group_name }
- if @group_saml_identity
%h4= _('Sign in to "%{group_name}"') % { group_name: @group_name }
- else
%h4= _('Allow "%{group_name}" to sign you in') % { group_name: @group_name }
%p= _("This group allows you to sign in with your %{group_name} Single Sign-On account. This will redirect you to an external sign in page.") % { group_name: @group_name }
%p= _('The "%{group_path}" group allows you to sign in with your Single Sign-On Account') % { group_path: @group_path }
= saml_link _('Sign in with Single Sign-On'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
- if @group_saml_identity
%p= _("This will redirect you to an external sign in page.")
= saml_link _('Sign in with Single Sign-On'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
- else
.card.card-body.bs-callout-warning
= _("Only proceed if you trust %{idp_url} to control your GitLab account sign in.") % { idp_url: @idp_url }
= saml_link _('Authorize'), @group_path, html_class: 'btn btn-success btn-block qa-saml-sso-signin-button'
......@@ -7,7 +7,7 @@
.billing-plans-alert.card.prepend-top-10
.card-header.bg-warning.text-white
= s_("BillingPlans|Automatic downgrade and upgrade to some plans is currently not available.")
- customer_support_url = 'https://support.gitlab.com/hc/en-us/requests/new?ticket_form_id=334447';
- customer_support_url = 'https://about.gitlab.com/sales/';
- customer_support_link = link_to s_("BillingPlans|Customer Support"), customer_support_url
= s_("BillingPlans|Please contact %{customer_support_link} in that case.").html_safe % { customer_support_link: customer_support_link }
......
---
title: Added recommendations for handling deleted documents in Elasticsearch
merge_request:
author:
type: other
---
title: Epic issue list and related issue list re-design
merge_request:
author:
type: changed
---
title: Group SAML SSO page warns when linking account
merge_request: 8295
author:
type: changed
......@@ -3,9 +3,10 @@ require 'spec_helper'
describe Groups::EpicIssuesController do
let(:group) { create(:group, :public) }
let(:project) { create(:project, :public, group: group) }
let(:milestone) { create(:milestone, project: project) }
let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project) }
let(:user) { create(:user) }
let(:user) { create(:user) }
let(:issue) { create(:issue, project: project, milestone: milestone, assignees: [user]) }
before do
stub_licensed_features(epics: true)
......@@ -45,18 +46,7 @@ describe Groups::EpicIssuesController do
end
it 'returns the correct json' do
expected_result = [
{
'id' => issue.id,
'title' => issue.title,
'state' => issue.state,
'reference' => "#{project.full_path}##{issue.iid}",
'path' => "/#{project.full_path}/issues/#{issue.iid}",
'relation_path' => "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue.id}",
'epic_issue_id' => epic_issue.id
}
]
expect(JSON.parse(response.body)).to eq(expected_result)
expect(JSON.parse(response.body)).to match_schema('related_issues', dir: 'ee')
end
end
end
......
......@@ -32,7 +32,7 @@ describe 'Epic Issues', :js do
end
it 'user can see issues from public project but cannot delete the associations' do
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
expect(page).to have_content(public_issue.title)
expect(page).not_to have_selector('button.js-issue-item-remove-button')
......@@ -70,7 +70,7 @@ describe 'Epic Issues', :js do
end
it 'user can see all issues of the group and delete the associations' do
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 2)
expect(page).to have_content(public_issue.title)
expect(page).to have_content(private_issue.title)
......@@ -80,7 +80,7 @@ describe 'Epic Issues', :js do
wait_for_requests
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 1)
end
end
......@@ -100,7 +100,7 @@ describe 'Epic Issues', :js do
expect(page).not_to have_selector('.content-wrapper .alert-wrapper .flash-text')
expect(page).not_to have_content('No Issue found for given params')
within('.related-issues-block ul.issuable-list') do
within('.related-issues-block ul.related-items-list') do
expect(page).to have_selector('li', count: 3)
expect(page).to have_content(issue_to_add.title)
end
......@@ -110,7 +110,7 @@ describe 'Epic Issues', :js do
expect(first('.js-related-issues-token-list-item')).to have_content(public_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(private_issue.title)
drag_to(selector: '.issuable-list', to_index: 1)
drag_to(selector: '.related-items-list', to_index: 1)
expect(first('.js-related-issues-token-list-item')).to have_content(private_issue.title)
expect(page.all('.js-related-issues-token-list-item').last).to have_content(public_issue.title)
......
......@@ -154,15 +154,37 @@ describe 'SAML provider settings' do
context 'when signed in' do
before do
sign_in(user)
end
it 'shows warning that linking accounts authorizes control over sign in' do
visit sso_group_saml_providers_path(group)
expect(page).to have_content(/Allow .* to sign you in/)
expect(page).to have_content(saml_provider.sso_url)
expect(page).to have_content('Authorize')
end
it 'Sign in button redirects to auth flow and back to group' do
click_link 'Sign in with Single Sign-On'
it 'Authorize/link button redirects to auth flow' do
visit sso_group_saml_providers_path(group)
click_link 'Authorize'
expect(current_path).to eq callback_path
end
context 'with linked account' do
before do
create(:group_saml_identity, saml_provider: saml_provider, user: user)
end
it 'Sign in button redirects to auth flow' do
visit sso_group_saml_providers_path(group)
click_link 'Sign in with Single Sign-On'
expect(current_path).to eq callback_path
end
end
end
context 'for a private group' do
......@@ -187,7 +209,7 @@ describe 'SAML provider settings' do
expect(current_path).to eq sso_group_saml_providers_path(group)
within '.login-box' do
expect(page).to have_link 'Sign in with Single Sign-On'
expect(page).to have_link 'Authorize'
end
end
......
......@@ -258,7 +258,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
# Form gets hidden after submission
expect(page).not_to have_selector('.js-add-related-issues-form-area')
......@@ -275,7 +275,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title)
......@@ -289,7 +289,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(1)
expect(items[0].text).to eq(issue_project_b_a.title)
......@@ -311,7 +311,7 @@ describe 'Related issues', :js do
end
it 'shows related issues' do
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
......@@ -319,7 +319,7 @@ describe 'Related issues', :js do
end
it 'allows us to remove a related issues' do
items_before = all('.js-related-issues-token-list-item .issue-token-title-text')
items_before = all('.item-title a')
expect(items_before.count).to eq(2)
......@@ -327,7 +327,7 @@ describe 'Related issues', :js do
wait_for_requests
items_after = all('.js-related-issues-token-list-item .issue-token-title-text')
items_after = all('.item-title a')
expect(items_after.count).to eq(1)
end
......@@ -339,7 +339,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(3)
expect(items[0].text).to eq(issue_b.title)
......@@ -355,7 +355,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
......@@ -370,7 +370,7 @@ describe 'Related issues', :js do
wait_for_requests
items = all('.js-related-issues-token-list-item .issue-token-title-text')
items = all('.item-title a')
expect(items.count).to eq(2)
expect(items[0].text).to eq(issue_b.title)
......
{
"type": "object",
"additionalProperties": false,
"required": [
"id",
"confidential",
"title",
"assignees",
"milestone",
"due_date",
"state",
"reference",
"path",
"relation_path",
"weight"
],
"properties": {
"id": { "type": "integer" },
"confidential": { "type": "boolean" },
"title": { "type": "string" },
"assignees": { "type": "array" },
"milestone": { "type": ["object", "null"] },
"due_date": { "type": ["string", "null"] },
"state": { "type": "string" },
"weight": { "type": ["integer", "null"] },
"reference": { "type": "string" },
"path": { "type": "string" },
"relation_path": { "type": "string" },
"epic_issue_id": { "type": ["integer", "null"] },
"created_at": { "type": "string" },
"closed_at": { "type": ["string", "null"] }
}
}
{
"type": "array",
"items": { "$ref": "related_issue.json" }
}
......@@ -48,6 +48,6 @@ describe('Issue card component', () => {
const el = vm.$el.querySelector('.board-card-weight');
expect(el).not.toBeNull();
expect(el.textContent.trim()).toBe('2');
expect(el.textContent.trim()).toContain('2');
});
});
......@@ -55,7 +55,7 @@ describe('EpicBodyComponent', () => {
expect(vm.$el.querySelector('.related-issues-block')).not.toBeNull();
expect(vm.$el.querySelector('.js-related-issues-header-issue-count')).not.toBeNull();
expect(vm.$el.querySelector('.related-issues-token-body')).not.toBeNull();
expect(vm.$el.querySelector('.issuable-list')).not.toBeNull();
expect(vm.$el.querySelector('.related-items-list')).not.toBeNull();
});
});
});
......@@ -21,6 +21,8 @@ const issuable2 = {
state: 'opened',
};
const pathIdSeparator = '#';
describe('AddIssuableForm', () => {
let AddIssuableForm;
let vm;
......@@ -47,6 +49,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: '',
pendingReferences: [],
pathIdSeparator,
},
}).$mount();
});
......@@ -63,6 +66,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: 'foo',
pendingReferences: [],
pathIdSeparator,
},
}).$mount();
});
......@@ -81,6 +85,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue,
pendingReferences: [issuable1.reference, issuable2.reference],
pathIdSeparator,
},
}).$mount();
});
......@@ -105,6 +110,7 @@ describe('AddIssuableForm', () => {
inputValue: '',
pendingReferences: [issuable1.reference, issuable2.reference],
isSubmitting: true,
pathIdSeparator,
},
}).$mount();
});
......@@ -125,6 +131,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount();
});
......@@ -144,6 +151,7 @@ describe('AddIssuableForm', () => {
propsData: {
inputValue: '',
autoCompleteSources: {},
pathIdSeparator,
},
}).$mount();
});
......@@ -185,6 +193,7 @@ describe('AddIssuableForm', () => {
autoCompleteSources: {
issues: '/fake/issues/path',
},
pathIdSeparator,
},
}).$mount(el);
});
......
......@@ -2,14 +2,22 @@ import Vue from 'vue';
import issueItem from 'ee/related_issues/components/issue_item.vue';
import eventHub from 'ee/related_issues/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { defaultMilestone, defaultAssignees } from '../mock_data';
describe('issueItem', () => {
let vm;
const props = {
idKey: 1,
displayReference: '#1',
displayReference: 'gitlab-org/gitlab-test#1',
pathIdSeparator: '#',
path: `${gl.TEST_HOST}/path`,
title: 'title',
confidential: true,
dueDate: '2018-12-31',
weight: 10,
createdAt: '2018-12-01T00:00:00.00Z',
milestone: defaultMilestone,
assignees: defaultAssignees,
};
beforeEach(() => {
......@@ -22,12 +30,6 @@ describe('issueItem', () => {
expect(vm.$el.querySelector('.issuable-info-container')).toBeNull();
});
it('renders displayReference', () => {
expect(vm.$el.querySelector('.text-secondary').innerText.trim()).toEqual(
props.displayReference,
);
});
it('does not render token state', () => {
expect(vm.$el.querySelector('.text-secondary svg')).toBeNull();
});
......@@ -38,11 +40,17 @@ describe('issueItem', () => {
describe('token title', () => {
it('links to computedPath', () => {
expect(vm.$el.querySelector('a').href).toEqual(props.path);
expect(vm.$el.querySelector('.item-title a').href).toEqual(props.path);
});
it('renders confidential icon', () => {
expect(
vm.$el.querySelector('.item-title svg.confidential-icon use').getAttribute('xlink:href'),
).toContain('eye-slash');
});
it('renders title', () => {
expect(vm.$el.querySelector('a').innerText.trim()).toEqual(props.title);
expect(vm.$el.querySelector('.item-title a').innerText.trim()).toEqual(props.title);
});
});
......@@ -52,7 +60,7 @@ describe('issueItem', () => {
beforeEach(done => {
vm.state = 'opened';
Vue.nextTick(() => {
tokenState = vm.$el.querySelector('.text-secondary svg');
tokenState = vm.$el.querySelector('.item-meta svg');
done();
});
});
......@@ -62,7 +70,12 @@ describe('issueItem', () => {
});
it('renders state title', () => {
expect(tokenState.getAttribute('data-original-title')).toEqual('Open');
const stateTitle = tokenState.getAttribute('data-original-title').trim();
expect(stateTitle).toContain('<span class="bold">Opened</span>');
expect(stateTitle).toContain(
'<span class="text-tertiary">Dec 1, 2018 12:00am GMT+0000</span>',
);
});
it('renders aria label', () => {
......@@ -75,6 +88,7 @@ describe('issueItem', () => {
it('renders close icon when close state', done => {
vm.state = 'closed';
vm.closedAt = '2018-12-01T00:00:00.00Z';
Vue.nextTick(() => {
expect(tokenState.classList.contains('issue-token-state-icon-closed')).toEqual(true);
......@@ -83,6 +97,57 @@ describe('issueItem', () => {
});
});
describe('token metadata', () => {
let tokenMetadata;
beforeEach(done => {
Vue.nextTick(() => {
tokenMetadata = vm.$el.querySelector('.item-meta');
done();
});
});
it('renders item path and ID', () => {
const pathAndID = tokenMetadata.querySelector('.item-path-id').innerText.trim();
expect(pathAndID).toContain('gitlab-org/gitlab-test');
expect(pathAndID).toContain('#1');
});
it('renders milestone icon and name', () => {
const milestoneIconEl = tokenMetadata.querySelector('.item-milestone svg use');
const milestoneTitle = tokenMetadata.querySelector('.item-milestone .milestone-title');
expect(milestoneIconEl.getAttribute('xlink:href')).toContain('clock');
expect(milestoneTitle.innerText.trim()).toContain('Milestone title');
});
it('renders date icon and due date', () => {
const dueDateIconEl = tokenMetadata.querySelector('.item-due-date svg use');
const dueDateEl = tokenMetadata.querySelector('.item-due-date time');
expect(dueDateIconEl.getAttribute('xlink:href')).toContain('calendar');
expect(dueDateEl.innerText.trim()).toContain('Dec 31');
});
it('renders weight icon and value', () => {
const dueDateIconEl = tokenMetadata.querySelector('.item-weight svg use');
const dueDateEl = tokenMetadata.querySelector('.item-weight span');
expect(dueDateIconEl.getAttribute('xlink:href')).toContain('weight');
expect(dueDateEl.innerText.trim()).toContain('10');
});
});
describe('token assignees', () => {
it('renders assignees avatars', () => {
const assigneesEl = vm.$el.querySelector('.item-assignees');
expect(assigneesEl.querySelectorAll('.user-avatar-link').length).toBe(2);
expect(assigneesEl.querySelector('.avatar-counter').innerText.trim()).toContain('+2');
});
});
describe('remove button', () => {
let removeBtn;
......
......@@ -6,6 +6,7 @@ describe('IssueToken', () => {
const idKey = 200;
const displayReference = 'foo/bar#123';
const title = 'some title';
const pathIdSeparator = '#';
let IssueToken;
let vm;
......@@ -25,6 +26,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
});
......@@ -45,6 +47,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
},
}).$mount();
......@@ -63,6 +66,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
path,
},
......@@ -81,6 +85,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'opened',
},
}).$mount();
......@@ -97,6 +102,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'reopened',
},
}).$mount();
......@@ -113,6 +119,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
state: 'closed',
},
}).$mount();
......@@ -131,6 +138,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
title,
state,
},
......@@ -153,6 +161,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
});
......@@ -168,6 +177,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
canRemove: true,
},
}).$mount();
......@@ -187,6 +197,7 @@ describe('IssueToken', () => {
propsData: {
idKey,
displayReference,
pathIdSeparator,
},
}).$mount();
removeRequestSpy = jasmine.createSpy('spy');
......
......@@ -7,6 +7,7 @@ export const defaultProps = {
export const issuable1 = {
id: 200,
epic_issue_id: 1,
confidential: false,
reference: 'foo/bar#123',
displayReference: '#123',
title: 'some title',
......@@ -17,6 +18,7 @@ export const issuable1 = {
export const issuable2 = {
id: 201,
epic_issue_id: 2,
confidential: false,
reference: 'foo/bar#124',
displayReference: '#124',
title: 'some other thing',
......@@ -27,6 +29,7 @@ export const issuable2 = {
export const issuable3 = {
id: 202,
epic_issue_id: 3,
confidential: false,
reference: 'foo/bar#125',
displayReference: '#125',
title: 'some other other thing',
......@@ -37,6 +40,7 @@ export const issuable3 = {
export const issuable4 = {
id: 203,
epic_issue_id: 4,
confidential: false,
reference: 'foo/bar#126',
displayReference: '#126',
title: 'some other other other thing',
......@@ -47,9 +51,61 @@ export const issuable4 = {
export const issuable5 = {
id: 204,
epic_issue_id: 5,
confidential: false,
reference: 'foo/bar#127',
displayReference: '#127',
title: 'some other other other thing',
path: '/foo/bar/issues/127',
state: 'opened',
};
export const defaultMilestone = {
id: 1,
state: 'active',
title: 'Milestone title',
start_date: '2018-01-01',
due_date: '2019-12-31',
};
export const defaultAssignees = [
{
id: 1,
name: 'Administrator',
username: 'root',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/root`,
status_tooltip_html: null,
path: '/root',
},
{
id: 13,
name: 'Brooks Beatty',
username: 'brynn_champlin',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/brynn_champlin`,
status_tooltip_html: null,
path: '/brynn_champlin',
},
{
id: 6,
name: 'Bryce Turcotte',
username: 'melynda',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/melynda`,
status_tooltip_html: null,
path: '/melynda',
},
{
id: 20,
name: 'Conchita Eichmann',
username: 'juliana_gulgowski',
state: 'active',
avatar_url: `${gl.TEST_HOST}`,
web_url: `${gl.TEST_HOST}/juliana_gulgowski`,
status_tooltip_html: null,
path: '/juliana_gulgowski',
},
];
......@@ -16,6 +16,15 @@ describe SamlProvider do
expect(subject).not_to allow_value('http://example.com').for(:sso_url)
end
it 'prevents homoglyph phishing attacks by only allowing ascii URLs' do
expect(subject).to allow_value('https://gitlab.com/adfs/ls').for(:sso_url)
expect(subject).not_to allow_value('https://𝕘itⅼaƄ.ᴄοm/adfs/ls').for(:sso_url)
end
it 'allows unicode domain names when encoded as ascii punycode' do
expect(subject).to allow_value('https://xn--gitl-ocb944a.xn--m-rmb025q/adfs/ls').for(:sso_url)
end
it 'expects certificate_fingerprint to be in an accepted format' do
expect(subject).to allow_value('000030EDC285E01D6B5EA33010A79ADD142F5004').for(:certificate_fingerprint)
expect(subject).to allow_value('00:00:30:ED:C2:85:E0:1D:6B:5E:A3:30:10:A7:9A:DD:14:2F:50:04').for(:certificate_fingerprint)
......
......@@ -7,7 +7,7 @@ describe EpicIssues::ListService do
let(:other_project) { create(:project_empty_repo, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue1) { create :issue, project: project }
let(:issue1) { create :issue, project: project, weight: 1 }
let(:issue2) { create :issue, project: project }
let(:issue3) { create :issue, project: other_project }
......@@ -31,6 +31,36 @@ describe EpicIssues::ListService do
stub_licensed_features(epics: true)
end
it 'does not have N+1 queries', :use_clean_rails_memory_store_caching, :request_store do
# The control query is made with the worst case scenario:
# * Two different issues from two different projects that belong to two different groups
# Then a new group with a new project is created and we do the call again to check if there will be no
# additional queries.
group.add_developer(user)
list_service = described_class.new(epic, user)
new_group = create(:group, :private)
new_group.add_developer(user)
new_project = create(:project, namespace: new_group)
milestone = create(:milestone, project: project)
milestone2 = create(:milestone, project: new_project)
new_issue1 = create(:issue, project: project, milestone: milestone, assignees: [user])
new_issue3 = create(:issue, project: new_project, milestone: milestone2)
create(:epic_issue, issue: new_issue1, epic: epic, relative_position: 3)
create(:epic_issue, issue: new_issue3, epic: epic, relative_position: 5)
control_count = ActiveRecord::QueryRecorder.new { list_service.execute }.count
new_group2 = create(:group, :private)
new_project2 = create(:project, namespace: new_group2)
new_group2.add_developer(user)
milestone3 = create(:milestone, project: new_project2)
new_issue4 = create(:issue, project: new_project, milestone: milestone3)
create(:epic_issue, issue: new_issue4, epic: epic, relative_position: 6)
expect { list_service.execute }.not_to exceed_query_limit(control_count)
end
context 'owner can see all issues and destroy their associations' do
before do
group.add_developer(user)
......@@ -41,31 +71,53 @@ describe EpicIssues::ListService do
{
id: issue2.id,
title: issue2.title,
assignees: [],
state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue2.id}",
epic_issue_id: epic_issue2.id
epic_issue_id: epic_issue2.id,
due_date: nil,
created_at: issue2.created_at.to_s,
closed_at: issue2.closed_at
},
{
id: issue1.id,
title: issue1.title,
assignees: [],
state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue1.id}",
epic_issue_id: epic_issue1.id
epic_issue_id: epic_issue1.id,
due_date: nil,
created_at: issue1.created_at.to_s,
closed_at: issue1.closed_at
},
{
id: issue3.id,
title: issue3.title,
assignees: [],
state: issue3.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue3.to_reference(full: true),
path: "/#{other_project.full_path}/issues/#{issue3.iid}",
relation_path: "/groups/#{group.full_path}/-/epics/#{epic.iid}/issues/#{epic_issue3.id}",
epic_issue_id: epic_issue3.id
epic_issue_id: epic_issue3.id,
due_date: nil,
created_at: issue3.created_at.to_s,
closed_at: issue3.closed_at
}
]
expect(subject).to eq(expected_result)
end
end
......@@ -80,20 +132,34 @@ describe EpicIssues::ListService do
{
id: issue2.id,
title: issue2.title,
assignees: [],
state: issue2.state,
milestone: nil,
weight: nil,
confidential: false,
reference: issue2.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue2.iid}",
relation_path: nil,
epic_issue_id: epic_issue2.id
epic_issue_id: epic_issue2.id,
due_date: nil,
created_at: issue2.created_at.to_s,
closed_at: issue2.closed_at
},
{
id: issue1.id,
title: issue1.title,
assignees: [],
state: issue1.state,
milestone: nil,
weight: 1,
confidential: false,
reference: issue1.to_reference(full: true),
path: "/#{project.full_path}/issues/#{issue1.iid}",
relation_path: nil,
epic_issue_id: epic_issue1.id
epic_issue_id: epic_issue1.id,
due_date: nil,
created_at: issue1.created_at.to_s,
closed_at: issue1.closed_at
}
]
......
......@@ -88,7 +88,7 @@ describe Groups::AutocompleteService do
let!(:subgroup_milestone) { create(:milestone, group: sub_group) }
before do
sub_group.add_guest(user)
sub_group.add_master(user)
end
context 'when group is public' do
......
......@@ -39,8 +39,9 @@ describe IssueLinks::ListService do
control_count = ActiveRecord::QueryRecorder.new { subject }.count
project = create :project, :public
issue_x = create :issue, project: project
issue_y = create :issue, project: project
milestone = create :milestone, project: project
issue_x = create :issue, project: project, milestone: milestone
issue_y = create :issue, project: project, assignees: [user]
issue_z = create :issue, project: project
create :issue_link, source: issue_x, target: issue_y
create :issue_link, source: issue_x, target: issue_z
......
......@@ -106,7 +106,7 @@ module Banzai
end
def link_class
reference_class(:project_member)
reference_class(:project_member, tooltip: false)
end
def link_to_all(link_content: nil)
......
......@@ -8,7 +8,7 @@ module Gitlab
BlockedUrlError = Class.new(StandardError)
class << self
def validate!(url, allow_localhost: false, allow_local_network: true, enforce_user: false, ports: [], protocols: [])
def validate!(url, ports: [], protocols: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false)
return true if url.nil?
# Param url can be a string, URI or Addressable::URI
......@@ -22,6 +22,7 @@ module Gitlab
validate_port!(port, ports) if ports.any?
validate_user!(uri.user) if enforce_user
validate_hostname!(uri.hostname)
validate_unicode_restriction!(uri) if ascii_only
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr|
......@@ -91,6 +92,12 @@ module Gitlab
raise BlockedUrlError, "Hostname or IP address invalid"
end
def validate_unicode_restriction!(uri)
return if uri.to_s.ascii_only?
raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}"
end
def validate_localhost!(addrs_info)
local_ips = ["::", "0.0.0.0"]
local_ips.concat(Socket.ip_address_list.map(&:ip_address))
......
......@@ -116,6 +116,9 @@ msgstr ""
msgid "%{authorsName}'s discussion"
msgstr ""
msgid "%{bio} at %{organization}"
msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
......@@ -578,6 +581,9 @@ msgstr ""
msgid "All users"
msgstr ""
msgid "Allow \"%{group_name}\" to sign you in"
msgstr ""
msgid "Allow commits from members who can merge to the target branch."
msgstr ""
......@@ -1055,6 +1061,9 @@ msgstr ""
msgid "Available specific runners"
msgstr ""
msgid "Avatar for %{assigneeName}"
msgstr ""
msgid "Avatar will be removed. Are you sure?"
msgstr ""
......@@ -3456,6 +3465,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
msgid "Expired %{expiredOn}"
msgstr ""
msgid "Expires in %{expires_at}"
msgstr ""
......@@ -6012,6 +6024,9 @@ msgstr ""
msgid "Only mirror protected branches"
msgstr ""
msgid "Only proceed if you trust %{idp_url} to control your GitLab account sign in."
msgstr ""
msgid "Only project members can comment."
msgstr ""
......@@ -7884,7 +7899,7 @@ msgstr ""
msgid "Sign in / Register"
msgstr ""
msgid "Sign in to %{group_name}"
msgid "Sign in to \"%{group_name}\""
msgstr ""
msgid "Sign in via 2FA code"
......@@ -8196,9 +8211,15 @@ msgstr ""
msgid "Started"
msgstr ""
msgid "Started %{startsIn}"
msgstr ""
msgid "Starting..."
msgstr ""
msgid "Starts %{startsIn}"
msgstr ""
msgid "Starts at (UTC)"
msgstr ""
......@@ -8463,6 +8484,9 @@ msgstr ""
msgid "Thanks! Don't show me this again"
msgstr ""
msgid "The \"%{group_path}\" group allows you to sign in with your Single Sign-On Account"
msgstr ""
msgid "The Advanced Global Search in GitLab is a powerful search service that saves you time. Instead of creating duplicate code and wasting time, you can now search for code within other teams that can help your own project."
msgstr ""
......@@ -8682,9 +8706,6 @@ msgstr ""
msgid "This group"
msgstr ""
msgid "This group allows you to sign in with your %{group_name} Single Sign-On account. This will redirect you to an external sign in page."
msgstr ""
msgid "This group does not provide any group Runners yet."
msgstr ""
......@@ -8820,6 +8841,9 @@ msgstr ""
msgid "This user will be the author of all events in the activity feed that are the result of an update, like new branches being created or new commits being pushed to existing branches. Upon creation or when reassigning you can only assign yourself to be the mirror user."
msgstr ""
msgid "This will redirect you to an external sign in page."
msgstr ""
msgid "Those emails automatically become issues (with the comments becoming the email conversation) listed here."
msgstr ""
......
......@@ -361,8 +361,14 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end
end
it 'shows jump to next discussion button' do
expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
it 'shows jump to next discussion button except on last discussion' do
wait_for_requests
all_discussion_replies = page.all('.discussion-reply-holder')
expect(all_discussion_replies.count).to eq(2)
expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(1)
expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(0)
end
it 'displays next discussion even if hidden' do
......@@ -380,7 +386,13 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
page.find('.discussion-next-btn').click
end
expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
page.all('.note-discussion').first do
expect(page.find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
end
page.all('.note-discussion').last do
expect(page.find('.discussion-with-resolve-btn')).not.to have_selector('.btn', text: 'Resolve discussion')
end
end
end
......
......@@ -11,6 +11,7 @@ describe "User browses files" do
let(:user) { project.owner }
before do
stub_feature_flags(csslab: false)
sign_in(user)
end
......
......@@ -355,6 +355,40 @@ describe('Api', () => {
});
});
describe('user', () => {
it('fetches single user', done => {
const userId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`;
mock.onGet(expectedUrl).reply(200, {
name: 'testuser',
});
Api.user(userId)
.then(({ data }) => {
expect(data.name).toBe('testuser');
})
.then(done)
.catch(done.fail);
});
});
describe('user status', () => {
it('fetches single user status', done => {
const userId = '123456';
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`;
mock.onGet(expectedUrl).reply(200, {
message: 'testmessage',
});
Api.userStatus(userId)
.then(({ data }) => {
expect(data.message).toBe('testmessage');
})
.then(done)
.catch(done.fail);
});
});
describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar';
......
......@@ -130,3 +130,12 @@ export const mockAssigneesList = [
path: '/root',
},
];
export const mockMilestone = {
id: 1,
state: 'active',
title: 'Milestone title',
description: 'Harum corporis aut consequatur quae dolorem error sequi quia.',
start_date: '2018-01-01',
due_date: '2019-12-31',
};
......@@ -26,7 +26,9 @@ import actions, {
toggleTreeOpen,
scrollToFile,
toggleShowTreeList,
renderFileForDiscussionId,
} from '~/diffs/store/actions';
import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
import axios from '~/lib/utils/axios_utils';
import mockDiffFile from 'spec/diffs/mock_data/diff_file';
......@@ -735,4 +737,63 @@ describe('DiffsStoreActions', () => {
expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true);
});
});
describe('renderFileForDiscussionId', () => {
const rootState = {
notes: {
discussions: [
{
id: '123',
diff_file: {
file_hash: 'HASH',
},
},
{
id: '456',
diff_file: {
file_hash: 'HASH',
},
},
],
},
};
let commit;
let $emit;
let scrollToElement;
const state = ({ collapsed, renderIt }) => ({
diffFiles: [
{
file_hash: 'HASH',
collapsed,
renderIt,
},
],
});
beforeEach(() => {
commit = jasmine.createSpy('commit');
scrollToElement = spyOnDependency(actions, 'scrollToElement').and.stub();
$emit = spyOn(eventHub, '$emit');
});
it('renders and expands file for the given discussion id', () => {
const localState = state({ collapsed: true, renderIt: false });
renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]);
expect($emit).toHaveBeenCalledTimes(1);
expect(scrollToElement).toHaveBeenCalledTimes(1);
});
it('jumps to discussion on already rendered and expanded file', () => {
const localState = state({ collapsed: false, renderIt: true });
renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
expect(commit).not.toHaveBeenCalled();
expect($emit).toHaveBeenCalledTimes(1);
expect(scrollToElement).not.toHaveBeenCalled();
});
});
});
......@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache';
describe('UsersCache', () => {
const dummyUsername = 'win';
const dummyUser = 'has a farm';
const dummyUserId = 123;
const dummyUser = { name: 'has a farm', username: 'farmer' };
const dummyUserStatus = 'my status';
beforeEach(() => {
UsersCache.internalStorage = {};
......@@ -135,4 +137,110 @@ describe('UsersCache', () => {
.catch(done.fail);
});
});
describe('retrieveById', () => {
let apiSpy;
beforeEach(() => {
spyOn(Api, 'user').and.callFake(id => apiSpy(id));
});
it('stores and returns data from API call if cache is empty', done => {
apiSpy = id => {
expect(id).toBe(dummyUserId);
return Promise.resolve({
data: dummyUser,
});
};
UsersCache.retrieveById(dummyUserId)
.then(user => {
expect(user).toBe(dummyUser);
expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser);
})
.then(done)
.catch(done.fail);
});
it('returns undefined if Ajax call fails and cache is empty', done => {
const dummyError = new Error('server exploded');
apiSpy = id => {
expect(id).toBe(dummyUserId);
return Promise.reject(dummyError);
};
UsersCache.retrieveById(dummyUserId)
.then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`))
.catch(error => {
expect(error).toBe(dummyError);
})
.then(done)
.catch(done.fail);
});
it('makes no Ajax call if matching data exists', done => {
UsersCache.internalStorage[dummyUserId] = dummyUser;
apiSpy = () => fail(new Error('expected no Ajax call!'));
UsersCache.retrieveById(dummyUserId)
.then(user => {
expect(user).toBe(dummyUser);
})
.then(done)
.catch(done.fail);
});
});
describe('retrieveStatusById', () => {
let apiSpy;
beforeEach(() => {
spyOn(Api, 'userStatus').and.callFake(id => apiSpy(id));
});
it('stores and returns data from API call if cache is empty', done => {
apiSpy = id => {
expect(id).toBe(dummyUserId);
return Promise.resolve({
data: dummyUserStatus,
});
};
UsersCache.retrieveStatusById(dummyUserId)
.then(userStatus => {
expect(userStatus).toBe(dummyUserStatus);
expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus);
})
.then(done)
.catch(done.fail);
});
it('returns undefined if Ajax call fails and cache is empty', done => {
const dummyError = new Error('server exploded');
apiSpy = id => {
expect(id).toBe(dummyUserId);
return Promise.reject(dummyError);
};
UsersCache.retrieveStatusById(dummyUserId)
.then(userStatus => fail(`Received unexpected user: ${JSON.stringify(userStatus)}`))
.catch(error => {
expect(error).toBe(dummyError);
})
.then(done)
.catch(done.fail);
});
it('makes no Ajax call if matching data exists', done => {
UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus };
apiSpy = () => fail(new Error('expected no Ajax call!'));
UsersCache.retrieveStatusById(dummyUserId)
.then(userStatus => {
expect(userStatus).toBe(dummyUserStatus);
})
.then(done)
.catch(done.fail);
});
});
});
......@@ -39,7 +39,7 @@ describe('note_edited_text', () => {
});
it('should render provided user information', () => {
const authorLink = vm.$el.querySelector('.js-vue-author');
const authorLink = vm.$el.querySelector('.js-user-link');
expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
......
......@@ -42,6 +42,9 @@ describe('note_header component', () => {
it('should render user information', () => {
expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root');
expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual('/root');
expect(vm.$el.querySelector('.note-header-info a').dataset.userId).toEqual('1');
expect(vm.$el.querySelector('.note-header-info a').dataset.username).toEqual('root');
expect(vm.$el.querySelector('.note-header-info a').classList).toContain('js-user-link');
});
it('should render timestamp link', () => {
......
......@@ -83,6 +83,7 @@ describe('noteable_discussion component', () => {
it('expands next unresolved discussion', done => {
const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
discussion2.resolved = false;
discussion2.active = true;
discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
window.mrTabs.currentAction = 'show';
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment