Commit b5d29112 authored by Craig Norris's avatar Craig Norris

Merge branch 'docs-aqualls-add-configure-metadata' into 'master'

Mark page as part of Configure docset

See merge request gitlab-org/gitlab!46735
parents 6c5dddf3 0df0929b
......@@ -676,10 +676,14 @@
##################
.releases:rules:canonical-dot-com-gitlab-stable-branch-only:
rules:
- if: '$CI_COMMIT_MESSAGE =~ /\[merge-train skip\]/'
when: never
- if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_PATH == "gitlab-org/gitlab" && $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable-ee$/'
.releases:rules:canonical-dot-com-security-gitlab-stable-branch-only:
rules:
- if: '$CI_COMMIT_MESSAGE =~ /\[merge-train skip\]/'
when: never
- if: '$CI_SERVER_HOST == "gitlab.com" && $CI_PROJECT_PATH == "gitlab-org/security/gitlab" && $CI_COMMIT_REF_NAME =~ /^[\d-]+-stable-ee$/'
#################
......
......@@ -183,19 +183,6 @@ RSpec/ContextWording:
RSpec/ExpectChange:
Enabled: false
# Offense count: 47
RSpec/ExpectGitlabTracking:
Exclude:
- 'spec/controllers/projects/registry/repositories_controller_spec.rb'
- 'spec/controllers/projects/registry/tags_controller_spec.rb'
- 'spec/controllers/projects/settings/operations_controller_spec.rb'
- 'spec/lib/api/helpers_spec.rb'
- 'spec/requests/api/project_container_repositories_spec.rb'
- 'spec/support/shared_examples/controllers/trackable_shared_examples.rb'
- 'spec/support/shared_examples/requests/api/discussions_shared_examples.rb'
- 'spec/support/shared_examples/requests/api/packages_shared_examples.rb'
- 'spec/support/shared_examples/requests/api/tracking_shared_examples.rb'
# Offense count: 751
RSpec/ExpectInHook:
Enabled: false
......@@ -1113,60 +1100,13 @@ Rails/SaveBang:
# Cop supports --auto-correct.
RSpec/TimecopFreeze:
Exclude:
- 'ee/spec/controllers/admin/application_settings_controller_spec.rb'
- 'ee/spec/controllers/projects/security/network_policies_controller_spec.rb'
- 'ee/spec/features/admin/admin_reset_pipeline_minutes_spec.rb'
- 'ee/spec/features/boards/sidebar_spec.rb'
- 'ee/spec/features/groups/analytics/cycle_analytics/filters_and_data_spec.rb'
- 'ee/spec/features/groups/iteration_spec.rb'
- 'ee/spec/features/projects/mirror_spec.rb'
- 'ee/spec/features/projects/services/prometheus_custom_metrics_spec.rb'
- 'ee/spec/finders/productivity_analytics_finder_spec.rb'
- 'ee/spec/frontend/fixtures/analytics.rb'
- 'ee/spec/helpers/vulnerabilities_helper_spec.rb'
- 'ee/spec/lib/analytics/merge_request_metrics_refresh_spec.rb'
- 'ee/spec/lib/analytics/productivity_analytics_request_params_spec.rb'
- 'ee/spec/lib/ee/gitlab/background_migration/populate_vulnerability_historical_statistics_spec.rb'
- 'ee/spec/lib/gitlab/analytics/cycle_analytics/data_collector_spec.rb'
- 'ee/spec/lib/gitlab/analytics/cycle_analytics/group_stage_time_summary_spec.rb'
- 'ee/spec/lib/gitlab/analytics/cycle_analytics/summary/group/stage_time_summary_spec.rb'
- 'ee/spec/lib/gitlab/analytics/type_of_work/tasks_by_type_spec.rb'
- 'ee/spec/lib/gitlab/auth/group_saml/sso_enforcer_spec.rb'
- 'ee/spec/lib/gitlab/database/load_balancing/host_spec.rb'
- 'ee/spec/lib/gitlab/geo/base_request_spec.rb'
- 'ee/spec/lib/gitlab/geo/event_gap_tracking_spec.rb'
- 'ee/spec/lib/gitlab/geo/git_push_http_spec.rb'
- 'ee/spec/lib/gitlab/geo/log_cursor/daemon_spec.rb'
- 'ee/spec/lib/gitlab/geo/log_cursor/events/repository_updated_event_spec.rb'
- 'ee/spec/lib/gitlab/analytics/cycle_analytics/data_collector_spec.rb'
- 'ee/spec/lib/gitlab/geo/oauth/login_state_spec.rb'
- 'ee/spec/lib/gitlab/insights/reducers/count_per_period_reducer_spec.rb'
- 'ee/spec/lib/gitlab/prometheus/queries/additional_metrics_environment_query_spec.rb'
- 'ee/spec/lib/gitlab/prometheus/queries/cluster_query_spec.rb'
- 'ee/spec/migrations/populate_vulnerability_historical_statistics_for_year_spec.rb'
- 'ee/spec/migrations/remove_duplicated_cs_findings_spec.rb'
- 'ee/spec/migrations/remove_duplicated_cs_findings_without_vulnerability_id_spec.rb'
- 'ee/spec/migrations/schedule_fix_orphan_promoted_issues_spec.rb'
- 'ee/spec/migrations/schedule_merge_request_any_approval_rule_migration_spec.rb'
- 'ee/spec/migrations/schedule_populate_resolved_on_default_branch_column_spec.rb'
- 'ee/spec/migrations/schedule_populate_vulnerability_historical_statistics_spec.rb'
- 'ee/spec/migrations/schedule_project_any_approval_rule_migration_spec.rb'
- 'ee/spec/migrations/set_resolved_state_on_vulnerabilities_spec.rb'
- 'ee/spec/migrations/20190926180443_schedule_epic_issues_after_epics_move_spec.rb'
- 'ee/spec/models/analytics/cycle_analytics/group_level_spec.rb'
- 'ee/spec/models/burndown_spec.rb'
- 'ee/spec/models/ee/namespace_spec.rb'
- 'ee/spec/models/geo/project_registry_spec.rb'
- 'ee/spec/models/merge_train_spec.rb'
- 'ee/spec/models/productivity_analytics_spec.rb'
- 'ee/spec/models/project_spec.rb'
- 'ee/spec/models/vulnerabilities/export_spec.rb'
- 'ee/spec/requests/api/vulnerabilities_spec.rb'
- 'ee/spec/services/geo/file_download_service_spec.rb'
- 'ee/spec/services/vulnerabilities/confirm_service_spec.rb'
- 'ee/spec/services/vulnerabilities/dismiss_service_spec.rb'
- 'ee/spec/services/vulnerabilities/resolve_service_spec.rb'
- 'ee/spec/services/vulnerabilities/revert_to_detected_service_spec.rb'
- 'ee/spec/services/vulnerability_exports/export_service_spec.rb'
- 'ee/spec/frontend/fixtures/analytics.rb'
- 'ee/spec/support/shared_contexts/lib/gitlab/insights/reducers/reducers_shared_contexts.rb'
- 'qa/spec/support/repeater_spec.rb'
- 'spec/features/profiles/active_sessions_spec.rb'
......
991ab1619abd34de70388d277892af9ad4c4994c
940a45ca938b20031820a4976f936a5b6173de92
......@@ -377,7 +377,7 @@ group :development, :test do
gem 'rubocop-rspec', '~> 1.37.0'
gem 'scss_lint', '~> 0.56.0', require: false
gem 'haml_lint', '~> 0.34.0', require: false
gem 'haml_lint', '~> 0.36.0', require: false
gem 'bundler-audit', '~> 0.6.1', require: false
gem 'benchmark-ips', '~> 2.3.0', require: false
......
......@@ -548,8 +548,9 @@ GEM
haml (5.1.2)
temple (>= 0.8.0)
tilt
haml_lint (0.34.0)
haml (>= 4.0, < 5.2)
haml_lint (0.36.0)
haml (>= 4.0, < 5.3)
parallel (~> 1.10)
rainbow
rubocop (>= 0.50.0)
sysexits (~> 1.1)
......@@ -1370,7 +1371,7 @@ DEPENDENCIES
grpc (~> 1.30.2)
gssapi
guard-rspec
haml_lint (~> 0.34.0)
haml_lint (~> 0.36.0)
hamlit (~> 2.11.0)
hangouts-chat (~> 0.0.5)
hashie
......
......@@ -17,10 +17,13 @@ export default {
},
},
computed: {
seriesData() {
return {
full: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]),
};
barSeriesData() {
return [
{
name: 'full',
data: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]),
},
];
},
},
};
......@@ -30,7 +33,7 @@ export default {
<div class="gl-xs-w-full">
<gl-column-chart
v-if="formattedData.keys"
:data="seriesData"
:bars="barSeriesData"
:x-axis-title="__('Value')"
:y-axis-title="__('Number of events')"
:x-axis-type="'category'"
......
......@@ -737,6 +737,12 @@ const Api = {
return axios.get(url, { params: { page } });
},
searchFeatureFlagUserLists(id, search) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
return axios.get(url, { params: { search } });
},
createFeatureFlagUserList(id, list) {
const url = Api.buildUrl(this.featureFlagUserLists).replace(':id', id);
......
......@@ -27,6 +27,7 @@ export default {
data() {
return {
content: '',
loading: false,
valid: false,
errors: null,
warnings: null,
......@@ -44,6 +45,7 @@ export default {
},
methods: {
async lint() {
this.loading = true;
try {
const {
data: {
......@@ -62,6 +64,8 @@ export default {
} catch (error) {
this.apiError = error;
this.isErrorDismissed = false;
} finally {
this.loading = false;
}
},
clear() {
......@@ -93,6 +97,7 @@ export default {
<div class="gl-display-flex gl-align-items-center">
<gl-button
class="gl-mr-4"
:loading="loading"
category="primary"
variant="success"
data-testid="ci-lint-validate"
......
<script>
import { ApolloMutation } from 'vue-apollo';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import destroyDesignMutation from '../graphql/mutations/destroy_design.mutation.graphql';
import { updateStoreAfterDesignsDelete } from '../utils/cache_update';
......
<script>
import { GlButton, GlIcon } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import { __, sprintf } from '~/locale';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import DesignNavigation from './design_navigation.vue';
import DeleteButton from '../delete_button.vue';
import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql';
import { DESIGNS_ROUTE_NAME } from '../../router/constants';
export default {
......
import { propertyOf } from 'lodash';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__ } from '~/locale';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allVersionsMixin from './all_versions';
import { DESIGNS_ROUTE_NAME } from '../router/constants';
......
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import { findVersionId } from '../utils/design_management_utils';
export default {
......
<script>
import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import VueDraggable from 'vuedraggable';
import getDesignListQuery from 'shared_queries/design_management/get_design_list.query.graphql';
import permissionsQuery from 'shared_queries/design_management/design_permissions.query.graphql';
import createFlash, { FLASH_TYPES } from '~/flash';
import { s__, sprintf } from '~/locale';
import { getFilename } from '~/lib/utils/file_upload';
......@@ -12,8 +14,6 @@ import DesignVersionDropdown from '../components/upload/design_version_dropdown.
import DesignDropzone from '../components/upload/design_dropzone.vue';
import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql';
import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql';
import permissionsQuery from '../graphql/queries/design_permissions.query.graphql';
import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql';
import allDesignsMixin from '../mixins/all_designs';
import {
UPLOAD_DESIGN_ERROR,
......
......@@ -11,10 +11,8 @@ import {
GlSprintf,
GlIcon,
} from '@gitlab/ui';
import Api from '~/api';
import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue';
import { s__ } from '~/locale';
import { deprecatedCreateFlash as flash, FLASH_TYPES } from '~/flash';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ToggleButton from '~/vue_shared/components/toggle_button.vue';
import EnvironmentsDropdown from './environments_dropdown.vue';
......@@ -89,7 +87,6 @@ export default {
},
},
inject: {
projectId: {},
featureFlagIssuesEndpoint: {
default: '',
},
......@@ -124,7 +121,6 @@ export default {
formStrategies: cloneDeep(this.strategies),
newScope: '',
userLists: [],
};
},
computed: {
......@@ -155,17 +151,6 @@ export default {
);
},
},
mounted() {
if (this.supportsStrategies) {
Api.fetchFeatureFlagUserLists(this.projectId)
.then(({ data }) => {
this.userLists = data;
})
.catch(() => {
flash(s__('FeatureFlags|There was an error retrieving user lists'), FLASH_TYPES.WARNING);
});
}
},
methods: {
keyFor(strategy) {
if (strategy.id) {
......@@ -346,7 +331,6 @@ export default {
:key="keyFor(strategy)"
:strategy="strategy"
:index="index"
:user-lists="userLists"
@change="onFormStrategyChange($event, index)"
@delete="deleteStrategy(strategy)"
/>
......
<script>
import { GlFormSelect } from '@gitlab/ui';
import { debounce } from 'lodash';
import { createNamespacedHelpers } from 'vuex';
import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
import { s__ } from '~/locale';
import ParameterFormGroup from './parameter_form_group.vue';
const { mapActions, mapGetters, mapState } = createNamespacedHelpers('userLists');
const { fetchUserLists, setFilter } = mapActions(['fetchUserLists', 'setFilter']);
export default {
components: {
GlFormSelect,
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
ParameterFormGroup,
},
props: {
......@@ -13,34 +22,40 @@ export default {
required: true,
type: Object,
},
userLists: {
required: false,
type: Array,
default: () => [],
},
},
translations: {
rolloutUserListLabel: s__('FeatureFlag|List'),
rolloutUserListLabel: s__('FeatureFlag|User List'),
rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
defaultDropdownText: s__('FeatureFlags|Select a user list'),
},
computed: {
userListOptions() {
return this.userLists.map(({ name, id }) => ({ value: id, text: name }));
},
hasUserLists() {
return this.userListOptions.length > 0;
},
...mapGetters(['hasUserLists', 'isLoading', 'hasError', 'userListOptions']),
...mapState(['filter', 'userLists']),
userListId() {
return this.strategy?.userListId ?? '';
return this.strategy?.userList?.id ?? '';
},
dropdownText() {
return this.strategy?.userList?.name ?? this.$options.defaultDropdownText;
},
},
mounted() {
fetchUserLists.apply(this);
},
methods: {
setFilter: debounce(setFilter, 250),
fetchUserLists: debounce(fetchUserLists, 250),
onUserListChange(list) {
this.$emit('change', {
userListId: list,
userList: list,
});
},
isSelectedUserList({ id }) {
return id === this.userListId;
},
setFocus() {
this.$refs.searchBox.focusInput();
},
},
};
</script>
......@@ -52,12 +67,26 @@ export default {
:description="hasUserLists ? $options.translations.rolloutUserListDescription : ''"
>
<template #default="{ inputId }">
<gl-form-select
:id="inputId"
:value="userListId"
:options="userListOptions"
@change="onUserListChange"
/>
<gl-dropdown :id="inputId" :text="dropdownText" @shown="setFocus">
<gl-search-box-by-type
ref="searchBox"
class="gl-m-3"
:value="filter"
@input="setFilter"
@focus="fetchUserLists"
@keyup="fetchUserLists"
/>
<gl-loading-icon v-if="isLoading" />
<gl-dropdown-item
v-for="list in userLists"
:key="list.id"
:is-checked="isSelectedUserList(list)"
is-check-item
@click="onUserListChange(list)"
>
{{ list.name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
</parameter-form-group>
</template>
......@@ -22,7 +22,7 @@ export default () => {
} = el.dataset;
return new Vue({
store: createStore({ endpoint, path: featureFlagsPath }),
store: createStore({ endpoint, projectId, path: featureFlagsPath }),
el,
provide: {
environmentsScopeDocsPath,
......
......@@ -22,7 +22,7 @@ export default () => {
return new Vue({
el,
store: createStore({ endpoint, path: featureFlagsPath }),
store: createStore({ endpoint, projectId, path: featureFlagsPath }),
provide: {
environmentsScopeDocsPath,
strategyTypeDocsPagePath,
......
import Vuex from 'vuex';
import userLists from '../gitlab_user_list';
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
......@@ -8,4 +9,7 @@ export default data =>
actions,
mutations,
state: state(data),
modules: {
userLists: userLists(data),
},
});
import Api from '~/api';
import * as types from './mutation_types';
const getErrorMessages = error => [].concat(error?.response?.data?.message ?? error.message);
export const fetchUserLists = ({ commit, state: { filter, projectId } }) => {
commit(types.FETCH_USER_LISTS);
return Api.searchFeatureFlagUserLists(projectId, filter)
.then(({ data }) => commit(types.RECEIVE_USER_LISTS_SUCCESS, data))
.catch(error => commit(types.RECEIVE_USER_LISTS_ERROR, getErrorMessages(error)));
};
export const setFilter = ({ commit, dispatch }, filter) => {
commit(types.SET_FILTER, filter);
return dispatch('fetchUserLists');
};
import statuses from './status';
export const userListOptions = ({ userLists }) =>
userLists.map(({ name, id }) => ({ value: id, text: name }));
export const hasUserLists = ({ userLists, status }) =>
[statuses.START, statuses.LOADING].indexOf(status) > -1 || userLists.length > 0;
export const isLoading = ({ status }) => status === statuses.LOADING;
export const hasError = ({ status }) => status === statuses.ERROR;
import state from './state';
import mutations from './mutations';
import * as actions from './actions';
import * as getters from './getters';
export default data => ({
state: state(data),
actions,
getters,
mutations,
namespaced: true,
});
export const FETCH_USER_LISTS = 'FETCH_USER_LISTS';
export const RECEIVE_USER_LISTS_SUCCESS = 'RECEIVE_USER_LISTS_SUCCESS';
export const RECEIVE_USER_LISTS_ERROR = 'RECEIVE_USER_LISTS_ERROR';
export const SET_FILTER = 'SET_FILTER';
import statuses from './status';
import * as types from './mutation_types';
export default {
[types.FETCH_USER_LISTS](state) {
state.status = statuses.LOADING;
},
[types.RECEIVE_USER_LISTS_SUCCESS](state, lists) {
state.userLists = lists;
state.status = statuses.IDLE;
},
[types.RECEIVE_USER_LISTS_ERROR](state, error) {
state.error = error;
state.status = statuses.ERROR;
},
[types.SET_FILTER](state, filter) {
state.filter = filter;
},
};
import statuses from './status';
export default ({ projectId }) => ({
projectId,
userLists: [],
filter: '',
status: statuses.START,
error: '',
});
export default {
START: 'START',
LOADING: 'LOADING',
IDLE: 'IDLE',
ERROR: 'ERROR',
};
......@@ -174,7 +174,7 @@ export const mapStrategiesToViewModel = strategiesFromRails =>
id: s.id,
name: s.name,
parameters: mapStrategiesParametersToViewModel(s.parameters),
userListId: s.user_list?.id,
userList: s.user_list,
// eslint-disable-next-line no-underscore-dangle
shouldBeDestroyed: Boolean(s._destroy),
scopes: mapStrategyScopesToView(s.scopes),
......@@ -197,7 +197,7 @@ const mapStrategyToRails = strategy => {
};
if (strategy.name === ROLLOUT_STRATEGY_GITLAB_USER_LIST) {
mappedStrategy.user_list_id = strategy.userListId;
mappedStrategy.user_list_id = strategy.userList.id;
}
return mappedStrategy;
};
......
import Vuex from 'vuex';
import userLists from '../gitlab_user_list';
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
......@@ -8,4 +9,7 @@ export default data =>
actions,
mutations,
state: state(data),
modules: {
userLists: userLists(data),
},
});
......@@ -96,43 +96,41 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => {
IssuableTokenKeys.tokenKeysWithAlternative.splice(tokenPosition, 0, ...[approvedBy.token]);
IssuableTokenKeys.conditions.push(...approvedBy.condition);
if (gon?.features?.deploymentFilters) {
const environmentToken = {
formattedKey: __('Environment'),
key: 'environment',
type: 'string',
param: '',
symbol: '',
icon: 'cloud-gear',
tag: 'environment',
};
const environmentToken = {
formattedKey: __('Environment'),
key: 'environment',
type: 'string',
param: '',
symbol: '',
icon: 'cloud-gear',
tag: 'environment',
};
const deployedBeforeToken = {
formattedKey: __('Deployed-before'),
key: 'deployed-before',
type: 'string',
param: '',
symbol: '',
icon: 'clock',
tag: 'deployed_before',
};
const deployedBeforeToken = {
formattedKey: __('Deployed-before'),
key: 'deployed-before',
type: 'string',
param: '',
symbol: '',
icon: 'clock',
tag: 'deployed_before',
};
const deployedAfterToken = {
formattedKey: __('Deployed-after'),
key: 'deployed-after',
type: 'string',
param: '',
symbol: '',
icon: 'clock',
tag: 'deployed_after',
};
const deployedAfterToken = {
formattedKey: __('Deployed-after'),
key: 'deployed-after',
type: 'string',
param: '',
symbol: '',
icon: 'clock',
tag: 'deployed_after',
};
IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken);
IssuableTokenKeys.tokenKeys.push(environmentToken, deployedBeforeToken, deployedAfterToken);
IssuableTokenKeys.tokenKeysWithAlternative.push(
environmentToken,
deployedBeforeToken,
deployedAfterToken,
);
}
IssuableTokenKeys.tokenKeysWithAlternative.push(
environmentToken,
deployedBeforeToken,
deployedAfterToken,
);
};
......@@ -3,9 +3,8 @@
import $ from 'jquery';
import 'vendor/jquery.scrollTo';
import { GlLoadingIcon } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { GlLoadingIcon, GlModal } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { HIDDEN_CLASS } from '~/lib/utils/constants';
import { getParameterByName } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
......@@ -16,8 +15,8 @@ import groupsComponent from './groups.vue';
export default {
components: {
DeprecatedModal,
groupsComponent,
GlModal,
GlLoadingIcon,
},
props: {
......@@ -49,13 +48,30 @@ export default {
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
showModal: false,
groupLeaveConfirmationMessage: '',
targetGroup: null,
targetParentGroup: null,
};
},
computed: {
primaryProps() {
return {
text: __('Leave group'),
attributes: [{ variant: 'warning' }, { category: 'primary' }],
};
},
cancelProps() {
return {
text: __('Cancel'),
};
},
groupLeaveConfirmationMessage() {
if (!this.targetGroup) {
return '';
}
return sprintf(s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), {
fullName: this.targetGroup.fullName,
});
},
groups() {
return this.store.getGroups();
},
......@@ -171,27 +187,17 @@ export default {
}
},
showLeaveGroupModal(group, parentGroup) {
const { fullName } = group;
this.targetGroup = group;
this.targetParentGroup = parentGroup;
this.showModal = true;
this.groupLeaveConfirmationMessage = sprintf(
s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'),
{ fullName },
);
},
hideLeaveGroupModal() {
this.showModal = false;
},
leaveGroup() {
this.showModal = false;
this.targetGroup.isBeingRemoved = true;
this.service
.leaveGroup(this.targetGroup.leavePath)
.then(res => {
$.scrollTo(0);
this.store.removeGroup(this.targetGroup, this.targetParentGroup);
Flash(res.data.notice, 'notice');
this.$toast.show(res.data.notice);
})
.catch(err => {
let message = COMMON_STR.FAILURE;
......@@ -245,21 +251,21 @@ export default {
class="loading-animation prepend-top-20"
/>
<groups-component
v-if="!isLoading"
v-else
:groups="groups"
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
:action="action"
/>
<deprecated-modal
v-show="showModal"
:primary-button-label="__('Leave')"
<gl-modal
modal-id="leave-group-modal"
:title="__('Are you sure?')"
:text="groupLeaveConfirmationMessage"
kind="warning"
@cancel="hideLeaveGroupModal"
@submit="leaveGroup"
/>
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary="leaveGroup"
>
{{ groupLeaveConfirmationMessage }}
</gl-modal>
</div>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { GlTooltipDirective, GlButton, GlModalDirective } from '@gitlab/ui';
import eventHub from '../event_hub';
import { COMMON_STR } from '../constants';
export default {
components: {
GlIcon,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
GlModal: GlModalDirective,
},
props: {
parentGroup: {
......@@ -44,28 +45,28 @@ export default {
<template>
<div class="controls d-flex justify-content-end">
<a
<gl-button
v-if="group.canLeave"
v-gl-tooltip.top
:href="group.leavePath"
v-gl-modal.leave-group-modal
:title="leaveBtnTitle"
:aria-label="leaveBtnTitle"
data-testid="leave-group-btn"
class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
@click.prevent="onLeaveGroup"
>
<gl-icon name="leave" class="position-top-0" />
</a>
<a
size="small"
icon="leave"
class="leave-group gl-ml-3"
@click.stop="onLeaveGroup"
/>
<gl-button
v-if="group.canEdit"
v-gl-tooltip.top
:href="group.editPath"
:title="editBtnTitle"
:aria-label="editBtnTitle"
data-testid="edit-group-btn"
class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5"
>
<gl-icon name="settings" class="position-top-0 align-middle" />
</a>
size="small"
icon="pencil"
class="edit-group gl-ml-3"
/>
</div>
</template>
import Vue from 'vue';
import { GlToast } from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import Translate from '../vue_shared/translate';
import GroupFilterableList from './groups_filterable_list';
......@@ -31,6 +32,8 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => {
Vue.component('group-folder', groupFolderComponent);
Vue.component('group-item', groupItemComponent);
Vue.use(GlToast);
// eslint-disable-next-line no-new
new Vue({
el,
......
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlIcon, GlLink, GlModal } from '@gitlab/ui';
import { mapGetters } from 'vuex';
import createFlash from '~/flash';
import { IssuableStatus, IssueStateEvent } from '~/issue_show/constants';
import { __ } from '~/locale';
import updateIssueMutation from '../queries/update_issue.mutation.graphql';
export default {
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlIcon,
GlLink,
GlModal,
},
actionCancel: {
text: __('Cancel'),
},
actionPrimary: {
text: __('Yes, close issue'),
attributes: [{ variant: 'warning' }],
},
inject: [
'canCreateIssue',
'canReopenIssue',
'canReportSpam',
'canUpdateIssue',
'iid',
'isIssueAuthor',
'newIssuePath',
'projectPath',
'reportAbusePath',
'submitAsSpamPath',
],
data() {
return {
isUpdatingState: false,
};
},
computed: {
...mapGetters(['getNoteableData']),
isClosed() {
return this.getNoteableData.state === IssuableStatus.Closed;
},
buttonText() {
return this.isClosed ? __('Reopen issue') : __('Close issue');
},
buttonVariant() {
return this.isClosed ? 'default' : 'warning';
},
showToggleIssueButton() {
const canClose = !this.isClosed && this.canUpdateIssue;
const canReopen = this.isClosed && this.canReopenIssue;
return canClose || canReopen;
},
},
methods: {
toggleIssueState() {
if (!this.isClosed && this.getNoteableData?.blocked_by_issues?.length) {
this.$refs.blockedByIssuesModal.show();
return;
}
this.invokeUpdateIssueMutation();
},
invokeUpdateIssueMutation() {
this.isUpdatingState = true;
this.$apollo
.mutate({
mutation: updateIssueMutation,
variables: {
input: {
iid: this.iid.toString(),
projectPath: this.projectPath,
stateEvent: this.isClosed ? IssueStateEvent.Reopen : IssueStateEvent.Close,
},
},
})
.then(({ data }) => {
if (data.updateIssue.errors.length) {
createFlash(data.updateIssue.errors.join('. '));
return;
}
const payload = {
detail: {
data: { id: this.iid },
isClosed: !this.isClosed,
},
};
// Dispatch event which updates open/close state, shared among the issue show page
document.dispatchEvent(new CustomEvent('issuable_vue_app:change', payload));
})
.catch(() => createFlash(__('Update failed. Please try again.')))
.finally(() => {
this.isUpdatingState = false;
});
},
},
};
</script>
<template>
<div class="detail-page-header-actions">
<gl-dropdown class="gl-display-block gl-display-sm-none!" block :text="__('Issue actions')">
<gl-dropdown-item
v-if="showToggleIssueButton"
:disabled="isUpdatingState"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-dropdown-item>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ __('New issue') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
data-method="post"
rel="nofollow"
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
</gl-dropdown>
<gl-button
v-if="showToggleIssueButton"
class="gl-display-none gl-display-sm-inline-flex!"
category="secondary"
:loading="isUpdatingState"
:variant="buttonVariant"
@click="toggleIssueState"
>
{{ buttonText }}
</gl-button>
<gl-dropdown
class="gl-display-none gl-display-sm-inline-flex!"
toggle-class="gl-border-0! gl-shadow-none!"
no-caret
right
>
<template #button-content>
<gl-icon name="ellipsis_v" aria-hidden="true" />
<span class="gl-sr-only">{{ __('Actions') }}</span>
</template>
<gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath">
{{ __('New issue') }}
</gl-dropdown-item>
<gl-dropdown-item v-if="!isIssueAuthor" :href="reportAbusePath">
{{ __('Report abuse') }}
</gl-dropdown-item>
<gl-dropdown-item
v-if="canReportSpam"
:href="submitAsSpamPath"
data-method="post"
rel="nofollow"
>
{{ __('Submit as spam') }}
</gl-dropdown-item>
</gl-dropdown>
<gl-modal
ref="blockedByIssuesModal"
modal-id="blocked-by-issues-modal"
:action-cancel="$options.actionCancel"
:action-primary="$options.actionPrimary"
:title="__('Are you sure you want to close this blocked issue?')"
@primary="invokeUpdateIssueMutation"
>
<p>{{ __('This issue is currently blocked by the following issues:') }}</p>
<ul>
<li v-for="issue in getNoteableData.blocked_by_issues" :key="issue.iid">
<gl-link :href="issue.web_url">#{{ issue.iid }}</gl-link>
</li>
</ul>
</gl-modal>
</div>
</template>
......@@ -18,5 +18,10 @@ export const IssuableType = {
MergeRequest: 'merge_request',
};
export const IssueStateEvent = {
Close: 'CLOSE',
Reopen: 'REOPEN',
};
export const STATUS_PAGE_PUBLISHED = __('Published on status page');
export const JOIN_ZOOM_MEETING = __('Join Zoom meeting');
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { mapGetters } from 'vuex';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils';
import IssuableApp from './components/app.vue';
import HeaderActions from './components/header_actions.vue';
export default function initIssuableApp(issuableData, store) {
export function initIssuableApp(issuableData, store) {
return new Vue({
el: document.getElementById('js-issuable-app'),
store,
......@@ -19,3 +23,36 @@ export default function initIssuableApp(issuableData, store) {
},
});
}
export function initIssueHeaderActions(store) {
const el = document.querySelector('.js-issue-header-actions');
if (!el) {
return undefined;
}
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
store,
provide: {
canCreateIssue: parseBoolean(el.dataset.canCreateIssue),
canReopenIssue: parseBoolean(el.dataset.canReopenIssue),
canReportSpam: parseBoolean(el.dataset.canReportSpam),
canUpdateIssue: parseBoolean(el.dataset.canUpdateIssue),
iid: el.dataset.iid,
isIssueAuthor: parseBoolean(el.dataset.isIssueAuthor),
newIssuePath: el.dataset.newIssuePath,
projectPath: el.dataset.projectPath,
reportAbusePath: el.dataset.reportAbusePath,
submitAsSpamPath: el.dataset.submitAsSpamPath,
},
render: createElement => createElement(HeaderActions),
});
}
mutation updateIssue($input: UpdateIssueInput!) {
updateIssue(input: $input) {
errors
}
}
......@@ -35,18 +35,14 @@ export default {
};
},
computed: {
chartData() {
const queryData = this.graphData.metrics.reduce((acc, query) => {
barChartData() {
return this.graphData.metrics.reduce((acc, query) => {
const series = makeDataSeries(query.result || [], {
name: this.formatLegendLabel(query),
});
return acc.concat(series);
}, []);
return {
values: queryData[0].data,
};
},
chartOptions() {
const xAxis = getTimeAxisOptions({ timezone: this.timezone });
......@@ -109,7 +105,7 @@ export default {
<gl-column-chart
ref="columnChart"
v-bind="$attrs"
:data="chartData"
:bars="barChartData"
:option="chartOptions"
:width="width"
:height="height"
......
......@@ -61,14 +61,16 @@ export default {
},
computed: {
chartData() {
return this.graphData.metrics.map(({ result }) => {
// This needs a fix. Not only metrics[0] should be shown.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
if (!result || result.length === 0) {
return [];
}
return result[0].values.map(val => val[1]);
});
return this.graphData.metrics
.map(({ label: name, result }) => {
// This needs a fix. Not only metrics[0] should be shown.
// See https://gitlab.com/gitlab-org/gitlab/-/issues/220492
if (!result || result.length === 0) {
return [];
}
return { name, data: result[0].values.map(val => val[1]) };
})
.slice(0, 1);
},
xAxisTitle() {
return this.graphData.x_label !== undefined ? this.graphData.x_label : '';
......@@ -136,7 +138,7 @@ export default {
<gl-stacked-column-chart
ref="chart"
v-bind="$attrs"
:data="chartData"
:bars="chartData"
:option="chartOptions"
:x-axis-title="xAxisTitle"
:y-axis-title="yAxisTitle"
......@@ -144,7 +146,6 @@ export default {
:group-by="groupBy"
:width="width"
:height="height"
:series-names="seriesNames"
:legend-layout="legendLayout"
:legend-average-text="legendAverageText"
:legend-current-text="legendCurrentText"
......
......@@ -5,6 +5,8 @@ import { __ } from '~/locale';
import CodeCoverage from '../components/code_coverage.vue';
import SeriesDataMixin from './series_data_mixin';
const seriesDataToBarData = raw => Object.entries(raw).map(([name, data]) => ({ name, data }));
document.addEventListener('DOMContentLoaded', () => {
waitForCSSLoaded(() => {
const languagesContainer = document.getElementById('js-languages-chart');
......@@ -41,13 +43,13 @@ document.addEventListener('DOMContentLoaded', () => {
},
computed: {
seriesData() {
return { full: this.chartData.map(d => [d.label, d.value]) };
return [{ name: 'full', data: this.chartData.map(d => [d.label, d.value]) }];
},
},
render(h) {
return h(GlColumnChart, {
props: {
data: this.seriesData,
bars: this.seriesData,
xAxisTitle: __('Used programming language'),
yAxisTitle: __('Percentage'),
xAxisType: 'category',
......@@ -86,7 +88,7 @@ document.addEventListener('DOMContentLoaded', () => {
render(h) {
return h(GlColumnChart, {
props: {
data: this.seriesData,
bars: seriesDataToBarData(this.seriesData),
xAxisTitle: __('Day of month'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
......@@ -113,13 +115,13 @@ document.addEventListener('DOMContentLoaded', () => {
acc.push([key, weekDays[key]]);
return acc;
}, []);
return { full: data };
return [{ name: 'full', data }];
},
},
render(h) {
return h(GlColumnChart, {
props: {
data: this.seriesData,
bars: this.seriesData,
xAxisTitle: __('Weekday'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
......@@ -143,7 +145,7 @@ document.addEventListener('DOMContentLoaded', () => {
render(h) {
return h(GlColumnChart, {
props: {
data: this.seriesData,
bars: seriesDataToBarData(this.seriesData),
xAxisTitle: __('Hour (UTC)'),
yAxisTitle: __('No. of commits'),
xAxisType: 'category',
......
......@@ -5,7 +5,7 @@ import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import ZenMode from '~/zen_mode';
import '~/notes/index';
import { store } from '~/notes/stores';
import initIssueApp from '~/issue_show/issue';
import { initIssuableApp, initIssueHeaderActions } from '~/issue_show/issue';
import initIncidentApp from '~/issue_show/incident';
import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning';
import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace';
......@@ -24,13 +24,14 @@ export default function() {
initIncidentApp(issuableData);
break;
case IssuableType.Issue:
initIssueApp(issuableData, store);
initIssuableApp(issuableData, store);
break;
default:
break;
}
initIssuableHeaderWarning(store);
initIssueHeaderActions(store);
initSentryErrorStackTraceApp();
initRelatedMergeRequestsApp();
......
<script>
import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui';
import { escape } from 'lodash';
import { s__, sprintf } from '~/locale';
import { s__, __, sprintf } from '~/locale';
export default {
components: {
......@@ -29,12 +29,6 @@ export default {
},
},
computed: {
modalId() {
return 'delete-wiki-modal';
},
message() {
return s__('WikiPageConfirmDelete|Are you sure you want to delete this page?');
},
title() {
return sprintf(
s__('WikiPageConfirmDelete|Delete page %{pageTitle}?'),
......@@ -44,6 +38,17 @@ export default {
false,
);
},
primaryProps() {
return {
text: this.$options.i18n.deletePageText,
attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' },
};
},
cancelProps() {
return {
text: this.$options.i18n.cancelButtonText,
};
},
},
methods: {
onSubmit() {
......@@ -51,30 +56,36 @@ export default {
this.$refs.form.submit();
},
},
i18n: {
deletePageText: s__('WikiPageConfirmDelete|Delete page'),
modalBody: s__('WikiPageConfirmDelete|Are you sure you want to delete this page?'),
cancelButtonText: __('Cancel'),
},
modal: {
modalId: 'delete-wiki-modal',
},
};
</script>
<template>
<div class="d-inline-block">
<gl-button
v-gl-modal="modalId"
category="primary"
v-gl-modal="$options.modal.modalId"
category="secondary"
variant="danger"
data-qa-selector="delete_button"
>
{{ __('Delete') }}
{{ $options.i18n.deletePageText }}
</gl-button>
<gl-modal
:title="title"
:action-primary="{
text: s__('WikiPageConfirmDelete|Delete page'),
attributes: { variant: 'danger', 'data-qa-selector': 'confirm_deletion_button' },
}"
:modal-id="modalId"
title-tag="h4"
:action-primary="primaryProps"
:action-cancel="cancelProps"
:modal-id="$options.modal.modalId"
size="sm"
@ok="onSubmit"
>
{{ message }}
{{ $options.i18n.modalBody }}
<form ref="form" :action="deleteWikiUrl" method="post" class="js-requires-input">
<input ref="method" type="hidden" name="_method" value="delete" />
<input :value="csrfToken" type="hidden" name="authenticity_token" />
......
......@@ -16,6 +16,11 @@ export default {
</script>
<template>
<div class="gl-border-solid gl-border-gray-100 gl-border-1">
<editor-lite v-model="value" file-name="*.yml" :editor-options="{ readOnly: true }" />
<editor-lite
v-model="value"
file-name="*.yml"
:editor-options="{ readOnly: true }"
@editor-ready="$emit('editor-ready')"
/>
</div>
</template>
<script>
import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
import { GlLoadingIcon, GlAlert, GlTabs, GlTab } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import TextEditor from './components/text_editor.vue';
import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue';
import getBlobContent from './graphql/queries/blob_content.graphql';
......@@ -10,7 +11,10 @@ export default {
components: {
GlLoadingIcon,
GlAlert,
GlTabs,
GlTab,
TextEditor,
PipelineGraph,
},
props: {
projectPath: {
......@@ -31,6 +35,7 @@ export default {
return {
error: null,
content: '',
editorIsReady: false,
};
},
apollo: {
......@@ -66,10 +71,16 @@ export default {
const reason = networkReason ?? generalReason ?? this.$options.i18n.unknownError;
return sprintf(this.$options.i18n.errorMessageWithReason, { reason });
},
pipelineData() {
// Note data will loaded as part of https://gitlab.com/gitlab-org/gitlab/-/issues/263141
return {};
},
},
i18n: {
unknownError: __('Unknown Error'),
errorMessageWithReason: s__('Pipelines|CI file could not be loaded: %{reason}'),
tabEdit: s__('Pipelines|Write pipeline configuration'),
tabGraph: s__('Pipelines|Visualize'),
},
};
</script>
......@@ -79,7 +90,19 @@ export default {
<gl-alert v-if="error" :dismissible="false" variant="danger">{{ errorMessage }}</gl-alert>
<div class="gl-mt-4">
<gl-loading-icon v-if="loading" size="lg" />
<text-editor v-else v-model="content" />
<div v-else class="file-editor">
<gl-tabs>
<!-- editor should be mounted when its tab is visible, so the container has a size -->
<gl-tab :title="$options.i18n.tabEdit" :lazy="!editorIsReady">
<!-- editor should be mounted only once, when the tab is displayed -->
<text-editor v-model="content" @editor-ready="editorIsReady = true" />
</gl-tab>
<gl-tab :title="$options.i18n.tabGraph">
<pipeline-graph :pipeline-data="pipelineData" />
</gl-tab>
</gl-tabs>
</div>
</div>
</div>
</template>
......@@ -45,9 +45,12 @@ export default {
},
data() {
return {
timesChartTransformedData: {
full: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
},
timesChartTransformedData: [
{
name: 'full',
data: this.mergeLabelsAndValues(this.timesChartData.labels, this.timesChartData.values),
},
],
};
},
computed: {
......@@ -128,7 +131,7 @@ export default {
<gl-column-chart
:height="$options.chartContainerHeight"
:option="$options.timesChartOptions"
:data="timesChartTransformedData"
:bars="timesChartTransformedData"
:y-axis-title="__('Minutes')"
:x-axis-title="__('Commit')"
x-axis-type="category"
......
......@@ -16,12 +16,7 @@ import { performanceMarkAndMeasure } from '~/performance_utils';
import UpdateSnippetMutation from '../mutations/updateSnippet.mutation.graphql';
import CreateSnippetMutation from '../mutations/createSnippet.mutation.graphql';
import { getSnippetMixin } from '../mixins/snippets';
import {
SNIPPET_CREATE_MUTATION_ERROR,
SNIPPET_UPDATE_MUTATION_ERROR,
SNIPPET_VISIBILITY_PRIVATE,
} from '../constants';
import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '../constants';
import { markBlobPerformance } from '../utils/blob';
import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue';
......@@ -41,15 +36,7 @@ export default {
GlLoadingIcon,
},
mixins: [getSnippetMixin],
apollo: {
defaultVisibility: {
query: defaultVisibilityQuery,
manual: true,
result({ data: { selectedLevel } }) {
this.selectedLevelDefault = selectedLevel;
},
},
},
inject: ['selectedLevel'],
props: {
markdownPreviewPath: {
type: String,
......@@ -73,9 +60,12 @@ export default {
data() {
return {
isUpdating: false,
newSnippet: false,
actions: [],
selectedLevelDefault: SNIPPET_VISIBILITY_PRIVATE,
snippet: {
title: '',
description: '',
visibilityLevel: this.selectedLevel,
},
};
},
computed: {
......@@ -112,13 +102,6 @@ export default {
}
return this.snippet.webUrl;
},
newSnippetSchema() {
return {
title: '',
description: '',
visibilityLevel: this.selectedLevelDefault,
};
},
},
beforeCreate() {
performanceMarkAndMeasure({ mark: SNIPPET_MARK_EDIT_APP_START });
......@@ -145,20 +128,6 @@ export default {
Flash(sprintf(defaultErrorMsg, { err }));
this.isUpdating = false;
},
onNewSnippetFetched() {
this.newSnippet = true;
this.snippet = this.newSnippetSchema;
},
onExistingSnippetFetched() {
this.newSnippet = false;
},
onSnippetFetch(snippetRes) {
if (snippetRes.data.snippets.nodes.length === 0) {
this.onNewSnippetFetched();
} else {
this.onExistingSnippetFetched();
}
},
getAttachedFiles() {
const fileInputs = Array.from(this.$el.querySelectorAll('[name="files[]"]'));
return fileInputs.map(node => node.value);
......@@ -209,7 +178,7 @@ export default {
</script>
<template>
<form
class="snippet-form js-requires-input js-quick-submit common-note-form"
class="snippet-form js-quick-submit common-note-form"
:data-snippet-type="isProjectSnippet ? 'project' : 'personal'"
data-testid="snippet-edit-form"
@submit.prevent="handleFormSubmit"
......
<script>
import { GlIcon, GlFormGroup, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import defaultVisibilityQuery from '../queries/snippet_visibility.query.graphql';
import { defaultSnippetVisibilityLevels } from '../utils/blob';
import { SNIPPET_LEVELS_RESTRICTED, SNIPPET_LEVELS_DISABLED } from '~/snippets/constants';
......@@ -12,16 +11,7 @@ export default {
GlFormRadioGroup,
GlLink,
},
apollo: {
defaultVisibility: {
query: defaultVisibilityQuery,
manual: true,
result({ data: { visibilityLevels, multipleLevelsRestricted } }) {
this.visibilityLevels = defaultSnippetVisibilityLevels(visibilityLevels);
this.multipleLevelsRestricted = multipleLevelsRestricted;
},
},
},
inject: ['visibilityLevels', 'multipleLevelsRestricted'],
props: {
helpLink: {
type: String,
......@@ -38,11 +28,10 @@ export default {
required: true,
},
},
data() {
return {
visibilityLevels: [],
multipleLevelsRestricted: false,
};
computed: {
defaultVisibilityLevels() {
return defaultSnippetVisibilityLevels(this.visibilityLevels);
},
},
SNIPPET_LEVELS_DISABLED,
SNIPPET_LEVELS_RESTRICTED,
......@@ -59,7 +48,7 @@ export default {
<gl-form-group id="visibility-level-setting" class="gl-mb-0">
<gl-form-radio-group :checked="value" stacked v-bind="$attrs" v-on="$listeners">
<gl-form-radio
v-for="option in visibilityLevels"
v-for="option in defaultVisibilityLevels"
:key="option.value"
:value="option.value"
class="mb-3"
......@@ -78,7 +67,9 @@ export default {
</gl-form-group>
<div class="text-muted" data-testid="restricted-levels-info">
<template v-if="!visibilityLevels.length">{{ $options.SNIPPET_LEVELS_DISABLED }}</template>
<template v-if="!defaultVisibilityLevels.length">{{
$options.SNIPPET_LEVELS_DISABLED
}}</template>
<template v-else-if="multipleLevelsRestricted">{{
$options.SNIPPET_LEVELS_RESTRICTED
}}</template>
......
......@@ -24,17 +24,14 @@ export default function appFactory(el, Component) {
...restDataset
} = el.dataset;
apolloProvider.clients.defaultClient.cache.writeData({
data: {
return new Vue({
el,
apolloProvider,
provide: {
visibilityLevels: JSON.parse(visibilityLevels),
selectedLevel: SNIPPET_LEVELS_MAP[selectedLevel] ?? SNIPPET_VISIBILITY_PRIVATE,
multipleLevelsRestricted: 'multipleLevelsRestricted' in el.dataset,
},
});
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(Component, {
props: {
......
......@@ -21,9 +21,9 @@ export const getSnippetMixin = {
},
result(res) {
this.blobs = res.data.snippets.nodes[0]?.blobs || blobsDefault;
if (this.onSnippetFetch) {
this.onSnippetFetch(res);
}
},
skip() {
return this.newSnippet;
},
},
},
......@@ -36,7 +36,7 @@ export const getSnippetMixin = {
data() {
return {
snippet: {},
newSnippet: false,
newSnippet: !this.snippetGid,
blobs: blobsDefault,
};
},
......
query defaultSnippetVisibility {
visibilityLevels @client
selectedLevel @client
multipleLevelsRestricted @client
}
......@@ -51,6 +51,7 @@ export const FIELDS = [
key: 'actions',
thClass: 'col-actions',
tdClass: 'col-actions',
showFunction: 'showActionsField',
},
];
......
......@@ -2,6 +2,12 @@
import { mapState } from 'vuex';
import { GlTable, GlBadge } from '@gitlab/ui';
import MembersTableCell from 'ee_else_ce/vue_shared/components/members/table/members_table_cell.vue';
import {
canOverride,
canRemove,
canResend,
canUpdate,
} from 'ee_else_ce/vue_shared/components/members/utils';
import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
......@@ -33,14 +39,40 @@ export default {
),
},
computed: {
...mapState(['members', 'tableFields']),
...mapState(['members', 'tableFields', 'currentUserId', 'sourceId']),
filteredFields() {
return FIELDS.filter(field => this.tableFields.includes(field.key));
return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field));
},
userIsLoggedIn() {
return this.currentUserId !== null;
},
},
mounted() {
initUserPopovers(this.$el.querySelectorAll('.js-user-link'));
},
methods: {
showField(field) {
if (!Object.prototype.hasOwnProperty.call(field, 'showFunction')) {
return true;
}
return this[field.showFunction]();
},
showActionsField() {
if (!this.userIsLoggedIn) {
return false;
}
return this.members.some(member => {
return (
canRemove(member, this.sourceId) ||
canResend(member) ||
canUpdate(member, this.currentUserId, this.sourceId) ||
canOverride(member)
);
});
},
},
};
</script>
......
<script>
import { mapState } from 'vuex';
import { MEMBER_TYPES } from '../constants';
import { isGroup, isDirectMember, isCurrentUser, canRemove, canResend, canUpdate } from '../utils';
export default {
name: 'MembersTableCell',
......@@ -13,7 +14,7 @@ export default {
computed: {
...mapState(['sourceId', 'currentUserId']),
isGroup() {
return Boolean(this.member.sharedWithGroup);
return isGroup(this.member);
},
isInvite() {
return Boolean(this.member.invite);
......@@ -33,19 +34,19 @@ export default {
return MEMBER_TYPES.user;
},
isDirectMember() {
return this.isGroup || this.member.source?.id === this.sourceId;
return isDirectMember(this.member, this.sourceId);
},
isCurrentUser() {
return this.member.user?.id === this.currentUserId;
return isCurrentUser(this.member, this.currentUserId);
},
canRemove() {
return this.isDirectMember && this.member.canRemove;
return canRemove(this.member, this.sourceId);
},
canResend() {
return Boolean(this.member.invite?.canResend);
return canResend(this.member);
},
canUpdate() {
return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
return canUpdate(this.member, this.currentUserId, this.sourceId);
},
},
render() {
......
......@@ -17,3 +17,32 @@ export const generateBadges = (member, isCurrentUser) => [
variant: 'info',
},
];
export const isGroup = member => {
return Boolean(member.sharedWithGroup);
};
export const isDirectMember = (member, sourceId) => {
return isGroup(member) || member.source?.id === sourceId;
};
export const isCurrentUser = (member, currentUserId) => {
return member.user?.id === currentUserId;
};
export const canRemove = (member, sourceId) => {
return isDirectMember(member, sourceId) && member.canRemove;
};
export const canResend = member => {
return Boolean(member.invite?.canResend);
};
export const canUpdate = (member, currentUserId, sourceId) => {
return (
!isCurrentUser(member, currentUserId) && isDirectMember(member, sourceId) && member.canUpdate
);
};
// Defined in `ee/app/assets/javascripts/vue_shared/components/members/utils.js`
export const canOverride = () => false;
......@@ -92,6 +92,13 @@ export default {
}
}
},
handleComponentAppear() {
// We can avoid putting `catch` block here
// as failure is handled within actions.js already.
return this.fetchLabels().then(() => {
this.$refs.searchInput.focusInput();
});
},
/**
* We want to remove loaded labels to ensure component
* fetches fresh set of labels every time when shown.
......@@ -139,7 +146,7 @@ export default {
</script>
<template>
<gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear">
<gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
<div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
<div
v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
......@@ -158,8 +165,8 @@ export default {
</div>
<div class="dropdown-input" @click.stop="() => {}">
<gl-search-box-by-type
ref="searchInput"
v-model="searchKey"
:autofocus="true"
:disabled="labelsFetchInProgress"
data-qa-selector="dropdown_input_field"
/>
......
......@@ -20,7 +20,7 @@ export const receiveLabelsFailure = ({ commit }) => {
};
export const fetchLabels = ({ state, dispatch }) => {
dispatch('requestLabels');
axios
return axios
.get(state.labelsFetchPath)
.then(({ data }) => {
dispatch('receiveLabelsSuccess', data);
......
import { merge } from 'lodash';
import { s__ } from '~/locale';
/**
* Validation messages will take priority based on the property order.
*
* For example:
* { valueMissing: {...}, urlTypeMismatch: {...} }
*
* `valueMissing` will be displayed the user has entered a value
* after that, if the input is not a valid URL then `urlTypeMismatch` will show
*/
const defaultFeedbackMap = {
valueMissing: {
isInvalid: el => el.validity?.valueMissing,
message: s__('Please fill out this field.'),
},
urlTypeMismatch: {
isInvalid: el => el.type === 'url' && el.validity?.typeMismatch,
message: s__('Please enter a valid URL format, ex: http://www.example.com/home'),
},
};
const getFeedbackForElement = (feedbackMap, el) =>
Object.values(feedbackMap).find(f => f.isInvalid(el))?.message || el.validationMessage;
const focusFirstInvalidInput = e => {
const { target: formEl } = e;
const invalidInput = formEl.querySelector('input:invalid');
if (invalidInput) {
invalidInput.focus();
}
};
const isEveryFieldValid = form => Object.values(form.fields).every(({ state }) => state === true);
const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = false }) => {
const { form } = context;
const { name } = el;
if (!name) {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.warn(
'[gitlab] the validation directive requires the given input to have "name" attribute',
);
}
return;
}
const formField = form.fields[name];
const isValid = el.checkValidity();
// This makes sure we always report valid fields - this can be useful for cases where the consuming
// component's logic depends on certain fields being in a valid state.
// Invalid input, on the other hand, should only be reported once we want to display feedback to the user.
// (eg.: After a field has been touched and moved away from, a submit-button has been clicked, ...)
formField.state = reportInvalidInput ? isValid : isValid || null;
formField.feedback = reportInvalidInput ? getFeedbackForElement(feedbackMap, el) : '';
form.state = isEveryFieldValid(form);
};
/**
* Takes an object that allows to add or change custom feedback messages.
*
* The passed in object will be merged with the built-in feedback
* so it is possible to override a built-in message.
*
* @example
* validate({
* tooLong: {
* check: el => el.validity.tooLong === true,
* message: 'Your custom feedback'
* }
* })
*
* @example
* validate({
* valueMissing: {
* message: 'Your custom feedback'
* }
* })
*
* @param {Object<string, { message: string, isValid: ?function}>} customFeedbackMap
* @returns {{ inserted: function, update: function }} validateDirective
*/
export default function(customFeedbackMap = {}) {
const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
const elDataMap = new WeakMap();
return {
inserted(el, binding, { context }) {
const { arg: showGlobalValidation } = binding;
const { form: formEl } = el;
const validate = createValidator(context, feedbackMap);
const elData = { validate, isTouched: false, isBlurred: false };
elDataMap.set(el, elData);
el.addEventListener('input', function markAsTouched() {
elData.isTouched = true;
// once the element has been marked as touched we can stop listening on the 'input' event
el.removeEventListener('input', markAsTouched);
});
el.addEventListener('blur', function markAsBlurred({ target }) {
if (elData.isTouched) {
elData.isBlurred = true;
validate({ el: target, reportInvalidInput: true });
// this event handler can be removed, since the live-feedback in `update` takes over
el.removeEventListener('blur', markAsBlurred);
}
});
if (formEl) {
formEl.addEventListener('submit', focusFirstInvalidInput);
}
validate({ el, reportInvalidInput: showGlobalValidation });
},
update(el, binding) {
const { arg: showGlobalValidation } = binding;
const { validate, isTouched, isBlurred } = elDataMap.get(el);
const showValidationFeedback = showGlobalValidation || (isTouched && isBlurred);
validate({ el, reportInvalidInput: showValidationFeedback });
},
};
}
@import 'mixins_and_variables_and_functions';
.signup-page {
.page-wrap {
background-color: var(--gray-10, $gray-10);
}
.signup-box-container {
max-width: 960px;
}
.signup-box {
background-color: var(--white, $white);
box-shadow: 0 0 0 1px var(--border-color, $border-color);
border-radius: $border-radius;
}
.form-control {
&:active,
&:focus {
background-color: var(--white, $white);
}
}
.devise-errors {
h2 {
font-size: $gl-font-size;
......
......@@ -30,7 +30,6 @@ class GroupsController < Groups::ApplicationController
before_action do
push_frontend_feature_flag(:vue_issuables_list, @group)
push_frontend_feature_flag(:deployment_filters)
end
before_action do
......@@ -181,8 +180,6 @@ class GroupsController < Groups::ApplicationController
end
def unfoldered_environment_names
return render_404 unless Feature.enabled?(:deployment_filters)
respond_to do |format|
format.json do
render json: EnvironmentNamesFinder.new(@group, current_user).execute
......
......@@ -44,6 +44,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_frontend_feature_flag(:vue_issuable_sidebar, project.group)
push_frontend_feature_flag(:tribute_autocomplete, @project)
push_frontend_feature_flag(:vue_issuables_list, project)
push_frontend_feature_flag(:vue_issue_header, @project)
end
before_action only: :show do
......
......@@ -40,7 +40,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:highlight_current_diff_row, @project)
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
push_frontend_feature_flag(:remove_resolve_note, @project)
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
record_experiment_user(:invite_members_version_a)
record_experiment_user(:invite_members_version_b)
......@@ -48,7 +48,6 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
push_frontend_feature_flag(:deployment_filters)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
......@@ -319,7 +318,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
end
def export_csv
return render_404 unless Feature.enabled?(:export_merge_requests_as_csv, project)
return render_404 unless Feature.enabled?(:export_merge_requests_as_csv, project, default_enabled: true)
IssuableExportCsvWorker.perform_async(:merge_request, current_user.id, project.id, finder_options.to_h) # rubocop:disable CodeReuse/Worker
......
......@@ -75,7 +75,7 @@ module Projects
[
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_human_readable, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
:auto_cancel_pending_pipelines, :ci_config_path, :auto_rollback_enabled,
auto_devops_attributes: [:id, :domain, :enabled, :deploy_strategy],
ci_cd_settings_attributes: [:default_git_depth, :forward_deployment_enabled]
].tap do |list|
......
......@@ -18,14 +18,13 @@ module Projects
end
def cleanup
cleanup_params = params.require(:project).permit(:bfg_object_map)
result = Projects::UpdateService.new(project, current_user, cleanup_params).execute
bfg_object_map = params.require(:project).require(:bfg_object_map)
result = Projects::CleanupService.enqueue(project, current_user, bfg_object_map)
if result[:status] == :success
RepositoryCleanupWorker.perform_async(project.id, current_user.id) # rubocop:disable CodeReuse/Worker
flash[:notice] = _('Repository cleanup has started. You will receive an email once the cleanup operation is complete.')
else
flash[:alert] = _('Failed to upload object map file')
flash[:alert] = status.fetch(:message, _('Failed to upload object map file'))
end
redirect_to project_settings_repository_path(project)
......
......@@ -317,8 +317,6 @@ class ProjectsController < Projects::ApplicationController
end
def unfoldered_environment_names
return render_404 unless Feature.enabled?(:deployment_filters)
respond_to do |format|
format.json do
render json: EnvironmentNamesFinder.new(@project, current_user).execute
......
......@@ -14,15 +14,12 @@ class RegistrationsController < Devise::RegistrationsController
prepend_before_action :check_captcha, only: :create
before_action :whitelist_query_limiting, :ensure_destroy_prerequisites_met, only: [:destroy]
before_action :load_recaptcha, only: :new
before_action :set_invite_params, only: :new
feature_category :authentication_and_authorization
def new
if experiment_enabled?(:signup_flow)
@resource = build_resource
else
redirect_to new_user_session_path(anchor: 'register-pane')
end
@resource = build_resource
end
def create
......@@ -206,8 +203,8 @@ class RegistrationsController < Devise::RegistrationsController
# Part of an experiment to build a new sign up flow. Will be resolved
# with https://gitlab.com/gitlab-org/growth/engineering/issues/64
def choose_layout
if %w(welcome update_registration).include?(action_name) || experiment_enabled?(:signup_flow)
'devise_experimental_separate_sign_up_flow'
if %w(welcome update_registration).include?(action_name)
'welcome'
else
'devise'
end
......@@ -225,6 +222,10 @@ class RegistrationsController < Devise::RegistrationsController
resource.state = BLOCKED_PENDING_APPROVAL_STATE
end
def set_invite_params
@invite_email = ActionController::Base.helpers.sanitize(params[:invite_email])
end
end
RegistrationsController.prepend_if_ee('EE::RegistrationsController')
# frozen_string_literal: true
class FeatureFlagsUserListsFinder
attr_reader :project, :current_user, :params
def initialize(project, current_user, params = {})
@project = project
@current_user = current_user
@params = params
end
def execute
unless Ability.allowed?(current_user, :read_feature_flag, project)
return Operations::FeatureFlagsUserList.none
end
items = feature_flags_user_lists
by_search(items)
end
private
def feature_flags_user_lists
project.operations_feature_flags_user_lists
end
def by_search(items)
if params[:search].present?
items.for_name_like(params[:search])
else
items
end
end
end
......@@ -155,19 +155,6 @@ class MergeRequestsFinder < IssuableFinder
.or(table[:title].matches('(Draft)%'))
end
# rubocop: disable CodeReuse/ActiveRecord
def by_deployment(items)
return items unless deployment_id
items.includes(:deployment_merge_requests)
.where(deployment_merge_requests: { deployment_id: deployment_id })
end
# rubocop: enable CodeReuse/ActiveRecord
def deployment_id
@deployment_id ||= params[:deployment_id].presence
end
# Filter by merge requests that had been approved by specific users
# rubocop: disable CodeReuse/Finder
def by_approvals(items)
......@@ -178,10 +165,6 @@ class MergeRequestsFinder < IssuableFinder
# rubocop: enable CodeReuse/Finder
def by_deployments(items)
# Until this feature flag is enabled permanently, we retain the old
# filtering behaviour/code.
return by_deployment(items) unless Feature.enabled?(:deployment_filters)
env = params[:environment]
before = params[:deployed_before]
after = params[:deployed_after]
......
# frozen_string_literal: true
module Mutations
module AlertManagement
module HttpIntegration
class Destroy < HttpIntegrationBase
graphql_name 'HttpIntegrationDestroy'
argument :id, Types::GlobalIDType[::AlertManagement::HttpIntegration],
required: true,
description: "The id of the integration to remove"
def resolve(id:)
integration = authorized_find!(id: id)
response ::AlertManagement::HttpIntegrations::DestroyService.new(
integration,
current_user
).execute
end
end
end
end
end
......@@ -7,7 +7,7 @@ module Mutations
field :integration,
Types::AlertManagement::HttpIntegrationType,
null: true,
description: "The updated HTTP integration"
description: "The HTTP integration"
authorize :admin_operations
......
query permissions($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
__typename
id
issue(iid: $iid) {
__typename
userPermissions {
__typename
createDesign
}
}
......
#import "../fragments/design_list.fragment.graphql"
#import "../fragments/version.fragment.graphql"
query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) {
project(fullPath: $fullPath) {
__typename
id
issue(iid: $iid) {
__typename
designCollection {
__typename
copyState
designs(atVersion: $atVersion) {
__typename
nodes {
...DesignListItem
__typename
id
event
filename
notesCount
image
imageV432x230
currentUserTodos(state: pending) {
__typename
nodes {
__typename
id
}
}
}
}
versions {
__typename
nodes {
...VersionListItem
__typename
id
sha
}
}
}
......
......@@ -13,10 +13,20 @@ module Resolvers
required: true,
description: 'The type of measurement/statistics to retrieve'
def resolve(identifier:)
argument :recorded_after, Types::TimeType,
required: false,
description: 'Measurement recorded after this date'
argument :recorded_before, Types::TimeType,
required: false,
description: 'Measurement recorded before this date'
def resolve(identifier:, recorded_before: nil, recorded_after: nil)
authorize!
::Analytics::InstanceStatistics::Measurement
.recorded_after(recorded_after)
.recorded_before(recorded_before)
.with_identifier(identifier)
.order_by_latest
end
......
......@@ -15,7 +15,9 @@ module Resolvers
def preloads
{
jobs: [:statuses]
jobs: [:statuses],
upstream: [:triggered_by_pipeline],
downstream: [:triggered_pipelines]
}
end
end
......
......@@ -56,12 +56,24 @@ module Types
description: 'Specifies if a pipeline can be canceled',
method: :cancelable?,
null: false
field :jobs,
::Types::Ci::JobType.connection_type,
null: true,
description: 'Jobs belonging to the pipeline',
method: :statuses
field :source_job, Types::Ci::JobType, null: true,
description: 'Job where pipeline was triggered from'
field :downstream, Types::Ci::PipelineType.connection_type, null: true,
description: 'Pipelines this pipeline will trigger',
method: :triggered_pipelines_with_preloads
field :upstream, Types::Ci::PipelineType, null: true,
description: 'Pipeline that triggered the pipeline',
method: :triggered_by_pipeline
field :path, GraphQL::STRING_TYPE, null: true,
description: "Relative path to the pipeline's page",
resolve: -> (obj, _args, _ctx) { ::Gitlab::Routing.url_helpers.project_pipeline_path(obj.project, obj) }
field :project, Types::ProjectType, null: true,
description: 'Project the pipeline belongs to'
end
end
end
......
......@@ -118,8 +118,7 @@ module Types
resolver: Resolvers::MergeRequestPipelinesResolver
field :milestone, Types::MilestoneType, null: true,
description: 'The milestone of the merge request',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }
description: 'The milestone of the merge request'
field :assignees, Types::UserType.connection_type, null: true, complexity: 5,
description: 'Assignees of the merge request'
field :author, Types::UserType, null: true,
......
......@@ -14,6 +14,7 @@ module Types
mount_mutation Mutations::AlertManagement::HttpIntegration::Create
mount_mutation Mutations::AlertManagement::HttpIntegration::Update
mount_mutation Mutations::AlertManagement::HttpIntegration::ResetToken
mount_mutation Mutations::AlertManagement::HttpIntegration::Destroy
mount_mutation Mutations::AlertManagement::PrometheusIntegration::Create
mount_mutation Mutations::AlertManagement::PrometheusIntegration::Update
mount_mutation Mutations::AlertManagement::PrometheusIntegration::ResetToken
......
......@@ -152,6 +152,21 @@ module IssuesHelper
sort: 'desc'
}
end
def issue_header_actions_data(project, issue, current_user)
{
can_create_issue: show_new_issue_link?(project).to_s,
can_reopen_issue: can?(current_user, :reopen_issue, issue).to_s,
can_report_spam: issue.submittable_as_spam_by?(current_user).to_s,
can_update_issue: can?(current_user, :update_issue, issue).to_s,
iid: issue.iid,
is_issue_author: issue.author == current_user,
new_issue_path: new_project_issue_path(project),
project_path: project.full_path,
report_abuse_path: new_abuse_report_path(user_id: issue.author.id, ref_url: issue_url(issue)),
submit_as_spam_path: mark_as_spam_project_issue_path(project, issue)
}
end
end
IssuesHelper.prepend_if_ee('EE::IssuesHelper')
......@@ -56,12 +56,9 @@ module Emails
subject: @message.subject)
end
def prometheus_alert_fired_email(project_id, user_id, alert_attributes)
@project = ::Project.find(project_id)
user = ::User.find(user_id)
@alert = AlertManagement::Alert.new(alert_attributes.with_indifferent_access).present
return unless @alert.parsed_payload.has_required_attributes?
def prometheus_alert_fired_email(project, user, alert)
@project = project
@alert = alert.present
subject_text = "Alert: #{@alert.email_title}"
mail(to: user.notification_email_for(@project.group), subject: subject(subject_text))
......
......@@ -36,6 +36,8 @@ module Analytics
scope :order_by_latest, -> { order(recorded_at: :desc) }
scope :with_identifier, -> (identifier) { where(identifier: identifier) }
scope :recorded_after, -> (date) { where(self.model.arel_table[:recorded_at].gteq(date)) if date.present? }
scope :recorded_before, -> (date) { where(self.model.arel_table[:recorded_at].lteq(date)) if date.present? }
def self.measurement_identifier_values
identifiers.values
......
......@@ -5,6 +5,7 @@ module Operations
class UserList < ApplicationRecord
include AtomicInternalId
include IidRoutes
include ::Gitlab::SQL::Pattern
self.table_name = 'operations_user_lists'
......@@ -23,6 +24,10 @@ module Operations
before_destroy :ensure_no_associated_strategies
scope :for_name_like, -> (query) do
fuzzy_search(query, [:name], use_minimum_char_limit: false)
end
private
def ensure_no_associated_strategies
......
......@@ -571,6 +571,7 @@ class Project < ApplicationRecord
scope :imported_from, -> (type) { where(import_type: type) }
scope :with_tracing_enabled, -> { joins(:tracing_setting) }
scope :with_enabled_error_tracking, -> { joins(:error_tracking_setting).where(project_error_tracking_settings: { enabled: true }) }
enum auto_cancel_pending_pipelines: { disabled: 0, enabled: 1 }
......
# frozen_string_literal: true
module AlertManagement
module HttpIntegrations
class DestroyService
# @param integration [AlertManagement::HttpIntegration]
# @param current_user [User]
def initialize(integration, current_user)
@integration = integration
@current_user = current_user
end
def execute
return error_no_permissions unless allowed?
return error_multiple_integrations unless Feature.enabled?(:multiple_http_integrations, integration.project)
if integration.destroy
success
else
error(integration.errors.full_messages.to_sentence)
end
end
private
attr_reader :integration, :current_user
def allowed?
current_user&.can?(:admin_operations, integration)
end
def error(message)
ServiceResponse.error(message: message)
end
def success
ServiceResponse.success(payload: { integration: integration })
end
def error_no_permissions
error(_('You have insufficient permissions to remove this HTTP integration'))
end
def error_multiple_integrations
error(_('Removing integrations is not supported for this project'))
end
end
end
end
......@@ -9,6 +9,10 @@ module AlertManagement
return bad_request unless incoming_payload.has_required_attributes?
process_alert_management_alert
return bad_request unless alert.persisted?
process_incident_issues if process_issues?
send_alert_email if send_email?
ServiceResponse.success
end
......@@ -30,8 +34,6 @@ module AlertManagement
else
create_alert_management_alert
end
process_incident_issues if process_issues?
end
def reset_alert_management_alert_status
......@@ -85,12 +87,17 @@ module AlertManagement
end
def process_incident_issues
return unless alert.persisted?
return if alert.issue
return if alert.issue || alert.resolved?
IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
end
def send_alert_email
notification_service
.async
.prometheus_alerts_fired(project, [alert])
end
def logger
@logger ||= Gitlab::AppLogger
end
......
......@@ -6,11 +6,11 @@ module JiraConnect
self.project = project
end
def execute(commits: nil, branches: nil, merge_requests: nil)
def execute(commits: nil, branches: nil, merge_requests: nil, update_sequence_id: nil)
JiraConnectInstallation.for_project(project).each do |installation|
client = Atlassian::JiraConnect::Client.new(installation.base_url, installation.shared_secret)
response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests)
response = client.store_dev_info(project: project, commits: commits, branches: branches, merge_requests: merge_requests, update_sequence_id: update_sequence_id)
log_response(response)
end
......
......@@ -601,7 +601,7 @@ class NotificationService
return if project.emails_disabled?
owners_and_maintainers_without_invites(project).to_a.product(alerts).each do |recipient, alert|
mailer.prometheus_alert_fired_email(project.id, recipient.user.id, alert).deliver_later
mailer.prometheus_alert_fired_email(project, recipient.user, alert).deliver_later
end
end
......
......@@ -73,7 +73,7 @@ module Projects
end
def process_incident_issues
return if alert.issue
return if alert.issue || alert.resolved?
::IncidentManagement::ProcessAlertWorker.perform_async(nil, nil, alert.id)
end
......@@ -81,7 +81,7 @@ module Projects
def send_alert_email
notification_service
.async
.prometheus_alerts_fired(project, [alert.attributes])
.prometheus_alerts_fired(project, [alert])
end
def alert
......
......@@ -11,6 +11,24 @@ module Projects
include Gitlab::Utils::StrongMemoize
class << self
def enqueue(project, current_user, bfg_object_map)
Projects::UpdateService.new(project, current_user, bfg_object_map: bfg_object_map).execute.tap do |result|
next unless result[:status] == :success
project.set_repository_read_only!
RepositoryCleanupWorker.perform_async(project.id, current_user.id)
end
rescue Project::RepositoryReadOnlyError => err
{ status: :error, message: (_('Failed to make repository read-only. %{reason}') % { reason: err.message }) }
end
def cleanup_after(project)
project.bfg_object_map.remove!
project.set_repository_writable!
end
end
# Attempt to clean up the project following the push. Warning: this is
# destructive!
#
......@@ -29,7 +47,7 @@ module Projects
# time. Better to feel the pain immediately.
project.repository.expire_all_method_caches
project.bfg_object_map.remove!
self.class.cleanup_after(project)
end
private
......
......@@ -23,7 +23,6 @@ module Projects
return unauthorized unless valid_alert_manager_token?(token)
process_prometheus_alerts
send_alert_email if send_email?
ServiceResponse.success
end
......@@ -120,14 +119,6 @@ module Projects
ActiveSupport::SecurityUtils.secure_compare(expected, actual)
end
def send_alert_email
return unless firings.any?
notification_service
.async
.prometheus_alerts_fired(project, alerts_attributes)
end
def process_prometheus_alerts
alerts.each do |alert|
AlertManagement::ProcessPrometheusAlertService
......@@ -136,18 +127,6 @@ module Projects
end
end
def alerts_attributes
firings.map do |payload|
alert_params = Gitlab::AlertManagement::Payload.parse(
project,
payload,
monitoring_tool: Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
).alert_params
AlertManagement::Alert.new(alert_params).attributes
end
end
def bad_request
ServiceResponse.error(message: 'Bad Request', http_status: :bad_request)
end
......
......@@ -50,11 +50,11 @@
= f.text_field :home_page_url, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'home_help_block'
%span.form-text.text-muted#home_help_block We will redirect non-logged in users to this page
.form-group
= f.label :after_sign_out_path, class: 'label-bold'
= f.label :after_sign_out_path, _('After sign-out path'), class: 'label-bold'
= f.text_field :after_sign_out_path, class: 'form-control', placeholder: 'http://company.example.com', :'aria-describedby' => 'after_sign_out_path_help_block'
%span.form-text.text-muted#after_sign_out_path_help_block We will redirect users to this page after they sign out
.form-group
= f.label :sign_in_text, class: 'label-bold'
= f.label :sign_in_text, _('Sign-in text'), class: 'label-bold'
= f.text_area :sign_in_text, class: 'form-control', rows: 4
.form-text.text-muted Markdown enabled
= f.submit 'Save changes', class: "gl-button btn btn-success"
- page_title _("Sign up")
- if experiment_enabled?(:signup_flow)
.row
.col-lg-7
%h1.mb-3.font-weight-bold.text-6.mt-0
= html_escape(_("Speed up your DevOps%{br_tag}with GitLab")) % { br_tag: '<br/>'.html_safe }
%p.text-3
= _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.")
.col-lg-5.order-12
.text-center.mb-3
%h2.font-weight-bold= _('Register for GitLab')
= render 'devise/shared/experimental_separate_sign_up_flow_box'
= render 'devise/shared/sign_in_link'
- else
- add_page_specific_style 'page_bundles/signup'
.signup-page
= render 'devise/shared/signup_box'
= render 'devise/shared/sign_in_link'
......@@ -2,23 +2,21 @@
#signin-container
- if any_form_based_providers_enabled?
= render 'devise/shared/tabs_ldap'
- else
- unless experiment_enabled?(:signup_flow)
= render 'devise/shared/tabs_normal'
= render 'devise/shared/tabs_ldap', render_signup_link: false
.tab-content
- if password_authentication_enabled_for_web? || ldap_sign_in_enabled? || crowd_enabled?
= render 'devise/shared/signin_box'
-# Signup only makes sense if you can also sign-in
- if allow_signup?
= render 'devise/shared/signup_box'
-# Show a message if none of the mechanisms above are enabled
- if !password_authentication_enabled_for_web? && !ldap_sign_in_enabled? && !(omniauth_enabled? && devise_mapping.omniauthable?)
%div
No authentication methods configured.
- if allow_signup?
%p.gl-mt-3
= _("Don't have an account yet?")
= link_to _("Register now"), new_registration_path(:user, invite_email: @invite_email), data: { qa_selector: 'register_link' }
- if omniauth_enabled? && devise_mapping.omniauthable? && button_based_providers_enabled?
.clearfix
= render 'devise/shared/omniauth_box'
- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
- min_username_length = 2
.signup-box.p-3.mb-2
.signup-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
.devise-errors.mt-0
= render "devise/shared/error_messages", resource: resource
- if Feature.enabled?(:invisible_captcha)
= invisible_captcha
.name.form-row
.col.form-group
= f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
= f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_firstname_field' }, required: true, title: _("This field is required.")
.col.form-group
= f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
= f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_lastname_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
= f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => _("Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-field-error-ignore.field-validation.mt-1.hide.cred= _('Username is already taken.')
%p.validation-success.gl-field-error-ignore.field-validation.mt-1.hide.cgreen= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.mt-1.hide= _('Checking username availability...')
.form-group
= f.label :email, class: 'label-bold'
= f.email_field :email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container.mt-3
= f.submit _("Register"), class: "btn-register gl-button btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' }
= render 'devise/shared/terms_of_service_notice'
- if omniauth_enabled? && button_based_providers_enabled?
= render 'devise/shared/experimental_separate_sign_up_flow_omniauth_box'
......@@ -22,8 +22,3 @@
.login-box.tab-pane.active{ id: 'login-pane', role: 'tabpanel' }
.login-body
= render 'devise/sessions/new_base'
- if experiment_enabled?(:signup_flow)
%p.light.mt-2
= _("Don't have an account yet?")
= link_to _("Register now"), new_registration_path(:user)
- max_first_name_length = max_last_name_length = 127
- max_username_length = 255
- min_username_length = 2
#register-pane.tab-pane.login-box{ role: 'tabpanel' }
.login-body
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
.devise-errors
= render "devise/shared/error_messages", resource: resource
- if Feature.enabled?(:invisible_captcha)
= invisible_captcha
.name.form-row
.col.form-group
= f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
= f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_first_name_field' }, required: true, title: _("This field is required.")
.col.form-group
= f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
= f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_last_name_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
= f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-field-error-ignore.field-validation.hide= _('Username is already taken.')
%p.validation-success.gl-field-error-ignore.field-validation.hide= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking username availability...')
.form-group
= f.label :email, class: 'label-bold'
= f.email_field :email, value: @invite_email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container
= f.submit _("Register"), class: "btn-register btn", data: { qa_selector: 'new_user_register_button' }
= render 'devise/shared/terms_of_service_notice'
.gl-mb-3.gl-p-4.gl-border-gray-100.gl-border-1.gl-border-solid.gl-rounded-base
= form_for(resource, as: "new_#{resource_name}", url: registration_path(resource_name), html: { class: "new_new_user gl-show-field-errors", "aria-live" => "assertive" }) do |f|
.devise-errors
= render "devise/shared/error_messages", resource: resource
- if Feature.enabled?(:invisible_captcha)
= invisible_captcha
.name.form-row
.col.form-group
= f.label :first_name, _('First name'), for: 'new_user_first_name', class: 'label-bold'
= f.text_field :first_name, class: 'form-control top js-block-emoji js-validate-length', :data => { :max_length => max_first_name_length, :max_length_message => _("First name is too long (maximum is %{max_length} characters).") % { max_length: max_first_name_length }, :qa_selector => 'new_user_first_name_field' }, required: true, title: _("This field is required.")
.col.form-group
= f.label :last_name, _('Last name'), for: 'new_user_last_name', class: 'label-bold'
= f.text_field :last_name, class: "form-control top js-block-emoji js-validate-length", :data => { :max_length => max_last_name_length, :max_length_message => _("Last name is too long (maximum is %{max_length} characters).") % { max_length: max_last_name_length }, :qa_selector => 'new_user_last_name_field' }, required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
= f.text_field :username, class: "form-control middle js-block-emoji js-validate-length js-validate-username", :data => { :min_length => min_username_length, :min_length_message => s_("SignUp|Username is too short (minimum is %{min_length} characters).") % { min_length: min_username_length }, :max_length => max_username_length, :max_length_message => s_("SignUp|Username is too long (maximum is %{max_length} characters).") % { max_length: max_username_length }, :qa_selector => 'new_user_username_field' }, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.')
%p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.')
%p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...')
.form-group
= f.label :email, class: 'label-bold'
= f.email_field :email, value: @invite_email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
%div
- if show_recaptcha_sign_up?
= recaptcha_tags
.submit-container
= f.submit _("Register"), class: "btn gl-button btn-success", data: { qa_selector: 'new_user_register_button' }
= render 'devise/shared/terms_of_service_notice'
- if omniauth_enabled? && button_based_providers_enabled?
= render 'devise/shared/signup_omniauth_providers'
- page_description brand_title unless page_description
-# Needs a redirect on the client side since it's using an anchor to distinguish
-# between sign in and registration. We need to inline the JS to not render
-# anything from this page beforehand.
-# Part of an experiment to build a new sign up flow. Will be removed again with
-# https://gitlab.com/gitlab-org/growth/engineering/issues/64
- if experiment_enabled?(:signup_flow) && current_path?("sessions#new")
= javascript_tag nonce: true do
:plain
if (window.location.hash === '#register-pane') {
window.location.replace("/users/sign_up")
}
- site_name = "GitLab"
%head{ prefix: "og: http://ogp.me/ns#" }
%meta{ charset: "utf-8" }
......
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
- add_page_specific_style 'page_bundles/experimental_separate_sign_up'
- add_page_specific_style 'page_bundles/signup'
= render "layouts/head"
%body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"
......
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
- add_page_specific_style 'page_bundles/experimental_separate_sign_up'
= render "layouts/head"
%body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"
= render "layouts/init_client_detection_flags"
.page-wrap
.container.signup-box-container.navless-container
= render "layouts/broadcast"
.content
= render "layouts/flash"
= yield
%hr.footer-fixed
.footer-container
.container
.footer-links
= link_to _("Help"), help_path
= link_to _("About GitLab"), "https://about.gitlab.com/"
= footer_message
!!! 5
%html.subscriptions-layout-html{ lang: 'en' }
= render 'layouts/head'
%body.ui-indigo.d-flex.vh-100.gl-bg-gray-10
= render "layouts/header/logo_with_title"
= render "layouts/broadcast"
.container.d-flex.flex-grow-1.m-0
= yield
- body = @alert.resolved? ? _('An alert has been resolved in %{project_path}.') : _('An alert has been triggered in %{project_path}.')
%p
= body % { project_path: @alert.project.full_path }
%p
= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path }
= link_to(_('View alert details.'), @alert.details_url)
- if description = @alert.description
%p
......
<%= _('An alert has been triggered in %{project_path}.') % { project_path: @alert.project.full_path } %>.
<% body = @alert.resolved? ? _('An alert has been resolved in %{project_path}.') : _('An alert has been triggered in %{project_path}.') %>
<%= body % { project_path: @alert.project.full_path } %>
<%= _('View alert details at') %> <%= @alert.details_url %>
<% if description = @alert.description %>
<%= _('Description:') %> <%= description %>
......
......@@ -6,6 +6,8 @@
- enable_lfs_message = s_("DesignManagement|To upload designs, you'll need to enable LFS and have admin enable hashed storage. %{requirements_link_start}More information%{requirements_link_end}").html_safe % { requirements_link_start: requirements_link_start, requirements_link_end: link_end }
- if @project.design_management_enabled?
- add_page_startup_graphql_call('design_management/get_design_list', { fullPath: @project.full_path, iid: @issue.iid.to_s, atVersion: nil })
- add_page_startup_graphql_call('design_management/design_permissions', { fullPath: @project.full_path, iid: @issue.iid.to_s })
.js-design-management{ data: { project_path: @project.full_path, issue_iid: @issue.iid, issue_path: project_issue_path(@project, @issue) } }
- else
.gl-border-solid.gl-border-1.gl-border-gray-100.gl-rounded-base.gl-mt-5.gl-p-3.gl-text-center
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment