Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-01-27 15:09:15 +00:00
parent 507c0e71cd
commit 0cbb4a7569
80 changed files with 891 additions and 377 deletions

View file

@ -48,3 +48,4 @@ overrides:
- '**/spec/**/*'
rules:
"@gitlab/require-i18n-strings": off
"@gitlab/no-runtime-template-compiler": off

View file

@ -1,8 +1,9 @@
import Vue from 'vue';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import UsagePingDisabled from './components/usage_ping_disabled.vue';
import AdminUsersApp from './components/app.vue';
export default function (el = document.querySelector('#js-admin-users-app')) {
export const initAdminUsersApp = (el = document.querySelector('#js-admin-users-app')) => {
if (!el) {
return false;
}
@ -19,4 +20,24 @@ export default function (el = document.querySelector('#js-admin-users-app')) {
},
}),
});
}
};
export const initCohortsEmptyState = (el = document.querySelector('#js-cohorts-empty-state')) => {
if (!el) {
return false;
}
const { emptyStateSvgPath, enableUsagePingLink, docsLink } = el.dataset;
return new Vue({
el,
provide: {
svgPath: emptyStateSvgPath,
primaryButtonPath: enableUsagePingLink,
docsLink,
},
render(h) {
return h(UsagePingDisabled);
},
});
};

View file

@ -0,0 +1,23 @@
import { historyPushState } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
const COHORTS_PANE = 'cohorts';
const tabClickHandler = (e) => {
const { hash } = e.currentTarget;
const tab = hash === `#${COHORTS_PANE}` ? COHORTS_PANE : null;
const newUrl = mergeUrlParams({ tab }, window.location.href);
historyPushState(newUrl);
};
const initTabs = () => {
const tabLinks = document.querySelectorAll('.js-users-tab-item a');
if (tabLinks.length) {
tabLinks.forEach((tabLink) => {
tabLink.addEventListener('click', (e) => tabClickHandler(e));
});
}
};
export default initTabs;

View file

@ -1,4 +1,7 @@
/* eslint-disable no-new */
// This is a true violation of @gitlab/no-runtime-template-compiler, as it
// relies on app/views/shared/boards/components/_sidebar.html.haml for its
// template.
/* eslint-disable no-new, @gitlab/no-runtime-template-compiler */
import $ from 'jquery';
import Vue from 'vue';

View file

