Commit 31c33cc1 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 0ce2fae6 3877ea35
<svg width="16" height="17" xmlns="http://www.w3.org/2000/svg"><path fill="#fffff" d="M1.53 7.639l-.476.88.476-.88zm0-1.758L1.054 5l.476.88zm2.257 2.982h1v-.596l-.523-.283-.477.879zm8.424 0l-.476-.88-.524.284v.596h1zm2.257-1.224l.477.88-.477-.88zm0-1.758l-.476.879.476-.88zM8.476 2.632l-.477.88.477-.88zm-.953 0l.476.88-.476-.88zM2.007 6.76l-.953-1.758c-1.396.756-1.396 2.76 0 3.516l.953-1.758zm2.257 1.224L2.007 6.76l-.953 1.758L3.31 9.742l.953-1.758zm.523 1.995V8.863h-2v1.116h2zM8 12.5c-1.949 0-3.212-1.289-3.212-2.52h-2c0 2.656 2.51 4.52 5.212 4.52v-2zm3.212-2.52c0 1.231-1.262 2.52-3.212 2.52v2c2.704 0 5.212-1.864 5.212-4.52h-2zm0-1.117v1.116h2V8.863h-2zm2.78-2.103l-2.256 1.223.953 1.759 2.257-1.224-.953-1.758zm0 0l.954 1.758c1.396-.757 1.396-2.76 0-3.516l-.953 1.758zM8 3.51l5.993 3.249.953-1.758-5.993-3.249L8 3.511zm0 0l.953-1.758a2 2 0 00-1.906 0L8 3.511zM2.007 6.76l5.992-3.25-.953-1.758-5.992 3.249.953 1.758z"/><path fill="#fffff" d="M7.228 7.541c-.187-.112-.277-.427-.201-.704.076-.276.288-.41.475-.297L11 8.644v5.316c0 .298-.163.54-.365.54-.2 0-.364-.242-.364-.54V9.37L7.228 7.54z"/></svg>
\ No newline at end of file
......@@ -61,11 +61,6 @@ export const NOT_FILTER = 'not[';
export const flashAnimationDuration = 2000;
export default {
BoardType,
ListType,
};
export const blockingIssuablesQueries = {
[issuableTypes.issue]: {
query: boardBlockingIssuesQuery,
......@@ -95,3 +90,8 @@ export const subscriptionQueries = {
mutation: updateEpicSubscriptionMutation,
},
};
export default {
BoardType,
ListType,
};
<script>
import { GlIcon, GlEmptyState, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Cookies from 'js-cookie';
import { deprecatedCreateFlash as Flash } from '~/flash';
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import banner from './banner.vue';
import stageCodeComponent from './stage_code_component.vue';
......@@ -39,94 +39,59 @@ export default {
type: String,
required: true,
},
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
},
},
data() {
return {
state: this.store.state,
isLoading: false,
isLoadingStage: false,
isEmptyStage: false,
hasError: true,
startDate: 30,
isOverviewDialogDismissed: Cookies.get(OVERVIEW_DIALOG_COOKIE),
};
},
computed: {
currentStage() {
return this.store.currentActiveStage();
...mapState([
'isLoading',
'isLoadingStage',
'isEmptyStage',
'selectedStage',
'selectedStageEvents',
'stages',
'summary',
'startDate',
]),
displayStageEvents() {
const { selectedStageEvents, isLoadingStage, isEmptyStage } = this;
return selectedStageEvents.length && !isLoadingStage && !isEmptyStage;
},
displayNotEnoughData() {
const { selectedStage, isEmptyStage, isLoadingStage } = this;
return selectedStage && isEmptyStage && !isLoadingStage;
},
displayNoAccess() {
const { selectedStage } = this;
return selectedStage && !selectedStage.isUserAllowed;
},
},
created() {
this.fetchCycleAnalyticsData();
},
methods: {
handleError() {
this.store.setErrorState(true);
return new Flash(__('There was an error while fetching value stream analytics data.'));
},
...mapActions([
'fetchCycleAnalyticsData',
'fetchStageData',
'setSelectedStage',
'setDateRange',
]),
handleDateSelect(startDate) {
this.startDate = startDate;
this.fetchCycleAnalyticsData({ startDate: this.startDate });
this.setDateRange({ startDate });
this.fetchCycleAnalyticsData();
},
fetchCycleAnalyticsData(options) {
const fetchOptions = options || { startDate: this.startDate };
this.isLoading = true;
this.service
.fetchCycleAnalyticsData(fetchOptions)
.then((response) => {
this.store.setCycleAnalyticsData(response);
this.selectDefaultStage();
})
.catch(() => {
this.handleError();
})
.finally(() => {
this.isLoading = false;
});
},
selectDefaultStage() {
const stage = this.state.stages[0];
this.selectStage(stage);
isActiveStage(stage) {
return stage.slug === this.selectedStage.slug;
},
selectStage(stage) {
if (this.isLoadingStage) return;
if (this.currentStage === stage) return;
if (this.selectedStage === stage) return;
this.setSelectedStage(stage);
if (!stage.isUserAllowed) {
this.store.setActiveStage(stage);
return;
}
this.isLoadingStage = true;
this.store.setStageEvents([], stage);
this.store.setActiveStage(stage);
this.service
.fetchStageData({
stage,
startDate: this.startDate,
projectIds: this.selectedProjectIds,
})
.then((response) => {
this.isEmptyStage = !response.events.length;
this.store.setStageEvents(response.events, stage);
})
.catch(() => {
this.isEmptyStage = true;
})
.finally(() => {
this.isLoadingStage = false;
});
this.fetchStageData();
},
dismissOverviewDialog() {
this.isOverviewDialogDismissed = true;
......@@ -146,7 +111,7 @@ export default {
<div class="card">
<div class="card-header">{{ __('Recent Project Activity') }}</div>
<div class="d-flex justify-content-between">
<div v-for="item in state.summary" :key="item.title" class="flex-grow text-center">
<div v-for="item in summary" :key="item.title" class="gl-flex-grow-1 gl-text-center">
<h3 class="header">{{ item.value }}</h3>
<p class="text">{{ item.title }}</p>
</div>
......@@ -207,11 +172,9 @@ export default {
</span>
</li>
<li class="event-header pl-3">
<span
v-if="currentStage && currentStage.legend"
class="stage-name font-weight-bold"
>{{ currentStage ? __(currentStage.legend) : __('Related Issues') }}</span
>
<span v-if="selectedStage" class="stage-name font-weight-bold">{{
selectedStage.legend ? __(selectedStage.legend) : __('Related Issues')
}}</span>
<span
class="has-tooltip"
data-placement="top"
......@@ -242,19 +205,19 @@ export default {
<nav class="stage-nav">
<ul>
<stage-nav-item
v-for="stage in state.stages"
v-for="stage in stages"
:key="stage.title"
:title="stage.title"
:is-user-allowed="stage.isUserAllowed"
:value="stage.value"
:is-active="stage.active"
:is-active="isActiveStage(stage)"
@select="selectStage(stage)"
/>
</ul>
</nav>
<section class="stage-events overflow-auto">
<gl-loading-icon v-show="isLoadingStage" size="lg" />
<template v-if="currentStage && !currentStage.isUserAllowed">
<template v-if="displayNoAccess">
<gl-empty-state
class="js-empty-state"
:title="__('You need permission.')"
......@@ -263,19 +226,19 @@ export default {
/>
</template>
<template v-else>
<template v-if="currentStage && isEmptyStage && !isLoadingStage">
<template v-if="displayNotEnoughData">
<gl-empty-state
class="js-empty-state"
:description="currentStage.emptyStageText"
:description="selectedStage.emptyStageText"
:svg-path="noDataSvgPath"
:title="__('We don\'t have enough data to show this stage.')"
/>
</template>
<template v-if="state.events.length && !isLoadingStage && !isEmptyStage">
<template v-if="displayStageEvents">
<component
:is="currentStage.component"
:stage="currentStage"
:items="state.events"
:is="selectedStage.component"
:stage="selectedStage"
:items="selectedStageEvents"
/>
</template>
</template>
......
import axios from '~/lib/utils/axios_utils';
export default class CycleAnalyticsService {
constructor(options) {
this.axios = axios.create({
baseURL: options.requestPath,
});
}
fetchCycleAnalyticsData(options = { startDate: 30 }) {
const { startDate, projectIds } = options;
return this.axios
.get('', {
params: {
'cycle_analytics[start_date]': startDate,
'cycle_analytics[project_ids]': projectIds,
},
})
.then((x) => x.data);
}
fetchStageData(options) {
const { stage, startDate, projectIds } = options;
return this.axios
.get(`events/${stage.name}.json`, {
params: {
'cycle_analytics[start_date]': startDate,
'cycle_analytics[project_ids]': projectIds,
},
})
.then((x) => x.data);
}
}
/* eslint-disable no-param-reassign */
import { dasherize } from '../lib/utils/text_utility';
import { __ } from '../locale';
import DEFAULT_EVENT_OBJECTS from './default_event_objects';
const EMPTY_STAGE_TEXTS = {
issue: __(
'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
),
plan: __(
'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
),
code: __(
'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
),
test: __(
'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
),
review: __(
'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
),
staging: __(
'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
),
};
export default {
state: {
summary: '',
stats: '',
analytics: '',
events: [],
stages: [],
},
setCycleAnalyticsData(data) {
this.state = Object.assign(this.state, this.decorateData(data));
},
decorateData(data) {
const newData = {};
newData.stages = data.stats || [];
newData.summary = data.summary || [];
newData.summary.forEach((item) => {
item.value = item.value || '-';
});
newData.stages.forEach((item) => {
const stageSlug = dasherize(item.name.toLowerCase());
item.active = false;
item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
item.component = `stage-${stageSlug}-component`;
item.slug = stageSlug;
});
newData.analytics = data;
return newData;
},
setLoadingState(state) {
this.state.isLoading = state;
},
setErrorState(state) {
this.state.hasError = state;
},
deactivateAllStages() {
this.state.stages.forEach((stage) => {
stage.active = false;
});
},
setActiveStage(stage) {
this.deactivateAllStages();
stage.active = true;
},
setStageEvents(events, stage) {
this.state.events = this.decorateEvents(events, stage);
},
decorateEvents(events, stage) {
const newEvents = [];
events.forEach((item) => {
if (!item) return;
const eventItem = { ...DEFAULT_EVENT_OBJECTS[stage.slug], ...item };
eventItem.totalTime = eventItem.total_time;
if (eventItem.author) {
eventItem.author.webUrl = eventItem.author.web_url;
eventItem.author.avatarUrl = eventItem.author.avatar_url;
}
if (eventItem.created_at) eventItem.createdAt = eventItem.created_at;
if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha;
if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url;
delete eventItem.author.web_url;
delete eventItem.author.avatar_url;
delete eventItem.total_time;
delete eventItem.created_at;
delete eventItem.short_sha;
delete eventItem.commit_url;
newEvents.push(eventItem);
});
return newEvents;
},
currentActiveStage() {
return this.state.stages.find((stage) => stage.active);
},
};
import Vue from 'vue';
import Translate from '../vue_shared/translate';
import CycleAnalytics from './components/base.vue';
import CycleAnalyticsService from './cycle_analytics_service';
import CycleAnalyticsStore from './cycle_analytics_store';
import createStore from './store';
Vue.use(Translate);
const createCycleAnalyticsService = (requestPath) =>
new CycleAnalyticsService({
requestPath,
});
export default () => {
const store = createStore();
const el = document.querySelector('#js-cycle-analytics');
const { noAccessSvgPath, noDataSvgPath } = el.dataset;
const { noAccessSvgPath, noDataSvgPath, requestPath } = el.dataset;
store.dispatch('initializeVsa', {
requestPath,
});
// eslint-disable-next-line no-new
new Vue({
el,
name: 'CycleAnalytics',
store,
render: (createElement) =>
createElement(CycleAnalytics, {
props: {
noDataSvgPath,
noAccessSvgPath,
store: CycleAnalyticsStore,
service: createCycleAnalyticsService(el.dataset.requestPath),
},
}),
});
......
<script>
import { GlButton, GlEmptyState, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui';
import {
GlButton,
GlEmptyState,
GlFilteredSearchToken,
GlIcon,
GlLink,
GlSprintf,
GlTooltipDirective,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { toNumber } from 'lodash';
import createFlash from '~/flash';
......@@ -26,7 +34,9 @@ import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase, getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import eventHub from '../eventhub';
import IssueCardTimeInfo from './issue_card_time_info.vue';
......@@ -52,6 +62,9 @@ export default {
GlTooltip: GlTooltipDirective,
},
inject: {
autocompleteAwardEmojisPath: {
default: '',
},
autocompleteUsersPath: {
default: '',
},
......@@ -88,6 +101,9 @@ export default {
projectLabelsPath: {
default: '',
},
projectMilestonesPath: {
default: '',
},
projectPath: {
default: '',
},
......@@ -155,6 +171,15 @@ export default {
defaultAuthors: [],
fetchAuthors: this.fetchUsers,
},
{
type: 'milestone',
title: __('Milestone'),
icon: 'clock',
token: MilestoneToken,
unique: true,
defaultMilestones: [],
fetchMilestones: this.fetchMilestones,
},
{
type: 'labels',
title: __('Label'),
......@@ -163,6 +188,28 @@ export default {
defaultLabels: [],
fetchLabels: this.fetchLabels,
},
{
type: 'my_reaction_emoji',
title: __('My-Reaction'),
icon: 'thumb-up',
token: EmojiToken,
unique: true,
operators: [{ value: '=', description: __('is') }],
defaultEmojis: [],
fetchEmojis: this.fetchEmojis,
},
{
type: 'confidential',
title: __('Confidential'),
icon: 'eye-slash',
token: GlFilteredSearchToken,
unique: true,
operators: [{ value: '=', description: __('is') }],
options: [
{ icon: 'eye-slash', value: 'yes', title: __('Yes') },
{ icon: 'eye', value: 'no', title: __('No') },
],
},
];
},
showPaginationControls() {
......@@ -187,29 +234,40 @@ export default {
};
},
},
created() {
this.cache = {};
},
mounted() {
eventHub.$on('issuables:toggleBulkEdit', (showBulkEditSidebar) => {
this.showBulkEditSidebar = showBulkEditSidebar;
});
eventHub.$on('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
this.fetchIssues();
},
beforeDestroy() {
// eslint-disable-next-line @gitlab/no-global-event-off
eventHub.$off('issuables:toggleBulkEdit');
eventHub.$off('issuables:toggleBulkEdit', this.toggleBulkEditSidebar);
},
methods: {
fetchLabels(search) {
if (this.labelsCache) {
return search
? Promise.resolve(fuzzaldrinPlus.filter(this.labelsCache, search, { key: 'title' }))
: Promise.resolve(this.labelsCache.slice(0, MAX_LIST_SIZE));
fetchWithCache(path, cacheName, searchKey, search, wrapData = false) {
if (this.cache[cacheName]) {
const data = search
? fuzzaldrinPlus.filter(this.cache[cacheName], search, { key: searchKey })
: this.cache[cacheName].slice(0, MAX_LIST_SIZE);
return wrapData ? Promise.resolve({ data }) : Promise.resolve(data);
}
return axios.get(this.projectLabelsPath).then(({ data }) => {
this.labelsCache = data;
return data.slice(0, MAX_LIST_SIZE);
return axios.get(path).then(({ data }) => {
this.cache[cacheName] = data;
const result = data.slice(0, MAX_LIST_SIZE);
return wrapData ? { data: result } : result;
});
},
fetchEmojis(search) {
return this.fetchWithCache(this.autocompleteAwardEmojisPath, 'emojis', 'name', search);
},
fetchLabels(search) {
return this.fetchWithCache(this.projectLabelsPath, 'labels', 'title', search);
},
fetchMilestones(search) {
return this.fetchWithCache(this.projectMilestonesPath, 'milestones', 'title', search, true);
},
fetchUsers(search) {
return axios.get(this.autocompleteUsersPath, { params: { search } });
},
......@@ -310,6 +368,9 @@ export default {
this.sortKey = value;
this.fetchIssues();
},
toggleBulkEditSidebar(showBulkEditSidebar) {
this.showBulkEditSidebar = showBulkEditSidebar;
},
},
};
</script>
......
......@@ -298,6 +298,16 @@ export const filters = {
[OPERATOR_IS_NOT]: 'not[assignee_username][]',
},
},
milestone: {
apiParam: {
[OPERATOR_IS]: 'milestone',
[OPERATOR_IS_NOT]: 'not[milestone]',
},
urlParam: {
[OPERATOR_IS]: 'milestone_title',
[OPERATOR_IS_NOT]: 'not[milestone_title]',
},
},
labels: {
apiParam: {
[OPERATOR_IS]: 'labels',
......@@ -308,4 +318,20 @@ export const filters = {
[OPERATOR_IS_NOT]: 'not[label_name][]',
},
},
my_reaction_emoji: {
apiParam: {
[OPERATOR_IS]: 'my_reaction_emoji',
},
urlParam: {
[OPERATOR_IS]: 'my_reaction_emoji',
},
},
confidential: {
apiParam: {
[OPERATOR_IS]: 'confidential',
},
urlParam: {
[OPERATOR_IS]: 'confidential',
},
},
};
......@@ -73,6 +73,7 @@ export function initIssuesListApp() {
}
const {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
calendarPath,
canBulkUpdate,
......@@ -94,6 +95,7 @@ export function initIssuesListApp() {
newIssuePath,
projectImportJiraPath,
projectLabelsPath,
projectMilestonesPath,
projectPath,
rssPath,
showNewIssueLink,
......@@ -106,6 +108,7 @@ export function initIssuesListApp() {
// issue is fixed upstream in https://github.com/vuejs/vue-apollo/pull/1153
apolloProvider: {},
provide: {
autocompleteAwardEmojisPath,
autocompleteUsersPath,
calendarPath,
canBulkUpdate: parseBoolean(canBulkUpdate),
......@@ -120,6 +123,7 @@ export function initIssuesListApp() {
jiraIntegrationPath,
newIssuePath,
projectLabelsPath,
projectMilestonesPath,
projectPath,
rssPath,
showNewIssueLink: parseBoolean(showNewIssueLink),
......
......@@ -47,6 +47,16 @@ export default {
);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.emojis.length) {
this.fetchEmojiBySearchTerm(this.value.data);
}
},
},
},
methods: {
fetchEmojiBySearchTerm(searchTerm) {
this.loading = true;
......
......@@ -36,7 +36,7 @@ export default {
v-for="packageName in feature.packages"
:key="packageName"
size="sm"
class="whats-new-item-badge gl-mr-2"
class="whats-new-item-badge gl-mr-2 gl-py-1!"
>
<gl-icon name="license" />{{ packageName }}
</gl-badge>
......
......@@ -166,6 +166,7 @@ module IssuesHelper
def issues_list_data(project, current_user, finder)
{
autocomplete_users_path: autocomplete_users_path(active: true, current_user: true, project_id: project.id, format: :json),
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
calendar_path: url_for(safe_params.merge(calendar_url_options)),
can_bulk_update: can?(current_user, :admin_issue, project).to_s,
can_edit: can?(current_user, :admin_project, project).to_s,
......@@ -183,6 +184,7 @@ module IssuesHelper
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.try(:id), milestone_id: finder.milestones.first.try(:id) }),
project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_milestones_path: project_milestones_path(project, format: :json),
project_path: project.full_path,
rss_path: url_for(safe_params.merge(rss_url_options)),
show_new_issue_link: show_new_issue_link?(project).to_s,
......
......@@ -17,7 +17,7 @@
= _("If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database.")
- clear_repository_checks_link = _('Clear all repository checks')
- clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?')
= link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger"
= link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "gl-button btn btn-sm btn-danger gl-mt-3"
.sub-section
%h4= _("Housekeeping")
......
......@@ -8,7 +8,7 @@
.form-group.project-name.col-sm-12
= f.label :name, class: 'label-bold' do
%span= _("Project name")
= f.text_field :name, placeholder: "My awesome project", class: "form-control input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
= f.text_field :name, placeholder: "My awesome project", class: "form-control gl-form-input input-lg", autofocus: true, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_name", track_value: "" }, required: true, aria: { required: true }
.form-group.project-path.col-sm-6
= f.label :namespace_id, class: 'label-bold' do
%span= s_("Project URL")
......@@ -33,7 +33,7 @@
.form-group.project-path.col-sm-6
= f.label :path, class: 'label-bold' do
%span= _("Project slug")
= f.text_field :path, placeholder: "my-awesome-project", class: "form-control", required: true, aria: { required: true }
= f.text_field :path, placeholder: "my-awesome-project", class: "form-control gl-form-input", required: true, aria: { required: true }
- if current_user.can_create_group?
.form-text.text-muted
- link_start_group_path = '<a href="%{path}">' % { path: new_group_path }
......@@ -43,7 +43,7 @@
.form-group
= f.label :description, class: 'label-bold' do
= s_('ProjectsNew|Project description %{tag_start}(optional)%{tag_end}').html_safe % { tag_start: '<span>'.html_safe, tag_end: '</span>'.html_safe }
= f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" }
= f.text_area :description, placeholder: s_('ProjectsNew|Description format'), class: "form-control gl-form-input", rows: 3, maxlength: 250, data: { track_label: "#{track_label}", track_event: "activate_form_input", track_property: "project_description", track_value: "" }
= f.label :visibility_level, class: 'label-bold' do
= s_('ProjectsNew|Visibility Level')
......
---
title: Add gl-form-input class for fields in create project page
merge_request: 58299
author: Yogi (@yo)
type: changed
---
title: Add more padding for whats new badge in drawer
merge_request: 58544
author: Yogi (@yo)
type: changed
---
title: Add margin top for clear repo check button in admin
merge_request: 58536
author: Yogi (@yo)
type: changed
......@@ -55,11 +55,11 @@ export default {
},
isDefaultTestStage() {
const { currentStage } = this;
return !currentStage.custom && currentStage.name.toLowerCase().trim() === 'test';
return !currentStage.custom && currentStage.title?.toLowerCase().trim() === 'test';
},
isDefaultStagingStage() {
const { currentStage } = this;
return !currentStage.custom && currentStage.name.toLowerCase().trim() === 'staging';
return !currentStage.custom && currentStage.title?.toLowerCase().trim() === 'staging';
},
isMergeRequestStage() {
const [firstEvent] = this.stageEvents;
......
......@@ -4,6 +4,7 @@ import { ContentTypeMultipartFormData } from '~/lib/utils/headers';
export default {
...Api,
geoNodePath: '/api/:version/geo_nodes/:id',
geoNodesPath: '/api/:version/geo_nodes',
geoNodesStatusPath: '/api/:version/geo_nodes/status',
geoReplicationPath: '/api/:version/geo_replication/:replicable',
......@@ -346,6 +347,11 @@ export default {
return axios.put(`${url}/${node.id}`, node);
},
removeGeoNode(id) {
const url = Api.buildUrl(this.geoNodePath).replace(':id', encodeURIComponent(id));
return axios.delete(url);
},
getApplicationSettings() {
const url = Api.buildUrl(this.applicationSettingsPath);
return axios.get(url);
......
......@@ -69,6 +69,7 @@ export const updateListQueries = {
};
export default {
updateListQueries,
DRAGGABLE_TAG,
EpicFilterType,
};
<script>
import { GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlLink, GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui';
import { mapActions, mapState } from 'vuex';
import { s__, __ } from '~/locale';
import { GEO_INFO_URL } from '../constants';
import { GEO_INFO_URL, REMOVE_NODE_MODAL_ID } from '../constants';
import GeoNodes from './geo_nodes.vue';
import GeoNodesEmptyState from './geo_nodes_empty_state.vue';
......@@ -15,6 +15,10 @@ export default {
),
learnMore: __('Learn more'),
addSite: s__('Geo|Add site'),
modalTitle: s__('Geo|Remove secondary node'),
modalBody: s__(
'Geo|Removing a Geo secondary node stops the synchronization to that node. Are you sure?',
),
},
components: {
GlLink,
......@@ -22,6 +26,7 @@ export default {
GlLoadingIcon,
GeoNodes,
GeoNodesEmptyState,
GlModal,
},
props: {
newNodeUrl: {
......@@ -43,9 +48,19 @@ export default {
this.fetchNodes();
},
methods: {
...mapActions(['fetchNodes']),
...mapActions(['fetchNodes', 'cancelNodeRemoval', 'removeNode']),
},
GEO_INFO_URL,
MODAL_PRIMARY_ACTION: {
text: s__('Geo|Remove node'),
attributes: {
variant: 'danger',
},
},
MODAL_CANCEL_ACTION: {
text: __('Cancel'),
},
REMOVE_NODE_MODAL_ID,
};
</script>
......@@ -75,5 +90,15 @@ export default {
<geo-nodes v-for="node in nodes" :key="node.id" :node="node" />
<geo-nodes-empty-state v-if="noNodes" :svg-path="geoNodesEmptyStateSvg" />
</div>
<gl-modal
:modal-id="$options.REMOVE_NODE_MODAL_ID"
:title="$options.i18n.modalTitle"
:action-primary="$options.MODAL_PRIMARY_ACTION"
:action-cancel="$options.MODAL_CANCEL_ACTION"
@primary="removeNode"
@cancel="cancelNodeRemoval"
>
{{ $options.i18n.modalBody }}
</gl-modal>
</section>
</template>
<script>
import { mapActions } from 'vuex';
import { REMOVE_NODE_MODAL_ID } from 'ee/geo_nodes_beta/constants';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import GeoNodeActionsDesktop from './geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from './geo_node_actions_mobile.vue';
......@@ -14,12 +17,23 @@ export default {
required: true,
},
},
methods: {
...mapActions(['prepNodeRemoval']),
async warnNodeRemoval() {
await this.prepNodeRemoval(this.node.id);
this.$root.$emit(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
},
},
};
</script>
<template>
<div>
<geo-node-actions-mobile class="gl-lg-display-none" :node="node" />
<geo-node-actions-desktop class="gl-display-none gl-lg-display-flex" :node="node" />
<geo-node-actions-mobile class="gl-lg-display-none" :node="node" @remove="warnNodeRemoval" />
<geo-node-actions-desktop
class="gl-display-none gl-lg-display-flex"
:node="node"
@remove="warnNodeRemoval"
/>
</div>
</template>
......@@ -30,6 +30,7 @@ export default {
category="secondary"
:disabled="node.primary"
data-testid="geo-desktop-remove-action"
@click="$emit('remove')"
>{{ $options.i18n.removeButtonLabel }}</gl-button
>
</div>
......
......@@ -33,7 +33,11 @@ export default {
<gl-icon name="ellipsis_h" />
</template>
<gl-dropdown-item :href="node.webEditUrl">{{ $options.i18n.editButtonLabel }}</gl-dropdown-item>
<gl-dropdown-item :disabled="node.primary" data-testid="geo-mobile-remove-action">
<gl-dropdown-item
:disabled="node.primary"
data-testid="geo-mobile-remove-action"
@click="$emit('remove')"
>
<span :class="dropdownRemoveClass">{{ $options.i18n.removeButtonLabel }}</span>
</gl-dropdown-item>
</gl-dropdown>
......
......@@ -68,3 +68,5 @@ export const STATUS_DELAY_THRESHOLD_MS = 600000;
export const REPOSITORY = 'repository';
export const BLOB = 'blob';
export const REMOVE_NODE_MODAL_ID = 'remove-node-modal';
......@@ -25,3 +25,24 @@ export const fetchNodes = ({ commit }) => {
commit(types.RECEIVE_NODES_ERROR);
});
};
export const prepNodeRemoval = ({ commit }, id) => {
commit(types.STAGE_NODE_REMOVAL, id);
};
export const cancelNodeRemoval = ({ commit }) => {
commit(types.UNSTAGE_NODE_REMOVAL);
};
export const removeNode = ({ commit, state }) => {
commit(types.REQUEST_NODE_REMOVAL);
return Api.removeGeoNode(state.nodeToBeRemoved)
.then(() => {
commit(types.RECEIVE_NODE_REMOVAL_SUCCESS);
})
.catch(() => {
createFlash({ message: s__('Geo|There was an error deleting the Geo Node') });
commit(types.RECEIVE_NODE_REMOVAL_ERROR);
});
};
export const REQUEST_NODES = 'REQUEST_NODES';
export const RECEIVE_NODES_SUCCESS = 'RECEIVE_NODES_SUCCESS';
export const RECEIVE_NODES_ERROR = 'RECEIVE_NODES_ERROR';
export const STAGE_NODE_REMOVAL = 'STAGE_NODE_REMOVAL';
export const UNSTAGE_NODE_REMOVAL = 'UNSTAGE_NODE_REMOVAL';
export const REQUEST_NODE_REMOVAL = 'REQUEST_NODE_REMOVAL';
export const RECEIVE_NODE_REMOVAL_SUCCESS = 'RECEIVE_NODE_REMOVAL_SUCCESS';
export const RECEIVE_NODE_REMOVAL_ERROR = 'RECEIVE_NODE_REMOVAL_ERROR';
......@@ -12,4 +12,25 @@ export default {
state.isLoading = false;
state.nodes = [];
},
[types.STAGE_NODE_REMOVAL](state, id) {
state.nodeToBeRemoved = id;
},
[types.UNSTAGE_NODE_REMOVAL](state) {
state.nodeToBeRemoved = null;
},
[types.REQUEST_NODE_REMOVAL](state) {
state.isLoading = true;
},
[types.RECEIVE_NODE_REMOVAL_SUCCESS](state) {
state.isLoading = false;
const index = state.nodes.findIndex((n) => n.id === state.nodeToBeRemoved);
state.nodes.splice(index, 1);
state.nodeToBeRemoved = null;
},
[types.RECEIVE_NODE_REMOVAL_ERROR](state) {
state.isLoading = false;
state.nodeToBeRemoved = null;
},
};
......@@ -4,5 +4,6 @@ const createState = ({ primaryVersion, primaryRevision, replicableTypes }) => ({
replicableTypes,
nodes: [],
isLoading: false,
nodeToBeRemoved: null,
});
export default createState;
---
title: Fix stage table loading with a deep linked stage
merge_request: 60261
author:
type: fixed
......@@ -70,6 +70,22 @@ describe('StageTable', () => {
});
});
describe('with minimal stage data', () => {
beforeEach(() => {
wrapper = createComponent({ currentStage: { title: 'New stage title' } });
});
it('will render the correct events', () => {
const evs = findStageEvents();
expect(evs).toHaveLength(issueEvents.length);
const titles = evs.wrappers.map((ev) => findStageEventTitle(ev).text());
issueEvents.forEach((ev, index) => {
expect(titles[index]).toBe(ev.title);
});
});
});
describe('default event', () => {
beforeEach(() => {
wrapper = createComponent({
......
......@@ -783,6 +783,22 @@ describe('Api', () => {
});
});
});
describe('removeGeoNode', () => {
it('DELETES with correct ID', () => {
mockNode = {
id: 1,
};
jest.spyOn(Api, 'buildUrl').mockReturnValue(`${expectedUrl}/${mockNode.id}`);
jest.spyOn(axios, 'delete');
mock.onDelete(`${expectedUrl}/${mockNode.id}`).replyOnce(httpStatus.OK, {});
return Api.removeGeoNode(mockNode.id).then(() => {
expect(axios.delete).toHaveBeenCalledWith(`${expectedUrl}/${mockNode.id}`);
});
});
});
});
describe('Application Settings', () => {
......
import { GlLink, GlButton, GlLoadingIcon } from '@gitlab/ui';
import { GlLink, GlButton, GlLoadingIcon, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import GeoNodesBetaApp from 'ee/geo_nodes_beta/components/app.vue';
......@@ -21,6 +21,8 @@ describe('GeoNodesBetaApp', () => {
const actionSpies = {
fetchNodes: jest.fn(),
removeNode: jest.fn(),
cancelNodeRemoval: jest.fn(),
};
const defaultProps = {
......@@ -59,6 +61,7 @@ describe('GeoNodesBetaApp', () => {
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGeoEmptyState = () => wrapper.findComponent(GeoNodesEmptyState);
const findGeoNodes = () => wrapper.findAllComponents(GeoNodes);
const findGlModal = () => wrapper.findComponent(GlModal);
describe('template', () => {
describe('always', () => {
......@@ -74,6 +77,10 @@ describe('GeoNodesBetaApp', () => {
expect(findGeoLearnMoreLink().exists()).toBe(true);
expect(findGeoLearnMoreLink().attributes('href')).toBe(GEO_INFO_URL);
});
it('renders the GlModal', () => {
expect(findGlModal().exists()).toBe(true);
});
});
describe.each`
......@@ -129,4 +136,22 @@ describe('GeoNodesBetaApp', () => {
expect(actionSpies.fetchNodes).toHaveBeenCalledTimes(1);
});
});
describe('Modal Events', () => {
beforeEach(() => {
createComponent();
});
it('calls removeNode when modal primary button clicked', () => {
findGlModal().vm.$emit('primary');
expect(actionSpies.removeNode).toHaveBeenCalled();
});
it('calls cancelNodeRemoval when modal cancel button clicked', () => {
findGlModal().vm.$emit('cancel');
expect(actionSpies.cancelNodeRemoval).toHaveBeenCalled();
});
});
});
......@@ -66,6 +66,12 @@ describe('GeoNodeActionsDesktop', () => {
MOCK_NODES[0].webEditUrl,
);
});
it('emits remove when remove button is clicked', () => {
findGeoDesktopActionsRemoveButton().vm.$emit('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
});
describe.each`
......
......@@ -72,6 +72,12 @@ describe('GeoNodeActionsMobile', () => {
MOCK_NODES[0].webEditUrl,
);
});
it('emits remove when remove button is clicked', () => {
findGeoMobileActionsRemoveDropdownItem().vm.$emit('click');
expect(wrapper.emitted('remove')).toHaveLength(1);
});
});
describe.each`
......
......@@ -3,11 +3,14 @@ import Vuex from 'vuex';
import GeoNodeActions from 'ee/geo_nodes_beta/components/header/geo_node_actions.vue';
import GeoNodeActionsDesktop from 'ee/geo_nodes_beta/components/header/geo_node_actions_desktop.vue';
import GeoNodeActionsMobile from 'ee/geo_nodes_beta/components/header/geo_node_actions_mobile.vue';
import { REMOVE_NODE_MODAL_ID } from 'ee/geo_nodes_beta/constants';
import {
MOCK_NODES,
MOCK_PRIMARY_VERSION,
MOCK_REPLICABLE_TYPES,
} from 'ee_jest/geo_nodes_beta/mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import { BV_SHOW_MODAL } from '~/lib/utils/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
......@@ -15,6 +18,10 @@ localVue.use(Vuex);
describe('GeoNodeActions', () => {
let wrapper;
const actionSpies = {
prepNodeRemoval: jest.fn(),
};
const defaultProps = {
node: MOCK_NODES[0],
};
......@@ -27,6 +34,7 @@ describe('GeoNodeActions', () => {
replicableTypes: MOCK_REPLICABLE_TYPES,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(GeoNodeActions, {
......@@ -64,4 +72,49 @@ describe('GeoNodeActions', () => {
]);
});
});
describe('events', () => {
describe('remove', () => {
beforeEach(() => {
createComponent();
jest.spyOn(wrapper.vm.$root, '$emit');
});
it('preps node for removal and opens model after promise returns on desktop', async () => {
findGeoDesktopActions().vm.$emit('remove');
expect(actionSpies.prepNodeRemoval).toHaveBeenCalledWith(
expect.any(Object),
MOCK_NODES[0].id,
);
expect(wrapper.vm.$root.$emit).not.toHaveBeenCalledWith(
BV_SHOW_MODAL,
REMOVE_NODE_MODAL_ID,
);
await waitForPromises();
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
});
it('preps node for removal and opens model after promise returns on mobile', async () => {
findGeoMobileActions().vm.$emit('remove');
expect(actionSpies.prepNodeRemoval).toHaveBeenCalledWith(
expect.any(Object),
MOCK_NODES[0].id,
);
expect(wrapper.vm.$root.$emit).not.toHaveBeenCalledWith(
BV_SHOW_MODAL,
REMOVE_NODE_MODAL_ID,
);
await waitForPromises();
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith(BV_SHOW_MODAL, REMOVE_NODE_MODAL_ID);
});
});
});
});
......@@ -72,4 +72,67 @@ describe('GeoNodesBeta Store Actions', () => {
});
});
});
describe('removeNode', () => {
describe('on success', () => {
beforeEach(() => {
mock.onDelete(/api\/.*\/geo_nodes/).replyOnce(200, {});
});
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.removeNode,
payload: null,
state,
expectedMutations: [
{ type: types.REQUEST_NODE_REMOVAL },
{ type: types.RECEIVE_NODE_REMOVAL_SUCCESS },
],
});
});
});
describe('on error', () => {
beforeEach(() => {
mock.onDelete(/api\/(.*)\/geo_nodes/).reply(500);
});
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.removeNode,
payload: null,
state,
expectedMutations: [
{ type: types.REQUEST_NODE_REMOVAL },
{ type: types.RECEIVE_NODE_REMOVAL_ERROR },
],
}).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
createFlash.mockClear();
});
});
});
});
describe('prepNodeRemoval', () => {
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.prepNodeRemoval,
payload: 1,
state,
expectedMutations: [{ type: types.STAGE_NODE_REMOVAL, payload: 1 }],
});
});
});
describe('cancelNodeRemoval', () => {
it('should dispatch the correct mutations', () => {
return testAction({
action: actions.cancelNodeRemoval,
payload: null,
state,
expectedMutations: [{ type: types.UNSTAGE_NODE_REMOVAL }],
});
});
});
});
......@@ -47,4 +47,62 @@ describe('GeoNodesBeta Store Mutations', () => {
expect(state.nodes).toEqual([]);
});
});
describe('STAGE_NODE_REMOVAL', () => {
it('sets nodeToBeRemoved to node id', () => {
mutations[types.STAGE_NODE_REMOVAL](state, 1);
expect(state.nodeToBeRemoved).toBe(1);
});
});
describe('UNSTAGE_NODE_REMOVAL', () => {
beforeEach(() => {
state.nodeToBeRemoved = 1;
});
it('sets nodeToBeRemoved to null', () => {
mutations[types.UNSTAGE_NODE_REMOVAL](state);
expect(state.nodeToBeRemoved).toBe(null);
});
});
describe('REQUEST_NODE_REMOVAL', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_NODE_REMOVAL](state);
expect(state.isLoading).toBe(true);
});
});
describe('RECEIVE_NODE_REMOVAL_SUCCESS', () => {
beforeEach(() => {
state.isLoading = true;
state.nodes = [{ id: 1 }, { id: 2 }];
state.nodeToBeRemoved = 1;
});
it('removes node, clears nodeToBeRemoved, and ends loading', () => {
mutations[types.RECEIVE_NODE_REMOVAL_SUCCESS](state);
expect(state.isLoading).toBe(false);
expect(state.nodes).toEqual([{ id: 2 }]);
expect(state.nodeToBeRemoved).toEqual(null);
});
});
describe('RECEIVE_NODE_REMOVAL_ERROR', () => {
beforeEach(() => {
state.isLoading = true;
state.nodeToBeRemoved = 1;
});
it('resets state', () => {
mutations[types.RECEIVE_NODE_REMOVAL_ERROR](state);
expect(state.isLoading).toBe(false);
expect(state.nodeToBeRemoved).toEqual(null);
});
});
});
......@@ -24,9 +24,9 @@ module Sidebars
{ class: 'home' }
end
override :sprite_icon
def sprite_icon
'home'
override :image_path
def image_path
'learn_gitlab/graduation_hat.svg'
end
override :render?
......
......@@ -14619,9 +14619,18 @@ msgstr ""
msgid "Geo|Remove entry"
msgstr ""
msgid "Geo|Remove node"
msgstr ""
msgid "Geo|Remove secondary node"
msgstr ""
msgid "Geo|Remove tracking database entry"
msgstr ""
msgid "Geo|Removing a Geo secondary node stops the synchronization to that node. Are you sure?"
msgstr ""
msgid "Geo|Replicated data is verified with the secondary node(s) using checksums."
msgstr ""
......@@ -14709,6 +14718,9 @@ msgstr ""
msgid "Geo|There are no %{replicable_type} to show"
msgstr ""
msgid "Geo|There was an error deleting the Geo Node"
msgstr ""
msgid "Geo|There was an error fetching the Geo Nodes"
msgstr ""
......
......@@ -3,7 +3,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
import { filteredTokens, locationSearch } from 'jest/issues_list/mock_data';
import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableList from '~/issuable_list/components/issuable_list_root.vue';
......@@ -558,25 +558,11 @@ describe('IssuesListApp component', () => {
});
it('makes an API call to search for issues with the search term', () => {
expect(axiosMock.history.get[1].params).toMatchObject({
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: 'bart',
'not[assignee_username]': 'lisa',
labels: 'cartoon,tv',
'not[labels]': 'live action,drama',
});
expect(axiosMock.history.get[1].params).toMatchObject(apiParams);
});
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject({
author_username: ['homer'],
'not[author_username]': ['marge'],
'assignee_username[]': ['bart'],
'not[assignee_username][]': ['lisa'],
'label_name[]': ['cartoon', 'tv'],
'not[label_name][]': ['live action', 'drama'],
});
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
});
});
......
......@@ -6,10 +6,14 @@ export const locationSearch = [
'not[author_username]=marge',
'assignee_username[]=bart',
'not[assignee_username][]=lisa',
'milestone_title=season+4',
'not[milestone_title]=season+20',
'label_name[]=cartoon',
'label_name[]=tv',
'not[label_name][]=live action',
'not[label_name][]=drama',
'my_reaction_emoji=thumbsup',
'confidential=no',
].join('&');
export const filteredTokens = [
......@@ -17,10 +21,40 @@ export const filteredTokens = [
{ type: 'author_username', value: { data: 'marge', operator: OPERATOR_IS_NOT } },
{ type: 'assignee_username', value: { data: 'bart', operator: OPERATOR_IS } },
{ type: 'assignee_username', value: { data: 'lisa', operator: OPERATOR_IS_NOT } },
{ type: 'milestone', value: { data: 'season 4', operator: OPERATOR_IS } },
{ type: 'milestone', value: { data: 'season 20', operator: OPERATOR_IS_NOT } },
{ type: 'labels', value: { data: 'cartoon', operator: OPERATOR_IS } },
{ type: 'labels', value: { data: 'tv', operator: OPERATOR_IS } },
{ type: 'labels', value: { data: 'live action', operator: OPERATOR_IS_NOT } },
{ type: 'labels', value: { data: 'drama', operator: OPERATOR_IS_NOT } },
{ type: 'my_reaction_emoji', value: { data: 'thumbsup', operator: OPERATOR_IS } },
{ type: 'confidential', value: { data: 'no', operator: OPERATOR_IS } },
{ type: 'filtered-search-term', value: { data: 'find' } },
{ type: 'filtered-search-term', value: { data: 'issues' } },
];
export const apiParams = {
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: 'bart',
'not[assignee_username]': 'lisa',
milestone: 'season 4',
'not[milestone]': 'season 20',
labels: 'cartoon,tv',
'not[labels]': 'live action,drama',
my_reaction_emoji: 'thumbsup',
confidential: 'no',
};
export const urlParams = {
author_username: ['homer'],
'not[author_username]': ['marge'],
'assignee_username[]': ['bart'],
'not[assignee_username][]': ['lisa'],
milestone_title: ['season 4'],
'not[milestone_title]': ['season 20'],
'label_name[]': ['cartoon', 'tv'],
'not[label_name][]': ['live action', 'drama'],
my_reaction_emoji: ['thumbsup'],
confidential: ['no'],
};
import { filteredTokens, locationSearch } from 'jest/issues_list/mock_data';
import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
import { sortParams } from '~/issues_list/constants';
import {
convertToApiParams,
......@@ -23,27 +23,13 @@ describe('getFilterTokens', () => {
describe('convertToApiParams', () => {
it('returns api params given filtered tokens', () => {
expect(convertToApiParams(filteredTokens)).toEqual({
author_username: 'homer',
'not[author_username]': 'marge',
assignee_username: 'bart',
'not[assignee_username]': 'lisa',
labels: 'cartoon,tv',
'not[labels]': 'live action,drama',
});
expect(convertToApiParams(filteredTokens)).toEqual(apiParams);
});
});
describe('convertToUrlParams', () => {
it('returns url params given filtered tokens', () => {
expect(convertToUrlParams(filteredTokens)).toEqual({
author_username: ['homer'],
'not[author_username]': ['marge'],
'assignee_username[]': ['bart'],
'not[assignee_username][]': ['lisa'],
'label_name[]': ['cartoon', 'tv'],
'not[label_name][]': ['live action', 'drama'],
});
expect(convertToUrlParams(filteredTokens)).toEqual(urlParams);
});
});
......
......@@ -293,6 +293,7 @@ RSpec.describe IssuesHelper do
allow(helper).to receive(:url_for).and_return('#')
expected = {
autocomplete_award_emojis_path: autocomplete_award_emojis_path,
autocomplete_users_path: autocomplete_users_path(active: true, current_user: true, project_id: project.id, format: :json),
calendar_path: '#',
can_bulk_update: 'true',
......@@ -311,6 +312,7 @@ RSpec.describe IssuesHelper do
new_issue_path: new_project_issue_path(project, issue: { assignee_id: finder.assignee.id, milestone_id: finder.milestones.first.id }),
project_import_jira_path: project_import_jira_path(project),
project_labels_path: project_labels_path(project, include_ancestor_groups: true, format: :json),
project_milestones_path: project_milestones_path(project, format: :json),
project_path: project.full_path,
rss_path: '#',
show_new_issue_link: 'true',
......
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