Commit c28047c4 authored by allison.browne's avatar allison.browne

Merge branch 'master' into 14926

Merge master into feature branch
parents 564df8c5 398fef1c
import $ from 'jquery';
import { slugify } from './lib/utils/text_utility';
import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability';
import flash from '~/flash';
import { __ } from '~/locale';
export default class Group {
constructor() {
this.groupPath = $('#group_path');
this.groupName = $('#group_name');
this.parentId = $('#group_parent_id');
this.updateHandler = this.update.bind(this);
this.resetHandler = this.reset.bind(this);
this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this);
if (this.groupName.val() === '') {
this.groupName.on('keyup', this.updateHandler);
this.groupPath.on('keydown', this.resetHandler);
if (!this.parentId.val()) {
this.groupName.on('blur', this.updateGroupPathSlugHandler);
}
}
}
......@@ -21,5 +29,21 @@ export default class Group {
reset() {
this.groupName.off('keyup', this.updateHandler);
this.groupPath.off('keydown', this.resetHandler);
this.groupName.off('blur', this.checkPathHandler);
}
updateGroupPathSlug() {
const slug = this.groupPath.val() || slugify(this.groupName.val());
if (!slug) return;
fetchGroupPathAvailability(slug)
.then(({ data }) => data)
.then(data => {
if (data.exists && data.suggests.length > 0) {
const suggestedSlug = data.suggests[0];
this.groupPath.val(suggestedSlug);
}
})
.catch(() => flash(__('An error occurred while checking group path')));
}
}
......@@ -92,6 +92,7 @@ export default {
},
methods: {
...mapActions(['getFileData', 'getRawFileData']),
...mapActions('clientside', ['pingUsage']),
loadFileContent(path) {
return this.getFileData({ path, makeFileActive: false }).then(() =>
this.getRawFileData({ path }),
......@@ -100,6 +101,8 @@ export default {
initPreview() {
if (!this.mainEntry) return null;
this.pingUsage();
return this.loadFileContent(this.mainEntry)
.then(() => this.$nextTick())
.then(() => {
......
......@@ -10,6 +10,7 @@ import mergeRequests from './modules/merge_requests';
import branches from './modules/branches';
import fileTemplates from './modules/file_templates';
import paneModule from './modules/pane';
import clientsideModule from './modules/clientside';
Vue.use(Vuex);
......@@ -26,6 +27,7 @@ export const createStore = () =>
branches,
fileTemplates: fileTemplates(),
rightPane: paneModule(),
clientside: clientsideModule(),
},
});
......
import axios from '~/lib/utils/axios_utils';
export const pingUsage = ({ rootGetters }) => {
const { web_url: projectUrl } = rootGetters.currentProject;
const url = `${projectUrl}/usage_ping/web_ide_clientside_preview`;
return axios.post(url);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import * as actions from './actions';
export default () => ({
namespaced: true,
actions,
});
/* eslint-disable class-methods-use-this, no-new */
import $ from 'jquery';
import { property } from 'underscore';
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import MilestoneSelect from './milestone_select';
import issueStatusSelect from './issue_status_select';
import subscriptionSelect from './subscription_select';
import LabelsSelect from './labels_select';
import issueableEventHub from './issuables_list/eventhub';
const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content';
......@@ -14,6 +16,8 @@ const SIDEBAR_COLLAPSED_CLASS = 'right-sidebar-collapsed issuable-bulk-update-si
export default class IssuableBulkUpdateSidebar {
constructor() {
this.vueIssuablesListFeature = property(['gon', 'features', 'vueIssuablesList'])(window);
this.initDomElements();
this.bindEvents();
this.initDropdowns();
......@@ -41,6 +45,17 @@ export default class IssuableBulkUpdateSidebar {
this.$issuesList.on('change', () => this.updateFormState());
this.$bulkEditSubmitBtn.on('click', () => this.prepForSubmit());
this.$checkAllContainer.on('click', () => this.updateFormState());
if (this.vueIssuablesListFeature) {
issueableEventHub.$on('issuables:updateBulkEdit', () => {
// Danger! Strong coupling ahead!
// The bulk update sidebar and its dropdowns look for .selected-issuable checkboxes, and get data on which issue
// is selected by inspecting the DOM. Ideally, we would pass the selected issuable IDs and their properties
// explicitly, but this component is used in too many places right now to refactor straight away.
this.updateFormState();
});
}
}
initDropdowns() {
......@@ -73,6 +88,8 @@ export default class IssuableBulkUpdateSidebar {
toggleBulkEdit(e, enable) {
e.preventDefault();
issueableEventHub.$emit('issuables:toggleBulkEdit', enable);
this.toggleSidebarDisplay(enable);
this.toggleBulkEditButtonDisabled(enable);
this.toggleOtherFiltersDisabled(enable);
......@@ -106,7 +123,7 @@ export default class IssuableBulkUpdateSidebar {
}
toggleCheckboxDisplay(show) {
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show);
this.$checkAllContainer.toggleClass(HIDDEN_CLASS, !show || this.vueIssuablesListFeature);
this.$issueChecks.toggleClass(HIDDEN_CLASS, !show);
}
......
<script>
/*
* This is tightly coupled to projects/issues/_issue.html.haml,
* any changes done to the haml need to be reflected here.
*/
import { escape, isNumber } from 'underscore';
import { GlLink, GlTooltipDirective as GlTooltip } from '@gitlab/ui';
import {
dateInWords,
formatDate,
getDayDifference,
getTimeago,
timeFor,
newDateAsLocaleTime,
} from '~/lib/utils/datetime_utility';
import { sprintf, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import Icon from '~/vue_shared/components/icon.vue';
import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
const ISSUE_TOKEN = '#';
export default {
components: {
Icon,
IssueAssignees,
GlLink,
},
directives: {
GlTooltip,
},
props: {
issuable: {
type: Object,
required: true,
},
isBulkEditing: {
type: Boolean,
required: false,
default: false,
},
selected: {
type: Boolean,
required: false,
default: false,
},
baseUrl: {
type: String,
required: false,
default() {
return window.location.href;
},
},
},
computed: {
hasLabels() {
return Boolean(this.issuable.labels && this.issuable.labels.length);
},
hasWeight() {
return isNumber(this.issuable.weight);
},
dueDate() {
return this.issuable.due_date ? newDateAsLocaleTime(this.issuable.due_date) : undefined;
},
dueDateWords() {
return this.dueDate ? dateInWords(this.dueDate, true) : undefined;
},
hasNoComments() {
return !this.userNotesCount;
},
isOverdue() {
return this.dueDate ? this.dueDate < new Date() : false;
},
isClosed() {
return this.issuable.state === 'closed';
},
issueCreatedToday() {
return getDayDifference(new Date(this.issuable.created_at), new Date()) < 1;
},
labelIdsString() {
return JSON.stringify(this.issuable.labels.map(l => l.id));
},
milestoneDueDate() {
const { due_date: dueDate } = this.issuable.milestone || {};
return dueDate ? newDateAsLocaleTime(dueDate) : undefined;
},
milestoneTooltipText() {
if (this.milestoneDueDate) {
return sprintf(__('%{primary} (%{secondary})'), {
primary: formatDate(this.milestoneDueDate, 'mmm d, yyyy'),
secondary: timeFor(this.milestoneDueDate),
});
}
return __('Milestone');
},
openedAgoByString() {
const { author, created_at } = this.issuable;
return sprintf(
__('opened %{timeAgoString} by %{user}'),
{
timeAgoString: escape(getTimeago().format(created_at)),
user: `<a href="${escape(author.web_url)}"
data-user-id=${escape(author.id)}
data-username=${escape(author.username)}
data-name=${escape(author.name)}
data-avatar-url="${escape(author.avatar_url)}">
${escape(author.name)}
</a>`,
},
false,
);
},
referencePath() {
// TODO: The API should return the reference path (it doesn't now) https://gitlab.com/gitlab-org/gitlab/issues/31301
return `${ISSUE_TOKEN}${this.issuable.iid}`;
},
updatedDateString() {
return formatDate(new Date(this.issuable.updated_at), 'mmm d, yyyy h:MMtt');
},
updatedDateAgo() {
// snake_case because it's the same i18n string as the HAML view
return sprintf(__('updated %{time_ago}'), {
time_ago: escape(getTimeago().format(this.issuable.updated_at)),
});
},
userNotesCount() {
return this.issuable.user_notes_count;
},
issuableMeta() {
return [
{
key: 'merge-requests',
value: this.issuable.merge_requests_count,
title: __('Related merge requests'),
class: 'js-merge-requests',
icon: 'merge-request',
},
{
key: 'upvotes',
value: this.issuable.upvotes,
title: __('Upvotes'),
class: 'js-upvotes',
faicon: 'fa-thumbs-up',
},
{
key: 'downvotes',
value: this.issuable.downvotes,
title: __('Downvotes'),
class: 'js-downvotes',
faicon: 'fa-thumbs-down',
},
];
},
},
mounted() {
// TODO: Refactor user popover to use its own component instead of
// spawning event listeners on Vue-rendered elements.
initUserPopovers([this.$refs.openedAgoByContainer.querySelector('a')]);
},
methods: {
labelStyle(label) {
return {
backgroundColor: label.color,
color: label.text_color,
};
},
labelHref({ name }) {
return mergeUrlParams({ 'label_name[]': name }, this.baseUrl);
},
onSelect(ev) {
this.$emit('select', {
issuable: this.issuable,
selected: ev.target.checked,
});
},
},
confidentialTooltipText: __('Confidential'),
};
</script>
<template>
<li
:id="`issue_${issuable.id}`"
class="issue"
:class="{ today: issueCreatedToday, closed: isClosed }"
:data-id="issuable.id"
:data-labels="labelIdsString"
:data-url="issuable.web_url"
>
<div class="d-flex">
<!-- Bulk edit checkbox -->
<div v-if="isBulkEditing" class="mr-2">
<input
:checked="selected"
class="selected-issuable"
type="checkbox"
:data-id="issuable.id"
@input="onSelect"
/>
</div>
<!-- Issuable info container -->
<!-- Issuable main info -->
<div class="flex-grow-1">
<div class="title">
<span class="issue-title-text">
<i
v-if="issuable.confidential"
v-gl-tooltip
class="fa fa-eye-slash"
:title="$options.confidentialTooltipText"
:aria-label="$options.confidentialTooltipText"
></i>
<gl-link :href="issuable.web_url">{{ issuable.title }}</gl-link>
</span>
<span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block">
{{ issuable.task_status }}
</span>
</div>
<div class="issuable-info">
<span>{{ referencePath }}</span>
<span class="d-none d-sm-inline-block mr-1">
&middot;
<span ref="openedAgoByContainer" v-html="openedAgoByString"></span>
</span>
<gl-link
v-if="issuable.milestone"
v-gl-tooltip
class="d-none d-sm-inline-block mr-1 js-milestone"
:href="issuable.milestone.web_url"
:title="milestoneTooltipText"
>
<i class="fa fa-clock-o"></i>
{{ issuable.milestone.title }}
</gl-link>
<span
v-if="dueDate"
v-gl-tooltip
class="d-none d-sm-inline-block mr-1 js-due-date"
:class="{ cred: isOverdue }"
:title="__('Due date')"
>
<i class="fa fa-calendar"></i>
{{ dueDateWords }}
</span>
<span v-if="hasLabels" class="js-labels">
<gl-link
v-for="label in issuable.labels"
:key="label.id"
class="label-link mr-1"
:href="labelHref(label)"
>
<span
v-gl-tooltip
class="badge color-label"
:style="labelStyle(label)"
:title="label.description"
>{{ label.name }}</span
>
</gl-link>
</span>
<span
v-if="hasWeight"
v-gl-tooltip
:title="__('Weight')"
class="d-none d-sm-inline-block js-weight"
>
<icon name="weight" class="align-text-bottom" />
{{ issuable.weight }}
</span>
</div>
</div>
<!-- Issuable meta -->
<div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center">
<div class="controls d-flex">
<span v-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span>
<issue-assignees
:assignees="issuable.assignees"
class="align-items-center d-flex ml-2"
:icon-size="16"
img-css-classes="mr-1"
:max-visible="4"
/>
<template v-for="meta in issuableMeta">
<span
v-if="meta.value"
:key="meta.key"
v-gl-tooltip
:class="['d-none d-sm-inline-block ml-2', meta.class]"
:title="meta.title"
>
<icon v-if="meta.icon" :name="meta.icon" />
<i v-else :class="['fa', meta.faicon]"></i>
{{ meta.value }}
</span>
</template>
<gl-link
v-gl-tooltip
class="ml-2 js-notes"
:href="`${issuable.web_url}#notes`"
:title="__('Comments')"
:class="{ 'no-comments': hasNoComments }"
>
<i class="fa fa-comments"></i>
{{ userNotesCount }}
</gl-link>
</div>
<div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString">
{{ updatedDateAgo }}
</div>
</div>
</div>
</li>
</template>
<script>
import { omit } from 'underscore';
import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { scrollToElement, urlParamsToObject } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import initManualOrdering from '~/manual_ordering';
import Issuable from './issuable.vue';
import {
sortOrderMap,
RELATIVE_POSITION,
PAGE_SIZE,
PAGE_SIZE_MANUAL,
LOADING_LIST_ITEMS_LENGTH,
} from '../constants';
import issueableEventHub from '../eventhub';
export default {
LOADING_LIST_ITEMS_LENGTH,
components: {
GlEmptyState,
GlPagination,
GlSkeletonLoading,
Issuable,
},
props: {
canBulkEdit: {
type: Boolean,
required: false,
default: false,
},
createIssuePath: {
type: String,
required: false,
default: '',
},
emptySvgPath: {
type: String,
required: false,
default: '',
},
endpoint: {
type: String,
required: true,
},
sortKey: {
type: String,
required: false,
default: '',
},
},
data() {
return {
filters: {},
isBulkEditing: false,
issuables: [],
loading: false,
page: 1,
selection: {},
totalItems: 0,
};
},
computed: {
allIssuablesSelected() {
// WARNING: Because we are only keeping track of selected values
// this works, we will need to rethink this if we start tracking
// [id]: false for not selected values.
return this.issuables.length === Object.keys(this.selection).length;
},
emptyState() {
if (this.issuables.length) {
return {}; // Empty state shouldn't be shown here
} else if (this.hasFilters) {
return {
title: __('Sorry, your filter produced no results'),
description: __('To widen your search, change or remove filters above'),
};
} else if (this.filters.state === 'opened') {
return {
title: __('There are no open issues'),
description: __('To keep this project going, create a new issue'),
primaryLink: this.createIssuePath,
primaryText: __('New issue'),
};
} else if (this.filters.state === 'closed') {
return {
title: __('There are no closed issues'),
};
}
return {
title: __('There are no issues to show'),
description: __(
'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.',
),
};
},
hasFilters() {
const ignored = ['utf8', 'state', 'scope', 'order_by', 'sort'];
return Object.keys(omit(this.filters, ignored)).length > 0;
},
isManualOrdering() {
return this.sortKey === RELATIVE_POSITION;
},
itemsPerPage() {
return this.isManualOrdering ? PAGE_SIZE_MANUAL : PAGE_SIZE;
},
baseUrl() {
return window.location.href.replace(/(\?.*)?(#.*)?$/, '');
},
},
watch: {
selection() {
// We need to call nextTick here to wait for all of the boxes to be checked and rendered
// before we query the dom in issuable_bulk_update_actions.js.
this.$nextTick(() => {
issueableEventHub.$emit('issuables:updateBulkEdit');
});
},
issuables() {
this.$nextTick(() => {
initManualOrdering();
});
},
},
mounted() {
if (this.canBulkEdit) {
this.unsubscribeToggleBulkEdit = issueableEventHub.$on('issuables:toggleBulkEdit', val => {
this.isBulkEditing = val;
});
}
this.fetchIssuables();
},
beforeDestroy() {
issueableEventHub.$off('issuables:toggleBulkEdit');
},
methods: {
isSelected(issuableId) {
return Boolean(this.selection[issuableId]);
},
setSelection(ids) {
ids.forEach(id => {
this.select(id, true);
});
},
clearSelection() {
this.selection = {};
},
select(id, isSelect = true) {
if (isSelect) {
this.$set(this.selection, id, true);
} else {
this.$delete(this.selection, id);
}
},
fetchIssuables(pageToFetch) {
this.loading = true;
this.clearSelection();
this.setFilters();
return axios
.get(this.endpoint, {
params: {
...this.filters,
with_labels_details: true,
page: pageToFetch || this.page,
per_page: this.itemsPerPage,
},
})
.then(response => {
this.loading = false;
this.issuables = response.data;
this.totalItems = Number(response.headers['x-total']);
this.page = Number(response.headers['x-page']);
})
.catch(() => {
this.loading = false;
return flash(__('An error occurred while loading issues'));
});
},
getQueryObject() {
return urlParamsToObject(window.location.search);
},
onPaginate(newPage) {
if (newPage === this.page) return;
scrollToElement('#content-body');
this.fetchIssuables(newPage);
},
onSelectAll() {
if (this.allIssuablesSelected) {
this.selection = {};
} else {
this.setSelection(this.issuables.map(({ id }) => id));
}
},
onSelectIssuable({ issuable, selected }) {
if (!this.canBulkEdit) return;
this.select(issuable.id, selected);
},
setFilters() {
const {
label_name: labels,
milestone_title: milestoneTitle,
...filters
} = this.getQueryObject();
if (milestoneTitle) {
filters.milestone = milestoneTitle;
}
if (Array.isArray(labels)) {
filters.labels = labels.join(',');
}
if (!filters.state) {
filters.state = 'opened';
}
Object.assign(filters, sortOrderMap[this.sortKey]);
this.filters = filters;
},
},
};
</script>
<template>
<ul v-if="loading" class="content-list">
<li v-for="n in $options.LOADING_LIST_ITEMS_LENGTH" :key="n" class="issue">
<gl-skeleton-loading />
</li>
</ul>
<div v-else-if="issuables.length">
<div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light">
<input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" />
<strong>{{ __('Select all') }}</strong>
</div>
<ul
class="content-list issuable-list issues-list"
:class="{ 'manual-ordering': isManualOrdering }"
>
<issuable
v-for="issuable in issuables"
:key="issuable.id"
class="pr-3"
:class="{ 'user-can-drag': isManualOrdering }"
:issuable="issuable"
:is-bulk-editing="isBulkEditing"
:selected="isSelected(issuable.id)"
:base-url="baseUrl"
@select="onSelectIssuable"
/>
</ul>
<div class="mt-3">
<gl-pagination
v-if="totalItems"
:value="page"
:per-page="itemsPerPage"
:total-items="totalItems"
class="justify-content-center"
@input="onPaginate"
/>
</div>
</div>
<gl-empty-state
v-else
:title="emptyState.title"
:description="emptyState.description"
:svg-path="emptySvgPath"
:primary-button-link="emptyState.primaryLink"
:primary-button-text="emptyState.primaryText"
/>
</template>
// Maps sort order as it appears in the URL query to API `order_by` and `sort` params.
const PRIORITY = 'priority';
const ASC = 'asc';
const DESC = 'desc';
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
const DUE_DATE = 'due_date';
const MILESTONE_DUE = 'milestone_due';
const POPULARITY = 'popularity';
const WEIGHT = 'weight';
const LABEL_PRIORITY = 'label_priority';
export const RELATIVE_POSITION = 'relative_position';
export const LOADING_LIST_ITEMS_LENGTH = 8;
export const PAGE_SIZE = 20;
export const PAGE_SIZE_MANUAL = 100;
export const sortOrderMap = {
priority: { order_by: PRIORITY, sort: ASC }, // asc and desc are flipped for some reason
created_date: { order_by: CREATED_AT, sort: DESC },
created_asc: { order_by: CREATED_AT, sort: ASC },
updated_desc: { order_by: UPDATED_AT, sort: DESC },
updated_asc: { order_by: UPDATED_AT, sort: ASC },
milestone_due_desc: { order_by: MILESTONE_DUE, sort: DESC },
milestone: { order_by: MILESTONE_DUE, sort: ASC },
due_date_desc: { order_by: DUE_DATE, sort: DESC },
due_date: { order_by: DUE_DATE, sort: ASC },
popularity: { order_by: POPULARITY, sort: DESC },
popularity_asc: { order_by: POPULARITY, sort: ASC },
label_priority: { order_by: LABEL_PRIORITY, sort: ASC }, // asc and desc are flipped
relative_position: { order_by: RELATIVE_POSITION, sort: ASC },
weight_desc: { order_by: WEIGHT, sort: DESC },
weight: { order_by: WEIGHT, sort: ASC },
};
import Vue from 'vue';
const issueablesEventBus = new Vue();
export default issueablesEventBus;
import Vue from 'vue';
import IssuablesListApp from './components/issuables_list_app.vue';
export default function initIssuablesList() {
if (!gon.features || !gon.features.vueIssuablesList) {
return;
}
document.querySelectorAll('.js-issuables-list').forEach(el => {
const { canBulkEdit, ...data } = el.dataset;
const props = {
...data,
canBulkEdit: Boolean(canBulkEdit),
};
return new Vue({
el,
render(createElement) {
return createElement(IssuablesListApp, { props });
},
});
});
}
......@@ -78,11 +78,11 @@ export const getDayName = date =>
* @param {date} datetime
* @returns {String}
*/
export const formatDate = datetime => {
export const formatDate = (datetime, format = 'mmm d, yyyy h:MMtt Z') => {
if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
throw new Error(__('Invalid date'));
}
return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
return dateFormat(datetime, format);
};
/**
......@@ -558,6 +558,17 @@ export const calculateRemainingMilliseconds = endDate => {
export const getDateInPast = (date, daysInPast) =>
new Date(newDate(date).setDate(date.getDate() - daysInPast));
/*
* Appending T00:00:00 makes JS assume local time and prevents it from shifting the date
* to match the user's time zone. We want to display the date in server time for now, to
* be consistent with the "edit issue -> due date" UI.
*/
export const newDateAsLocaleTime = date => {
const suffix = 'T00:00:00';
return new Date(`${date}${suffix}`);
};
export const beginOfDayTime = 'T00:00:00Z';
export const endOfDayTime = 'T23:59:59Z';
......
......@@ -138,6 +138,14 @@ export const stripHtml = (string, replace = '') => {
*/
export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
/**
* Converts camelCase string to snake_case
*
* @param {*} string
*/
export const convertToSnakeCase = string =>
slugifyWithUnderscore(string.match(/([a-zA-Z][^A-Z]*)/g).join(' '));
/**
* Converts a sentence to lower case from the second word onwards
* e.g. Hello World => Hello world
......
......@@ -18,7 +18,7 @@ const updateIssue = (url, issueList, { move_before_id, move_after_id }) =>
createFlash(s__("ManualOrdering|Couldn't save the order of the issues"));
});
const initManualOrdering = () => {
const initManualOrdering = (draggableSelector = 'li.issue') => {
const issueList = document.querySelector('.manual-ordering');
if (!issueList || !(gon.current_user_id > 0)) {
......@@ -34,14 +34,14 @@ const initManualOrdering = () => {
group: {
name: 'issues',
},
draggable: 'li.issue',
draggable: draggableSelector,
onStart: () => {
sortableStart();
},
onUpdate: event => {
const el = event.item;
const url = el.getAttribute('url');
const url = el.getAttribute('url') || el.dataset.url;
const prev = el.previousElementSibling;
const next = el.nextElementSibling;
......
import initIssuablesList from '~/issuables_list';
import projectSelect from '~/project_select';
import initFilteredSearch from '~/pages/search/init_filtered_search';
import issuableInitBulkUpdateSidebar from '~/issuable_init_bulk_update_sidebar';
......@@ -11,6 +12,8 @@ document.addEventListener('DOMContentLoaded', () => {
IssuableFilteredSearchTokenKeys.addExtraTokensForIssues();
issuableInitBulkUpdateSidebar.init(ISSUE_BULK_UPDATE_PREFIX);
initIssuablesList();
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
......
import axios from '~/lib/utils/axios_utils';
const rootUrl = gon.relative_url_root;
export default function fetchGroupPathAvailability(groupPath) {
return axios.get(`${rootUrl}/users/${groupPath}/suggests`);
}
import InputValidator from '~/validators/input_validator';
import _ from 'underscore';
import fetchGroupPathAvailability from './fetch_group_path_availability';
import flash from '~/flash';
import { __ } from '~/locale';
const debounceTimeoutDuration = 1000;
const invalidInputClass = 'gl-field-error-outline';
const successInputClass = 'gl-field-success-outline';
const successMessageSelector = '.validation-success';
const pendingMessageSelector = '.validation-pending';
const unavailableMessageSelector = '.validation-error';
const suggestionsMessageSelector = '.gl-path-suggestions';
export default class GroupPathValidator extends InputValidator {
constructor(opts = {}) {
super();
const container = opts.container || '';
const validateElements = document.querySelectorAll(`${container} .js-validate-group-path`);
this.debounceValidateInput = _.debounce(inputDomElement => {
GroupPathValidator.validateGroupPathInput(inputDomElement);
}, debounceTimeoutDuration);
validateElements.forEach(element =>
element.addEventListener('input', this.eventHandler.bind(this)),
);
}
eventHandler(event) {
const inputDomElement = event.target;
GroupPathValidator.resetInputState(inputDomElement);
this.debounceValidateInput(inputDomElement);
}
static validateGroupPathInput(inputDomElement) {
const groupPath = inputDomElement.value;
if (inputDomElement.checkValidity() && groupPath.length > 0) {
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector);
fetchGroupPathAvailability(groupPath)
.then(({ data }) => data)
.then(data => {
GroupPathValidator.setInputState(inputDomElement, !data.exists);
GroupPathValidator.setMessageVisibility(inputDomElement, pendingMessageSelector, false);
GroupPathValidator.setMessageVisibility(
inputDomElement,
data.exists ? unavailableMessageSelector : successMessageSelector,
);
if (data.exists) {
GroupPathValidator.showSuggestions(inputDomElement, data.suggests);
}
})
.catch(() => flash(__('An error occurred while validating group path')));
}
}
static showSuggestions(inputDomElement, suggestions) {
const messageElement = inputDomElement.parentElement.parentElement.querySelector(
suggestionsMessageSelector,
);
const textSuggestions = suggestions && suggestions.length > 0 ? suggestions.join(', ') : 'none';
messageElement.textContent = textSuggestions;
}
static setMessageVisibility(inputDomElement, messageSelector, isVisible = true) {
const messageElement = inputDomElement.parentElement.parentElement.querySelector(
messageSelector,
);
messageElement.classList.toggle('hide', !isVisible);
}
static setInputState(inputDomElement, success = true) {
inputDomElement.classList.toggle(successInputClass, success);
inputDomElement.classList.toggle(invalidInputClass, !success);
}
static resetInputState(inputDomElement) {
GroupPathValidator.setMessageVisibility(inputDomElement, successMessageSelector, false);
GroupPathValidator.setMessageVisibility(inputDomElement, unavailableMessageSelector, false);
if (inputDomElement.checkValidity()) {
inputDomElement.classList.remove(successInputClass, invalidInputClass);
}
}
}
import $ from 'jquery';
import BindInOut from '~/behaviors/bind_in_out';
import Group from '~/group';
import initAvatarPicker from '~/avatar_picker';
import GroupPathValidator from './group_path_validator';
document.addEventListener('DOMContentLoaded', () => {
const parentId = $('#group_parent_id');
if (!parentId.val()) {
new GroupPathValidator(); // eslint-disable-line no-new
}
BindInOut.initAll();
new Group(); // eslint-disable-line no-new
initAvatarPicker();
......
/* eslint-disable func-names, no-var */
import $ from 'jquery';
import BranchGraph from '../../../network/branch_graph';
export default (function() {
function Network(opts) {
var vph;
$('#filter_ref').click(function() {
return $(this)
.closest('form')
.submit();
});
this.branch_graph = new BranchGraph($('.network-graph'), opts);
vph = $(window).height() - 250;
$('.network-graph').css({
height: `${vph}px`,
});
const vph = $(window).height() - 250;
export default class Network {
constructor(opts) {
this.opts = opts;
this.filter_ref = $('#filter_ref');
this.network_graph = $('.network-graph');
this.filter_ref.click(() => this.submit());
this.branch_graph = new BranchGraph(this.network_graph, this.opts);
this.network_graph.css({ height: `${vph}px` });
}
return Network;
})();
submit() {
return this.filter_ref.closest('form').submit();
}
}
......@@ -30,67 +30,65 @@ const projectSelect = () => {
$(select).select2({
placeholder,
minimumInputLength: 0,
query: (function(_this) {
return function(query) {
var finalCallback, projectsCallback;
finalCallback = function(projects) {
var data;
data = {
results: projects,
};
return query.callback(data);
query: query => {
var finalCallback, projectsCallback;
finalCallback = function(projects) {
var data;
data = {
results: projects,
};
if (_this.includeGroups) {
projectsCallback = function(projects) {
var groupsCallback;
groupsCallback = function(groups) {
var data;
data = groups.concat(projects);
return finalCallback(data);
};
return Api.groups(query.term, {}, groupsCallback);
};
} else {
projectsCallback = finalCallback;
}
if (_this.groupId) {
return Api.groupProjects(
_this.groupId,
query.term,
{
with_issues_enabled: _this.withIssuesEnabled,
with_merge_requests_enabled: _this.withMergeRequestsEnabled,
with_shared: _this.withShared,
include_subgroups: _this.includeProjectsInSubgroups,
},
projectsCallback,
);
} else if (_this.userId) {
return Api.userProjects(
_this.userId,
query.term,
{
with_issues_enabled: _this.withIssuesEnabled,
with_merge_requests_enabled: _this.withMergeRequestsEnabled,
with_shared: _this.withShared,
include_subgroups: _this.includeProjectsInSubgroups,
},
projectsCallback,
);
} else {
return Api.projects(
query.term,
{
order_by: _this.orderBy,
with_issues_enabled: _this.withIssuesEnabled,
with_merge_requests_enabled: _this.withMergeRequestsEnabled,
membership: !_this.allProjects,
},
projectsCallback,
);
}
return query.callback(data);
};
})(this),
if (this.includeGroups) {
projectsCallback = function(projects) {
var groupsCallback;
groupsCallback = function(groups) {
var data;
data = groups.concat(projects);
return finalCallback(data);
};
return Api.groups(query.term, {}, groupsCallback);
};
} else {
projectsCallback = finalCallback;
}
if (this.groupId) {
return Api.groupProjects(
this.groupId,
query.term,
{
with_issues_enabled: this.withIssuesEnabled,
with_merge_requests_enabled: this.withMergeRequestsEnabled,
with_shared: this.withShared,
include_subgroups: this.includeProjectsInSubgroups,
},
projectsCallback,
);
} else if (this.userId) {
return Api.userProjects(
this.userId,
query.term,
{
with_issues_enabled: this.withIssuesEnabled,
with_merge_requests_enabled: this.withMergeRequestsEnabled,
with_shared: this.withShared,
include_subgroups: this.includeProjectsInSubgroups,
},
projectsCallback,
);
} else {
return Api.projects(
query.term,
{
order_by: this.orderBy,
with_issues_enabled: this.withIssuesEnabled,
with_merge_requests_enabled: this.withMergeRequestsEnabled,
membership: !this.allProjects,
},
projectsCallback,
);
}
},
id(project) {
if (simpleFilter) return project.id;
return JSON.stringify({
......
<script>
import { GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
export default {
......@@ -16,44 +15,47 @@ export default {
type: Array,
required: true,
},
iconSize: {
type: Number,
required: false,
default: 24,
},
imgCssClasses: {
type: String,
required: false,
default: '',
},
maxVisible: {
type: Number,
required: false,
default: 3,
},
},
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;
const numShownAssignees = this.assignees.length - this.numHiddenAssignees;
return this.assignees.slice(0, numShownAssignees);
},
assigneesCounterTooltip() {
const { countOverLimit, maxAssignees } = this;
const count = countOverLimit > maxAssignees ? maxAssignees : countOverLimit;
return sprintf(__('%{count} more assignees'), { count });
return sprintf(__('%{count} more assignees'), { count: this.numHiddenAssignees });
},
shouldRenderAssigneesCounter() {
const assigneesCount = this.assignees.length;
if (assigneesCount <= this.maxAssigneeAvatars) {
return false;
numHiddenAssignees() {
if (this.assignees.length > this.maxVisible) {
return this.assignees.length - this.maxVisible + 1;
}
return assigneesCount > this.countOverLimit;
return 0;
},
assigneeCounterLabel() {
if (this.countOverLimit > this.maxAssignees) {
if (this.numHiddenAssignees > this.maxAssignees) {
return `${this.maxAssignees}+`;
}
return `+${this.countOverLimit}`;
return `+${this.numHiddenAssignees}`;
},
},
methods: {
......@@ -81,8 +83,9 @@ export default {
:key="assignee.id"
:link-href="webUrl(assignee)"
:img-alt="avatarUrlTitle(assignee)"
:img-css-classes="imgCssClasses"
:img-src="avatarUrl(assignee)"
:img-size="24"
:img-size="iconSize"
class="js-no-trigger"
tooltip-placement="bottom"
>
......@@ -92,7 +95,7 @@ export default {
</span>
</user-avatar-link>
<span
v-if="shouldRenderAssigneesCounter"
v-if="numHiddenAssignees > 0"
v-gl-tooltip
:title="assigneesCounterTooltip"
class="avatar-counter"
......
......@@ -563,3 +563,10 @@ img.emoji {
.gl-font-size-small { font-size: $gl-font-size-small; }
.gl-line-height-24 { line-height: $gl-line-height-24; }
.gl-font-size-12 { font-size: $gl-font-size-12; }
.gl-font-size-14 { font-size: $gl-font-size-14; }
.gl-font-size-16 { font-size: $gl-font-size-16; }
.gl-font-size-20 { font-size: $gl-font-size-20; }
.gl-font-size-28 { font-size: $gl-font-size-28; }
.gl-font-size-42 { font-size: $gl-font-size-42; }
......@@ -249,7 +249,7 @@
}
.filtered-search-input-dropdown-menu {
max-height: $dropdown-max-height;
max-height: $dropdown-max-height-lg;
max-width: 280px;
overflow: auto;
......
......@@ -325,6 +325,12 @@ $gl-grayish-blue: #7f8fa4;
$gl-gray-dark: #313236;
$gl-gray-light: #5c5c5c;
$gl-header-color: #4c4e54;
$gl-font-size-12: 12px;
$gl-font-size-14: 14px;
$gl-font-size-16: 16px;
$gl-font-size-20: 20px;
$gl-font-size-28: 28px;
$gl-font-size-42: 42px;
$type-scale: (
1: 12px,
......
......@@ -25,6 +25,10 @@ class GroupsController < Groups::ApplicationController
before_action :user_actions, only: [:show]
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
end
skip_cross_project_access_check :index, :new, :create, :edit, :update,
:destroy, :projects
# When loading show as an atom feed, we render events that could leak cross
......
......@@ -23,6 +23,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
push_frontend_feature_flag(:release_search_filter, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
......
# frozen_string_literal: true
class Projects::UsagePingController < Projects::ApplicationController
before_action :authenticate_user!
def web_ide_clientside_preview
return render_404 unless Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?
Gitlab::UsageDataCounters::WebIdeCounter.increment_previews_count
head(200)
end
end
......@@ -170,6 +170,14 @@ module MilestonesHelper
content.join('<br />').html_safe
end
def milestone_releases_tooltip_text(milestone)
count = milestone.releases.count
return _("Releases") if count.zero?
n_("%{releases} release", "%{releases} releases", count) % { releases: count }
end
def recent_releases_with_counts(milestone)
total_count = milestone.releases.size
return [[], 0, 0] if total_count == 0
......
......@@ -55,7 +55,7 @@ module Storage
def move_repositories
# Move the namespace directory in all storages used by member projects
repository_storages.each do |repository_storage|
repository_storages(legacy_only: true).each do |repository_storage|
# Ensure old directory exists before moving it
gitlab_shell.add_namespace(repository_storage, full_path_before_last_save)
......@@ -77,12 +77,14 @@ module Storage
@old_repository_storage_paths ||= repository_storages
end
def repository_storages
def repository_storages(legacy_only: false)
# We need to get the storage paths for all the projects, even the ones that are
# pending delete. Unscoping also get rids of the default order, which causes
# problems with SELECT DISTINCT.
Project.unscoped do
all_projects.select('distinct(repository_storage)').to_a.map(&:repository_storage)
namespace_projects = all_projects
namespace_projects = namespace_projects.without_storage_feature(:repository) if legacy_only
namespace_projects.pluck(Arel.sql('distinct(repository_storage)'))
end
end
......
......@@ -55,9 +55,9 @@ class Issue < ApplicationRecord
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
scope :order_closest_future_date, -> { reorder('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC') }
scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) }
scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) }
scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) }
scope :preload_associations, -> { preload(:labels, project: :namespace) }
......
......@@ -1420,14 +1420,13 @@ class User < ApplicationRecord
# flow means we don't call that automatically (and can't conveniently do so).
#
# See:
# <https://github.com/plataformatec/devise/blob/v4.0.0/lib/devise/models/lockable.rb#L92>
# <https://github.com/plataformatec/devise/blob/v4.7.1/lib/devise/models/lockable.rb#L104>
#
# rubocop: disable CodeReuse/ServiceClass
def increment_failed_attempts!
return if ::Gitlab::Database.read_only?
self.failed_attempts ||= 0
self.failed_attempts += 1
increment_failed_attempts
if attempts_exceeded?
lock_access! unless access_locked?
......@@ -1455,7 +1454,7 @@ class User < ApplicationRecord
# Does the user have access to all private groups & projects?
# Overridden in EE to also check auditor?
def full_private_access?
admin?
can?(:read_all_resources)
end
def update_two_factor_requirement
......
......@@ -21,10 +21,6 @@ class BasePolicy < DeclarativePolicy::Base
with_options scope: :user, score: 0
condition(:deactivated) { @user&.deactivated? }
desc "User has access to all private groups & projects"
with_options scope: :user, score: 0
condition(:full_private_access) { @user&.full_private_access? }
with_options scope: :user, score: 0
condition(:external_user) { @user.nil? || @user.external? }
......@@ -40,10 +36,12 @@ class BasePolicy < DeclarativePolicy::Base
::Gitlab::ExternalAuthorization.perform_check?
end
rule { external_authorization_enabled & ~full_private_access }.policy do
rule { external_authorization_enabled & ~can?(:read_all_resources) }.policy do
prevent :read_cross_project
end
rule { admin }.enable :read_all_resources
rule { default }.enable :read_cross_project
end
......
......@@ -30,5 +30,5 @@ class PersonalSnippetPolicy < BasePolicy
rule { can?(:create_note) }.enable :award_emoji
rule { full_private_access }.enable :read_personal_snippet
rule { can?(:read_all_resources) }.enable :read_personal_snippet
end
......@@ -28,7 +28,7 @@ class ProjectSnippetPolicy < BasePolicy
all?(private_snippet | (internal_snippet & external_user),
~project.guest,
~is_author,
~full_private_access)
~can?(:read_all_resources))
end.prevent :read_project_snippet
rule { internal_snippet & ~is_author & ~admin }.policy do
......
......@@ -22,4 +22,10 @@
- if @can_bulk_update
= render_if_exists 'shared/issuable/group_bulk_update_sidebar', group: @group, type: :issues
= render 'shared/issues'
- if Feature.enabled?(:vue_issuables_list, @group)
.js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)),
'can-bulk-edit': @can_bulk_update.to_json,
'empty-svg-path': image_path('illustrations/issues.svg'),
'sort-key': @sort } }
- else
= render 'shared/issues'
-# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue!
%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } }
.issue-box
- if @can_bulk_update
......
......@@ -22,11 +22,16 @@
- if parent
%strong= parent.full_path + '/'
= f.hidden_field :parent_id
= f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control',
= f.text_field :path, placeholder: 'my-awesome-group', class: 'form-control js-validate-group-path',
autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS,
title: _('Please choose a group URL with no special characters.'),
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
%p.validation-error.gl-field-error.field-validation.hide
= _('Group path is already taken. Suggestions: ')
%span.gl-path-suggestions
%p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...')
- if @group.persisted?
.alert.alert-warning.prepend-top-10
......
......@@ -138,6 +138,27 @@
Merged:
= milestone.merge_requests.merged.count
- if project
- recent_releases, total_count, more_count = recent_releases_with_counts(milestone)
.block.releases
.sidebar-collapsed-icon.has-tooltip{ title: milestone_releases_tooltip_text(milestone), data: { container: 'body', placement: 'left', boundary: 'viewport' } }
%strong
= icon('rocket')
%span= total_count
.title.hide-collapsed= n_('Release', 'Releases', total_count)
.hide-collapsed
- if total_count.zero?
.no-value= _('None')
- else
.font-weight-bold
- recent_releases.each do |release|
= link_to release.name, project_releases_path(project, :anchor => release.tag)
- unless release == recent_releases.last
%span.font-weight-normal &bull;
- if more_count > 0
%span.font-weight-normal &bull;
= link_to n_('%{count} more release', '%{count} more releases', more_count) % { count: more_count }, project_releases_path(project), class: 'font-weight-normal'
- milestone_ref = milestone.try(:to_reference, full: true)
- if milestone_ref.present?
.block.reference
......
---
title: Smaller width for design comments layout, truncate image title
merge_request: 17547
author:
type: fixed
---
title: Add EKS cluster count to usage data
merge_request: 17059
author:
type: other
---
title: Update incrementing of failed logins to be thread-safe
merge_request: 19614
author:
type: security
---
title: "[Geo] Fix: rake gitlab:geo:check on the primary is cluttered"
merge_request: 19460
author:
type: changed
---
title: New group path uniqueness check
merge_request: 17394
author:
type: added
---
title: Added report_type attribute to Vulnerabilities
merge_request: 19179
author:
type: changed
---
title: Remove IIFEs from project_select.js
merge_request: 19288
author: minghuan lei
type: other
---
title: Add pipeline information to dependency list header
merge_request: 19352
author:
type: added
---
title: Hide trial banner for namespaces with expired trials
merge_request: 19510
author:
type: changed
---
title: Removed IIFEs from network.js file
merge_request: 19254
author: nuwe1
type: other
---
title: Add a Slack slash command to add a comment to an issue
merge_request: 18946
author:
type: added
---
title: Add Codesandbox metrics to usage ping
merge_request: 19075
author:
type: other
---
title: Hide projects without access to admin user when admin mode is disabled
merge_request: 18530
author: Diego Louzán
type: changed
---
title: Only move repos for legacy project storage
merge_request: 19410
author:
type: fixed
---
title: Add links to associated release(s) to the milestone detail page
merge_request: 17278
author:
type: added
---
title: Add "release" filter to merge request search page
merge_request: 19315
author:
type: added
......@@ -617,6 +617,10 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
end
end
scope :usage_ping, controller: :usage_ping do
post :web_ide_clientside_preview
end
# Since both wiki and repository routing contains wildcard characters
# its preferable to keep it below all other project routes
draw :wiki
......
# frozen_string_literal: true
class ReplaceIndexOnMetricsMergedAt < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :merge_request_metrics, :merged_at
remove_concurrent_index :merge_request_metrics, [:merged_at, :id]
end
def down
add_concurrent_index :merge_request_metrics, [:merged_at, :id]
remove_concurrent_index :merge_request_metrics, :merged_at
end
end
# frozen_string_literal: true
class AddReportTypeToVulnerabilities < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
add_column :vulnerabilities, :report_type, :integer, limit: 2
end
end
......@@ -6,7 +6,7 @@ class ScheduleProductivityAnalyticsBackfill < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
# no-op since the scheduling times out on GitLab.com
# no-op since the migration was removed
end
def down
......
# frozen_string_literal: true
class SetReportTypeForVulnerabilities < ActiveRecord::Migration[5.2]
DOWNTIME = false
def up
# set report_type based on associated vulnerability_occurrences
execute <<~SQL
UPDATE vulnerabilities
SET report_type = vulnerability_occurrences.report_type
FROM vulnerability_occurrences
WHERE vulnerabilities.id = vulnerability_occurrences.vulnerability_id
SQL
# set default report_type for orphan vulnerabilities (there should be none but...)
execute 'UPDATE vulnerabilities SET report_type = 0 WHERE report_type IS NULL'
change_column_null :vulnerabilities, :report_type, false
end
def down
change_column_null :vulnerabilities, :report_type, true
execute 'UPDATE vulnerabilities SET report_type = NULL'
end
end
......@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_10_29_191901) do
ActiveRecord::Schema.define(version: 2019_11_05_094625) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
......@@ -2290,7 +2290,7 @@ ActiveRecord::Schema.define(version: 2019_10_29_191901) do
t.index ["latest_closed_by_id"], name: "index_merge_request_metrics_on_latest_closed_by_id"
t.index ["merge_request_id", "merged_at"], name: "index_merge_request_metrics_on_merge_request_id_and_merged_at", where: "(merged_at IS NOT NULL)"
t.index ["merge_request_id"], name: "index_merge_request_metrics"
t.index ["merged_at", "id"], name: "index_merge_request_metrics_on_merged_at_and_id"
t.index ["merged_at"], name: "index_merge_request_metrics_on_merged_at"
t.index ["merged_by_id"], name: "index_merge_request_metrics_on_merged_by_id"
t.index ["pipeline_id"], name: "index_merge_request_metrics_on_pipeline_id"
end
......@@ -3915,6 +3915,7 @@ ActiveRecord::Schema.define(version: 2019_10_29_191901) do
t.boolean "severity_overridden", default: false
t.integer "confidence", limit: 2, null: false
t.boolean "confidence_overridden", default: false
t.integer "report_type", limit: 2, null: false
t.index ["author_id"], name: "index_vulnerabilities_on_author_id"
t.index ["closed_by_id"], name: "index_vulnerabilities_on_closed_by_id"
t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id"
......
......@@ -89,6 +89,10 @@ your GitLab installation has three repository storages: `default`,
`storage1` and `storage2`. You can use as little as just one server with one
repository storage if desired.
Note: **Note:** The token referred to throughout the Gitaly documentation is
just an arbitrary password selected by the administrator. It is unrelated to
tokens created for the GitLab API or other similar web API tokens.
### 1. Installation
First install Gitaly on each Gitaly server using either
......
......@@ -245,47 +245,29 @@ end
#### Use self-descriptive wrapper methods
When it's not possible/logical to modify the implementation of a
method. Wrap it in a self-descriptive method and use that method.
When it's not possible/logical to modify the implementation of a method, then
wrap it in a self-descriptive method and use that method.
For example, in CE only an `admin` is allowed to access all private
projects/groups, but in EE also an `auditor` has full private
access. It would be incorrect to override the implementation of
`User#admin?`, so instead add a method `full_private_access?` to
`app/models/users.rb`. The implementation in CE will be:
For example, in GitLab-FOSS, the only user created by the system is `User.ghost`
but in EE there are several types of bot-users that aren't really users. It would
be incorrect to override the implementation of `User#ghost?`, so instead we add
a method `#internal?` to `app/models/user.rb`. The implementation will be:
```ruby
def full_private_access?
admin?
def internal?
ghost?
end
```
In EE, the implementation `ee/app/models/ee/users.rb` would be:
```ruby
override :full_private_access?
def full_private_access?
super || auditor?
override :internal?
def internal?
super || bot?
end
```
In `lib/gitlab/visibility_level.rb` this method is used to return the
allowed visibility levels:
```ruby
def levels_for_user(user = nil)
if user.full_private_access?
[PRIVATE, INTERNAL, PUBLIC]
elsif # ...
end
```
See [CE MR][ce-mr-full-private] and [EE MR][ee-mr-full-private] for
full implementation details.
[ce-mr-full-private]: https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/12373
[ee-mr-full-private]: https://gitlab.com/gitlab-org/gitlab/merge_requests/2199
### Code in `config/routes`
When we add `draw :admin` in `config/routes.rb`, the application will try to
......
......@@ -184,3 +184,83 @@ class Commit
request_cache(:author) { author_email }
end
```
## `ReactiveCaching`
The `ReactiveCaching` concern is used to fetch some data in the background and
store it in the Rails cache, keeping it up-to-date for as long as it is being
requested. If the data hasn't been requested for `reactive_cache_lifetime`,
it will stop being refreshed, and then be removed.
Example of use:
```ruby
class Foo < ApplicationRecord
include ReactiveCaching
after_save :clear_reactive_cache!
def calculate_reactive_cache
# Expensive operation here. The return value of this method is cached
end
def result
with_reactive_cache do |data|
# ...
end
end
end
```
In this example, the first time `#result` is called, it will return `nil`.
However, it will enqueue a background worker to call `#calculate_reactive_cache`
and set an initial cache lifetime of ten minutes.
The background worker needs to find or generate the object on which
`with_reactive_cache` was called.
The default behaviour can be overridden by defining a custom
`reactive_cache_worker_finder`.
Otherwise, the background worker will use the class name and primary key to get
the object using the ActiveRecord `find_by` method.
```ruby
class Bar
include ReactiveCaching
self.reactive_cache_key = ->() { ["bar", "thing"] }
self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) }
def self.from_cache(var1, var2)
# This method will be called by the background worker with "bar1" and
# "bar2" as arguments.
new(var1, var2)
end
def initialize(var1, var2)
# ...
end
def calculate_reactive_cache
# Expensive operation here. The return value of this method is cached
end
def result
with_reactive_cache("bar1", "bar2") do |data|
# ...
end
end
end
```
Each time the background job completes, it stores the return value of
`#calculate_reactive_cache`. It is also re-enqueued to run again after
`reactive_cache_refresh_interval`, therefore, it will keep the stored value up to date.
Calculations are never run concurrently.
Calling `#result` while a value is cached will call the block given to
`#with_reactive_cache`, yielding the cached value. It will also extend the
lifetime by the `reactive_cache_lifetime` value.
Once the lifetime has expired, no more background jobs will be enqueued and
calling `#result` will again return `nil` - starting the process all over
again.
......@@ -18,6 +18,7 @@ Taking the trigger term as `project-name`, the commands are:
| `/project-name issue close <id>` | Closes the issue with id `<id>` |
| `/project-name issue search <query>` | Shows up to 5 issues matching `<query>` |
| `/project-name issue move <id> to <project>` | Moves issue ID `<id>` to `<project>` |
| `/project-name issue comment <id> <shift+return> <comment>` | Adds a new comment to an issue with id `<id>` and comment body `<comment>` |
| `/project-name deploy <from> to <to>` | Deploy from the `<from>` environment to the `<to>` environment |
| `/project-name run <job name> <arguments>` | Execute [ChatOps](../ci/chatops/README.md) job `<job name>` on `master` |
......
......@@ -99,7 +99,7 @@ To make full use of Auto DevOps, you will need:
To enable deployments, you will need:
1. A [Kubernetes 1.5+ cluster](../../user/project/clusters/index.md) for the project. The easiest
1. A [Kubernetes 1.12+ cluster](../../user/project/clusters/index.md) for the project. The easiest
way is to add a [new GKE cluster using the GitLab UI](../../user/project/clusters/add_remove_clusters.md#add-new-gke-cluster).
1. NGINX Ingress. You can deploy it to your Kubernetes cluster by installing
the [GitLab-managed app for Ingress](../../user/clusters/applications.md#ingress),
......
......@@ -17,7 +17,7 @@ sidebar.
## Viewing dependencies
![Dependency List](img/dependency_list_v12_3.png)
![Dependency List](img/dependency_list_v12_4.png)
Dependencies are displayed with the following information:
......
......@@ -176,7 +176,7 @@ Here's a list of what you can't do with subgroups:
- [GitLab Pages](../../project/pages/index.md) supports projects hosted under
a subgroup, but not subgroup websites.
That means that only the highest-level group supports
[group websites](../../project/pages/getting_started_part_one.md#gitlab-pages-domain-names),
[group websites](../../project/pages/getting_started_part_one.md#gitlab-pages-default-domain-names),
although you can have project websites under a subgroup.
- It is not possible to share a project with a group that's an ancestor of
the group the project is in. That means you can only share as you walk down
......
......@@ -37,12 +37,12 @@ Example request:
```sh
curl --request POST \
--data '{"title": "Incident title"}' \
--header "Authorization: Bearer <autorization_key>" \
--header "Authorization: Bearer <authorization_key>" \
--header "Content-Type: application/json" \
<url>
```
The `<autorization_key>` and `<url>` values can be found when [setting up generic alerts](#setting-up-generic-alerts).
The `<authorization_key>` and `<url>` values can be found when [setting up generic alerts](#setting-up-generic-alerts).
Example payload:
......
......@@ -14,7 +14,7 @@ To do so, follow the steps below.
1. From your **Project**'s **[Dashboard](https://gitlab.com/dashboard/projects)**,
click **New project**, and name it according to the
[Pages domain names](../getting_started_part_one.md#gitlab-pages-domain-names).
[Pages domain names](../getting_started_part_one.md#gitlab-pages-default-domain-names).
1. Clone it to your local computer, add your website
files to your project, add, commit and push to GitLab.
Alternativelly, you can run `git init` in your local directory,
......
......@@ -8,7 +8,7 @@ type: concepts, reference
On this document, learn how to name your project for GitLab Pages
according to your intended website's URL.
## GitLab Pages domain names
## GitLab Pages default domain names
>**Note:**
If you use your own GitLab instance to deploy your
......@@ -80,7 +80,7 @@ To understand Pages domains clearly, read the examples below.
- On your GitLab instance, replace `gitlab.io` above with your
Pages server domain. Ask your sysadmin for this information.
## URLs and Baseurls
## URLs and baseurls
Every Static Site Generator (SSG) default configuration expects
to find your website under a (sub)domain (`example.com`), not
......@@ -108,7 +108,7 @@ example we've just mentioned, you'd have to change Jekyll's `_config.yml` to:
baseurl: ""
```
## Custom Domains
## Custom domains
GitLab Pages supports custom domains and subdomains, served under HTTP or HTTPS.
See [GitLab Pages custom domains and SSL/TLS Certificates](custom_domains_ssl_tls_certification/index.md) for more information.
......@@ -83,7 +83,7 @@ to build your site and publish it to the GitLab Pages server. The sequence of
scripts that GitLab CI/CD runs to accomplish this task is created from a file named
`.gitlab-ci.yml`, which you can [create and modify](getting_started_part_four.md) at will. A specific `job` called `pages` in the configuration file will make GitLab aware that you are deploying a GitLab Pages website.
You can either use GitLab's [default domain for GitLab Pages websites](getting_started_part_one.md#gitlab-pages-domain-names),
You can either use GitLab's [default domain for GitLab Pages websites](getting_started_part_one.md#gitlab-pages-default-domain-names),
`*.gitlab.io`, or your own domain (`example.com`). In that case, you'll
need admin access to your domain's registrar (or control panel) to set it up with Pages.
......
<script>
import { GlEmptyState, GlDaterangePicker } from '@gitlab/ui';
import { GlEmptyState, GlDaterangePicker, GlLoadingIcon } from '@gitlab/ui';
import { mapActions, mapState, mapGetters } from 'vuex';
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { featureAccessLevel } from '~/pages/projects/shared/permissions/constants';
......@@ -14,6 +14,7 @@ export default {
name: 'CycleAnalytics',
components: {
GlEmptyState,
GlLoadingIcon,
GroupsDropdownFilter,
ProjectsDropdownFilter,
SummaryTable,
......@@ -49,7 +50,7 @@ export default {
'isAddingCustomStage',
'selectedGroup',
'selectedProjectIds',
'selectedStageName',
'selectedStageId',
'stages',
'summary',
'labels',
......@@ -83,7 +84,7 @@ export default {
},
methods: {
...mapActions([
'fetchGroupLabels',
'fetchCustomStageFormData',
'fetchCycleAnalyticsData',
'fetchStageData',
'setCycleAnalyticsDataEndpoint',
......@@ -92,7 +93,7 @@ export default {
'setSelectedProjects',
'setSelectedTimeframe',
'fetchStageData',
'setSelectedStageName',
'setSelectedStageId',
'hideCustomStageForm',
'showCustomStageForm',
'setDateRange',
......@@ -101,7 +102,6 @@ export default {
this.setCycleAnalyticsDataEndpoint(group.full_path);
this.setSelectedGroup(group);
this.fetchCycleAnalyticsData();
this.fetchGroupLabels(this.currentGroupPath);
},
onProjectsSelect(projects) {
const projectIds = projects.map(value => value.id);
......@@ -110,7 +110,7 @@ export default {
},
onStageSelect(stage) {
this.hideCustomStageForm();
this.setSelectedStageName(stage.name);
this.setSelectedStageId(stage.id);
this.setStageDataEndpoint(this.currentStage.slug);
this.fetchStageData(this.currentStage.name);
},
......@@ -196,24 +196,29 @@ export default {
"
/>
<div v-else-if="!errorCode">
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
<div v-if="isLoading">
<gl-loading-icon class="mt-4" size="md" />
</div>
<div v-else>
<summary-table class="js-summary-table" :items="summary" />
<stage-table
v-if="currentStage"
class="js-stage-table"
:current-stage="currentStage"
:stages="stages"
:is-loading="isLoadingStage"
:is-empty-stage="isEmptyStage"
:is-adding-custom-stage="isAddingCustomStage"
:current-stage-events="currentStageEvents"
:custom-stage-form-events="customStageFormEvents"
:labels="labels"
:no-data-svg-path="noDataSvgPath"
:no-access-svg-path="noAccessSvgPath"
:can-edit-stages="hasCustomizableCycleAnalytics"
@selectStage="onStageSelect"
@showAddStageForm="onShowAddStageForm"
/>
</div>
</div>
</div>
</div>
......
......@@ -40,13 +40,13 @@ export default {
<limit-warning :count="events.length" />
</div>
<stage-build-item
v-if="isCurrentStage(stage.name, STAGE_NAME_TEST)"
v-if="isCurrentStage(stage.slug, STAGE_NAME_TEST)"
:stage="stage"
:events="events"
:with-build-status="true"
/>
<stage-build-item
v-else-if="isCurrentStage(stage.name, STAGE_NAME_STAGING)"
v-else-if="isCurrentStage(stage.slug, STAGE_NAME_STAGING)"
:stage="stage"
:events="events"
/>
......
......@@ -17,10 +17,6 @@ export default {
default: false,
required: false,
},
isUserAllowed: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
......@@ -41,7 +37,7 @@ export default {
return this.value && this.value.length > 0;
},
editable() {
return this.isUserAllowed && this.canEdit;
return this.canEdit;
},
},
};
......@@ -54,13 +50,8 @@ export default {
{{ title }}
</div>
<div class="stage-nav-item-cell stage-median mr-4">
<template v-if="isUserAllowed">
<span v-if="hasValue">{{ value }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</template>
<template v-else>
<span class="not-available">{{ __('Not available') }}</span>
</template>
<span v-if="hasValue">{{ value }}</span>
<span v-else class="stage-empty">{{ __('Not enough data') }}</span>
</div>
<template v-slot:dropdown-options>
<template v-if="isDefaultStage">
......
......@@ -71,7 +71,7 @@ export default {
},
computed: {
stageName() {
return this.currentStage ? this.currentStage.legend : __('Related Issues');
return this.currentStage ? this.currentStage.title : __('Related Issues');
},
shouldDisplayStage() {
const { currentStageEvents = [], isLoading, isEmptyStage } = this;
......@@ -138,8 +138,8 @@ export default {
:key="`ca-stage-title-${stage.title}`"
:title="stage.title"
:value="stage.value"
:is-active="!isAddingCustomStage && stage.name === currentStage.name"
:is-user-allowed="stage.isUserAllowed"
:is-active="!isAddingCustomStage && stage.id === currentStage.id"
:is-default-stage="!stage.custom"
@select="selectStage(stage)"
/>
<add-stage-button
......@@ -151,12 +151,6 @@ export default {
</nav>
<div class="section stage-events">
<gl-loading-icon v-if="isLoading" class="mt-4" size="md" />
<gl-empty-state
v-else-if="currentStage && !currentStage.isUserAllowed"
:title="__('You need permission.')"
:description="__('Want to see the data? Please ask an administrator for access.')"
:svg-path="noAccessSvgPath"
/>
<custom-stage-form
v-else-if="isAddingCustomStage"
:events="customStageFormEvents"
......
import dateFormat from 'dateformat';
import axios from '~/lib/utils/axios_utils';
import createFlash, { hideFlash } from '~/flash';
import { __ } from '~/locale';
import Api from '~/api';
import httpStatus from '~/lib/utils/http_status';
import * as types from './mutation_types';
import { dateFormats } from '../../shared/constants';
import { nestQueryStringKeys } from '../utils';
const removeError = () => {
const flashEl = document.querySelector('.flash-alert');
......@@ -22,8 +21,8 @@ export const setStageDataEndpoint = ({ commit }, stageSlug) =>
export const setSelectedGroup = ({ commit }, group) => commit(types.SET_SELECTED_GROUP, group);
export const setSelectedProjects = ({ commit }, projectIds) =>
commit(types.SET_SELECTED_PROJECTS, projectIds);
export const setSelectedStageName = ({ commit }, stageName) =>
commit(types.SET_SELECTED_STAGE_NAME, stageName);
export const setSelectedStageId = ({ commit }, stageId) =>
commit(types.SET_SELECTED_STAGE_ID, stageId);
export const setDateRange = (
{ commit, dispatch, state },
......@@ -42,38 +41,24 @@ export const receiveStageDataSuccess = ({ commit }, data) =>
export const receiveStageDataError = ({ commit }) => {
commit(types.RECEIVE_STAGE_DATA_ERROR);
createFlash(__('There was an error while fetching cycle analytics data.'));
createFlash(__('There was an error fetching data for the selected stage'));
};
export const fetchStageData = ({ state, dispatch }) => {
export const fetchStageData = ({ state, dispatch, getters }) => {
const { cycleAnalyticsRequestParams = {} } = getters;
dispatch('requestStageData');
axios
.get(state.endpoints.stageData, {
params: {
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
params: nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
})
.then(({ data }) => dispatch('receiveStageDataSuccess', data))
.catch(error => dispatch('receiveStageDataError', error));
};
export const requestCycleAnalyticsData = ({ commit }) => commit(types.REQUEST_CYCLE_ANALYTICS_DATA);
export const receiveCycleAnalyticsDataSuccess = ({ state, commit, dispatch }, data) => {
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS, data);
const { stages = [] } = state;
if (stages && stages.length) {
removeError();
const { slug } = stages[0];
dispatch('setStageDataEndpoint', slug);
dispatch('fetchStageData');
} else {
createFlash(__('There was an error while fetching cycle analytics data.'));
}
};
export const receiveCycleAnalyticsDataSuccess = ({ commit }) =>
commit(types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS);
export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
const { status } = response;
......@@ -83,21 +68,41 @@ export const receiveCycleAnalyticsDataError = ({ commit }, { response }) => {
createFlash(__('There was an error while fetching cycle analytics data.'));
};
export const fetchCycleAnalyticsData = ({ state, dispatch }) => {
dispatch('requestCycleAnalyticsData');
export const fetchCycleAnalyticsData = ({ dispatch }) => {
removeError();
return dispatch('requestCycleAnalyticsData')
.then(() => dispatch('fetchGroupLabels')) // fetch group label data
.then(() => dispatch('fetchGroupStagesAndEvents')) // fetch stage data
.then(() => dispatch('fetchSummaryData')) // fetch summary data and stage medians
.then(() => dispatch('receiveCycleAnalyticsDataSuccess'))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
};
axios
export const requestSummaryData = ({ commit }) => commit(types.REQUEST_SUMMARY_DATA);
export const receiveSummaryDataError = ({ commit }, error) => {
commit(types.RECEIVE_SUMMARY_DATA_ERROR, error);
createFlash(__('There was an error while fetching cycle analytics summary data.'));
};
export const receiveSummaryDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_SUMMARY_DATA_SUCCESS, data);
export const fetchSummaryData = ({ state, dispatch, getters }) => {
const { cycleAnalyticsRequestParams = {} } = getters;
dispatch('requestSummaryData');
return axios
.get(state.endpoints.cycleAnalyticsData, {
params: {
'cycle_analytics[created_after]': dateFormat(state.startDate, dateFormats.isoDate),
'cycle_analytics[created_before]': dateFormat(state.endDate, dateFormats.isoDate),
'cycle_analytics[project_ids]': state.selectedProjectIds,
},
params: nestQueryStringKeys(cycleAnalyticsRequestParams, 'cycle_analytics'),
})
.then(({ data }) => dispatch('receiveCycleAnalyticsDataSuccess', data))
.catch(error => dispatch('receiveCycleAnalyticsDataError', error));
.then(({ data }) => dispatch('receiveSummaryDataSuccess', data))
.catch(error => dispatch('receiveSummaryDataError', error));
};
export const requestGroupStagesAndEvents = ({ commit }) =>
commit(types.REQUEST_GROUP_STAGES_AND_EVENTS);
export const hideCustomStageForm = ({ commit }) => commit(types.HIDE_CUSTOM_STAGE_FORM);
export const showCustomStageForm = ({ commit }) => commit(types.SHOW_CUSTOM_STAGE_FORM);
......@@ -111,10 +116,44 @@ export const receiveGroupLabelsError = ({ commit }, error) => {
export const requestGroupLabels = ({ commit }) => commit(types.REQUEST_GROUP_LABELS);
export const fetchGroupLabels = ({ dispatch }, groupPath) => {
export const fetchGroupLabels = ({ dispatch, state }) => {
dispatch('requestGroupLabels');
const {
selectedGroup: { fullPath },
} = state;
return Api.groupLabels(groupPath)
return Api.groupLabels(fullPath)
.then(data => dispatch('receiveGroupLabelsSuccess', data))
.catch(error => dispatch('receiveGroupLabelsError', error));
};
export const receiveGroupStagesAndEventsError = ({ commit }) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR);
createFlash(__('There was an error fetching cycle analytics stages.'));
};
export const receiveGroupStagesAndEventsSuccess = ({ state, commit, dispatch }, data) => {
commit(types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS, data);
const { stages = [] } = state;
if (stages && stages.length) {
const { slug } = stages[0];
dispatch('setStageDataEndpoint', slug);
dispatch('fetchStageData');
} else {
createFlash(__('There was an error while fetching cycle analytics data.'));
}
};
export const fetchGroupStagesAndEvents = ({ state, dispatch, getters }) => {
const {
cycleAnalyticsRequestParams: { created_after, project_ids },
} = getters;
dispatch('requestGroupStagesAndEvents');
return axios
.get(state.endpoints.cycleAnalyticsStagesAndEvents, {
params: nestQueryStringKeys({ start_date: created_after, project_ids }, 'cycle_analytics'),
})
.then(({ data }) => dispatch('receiveGroupStagesAndEventsSuccess', data))
.catch(error => dispatch('receiveGroupStagesAndEventsError', error));
};
import dateFormat from 'dateformat';
import httpStatus from '~/lib/utils/http_status';
import { dateFormats } from '../../shared/constants';
export const currentStage = ({ stages, selectedStageName }) =>
stages.length && selectedStageName
? stages.find(stage => stage.name === selectedStageName)
: null;
export const currentStage = ({ stages, selectedStageId }) =>
stages.length && selectedStageId ? stages.find(stage => stage.id === selectedStageId) : null;
export const defaultStage = state => (state.stages.length ? state.stages[0] : null);
export const hasNoAccessError = state => state.errorCode === httpStatus.FORBIDDEN;
export const currentGroupPath = ({ selectedGroup }) =>
selectedGroup && selectedGroup.fullPath ? selectedGroup.fullPath : null;
export const cycleAnalyticsRequestParams = ({
startDate = null,
endDate = null,
selectedProjectIds = [],
}) => ({
project_ids: selectedProjectIds,
created_after: startDate ? dateFormat(startDate, dateFormats.isoDate) : null,
created_before: endDate ? dateFormat(endDate, dateFormats.isoDate) : null,
});
......@@ -3,7 +3,7 @@ export const SET_STAGE_DATA_ENDPOINT = 'SET_STAGE_DATA_ENDPOINT';
export const SET_SELECTED_GROUP = 'SET_SELECTED_GROUP';
export const SET_SELECTED_PROJECTS = 'SET_SELECTED_PROJECTS';
export const SET_SELECTED_STAGE_NAME = 'SET_SELECTED_STAGE_NAME';
export const SET_SELECTED_STAGE_ID = 'SET_SELECTED_STAGE_ID';
export const SET_DATE_RANGE = 'SET_DATE_RANGE';
......@@ -21,3 +21,11 @@ export const SHOW_CUSTOM_STAGE_FORM = 'SHOW_CUSTOM_STAGE_FORM';
export const REQUEST_GROUP_LABELS = 'REQUEST_GROUP_LABELS';
export const RECEIVE_GROUP_LABELS_SUCCESS = 'RECEIVE_GROUP_LABELS_SUCCESS';
export const RECEIVE_GROUP_LABELS_ERROR = 'RECEIVE_GROUP_LABELS_ERROR';
export const REQUEST_SUMMARY_DATA = 'REQUEST_SUMMARY_DATA';
export const RECEIVE_SUMMARY_DATA_SUCCESS = 'RECEIVE_SUMMARY_DATA_SUCCESS';
export const RECEIVE_SUMMARY_DATA_ERROR = 'RECEIVE_SUMMARY_DATA_ERROR';
export const REQUEST_GROUP_STAGES_AND_EVENTS = 'REQUEST_GROUP_STAGES_AND_EVENTS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS = 'RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS';
export const RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR = 'RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR';
import { dasherize } from '~/lib/utils/text_utility';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import * as types from './mutation_types';
import { EMPTY_STAGE_TEXT } from '../constants';
import { transformRawStages } from '../utils';
export default {
[types.SET_CYCLE_ANALYTICS_DATA_ENDPOINT](state, groupPath) {
// TODO: this endpoint will be removed when the /-/analytics endpoints are ready
// https://gitlab.com/gitlab-org/gitlab/issues/34751
state.endpoints.cycleAnalyticsData = `/groups/${groupPath}/-/cycle_analytics`;
state.endpoints.cycleAnalyticsStagesAndEvents = `/-/analytics/cycle_analytics/stages?group_id=${groupPath}`;
},
[types.SET_STAGE_DATA_ENDPOINT](state, stageSlug) {
state.endpoints.stageData = `${state.endpoints.cycleAnalyticsData}/events/${stageSlug}.json`;
// TODO: this endpoint will be replaced with a /-/analytics... endpoint when backend is ready
// https://gitlab.com/gitlab-org/gitlab/issues/34751
const { fullPath } = state.selectedGroup;
state.endpoints.stageData = `/groups/${fullPath}/-/cycle_analytics/events/${stageSlug}.json`;
},
[types.SET_SELECTED_GROUP](state, group) {
state.selectedGroup = convertObjectPropsToCamelCase(group, { deep: true });
......@@ -17,8 +22,8 @@ export default {
[types.SET_SELECTED_PROJECTS](state, projectIds) {
state.selectedProjectIds = projectIds;
},
[types.SET_SELECTED_STAGE_NAME](state, stageName) {
state.selectedStageName = stageName;
[types.SET_SELECTED_STAGE_ID](state, stageId) {
state.selectedStageId = stageId;
},
[types.SET_DATE_RANGE](state, { startDate, endDate }) {
state.startDate = startDate;
......@@ -28,27 +33,7 @@ export default {
state.isLoading = true;
state.isAddingCustomStage = false;
},
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state, data) {
state.summary = data.summary.map(item => ({
...item,
value: item.value || '-',
}));
state.stages = data.stats.map(item => {
const slug = dasherize(item.name.toLowerCase());
return {
...item,
isUserAllowed: data.permissions[slug],
emptyStageText: EMPTY_STAGE_TEXT[slug],
slug,
};
});
if (state.stages.length) {
const { name } = state.stages[0];
state.selectedStageName = name;
}
[types.RECEIVE_CYCLE_ANALYTICS_DATA_SUCCESS](state) {
state.errorCode = null;
state.isLoading = false;
},
......@@ -58,13 +43,15 @@ export default {
},
[types.REQUEST_STAGE_DATA](state) {
state.isLoadingStage = true;
state.isEmptyStage = false;
},
[types.RECEIVE_STAGE_DATA_SUCCESS](state, data = {}) {
const { events = [] } = data;
state.currentStageEvents = events.map(({ name = '', ...rest }) =>
convertObjectPropsToCamelCase({ title: name, ...rest }, { deep: true }),
);
state.isEmptyStage = state.currentStageEvents.length === 0;
state.isEmptyStage = !events.length;
state.isLoadingStage = false;
},
[types.RECEIVE_STAGE_DATA_ERROR](state) {
......@@ -86,4 +73,51 @@ export default {
[types.SHOW_CUSTOM_STAGE_FORM](state) {
state.isAddingCustomStage = true;
},
[types.RECEIVE_SUMMARY_DATA_ERROR](state) {
state.summary = [];
},
[types.REQUEST_SUMMARY_DATA](state) {
state.summary = [];
},
[types.RECEIVE_SUMMARY_DATA_SUCCESS](state, data) {
const { stages } = state;
const { summary, stats } = data;
state.summary = summary.map(item => ({
...item,
value: item.value || '-',
}));
/*
* Medians will eventually be fetched from a separate endpoint, which will
* include the median calculations for the custom stages, for now we will
* grab the medians from the group level cycle analytics endpoint, which does
* not include the custom stages
* https://gitlab.com/gitlab-org/gitlab/issues/34751
*/
state.stages = stages.map(stage => {
const stat = stats.find(m => m.name === stage.slug);
return { ...stage, value: stat ? stat.value : null };
});
},
[types.REQUEST_GROUP_STAGES_AND_EVENTS](state) {
state.stages = [];
state.customStageFormEvents = [];
},
[types.RECEIVE_GROUP_STAGES_AND_EVENTS_ERROR](state) {
state.stages = [];
state.customStageFormEvents = [];
},
[types.RECEIVE_GROUP_STAGES_AND_EVENTS_SUCCESS](state, data) {
const { events = [], stages = [] } = data;
state.stages = transformRawStages(stages);
state.customStageFormEvents = events.map(ev =>
convertObjectPropsToCamelCase(ev, { deep: true }),
);
if (state.stages.length) {
const { id } = state.stages[0];
state.selectedStageId = id;
}
},
};
......@@ -2,6 +2,8 @@ export default () => ({
endpoints: {
cycleAnalyticsData: null,
stageData: null,
cycleAnalyticsStagesAndEvents: null,
summaryData: null,
},
startDate: null,
......@@ -17,7 +19,7 @@ export default () => ({
selectedGroup: null,
selectedProjectIds: [],
selectedStageName: null,
selectedStageId: null,
currentStageEvents: [],
......
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { convertToSnakeCase } from '~/lib/utils/text_utility';
import { isString } from 'underscore';
const EVENT_TYPE_LABEL = 'label';
export const isStartEvent = ev => Boolean(ev) && Boolean(ev.canBeStartEvent) && ev.canBeStartEvent;
......@@ -24,3 +28,20 @@ export const isLabelEvent = (labelEvents = [], ev = null) =>
export const getLabelEventsIdentifiers = (events = []) =>
events.filter(ev => ev.type && ev.type === EVENT_TYPE_LABEL).map(i => i.identifier);
export const transformRawStages = (stages = []) =>
stages
.map(({ title, ...rest }) => ({
...convertObjectPropsToCamelCase(rest, { deep: true }),
slug: convertToSnakeCase(title),
title,
}))
.sort((a, b) => a.id > b.id);
export const nestQueryStringKeys = (obj = null, targetKey = '') => {
if (!obj || !isString(targetKey) || !targetKey.length) return {};
return Object.entries(obj).reduce((prev, [key, value]) => {
const customKey = `${targetKey}[${key}]`;
return { ...prev, [customKey]: value };
}, {});
};
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlTabs } from '@gitlab/ui';
import { GlBadge, GlEmptyState, GlLoadingIcon, GlTab, GlTabs, GlLink } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import DependenciesActions from './dependencies_actions.vue';
import DependencyListIncompleteAlert from './dependency_list_incomplete_alert.vue';
import DependencyListJobFailedAlert from './dependency_list_job_failed_alert.vue';
......@@ -16,8 +18,10 @@ export default {
GlLoadingIcon,
GlTab,
GlTabs,
GlLink,
DependencyListIncompleteAlert,
DependencyListJobFailedAlert,
Icon,
PaginatedDependenciesTable,
},
props: {
......@@ -43,6 +47,7 @@ export default {
computed: {
...mapState(['currentList', 'listTypes']),
...mapGetters([
'generatedAtTimeAgo',
'isInitialized',
'isJobNotSetUp',
'isJobFailed',
......@@ -60,6 +65,18 @@ export default {
this.setCurrentList(namespace);
},
},
subHeadingText() {
const { jobPath } = this.reportInfo;
const body = __(
'Displays dependencies and known vulnerabilities, based on the %{linkStart}latest pipeline%{linkEnd} scan',
);
const linkStart = jobPath ? `<a href="${jobPath}">` : '';
const linkEnd = jobPath ? '</a>' : '';
return sprintf(body, { linkStart, linkEnd }, false);
},
},
created() {
this.setDependenciesEndpoint(this.endpoint);
......@@ -97,7 +114,7 @@ export default {
:primary-button-text="__('Learn more about the dependency list')"
/>
<div v-else>
<section v-else>
<dependency-list-incomplete-alert
v-if="isIncomplete && !isIncompleteAlertDismissed"
@close="dismissIncompleteListAlert"
......@@ -109,7 +126,24 @@ export default {
@close="dismissJobFailedAlert"
/>
<h3 class="h5">{{ __('Dependencies') }}</h3>
<header class="my-3">
<h2 class="h4 mb-1">
{{ __('Dependencies') }}
<gl-link
target="_blank"
:href="documentationPath"
:aria-label="__('Dependencies help page link')"
><icon name="question"
/></gl-link>
</h2>
<p class="mb-0">
<span v-html="subHeadingText"></span>
<span v-if="generatedAtTimeAgo"
><span aria-hidden="true">&bull;</span>
<span class="text-secondary"> {{ generatedAtTimeAgo }}</span></span
>
</p>
</header>
<gl-tabs v-model="currentListIndex" content-class="pt-0">
<gl-tab
......@@ -131,5 +165,5 @@ export default {
</li>
</template>
</gl-tabs>
</div>
</section>
</template>
export const isInitialized = ({ currentList, ...state }) => state[currentList].initialized;
export const reportInfo = ({ currentList, ...state }) => state[currentList].reportInfo;
export const generatedAtTimeAgo = ({ currentList }, getters) =>
getters[`${currentList}/generatedAtTimeAgo`];
export const isJobNotSetUp = ({ currentList }, getters) => getters[`${currentList}/isJobNotSetUp`];
export const isJobFailed = ({ currentList }, getters) => getters[`${currentList}/isJobFailed`];
export const isIncomplete = ({ currentList }, getters) => getters[`${currentList}/isIncomplete`];
......
import { REPORT_STATUS } from './constants';
import { getTimeago } from '~/lib/utils/datetime_utility';
export const generatedAtTimeAgo = ({ reportInfo: { generatedAt } }) =>
generatedAt ? getTimeago().format(generatedAt) : '';
export const isJobNotSetUp = state => state.reportInfo.status === REPORT_STATUS.jobNotSetUp;
export const isJobFailed = state =>
......
......@@ -19,6 +19,7 @@ export default {
state.errorLoading = false;
state.reportInfo.status = reportInfo.status;
state.reportInfo.jobPath = reportInfo.job_path;
state.reportInfo.generatedAt = reportInfo.generated_at;
state.initialized = true;
},
[types.RECEIVE_DEPENDENCIES_ERROR](state) {
......@@ -29,6 +30,7 @@ export default {
state.reportInfo = {
status: REPORT_STATUS.ok,
jobPath: '',
generatedAt: '',
};
state.initialized = true;
},
......
......@@ -12,6 +12,7 @@ export default () => ({
reportInfo: {
status: REPORT_STATUS.ok,
jobPath: '',
generatedAt: '',
},
filter: FILTER.all,
sortField: 'name',
......
......@@ -87,22 +87,22 @@ export default {
</script>
<template>
<header class="d-flex w-100 p-2 bg-white align-items-center js-design-header">
<header class="d-flex p-2 bg-white align-items-center js-design-header">
<router-link
:to="{
name: 'designs',
query: $route.query,
}"
:aria-label="s__('DesignManagement|Go back to designs')"
class="mr-3 text-plain"
class="mr-3 text-plain d-flex justify-content-center align-items-center"
>
<icon :size="18" name="close" />
</router-link>
<div>
<h2 class="m-0">{{ name }}</h2>
<div class="overflow-hidden d-flex align-items-center">
<h2 class="m-0 str-truncated-100">{{ name }}</h2>
<small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small>
</div>
<pagination :id="id" class="ml-auto" />
<pagination :id="id" class="ml-auto flex-shrink-0" />
<delete-button
v-if="isLatestVersion && canDeleteDesign"
:is-deleting="isDeleting"
......
......@@ -46,7 +46,7 @@ export default {
<template>
<div v-if="designsCount" class="d-flex align-items-center">
{{ paginationText }}
<div class="btn-group ml-3">
<div class="btn-group ml-3 mr-3">
<pagination-button
:design="previousDesign"
:title="s__('DesignManagement|Go to previous design')"
......
......@@ -192,10 +192,12 @@ export default {
</script>
<template>
<div class="design-detail fixed-top w-100 position-bottom-0 d-sm-flex justify-content-center">
<div
class="design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
>
<gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" />
<template v-else>
<div class="d-flex flex-column w-100">
<div class="d-flex overflow-hidden flex-lg-grow-1 flex-column">
<design-destroyer
:filenames="[design.filename]"
:project-path="projectPath"
......@@ -215,7 +217,7 @@ export default {
/>
</template>
</design-destroyer>
<div class="d-flex flex-column w-100 h-100 mh-100 position-relative">
<div class="d-flex flex-column h-100 mh-100 position-relative">
<design-image
:image="design.image"
:name="design.filename"
......
<script>
import _ from 'underscore';
import { GlTooltipDirective, GlLink } from '@gitlab/ui';
import { GlTooltipDirective, GlLink, GlBadge } from '@gitlab/ui';
import { s__, __, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import timeagoMixin from '~/vue_shared/mixins/timeago';
......@@ -8,7 +8,7 @@ import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link
import Commit from '~/vue_shared/components/commit.vue';
import Alerts from 'ee/vue_shared/dashboards/components/alerts.vue';
import TimeAgo from 'ee/vue_shared/dashboards/components/time_ago.vue';
import { STATUS_FAILED, STATUS_RUNNING } from 'ee/vue_shared/dashboards/constants';
import { STATUS_FAILED } from 'ee/vue_shared/dashboards/constants';
import ProjectPipeline from 'ee/vue_shared/dashboards/components/project_pipeline.vue';
import EnvironmentHeader from './environment_header.vue';
......@@ -17,6 +17,7 @@ export default {
EnvironmentHeader,
UserAvatarLink,
GlLink,
GlBadge,
Commit,
Alerts,
ProjectPipeline,
......@@ -56,7 +57,7 @@ export default {
'dashboard-card-body-warning': !this.hasPipelineFailed && this.hasPipelineErrors,
'dashboard-card-body-failed': this.hasPipelineFailed,
'bg-secondary': !this.hasPipelineFailed && !this.hasPipelineErrors,
'd-flex flex-column justify-content-center align-items-center': !this.deployable,
'd-flex flex-column justify-content-center align-items-center': !this.lastDeployment,
};
},
user() {
......@@ -77,7 +78,9 @@ export default {
return !_.isEmpty(this.lastDeployment.commit) ? this.lastDeployment.commit : {};
},
jobTooltip() {
return sprintf(this.$options.tooltips.job, { job: this.buildName });
return this.deployable
? sprintf(this.$options.tooltips.job, { job: this.buildName })
: s__('EnvironmentDashboard|Created through the Deployment API');
},
commitRef() {
return this.lastDeployment && !_.isEmpty(this.lastDeployment.commit)
......@@ -98,21 +101,18 @@ export default {
);
},
finishedTime() {
return this.deployable.updated_at;
return this.lastDeployment.deployed_at;
},
finishedTimeTitle() {
return this.tooltipTitle(this.finishedTime);
},
shouldShowTimeAgo() {
return (
this.deployable &&
this.deployable.status &&
this.deployable.status.group !== STATUS_RUNNING &&
this.finishedTime
);
return Boolean(this.finishedTime);
},
buildName() {
return this.deployable ? `${this.deployable.name} #${this.deployable.id}` : '';
return this.deployable
? `${this.deployable.name} #${this.deployable.id}`
: s__('EnvironmentDashboard|API');
},
},
};
......@@ -126,7 +126,7 @@ export default {
/>
<div :class="cardClasses" class="dashboard-card-body card-body">
<div v-if="deployable" class="row">
<div v-if="lastDeployment" class="row">
<div class="col-1 align-self-center">
<user-avatar-link
v-if="user"
......@@ -140,9 +140,15 @@ export default {
<div class="col-10 col-sm-7 pr-0 pl-5 align-self-center align-middle ci-table">
<div class="branch-commit">
<icon name="work" />
<gl-link v-gl-tooltip="jobTooltip" :href="deployable.build_path" class="str-truncated">
<gl-link
v-if="deployable"
v-gl-tooltip="jobTooltip"
:href="deployable.build_path"
class="str-truncated"
>
{{ buildName }}
</gl-link>
<gl-badge v-else v-gl-tooltip="jobTooltip" variant="primary">{{ buildName }}</gl-badge>
</div>
<commit
:tag="lastDeployment.tag"
......
......@@ -2,7 +2,6 @@
import { mapState, mapActions } from 'vuex';
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
import { ChildType } from '../constants';
import TreeDragAndDropMixin from '../mixins/tree_dd_mixin';
export default {
......@@ -28,9 +27,6 @@ export default {
},
computed: {
...mapState(['childrenFlags', 'userSignedIn']),
currentItemIssuesBeginAtIndex() {
return this.children.findIndex(item => item.type === ChildType.Issue);
},
hasMoreChildren() {
const flags = this.childrenFlags[this.parentItem.reference];
......
......@@ -48,7 +48,7 @@ export default {
* @param {number} object.newIndex new position of target item
* @param {object} object.targetItem target item object
*/
getTreeReorderMutation({ newIndex, targetItem }) {
getTreeReorderMutation({ oldIndex, newIndex, targetItem }) {
let relativePosition;
// adjacentReference is always the item that's at the position
......@@ -65,10 +65,14 @@ export default {
// Adjacent reference will be the one which is currently at the bottom,
// and it's relative position with respect to target's new position is `before`.
relativePosition = relativePositions.Before;
} else {
} else if (oldIndex < newIndex) {
// If newIndex is neither top nor bottom, it was moved somewhere in the middle.
// Adjacent reference will be the one which currently at that position,
// and it's relative postion with respect to target's new position is `after`.
// when the item is moved down, the newIndex is before the adjacent reference.
relativePosition = relativePositions.Before;
} else {
// when the item is moved up, the newIndex is after the adjacent reference.
relativePosition = relativePositions.After;
}
......@@ -106,7 +110,7 @@ export default {
const targetItem = this.children[oldIndex];
this.reorderItem({
treeReorderMutation: this.getTreeReorderMutation({ newIndex, targetItem }),
treeReorderMutation: this.getTreeReorderMutation({ oldIndex, newIndex, targetItem }),
parentItem: this.parentItem,
targetItem,
oldIndex,
......
......@@ -286,7 +286,7 @@ export const receiveAddItemSuccess = ({ dispatch, commit, getters }, { rawItems
});
commit(types.RECEIVE_ADD_ITEM_SUCCESS, {
insertAt: getters.isEpic ? 0 : getters.issuesBeginAtIndex,
insertAt: 0,
items,
});
......@@ -342,7 +342,7 @@ export const receiveCreateItemSuccess = ({ state, commit, dispatch, getters }, {
});
commit(types.RECEIVE_CREATE_ITEM_SUCCESS, {
insertAt: getters.issuesBeginAtIndex > 0 ? getters.issuesBeginAtIndex - 1 : 0,
insertAt: 0,
item,
});
......
......@@ -23,9 +23,6 @@ export const headerItems = state => [
},
];
export const issuesBeginAtIndex = (state, getters) =>
getters.directChildren.findIndex(item => item.type === ChildType.Issue);
export const itemAutoCompleteSources = (state, getters) => {
if (getters.isEpic) {
return state.autoCompleteEpics ? getters.autoCompleteSources : {};
......
......@@ -2,10 +2,6 @@
object-fit: contain;
}
.design-detail {
overflow-y: scroll;
}
.design-list-item .design-event {
top: $gl-padding;
right: $gl-padding;
......@@ -22,7 +18,9 @@
padding: 0 $gl-padding;
padding-top: 50px;
background-color: $white-light;
flex-shrink: 0;
min-width: 400px;
flex-basis: 28%;
li {
list-style: none;
......@@ -74,7 +72,7 @@
}
}
@media (max-width: map-get($grid-breakpoints, sm)) {
@media (max-width: map-get($grid-breakpoints, lg)) {
.design-detail {
overflow-y: scroll;
}
......@@ -82,5 +80,7 @@
.image-notes {
overflow-y: auto;
min-width: 100%;
flex-grow: 1;
flex-basis: auto;
}
}
......@@ -7,7 +7,6 @@
h2 {
font-size: 14px;
line-height: 1;
}
}
......
......@@ -37,26 +37,10 @@ class ProductivityAnalyticsFinder < MergeRequestsFinder
return items unless params[:merged_at_after] || params[:merged_at_before]
items = items.joins(:metrics)
items = items.where(metrics_table[:merged_at].gteq(merged_at_between[:from])) if merged_at_between[:from]
items = items.where(metrics_table[:merged_at].lteq(merged_at_between[:to])) if merged_at_between[:to]
items = items.where(metrics_table[:merged_at].gteq(params[:merged_at_after])) if params[:merged_at_after]
items = items.where(metrics_table[:merged_at].lteq(params[:merged_at_before])) if params[:merged_at_before]
items
end
# rubocop: enable CodeReuse/ActiveRecord
def merged_at_between
@merged_at_between ||= begin
if merged_at_period
{ from: Time.zone.now.ago(merged_at_period.days) }
else
{ from: params[:merged_at_after], to: params[:merged_at_before] }
end
end
end
def merged_at_period
matches = params[:merged_at_after]&.match(/^(?<days>\d+)days?$/)
matches && matches[:days].to_i
end
end
......@@ -50,13 +50,15 @@ module EE
def render_dashboard_gold_trial(user)
return unless show_gold_trial?(user, GOLD_TRIAL) &&
user_default_dashboard?(user) &&
has_no_trial_or_gold_plan?(user)
has_no_trial_or_gold_plan?(user) &&
has_some_namespaces_with_no_trials?(user)
render 'shared/gold_trial_callout_content'
end
def render_billings_gold_trial(user, namespace)
return if namespace.gold_plan?
return unless namespace.never_had_trial?
return unless show_gold_trial?(user, GOLD_TRIAL_BILLINGS)
render 'shared/gold_trial_callout_content', is_dismissable: !namespace.free_plan?, callout: GOLD_TRIAL_BILLINGS
......@@ -107,5 +109,9 @@ module EE
!user.any_namespace_with_trial?
end
def has_some_namespaces_with_no_trials?(user)
user&.any_namespace_without_trial?
end
end
end
......@@ -259,6 +259,10 @@ module EE
trial? && trial_ends_on.present? && trial_ends_on >= Date.today
end
def never_had_trial?
trial_ends_on.nil?
end
def trial_expired?
trial_ends_on.present? &&
trial_ends_on < Date.today &&
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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