diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-04 00:09:35 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-12-04 00:09:35 +0300 |
commit | e701659ba316541833e50d68f14720d17be58f8c (patch) | |
tree | 9e123fa2a749deaaf0a97612b05156576f55ff9f /app | |
parent | c2a6cc86754adb3c5e064cebc58d206a52cb412e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
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") |