Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue150
-rw-r--r--app/assets/javascripts/feature_flags/components/edit_feature_flag.vue19
-rw-r--r--app/assets/javascripts/feature_flags/components/feature_flags_table.vue5
-rw-r--r--app/assets/javascripts/feature_flags/components/form.vue3
-rw-r--r--app/assets/javascripts/feature_flags/components/new_feature_flag.vue28
-rw-r--r--app/assets/javascripts/feature_flags/constants.js4
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list.vue33
-rw-r--r--app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue34
-rw-r--r--app/assets/javascripts/registry/explorer/constants/list.js5
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql11
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/index.js14
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.graphql9
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.graphql23
-rw-r--r--app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.graphql23
-rw-r--r--app/assets/javascripts/registry/explorer/index.js2
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue134
-rw-r--r--app/assets/javascripts/search/group_filter/components/group_filter.vue124
-rw-r--r--app/assets/javascripts/search/group_filter/constants.js10
-rw-r--r--app/assets/javascripts/search/group_filter/index.js28
-rw-r--r--app/assets/javascripts/search/index.js4
-rw-r--r--app/assets/javascripts/search/topbar/components/group_filter.vue49
-rw-r--r--app/assets/javascripts/search/topbar/components/searchable_dropdown.vue144
-rw-r--r--app/assets/javascripts/search/topbar/constants.js21
-rw-r--r--app/assets/javascripts/search/topbar/index.js39
-rw-r--r--app/controllers/projects/feature_flags_controller.rb11
-rw-r--r--app/finders/feature_flags_finder.rb6
-rw-r--r--app/helpers/system_note_helper.rb3
-rw-r--r--app/models/environment.rb1
-rw-r--r--app/models/experiment.rb8
-rw-r--r--app/models/issue.rb1
-rw-r--r--app/models/system_note_metadata.rb3
-rw-r--r--app/serializers/environment_entity.rb8
-rw-r--r--app/services/feature_flags/create_service.rb9
-rw-r--r--app/services/issues/clone_service.rb81
-rw-r--r--app/services/issues/update_service.rb13
-rw-r--r--app/services/system_note_service.rb4
-rw-r--r--app/services/system_notes/issuables_service.rb21
-rw-r--r--app/views/groups/registry/repositories/index.html.haml1
-rw-r--r--app/views/layouts/_loading_hints.html.haml1
-rw-r--r--app/views/projects/registry/repositories/index.html.haml2
-rw-r--r--app/views/search/_filter.html.haml2
-rw-r--r--changelogs/unreleased/234013-add-experiment-record-context.yml5
-rw-r--r--changelogs/unreleased/270090-add-pk-to-elasticsearch-indexed-projects.yml5
-rw-r--r--changelogs/unreleased/271538-remove-vsa-usage-ping.yml5
-rw-r--r--changelogs/unreleased/276432-refactor-container-registry-frontend-to-graphql.yml5
-rw-r--r--changelogs/unreleased/287724-disable-name-prometheus.yml5
-rw-r--r--changelogs/unreleased/9421-clone-quickaction.yml5
-rw-r--r--changelogs/unreleased/expose-upcoming-deployment.yml5
-rw-r--r--config/feature_flags/development/feature_flags_new_version.yml8
-rw-r--r--config/feature_flags/development/security_on_demand_scans_http_header_validation.yml8
-rw-r--r--db/migrate/20201201161655_add_primary_key_to_elastic_search_indexed_projects.rb31
-rw-r--r--db/migrate/20201201190002_add_other_context_to_experiment_user.rb19
-rw-r--r--db/schema_migrations/202012011616551
-rw-r--r--db/schema_migrations/202012011900021
-rw-r--r--db/structure.sql10
-rw-r--r--doc/.vale/gitlab/Acronyms.yml1
-rw-r--r--doc/.vale/gitlab/spelling-exceptions.txt1
-rw-r--r--doc/administration/gitaly/praefect.md2
-rw-r--r--doc/administration/job_logs.md2
-rw-r--r--doc/administration/packages/index.md2
-rw-r--r--doc/api/appearance.md2
-rw-r--r--doc/api/feature_flags.md4
-rw-r--r--doc/development/documentation/styleguide/index.md23
-rw-r--r--doc/development/fe_guide/performance.md12
-rw-r--r--doc/development/go_guide/index.md2
-rw-r--r--doc/development/iterating_tables_in_batches.md6
-rw-r--r--doc/development/migration_style_guide.md3
-rw-r--r--doc/development/product_analytics/usage_ping.md44
-rw-r--r--doc/development/sidekiq_style_guide.md2
-rw-r--r--doc/development/testing_guide/frontend_testing.md2
-rw-r--r--doc/install/requirements.md2
-rw-r--r--doc/integration/jenkins.md4
-rw-r--r--doc/integration/sourcegraph.md2
-rw-r--r--doc/operations/feature_flags.md20
-rw-r--r--doc/raketasks/backup_restore.md6
-rw-r--r--doc/user/application_security/secret_detection/index.md10
-rw-r--r--doc/user/group/saml_sso/index.md10
-rw-r--r--doc/user/packages/conan_repository/index.md2
-rw-r--r--doc/user/packages/index.md2
-rw-r--r--doc/user/project/integrations/jira_server_configuration.md2
-rw-r--r--doc/user/project/integrations/webhooks.md60
-rw-r--r--doc/user/project/quick_actions.md1
-rw-r--r--doc/user/project/releases/index.md2
-rw-r--r--doc/user/project/settings/project_access_tokens.md33
-rw-r--r--lib/api/entities/feature_flag.rb4
-rw-r--r--lib/api/feature_flags.rb24
-rw-r--r--lib/gitlab/cycle_analytics/usage_data.rb91
-rw-r--r--lib/gitlab/experimentation/controller_concern.rb4
-rw-r--r--lib/gitlab/quick_actions/issue_actions.rb24
-rw-r--r--lib/gitlab/usage_data.rb7
-rw-r--r--locale/gitlab.pot24
-rw-r--r--package.json2
-rw-r--r--spec/controllers/projects/feature_flags_controller_spec.rb101
-rw-r--r--spec/db/schema_spec.rb1
-rw-r--r--spec/features/groups/container_registry_spec.rb7
-rw-r--r--spec/features/issues/user_uses_quick_actions_spec.rb1
-rw-r--r--spec/features/projects/container_registry_spec.rb19
-rw-r--r--spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb112
-rw-r--r--spec/finders/feature_flags_finder_spec.rb10
-rw-r--r--spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js4
-rw-r--r--spec/frontend/alerts_settings/alerts_settings_form_spec.js18
-rw-r--r--spec/frontend/branches/ajax_loading_spinner_spec.js2
-rw-r--r--spec/frontend/feature_flags/components/edit_feature_flag_spec.js34
-rw-r--r--spec/frontend/feature_flags/components/new_feature_flag_spec.js45
-rw-r--r--spec/frontend/filtered_search/filtered_search_manager_spec.js2
-rw-r--r--spec/frontend/lib/utils/common_utils_spec.js4
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js61
-rw-r--r--spec/frontend/registry/explorer/components/list_page/image_list_spec.js60
-rw-r--r--spec/frontend/registry/explorer/mock_data.js126
-rw-r--r--spec/frontend/registry/explorer/pages/list_spec.js310
-rw-r--r--spec/frontend/search/index_spec.js2
-rw-r--r--spec/frontend/search/topbar/components/group_filter_spec.js121
-rw-r--r--spec/frontend/search/topbar/components/searchable_dropdown_spec.js (renamed from spec/frontend/search/group_filter/components/group_filter_spec.js)91
-rw-r--r--spec/lib/gitlab/cycle_analytics/usage_data_spec.rb95
-rw-r--r--spec/lib/gitlab/experimentation/controller_concern_spec.rb19
-rw-r--r--spec/lib/gitlab/usage_data_spec.rb18
-rw-r--r--spec/models/environment_spec.rb17
-rw-r--r--spec/models/experiment_spec.rb43
-rw-r--r--spec/requests/api/feature_flags_spec.rb117
-rw-r--r--spec/serializers/environment_entity_spec.rb58
-rw-r--r--spec/services/issues/clone_service_spec.rb300
-rw-r--r--spec/services/issues/update_service_spec.rb20
-rw-r--r--spec/services/system_note_service_spec.rb13
-rw-r--r--spec/services/system_notes/issuables_service_spec.rb61
-rw-r--r--spec/support/helpers/usage_data_helpers.rb1
-rw-r--r--spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb159
-rw-r--r--yarn.lock8
127 files changed, 2258 insertions, 1450 deletions
diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
index bf2874b6cc7..b2be563522a 100644
--- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
+++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue
@@ -32,6 +32,75 @@ import {
// feature rollout plan - https://gitlab.com/gitlab-org/gitlab/-/issues/262707#note_442529171
import mockedCustomMapping from './mocks/parsedMapping.json';
+export const i18n = {
+ integrationFormSteps: {
+ step1: {
+ label: s__('AlertSettings|1. Select integration type'),
+ enterprise: s__(
+ 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
+ ),
+ },
+ step2: {
+ label: s__('AlertSettings|2. Name integration'),
+ placeholder: s__('AlertSettings|Enter integration name'),
+ prometheus: s__('AlertSettings|Prometheus'),
+ },
+ step3: {
+ label: s__('AlertSettings|3. Set up webhook'),
+ help: s__(
+ "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
+ ),
+ info: s__('AlertSettings|Authorization key'),
+ reset: s__('AlertSettings|Reset Key'),
+ },
+ step4: {
+ label: s__('AlertSettings|4. Sample alert payload (optional)'),
+ help: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
+ ),
+ prometheusHelp: s__(
+ 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
+ ),
+ placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
+ resetHeader: s__('AlertSettings|Reset the mapping'),
+ resetBody: s__(
+ "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
+ ),
+ resetOk: s__('AlertSettings|Proceed with editing'),
+ editPayload: s__('AlertSettings|Edit payload'),
+ submitPayload: s__('AlertSettings|Submit payload'),
+ payloadParsedSucessMsg: s__(
+ 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
+ ),
+ },
+ step5: {
+ label: s__('AlertSettings|5. Map fields (optional)'),
+ intro: s__(
+ "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
+ ),
+ },
+ prometheusFormUrl: {
+ label: s__('AlertSettings|Prometheus API base URL'),
+ help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
+ },
+ restKeyInfo: {
+ label: s__(
+ 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
+ ),
+ },
+ // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
+ opsgenie: {
+ label: s__('AlertSettings|2. Add link to your Opsgenie alert list'),
+ info: s__(
+ 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.',
+ ),
+ },
+ },
+};
+
export default {
placeholders: {
prometheus: targetPrometheusUrlPlaceholder,
@@ -39,73 +108,7 @@ export default {
},
JSON_VALIDATE_DELAY,
typeSet,
- i18n: {
- integrationFormSteps: {
- step1: {
- label: s__('AlertSettings|1. Select integration type'),
- enterprise: s__(
- 'AlertSettings|In free versions of GitLab, only one integration for each type can be added. %{linkStart}Upgrade your subscription%{linkEnd} to add additional integrations.',
- ),
- },
- step2: {
- label: s__('AlertSettings|2. Name integration'),
- placeholder: s__('AlertSettings|Enter integration name'),
- },
- step3: {
- label: s__('AlertSettings|3. Set up webhook'),
- help: s__(
- "AlertSettings|Utilize the URL and authorization key below to authorize an external service to send alerts to GitLab. Review your external service's documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.",
- ),
- prometheusHelp: s__(
- 'AlertSettings|Utilize the URL and authorization key below to authorize Prometheus to send alerts to GitLab. Review the Prometheus documentation to learn where to add these details, and the %{linkStart}GitLab documentation%{linkEnd} to learn more about configuring your endpoint.',
- ),
- info: s__('AlertSettings|Authorization key'),
- reset: s__('AlertSettings|Reset Key'),
- },
- step4: {
- label: s__('AlertSettings|4. Sample alert payload (optional)'),
- help: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to create a custom mapping (optional), or to test the integration (also optional).',
- ),
- prometheusHelp: s__(
- 'AlertSettings|Provide an example payload from the monitoring tool you intend to integrate with. This payload can be used to test the integration (optional).',
- ),
- placeholder: s__('AlertSettings|{ "events": [{ "application": "Name of application" }] }'),
- resetHeader: s__('AlertSettings|Reset the mapping'),
- resetBody: s__(
- "AlertSettings|If you edit the payload, the stored mapping will be reset, and you'll need to re-map the fields.",
- ),
- resetOk: s__('AlertSettings|Proceed with editing'),
- editPayload: s__('AlertSettings|Edit payload'),
- submitPayload: s__('AlertSettings|Submit payload'),
- payloadParsedSucessMsg: s__(
- 'AlertSettings|Sample payload has been parsed. You can now map the fields.',
- ),
- },
- step5: {
- label: s__('AlertSettings|5. Map fields (optional)'),
- intro: s__(
- "AlertSettings|If you've provided a sample alert payload, you can create a custom mapping for your endpoint. The default GitLab alert keys are listed below. Please define which payload key should map to the specified GitLab key.",
- ),
- },
- prometheusFormUrl: {
- label: s__('AlertSettings|Prometheus API base URL'),
- help: s__('AlertSettings|URL cannot be blank and must start with http or https'),
- },
- restKeyInfo: {
- label: s__(
- 'AlertSettings|Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in.',
- ),
- },
- // TODO: Will be removed in 13.7 as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/273657
- opsgenie: {
- label: s__('AlertSettings|2. Add link to your Opsgenie alert list'),
- info: s__(
- 'AlertSettings|Utilizing this option will link the GitLab Alerts navigation item to your existing Opsgenie instance. By selecting this option, you cannot receive alerts from any other source in GitLab; it will effectively be turning Alerts within GitLab off as a feature.',
- ),
- },
- },
- },
+ i18n,
components: {
ClipboardButton,
GlButton,
@@ -265,6 +268,9 @@ export default {
this.integrationTestPayload.json === ''
);
},
+ isSelectDisabled() {
+ return this.currentIntegration !== null || !this.canAddIntegration;
+ },
},
watch: {
currentIntegration(val) {
@@ -421,7 +427,8 @@ export default {
>
<gl-form-select
v-model="selectedIntegration"
- :disabled="currentIntegration !== null || !canAddIntegration"
+ :disabled="isSelectDisabled"
+ :class="{ 'gl-bg-gray-100!': isSelectDisabled }"
:options="options"
@change="integrationTypeSelect"
/>
@@ -472,8 +479,13 @@ export default {
>
<gl-form-input
v-model="integrationForm.name"
+ :disabled="isPrometheus"
type="text"
- :placeholder="$options.i18n.integrationFormSteps.step2.placeholder"
+ :placeholder="
+ isPrometheus
+ ? $options.i18n.integrationFormSteps.step2.prometheus
+ : $options.i18n.integrationFormSteps.step2.placeholder
+ "
/>
</gl-form-group>
<gl-form-group
diff --git a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
index 9ec65bb0b43..b89e9723606 100644
--- a/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/edit_feature_flag.vue
@@ -4,7 +4,7 @@ import { mapState, mapActions } from 'vuex';
import axios from '~/lib/utils/axios_utils';
import { sprintf, s__ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
-import { LEGACY_FLAG, NEW_FLAG_ALERT } from '../constants';
+import { LEGACY_FLAG } from '../constants';
import FeatureFlagForm from './form.vue';
export default {
@@ -36,7 +36,6 @@ export default {
legacyReadOnlyFlagAlert: s__(
'FeatureFlags|GitLab is moving to a new way of managing feature flags. This feature flag is read-only, and it will be removed in 14.0. Please create a new feature flag.',
),
- newFlagAlert: NEW_FLAG_ALERT,
},
computed: {
...mapState([
@@ -58,7 +57,7 @@ export default {
: sprintf(s__('Edit %{name}'), { name: this.name });
},
deprecated() {
- return this.hasNewVersionFlags && this.version === LEGACY_FLAG;
+ return this.version === LEGACY_FLAG;
},
deprecatedAndEditable() {
return this.deprecated && !this.hasLegacyReadOnlyFlags;
@@ -66,18 +65,12 @@ export default {
deprecatedAndReadOnly() {
return this.deprecated && this.hasLegacyReadOnlyFlags;
},
- hasNewVersionFlags() {
- return this.glFeatures.featureFlagsNewVersion;
- },
hasLegacyReadOnlyFlags() {
return (
this.glFeatures.featureFlagsLegacyReadOnly &&
!this.glFeatures.featureFlagsLegacyReadOnlyOverride
);
},
- shouldShowNewFlagAlert() {
- return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
- },
},
created() {
return this.fetchFeatureFlag();
@@ -95,14 +88,6 @@ export default {
</script>
<template>
<div>
- <gl-alert
- v-if="shouldShowNewFlagAlert"
- variant="warning"
- class="gl-my-5"
- @dismiss="dismissNewVersionFlagAlert"
- >
- {{ $options.translations.newFlagAlert }}
- </gl-alert>
<gl-loading-icon v-if="isLoading" size="xl" class="gl-mt-7" />
<template v-else-if="!isLoading && !hasError">
diff --git a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
index 54d038606f4..ba46bab2df0 100644
--- a/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
+++ b/app/assets/javascripts/feature_flags/components/feature_flags_table.vue
@@ -38,9 +38,6 @@ export default {
permissions() {
return this.glFeatures.featureFlagPermissions;
},
- isNewVersionFlagsEnabled() {
- return this.glFeatures.featureFlagsNewVersion;
- },
isLegacyReadOnlyFlagsEnabled() {
return (
this.glFeatures.featureFlagsLegacyReadOnly &&
@@ -68,7 +65,7 @@ export default {
},
methods: {
isLegacyFlag(flag) {
- return !this.isNewVersionFlagsEnabled || flag.version !== NEW_VERSION_FLAG;
+ return flag.version !== NEW_VERSION_FLAG;
},
statusToggleDisabled(flag) {
return this.isLegacyReadOnlyFlagsEnabled && flag.version === LEGACY_FLAG;
diff --git a/app/assets/javascripts/feature_flags/components/form.vue b/app/assets/javascripts/feature_flags/components/form.vue
index 36ebf893486..12856b79f63 100644
--- a/app/assets/javascripts/feature_flags/components/form.vue
+++ b/app/assets/javascripts/feature_flags/components/form.vue
@@ -137,14 +137,13 @@ export default {
return this.glFeatures.featureFlagPermissions;
},
supportsStrategies() {
- return this.glFeatures.featureFlagsNewVersion && this.version === NEW_VERSION_FLAG;
+ return this.version === NEW_VERSION_FLAG;
},
showRelatedIssues() {
return this.featureFlagIssuesEndpoint.length > 0;
},
readOnly() {
return (
- this.glFeatures.featureFlagsNewVersion &&
this.glFeatures.featureFlagsLegacyReadOnly &&
!this.glFeatures.featureFlagsLegacyReadOnlyOverride &&
this.version === LEGACY_FLAG
diff --git a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
index 9472eddf336..e6949d8028b 100644
--- a/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
+++ b/app/assets/javascripts/feature_flags/components/new_feature_flag.vue
@@ -1,21 +1,14 @@
<script>
import { mapState, mapActions } from 'vuex';
-import { GlAlert } from '@gitlab/ui';
import axios from '~/lib/utils/axios_utils';
import FeatureFlagForm from './form.vue';
-import {
- LEGACY_FLAG,
- NEW_VERSION_FLAG,
- NEW_FLAG_ALERT,
- ROLLOUT_STRATEGY_ALL_USERS,
-} from '../constants';
+import { NEW_VERSION_FLAG, ROLLOUT_STRATEGY_ALL_USERS } from '../constants';
import { createNewEnvironmentScope } from '../store/helpers';
import featureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export default {
components: {
- GlAlert,
FeatureFlagForm,
},
mixins: [featureFlagsMixin()],
@@ -33,9 +26,6 @@ export default {
userShouldSeeNewFlagAlert: this.showUserCallout,
};
},
- translations: {
- newFlagAlert: NEW_FLAG_ALERT,
- },
computed: {
...mapState(['error', 'path']),
scopes() {
@@ -50,13 +40,7 @@ export default {
];
},
version() {
- return this.hasNewVersionFlags ? NEW_VERSION_FLAG : LEGACY_FLAG;
- },
- hasNewVersionFlags() {
- return this.glFeatures.featureFlagsNewVersion;
- },
- shouldShowNewFlagAlert() {
- return !this.hasNewVersionFlags && this.userShouldSeeNewFlagAlert;
+ return NEW_VERSION_FLAG;
},
strategies() {
return [{ name: ROLLOUT_STRATEGY_ALL_USERS, parameters: {}, scopes: [] }];
@@ -75,14 +59,6 @@ export default {
</script>
<template>
<div>
- <gl-alert
- v-if="shouldShowNewFlagAlert"
- variant="warning"
- class="gl-my-5"
- @dismiss="dismissNewVersionFlagAlert"
- >
- {{ $options.translations.newFlagAlert }}
- </gl-alert>
<h3 class="page-title">{{ s__('FeatureFlags|New feature flag') }}</h3>
<div v-if="error.length" class="alert alert-danger">
diff --git a/app/assets/javascripts/feature_flags/constants.js b/app/assets/javascripts/feature_flags/constants.js
index 4843eca149a..658984456a5 100644
--- a/app/assets/javascripts/feature_flags/constants.js
+++ b/app/assets/javascripts/feature_flags/constants.js
@@ -21,10 +21,6 @@ export const fetchUserIdParams = property(['parameters', 'userIds']);
export const NEW_VERSION_FLAG = 'new_version_flag';
export const LEGACY_FLAG = 'legacy_flag';
-export const NEW_FLAG_ALERT = s__(
- 'FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow.',
-);
-
export const FEATURE_FLAG_SCOPE = 'featureFlags';
export const USER_LIST_SCOPE = 'userLists';
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
index d1b9894da0e..f8b3233438f 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list.vue
@@ -1,11 +1,11 @@
<script>
-import { GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
import ImageListRow from './image_list_row.vue';
export default {
name: 'ImageList',
components: {
- GlPagination,
+ GlKeysetPagination,
ImageListRow,
},
props: {
@@ -13,19 +13,14 @@ export default {
type: Array,
required: true,
},
- pagination: {
+ pageInfo: {
type: Object,
required: true,
},
},
computed: {
- currentPage: {
- get() {
- return this.pagination.page;
- },
- set(page) {
- this.$emit('pageChange', page);
- },
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
},
},
};
@@ -40,13 +35,15 @@ export default {
:first="index === 0"
@delete="$emit('delete', $event)"
/>
-
- <gl-pagination
- v-model="currentPage"
- :per-page="pagination.perPage"
- :total-items="pagination.total"
- align="center"
- class="w-100 gl-mt-3"
- />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="pageInfo.hasNextPage"
+ :has-previous-page="pageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
</div>
</template>
diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
index b0a7c4824bd..3fe61dc231a 100644
--- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue
@@ -1,6 +1,8 @@
<script>
import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import DeleteButton from '../delete_button.vue';
@@ -11,6 +13,8 @@ import {
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ IMAGE_DELETE_SCHEDULED_STATUS,
+ IMAGE_FAILED_DELETED_STATUS,
} from '../../constants/index';
export default {
@@ -38,19 +42,29 @@ export default {
},
computed: {
disabledDelete() {
- return !this.item.destroy_path || this.item.deleting;
+ return !this.item.canDelete || this.deleting;
+ },
+ id() {
+ return getIdFromGraphQLId(this.item.id);
+ },
+ deleting() {
+ return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
+ },
+ failedDelete() {
+ return this.item.status === IMAGE_FAILED_DELETED_STATUS;
},
tagsCountText() {
return n__(
'ContainerRegistry|%{count} Tag',
'ContainerRegistry|%{count} Tags',
- this.item.tags_count,
+ this.item.tagsCount,
);
},
warningIconText() {
- if (this.item.failedDelete) {
+ if (this.failedDelete) {
return ASYNC_DELETE_IMAGE_ERROR_MESSAGE;
- } else if (this.item.cleanup_policy_started_at) {
+ }
+ if (this.item.expirationPolicyStartedAt) {
return CLEANUP_TIMED_OUT_ERROR_MESSAGE;
}
return null;
@@ -63,23 +77,23 @@ export default {
<list-item
v-gl-tooltip="{
placement: 'left',
- disabled: !item.deleting,
+ disabled: !deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}"
v-bind="$attrs"
- :disabled="item.deleting"
+ :disabled="deleting"
>
<template #left-primary>
<router-link
class="gl-text-body gl-font-weight-bold"
data-testid="details-link"
- :to="{ name: 'details', params: { id: item.id } }"
+ :to="{ name: 'details', params: { id } }"
>
{{ item.path }}
</router-link>
<clipboard-button
v-if="item.location"
- :disabled="item.deleting"
+ :disabled="deleting"
:text="item.location"
:title="item.location"
category="tertiary"
@@ -97,7 +111,7 @@ export default {
<gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
- {{ item.tags_count }}
+ {{ item.tagsCount }}
</template>
</gl-sprintf>
</span>
@@ -106,7 +120,7 @@ export default {
<delete-button
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
- :tooltip-disabled="Boolean(item.destroy_path)"
+ :tooltip-disabled="item.canDelete"
:tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
@delete="$emit('delete', item)"
/>
diff --git a/app/assets/javascripts/registry/explorer/constants/list.js b/app/assets/javascripts/registry/explorer/constants/list.js
index 39f63d2a153..37ced72861e 100644
--- a/app/assets/javascripts/registry/explorer/constants/list.js
+++ b/app/assets/javascripts/registry/explorer/constants/list.js
@@ -44,5 +44,6 @@ export const EMPTY_RESULT_MESSAGE = s__(
// Parameters
-export const IMAGE_DELETE_SCHEDULED_STATUS = 'delete_scheduled';
-export const IMAGE_FAILED_DELETED_STATUS = 'delete_failed';
+export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
+export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
+export const GRAPHQL_PAGE_SIZE = 10;
diff --git a/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql
new file mode 100644
index 00000000000..9a3579ee8e0
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/fragments/container_repository.fragment.graphql
@@ -0,0 +1,11 @@
+fragment ContainerRepositoryFields on ContainerRepository {
+ id
+ name
+ path
+ status
+ location
+ canDelete
+ createdAt
+ tagsCount
+ expirationPolicyStartedAt
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/index.js b/app/assets/javascripts/registry/explorer/graphql/index.js
new file mode 100644
index 00000000000..16152eb81f6
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ assumeImmutableResults: true,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.graphql b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.graphql
new file mode 100644
index 00000000000..4c88b726ee5
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/mutations/delete_container_repository.graphql
@@ -0,0 +1,9 @@
+mutation destroyContainerRepository($id: ContainerRepositoryID!) {
+ destroyContainerRepository(input: { id: $id }) {
+ containerRepository {
+ id
+ status
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.graphql
new file mode 100644
index 00000000000..a3bafef15d9
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_group_container_repositories.graphql
@@ -0,0 +1,23 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/container_repository.fragment.graphql"
+
+query getProjectContainerRepositories(
+ $fullPath: ID!
+ $name: String
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ group(fullPath: $fullPath) {
+ containerRepositoriesCount
+ containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ ...ContainerRepositoryFields
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.graphql b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.graphql
new file mode 100644
index 00000000000..338e27745f7
--- /dev/null
+++ b/app/assets/javascripts/registry/explorer/graphql/queries/get_project_container_repositories.graphql
@@ -0,0 +1,23 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+#import "../fragments/container_repository.fragment.graphql"
+
+query getProjectContainerRepositories(
+ $fullPath: ID!
+ $name: String
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ project(fullPath: $fullPath) {
+ containerRepositoriesCount
+ containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ ...ContainerRepositoryFields
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/registry/explorer/index.js b/app/assets/javascripts/registry/explorer/index.js
index 2bba3ee4ff9..5fafd861a06 100644
--- a/app/assets/javascripts/registry/explorer/index.js
+++ b/app/assets/javascripts/registry/explorer/index.js
@@ -5,6 +5,7 @@ import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores';
import createRouter from './router';
+import { apolloProvider } from './graphql/index';
Vue.use(Translate);
Vue.use(GlToast);
@@ -27,6 +28,7 @@ export default () => {
el,
store,
router,
+ apolloProvider,
components: {
RegistryExplorer,
},
diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue
index 81e47073fe9..9b8826138ae 100644
--- a/app/assets/javascripts/registry/explorer/pages/list.vue
+++ b/app/assets/javascripts/registry/explorer/pages/list.vue
@@ -1,5 +1,5 @@
<script>
-import { mapState, mapActions } from 'vuex';
+import { mapState } from 'vuex';
import {
GlEmptyState,
GlTooltipDirective,
@@ -11,6 +11,7 @@ import {
GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
+import createFlash from '~/flash';
import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
import GroupEmptyState from '../components/list_page/group_empty_state.vue';
@@ -18,6 +19,10 @@ import RegistryHeader from '../components/list_page/registry_header.vue';
import ImageList from '../components/list_page/image_list.vue';
import CliCommands from '../components/list_page/cli_commands.vue';
+import getProjectContainerRepositories from '../graphql/queries/get_project_container_repositories.graphql';
+import getGroupContainerRepositories from '../graphql/queries/get_group_container_repositories.graphql';
+import deleteContainerRepository from '../graphql/mutations/delete_container_repository.graphql';
+
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
@@ -29,6 +34,8 @@ import {
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../constants/index';
export default {
@@ -66,21 +73,63 @@ export default {
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
},
+ apollo: {
+ images: {
+ query() {
+ return this.graphQlQuery;
+ },
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data[this.graphqlResource]?.containerRepositories.nodes;
+ },
+ result({ data }) {
+ this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
+ this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
data() {
return {
+ images: [],
+ pageInfo: {},
+ containerRepositoriesCount: 0,
itemToDelete: {},
deleteAlertType: null,
- search: null,
- isEmpty: false,
+ searchValue: null,
+ name: null,
+ mutationLoading: false,
};
},
computed: {
- ...mapState(['config', 'isLoading', 'images', 'pagination']),
+ ...mapState(['config']),
+ graphqlResource() {
+ return this.config.isGroupPage ? 'group' : 'project';
+ },
+ graphQlQuery() {
+ return this.config.isGroupPage
+ ? getGroupContainerRepositories
+ : getProjectContainerRepositories;
+ },
+ queryVariables() {
+ return {
+ name: this.name,
+ fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
tracking() {
return {
label: 'registry_repository_delete',
};
},
+ isLoading() {
+ return this.$apollo.queries.images.loading || this.mutationLoading;
+ },
showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
@@ -93,19 +142,7 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
- mounted() {
- this.loadImageList(this.$route.name);
- },
methods: {
- ...mapActions(['requestImagesList', 'requestDeleteImage']),
- loadImageList(fromName) {
- if (!fromName || !this.images?.length) {
- return this.requestImagesList().then(() => {
- this.isEmpty = this.images.length === 0;
- });
- }
- return Promise.resolve();
- },
deleteImage(item) {
this.track('click_button');
this.itemToDelete = item;
@@ -113,18 +150,59 @@ export default {
},
handleDeleteImage() {
this.track('confirm_delete');
- return this.requestDeleteImage(this.itemToDelete)
- .then(() => {
- this.deleteAlertType = 'success';
+ this.mutationLoading = true;
+ return this.$apollo
+ .mutate({
+ mutation: deleteContainerRepository,
+ variables: {
+ id: this.itemToDelete.id,
+ },
+ })
+ .then(({ data }) => {
+ if (data?.destroyContainerRepository?.errors[0]) {
+ this.deleteAlertType = 'danger';
+ } else {
+ this.deleteAlertType = 'success';
+ }
})
.catch(() => {
this.deleteAlertType = 'danger';
+ })
+ .finally(() => {
+ this.mutationLoading = false;
});
},
dismissDeleteAlert() {
this.deleteAlertType = null;
this.itemToDelete = {};
},
+ fetchNextPage() {
+ if (this.pageInfo?.hasNextPage) {
+ this.$apollo.queries.images.fetchMore({
+ variables: {
+ after: this.pageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ }
+ },
+ fetchPreviousPage() {
+ if (this.pageInfo?.hasPreviousPage) {
+ this.$apollo.queries.images.fetchMore({
+ variables: {
+ first: null,
+ before: this.pageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ }
+ },
},
};
</script>
@@ -134,7 +212,7 @@ export default {
<gl-alert
v-if="showDeleteAlert"
:variant="deleteAlertType"
- class="mt-2"
+ class="gl-mt-5"
dismissible
@dismiss="dismissDeleteAlert"
>
@@ -165,7 +243,7 @@ export default {
<template v-else>
<registry-header
- :images-count="pagination.total"
+ :images-count="containerRepositoriesCount"
:expiration-policy="config.expirationPolicy"
:help-page-path="config.helpPagePath"
:expiration-policy-help-page-path="config.expirationPolicyHelpPagePath"
@@ -176,7 +254,7 @@ export default {
</template>
</registry-header>
- <div v-if="isLoading" class="mt-2">
+ <div v-if="isLoading" class="gl-mt-5">
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
@@ -190,16 +268,17 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
- <template v-if="!isEmpty">
+ <template v-if="images.length > 0 || name">
<div class="gl-display-flex gl-p-1 gl-mt-3" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
<div>
<gl-search-box-by-click
- v-model="search"
+ v-model="searchValue"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
- @submit="requestImagesList({ name: $event })"
+ @clear="name = null"
+ @submit="name = $event"
/>
</div>
</div>
@@ -207,9 +286,10 @@ export default {
<image-list
v-if="images.length"
:images="images"
- :pagination="pagination"
- @pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
+ :page-info="pageInfo"
@delete="deleteImage"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
/>
<gl-empty-state
diff --git a/app/assets/javascripts/search/group_filter/components/group_filter.vue b/app/assets/javascripts/search/group_filter/components/group_filter.vue
deleted file mode 100644
index 4b7963c5187..00000000000
--- a/app/assets/javascripts/search/group_filter/components/group_filter.vue
+++ /dev/null
@@ -1,124 +0,0 @@
-<script>
-import {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
- GlLoadingIcon,
- GlIcon,
- GlSkeletonLoader,
- GlTooltipDirective,
-} from '@gitlab/ui';
-import { mapState, mapActions } from 'vuex';
-import { isEmpty } from 'lodash';
-import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
-import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants';
-
-export default {
- name: 'GroupFilter',
- components: {
- GlDropdown,
- GlDropdownItem,
- GlSearchBoxByType,
- GlLoadingIcon,
- GlIcon,
- GlSkeletonLoader,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- initialGroup: {
- type: Object,
- required: false,
- default: () => ({}),
- },
- },
- data() {
- return {
- groupSearch: '',
- };
- },
- computed: {
- ...mapState(['groups', 'fetchingGroups']),
- selectedGroup: {
- get() {
- return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup;
- },
- set(group) {
- visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null }));
- },
- },
- },
- methods: {
- ...mapActions(['fetchGroups']),
- isGroupSelected(group) {
- return group.id === this.selectedGroup.id;
- },
- handleGroupChange(group) {
- this.selectedGroup = group;
- },
- },
- ANY_GROUP,
-};
-</script>
-
-<template>
- <gl-dropdown
- ref="groupFilter"
- class="gl-w-full"
- menu-class="gl-w-full!"
- toggle-class="gl-text-truncate gl-reset-line-height!"
- :header-text="__('Filter results by group')"
- @show="fetchGroups(groupSearch)"
- >
- <template #button-content>
- <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
- {{ selectedGroup.name }}
- </span>
- <gl-loading-icon v-if="fetchingGroups" inline class="mr-2" />
- <gl-icon
- v-if="!isGroupSelected($options.ANY_GROUP)"
- v-gl-tooltip
- name="clear"
- :title="__('Clear')"
- class="gl-text-gray-200! gl-hover-text-blue-800!"
- @click.stop="handleGroupChange($options.ANY_GROUP)"
- />
- <gl-icon name="chevron-down" />
- </template>
- <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
- <gl-search-box-by-type
- v-model="groupSearch"
- class="m-2"
- :debounce="500"
- @input="fetchGroups"
- />
- <gl-dropdown-item
- class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
- :is-check-item="true"
- :is-checked="isGroupSelected($options.ANY_GROUP)"
- @click="handleGroupChange($options.ANY_GROUP)"
- >
- {{ $options.ANY_GROUP.name }}
- </gl-dropdown-item>
- </div>
- <div v-if="!fetchingGroups">
- <gl-dropdown-item
- v-for="group in groups"
- :key="group.id"
- :is-check-item="true"
- :is-checked="isGroupSelected(group)"
- @click="handleGroupChange(group)"
- >
- {{ group.full_name }}
- </gl-dropdown-item>
- </div>
- <div v-if="fetchingGroups" class="mx-3 mt-2">
- <gl-skeleton-loader :height="100">
- <rect y="0" width="90%" height="20" rx="4" />
- <rect y="40" width="70%" height="20" rx="4" />
- <rect y="80" width="80%" height="20" rx="4" />
- </gl-skeleton-loader>
- </div>
- </gl-dropdown>
-</template>
diff --git a/app/assets/javascripts/search/group_filter/constants.js b/app/assets/javascripts/search/group_filter/constants.js
deleted file mode 100644
index 9bd92eaa130..00000000000
--- a/app/assets/javascripts/search/group_filter/constants.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { __ } from '~/locale';
-
-export const ANY_GROUP = Object.freeze({
- id: null,
- name: __('Any'),
-});
-
-export const GROUP_QUERY_PARAM = 'group_id';
-
-export const PROJECT_QUERY_PARAM = 'project_id';
diff --git a/app/assets/javascripts/search/group_filter/index.js b/app/assets/javascripts/search/group_filter/index.js
deleted file mode 100644
index 9b009bc0305..00000000000
--- a/app/assets/javascripts/search/group_filter/index.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import Vue from 'vue';
-import Translate from '~/vue_shared/translate';
-import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
-import GroupFilter from './components/group_filter.vue';
-
-Vue.use(Translate);
-
-export default store => {
- let initialGroup;
- const el = document.getElementById('js-search-group-dropdown');
-
- const { initialGroupData } = el.dataset;
-
- initialGroup = JSON.parse(initialGroupData);
- initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true });
-
- return new Vue({
- el,
- store,
- render(createElement) {
- return createElement(GroupFilter, {
- props: {
- initialGroup,
- },
- });
- },
- });
-};
diff --git a/app/assets/javascripts/search/index.js b/app/assets/javascripts/search/index.js
index 781a564d077..d2bb1ccfc44 100644
--- a/app/assets/javascripts/search/index.js
+++ b/app/assets/javascripts/search/index.js
@@ -1,7 +1,7 @@
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
+import { initTopbar } from './topbar';
import { initSidebar } from './sidebar';
-import initGroupFilter from './group_filter';
export const initSearchApp = () => {
// Similar to url_utility.decodeUrlParameter
@@ -9,6 +9,6 @@ export const initSearchApp = () => {
const sanitizedSearch = window.location.search.replace(/\+/g, '%20');
const store = createStore({ query: queryToObject(sanitizedSearch) });
+ initTopbar(store);
initSidebar(store);
- initGroupFilter(store);
};
diff --git a/app/assets/javascripts/search/topbar/components/group_filter.vue b/app/assets/javascripts/search/topbar/components/group_filter.vue
new file mode 100644
index 00000000000..fce9ec17d23
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/components/group_filter.vue
@@ -0,0 +1,49 @@
+<script>
+import { mapState, mapActions } from 'vuex';
+import { isEmpty } from 'lodash';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import SearchableDropdown from './searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
+
+export default {
+ name: 'GroupFilter',
+ components: {
+ SearchableDropdown,
+ },
+ props: {
+ initialData: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ },
+ computed: {
+ ...mapState(['groups', 'fetchingGroups']),
+ selectedGroup() {
+ return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
+ },
+ },
+ methods: {
+ ...mapActions(['fetchGroups']),
+ handleGroupChange(group) {
+ visitUrl(
+ setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }),
+ );
+ },
+ },
+ GROUP_DATA,
+};
+</script>
+
+<template>
+ <searchable-dropdown
+ :header-text="$options.GROUP_DATA.headerText"
+ :selected-display-value="$options.GROUP_DATA.selectedDisplayValue"
+ :items-display-value="$options.GROUP_DATA.itemsDisplayValue"
+ :loading="fetchingGroups"
+ :selected-item="selectedGroup"
+ :items="groups"
+ @search="fetchGroups"
+ @change="handleGroupChange"
+ />
+</template>
diff --git a/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
new file mode 100644
index 00000000000..55f3637b015
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/components/searchable_dropdown.vue
@@ -0,0 +1,144 @@
+<script>
+import {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlIcon,
+ GlButton,
+ GlSkeletonLoader,
+ GlTooltipDirective,
+} from '@gitlab/ui';
+
+import { ANY_OPTION } from '../constants';
+
+export default {
+ name: 'SearchableDropdown',
+ components: {
+ GlDropdown,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ GlLoadingIcon,
+ GlIcon,
+ GlButton,
+ GlSkeletonLoader,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ headerText: {
+ type: String,
+ required: false,
+ default: "__('Filter')",
+ },
+ selectedDisplayValue: {
+ type: String,
+ required: false,
+ default: 'name',
+ },
+ itemsDisplayValue: {
+ type: String,
+ required: false,
+ default: 'name',
+ },
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ selectedItem: {
+ type: Object,
+ required: true,
+ },
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ searchText: '',
+ };
+ },
+ methods: {
+ isSelected(selected) {
+ return selected.id === this.selectedItem.id;
+ },
+ openDropdown() {
+ this.$emit('search', this.searchText);
+ },
+ resetDropdown() {
+ this.$emit('change', ANY_OPTION);
+ },
+ },
+ ANY_OPTION,
+};
+</script>
+
+<template>
+ <gl-dropdown
+ class="gl-w-full"
+ menu-class="gl-w-full!"
+ toggle-class="gl-text-truncate gl-reset-line-height!"
+ :header-text="headerText"
+ @show="$emit('search', searchText)"
+ @shown="$refs.searchBox.focusInput()"
+ >
+ <template #button-content>
+ <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
+ {{ selectedItem[selectedDisplayValue] }}
+ </span>
+ <gl-loading-icon v-if="loading" inline class="gl-mr-3" />
+ <gl-button
+ v-if="!isSelected($options.ANY_OPTION)"
+ v-gl-tooltip
+ name="clear"
+ category="tertiary"
+ :title="__('Clear')"
+ class="gl-p-0! gl-mr-2"
+ @keydown.enter.stop="resetDropdown"
+ @click.stop="resetDropdown"
+ >
+ <gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" />
+ </gl-button>
+ <gl-icon name="chevron-down" />
+ </template>
+ <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
+ <gl-search-box-by-type
+ ref="searchBox"
+ v-model="searchText"
+ class="gl-m-3"
+ :debounce="500"
+ @input="$emit('search', searchText)"
+ />
+ <gl-dropdown-item
+ class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
+ :is-check-item="true"
+ :is-checked="isSelected($options.ANY_OPTION)"
+ @click="resetDropdown"
+ >
+ {{ $options.ANY_OPTION.name }}
+ </gl-dropdown-item>
+ </div>
+ <div v-if="!loading">
+ <gl-dropdown-item
+ v-for="item in items"
+ :key="item.id"
+ :is-check-item="true"
+ :is-checked="isSelected(item)"
+ @click="$emit('change', item)"
+ >
+ {{ item[itemsDisplayValue] }}
+ </gl-dropdown-item>
+ </div>
+ <div v-if="loading" class="gl-mx-4 gl-mt-3">
+ <gl-skeleton-loader :height="100">
+ <rect y="0" width="90%" height="20" rx="4" />
+ <rect y="40" width="70%" height="20" rx="4" />
+ <rect y="80" width="80%" height="20" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/search/topbar/constants.js b/app/assets/javascripts/search/topbar/constants.js
new file mode 100644
index 00000000000..3944b2c8374
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/constants.js
@@ -0,0 +1,21 @@
+import { __ } from '~/locale';
+
+export const ANY_OPTION = Object.freeze({
+ id: null,
+ name: __('Any'),
+ name_with_namespace: __('Any'),
+});
+
+export const GROUP_DATA = {
+ headerText: __('Filter results by group'),
+ queryParam: 'group_id',
+ selectedDisplayValue: 'name',
+ itemsDisplayValue: 'full_name',
+};
+
+export const PROJECT_DATA = {
+ headerText: __('Filter results by project'),
+ queryParam: 'project_id',
+ selectedDisplayValue: 'name_with_namespace',
+ itemsDisplayValue: 'name_with_namespace',
+};
diff --git a/app/assets/javascripts/search/topbar/index.js b/app/assets/javascripts/search/topbar/index.js
new file mode 100644
index 00000000000..a751fa53e03
--- /dev/null
+++ b/app/assets/javascripts/search/topbar/index.js
@@ -0,0 +1,39 @@
+import Vue from 'vue';
+import Translate from '~/vue_shared/translate';
+import GroupFilter from './components/group_filter.vue';
+
+Vue.use(Translate);
+
+const mountSearchableDropdown = (store, { id, component }) => {
+ const el = document.getElementById(id);
+
+ if (!el) {
+ return false;
+ }
+
+ let { initialData } = el.dataset;
+
+ initialData = JSON.parse(initialData);
+
+ return new Vue({
+ el,
+ store,
+ render(createElement) {
+ return createElement(component, {
+ props: {
+ initialData,
+ },
+ });
+ },
+ });
+};
+
+const searchableDropdowns = [
+ {
+ id: 'js-search-group-dropdown',
+ component: GroupFilter,
+ },
+];
+
+export const initTopbar = store =>
+ searchableDropdowns.map(dropdown => mountSearchableDropdown(store, dropdown));
diff --git a/app/controllers/projects/feature_flags_controller.rb b/app/controllers/projects/feature_flags_controller.rb
index e9d450a6ce3..8f623f90318 100644
--- a/app/controllers/projects/feature_flags_controller.rb
+++ b/app/controllers/projects/feature_flags_controller.rb
@@ -14,7 +14,6 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:feature_flag_permissions)
- push_frontend_feature_flag(:feature_flags_new_version, project, default_enabled: true)
push_frontend_feature_flag(:feature_flags_legacy_read_only, project, default_enabled: true)
push_frontend_feature_flag(:feature_flags_legacy_read_only_override, project)
end
@@ -101,15 +100,7 @@ class Projects::FeatureFlagsController < Projects::ApplicationController
protected
def feature_flag
- @feature_flag ||= @noteable = if new_version_feature_flags_enabled?
- project.operations_feature_flags.find_by_iid!(params[:iid])
- else
- project.operations_feature_flags.legacy_flag.find_by_iid!(params[:iid])
- end
- end
-
- def new_version_feature_flags_enabled?
- ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
+ @feature_flag ||= @noteable = project.operations_feature_flags.find_by_iid!(params[:iid])
end
def ensure_legacy_flags_writable!
diff --git a/app/finders/feature_flags_finder.rb b/app/finders/feature_flags_finder.rb
index 9cb3bf7fa23..7b38841970d 100644
--- a/app/finders/feature_flags_finder.rb
+++ b/app/finders/feature_flags_finder.rb
@@ -24,11 +24,7 @@ class FeatureFlagsFinder
private
def feature_flags
- if Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
- project.operations_feature_flags
- else
- project.operations_feature_flags.legacy_flag
- end
+ project.operations_feature_flags
end
def by_scope(items)
diff --git a/app/helpers/system_note_helper.rb b/app/helpers/system_note_helper.rb
index 79f4810e13a..85e644967ea 100644
--- a/app/helpers/system_note_helper.rb
+++ b/app/helpers/system_note_helper.rb
@@ -38,7 +38,8 @@ module SystemNoteHelper
'status' => 'status',
'alert_issue_added' => 'issues',
'new_alert_added' => 'warning',
- 'severity' => 'information-o'
+ 'severity' => 'information-o',
+ 'cloned' => 'documents'
}.freeze
def system_note_icon_name(note)
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 46cfdd74b5f..45bb8a44840 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -32,6 +32,7 @@ class Environment < ApplicationRecord
has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment'
has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus'
has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline'
+ has_one :upcoming_deployment, -> { running.order('deployments.id DESC') }, class_name: 'Deployment'
has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment
before_validation :nullify_external_url
diff --git a/app/models/experiment.rb b/app/models/experiment.rb
index 2bced3911d0..29a40d36a79 100644
--- a/app/models/experiment.rb
+++ b/app/models/experiment.rb
@@ -5,8 +5,8 @@ class Experiment < ApplicationRecord
validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
- def self.add_user(name, group_type, user)
- find_or_create_by!(name: name).record_user_and_group(user, group_type)
+ def self.add_user(name, group_type, user, context = {})
+ find_or_create_by!(name: name).record_user_and_group(user, group_type, context)
end
def self.record_conversion_event(name, user)
@@ -14,8 +14,8 @@ class Experiment < ApplicationRecord
end
# Create or update the recorded experiment_user row for the user in this experiment.
- def record_user_and_group(user, group_type)
- experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type)
+ def record_user_and_group(user, group_type, context = {})
+ experiment_users.find_or_initialize_by(user: user).update!(group_type: group_type, context: context)
end
def record_conversion_event_for_user(user)
diff --git a/app/models/issue.rb b/app/models/issue.rb
index d6dba8bb9e5..b4071307e06 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -308,6 +308,7 @@ class Issue < ApplicationRecord
!moved? && persisted? &&
user.can?(:admin_issue, self.project)
end
+ alias_method :can_clone?, :can_move?
def to_branch_name
if self.confidential?
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index 8dd471b259e..20107147b4f 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -14,12 +14,13 @@ class SystemNoteMetadata < ApplicationRecord
moved merge
label milestone
relate unrelate
+ cloned
].freeze
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
designs_added designs_modified designs_removed designs_discussion_added
- title time_tracking branch milestone discussion task moved
+ title time_tracking branch milestone discussion task moved cloned
opened closed merged duplicate locked unlocked outdated reviewer
tag due_date pinned_embed cherry_pick health_status approved unapproved
status alert_issue_added relate unrelate new_alert_added severity
diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb
index 0bd9c602bf5..8c6ad010d69 100644
--- a/app/serializers/environment_entity.rb
+++ b/app/serializers/environment_entity.rb
@@ -3,6 +3,9 @@
class EnvironmentEntity < Grape::Entity
include RequestAwareEntity
+ UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT =
+ %i[manual_actions scheduled_actions playable_build cluster].freeze
+
expose :id
expose :global_id do |environment|
@@ -17,6 +20,11 @@ class EnvironmentEntity < Grape::Entity
expose :last_deployment, using: DeploymentEntity
expose :stop_action_available?, as: :has_stop_action
+ expose :upcoming_deployment, expose_nil: false do |environment, ops|
+ DeploymentEntity.represent(environment.upcoming_deployment,
+ ops.merge(except: UNNECESSARY_ENTRIES_FOR_UPCOMING_DEPLOYMENT))
+ end
+
expose :metrics_path, if: -> (*) { environment.has_metrics? } do |environment|
metrics_project_environment_path(environment.project, environment)
end
diff --git a/app/services/feature_flags/create_service.rb b/app/services/feature_flags/create_service.rb
index b4ca90f7aae..de3a55d10fc 100644
--- a/app/services/feature_flags/create_service.rb
+++ b/app/services/feature_flags/create_service.rb
@@ -5,7 +5,6 @@ module FeatureFlags
def execute
return error('Access Denied', 403) unless can_create?
return error('Version is invalid', :bad_request) unless valid_version?
- return error('New version feature flags are not enabled for this project', :bad_request) unless flag_version_enabled?
ActiveRecord::Base.transaction do
feature_flag = project.operations_feature_flags.new(params)
@@ -40,13 +39,5 @@ module FeatureFlags
def valid_version?
!params.key?(:version) || Operations::FeatureFlag.versions.key?(params[:version])
end
-
- def flag_version_enabled?
- params[:version] != 'new_version_flag' || new_version_feature_flags_enabled?
- end
-
- def new_version_feature_flags_enabled?
- ::Feature.enabled?(:feature_flags_new_version, project, default_enabled: true)
- end
end
end
diff --git a/app/services/issues/clone_service.rb b/app/services/issues/clone_service.rb
new file mode 100644
index 00000000000..a8c0cb05ebe
--- /dev/null
+++ b/app/services/issues/clone_service.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+module Issues
+ class CloneService < Issuable::Clone::BaseService
+ CloneError = Class.new(StandardError)
+
+ def execute(issue, target_project)
+ @target_project = target_project
+
+ unless issue.can_clone?(current_user, @target_project)
+ raise CloneError, s_('CloneIssue|Cannot clone issue due to insufficient permissions!')
+ end
+
+ if target_project.pending_delete?
+ raise CloneError, s_('CloneIssue|Cannot clone issue to target project as it is pending deletion.')
+ end
+
+ super(issue, target_project)
+
+ queue_copy_designs
+
+ new_entity
+ end
+
+ private
+
+ attr_reader :target_project
+
+ def update_new_entity
+ # we don't call `super` because we want to be able to decide whether or not to copy all comments over.
+ update_new_entity_description
+ update_new_entity_attributes
+ copy_award_emoji
+ end
+
+ def update_old_entity
+ # no-op
+ # The base_service closes the old issue, we don't want that, so we override here so nothing happens.
+ end
+
+ def create_new_entity
+ new_params = {
+ id: nil,
+ iid: nil,
+ project: target_project,
+ author: original_entity.author,
+ assignee_ids: original_entity.assignee_ids
+ }
+
+ new_params = original_entity.serializable_hash.symbolize_keys.merge(new_params)
+
+ # Skip creation of system notes for existing attributes of the issue. The system notes of the old
+ # issue are copied over so we don't want to end up with duplicate notes.
+ CreateService.new(@target_project, @current_user, new_params).execute(skip_system_notes: true)
+ end
+
+ def queue_copy_designs
+ return unless original_entity.designs.present?
+
+ response = DesignManagement::CopyDesignCollection::QueueService.new(
+ current_user,
+ original_entity,
+ new_entity
+ ).execute
+
+ log_error(response.message) if response.error?
+ end
+
+ def add_note_from
+ SystemNoteService.noteable_cloned(new_entity, target_project,
+ original_entity, current_user,
+ direction: :from)
+ end
+
+ def add_note_to
+ SystemNoteService.noteable_cloned(original_entity, old_project,
+ new_entity, current_user,
+ direction: :to)
+ end
+ end
+end
diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb
index b9832400302..a2c11cb0a7c 100644
--- a/app/services/issues/update_service.rb
+++ b/app/services/issues/update_service.rb
@@ -9,7 +9,7 @@ module Issues
handle_move_between_ids(issue)
filter_spam_check_params
change_issue_duplicate(issue)
- move_issue_to_new_project(issue) || update_task_event(issue) || update(issue)
+ move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue)
end
def update(issue)
@@ -127,6 +127,17 @@ module Issues
private
+ def clone_issue(issue)
+ target_project = params.delete(:target_clone_project)
+
+ return unless target_project &&
+ issue.can_clone?(current_user, target_project)
+
+ # we've pre-empted this from running in #execute, so let's go ahead and update the Issue now.
+ update(issue)
+ Issues::CloneService.new(project, current_user).execute(issue, target_project)
+ end
+
def create_merge_request_from_quick_action
create_merge_request_params = params.delete(:create_merge_request)
return unless create_merge_request_params
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index eacc88f98a3..58f72e9badc 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -226,6 +226,10 @@ module SystemNoteService
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_moved(noteable_ref, direction)
end
+ def noteable_cloned(noteable, project, noteable_ref, author, direction:)
+ ::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).noteable_cloned(noteable_ref, direction)
+ end
+
def mark_duplicate_issue(noteable, project, author, canonical_issue)
::SystemNotes::IssuablesService.new(noteable: noteable, project: project, author: author).mark_duplicate_issue(canonical_issue)
end
diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb
index 7a73af0a81a..9f2b7d30fdc 100644
--- a/app/services/system_notes/issuables_service.rb
+++ b/app/services/system_notes/issuables_service.rb
@@ -242,6 +242,27 @@ module SystemNotes
create_note(NoteSummary.new(noteable, project, author, body, action: 'moved'))
end
+ # Called when noteable has been cloned
+ #
+ # noteable_ref - Referenced noteable
+ # direction - symbol, :to or :from
+ #
+ # Example Note text:
+ #
+ # "cloned to some_namespace/project_new#11"
+ #
+ # Returns the created Note object
+ def noteable_cloned(noteable_ref, direction)
+ unless [:to, :from].include?(direction)
+ raise ArgumentError, "Invalid direction `#{direction}`"
+ end
+
+ cross_reference = noteable_ref.to_reference(project)
+ body = "cloned #{direction} #{cross_reference}"
+
+ create_note(NoteSummary.new(noteable, project, author, body, action: 'cloned'))
+ end
+
# Called when the confidentiality changes
#
# Example Note text:
diff --git a/app/views/groups/registry/repositories/index.html.haml b/app/views/groups/registry/repositories/index.html.haml
index 21882c3e3ce..1d035bb2f7b 100644
--- a/app/views/groups/registry/repositories/index.html.haml
+++ b/app/views/groups/registry/repositories/index.html.haml
@@ -16,4 +16,5 @@
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"is_admin": current_user&.admin.to_s,
is_group_page: "true",
+ "group_path": @group.full_path,
character_error: @character_error.to_s } }
diff --git a/app/views/layouts/_loading_hints.html.haml b/app/views/layouts/_loading_hints.html.haml
index a75b602ff6b..0ef50d1b122 100644
--- a/app/views/layouts/_loading_hints.html.haml
+++ b/app/views/layouts/_loading_hints.html.haml
@@ -6,6 +6,5 @@
- else
%link{ { rel: 'preload', href: stylesheet_url('application'), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
%link{ { rel: 'preload', href: stylesheet_url("highlight/themes/#{user_color_scheme}"), as: 'style' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
-%link{ { rel: 'preload', href: asset_url("fontawesome-webfont.woff2?v=4.7.0"), as: 'font', type: 'font/woff2' }, ActionController::Base.asset_host ? { crossorigin: 'anonymous' } : {} }
- if Gitlab::CurrentSettings.snowplow_enabled? && Gitlab::CurrentSettings.snowplow_collector_hostname
%link{ rel: 'preconnect', href: Gitlab::CurrentSettings.snowplow_collector_hostname, crossorigin: '' }
diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml
index 9ac1fda169f..efdc54afc07 100644
--- a/app/views/projects/registry/repositories/index.html.haml
+++ b/app/views/projects/registry/repositories/index.html.haml
@@ -17,6 +17,6 @@
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
-
+ "project_path": @project.full_path,
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
diff --git a/app/views/search/_filter.html.haml b/app/views/search/_filter.html.haml
index 05895d83c2b..c2f7fd23554 100644
--- a/app/views/search/_filter.html.haml
+++ b/app/views/search/_filter.html.haml
@@ -5,7 +5,7 @@
.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" }
= _("Group")
- %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-data": @group.to_json } }
+ %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } }
.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } }
%label.d-block{ for: "dashboard_search_project" }
= _("Project")
diff --git a/changelogs/unreleased/234013-add-experiment-record-context.yml b/changelogs/unreleased/234013-add-experiment-record-context.yml
new file mode 100644
index 00000000000..5613d145be5
--- /dev/null
+++ b/changelogs/unreleased/234013-add-experiment-record-context.yml
@@ -0,0 +1,5 @@
+---
+title: Add context to the experiment user records
+merge_request: 48896
+author:
+type: added
diff --git a/changelogs/unreleased/270090-add-pk-to-elasticsearch-indexed-projects.yml b/changelogs/unreleased/270090-add-pk-to-elasticsearch-indexed-projects.yml
new file mode 100644
index 00000000000..c40fcefc914
--- /dev/null
+++ b/changelogs/unreleased/270090-add-pk-to-elasticsearch-indexed-projects.yml
@@ -0,0 +1,5 @@
+---
+title: Add primary key to elasticsearch_indexed_projects
+merge_request: 48919
+author:
+type: changed
diff --git a/changelogs/unreleased/271538-remove-vsa-usage-ping.yml b/changelogs/unreleased/271538-remove-vsa-usage-ping.yml
new file mode 100644
index 00000000000..6f24b58119b
--- /dev/null
+++ b/changelogs/unreleased/271538-remove-vsa-usage-ping.yml
@@ -0,0 +1,5 @@
+---
+title: Remove avg_cycle_analytics from usage ping
+merge_request: 47812
+author:
+type: other
diff --git a/changelogs/unreleased/276432-refactor-container-registry-frontend-to-graphql.yml b/changelogs/unreleased/276432-refactor-container-registry-frontend-to-graphql.yml
new file mode 100644
index 00000000000..bd2e0a05528
--- /dev/null
+++ b/changelogs/unreleased/276432-refactor-container-registry-frontend-to-graphql.yml
@@ -0,0 +1,5 @@
+---
+title: Refactor container registry list page to grapqhl
+merge_request: 48602
+author:
+type: changed
diff --git a/changelogs/unreleased/287724-disable-name-prometheus.yml b/changelogs/unreleased/287724-disable-name-prometheus.yml
new file mode 100644
index 00000000000..b650665635d
--- /dev/null
+++ b/changelogs/unreleased/287724-disable-name-prometheus.yml
@@ -0,0 +1,5 @@
+---
+title: Prometheus integration name should not have a modifiable input field
+merge_request: 48437
+author:
+type: fixed
diff --git a/changelogs/unreleased/9421-clone-quickaction.yml b/changelogs/unreleased/9421-clone-quickaction.yml
new file mode 100644
index 00000000000..2335cafa37a
--- /dev/null
+++ b/changelogs/unreleased/9421-clone-quickaction.yml
@@ -0,0 +1,5 @@
+---
+title: Implement a /clone quick-action to quickly clone an Issue
+merge_request: 48394
+author:
+type: added
diff --git a/changelogs/unreleased/expose-upcoming-deployment.yml b/changelogs/unreleased/expose-upcoming-deployment.yml
new file mode 100644
index 00000000000..70693858bf5
--- /dev/null
+++ b/changelogs/unreleased/expose-upcoming-deployment.yml
@@ -0,0 +1,5 @@
+---
+title: Expose upcoming deployment in environment.json
+merge_request: 48449
+author:
+type: added
diff --git a/config/feature_flags/development/feature_flags_new_version.yml b/config/feature_flags/development/feature_flags_new_version.yml
deleted file mode 100644
index f42054ce3ef..00000000000
--- a/config/feature_flags/development/feature_flags_new_version.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: feature_flags_new_version
-introduced_by_url:
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/258831
-milestone: '13.7'
-type: development
-group: group::progressive delivery
-default_enabled: true
diff --git a/config/feature_flags/development/security_on_demand_scans_http_header_validation.yml b/config/feature_flags/development/security_on_demand_scans_http_header_validation.yml
deleted file mode 100644
index 475cbfa4d9a..00000000000
--- a/config/feature_flags/development/security_on_demand_scans_http_header_validation.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: security_on_demand_scans_http_header_validation
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/42812
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276403
-milestone: '13.6'
-type: development
-group: group::dynamic analysis
-default_enabled: false
diff --git a/db/migrate/20201201161655_add_primary_key_to_elastic_search_indexed_projects.rb b/db/migrate/20201201161655_add_primary_key_to_elastic_search_indexed_projects.rb
new file mode 100644
index 00000000000..cf7221693f9
--- /dev/null
+++ b/db/migrate/20201201161655_add_primary_key_to_elastic_search_indexed_projects.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class AddPrimaryKeyToElasticSearchIndexedProjects < ActiveRecord::Migration[6.0]
+ DOWNTIME = false
+
+ UNIQUE_INDEX_NAME = 'index_elasticsearch_indexed_projects_on_project_id'
+ PRIMARY_KEY_NAME = 'elasticsearch_indexed_projects_pkey'
+
+ def up
+ execute(<<~SQL)
+ DELETE FROM elasticsearch_indexed_projects
+ WHERE project_id IS NULL
+ SQL
+
+ execute(<<~SQL)
+ ALTER TABLE elasticsearch_indexed_projects
+ ALTER COLUMN project_id SET NOT NULL,
+ ADD CONSTRAINT #{PRIMARY_KEY_NAME} PRIMARY KEY USING INDEX #{UNIQUE_INDEX_NAME}
+ SQL
+ end
+
+ def down
+ add_index :elasticsearch_indexed_projects, :project_id, unique: true, name: UNIQUE_INDEX_NAME # rubocop:disable Migration/AddIndex
+
+ execute(<<~SQL)
+ ALTER TABLE elasticsearch_indexed_projects
+ DROP CONSTRAINT #{PRIMARY_KEY_NAME},
+ ALTER COLUMN project_id DROP NOT NULL
+ SQL
+ end
+end
diff --git a/db/migrate/20201201190002_add_other_context_to_experiment_user.rb b/db/migrate/20201201190002_add_other_context_to_experiment_user.rb
new file mode 100644
index 00000000000..c901f049e75
--- /dev/null
+++ b/db/migrate/20201201190002_add_other_context_to_experiment_user.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class AddOtherContextToExperimentUser < ActiveRecord::Migration[6.0]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+
+ def up
+ with_lock_retries do
+ add_column :experiment_users, :context, :jsonb, default: {}, null: false
+ end
+ end
+
+ def down
+ with_lock_retries do
+ remove_column :experiment_users, :context
+ end
+ end
+end
diff --git a/db/schema_migrations/20201201161655 b/db/schema_migrations/20201201161655
new file mode 100644
index 00000000000..892d2bfc08d
--- /dev/null
+++ b/db/schema_migrations/20201201161655
@@ -0,0 +1 @@
+d9ad12dce02d6823536f3206e9c90a0da82c08089c3ce252e8ef28a59589e747 \ No newline at end of file
diff --git a/db/schema_migrations/20201201190002 b/db/schema_migrations/20201201190002
new file mode 100644
index 00000000000..aac9ac34c64
--- /dev/null
+++ b/db/schema_migrations/20201201190002
@@ -0,0 +1 @@
+f4ec800e68cbe092775b428d3ff85a4a84be0d55d70e59d23de390847ea3c2b7 \ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 94f92b2af16..9c53bb2de03 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -11935,7 +11935,7 @@ CREATE TABLE elasticsearch_indexed_namespaces (
CREATE TABLE elasticsearch_indexed_projects (
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
- project_id integer
+ project_id integer NOT NULL
);
CREATE TABLE emails (
@@ -12124,7 +12124,8 @@ CREATE TABLE experiment_users (
group_type smallint DEFAULT 0 NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
- converted_at timestamp with time zone
+ converted_at timestamp with time zone,
+ context jsonb DEFAULT '{}'::jsonb NOT NULL
);
CREATE SEQUENCE experiment_users_id_seq
@@ -19255,6 +19256,9 @@ ALTER TABLE ONLY draft_notes
ALTER TABLE ONLY elastic_reindexing_tasks
ADD CONSTRAINT elastic_reindexing_tasks_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY elasticsearch_indexed_projects
+ ADD CONSTRAINT elasticsearch_indexed_projects_pkey PRIMARY KEY (project_id);
+
ALTER TABLE ONLY emails
ADD CONSTRAINT emails_pkey PRIMARY KEY (id);
@@ -21056,8 +21060,6 @@ CREATE INDEX index_elasticsearch_indexed_namespaces_on_created_at ON elasticsear
CREATE UNIQUE INDEX index_elasticsearch_indexed_namespaces_on_namespace_id ON elasticsearch_indexed_namespaces USING btree (namespace_id);
-CREATE UNIQUE INDEX index_elasticsearch_indexed_projects_on_project_id ON elasticsearch_indexed_projects USING btree (project_id);
-
CREATE UNIQUE INDEX index_emails_on_confirmation_token ON emails USING btree (confirmation_token);
CREATE UNIQUE INDEX index_emails_on_email ON emails USING btree (email);
diff --git a/doc/.vale/gitlab/Acronyms.yml b/doc/.vale/gitlab/Acronyms.yml
index 829f46d3a83..b3a220ccda4 100644
--- a/doc/.vale/gitlab/Acronyms.yml
+++ b/doc/.vale/gitlab/Acronyms.yml
@@ -99,6 +99,7 @@ exceptions:
- SEO
- SHA
- SLA
+ - SMS
- SMTP
- SQL
- SSD
diff --git a/doc/.vale/gitlab/spelling-exceptions.txt b/doc/.vale/gitlab/spelling-exceptions.txt
index 7ec0dcd6a77..82a4ac34689 100644
--- a/doc/.vale/gitlab/spelling-exceptions.txt
+++ b/doc/.vale/gitlab/spelling-exceptions.txt
@@ -484,6 +484,7 @@ sudo
swimlane
swimlanes
syslog
+tanuki
tcpdump
Thanos
Tiller
diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md
index 494d1979784..618efa10cf1 100644
--- a/doc/administration/gitaly/praefect.md
+++ b/doc/administration/gitaly/praefect.md
@@ -144,7 +144,7 @@ package (highly recommended), follow the steps below:
Before beginning, you should already have a working GitLab instance. [Learn how
to install GitLab](https://about.gitlab.com/install/).
-Provision a PostgreSQL server (PostgreSQL 11 or newer).
+Provision a PostgreSQL server (PostgreSQL 11 or newer).
Prepare all your new nodes by [installing
GitLab](https://about.gitlab.com/install/).
diff --git a/doc/administration/job_logs.md b/doc/administration/job_logs.md
index c245d6f06a6..09bda0d39d2 100644
--- a/doc/administration/job_logs.md
+++ b/doc/administration/job_logs.md
@@ -185,7 +185,7 @@ Feature.enable(:ci_enable_live_trace)
```
NOTE: **Note:**
-The transition period is handled gracefully. Upcoming logs are
+The transition period is handled gracefully. Upcoming logs are
generated with the incremental architecture, and on-going logs stay with the
legacy architecture, which means that on-going logs aren't forcibly
re-generated with the incremental architecture.
diff --git a/doc/administration/packages/index.md b/doc/administration/packages/index.md
index 8013067af2a..ce09006dc5b 100644
--- a/doc/administration/packages/index.md
+++ b/doc/administration/packages/index.md
@@ -55,7 +55,7 @@ guides you through the process.
NOTE: **Note:**
After the Packages feature is enabled, the repositories are available
-for all new projects by default. To enable it for existing projects, users
+for all new projects by default. To enable it for existing projects, users
explicitly do so in the project's settings.
To enable the Packages feature:
diff --git a/doc/api/appearance.md b/doc/api/appearance.md
index 7dd7f88780a..c489f7aa316 100644
--- a/doc/api/appearance.md
+++ b/doc/api/appearance.md
@@ -94,7 +94,7 @@ Upload a logo to your GitLab instance.
To upload an avatar from your file system, use the `--form` argument. This causes
cURL to post data using the header `Content-Type: multipart/form-data`. The
-`file=` parameter must point to an image file on your file system and be
+`file=` parameter must point to an image file on your file system and be
preceded by `@`.
```plaintext
diff --git a/doc/api/feature_flags.md b/doc/api/feature_flags.md
index 4f65f649be1..45028408491 100644
--- a/doc/api/feature_flags.md
+++ b/doc/api/feature_flags.md
@@ -10,10 +10,6 @@ info: To determine the technical writer assigned to the Stage/Group associated w
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212318) to [GitLab Starter](https://about.gitlab.com/pricing/) in 13.4.
> - [Moved](https://gitlab.com/gitlab-org/gitlab/-/issues/212318) to [GitLab Core](https://about.gitlab.com/pricing/) in 13.5.
-NOTE: **Note:**
-This API is behind a [feature flag](../operations/feature_flags.md#enable-or-disable-feature-flag-strategies).
-If this flag is not enabled in your environment, you can use the [legacy feature flags API](feature_flags_legacy.md).
-
API for accessing resources of [GitLab Feature Flags](../operations/feature_flags.md).
Users with Developer or higher [permissions](../user/permissions.md) can access Feature Flag API.
diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md
index 73c83c614b5..b6ed1bf9bf4 100644
--- a/doc/development/documentation/styleguide/index.md
+++ b/doc/development/documentation/styleguide/index.md
@@ -533,7 +533,9 @@ tenses, words, and phrases:
content is accessible to more readers.
- Don't write in the first person singular.
(Tested in [`FirstPerson.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/.vale/gitlab/FirstPerson.yml).)
+ <!-- vale gitlab.FirstPerson = NO -->
- Instead of _I_ or _me_, use _we_, _you_, _us_, or _one_.
+ <!-- vale gitlab.FirstPerson = YES -->
- When possible, stay user focused by writing in the second person (_you_ or
the imperative).
- Don't overuse "that". In many cases, you can remove "that" from a sentence
@@ -795,6 +797,8 @@ Items nested in lists should always align with the first character of the list
item. In unordered lists (using `-`), this means two spaces for each level of
indentation:
+<!-- vale off -->
+
````markdown
- Unordered list item 1
@@ -816,8 +820,12 @@ indentation:
![an image that will nest inside list item 4](image.png)
````
+<!-- vale on -->
+
For ordered lists, use three spaces for each level of indentation:
+<!-- vale off -->
+
````markdown
1. Ordered list item 1
@@ -839,6 +847,8 @@ For ordered lists, use three spaces for each level of indentation:
![an image that will nest inside list item 4](image.png)
````
+<!-- vale on -->
+
You can nest full lists inside other lists using the same rules as above. If you
want to mix types, that's also possible, if you don't mix items at the same
level:
@@ -904,7 +914,7 @@ Valid for Markdown content only, not for front matter entries:
- Standard quotes: double quotes (`"`). Example: "This is wrapped in double
quotes".
- Quote inside a quote: double quotes (`"`) wrap single quotes (`'`). Example:
- "I am 'quoting' something in a quote".
+ "This sentence 'quotes' something in a quote".
For other punctuation rules, refer to the
[GitLab UX guide](https://design.gitlab.com/content/punctuation/).
@@ -1367,6 +1377,8 @@ hidden on the documentation site, but is displayed by `/help`.
- For regular fenced code blocks, always use a highlighting class corresponding to
the language for better readability. Examples:
+ <!-- vale off -->
+
````markdown
```ruby
Ruby code
@@ -1385,6 +1397,8 @@ hidden on the documentation site, but is displayed by `/help`.
```
````
+ <!-- vale on -->
+
Syntax highlighting is required for fenced code blocks added to the GitLab
documentation. Refer to the following table for the most common language classes,
or check the [complete list](https://github.com/rouge-ruby/rouge/wiki/List-of-supported-languages-and-lexers)
@@ -1771,8 +1785,7 @@ for use in GitLab X.X, and is planned for [removal](link-to-issue) in GitLab X.X
```
After the feature or product is officially deprecated and removed, remove
-its information from the GitLab documentation based on
-the GitLab version where it's actually removed.
+its information from the GitLab documentation.
### Versions in the past or future
@@ -1926,6 +1939,8 @@ Configuration settings include:
When you document a list of steps, it may entail editing the configuration file
and reconfiguring or restarting GitLab. In that case, use these styles:
+<!-- vale off -->
+
````markdown
**For Omnibus installations**
@@ -1953,6 +1968,8 @@ and reconfiguring or restarting GitLab. In that case, use these styles:
GitLab for the changes to take effect.
````
+<!-- vale on -->
+
In this case:
- Before each step list the installation method is declared in bold.
diff --git a/doc/development/fe_guide/performance.md b/doc/development/fe_guide/performance.md
index 8279cdbe1a4..99e56058881 100644
--- a/doc/development/fe_guide/performance.md
+++ b/doc/development/fe_guide/performance.md
@@ -119,21 +119,21 @@ browser's developer console while on any page within GitLab.
```
Note that `waitForCSSLoaded()` methods supports receiving the action in different ways:
-
+
- With a callback:
-
+
```javascript
waitForCSSLoaded(action)
```
-
+
- With `then()`:
-
+
```javascript
waitForCSSLoaded().then(action);
```
-
+
- With `await` followed by `action`:
-
+
```javascript
await waitForCSSLoaded;
action();
diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md
index 5fa741f6b61..278e915c597 100644
--- a/doc/development/go_guide/index.md
+++ b/doc/development/go_guide/index.md
@@ -131,6 +131,8 @@ Once [recursive includes](https://gitlab.com/gitlab-org/gitlab-foss/-/issues/568
become available, you can share job templates like this
[analyzer](https://gitlab.com/gitlab-org/security-products/ci-templates/raw/master/includes-dev/analyzer.yml).
+Go GitLab linter plugins are maintained in the [`gitlab-org/language-tools/go/linters`](https://gitlab.com/gitlab-org/language-tools/go/linters/) namespace.
+
## Dependencies
Dependencies should be kept to the minimum. The introduction of a new
diff --git a/doc/development/iterating_tables_in_batches.md b/doc/development/iterating_tables_in_batches.md
index c64cb5e8dfb..814397a3b52 100644
--- a/doc/development/iterating_tables_in_batches.md
+++ b/doc/development/iterating_tables_in_batches.md
@@ -243,8 +243,8 @@ end
The iteration uses the primary key index (on the `id` column) which makes it safe from statement
timeouts. The filter (`sign_in_count: 0`) is applied on the `relation` where the `id` is already constrained (range). The number of rows are limited.
-Slow iteration generally takes more time to finish. The iteration count is higher and
-one iteration could yield fewer records than the batch size. Iterations may even yield
+Slow iteration generally takes more time to finish. The iteration count is higher and
+one iteration could yield fewer records than the batch size. Iterations may even yield
0 records. This is not an optimal solution; however, in some cases (especially when
dealing with large tables) this is the only viable option.
@@ -346,7 +346,7 @@ Here, we expect that the `relation` query reads the `BATCH_SIZE` of user records
filters down the results according to the provided queries. The planner might decide that
using a bitmap index lookup with the index on the `confidential` column is a better way to
execute the query. This can cause unexpectedly high amount of rows to be read and the query
-could time out.
+could time out.
Problem: we know for sure that the relation is returning maximum `BATCH_SIZE` of records, however the planner does not know this.
diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md
index 85b14ed1c64..707251a24bd 100644
--- a/doc/development/migration_style_guide.md
+++ b/doc/development/migration_style_guide.md
@@ -462,8 +462,9 @@ class MyMigration < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
+ INDEX_NAME = 'index_name'
def up
- remove_concurrent_index :table_name, :column_name, name: :index_name
+ remove_concurrent_index :table_name, :column_name, name: INDEX_NAME
end
end
```
diff --git a/doc/development/product_analytics/usage_ping.md b/doc/development/product_analytics/usage_ping.md
index 7293397c9be..a66e92a6116 100644
--- a/doc/development/product_analytics/usage_ping.md
+++ b/doc/development/product_analytics/usage_ping.md
@@ -118,7 +118,7 @@ sequenceDiagram
1. `GitLab::UsageData.to_json` [cascades down](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data.rb#L22) to ~400+ other counter method calls.
1. The response of all methods calls are [merged together](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/usage_data.rb#L14) into a single JSON payload in `GitLab::UsageData.to_json`.
1. The JSON payload is then [posted to the Versions application]( https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/submit_usage_ping_service.rb#L20)
- If a firewall exception is needed, the required URL depends on several things. If
+ If a firewall exception is needed, the required URL depends on several things. If
the hostname is `version.gitlab.com`, the protocol is `TCP`, and the port number is `443`,
the required URL is <https://version.gitlab.com/>.
@@ -477,11 +477,11 @@ Next, get the unique events for the current week.
We have the following recommendations for [Adding new events](#adding-new-events):
- Event aggregation: weekly.
-- Key expiry time:
+- Key expiry time:
- Daily: 29 days.
- Weekly: 42 days.
- When adding new metrics, use a [feature flag](../../operations/feature_flags.md) to control the impact.
-- For feature flags triggered by another service, set `default_enabled: false`,
+- For feature flags triggered by another service, set `default_enabled: false`,
- Events can be triggered using the `UsageData` API, which helps when there are > 10 events per change
##### Enable/Disable Redis HLL tracking
@@ -869,44 +869,6 @@ The following is example content of the Usage Ping payload.
"version": "9.6.15",
"pg_system_id": 6842684531675334351
},
- "avg_cycle_analytics": {
- "issue": {
- "average": 999,
- "sd": 999,
- "missing": 999
- },
- "plan": {
- "average": null,
- "sd": 999,
- "missing": 999
- },
- "code": {
- "average": null,
- "sd": 999,
- "missing": 999
- },
- "test": {
- "average": null,
- "sd": 999,
- "missing": 999
- },
- "review": {
- "average": null,
- "sd": 999,
- "missing": 999
- },
- "staging": {
- "average": null,
- "sd": 999,
- "missing": 999
- },
- "production": {
- "average": null,
- "sd": 999,
- "missing": 999
- },
- "total": 999
- },
"analytics_unique_visits": {
"g_analytics_contribution": 999,
...
diff --git a/doc/development/sidekiq_style_guide.md b/doc/development/sidekiq_style_guide.md
index 2d9cb40d143..909b6de1134 100644
--- a/doc/development/sidekiq_style_guide.md
+++ b/doc/development/sidekiq_style_guide.md
@@ -27,7 +27,7 @@ After adding a new queue, run `bin/rake
gitlab:sidekiq:all_queues_yml:generate` to regenerate
`app/workers/all_queues.yml` or `ee/app/workers/all_queues.yml` so that
it can be picked up by
-[`sidekiq-cluster`](../administration/operations/extra_sidekiq_processes.md).
+[`sidekiq-cluster`](../administration/operations/extra_sidekiq_processes.md).
Additionally, run
`bin/rake gitlab:sidekiq:sidekiq_queues_yml:generate` to regenerate
`config/sidekiq_queues.yml`.
diff --git a/doc/development/testing_guide/frontend_testing.md b/doc/development/testing_guide/frontend_testing.md
index c76e6d1880e..9931e15ad44 100644
--- a/doc/development/testing_guide/frontend_testing.md
+++ b/doc/development/testing_guide/frontend_testing.md
@@ -106,7 +106,7 @@ Remember that the performance of each test depends on the environment.
### Timout error due to async components
-If your component is fetching some other components asynchroneously based on some conditions, it might happen so that your Jest suite for this component will become flaky timing out from time to time.
+If your component is fetching some other components asynchroneously based on some conditions, it might happen so that your Jest suite for this component will become flaky timing out from time to time.
```javascript
// ide.vue
diff --git a/doc/install/requirements.md b/doc/install/requirements.md
index 2a0092450f1..c1eaba9913a 100644
--- a/doc/install/requirements.md
+++ b/doc/install/requirements.md
@@ -227,7 +227,7 @@ The storage requirements for Redis are minimal, about 25kB per user.
Sidekiq processes the background jobs with a multithreaded process.
This process starts with the entire Rails stack (200MB+) but it can grow over time due to memory leaks.
On a very active server (10,000 billable users) the Sidekiq process can use 1GB+ of memory.
-
+
## Prometheus and its exporters
As of Omnibus GitLab 9.0, [Prometheus](https://prometheus.io) and its related
diff --git a/doc/integration/jenkins.md b/doc/integration/jenkins.md
index 70a7f417c75..0a383f1e3b1 100644
--- a/doc/integration/jenkins.md
+++ b/doc/integration/jenkins.md
@@ -196,14 +196,14 @@ WebHook Error => execution expired
```
If those are present, the request is exceeding the
-[webhook timeout](../user/project/integrations/webhooks.md#receiving-duplicate-or-multiple-webhook-requests-triggered-by-one-event),
+[webhook timeout](../user/project/integrations/webhooks.md#webhook-fails-or-multiple-webhook-requests-are-triggered),
which is set to 10 seconds by default.
To fix this the `gitlab_rails['webhook_timeout']` value must be increased
in the `gitlab.rb` config file, followed by the [`gitlab-ctl reconfigure` command](../administration/restart_gitlab.md).
If you don't find the errors above, but do find *duplicate* entries like below (in `/var/log/gitlab/gitlab-rail`), this
-could also indicate that [webhook requests are timing out](../user/project/integrations/webhooks.md#receiving-duplicate-or-multiple-webhook-requests-triggered-by-one-event):
+could also indicate that [webhook requests are timing out](../user/project/integrations/webhooks.md#webhook-fails-or-multiple-webhook-requests-are-triggered):
```plaintext
2019-10-25_04:22:41.25630 2019-10-25T04:22:41.256Z 1584 TID-ovowh4tek WebHookWorker JID-941fb7f40b69dff3d833c99b INFO: start
diff --git a/doc/integration/sourcegraph.md b/doc/integration/sourcegraph.md
index 16492f08028..861e5563328 100644
--- a/doc/integration/sourcegraph.md
+++ b/doc/integration/sourcegraph.md
@@ -64,7 +64,7 @@ Feature.enable(:sourcegraph, Project.find_by_full_path('my_group/my_project'))
If you are new to Sourcegraph, head over to the [Sourcegraph installation documentation](https://docs.sourcegraph.com/admin) and get your instance up and running.
-If you are using an HTTPS connection to GitLab, you will need to [configure HTTPS](https://docs.sourcegraph.com/admin/http_https_configuration) for your Sourcegraph instance.
+If you are using an HTTPS connection to GitLab, you will need to [configure HTTPS](https://docs.sourcegraph.com/admin/http_https_configuration) for your Sourcegraph instance.
### Connect your Sourcegraph instance to your GitLab instance
diff --git a/doc/operations/feature_flags.md b/doc/operations/feature_flags.md
index da1dbf2bba8..37a9e0512c4 100644
--- a/doc/operations/feature_flags.md
+++ b/doc/operations/feature_flags.md
@@ -77,7 +77,6 @@ is 200. On GitLab.com, the maximum number is determined by [GitLab.com tier](htt
> - It became [enabled by default](https://gitlab.com/gitlab-org/gitlab/-/issues/214684) in GitLab 13.2.
> - It's recommended for production use.
> - It's enabled on GitLab.com.
-> - For GitLab self-managed instances, a GitLab administrator can choose to [disable it](#enable-or-disable-feature-flag-strategies). **(CORE ONLY)**
You can apply a feature flag strategy across multiple environments, without defining
the strategy multiple times.
@@ -222,25 +221,6 @@ To remove users from a user list:
1. Click on the **{pencil}** (edit) button next to the list you want to change.
1. Click on the **{remove}** (remove) button next to the ID you want to remove.
-### Enable or disable feature flag strategies
-
-This feature is under development, but is ready for production use. It's
-deployed behind a feature flag that is **enabled by default**.
-[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md)
-can disable it for your instance.
-
-To disable it:
-
-```ruby
-Feature.disable(:feature_flags_new_version)
-```
-
-To enable it:
-
-```ruby
-Feature.enable(:feature_flags_new_version)
-```
-
## Rollout strategy (legacy)
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/8240) in GitLab 12.2.
diff --git a/doc/raketasks/backup_restore.md b/doc/raketasks/backup_restore.md
index e931e5871a4..868c1efa3c1 100644
--- a/doc/raketasks/backup_restore.md
+++ b/doc/raketasks/backup_restore.md
@@ -266,12 +266,12 @@ You can exclude specific directories from the backup by adding the environment v
- `lfs` (LFS objects)
- `registry` (Container Registry images)
- `pages` (Pages content)
-- `repositories` (Git repositories data)
+- `repositories` (Git repositories data)
All wikis will be backed up as part of the `repositories` group. Non-existent wikis will be skipped during a backup.
-
+
NOTE: **Note:**
-When [backing up and restoring Helm Charts](https://docs.gitlab.com/charts/architecture/backup-restore.html), there is an additional option `packages`, which refers to any packages managed by the GitLab [package registry](../user/packages/package_registry/index.md).
+When [backing up and restoring Helm Charts](https://docs.gitlab.com/charts/architecture/backup-restore.html), there is an additional option `packages`, which refers to any packages managed by the GitLab [package registry](../user/packages/package_registry/index.md).
For more information see [command line arguments](https://docs.gitlab.com/charts/architecture/backup-restore.html#command-line-arguments).
All wikis are backed up as part of the `repositories` group. Non-existent
diff --git a/doc/user/application_security/secret_detection/index.md b/doc/user/application_security/secret_detection/index.md
index 0bd2bda5bfb..3d36c64e67d 100644
--- a/doc/user/application_security/secret_detection/index.md
+++ b/doc/user/application_security/secret_detection/index.md
@@ -130,13 +130,13 @@ always take the latest Secret Detection artifact available.
> [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/4639) in GitLab 13.6.
-Upon detection of a secret, GitLab supports post processing hooks. These can be used to take actions like notifying the cloud service who issued the secret. The cloud provider can confirm the credentials and take remediation actions like revoking or reissuing a new secret and notifying the creator of the secret. Post-processing workflows vary by supported cloud providers.
+Upon detection of a secret, GitLab supports post processing hooks. These can be used to take actions like notifying the cloud service who issued the secret. The cloud provider can confirm the credentials and take remediation actions like revoking or reissuing a new secret and notifying the creator of the secret. Post-processing workflows vary by supported cloud providers.
GitLab currently supports post-processing for following service providers:
- Amazon Web Services (AWS)
-Third party cloud and SaaS providers can [express integration interest by filling out this form](https://forms.gle/wWpvrtLRK21Q2WJL9). Learn more about the [technical details of post-processing secrets](https://gitlab.com/groups/gitlab-org/-/epics/4639).
+Third party cloud and SaaS providers can [express integration interest by filling out this form](https://forms.gle/wWpvrtLRK21Q2WJL9). Learn more about the [technical details of post-processing secrets](https://gitlab.com/groups/gitlab-org/-/epics/4639).
### Customizing settings
@@ -286,14 +286,14 @@ For information on this, see the [general Application Security troubleshooting s
### Error: `Couldn't run the gitleaks command: exit status 2`
-This error is usually caused by the `GIT_DEPTH` value of 50 that is set for all [projects by default](../../../ci/pipelines/settings.md#git-shallow-clone).
+This error is usually caused by the `GIT_DEPTH` value of 50 that is set for all [projects by default](../../../ci/pipelines/settings.md#git-shallow-clone).
-For example, if a pipeline is triggered from a Merge Request containing 60 commits while the `GIT_DEPTH` is set to 50, the Secret Detection job will fail as the clone will not have been deep enough to contain all of the relevant commits.
+For example, if a pipeline is triggered from a Merge Request containing 60 commits while the `GIT_DEPTH` is set to 50, the Secret Detection job will fail as the clone will not have been deep enough to contain all of the relevant commits.
You can confirm this to be the cause of the error by implementing a [logging level](../../application_security/secret_detection/index.md#logging-level) of `debug`. Once implemented, the logs should look similar to the following example, wherein an "object not found" error can be seen:
```plaintext
-ERRO[2020-11-18T18:05:52Z] object not found
+ERRO[2020-11-18T18:05:52Z] object not found
[ERRO] [secrets] [2020-11-18T18:05:52Z] â–¶ Couldn't run the gitleaks command: exit status 2
[ERRO] [secrets] [2020-11-18T18:05:52Z] â–¶ Gitleaks analysis failed: exit status 2
```
diff --git a/doc/user/group/saml_sso/index.md b/doc/user/group/saml_sso/index.md
index 31ff86adf38..df4e5ea091e 100644
--- a/doc/user/group/saml_sso/index.md
+++ b/doc/user/group/saml_sso/index.md
@@ -241,7 +241,7 @@ Users can unlink SAML for a group from their profile page. This can be helpful i
- Your SAML NameID has changed and so GitLab can no longer find your user.
CAUTION: **Warning:**
-Unlinking an account removes all roles assigned to that user within the group.
+Unlinking an account removes all roles assigned to that user within the group.
If a user relinks their account, roles need to be reassigned.
For example, to unlink the `MyOrg` account, the following **Disconnect** button is available under **Profile > Accounts**:
@@ -274,14 +274,14 @@ To link the SAML `Freelancers` group in the attribute statement example above:
1. Enter `Freelancers` in the `SAML Group Name` field.
1. Choose the desired `Access Level`.
-1. **Save** the group link.
-1. Repeat to add additional group links if desired.
+1. **Save** the group link.
+1. Repeat to add additional group links if desired.
![SAML Group Links](img/saml_group_links_v13_6.png)
-If a user is a member of multiple SAML groups mapped to the same GitLab group,
+If a user is a member of multiple SAML groups mapped to the same GitLab group,
the user gets the highest access level from the groups. For example, if one group
-is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
+is linked as `Guest` and another `Maintainer`, a user in both groups gets `Maintainer`
access.
## Glossary
diff --git a/doc/user/packages/conan_repository/index.md b/doc/user/packages/conan_repository/index.md
index 35d854b0aa3..92fdcde7331 100644
--- a/doc/user/packages/conan_repository/index.md
+++ b/doc/user/packages/conan_repository/index.md
@@ -292,7 +292,7 @@ Prerequisites:
- [Authentication](#authenticate-to-the-package-registry) with the
Package Registry must be configured.
-1. In the project where you want to install the package as a dependency, open
+1. In the project where you want to install the package as a dependency, open
`conanfile.txt`. Or, in the root of your project, create a file called
`conanfile.txt`.
diff --git a/doc/user/packages/index.md b/doc/user/packages/index.md
index d9f79505eea..9da14842845 100644
--- a/doc/user/packages/index.md
+++ b/doc/user/packages/index.md
@@ -32,7 +32,7 @@ You can also use the [API](../../api/packages.md) to administer the Package Regi
## Accepting contributions
-The below table lists formats that are not supported, but are accepting Community contributions for. Consider contributing to GitLab. This [development documentation](../../development/packages.md)
+The below table lists formats that are not supported, but are accepting Community contributions for. Consider contributing to GitLab. This [development documentation](../../development/packages.md)
guides you through the process.
| Format | Status |
diff --git a/doc/user/project/integrations/jira_server_configuration.md b/doc/user/project/integrations/jira_server_configuration.md
index 39370f8a84e..161b6754c55 100644
--- a/doc/user/project/integrations/jira_server_configuration.md
+++ b/doc/user/project/integrations/jira_server_configuration.md
@@ -23,7 +23,7 @@ access to your Jira projects. This is covered in the process below.
1. The next step is to create a new user (e.g., `gitlab`) who has write access
to projects in Jira. Enter the user's name and a _valid_ e-mail address
since Jira sends a verification e-mail to set up the password.
-
+
Jira creates the username automatically by using the e-mail
prefix. You can change it later, if needed. Our integration does not support SSO (such as SAML). You
need to create an HTTP basic authentication password. You can do this by visiting the user
diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md
index 4fc55d5afd9..089ae9e05d1 100644
--- a/doc/user/project/integrations/webhooks.md
+++ b/doc/user/project/integrations/webhooks.md
@@ -11,8 +11,8 @@ a new issue is created. You can configure webhooks to listen for specific events
like pushes, issues or merge requests. GitLab sends a POST request with data
to the webhook URL.
-In most cases, you need to set up your own [webhook receiver](#example-webhook-receiver)
-to receive information from GitLab, and send it to another app, according to your needs.
+You usually need to set up your own [webhook receiver](#example-webhook-receiver)
+to receive information from GitLab and send it to another app, according to your requirements.
We already have a [built-in receiver](slack.md)
for sending [Slack](https://api.slack.com/incoming-webhooks) notifications _per project_.
@@ -33,7 +33,7 @@ and **per project and per group** for **GitLab Enterprise Edition**.
Navigate to the webhooks page at your project's **Settings > Webhooks**.
-NOTE: **Note:**
+NOTE:
On GitLab.com, the [maximum number of webhooks and their size](../../../user/gitlab_com/index.md#webhooks) per project, and per group, is limited.
## Version history
@@ -54,7 +54,7 @@ Starting from GitLab 11.2:
`![](/uploads/...)`) have their target URL changed to an absolute URL. See
[image URL rewriting](#image-url-rewriting) for more details.
-## Use-cases
+## Possible uses for webhooks
- You can set up a webhook in GitLab to send a notification to
[Slack](https://api.slack.com/incoming-webhooks) every time a job fails.
@@ -65,12 +65,12 @@ Starting from GitLab 11.2:
## Webhook endpoint tips
If you are writing your own endpoint (web server) to receive
-GitLab webhooks, keep in mind the following things:
+GitLab webhooks, keep in mind the following:
-- Your endpoint should send its HTTP response as fast as possible. If
- you wait too long (by default, a timeout of 10 seconds), GitLab may decide
- the hook failed and retry it. You can configure this timeout with
- `gitlab_rails['webhook_timeout']`.
+- Your endpoint should send its HTTP response as fast as possible. If the response takes longer than
+ the configured timeout, GitLab decides the hook failed and retries it. For information on
+ customizing this timeout, see
+ [Webhook fails or multiple webhook requests are triggered](#webhook-fails-or-multiple-webhook-requests-are-triggered).
- Your endpoint should ALWAYS return a valid HTTP response. If you do
not do this then GitLab thinks the hook failed and retries it.
Most HTTP libraries take care of this for you automatically but if
@@ -86,7 +86,7 @@ that the request is legitimate.
## SSL verification
By default, the SSL certificate of the webhook endpoint is verified based on
-an internal list of Certificate Authorities, which means the certificate cannot
+an internal list of Certificate Authorities. This means the certificate cannot
be self-signed.
You can turn this off in the webhook settings in your GitLab projects.
@@ -109,7 +109,7 @@ Below are described the supported events.
Triggered when you push to the repository except when pushing tags.
-NOTE: **Note:**
+NOTE:
When more than 20 commits are pushed at once, the `commits` webhook
attribute only contains the first 20 for performance reasons. Loading
detailed commit data is expensive. Note that despite only 20 commits being
@@ -204,7 +204,7 @@ X-Gitlab-Event: Push Hook
Triggered when you create (or delete) tags to the repository.
-NOTE: **Note:**
+NOTE:
If a single push includes changes for more than three (by default, depending on
[`push_event_hooks_limit` setting](../../../api/settings.md#list-of-settings-that-can-be-accessed-via-api-calls))
tags, this hook is not executed.
@@ -409,12 +409,12 @@ X-Gitlab-Event: Issue Hook
}
```
-> **Note**: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only.
+NOTE: `assignee` and `assignee_id` keys are deprecated and now show the first assignee only.
### Comment events
Triggered when a new comment is made on commits, merge requests, issues, and code snippets.
-The note data is stored in `object_attributes` (e.g. `note`, `noteable_type`). The
+The note data is stored in `object_attributes` (for example, `note` or `noteable_type`). The
payload also includes information about the target of the comment. For example,
a comment on an issue includes the specific issue information under the `issue` key.
Valid target types:
@@ -734,7 +734,7 @@ X-Gitlab-Event: Note Hook
}
```
-> **Note**: `assignee_id` field is deprecated and now shows the first assignee only.
+NOTE: `assignee_id` field is deprecated and now shows the first assignee only.
#### Comment on code snippet
@@ -1531,29 +1531,36 @@ You can find records for last 2 days in "Recent Deliveries" section on the edit
![Recent deliveries](img/webhook_logs.png)
-In this section you can see HTTP status code (green for 200-299 codes, red for the others, `internal error` for failed deliveries ), triggered event, a time when the event was called, elapsed time of the request.
+In this section you can see:
+
+- HTTP status code (green for `200-299` codes, red for the others, `internal error` for failed deliveries).
+- Triggered event.
+- A time when the event was called.
+- Elapsed time of the request.
If you need more information about execution, you can click `View details` link.
On this page, you can see data that GitLab sends (request headers and body) and data that it received (response headers and body).
From this page, you can repeat delivery with the same data by clicking `Resend Request` button.
-NOTE: **Note:**
+NOTE:
If URL or secret token of the webhook were updated, data is delivered to the new address.
-### Receiving duplicate or multiple webhook requests triggered by one event
+### Webhook fails or multiple webhook requests are triggered
-When GitLab sends a webhook, it expects a response in 10 seconds (set default value). If it does not receive one, it retries the webhook.
-If the endpoint doesn't send its HTTP response within those 10 seconds, GitLab may decide the hook failed and retry it.
+When GitLab sends a webhook, it expects a response in 10 seconds by default. If it does not receive
+one, it retries the webhook. If the endpoint doesn't send its HTTP response within those 10 seconds,
+GitLab may decide the hook failed and retry it.
-If you are receiving multiple requests, you can try increasing the default value to wait for the HTTP response after sending the webhook
-by uncommenting or adding the following setting to your `/etc/gitlab/gitlab.rb`:
+If your webhooks are failing or you are receiving multiple requests, you can try changing the
+default value. You can do this by uncommenting or adding the following setting to your
+`/etc/gitlab/gitlab.rb` file:
```ruby
gitlab_rails['webhook_timeout'] = 10
```
-### Troubleshooting: "Unable to get local issuer certificate"
+### Unable to get local issuer certificate
When SSL verification is enabled, this error indicates that GitLab isn't able to verify the SSL certificate of the webhook endpoint.
Typically, this is because the root certificate isn't issued by a trusted certification authority as
@@ -1584,7 +1591,7 @@ end
server.start
```
-Pick an unused port (e.g. 8000) and start the script: `ruby print_http_body.rb
+Pick an unused port (for example, `8000`) and start the script: `ruby print_http_body.rb
8000`. Then add your server as a webhook receiver in GitLab as
`http://my.host:8000/`.
@@ -1597,5 +1604,6 @@ example.com - - [14/May/2014:07:45:26 EDT] "POST / HTTP/1.1" 200 0
- -> /
```
-NOTE: **Note:**
-You may need to [allow requests to the local network](../../../security/webhooks.md) for this receiver to be added.
+NOTE:
+You may need to [allow requests to the local network](../../../security/webhooks.md) for this
+receiver to be added.
diff --git a/doc/user/project/quick_actions.md b/doc/user/project/quick_actions.md
index 306eee36204..45289d615a8 100644
--- a/doc/user/project/quick_actions.md
+++ b/doc/user/project/quick_actions.md
@@ -34,6 +34,7 @@ The following quick actions are applicable to descriptions, discussions and thre
| `/award :emoji:` | ✓ | ✓ | ✓ | Toggle emoji award. |
| `/child_epic <epic>` | | | ✓ | Add child epic to `<epic>`. The `<epic>` value should be in the format of `&epic`, `group&epic`, or a URL to an epic ([introduced in GitLab 12.0](https://gitlab.com/gitlab-org/gitlab/-/issues/7330)). **(ULTIMATE)** |
| `/clear_weight` | ✓ | | | Clear weight. **(STARTER)** |
+| `/clone <path/to/project>` | ✓ | | | Clone the issue to given project, or the current one if no arguments are given ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/9421) in GitLab 13.7). Copies as much data as possible as long as the target project contains equivalent labels, milestones, etc. Does not copy comments or system notes. |
| `/close` | ✓ | ✓ | ✓ | Close. |
| `/confidential` | ✓ | | | Make confidential. |
| `/copy_metadata <!merge_request>` | ✓ | ✓ | | Copy labels and milestone from another merge request in the project. |
diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md
index 56902b4a82c..3562ef1bb28 100644
--- a/doc/user/project/releases/index.md
+++ b/doc/user/project/releases/index.md
@@ -79,7 +79,7 @@ To create a new release through the GitLab UI:
[release notes](#release-notes-description), or [assets links](#links).
1. Click **Create release**.
-### Create release from GitLab CI
+### Create release from GitLab CI
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/19298) in GitLab 12.7.
diff --git a/doc/user/project/settings/project_access_tokens.md b/doc/user/project/settings/project_access_tokens.md
index fcd32989251..13c9143e512 100644
--- a/doc/user/project/settings/project_access_tokens.md
+++ b/doc/user/project/settings/project_access_tokens.md
@@ -16,6 +16,9 @@ Project access tokens are supported for self-managed instances on Core and above
> - [Became available on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/235765) in 13.5.
> - It's recommended for production use.
+CAUTION: **Warning:**
+This feature might not be available to you. Check the **version history** note above for details.
+
Project access tokens are scoped to a project and can be used to authenticate with the [GitLab API](../../../api/README.md#personalproject-access-tokens). You can also use project access tokens with Git to authenticate over HTTP.
Project access tokens expire on the date you define, at midnight UTC.
@@ -75,3 +78,33 @@ the following table.
| `write_registry` | Allows write-access (push) to [container registry](../../packages/container_registry/index.md). |
| `read_repository` | Allows read-only access (pull) to the repository. |
| `write_repository` | Allows read-write access (pull, push) to the repository. |
+
+### Enable or disable project access tokens
+
+Project access tokens are deployed behind a feature flag that is **enabled by default**.
+[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
+can disable it for your instance, globally or by project.
+
+To disable it globally:
+
+```ruby
+Feature.disable(:resource_access_token)
+```
+
+To disable it for a specific project:
+
+```ruby
+Feature.disable(:resource_access_token, project)
+```
+
+To enable it globally:
+
+```ruby
+Feature.enable(:resource_access_token)
+```
+
+To enable it for a specific project:
+
+```ruby
+Feature.enable(:resource_access_token, project)
+```
diff --git a/lib/api/entities/feature_flag.rb b/lib/api/entities/feature_flag.rb
index 82fdb20af00..f383eabd5dc 100644
--- a/lib/api/entities/feature_flag.rb
+++ b/lib/api/entities/feature_flag.rb
@@ -6,11 +6,11 @@ module API
expose :name
expose :description
expose :active
- expose :version, if: :feature_flags_new_version_enabled
+ expose :version
expose :created_at
expose :updated_at
expose :scopes, using: FeatureFlag::LegacyScope
- expose :strategies, using: FeatureFlag::Strategy, if: :feature_flags_new_version_enabled
+ expose :strategies, using: FeatureFlag::Strategy
end
end
end
diff --git a/lib/api/feature_flags.rb b/lib/api/feature_flags.rb
index 67168ba9be6..6fdc4535be3 100644
--- a/lib/api/feature_flags.rb
+++ b/lib/api/feature_flags.rb
@@ -62,8 +62,6 @@ module API
attrs = declared_params(include_missing: false)
- ensure_post_version_2_flags_enabled! if attrs[:version] == 'new_version_flag'
-
rename_key(attrs, :scopes, :scopes_attributes)
rename_key(attrs, :strategies, :strategies_attributes)
update_value(attrs, :strategies_attributes) do |strategies|
@@ -143,7 +141,7 @@ module API
end
desc 'Update a feature flag' do
- detail 'This feature will be introduced in GitLab 13.1 if feature_flags_new_version feature flag is removed'
+ detail 'This feature was introduced in GitLab 13.2'
success ::API::Entities::FeatureFlag
end
params do
@@ -163,7 +161,6 @@ module API
end
end
put do
- not_found! unless feature_flags_new_version_enabled?
authorize_update_feature_flag!
render_api_error!('PUT operations are not supported for legacy feature flags', :unprocessable_entity) if feature_flag.legacy_flag?
@@ -228,32 +225,17 @@ module API
def present_entity(result)
present result,
- with: ::API::Entities::FeatureFlag,
- feature_flags_new_version_enabled: feature_flags_new_version_enabled?
- end
-
- def ensure_post_version_2_flags_enabled!
- unless feature_flags_new_version_enabled?
- render_api_error!('Version 2 flags are not enabled for this project', :unprocessable_entity)
- end
+ with: ::API::Entities::FeatureFlag
end
def feature_flag
- @feature_flag ||= if feature_flags_new_version_enabled?
- user_project.operations_feature_flags.find_by_name!(params[:feature_flag_name])
- else
- user_project.operations_feature_flags.legacy_flag.find_by_name!(params[:feature_flag_name])
- end
+ @feature_flag ||= user_project.operations_feature_flags.find_by_name!(params[:feature_flag_name])
end
def new_version_flag_present?
user_project.operations_feature_flags.new_version_flag.find_by_name(params[:name]).present?
end
- def feature_flags_new_version_enabled?
- Feature.enabled?(:feature_flags_new_version, user_project, default_enabled: true)
- end
-
def rename_key(hash, old_key, new_key)
hash[new_key] = hash.delete(old_key) if hash.key?(old_key)
hash
diff --git a/lib/gitlab/cycle_analytics/usage_data.rb b/lib/gitlab/cycle_analytics/usage_data.rb
deleted file mode 100644
index e58def57e69..00000000000
--- a/lib/gitlab/cycle_analytics/usage_data.rb
+++ /dev/null
@@ -1,91 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
- module CycleAnalytics
- class UsageData
- include Gitlab::Utils::StrongMemoize
- PROJECTS_LIMIT = 10
-
- attr_reader :options
-
- def initialize
- @options = { from: 7.days.ago }
- end
-
- def projects
- strong_memoize(:projects) do
- projects = Project.where.not(last_activity_at: nil).order(last_activity_at: :desc).limit(10) +
- Project.where.not(last_repository_updated_at: nil).order(last_repository_updated_at: :desc).limit(10)
-
- projects = projects.uniq.sort_by do |project|
- [project.last_activity_at, project.last_repository_updated_at].min
- end
-
- if projects.size < 10
- projects.concat(Project.where(last_activity_at: nil, last_repository_updated_at: nil).limit(10))
- end
-
- projects.uniq.first(10)
- end
- end
-
- def to_json(*)
- total = 0
-
- values =
- medians_per_stage.each_with_object({}) do |(stage_name, medians), hsh|
- calculations = stage_values(medians)
-
- total += calculations.values.compact.sum
- hsh[stage_name] = calculations
- end
-
- values[:total] = total
-
- { avg_cycle_analytics: values }
- end
-
- private
-
- def medians_per_stage
- projects.each_with_object({}) do |project, hsh|
- ::CycleAnalytics::ProjectLevel.new(project, options: options).all_medians_by_stage.each do |stage_name, median|
- hsh[stage_name] ||= []
- hsh[stage_name] << median
- end
- end
- end
-
- def stage_values(medians)
- medians = medians.map(&:presence).compact
- average = calc_average(medians)
-
- {
- average: average,
- sd: standard_deviation(medians, average),
- missing: projects.length - medians.length
- }
- end
-
- def calc_average(values)
- return if values.empty?
-
- (values.sum / values.length).to_i
- end
-
- def standard_deviation(values, average)
- Math.sqrt(sample_variance(values, average)).to_i
- end
-
- def sample_variance(values, average)
- return 0 if values.length <= 1
-
- sum = values.inject(0) do |acc, val|
- acc + (val - average)**2
- end
-
- sum / (values.length - 1)
- end
- end
- end
-end
diff --git a/lib/gitlab/experimentation/controller_concern.rb b/lib/gitlab/experimentation/controller_concern.rb
index 3b9b19d5606..c85d3f4eee6 100644
--- a/lib/gitlab/experimentation/controller_concern.rb
+++ b/lib/gitlab/experimentation/controller_concern.rb
@@ -61,11 +61,11 @@ module Gitlab
end
end
- def record_experiment_user(experiment_key)
+ def record_experiment_user(experiment_key, context = {})
return if dnt_enabled?
return unless Experimentation.active?(experiment_key) && current_user
- ::Experiment.add_user(experiment_key, tracking_group(experiment_key, nil, subject: current_user), current_user)
+ ::Experiment.add_user(experiment_key, tracking_group(experiment_key, nil, subject: current_user), current_user, context)
end
def record_experiment_conversion_event(experiment_key)
diff --git a/lib/gitlab/quick_actions/issue_actions.rb b/lib/gitlab/quick_actions/issue_actions.rb
index b0aae363749..400547bb6c1 100644
--- a/lib/gitlab/quick_actions/issue_actions.rb
+++ b/lib/gitlab/quick_actions/issue_actions.rb
@@ -102,6 +102,30 @@ module Gitlab
@execution_message[:duplicate] = message
end
+ desc _('Clone this issue')
+ explanation do |project = quick_action_target.project.full_path|
+ _("Clones this issue, without comments, to %{project}.") % { project: project }
+ end
+ params 'path/to/project'
+ types Issue
+ condition do
+ quick_action_target.persisted? &&
+ current_user.can?(:"admin_#{quick_action_target.to_ability_name}", project)
+ end
+ command :clone do |target_project_path = nil|
+ target_project = target_project_path.present? ? Project.find_by_full_path(target_project_path) : quick_action_target.project
+
+ if target_project.present?
+ @updates[:target_clone_project] = target_project
+
+ message = _("Cloned this issue to %{path_to_project}.") % { path_to_project: target_project_path || quick_action_target.project.full_path }
+ else
+ message = _("Failed to clone this issue because target project doesn't exist.")
+ end
+
+ @execution_message[:clone] = message
+ end
+
desc _('Move this issue to another project.')
explanation do |path_to_project|
_("Moves this issue to %{path_to_project}.") % { path_to_project: path_to_project }
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 91f9c8c2cff..b182a160e29 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -47,7 +47,6 @@ module Gitlab
.merge(system_usage_data_weekly)
.merge(features_usage_data)
.merge(components_usage_data)
- .merge(cycle_analytics_usage_data)
.merge(object_store_usage_data)
.merge(topology_usage_data)
.merge(usage_activity_by_stage)
@@ -250,12 +249,6 @@ module Gitlab
}
end
- def cycle_analytics_usage_data
- Gitlab::CycleAnalytics::UsageData.new.to_json
- rescue ActiveRecord::StatementInvalid
- { avg_cycle_analytics: {} }
- end
-
# rubocop:disable CodeReuse/ActiveRecord
def grafana_embed_usage_data
count(Issue.joins('JOIN grafana_integrations USING (project_id)')
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index fcf397e3162..f94829de0b4 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2619,6 +2619,9 @@ msgstr ""
msgid "AlertSettings|Proceed with editing"
msgstr ""
+msgid "AlertSettings|Prometheus"
+msgstr ""
+
msgid "AlertSettings|Prometheus API base URL"
msgstr ""
@@ -5662,6 +5665,9 @@ msgstr ""
msgid "Clone repository"
msgstr ""
+msgid "Clone this issue"
+msgstr ""
+
msgid "Clone with %{http_label}"
msgstr ""
@@ -5674,6 +5680,18 @@ msgstr ""
msgid "Clone with SSH"
msgstr ""
+msgid "CloneIssue|Cannot clone issue due to insufficient permissions!"
+msgstr ""
+
+msgid "CloneIssue|Cannot clone issue to target project as it is pending deletion."
+msgstr ""
+
+msgid "Cloned this issue to %{path_to_project}."
+msgstr ""
+
+msgid "Clones this issue, without comments, to %{project}."
+msgstr ""
+
msgid "Close"
msgstr ""
@@ -11421,6 +11439,9 @@ msgstr ""
msgid "Failed to check related branches."
msgstr ""
+msgid "Failed to clone this issue because target project doesn't exist."
+msgstr ""
+
msgid "Failed to create Merge Request. Please try again."
msgstr ""
@@ -11756,9 +11777,6 @@ msgstr ""
msgid "FeatureFlags|Feature Flags"
msgstr ""
-msgid "FeatureFlags|Feature Flags will look different in the next milestone. No action is needed, but you may notice the functionality was changed to improve the workflow."
-msgstr ""
-
msgid "FeatureFlags|Feature flag %{name} will be removed. Are you sure?"
msgstr ""
diff --git a/package.json b/package.json
index 471998f7c20..20b0cc8d958 100644
--- a/package.json
+++ b/package.json
@@ -42,7 +42,7 @@
"@babel/plugin-syntax-import-meta": "^7.10.1",
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
- "@gitlab/svgs": "1.175.0",
+ "@gitlab/svgs": "1.176.0",
"@gitlab/ui": "24.4.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-3",
diff --git a/spec/controllers/projects/feature_flags_controller_spec.rb b/spec/controllers/projects/feature_flags_controller_spec.rb
index 96eeb6f239f..ebe964aa465 100644
--- a/spec/controllers/projects/feature_flags_controller_spec.rb
+++ b/spec/controllers/projects/feature_flags_controller_spec.rb
@@ -217,15 +217,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['feature_flags'].count).to eq(3)
end
-
- it 'returns only version 1 flags when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expected = [feature_flag_active.name, feature_flag_inactive.name].sort
- expect(json_response['feature_flags'].map { |f| f['name'] }.sort).to eq(expected)
- end
end
end
@@ -283,24 +274,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['name']).to eq(other_feature_flag.name)
end
- it 'routes based on iid when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- other_project = create(:project)
- other_project.add_developer(user)
- other_feature_flag = create(:operations_feature_flag, project: other_project,
- name: 'other_flag')
- params = {
- namespace_id: other_project.namespace,
- project_id: other_project,
- iid: other_feature_flag.iid
- }
-
- get(:show, params: params, format: :json)
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response['name']).to eq(other_feature_flag.name)
- end
-
context 'when feature flag is not found' do
let!(:feature_flag) { }
@@ -386,14 +359,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['version']).to eq('new_version_flag')
end
- it 'returns a 404 when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
-
it 'returns strategies ordered by id' do
first_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag)
second_strategy = create(:operations_strategy, feature_flag: new_version_feature_flag)
@@ -791,54 +756,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(Operations::FeatureFlag.count).to eq(0)
end
end
-
- context 'when version 2 flags are disabled' do
- context 'and attempting to create a version 2 flag' do
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true,
- version: 'new_version_flag'
- }
- }
- end
-
- it 'returns a 400' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:bad_request)
- expect(Operations::FeatureFlag.count).to eq(0)
- end
- end
-
- context 'and attempting to create a version 1 flag' do
- let(:params) do
- {
- namespace_id: project.namespace,
- project_id: project,
- operations_feature_flag: {
- name: 'my_feature_flag',
- active: true
- }
- }
- end
-
- it 'creates the flag' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(Operations::FeatureFlag.count).to eq(1)
- expect(json_response['version']).to eq('legacy_flag')
- end
- end
- end
end
describe 'DELETE destroy.json' do
@@ -913,15 +830,6 @@ RSpec.describe Projects::FeatureFlagsController do
it 'deletes the flag' do
expect { subject }.to change { Operations::FeatureFlag.count }.by(-1)
end
-
- context 'when new version flags are disabled' do
- it 'returns a 404' do
- stub_feature_flags(feature_flags_new_version: false)
-
- expect { subject }.not_to change { Operations::FeatureFlag.count }
- expect(response).to have_gitlab_http_status(:not_found)
- end
- end
end
end
@@ -1576,15 +1484,6 @@ RSpec.describe Projects::FeatureFlagsController do
expect(json_response['strategies'].first['scopes']).to eq([])
end
- it 'does not update the flag if version 2 flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- put_request(new_version_flag, { name: 'some-other-name' })
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(new_version_flag.reload.name).to eq('new-feature')
- end
-
it 'updates the flag when legacy feature flags are set to be read only' do
stub_feature_flags(feature_flags_legacy_read_only: true)
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index a06ba4f229a..1889e2b81a5 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -184,6 +184,7 @@ RSpec.describe 'Database schema' do
"ApplicationSetting" => %w[repository_storages_weighted],
"AlertManagement::Alert" => %w[payload],
"Ci::BuildMetadata" => %w[config_options config_variables],
+ "ExperimentUser" => %w[context],
"Geo::Event" => %w[payload],
"GeoNodeStatus" => %w[status],
"Operations::FeatureFlagScope" => %w[strategies],
diff --git a/spec/features/groups/container_registry_spec.rb b/spec/features/groups/container_registry_spec.rb
index 1b23b8b4bf9..cacabdda22d 100644
--- a/spec/features/groups/container_registry_spec.rb
+++ b/spec/features/groups/container_registry_spec.rb
@@ -51,13 +51,6 @@ RSpec.describe 'Container Registry', :js do
expect(page).to have_content 'my/image'
end
- it 'image repository delete is disabled' do
- visit_container_registry
-
- delete_btn = find('[title="Remove repository"]')
- expect(delete_btn).to be_disabled
- end
-
it 'navigates to repo details' do
visit_container_registry_details('my/image')
diff --git a/spec/features/issues/user_uses_quick_actions_spec.rb b/spec/features/issues/user_uses_quick_actions_spec.rb
index c5eb3f415ff..d88b816b186 100644
--- a/spec/features/issues/user_uses_quick_actions_spec.rb
+++ b/spec/features/issues/user_uses_quick_actions_spec.rb
@@ -43,5 +43,6 @@ RSpec.describe 'Issues > User uses quick actions', :js do
it_behaves_like 'create_merge_request quick action'
it_behaves_like 'move quick action'
it_behaves_like 'zoom quick actions'
+ it_behaves_like 'clone quick action'
end
end
diff --git a/spec/features/projects/container_registry_spec.rb b/spec/features/projects/container_registry_spec.rb
index 45bf35a6aab..5231e8d5550 100644
--- a/spec/features/projects/container_registry_spec.rb
+++ b/spec/features/projects/container_registry_spec.rb
@@ -94,7 +94,8 @@ RSpec.describe 'Container Registry', :js do
end
it('pagination navigate to the second page') do
- visit_second_page
+ visit_details_second_page
+
expect(page).to have_content '20'
end
end
@@ -116,22 +117,23 @@ RSpec.describe 'Container Registry', :js do
context 'when there are more than 10 images' do
before do
- create_list(:container_repository, 12, project: project)
project.container_repositories << container_repository
+ create_list(:container_repository, 12, project: project)
+
visit_container_registry
end
it 'shows pagination' do
- expect(page).to have_css '.gl-pagination'
+ expect(page).to have_css '.gl-keyset-pagination'
end
it 'pagination goes to second page' do
- visit_second_page
+ visit_list_next_page
expect(page).to have_content 'my/image'
end
it 'pagination is preserved after navigating back from details' do
- visit_second_page
+ visit_list_next_page
click_link 'my/image'
breadcrumb = find '.breadcrumbs'
breadcrumb.click_link 'Container Registry'
@@ -148,7 +150,12 @@ RSpec.describe 'Container Registry', :js do
click_link name
end
- def visit_second_page
+ def visit_list_next_page
+ pagination = find '.gl-keyset-pagination'
+ pagination.click_button 'Next'
+ end
+
+ def visit_details_second_page
pagination = find '.gl-pagination'
pagination.click_link '2'
end
diff --git a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
index 830dda737b0..eaafc7e607b 100644
--- a/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
+++ b/spec/features/projects/feature_flags/user_creates_feature_flag_spec.rb
@@ -67,118 +67,6 @@ RSpec.describe 'User creates feature flag', :js do
end
end
- context 'with new version flags disabled' do
- before do
- stub_feature_flags(feature_flags_new_version: false)
- end
-
- context 'when creates without changing scopes' do
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('ci_live_trace', 'For live trace')
- click_button 'Create feature flag'
- expect(page).to have_current_path(project_feature_flags_path(project))
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
- end
- end
- end
- end
-
- context 'when creates with disabling the default scope' do
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('ci_live_trace', 'For live trace')
-
- within_scope_row(1) do
- within_status { find('.project-feature-toggle').click }
- end
-
- click_button 'Create feature flag'
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('ci_live_trace')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(1)')).to have_content('*')
- end
- end
- end
- end
-
- context 'when creates with an additional scope' do
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('mr_train', '')
-
- within_scope_row(2) do
- within_environment_spec do
- find('.js-env-search > input').set("review/*")
- find('.js-create-button').click
- end
- end
-
- within_scope_row(2) do
- within_status { find('.project-feature-toggle').click }
- end
-
- click_button 'Create feature flag'
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('mr_train')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(2)')).to have_content('review/*')
- end
- end
- end
- end
-
- context 'when searches an environment name for scope creation' do
- let!(:environment) { create(:environment, name: 'production', project: project) }
-
- before do
- visit(new_project_feature_flag_path(project))
- set_feature_flag_info('mr_train', '')
-
- within_scope_row(2) do
- within_environment_spec do
- find('.js-env-search > input').set('prod')
- click_button 'production'
- end
- end
-
- click_button 'Create feature flag'
- end
-
- it 'shows the created feature flag' do
- within_feature_flag_row(1) do
- expect(page.find('.feature-flag-name')).to have_content('mr_train')
- expect_status_toggle_button_to_be_checked
-
- within_feature_flag_scopes do
- expect(page.find('[data-qa-selector="feature-flag-scope-info-badge"]:nth-child(1)')).to have_content('*')
- expect(page.find('[data-qa-selector="feature-flag-scope-muted-badge"]:nth-child(2)')).to have_content('production')
- end
- end
- end
- end
- end
-
private
def set_feature_flag_info(name, description)
diff --git a/spec/finders/feature_flags_finder_spec.rb b/spec/finders/feature_flags_finder_spec.rb
index cab1094672b..8744a186212 100644
--- a/spec/finders/feature_flags_finder_spec.rb
+++ b/spec/finders/feature_flags_finder_spec.rb
@@ -80,15 +80,5 @@ RSpec.describe FeatureFlagsFinder do
is_expected.to eq([feature_flag_1, feature_flag_2, feature_flag_3])
end
end
-
- context 'when new version flags are disabled' do
- let!(:feature_flag_3) { create(:operations_feature_flag, :new_version_flag, name: 'flag-c', project: project) }
-
- it 'returns only legacy flags' do
- stub_feature_flags(feature_flags_new_version: false)
-
- is_expected.to eq([feature_flag_1, feature_flag_2])
- end
- end
end
end
diff --git a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js
index 5574c83eb76..ed78a593944 100644
--- a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js
+++ b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js
@@ -42,8 +42,8 @@ describe('AlertsServiceForm', () => {
mockAxios = new MockAdapter(axios);
setFixtures(`
<div>
- <span class="js-service-active-status fa fa-circle" data-value="true"></span>
- <span class="js-service-active-status fa fa-power-off" data-value="false"></span>
+ <span class="js-service-active-status" data-value="true"><svg class="s16 cgreen" data-testid="check-icon"><use xlink:href="icons.svg#check" /></svg></span>
+ <span class="js-service-active-status" data-value="false"><svg class="s16 clgray" data-testid="power-icon"><use xlink:href="icons.svg#power" /></svg></span>
</div>`);
});
diff --git a/spec/frontend/alerts_settings/alerts_settings_form_spec.js b/spec/frontend/alerts_settings/alerts_settings_form_spec.js
index dd7d32c5e75..428c6f93444 100644
--- a/spec/frontend/alerts_settings/alerts_settings_form_spec.js
+++ b/spec/frontend/alerts_settings/alerts_settings_form_spec.js
@@ -93,16 +93,28 @@ describe('AlertsSettingsFormNew', () => {
).toBe(true);
});
- it('disabled the dropdown and shows help text when multi integrations are not supported', async () => {
+ it('disables the dropdown and shows help text when multi integrations are not supported', async () => {
createComponent({ props: { canAddIntegration: false } });
expect(findSelect().attributes('disabled')).toBe('disabled');
expect(findMultiSupportText().exists()).toBe(true);
});
+
+ it('disabled the name input when the selected value is prometheus', async () => {
+ createComponent();
+ const options = findSelect().findAll('option');
+ await options.at(2).setSelected();
+
+ expect(
+ findFormFields()
+ .at(0)
+ .attributes('disabled'),
+ ).toBe('disabled');
+ });
});
describe('submitting integration form', () => {
it('allows for create-new-integration with the correct form values for HTTP', async () => {
- createComponent({});
+ createComponent();
const options = findSelect().findAll('option');
await options.at(1).setSelected();
@@ -128,7 +140,7 @@ describe('AlertsSettingsFormNew', () => {
});
it('allows for create-new-integration with the correct form values for PROMETHEUS', async () => {
- createComponent({});
+ createComponent();
const options = findSelect().findAll('option');
await options.at(2).setSelected();
diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js
index a6404faa445..31cc7b99e42 100644
--- a/spec/frontend/branches/ajax_loading_spinner_spec.js
+++ b/spec/frontend/branches/ajax_loading_spinner_spec.js
@@ -9,7 +9,7 @@ describe('Ajax Loading Spinner', () => {
<a class="js-ajax-loading-spinner"
data-remote
href="http://goesnowhere.nothing/whereami">
- <i class="fa fa-trash-o"></i>
+ Remove me
</a></div>`;
AjaxLoadingSpinner.init();
ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner');
diff --git a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
index 6a394251060..f8e25925774 100644
--- a/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/edit_feature_flag_spec.js
@@ -4,7 +4,7 @@ import MockAdapter from 'axios-mock-adapter';
import { GlToggle, GlAlert } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import { mockTracking } from 'helpers/tracking_helper';
-import { LEGACY_FLAG, NEW_VERSION_FLAG, NEW_FLAG_ALERT } from '~/feature_flags/constants';
+import { LEGACY_FLAG, NEW_VERSION_FLAG } from '~/feature_flags/constants';
import Form from '~/feature_flags/components/form.vue';
import createStore from '~/feature_flags/store/edit';
import EditFeatureFlag from '~/feature_flags/components/edit_feature_flag.vue';
@@ -37,9 +37,6 @@ describe('Edit feature flag form', () => {
showUserCallout: true,
userCalloutId,
userCalloutsPath,
- glFeatures: {
- featureFlagsNewVersion: true,
- },
...opts,
},
});
@@ -151,33 +148,4 @@ describe('Edit feature flag form', () => {
});
});
});
-
- describe('without new version flags', () => {
- beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } }));
-
- it('should alert users that feature flags are changing soon', () => {
- expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
- });
- });
-
- describe('dismissing new version alert', () => {
- beforeEach(() => {
- factory({ glFeatures: { featureFlagsNewVersion: false } });
- mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
- findAlert().vm.$emit('dismiss');
- return wrapper.vm.$nextTick();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should hide the alert', () => {
- expect(findAlert().exists()).toBe(false);
- });
-
- it('should send the dismissal event', () => {
- expect(mock.history.post.length).toBe(1);
- });
- });
});
diff --git a/spec/frontend/feature_flags/components/new_feature_flag_spec.js b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
index dbc6e03d922..e317ac4b092 100644
--- a/spec/frontend/feature_flags/components/new_feature_flag_spec.js
+++ b/spec/frontend/feature_flags/components/new_feature_flag_spec.js
@@ -1,17 +1,11 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
-import MockAdapter from 'axios-mock-adapter';
import { GlAlert } from '@gitlab/ui';
import { TEST_HOST } from 'spec/test_constants';
import Form from '~/feature_flags/components/form.vue';
import createStore from '~/feature_flags/store/new';
import NewFeatureFlag from '~/feature_flags/components/new_feature_flag.vue';
-import {
- ROLLOUT_STRATEGY_ALL_USERS,
- DEFAULT_PERCENT_ROLLOUT,
- NEW_FLAG_ALERT,
-} from '~/feature_flags/constants';
-import axios from '~/lib/utils/axios_utils';
+import { ROLLOUT_STRATEGY_ALL_USERS, DEFAULT_PERCENT_ROLLOUT } from '~/feature_flags/constants';
import { allUsersStrategy } from '../mock_data';
const userCalloutId = 'feature_flags_new_version';
@@ -42,9 +36,6 @@ describe('New feature flag form', () => {
userCalloutsPath,
environmentsEndpoint: 'environments.json',
projectId: '8',
- glFeatures: {
- featureFlagsNewVersion: true,
- },
...opts,
},
});
@@ -58,8 +49,6 @@ describe('New feature flag form', () => {
wrapper.destroy();
});
- const findAlert = () => wrapper.find(GlAlert);
-
describe('with error', () => {
it('should render the error', () => {
store.dispatch('receiveCreateFeatureFlagError', { message: ['The name is required'] });
@@ -101,36 +90,4 @@ describe('New feature flag form', () => {
expect(strategies).toEqual([allUsersStrategy]);
});
-
- describe('without new version flags', () => {
- beforeEach(() => factory({ glFeatures: { featureFlagsNewVersion: false } }));
-
- it('should alert users that feature flags are changing soon', () => {
- expect(findAlert().text()).toBe(NEW_FLAG_ALERT);
- });
- });
-
- describe('dismissing new version alert', () => {
- let mock;
-
- beforeEach(() => {
- mock = new MockAdapter(axios);
- mock.onPost(userCalloutsPath, { feature_name: userCalloutId }).reply(200);
- factory({ glFeatures: { featureFlagsNewVersion: false } });
- findAlert().vm.$emit('dismiss');
- return wrapper.vm.$nextTick();
- });
-
- afterEach(() => {
- mock.restore();
- });
-
- it('should hide the alert', () => {
- expect(findAlert().exists()).toBe(false);
- });
-
- it('should send the dismissal event', () => {
- expect(mock.history.post.length).toBe(1);
- });
- });
});
diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js
index 5c37d986ef1..b1c299ba91f 100644
--- a/spec/frontend/filtered_search/filtered_search_manager_spec.js
+++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js
@@ -69,7 +69,7 @@ describe('Filtered Search Manager', () => {
${FilteredSearchSpecHelper.createInputHTML(placeholder)}
</ul>
<button class="clear-search" type="button">
- <i class="fa fa-times"></i>
+ <svg class="s16 clear-search-icon" data-testid="close-icon"><use xlink:href="icons.svg#close" /></svg>
</button>
</form>
</div>
diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js
index a0282e838cd..433fb368f55 100644
--- a/spec/frontend/lib/utils/common_utils_spec.js
+++ b/spec/frontend/lib/utils/common_utils_spec.js
@@ -526,8 +526,8 @@ describe('common_utils', () => {
});
it('should set svg className when passed', () => {
- expect(commonUtils.spriteIcon('test', 'fa fa-test')).toEqual(
- '<svg class="fa fa-test"><use xlink:href="icons.svg#test" /></svg>',
+ expect(commonUtils.spriteIcon('test', 'first-icon-class second-icon-class')).toEqual(
+ '<svg class="first-icon-class second-icon-class"><use xlink:href="icons.svg#test" /></svg>',
);
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
index 9f7a2758ae1..b9839d92f1d 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import Component from '~/registry/explorer/components/list_page/image_list_row.vue';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
@@ -11,13 +12,15 @@ import {
REMOVE_REPOSITORY_LABEL,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ IMAGE_DELETE_SCHEDULED_STATUS,
+ IMAGE_FAILED_DELETED_STATUS,
} from '~/registry/explorer/constants';
import { RouterLink } from '../../stubs';
import { imagesListResponse } from '../../mock_data';
describe('Image List Row', () => {
let wrapper;
- const item = imagesListResponse.data[0];
+ const [item] = imagesListResponse;
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
@@ -50,13 +53,15 @@ describe('Image List Row', () => {
describe('main tooltip', () => {
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
mountComponent();
+
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
});
it('is disabled when item is being deleted', () => {
- mountComponent({ item: { ...item, deleting: true } });
+ mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
+
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
expect(tooltip.value.disabled).toBe(false);
});
@@ -65,12 +70,13 @@ describe('Image List Row', () => {
describe('image title and path', () => {
it('contains a link to the details page', () => {
mountComponent();
+
const link = findDetailsLink();
expect(link.html()).toContain(item.path);
expect(link.props('to')).toMatchObject({
name: 'details',
params: {
- id: item.id,
+ id: getIdFromGraphQLId(item.id),
},
});
});
@@ -85,16 +91,18 @@ describe('Image List Row', () => {
describe('warning icon', () => {
it.each`
- failedDelete | cleanup_policy_started_at | shown | title
- ${true} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
- ${false} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
- ${false} | ${false} | ${false} | ${''}
+ status | expirationPolicyStartedAt | shown | title
+ ${IMAGE_FAILED_DELETED_STATUS} | ${true} | ${true} | ${ASYNC_DELETE_IMAGE_ERROR_MESSAGE}
+ ${''} | ${true} | ${true} | ${CLEANUP_TIMED_OUT_ERROR_MESSAGE}
+ ${''} | ${false} | ${false} | ${''}
`(
- 'when failedDelete is $failedDelete and cleanup_policy_started_at is $cleanup_policy_started_at',
- ({ cleanup_policy_started_at, failedDelete, shown, title }) => {
- mountComponent({ item: { ...item, failedDelete, cleanup_policy_started_at } });
+ 'when status is $status and expirationPolicyStartedAt is $expirationPolicyStartedAt',
+ ({ expirationPolicyStartedAt, status, shown, title }) => {
+ mountComponent({ item: { ...item, status, expirationPolicyStartedAt } });
+
const icon = findWarningIcon();
expect(icon.exists()).toBe(shown);
+
if (shown) {
const tooltip = getBinding(icon.element, 'gl-tooltip');
expect(tooltip.value.title).toBe(title);
@@ -112,30 +120,33 @@ describe('Image List Row', () => {
it('has the correct props', () => {
mountComponent();
- expect(findDeleteBtn().attributes()).toMatchObject({
+
+ expect(findDeleteBtn().props()).toMatchObject({
title: REMOVE_REPOSITORY_LABEL,
- tooltipdisabled: `${Boolean(item.destroy_path)}`,
- tooltiptitle: LIST_DELETE_BUTTON_DISABLED,
+ tooltipDisabled: item.canDelete,
+ tooltipTitle: LIST_DELETE_BUTTON_DISABLED,
});
});
it('emits a delete event', () => {
mountComponent();
+
findDeleteBtn().vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[item]]);
});
it.each`
- destroy_path | deleting | state
- ${null} | ${null} | ${'true'}
- ${null} | ${true} | ${'true'}
- ${'foo'} | ${true} | ${'true'}
- ${'foo'} | ${false} | ${undefined}
+ canDelete | status | state
+ ${false} | ${''} | ${true}
+ ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
+ ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
+ ${true} | ${''} | ${false}
`(
- 'disabled is $state when destroy_path is $destroy_path and deleting is $deleting',
- ({ destroy_path, deleting, state }) => {
- mountComponent({ item: { ...item, destroy_path, deleting } });
- expect(findDeleteBtn().attributes('disabled')).toBe(state);
+ 'disabled is $state when canDelete is $canDelete and status is $status',
+ ({ canDelete, status, state }) => {
+ mountComponent({ item: { ...item, canDelete, status } });
+
+ expect(findDeleteBtn().props('disabled')).toBe(state);
},
);
});
@@ -155,11 +166,13 @@ describe('Image List Row', () => {
describe('tags count text', () => {
it('with one tag in the image', () => {
- mountComponent({ item: { ...item, tags_count: 1 } });
+ mountComponent({ item: { ...item, tagsCount: 1 } });
+
expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
});
it('with more than one tag in the image', () => {
- mountComponent({ item: { ...item, tags_count: 3 } });
+ mountComponent({ item: { ...item, tagsCount: 3 } });
+
expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
});
});
diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
index 03ba6ad7f80..54befc9973a 100644
--- a/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
+++ b/spec/frontend/registry/explorer/components/list_page/image_list_spec.js
@@ -1,29 +1,25 @@
import { shallowMount } from '@vue/test-utils';
-import { GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination } from '@gitlab/ui';
import Component from '~/registry/explorer/components/list_page/image_list.vue';
import ImageListRow from '~/registry/explorer/components/list_page/image_list_row.vue';
-import { imagesListResponse, imagePagination } from '../../mock_data';
+import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data';
describe('Image List', () => {
let wrapper;
const findRow = () => wrapper.findAll(ImageListRow);
- const findPagination = () => wrapper.find(GlPagination);
+ const findPagination = () => wrapper.find(GlKeysetPagination);
- const mountComponent = () => {
+ const mountComponent = (pageInfo = defaultPageInfo) => {
wrapper = shallowMount(Component, {
propsData: {
- images: imagesListResponse.data,
- pagination: imagePagination,
+ images: imagesListResponse,
+ pageInfo,
},
});
};
- beforeEach(() => {
- mountComponent();
- });
-
afterEach(() => {
wrapper.destroy();
wrapper = null;
@@ -31,10 +27,14 @@ describe('Image List', () => {
describe('list', () => {
it('contains one list element for each image', () => {
- expect(findRow().length).toBe(imagesListResponse.data.length);
+ mountComponent();
+
+ expect(findRow().length).toBe(imagesListResponse.length);
});
it('when delete event is emitted on the row it emits up a delete event', () => {
+ mountComponent();
+
findRow()
.at(0)
.vm.$emit('delete', 'foo');
@@ -44,19 +44,41 @@ describe('Image List', () => {
describe('pagination', () => {
it('exists', () => {
+ mountComponent();
+
expect(findPagination().exists()).toBe(true);
});
- it('is wired to the correct pagination props', () => {
- const pagination = findPagination();
- expect(pagination.props('perPage')).toBe(imagePagination.perPage);
- expect(pagination.props('totalItems')).toBe(imagePagination.total);
- expect(pagination.props('value')).toBe(imagePagination.page);
+ it.each`
+ hasNextPage | hasPreviousPage | isVisible
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ `(
+ 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible',
+ ({ hasNextPage, hasPreviousPage, isVisible }) => {
+ mountComponent({ hasNextPage, hasPreviousPage });
+
+ expect(findPagination().exists()).toBe(isVisible);
+ expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage);
+ expect(findPagination().props('hasNextPage')).toBe(hasNextPage);
+ },
+ );
+
+ it('emits "prev-page" when the user clicks the back page button', () => {
+ mountComponent({ hasPreviousPage: true });
+
+ findPagination().vm.$emit('prev');
+
+ expect(wrapper.emitted('prev-page')).toEqual([[]]);
});
- it('emits a pageChange event when the page change', () => {
- findPagination().vm.$emit(GlPagination.model.event, 2);
- expect(wrapper.emitted('pageChange')).toEqual([[2]]);
+ it('emits "next-page" when the user clicks the forward page button', () => {
+ mountComponent({ hasNextPage: true });
+
+ findPagination().vm.$emit('next');
+
+ expect(wrapper.emitted('next-page')).toEqual([[]]);
});
});
});
diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js
index da5f1840b5c..13b1c4a485d 100644
--- a/spec/frontend/registry/explorer/mock_data.js
+++ b/spec/frontend/registry/explorer/mock_data.js
@@ -45,21 +45,32 @@ export const registryServerResponse = [
},
];
-export const imagesListResponse = {
- data: [
- {
- path: 'foo',
- location: 'location',
- destroy_path: 'path',
- },
- {
- path: 'bar',
- location: 'location-2',
- destroy_path: 'path-2',
- },
- ],
- headers,
-};
+export const imagesListResponse = [
+ {
+ __typename: 'ContainerRepository',
+ id: 'gid://gitlab/ContainerRepository/26',
+ name: 'rails-12009',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ status: null,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
+ canDelete: true,
+ createdAt: '2020-11-03T13:29:21Z',
+ tagsCount: 18,
+ expirationPolicyStartedAt: null,
+ },
+ {
+ __typename: 'ContainerRepository',
+ id: 'gid://gitlab/ContainerRepository/11',
+ name: 'rails-20572',
+ path: 'gitlab-org/gitlab-test/rails-20572',
+ status: null,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
+ canDelete: true,
+ createdAt: '2020-09-21T06:57:43Z',
+ tagsCount: 1,
+ expirationPolicyStartedAt: null,
+ },
+];
export const tagsListResponse = {
data: [
@@ -90,12 +101,12 @@ export const tagsListResponse = {
headers,
};
-export const imagePagination = {
- perPage: 10,
- page: 1,
- total: 14,
- totalPages: 2,
- nextPage: 2,
+export const pageInfo = {
+ hasNextPage: true,
+ hasPreviousPage: true,
+ startCursor: 'eyJpZCI6IjI2In0',
+ endCursor: 'eyJpZCI6IjgifQ',
+ __typename: 'ContainerRepositoryConnection',
};
export const imageDetailsMock = {
@@ -108,3 +119,76 @@ export const imageDetailsMock = {
cleanup_policy_started_at: null,
delete_api_path: 'http://0.0.0.0:3000/api/v4/projects/1/registry/repositories/1',
};
+
+export const graphQLImageListMock = {
+ data: {
+ project: {
+ __typename: 'Project',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: imagesListResponse,
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const graphQLEmptyImageListMock = {
+ data: {
+ project: {
+ __typename: 'Project',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: [],
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const graphQLEmptyGroupImageListMock = {
+ data: {
+ group: {
+ __typename: 'Group',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: [],
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const deletedContainerRepository = {
+ id: 'gid://gitlab/ContainerRepository/11',
+ status: 'DELETE_SCHEDULED',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ __typename: 'ContainerRepository',
+};
+
+export const graphQLImageDeleteMock = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ ...deletedContainerRepository,
+ },
+ errors: [],
+ __typename: 'DestroyContainerRepositoryPayload',
+ },
+ },
+};
+
+export const graphQLImageDeleteMockError = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ ...deletedContainerRepository,
+ },
+ errors: ['foo'],
+ __typename: 'DestroyContainerRepositoryPayload',
+ },
+ },
+};
diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js
index b24422adb03..c954837de72 100644
--- a/spec/frontend/registry/explorer/pages/list_spec.js
+++ b/spec/frontend/registry/explorer/pages/list_spec.js
@@ -1,5 +1,7 @@
-import { shallowMount } from '@vue/test-utils';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
+import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
@@ -10,26 +12,36 @@ import RegistryHeader from '~/registry/explorer/components/list_page/registry_he
import ImageList from '~/registry/explorer/components/list_page/image_list.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { createStore } from '~/registry/explorer/stores/';
-import {
- SET_MAIN_LOADING,
- SET_IMAGES_LIST_SUCCESS,
- SET_PAGINATION,
- SET_INITIAL_STATE,
-} from '~/registry/explorer/stores/mutation_types';
+import { SET_INITIAL_STATE } from '~/registry/explorer/stores/mutation_types';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
IMAGE_REPOSITORY_LIST_LABEL,
SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
-import { imagesListResponse } from '../mock_data';
+
+import getProjectContainerRepositories from '~/registry/explorer/graphql/queries/get_project_container_repositories.graphql';
+import getGroupContainerRepositories from '~/registry/explorer/graphql/queries/get_group_container_repositories.graphql';
+import deleteContainerRepository from '~/registry/explorer/graphql/mutations/delete_container_repository.graphql';
+
+import {
+ graphQLImageListMock,
+ graphQLImageDeleteMock,
+ deletedContainerRepository,
+ graphQLImageDeleteMockError,
+ graphQLEmptyImageListMock,
+ graphQLEmptyGroupImageListMock,
+ pageInfo,
+} from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
+const localVue = createLocalVue();
+
describe('List Page', () => {
let wrapper;
- let dispatchSpy;
let store;
+ let apolloProvider;
const findDeleteModal = () => wrapper.find(GlModal);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
@@ -47,8 +59,30 @@ describe('List Page', () => {
const findSearchBox = () => wrapper.find(GlSearchBoxByClick);
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
- const mountComponent = ({ mocks } = {}) => {
+ const waitForApolloRequestRender = async () => {
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ };
+
+ const mountComponent = ({
+ mocks,
+ resolver = jest.fn().mockResolvedValue(graphQLImageListMock),
+ groupResolver = jest.fn().mockResolvedValue(graphQLImageListMock),
+ mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
+ } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [getProjectContainerRepositories, resolver],
+ [getGroupContainerRepositories, groupResolver],
+ [deleteContainerRepository, mutationResolver],
+ ];
+
+ apolloProvider = createMockApollo(requestHandlers);
+
wrapper = shallowMount(component, {
+ localVue,
+ apolloProvider,
store,
stubs: {
GlModal,
@@ -69,37 +103,21 @@ describe('List Page', () => {
beforeEach(() => {
store = createStore();
- dispatchSpy = jest.spyOn(store, 'dispatch');
- dispatchSpy.mockResolvedValue();
- store.commit(SET_IMAGES_LIST_SUCCESS, imagesListResponse.data);
- store.commit(SET_PAGINATION, imagesListResponse.headers);
});
afterEach(() => {
wrapper.destroy();
});
- describe('API calls', () => {
- it.each`
- imageList | name | called
- ${[]} | ${'foo'} | ${['requestImagesList']}
- ${imagesListResponse.data} | ${undefined} | ${['requestImagesList']}
- ${imagesListResponse.data} | ${'foo'} | ${undefined}
- `(
- 'with images equal $imageList and name $name dispatch calls $called',
- ({ imageList, name, called }) => {
- store.commit(SET_IMAGES_LIST_SUCCESS, imageList);
- dispatchSpy.mockClear();
- mountComponent({ mocks: { $route: { name } } });
-
- expect(dispatchSpy.mock.calls[0]).toEqual(called);
- },
- );
- });
-
- it('contains registry header', () => {
+ it('contains registry header', async () => {
mountComponent();
+
+ await waitForApolloRequestRender();
+
expect(findRegistryHeader().exists()).toBe(true);
+ expect(findRegistryHeader().props()).toMatchObject({
+ imagesCount: 2,
+ });
});
describe('connection error', () => {
@@ -111,7 +129,6 @@ describe('List Page', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, config);
- mountComponent();
});
afterEach(() => {
@@ -119,78 +136,103 @@ describe('List Page', () => {
});
it('should show an empty state', () => {
+ mountComponent();
+
expect(findEmptyState().exists()).toBe(true);
});
it('empty state should have an svg-path', () => {
+ mountComponent();
+
expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage);
});
it('empty state should have a description', () => {
+ mountComponent();
+
expect(findEmptyState().html()).toContain('connection error');
});
it('should not show the loading or default state', () => {
+ mountComponent();
+
expect(findSkeletonLoader().exists()).toBe(false);
expect(findImageList().exists()).toBe(false);
});
});
describe('isLoading is true', () => {
- beforeEach(() => {
- store.commit(SET_MAIN_LOADING, true);
+ it('shows the skeleton loader', () => {
mountComponent();
- });
- afterEach(() => store.commit(SET_MAIN_LOADING, false));
-
- it('shows the skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('imagesList is not visible', () => {
+ mountComponent();
+
expect(findImageList().exists()).toBe(false);
});
it('cli commands is not visible', () => {
+ mountComponent();
+
expect(findCliCommands().exists()).toBe(false);
});
});
describe('list is empty', () => {
- beforeEach(() => {
- store.commit(SET_IMAGES_LIST_SUCCESS, []);
- mountComponent();
- return waitForPromises();
- });
+ describe('project page', () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock);
- it('cli commands is not visible', () => {
- expect(findCliCommands().exists()).toBe(false);
- });
+ it('cli commands is not visible', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findCliCommands().exists()).toBe(false);
+ });
+
+ it('project empty state is visible', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
- it('project empty state is visible', () => {
- expect(findProjectEmptyState().exists()).toBe(true);
+ expect(findProjectEmptyState().exists()).toBe(true);
+ });
});
+ describe('group page', () => {
+ const groupResolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
- describe('is group page is true', () => {
beforeEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: true });
- mountComponent();
});
afterEach(() => {
store.commit(SET_INITIAL_STATE, { isGroupPage: undefined });
});
- it('group empty state is visible', () => {
+ it('group empty state is visible', async () => {
+ mountComponent({ groupResolver });
+
+ await waitForApolloRequestRender();
+
expect(findGroupEmptyState().exists()).toBe(true);
});
- it('cli commands is not visible', () => {
+ it('cli commands is not visible', async () => {
+ mountComponent({ groupResolver });
+
+ await waitForApolloRequestRender();
+
expect(findCliCommands().exists()).toBe(false);
});
- it('list header is not visible', () => {
+ it('list header is not visible', async () => {
+ mountComponent({ groupResolver });
+
+ await waitForApolloRequestRender();
+
expect(findListHeader().exists()).toBe(false);
});
});
@@ -198,55 +240,91 @@ describe('List Page', () => {
describe('list is not empty', () => {
describe('unfiltered state', () => {
- beforeEach(() => {
+ it('quick start is visible', async () => {
mountComponent();
- });
- it('quick start is visible', () => {
+ await waitForApolloRequestRender();
+
expect(findCliCommands().exists()).toBe(true);
});
- it('list component is visible', () => {
+ it('list component is visible', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
expect(findImageList().exists()).toBe(true);
});
- it('list header is visible', () => {
+ it('list header is visible', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
const header = findListHeader();
expect(header.exists()).toBe(true);
expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
describe('delete image', () => {
- const itemToDelete = { path: 'bar' };
- it('should call deleteItem when confirming deletion', () => {
- dispatchSpy.mockResolvedValue();
- findImageList().vm.$emit('delete', itemToDelete);
- expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
+ const deleteImage = async () => {
+ await wrapper.vm.$nextTick();
+
+ findImageList().vm.$emit('delete', deletedContainerRepository);
findDeleteModal().vm.$emit('ok');
- expect(store.dispatch).toHaveBeenCalledWith(
- 'requestDeleteImage',
- wrapper.vm.itemToDelete,
+
+ await waitForApolloRequestRender();
+ };
+
+ it('should call deleteItem when confirming deletion', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
+ expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository);
+ expect(mutationResolver).toHaveBeenCalledWith({ id: deletedContainerRepository.id });
+
+ const updatedImage = findImageList()
+ .props('images')
+ .find(i => i.id === deletedContainerRepository.id);
+
+ expect(updatedImage.status).toBe(deletedContainerRepository.status);
+ });
+
+ it('should show a success alert when delete request is successful', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
+ const alert = findDeleteAlert();
+ expect(alert.exists()).toBe(true);
+ expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
+ DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
- it('should show a success alert when delete request is successful', () => {
- dispatchSpy.mockResolvedValue();
- findImageList().vm.$emit('delete', itemToDelete);
- expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
- return wrapper.vm.handleDeleteImage().then(() => {
+ describe('when delete request fails it shows an alert', () => {
+ it('user recoverable error', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMockError);
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
- DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
+ DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
);
});
- });
- it('should show an error alert when delete request fails', () => {
- dispatchSpy.mockRejectedValue();
- findImageList().vm.$emit('delete', itemToDelete);
- expect(wrapper.vm.itemToDelete).toEqual(itemToDelete);
- return wrapper.vm.handleDeleteImage().then(() => {
+ it('network error', async () => {
+ const mutationResolver = jest.fn().mockRejectedValue();
+ mountComponent({ mutationResolver });
+
+ await deleteImage();
+
const alert = findDeleteAlert();
expect(alert.exists()).toBe(true);
expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
@@ -258,38 +336,68 @@ describe('List Page', () => {
});
describe('search', () => {
- it('has a search box element', () => {
+ const doSearch = async () => {
+ await waitForApolloRequestRender();
+ findSearchBox().vm.$emit('submit', 'centos6');
+ await wrapper.vm.$nextTick();
+ };
+
+ it('has a search box element', async () => {
mountComponent();
+
+ await waitForApolloRequestRender();
+
const searchBox = findSearchBox();
expect(searchBox.exists()).toBe(true);
expect(searchBox.attributes('placeholder')).toBe(SEARCH_PLACEHOLDER_TEXT);
});
- it('performs a search', () => {
- mountComponent();
- findSearchBox().vm.$emit('submit', 'foo');
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
- name: 'foo',
- });
+ it('performs a search', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await doSearch();
+
+ expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' }));
});
- it('when search result is empty displays an empty search message', () => {
- mountComponent();
- store.commit(SET_IMAGES_LIST_SUCCESS, []);
- return wrapper.vm.$nextTick().then(() => {
- expect(findEmptySearchMessage().exists()).toBe(true);
- });
+ it('when search result is empty displays an empty search message', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ resolver.mockResolvedValue(graphQLEmptyImageListMock);
+
+ await doSearch();
+
+ expect(findEmptySearchMessage().exists()).toBe(true);
});
});
describe('pagination', () => {
- it('pageChange event triggers the appropriate store function', () => {
- mountComponent();
- findImageList().vm.$emit('pageChange', 2);
- expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', {
- pagination: { page: 2 },
- name: wrapper.vm.search,
- });
+ it('prev-page event triggers a fetchMore request', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('prev-page');
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ first: null, before: pageInfo.startCursor }),
+ );
+ });
+
+ it('next-page event triggers a fetchMore request', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('next-page');
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ after: pageInfo.endCursor }),
+ );
});
});
});
@@ -324,11 +432,11 @@ describe('List Page', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
- dispatchSpy.mockResolvedValue();
});
it('send an event when delete button is clicked', () => {
findImageList().vm.$emit('delete', {});
+
testTrackingCall('click_button');
});
diff --git a/spec/frontend/search/index_spec.js b/spec/frontend/search/index_spec.js
index 8a86cc4c52a..31b5aa3686b 100644
--- a/spec/frontend/search/index_spec.js
+++ b/spec/frontend/search/index_spec.js
@@ -2,8 +2,8 @@ import { initSearchApp } from '~/search';
import createStore from '~/search/store';
jest.mock('~/search/store');
+jest.mock('~/search/topbar');
jest.mock('~/search/sidebar');
-jest.mock('~/search/group_filter');
describe('initSearchApp', () => {
let defaultLocation;
diff --git a/spec/frontend/search/topbar/components/group_filter_spec.js b/spec/frontend/search/topbar/components/group_filter_spec.js
new file mode 100644
index 00000000000..017808d576e
--- /dev/null
+++ b/spec/frontend/search/topbar/components/group_filter_spec.js
@@ -0,0 +1,121 @@
+import Vuex from 'vuex';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
+import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
+import GroupFilter from '~/search/topbar/components/group_filter.vue';
+import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+ setUrlParams: jest.fn(),
+}));
+
+describe('GroupFilter', () => {
+ let wrapper;
+
+ const actionSpies = {
+ fetchGroups: jest.fn(),
+ };
+
+ const defaultProps = {
+ initialData: null,
+ };
+
+ const createComponent = (initialState, props) => {
+ const store = new Vuex.Store({
+ state: {
+ query: MOCK_QUERY,
+ ...initialState,
+ },
+ actions: actionSpies,
+ });
+
+ wrapper = shallowMount(GroupFilter, {
+ localVue,
+ store,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findSearchableDropdown = () => wrapper.find(SearchableDropdown);
+
+ describe('template', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders SearchableDropdown always', () => {
+ expect(findSearchableDropdown().exists()).toBe(true);
+ });
+ });
+
+ describe('events', () => {
+ describe('when @search is emitted', () => {
+ const search = 'test';
+
+ beforeEach(() => {
+ createComponent();
+
+ findSearchableDropdown().vm.$emit('search', search);
+ });
+
+ it('calls fetchGroups with the search paramter', () => {
+ expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1);
+ expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search);
+ });
+ });
+
+ describe('when @change is emitted', () => {
+ beforeEach(() => {
+ createComponent();
+
+ findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
+ });
+
+ it('calls calls setUrlParams with group id, project id null, and visitUrl', () => {
+ expect(setUrlParams).toHaveBeenCalledWith({
+ [GROUP_DATA.queryParam]: MOCK_GROUP.id,
+ [PROJECT_DATA.queryParam]: null,
+ });
+
+ expect(visitUrl).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('computed', () => {
+ describe('selectedGroup', () => {
+ describe('when initialData is null', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('sets selectedGroup to ANY_OPTION', () => {
+ expect(wrapper.vm.selectedGroup).toBe(ANY_OPTION);
+ });
+ });
+
+ describe('when initialData is set', () => {
+ beforeEach(() => {
+ createComponent({}, { initialData: MOCK_GROUP });
+ });
+
+ it('sets selectedGroup to ANY_OPTION', () => {
+ expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/search/group_filter/components/group_filter_spec.js b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
index fd3a4449f41..c4ebaabbf96 100644
--- a/spec/frontend/search/group_filter/components/group_filter_spec.js
+++ b/spec/frontend/search/topbar/components/searchable_dropdown_spec.js
@@ -1,41 +1,34 @@
import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
-import * as urlUtils from '~/lib/utils/url_utility';
-import GroupFilter from '~/search/group_filter/components/group_filter.vue';
-import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants';
-import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data';
+import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
+import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
+import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
-jest.mock('~/flash');
-jest.mock('~/lib/utils/url_utility', () => ({
- visitUrl: jest.fn(),
- setUrlParams: jest.fn(),
-}));
-
-describe('Global Search Group Filter', () => {
+describe('Global Search Searchable Dropdown', () => {
let wrapper;
- const actionSpies = {
- fetchGroups: jest.fn(),
- };
-
const defaultProps = {
- initialGroup: null,
+ headerText: GROUP_DATA.headerText,
+ selectedDisplayValue: GROUP_DATA.selectedDisplayValue,
+ itemsDisplayValue: GROUP_DATA.itemsDisplayValue,
+ loading: false,
+ selectedItem: ANY_OPTION,
+ items: [],
};
- const createComponent = (initialState, props = {}, mountFn = shallowMount) => {
+ const createComponent = (initialState, props, mountFn = shallowMount) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
- actions: actionSpies,
});
- wrapper = mountFn(GroupFilter, {
+ wrapper = mountFn(SearchableDropdown, {
localVue,
store,
propsData: {
@@ -78,22 +71,22 @@ describe('Global Search Group Filter', () => {
});
describe('onSearch', () => {
- const groupSearch = 'test search';
+ const search = 'test search';
beforeEach(() => {
- findGlDropdownSearch().vm.$emit('input', groupSearch);
+ findGlDropdownSearch().vm.$emit('input', search);
});
- it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => {
- expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch);
+ it('$emits @search when input event is fired from GlSearchBoxByType', () => {
+ expect(wrapper.emitted('search')[0]).toEqual([search]);
});
});
});
describe('findDropdownItems', () => {
- describe('when fetchingGroups is false', () => {
+ describe('when loading is false', () => {
beforeEach(() => {
- createComponent({ groups: MOCK_GROUPS });
+ createComponent({}, { items: MOCK_GROUPS });
});
it('does not render loader', () => {
@@ -101,14 +94,14 @@ describe('Global Search Group Filter', () => {
});
it('renders an instance for each namespace', () => {
- const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
- expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny);
+ const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
+ expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny);
});
});
- describe('when fetchingGroups is true', () => {
+ describe('when loading is true', () => {
beforeEach(() => {
- createComponent({ fetchingGroups: true, groups: MOCK_GROUPS });
+ createComponent({}, { loading: true, items: MOCK_GROUPS });
});
it('does render loader', () => {
@@ -119,26 +112,36 @@ describe('Global Search Group Filter', () => {
expect(findDropdownItemsText()).toStrictEqual(['Any']);
});
});
+
+ describe('when item is selected', () => {
+ beforeEach(() => {
+ createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] });
+ });
+
+ it('marks the dropdown as checked', () => {
+ expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true');
+ });
+ });
});
describe('Dropdown Text', () => {
- describe('when initialGroup is null', () => {
+ describe('when selectedItem is any', () => {
beforeEach(() => {
createComponent({}, {}, mount);
});
it('sets dropdown text to Any', () => {
- expect(findDropdownText().text()).toBe(ANY_GROUP.name);
+ expect(findDropdownText().text()).toBe(ANY_OPTION.name);
});
});
- describe('initialGroup is set', () => {
+ describe('selectedItem is set', () => {
beforeEach(() => {
- createComponent({}, { initialGroup: MOCK_GROUP }, mount);
+ createComponent({}, { selectedItem: MOCK_GROUP }, mount);
});
- it('sets dropdown text to group name', () => {
- expect(findDropdownText().text()).toBe(MOCK_GROUP.name);
+ it('sets dropdown text to the selectedItem selectedDisplayValue', () => {
+ expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]);
});
});
});
@@ -146,27 +149,19 @@ describe('Global Search Group Filter', () => {
describe('actions', () => {
beforeEach(() => {
- createComponent({ groups: MOCK_GROUPS });
+ createComponent({}, { items: MOCK_GROUPS });
});
- it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => {
+ it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => {
findAnyDropdownItem().vm.$emit('click');
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- [GROUP_QUERY_PARAM]: ANY_GROUP.id,
- [PROJECT_QUERY_PARAM]: null,
- });
- expect(urlUtils.visitUrl).toHaveBeenCalled();
+ expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]);
});
- it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => {
+ it('clicking result dropdown item $emits @change with result', () => {
findFirstGroupDropdownItem().vm.$emit('click');
- expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
- [GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id,
- [PROJECT_QUERY_PARAM]: null,
- });
- expect(urlUtils.visitUrl).toHaveBeenCalled();
+ expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
});
});
});
diff --git a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb b/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb
deleted file mode 100644
index 9ebdacb16de..00000000000
--- a/spec/lib/gitlab/cycle_analytics/usage_data_spec.rb
+++ /dev/null
@@ -1,95 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe Gitlab::CycleAnalytics::UsageData do
- describe '#to_json' do
- before do
- # Since git commits only have second precision, round up to the
- # nearest second to ensure we have accurate median and standard
- # deviation calculations.
- current_time = Time.at(Time.now.to_i)
-
- Timecop.freeze(current_time) do
- user = create(:user, :admin)
- projects = create_list(:project, 2, :repository)
-
- projects.each_with_index do |project, time|
- issue = create(:issue, project: project, created_at: (time + 1).hour.ago)
-
- allow_next_instance_of(Gitlab::ReferenceExtractor) do |instance|
- allow(instance).to receive(:issues).and_return([issue])
- end
-
- milestone = create(:milestone, project: project)
- mr = create_merge_request_closing_issue(user, project, issue, commit_message: "References #{issue.to_reference}")
- pipeline = create(:ci_empty_pipeline, status: 'created', project: project, ref: mr.source_branch, sha: mr.source_branch_sha, head_pipeline_of: mr)
-
- create_cycle(user, project, issue, mr, milestone, pipeline)
- deploy_master(user, project, environment: 'staging')
- deploy_master(user, project)
- end
- end
- end
-
- context 'a valid usage data result' do
- let(:expect_values_per_stage) do
- {
- issue: {
- average: 5400,
- sd: 2545,
- missing: 0
- },
- plan: {
- average: 1,
- sd: 0,
- missing: 0
- },
- code: {
- average: nil,
- sd: 0,
- missing: 2
- },
- test: {
- average: nil,
- sd: 0,
- missing: 2
- },
- review: {
- average: 0,
- sd: 0,
- missing: 0
- },
- staging: {
- average: 0,
- sd: 0,
- missing: 0
- },
- production: {
- average: 5400,
- sd: 2545,
- missing: 0
- }
- }
- end
-
- it 'returns the aggregated usage data of every selected project', :sidekiq_might_not_need_inline do
- result = subject.to_json
-
- expect(result).to have_key(:avg_cycle_analytics)
-
- CycleAnalytics::LevelBase::STAGES.each do |stage|
- expect(result[:avg_cycle_analytics]).to have_key(stage)
-
- stage_values = result[:avg_cycle_analytics][stage]
- expected_values = expect_values_per_stage[stage]
-
- expected_values.each_pair do |op, value|
- expect(stage_values).to have_key(op)
- expect(stage_values[op]).to eq(value)
- end
- end
- end
- end
- end
-end
diff --git a/spec/lib/gitlab/experimentation/controller_concern_spec.rb b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
index 3cda5d230c5..03cb89ee033 100644
--- a/spec/lib/gitlab/experimentation/controller_concern_spec.rb
+++ b/spec/lib/gitlab/experimentation/controller_concern_spec.rb
@@ -392,6 +392,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
describe '#record_experiment_user' do
let(:user) { build(:user) }
+ let(:context) { { a: 42 } }
context 'when the experiment is enabled' do
before do
@@ -405,9 +406,9 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user)
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :experimental, user, context)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -417,9 +418,9 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user, context)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
end
@@ -433,7 +434,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -445,7 +446,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -462,9 +463,9 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
end
it 'calls add_user on the Experiment model' do
- expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user)
+ expect(::Experiment).to receive(:add_user).with(:test_experiment, :control, user, context)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
@@ -476,7 +477,7 @@ RSpec.describe Gitlab::Experimentation::ControllerConcern, type: :controller do
it 'does not call add_user on the Experiment model' do
expect(::Experiment).not_to receive(:add_user)
- controller.record_experiment_user(:test_experiment)
+ controller.record_experiment_user(:test_experiment, context)
end
end
end
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index e07e13f4920..7e1fa3280b6 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -841,24 +841,6 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do
end
end
- describe '.cycle_analytics_usage_data' do
- subject { described_class.cycle_analytics_usage_data }
-
- it 'works when queries time out in new' do
- allow(Gitlab::CycleAnalytics::UsageData)
- .to receive(:new).and_raise(ActiveRecord::StatementInvalid.new(''))
-
- expect { subject }.not_to raise_error
- end
-
- it 'works when queries time out in to_json' do
- allow_any_instance_of(Gitlab::CycleAnalytics::UsageData)
- .to receive(:to_json).and_raise(ActiveRecord::StatementInvalid.new(''))
-
- expect { subject }.not_to raise_error
- end
- end
-
describe '.ingress_modsecurity_usage' do
subject { described_class.ingress_modsecurity_usage }
diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb
index 90884bfd0fb..e5608486092 100644
--- a/spec/models/environment_spec.rb
+++ b/spec/models/environment_spec.rb
@@ -19,6 +19,7 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
it { is_expected.to have_many(:deployments) }
it { is_expected.to have_many(:metrics_dashboard_annotations) }
it { is_expected.to have_many(:alert_management_alerts) }
+ it { is_expected.to have_one(:upcoming_deployment) }
it { is_expected.to have_one(:latest_opened_most_severe_alert) }
it { is_expected.to delegate_method(:stop_action).to(:last_deployment) }
@@ -723,6 +724,22 @@ RSpec.describe Environment, :use_clean_rails_memory_store_caching do
end
end
+ describe '#upcoming_deployment' do
+ subject { environment.upcoming_deployment }
+
+ context 'when environment has a successful deployment' do
+ let!(:deployment) { create(:deployment, :success, environment: environment, project: project) }
+
+ it { is_expected.to be_nil }
+ end
+
+ context 'when environment has a running deployment' do
+ let!(:deployment) { create(:deployment, :running, environment: environment, project: project) }
+
+ it { is_expected.to eq(deployment) }
+ end
+ end
+
describe '#has_terminals?' do
subject { environment.has_terminals? }
diff --git a/spec/models/experiment_spec.rb b/spec/models/experiment_spec.rb
index 0d9c57643ed..4106630ea20 100644
--- a/spec/models/experiment_spec.rb
+++ b/spec/models/experiment_spec.rb
@@ -19,20 +19,21 @@ RSpec.describe Experiment do
let_it_be(:experiment_name) { :experiment_key }
let_it_be(:user) { 'a user' }
let_it_be(:group) { 'a group' }
+ let_it_be(:context) { { a: 42 } }
- subject(:add_user) { described_class.add_user(experiment_name, group, user) }
+ subject(:add_user) { described_class.add_user(experiment_name, group, user, context) }
context 'when an experiment with the provided name does not exist' do
it 'creates a new experiment record' do
allow_next_instance_of(described_class) do |experiment|
- allow(experiment).to receive(:record_user_and_group).with(user, group)
+ allow(experiment).to receive(:record_user_and_group).with(user, group, context)
end
expect { add_user }.to change(described_class, :count).by(1)
end
- it 'forwards the user and group_type to the instance' do
+ it 'forwards the user, group_type, and context to the instance' do
expect_next_instance_of(described_class) do |experiment|
- expect(experiment).to receive(:record_user_and_group).with(user, group)
+ expect(experiment).to receive(:record_user_and_group).with(user, group, context)
end
add_user
end
@@ -43,18 +44,26 @@ RSpec.describe Experiment do
it 'does not create a new experiment record' do
allow_next_found_instance_of(described_class) do |experiment|
- allow(experiment).to receive(:record_user_and_group).with(user, group)
+ allow(experiment).to receive(:record_user_and_group).with(user, group, context)
end
expect { add_user }.not_to change(described_class, :count)
end
- it 'forwards the user and group_type to the instance' do
+ it 'forwards the user, group_type, and context to the instance' do
expect_next_found_instance_of(described_class) do |experiment|
- expect(experiment).to receive(:record_user_and_group).with(user, group)
+ expect(experiment).to receive(:record_user_and_group).with(user, group, context)
end
add_user
end
end
+
+ it 'works without the optional context argument' do
+ allow_next_instance_of(described_class) do |experiment|
+ expect(experiment).to receive(:record_user_and_group).with(user, group, {})
+ end
+
+ expect { described_class.add_user(experiment_name, group, user) }.not_to raise_error
+ end
end
describe '.record_conversion_event' do
@@ -131,8 +140,9 @@ RSpec.describe Experiment do
let_it_be(:user) { create(:user) }
let(:group) { :control }
+ let(:context) { { a: 42 } }
- subject(:record_user_and_group) { experiment.record_user_and_group(user, group) }
+ subject(:record_user_and_group) { experiment.record_user_and_group(user, group, context) }
context 'when an experiment_user does not yet exist for the given user' do
it 'creates a new experiment_user record' do
@@ -143,24 +153,35 @@ RSpec.describe Experiment do
record_user_and_group
expect(ExperimentUser.last.group_type).to eq('control')
end
+
+ it 'adds the correct context to the experiment_user' do
+ record_user_and_group
+ expect(ExperimentUser.last.context).to eq({ 'a' => 42 })
+ end
end
context 'when an experiment_user already exists for the given user' do
before do
# Create an existing experiment_user for this experiment and the :control group
- experiment.record_user_and_group(user, :control)
+ experiment.record_user_and_group(user, :control, context)
end
it 'does not create a new experiment_user record' do
expect { record_user_and_group }.not_to change(ExperimentUser, :count)
end
- context 'but the group_type has changed' do
+ context 'but the group_type and context has changed' do
let(:group) { :experimental }
+ let(:context) { { b: 37 } }
- it 'updates the existing experiment_user record' do
+ it 'updates the existing experiment_user record with group_type' do
expect { record_user_and_group }.to change { ExperimentUser.last.group_type }
end
+
+ it 'updates the existing experiment_user record with context' do
+ record_user_and_group
+ expect(ExperimentUser.last.context).to eq({ 'b' => 37 })
+ end
end
end
end
diff --git a/spec/requests/api/feature_flags_spec.rb b/spec/requests/api/feature_flags_spec.rb
index 90d4a7b8b21..dd12648f4dd 100644
--- a/spec/requests/api/feature_flags_spec.rb
+++ b/spec/requests/api/feature_flags_spec.rb
@@ -65,26 +65,6 @@ RSpec.describe API::FeatureFlags do
expect(json_response.map { |f| f['version'] }).to eq(%w[legacy_flag legacy_flag])
end
- it 'does not return the legacy flag version when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.select { |f| f.key?('version') }).to eq([])
- end
-
- it 'does not return strategies if the new flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.select { |f| f.key?('strategies') }).to eq([])
- end
-
it 'does not have N+1 problem' do
control_count = ActiveRecord::QueryRecorder.new { subject }
@@ -134,16 +114,6 @@ RSpec.describe API::FeatureFlags do
}]
}])
end
-
- it 'does not return a version 2 flag when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response).to eq([])
- end
end
context 'with version 1 and 2 feature flags' do
@@ -159,20 +129,6 @@ RSpec.describe API::FeatureFlags do
expect(response).to match_response_schema('public_api/v4/feature_flags')
expect(json_response.map { |f| f['name'] }).to eq(%w[legacy_flag new_version_flag])
end
-
- it 'returns only version 1 flags when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- create(:operations_feature_flag, project: project, name: 'legacy_flag')
- feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'new_version_flag')
- strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
- create(:operations_scope, strategy: strategy, environment_scope: 'production')
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(response).to match_response_schema('public_api/v4/feature_flags')
- expect(json_response.map { |f| f['name'] }).to eq(['legacy_flag'])
- end
end
end
@@ -224,18 +180,6 @@ RSpec.describe API::FeatureFlags do
}]
})
end
-
- it 'returns a 404 when the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- feature_flag = create(:operations_feature_flag, :new_version_flag, project: project, name: 'feature1')
- strategy = create(:operations_strategy, feature_flag: feature_flag, name: 'default', parameters: {})
- create(:operations_scope, strategy: strategy, environment_scope: 'production')
-
- get api("/projects/#{project.id}/feature_flags/feature1", user)
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(json_response).to eq({ 'message' => '404 Not found' })
- end
end
end
@@ -290,16 +234,6 @@ RSpec.describe API::FeatureFlags do
expect(json_response['version']).to eq('legacy_flag')
end
- it 'does not return version when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:created)
- expect(response).to match_response_schema('public_api/v4/feature_flag')
- expect(json_response.key?('version')).to eq(false)
- end
-
context 'with active set to false in the params for a legacy flag' do
let(:params) do
{
@@ -505,20 +439,6 @@ RSpec.describe API::FeatureFlags do
environment_scope: 'staging'
}])
end
-
- it 'returns a 422 when the feature flag is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- params = {
- name: 'new-feature',
- version: 'new_version_flag'
- }
-
- post api("/projects/#{project.id}/feature_flags", user), params: params
-
- expect(response).to have_gitlab_http_status(:unprocessable_entity)
- expect(json_response).to eq({ 'message' => 'Version 2 flags are not enabled for this project' })
- expect(project.operations_feature_flags.count).to eq(0)
- end
end
context 'when given invalid parameters' do
@@ -744,16 +664,6 @@ RSpec.describe API::FeatureFlags do
name: 'feature1', description: 'old description')
end
- it 'returns a 404 if the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- params = { description: 'new description' }
-
- put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(feature_flag.reload.description).to eq('old description')
- end
-
it 'returns a 422' do
params = { description: 'new description' }
@@ -771,16 +681,6 @@ RSpec.describe API::FeatureFlags do
name: 'feature1', description: 'old description')
end
- it 'returns a 404 if the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
- params = { description: 'new description' }
-
- put api("/projects/#{project.id}/feature_flags/feature1", user), params: params
-
- expect(response).to have_gitlab_http_status(:not_found)
- expect(feature_flag.reload.description).to eq('old description')
- end
-
it 'returns a 404 if the feature flag does not exist' do
params = { description: 'new description' }
@@ -1100,15 +1000,6 @@ RSpec.describe API::FeatureFlags do
expect(json_response['version']).to eq('legacy_flag')
end
- it 'does not return version when new version flags are disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- subject
-
- expect(response).to have_gitlab_http_status(:ok)
- expect(json_response.key?('version')).to eq(false)
- end
-
context 'with a version 2 feature flag' do
let!(:feature_flag) { create(:operations_feature_flag, :new_version_flag, project: project) }
@@ -1117,14 +1008,6 @@ RSpec.describe API::FeatureFlags do
expect(response).to have_gitlab_http_status(:ok)
end
-
- it 'returns a 404 if the feature is disabled' do
- stub_feature_flags(feature_flags_new_version: false)
-
- expect { subject }.not_to change { Operations::FeatureFlag.count }
-
- expect(response).to have_gitlab_http_status(:not_found)
- end
end
end
end
diff --git a/spec/serializers/environment_entity_spec.rb b/spec/serializers/environment_entity_spec.rb
index f5d6706a844..5b83507b4ec 100644
--- a/spec/serializers/environment_entity_spec.rb
+++ b/spec/serializers/environment_entity_spec.rb
@@ -7,15 +7,20 @@ RSpec.describe EnvironmentEntity do
let(:request) { double('request') }
let(:entity) do
- described_class.new(environment, request: spy('request'))
+ described_class.new(environment, request: request)
end
let_it_be(:user) { create(:user) }
- let_it_be(:project) { create(:project) }
- let_it_be(:environment) { create(:environment, project: project) }
+ let_it_be(:project) { create(:project, :repository) }
+ let_it_be(:environment, refind: true) { create(:environment, project: project) }
+
+ before_all do
+ project.add_developer(user)
+ end
before do
- allow(entity).to receive(:current_user).and_return(user)
+ allow(request).to receive(:current_user).and_return(user)
+ allow(request).to receive(:project).and_return(project)
end
subject { entity.as_json }
@@ -32,6 +37,51 @@ RSpec.describe EnvironmentEntity do
expect(subject).to include(:folder_path)
end
+ context 'when there is a successful deployment' do
+ let!(:pipeline) { create(:ci_pipeline, :success, project: project) }
+ let!(:deployable) { create(:ci_build, :success, project: project, pipeline: pipeline) }
+ let!(:deployment) { create(:deployment, :success, project: project, environment: environment, deployable: deployable) }
+
+ it 'exposes it as the latest deployment' do
+ expect(subject[:last_deployment][:sha]).to eq(deployment.sha)
+ end
+
+ it 'does not expose it as an upcoming deployment' do
+ expect(subject[:upcoming_deployment]).to be_nil
+ end
+
+ context 'when the deployment pipeline has the other manual job' do
+ let!(:manual_job) { create(:ci_build, :manual, name: 'stop-review', project: project, pipeline: pipeline) }
+
+ it 'exposes the manual job in the latest deployment' do
+ expect(subject[:last_deployment][:manual_actions].first[:name])
+ .to eq(manual_job.name)
+ end
+ end
+ end
+
+ context 'when there is a running deployment' do
+ let!(:pipeline) { create(:ci_pipeline, :running, project: project) }
+ let!(:deployable) { create(:ci_build, :running, project: project, pipeline: pipeline) }
+ let!(:deployment) { create(:deployment, :running, project: project, environment: environment, deployable: deployable) }
+
+ it 'does not expose it as the latest deployment' do
+ expect(subject[:last_deployment]).to be_nil
+ end
+
+ it 'exposes it as an upcoming deployment' do
+ expect(subject[:upcoming_deployment][:sha]).to eq(deployment.sha)
+ end
+
+ context 'when the deployment pipeline has the other manual job' do
+ let!(:manual_job) { create(:ci_build, :manual, name: 'stop-review', project: project, pipeline: pipeline) }
+
+ it 'does not expose the manual job in the latest deployment' do
+ expect(subject[:upcoming_deployment][:manual_actions]).to be_nil
+ end
+ end
+ end
+
context 'metrics disabled' do
before do
allow(environment).to receive(:has_metrics?).and_return(false)
diff --git a/spec/services/issues/clone_service_spec.rb b/spec/services/issues/clone_service_spec.rb
new file mode 100644
index 00000000000..d28fc40d32e
--- /dev/null
+++ b/spec/services/issues/clone_service_spec.rb
@@ -0,0 +1,300 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Issues::CloneService do
+ include DesignManagementTestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:author) { create(:user) }
+ let_it_be(:title) { 'Some issue' }
+ let_it_be(:description) { "Some issue description with mention to #{user.to_reference}" }
+ let_it_be(:group) { create(:group, :private) }
+ let_it_be(:sub_group_1) { create(:group, :private, parent: group) }
+ let_it_be(:sub_group_2) { create(:group, :private, parent: group) }
+ let_it_be(:old_project) { create(:project, namespace: sub_group_1) }
+ let_it_be(:new_project) { create(:project, namespace: sub_group_2) }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author)
+ end
+
+ subject(:clone_service) do
+ described_class.new(old_project, user)
+ end
+
+ shared_context 'user can clone issue' do
+ before do
+ old_project.add_reporter(user)
+ new_project.add_reporter(user)
+ end
+ end
+
+ describe '#execute' do
+ context 'issue movable' do
+ include_context 'user can clone issue'
+
+ context 'generic issue' do
+ let!(:new_issue) { clone_service.execute(old_issue, new_project) }
+
+ it 'creates a new issue in the selected project' do
+ expect do
+ clone_service.execute(old_issue, new_project)
+ end.to change { new_project.issues.count }.by(1)
+ end
+
+ it 'copies issue title' do
+ expect(new_issue.title).to eq title
+ end
+
+ it 'copies issue description' do
+ expect(new_issue.description).to eq description
+ end
+
+ it 'adds system note to old issue at the end' do
+ expect(old_issue.notes.last.note).to start_with 'cloned to'
+ end
+
+ it 'adds system note to new issue at the end' do
+ expect(new_issue.notes.last.note).to start_with 'cloned from'
+ end
+
+ it 'keeps old issue open' do
+ expect(old_issue.open?).to be true
+ end
+
+ it 'persists new issue' do
+ expect(new_issue.persisted?).to be true
+ end
+
+ it 'persists all changes' do
+ expect(old_issue.changed?).to be false
+ expect(new_issue.changed?).to be false
+ end
+
+ it 'preserves author' do
+ expect(new_issue.author).to eq author
+ end
+
+ it 'creates a new internal id for issue' do
+ expect(new_issue.iid).to be_present
+ end
+
+ it 'preserves create time' do
+ expect(old_issue.created_at.strftime('%D')).to eq new_issue.created_at.strftime('%D')
+ end
+
+ it 'does not copy system notes' do
+ expect(new_issue.notes.count).to eq(1)
+ end
+
+ it 'does not set moved_issue' do
+ expect(old_issue.moved?).to eq(false)
+ end
+ end
+
+ context 'issue with award emoji' do
+ let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
+
+ it 'copies the award emoji' do
+ old_issue.reload
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(old_issue.award_emoji.first.name).to eq new_issue.reload.award_emoji.first.name
+ end
+ end
+
+ context 'issue with milestone' do
+ let(:milestone) { create(:milestone, group: sub_group_1) }
+ let(:new_project) { create(:project, namespace: sub_group_1) }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author, milestone: milestone)
+ end
+
+ before do
+ create(:resource_milestone_event, issue: old_issue, milestone: milestone, action: :add)
+ end
+
+ it 'does not create extra milestone events' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.resource_milestone_events.count).to eq(old_issue.resource_milestone_events.count)
+ end
+ end
+
+ context 'issue with due date' do
+ let(:date) { Date.parse('2020-01-10') }
+
+ let(:old_issue) do
+ create(:issue, title: title, description: description, project: old_project, author: author, due_date: date)
+ end
+
+ before do
+ SystemNoteService.change_due_date(old_issue, old_project, author, old_issue.due_date)
+ end
+
+ it 'keeps the same due date' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.due_date).to eq(date)
+ end
+ end
+
+ context 'issue with assignee' do
+ let_it_be(:assignee) { create(:user) }
+
+ before do
+ old_issue.assignees = [assignee]
+ end
+
+ it 'preserves assignee with access to the new issue' do
+ new_project.add_reporter(assignee)
+
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.assignees).to eq([assignee])
+ end
+
+ it 'ignores assignee without access to the new issue' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.assignees).to be_empty
+ end
+ end
+
+ context 'issue is confidential' do
+ before do
+ old_issue.update_columns(confidential: true)
+ end
+
+ it 'preserves the confidential flag' do
+ new_issue = clone_service.execute(old_issue, new_project)
+
+ expect(new_issue.confidential).to be true
+ end
+ end
+
+ context 'moving to same project' do
+ it 'also works' do
+ new_issue = clone_service.execute(old_issue, old_project)
+
+ expect(new_issue.project).to eq(old_project)
+ expect(new_issue.iid).not_to eq(old_issue.iid)
+ end
+ end
+
+ context 'project issue hooks' do
+ let!(:hook) { create(:project_hook, project: old_project, issues_events: true) }
+
+ it 'executes project issue hooks' do
+ allow_next_instance_of(WebHookService) do |instance|
+ allow(instance).to receive(:execute)
+ end
+
+ # Ideally, we'd test that `WebHookWorker.jobs.size` increased by 1,
+ # but since the entire spec run takes place in a transaction, we never
+ # actually get to the `after_commit` hook that queues these jobs.
+ expect { clone_service.execute(old_issue, new_project) }
+ .not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
+ end
+ end
+
+ context 'issue with a design', :clean_gitlab_redis_shared_state do
+ let_it_be(:new_project) { create(:project) }
+ let!(:design) { create(:design, :with_lfs_file, issue: old_issue) }
+ let!(:note) { create(:diff_note_on_design, noteable: design, issue: old_issue, project: old_issue.project) }
+ let(:subject) { clone_service.execute(old_issue, new_project) }
+
+ before do
+ enable_design_management
+ end
+
+ it 'calls CopyDesignCollection::QueueService' do
+ expect(DesignManagement::CopyDesignCollection::QueueService).to receive(:new)
+ .with(user, old_issue, kind_of(Issue))
+ .and_call_original
+
+ subject
+ end
+
+ it 'logs if QueueService returns an error', :aggregate_failures do
+ error_message = 'error'
+
+ expect_next_instance_of(DesignManagement::CopyDesignCollection::QueueService) do |service|
+ expect(service).to receive(:execute).and_return(
+ ServiceResponse.error(message: error_message)
+ )
+ end
+ expect(Gitlab::AppLogger).to receive(:error).with(error_message)
+
+ subject
+ end
+
+ # Perform a small integration test to ensure the services and worker
+ # can correctly create designs.
+ it 'copies the design and its notes', :sidekiq_inline, :aggregate_failures do
+ new_issue = subject
+
+ expect(new_issue.designs.size).to eq(1)
+ expect(new_issue.designs.first.notes.size).to eq(1)
+ end
+ end
+ end
+
+ describe 'clone permissions' do
+ let(:clone) { clone_service.execute(old_issue, new_project) }
+
+ context 'target project is pending deletion' do
+ include_context 'user can clone issue'
+
+ before do
+ new_project.update_columns(pending_delete: true)
+ end
+
+ after do
+ new_project.update_columns(pending_delete: false)
+ end
+
+ it { expect { clone }.to raise_error(Issues::CloneService::CloneError, /pending deletion/) }
+ end
+
+ context 'user is reporter in both projects' do
+ include_context 'user can clone issue'
+ it { expect { clone }.not_to raise_error }
+ end
+
+ context 'user is reporter only in new project' do
+ before do
+ new_project.add_reporter(user)
+ end
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+
+ context 'user is reporter only in old project' do
+ before do
+ old_project.add_reporter(user)
+ end
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+
+ context 'user is reporter in one project and guest in another' do
+ before do
+ new_project.add_guest(user)
+ old_project.add_reporter(user)
+ end
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+
+ context 'issue is not persisted' do
+ include_context 'user can clone issue'
+ let(:old_issue) { build(:issue, project: old_project, author: author) }
+
+ it { expect { clone }.to raise_error(StandardError, /permissions/) }
+ end
+ end
+ end
+end
diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb
index cfda27795c7..5dd60226e3f 100644
--- a/spec/services/issues/update_service_spec.rb
+++ b/spec/services/issues/update_service_spec.rb
@@ -968,6 +968,26 @@ RSpec.describe Issues::UpdateService, :mailer do
end
end
+ context 'clone an issue' do
+ context 'valid project' do
+ let(:target_project) { create(:project) }
+
+ before do
+ target_project.add_maintainer(user)
+ end
+
+ it 'calls the move service with the proper issue and project' do
+ clone_stub = instance_double(Issues::CloneService)
+ allow(Issues::CloneService).to receive(:new).and_return(clone_stub)
+ allow(clone_stub).to receive(:execute).with(issue, target_project).and_return(issue)
+
+ expect(clone_stub).to receive(:execute).with(issue, target_project)
+
+ update_issue(target_clone_project: target_project)
+ end
+ end
+ end
+
context 'when moving an issue ' do
it 'raises an error for invalid move ids within a project' do
opts = { move_between_ids: [9000, non_existing_record_id] }
diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb
index a4ae7e42958..9c35f9e3817 100644
--- a/spec/services/system_note_service_spec.rb
+++ b/spec/services/system_note_service_spec.rb
@@ -333,6 +333,19 @@ RSpec.describe SystemNoteService do
end
end
+ describe '.noteable_cloned' do
+ let(:noteable_ref) { double }
+ let(:direction) { double }
+
+ it 'calls IssuableService' do
+ expect_next_instance_of(::SystemNotes::IssuablesService) do |service|
+ expect(service).to receive(:noteable_cloned).with(noteable_ref, direction)
+ end
+
+ described_class.noteable_cloned(double, double, noteable_ref, double, direction: direction)
+ end
+ end
+
describe 'Jira integration' do
include JiraServiceHelper
diff --git a/spec/services/system_notes/issuables_service_spec.rb b/spec/services/system_notes/issuables_service_spec.rb
index b70c5e899fc..dfbfee003a3 100644
--- a/spec/services/system_notes/issuables_service_spec.rb
+++ b/spec/services/system_notes/issuables_service_spec.rb
@@ -522,6 +522,67 @@ RSpec.describe ::SystemNotes::IssuablesService do
end
end
+ describe '#noteable_cloned' do
+ let(:new_project) { create(:project) }
+ let(:new_noteable) { create(:issue, project: new_project) }
+
+ subject do
+ service.noteable_cloned(new_noteable, direction)
+ end
+
+ shared_examples 'cross project mentionable' do
+ include MarkupHelper
+
+ it 'contains cross reference to new noteable' do
+ expect(subject.note).to include cross_project_reference(new_project, new_noteable)
+ end
+
+ it 'mentions referenced noteable' do
+ expect(subject.note).to include new_noteable.to_reference
+ end
+
+ it 'mentions referenced project' do
+ expect(subject.note).to include new_project.full_path
+ end
+ end
+
+ context 'cloned to' do
+ let(:direction) { :to }
+
+ it_behaves_like 'cross project mentionable'
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'cloned' }
+ end
+
+ it 'notifies about noteable being cloned to' do
+ expect(subject.note).to match('cloned to')
+ end
+ end
+
+ context 'cloned from' do
+ let(:direction) { :from }
+
+ it_behaves_like 'cross project mentionable'
+
+ it_behaves_like 'a system note' do
+ let(:action) { 'cloned' }
+ end
+
+ it 'notifies about noteable being cloned from' do
+ expect(subject.note).to match('cloned from')
+ end
+ end
+
+ context 'invalid direction' do
+ let(:direction) { :invalid }
+
+ it 'raises error' do
+ expect { subject }.to raise_error StandardError, /Invalid direction/
+ end
+ end
+ end
+
describe '#mark_duplicate_issue' do
subject { service.mark_duplicate_issue(canonical_issue) }
diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb
index df562761f02..df79049123d 100644
--- a/spec/support/helpers/usage_data_helpers.rb
+++ b/spec/support/helpers/usage_data_helpers.rb
@@ -162,7 +162,6 @@ module UsageDataHelpers
git
gitaly
database
- avg_cycle_analytics
prometheus_metrics_enabled
web_ide_clientside_preview_enabled
ingress_modsecurity_enabled
diff --git a/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb
new file mode 100644
index 00000000000..35d65302a61
--- /dev/null
+++ b/spec/support/shared_examples/quick_actions/issue/clone_quick_action_shared_examples.rb
@@ -0,0 +1,159 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'clone quick action' do
+ context 'clone the issue to another project' do
+ let(:target_project) { create(:project, :public) }
+
+ context 'when no target is given' do
+ it 'clones the issue in the current project' do
+ add_note("/clone")
+
+ expect(page).to have_content "Cloned this issue to #{project.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(project, issue)
+
+ expect(page).to have_content 'Issues 2'
+ end
+ end
+
+ context 'when the project is valid' do
+ before do
+ target_project.add_maintainer(user)
+ end
+
+ it 'clones the issue' do
+ add_note("/clone #{target_project.full_path}")
+
+ expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'Issues 1'
+ end
+ end
+
+ context 'when the project is valid but the user not authorized' do
+ let(:project_unauthorized) { create(:project, :public) }
+
+ it 'does not clone the issue' do
+ add_note("/clone #{project_unauthorized.full_path}")
+
+ wait_for_requests
+
+ expect(page).to have_content "Cloned this issue to #{project_unauthorized.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).not_to have_content 'Issues 1'
+ end
+ end
+
+ context 'when the project is invalid' do
+ it 'does not clone the issue' do
+ add_note("/clone not/valid")
+
+ wait_for_requests
+
+ expect(page).to have_content "Failed to clone this issue because target project doesn't exist."
+ expect(issue.reload).to be_open
+ end
+ end
+
+ context 'when the user issues multiple commands' do
+ let(:milestone) { create(:milestone, title: '1.0', project: project) }
+ let(:bug) { create(:label, project: project, title: 'bug') }
+ let(:wontfix) { create(:label, project: project, title: 'wontfix') }
+
+ let!(:target_milestone) { create(:milestone, title: '1.0', project: target_project) }
+
+ before do
+ target_project.add_maintainer(user)
+ end
+
+ shared_examples 'applies the commands to issues in both projects, target and source' do
+ it "applies quick actions" do
+ expect(page).to have_content "Cloned this issue to #{target_project.full_path}."
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+
+ visit project_issue_path(project, issue)
+ expect(page).to have_content 'bug'
+ expect(page).to have_content 'wontfix'
+ expect(page).to have_content '1.0'
+ end
+ end
+
+ context 'applies multiple commands with clone command in the end' do
+ before do
+ add_note("/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"\n\n/clone #{target_project.full_path}")
+ end
+
+ it_behaves_like 'applies the commands to issues in both projects, target and source'
+ end
+
+ context 'applies multiple commands with clone command in the begining' do
+ before do
+ add_note("/clone #{target_project.full_path}\n\n/label ~#{bug.title} ~#{wontfix.title}\n\n/milestone %\"#{milestone.title}\"")
+ end
+
+ it_behaves_like 'applies the commands to issues in both projects, target and source'
+ end
+ end
+
+ context 'when editing comments' do
+ let(:target_project) { create(:project, :public) }
+
+ before do
+ target_project.add_maintainer(user)
+
+ sign_in(user)
+ visit project_issue_path(project, issue)
+ wait_for_all_requests
+ end
+
+ it 'clones the issue after quickcommand note was updated' do
+ # misspelled quick action
+ add_note("test note.\n/cloe #{target_project.full_path}")
+
+ expect(issue.reload).not_to be_closed
+
+ edit_note("/cloe #{target_project.full_path}", "test note.\n/clone #{target_project.full_path}")
+ wait_for_all_requests
+
+ expect(page).to have_content 'test note.'
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+ wait_for_all_requests
+
+ expect(page).to have_content 'Issues 1'
+ end
+
+ it 'deletes the note if it was updated to just contain a command' do
+ # missspelled quick action
+ add_note("test note.\n/cloe #{target_project.full_path}")
+
+ expect(page).not_to have_content 'Commands applied'
+
+ edit_note("/cloe #{target_project.full_path}", "/clone #{target_project.full_path}")
+ wait_for_all_requests
+
+ expect(page).not_to have_content "/clone #{target_project.full_path}"
+ expect(issue.reload).to be_open
+
+ visit project_issue_path(target_project, issue)
+ wait_for_all_requests
+
+ expect(page).to have_content 'Issues 1'
+ end
+ end
+ end
+end
diff --git a/yarn.lock b/yarn.lock
index fb12f40e847..5585e2d259a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -861,10 +861,10 @@
eslint-plugin-vue "^6.2.1"
vue-eslint-parser "^7.0.0"
-"@gitlab/svgs@1.175.0":
- version "1.175.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.175.0.tgz#734f341784af1cd1d62d160a17bcdfb61ff7b04d"
- integrity sha512-gXpc87TGSXIzfAr4QER1Qw1v3P47pBO6BXkma52blgwXVmcFNe3nhQzqsqt66wKNzrIrk3lAcB4GUyPHbPVXpg==
+"@gitlab/svgs@1.176.0":
+ version "1.176.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.176.0.tgz#3b6d415704ed36db87146cfa361f3f9e2c01f535"
+ integrity sha512-cl3SlGmLF/bnPAxrg52eRQOxRSrU54AB3yfV9TLi2iNAg/Jws0nuvdVZ4LRKKyyzHmT6yfVusCIGCISzvON6ew==
"@gitlab/ui@24.4.0":
version "24.4.0"