diff options
37 files changed, 738 insertions, 142 deletions
diff --git a/.gitlab/merge_request_templates/New Static Analysis Check.md b/.gitlab/merge_request_templates/New Static Analysis Check.md index 5fd2d31767a..66041a784e8 100644 --- a/.gitlab/merge_request_templates/New Static Analysis Check.md +++ b/.gitlab/merge_request_templates/New Static Analysis Check.md @@ -1,3 +1,8 @@ +<!-- +When creating a new cop that could be applied to multiple applications, +we encourage you to add it to https://gitlab.com/gitlab-org/gitlab-styles gem. +--> + ## Description of the proposal <!-- diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 22c0679fc86..84ed6bb02a5 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -a3568f3bf5dbe84c238d2666af4ec7cc438faa31 +ad75eb15e8e667620e7cd1386e8361aaef7598d9 diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index 81ad3137b8b..060ed896d7c 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -151,7 +151,7 @@ export const linkTypes = { * chart legend layout. * * Currently defined in - * https://gitlab.com/gitlab-org/gitlab-ui/-/blob/master/src/utils/charts/constants.js + * https://gitlab.com/gitlab-org/gitlab-ui/-/blob/main/src/utils/charts/constants.js * */ export const legendLayoutTypes = { diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 6b7381883cc..0fe03b3be6b 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -97,8 +97,13 @@ export default { <div class="table-section section-10 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div> <div class="table-mobile-content gl-md-pr-2 gl-overflow-wrap-break"> - <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" /> + <gl-friendly-wrap + v-if="testCase.file" + :symbols="$options.wrapSymbols" + :text="testCase.file" + /> <gl-button + v-if="testCase.file" v-gl-tooltip size="small" category="tertiary" diff --git a/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue new file mode 100644 index 00000000000..43b36da8b2c --- /dev/null +++ b/app/assets/javascripts/projects/pipelines/charts/components/ci_cd_analytics_charts.vue @@ -0,0 +1,50 @@ +<script> +import { GlSegmentedControl } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue'; + +export default { + components: { + GlSegmentedControl, + CiCdAnalyticsAreaChart, + }, + props: { + charts: { + required: true, + type: Array, + }, + chartOptions: { + required: true, + type: Object, + }, + }, + data() { + return { + selectedChart: 0, + }; + }, + computed: { + chartRanges() { + return this.charts.map(({ title }, index) => ({ text: title, value: index })); + }, + chart() { + return this.charts[this.selectedChart]; + }, + dateRange() { + return sprintf(s__('CiCdAnalytics|Date range: %{range}'), { range: this.chart.range }); + }, + }, +}; +</script> +<template> + <div> + <gl-segmented-control v-model="selectedChart" :options="chartRanges" class="gl-mb-4" /> + <ci-cd-analytics-area-chart + v-if="chart" + :chart-data="chart.data" + :area-chart-options="chartOptions" + > + {{ dateRange }} + </ci-cd-analytics-area-chart> + </div> +</template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue index 86433e1b59f..e9ff30008d0 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/pipeline_charts.vue @@ -13,6 +13,7 @@ import { INNER_CHART_HEIGHT, ONE_WEEK_AGO_DAYS, ONE_MONTH_AGO_DAYS, + ONE_YEAR_AGO_DAYS, X_AXIS_LABEL_ROTATION, X_AXIS_TITLE_OFFSET, PARSE_FAILURE, @@ -21,7 +22,7 @@ import { UNSUPPORTED_DATA, } from '../constants'; import StatisticsList from './statistics_list.vue'; -import CiCdAnalyticsAreaChart from './ci_cd_analytics_area_chart.vue'; +import CiCdAnalyticsCharts from './ci_cd_analytics_charts.vue'; const defaultAnalyticsValues = { weekPipelinesTotals: [], @@ -52,7 +53,7 @@ export default { GlColumnChart, GlSkeletonLoader, StatisticsList, - CiCdAnalyticsAreaChart, + CiCdAnalyticsCharts, }, inject: { projectPath: { @@ -173,10 +174,11 @@ export default { }, areaCharts() { const { lastWeek, lastMonth, lastYear } = this.$options.chartTitles; + const { lastWeekRange, lastMonthRange, lastYearRange } = this.$options.chartRanges; const charts = [ - { title: lastWeek, data: this.lastWeekChartData }, - { title: lastMonth, data: this.lastMonthChartData }, - { title: lastYear, data: this.lastYearChartData }, + { title: lastWeek, range: lastWeekRange, data: this.lastWeekChartData }, + { title: lastMonth, range: lastMonthRange, data: this.lastMonthChartData }, + { title: lastYear, range: lastYearRange, data: this.lastYearChartData }, ]; let areaChartsData = []; @@ -209,11 +211,12 @@ export default { mergeLabelsAndValues(labels, values) { return labels.map((label, index) => [label, values[index]]); }, - buildAreaChartData({ title, data }) { + buildAreaChartData({ title, data, range }) { const { labels, totals, success } = data; return { title, + range, data: [ { name: 'all', @@ -257,20 +260,28 @@ export default { [PARSE_FAILURE]: s__('PipelineCharts|There was an error parsing the data for the charts.'), [DEFAULT]: s__('PipelineCharts|An unknown error occurred while processing CI/CD analytics.'), }, - get chartTitles() { + chartTitles: { + lastWeek: __('Last week'), + lastMonth: __('Last month'), + lastYear: __('Last year'), + }, + get chartRanges() { const today = dateFormat(new Date(), CHART_DATE_FORMAT); const pastDate = (timeScale) => dateFormat(getDateInPast(new Date(), timeScale), CHART_DATE_FORMAT); return { - lastWeek: sprintf(__('Pipelines for last week (%{oneWeekAgo} - %{today})'), { + lastWeekRange: sprintf(__('%{oneWeekAgo} - %{today}'), { oneWeekAgo: pastDate(ONE_WEEK_AGO_DAYS), today, }), - lastMonth: sprintf(__('Pipelines for last month (%{oneMonthAgo} - %{today})'), { + lastMonthRange: sprintf(__('%{oneMonthAgo} - %{today}'), { oneMonthAgo: pastDate(ONE_MONTH_AGO_DAYS), today, }), - lastYear: __('Pipelines for last year'), + lastYearRange: sprintf(__('%{oneYearAgo} - %{today}'), { + oneYearAgo: pastDate(ONE_YEAR_AGO_DAYS), + today, + }), }; }, }; @@ -304,13 +315,7 @@ export default { <template v-if="!loading"> <hr /> <h4 class="gl-my-4">{{ __('Pipelines charts') }}</h4> - <ci-cd-analytics-area-chart - v-for="(chart, index) in areaCharts" - :key="index" - :chart-data="chart.data" - :area-chart-options="$options.areaChartOptions" - >{{ chart.title }}</ci-cd-analytics-area-chart - > + <ci-cd-analytics-charts :charts="areaCharts" :chart-options="$options.areaChartOptions" /> </template> </div> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/constants.js b/app/assets/javascripts/projects/pipelines/charts/constants.js index 079e23943c1..41fe81f21ea 100644 --- a/app/assets/javascripts/projects/pipelines/charts/constants.js +++ b/app/assets/javascripts/projects/pipelines/charts/constants.js @@ -10,6 +10,8 @@ export const ONE_WEEK_AGO_DAYS = 7; export const ONE_MONTH_AGO_DAYS = 31; +export const ONE_YEAR_AGO_DAYS = 365; + export const CHART_DATE_FORMAT = 'dd mmm'; export const DEFAULT = 'default'; diff --git a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue index 96f221bf71d..0432cf1123c 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/delete_modal.vue @@ -1,7 +1,12 @@ <script> import { GlModal, GlSprintf } from '@gitlab/ui'; import { n__ } from '~/locale'; -import { REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT } from '../../constants/index'; +import { + REMOVE_TAG_CONFIRMATION_TEXT, + REMOVE_TAGS_CONFIRMATION_TEXT, + DELETE_IMAGE_CONFIRMATION_TITLE, + DELETE_IMAGE_CONFIRMATION_TEXT, +} from '../../constants'; export default { components: { @@ -14,9 +19,17 @@ export default { required: false, default: () => [], }, + deleteImage: { + type: Boolean, + default: false, + required: false, + }, }, computed: { - modalAction() { + modalTitle() { + if (this.deleteImage) { + return DELETE_IMAGE_CONFIRMATION_TITLE; + } return n__( 'ContainerRegistry|Remove tag', 'ContainerRegistry|Remove tags', @@ -24,14 +37,19 @@ export default { ); }, modalDescription() { + if (this.deleteImage) { + return { + message: DELETE_IMAGE_CONFIRMATION_TEXT, + }; + } if (this.itemsToBeDeleted.length > 1) { return { message: REMOVE_TAGS_CONFIRMATION_TEXT, item: this.itemsToBeDeleted.length, }; } - const [first] = this.itemsToBeDeleted; + const [first] = this.itemsToBeDeleted; return { message: REMOVE_TAG_CONFIRMATION_TEXT, item: first?.path, @@ -51,16 +69,17 @@ export default { ref="deleteModal" modal-id="delete-tag-modal" ok-variant="danger" - @ok="$emit('confirmDelete')" + :action-primary="{ text: __('Confirm'), attributes: { variant: 'danger' } }" + :action-cancel="{ text: __('Cancel') }" + @primary="$emit('confirmDelete')" @cancel="$emit('cancelDelete')" > - <template #modal-title>{{ modalAction }}</template> - <template #modal-ok>{{ modalAction }}</template> + <template #modal-title>{{ modalTitle }}</template> <p v-if="modalDescription" data-testid="description"> <gl-sprintf :message="modalDescription.message"> - <template #item - ><b>{{ modalDescription.item }}</b></template - > + <template #item> + <b>{{ modalDescription.item }}</b> + </template> </gl-sprintf> </p> </gl-modal> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue index ed02aa264ed..5f193527a60 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/details_header.vue @@ -1,5 +1,5 @@ <script> -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlButton } from '@gitlab/ui'; import { sprintf, n__ } from '~/locale'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; @@ -24,7 +24,7 @@ import { export default { name: 'DetailsHeader', - components: { GlSprintf, TitleArea, MetadataItem }, + components: { GlSprintf, GlButton, TitleArea, MetadataItem }, mixins: [timeagoMixin], props: { image: { @@ -36,6 +36,11 @@ export default { required: false, default: false, }, + disabled: { + type: Boolean, + default: false, + required: false, + }, }, computed: { visibilityIcon() { @@ -65,6 +70,9 @@ export default { [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP }, }[this.image?.expirationPolicyCleanupStatus]; }, + deleteButtonDisabled() { + return this.disabled || !this.image.canDelete; + }, }, i18n: { DETAILS_PAGE_TITLE, @@ -75,11 +83,13 @@ export default { <template> <title-area :metadata-loading="metadataLoading"> <template #title> - <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> - <template #imageName> - {{ image.name }} - </template> - </gl-sprintf> + <span data-testid="title"> + <gl-sprintf :message="$options.i18n.DETAILS_PAGE_TITLE"> + <template #imageName> + {{ image.name }} + </template> + </gl-sprintf> + </span> </template> <template #metadata-tags-count> <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" /> @@ -103,5 +113,15 @@ export default { data-testid="updated-and-visibility" /> </template> + <template #right-actions> + <gl-button + v-if="!metadataLoading" + variant="danger" + :disabled="deleteButtonDisabled" + @click="$emit('delete')" + > + {{ __('Delete') }} + </gl-button> + </template> </title-area> </template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue b/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue new file mode 100644 index 00000000000..fc1504f6c31 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/details_page/status_alert.vue @@ -0,0 +1,50 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { + IMAGE_STATUS_MESSAGES, + IMAGE_STATUS_TITLES, + IMAGE_STATUS_ALERT_TYPE, + PACKAGE_DELETE_HELP_PAGE_PATH, +} from '../../constants'; + +export default { + components: { + GlAlert, + GlSprintf, + GlLink, + }, + props: { + status: { + type: String, + required: true, + }, + }, + computed: { + message() { + return IMAGE_STATUS_MESSAGES[this.status]; + }, + title() { + return IMAGE_STATUS_TITLES[this.status]; + }, + variant() { + return IMAGE_STATUS_ALERT_TYPE[this.status]; + }, + }, + links: { + PACKAGE_DELETE_HELP_PAGE_PATH, + }, +}; +</script> +<template> + <gl-alert :title="title" :variant="variant"> + <span data-testid="message"> + <gl-sprintf :message="message"> + <template #link="{ content }"> + <gl-link :href="$options.links.PACKAGE_DELETE_HELP_PAGE_PATH" target="_blank">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </span> + </gl-alert> +</template> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue index 9a4ae41d275..bc10246614a 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list.vue @@ -20,6 +20,11 @@ export default { default: true, required: false, }, + disabled: { + type: Boolean, + default: false, + required: false, + }, }, i18n: { REMOVE_TAGS_BUTTON_TITLE, @@ -37,6 +42,9 @@ export default { showMultiDeleteButton() { return this.tags.some((tag) => tag.canDelete) && !this.isMobile; }, + multiDeleteButtonIsDisabled() { + return !this.hasSelectedItems || this.disabled; + }, }, methods: { updateSelectedItems(name) { @@ -55,7 +63,7 @@ export default { <gl-button v-if="showMultiDeleteButton" - :disabled="!hasSelectedItems" + :disabled="multiDeleteButtonIsDisabled" category="secondary" variant="danger" @click="$emit('delete', selectedItems)" @@ -70,6 +78,7 @@ export default { :first="index === 0" :selected="selectedItems[tag.name]" :is-mobile="isMobile" + :disabled="disabled" @select="updateSelectedItems(tag.name)" @delete="$emit('delete', { [tag.name]: true })" /> diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 0cf83d9c62e..4d992a395af 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -12,6 +12,8 @@ import DetailsHeader from '../components/details_page/details_header.vue'; import TagsList from '../components/details_page/tags_list.vue'; import TagsLoader from '../components/details_page/tags_loader.vue'; import EmptyState from '../components/details_page/empty_state.vue'; +import StatusAlert from '../components/details_page/status_alert.vue'; +import DeleteImage from '../components/delete_image.vue'; import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql'; import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql'; @@ -21,6 +23,7 @@ import { ALERT_DANGER_TAG, ALERT_SUCCESS_TAGS, ALERT_DANGER_TAGS, + ALERT_DANGER_IMAGE, GRAPHQL_PAGE_SIZE, FETCH_IMAGES_LIST_ERROR_MESSAGE, UNFINISHED_STATUS, @@ -38,6 +41,8 @@ export default { TagsList, TagsLoader, EmptyState, + StatusAlert, + DeleteImage, }, directives: { GlResizeObserver: GlResizeObserverDirective, @@ -71,6 +76,7 @@ export default { mutationLoading: false, deleteAlertType: null, hidePartialCleanupWarning: false, + deleteImageAlert: false, }; }, computed: { @@ -105,6 +111,9 @@ export default { hasNoTags() { return this.tags.length === 0; }, + pageActionsAreDisabled() { + return Boolean(this.image?.status); + }, }, methods: { updateBreadcrumb() { @@ -112,11 +121,19 @@ export default { this.breadCrumbState.updateName(name); }, deleteTags(toBeDeleted) { + this.deleteImageAlert = false; this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]); this.track('click_button'); this.$refs.deleteModal.show(); }, - async handleDelete() { + confirmDelete() { + if (this.deleteImageAlert) { + this.$refs.deleteImage.doDelete(); + } else { + this.handleDeleteTag(); + } + }, + async handleDeleteTag() { this.track('confirm_delete'); const { itemsToBeDeleted } = this; this.itemsToBeDeleted = []; @@ -184,6 +201,18 @@ export default { feature_name: this.config.userCalloutId, }); }, + deleteImage() { + this.deleteImageAlert = true; + this.itemsToBeDeleted = [{ path: this.image.path }]; + this.$refs.deleteModal.show(); + }, + deleteImageError() { + this.deleteAlertType = ALERT_DANGER_IMAGE; + }, + deleteImageIniit() { + this.itemsToBeDeleted = []; + this.mutationLoading = true; + }, }, }; </script> @@ -205,13 +234,25 @@ export default { @dismiss="dismissPartialCleanupWarning" /> - <details-header :image="image" :metadata-loading="isLoading" /> + <status-alert v-if="image.status" :status="image.status" /> + + <details-header + :image="image" + :metadata-loading="isLoading" + :disabled="pageActionsAreDisabled" + @delete="deleteImage" + /> <tags-loader v-if="isLoading" /> <template v-else> <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" /> <template v-else> - <tags-list :tags="tags" :is-mobile="isMobile" @delete="deleteTags" /> + <tags-list + :tags="tags" + :is-mobile="isMobile" + :disabled="pageActionsAreDisabled" + @delete="deleteTags" + /> <div class="gl-display-flex gl-justify-content-center"> <gl-keyset-pagination v-if="showPagination" @@ -225,10 +266,20 @@ export default { </template> </template> + <delete-image + :id="image.id" + ref="deleteImage" + use-update-fn + @start="deleteImageIniit" + @error="deleteImageError" + @end="mutationLoading = false" + /> + <delete-modal ref="deleteModal" :items-to-be-deleted="itemsToBeDeleted" - @confirmDelete="handleDelete" + :delete-image="deleteImageAlert" + @confirmDelete="confirmDelete" @cancel="track('cancel_delete')" /> </template> diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 45402964d11..ff8c790f43d 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -130,7 +130,7 @@ class Projects::IssuesController < Projects::ApplicationController service = ::Issues::CreateService.new(project, current_user, create_params) @issue = service.execute - create_vulnerability_issue_link(issue) + create_vulnerability_issue_feedback(issue) if service.discussions_to_resolve.count(&:resolved?) > 0 flash[:notice] = if service.discussion_to_resolve_id @@ -402,7 +402,7 @@ class Projects::IssuesController < Projects::ApplicationController end # Overridden in EE - def create_vulnerability_issue_link(issue); end + def create_vulnerability_issue_feedback(issue); end end Projects::IssuesController.prepend_if_ee('EE::Projects::IssuesController') diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 83acf0c12d7..f97d94c14f0 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -82,7 +82,7 @@ class ProjectPolicy < BasePolicy with_scope :subject condition(:metrics_dashboard_allowed) do - feature_available?(:metrics_dashboard) + access_allowed_to?(:metrics_dashboard) end with_scope :global @@ -161,7 +161,7 @@ class ProjectPolicy < BasePolicy features.each do |f| # these are scored high because they are unlikely desc "Project has #{f} disabled" - condition(:"#{f}_disabled", score: 32) { !feature_available?(f.to_sym) } + condition(:"#{f}_disabled", score: 32) { !access_allowed_to?(f.to_sym) } end # `:read_project` may be prevented in EE, but `:read_project_for_iids` should @@ -696,7 +696,7 @@ class ProjectPolicy < BasePolicy project.team.max_member_access(@user.id) end - def feature_available?(feature) + def access_allowed_to?(feature) return false unless project.project_feature case project.project_feature.access_level(feature) diff --git a/changelogs/unreleased/216761-add-image-repository-level-delete-functionality-to-the-image-repos.yml b/changelogs/unreleased/216761-add-image-repository-level-delete-functionality-to-the-image-repos.yml new file mode 100644 index 00000000000..950a1b061be --- /dev/null +++ b/changelogs/unreleased/216761-add-image-repository-level-delete-functionality-to-the-image-repos.yml @@ -0,0 +1,5 @@ +--- +title: Add delete functionality to the Image Repository detail view +merge_request: 51980 +author: +type: added diff --git a/changelogs/unreleased/301140-conditionally-render-test-case-file.yml b/changelogs/unreleased/301140-conditionally-render-test-case-file.yml new file mode 100644 index 00000000000..2c5efb40db6 --- /dev/null +++ b/changelogs/unreleased/301140-conditionally-render-test-case-file.yml @@ -0,0 +1,5 @@ +--- +title: Conditionally render test case file +merge_request: 53497 +author: +type: fixed diff --git a/changelogs/unreleased/use-segmented-controls-ci-cd-analytics.yml b/changelogs/unreleased/use-segmented-controls-ci-cd-analytics.yml new file mode 100644 index 00000000000..61a9a0fc429 --- /dev/null +++ b/changelogs/unreleased/use-segmented-controls-ci-cd-analytics.yml @@ -0,0 +1,5 @@ +--- +title: Only Display One Chart at a Time +merge_request: 52952 +author: +type: changed diff --git a/doc/administration/audit_events.md b/doc/administration/audit_events.md index 6512879e8b8..97222f63e2c 100644 --- a/doc/administration/audit_events.md +++ b/doc/administration/audit_events.md @@ -9,7 +9,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w GitLab offers a way to view the changes made within the GitLab server for owners and administrators on a [paid plan](https://about.gitlab.com/pricing/). GitLab system administrators can also take advantage of the logs located on the -file system. See [the logs system documentation](logs.md) for more details. +file system. See [the logs system documentation](logs.md#audit_jsonlog) for more details. You can generate an [Audit report](audit_reports.md) of audit events. diff --git a/doc/administration/logs.md b/doc/administration/logs.md index 36172869493..3ea8efad51a 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -391,7 +391,8 @@ This file lives in `/var/log/gitlab/gitlab-rails/audit_json.log` for Omnibus GitLab packages or in `/home/git/gitlab/log/audit_json.log` for installations from source. -Changes to group or project settings are logged to this file. For example: +Changes to group or project settings and memberships (`target_details`) are logged to this file. +For example: ```json { diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index 2b7a7300ad8..49adaa7eabf 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -216,7 +216,7 @@ control over how the Pages daemon runs and serves content in your environment. | Setting | Description | | ------- | ----------- | -| `pages_external_url` | The URL where GitLab Pages is accessible, including protocol (HTTP / HTTPS). If `https://` is used, you must also set `gitlab_pages['ssl_certificate']` and `gitlab_pages['ssl_certificate_key']`. +| `pages_external_url` | The URL where GitLab Pages is accessible, including protocol (HTTP / HTTPS). If `https://` is used, additional configuration is required. See [Wildcard domains with TLS support](#wildcard-domains-with-tls-support) and [Custom domains with TLS support](#custom-domains-with-tls-support) for details. | `gitlab_pages[]` | | | `access_control` | Whether to enable [access control](index.md#access-control). | `api_secret_key` | Full path to file with secret key used to authenticate with the GitLab API. Auto-generated when left unset. diff --git a/doc/api/users.md b/doc/api/users.md index 70a7e2a815f..a7714b72b59 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -217,7 +217,9 @@ For example: GET /users?extern_uid=1234567&provider=github ``` -You can search for users who are external with: `/users?external=true` +Instance administrators can search for users who are external with: `/users?external=true` + +You cannot search for external users if you are not an instance administrator. You can search users by creation date time range with: diff --git a/doc/development/fe_guide/style/scss.md b/doc/development/fe_guide/style/scss.md index a4cae12c4f3..7df16bc70d7 100644 --- a/doc/development/fe_guide/style/scss.md +++ b/doc/development/fe_guide/style/scss.md @@ -20,15 +20,17 @@ In order to reduce the generation of more CSS as our site grows, prefer the use #### Where are utility classes defined? -Prefer the use of [utility classes defined in GitLab UI](https://gitlab.com/gitlab-org/gitlab-ui/-/blob/master/doc/css.md#utilities). An easy list of classes can also be [seen on Unpkg](https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss). +Prefer the use of [utility classes defined in GitLab UI](https://gitlab.com/gitlab-org/gitlab-ui/-/blob/main/doc/css.md#utilities). +An easy list of classes can also be [seen on Unpkg](https://unpkg.com/browse/@gitlab/ui/src/scss/utilities.scss). -Classes in [`utilities.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/utilities.scss) and [`common.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/framework/common.scss) are being deprecated. Classes in [`common.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/framework/common.scss) that use non-design system values should be avoided in favor of conformant values. +Classes in [`utilities.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/utilities.scss) and [`common.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/framework/common.scss) are being deprecated. +Classes in [`common.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/framework/common.scss) that use non-design system values should be avoided in favor of conformant values. Avoid [Bootstrap's Utility Classes](https://getbootstrap.com/docs/4.3/utilities/). NOTE: While migrating [Bootstrap's Utility Classes](https://getbootstrap.com/docs/4.3/utilities/) -to the [GitLab UI](https://gitlab.com/gitlab-org/gitlab-ui/-/blob/master/doc/css.md#utilities) +to the [GitLab UI](https://gitlab.com/gitlab-org/gitlab-ui/-/blob/main/doc/css.md#utilities) utility classes, note both the classes for margin and padding differ. The size scale used at GitLab differs from the scale used in the Bootstrap library. For a Bootstrap padding or margin utility, you may need to double the size of the applied utility to achieve the same visual @@ -36,9 +38,9 @@ result (such as `ml-1` becoming `gl-ml-2`). #### Where should I put new utility classes? -If a class you need has not been added to GitLab UI, you get to add it! Follow the naming patterns documented in the [utility files](https://gitlab.com/gitlab-org/gitlab-ui/-/tree/master/src/scss/utility-mixins) and refer to [GitLab UI's CSS documentation](https://gitlab.com/gitlab-org/gitlab-ui/-/blob/master/doc/contributing/adding_css.md#adding-utility-mixins) for more details, especially about adding responsive and stateful rules. +If a class you need has not been added to GitLab UI, you get to add it! Follow the naming patterns documented in the [utility files](https://gitlab.com/gitlab-org/gitlab-ui/-/tree/main/src/scss/utility-mixins) and refer to [GitLab UI's CSS documentation](https://gitlab.com/gitlab-org/gitlab-ui/-/blob/main/doc/contributing/adding_css.md#adding-utility-mixins) for more details, especially about adding responsive and stateful rules. -If it is not possible to wait for a GitLab UI update (generally one day), add the class to [`utilities.scss`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/assets/stylesheets/utilities.scss) following the same naming conventions documented in GitLab UI. A follow—up issue to backport the class to GitLab UI and delete it from GitLab should be opened. +If it is not possible to wait for a GitLab UI update (generally one day), add the class to [`utilities.scss`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/assets/stylesheets/utilities.scss) following the same naming conventions documented in GitLab UI. A follow—up issue to backport the class to GitLab UI and delete it from GitLab should be opened. #### When should I create component classes? diff --git a/doc/user/analytics/code_review_analytics.md b/doc/user/analytics/code_review_analytics.md index 7f052aec9cd..8d0d6eb1628 100644 --- a/doc/user/analytics/code_review_analytics.md +++ b/doc/user/analytics/code_review_analytics.md @@ -8,7 +8,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Code Review Analytics **(PREMIUM)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/38062) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.7. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/38062) in [GitLab Starter](https://about.gitlab.com/pricing/) 12.7. +> - Moved to [GitLab Premium](https://about.gitlab.com/pricing/) due to Starter/Bronze being [discontinued](https://about.gitlab.com/blog/2021/01/26/new-gitlab-product-subscription-model/) in 13.9. Code Review Analytics makes it easy to view the longest-running reviews among open merge requests and enables you to: diff --git a/doc/user/analytics/index.md b/doc/user/analytics/index.md index a186009cc15..f5da373ee6d 100644 --- a/doc/user/analytics/index.md +++ b/doc/user/analytics/index.md @@ -40,11 +40,12 @@ in one place. ## Group-level analytics -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/195979) in GitLab 12.8. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/195979) in GitLab 12.8. +> - Moved to [GitLab Premium](https://about.gitlab.com/pricing/) due to Starter/Bronze being [discontinued](https://about.gitlab.com/blog/2021/01/26/new-gitlab-product-subscription-model/) in 13.9. The following analytics features are available at the group level: -- [Contribution](../group/contribution_analytics/index.md). **(STARTER)** +- [Contribution](../group/contribution_analytics/index.md). **(PREMIUM)** - [Insights](../group/insights/index.md). **(ULTIMATE)** - [Issue](../group/issues_analytics/index.md). **(PREMIUM)** - [Productivity](productivity_analytics.md) **(PREMIUM)** @@ -56,10 +57,10 @@ The following analytics features are available at the group level: The following analytics features are available at the project level: - [CI/CD](ci_cd_analytics.md). **(FREE)** -- [Code Review](code_review_analytics.md). **(STARTER)** +- [Code Review](code_review_analytics.md). **(PREMIUM)** - [Insights](../project/insights/index.md). **(ULTIMATE)** - [Issue](../group/issues_analytics/index.md). **(PREMIUM)** - [Merge Request](merge_request_analytics.md), enabled with the `project_merge_request_analytics` - [feature flag](../../development/feature_flags/development.md#enabling-a-feature-flag-locally-in-development). **(STARTER)** + [feature flag](../../development/feature_flags/development.md#enabling-a-feature-flag-locally-in-development). **(PREMIUM)** - [Repository](repository_analytics.md). **(FREE)** - [Value Stream](value_stream_analytics.md). **(FREE)** diff --git a/doc/user/analytics/merge_request_analytics.md b/doc/user/analytics/merge_request_analytics.md index 6dd50a3be80..3edbe3e8aa4 100644 --- a/doc/user/analytics/merge_request_analytics.md +++ b/doc/user/analytics/merge_request_analytics.md @@ -7,7 +7,8 @@ info: To determine the technical writer assigned to the Stage/Group associated w # Merge Request Analytics **(PREMIUM)** -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/229045) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.3. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/229045) in [GitLab Starter](https://about.gitlab.com/pricing/) 13.3. +> - Moved to [GitLab Premium](https://about.gitlab.com/pricing/) due to Starter/Bronze being [discontinued](https://about.gitlab.com/blog/2021/01/26/new-gitlab-product-subscription-model/) in 13.9. Merge Request Analytics helps you understand the efficiency of your code review process, and the productivity of your team. @@ -102,5 +103,5 @@ bookmark for those preferred settings in your browser. The **Merge Request Analytics** feature can be accessed only: -- On [Starter](https://about.gitlab.com/pricing/) and above. +- On [Premium](https://about.gitlab.com/pricing/) and above. - By users with [Reporter access](../permissions.md) and above. diff --git a/doc/user/packages/dependency_proxy/index.md b/doc/user/packages/dependency_proxy/index.md index fd75df513c7..878354ab264 100644 --- a/doc/user/packages/dependency_proxy/index.md +++ b/doc/user/packages/dependency_proxy/index.md @@ -221,3 +221,19 @@ RateLimit-Remaining: 98;w=21600 ``` This example shows the total limit of 100 pulls in six hours, with 98 pulls remaining. + +#### Check the rate limit in a CI/CD job + +This example shows a GitLab CI/CD job that uses an image with `jq` and `curl` installed: + +```yaml +hub_docker_quota_check: + stage: build + image: alpine:latest + tags: + - <optional_runner_tag> + before_script: apk add curl jq + script: + - | + TOKEN=$(curl "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq --raw-output .token) && curl --head --header "Authorization: Bearer $TOKEN" "https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest" 2>&1 +``` diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d7f6f5ace0c..90bbdea3cda 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -682,6 +682,15 @@ msgstr[1] "" msgid "%{number_commits_behind} commits behind %{default_branch}, %{number_commits_ahead} commits ahead" msgstr "" +msgid "%{oneMonthAgo} - %{today}" +msgstr "" + +msgid "%{oneWeekAgo} - %{today}" +msgstr "" + +msgid "%{oneYearAgo} - %{today}" +msgstr "" + msgid "%{openOrClose} %{noteable}" msgstr "" @@ -775,6 +784,9 @@ msgstr "" msgid "%{spanStart}in%{spanEnd} %{errorFn}" msgstr "" +msgid "%{startDate} - %{endDate}" +msgstr "" + msgid "%{start} to %{end}" msgstr "" @@ -5700,6 +5712,9 @@ msgstr "" msgid "Choose your framework" msgstr "" +msgid "CiCdAnalytics|Date range: %{range}" +msgstr "" + msgid "CiStatusLabel|canceled" msgstr "" @@ -9822,6 +9837,9 @@ msgstr "" msgid "Deployment Frequency" msgstr "" +msgid "DeploymentFrequencyCharts|%{startDate} - %{endDate}" +msgstr "" + msgid "DeploymentFrequencyCharts|Date" msgstr "" @@ -9831,15 +9849,6 @@ msgstr "" msgid "DeploymentFrequencyCharts|Deployments charts" msgstr "" -msgid "DeploymentFrequencyCharts|Deployments to production for last month (%{startDate} - %{endDate})" -msgstr "" - -msgid "DeploymentFrequencyCharts|Deployments to production for last week (%{startDate} - %{endDate})" -msgstr "" - -msgid "DeploymentFrequencyCharts|Deployments to production for the last 90 days (%{startDate} - %{endDate})" -msgstr "" - msgid "DeploymentFrequencyCharts|Something went wrong while getting deployment frequency data" msgstr "" @@ -17073,6 +17082,9 @@ msgstr "" msgid "Last item before this page loaded in your browser:" msgstr "" +msgid "Last month" +msgstr "" + msgid "Last name" msgstr "" @@ -17121,6 +17133,9 @@ msgstr "" msgid "Last week" msgstr "" +msgid "Last year" +msgstr "" + msgid "LastCommit|authored" msgstr "" @@ -21446,15 +21461,6 @@ msgstr "" msgid "Pipelines emails" msgstr "" -msgid "Pipelines for last month (%{oneMonthAgo} - %{today})" -msgstr "" - -msgid "Pipelines for last week (%{oneWeekAgo} - %{today})" -msgstr "" - -msgid "Pipelines for last year" -msgstr "" - msgid "Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more in the documentation for Pipelines for Merged Results." msgstr "" diff --git a/qa/qa/page/project/commit/show.rb b/qa/qa/page/project/commit/show.rb index ba09dd1b92a..8ece81f7088 100644 --- a/qa/qa/page/project/commit/show.rb +++ b/qa/qa/page/project/commit/show.rb @@ -14,12 +14,12 @@ module QA def select_email_patches click_element :options_button - click_element :email_patches + visit_link_in_element :email_patches end def select_plain_diff click_element :options_button - click_element :plain_diff + visit_link_in_element :plain_diff end def commit_sha diff --git a/spec/features/projects/graph_spec.rb b/spec/features/projects/graph_spec.rb index 7b9f79c9f7f..72df84bf905 100644 --- a/spec/features/projects/graph_spec.rb +++ b/spec/features/projects/graph_spec.rb @@ -72,9 +72,9 @@ RSpec.describe 'Project Graph', :js do it 'renders CI graphs' do expect(page).to have_content 'Overall' - expect(page).to have_content 'Pipelines for last week' - expect(page).to have_content 'Pipelines for last month' - expect(page).to have_content 'Pipelines for last year' + expect(page).to have_content 'Last week' + expect(page).to have_content 'Last month' + expect(page).to have_content 'Last year' expect(page).to have_content 'Duration for the last 30 commits' end end diff --git a/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js new file mode 100644 index 00000000000..773d7526f12 --- /dev/null +++ b/spec/frontend/projects/pipelines/charts/components/ci_cd_analytics_charts_spec.js @@ -0,0 +1,94 @@ +import { nextTick } from 'vue'; +import { shallowMount } from '@vue/test-utils'; +import { GlSegmentedControl } from '@gitlab/ui'; +import CiCdAnalyticsCharts from '~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue'; +import CiCdAnalyticsAreaChart from '~/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue'; +import { transformedAreaChartData, chartOptions } from '../mock_data'; + +const DEFAULT_PROPS = { + chartOptions, + charts: [ + { + range: 'test range 1', + title: 'title 1', + data: transformedAreaChartData, + }, + { + range: 'test range 2', + title: 'title 2', + data: transformedAreaChartData, + }, + { + range: 'test range 3', + title: 'title 3', + data: transformedAreaChartData, + }, + ], +}; + +describe('~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue', () => { + let wrapper; + + const createWrapper = (props = {}) => + shallowMount(CiCdAnalyticsCharts, { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + describe('segmented control', () => { + let segmentedControl; + + beforeEach(() => { + wrapper = createWrapper(); + segmentedControl = wrapper.find(GlSegmentedControl); + }); + + it('should default to the first chart', () => { + expect(segmentedControl.props('checked')).toBe(0); + }); + + it('should use the title and index as values', () => { + const options = segmentedControl.props('options'); + expect(options).toHaveLength(3); + expect(options).toEqual([ + { + text: 'title 1', + value: 0, + }, + { + text: 'title 2', + value: 1, + }, + { + text: 'title 3', + value: 2, + }, + ]); + }); + + it('should select a different chart on change', async () => { + segmentedControl.vm.$emit('input', 1); + + const chart = wrapper.find(CiCdAnalyticsAreaChart); + + await nextTick(); + + expect(chart.props('chartData')).toEqual(transformedAreaChartData); + expect(chart.text()).toBe('Date range: test range 2'); + }); + }); + + it('should not display charts if there are no charts', () => { + wrapper = createWrapper({ charts: [] }); + expect(wrapper.find(CiCdAnalyticsAreaChart).exists()).toBe(false); + }); +}); diff --git a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js index 3e588d46a4f..9e051550fae 100644 --- a/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js +++ b/spec/frontend/projects/pipelines/charts/components/pipeline_charts_spec.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; import createMockApollo from 'helpers/mock_apollo_helper'; import StatisticsList from '~/projects/pipelines/charts/components/statistics_list.vue'; -import CiCdAnalyticsAreaChart from '~/projects/pipelines/charts/components/ci_cd_analytics_area_chart.vue'; +import CiCdAnalyticsCharts from '~/projects/pipelines/charts/components/ci_cd_analytics_charts.vue'; import PipelineCharts from '~/projects/pipelines/charts/components/pipeline_charts.vue'; import getPipelineCountByStatus from '~/projects/pipelines/charts/graphql/queries/get_pipeline_count_by_status.query.graphql'; import getProjectPipelineStatistics from '~/projects/pipelines/charts/graphql/queries/get_project_pipeline_statistics.query.graphql'; @@ -65,20 +65,17 @@ describe('~/projects/pipelines/charts/components/pipeline_charts.vue', () => { }); describe('pipelines charts', () => { - it('displays 3 area charts', () => { - expect(wrapper.findAll(CiCdAnalyticsAreaChart)).toHaveLength(3); + it('displays the charts components', () => { + expect(wrapper.find(CiCdAnalyticsCharts).exists()).toBe(true); }); describe('displays individual correctly', () => { it('renders with the correct data', () => { - const charts = wrapper.findAll(CiCdAnalyticsAreaChart); - for (let i = 0; i < charts.length; i += 1) { - const chart = charts.at(i); - - expect(chart.exists()).toBeTruthy(); - expect(chart.props('chartData')).toBe(wrapper.vm.areaCharts[i].data); - expect(chart.text()).toBe(wrapper.vm.areaCharts[i].title); - } + const charts = wrapper.find(CiCdAnalyticsCharts); + expect(charts.props()).toEqual({ + charts: wrapper.vm.areaCharts, + chartOptions: wrapper.vm.$options.areaChartOptions, + }); }); }); }); diff --git a/spec/frontend/projects/pipelines/charts/mock_data.js b/spec/frontend/projects/pipelines/charts/mock_data.js index 3bc09f0b0a0..2e2c594102c 100644 --- a/spec/frontend/projects/pipelines/charts/mock_data.js +++ b/spec/frontend/projects/pipelines/charts/mock_data.js @@ -57,6 +57,16 @@ export const mockPipelineCount = { }, }; +export const chartOptions = { + xAxis: { + name: 'X axis title', + type: 'category', + }, + yAxis: { + name: 'Y axis title', + }, +}; + export const mockPipelineStatistics = { data: { project: { diff --git a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js index 636e0a285a6..85d7e87b8eb 100644 --- a/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/delete_modal_spec.js @@ -4,6 +4,8 @@ import component from '~/registry/explorer/components/details_page/delete_modal. import { REMOVE_TAG_CONFIRMATION_TEXT, REMOVE_TAGS_CONFIRMATION_TEXT, + DELETE_IMAGE_CONFIRMATION_TITLE, + DELETE_IMAGE_CONFIRMATION_TEXT, } from '~/registry/explorer/constants'; import { GlModal } from '../../stubs'; @@ -35,13 +37,13 @@ describe('Delete Modal', () => { describe('events', () => { it.each` - glEvent | localEvent - ${'ok'} | ${'confirmDelete'} - ${'cancel'} | ${'cancelDelete'} + glEvent | localEvent + ${'primary'} | ${'confirmDelete'} + ${'cancel'} | ${'cancelDelete'} `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => { mountComponent(); findModal().vm.$emit(glEvent); - expect(wrapper.emitted(localEvent)).toBeTruthy(); + expect(wrapper.emitted(localEvent)).toEqual([[]]); }); }); @@ -53,27 +55,51 @@ describe('Delete Modal', () => { }); }); - describe('itemsToBeDeleted contains one element', () => { - beforeEach(() => { - mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); - }); - it(`has the correct description`, () => { - expect(findDescription().text()).toBe(REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo')); + describe('when we are deleting images', () => { + it('has the correct title', () => { + mountComponent({ deleteImage: true }); + + expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE); }); - it('has the correct action', () => { - expect(wrapper.text()).toContain('Remove tag'); + + it('has the correct description', () => { + mountComponent({ deleteImage: true }); + + expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TEXT); }); }); - describe('itemsToBeDeleted contains more than element', () => { - beforeEach(() => { - mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] }); - }); - it(`has the correct description`, () => { - expect(findDescription().text()).toBe(REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2')); + describe('when we are deleting tags', () => { + describe('itemsToBeDeleted contains one element', () => { + beforeEach(() => { + mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] }); + }); + + it(`has the correct description`, () => { + expect(findDescription().text()).toBe( + REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'), + ); + }); + + it('has the correct title', () => { + expect(wrapper.text()).toContain('Remove tag'); + }); }); - it('has the correct action', () => { - expect(wrapper.text()).toContain('Remove tags'); + + describe('itemsToBeDeleted contains more than element', () => { + beforeEach(() => { + mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] }); + }); + + it(`has the correct description`, () => { + expect(findDescription().text()).toBe( + REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'), + ); + }); + + it('has the correct title', () => { + expect(wrapper.text()).toContain('Remove tags'); + }); }); }); }); diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index 337235e3de5..5885537542c 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlButton } from '@gitlab/ui'; import { useFakeDate } from 'helpers/fake_date'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import component from '~/registry/explorer/components/details_page/details_header.vue'; @@ -23,6 +23,7 @@ describe('Details Header', () => { name: 'foo', updatedAt: '2020-11-03T13:29:21Z', tagsCount: 10, + canDelete: true, project: { visibility: 'public', containerExpirationPolicy: { @@ -36,8 +37,10 @@ describe('Details Header', () => { const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility'); + const findTitle = () => findByTestId('title'); const findTagsCount = () => findByTestId('tags-count'); const findCleanup = () => findByTestId('cleanup'); + const findDeleteButton = () => wrapper.find(GlButton); const waitForMetadataItems = async () => { // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available @@ -45,11 +48,9 @@ describe('Details Header', () => { await wrapper.vm.$nextTick(); }; - const mountComponent = (image = defaultImage) => { + const mountComponent = (propsData = { image: defaultImage }) => { wrapper = shallowMount(component, { - propsData: { - image, - }, + propsData, stubs: { GlSprintf, TitleArea, @@ -63,13 +64,65 @@ describe('Details Header', () => { }); it('has the correct title ', () => { - mountComponent({ ...defaultImage, name: '' }); - expect(wrapper.text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); + mountComponent({ image: { ...defaultImage, name: '' } }); + expect(findTitle().text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE); }); it('shows imageName in the title', () => { mountComponent(); - expect(wrapper.text()).toContain('foo'); + expect(findTitle().text()).toContain('foo'); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('is hidden while loading', () => { + mountComponent({ image: defaultImage, metadataLoading: true }); + + expect(findDeleteButton().exists()).toBe(false); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findDeleteButton().text()).toBe('Delete'); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteButton().props()).toMatchObject({ + variant: 'danger', + disabled: false, + }); + }); + + it('emits the correct event', () => { + mountComponent(); + + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + + it.each` + canDelete | disabled | isDisabled + ${true} | ${false} | ${false} + ${true} | ${true} | ${true} + ${false} | ${false} | ${true} + ${false} | ${true} | ${true} + `( + 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled', + ({ canDelete, disabled, isDisabled }) => { + mountComponent({ image: { ...defaultImage, canDelete }, disabled }); + + expect(findDeleteButton().props('disabled')).toBe(isDisabled); + }, + ); }); describe('metadata items', () => { @@ -82,7 +135,7 @@ describe('Details Header', () => { }); it('when there is one tag has the correct text', async () => { - mountComponent({ ...defaultImage, tagsCount: 1 }); + mountComponent({ image: { ...defaultImage, tagsCount: 1 } }); await waitForMetadataItems(); expect(findTagsCount().props('text')).toBe('1 tag'); @@ -124,10 +177,12 @@ describe('Details Header', () => { 'when the status is $status the text is $text and the tooltip is $tooltip', async ({ status, text, tooltip }) => { mountComponent({ - ...defaultImage, - expirationPolicyCleanupStatus: status, - project: { - containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + image: { + ...defaultImage, + expirationPolicyCleanupStatus: status, + project: { + containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' }, + }, }, }); await waitForMetadataItems(); @@ -156,7 +211,7 @@ describe('Details Header', () => { expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye'); }); it('shows an eye slashed when the project is not public', async () => { - mountComponent({ ...defaultImage, project: { visibility: 'private' } }); + mountComponent({ image: { ...defaultImage, project: { visibility: 'private' } } }); await waitForMetadataItems(); expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash'); diff --git a/spec/frontend/registry/explorer/components/details_page/status_alert_spec.js b/spec/frontend/registry/explorer/components/details_page/status_alert_spec.js new file mode 100644 index 00000000000..9bace6d2376 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/status_alert_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/status_alert.vue'; +import { + DELETE_SCHEDULED, + DELETE_FAILED, + PACKAGE_DELETE_HELP_PAGE_PATH, + SCHEDULED_FOR_DELETION_STATUS_TITLE, + SCHEDULED_FOR_DELETION_STATUS_MESSAGE, + FAILED_DELETION_STATUS_TITLE, + FAILED_DELETION_STATUS_MESSAGE, +} from '~/registry/explorer/constants'; + +describe('Status Alert', () => { + let wrapper; + + const findLink = () => wrapper.find(GlLink); + const findAlert = () => wrapper.find(GlAlert); + const findMessage = () => wrapper.find('[data-testid="message"]'); + + const mountComponent = (propsData) => { + wrapper = shallowMount(component, { + propsData, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it.each` + status | title | variant | message | link + ${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH} + ${DELETE_FAILED} | ${FAILED_DELETION_STATUS_TITLE} | ${'warning'} | ${FAILED_DELETION_STATUS_MESSAGE} | ${''} + `( + `when the status is $status, title is $title, variant is $variant, message is $message and the link is $link`, + ({ status, title, variant, message, link }) => { + mountComponent({ status }); + + expect(findMessage().text()).toMatchInterpolatedText(message); + expect(findAlert().props()).toMatchObject({ + title, + variant, + }); + if (link) { + expect(findLink().attributes()).toMatchObject({ + target: '_blank', + href: link, + }); + } + }, + ); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js index 413795a7a57..6284eed39eb 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -70,18 +70,25 @@ describe('Tags List', () => { }); }); - it('is disabled when no item is selected', () => { - mountComponent(); + it.each` + disabled | doSelect | buttonDisabled + ${true} | ${false} | ${'true'} + ${true} | ${true} | ${'true'} + ${false} | ${false} | ${'true'} + ${false} | ${true} | ${undefined} + `( + 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag', + async ({ disabled, buttonDisabled, doSelect }) => { + mountComponent({ tags, disabled, isMobile: false }); - expect(findDeleteButton().attributes('disabled')).toBe('true'); - }); + if (doSelect) { + findTagsListRow().at(0).vm.$emit('select'); + await wrapper.vm.$nextTick(); + } - it('is enabled when at least one item is selected', async () => { - mountComponent(); - findTagsListRow().at(0).vm.$emit('select'); - await wrapper.vm.$nextTick(); - expect(findDeleteButton().attributes('disabled')).toBe(undefined); - }); + expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled); + }, + ); it('click event emits a deleted event with selected items', () => { mountComponent(); @@ -100,12 +107,13 @@ describe('Tags List', () => { }); it('the correct props are bound to it', () => { - mountComponent(); + mountComponent({ tags, disabled: true }); const rows = findTagsListRow(); expect(rows.at(0).attributes()).toMatchObject({ first: 'true', + disabled: 'true', }); }); diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 7106cdae27f..541db5f21c4 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -12,11 +12,17 @@ import DetailsHeader from '~/registry/explorer/components/details_page/details_h import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; import EmptyTagsState from '~/registry/explorer/components/details_page/empty_state.vue'; +import StatusAlert from '~/registry/explorer/components/details_page/status_alert.vue'; +import DeleteImage from '~/registry/explorer/components/delete_image.vue'; import getContainerRepositoryDetailsQuery from '~/registry/explorer/graphql/queries/get_container_repository_details.query.graphql'; import deleteContainerRepositoryTagsMutation from '~/registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql'; -import { UNFINISHED_STATUS } from '~/registry/explorer/constants/index'; +import { + UNFINISHED_STATUS, + DELETE_SCHEDULED, + ALERT_DANGER_IMAGE, +} from '~/registry/explorer/constants'; import { graphQLImageDetailsMock, @@ -43,6 +49,8 @@ describe('Details Page', () => { const findDetailsHeader = () => wrapper.find(DetailsHeader); const findEmptyState = () => wrapper.find(EmptyTagsState); const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert); + const findStatusAlert = () => wrapper.find(StatusAlert); + const findDeleteImage = () => wrapper.find(DeleteImage); const routeId = 1; @@ -88,6 +96,7 @@ describe('Details Page', () => { apolloProvider, stubs: { DeleteModal, + DeleteImage, }, mocks: { $route: { @@ -507,4 +516,83 @@ describe('Details Page', () => { expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name); }); }); + + describe('when the image has a status different from null', () => { + const resolver = jest + .fn() + .mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED })); + it('disables all the actions', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findDetailsHeader().props('disabled')).toBe(true); + expect(findTagsList().props('disabled')).toBe(true); + }); + + it('shows a status alert', async () => { + mountComponent({ resolver }); + + await waitForApolloRequestRender(); + + expect(findStatusAlert().exists()).toBe(true); + expect(findStatusAlert().props()).toMatchObject({ + status: DELETE_SCHEDULED, + }); + }); + }); + + describe('delete the image', () => { + const mountComponentAndDeleteImage = async () => { + mountComponent(); + + await waitForApolloRequestRender(); + findDetailsHeader().vm.$emit('delete'); + + await wrapper.vm.$nextTick(); + }; + + it('on delete event it deletes the image', async () => { + await mountComponentAndDeleteImage(); + + findDeleteModal().vm.$emit('confirmDelete'); + + expect(findDeleteImage().emitted('start')).toEqual([[]]); + }); + + it('binds the correct props to the modal', async () => { + await mountComponentAndDeleteImage(); + + expect(findDeleteModal().props()).toMatchObject({ + itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }], + deleteImage: true, + }); + }); + + it('binds correctly to delete-image start and end events', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('start'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(true); + + findDeleteImage().vm.$emit('end'); + + await wrapper.vm.$nextTick(); + + expect(findTagsLoader().exists()).toBe(false); + }); + + it('binds correctly to delete-image error event', async () => { + mountComponent(); + + findDeleteImage().vm.$emit('error'); + + await wrapper.vm.$nextTick(); + + expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE); + }); + }); }); |