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
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-12-04 00:09:35 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-12-04 00:09:35 +0300
commite701659ba316541833e50d68f14720d17be58f8c (patch)
tree9e123fa2a749deaaf0a97612b05156576f55ff9f /app
parentc2a6cc86754adb3c5e064cebc58d206a52cb412e (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-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
41 files changed, 714 insertions, 377 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")