diff options
47 files changed, 770 insertions, 321 deletions
diff --git a/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js new file mode 100644 index 00000000000..67d32648ce8 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js @@ -0,0 +1,3 @@ +import { initPipelineEditor } from '~/pipeline_editor'; + +initPipelineEditor(); diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js new file mode 100644 index 00000000000..7d3d5159d58 --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import PipelineEditorApp from './pipeline_editor_app.vue'; + +export const initPipelineEditor = (selector = '#js-pipeline-editor') => { + const el = document.querySelector(selector); + + return new Vue({ + el, + render(h) { + return h(PipelineEditorApp); + }, + }); +}; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue new file mode 100644 index 00000000000..add8cfb2a3e --- /dev/null +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -0,0 +1,26 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + components: { + GlEmptyState, + }, + i18n: { + title: s__('Pipelines|Pipeline Editor'), + description: s__( + 'Pipelines|We are beginning our work around building the foundation for our dedicated pipeline editor.', + ), + primaryButtonText: __('Learn more'), + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :description="$options.i18n.description" + :primary-button-text="$options.i18n.primaryButtonText" + primary-button-link="https://about.gitlab.com/direction/verify/pipeline_authoring/" + /> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue index 71f415baf0d..5824cb9438f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar.vue @@ -70,34 +70,50 @@ export default { </span> <span class="uploading-error-container hide"> <span class="uploading-error-icon"> - <template> - <gl-icon name="media" /> - </template> + <gl-icon name="media" /> </span> <span class="uploading-error-message"></span> <gl-sprintf :message=" __( - '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}', + '%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}.', ) " > <template #retryButton="{content}"> - <button class="retry-uploading-link" type="button">{{ content }}</button> + <gl-button + variant="link" + category="primary" + class="retry-uploading-link gl-vertical-align-baseline" + > + {{ content }} + </gl-button> </template> <template #newFileButton="{content}"> - <button class="attach-new-file markdown-selector" type="button">{{ content }}</button> + <gl-button + variant="link" + category="primary" + class="markdown-selector attach-new-file gl-vertical-align-baseline" + > + {{ content }} + </gl-button> </template> </gl-sprintf> </span> - <gl-button class="markdown-selector button-attach-file" variant="link"> - <template> - <gl-icon name="media" :size="16" /> - </template> - <span class="text-attach-file">{{ __('Attach a file') }}</span> + <gl-button + icon="media" + variant="link" + category="primary" + class="markdown-selector button-attach-file gl-vertical-align-text-bottom" + > + {{ __('Attach a file') }} </gl-button> - <gl-button class="btn btn-default btn-sm hide button-cancel-uploading-files" variant="link"> + <gl-button + variant="link" + category="primary" + class="button-cancel-uploading-files gl-vertical-align-baseline hide" + > {{ __('Cancel') }} </gl-button> </span> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js index 746e38e98e8..00c54313292 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js @@ -3,5 +3,3 @@ export const DropdownVariant = { Standalone: 'standalone', Embedded: 'embedded', }; - -export const LIST_BUFFER_SIZE = 5; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index c8dee81d746..353dee862d0 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -1,23 +1,25 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { + GlIntersectionObserver, + GlLoadingIcon, + GlButton, + GlSearchBoxByType, + GlLink, +} from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; -import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import LabelItem from './label_item.vue'; -import { LIST_BUFFER_SIZE } from './constants'; - export default { - LIST_BUFFER_SIZE, components: { + GlIntersectionObserver, GlLoadingIcon, GlButton, GlSearchBoxByType, GlLink, - SmartVirtualList, LabelItem, }, data() { @@ -46,15 +48,8 @@ export default { } return this.labels; }, - showListContainer() { - if (this.isDropdownVariantSidebar) { - return !this.labelsFetchInProgress; - } - - return true; - }, showNoMatchingResultsMessage() { - return !this.labelsFetchInProgress && !this.visibleLabels.length; + return Boolean(this.searchKey) && this.visibleLabels.length === 0; }, }, watch: { @@ -67,14 +62,12 @@ export default { } }, }, - mounted() { - this.fetchLabels(); - }, methods: { ...mapActions([ 'toggleDropdownContents', 'toggleDropdownContentsCreateView', 'fetchLabels', + 'receiveLabelsSuccess', 'updateSelectedLabels', 'toggleDropdownContents', ]), @@ -100,6 +93,17 @@ export default { } }, /** + * We want to remove loaded labels to ensure component + * fetches fresh set of labels every time when shown. + */ + handleComponentDisappear() { + this.receiveLabelsSuccess([]); + }, + handleCreateLabelClick() { + this.receiveLabelsSuccess([]); + this.toggleDropdownContentsCreateView(); + }, + /** * This method enables keyboard navigation support for * the dropdown. */ @@ -135,84 +139,75 @@ export default { </script> <template> - <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> - <gl-loading-icon - v-if="labelsFetchInProgress" - class="labels-fetch-loading position-absolute gl-display-flex gl-align-items-center w-100 h-100" - size="md" - /> - <div - v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" - class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" - data-testid="dropdown-title" - > - <span class="flex-grow-1">{{ labelsListTitle }}</span> - <gl-button - :aria-label="__('Close')" - variant="link" - size="small" - class="dropdown-header-button gl-p-0!" - icon="close" - @click="toggleDropdownContents" - /> - </div> - <div class="dropdown-input" @click.stop="() => {}"> - <gl-search-box-by-type - v-model="searchKey" - :autofocus="true" - data-qa-selector="dropdown_input_field" - /> - </div> - <div - v-show="showListContainer" - ref="labelsListContainer" - class="dropdown-content" - data-testid="dropdown-content" - > - <smart-virtual-list - :length="visibleLabels.length" - :remain="$options.LIST_BUFFER_SIZE" - :size="$options.LIST_BUFFER_SIZE" - wclass="list-unstyled mb-0" - wtag="ul" - class="h-100" + <gl-intersection-observer @appear="fetchLabels" @disappear="handleComponentDisappear"> + <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown"> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-title" > - <li v-for="(label, index) in visibleLabels" :key="label.id" class="d-block text-left"> + <span class="flex-grow-1">{{ labelsListTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button gl-p-0!" + icon="close" + @click="toggleDropdownContents" + /> + </div> + <div class="dropdown-input" @click.stop="() => {}"> + <gl-search-box-by-type + v-model="searchKey" + :autofocus="true" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + /> + </div> + <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center w-100 h-100" + size="md" + /> + <ul v-else class="list-unstyled mb-0"> <label-item + v-for="(label, index) in visibleLabels" + :key="label.id" :label="label" :is-label-set="label.set" :highlight="index === currentHighlightItem" @clickLabel="handleLabelClick(label)" /> - </li> - <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> - {{ __('No matching results') }} - </li> - </smart-virtual-list> - </div> - <div - v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" - class="dropdown-footer" - data-testid="dropdown-footer" - > - <ul class="list-unstyled"> - <li v-if="allowLabelCreate"> - <gl-link - class="gl-display-flex w-100 flex-row text-break-word label-item" - @click="toggleDropdownContentsCreateView" - > - {{ footerCreateLabelTitle }} - </gl-link> - </li> - <li> - <gl-link - :href="labelsManagePath" - class="gl-display-flex flex-row text-break-word label-item" - > - {{ footerManageLabelTitle }} - </gl-link> - </li> - </ul> + <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> + {{ __('No matching results') }} + </li> + </ul> + </div> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-footer" + data-testid="dropdown-footer" + > + <ul class="list-unstyled"> + <li v-if="allowLabelCreate"> + <gl-link + class="gl-display-flex w-100 flex-row text-break-word label-item" + @click="handleCreateLabelClick" + > + {{ footerCreateLabelTitle }} + </gl-link> + </li> + <li> + <gl-link + :href="labelsManagePath" + class="gl-display-flex flex-row text-break-word label-item" + > + {{ footerManageLabelTitle }} + </gl-link> + </li> + </ul> + </div> </div> - </div> + </gl-intersection-observer> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue index 002e741ab96..e431fd000a6 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue @@ -1,11 +1,8 @@ <script> -import { GlIcon, GlLink } from '@gitlab/ui'; +import { GlLink, GlIcon } from '@gitlab/ui'; export default { - components: { - GlIcon, - GlLink, - }, + functional: true, props: { label: { type: Object, @@ -21,46 +18,65 @@ export default { default: false, }, }, - data() { - return { - isSet: this.isLabelSet, - }; - }, - computed: { - labelBoxStyle() { - return { - backgroundColor: this.label.color, - }; - }, - }, - watch: { - /** - * This watcher assures that if user used - * `Enter` key to set/unset label, changes - * are reflected here too. - */ - isLabelSet(value) { - this.isSet = value; - }, - }, - methods: { - handleClick() { - this.isSet = !this.isSet; - this.$emit('clickLabel', this.label); - }, + render(h, { props, listeners }) { + const { label, highlight, isLabelSet } = props; + + const labelColorBox = h('span', { + class: 'dropdown-label-box', + style: { + backgroundColor: label.color, + }, + attrs: { + 'data-testid': 'label-color-box', + }, + }); + + const checkedIcon = h(GlIcon, { + class: { + 'mr-2 align-self-center': true, + hidden: !isLabelSet, + }, + props: { + name: 'mobile-issue-close', + }, + }); + + const noIcon = h('span', { + class: { + 'mr-3 pr-2': true, + hidden: isLabelSet, + }, + attrs: { + 'data-testid': 'no-icon', + }, + }); + + const labelTitle = h('span', label.title); + + const labelLink = h( + GlLink, + { + class: 'd-flex align-items-baseline text-break-word label-item', + on: { + click: () => { + listeners.clickLabel(label); + }, + }, + }, + [noIcon, checkedIcon, labelColorBox, labelTitle], + ); + + return h( + 'li', + { + class: { + 'd-block': true, + 'text-left': true, + 'is-focused': highlight, + }, + }, + [labelLink], + ); }, }; </script> - -<template> - <gl-link - class="d-flex align-items-baseline text-break-word label-item" - :class="{ 'is-focused': highlight }" - @click="handleClick" - > - <gl-icon v-show="isSet" name="mobile-issue-close" class="mr-2 align-self-center" /> - <span v-show="!isSet" data-testid="no-icon" class="mr-3 pr-2"></span> - <span class="dropdown-label-box" data-testid="label-color-box" :style="labelBoxStyle"></span> - <span>{{ label.title }}</span> - </gl-link> -</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index c651013c5f5..2f71907f772 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -266,7 +266,7 @@ export default { </dropdown-value> <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" /> <dropdown-contents - v-if="dropdownButtonVisible && showDropdownContents" + v-show="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" /> </template> diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index ebb957645a0..2094c824286 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -1017,6 +1017,23 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { } } + li { + &:hover, + &.is-focused { + .label-item { + @include dropdown-item-hover; + + text-decoration: none; + } + } + } + + .labels-select-dropdown-button { + .gl-button-text { + width: 100%; + } + } + .labels-select-dropdown-contents { min-height: $dropdown-min-height; max-height: 330px; @@ -1050,13 +1067,6 @@ header.header-content .dropdown-menu.frequent-items-dropdown-menu { .label-item { padding: 8px 20px; - - &:hover, - &.is-focused { - @include dropdown-item-hover; - - text-decoration: none; - } } .color-input-container { diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 776dbb486ae..f4a8ab58297 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -450,31 +450,6 @@ table { margin-right: 5px; } -.attach-new-file, -.button-attach-file, -.retry-uploading-link { - color: $blue-600; - padding: 0; - background: none; - border: 0; - font-size: 14px; - line-height: 16px; - vertical-align: initial; - - &:hover, - &:focus { - text-decoration: none; - - .text-attach-file { - text-decoration: underline; - } - } - - .gl-icon:not(:last-child) { - margin-right: 0; - } -} - .markdown-selector { color: $blue-600; } diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb new file mode 100644 index 00000000000..c2428270fa6 --- /dev/null +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class Projects::Ci::PipelineEditorController < Projects::ApplicationController + before_action :check_can_collaborate! + + feature_category :pipeline_authoring + + def show + render_404 unless ::Gitlab::Ci::Features.ci_pipeline_editor_page_enabled?(@project) + end + + private + + def check_can_collaborate! + render_404 unless can_collaborate_with_project?(@project) + end +end diff --git a/app/graphql/resolvers/echo_resolver.rb b/app/graphql/resolvers/echo_resolver.rb index fe0b1893a23..6b85b700712 100644 --- a/app/graphql/resolvers/echo_resolver.rb +++ b/app/graphql/resolvers/echo_resolver.rb @@ -2,15 +2,16 @@ module Resolvers class EchoResolver < BaseResolver + type ::GraphQL::STRING_TYPE, null: false description 'Testing endpoint to validate the API with' argument :text, GraphQL::STRING_TYPE, required: true, description: 'Text to echo back' - def resolve(**args) - username = context[:current_user]&.username + def resolve(text:) + username = current_user&.username - "#{username.inspect} says: #{args[:text]}" + "#{username.inspect} says: #{text}" end end end diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb new file mode 100644 index 00000000000..3f48b2687b9 --- /dev/null +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Ci + module PipelineEditorHelper + include ChecksCollaboration + + def can_view_pipeline_editor?(project) + can_collaborate_with_project?(project) && + Gitlab::Ci::Features.ci_pipeline_editor_page_enabled?(project) + end + end +end diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 8514e924196..95f9dfb8bbe 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -167,7 +167,7 @@ = render_if_exists "layouts/nav/requirements_link", project: @project - if project_nav_tab? :pipelines - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases], unless: -> { current_path?('projects/pipelines#charts') }) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], unless: -> { current_path?('projects/pipelines#charts') }) do = link_to project_pipelines_path(@project), class: 'shortcuts-pipelines qa-link-pipelines rspec-link-pipelines', data: { qa_selector: 'ci_cd_link' } do .nav-icon-container = sprite_icon('rocket') @@ -175,7 +175,7 @@ = _('CI / CD') %ul.sidebar-sub-level-items - = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases], html_options: { class: "fly-out-top-item" }) do + = nav_link(controller: [:pipelines, :builds, :jobs, :pipeline_schedules, :artifacts, :test_cases, :pipeline_editor], html_options: { class: "fly-out-top-item" }) do = link_to project_pipelines_path(@project) do %strong.fly-out-top-item-name = _('CI / CD') @@ -186,6 +186,12 @@ %span = _('Pipelines') + - if can_view_pipeline_editor?(@project) + = nav_link(controller: :pipeline_editor, action: :show) do + = link_to project_ci_pipeline_editor_path(@project), title: s_('Pipelines|Editor') do + %span + = s_('Pipelines|Editor') + - if project_nav_tab? :builds = nav_link(controller: :jobs) do = link_to project_jobs_path(@project), title: _('Jobs'), class: 'shortcuts-builds' do diff --git a/app/views/notify/_issuable_csv_export.html.haml b/app/views/notify/_issuable_csv_export.html.haml index 239b5b14966..5a581811179 100644 --- a/app/views/notify/_issuable_csv_export.html.haml +++ b/app/views/notify/_issuable_csv_export.html.haml @@ -1,6 +1,6 @@ %p{ style: 'font-size:18px; text-align:center; line-height:30px;' } - project_link = link_to(@project.full_name, project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;") - = _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.to_s), project_link: project_link } + = _('Your CSV export of %{count} from project %{project_link} has been added to this email as an attachment.').html_safe % { count: pluralize(@written_count, type.to_s.titleize.downcase), project_link: project_link } - if @truncated %p = _('This attachment has been truncated to avoid exceeding the maximum allowed attachment size of 15MB. %{written_count} of %{count} issues have been included. Consider re-exporting with a narrower selection of issues.') % { written_count: @written_count, count: @count } diff --git a/app/views/projects/ci/pipeline_editor/show.html.haml b/app/views/projects/ci/pipeline_editor/show.html.haml new file mode 100644 index 00000000000..1482c2fddc0 --- /dev/null +++ b/app/views/projects/ci/pipeline_editor/show.html.haml @@ -0,0 +1,3 @@ +- page_title s_('Pipelines|Pipeline Editor') + +#js-pipeline-editor diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index 3703cca2290..a03e8446f5d 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -23,13 +23,20 @@ = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') %span.uploading-error-message -# Populated by app/assets/javascripts/dropzone_input.js - %button.retry-uploading-link{ type: 'button' }= _("Try again") - or - %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") + %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link + %span.gl-button-text + = _("Try again") + = _("or") + %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline + %span.gl-button-text + = _("attach a new file") + = _(".") - %button.btn.markdown-selector.button-attach-file.btn-link{ type: 'button' } + %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom = sprite_icon('media') - %span.text-attach-file<> + %span.gl-button-text = _("Attach a file") - %button.btn.btn-default.btn-sm.hide.button-cancel-uploading-files{ type: 'button' }= _("Cancel") + %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide + %span.gl-button-text + = _("Cancel") diff --git a/changelogs/unreleased/209916-container-repository-api.yml b/changelogs/unreleased/209916-container-repository-api.yml new file mode 100644 index 00000000000..892639c535f --- /dev/null +++ b/changelogs/unreleased/209916-container-repository-api.yml @@ -0,0 +1,5 @@ +--- +title: Add container repositories API +merge_request: 46495 +author: +type: added diff --git a/changelogs/unreleased/229300-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml b/changelogs/unreleased/229300-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml new file mode 100644 index 00000000000..675c90449a9 --- /dev/null +++ b/changelogs/unreleased/229300-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml @@ -0,0 +1,5 @@ +--- +title: Migrate Bootstrap buttons to GitLab UI buttons for attach a file form actions +merge_request: 46041 +author: +type: other diff --git a/changelogs/unreleased/254997-fix-labels-search-scroll.yml b/changelogs/unreleased/254997-fix-labels-search-scroll.yml new file mode 100644 index 00000000000..da5a024393b --- /dev/null +++ b/changelogs/unreleased/254997-fix-labels-search-scroll.yml @@ -0,0 +1,5 @@ +--- +title: Fix Vue Labels Select dropdown keyboard scroll +merge_request: 43874 +author: +type: fixed diff --git a/config/feature_flags/development/ci_pipeline_editor_page.yml b/config/feature_flags/development/ci_pipeline_editor_page.yml new file mode 100644 index 00000000000..b035cb4b814 --- /dev/null +++ b/config/feature_flags/development/ci_pipeline_editor_page.yml @@ -0,0 +1,7 @@ +--- +name: ci_pipeline_editor_page +introduced_by_url: +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/270059 +type: development +group: group::pipeline authoring +default_enabled: false diff --git a/config/routes/project.rb b/config/routes/project.rb index 6633acc2ba5..33a96345a93 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -85,6 +85,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do namespace :ci do resource :lint, only: [:show, :create] + resource :pipeline_editor, only: [:show], controller: :pipeline_editor, path: 'editor' resources :daily_build_group_report_results, only: [:index], constraints: { format: /(csv|json)/ } end diff --git a/doc/api/container_registry.md b/doc/api/container_registry.md index 3a7ebf9a2aa..b45169e6336 100644 --- a/doc/api/container_registry.md +++ b/doc/api/container_registry.md @@ -124,6 +124,48 @@ Example response: ] ``` +## Get details of a single repository + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/209916) in GitLab 13.6. + +Get details of a registry repository. + +```plaintext +GET /registry/repositories/:id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID of the registry repository accessible by the authenticated user. | +| `tags` | boolean | no | If the parameter is included as `true`, the response includes an array of `"tags"`. | +| `tags_count` | boolean | no | If the parameter is included as `true`, the response includes `"tags_count"`. | + +```shell +curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/registry/repositories/2?tags=true&tags_count=true" +``` + +Example response: + +```json +{ + "id": 2, + "name": "", + "path": "group/project", + "project_id": 9, + "location": "gitlab.example.com:5000/group/project", + "created_at": "2019-01-10T13:38:57.391Z", + "cleanup_policy_started_at": "2020-08-17T03:12:35.489Z", + "tags_count": 1, + "tags": [ + { + "name": "0.0.1", + "path": "group/project:0.0.1", + "location": "gitlab.example.com:5000/group/project:0.0.1" + } + ] +} +``` + ## Delete registry repository Delete a repository in registry. diff --git a/lib/api/api.rb b/lib/api/api.rb index 358967c72d2..c28a6608dc5 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -161,6 +161,7 @@ module API mount ::API::Commits mount ::API::CommitStatuses mount ::API::ContainerRegistryEvent + mount ::API::ContainerRepositories mount ::API::DeployKeys mount ::API::DeployTokens mount ::API::Deployments diff --git a/lib/api/container_repositories.rb b/lib/api/container_repositories.rb new file mode 100644 index 00000000000..e7c82e30025 --- /dev/null +++ b/lib/api/container_repositories.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module API + class ContainerRepositories < ::API::Base + include Gitlab::Utils::StrongMemoize + helpers ::API::Helpers::PackagesHelpers + + before { authenticate! } + + namespace 'registry' do + params do + requires :id, type: String, desc: 'The ID of a project' + end + resource :repositories, requirements: { id: /[0-9]*/ } do + desc 'Get a container repository' do + detail 'This feature was introduced in GitLab 13.6.' + success Entities::ContainerRegistry::Repository + end + params do + optional :tags, type: Boolean, default: false, desc: 'Determines if tags should be included' + optional :tags_count, type: Boolean, default: false, desc: 'Determines if the tags count should be included' + end + get ':id' do + authorize!(:read_container_image, repository) + + present repository, with: Entities::ContainerRegistry::Repository, tags: params[:tags], tags_count: params[:tags_count], user: current_user + end + end + end + + helpers do + def repository + strong_memoize(:repository) do + ContainerRepository.find(params[:id]) + end + end + end + end +end diff --git a/lib/api/entities/container_registry.rb b/lib/api/entities/container_registry.rb index c430b73580b..c9c2c5156cc 100644 --- a/lib/api/entities/container_registry.rb +++ b/lib/api/entities/container_registry.rb @@ -10,6 +10,8 @@ module API end class Repository < Grape::Entity + include ::API::Helpers::RelatedResourcesHelpers + expose :id expose :name expose :path @@ -19,6 +21,13 @@ module API expose :expiration_policy_started_at, as: :cleanup_policy_started_at expose :tags_count, if: -> (_, options) { options[:tags_count] } expose :tags, using: Tag, if: -> (_, options) { options[:tags] } + expose :delete_api_path, if: ->(object, options) { Ability.allowed?(options[:user], :admin_container_image, object) } + + private + + def delete_api_path + expose_url api_v4_projects_registry_repositories_path(repository_id: object.id, id: object.project_id) + end end class TagDetails < Tag diff --git a/lib/gitlab/ci/features.rb b/lib/gitlab/ci/features.rb index 6480c591942..be083f528a9 100644 --- a/lib/gitlab/ci/features.rb +++ b/lib/gitlab/ci/features.rb @@ -66,6 +66,10 @@ module Gitlab def self.seed_block_run_before_workflow_rules_enabled?(project) ::Feature.enabled?(:ci_seed_block_run_before_workflow_rules, project, default_enabled: false) end + + def self.ci_pipeline_editor_page_enabled?(project) + ::Feature.enabled?(:ci_pipeline_editor_page, project, default_enabled: false) + end end end end diff --git a/lib/gitlab/import_export/json/ndjson_reader.rb b/lib/gitlab/import_export/json/ndjson_reader.rb index 0d9839b86cf..5c8edd485e5 100644 --- a/lib/gitlab/import_export/json/ndjson_reader.rb +++ b/lib/gitlab/import_export/json/ndjson_reader.rb @@ -29,9 +29,9 @@ module Gitlab json_decode(data) end - def consume_relation(importable_path, key) + def consume_relation(importable_path, key, mark_as_consumed: true) Enumerator.new do |documents| - next unless @consumed_relations.add?("#{importable_path}/#{key}") + next if mark_as_consumed && !@consumed_relations.add?("#{importable_path}/#{key}") # This reads from `tree/project/merge_requests.ndjson` path = file_path(importable_path, "#{key}.ndjson") @@ -44,11 +44,6 @@ module Gitlab end end - # TODO: Move clear logic into main comsume_relation method (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41699#note_430465330) - def clear_consumed_relations - @consumed_relations.clear - end - private def json_decode(string) diff --git a/lib/gitlab/import_export/project/sample/date_calculator.rb b/lib/gitlab/import_export/project/sample/date_calculator.rb index 2d989d21166..543fd25d883 100644 --- a/lib/gitlab/import_export/project/sample/date_calculator.rb +++ b/lib/gitlab/import_export/project/sample/date_calculator.rb @@ -9,7 +9,6 @@ module Gitlab def initialize(dates) @dates = dates.dup - @dates.flatten! @dates.compact! @dates.sort! @dates.map! { |date| date.to_time.to_f } diff --git a/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer.rb b/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer.rb index b0c3940b5f9..6285898fc63 100644 --- a/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer.rb +++ b/lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer.rb @@ -30,13 +30,12 @@ module Gitlab data_hash['due_date'] = date_calculator.calculate_by_closest_date_to_average(data_hash['due_date'].to_time) unless data_hash['due_date'].nil? end - # TODO: Move clear logic into main comsume_relation method (see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/41699#note_430465330) def dates - unless relation_reader.legacy? - DATE_MODELS.map do |tag| - relation_reader.consume_relation(@importable_path, tag).map { |model| model.first['due_date'] }.tap do - relation_reader.clear_consumed_relations - end + return if relation_reader.legacy? + + DATE_MODELS.flat_map do |tag| + relation_reader.consume_relation(@importable_path, tag, mark_as_consumed: false).map do |model| + model.first['due_date'] end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index ae26763298e..d0a533b60bb 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -737,7 +737,7 @@ msgstr[1] "" msgid "%{reportType} %{status} detected no vulnerabilities." msgstr "" -msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}" +msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}." msgstr "" msgid "%{seconds}s" @@ -1047,6 +1047,9 @@ msgstr "" msgid "- show less" msgstr "" +msgid "." +msgstr "" + msgid "0 bytes" msgstr "" @@ -19537,6 +19540,9 @@ msgstr "" msgid "Pipelines|Edit" msgstr "" +msgid "Pipelines|Editor" +msgstr "" + msgid "Pipelines|Get started with Pipelines" msgstr "" @@ -19567,6 +19573,9 @@ msgstr "" msgid "Pipelines|Owner" msgstr "" +msgid "Pipelines|Pipeline Editor" +msgstr "" + msgid "Pipelines|Project cache successfully reset." msgstr "" @@ -19603,6 +19612,9 @@ msgstr "" msgid "Pipelines|Trigger user has insufficient permissions to project" msgstr "" +msgid "Pipelines|We are beginning our work around building the foundation for our dedicated pipeline editor." +msgstr "" + msgid "Pipelines|invalid" msgstr "" diff --git a/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb new file mode 100644 index 00000000000..1bf6ff95c44 --- /dev/null +++ b/spec/controllers/projects/ci/pipeline_editor_controller_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Projects::Ci::PipelineEditorController do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:user) { create(:user) } + + before do + sign_in(user) + end + + describe 'GET #show' do + context 'with enough privileges' do + before do + project.add_developer(user) + + get :show, params: { namespace_id: project.namespace, project_id: project } + end + + it { expect(response).to have_gitlab_http_status(:ok) } + + it 'renders show page' do + expect(response).to render_template :show + end + end + + context 'without enough privileges' do + before do + project.add_reporter(user) + + get :show, params: { namespace_id: project.namespace, project_id: project } + end + + it 'responds with 404' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when ci_pipeline_editor_page feature flag is disabled' do + before do + stub_feature_flags(ci_pipeline_editor_page: false) + project.add_developer(user) + + get :show, params: { namespace_id: project.namespace, project_id: project } + end + + it 'responds with 404' do + expect(response).to have_gitlab_http_status(:not_found) + end + end + end +end diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/issuables/close_reopen_report_toggle_spec.rb index 6e99cfb3293..a730b94a898 100644 --- a/spec/features/issuables/close_reopen_report_toggle_spec.rb +++ b/spec/features/issuables/close_reopen_report_toggle_spec.rb @@ -95,7 +95,7 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do expect(page).to have_link('New issue') expect(page).not_to have_button('Close issue') expect(page).not_to have_button('Reopen issue') - expect(page).not_to have_link('Edit') + expect(page).not_to have_link(title: 'Edit title and description') end end end @@ -121,7 +121,7 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do it 'shows only the `Report abuse` and `Edit` button' do expect(page).to have_link('Report abuse') - expect(page).to have_link('Edit') + expect(page).to have_link(exact_text: 'Edit') expect(page).not_to have_button('Close merge request') expect(page).not_to have_button('Reopen merge request') end @@ -130,8 +130,8 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do let(:issuable) { create(:merge_request, :merged, source_project: project, author: user) } it 'shows only the `Edit` button' do - expect(page).to have_link('Edit') expect(page).to have_link('Report abuse') + expect(page).to have_link(exact_text: 'Edit') expect(page).not_to have_button('Close merge request') expect(page).not_to have_button('Reopen merge request') end @@ -153,7 +153,7 @@ RSpec.describe 'Issuables Close/Reopen/Report toggle' do expect(page).to have_link('Report abuse') expect(page).not_to have_button('Close merge request') expect(page).not_to have_button('Reopen merge request') - expect(page).not_to have_link('Edit') + expect(page).not_to have_link(exact_text: 'Edit') end end end diff --git a/spec/features/projects/ci/editor_spec.rb b/spec/features/projects/ci/editor_spec.rb new file mode 100644 index 00000000000..7012cc6edaa --- /dev/null +++ b/spec/features/projects/ci/editor_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Pipeline Editor', :js do + include Spec::Support::Helpers::Features::EditorLiteSpecHelpers + + let(:project) { create(:project, :repository) } + let(:user) { create(:user) } + + before do + sign_in(user) + project.add_developer(user) + + visit project_ci_pipeline_editor_path(project) + end + + it 'user sees the Pipeline Editor page' do + expect(page).to have_content('Pipeline Editor') + end +end diff --git a/spec/features/uploads/user_uploads_file_to_note_spec.rb b/spec/features/uploads/user_uploads_file_to_note_spec.rb index 7f55ddc1d64..1eb3b016152 100644 --- a/spec/features/uploads/user_uploads_file_to_note_spec.rb +++ b/spec/features/uploads/user_uploads_file_to_note_spec.rb @@ -58,8 +58,8 @@ RSpec.describe 'User uploads file to note' do error_text = 'File is too big (0.06MiB). Max filesize: 0.01MiB.' expect(page).to have_selector('.uploading-error-message', visible: true, text: error_text) - expect(page).to have_selector('.retry-uploading-link', visible: true, text: 'Try again') - expect(page).to have_selector('.attach-new-file', visible: true, text: 'attach a new file') + expect(page).to have_button('Try again', visible: true) + expect(page).to have_button('attach a new file', visible: true) expect(page).not_to have_button('Attach a file') end end diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js new file mode 100644 index 00000000000..0ca44cff19e --- /dev/null +++ b/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js @@ -0,0 +1,28 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; + +import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; + +describe('~/pipeline_editor/pipeline_editor_app.vue', () => { + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + wrapper = mountFn(PipelineEditorApp); + }; + + const findEmptyState = () => wrapper.find(GlEmptyState); + + it('contains an empty state', () => { + createComponent(); + + expect(findEmptyState().exists()).toBe(true); + }); + + it('contains a text description', () => { + createComponent(mount); + + expect(findEmptyState().text()).toMatchInterpolatedText( + 'Pipeline Editor We are beginning our work around building the foundation for our dedicated pipeline editor. Learn more', + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index a9350bc059d..e8a126d8774 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -1,9 +1,14 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlButton, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui'; +import { + GlIntersectionObserver, + GlButton, + GlLoadingIcon, + GlSearchBoxByType, + GlLink, +} from '@gitlab/ui'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; -import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; @@ -88,20 +93,25 @@ describe('DropdownContentsLabelsView', () => { }); }); - describe('showListContainer', () => { + describe('showNoMatchingResultsMessage', () => { it.each` - variant | loading | showList - ${'sidebar'} | ${false} | ${true} - ${'sidebar'} | ${true} | ${false} - ${'not-sidebar'} | ${true} | ${true} - ${'not-sidebar'} | ${false} | ${true} + searchKey | labels | labelsDescription | returnValue + ${''} | ${[]} | ${'empty'} | ${false} + ${'bug'} | ${[]} | ${'empty'} | ${true} + ${''} | ${mockLabels} | ${'not empty'} | ${false} + ${'bug'} | ${mockLabels} | ${'not empty'} | ${false} `( - 'returns $showList if `state.variant` is "$variant" and `labelsFetchInProgress` is $loading', - ({ variant, loading, showList }) => { - createComponent({ ...mockConfig, variant }); - wrapper.vm.$store.state.labelsFetchInProgress = loading; + 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription', + async ({ searchKey, labels, returnValue }) => { + wrapper.setData({ + searchKey, + }); - expect(wrapper.vm.showListContainer).toBe(showList); + wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue); }, ); }); @@ -118,6 +128,28 @@ describe('DropdownContentsLabelsView', () => { }); }); + describe('handleComponentDisappear', () => { + it('calls action `receiveLabelsSuccess` with empty array', () => { + jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); + + wrapper.vm.handleComponentDisappear(); + + expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]); + }); + }); + + describe('handleCreateLabelClick', () => { + it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => { + jest.spyOn(wrapper.vm, 'receiveLabelsSuccess'); + jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView'); + + wrapper.vm.handleCreateLabelClick(); + + expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]); + expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled(); + }); + }); + describe('handleKeyDown', () => { it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => { wrapper.setData({ @@ -226,8 +258,8 @@ describe('DropdownContentsLabelsView', () => { }); describe('template', () => { - it('renders component container element with class `labels-select-contents-list`', () => { - expect(wrapper.attributes('class')).toContain('labels-select-contents-list'); + it('renders gl-intersection-observer as component root', () => { + expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true); }); it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => { @@ -272,15 +304,11 @@ describe('DropdownContentsLabelsView', () => { expect(searchInputEl.attributes('autofocus')).toBe('true'); }); - it('renders smart-virtual-list element', () => { - expect(wrapper.find(SmartVirtualList).exists()).toBe(true); - }); - it('renders label elements for all labels', () => { expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length); }); - it('renders label element with "is-focused" when value of `currentHighlightItem` is more than -1', () => { + it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => { wrapper.setData({ currentHighlightItem: 0, }); @@ -288,7 +316,7 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const labelItemEl = findDropdownContent().find(LabelItem); - expect(labelItemEl.props('highlight')).toBe(true); + expect(labelItemEl.attributes('highlight')).toBe('true'); }); }); @@ -310,9 +338,12 @@ describe('DropdownContentsLabelsView', () => { return wrapper.vm.$nextTick(() => { const dropdownContent = findDropdownContent(); + const loadingIcon = findLoadingIcon(); expect(dropdownContent.exists()).toBe(true); - expect(dropdownContent.isVisible()).toBe(false); + expect(dropdownContent.isVisible()).toBe(true); + expect(loadingIcon.exists()).toBe(true); + expect(loadingIcon.isVisible()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js index ad3f073fdf9..a6ec01ad7e1 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js @@ -6,11 +6,15 @@ import { mockRegularLabel } from './mock_data'; const mockLabel = { ...mockRegularLabel, set: true }; -const createComponent = ({ label = mockLabel, highlight = true } = {}) => +const createComponent = ({ + label = mockLabel, + isLabelSet = mockLabel.set, + highlight = true, +} = {}) => shallowMount(LabelItem, { propsData: { label, - isLabelSet: label.set, + isLabelSet, highlight, }, }); @@ -26,94 +30,44 @@ describe('LabelItem', () => { wrapper.destroy(); }); - describe('computed', () => { - describe('labelBoxStyle', () => { - it('returns an object containing `backgroundColor` based on `label` prop', () => { - expect(wrapper.vm.labelBoxStyle).toEqual( - expect.objectContaining({ - backgroundColor: mockLabel.color, - }), - ); - }); - }); - }); - - describe('watchers', () => { - describe('isLabelSet', () => { - it('sets value of `isLabelSet` to `isSet` data prop', () => { - expect(wrapper.vm.isSet).toBe(true); - - wrapper.setProps({ - isLabelSet: false, - }); - - return wrapper.vm.$nextTick(() => { - expect(wrapper.vm.isSet).toBe(false); - }); - }); - }); - }); - - describe('methods', () => { - describe('handleClick', () => { - it('sets value of `isSet` data prop to opposite of its current value', () => { - wrapper.setData({ - isSet: true, - }); - - wrapper.vm.handleClick(); - expect(wrapper.vm.isSet).toBe(false); - wrapper.vm.handleClick(); - expect(wrapper.vm.isSet).toBe(true); - }); - - it('emits event `clickLabel` on component with `label` prop as param', () => { - wrapper.vm.handleClick(); - - expect(wrapper.emitted('clickLabel')).toBeTruthy(); - expect(wrapper.emitted('clickLabel')[0]).toEqual([mockLabel]); - }); - }); - }); - describe('template', () => { it('renders gl-link component', () => { expect(wrapper.find(GlLink).exists()).toBe(true); }); - it('renders gl-link component with class `is-focused` when `highlight` prop is true', () => { - wrapper.setProps({ + it('renders component root with class `is-focused` when `highlight` prop is true', () => { + const wrapperTemp = createComponent({ highlight: true, }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.find(GlLink).classes()).toContain('is-focused'); - }); + expect(wrapperTemp.classes()).toContain('is-focused'); + + wrapperTemp.destroy(); }); - it('renders visible gl-icon component when `isSet` prop is true', () => { - wrapper.setData({ - isSet: true, + it('renders visible gl-icon component when `isLabelSet` prop is true', () => { + const wrapperTemp = createComponent({ + isLabelSet: true, }); - return wrapper.vm.$nextTick(() => { - const iconEl = wrapper.find(GlIcon); + const iconEl = wrapperTemp.find(GlIcon); - expect(iconEl.isVisible()).toBe(true); - expect(iconEl.props('name')).toBe('mobile-issue-close'); - }); + expect(iconEl.isVisible()).toBe(true); + expect(iconEl.props('name')).toBe('mobile-issue-close'); + + wrapperTemp.destroy(); }); - it('renders visible span element as placeholder instead of gl-icon when `isSet` prop is false', () => { - wrapper.setData({ - isSet: false, + it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => { + const wrapperTemp = createComponent({ + isLabelSet: false, }); - return wrapper.vm.$nextTick(() => { - const placeholderEl = wrapper.find('[data-testid="no-icon"]'); + const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]'); - expect(placeholderEl.isVisible()).toBe(true); - }); + expect(placeholderEl.isVisible()).toBe(true); + + wrapperTemp.destroy(); }); it('renders label color element', () => { diff --git a/spec/graphql/resolvers/echo_resolver_spec.rb b/spec/graphql/resolvers/echo_resolver_spec.rb index 2182ac221f6..7d7e8cdf387 100644 --- a/spec/graphql/resolvers/echo_resolver_spec.rb +++ b/spec/graphql/resolvers/echo_resolver_spec.rb @@ -8,6 +8,13 @@ RSpec.describe Resolvers::EchoResolver do let(:current_user) { create(:user) } let(:text) { 'Message test' } + specify do + expect(described_class.field_options).to include( + type: eq(::GraphQL::STRING_TYPE), + null: be_falsey + ) + end + describe '#resolve' do it 'echoes text and username' do expect(resolve_echo(text)).to eq %Q("#{current_user.username}" says: #{text}) diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb new file mode 100644 index 00000000000..8f38d3b1439 --- /dev/null +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ci::PipelineEditorHelper do + let_it_be(:project) { create(:project) } + + describe 'can_view_pipeline_editor?' do + subject { helper.can_view_pipeline_editor?(project) } + + it 'user can view editor if they can collaborate' do + allow(helper).to receive(:can_collaborate_with_project?).and_return(true) + + expect(subject).to be true + end + + it 'user can not view editor if they cannot collaborate' do + allow(helper).to receive(:can_collaborate_with_project?).and_return(false) + + expect(subject).to be false + end + + it 'user can not view editor if feature is disabled' do + allow(helper).to receive(:can_collaborate_with_project?).and_return(true) + stub_feature_flags(ci_pipeline_editor_page: false) + + expect(subject).to be false + end + end +end diff --git a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb index e208a1c383c..b477ac45577 100644 --- a/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb +++ b/spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb @@ -67,6 +67,14 @@ RSpec.describe Gitlab::ImportExport::JSON::NdjsonReader do it 'yields nothing to the Enumerator' do expect(subject.to_a).to eq([]) end + + context 'with mark_as_consumed: false' do + subject { ndjson_reader.consume_relation(importable_path, key, mark_as_consumed: false) } + + it 'yields every relation value to the Enumerator' do + expect(subject.count).to eq(1) + end + end end context 'key has not been consumed' do @@ -102,14 +110,4 @@ RSpec.describe Gitlab::ImportExport::JSON::NdjsonReader do end end end - - describe '#clear_consumed_relations' do - let(:dir_path) { fixture } - - subject { ndjson_reader.clear_consumed_relations } - - it 'returns empty set' do - expect(subject).to be_empty - end - end end diff --git a/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb b/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb index 82f59245519..645242c6f05 100644 --- a/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb +++ b/spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb @@ -13,7 +13,7 @@ RSpec.describe Gitlab::ImportExport::Project::Sample::DateCalculator do end context 'when dates are not empty' do - let(:dates) { [[nil, '2020-01-01 00:00:00 +0000'], [nil, '2021-01-01 00:00:00 +0000'], [nil, '2022-01-01 23:59:59 +0000']] } + let(:dates) { [nil, '2020-01-01 00:00:00 +0000', '2021-01-01 00:00:00 +0000', nil, '2022-01-01 23:59:59 +0000'] } it { is_expected.to eq(Time.zone.parse('2021-01-01 00:00:00 +0000')) } end diff --git a/spec/mailers/emails/merge_requests_spec.rb b/spec/mailers/emails/merge_requests_spec.rb index 9235a946394..412cdff3aba 100644 --- a/spec/mailers/emails/merge_requests_spec.rb +++ b/spec/mailers/emails/merge_requests_spec.rb @@ -52,7 +52,8 @@ RSpec.describe Emails::MergeRequests do it { expect(subject.subject).to eq("#{project.name} | Exported merge requests") } it { expect(subject.to).to contain_exactly(user.notification_email_for(project.group)) } - it { expect(subject).to have_content('Your CSV export of 10 merge requests from project')} + it { expect(subject.html_part).to have_content("Your CSV export of 10 merge requests from project") } + it { expect(subject.text_part).to have_content("Your CSV export of 10 merge requests from project") } context 'when truncated' do let(:export_status) do diff --git a/spec/requests/api/container_repositories_spec.rb b/spec/requests/api/container_repositories_spec.rb new file mode 100644 index 00000000000..8d7494ffce1 --- /dev/null +++ b/spec/requests/api/container_repositories_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::ContainerRepositories do + let_it_be(:project) { create(:project, :private) } + let_it_be(:reporter) { create(:user) } + let_it_be(:guest) { create(:user) } + let_it_be(:repository) { create(:container_repository, project: project) } + + let(:users) do + { + anonymous: nil, + guest: guest, + reporter: reporter + } + end + + let(:api_user) { reporter } + + before do + project.add_reporter(reporter) + project.add_guest(guest) + + stub_container_registry_config(enabled: true) + end + + describe 'GET /registry/repositories/:id' do + let(:url) { "/registry/repositories/#{repository.id}" } + + subject { get api(url, api_user) } + + it_behaves_like 'rejected container repository access', :guest, :forbidden + it_behaves_like 'rejected container repository access', :anonymous, :unauthorized + + context 'for allowed user' do + it 'returns a repository' do + subject + + expect(json_response['id']).to eq(repository.id) + expect(response.body).not_to include('tags') + end + + it 'returns a matching schema' do + subject + + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('registry/repository') + end + + context 'with tags param' do + let(:url) { "/registry/repositories/#{repository.id}?tags=true" } + + before do + stub_container_registry_tags(repository: repository.path, tags: %w(rootA latest), with_manifest: true) + end + + it 'returns a repository and its tags' do + subject + + expect(json_response['id']).to eq(repository.id) + expect(response.body).to include('tags') + end + end + + context 'with tags_count param' do + let(:url) { "/registry/repositories/#{repository.id}?tags_count=true" } + + before do + stub_container_registry_tags(repository: repository.path, tags: %w(rootA latest), with_manifest: true) + end + + it 'returns a repository and its tags_count' do + subject + + expect(response.body).to include('tags_count') + expect(json_response['tags_count']).to eq(2) + end + end + end + + context 'with invalid repository id' do + let(:url) { "/registry/repositories/#{non_existing_record_id}" } + + it_behaves_like 'returning response status', :not_found + end + end +end diff --git a/spec/requests/api/graphql_spec.rb b/spec/requests/api/graphql_spec.rb index 94a66f54e4d..5dc8edb87e9 100644 --- a/spec/requests/api/graphql_spec.rb +++ b/spec/requests/api/graphql_spec.rb @@ -4,7 +4,7 @@ require 'spec_helper' RSpec.describe 'GraphQL' do include GraphqlHelpers - let(:query) { graphql_query_for('echo', 'text' => 'Hello world' ) } + let(:query) { graphql_query_for('echo', text: 'Hello world' ) } context 'logging' do shared_examples 'logging a graphql query' do diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 9ebfdcb9522..d304a3e9a0d 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -56,6 +56,7 @@ RSpec.shared_context 'project navbar structure' do nav_item: _('CI / CD'), nav_sub_items: [ _('Pipelines'), + s_('Pipelines|Editor'), _('Jobs'), _('Artifacts'), _('Schedules') diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index 3fb9fb54b01..c5b56b15431 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -219,6 +219,22 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end end + describe 'pipeline editor link' do + it 'shows the pipeline editor link' do + render + + expect(rendered).to have_link('Editor', href: project_ci_pipeline_editor_path(project)) + end + + it 'does not show the pipeline editor link' do + allow(view).to receive(:can_view_pipeline_editor?).and_return(false) + + render + + expect(rendered).not_to have_link('Editor', href: project_ci_pipeline_editor_path(project)) + end + end + describe 'operations settings tab' do describe 'archive projects' do before do |