summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/pages/projects/ci/pipeline_editor/show/index.js3
-rw-r--r--app/assets/javascripts/pipeline_editor/index.js13
-rw-r--r--app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue26
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/toolbar.vue40
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/constants.js2
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue173
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/label_item.vue106
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue2
-rw-r--r--app/assets/stylesheets/framework/dropdowns.scss24
-rw-r--r--app/assets/stylesheets/pages/note_form.scss25
-rw-r--r--app/controllers/projects/ci/pipeline_editor_controller.rb17
-rw-r--r--app/graphql/resolvers/echo_resolver.rb7
-rw-r--r--app/helpers/ci/pipeline_editor_helper.rb12
-rw-r--r--app/views/layouts/nav/sidebar/_project.html.haml10
-rw-r--r--app/views/notify/_issuable_csv_export.html.haml2
-rw-r--r--app/views/projects/ci/pipeline_editor/show.html.haml3
-rw-r--r--app/views/shared/notes/_hints.html.haml19
-rw-r--r--changelogs/unreleased/209916-container-repository-api.yml5
-rw-r--r--changelogs/unreleased/229300-migrate-bootstrap-button-to-gitlab-ui-glbutton-in-app-assets-javas.yml5
-rw-r--r--changelogs/unreleased/254997-fix-labels-search-scroll.yml5
-rw-r--r--config/feature_flags/development/ci_pipeline_editor_page.yml7
-rw-r--r--config/routes/project.rb1
-rw-r--r--doc/api/container_registry.md42
-rw-r--r--lib/api/api.rb1
-rw-r--r--lib/api/container_repositories.rb39
-rw-r--r--lib/api/entities/container_registry.rb9
-rw-r--r--lib/gitlab/ci/features.rb4
-rw-r--r--lib/gitlab/import_export/json/ndjson_reader.rb9
-rw-r--r--lib/gitlab/import_export/project/sample/date_calculator.rb1
-rw-r--r--lib/gitlab/import_export/project/sample/sample_data_relation_tree_restorer.rb11
-rw-r--r--locale/gitlab.pot14
-rw-r--r--spec/controllers/projects/ci/pipeline_editor_controller_spec.rb53
-rw-r--r--spec/features/issuables/close_reopen_report_toggle_spec.rb8
-rw-r--r--spec/features/projects/ci/editor_spec.rb21
-rw-r--r--spec/features/uploads/user_uploads_file_to_note_spec.rb4
-rw-r--r--spec/frontend/pipeline_editor/pipeline_editor_app_spec.js28
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js75
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js98
-rw-r--r--spec/graphql/resolvers/echo_resolver_spec.rb7
-rw-r--r--spec/helpers/ci/pipeline_editor_helper_spec.rb30
-rw-r--r--spec/lib/gitlab/import_export/json/ndjson_reader_spec.rb18
-rw-r--r--spec/lib/gitlab/import_export/project/sample/date_calculator_spec.rb2
-rw-r--r--spec/mailers/emails/merge_requests_spec.rb3
-rw-r--r--spec/requests/api/container_repositories_spec.rb88
-rw-r--r--spec/requests/api/graphql_spec.rb2
-rw-r--r--spec/support/shared_contexts/navbar_structure_context.rb1
-rw-r--r--spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb16
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