@ -0,0 +1,52 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { hide } from '~/tooltips';
export default {
components: {
GlIcon,
},
props: {
issueBoardsContentSelector: {
type: String,
required: true,
},
},
data() {
return {
isFullscreen: false,
};
},
methods: {
toggleFocusMode() {
hide(this.$refs.toggleFocusModeButton);
const issueBoardsContent = document.querySelector(this.issueBoardsContentSelector);
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
},
i18n: {
toggleFocusMode: __('Toggle focus mode'),
},
};
</script>
<template>
<div class="board-extra-actions">
<a
ref="toggleFocusModeButton"
href="#"
class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn"
data-qa-selector="focus_mode_button"
role="button"
:aria-label="$options.i18n.toggleFocusMode"
:title="$options.i18n.toggleFocusMode"
@click="toggleFocusMode"
>
<gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" />
</a>
</div>
</template>

View file

@ -1,45 +1,17 @@
import $ from 'jquery';
import Vue from 'vue';
import { GlIcon } from '@gitlab/ui';
import { hide } from '~/tooltips';
import ToggleFocus from './components/toggle_focus.vue';
export default (ModalStore, boardsStore) => {
const issueBoardsContent = document.querySelector('.content-wrapper > .js-focus-mode-board');
export default () => {
const issueBoardsContentSelector = '.content-wrapper > .js-focus-mode-board';
return new Vue({
el: document.getElementById('js-toggle-focus-btn'),
components: {
GlIcon,
el: '#js-toggle-focus-btn',
render(h) {
return h(ToggleFocus, {
props: {
issueBoardsContentSelector,
},
});
},
data: {
modal: ModalStore.store,
store: boardsStore.state,
isFullscreen: false,
},
methods: {
toggleFocusMode() {
const $el = $(this.$refs.toggleFocusModeButton);
hide($el);
issueBoardsContent.classList.toggle('is-focused');
this.isFullscreen = !this.isFullscreen;
},
},
template: `
<div class="board-extra-actions">
<a
href="#"
class="btn btn-default has-tooltip gl-ml-3 js-focus-mode-btn"
data-qa-selector="focus_mode_button"
role="button"
aria-label="Toggle focus mode"
title="Toggle focus mode"
ref="toggleFocusModeButton"
@click="toggleFocusMode">
<gl-icon :name="isFullscreen ? 'minimize' : 'maximize'" />
</a>
</div>
`,
});
};

View file

@ -1,3 +1,7 @@
// This is a true violation of @gitlab/no-runtime-template-compiler, as it
// relies on app/views/projects/cycle_analytics/show.html.haml for its
// template.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import $ from 'jquery';
import Vue from 'vue';
import Cookies from 'js-cookie';

View file

@ -28,19 +28,18 @@ class RecentSearchesRoot {
const { state } = this.store;
this.vm = new Vue({
el: this.wrapperElement,
components: {
RecentSearchesDropdownContent,
},
data() {
return state;
},
template: `
<recent-searches-dropdown-content
:items="recentSearches"
:is-local-storage-available="isLocalStorageAvailable"
:allowed-keys="allowedKeys"
/>
`,
render(h) {
return h(RecentSearchesDropdownContent, {
props: {
items: this.recentSearches,
isLocalStorageAvailable: this.isLocalStorageAvailable,
allowedKeys: this.allowedKeys,
},
});
},
});
}

View file

@ -1,4 +1,7 @@
/* eslint-disable no-param-reassign */
// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on
// app/views/projects/merge_requests/conflicts/components/_diff_file_editor.html.haml
// for its template.
/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import { debounce } from 'lodash';

View file

@ -1,4 +1,7 @@
/* eslint-disable no-param-reassign */
// This is a true violation of @gitlab/no-runtime-template-compiler, as it relies on
// app/views/projects/merge_requests/conflicts/components/_inline_conflict_lines.html.haml
// for its template.
/* eslint-disable no-param-reassign, @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import actionsMixin from '../mixins/line_conflict_actions';

View file

@ -15,6 +15,9 @@ import utilsMixin from '../mixins/line_conflict_utils';
required: true,
},
},
// This is a true violation of @gitlab/no-runtime-template-compiler, as it
// has a template string.
// eslint-disable-next-line @gitlab/no-runtime-template-compiler
template: `
<table class="diff-wrap-lines code js-syntax-highlight">
<tr class="line_holder parallel" v-for="section in file.parallelLines">

View file

@ -1,3 +1,7 @@
// This is a true violation of @gitlab/no-runtime-template-compiler, as it
// relies on app/views/projects/merge_requests/conflicts/show.html.haml for its
// template.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import $ from 'jquery';
import Vue from 'vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';

View file

@ -26,7 +26,24 @@ export default (props = {}) => {
dashboardProps: { ...dataProps, ...props },
};
},
template: `<router-view :dashboardProps="dashboardProps"/>`,
render(h) {
return h('RouterView', {
// This is attrs rather than props because:
// 1. RouterView only actually defines one prop: `name`.
// 2. The RouterView [throws away other props][1] given to it, in
// favour of those configured in the route config/params.
// 3. The Vue template compiler itself in general compiles anything
// like <some-component :foo="bar" /> into roughly
// h('some-component', { attrs: { foo: bar } }). Then later, Vue
// [extract props from attrs and merges them with props][2],
// matching them up according to the component's definition.
// [1]: https://github.com/vuejs/vue-router/blob/v3.4.9/src/components/view.js#L124
// [2]: https://github.com/vuejs/vue/blob/v2.6.12/src/core/vdom/helpers/extract-props.js#L12-L50
attrs: {
dashboardProps: this.dashboardProps,
},
});
},
});
}
};

View file

@ -35,16 +35,16 @@ const showPopover = (el, path, footer, options) => {
boundary: 'window',
html: true,
placement: 'top',
template: `<div class="popover blue learn-gitlab d-none d-xl-block" role="tooltip">
template: `<div class="gl-popover popover blue learn-gitlab d-none d-xl-block" role="tooltip">
<div class="arrow"></div>
<div class="close cursor-pointer gl-font-base text-white gl-opacity-10 p-2">&#10005</div>
<div class="popover-body gl-font-base gl-line-height-20 pb-0 px-3"></div>
<div class="bold text-right text-white p-2">${footer}</div>
<div class="js-close-learn-gitlab gl-font-weight-bold gl-line-height-normal float-right gl-cursor-pointer gl-font-base gl-text-white gl-opacity-10 gl-p-3">&#10005</div>
<div class="popover-body gl-font-base"></div>
<div class="gl-font-weight-bold gl-text-right gl-text-white gl-p-3 gl-pt-0">${footer}</div>
</div>`,
};
// When one of the popovers is dismissed, remove the cookie.
const closeButton = () => document.querySelector('.learn-gitlab.popover .close');
const closeButton = () => document.querySelector('.js-close-learn-gitlab');
// We still have to use jQuery, since Bootstrap's Popover is based on jQuery.
const jQueryEl = $(el);

View file

@ -1,3 +1,7 @@
// This is a true violation of @gitlab/no-runtime-template-compiler, as it
// relies on app/views/admin/application_settings/_gitpod.html.haml for its
// template.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import initUserInternalRegexPlaceholder from '../account_and_limits';
import IntegrationHelpText from '~/vue_shared/components/integrations_help_text.vue';

View file

@ -1,22 +0,0 @@
import Vue from 'vue';
import UsagePingDisabled from '~/admin/cohorts/components/usage_ping_disabled.vue';
document.addEventListener('DOMContentLoaded', () => {
const emptyStateContainer = document.getElementById('js-cohorts-empty-state');
if (!emptyStateContainer) return false;
const { emptyStateSvgPath, enableUsagePingLink, docsLink } = emptyStateContainer.dataset;
return new Vue({
el: emptyStateContainer,
provide: {
svgPath: emptyStateSvgPath,
primaryButtonPath: enableUsagePingLink,
docsLink,
},
render(h) {
return h(UsagePingDisabled);
},
});
});

View file

@ -4,7 +4,8 @@ import Translate from '~/vue_shared/translate';
import ModalManager from './components/user_modal_manager.vue';
import csrf from '~/lib/utils/csrf';
import initConfirmModal from '~/confirm_modal';
import initAdminUsersApp from '~/admin/users';
import { initAdminUsersApp, initCohortsEmptyState } from '~/admin/users';
import initTabs from '~/admin/users/tabs';
const MODAL_TEXTS_CONTAINER_SELECTOR = '#js-modal-texts';
const MODAL_MANAGER_SELECTOR = '#js-delete-user-modal';
@ -58,4 +59,6 @@ document.addEventListener('DOMContentLoaded', () => {
initConfirmModal();
initAdminUsersApp();
initCohortsEmptyState();
initTabs();
});

View file

@ -7,10 +7,13 @@ document.addEventListener('DOMContentLoaded', () => {
remainingTimeElements.forEach(
(el) =>
new Vue({
...GlCountdown,
el,
propsData: {
endDateString: el.dateTime,
render(h) {
return h(GlCountdown, {
props: {
endDateString: el.dateTime,
},
});
},
}),
);

View file

@ -1,3 +1,6 @@
// This is a false violation of @gitlab/no-runtime-template-compiler, since it
// is simply defining a global Vue mixin.
/* eslint-disable @gitlab/no-runtime-template-compiler */
const ComponentPerformancePlugin = {
install(Vue, options) {
Vue.mixin({

View file

@ -114,7 +114,7 @@ export default {
<div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div>
<div class="table-mobile-content text-center">
<div
class="add-border ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center"
class="ci-status-icon d-flex align-items-center justify-content-end justify-content-md-center"
:class="`ci-status-icon-${testCase.status}`"
>
<gl-icon :size="24" :name="testCase.icon" />

View file

@ -4,16 +4,16 @@ import { TestStatus } from '../../constants';
export function iconForTestStatus(status) {
switch (status) {
case TestStatus.SUCCESS:
return 'status_success_borderless';
return 'status_success';
case TestStatus.FAILED:
return 'status_failed_borderless';
return 'status_failed';
case TestStatus.ERROR:
return 'status_warning_borderless';
return 'status_warning';
case TestStatus.SKIPPED:
return 'status_skipped_borderless';
return 'status_skipped';
case TestStatus.UNKNOWN:
default:
return 'status_notfound_borderless';
return 'status_notfound';
}
}

View file

@ -1,5 +1,5 @@
<script>
import { GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
import { GlAlert, GlBadge, GlIcon, GlLink, GlSprintf, GlTable, GlTooltip } from '@gitlab/ui';
import { s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import CiBadge from '~/vue_shared/components/ci_badge_link.vue';
@ -10,6 +10,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago';
export default {
components: {
CiBadge,
GlAlert,
GlBadge,
GlIcon,
GlLink,
@ -105,6 +106,7 @@ export default {
:items="states"
:fields="fields"
data-testid="terraform-states-table"
details-td-class="gl-p-0!"
fixed
stacked="md"
>
@ -189,5 +191,21 @@ export default {
<template v-if="terraformAdmin" #cell(actions)="{ item }">
<state-actions :state="item" />
</template>
<template #row-details="row">
<gl-alert
data-testid="terraform-states-table-error"
variant="danger"
@dismiss="row.toggleDetails"
>
<span
v-for="errorMessage in row.item.errorMessages"
:key="errorMessage"
class="gl-display-flex gl-justify-content-start"
>
{{ errorMessage }}
</span>
</gl-alert>
</template>
</gl-table>
</template>

View file

@ -10,6 +10,7 @@ import {
GlSprintf,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import addDataToState from '../graphql/mutations/add_data_to_state.mutation.graphql';
import lockState from '../graphql/mutations/lock_state.mutation.graphql';
import unlockState from '../graphql/mutations/unlock_state.mutation.graphql';
import removeState from '../graphql/mutations/remove_state.mutation.graphql';
@ -33,13 +34,13 @@ export default {
},
data() {
return {
loading: false,
showRemoveModal: false,
removeConfirmText: '',
};
},
i18n: {
downloadJSON: s__('Terraform|Download JSON'),
errorUpdate: s__('Terraform|An error occurred while changing the state file'),
lock: s__('Terraform|Lock'),
modalBody: s__(
'Terraform|You are about to remove the State file %{name}. This will permanently delete all the State versions and history. The infrastructure provisioned previously will remain intact, only the state file with all its versions are to be removed. This action is non-revertible.',
@ -76,19 +77,37 @@ export default {
this.removeConfirmText = '';
},
lock() {
this.stateMutation(lockState);
this.stateActionMutation(lockState);
},
unlock() {
this.stateMutation(unlockState);
this.stateActionMutation(unlockState);
},
updateStateCache(newData) {
this.$apollo.mutate({
mutation: addDataToState,
variables: {
terraformState: {
...this.state,
...newData,
},
},
});
},
remove() {
if (!this.disableModalSubmit) {
this.hideModal();
this.stateMutation(removeState);
this.stateActionMutation(removeState);
}
},
stateMutation(mutation) {
this.loading = true;
stateActionMutation(mutation) {
let errorMessages = [];
this.updateStateCache({
_showDetails: false,
errorMessages,
loadingActions: true,
});
this.$apollo
.mutate({
mutation,
@ -99,9 +118,22 @@ export default {
awaitRefetchQueries: true,
notifyOnNetworkStatusChange: true,
})
.catch(() => {})
.then(({ data }) => {
errorMessages =
data?.terraformStateDelete?.errors ||
data?.terraformStateLock?.errors ||
data?.terraformStateUnlock?.errors ||
[];
})
.catch(() => {
errorMessages = [this.$options.i18n.errorUpdate];
})
.finally(() => {
this.loading = false;
this.updateStateCache({
_showDetails: Boolean(errorMessages.length),
errorMessages,
loadingActions: false,
});
});
},
},
@ -114,7 +146,7 @@ export default {
icon="ellipsis_v"
right
:data-testid="`terraform-state-actions-${state.name}`"
:disabled="loading"
:disabled="state.loadingActions"
toggle-class="gl-px-3! gl-shadow-none!"
>
<template #button-content>

View file

@ -2,6 +2,10 @@
#import "./state_version.fragment.graphql"
fragment State on TerraformState {
_showDetails @client
errorMessages @client
loadingActions @client
id
name
lockedAt

View file

@ -0,0 +1,3 @@
mutation addDataToTerraformState($terraformState: State!) {
addDataToTerraformState(terraformState: $terraformState) @client
}

View file

@ -0,0 +1,41 @@
import TerraformState from './fragments/state.fragment.graphql';
export default {
TerraformState: {
_showDetails: (state) => {
// eslint-disable-next-line no-underscore-dangle
return state._showDetails || false;
},
errorMessages: (state) => {
return state.errorMessages || [];
},
loadingActions: (state) => {
return state.loadingActions || false;
},
},
Mutation: {
addDataToTerraformState: (_, { terraformState }, { client }) => {
const fragmentData = {
id: terraformState.id,
fragment: TerraformState,
// eslint-disable-next-line @gitlab/require-i18n-strings
fragmentName: 'State',
};
const previousTerraformState = client.readFragment(fragmentData);
if (previousTerraformState) {
client.writeFragment({
...fragmentData,
data: {
...previousTerraformState,
// eslint-disable-next-line no-underscore-dangle
_showDetails: terraformState._showDetails,
errorMessages: terraformState.errorMessages,
loadingActions: terraformState.loadingActions,
},
});
}
},
},
};

View file

@ -1,7 +1,9 @@
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import TerraformList from './components/terraform_list.vue';
import createDefaultClient from '~/lib/graphql';
import resolvers from './graphql/resolvers';
Vue.use(VueApollo);
@ -12,7 +14,13 @@ export default () => {
return null;
}
const defaultClient = createDefaultClient();
const defaultClient = createDefaultClient(resolvers, {
cacheConfig: {
dataIdFromObject: (object) => {
return object.id || defaultDataIdFromObject(object);
},
},
});
const { emptyStateImage, projectPath } = el.dataset;

View file

@ -177,7 +177,7 @@ export default {
</template>
<template v-else-if="hasPipeline">
<a :href="status.details_path" class="align-self-start gl-mr-3">
<ci-icon :status="status" :size="24" :borderless="true" class="add-border" />
<ci-icon :status="status" :size="24" />
</a>
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">

View file

@ -1,3 +1,7 @@
// This is a false violation of @gitlab/no-runtime-template-compiler, since it
// creates a new Vue instance by spreading a _valid_ Vue component definition
// into the Vue constructor.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import Vue from 'vue';
import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue';
import VueApollo from 'vue-apollo';

View file

@ -1,3 +1,6 @@
// This is a false violation of @gitlab/no-runtime-template-compiler, since it
// is simply defining a global Vue mixin.
/* eslint-disable @gitlab/no-runtime-template-compiler */
export default (Vue) => {
Vue.mixin({
provide: {

View file

@ -1,3 +1,6 @@
// This is a false violation of @gitlab/no-runtime-template-compiler, since it
// is simply defining a global Vue mixin.
/* eslint-disable @gitlab/no-runtime-template-compiler */
import { __, n__, s__, sprintf } from '../locale';
export default (Vue) => {

View file

@ -3,10 +3,6 @@
svg {
fill: $green-500;
}
&.add-border {
@include borderless-status-icon($green-500);
}
}
.ci-status-icon-error,
@ -14,10 +10,6 @@
svg {
fill: $red-500;
}
&.add-border {
@include borderless-status-icon($red-500);
}
}
.ci-status-icon-pending,
@ -27,31 +19,21 @@
svg {
fill: $orange-500;
}
&.add-border {
@include borderless-status-icon($orange-500);
}
}
.ci-status-icon-running {
svg {
fill: $blue-400;
}
&.add-border {
@include borderless-status-icon($blue-400);
}
}
.ci-status-icon-canceled,
.ci-status-icon-disabled {
.ci-status-icon-disabled,
.ci-status-icon-scheduled,
.ci-status-icon-manual {
svg {
fill: $gl-text-color;
}
&.add-border {
@include borderless-status-icon($gl-text-color);
}
}
.ci-status-icon-preparing,
@ -61,17 +43,6 @@
svg {
fill: var(--gray-400, $gray-400);
}
&.add-border {
@include borderless-status-icon(var(--gray-400, $gray-400));
}
}
.ci-status-icon-scheduled,
.ci-status-icon-manual {
svg {
fill: $gl-text-color;
}
}
.icon-link {

View file

@ -354,13 +354,6 @@
}
}
@mixin borderless-status-icon($color) {
svg {
border: 1px solid $color;
border-radius: 50%;
}
}
@mixin emoji-menu-toggle-button {
line-height: 1;
padding: 0;

View file

@ -1,19 +1,11 @@
# frozen_string_literal: true
class Admin::CohortsController < Admin::ApplicationController
include Analytics::UniqueVisitsHelper
track_unique_visits :index, target_id: 'i_analytics_cohorts'
feature_category :devops_reports
# Backwards compatibility. Remove it and routing in 14.0
# @see https://gitlab.com/gitlab-org/gitlab/-/issues/299303
def index
if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
CohortsService.new.execute
end
@cohorts = CohortsSerializer.new.represent(cohorts_results)
end
redirect_to admin_users_path(tab: 'cohorts')
end
end

View file

@ -2,6 +2,7 @@
class Admin::UsersController < Admin::ApplicationController
include RoutableActions
include Analytics::UniqueVisitsHelper
before_action :user, except: [:index, :new, :create]
before_action :check_impersonation_availability, only: :impersonate
@ -15,6 +16,10 @@ class Admin::UsersController < Admin::ApplicationController
@users = @users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord
@users = @users.sort_by_attribute(@sort = params[:sort])
@users = @users.page(params[:page])
@cohorts = load_cohorts
track_cohorts_visit if params[:tab] == 'cohorts'
end
def show
@ -307,6 +312,22 @@ class Admin::UsersController < Admin::ApplicationController
def log_impersonation_event
Gitlab::AppLogger.info(_("User %{current_user_username} has started impersonating %{username}") % { current_user_username: current_user.username, username: user.username })
end
def load_cohorts
if Gitlab::CurrentSettings.usage_ping_enabled
cohorts_results = Rails.cache.fetch('cohorts', expires_in: 1.day) do
CohortsService.new.execute
end
CohortsSerializer.new.represent(cohorts_results)
end
end
def track_cohorts_visit
if request.format.html? && request.headers['DNT'] != '1'
track_visit('i_analytics_cohorts')
end
end
end
Admin::UsersController.prepend_if_ee('EE::Admin::UsersController')

View file

@ -86,7 +86,7 @@ class Projects::ForksController < Projects::ApplicationController
def fork_service
strong_memoize(:fork_service) do
::Projects::ForkService.new(project, current_user, namespace: fork_namespace)
::Projects::ForkService.new(project, current_user, fork_params)
end
end
@ -96,6 +96,12 @@ class Projects::ForksController < Projects::ApplicationController
end
end
def fork_params
params.permit(:path, :name, :description, :visibility).tap do |param|
param[:namespace] = fork_namespace
end
end
def authorize_fork_namespace!
access_denied! unless fork_namespace && fork_service.valid_fork_target?
end

View file

@ -64,7 +64,7 @@ module NavHelper
end
def admin_analytics_nav_links
%w(dev_ops_report cohorts)
%w(dev_ops_report)
end
def group_issues_sub_menu_items

View file

@ -24,7 +24,9 @@ module Pages
@queue.close
@logger.info("Waiting for threads to finish...")
threads.each(&:join)
ActiveSupport::Dependencies.interlock.permit_concurrent_loads do
threads.each(&:join)
end
{ migrated: @migrated, errored: @errored }
end

View file

@ -43,8 +43,8 @@ module Projects
def new_fork_params
new_params = {
forked_from_project: @project,
visibility_level: allowed_visibility_level,
description: @project.description,
visibility_level: target_visibility_level,
description: target_description,
name: target_name,
path: target_path,
shared_runners_enabled: @project.shared_runners_enabled,
@ -107,6 +107,10 @@ module Projects
@target_name ||= @params[:name] || @project.name
end
def target_description
@target_description ||= @params[:description] || @project.description
end
def target_namespace
@target_namespace ||= @params[:namespace] || current_user.namespace
end
@ -115,8 +119,9 @@ module Projects
@skip_disk_validation ||= @params[:skip_disk_validation] || false
end
def allowed_visibility_level
def target_visibility_level
target_level = [@project.visibility_level, target_namespace.visibility_level].min
target_level = [target_level, Gitlab::VisibilityLevel.level_value(params[:visibility])].min if params.key?(:visibility)
Gitlab::VisibilityLevel.closest_allowed_level(target_level)
end

View file

@ -1,6 +1,3 @@
- breadcrumb_title _("Cohorts")
- page_title _("Cohorts")
- if @cohorts
= render 'cohorts_table'
- else

View file

@ -0,0 +1,90 @@
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left
= sprite_icon('chevron-lg-left', size: 12)
.fade-right
= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.nav.nav-tabs.scrolling-tabs
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
= s_('AdminUsers|Active')
%small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
= s_('AdminUsers|Admins')
%small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do
= s_('AdminUsers|2FA Enabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do
= s_('AdminUsers|2FA Disabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
= nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do
= s_('AdminUsers|External')
%small.badge.badge-pill= limited_counter_with_delimiter(User.external)
= nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do
= link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do
= s_('AdminUsers|Pending approval')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval)
= nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
= link_to admin_users_path(filter: "deactivated") do
= s_('AdminUsers|Deactivated')
%small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
= link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right'
.filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder.gl-mb-4
= search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
= sprite_icon('search', css_class: 'search-icon')
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
= label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
= s_('AdminUsers|Sort by')
%li
- users_sort_options_hash.each do |value, title|
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
- if Feature.enabled?(:vue_admin_users)
#js-admin-users-app{ data: admin_users_data_attributes(@users) }
.gl-spinner-container.gl-my-7
%span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } }
- elsif @users.empty?
.nothing-here-block.border-top-0
= s_('AdminUsers|No users found')
- else
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Name')
.table-section.section-10{ role: 'rowheader' }= _('Projects')
.table-section.section-15{ role: 'rowheader' }= _('Created on')
.table-section.section-15{ role: 'rowheader' }= _('Last activity')
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
= render partial: 'admin/users/modals'

View file

@ -1,92 +1,17 @@
- page_title _("Users")
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
.fade-left
= sprite_icon('chevron-lg-left', size: 12)
.fade-right
= sprite_icon('chevron-lg-right', size: 12)
%ul.nav-links.nav.nav-tabs.scrolling-tabs
= nav_link(html_options: { class: active_when(params[:filter].nil?) }) do
= link_to admin_users_path do
= s_('AdminUsers|Active')
%small.badge.badge-pill= limited_counter_with_delimiter(User.active_without_ghosts)
= nav_link(html_options: { class: active_when(params[:filter] == 'admins') }) do
= link_to admin_users_path(filter: "admins") do
= s_('AdminUsers|Admins')
%small.badge.badge-pill= limited_counter_with_delimiter(User.admins)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_enabled')} filter-two-factor-enabled" }) do
= link_to admin_users_path(filter: 'two_factor_enabled') do
= s_('AdminUsers|2FA Enabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.with_two_factor)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'two_factor_disabled')} filter-two-factor-disabled" }) do
= link_to admin_users_path(filter: 'two_factor_disabled') do
= s_('AdminUsers|2FA Disabled')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_two_factor)
= nav_link(html_options: { class: active_when(params[:filter] == 'external') }) do
= link_to admin_users_path(filter: 'external') do
= s_('AdminUsers|External')
%small.badge.badge-pill= limited_counter_with_delimiter(User.external)
= nav_link(html_options: { class: active_when(params[:filter] == 'blocked') }) do
= link_to admin_users_path(filter: "blocked") do
= s_('AdminUsers|Blocked')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked)
= nav_link(html_options: { class: "#{active_when(params[:filter] == 'blocked_pending_approval')} filter-blocked-pending-approval" }) do
= link_to admin_users_path(filter: "blocked_pending_approval"), data: { qa_selector: 'pending_approval_tab' } do
= s_('AdminUsers|Pending approval')
%small.badge.badge-pill= limited_counter_with_delimiter(User.blocked_pending_approval)
= nav_link(html_options: { class: active_when(params[:filter] == 'deactivated') }) do
= link_to admin_users_path(filter: "deactivated") do
= s_('AdminUsers|Deactivated')
%small.badge.badge-pill= limited_counter_with_delimiter(User.deactivated)
= nav_link(html_options: { class: active_when(params[:filter] == 'wop') }) do
= link_to admin_users_path(filter: "wop") do
= s_('AdminUsers|Without projects')
%small.badge.badge-pill= limited_counter_with_delimiter(User.without_projects)
.nav-controls
= render_if_exists 'admin/users/admin_email_users'
= render_if_exists 'admin/users/admin_export_user_permissions'
= link_to s_('AdminUsers|New user'), new_admin_user_path, class: 'btn gl-button btn-success btn-search float-right'
%ul.nav-links.nav-tabs.nav.js-users-tabs{ role: 'tablist' }
%li.nav-item.js-users-tab-item{ role: 'presentation' }
%a.nav-link{ href: '#users', class: active_when(params[:tab] != 'cohorts'), data: { toggle: 'tab' }, role: 'tab' }
= s_('AdminUsers|Users')
%li.nav-item.js-users-tab-item{ role: 'presentation' }
%a.nav-link{ href: '#cohorts', class: active_when(params[:tab] == 'cohorts'), data: { toggle: 'tab', track: { event: 'i_analytics_cohorts', action: 'click_tab' } }, role: 'tab' }
= s_('AdminUsers|Cohorts')
.filtered-search-block.row-content-block.border-top-0
= form_tag admin_users_path, method: :get do
- if params[:filter].present?
= hidden_field_tag "filter", h(params[:filter])
.search-holder
.search-field-holder.gl-mb-4
= search_field_tag :search_query, params[:search_query], placeholder: s_('AdminUsers|Search by name, email or username'), class: 'form-control search-text-input js-search-input', spellcheck: false, data: { qa_selector: 'user_search_field' }
- if @sort.present?
= hidden_field_tag :sort, @sort
= sprite_icon('search', css_class: 'search-icon')
= button_tag s_('AdminUsers|Search users') if Rails.env.test?
.dropdown.user-sort-dropdown
= label_tag 'Sort by', nil, class: 'label-bold'
- toggle_text = @sort.present? ? users_sort_options_hash[@sort] : sort_title_name
= dropdown_toggle(toggle_text, { toggle: 'dropdown' })
%ul.dropdown-menu.dropdown-menu-right
%li.dropdown-header
= s_('AdminUsers|Sort by')
%li
- users_sort_options_hash.each do |value, title|
= link_to admin_users_path(sort: value, filter: params[:filter], search_query: params[:search_query]) do
= title
.tab-content
.tab-pane{ id: 'users', class: ('active' if params[:tab] != 'cohorts') }
= render 'users'
.tab-pane{ id: 'cohorts', class: ('active' if params[:tab] == 'cohorts') }
= render 'cohorts'
- if Feature.enabled?(:vue_admin_users)
#js-admin-users-app{ data: admin_users_data_attributes(@users) }
.gl-spinner-container.gl-my-7
%span.gl-vertical-align-bottom.gl-spinner.gl-spinner-dark.gl-spinner-lg{ aria: { label: _('Loading') } }
- elsif @users.empty?
.nothing-here-block.border-top-0
= s_('AdminUsers|No users found')
- else
.table-holder
.thead-white.text-nowrap.gl-responsive-table-row.table-row-header{ role: 'row' }
.table-section.section-40{ role: 'rowheader' }= _('Name')
.table-section.section-10{ role: 'rowheader' }= _('Projects')
.table-section.section-15{ role: 'rowheader' }= _('Created on')
.table-section.section-15{ role: 'rowheader' }= _('Last activity')
= render partial: 'admin/users/user', collection: @users
= paginate @users, theme: "gitlab"
= render partial: 'admin/users/modals'

View file

@ -65,10 +65,6 @@
= link_to admin_dev_ops_report_path, title: _('DevOps Report') do
%span
= _('DevOps Report')
= nav_link(controller: :cohorts) do
= link_to admin_cohorts_path, title: _('Cohorts') do
%span
= _('Cohorts')
- if Feature.enabled?(:instance_statistics, default_enabled: true)
= nav_link(controller: :instance_statistics) do
= link_to admin_instance_statistics_path, title: _('Usage Trends') do

View file

@ -0,0 +1,5 @@
---
title: Support setting more attributes when forking a project
merge_request: 51962
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Add unique index on services project_id and type
merge_request: 52563
author:
type: changed

View file

@ -0,0 +1,5 @@
---
title: Move Cohorts page to Overiew-Users
merge_request: 51707
author:
type: changed

View file

@ -0,0 +1,6 @@
---
title: Make CI Icon in merge request pipeline detail consistent with other widget
icons
merge_request: 52516
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Display Terraform list errors to user
merge_request: 51397
author:
type: changed

View file

@ -0,0 +1,19 @@
# frozen_string_literal: true
class AddUniqueIndexServicesProjectIdAndType < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
INDEX_NAME = 'index_services_on_project_id_and_type_unique'
def up
add_concurrent_index :services, [:project_id, :type], name: INDEX_NAME, unique: true
end
def down
remove_concurrent_index_by_name :services, name: INDEX_NAME
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
class RemoveIndexServicesProjectIdAndType < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
INDEX_NAME = 'index_services_on_project_id_and_type'
# Replaced by the index added in 20210126091713_add_unique_index_services_project_id_and_type.rb
def up
remove_concurrent_index_by_name :services, name: INDEX_NAME
end
def down
add_concurrent_index :services, [:project_id, :type], name: INDEX_NAME
end
end

View file

@ -0,0 +1 @@
3906739d07514e6e59f79a4a81d28859a2481614a299c95ec1b1d9825a07ec64

View file

@ -0,0 +1 @@
124c5ae1a1ccade5dec01f72b726e03febc8f56411d7d8990f976bb2a9516037

View file

@ -23003,7 +23003,7 @@ CREATE INDEX index_service_desk_enabled_projects_on_id_creator_id_created_at ON
CREATE INDEX index_services_on_inherit_from_id ON services USING btree (inherit_from_id);
CREATE INDEX index_services_on_project_id_and_type ON services USING btree (project_id, type);
CREATE UNIQUE INDEX index_services_on_project_id_and_type_unique ON services USING btree (project_id, type);
CREATE INDEX index_services_on_template ON services USING btree (template);

View file

@ -1280,6 +1280,8 @@ POST /projects/:id/fork
| `namespace_path` | string | **{dotted-circle}** No | The path of the namespace that the project is forked to. |
| `namespace` | integer/string | **{dotted-circle}** No | _(Deprecated)_ The ID or path of the namespace that the project is forked to. |
| `path` | string | **{dotted-circle}** No | The path assigned to the resultant project after forking. |
| `description` | string | **{dotted-circle}** No | The description assigned to the resultant project after forking. |
| `visibility` | string | **{dotted-circle}** No | The [visibility level](#project-visibility-level) assigned to the resultant project after forking. |
## List Forks of a project

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View file

@ -9,14 +9,14 @@ info: To determine the technical writer assigned to the Stage/Group associated w
As a benefit of having the [usage ping active](../settings/usage_statistics.md),
you can analyze your users' GitLab activities over time.
To see user cohorts, go to **Admin Area > Analytics > Cohorts**.
To see user cohorts, go to **Admin Area > Overview > Users**.
## Overview
How do you interpret the user cohorts table? Let's review an example with the
following user cohorts:
![User cohort example](img/cohorts_v13_4.png)
![User cohort example](img/cohorts_v13_9.png)
For the cohort of March 2020, three users were added to this server and have
been active since this month. One month later (April 2020), two users are still

View file

@ -260,7 +260,8 @@ For GitLab.com, it is set to 10 MB.
## Export requirements to a CSV file
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290813) in GitLab 13.8.
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/290813) in GitLab 13.8.
> - Revised CSV column headers [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/299247) in GitLab 13.9.
You can export GitLab requirements to a
[CSV file](https://en.wikipedia.org/wiki/Comma-separated_values) sent to your default notification
@ -285,11 +286,24 @@ You can preview the exported CSV file in a spreadsheet editor, such as Microsoft
OpenOffice Calc, or Google Sheets.
<!-- vale gitlab.Spelling = YES -->
The exported CSV file contains the following columns:
The exported CSV file contains the following headers:
- Requirement ID
- Title
- Description
- Author Username
- Latest Test Report State
- Latest Test Report Created At (UTC)
- In GitLab 13.8:
- Requirement ID
- Title
- Description
- Author Username
- Latest Test Report State
- Latest Test Report Created At (UTC)
- In GitLab 13.9 and later:
- Requirement ID
- Title
- Description
- Author
- Author Username
- Created At (UTC)
- State
- State Updated At (UTC)

View file

@ -295,6 +295,8 @@ module API
optional :namespace_path, type: String, desc: 'The path of the namespace that the project will be forked into'
optional :path, type: String, desc: 'The path that will be assigned to the fork'
optional :name, type: String, desc: 'The name that will be assigned to the fork'
optional :description, type: String, desc: 'The description that will be assigned to the fork'
optional :visibility, type: String, values: Gitlab::VisibilityLevel.string_values, desc: 'The visibility of the fork'
end
post ':id/fork', feature_category: :source_code_management do
Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/42284')

View file

@ -2203,6 +2203,9 @@ msgstr ""
msgid "AdminUsers|Cannot unblock LDAP blocked users"
msgstr ""
msgid "AdminUsers|Cohorts"
msgstr ""
msgid "AdminUsers|Deactivate"
msgstr ""
@ -2341,6 +2344,9 @@ msgstr ""
msgid "AdminUsers|User will not be able to login"
msgstr ""
msgid "AdminUsers|Users"
msgstr ""
msgid "AdminUsers|When the user logs back in, their account will reactivate as a fully active account"
msgstr ""
@ -7063,9 +7069,6 @@ msgstr ""
msgid "CodeOwner|Pattern"
msgstr ""
msgid "Cohorts"
msgstr ""
msgid "Cohorts|Inactive users"
msgstr ""
@ -27881,6 +27884,9 @@ msgstr ""
msgid "Terraform|Actions"
msgstr ""
msgid "Terraform|An error occurred while changing the state file"
msgstr ""
msgid "Terraform|An error occurred while loading your Terraform States"
msgstr ""
@ -29937,6 +29943,9 @@ msgstr ""
msgid "Toggle emoji award"
msgstr ""
msgid "Toggle focus mode"
msgstr ""
msgid "Toggle navigation"
msgstr ""

View file

@ -163,7 +163,7 @@
},
"devDependencies": {
"@babel/plugin-transform-modules-commonjs": "^7.10.1",
"@gitlab/eslint-plugin": "6.0.0",
"@gitlab/eslint-plugin": "7.0.0",
"@testing-library/dom": "^7.16.2",
"@vue/test-utils": "1.1.2",
"acorn": "^6.3.0",

View file

@ -6,7 +6,7 @@ module QA
module Overview
module Users
class Index < QA::Page::Base
view 'app/views/admin/users/index.html.haml' do
view 'app/views/admin/users/_users.html.haml' do
element :user_search_field
element :pending_approval_tab
end

View file

@ -39,7 +39,7 @@ module QA
element :boards_list
end
view 'app/assets/javascripts/boards/toggle_focus.js' do
view 'app/assets/javascripts/boards/components/toggle_focus.vue' do
element :focus_mode_button
end

View file

@ -3,37 +3,15 @@
require 'spec_helper'
RSpec.describe Admin::CohortsController do
context 'as admin' do
let(:user) { create(:admin) }
let(:user) { create(:admin) }
before do
sign_in(user)
end
it 'renders 200' do
get :index
expect(response).to have_gitlab_http_status(:success)
end
describe 'GET #index' do
it_behaves_like 'tracking unique visits', :index do
let(:target_id) { 'i_analytics_cohorts' }
end
end
before do
sign_in(user)
end
context 'as normal user' do
let(:user) { create(:user) }
it 'redirects to Overview->Users' do
get :index
before do
sign_in(user)
end
it 'renders a 404' do
get :index
expect(response).to have_gitlab_http_status(:not_found)
end
expect(response).to redirect_to(admin_users_path(tab: 'cohorts'))
end
end

View file

@ -29,6 +29,11 @@ RSpec.describe Admin::UsersController do
expect(assigns(:users).first.association(:authorized_projects)).to be_loaded
end
it_behaves_like 'tracking unique visits', :index do
let(:target_id) { 'i_analytics_cohorts' }
let(:request_params) { { tab: 'cohorts' } }
end
end
describe 'GET :id' do

View file

@ -209,6 +209,13 @@ RSpec.describe Projects::ForksController do
}
end
let(:created_project) do
Namespace
.find_by_id(params[:namespace_key])
.projects
.find_by_path(params.fetch(:path, project.path))
end
subject do
post :create, params: params
end
@ -260,6 +267,21 @@ RSpec.describe Projects::ForksController do
expect(response).to redirect_to(namespace_project_import_path(user.namespace, project, continue: continue_params))
end
end
context 'custom attributes set' do
let(:params) { super().merge(path: 'something_custom', name: 'Something Custom', description: 'Something Custom', visibility: 'private') }
it 'creates a project with custom values' do
subject
expect(response).to have_gitlab_http_status(:found)
expect(response).to redirect_to(namespace_project_import_path(user.namespace, params[:path]))
expect(created_project.path).to eq(params[:path])
expect(created_project.name).to eq(params[:name])
expect(created_project.description).to eq(params[:description])
expect(created_project.visibility).to eq(params[:visibility])
end
end
end
context 'when user is not signed in' do

View file

@ -1,33 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Cohorts page' do
before do
admin = create(:admin)
sign_in(admin)
gitlab_enable_admin_mode_sign_in(admin)
end
context 'with usage ping enabled' do
it 'shows users count per month' do
stub_application_setting(usage_ping_enabled: true)
create_list(:user, 2)
visit admin_cohorts_path
expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0")
end
end
context 'with usage ping disabled' do
it 'shows empty state', :js do
stub_application_setting(usage_ping_enabled: false)
visit admin_cohorts_path
expect(page).to have_selector(".js-empty-state")
end
end
end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe "Admin::Users" do
let(:current_user) { create(:admin) }
before do
sign_in(current_user)
gitlab_enable_admin_mode_sign_in(current_user)
end
describe 'Tabs', :js do
let(:tabs_selector) { '.js-users-tabs' }
let(:active_tab_selector) { '.nav-link.active' }
it 'does not add the tab param when the Users tab is selected' do
visit admin_users_path
within tabs_selector do
click_link 'Users'
end
expect(page).to have_current_path(admin_users_path)
end
it 'adds the ?tab=cohorts param when the Cohorts tab is selected' do
visit admin_users_path
within tabs_selector do
click_link 'Cohorts'
end
expect(page).to have_current_path(admin_users_path(tab: 'cohorts'))
end
it 'shows the cohorts tab when the tab param is set' do
visit admin_users_path(tab: 'cohorts')
within tabs_selector do
expect(page).to have_selector active_tab_selector, text: 'Cohorts'
end
end
end
describe 'Cohorts tab content' do
context 'with usage ping enabled' do
it 'shows users count per month' do
stub_application_setting(usage_ping_enabled: true)
create_list(:user, 2)
visit admin_users_path(tab: 'cohorts')
expect(page).to have_content("#{Time.now.strftime('%b %Y')} 3 0")
end
end
context 'with usage ping disabled' do
it 'shows empty state', :js do
stub_application_setting(usage_ping_enabled: false)
visit admin_users_path(tab: 'cohorts')
expect(page).to have_selector(".js-empty-state")
expect(page).to have_content("Activate user activity analysis")
end
end
end
end

View file

@ -1,5 +1,5 @@
import { createWrapper } from '@vue/test-utils';
import initAdminUsers from '~/admin/users';
import { initAdminUsersApp } from '~/admin/users';
import AdminUsersApp from '~/admin/users/components/app.vue';
import { users, paths } from './mock_data';
@ -16,7 +16,7 @@ describe('initAdminUsersApp', () => {
document.body.appendChild(el);
wrapper = createWrapper(initAdminUsers(el));
wrapper = createWrapper(initAdminUsersApp(el));
});
afterEach(() => {

View file

@ -1,32 +1,51 @@
import Vue from 'vue';
import { setHTMLFixture } from 'helpers/fixtures';
import RecentSearchesRoot from '~/filtered_search/recent_searches_root';
jest.mock('vue');
const containerId = 'test-container';
const dropdownElementId = 'test-dropdown-element';
describe('RecentSearchesRoot', () => {
describe('render', () => {
let recentSearchesRoot;
let data;
let template;
let recentSearchesRootMockInstance;
let vm;
let containerEl;
beforeEach(() => {
recentSearchesRoot = {
setHTMLFixture(`
<div id="${containerId}">
<div id="${dropdownElementId}"></div>
</div>
`);
containerEl = document.getElementById(containerId);
recentSearchesRootMockInstance = {
store: {
state: 'state',
state: {
recentSearches: ['foo', 'bar', 'qux'],
isLocalStorageAvailable: true,
allowedKeys: ['test'],
},
},
wrapperElement: document.getElementById(dropdownElementId),
};
Vue.mockImplementation((options) => {
({ data, template } = options);
});
RecentSearchesRoot.prototype.render.call(recentSearchesRootMockInstance);
vm = recentSearchesRootMockInstance.vm;
RecentSearchesRoot.prototype.render.call(recentSearchesRoot);
return vm.$nextTick();
});
it('should instantiate Vue', () => {
expect(Vue).toHaveBeenCalled();
expect(data()).toBe(recentSearchesRoot.store.state);
expect(template).toContain(':is-local-storage-available="isLocalStorageAvailable"');
afterEach(() => {
vm.$destroy();
});
it('should render the recent searches', () => {
const { recentSearches } = recentSearchesRootMockInstance.store.state;
recentSearches.forEach((recentSearch) => {
expect(containerEl.textContent).toContain(recentSearch);
});
});
});
});

View file

@ -118,7 +118,7 @@ describe('Onboarding Issues Popovers', () => {
describe('when dismissing the popover', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
document.querySelector('.learn-gitlab.popover .close').click();
document.querySelector('.learn-gitlab.popover .js-close-learn-gitlab').click();
});
it('deletes the cookie', () => {

View file

@ -1,6 +1,7 @@
import { GlDropdown, GlModal, GlSprintf } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import VueApollo from 'vue-apollo';
import StateActions from '~/terraform/components/states_table_actions.vue';
import lockStateMutation from '~/terraform/graphql/mutations/lock_state.mutation.graphql';
@ -14,6 +15,7 @@ describe('StatesTableActions', () => {
let lockResponse;
let removeResponse;
let unlockResponse;
let updateStateResponse;
let wrapper;
const defaultProps = {
@ -26,7 +28,9 @@ describe('StatesTableActions', () => {
};
const createMockApolloProvider = () => {
lockResponse = jest.fn().mockResolvedValue({ data: { terraformStateLock: { errors: [] } } });
lockResponse = jest
.fn()
.mockResolvedValue({ data: { terraformStateLock: { errors: ['There was an error'] } } });
removeResponse = jest
.fn()
@ -36,11 +40,20 @@ describe('StatesTableActions', () => {
.fn()
.mockResolvedValue({ data: { terraformStateUnlock: { errors: [] } } });
return createMockApollo([
[lockStateMutation, lockResponse],
[removeStateMutation, removeResponse],
[unlockStateMutation, unlockResponse],
]);
updateStateResponse = jest.fn().mockResolvedValue({});
return createMockApollo(
[
[lockStateMutation, lockResponse],
[removeStateMutation, removeResponse],
[unlockStateMutation, unlockResponse],
],
{
Mutation: {
addDataToTerraformState: updateStateResponse,
},
},
);
};
const createComponent = (propsData = defaultProps) => {
@ -56,6 +69,7 @@ describe('StatesTableActions', () => {
return wrapper.vm.$nextTick();
};
const findActionsDropdown = () => wrapper.find(GlDropdown);
const findLockBtn = () => wrapper.find('[data-testid="terraform-state-lock"]');
const findUnlockBtn = () => wrapper.find('[data-testid="terraform-state-unlock"]');
const findDownloadBtn = () => wrapper.find('[data-testid="terraform-state-download"]');
@ -70,9 +84,25 @@ describe('StatesTableActions', () => {
lockResponse = null;
removeResponse = null;
unlockResponse = null;
updateStateResponse = null;
wrapper.destroy();
});
describe('when the state is loading', () => {
beforeEach(() => {
return createComponent({
state: {
...defaultProps.state,
loadingActions: true,
},
});
});
it('disables the actions dropdown', () => {
expect(findActionsDropdown().props('disabled')).toBe(true);
});
});
describe('download button', () => {
it('displays a download button', () => {
expect(findDownloadBtn().text()).toBe('Download JSON');
@ -104,7 +134,8 @@ describe('StatesTableActions', () => {
describe('when clicking the unlock button', () => {
beforeEach(() => {
findUnlockBtn().vm.$emit('click');
return wrapper.vm.$nextTick();
return waitForPromises();
});
it('calls the unlock mutation', () => {
@ -137,7 +168,8 @@ describe('StatesTableActions', () => {
describe('when clicking the lock button', () => {
beforeEach(() => {
findLockBtn().vm.$emit('click');
return wrapper.vm.$nextTick();
return waitForPromises();
});
it('calls the lock mutation', () => {
@ -145,6 +177,42 @@ describe('StatesTableActions', () => {
stateID: unlockedProps.state.id,
});
});
it('calls mutations to set loading and errors', () => {
// loading update
expect(updateStateResponse).toHaveBeenNthCalledWith(
1,
{},
{
terraformState: {
...unlockedProps.state,
_showDetails: false,
errorMessages: [],
loadingActions: true,
},
},
// Apollo fields
expect.any(Object),
expect.any(Object),
);
// final update
expect(updateStateResponse).toHaveBeenNthCalledWith(
2,
{},
{
terraformState: {
...unlockedProps.state,
_showDetails: true,
errorMessages: ['There was an error'],
loadingActions: false,
},
},
// Apollo fields
expect.any(Object),
expect.any(Object),
);
});
});
});
@ -156,7 +224,8 @@ describe('StatesTableActions', () => {
describe('when clicking the remove button', () => {
beforeEach(() => {
findRemoveBtn().vm.$emit('click');
return wrapper.vm.$nextTick();
return waitForPromises();
});
it('displays a remove modal', () => {

View file

@ -11,6 +11,8 @@ describe('StatesTable', () => {
const defaultProps = {
states: [
{
_showDetails: true,
errorMessages: ['State 1 has errored'],
name: 'state-1',
lockedAt: '2020-10-13T00:00:00Z',
lockedByUser: {
@ -20,6 +22,8 @@ describe('StatesTable', () => {
latestVersion: null,
},
{
_showDetails: false,
errorMessages: [],
name: 'state-2',
lockedAt: null,
lockedByUser: null,
@ -27,6 +31,8 @@ describe('StatesTable', () => {
latestVersion: null,
},
{
_showDetails: false,
errorMessages: [],
name: 'state-3',
lockedAt: '2020-10-10T00:00:00Z',
lockedByUser: {
@ -54,6 +60,8 @@ describe('StatesTable', () => {
},
},
{
_showDetails: true,
errorMessages: ['State 4 has errored'],
name: 'state-4',
lockedAt: '2020-10-10T00:00:00Z',
lockedByUser: null,
@ -154,6 +162,17 @@ describe('StatesTable', () => {
expect(findActions().length).toEqual(0);
});
it.each`
errorMessage | lineNumber
${defaultProps.states[0].errorMessages[0]} | ${0}
${defaultProps.states[3].errorMessages[0]} | ${1}
`('displays table error message "$errorMessage"', ({ errorMessage, lineNumber }) => {
const states = wrapper.findAll('[data-testid="terraform-states-table-error"]');
const state = states.at(lineNumber);
expect(state.text()).toBe(errorMessage);
});
describe('when user is a terraform administrator', () => {
beforeEach(() => {
return createComponent({

View file

@ -27,6 +27,15 @@ describe('TerraformList', () => {
},
};
// Override @client _showDetails
getStatesQuery.getStates.definitions[1].selectionSet.selections[0].directives = [];
// Override @client errorMessages
getStatesQuery.getStates.definitions[1].selectionSet.selections[1].directives = [];
// Override @client loadingActions
getStatesQuery.getStates.definitions[1].selectionSet.selections[2].directives = [];
const statsQueryResponse = queryResponse || jest.fn().mockResolvedValue(apolloQueryResponse);
const apolloProvider = createMockApollo([[getStatesQuery, statsQueryResponse]]);
@ -52,20 +61,26 @@ describe('TerraformList', () => {
describe('when there is a list of terraform states', () => {
const states = [
{
_showDetails: false,
errorMessages: [],
id: 'gid://gitlab/Terraform::State/1',
name: 'state-1',
lockedAt: null,
updatedAt: null,
lockedByUser: null,
latestVersion: null,
loadingActions: false,
lockedAt: null,
lockedByUser: null,
updatedAt: null,
},
{
_showDetails: false,
errorMessages: [],
id: 'gid://gitlab/Terraform::State/2',
name: 'state-2',
lockedAt: null,
updatedAt: null,
lockedByUser: null,
latestVersion: null,
loadingActions: false,
lockedAt: null,
lockedByUser: null,
updatedAt: null,
},
];

View file

@ -3328,8 +3328,8 @@ RSpec.describe API::Projects do
expect(json_response['message']['path']).to eq(['has already been taken'])
end
it 'accepts a name for the target project' do
post api("/projects/#{project.id}/fork", user2), params: { name: 'My Random Project' }
it 'accepts custom parameters for the target project' do
post api("/projects/#{project.id}/fork", user2), params: { name: 'My Random Project', description: 'A description', visibility: 'private' }
expect(response).to have_gitlab_http_status(:created)
expect(json_response['name']).to eq('My Random Project')
@ -3337,6 +3337,8 @@ RSpec.describe API::Projects do
expect(json_response['owner']['id']).to eq(user2.id)
expect(json_response['namespace']['id']).to eq(user2.namespace.id)
expect(json_response['forked_from_project']['id']).to eq(project.id)
expect(json_response['description']).to eq('A description')
expect(json_response['visibility']).to eq('private')
expect(json_response['import_status']).to eq('scheduled')
expect(json_response).to include("import_error")
end
@ -3368,6 +3370,13 @@ RSpec.describe API::Projects do
expect(json_response['message']['path']).to eq(['has already been taken'])
expect(json_response['message']['name']).to eq(['has already been taken'])
end
it 'fails to fork with an unknown visibility level' do
post api("/projects/#{project.id}/fork", user2), params: { visibility: 'something' }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response['error']).to eq('visibility does not have a valid value')
end
end
context 'when unauthenticated' do

View file

@ -141,13 +141,6 @@ RSpec.describe Admin::DevOpsReportController, "routing" do
end
end
# admin_cohorts GET /admin/cohorts(.:format) admin/cohorst#index
RSpec.describe Admin::CohortsController, "routing" do
it "to #index" do
expect(get("/admin/cohorts")).to route_to('admin/cohorts#index')
end
end
RSpec.describe Admin::GroupsController, "routing" do
let(:name) { 'complex.group-namegit' }

View file

@ -323,6 +323,50 @@ RSpec.describe Projects::ForkService do
end
end
end
describe 'fork with optional attributes' do
let(:public_project) { create(:project, :public) }
it 'sets optional attributes to specified values' do
forked_project = fork_project(
public_project,
nil,
namespace: public_project.namespace,
path: 'forked',
name: 'My Fork',
description: 'Description',
visibility: 'internal',
using_service: true
)
expect(forked_project.path).to eq('forked')
expect(forked_project.name).to eq('My Fork')
expect(forked_project.description).to eq('Description')
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::INTERNAL)
end
it 'sets visibility level to private if an unknown visibility is requested' do
forked_project = fork_project(public_project, nil, using_service: true, visibility: 'unknown')
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
it 'sets visibility level to project visibility level if requested visibility is greater' do
private_project = create(:project, :private)
forked_project = fork_project(private_project, nil, using_service: true, visibility: 'public')
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
it 'sets visibility level to target namespace visibility level if requested visibility is greater' do
private_group = create(:group, :private)
forked_project = fork_project(public_project, nil, namespace: private_group, using_service: true, visibility: 'public')
expect(forked_project.visibility_level).to eq(Gitlab::VisibilityLevel::PRIVATE)
end
end
end
context 'when a project is already forked' do

View file

@ -845,10 +845,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/at.js/-/at.js-1.5.7.tgz#1ee6f838cc4410a1d797770934df91d90df8179e"
integrity sha512-c6ySRK/Ma7lxwpIVbSAF3P+xiTLrNTGTLRx4/pHK111AdFxwgUwrYF6aVZFXvmG65jHOJHoa0eQQ21RW6rm0Rg==
"@gitlab/eslint-plugin@6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-6.0.0.tgz#deb18f63808af1cb1cc117a92558f07edb1e2256"
integrity sha512-3TihEG0EzbGtc6wxZLANZN1ge2tnAv0qU8w6smUACmPhqFj0/DrCq9V6QKPqAHk/Yn3hrfGk5nznAzzuMEgwDQ==
"@gitlab/eslint-plugin@7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@gitlab/eslint-plugin/-/eslint-plugin-7.0.0.tgz#3c46d88dde2f7aa0be2a7df5af8e593006becea9"
integrity sha512-XqISaNqQwJ12jTanESvAFNVAniqFN/UFKj068ESiNumlsxnQA36V945wZ6LnwI7WgSCGQCUfHi9MEgyjUvuvdg==
dependencies:
babel-eslint "^10.0.3"
eslint-config-airbnb-base "^14.0.0"