Commit 2b043b03 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 23be6b41 3399b4c5
......@@ -28,6 +28,7 @@ export default {
modal-type="delete"
:username="username"
:paths="paths"
:delete-path="paths.delete"
:oncall-schedules="oncallSchedules"
>
<slot></slot>
......
......@@ -28,6 +28,7 @@ export default {
modal-type="delete-with-contributions"
:username="username"
:paths="paths"
:delete-path="paths.deleteWithContributions"
:oncall-schedules="oncallSchedules"
>
<slot></slot>
......
......@@ -14,6 +14,10 @@ export default {
type: Object,
required: true,
},
deletePath: {
type: String,
required: true,
},
modalType: {
type: String,
required: true,
......@@ -27,7 +31,7 @@ export default {
modalAttributes() {
return {
'data-block-user-url': this.paths.block,
'data-delete-user-url': this.paths.delete,
'data-delete-user-url': this.deletePath,
'data-gl-modal-action': this.modalType,
'data-username': this.username,
'data-oncall-schedules': JSON.stringify(this.oncallSchedules),
......
......@@ -36,10 +36,13 @@ import {
filterVariables,
} from '../boards_util';
import boardLabelsQuery from '../graphql/board_labels.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import groupProjectsQuery from '../graphql/group_projects.query.graphql';
import issueCreateMutation from '../graphql/issue_create.mutation.graphql';
import issueSetLabelsMutation from '../graphql/issue_set_labels.mutation.graphql';
import listsIssuesQuery from '../graphql/lists_issues.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import * as types from './mutation_types';
export const gqlClient = createGqClient(
......@@ -216,6 +219,52 @@ export default {
});
},
fetchMilestones({ state, commit }, searchTerm) {
commit(types.RECEIVE_MILESTONES_REQUEST);
const { fullPath, boardType } = state;
const variables = {
fullPath,
searchTerm,
};
let query;
if (boardType === BoardType.project) {
query = projectBoardMilestonesQuery;
}
if (boardType === BoardType.group) {
query = groupBoardMilestonesQuery;
}
if (!query) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unknown board type');
}
return gqlClient
.query({
query,
variables,
})
.then(({ data }) => {
const errors = data[boardType]?.errors;
const milestones = data[boardType]?.milestones.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
}
commit(types.RECEIVE_MILESTONES_SUCCESS, milestones);
return milestones;
})
.catch((e) => {
commit(types.RECEIVE_MILESTONES_FAILURE);
throw e;
});
},
moveList: (
{ state: { boardLists }, commit, dispatch },
{
......
......@@ -18,6 +18,9 @@ export const RESET_ITEMS_FOR_LIST = 'RESET_ITEMS_FOR_LIST';
export const REQUEST_ITEMS_FOR_LIST = 'REQUEST_ITEMS_FOR_LIST';
export const RECEIVE_ITEMS_FOR_LIST_FAILURE = 'RECEIVE_ITEMS_FOR_LIST_FAILURE';
export const RECEIVE_ITEMS_FOR_LIST_SUCCESS = 'RECEIVE_ITEMS_FOR_LIST_SUCCESS';
export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const UPDATE_BOARD_ITEM = 'UPDATE_BOARD_ITEM';
export const REMOVE_BOARD_ITEM = 'REMOVE_BOARD_ITEM';
export const MUTATE_ISSUE_SUCCESS = 'MUTATE_ISSUE_SUCCESS';
......
import { cloneDeep, pull, union } from 'lodash';
import Vue from 'vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
import { s__, __ } from '~/locale';
import { formatIssue } from '../boards_util';
import { issuableTypes } from '../constants';
import * as mutationTypes from './mutation_types';
......@@ -133,6 +133,20 @@ export default {
Vue.set(state.listsFlags, listId, { [fetchNext ? 'isLoadingMore' : 'isLoading']: true });
},
[mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) {
state.milestones = milestones;
state.milestonesLoading = false;
},
[mutationTypes.RECEIVE_MILESTONES_REQUEST](state) {
state.milestonesLoading = true;
},
[mutationTypes.RECEIVE_MILESTONES_FAILURE](state) {
state.milestonesLoading = false;
state.error = __('Failed to load milestones.');
},
[mutationTypes.RECEIVE_ITEMS_FOR_LIST_SUCCESS]: (state, { listItems, listPageInfo, listId }) => {
const { listData, boardItems } = listItems;
Vue.set(state, 'boardItems', { ...state.boardItems, ...boardItems });
......
......@@ -19,6 +19,8 @@ export default () => ({
boardConfig: {},
labelsLoading: false,
labels: [],
milestones: [],
milestonesLoading: false,
highlightedLists: [],
selectedBoardItems: [],
groupProjects: [],
......
......@@ -75,6 +75,7 @@ export default {
:key="note.id"
:img-src="note.author.avatar_url"
:tooltip-text="getTooltipText(note)"
lazy
class="diff-comment-avatar js-diff-comment-avatar"
@click.native="$emit('toggleLineDiscussions')"
/>
......
......@@ -392,6 +392,7 @@ export default {
:img-src="author.avatar_url"
:img-alt="author.name"
:img-size="40"
lazy
>
<template #avatar-badge>
<slot name="avatar-badge"></slot>
......
......@@ -30,6 +30,11 @@ export default {
GlTooltip: GlTooltipDirective,
},
props: {
lazy: {
type: Boolean,
required: false,
default: false,
},
linkHref: {
type: String,
required: false,
......@@ -91,6 +96,7 @@ export default {
:size="imgSize"
:tooltip-text="avatarTooltipText"
:tooltip-placement="tooltipPlacement"
:lazy="lazy"
>
<slot></slot> </user-avatar-image
><span
......
......@@ -48,9 +48,9 @@ module Admin
end
def delete_actions
return unless can?(current_user, :destroy_user, @user) && !@user.blocked_pending_approval? && @user.can_be_removed?
return unless can?(current_user, :destroy_user, @user) && !@user.blocked_pending_approval?
@actions << 'delete'
@actions << 'delete' if @user.can_be_removed?
@actions << 'delete_with_contributions'
end
......
......@@ -184,7 +184,7 @@ module UsersHelper
activate: activate_admin_user_path(:id),
unlock: unlock_admin_user_path(:id),
delete: admin_user_path(:id),
delete_with_contributions: admin_user_path(:id),
delete_with_contributions: admin_user_path(:id, hard_delete: true),
admin_user: admin_user_path(:id),
ban: ban_admin_user_path(:id),
unban: unban_admin_user_path(:id)
......
......@@ -119,7 +119,7 @@ script on the GitLab task runner pod. For more details, see
[backing up a GitLab installation](https://gitlab.com/gitlab-org/charts/gitlab/blob/master/doc/backup-restore/backup.md#backing-up-a-gitlab-installation).
```shell
kubectl exec -it <gitlab task-runner pod> backup-utility
kubectl exec -it <gitlab task-runner pod> -- backup-utility
```
Similar to the Kubernetes case, if you have scaled out your GitLab cluster to
......
......@@ -103,7 +103,7 @@ being modified after the database dump is created.
1. Connect to the pod with:
```shell
kubectl exec -it production-postgres-5db86568d7-qxlxv --namespace "$APP_NAMESPACE" bash
kubectl exec -it production-postgres-5db86568d7-qxlxv --namespace "$APP_NAMESPACE" -- bash
```
1. Once, connected, create a dump file with the following command.
......@@ -221,7 +221,7 @@ higher*. This is the
1. Connect to the pod:
```shell
kubectl exec -it production-postgresql-0 --namespace "$APP_NAMESPACE" bash
kubectl exec -it production-postgresql-0 --namespace "$APP_NAMESPACE" -- bash
```
1. Once connected to the pod, run the following command to restore the database.
......
......@@ -34,11 +34,9 @@ import epicCreateMutation from '../graphql/epic_create.mutation.graphql';
import epicMoveListMutation from '../graphql/epic_move_list.mutation.graphql';
import epicsSwimlanesQuery from '../graphql/epics_swimlanes.query.graphql';
import groupBoardIterationsQuery from '../graphql/group_board_iterations.query.graphql';
import groupBoardMilestonesQuery from '../graphql/group_board_milestones.query.graphql';
import listUpdateLimitMetricsMutation from '../graphql/list_update_limit_metrics.mutation.graphql';
import listsEpicsQuery from '../graphql/lists_epics.query.graphql';
import projectBoardIterationsQuery from '../graphql/project_board_iterations.query.graphql';
import projectBoardMilestonesQuery from '../graphql/project_board_milestones.query.graphql';
import updateBoardEpicUserPreferencesMutation from '../graphql/update_board_epic_user_preferences.mutation.graphql';
import updateEpicLabelsMutation from '../graphql/update_epic_labels.mutation.graphql';
......@@ -424,50 +422,6 @@ export default {
);
},
fetchMilestones({ state, commit }, searchTerm) {
commit(types.RECEIVE_MILESTONES_REQUEST);
const { fullPath, boardType } = state;
const variables = {
fullPath,
searchTerm,
};
let query;
if (boardType === BoardType.project) {
query = projectBoardMilestonesQuery;
}
if (boardType === BoardType.group) {
query = groupBoardMilestonesQuery;
}
if (!query) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Unknown board type');
}
return gqlClient
.query({
query,
variables,
})
.then(({ data }) => {
const errors = data[boardType]?.errors;
const milestones = data[boardType]?.milestones.nodes;
if (errors?.[0]) {
throw new Error(errors[0]);
}
commit(types.RECEIVE_MILESTONES_SUCCESS, milestones);
})
.catch((e) => {
commit(types.RECEIVE_MILESTONES_FAILURE);
throw e;
});
},
fetchIterations({ state, commit }, title) {
commit(types.RECEIVE_ITERATIONS_REQUEST);
......
......@@ -15,9 +15,6 @@ export const SET_SHOW_LABELS = 'SET_SHOW_LABELS';
export const MOVE_EPIC = 'MOVE_EPIC';
export const MOVE_EPIC_FAILURE = 'MOVE_EPIC_FAILURE';
export const SET_BOARD_EPIC_USER_PREFERENCES = 'SET_BOARD_EPIC_USER_PREFERENCES';
export const RECEIVE_MILESTONES_REQUEST = 'RECEIVE_MILESTONES_REQUEST';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_FAILURE = 'RECEIVE_MILESTONES_FAILURE';
export const RECEIVE_ITERATIONS_REQUEST = 'RECEIVE_ITERATIONS_REQUEST';
export const RECEIVE_ITERATIONS_SUCCESS = 'RECEIVE_ITERATIONS_SUCCESS';
export const RECEIVE_ITERATIONS_FAILURE = 'RECEIVE_ITERATIONS_FAILURE';
......
......@@ -171,20 +171,6 @@ export default {
}
},
[mutationTypes.RECEIVE_MILESTONES_REQUEST](state) {
state.milestonesLoading = true;
},
[mutationTypes.RECEIVE_MILESTONES_SUCCESS](state, milestones) {
state.milestones = milestones;
state.milestonesLoading = false;
},
[mutationTypes.RECEIVE_MILESTONES_FAILURE](state) {
state.milestonesLoading = false;
state.error = __('Failed to load milestones.');
},
[mutationTypes.RECEIVE_ITERATIONS_REQUEST](state) {
state.iterationsLoading = true;
},
......
......@@ -15,8 +15,6 @@ export default () => ({
epicsEndCursor: null,
epics: [],
epicsFlags: {},
milestones: [],
milestonesLoading: false,
iterations: [],
iterationsLoading: false,
assignees: [],
......
......@@ -1120,87 +1120,6 @@ describe('addListNewEpic', () => {
});
});
describe('fetchMilestones', () => {
const queryResponse = {
data: {
project: {
milestones: {
nodes: mockMilestones,
},
},
},
};
const queryErrors = {
data: {
project: {
errors: ['You cannot view these milestones'],
milestones: {},
},
},
};
function createStore({
state = {
boardType: 'project',
fullPath: 'gitlab-org/gitlab',
milestones: [],
milestonesLoading: false,
},
} = {}) {
return new Vuex.Store({
state,
mutations,
});
}
it('throws error if state.boardType is not group or project', () => {
const store = createStore({
state: {
boardType: 'invalid',
},
});
expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type'));
});
it('sets milestonesLoading to true', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
actions.fetchMilestones(store);
expect(store.state.milestonesLoading).toBe(true);
});
describe('success', () => {
it('sets state.milestones from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchMilestones(store);
expect(store.state.milestonesLoading).toBe(false);
expect(store.state.milestones).toBe(mockMilestones);
});
});
describe('failure', () => {
it('sets state.milestones from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
const store = createStore();
await expect(actions.fetchMilestones(store)).rejects.toThrow();
expect(store.state.milestonesLoading).toBe(false);
expect(store.state.error).toBe('Failed to load milestones.');
});
});
});
describe('fetchIterations', () => {
const queryResponse = {
data: {
......
......@@ -90,6 +90,39 @@ RSpec.describe 'Admin::Users::User' do
end
end
context 'when user is the sole owner of a group' do
let_it_be(:group) { create(:group) }
let_it_be(:user_sole_owner_of_group) { create(:user) }
before do
group.add_owner(user_sole_owner_of_group)
end
it 'shows `Delete user and contributions` action but not `Delete user` action', :js do
visit admin_user_path(user_sole_owner_of_group)
click_user_dropdown_toggle(user_sole_owner_of_group.id)
expect(page).to have_button('Delete user and contributions')
expect(page).not_to have_button('Delete user', exact: true)
end
it 'allows user to be deleted by using the `Delete user and contributions` action', :js do
visit admin_user_path(user_sole_owner_of_group)
click_action_in_user_dropdown(user_sole_owner_of_group.id, 'Delete user and contributions')
page.within('[role="dialog"]') do
fill_in('username', with: user_sole_owner_of_group.name)
click_button('Delete user and contributions')
end
wait_for_requests
expect(page).to have_content('The user is being deleted.')
end
end
describe 'Impersonation' do
let_it_be(:another_user) { create(:user) }
......
......@@ -5,8 +5,8 @@ import { nextTick } from 'vue';
import Actions from '~/admin/users/components/actions';
import SharedDeleteAction from '~/admin/users/components/actions/shared/shared_delete_action.vue';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import { CONFIRMATION_ACTIONS, DELETE_ACTIONS } from '../../constants';
import { paths } from '../../mock_data';
describe('Action components', () => {
let wrapper;
......@@ -47,32 +47,33 @@ describe('Action components', () => {
describe('DELETE_ACTION_COMPONENTS', () => {
const oncallSchedules = [{ name: 'schedule1' }, { name: 'schedule2' }];
it.each(DELETE_ACTIONS)('renders a dropdown item for "%s"', async (action) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
username: 'John Doe',
paths: {
delete: '/delete',
block: '/block',
it.each(DELETE_ACTIONS.map((action) => [action, paths[action]]))(
'renders a dropdown item for "%s"',
async (action, expectedPath) => {
initComponent({
component: Actions[capitalizeFirstCharacter(action)],
props: {
username: 'John Doe',
paths,
oncallSchedules,
},
oncallSchedules,
},
stubs: { SharedDeleteAction },
});
stubs: { SharedDeleteAction },
});
await nextTick();
await nextTick();
const sharedAction = wrapper.find(SharedDeleteAction);
const sharedAction = wrapper.find(SharedDeleteAction);
expect(sharedAction.attributes('data-block-user-url')).toBe('/block');
expect(sharedAction.attributes('data-delete-user-url')).toBe('/delete');
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
expect(sharedAction.attributes('data-username')).toBe('John Doe');
expect(sharedAction.attributes('data-oncall-schedules')).toBe(
JSON.stringify(oncallSchedules),
);
expect(findDropdownItem().exists()).toBe(true);
});
expect(sharedAction.attributes('data-block-user-url')).toBe(paths.block);
expect(sharedAction.attributes('data-delete-user-url')).toBe(expectedPath);
expect(sharedAction.attributes('data-gl-modal-action')).toBe(kebabCase(action));
expect(sharedAction.attributes('data-username')).toBe('John Doe');
expect(sharedAction.attributes('data-oncall-schedules')).toBe(
JSON.stringify(oncallSchedules),
);
expect(findDropdownItem().exists()).toBe(true);
},
);
});
});
......@@ -30,7 +30,7 @@ export const paths = {
activate: '/admin/users/id/activate',
unlock: '/admin/users/id/unlock',
delete: '/admin/users/id',
deleteWithContributions: '/admin/users/id',
deleteWithContributions: '/admin/users/id?hard_delete=true',
adminUser: '/admin/users/id',
ban: '/admin/users/id/ban',
unban: '/admin/users/id/unban',
......
......@@ -101,6 +101,17 @@ export const mockMilestone = {
due_date: '2019-12-31',
};
export const mockMilestones = [
{
id: 'gid://gitlab/Milestone/1',
title: 'Milestone 1',
},
{
id: 'gid://gitlab/Milestone/2',
title: 'Milestone 2',
},
];
export const assignees = [
{
id: 'gid://gitlab/User/2',
......
import * as Sentry from '@sentry/browser';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import Vuex from 'vuex';
import {
inactiveId,
ISSUABLE,
......@@ -22,6 +24,7 @@ import destroyBoardListMutation from '~/boards/graphql/board_list_destroy.mutati
import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import mutations from '~/boards/stores/mutations';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
......@@ -38,6 +41,7 @@ import {
mockMoveState,
mockMoveData,
mockList,
mockMilestones,
} from '../mock_data';
jest.mock('~/flash');
......@@ -46,6 +50,8 @@ jest.mock('~/flash');
// subgroups when the movIssue action is called.
const getProjectPath = (path) => path.split('#')[0];
Vue.use(Vuex);
beforeEach(() => {
window.gon = { features: {} };
});
......@@ -261,6 +267,87 @@ describe('fetchLists', () => {
);
});
describe('fetchMilestones', () => {
const queryResponse = {
data: {
project: {
milestones: {
nodes: mockMilestones,
},
},
},
};
const queryErrors = {
data: {
project: {
errors: ['You cannot view these milestones'],
milestones: {},
},
},
};
function createStore({
state = {
boardType: 'project',
fullPath: 'gitlab-org/gitlab',
milestones: [],
milestonesLoading: false,
},
} = {}) {
return new Vuex.Store({
state,
mutations,
});
}
it('throws error if state.boardType is not group or project', () => {
const store = createStore({
state: {
boardType: 'invalid',
},
});
expect(() => actions.fetchMilestones(store)).toThrow(new Error('Unknown board type'));
});
it('sets milestonesLoading to true', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
actions.fetchMilestones(store);
expect(store.state.milestonesLoading).toBe(true);
});
describe('success', () => {
it('sets state.milestones from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryResponse);
const store = createStore();
await actions.fetchMilestones(store);
expect(store.state.milestonesLoading).toBe(false);
expect(store.state.milestones).toBe(mockMilestones);
});
});
describe('failure', () => {
it('sets state.milestones from query result', async () => {
jest.spyOn(gqlClient, 'query').mockResolvedValue(queryErrors);
const store = createStore();
await expect(actions.fetchMilestones(store)).rejects.toThrow();
expect(store.state.milestonesLoading).toBe(false);
expect(store.state.error).toBe('Failed to load milestones.');
});
});
});
describe('createList', () => {
it('should dispatch createIssueList action', () => {
testAction({
......
......@@ -104,4 +104,15 @@ describe('User Avatar Link Component', () => {
);
});
});
describe('lazy', () => {
it('passes lazy prop to avatar image', () => {
createWrapper({
username: '',
lazy: true,
});
expect(wrapper.find(UserAvatarImage).props('lazy')).toBe(true);
});
});
});
......@@ -106,7 +106,7 @@ RSpec.describe Admin::UserActionsHelper do
group.add_owner(user)
end
it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate") }
it { is_expected.to contain_exactly("edit", "block", "ban", "deactivate", "delete_with_contributions") }
end
context 'the user is a bot' do
......
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