Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/assets/javascripts/artifacts/components/artifact_row.vue24
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue182
-rw-r--r--app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue9
-rw-r--r--app/assets/javascripts/artifacts/components/job_artifacts_table.vue73
-rw-r--r--app/assets/javascripts/artifacts/components/job_checkbox.vue52
-rw-r--r--app/assets/javascripts/artifacts/constants.js39
-rw-r--r--app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql7
-rw-r--r--app/assets/javascripts/artifacts/index.js10
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue11
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue39
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js7
-rw-r--r--app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue3
-rw-r--r--app/assets/javascripts/content_editor/components/content_editor.vue37
-rw-r--r--app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue14
-rw-r--r--app/assets/javascripts/content_editor/services/content_editor.js4
-rw-r--r--app/assets/javascripts/content_editor/services/create_content_editor.js6
-rw-r--r--app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js5
-rw-r--r--app/assets/javascripts/header_search/components/app.vue60
-rw-r--r--app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue14
-rw-r--r--app/assets/javascripts/header_search/components/header_search_default_items.vue6
-rw-r--r--app/assets/javascripts/header_search/components/header_search_scoped_items.vue6
-rw-r--r--app/assets/javascripts/header_search/constants.js44
-rw-r--r--app/assets/javascripts/header_search/store/getters.js10
-rw-r--r--app/assets/javascripts/notes/components/comment_form.vue118
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue8
-rw-r--r--app/assets/javascripts/super_sidebar/components/context_switcher.vue13
-rw-r--r--app/assets/javascripts/super_sidebar/components/frequent_items_list.vue63
-rw-r--r--app/assets/javascripts/super_sidebar/components/groups_list.vue (renamed from app/assets/javascripts/super_sidebar/components/frequent_groups_list.vue)17
-rw-r--r--app/assets/javascripts/super_sidebar/components/items_list.vue40
-rw-r--r--app/assets/javascripts/super_sidebar/components/projects_list.vue (renamed from app/assets/javascripts/super_sidebar/components/frequent_projects_list.vue)18
-rw-r--r--app/assets/javascripts/super_sidebar/components/search_results.vue49
-rw-r--r--app/assets/javascripts/super_sidebar/components/user_menu.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue26
-rw-r--r--app/assets/javascripts/vue_shared/global_search/constants.js73
-rw-r--r--app/controllers/projects/artifacts_controller.rb4
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/helpers/artifacts_helper.rb1
-rw-r--r--config/feature_flags/development/show_tags_on_commits_view.yml2
-rw-r--r--doc/user/project/repository/branches/img/view_branch_protections_v15_10.pngbin0 -> 5103 bytes
-rw-r--r--doc/user/project/repository/branches/index.md140
-rw-r--r--locale/gitlab.pot50
-rw-r--r--spec/features/issues/user_comments_on_issue_spec.rb2
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb22
-rw-r--r--spec/features/merge_request/user_comments_on_merge_request_spec.rb2
-rw-r--r--spec/features/merge_request/user_views_open_merge_request_spec.rb12
-rw-r--r--spec/features/projects/pipeline_schedules_spec.rb2
-rw-r--r--spec/frontend/__helpers__/create_mock_source_editor_extension.js12
-rw-r--r--spec/frontend/artifacts/components/artifact_row_spec.js35
-rw-r--r--spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js96
-rw-r--r--spec/frontend/artifacts/components/artifacts_table_row_details_spec.js22
-rw-r--r--spec/frontend/artifacts/components/job_artifacts_table_spec.js126
-rw-r--r--spec/frontend/artifacts/components/job_checkbox_spec.js71
-rw-r--r--spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js74
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js39
-rw-r--r--spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js36
-rw-r--r--spec/frontend/content_editor/components/content_editor_spec.js27
-rw-r--r--spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js15
-rw-r--r--spec/frontend/content_editor/services/create_content_editor_spec.js2
-rw-r--r--spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js22
-rw-r--r--spec/frontend/header_search/components/app_spec.js10
-rw-r--r--spec/frontend/header_search/components/header_search_autocomplete_items_spec.js7
-rw-r--r--spec/frontend/header_search/components/header_search_scoped_items_spec.js3
-rw-r--r--spec/frontend/header_search/mock_data.js10
-rw-r--r--spec/frontend/notes/components/comment_form_spec.js106
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js13
-rw-r--r--spec/frontend/super_sidebar/components/context_switcher_spec.js36
-rw-r--r--spec/frontend/super_sidebar/components/frequent_groups_list_spec.js51
-rw-r--r--spec/frontend/super_sidebar/components/frequent_items_list_spec.js55
-rw-r--r--spec/frontend/super_sidebar/components/frequent_projects_list_spec.js51
-rw-r--r--spec/frontend/super_sidebar/components/groups_list_spec.js87
-rw-r--r--spec/frontend/super_sidebar/components/items_list_spec.js63
-rw-r--r--spec/frontend/super_sidebar/components/projects_list_spec.js82
-rw-r--r--spec/frontend/super_sidebar/components/search_results_spec.js57
-rw-r--r--spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js81
-rw-r--r--spec/helpers/artifacts_helper_spec.rb1
-rw-r--r--spec/support/helpers/content_editor_helpers.rb58
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb115
-rw-r--r--spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb1
78 files changed, 2037 insertions, 754 deletions
diff --git a/app/assets/javascripts/artifacts/components/artifact_row.vue b/app/assets/javascripts/artifacts/components/artifact_row.vue
index fffdfce60a7..f37c4c6f107 100644
--- a/app/assets/javascripts/artifacts/components/artifact_row.vue
+++ b/app/assets/javascripts/artifacts/components/artifact_row.vue
@@ -1,7 +1,8 @@
<script>
-import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap } from '@gitlab/ui';
+import { GlButtonGroup, GlButton, GlBadge, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
-import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE } from '../constants';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
+import { I18N_EXPIRED, I18N_DOWNLOAD, I18N_DELETE, BULK_DELETE_FEATURE_FLAG } from '../constants';
export default {
name: 'ArtifactRow',
@@ -10,13 +11,19 @@ export default {
GlButton,
GlBadge,
GlFriendlyWrap,
+ GlFormCheckbox,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['canDestroyArtifacts'],
props: {
artifact: {
type: Object,
required: true,
},
+ isSelected: {
+ type: Boolean,
+ required: true,
+ },
isLastRow: {
type: Boolean,
required: true,
@@ -32,6 +39,16 @@ export default {
artifactSize() {
return numberToHumanSize(this.artifact.size);
},
+ canBulkDestroyArtifacts() {
+ return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ },
+ },
+ methods: {
+ handleInput(checked) {
+ if (checked === this.isSelected) return;
+
+ this.$emit('selectArtifact', this.artifact, checked);
+ },
},
i18n: {
expired: I18N_EXPIRED,
@@ -46,6 +63,9 @@ export default {
:class="{ 'gl-border-b-solid gl-border-b-1 gl-border-gray-100': !isLastRow }"
>
<div class="gl-display-inline-flex gl-align-items-center gl-w-full">
+ <span v-if="canBulkDestroyArtifacts" class="gl-pl-5">
+ <gl-form-checkbox :checked="isSelected" @input="handleInput" />
+ </span>
<span
class="gl-w-half gl-pl-8 gl-display-flex gl-align-items-center"
data-testid="job-artifact-row-name"
diff --git a/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
new file mode 100644
index 00000000000..cc08551fdb7
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/artifacts_bulk_delete.vue
@@ -0,0 +1,182 @@
+<script>
+import { GlButton, GlModal, GlSprintf } from '@gitlab/ui';
+import { createAlert } from '~/alert';
+import { TYPENAME_PROJECT } from '~/graphql_shared/constants';
+import { convertToGraphQLId } from '~/graphql_shared/utils';
+import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
+import bulkDestroyJobArtifactsMutation from '../graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+import { removeArtifactFromStore } from '../graphql/cache_update';
+import {
+ I18N_BULK_DELETE_BANNER,
+ I18N_BULK_DELETE_CLEAR_SELECTION,
+ I18N_BULK_DELETE_DELETE_SELECTED,
+ I18N_BULK_DELETE_MODAL_TITLE,
+ I18N_BULK_DELETE_BODY,
+ I18N_BULK_DELETE_ACTION,
+ I18N_BULK_DELETE_PARTIAL_ERROR,
+ I18N_BULK_DELETE_ERROR,
+ I18N_MODAL_CANCEL,
+ BULK_DELETE_MODAL_ID,
+} from '../constants';
+
+export default {
+ name: 'ArtifactsBulkDelete',
+ components: {
+ GlButton,
+ GlModal,
+ GlSprintf,
+ },
+ inject: ['projectId'],
+ props: {
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ queryVariables: {
+ type: Object,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ isModalVisible: false,
+ isDeleting: false,
+ };
+ },
+ computed: {
+ checkedCount() {
+ return this.selectedArtifacts.length || 0;
+ },
+ modalActionPrimary() {
+ return {
+ text: I18N_BULK_DELETE_ACTION(this.checkedCount),
+ attributes: {
+ loading: this.isDeleting,
+ variant: 'danger',
+ },
+ };
+ },
+ modalActionCancel() {
+ return {
+ text: I18N_MODAL_CANCEL,
+ attributes: {
+ loading: this.isDeleting,
+ },
+ };
+ },
+ },
+ methods: {
+ async onConfirmDelete(e) {
+ // don't close modal until deletion is complete
+ if (e) {
+ e.preventDefault();
+ }
+ this.isDeleting = true;
+
+ try {
+ await this.$apollo.mutate({
+ mutation: bulkDestroyJobArtifactsMutation,
+ variables: {
+ projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId),
+ ids: this.selectedArtifacts,
+ },
+ update: (store, { data }) => {
+ const { errors, destroyedCount, destroyedIds } = data.bulkDestroyJobArtifacts;
+ if (errors?.length) {
+ createAlert({
+ message: I18N_BULK_DELETE_PARTIAL_ERROR,
+ captureError: true,
+ error: new Error(errors.join(' ')),
+ });
+ }
+ if (destroyedIds?.length) {
+ this.$emit('deleted', destroyedCount);
+
+ // Remove deleted artifacts from the cache
+ destroyedIds.forEach((id) => {
+ removeArtifactFromStore(store, id, getJobArtifactsQuery, this.queryVariables);
+ });
+ store.gc();
+
+ this.$emit('clearSelectedArtifacts');
+ }
+ },
+ });
+ } catch (error) {
+ this.onError(error);
+ } finally {
+ this.isDeleting = false;
+ this.isModalVisible = false;
+ }
+ },
+ onError(error) {
+ createAlert({
+ message: I18N_BULK_DELETE_ERROR,
+ captureError: true,
+ error,
+ });
+ },
+ handleClearSelection() {
+ this.$emit('clearSelectedArtifacts');
+ },
+ handleModalShow() {
+ this.isModalVisible = true;
+ },
+ handleModalHide() {
+ this.isModalVisible = false;
+ },
+ },
+ i18n: {
+ banner: I18N_BULK_DELETE_BANNER,
+ clearSelection: I18N_BULK_DELETE_CLEAR_SELECTION,
+ deleteSelected: I18N_BULK_DELETE_DELETE_SELECTED,
+ modalTitle: I18N_BULK_DELETE_MODAL_TITLE,
+ modalBody: I18N_BULK_DELETE_BODY,
+ },
+ BULK_DELETE_MODAL_ID,
+};
+</script>
+<template>
+ <div class="gl-my-4 gl-p-4 gl-border-1 gl-border-solid gl-border-gray-100">
+ <div class="gl-display-flex gl-align-items-center">
+ <div>
+ <gl-sprintf :message="$options.i18n.banner(checkedCount)">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </div>
+ <div class="gl-ml-auto">
+ <gl-button
+ variant="default"
+ data-testid="bulk-delete-clear-button"
+ @click="handleClearSelection"
+ >
+ {{ $options.i18n.clearSelection }}
+ </gl-button>
+ <gl-button
+ variant="danger"
+ data-testid="bulk-delete-delete-button"
+ @click="handleModalShow"
+ >
+ {{ $options.i18n.deleteSelected }}
+ </gl-button>
+ </div>
+ </div>
+ <gl-modal
+ size="sm"
+ :modal-id="$options.BULK_DELETE_MODAL_ID"
+ :visible="isModalVisible"
+ :title="$options.i18n.modalTitle(checkedCount)"
+ :action-primary="modalActionPrimary"
+ :action-cancel="modalActionCancel"
+ @hide="handleModalHide"
+ @primary="onConfirmDelete"
+ >
+ <gl-sprintf
+ data-testid="bulk-delete-modal-content"
+ :message="$options.i18n.modalBody(checkedCount)"
+ />
+ </gl-modal>
+ </div>
+</template>
diff --git a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
index b9aae4bf4e5..7d675251ffd 100644
--- a/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
+++ b/app/assets/javascripts/artifacts/components/artifacts_table_row_details.vue
@@ -25,6 +25,10 @@ export default {
type: Object,
required: true,
},
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
queryVariables: {
type: Object,
required: true,
@@ -52,6 +56,9 @@ export default {
isLastRow(index) {
return index === this.artifacts.nodes.length - 1;
},
+ isSelected(item) {
+ return this.selectedArtifacts.includes(item.id);
+ },
showModal(item) {
this.deletingArtifactId = item.id;
this.deletingArtifactName = item.name;
@@ -98,7 +105,9 @@ export default {
<dynamic-scroller-item :item="item" :active="active" :class="{ active }">
<artifact-row
:artifact="item"
+ :is-selected="isSelected(item)"
:is-last-row="isLastRow(index)"
+ v-on="$listeners"
@delete="showModal(item)"
/>
</dynamic-scroller-item>
diff --git a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
index 1b7782c6860..ba4026190a2 100644
--- a/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
+++ b/app/assets/javascripts/artifacts/components/job_artifacts_table.vue
@@ -8,11 +8,13 @@ import {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
} from '@gitlab/ui';
import { createAlert } from '~/alert';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import getJobArtifactsQuery from '../graphql/queries/get_job_artifacts.query.graphql';
import { totalArtifactsSizeForJob, mapArchivesToJobNodes, mapBooleansToJobNodes } from '../utils';
import {
@@ -33,7 +35,11 @@ import {
INITIAL_NEXT_PAGE_CURSOR,
JOBS_PER_PAGE,
INITIAL_LAST_PAGE_SIZE,
+ BULK_DELETE_FEATURE_FLAG,
+ I18N_BULK_DELETE_CONFIRMATION_TOAST,
} from '../constants';
+import JobCheckbox from './job_checkbox.vue';
+import ArtifactsBulkDelete from './artifacts_bulk_delete.vue';
import ArtifactsTableRowDetails from './artifacts_table_row_details.vue';
import FeedbackBanner from './feedback_banner.vue';
@@ -56,11 +62,15 @@ export default {
GlBadge,
GlIcon,
GlPagination,
+ GlFormCheckbox,
CiIcon,
TimeAgo,
+ JobCheckbox,
+ ArtifactsBulkDelete,
ArtifactsTableRowDetails,
FeedbackBanner,
},
+ mixins: [glFeatureFlagsMixin()],
inject: ['projectPath', 'canDestroyArtifacts'],
apollo: {
jobArtifacts: {
@@ -94,6 +104,7 @@ export default {
jobArtifacts: [],
pageInfo: {},
expandedJobs: [],
+ selectedArtifacts: [],
pagination: INITIAL_PAGINATION_STATE,
};
},
@@ -118,6 +129,21 @@ export default {
nextPage() {
return Number(this.pageInfo.hasNextPage);
},
+ fields() {
+ return [
+ this.canBulkDestroyArtifacts && {
+ key: 'checkbox',
+ label: '',
+ },
+ ...this.$options.fields,
+ ];
+ },
+ anyArtifactsSelected() {
+ return Boolean(this.selectedArtifacts.length);
+ },
+ canBulkDestroyArtifacts() {
+ return this.glFeatures[BULK_DELETE_FEATURE_FLAG] && this.canDestroyArtifacts;
+ },
},
methods: {
refetchArtifacts() {
@@ -158,6 +184,19 @@ export default {
this.expandedJobs.splice(this.expandedJobs.indexOf(id), 1);
}
},
+ selectArtifact(artifactNode, checked) {
+ if (checked) {
+ this.selectedArtifacts.push(artifactNode.id);
+ } else {
+ this.selectedArtifacts.splice(this.selectedArtifacts.indexOf(artifactNode.id), 1);
+ }
+ },
+ clearSelectedArtifacts() {
+ this.selectedArtifacts = [];
+ },
+ showDeletedToast(deletedCount) {
+ this.$toast.show(I18N_BULK_DELETE_CONFIRMATION_TOAST(deletedCount));
+ },
downloadPath(job) {
return job.archive?.downloadPath;
},
@@ -217,9 +256,16 @@ export default {
<template>
<div>
<feedback-banner />
+ <artifacts-bulk-delete
+ v-if="canBulkDestroyArtifacts && anyArtifactsSelected"
+ :selected-artifacts="selectedArtifacts"
+ :query-variables="queryVariables"
+ @clearSelectedArtifacts="clearSelectedArtifacts"
+ @deleted="showDeletedToast"
+ />
<gl-table
:items="jobArtifacts"
- :fields="$options.fields"
+ :fields="fields"
:busy="$apollo.queries.jobArtifacts.loading"
stacked="sm"
details-td-class="gl-bg-gray-10! gl-p-0! gl-overflow-auto"
@@ -227,6 +273,29 @@ export default {
<template #table-busy>
<gl-loading-icon size="lg" />
</template>
+ <template v-if="canBulkDestroyArtifacts" #head(checkbox)>
+ <gl-form-checkbox
+ :disabled="!anyArtifactsSelected"
+ :checked="anyArtifactsSelected"
+ :indeterminate="anyArtifactsSelected"
+ @change="clearSelectedArtifacts"
+ />
+ </template>
+ <template
+ v-if="canBulkDestroyArtifacts"
+ #cell(checkbox)="{ item: { hasArtifacts, artifacts } }"
+ >
+ <job-checkbox
+ :has-artifacts="hasArtifacts"
+ :selected-artifacts="
+ artifacts.nodes.filter((node) => selectedArtifacts.includes(node.id))
+ "
+ :unselected-artifacts="
+ artifacts.nodes.filter((node) => !selectedArtifacts.includes(node.id))
+ "
+ @selectArtifact="selectArtifact"
+ />
+ </template>
<template
#cell(artifacts)="{ item: { id, artifacts, hasArtifacts }, toggleDetails, detailsShowing }"
>
@@ -323,8 +392,10 @@ export default {
<template #row-details="{ item: { artifacts } }">
<artifacts-table-row-details
:artifacts="artifacts"
+ :selected-artifacts="selectedArtifacts"
:query-variables="queryVariables"
@refetch="refetchArtifacts"
+ @selectArtifact="selectArtifact"
/>
</template>
</gl-table>
diff --git a/app/assets/javascripts/artifacts/components/job_checkbox.vue b/app/assets/javascripts/artifacts/components/job_checkbox.vue
new file mode 100644
index 00000000000..ce49b3f8678
--- /dev/null
+++ b/app/assets/javascripts/artifacts/components/job_checkbox.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlFormCheckbox } from '@gitlab/ui';
+
+export default {
+ name: 'JobCheckbox',
+ components: {
+ GlFormCheckbox,
+ },
+ props: {
+ hasArtifacts: {
+ type: Boolean,
+ required: true,
+ },
+ selectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ unselectedArtifacts: {
+ type: Array,
+ required: true,
+ },
+ },
+ computed: {
+ disabled() {
+ return !this.hasArtifacts;
+ },
+ checked() {
+ return this.hasArtifacts && this.unselectedArtifacts.length === 0;
+ },
+ indeterminate() {
+ return this.selectedArtifacts.length > 0 && this.unselectedArtifacts.length > 0;
+ },
+ },
+ methods: {
+ handleInput(checked) {
+ if (checked) {
+ this.unselectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, true));
+ } else {
+ this.selectedArtifacts.forEach((node) => this.$emit('selectArtifact', node, false));
+ }
+ },
+ },
+};
+</script>
+<template>
+ <gl-form-checkbox
+ :disabled="disabled"
+ :checked="checked"
+ :indeterminate="indeterminate"
+ @input="handleInput"
+ />
+</template>
diff --git a/app/assets/javascripts/artifacts/constants.js b/app/assets/javascripts/artifacts/constants.js
index da562b03bf8..4ac20d963d1 100644
--- a/app/assets/javascripts/artifacts/constants.js
+++ b/app/assets/javascripts/artifacts/constants.js
@@ -54,6 +54,45 @@ export const I18N_FEEDBACK_BANNER_BODY = s__(
export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey');
export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8';
+export const BULK_DELETE_FEATURE_FLAG = 'ciJobArtifactBulkDestroy';
+export const I18N_BULK_DELETE_BANNER = (count) =>
+ sprintf(
+ n__(
+ 'Artifacts|%{strongStart}%{count}%{strongEnd} artifact selected',
+ 'Artifacts|%{strongStart}%{count}%{strongEnd} artifacts selected',
+ count,
+ ),
+ {
+ count,
+ },
+ );
+export const I18N_BULK_DELETE_CLEAR_SELECTION = s__('Artifacts|Clear selection');
+export const I18N_BULK_DELETE_DELETE_SELECTED = s__('Artifacts|Delete selected');
+
+export const BULK_DELETE_MODAL_ID = 'artifacts-bulk-delete-modal';
+export const I18N_BULK_DELETE_MODAL_TITLE = (count) =>
+ n__('Artifacts|Delete %d artifact?', 'Artifacts|Delete %d artifacts?', count);
+export const I18N_BULK_DELETE_BODY = (count) =>
+ sprintf(
+ n__(
+ 'Artifacts|The selected artifact will be permanently deleted. Any reports generated from these artifacts will be empty.',
+ 'Artifacts|The selected artifacts will be permanently deleted. Any reports generated from these artifacts will be empty.',
+ count,
+ ),
+ { count },
+ );
+export const I18N_BULK_DELETE_ACTION = (count) =>
+ n__('Artifacts|Delete %d artifact', 'Artifacts|Delete %d artifacts', count);
+
+export const I18N_BULK_DELETE_PARTIAL_ERROR = s__(
+ 'Artifacts|An error occurred while deleting. Some artifacts may not have been deleted.',
+);
+export const I18N_BULK_DELETE_ERROR = s__(
+ 'Artifacts|Something went wrong while deleting. Please refresh the page to try again.',
+);
+export const I18N_BULK_DELETE_CONFIRMATION_TOAST = (count) =>
+ n__('Artifacts|%d selected artifact deleted', 'Artifacts|%d selected artifacts deleted', count);
+
export const INITIAL_CURRENT_PAGE = 1;
export const INITIAL_PREVIOUS_PAGE_CURSOR = '';
export const INITIAL_NEXT_PAGE_CURSOR = '';
diff --git a/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql b/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
new file mode 100644
index 00000000000..421b9258ca0
--- /dev/null
+++ b/app/assets/javascripts/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql
@@ -0,0 +1,7 @@
+mutation bulkDestroyJobArtifacts($projectId: ProjectID!, $ids: [CiJobArtifactID!]!) {
+ bulkDestroyJobArtifacts(input: { projectId: $projectId, ids: $ids }) {
+ destroyedCount
+ destroyedIds
+ errors
+ }
+}
diff --git a/app/assets/javascripts/artifacts/index.js b/app/assets/javascripts/artifacts/index.js
index a62b3daa961..6e795fd9bd7 100644
--- a/app/assets/javascripts/artifacts/index.js
+++ b/app/assets/javascripts/artifacts/index.js
@@ -1,3 +1,4 @@
+import { GlToast } from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
@@ -5,6 +6,7 @@ import { parseBoolean } from '~/lib/utils/common_utils';
import App from './components/app.vue';
Vue.use(VueApollo);
+Vue.use(GlToast);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@@ -17,13 +19,19 @@ export const initArtifactsTable = () => {
return false;
}
- const { projectPath, canDestroyArtifacts, artifactsManagementFeedbackImagePath } = el.dataset;
+ const {
+ projectPath,
+ projectId,
+ canDestroyArtifacts,
+ artifactsManagementFeedbackImagePath,
+ } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
projectPath,
+ projectId,
canDestroyArtifacts: parseBoolean(canDestroyArtifacts),
artifactsManagementFeedbackImagePath,
},
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
index 891c40482d3..1192f0bf418 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/text_editor.vue
@@ -2,6 +2,7 @@
import { EDITOR_READY_EVENT } from '~/editor/constants';
import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import SourceEditor from '~/vue_shared/components/source_editor.vue';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import { SOURCE_EDITOR_DEBOUNCE } from '../../constants';
export default {
@@ -16,6 +17,12 @@ export default {
},
inject: ['ciConfigPath'],
inheritAttrs: false,
+ created() {
+ eventHub.$on(SCROLL_EDITOR_TO_BOTTOM, this.scrollEditorToBottom);
+ },
+ beforeDestroy() {
+ eventHub.$off(SCROLL_EDITOR_TO_BOTTOM, this.scrollEditorToBottom);
+ },
methods: {
onCiConfigUpdate(content) {
this.$emit('updateCiConfig', content);
@@ -24,6 +31,10 @@ export default {
instance.use({ definition: CiSchemaExtension });
instance.registerCiSchema();
},
+ scrollEditorToBottom() {
+ const editor = this.$refs.editor.getEditor();
+ editor.setScrollTop(editor.getScrollHeight());
+ },
},
readyEvent: EDITOR_READY_EVENT,
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
new file mode 100644
index 00000000000..c2ae7d7be49
--- /dev/null
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlFormGroup, GlAccordionItem, GlFormInput } from '@gitlab/ui';
+import { i18n } from '../constants';
+
+export default {
+ i18n,
+ components: {
+ GlAccordionItem,
+ GlFormInput,
+ GlFormGroup,
+ },
+ props: {
+ job: {
+ type: Object,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <gl-accordion-item :title="$options.i18n.IMAGE">
+ <div class="gl-display-flex">
+ <gl-form-group class="gl-flex-grow-1 gl-mr-3" :label="$options.i18n.IMAGE_NAME">
+ <gl-form-input
+ :value="job.image.name"
+ data-testid="image-name-input"
+ @input="$emit('update-job', 'image.name', $event)"
+ />
+ </gl-form-group>
+ <gl-form-group class="gl-flex-grow-1" :label="$options.i18n.IMAGE_ENTRYPOINT">
+ <gl-form-input
+ :value="job.image.entrypoint.join(' ')"
+ data-testid="image-entrypoint-input"
+ @input="$emit('update-job', 'image.entrypoint', $event.split(' '))"
+ />
+ </gl-form-group>
+ </div>
+ </gl-accordion-item>
+</template>
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
index 05a616596e2..994a6e719fe 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/constants.js
@@ -9,12 +9,12 @@ export const JOB_TEMPLATE = {
tags: [],
image: {
name: '',
- entrypoint: '',
+ entrypoint: [''],
},
services: [
{
name: '',
- entrypoint: '',
+ entrypoint: [''],
},
],
artifacts: {
@@ -34,5 +34,8 @@ export const i18n = {
JOB_SETUP: s__('JobAssistant|Job Setup'),
STAGE: s__('JobAssistant|Stage (optional)'),
TAGS: s__('JobAssistant|Tags (optional)'),
+ IMAGE: s__('JobAssistant|Image'),
+ IMAGE_NAME: s__('JobAssistant|Image name (optional)'),
+ IMAGE_ENTRYPOINT: s__('JobAssistant|Image entrypoint (optional)'),
THIS_FIELD_IS_REQUIRED: __('This field is required'),
};
diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
index d9df32ad84e..9f68b97b329 100644
--- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
+++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue
@@ -10,6 +10,7 @@ import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
import { DRAWER_CONTAINER_CLASS, JOB_TEMPLATE, i18n } from './constants';
import { removeEmptyObj, trimFields } from './utils';
import JobSetupItem from './accordion_items/job_setup_item.vue';
+import ImageItem from './accordion_items/image_item.vue';
export default {
i18n,
@@ -18,6 +19,7 @@ export default {
GlAccordion,
GlButton,
JobSetupItem,
+ ImageItem,
},
props: {
isVisible: {
@@ -131,6 +133,7 @@ export default {
:is-script-valid="isScriptValid"
@update-job="updateJob"
/>
+ <image-item :job="job" @update-job="updateJob" />
</gl-accordion>
<template #footer>
<div class="gl-display-flex gl-justify-content-end">
diff --git a/app/assets/javascripts/content_editor/components/content_editor.vue b/app/assets/javascripts/content_editor/components/content_editor.vue
index 09a5bab8d37..9e08a257abf 100644
--- a/app/assets/javascripts/content_editor/components/content_editor.vue
+++ b/app/assets/javascripts/content_editor/components/content_editor.vue
@@ -56,6 +56,11 @@ export default {
required: false,
default: '',
},
+ placeholder: {
+ type: String,
+ required: false,
+ default: '',
+ },
autofocus: {
type: [String, Boolean],
required: false,
@@ -67,6 +72,16 @@ export default {
required: false,
default: '',
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ editable: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
},
data() {
return {
@@ -81,9 +96,20 @@ export default {
this.setSerializedContent(markdown);
}
},
+ editable(value) {
+ this.contentEditor.setEditable(value);
+ },
},
created() {
- const { renderMarkdown, uploadsPath, extensions, serializerConfig, autofocus } = this;
+ const {
+ renderMarkdown,
+ uploadsPath,
+ extensions,
+ serializerConfig,
+ autofocus,
+ drawioEnabled,
+ editable,
+ } = this;
// This is a non-reactive attribute intentionally since this is a complex object.
this.contentEditor = createContentEditor({
@@ -91,8 +117,10 @@ export default {
uploadsPath,
extensions,
serializerConfig,
+ drawioEnabled,
tiptapOptions: {
autofocus,
+ editable,
},
});
},
@@ -109,10 +137,10 @@ export default {
try {
await this.contentEditor.setSerializedContent(markdown);
- this.contentEditor.setEditable(true);
this.notifyLoadingSuccess();
this.latestMarkdown = markdown;
} catch {
+ this.contentEditor.setEditable(false);
this.contentEditor.eventHub.$emit(ALERT_EVENT, {
message: __(
'An error occurred while trying to render the content editor. Please try again.',
@@ -120,10 +148,10 @@ export default {
variant: VARIANT_DANGER,
actionLabel: __('Retry'),
action: () => {
+ this.contentEditor.setEditable(true);
this.setSerializedContent(markdown);
},
});
- this.contentEditor.setEditable(false);
this.notifyLoadingError();
}
},
@@ -189,6 +217,9 @@ export default {
<code-block-bubble-menu />
<link-bubble-menu />
<media-bubble-menu />
+ <div v-if="placeholder && !markdown && !focused" class="gl-absolute gl-text-gray-400">
+ {{ placeholder }}
+ </div>
<tiptap-editor-content
class="md"
data-testid="content_editor_editablebox"
diff --git a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
index 7edc99d0e6b..99ba8c51948 100644
--- a/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
+++ b/app/assets/javascripts/content_editor/components/toolbar_more_dropdown.vue
@@ -9,7 +9,7 @@ export default {
GlDisclosureDropdown,
GlTooltip,
},
- inject: ['tiptapEditor'],
+ inject: ['tiptapEditor', 'contentEditor'],
data() {
return {
toggleId: uniqueId('dropdown-toggle-btn-'),
@@ -53,10 +53,14 @@ export default {
text: __('PlantUML diagram'),
action: () => this.insert('diagram', { language: 'plantuml' }),
},
- {
- text: __('Create or edit diagram'),
- action: () => this.execute('createOrEditDiagram', 'drawioDiagram'),
- },
+ ...(this.contentEditor.drawioEnabled
+ ? [
+ {
+ text: __('Create or edit diagram'),
+ action: () => this.execute('createOrEditDiagram', 'drawioDiagram'),
+ },
+ ]
+ : []),
{
text: __('Table of contents'),
action: () => this.execute('insertTableOfContents', 'tableOfContents'),
diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js
index 514ab9699bc..a988e1df2a6 100644
--- a/app/assets/javascripts/content_editor/services/content_editor.js
+++ b/app/assets/javascripts/content_editor/services/content_editor.js
@@ -1,12 +1,14 @@
/* eslint-disable no-underscore-dangle */
export class ContentEditor {
- constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub }) {
+ constructor({ tiptapEditor, serializer, deserializer, assetResolver, eventHub, drawioEnabled }) {
this._tiptapEditor = tiptapEditor;
this._serializer = serializer;
this._deserializer = deserializer;
this._eventHub = eventHub;
this._assetResolver = assetResolver;
this._pristineDoc = null;
+
+ this.drawioEnabled = drawioEnabled;
}
get tiptapEditor() {
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index b9740894b2c..9d536793287 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -87,6 +87,7 @@ export const createContentEditor = ({
extensions = [],
serializerConfig = { marks: {}, nodes: {} },
tiptapOptions,
+ drawioEnabled = false,
} = {}) => {
if (!isFunction(renderMarkdown)) {
throw new Error(PROVIDE_SERIALIZER_OR_RENDERER_ERROR);
@@ -110,7 +111,6 @@ export const createContentEditor = ({
DetailsContent,
Document,
Diagram,
- DrawioDiagram.configure({ uploadsPath, renderMarkdown }),
Dropcursor,
Emoji,
Figure,
@@ -159,6 +159,9 @@ export const createContentEditor = ({
];
const allExtensions = [...builtInContentEditorExtensions, ...extensions];
+
+ if (drawioEnabled) allExtensions.push(DrawioDiagram.configure({ uploadsPath, renderMarkdown }));
+
const trackedExtensions = allExtensions.map(trackInputRulesAndShortcuts);
const tiptapEditor = createTiptapEditor({ extensions: trackedExtensions, ...tiptapOptions });
const serializer = createMarkdownSerializer({ serializerConfig });
@@ -175,5 +178,6 @@ export const createContentEditor = ({
eventHub,
deserializer,
assetResolver,
+ drawioEnabled,
});
};
diff --git a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
index e65644d2c5c..91f8aaf6324 100644
--- a/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
+++ b/app/assets/javascripts/content_editor/services/gl_api_markdown_deserializer.js
@@ -18,10 +18,7 @@ export default ({ render }) => {
*/
return {
deserialize: async ({ schema, markdown }) => {
- const html = await render(markdown);
-
- if (!html) return {};
-
+ const html = markdown ? await render(markdown) : '<p></p>';
const parser = new DOMParser();
const { body } = parser.parseFromString(`<body>${html}</body>`, 'text/html');
diff --git a/app/assets/javascripts/header_search/components/app.vue b/app/assets/javascripts/header_search/components/app.vue
index ace0d77c431..c0a06706fc6 100644
--- a/app/assets/javascripts/header_search/components/app.vue
+++ b/app/assets/javascripts/header_search/components/app.vue
@@ -12,10 +12,20 @@ import { debounce } from 'lodash';
import { visitUrl } from '~/lib/utils/url_utility';
import { truncate } from '~/lib/utils/text_utility';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
-import { s__, sprintf } from '~/locale';
+import { sprintf } from '~/locale';
import Tracking from '~/tracking';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import {
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
+} from '~/vue_shared/global_search/constants';
+import {
FIRST_DROPDOWN_INDEX,
SEARCH_BOX_INDEX,
SEARCH_INPUT_DESCRIPTION,
@@ -34,26 +44,14 @@ import HeaderSearchScopedItems from './header_search_scoped_items.vue';
export default {
name: 'HeaderSearchApp',
i18n: {
- searchGitlab: s__('GlobalSearch|Search GitLab'),
- searchInputDescribeByNoDropdown: s__(
- 'GlobalSearch|Type and press the enter key to submit search.',
- ),
- searchInputDescribeByWithDropdown: s__(
- 'GlobalSearch|Type for new suggestions to appear below.',
- ),
- searchDescribedByDefault: s__(
- 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
- ),
- searchDescribedByUpdated: s__(
- 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
- ),
- searchResultsLoading: s__('GlobalSearch|Search results are loading'),
- searchResultsScope: s__('GlobalSearch|in %{scope}'),
- kbdHelp: sprintf(
- s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
- { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
- false,
- ),
+ SEARCH_GITLAB,
+ SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN,
+ SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN,
+ SEARCH_DESCRIBED_BY_DEFAULT,
+ SEARCH_DESCRIBED_BY_UPDATED,
+ SEARCH_RESULTS_LOADING,
+ SEARCH_RESULTS_SCOPE,
+ KBD_HELP,
},
directives: { Outside, GlTooltip: GlTooltipDirective, GlResizeObserverDirective },
components: {
@@ -113,9 +111,9 @@ export default {
searchInputDescribeBy() {
if (this.isLoggedIn) {
- return this.$options.i18n.searchInputDescribeByWithDropdown;
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN;
}
- return this.$options.i18n.searchInputDescribeByNoDropdown;
+ return this.$options.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN;
},
dropdownResultsDescription() {
if (!this.showSearchDropdown) {
@@ -123,14 +121,14 @@ export default {
}
if (this.showDefaultItems) {
- return sprintf(this.$options.i18n.searchDescribedByDefault, {
+ return sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_DEFAULT, {
count: this.searchOptions.length,
});
}
return this.loading
- ? this.$options.i18n.searchResultsLoading
- : sprintf(this.$options.i18n.searchDescribedByUpdated, {
+ ? this.$options.i18n.SEARCH_RESULTS_LOADING
+ : sprintf(this.$options.i18n.SEARCH_DESCRIBED_BY_UPDATED, {
count: this.searchOptions.length,
});
},
@@ -154,7 +152,7 @@ export default {
return this.searchBarItem?.icon;
},
scopeTokenTitle() {
- return sprintf(this.$options.i18n.searchResultsScope, {
+ return sprintf(this.$options.i18n.SEARCH_RESULTS_SCOPE, {
scope: this.infieldHelpContent,
});
},
@@ -230,7 +228,7 @@ export default {
<form
v-outside="closeDropdown"
role="search"
- :aria-label="$options.i18n.searchGitlab"
+ :aria-label="$options.i18n.SEARCH_GITLAB"
class="header-search gl-relative gl-rounded-base gl-w-full"
:class="searchBarClasses"
data-testid="header-search-form"
@@ -243,7 +241,7 @@ export default {
class="gl-z-index-1"
data-qa-selector="search_term_field"
autocomplete="off"
- :placeholder="$options.i18n.searchGitlab"
+ :placeholder="$options.i18n.SEARCH_GITLAB"
:aria-activedescendant="currentFocusedId"
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
@focus="openDropdown"
@@ -267,7 +265,7 @@ export default {
:size="16"
/>{{
getTruncatedScope(
- sprintf($options.i18n.searchResultsScope, {
+ sprintf($options.i18n.SEARCH_RESULTS_SCOPE, {
scope: infieldHelpContent,
}),
)
@@ -277,7 +275,7 @@ export default {
v-show="!isFocused"
v-gl-tooltip.bottom.hover.html
class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
- :title="$options.i18n.kbdHelp"
+ :title="$options.i18n.KBD_HELP"
>/</kbd
>
<span :id="$options.SEARCH_INPUT_DESCRIPTION" role="region" class="gl-sr-only">{{
diff --git a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
index c85fb4f4158..1838214def6 100644
--- a/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_autocomplete_items.vue
@@ -9,27 +9,23 @@ import {
} from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
-import { s__ } from '~/locale';
import highlight from '~/lib/utils/highlight';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { truncateNamespace } from '~/lib/utils/text_utility';
-
import {
GROUPS_CATEGORY,
PROJECTS_CATEGORY,
MERGE_REQUEST_CATEGORY,
ISSUES_CATEGORY,
RECENT_EPICS_CATEGORY,
- LARGE_AVATAR_PX,
- SMALL_AVATAR_PX,
-} from '../constants';
+ AUTOCOMPLETE_ERROR_MESSAGE,
+} from '~/vue_shared/global_search/constants';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
i18n: {
- autocompleteErrorMessage: s__(
- 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
- ),
+ AUTOCOMPLETE_ERROR_MESSAGE,
},
components: {
GlDropdownItem,
@@ -165,7 +161,7 @@ export default {
:dismissible="false"
variant="danger"
>
- {{ $options.i18n.autocompleteErrorMessage }}
+ {{ $options.i18n.AUTOCOMPLETE_ERROR_MESSAGE }}
</gl-alert>
</div>
</template>
diff --git a/app/assets/javascripts/header_search/components/header_search_default_items.vue b/app/assets/javascripts/header_search/components/header_search_default_items.vue
index 04deaba7b0f..f0d398297e9 100644
--- a/app/assets/javascripts/header_search/components/header_search_default_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_default_items.vue
@@ -1,12 +1,12 @@
<script>
import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
-import { __ } from '~/locale';
+import { ALL_GITLAB } from '~/vue_shared/global_search/constants';
export default {
name: 'HeaderSearchDefaultItems',
i18n: {
- allGitLab: __('All GitLab'),
+ ALL_GITLAB,
},
components: {
GlDropdownSectionHeader,
@@ -26,7 +26,7 @@ export default {
return (
this.searchContext?.project?.name ||
this.searchContext?.group?.name ||
- this.$options.i18n.allGitLab
+ this.$options.i18n.ALL_GITLAB
);
},
},
diff --git a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
index f5be1bcb786..1ef88492b23 100644
--- a/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
+++ b/app/assets/javascripts/header_search/components/header_search_scoped_items.vue
@@ -3,10 +3,14 @@ import { GlDropdownItem, GlIcon, GlToken } from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import { s__, sprintf } from '~/locale';
import { truncate } from '~/lib/utils/text_utility';
+import { SCOPED_SEARCH_ITEM_ARIA_LABEL } from '~/vue_shared/global_search/constants';
import { SCOPE_TOKEN_MAX_LENGTH } from '../constants';
export default {
name: 'HeaderSearchScopedItems',
+ i18n: {
+ SCOPED_SEARCH_ITEM_ARIA_LABEL,
+ },
components: {
GlDropdownItem,
GlIcon,
@@ -28,7 +32,7 @@ export default {
return this.currentFocusedOption?.html_id === option.html_id;
},
ariaLabel(option) {
- return sprintf(s__('GlobalSearch| %{search} %{description} %{scope}'), {
+ return sprintf(this.$options.i18n.SCOPED_SEARCH_ITEM_ARIA_LABEL, {
search: this.search,
description: option.description || option.icon,
scope: option.scope || '',
diff --git a/app/assets/javascripts/header_search/constants.js b/app/assets/javascripts/header_search/constants.js
index 76fbf664913..b9bb4e573fd 100644
--- a/app/assets/javascripts/header_search/constants.js
+++ b/app/assets/javascripts/header_search/constants.js
@@ -1,41 +1,9 @@
-import { s__ } from '~/locale';
-
-export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
-
-export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
-
-export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
-
-export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
-
-export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
-
-export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
-
export const ICON_PROJECT = 'project';
export const ICON_GROUP = 'group';
export const ICON_SUBGROUP = 'subgroup';
-export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
-
-export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
-
-export const USERS_CATEGORY = s__('GlobalSearch|Users');
-
-export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
-
-export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
-
-export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
-
-export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
-
-export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
-
-export const HELP_CATEGORY = s__('GlobalSearch|Help');
-
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
@@ -60,18 +28,6 @@ export const IS_SEARCHING = 'is-searching';
export const IS_FOCUSED = 'is-focused';
export const IS_NOT_FOCUSED = 'is-not-focused';
-export const DROPDOWN_ORDER = [
- MERGE_REQUEST_CATEGORY,
- ISSUES_CATEGORY,
- RECENT_EPICS_CATEGORY,
- GROUPS_CATEGORY,
- PROJECTS_CATEGORY,
- USERS_CATEGORY,
- IN_THIS_PROJECT_CATEGORY,
- SETTINGS_CATEGORY,
- HELP_CATEGORY,
-];
-
export const FETCH_TYPES = ['generic', 'search'];
export const SEARCH_INPUT_FIELD_MAX_WIDTH = '640px';
diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js
index 3dec857930d..f86463b94d1 100644
--- a/app/assets/javascripts/header_search/store/getters.js
+++ b/app/assets/javascripts/header_search/store/getters.js
@@ -7,14 +7,16 @@ import {
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
- ICON_GROUP,
- ICON_SUBGROUP,
- ICON_PROJECT,
MSG_IN_ALL_GITLAB,
PROJECTS_CATEGORY,
GROUPS_CATEGORY,
- SEARCH_SHORTCUTS_MIN_CHARACTERS,
DROPDOWN_ORDER,
+} from '~/vue_shared/global_search/constants';
+import {
+ ICON_GROUP,
+ ICON_SUBGROUP,
+ ICON_PROJECT,
+ SEARCH_SHORTCUTS_MIN_CHARACTERS,
} from '../constants';
export const searchQuery = (state) => {
diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue
index 1e489b1dcab..d78b48e0a6d 100644
--- a/app/assets/javascripts/notes/components/comment_form.vue
+++ b/app/assets/javascripts/notes/components/comment_form.vue
@@ -1,9 +1,7 @@
<script>
import { GlAlert, GlButton, GlIcon, GlFormCheckbox, GlTooltipDirective } from '@gitlab/ui';
-import Autosize from 'autosize';
import $ from 'jquery';
import { mapActions, mapGetters, mapState } from 'vuex';
-import Autosave from '~/autosave';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/alert';
import { badgeState } from '~/issuable/components/status_box.vue';
@@ -15,7 +13,7 @@ import {
slugifyWithUnderscore,
} from '~/lib/utils/text_utility';
import { sprintf } from '~/locale';
-import MarkdownField from '~/vue_shared/components/markdown/field.vue';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
@@ -36,7 +34,7 @@ export default {
components: {
NoteSignedOutWidget,
DiscussionLockedWidget,
- MarkdownField,
+ MarkdownEditor,
GlAlert,
GlButton,
TimelineEntryItem,
@@ -62,6 +60,14 @@ export default {
errors: [],
noteIsInternal: false,
isSubmitting: false,
+ formFieldProps: {
+ 'aria-label': this.$options.i18n.comment,
+ placeholder: this.$options.i18n.bodyPlaceholder,
+ id: 'note-body',
+ name: 'note[note]',
+ class: 'js-note-text note-textarea js-gfm-input markdown-area',
+ 'data-qa-selector': 'comment_field',
+ },
};
},
computed: {
@@ -96,11 +102,6 @@ export default {
}
return this.noteType === constants.COMMENT ? comment : startThread;
},
- textareaPlaceholder() {
- return this.noteIsInternal
- ? this.$options.i18n.bodyPlaceholderInternal
- : this.$options.i18n.bodyPlaceholder;
- },
discussionsRequireResolution() {
return this.getNoteableData.noteableType === constants.MERGE_REQUEST_NOTEABLE_TYPE;
},
@@ -181,14 +182,27 @@ export default {
containsLink() {
return ATTACHMENT_REGEXP.test(this.note);
},
+ autosaveKey() {
+ if (this.isLoggedIn) {
+ const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
+ return `${this.$options.i18n.note}/${noteableType}/${this.getNoteableData.id}`;
+ }
+
+ return null;
+ },
+ },
+ watch: {
+ noteIsInternal(val) {
+ this.formFieldProps.placeholder = val
+ ? this.$options.i18n.bodyPlaceholderInternal
+ : this.$options.i18n.bodyPlaceholder;
+ },
},
mounted() {
// jQuery is needed here because it is a custom event being dispatched with jQuery.
$(document).on('issuable:change', (e, isClosed) => {
this.toggleIssueLocalState(isClosed ? STATUS_CLOSED : STATUS_REOPENED);
});
-
- this.initAutoSave();
},
methods: {
...mapActions([
@@ -233,7 +247,6 @@ export default {
}
this.note = ''; // Empty textarea while being requested. Repopulate in catch
- this.resizeTextarea();
this.stopPolling();
this.isSubmitting = true;
@@ -250,7 +263,6 @@ export default {
.catch(({ response }) => {
this.handleSaveError(response);
- this.discard(false);
this.note = noteData.data.note.note; // Restore textarea content.
this.removePlaceholderNotes();
})
@@ -287,20 +299,10 @@ export default {
}),
);
},
- discard(shouldClear = true) {
- // `blur` is needed to clear slash commands autocomplete cache if event fired.
- // `focus` is needed to remain cursor in the textarea.
- this.$refs.textarea.blur();
- this.$refs.textarea.focus();
-
- if (shouldClear) {
- this.note = '';
- this.noteIsInternal = false;
- this.resizeTextarea();
- this.$refs.markdownField.previewMarkdown = false;
- }
-
- this.autosave.reset();
+ discard() {
+ this.note = '';
+ this.noteIsInternal = false;
+ this.$refs.markdownEditor.togglePreview(false);
},
editCurrentUserLastNote() {
if (this.note === '') {
@@ -313,28 +315,15 @@ export default {
}
}
},
- initAutoSave() {
- if (this.isLoggedIn) {
- const noteableType = capitalizeFirstCharacter(convertToCamelCase(this.noteableType));
-
- this.autosave = new Autosave(this.$refs.textarea, [
- this.$options.i18n.note,
- noteableType,
- this.getNoteableData.id,
- ]);
- }
- },
- resizeTextarea() {
- this.$nextTick(() => {
- Autosize.update(this.$refs.textarea);
- });
- },
hasEmailParticipants() {
return this.getNoteableData.issue_email_participants?.length;
},
dismissError(index) {
this.errors.splice(index, 1);
},
+ onInput(value) {
+ this.note = value;
+ },
},
};
</script>
@@ -363,35 +352,24 @@ export default {
:noteable-type="noteableType"
:contains-link="containsLink"
>
- <markdown-field
- ref="markdownField"
- :is-submitting="isSubmitting"
- :markdown-preview-path="markdownPreviewPath"
+ <markdown-editor
+ ref="markdownEditor"
+ :enable-content-editor="Boolean(glFeatures.contentEditorOnIssues)"
+ :value="note"
+ :render-markdown-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
- :quick-actions-docs-path="quickActionsDocsPath"
:add-spacing-classes="false"
- :textarea-value="note"
- >
- <template #textarea>
- <textarea
- id="note-body"
- ref="textarea"
- v-model="note"
- dir="auto"
- :disabled="isSubmitting"
- name="note[note]"
- class="note-textarea js-vue-comment-form js-note-text js-gfm-input js-autosize markdown-area"
- data-qa-selector="comment_field"
- data-testid="comment-field"
- data-supports-quick-actions="true"
- :aria-label="$options.i18n.comment"
- :placeholder="textareaPlaceholder"
- @keydown.up="editCurrentUserLastNote()"
- @keydown.meta.enter="handleEnter()"
- @keydown.ctrl.enter="handleEnter()"
- ></textarea>
- </template>
- </markdown-field>
+ :quick-actions-docs-path="quickActionsDocsPath"
+ :form-field-props="formFieldProps"
+ :autosave-key="autosaveKey"
+ :disabled="isSubmitting"
+ supports-quick-actions
+ autofocus
+ @keydown.up="editCurrentUserLastNote()"
+ @keydown.meta.enter="handleEnter()"
+ @keydown.ctrl.enter="handleEnter()"
+ @input="onInput"
+ />
</comment-field-layout>
<div class="note-form-actions">
<template v-if="hasDrafts">
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
index 4f89d217623..f6f816f435c 100644
--- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -1,5 +1,5 @@
<script>
-import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
+import { GlTooltipDirective, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { n__ } from '~/locale';
import Tracking from '~/tracking';
@@ -28,7 +28,6 @@ export default {
DeleteButton,
GlSprintf,
GlButton,
- GlIcon,
ListItem,
GlSkeletonLoader,
CleanupStatus,
@@ -80,8 +79,8 @@ export default {
},
tagsCountText() {
return n__(
- 'ContainerRegistry|%{count} Tag',
- 'ContainerRegistry|%{count} Tags',
+ 'ContainerRegistry|%{count} tag',
+ 'ContainerRegistry|%{count} tags',
this.item.tagsCount,
);
},
@@ -152,7 +151,6 @@ export default {
<span v-if="deleting">{{ $options.i18n.ROW_SCHEDULED_FOR_DELETION }}</span>
<template v-else>
<span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
- <gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
{{ item.tagsCount }}
diff --git a/app/assets/javascripts/super_sidebar/components/context_switcher.vue b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
index 23340607825..b3d4ecdda47 100644
--- a/app/assets/javascripts/super_sidebar/components/context_switcher.vue
+++ b/app/assets/javascripts/super_sidebar/components/context_switcher.vue
@@ -7,8 +7,8 @@ import searchUserProjectsAndGroups from '../graphql/queries/search_user_groups_a
import { contextSwitcherItems } from '../mock_data';
import { trackContextAccess, formatContextSwitcherItems } from '../utils';
import NavItem from './nav_item.vue';
-import FrequentProjectsList from './frequent_projects_list.vue';
-import FrequentGroupsList from './frequent_groups_list.vue';
+import ProjectsList from './projects_list.vue';
+import GroupsList from './groups_list.vue';
export default {
i18n: {
@@ -55,8 +55,8 @@ export default {
components: {
GlSearchBoxByType,
NavItem,
- FrequentProjectsList,
- FrequentGroupsList,
+ ProjectsList,
+ GroupsList,
},
props: {
username: {
@@ -119,14 +119,13 @@ export default {
<nav-item :item="$options.contextSwitcherItems.explore" />
</ul>
</li>
- <frequent-projects-list
- :class="{ 'gl-border-t-0': isSearch }"
+ <projects-list
:username="username"
:view-all-link="projectsPath"
:is-search="isSearch"
:search-results="projects"
/>
- <frequent-groups-list
+ <groups-list
:username="username"
:view-all-link="groupsPath"
:is-search="isSearch"
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
index dfd983a5a77..5269c7f8d5e 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/frequent_items_list.vue
@@ -1,32 +1,22 @@
<script>
import * as Sentry from '@sentry/browser';
-import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
import AccessorUtilities from '~/lib/utils/accessor';
import { getTopFrequentItems, formatContextSwitcherItems } from '../utils';
-import NavItem from './nav_item.vue';
+import ItemsList from './items_list.vue';
export default {
components: {
- ProjectAvatar,
- NavItem,
+ ItemsList,
},
props: {
title: {
type: String,
required: true,
},
- searchTitle: {
- type: String,
- required: true,
- },
pristineText: {
type: String,
required: true,
},
- noResultsText: {
- type: String,
- required: true,
- },
storageKey: {
type: String,
required: true,
@@ -35,16 +25,6 @@ export default {
type: Number,
required: true,
},
- isSearch: {
- type: Boolean,
- required: false,
- default: false,
- },
- searchResults: {
- type: Array,
- required: false,
- default: () => [],
- },
},
data() {
return {
@@ -52,17 +32,8 @@ export default {
};
},
computed: {
- items() {
- return this.isSearch ? this.searchResults : this.cachedFrequentItems;
- },
isEmpty() {
- return !this.items.length;
- },
- listTitle() {
- return this.isSearch ? this.searchTitle : this.title;
- },
- emptyText() {
- return this.isSearch ? this.noResultsText : this.pristineText;
+ return !this.cachedFrequentItems.length;
},
},
created() {
@@ -92,29 +63,15 @@ export default {
aria-hidden="true"
class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
>
- {{ listTitle }}
+ {{ title }}
</div>
<div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
- {{ emptyText }}
+ {{ pristineText }}
</div>
- <ul :aria-label="title" class="gl-p-0 gl-list-style-none">
- <nav-item
- v-for="item in items"
- :key="item.id"
- :item="item"
- :link-classes="{ 'gl-py-2!': true }"
- >
- <template #icon>
- <project-avatar
- :project-id="item.id"
- :project-name="item.title"
- :project-avatar-url="item.avatar"
- :size="24"
- aria-hidden="true"
- />
- </template>
- </nav-item>
- <slot name="view-all-items"></slot>
- </ul>
+ <items-list :aria-label="title" :items="cachedFrequentItems">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
</li>
</template>
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_groups_list.vue b/app/assets/javascripts/super_sidebar/components/groups_list.vue
index 4cdf052cb42..78b5ed2d31e 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_groups_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/groups_list.vue
@@ -2,12 +2,14 @@
import { s__ } from '~/locale';
import { MAX_FREQUENT_GROUPS_COUNT } from '../constants';
import FrequentItemsList from './frequent_items_list.vue';
+import SearchResults from './search_results.vue';
import NavItem from './nav_item.vue';
export default {
MAX_FREQUENT_GROUPS_COUNT,
components: {
FrequentItemsList,
+ SearchResults,
NavItem,
},
props: {
@@ -52,15 +54,22 @@ export default {
</script>
<template>
+ <search-results
+ v-if="isSearch"
+ :title="$options.i18n.searchTitle"
+ :no-results-text="$options.i18n.noResultsText"
+ :search-results="searchResults"
+ >
+ <template #view-all-items>
+ <nav-item :item="viewAllItem" />
+ </template>
+ </search-results>
<frequent-items-list
+ v-else
:title="$options.i18n.title"
- :search-title="$options.i18n.searchTitle"
:storage-key="storageKey"
:max-items="$options.MAX_FREQUENT_GROUPS_COUNT"
:pristine-text="$options.i18n.pristineText"
- :no-results-text="$options.i18n.noResultsText"
- :is-search="isSearch"
- :search-results="searchResults"
>
<template #view-all-items>
<nav-item :item="viewAllItem" />
diff --git a/app/assets/javascripts/super_sidebar/components/items_list.vue b/app/assets/javascripts/super_sidebar/components/items_list.vue
new file mode 100644
index 00000000000..0a72105fcc4
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/items_list.vue
@@ -0,0 +1,40 @@
+<script>
+import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
+import NavItem from './nav_item.vue';
+
+export default {
+ components: {
+ ProjectAvatar,
+ NavItem,
+ },
+ props: {
+ items: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <nav-item
+ v-for="item in items"
+ :key="item.id"
+ :item="item"
+ :link-classes="{ 'gl-py-2!': true }"
+ >
+ <template #icon>
+ <project-avatar
+ :project-id="item.id"
+ :project-name="item.title"
+ :project-avatar-url="item.avatar"
+ :size="24"
+ aria-hidden="true"
+ />
+ </template>
+ </nav-item>
+ <slot name="view-all-items"></slot>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/frequent_projects_list.vue b/app/assets/javascripts/super_sidebar/components/projects_list.vue
index 8653c4787d5..a545de06bd4 100644
--- a/app/assets/javascripts/super_sidebar/components/frequent_projects_list.vue
+++ b/app/assets/javascripts/super_sidebar/components/projects_list.vue
@@ -2,12 +2,14 @@
import { s__ } from '~/locale';
import { MAX_FREQUENT_PROJECTS_COUNT } from '../constants';
import FrequentItemsList from './frequent_items_list.vue';
+import SearchResults from './search_results.vue';
import NavItem from './nav_item.vue';
export default {
MAX_FREQUENT_PROJECTS_COUNT,
components: {
FrequentItemsList,
+ SearchResults,
NavItem,
},
props: {
@@ -52,15 +54,23 @@ export default {
</script>
<template>
+ <search-results
+ v-if="isSearch"
+ class="gl-border-t-0"
+ :title="$options.i18n.searchTitle"
+ :no-results-text="$options.i18n.noResultsText"
+ :search-results="searchResults"
+ >
+ <template #view-all-items>
+ <nav-item :item="viewAllItem" />
+ </template>
+ </search-results>
<frequent-items-list
+ v-else
:title="$options.i18n.title"
- :search-title="$options.i18n.searchTitle"
:storage-key="storageKey"
:max-items="$options.MAX_FREQUENT_PROJECTS_COUNT"
:pristine-text="$options.i18n.pristineText"
- :no-results-text="$options.i18n.noResultsText"
- :is-search="isSearch"
- :search-results="searchResults"
>
<template #view-all-items>
<nav-item :item="viewAllItem" />
diff --git a/app/assets/javascripts/super_sidebar/components/search_results.vue b/app/assets/javascripts/super_sidebar/components/search_results.vue
new file mode 100644
index 00000000000..7c172110bad
--- /dev/null
+++ b/app/assets/javascripts/super_sidebar/components/search_results.vue
@@ -0,0 +1,49 @@
+<script>
+import ItemsList from './items_list.vue';
+
+export default {
+ components: {
+ ItemsList,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ noResultsText: {
+ type: String,
+ required: true,
+ },
+ searchResults: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ computed: {
+ isEmpty() {
+ return !this.searchResults.length;
+ },
+ },
+};
+</script>
+
+<template>
+ <li class="gl-border-t gl-border-gray-50 gl-mx-3 gl-py-3">
+ <div
+ data-testid="list-title"
+ aria-hidden="true"
+ class="gl-text-transform-uppercase gl-text-secondary gl-font-weight-bold gl-font-xs gl-line-height-12 gl-letter-spacing-06em gl-my-3"
+ >
+ {{ title }}
+ </div>
+ <div v-if="isEmpty" data-testid="empty-text" class="gl-text-gray-500 gl-font-sm gl-my-3">
+ {{ noResultsText }}
+ </div>
+ <items-list :aria-label="title" :items="searchResults">
+ <template #view-all-items>
+ <slot name="view-all-items"></slot>
+ </template>
+ </items-list>
+ </li>
+</template>
diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue
index 883fa85f1d9..34bbb3ce177 100644
--- a/app/assets/javascripts/super_sidebar/components/user_menu.vue
+++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue
@@ -14,7 +14,7 @@ import PersistentUserCallout from '~/persistent_user_callout';
import UserNameGroup from './user_name_group.vue';
export default {
- feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/new',
+ feedbackUrl: 'https://gitlab.com/gitlab-org/gitlab/-/issues/391533',
i18n: {
newNavigation: {
badgeLabel: s__('NorthstarNavigation|Alpha'),
diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
index d8e3691aa4a..93583907a11 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue
@@ -59,6 +59,16 @@ export default {
required: false,
default: '',
},
+ drawioEnabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ disabled: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
data() {
return {
@@ -132,6 +142,11 @@ export default {
if (this.markdown) updateDraft(this.autosaveKey, this.markdown);
else clearDraft(this.autosaveKey);
},
+ togglePreview(value) {
+ if (this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
+ this.$refs.markdownField.previewMarkdown = value;
+ }
+ },
autosizeTextarea() {
if (this.editingMode === EDITING_MODE_MARKDOWN_FIELD) {
this.$nextTick(() => {
@@ -151,6 +166,7 @@ export default {
/>
<markdown-field
v-if="!isContentEditorActive"
+ ref="markdownField"
v-bind="$attrs"
data-testid="markdown-field"
:markdown-preview-path="renderMarkdownPath"
@@ -158,7 +174,8 @@ export default {
:textarea-value="markdown"
:uploads-path="uploadsPath"
:quick-actions-docs-path="quickActionsDocsPath"
- show-content-editor-switcher
+ :show-content-editor-switcher="enableContentEditor"
+ :drawio-enabled="drawioEnabled"
class="bordered-box"
@enableContentEditor="onEditingModeChange('contentEditor')"
>
@@ -170,7 +187,8 @@ export default {
class="note-textarea js-gfm-input markdown-area"
dir="auto"
:data-supports-quick-actions="supportsQuickActions"
- data-qa-selector="markdown_editor_form_field"
+ :data-qa-selector="formFieldProps['data-qa-selector'] || 'markdown_editor_form_field'"
+ :disabled="disabled"
@input="updateMarkdownFromMarkdownField"
@keydown="$emit('keydown', $event)"
>
@@ -179,11 +197,15 @@ export default {
</markdown-field>
<div v-else>
<content-editor
+ ref="contentEditor"
:render-markdown="renderMarkdown"
:uploads-path="uploadsPath"
:markdown="markdown"
:quick-actions-docs-path="quickActionsDocsPath"
:autofocus="contentEditorAutofocused"
+ :placeholder="formFieldProps.placeholder"
+ :drawio-enabled="drawioEnabled"
+ :editable="!disabled"
@initialized="setEditorAsAutofocused"
@change="updateMarkdownFromContentEditor"
@keydown="$emit('keydown', $event)"
diff --git a/app/assets/javascripts/vue_shared/global_search/constants.js b/app/assets/javascripts/vue_shared/global_search/constants.js
new file mode 100644
index 00000000000..388e7c92f03
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/global_search/constants.js
@@ -0,0 +1,73 @@
+import { s__, __, sprintf } from '~/locale';
+
+export const AUTOCOMPLETE_ERROR_MESSAGE = s__(
+ 'GlobalSearch|There was an error fetching search autocomplete suggestions.',
+);
+
+export const ALL_GITLAB = __('All GitLab');
+export const SEARCH_GITLAB = s__('GlobalSearch|Search GitLab');
+
+export const SEARCH_DESCRIBED_BY_DEFAULT = s__(
+ 'GlobalSearch|%{count} default results provided. Use the up and down arrow keys to navigate search results list.',
+);
+export const SEARCH_DESCRIBED_BY_WITH_RESULTS = s__(
+ 'GlobalSearch|Type for new suggestions to appear below.',
+);
+export const SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN = s__(
+ 'GlobalSearch|Type and press the enter key to submit search.',
+);
+export const SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN = SEARCH_DESCRIBED_BY_WITH_RESULTS;
+export const SEARCH_DESCRIBED_BY_UPDATED = s__(
+ 'GlobalSearch|Results updated. %{count} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.',
+);
+export const SEARCH_RESULTS_LOADING = s__('GlobalSearch|Search results are loading');
+export const SEARCH_RESULTS_SCOPE = s__('GlobalSearch|in %{scope}');
+export const KBD_HELP = sprintf(
+ s__('GlobalSearch|Use the shortcut key %{kbdOpen}/%{kbdClose} to start a search'),
+ { kbdOpen: '<kbd>', kbdClose: '</kbd>' },
+ false,
+);
+export const SCOPED_SEARCH_ITEM_ARIA_LABEL = s__('GlobalSearch| %{search} %{description} %{scope}');
+
+export const MSG_ISSUES_ASSIGNED_TO_ME = s__('GlobalSearch|Issues assigned to me');
+
+export const MSG_ISSUES_IVE_CREATED = s__("GlobalSearch|Issues I've created");
+
+export const MSG_MR_ASSIGNED_TO_ME = s__('GlobalSearch|Merge requests assigned to me');
+
+export const MSG_MR_IM_REVIEWER = s__("GlobalSearch|Merge requests that I'm a reviewer");
+
+export const MSG_MR_IVE_CREATED = s__("GlobalSearch|Merge requests I've created");
+
+export const MSG_IN_ALL_GITLAB = s__('GlobalSearch|all GitLab');
+
+export const GROUPS_CATEGORY = s__('GlobalSearch|Groups');
+
+export const PROJECTS_CATEGORY = s__('GlobalSearch|Projects');
+
+export const USERS_CATEGORY = s__('GlobalSearch|Users');
+
+export const ISSUES_CATEGORY = s__('GlobalSearch|Recent issues');
+
+export const MERGE_REQUEST_CATEGORY = s__('GlobalSearch|Recent merge requests');
+
+export const RECENT_EPICS_CATEGORY = s__('GlobalSearch|Recent epics');
+
+export const IN_THIS_PROJECT_CATEGORY = s__('GlobalSearch|In this project');
+
+export const SETTINGS_CATEGORY = s__('GlobalSearch|Settings');
+
+export const HELP_CATEGORY = s__('GlobalSearch|Help');
+
+export const SEARCH_RESULTS_ORDER = [
+ MERGE_REQUEST_CATEGORY,
+ ISSUES_CATEGORY,
+ RECENT_EPICS_CATEGORY,
+ GROUPS_CATEGORY,
+ PROJECTS_CATEGORY,
+ USERS_CATEGORY,
+ IN_THIS_PROJECT_CATEGORY,
+ SETTINGS_CATEGORY,
+ HELP_CATEGORY,
+];
+export const DROPDOWN_ORDER = SEARCH_RESULTS_ORDER;
diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb
index 5f8060ad756..65576bcade6 100644
--- a/app/controllers/projects/artifacts_controller.rb
+++ b/app/controllers/projects/artifacts_controller.rb
@@ -19,6 +19,10 @@ class Projects::ArtifactsController < Projects::ApplicationController
before_action :validate_artifacts!, except: [:index, :download, :raw, :destroy]
before_action :entry, only: [:external_file, :file]
+ before_action only: :index do
+ push_frontend_feature_flag(:ci_job_artifact_bulk_destroy, @project)
+ end
+
MAX_PER_PAGE = 20
feature_category :build_artifacts
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index f933ed9f50b..a204023e34d 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -33,6 +33,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action :check_user_can_push_to_source_branch!, only: [:rebase]
before_action only: [:show, :diffs] do
+ push_frontend_feature_flag(:content_editor_on_issues, project)
push_frontend_feature_flag(:core_security_mr_widget_counts, project)
push_frontend_feature_flag(:issue_assignees_widget, @project)
push_frontend_feature_flag(:refactor_security_extension, @project)
diff --git a/app/helpers/artifacts_helper.rb b/app/helpers/artifacts_helper.rb
index df0432105d5..f90d59409ed 100644
--- a/app/helpers/artifacts_helper.rb
+++ b/app/helpers/artifacts_helper.rb
@@ -4,6 +4,7 @@ module ArtifactsHelper
def artifacts_app_data(project)
{
project_path: project.full_path,
+ project_id: project.id,
can_destroy_artifacts: can?(current_user, :destroy_artifacts, project).to_s,
artifacts_management_feedback_image_path: image_path('illustrations/chat-bubble-sm.svg')
}
diff --git a/config/feature_flags/development/show_tags_on_commits_view.yml b/config/feature_flags/development/show_tags_on_commits_view.yml
index 834179d1636..1dba952c33f 100644
--- a/config/feature_flags/development/show_tags_on_commits_view.yml
+++ b/config/feature_flags/development/show_tags_on_commits_view.yml
@@ -5,4 +5,4 @@ rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/392003
milestone: '15.10'
type: development
group: group::source code
-default_enabled: false
+default_enabled: true
diff --git a/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png b/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png
new file mode 100644
index 00000000000..09b30af91d0
--- /dev/null
+++ b/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png
Binary files differ
diff --git a/doc/user/project/repository/branches/index.md b/doc/user/project/repository/branches/index.md
index 13ee6fcdc91..ef625739956 100644
--- a/doc/user/project/repository/branches/index.md
+++ b/doc/user/project/repository/branches/index.md
@@ -6,41 +6,98 @@ info: "To determine the technical writer assigned to the Stage/Group associated
# Branches **(FREE)**
-A branch is a version of a project's working tree. You create a branch for each
-set of related changes you make. This keeps each set of changes separate from
-each other, allowing changes to be made in parallel, without affecting each
-other.
+Branches are versions of a project's working tree. When you create a new
+[project](../../index.md), GitLab creates a [default branch](default.md) (which
+cannot be deleted) for your repository. Default branch settings can be configured
+at the project, subgroup, group, or instance level.
-After pushing your changes to a new branch, you can:
+As your project grows, your team [creates](../web_editor.md#create-a-branch) more
+branches, preferably by following [branch naming patterns](#prefix-branch-names-with-issue-numbers).
+Each branch represents a set of changes, which allows development work to be done
+in parallel. Development work in one branch does not affect another branch.
-- Create a [merge request](../../merge_requests/index.md). You can streamline this process
- by following [branch naming patterns](#prefix-branch-names-with-issue-numbers).
-- Perform inline code review.
-- [Discuss](../../../discussions/index.md) your implementation with your team.
-- Preview changes submitted to a new branch with [Review Apps](../../../../ci/review_apps/index.md).
+Branches are the foundation of development in a project:
-You can also request [approval](../../merge_requests/approvals/index.md)
-from your managers.
+1. To get started, create a branch and add commits to it.
+1. When the work is ready for review, create a [merge request](../../merge_requests/index.md) to propose
+ merging the changes in your branch. To streamline this process, you should follow
+ [branch naming patterns](#prefix-branch-names-with-issue-numbers).
+1. Preview changes in a branch with a [review app](../../../../ci/review_apps/index.md).
+1. After the contents of your branch are merged, [delete the merged branch](#delete-merged-branches).
-For more information on managing branches using the GitLab UI, see:
+## Manage and protect branches
-- [Default branches](default.md): When you create a new [project](../../index.md), GitLab creates a
- default branch for the repository. You can change this setting at the project,
- subgroup, group, or instance level.
-- [Create a branch](../web_editor.md#create-a-branch)
-- [Protected branches](../../protected_branches.md#protected-branches)
-- [Delete merged branches](#delete-merged-branches)
+GitLab provides you multiple methods to protect individual branches. These methods
+ensure your branches receive oversight and quality checks from their creation to their deletion:
-You can also manage branches using the
-[command line](../../../../gitlab-basics/start-using-git.md#create-a-branch).
+- The [default branch](default.md) in your project receives extra protection.
+- Configure [protected branches](../../protected_branches.md#protected-branches)
+ to restrict who can commit to a branch, merge other branches into it, or merge
+ the branch itself into another branch.
+- Configure [approval rules](../../merge_requests/approvals/rules.md) to set review
+ requirements, including [security-related approvals](../../merge_requests/approvals/rules.md#security-approvals), before a branch can merge.
+- Integrate with third-party [status checks](../../merge_requests/status_checks.md)
+ to ensure your branch contents meet your standards of quality.
-<i class="fa fa-youtube-play youtube" aria-hidden="true"></i>Watch the video [GitLab Flow](https://www.youtube.com/watch?v=InKNIvky2KE).
+You can manage your branches:
-See also:
+- With the GitLab user interface.
+- With the [command line](../../../../gitlab-basics/start-using-git.md#create-a-branch).
+- With the [Branches API](../../../../api/branches.md).
-- [Branches API](../../../../api/branches.md), for information on operating on repository branches using the GitLab API.
-- [GitLab Flow](../../../../topics/gitlab_flow.md) documentation.
-- [Getting started with Git](../../../../topics/git/index.md) and GitLab.
+### View all branches
+
+To view and manage your branches in the GitLab user interface:
+
+1. On the top bar, select **Main menu > Projects** and find your project.
+1. On the left sidebar, select **Repository > Branches**.
+
+On this page, you can:
+
+- See all branches, active branches, or stale branches.
+- Create new branches.
+- [Compare branches](#compare-branches).
+- Delete merged branches.
+
+### View branches with configured protections
+
+> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88279) in GitLab 15.1 with a flag named `branch_rules`. Disabled by default.
+> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/363170) in GitLab 15.10.
+
+FLAG:
+On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../../feature_flags.md) named `branch_rules`.
+On GitLab.com, this feature is available.
+
+Branches in your repository can be [protected](../../protected_branches.md) in multiple ways. You can:
+
+- Limit who can push to the branch.
+- Limit who can merge the branch.
+- Require approval of all changes.
+- Require external tests to pass.
+
+The **Branch rules overview** page shows all branches with any configured protections,
+and their protection methods:
+
+![Example of a branch with configured protections](img/view_branch_protections_v15_10.png)
+
+Prerequisites:
+
+- You must have at least the Developer role in the project.
+
+To view the **Branch rules overview** list:
+
+1. On the top bar, select **Main menu > Projects** and find your project.
+1. On the left sidebar, select **Settings > Repository**.
+1. Expand **Branch Rules** to view all branches with protections.
+ - To add protections to a new branch:
+ 1. Select **Add branch rule**.
+ 1. Select **Create protected branch**.
+ - To view more information about protections on an existing branch:
+ 1. Identify the branch you want more information about.
+ 1. Select **Details** to see information about its:
+ - [Branch protections](../../protected_branches.md).
+ - [Approval rules](../../merge_requests/approvals/rules.md).
+ - [Status checks](../../merge_requests/status_checks.md).
## Prefix branch names with issue numbers
@@ -85,35 +142,12 @@ this operation.
It's particularly useful to clean up old branches that were not deleted
automatically when a merge request was merged.
-## View branches with configured protections **(FREE SELF)**
-
-> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/88279) in GitLab 15.1 with a flag named `branch_rules`. Disabled by default.
-
-FLAG:
-On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../../../feature_flags.md) named `branch_rules`.
-On GitLab.com, this feature is not available.
-This feature is not ready for production use.
-
-Branches in your repository can be [protected](../../protected_branches.md) by limiting
-who can push to a branch, require approval for those pushed changes, or merge those changes.
-To help you track the protections for all branches, the **Branch rules overview**
-page shows your branches with their configured rules.
-
-To view the **Branch rules overview** list:
-
-1. On the top bar, select **Main menu > Projects** and find your project.
-1. On the left sidebar, select **Settings > Repository**.
-1. Expand **Branch Rules** to view all branches with protections.
-1. Select **Details** next to your desired branch to show information about its:
- - [Branch protections](../../protected_branches.md).
- - [Approval rules](../../merge_requests/approvals/rules.md).
- - [Status checks](../../merge_requests/status_checks.md).
-
## Related topics
-- [Protected branches](../../protected_branches.md) user documentation
-- [Branches API](../../../../api/branches.md)
-- [Protected Branches API](../../../../api/protected_branches.md)
+- [Protected branches](../../protected_branches.md) user documentation.
+- [Branches API](../../../../api/branches.md), for information on operating on repository branches using the GitLab API.
+- [Protected Branches API](../../../../api/protected_branches.md).
+- [Getting started with Git](../../../../topics/git/index.md) and GitLab.
## Troubleshooting
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index bd78f4c5a41..67cbb0d6110 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -5648,9 +5648,22 @@ msgstr ""
msgid "Artifacts"
msgstr ""
+msgid "Artifacts|%d selected artifact deleted"
+msgid_plural "Artifacts|%d selected artifacts deleted"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Artifacts|%{strongStart}%{count}%{strongEnd} artifact selected"
+msgid_plural "Artifacts|%{strongStart}%{count}%{strongEnd} artifacts selected"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Artifacts|An error occurred while deleting the artifact"
msgstr ""
+msgid "Artifacts|An error occurred while deleting. Some artifacts may not have been deleted."
+msgstr ""
+
msgid "Artifacts|An error occurred while retrieving job artifacts"
msgstr ""
@@ -5660,18 +5673,42 @@ msgstr ""
msgid "Artifacts|Browse"
msgstr ""
+msgid "Artifacts|Clear selection"
+msgstr ""
+
+msgid "Artifacts|Delete %d artifact"
+msgid_plural "Artifacts|Delete %d artifacts"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Artifacts|Delete %d artifact?"
+msgid_plural "Artifacts|Delete %d artifacts?"
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Artifacts|Delete %{name}?"
msgstr ""
msgid "Artifacts|Delete artifact"
msgstr ""
+msgid "Artifacts|Delete selected"
+msgstr ""
+
msgid "Artifacts|Help us improve this page"
msgstr ""
+msgid "Artifacts|Something went wrong while deleting. Please refresh the page to try again."
+msgstr ""
+
msgid "Artifacts|Take a quick survey"
msgstr ""
+msgid "Artifacts|The selected artifact will be permanently deleted. Any reports generated from these artifacts will be empty."
+msgid_plural "Artifacts|The selected artifacts will be permanently deleted. Any reports generated from these artifacts will be empty."
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Artifacts|This artifact will be permanently deleted. Any reports generated from this artifact will be empty."
msgstr ""
@@ -11055,8 +11092,8 @@ msgid_plural "ContainerRegistry|%{count} Image repositories"
msgstr[0] ""
msgstr[1] ""
-msgid "ContainerRegistry|%{count} Tag"
-msgid_plural "ContainerRegistry|%{count} Tags"
+msgid "ContainerRegistry|%{count} tag"
+msgid_plural "ContainerRegistry|%{count} tags"
msgstr[0] ""
msgstr[1] ""
@@ -24659,6 +24696,15 @@ msgstr ""
msgid "JobAssistant|Add job"
msgstr ""
+msgid "JobAssistant|Image"
+msgstr ""
+
+msgid "JobAssistant|Image entrypoint (optional)"
+msgstr ""
+
+msgid "JobAssistant|Image name (optional)"
+msgstr ""
+
msgid "JobAssistant|Job Setup"
msgstr ""
diff --git a/spec/features/issues/user_comments_on_issue_spec.rb b/spec/features/issues/user_comments_on_issue_spec.rb
index 59e1413fc97..145fa3c4a9e 100644
--- a/spec/features/issues/user_comments_on_issue_spec.rb
+++ b/spec/features/issues/user_comments_on_issue_spec.rb
@@ -32,6 +32,8 @@ RSpec.describe "User comments on issue", :js, feature_category: :team_planning d
end
end
+ it_behaves_like 'edits content using the content editor'
+
it "adds comment with code block" do
code_block_content = "Command [1]: /usr/local/bin/git , see [text](doc/text)"
comment = "```\n#{code_block_content}\n```"
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index 6c375018a4b..06c1b2afdb0 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -111,23 +111,29 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
markdown_field_focused_selector = 'textarea:focus'
click_edit_issue_description
- expect(page).to have_selector(markdown_field_focused_selector)
+ issuable_form = find('[data-testid="issuable-form"]')
- click_on _('Viewing markdown')
- click_on _('Rich text')
+ expect(issuable_form).to have_selector(markdown_field_focused_selector)
- expect(page).not_to have_selector(content_editor_focused_selector)
+ page.within issuable_form do
+ click_on _('Viewing markdown')
+ click_on _('Rich text')
+ end
+
+ expect(issuable_form).not_to have_selector(content_editor_focused_selector)
refresh
click_edit_issue_description
- expect(page).to have_selector(content_editor_focused_selector)
+ expect(issuable_form).to have_selector(content_editor_focused_selector)
- click_on _('Viewing rich text')
- click_on _('Markdown')
+ page.within issuable_form do
+ click_on _('Viewing rich text')
+ click_on _('Markdown')
+ end
- expect(page).not_to have_selector(markdown_field_focused_selector)
+ expect(issuable_form).not_to have_selector(markdown_field_focused_selector)
end
end
diff --git a/spec/features/merge_request/user_comments_on_merge_request_spec.rb b/spec/features/merge_request/user_comments_on_merge_request_spec.rb
index 9335615b4c7..e113e305af5 100644
--- a/spec/features/merge_request/user_comments_on_merge_request_spec.rb
+++ b/spec/features/merge_request/user_comments_on_merge_request_spec.rb
@@ -30,6 +30,8 @@ RSpec.describe 'User comments on a merge request', :js, feature_category: :code_
end
end
+ it_behaves_like 'edits content using the content editor'
+
it 'replys to a new comment' do
page.within('.js-main-target-form') do
fill_in('note[note]', with: 'comment 1')
diff --git a/spec/features/merge_request/user_views_open_merge_request_spec.rb b/spec/features/merge_request/user_views_open_merge_request_spec.rb
index e481e3f2dfb..afa57cb0f8f 100644
--- a/spec/features/merge_request/user_views_open_merge_request_spec.rb
+++ b/spec/features/merge_request/user_views_open_merge_request_spec.rb
@@ -7,6 +7,18 @@ RSpec.describe 'User views an open merge request', feature_category: :code_revie
create(:merge_request, source_project: project, target_project: project, description: '# Description header')
end
+ context 'feature flags' do
+ let_it_be(:project) { create(:project, :public, :repository) }
+
+ it 'pushes content_editor_on_issues feature flag to frontend' do
+ stub_feature_flags(content_editor_on_issues: true)
+
+ visit merge_request_path(merge_request)
+
+ expect(page).to have_pushed_frontend_feature_flags(contentEditorOnIssues: true)
+ end
+ end
+
context 'when a merge request does not have repository' do
let(:project) { create(:project, :public, :repository) }
diff --git a/spec/features/projects/pipeline_schedules_spec.rb b/spec/features/projects/pipeline_schedules_spec.rb
index 3ede76d3360..acb2af07e50 100644
--- a/spec/features/projects/pipeline_schedules_spec.rb
+++ b/spec/features/projects/pipeline_schedules_spec.rb
@@ -193,7 +193,7 @@ RSpec.describe 'Pipeline Schedules', :js, feature_category: :projects do
save_pipeline_schedule
end
- it 'user sees the new variable in edit window' do
+ it 'user sees the new variable in edit window', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/397040' do
find(".content-list .pipeline-schedule-table-row:nth-child(1) .btn-group a[title='Edit']").click
page.within('.ci-variable-list') do
expect(find(".ci-variable-row:nth-child(1) .js-ci-variable-input-key").value).to eq('AAA')
diff --git a/spec/frontend/__helpers__/create_mock_source_editor_extension.js b/spec/frontend/__helpers__/create_mock_source_editor_extension.js
new file mode 100644
index 00000000000..fa529604d6f
--- /dev/null
+++ b/spec/frontend/__helpers__/create_mock_source_editor_extension.js
@@ -0,0 +1,12 @@
+export const createMockSourceEditorExtension = (ActualExtension) => {
+ const { extensionName } = ActualExtension;
+ const providedKeys = Object.keys(new ActualExtension().provides());
+
+ const mockedMethods = Object.fromEntries(providedKeys.map((key) => [key, jest.fn()]));
+ const MockExtension = function MockExtension() {};
+ MockExtension.extensionName = extensionName;
+ MockExtension.mockedMethods = mockedMethods;
+ MockExtension.prototype.provides = jest.fn().mockReturnValue(mockedMethods);
+
+ return MockExtension;
+};
diff --git a/spec/frontend/artifacts/components/artifact_row_spec.js b/spec/frontend/artifacts/components/artifact_row_spec.js
index b9d75e2a9b2..268772ed4c0 100644
--- a/spec/frontend/artifacts/components/artifact_row_spec.js
+++ b/spec/frontend/artifacts/components/artifact_row_spec.js
@@ -1,9 +1,10 @@
-import { GlBadge, GlButton, GlFriendlyWrap } from '@gitlab/ui';
+import { GlBadge, GlButton, GlFriendlyWrap, GlFormCheckbox } from '@gitlab/ui';
import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ArtifactRow from '~/artifacts/components/artifact_row.vue';
+import { BULK_DELETE_FEATURE_FLAG } from '~/artifacts/constants';
describe('ArtifactRow component', () => {
let wrapper;
@@ -15,15 +16,17 @@ describe('ArtifactRow component', () => {
const findSize = () => wrapper.findByTestId('job-artifact-row-size');
const findDownloadButton = () => wrapper.findByTestId('job-artifact-row-download-button');
const findDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
+ const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
- const createComponent = ({ canDestroyArtifacts = true } = {}) => {
+ const createComponent = ({ canDestroyArtifacts = true, glFeatures = {} } = {}) => {
wrapper = shallowMountExtended(ArtifactRow, {
propsData: {
artifact,
+ isSelected: false,
isLoading: false,
isLastRow: false,
},
- provide: { canDestroyArtifacts },
+ provide: { canDestroyArtifacts, glFeatures },
stubs: { GlBadge, GlButton, GlFriendlyWrap },
});
};
@@ -73,4 +76,30 @@ describe('ArtifactRow component', () => {
expect(wrapper.emitted('delete')).toBeDefined();
});
});
+
+ describe('bulk delete checkbox', () => {
+ describe('with permission and feature flag enabled', () => {
+ beforeEach(() => {
+ createComponent({ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true } });
+ });
+
+ it('emits selectArtifact when toggled', () => {
+ findCheckbox().vm.$emit('input', true);
+
+ expect(wrapper.emitted('selectArtifact')).toStrictEqual([[artifact, true]]);
+ });
+ });
+
+ it('is not shown without permission', () => {
+ createComponent({ canDestroyArtifacts: false });
+
+ expect(findCheckbox().exists()).toBe(false);
+ });
+
+ it('is not shown with feature flag disabled', () => {
+ createComponent();
+
+ expect(findCheckbox().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js b/spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js
new file mode 100644
index 00000000000..876906b2c3c
--- /dev/null
+++ b/spec/frontend/artifacts/components/artifacts_bulk_delete_spec.js
@@ -0,0 +1,96 @@
+import { GlSprintf, GlModal } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import ArtifactsBulkDelete from '~/artifacts/components/artifacts_bulk_delete.vue';
+import bulkDestroyArtifactsMutation from '~/artifacts/graphql/mutations/bulk_destroy_job_artifacts.mutation.graphql';
+
+Vue.use(VueApollo);
+
+describe('ArtifactsBulkDelete component', () => {
+ let wrapper;
+ let requestHandlers;
+
+ const projectId = '123';
+ const selectedArtifacts = [
+ mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[0].id,
+ mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes[1].id,
+ ];
+
+ const findText = () => wrapper.findComponent(GlSprintf).text();
+ const findDeleteButton = () => wrapper.findByTestId('bulk-delete-delete-button');
+ const findClearButton = () => wrapper.findByTestId('bulk-delete-clear-button');
+ const findModal = () => wrapper.findComponent(GlModal);
+
+ const createComponent = ({
+ handlers = {
+ bulkDestroyArtifactsMutation: jest.fn(),
+ },
+ } = {}) => {
+ requestHandlers = handlers;
+ wrapper = mountExtended(ArtifactsBulkDelete, {
+ apolloProvider: createMockApollo([
+ [bulkDestroyArtifactsMutation, requestHandlers.bulkDestroyArtifactsMutation],
+ ]),
+ propsData: {
+ selectedArtifacts,
+ queryVariables: {},
+ isLoading: false,
+ isLastRow: false,
+ },
+ provide: { projectId },
+ });
+ };
+
+ describe('selected artifacts box', () => {
+ beforeEach(async () => {
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('displays selected artifacts count', () => {
+ expect(findText()).toContain(String(selectedArtifacts.length));
+ });
+
+ it('opens the confirmation modal when the delete button is clicked', async () => {
+ expect(findModal().props('visible')).toBe(false);
+
+ findDeleteButton().trigger('click');
+ await waitForPromises();
+
+ expect(findModal().props('visible')).toBe(true);
+ });
+
+ it('emits clearSelectedArtifacts event when the clear button is clicked', () => {
+ findClearButton().trigger('click');
+
+ expect(wrapper.emitted('clearSelectedArtifacts')).toBeDefined();
+ });
+ });
+
+ describe('bulk delete confirmation modal', () => {
+ beforeEach(async () => {
+ createComponent();
+ findDeleteButton().trigger('click');
+ await waitForPromises();
+ });
+
+ it('calls the bulk delete mutation with the selected artifacts on confirm', () => {
+ findModal().vm.$emit('primary');
+
+ expect(requestHandlers.bulkDestroyArtifactsMutation).toHaveBeenCalledWith({
+ projectId: `gid://gitlab/Project/${projectId}`,
+ ids: selectedArtifacts,
+ });
+ });
+
+ it('does not call the bulk delete mutation on cancel', () => {
+ findModal().vm.$emit('cancel');
+
+ expect(requestHandlers.bulkDestroyArtifactsMutation).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js b/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js
index eced6d3f3ba..6bf3498f9b0 100644
--- a/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js
+++ b/spec/frontend/artifacts/components/artifacts_table_row_details_spec.js
@@ -25,11 +25,12 @@ describe('ArtifactsTableRowDetails component', () => {
const findModal = () => wrapper.findComponent(GlModal);
- const createComponent = (
+ const createComponent = ({
handlers = {
destroyArtifactMutation: jest.fn(),
},
- ) => {
+ selectedArtifacts = [],
+ } = {}) => {
requestHandlers = handlers;
wrapper = mountExtended(ArtifactsTableRowDetails, {
apolloProvider: createMockApollo([
@@ -37,6 +38,7 @@ describe('ArtifactsTableRowDetails component', () => {
]),
propsData: {
artifacts,
+ selectedArtifacts,
refetchArtifacts,
queryVariables: {},
},
@@ -116,4 +118,20 @@ describe('ArtifactsTableRowDetails component', () => {
expect(requestHandlers.destroyArtifactMutation).not.toHaveBeenCalled();
});
});
+
+ describe('bulk delete selection', () => {
+ it('is not selected for unselected artifact', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(wrapper.findAllComponents(ArtifactRow).at(0).props('isSelected')).toBe(false);
+ });
+
+ it('is selected for selected artifacts', async () => {
+ createComponent({ selectedArtifacts: [artifacts.nodes[0].id] });
+ await waitForPromises();
+
+ expect(wrapper.findAllComponents(ArtifactRow).at(0).props('isSelected')).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/artifacts/components/job_artifacts_table_spec.js b/spec/frontend/artifacts/components/job_artifacts_table_spec.js
index 44c242fa2cb..40f3c9633ab 100644
--- a/spec/frontend/artifacts/components/job_artifacts_table_spec.js
+++ b/spec/frontend/artifacts/components/job_artifacts_table_spec.js
@@ -1,4 +1,12 @@
-import { GlLoadingIcon, GlTable, GlLink, GlBadge, GlPagination, GlModal } from '@gitlab/ui';
+import {
+ GlLoadingIcon,
+ GlTable,
+ GlLink,
+ GlBadge,
+ GlPagination,
+ GlModal,
+ GlFormCheckbox,
+} from '@gitlab/ui';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import getJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
@@ -8,6 +16,7 @@ import JobArtifactsTable from '~/artifacts/components/job_artifacts_table.vue';
import FeedbackBanner from '~/artifacts/components/feedback_banner.vue';
import ArtifactsTableRowDetails from '~/artifacts/components/artifacts_table_row_details.vue';
import ArtifactDeleteModal from '~/artifacts/components/artifact_delete_modal.vue';
+import ArtifactsBulkDelete from '~/artifacts/components/artifacts_bulk_delete.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import getJobArtifactsQuery from '~/artifacts/graphql/queries/get_job_artifacts.query.graphql';
@@ -17,6 +26,7 @@ import {
JOBS_PER_PAGE,
I18N_FETCH_ERROR,
INITIAL_CURRENT_PAGE,
+ BULK_DELETE_FEATURE_FLAG,
} from '~/artifacts/constants';
import { totalArtifactsSizeForJob } from '~/artifacts/utils';
import { createAlert } from '~/alert';
@@ -29,6 +39,8 @@ describe('JobArtifactsTable component', () => {
let wrapper;
let requestHandlers;
+ const mockToastShow = jest.fn();
+
const findBanner = () => wrapper.findComponent(FeedbackBanner);
const findLoadingState = () => wrapper.findComponent(GlLoadingIcon);
@@ -60,6 +72,11 @@ describe('JobArtifactsTable component', () => {
const findDeleteButton = () => wrapper.findByTestId('job-artifacts-delete-button');
const findArtifactDeleteButton = () => wrapper.findByTestId('job-artifact-row-delete-button');
+ // first checkbox is a "select all", this finder should get the first job checkbox
+ const findJobCheckbox = () => wrapper.findAllComponents(GlFormCheckbox).at(1);
+ const findAnyCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+ const findBulkDelete = () => wrapper.findComponent(ArtifactsBulkDelete);
+
const findPagination = () => wrapper.findComponent(GlPagination);
const setPage = async (page) => {
findPagination().vm.$emit('input', page);
@@ -89,13 +106,14 @@ describe('JobArtifactsTable component', () => {
(artifact) => artifact.fileType === ARCHIVE_FILE_TYPE,
);
- const createComponent = (
+ const createComponent = ({
handlers = {
getJobArtifactsQuery: jest.fn().mockResolvedValue(getJobArtifactsResponse),
},
data = {},
canDestroyArtifacts = true,
- ) => {
+ glFeatures = {},
+ } = {}) => {
requestHandlers = handlers;
wrapper = mountExtended(JobArtifactsTable, {
apolloProvider: createMockApollo([
@@ -103,8 +121,15 @@ describe('JobArtifactsTable component', () => {
]),
provide: {
projectPath: 'project/path',
+ projectId: 'gid://projects/id',
canDestroyArtifacts,
artifactsManagementFeedbackImagePath: 'banner/image/path',
+ glFeatures,
+ },
+ mocks: {
+ $toast: {
+ show: mockToastShow,
+ },
},
data() {
return data;
@@ -126,7 +151,9 @@ describe('JobArtifactsTable component', () => {
it('on error, shows an alert', async () => {
createComponent({
- getJobArtifactsQuery: jest.fn().mockRejectedValue(new Error('Error!')),
+ handlers: {
+ getJobArtifactsQuery: jest.fn().mockRejectedValue(new Error('Error!')),
+ },
});
await waitForPromises();
@@ -267,10 +294,10 @@ describe('JobArtifactsTable component', () => {
archive: { downloadPath: null },
};
- createComponent(
- { getJobArtifactsQuery: jest.fn() },
- { jobArtifacts: [jobWithoutDownloadPath] },
- );
+ createComponent({
+ handlers: { getJobArtifactsQuery: jest.fn() },
+ data: { jobArtifacts: [jobWithoutDownloadPath] },
+ });
await waitForPromises();
@@ -293,10 +320,10 @@ describe('JobArtifactsTable component', () => {
browseArtifactsPath: null,
};
- createComponent(
- { getJobArtifactsQuery: jest.fn() },
- { jobArtifacts: [jobWithoutBrowsePath] },
- );
+ createComponent({
+ handlers: { getJobArtifactsQuery: jest.fn() },
+ data: { jobArtifacts: [jobWithoutBrowsePath] },
+ });
await waitForPromises();
@@ -306,7 +333,7 @@ describe('JobArtifactsTable component', () => {
describe('delete button', () => {
it('does not show when user does not have permission', async () => {
- createComponent({}, {}, false);
+ createComponent({ canDestroyArtifacts: false });
await waitForPromises();
@@ -322,17 +349,82 @@ describe('JobArtifactsTable component', () => {
});
});
+ describe('bulk delete', () => {
+ describe('with permission and feature flag enabled', () => {
+ beforeEach(async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ });
+
+ await waitForPromises();
+ });
+
+ it('shows selected artifacts when a job is checked', async () => {
+ expect(findBulkDelete().exists()).toBe(false);
+
+ await findJobCheckbox().vm.$emit('input', true);
+
+ expect(findBulkDelete().exists()).toBe(true);
+ expect(findBulkDelete().props('selectedArtifacts')).toStrictEqual(
+ job.artifacts.nodes.map((node) => node.id),
+ );
+ });
+
+ it('disappears when selected artifacts are cleared', async () => {
+ await findJobCheckbox().vm.$emit('input', true);
+
+ expect(findBulkDelete().exists()).toBe(true);
+
+ await findBulkDelete().vm.$emit('clearSelectedArtifacts');
+
+ expect(findBulkDelete().exists()).toBe(false);
+ });
+
+ it('shows a toast when artifacts are deleted', async () => {
+ const count = job.artifacts.nodes.length;
+
+ await findJobCheckbox().vm.$emit('input', true);
+ findBulkDelete().vm.$emit('deleted', count);
+
+ expect(mockToastShow).toHaveBeenCalledWith(`${count} selected artifacts deleted`);
+ });
+ });
+
+ it('shows no checkboxes without permission', async () => {
+ createComponent({
+ canDestroyArtifacts: false,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: true },
+ });
+
+ await waitForPromises();
+
+ expect(findAnyCheckbox().exists()).toBe(false);
+ });
+
+ it('shows no checkboxes with feature flag disabled', async () => {
+ createComponent({
+ canDestroyArtifacts: true,
+ glFeatures: { [BULK_DELETE_FEATURE_FLAG]: false },
+ });
+
+ await waitForPromises();
+
+ expect(findAnyCheckbox().exists()).toBe(false);
+ });
+ });
+
describe('pagination', () => {
const { pageInfo } = getJobArtifactsResponseThatPaginates.data.project.jobs;
const query = jest.fn().mockResolvedValue(getJobArtifactsResponseThatPaginates);
beforeEach(async () => {
- createComponent(
- {
+ createComponent({
+ handlers: {
getJobArtifactsQuery: query,
},
- { pageInfo },
- );
+ data: { pageInfo },
+ });
await waitForPromises();
});
diff --git a/spec/frontend/artifacts/components/job_checkbox_spec.js b/spec/frontend/artifacts/components/job_checkbox_spec.js
new file mode 100644
index 00000000000..95cc548b8c8
--- /dev/null
+++ b/spec/frontend/artifacts/components/job_checkbox_spec.js
@@ -0,0 +1,71 @@
+import { GlFormCheckbox } from '@gitlab/ui';
+import mockGetJobArtifactsResponse from 'test_fixtures/graphql/artifacts/graphql/queries/get_job_artifacts.query.graphql.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import JobCheckbox from '~/artifacts/components/job_checkbox.vue';
+
+describe('JobCheckbox component', () => {
+ let wrapper;
+
+ const mockArtifactNodes = mockGetJobArtifactsResponse.data.project.jobs.nodes[0].artifacts.nodes;
+ const mockSelectedArtifacts = [mockArtifactNodes[0], mockArtifactNodes[1]];
+ const mockUnselectedArtifacts = [mockArtifactNodes[2]];
+
+ const findCheckbox = () => wrapper.findComponent(GlFormCheckbox);
+
+ const createComponent = ({
+ hasArtifacts = true,
+ selectedArtifacts = mockSelectedArtifacts,
+ unselectedArtifacts = mockUnselectedArtifacts,
+ } = {}) => {
+ wrapper = shallowMountExtended(JobCheckbox, {
+ propsData: {
+ hasArtifacts,
+ selectedArtifacts,
+ unselectedArtifacts,
+ },
+ mocks: { GlFormCheckbox },
+ });
+ };
+
+ it('is disabled when the job has no artifacts', () => {
+ createComponent({ hasArtifacts: false });
+
+ expect(findCheckbox().attributes('disabled')).toBe('true');
+ });
+
+ describe('when some artifacts are selected', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('is indeterminate', () => {
+ expect(findCheckbox().attributes('indeterminate')).toBe('true');
+ expect(findCheckbox().attributes('checked')).toBeUndefined();
+ });
+
+ it('selects the unselected artifacts on click', () => {
+ findCheckbox().vm.$emit('input', true);
+
+ expect(wrapper.emitted('selectArtifact')).toMatchObject([[mockUnselectedArtifacts[0], true]]);
+ });
+ });
+
+ describe('when all artifacts are selected', () => {
+ beforeEach(() => {
+ createComponent({ unselectedArtifacts: [] });
+ });
+
+ it('is checked', () => {
+ expect(findCheckbox().attributes('checked')).toBe('true');
+ });
+
+ it('deselects the selected artifacts on click', () => {
+ findCheckbox().vm.$emit('input', false);
+
+ expect(wrapper.emitted('selectArtifact')).toMatchObject([
+ [mockSelectedArtifacts[0], false],
+ [mockSelectedArtifacts[1], false],
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
index 4b185a545b0..0be26570fbf 100644
--- a/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js
@@ -1,7 +1,11 @@
import { shallowMount } from '@vue/test-utils';
+import { editor as monacoEditor } from 'monaco-editor';
+import SourceEditor from '~/vue_shared/components/source_editor.vue';
import { EDITOR_READY_EVENT } from '~/editor/constants';
+import { CiSchemaExtension as MockedCiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext';
import { SOURCE_EDITOR_DEBOUNCE } from '~/ci/pipeline_editor/constants';
+import eventHub, { SCROLL_EDITOR_TO_BOTTOM } from '~/ci/pipeline_editor/event_hub';
import TextEditor from '~/ci/pipeline_editor/components/editor/text_editor.vue';
import {
mockCiConfigPath,
@@ -12,19 +16,26 @@ import {
mockDefaultBranch,
} from '../../mock_data';
+jest.mock('monaco-editor');
+jest.mock('~/editor/extensions/source_editor_ci_schema_ext', () => {
+ const { createMockSourceEditorExtension } = jest.requireActual(
+ 'helpers/create_mock_source_editor_extension',
+ );
+ const { CiSchemaExtension } = jest.requireActual(
+ '~/editor/extensions/source_editor_ci_schema_ext',
+ );
+
+ return {
+ CiSchemaExtension: createMockSourceEditorExtension(CiSchemaExtension),
+ };
+});
+
describe('Pipeline Editor | Text editor component', () => {
let wrapper;
let editorReadyListener;
- let mockUse;
- let mockRegisterCiSchema;
- let mockEditorInstance;
- let editorInstanceDetail;
-
- const MockSourceEditor = {
- template: '<div/>',
- props: ['value', 'fileName', 'editorOptions', 'debounceValue'],
- };
+
+ const getMonacoEditor = () => monacoEditor.create.mock.results[0].value;
const createComponent = (mountFn = shallowMount) => {
wrapper = mountFn(TextEditor, {
@@ -44,31 +55,17 @@ describe('Pipeline Editor | Text editor component', () => {
[EDITOR_READY_EVENT]: editorReadyListener,
},
stubs: {
- SourceEditor: MockSourceEditor,
+ SourceEditor,
},
});
};
- const findEditor = () => wrapper.findComponent(MockSourceEditor);
+ const findEditor = () => wrapper.findComponent(SourceEditor);
beforeEach(() => {
- editorReadyListener = jest.fn();
- mockUse = jest.fn();
- mockRegisterCiSchema = jest.fn();
- mockEditorInstance = {
- use: mockUse,
- registerCiSchema: mockRegisterCiSchema,
- };
- editorInstanceDetail = {
- detail: {
- instance: mockEditorInstance,
- },
- };
- });
+ jest.spyOn(monacoEditor, 'create');
- afterEach(() => {
- mockUse.mockClear();
- mockRegisterCiSchema.mockClear();
+ editorReadyListener = jest.fn();
});
describe('template', () => {
@@ -97,21 +94,34 @@ describe('Pipeline Editor | Text editor component', () => {
});
it('bubbles up events', () => {
- findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
-
expect(editorReadyListener).toHaveBeenCalled();
});
+
+ it('scrolls editor to bottom on scroll editor to bottom event', () => {
+ const setScrollTop = jest.spyOn(getMonacoEditor(), 'setScrollTop');
+
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ expect(setScrollTop).toHaveBeenCalledWith(getMonacoEditor().getScrollHeight());
+ });
+
+ it('when destroyed, destroys scroll listener', () => {
+ const setScrollTop = jest.spyOn(getMonacoEditor(), 'setScrollTop');
+
+ wrapper.destroy();
+ eventHub.$emit(SCROLL_EDITOR_TO_BOTTOM);
+
+ expect(setScrollTop).not.toHaveBeenCalled();
+ });
});
describe('CI schema', () => {
beforeEach(() => {
createComponent();
- findEditor().vm.$emit(EDITOR_READY_EVENT, editorInstanceDetail);
});
it('configures editor with syntax highlight', () => {
- expect(mockUse).toHaveBeenCalledTimes(1);
- expect(mockRegisterCiSchema).toHaveBeenCalledTimes(1);
+ expect(MockedCiSchemaExtension.mockedMethods.registerCiSchema).toHaveBeenCalledTimes(1);
});
});
});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
new file mode 100644
index 00000000000..c7c40c3a4b9
--- /dev/null
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item_spec.js
@@ -0,0 +1,39 @@
+import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { JOB_TEMPLATE } from '~/ci/pipeline_editor/components/job_assistant_drawer/constants';
+
+describe('Image item', () => {
+ let wrapper;
+
+ const findImageNameInput = () => wrapper.findByTestId('image-name-input');
+ const findImageEntrypointInput = () => wrapper.findByTestId('image-entrypoint-input');
+
+ const dummyImageName = 'dummyImageName';
+ const dummyImageEntrypoint = 'dummyImageEntrypoint';
+
+ const createComponent = ({ job = JSON.parse(JSON.stringify(JOB_TEMPLATE)) } = {}) => {
+ wrapper = shallowMountExtended(ImageItem, {
+ propsData: {
+ job,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('should emit update job event when filling inputs', () => {
+ expect(wrapper.emitted('update-job')).toBeUndefined();
+
+ findImageNameInput().vm.$emit('input', dummyImageName);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(1);
+ expect(wrapper.emitted('update-job')[0]).toEqual(['image.name', dummyImageName]);
+
+ findImageEntrypointInput().vm.$emit('input', dummyImageEntrypoint);
+
+ expect(wrapper.emitted('update-job')).toHaveLength(2);
+ expect(wrapper.emitted('update-job')[1]).toEqual(['image.entrypoint', [dummyImageEntrypoint]]);
+ });
+});
diff --git a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
index f29b6b00e0b..b293805d653 100644
--- a/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
+++ b/spec/frontend/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer_spec.js
@@ -4,6 +4,7 @@ import Vue, { nextTick } from 'vue';
import { stringify } from 'yaml';
import JobAssistantDrawer from '~/ci/pipeline_editor/components/job_assistant_drawer/job_assistant_drawer.vue';
import JobSetupItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/job_setup_item.vue';
+import ImageItem from '~/ci/pipeline_editor/components/job_assistant_drawer/accordion_items/image_item.vue';
import getAllRunners from '~/ci/runner/graphql/list/all_runners.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
@@ -20,9 +21,12 @@ describe('Job assistant drawer', () => {
const dummyJobName = 'a';
const dummyJobScript = 'b';
+ const dummyImageName = 'c';
+ const dummyImageEntrypoint = 'd';
const findDrawer = () => wrapper.findComponent(GlDrawer);
const findJobSetupItem = () => wrapper.findComponent(JobSetupItem);
+ const findImageItem = () => wrapper.findComponent(ImageItem);
const findConfirmButton = () => wrapper.findByTestId('confirm-button');
const findCancelButton = () => wrapper.findByTestId('cancel-button');
@@ -46,6 +50,14 @@ describe('Job assistant drawer', () => {
await waitForPromises();
});
+ it('should contain job setup accordion', () => {
+ expect(findJobSetupItem().exists()).toBe(true);
+ });
+
+ it('should contain image accordion', () => {
+ expect(findImageItem().exists()).toBe(true);
+ });
+
it('should emit close job assistant drawer event when closing the drawer', () => {
expect(wrapper.emitted('close-job-assistant-drawer')).toBeUndefined();
@@ -78,12 +90,21 @@ describe('Job assistant drawer', () => {
beforeEach(() => {
findJobSetupItem().vm.$emit('update-job', 'name', dummyJobName);
findJobSetupItem().vm.$emit('update-job', 'script', dummyJobScript);
+ findImageItem().vm.$emit('update-job', 'image.name', dummyImageName);
+ findImageItem().vm.$emit('update-job', 'image.entrypoint', [dummyImageEntrypoint]);
});
- it('job name and script have correct value', () => {
- expect(findJobSetupItem().props('job')).toMatchObject({
- name: dummyJobName,
- script: dummyJobScript,
+ it('passes correct prop to accordions', () => {
+ const accordions = [findJobSetupItem(), findImageItem()];
+ accordions.forEach((accordion) => {
+ expect(accordion.props('job')).toMatchObject({
+ name: dummyJobName,
+ script: dummyJobScript,
+ image: {
+ name: dummyImageName,
+ entrypoint: [dummyImageEntrypoint],
+ },
+ });
});
});
@@ -114,7 +135,12 @@ describe('Job assistant drawer', () => {
findConfirmButton().trigger('click');
expect(updateCiConfigSpy).toHaveBeenCalledWith(
- `\n${stringify({ [dummyJobName]: { script: dummyJobScript } })}`,
+ `\n${stringify({
+ [dummyJobName]: {
+ script: dummyJobScript,
+ image: { name: dummyImageName, entrypoint: [dummyImageEntrypoint] },
+ },
+ })}`,
);
});
diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js
index 009f5a5a45f..b642ac9c46b 100644
--- a/spec/frontend/content_editor/components/content_editor_spec.js
+++ b/spec/frontend/content_editor/components/content_editor_spec.js
@@ -35,6 +35,7 @@ describe('ContentEditor', () => {
uploadsPath,
markdown,
autofocus,
+ placeholder: 'Enter some text here...',
...props,
},
stubs: {
@@ -131,9 +132,9 @@ describe('ContentEditor', () => {
describe('succeeds', () => {
beforeEach(async () => {
- renderMarkdown.mockResolvedValueOnce('hello world');
+ renderMarkdown.mockResolvedValueOnce('');
- createWrapper({ markddown: 'hello world' });
+ createWrapper({ markddown: '' });
await nextTick();
});
@@ -145,13 +146,17 @@ describe('ContentEditor', () => {
it('emits loadingSuccess event', () => {
expect(wrapper.emitted('loadingSuccess')).toHaveLength(1);
});
+
+ it('shows placeholder text', () => {
+ expect(wrapper.text()).toContain('Enter some text here...');
+ });
});
describe('fails', () => {
beforeEach(async () => {
renderMarkdown.mockRejectedValueOnce(new Error());
- createWrapper({ markddown: 'hello world' });
+ createWrapper({ markdown: 'hello world' });
await nextTick();
});
@@ -216,11 +221,17 @@ describe('ContentEditor', () => {
expect(findEditorElement().classes()).not.toContain('is-focused');
});
+
+ it('hides placeholder text', () => {
+ expect(wrapper.text()).not.toContain('Enter some text here...');
+ });
});
describe('when editorStateObserver emits docUpdate event', () => {
- it('emits change event with the latest markdown', async () => {
- const markdown = 'Loaded content';
+ let markdown;
+
+ beforeEach(async () => {
+ markdown = 'Loaded content';
renderMarkdown.mockResolvedValueOnce(markdown);
@@ -230,7 +241,9 @@ describe('ContentEditor', () => {
await waitForPromises();
findEditorStateObserver().vm.$emit('docUpdate');
+ });
+ it('emits change event with the latest markdown', () => {
expect(wrapper.emitted('change')).toEqual([
[
{
@@ -241,6 +254,10 @@ describe('ContentEditor', () => {
],
]);
});
+
+ it('hides the placeholder text', () => {
+ expect(wrapper.text()).not.toContain('Enter some text here...');
+ });
});
describe('when editorStateObserver emits keydown event', () => {
diff --git a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
index 40f05f66b5d..5af4784f358 100644
--- a/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
+++ b/spec/frontend/content_editor/components/toolbar_more_dropdown_spec.js
@@ -9,12 +9,14 @@ import { createTestEditor, mockChainedCommands, emitEditorEvent } from '../test_
describe('content_editor/components/toolbar_more_dropdown', () => {
let wrapper;
let tiptapEditor;
+ let contentEditor;
let eventHub;
const buildEditor = () => {
tiptapEditor = createTestEditor({
extensions: [Diagram, HorizontalRule],
});
+ contentEditor = { drawioEnabled: true };
eventHub = eventHubFactory();
};
@@ -22,6 +24,7 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
wrapper = mountExtended(ToolbarMoreDropdown, {
provide: {
tiptapEditor,
+ contentEditor,
eventHub,
},
propsData,
@@ -32,7 +35,6 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
beforeEach(() => {
buildEditor();
- buildWrapper();
});
describe.each`
@@ -52,6 +54,8 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
let btn;
beforeEach(async () => {
+ buildWrapper();
+
commands = mockChainedCommands(tiptapEditor, [command, 'focus', 'run']);
btn = wrapper.findByRole('button', { name });
});
@@ -68,8 +72,17 @@ describe('content_editor/components/toolbar_more_dropdown', () => {
});
});
+ it('does not show drawio option when drawio is disabled', () => {
+ contentEditor.drawioEnabled = false;
+ buildWrapper();
+
+ expect(wrapper.findByRole('button', { name: 'Create or edit diagram' }).exists()).toBe(false);
+ });
+
describe('a11y tests', () => {
it('sets toggleText and text-sr-only properties to the table button dropdown', () => {
+ buildWrapper();
+
expect(findDropdown().props()).toMatchObject({
textSrOnly: true,
toggleText: 'More options',
diff --git a/spec/frontend/content_editor/services/create_content_editor_spec.js b/spec/frontend/content_editor/services/create_content_editor_spec.js
index 62457ad8c10..00cc628ca72 100644
--- a/spec/frontend/content_editor/services/create_content_editor_spec.js
+++ b/spec/frontend/content_editor/services/create_content_editor_spec.js
@@ -20,7 +20,7 @@ describe('content_editor/services/create_content_editor', () => {
preserveUnchangedMarkdown: false,
},
};
- editor = createContentEditor({ renderMarkdown, uploadsPath });
+ editor = createContentEditor({ renderMarkdown, uploadsPath, drawioEnabled: true });
});
describe('when preserveUnchangedMarkdown feature is on', () => {
diff --git a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
index 90d83820c70..8ee37282ee9 100644
--- a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
+++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js
@@ -35,12 +35,10 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => {
beforeEach(async () => {
const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
- renderMarkdown.mockResolvedValueOnce(
- `<p><strong>${text}</strong></p><pre lang="javascript"></pre><!-- some comment -->`,
- );
+ renderMarkdown.mockResolvedValueOnce(`<p><strong>${text}</strong></p><!-- some comment -->`);
result = await deserializer.deserialize({
- content: 'content',
+ markdown: '**Bold text**\n<!-- some comment -->',
schema: tiptapEditor.schema,
});
});
@@ -53,12 +51,22 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => {
});
describe('when the render function returns an empty value', () => {
- it('returns an empty object', async () => {
- const deserializer = createMarkdownDeserializer({ render: renderMarkdown });
+ it('returns an empty prosemirror document', async () => {
+ const deserializer = createMarkdownDeserializer({
+ render: renderMarkdown,
+ schema: tiptapEditor.schema,
+ });
renderMarkdown.mockResolvedValueOnce(null);
- expect(await deserializer.deserialize({ content: 'content' })).toEqual({});
+ const result = await deserializer.deserialize({
+ markdown: '',
+ schema: tiptapEditor.schema,
+ });
+
+ const document = doc(p());
+
+ expect(result.document.toJSON()).toEqual(document.toJSON());
});
});
});
diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js
index 35104b84a88..8e84c672d90 100644
--- a/spec/frontend/header_search/components/app_spec.js
+++ b/spec/frontend/header_search/components/app_spec.js
@@ -183,10 +183,10 @@ describe('HeaderSearchApp', () => {
describe.each`
username | showDropdown | expectedDesc
- ${null} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
- ${null} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown}
- ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
- ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown}
+ ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN}
+ ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN}
+ ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN}
+ ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN}
`('Search Input Description', ({ username, showDropdown, expectedDesc }) => {
describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => {
beforeEach(() => {
@@ -208,7 +208,7 @@ describe('HeaderSearchApp', () => {
${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`}
${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`}
- ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.searchResultsLoading}
+ ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.SEARCH_RESULTS_LOADING}
`(
'Search Results Description',
({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => {
diff --git a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
index 06dfe1ff29b..e77a9231b7a 100644
--- a/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_autocomplete_items_spec.js
@@ -3,15 +3,14 @@ import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
+import { LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '~/header_search/constants';
import {
- GROUPS_CATEGORY,
- LARGE_AVATAR_PX,
PROJECTS_CATEGORY,
- SMALL_AVATAR_PX,
+ GROUPS_CATEGORY,
ISSUES_CATEGORY,
MERGE_REQUEST_CATEGORY,
RECENT_EPICS_CATEGORY,
-} from '~/header_search/constants';
+} from '~/vue_shared/global_search/constants';
import {
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
diff --git a/spec/frontend/header_search/components/header_search_scoped_items_spec.js b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
index 18ea927a78c..51d67198f04 100644
--- a/spec/frontend/header_search/components/header_search_scoped_items_spec.js
+++ b/spec/frontend/header_search/components/header_search_scoped_items_spec.js
@@ -5,7 +5,8 @@ import Vuex from 'vuex';
import { trimText } from 'helpers/text_helper';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { truncate } from '~/lib/utils/text_utility';
-import { MSG_IN_ALL_GITLAB, SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
+import { SCOPE_TOKEN_MAX_LENGTH } from '~/header_search/constants';
+import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants';
import {
MOCK_SEARCH,
MOCK_SCOPED_SEARCH_OPTIONS,
diff --git a/spec/frontend/header_search/mock_data.js b/spec/frontend/header_search/mock_data.js
index 3a8624ad9dd..2218c81efc3 100644
--- a/spec/frontend/header_search/mock_data.js
+++ b/spec/frontend/header_search/mock_data.js
@@ -1,16 +1,14 @@
+import { ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP } from '~/header_search/constants';
import {
+ PROJECTS_CATEGORY,
+ GROUPS_CATEGORY,
MSG_ISSUES_ASSIGNED_TO_ME,
MSG_ISSUES_IVE_CREATED,
MSG_MR_ASSIGNED_TO_ME,
MSG_MR_IM_REVIEWER,
MSG_MR_IVE_CREATED,
MSG_IN_ALL_GITLAB,
- PROJECTS_CATEGORY,
- ICON_PROJECT,
- GROUPS_CATEGORY,
- ICON_GROUP,
- ICON_SUBGROUP,
-} from '~/header_search/constants';
+} from '~/vue_shared/global_search/constants';
export const MOCK_USERNAME = 'anyone';
diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js
index db435e6e43c..062cd098640 100644
--- a/spec/frontend/notes/components/comment_form_spec.js
+++ b/spec/frontend/notes/components/comment_form_spec.js
@@ -5,12 +5,12 @@ import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import Vuex from 'vuex';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
-import Autosave from '~/autosave';
import batchComments from '~/batch_comments/stores/modules/batch_comments';
import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests';
import { createAlert } from '~/alert';
import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import axios from '~/lib/utils/axios_utils';
+import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue';
import { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status';
import CommentForm from '~/notes/components/comment_form.vue';
import CommentTypeDropdown from '~/notes/components/comment_type_dropdown.vue';
@@ -23,7 +23,6 @@ import { loggedOutnoteableData, notesDataMock, userDataMock, noteableDataMock }
jest.mock('autosize');
jest.mock('~/commons/nav/user_merge_requests');
jest.mock('~/alert');
-jest.mock('~/autosave');
Vue.use(Vuex);
@@ -33,7 +32,8 @@ describe('issue_comment_form component', () => {
let axiosMock;
const findCloseReopenButton = () => wrapper.findByTestId('close-reopen-button');
- const findTextArea = () => wrapper.findByTestId('comment-field');
+ const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor);
+ const findMarkdownEditorTextarea = () => findMarkdownEditor().find('textarea');
const findAddToReviewButton = () => wrapper.findByTestId('add-to-review-button');
const findAddCommentNowButton = () => wrapper.findByTestId('add-comment-now-button');
const findConfidentialNoteCheckbox = () => wrapper.findByTestId('internal-note-checkbox');
@@ -136,7 +136,6 @@ describe('issue_comment_form component', () => {
mountComponent({ mountFunction: mount, initialData: { note: 'hello world' } });
jest.spyOn(wrapper.vm, 'saveNote').mockResolvedValue();
- jest.spyOn(wrapper.vm, 'resizeTextarea');
jest.spyOn(wrapper.vm, 'stopPolling');
findCloseReopenButton().trigger('click');
@@ -145,7 +144,6 @@ describe('issue_comment_form component', () => {
expect(wrapper.vm.note).toBe('');
expect(wrapper.vm.saveNote).toHaveBeenCalled();
expect(wrapper.vm.stopPolling).toHaveBeenCalled();
- expect(wrapper.vm.resizeTextarea).toHaveBeenCalled();
});
it('does not report errors in the UI when the save succeeds', async () => {
@@ -260,6 +258,18 @@ describe('issue_comment_form component', () => {
});
});
+ it('hides content editor switcher if feature flag content_editor_on_issues is off', () => {
+ mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: false } });
+
+ expect(wrapper.text()).not.toContain('Rich text');
+ });
+
+ it('shows content editor switcher if feature flag content_editor_on_issues is on', () => {
+ mountComponent({ mountFunction: mount, features: { contentEditorOnIssues: true } });
+
+ expect(wrapper.text()).toContain('Rich text');
+ });
+
describe('textarea', () => {
describe('general', () => {
it.each`
@@ -268,13 +278,13 @@ describe('issue_comment_form component', () => {
${'internal note'} | ${true} | ${'Write an internal note or drag your files here…'}
`(
'should render textarea with placeholder for $noteType',
- ({ noteIsInternal, placeholder }) => {
- mountComponent({
- mountFunction: mount,
- initialData: { noteIsInternal },
- });
+ async ({ noteIsInternal, placeholder }) => {
+ mountComponent();
+
+ wrapper.vm.noteIsInternal = noteIsInternal;
+ await nextTick();
- expect(findTextArea().attributes('placeholder')).toBe(placeholder);
+ expect(findMarkdownEditor().props('formFieldProps').placeholder).toBe(placeholder);
},
);
@@ -290,13 +300,13 @@ describe('issue_comment_form component', () => {
await findCommentButton().trigger('click');
- expect(findTextArea().attributes('disabled')).toBe('disabled');
+ expect(findMarkdownEditor().find('textarea').attributes('disabled')).toBe('disabled');
});
it('should support quick actions', () => {
mountComponent({ mountFunction: mount });
- expect(findTextArea().attributes('data-supports-quick-actions')).toBe('true');
+ expect(findMarkdownEditor().props('supportsQuickActions')).toBe(true);
});
it('should link to markdown docs', () => {
@@ -336,63 +346,51 @@ describe('issue_comment_form component', () => {
it('should enter edit mode when arrow up is pressed', () => {
jest.spyOn(wrapper.vm, 'editCurrentUserLastNote');
- findTextArea().trigger('keydown.up');
+ findMarkdownEditorTextarea().trigger('keydown.up');
expect(wrapper.vm.editCurrentUserLastNote).toHaveBeenCalled();
});
- it('inits autosave', () => {
- expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [
- 'Note',
- 'Issue',
- noteableDataMock.id,
- ]);
- });
- });
+ describe('event enter', () => {
+ describe('when no draft exists', () => {
+ it('should save note when cmd+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSave');
- describe('event enter', () => {
- beforeEach(() => {
- mountComponent({ mountFunction: mount });
- });
-
- describe('when no draft exists', () => {
- it('should save note when cmd+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSave');
-
- findTextArea().trigger('keydown.enter', { metaKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
- expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
- });
+ expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
+ });
- it('should save note when ctrl+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSave');
+ it('should save note when ctrl+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSave');
- findTextArea().trigger('keydown.enter', { ctrlKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
- expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
+ expect(wrapper.vm.handleSave).toHaveBeenCalledWith();
+ });
});
- });
- describe('when a draft exists', () => {
- beforeEach(() => {
- store.registerModule('batchComments', batchComments());
- store.state.batchComments.drafts = [{ note: 'A' }];
- });
+ describe('when a draft exists', () => {
+ beforeEach(() => {
+ store.registerModule('batchComments', batchComments());
+ store.state.batchComments.drafts = [{ note: 'A' }];
+ });
- it('should save note draft when cmd+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSaveDraft');
+ it('should save note draft when cmd+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSaveDraft');
- findTextArea().trigger('keydown.enter', { metaKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { metaKey: true });
- expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
- });
+ expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
+ });
- it('should save note draft when ctrl+enter is pressed', () => {
- jest.spyOn(wrapper.vm, 'handleSaveDraft');
+ it('should save note draft when ctrl+enter is pressed', () => {
+ jest.spyOn(wrapper.vm, 'handleSaveDraft');
- findTextArea().trigger('keydown.enter', { ctrlKey: true });
+ findMarkdownEditorTextarea().trigger('keydown.enter', { ctrlKey: true });
- expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
+ expect(wrapper.vm.handleSaveDraft).toHaveBeenCalledWith();
+ });
});
});
});
@@ -661,7 +659,7 @@ describe('issue_comment_form component', () => {
});
it('should not render submission form', () => {
- expect(findTextArea().exists()).toBe(false);
+ expect(findMarkdownEditor().exists()).toBe(false);
});
});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
index 1bab8b51220..7da9c7533a0 100644
--- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
@@ -1,4 +1,4 @@
-import { GlIcon, GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
+import { GlSprintf, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { createMockDirective } from 'helpers/vue_mock_directive';
import { mockTracking } from 'helpers/tracking_helper';
@@ -201,13 +201,6 @@ describe('Image List Row', () => {
expect(findTagsCount().exists()).toBe(true);
});
- it('contains a tag icon', () => {
- mountComponent();
- const icon = findTagsCount().findComponent(GlIcon);
- expect(icon.exists()).toBe(true);
- expect(icon.props('name')).toBe('tag');
- });
-
describe('loading state', () => {
it('shows a loader when metadataLoading is true', () => {
mountComponent({ metadataLoading: true });
@@ -226,12 +219,12 @@ describe('Image List Row', () => {
it('with one tag in the image', () => {
mountComponent({ item: { ...item, tagsCount: 1 } });
- expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
+ expect(findTagsCount().text()).toMatchInterpolatedText('1 tag');
});
it('with more than one tag in the image', () => {
mountComponent({ item: { ...item, tagsCount: 3 } });
- expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
+ expect(findTagsCount().text()).toMatchInterpolatedText('3 tags');
});
});
});
diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js
index 1c93abbc758..538e87cf843 100644
--- a/spec/frontend/super_sidebar/components/context_switcher_spec.js
+++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js
@@ -5,8 +5,8 @@ import * as Sentry from '@sentry/browser';
import { s__ } from '~/locale';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue';
-import FrequentProjectsList from '~/super_sidebar/components/frequent_projects_list.vue';
-import FrequentGroupsList from '~/super_sidebar/components/frequent_groups_list.vue';
+import ProjectsList from '~/super_sidebar/components/projects_list.vue';
+import GroupsList from '~/super_sidebar/components/groups_list.vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import searchUserProjectsAndGroupsQuery from '~/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql';
import { trackContextAccess, formatContextSwitcherItems } from '~/super_sidebar/utils';
@@ -34,8 +34,8 @@ describe('ContextSwitcher component', () => {
let mockApollo;
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
- const findFrequentProjectsList = () => wrapper.findComponent(FrequentProjectsList);
- const findFrequentGroupsList = () => wrapper.findComponent(FrequentGroupsList);
+ const findProjectsList = () => wrapper.findComponent(ProjectsList);
+ const findGroupsList = () => wrapper.findComponent(GroupsList);
const triggerSearchQuery = async () => {
findSearchBox().vm.$emit('input', 'foo');
@@ -69,10 +69,10 @@ describe('ContextSwitcher component', () => {
GlSearchBoxByType: stubComponent(GlSearchBoxByType, {
props: ['placeholder'],
}),
- FrequentProjectsList: stubComponent(FrequentProjectsList, {
+ ProjectsList: stubComponent(ProjectsList, {
props: ['username', 'viewAllLink', 'isSearch', 'searchResults'],
}),
- FrequentGroupsList: stubComponent(FrequentGroupsList, {
+ GroupsList: stubComponent(GroupsList, {
props: ['username', 'viewAllLink', 'isSearch', 'searchResults'],
}),
},
@@ -91,7 +91,7 @@ describe('ContextSwitcher component', () => {
});
it('passes the correct props the frequent projects list', () => {
- expect(findFrequentProjectsList().props()).toEqual({
+ expect(findProjectsList().props()).toEqual({
username,
viewAllLink: projectsPath,
isSearch: false,
@@ -100,7 +100,7 @@ describe('ContextSwitcher component', () => {
});
it('passes the correct props the frequent groups list', () => {
- expect(findFrequentGroupsList().props()).toEqual({
+ expect(findGroupsList().props()).toEqual({
username,
viewAllLink: groupsPath,
isSearch: false,
@@ -142,20 +142,16 @@ describe('ContextSwitcher component', () => {
expect(searchUserProjectsAndGroupsHandlerSuccess).toHaveBeenCalled();
});
- it('removes the top border from the projects list', () => {
- expect(findFrequentProjectsList().attributes('class')).toContain('gl-border-t-0');
- });
-
it('passes the projects to the frequent projects list', () => {
- expect(findFrequentProjectsList().props('isSearch')).toBe(true);
- expect(findFrequentProjectsList().props('searchResults')).toEqual(
+ expect(findProjectsList().props('isSearch')).toBe(true);
+ expect(findProjectsList().props('searchResults')).toEqual(
formatContextSwitcherItems(searchUserProjectsAndGroupsResponseMock.data.projects.nodes),
);
});
it('passes the groups to the frequent groups list', () => {
- expect(findFrequentGroupsList().props('isSearch')).toBe(true);
- expect(findFrequentGroupsList().props('searchResults')).toEqual(
+ expect(findGroupsList().props('isSearch')).toBe(true);
+ expect(findGroupsList().props('searchResults')).toEqual(
formatContextSwitcherItems(searchUserProjectsAndGroupsResponseMock.data.user.groups.nodes),
);
});
@@ -184,10 +180,10 @@ describe('ContextSwitcher component', () => {
});
it('passes empty results to the lists', () => {
- expect(findFrequentProjectsList().props('isSearch')).toBe(true);
- expect(findFrequentProjectsList().props('searchResults')).toEqual([]);
- expect(findFrequentGroupsList().props('isSearch')).toBe(true);
- expect(findFrequentGroupsList().props('searchResults')).toEqual([]);
+ expect(findProjectsList().props('isSearch')).toBe(true);
+ expect(findProjectsList().props('searchResults')).toEqual([]);
+ expect(findGroupsList().props('isSearch')).toBe(true);
+ expect(findGroupsList().props('searchResults')).toEqual([]);
});
});
diff --git a/spec/frontend/super_sidebar/components/frequent_groups_list_spec.js b/spec/frontend/super_sidebar/components/frequent_groups_list_spec.js
deleted file mode 100644
index c3a1bd5d967..00000000000
--- a/spec/frontend/super_sidebar/components/frequent_groups_list_spec.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { s__ } from '~/locale';
-import FrequentGroupsList from '~/super_sidebar/components//frequent_groups_list.vue';
-import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue';
-import NavItem from '~/super_sidebar/components/nav_item.vue';
-import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants';
-
-const username = 'root';
-const viewAllLink = '/path/to/groups';
-const storageKey = `${username}/frequent-groups`;
-
-describe('FrequentGroupsList component', () => {
- let wrapper;
-
- const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
- const findViewAllLink = () => wrapper.findComponent(NavItem);
-
- const createWrapper = () => {
- wrapper = shallowMountExtended(FrequentGroupsList, {
- propsData: {
- username,
- viewAllLink,
- },
- });
- };
-
- beforeEach(() => {
- createWrapper();
- });
-
- it('passes the correct props to the frequent items list', () => {
- expect(findFrequentItemsList().props()).toEqual({
- title: s__('Navigation|Frequent groups'),
- searchTitle: s__('Navigation|Groups'),
- storageKey,
- maxItems: MAX_FREQUENT_GROUPS_COUNT,
- pristineText: s__('Navigation|Groups you visit often will appear here.'),
- noResultsText: s__('Navigation|No group matches found'),
- isSearch: false,
- searchResults: [],
- });
- });
-
- it('renders the "View all..." item', () => {
- expect(findViewAllLink().props('item')).toEqual({
- icon: 'group',
- link: viewAllLink,
- title: s__('Navigation|View all groups'),
- });
- });
-});
diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
index 6c8a131f73d..1e98db091f2 100644
--- a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
+++ b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js
@@ -1,18 +1,14 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { s__ } from '~/locale';
import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue';
-import NavItem from '~/super_sidebar/components/nav_item.vue';
+import ItemsList from '~/super_sidebar/components/items_list.vue';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { cachedFrequentProjects } from '../mock_data';
const title = s__('Navigation|FREQUENT PROJECTS');
-const searchTitle = 'PROJECTS';
const pristineText = s__('Navigation|Projects you visit often will appear here.');
-const noResultsText = s__('Navigation|No project matches found');
const storageKey = 'storageKey';
const maxItems = 5;
-const mockItems = JSON.parse(cachedFrequentProjects);
-const mostFrequentProject = mockItems[4];
describe('FrequentItemsList component', () => {
useLocalStorageSpy();
@@ -20,16 +16,14 @@ describe('FrequentItemsList component', () => {
let wrapper;
const findListTitle = () => wrapper.findByTestId('list-title');
- const findNavItems = () => wrapper.findAllComponents(NavItem);
+ const findItemsList = () => wrapper.findComponent(ItemsList);
const findEmptyText = () => wrapper.findByTestId('empty-text');
const createWrapper = ({ props = {} } = {}) => {
wrapper = shallowMountExtended(FrequentItemsList, {
propsData: {
title,
- searchTitle,
pristineText,
- noResultsText,
storageKey,
maxItems,
...props,
@@ -58,58 +52,17 @@ describe('FrequentItemsList component', () => {
createWrapper();
});
- it('attempts to retrieve the projects and groups from the local storage', () => {
+ it('attempts to retrieve the items from the local storage', () => {
expect(window.localStorage.getItem).toHaveBeenCalledTimes(1);
expect(window.localStorage.getItem).toHaveBeenCalledWith(storageKey);
});
it('renders the maximum amount of items', () => {
- expect(findNavItems().length).toBe(maxItems);
- });
-
- it('passes the remapped `item` prop to nav items', () => {
- const firstNavItem = findNavItems().at(0);
-
- expect(firstNavItem.props('item')).toEqual({
- id: mostFrequentProject.id,
- title: mostFrequentProject.name,
- subtitle: mostFrequentProject.namespace.split(' / ')[0],
- avatar: mostFrequentProject.avatarUrl,
- link: mostFrequentProject.webUrl,
- });
+ expect(findItemsList().props('items').length).toBe(maxItems);
});
it('does not render the empty text slot', () => {
expect(findEmptyText().exists()).toBe(false);
});
});
-
- describe('when displaying search results', () => {
- beforeEach(() => {
- window.localStorage.setItem(storageKey, cachedFrequentProjects);
- });
-
- it('render the search title', () => {
- const searchResults = [{ id: 1 }];
- createWrapper({ props: { isSearch: true, searchResults } });
-
- expect(findListTitle().text()).toBe(searchTitle);
- });
-
- it('shows search results instead of cached items', () => {
- const searchResults = [{ id: 1 }];
- createWrapper({ props: { isSearch: true, searchResults } });
- const firstNavItem = findNavItems().at(0);
-
- expect(firstNavItem.props('item')).toEqual(searchResults[0]);
- });
-
- it('shows the no results text if search results are empty', () => {
- const searchResults = [];
- createWrapper({ props: { isSearch: true, searchResults } });
-
- expect(findNavItems().length).toEqual(0);
- expect(findEmptyText().text()).toBe(noResultsText);
- });
- });
});
diff --git a/spec/frontend/super_sidebar/components/frequent_projects_list_spec.js b/spec/frontend/super_sidebar/components/frequent_projects_list_spec.js
deleted file mode 100644
index 35c8b2721bf..00000000000
--- a/spec/frontend/super_sidebar/components/frequent_projects_list_spec.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import { s__ } from '~/locale';
-import FrequentProjectsList from '~/super_sidebar/components//frequent_projects_list.vue';
-import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue';
-import NavItem from '~/super_sidebar/components/nav_item.vue';
-import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants';
-
-const username = 'root';
-const viewAllLink = '/path/to/projects';
-const storageKey = `${username}/frequent-projects`;
-
-describe('FrequentProjectsList component', () => {
- let wrapper;
-
- const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
- const findViewAllLink = () => wrapper.findComponent(NavItem);
-
- const createWrapper = () => {
- wrapper = shallowMountExtended(FrequentProjectsList, {
- propsData: {
- username,
- viewAllLink,
- },
- });
- };
-
- beforeEach(() => {
- createWrapper();
- });
-
- it('passes the correct props to the frequent items list', () => {
- expect(findFrequentItemsList().props()).toEqual({
- title: s__('Navigation|Frequent projects'),
- searchTitle: s__('Navigation|Projects'),
- storageKey,
- maxItems: MAX_FREQUENT_PROJECTS_COUNT,
- pristineText: s__('Navigation|Projects you visit often will appear here.'),
- noResultsText: s__('Navigation|No project matches found'),
- isSearch: false,
- searchResults: [],
- });
- });
-
- it('renders the "View all..." item', () => {
- expect(findViewAllLink().props('item')).toEqual({
- icon: 'project',
- link: viewAllLink,
- title: s__('Navigation|View all projects'),
- });
- });
-});
diff --git a/spec/frontend/super_sidebar/components/groups_list_spec.js b/spec/frontend/super_sidebar/components/groups_list_spec.js
new file mode 100644
index 00000000000..6aee895f611
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/groups_list_spec.js
@@ -0,0 +1,87 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import GroupsList from '~/super_sidebar/components/groups_list.vue';
+import SearchResults from '~/super_sidebar/components/search_results.vue';
+import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants';
+
+const username = 'root';
+const viewAllLink = '/path/to/groups';
+const storageKey = `${username}/frequent-groups`;
+
+describe('GroupsList component', () => {
+ let wrapper;
+
+ const findSearchResults = () => wrapper.findComponent(SearchResults);
+ const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
+ const findViewAllLink = () => wrapper.findComponent(NavItem);
+
+ const itRendersViewAllItem = () => {
+ it('renders the "View all..." item', () => {
+ expect(findViewAllLink().props('item')).toEqual({
+ icon: 'group',
+ link: viewAllLink,
+ title: s__('Navigation|View all groups'),
+ });
+ });
+ };
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(GroupsList, {
+ propsData: {
+ username,
+ viewAllLink,
+ ...props,
+ },
+ });
+ };
+
+ describe('when displaying search results', () => {
+ const searchResults = ['A search result'];
+
+ beforeEach(() => {
+ createWrapper({
+ isSearch: true,
+ searchResults,
+ });
+ });
+
+ it('renders the search results component', () => {
+ expect(findSearchResults().exists()).toBe(true);
+ expect(findFrequentItemsList().exists()).toBe(false);
+ });
+
+ it('passes the correct props to the search results component', () => {
+ expect(findSearchResults().props()).toEqual({
+ title: s__('Navigation|Groups'),
+ noResultsText: s__('Navigation|No group matches found'),
+ searchResults,
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+
+ describe('when displaying frequent groups', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('renders the frequent items list', () => {
+ expect(findFrequentItemsList().exists()).toBe(true);
+ expect(findSearchResults().exists()).toBe(false);
+ });
+
+ it('passes the correct props to the frequent items list', () => {
+ expect(findFrequentItemsList().props()).toEqual({
+ title: s__('Navigation|Frequent groups'),
+ storageKey,
+ maxItems: MAX_FREQUENT_GROUPS_COUNT,
+ pristineText: s__('Navigation|Groups you visit often will appear here.'),
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js
new file mode 100644
index 00000000000..8e00984f500
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/items_list_spec.js
@@ -0,0 +1,63 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ItemsList from '~/super_sidebar/components/items_list.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { cachedFrequentProjects } from '../mock_data';
+
+const mockItems = JSON.parse(cachedFrequentProjects);
+const [firstMockedProject] = mockItems;
+
+describe('ItemsList component', () => {
+ let wrapper;
+
+ const findNavItems = () => wrapper.findAllComponents(NavItem);
+
+ const createWrapper = ({ props = {}, slots = {} } = {}) => {
+ wrapper = shallowMountExtended(ItemsList, {
+ propsData: {
+ ...props,
+ },
+ slots,
+ });
+ };
+
+ it('does not render nav items when there are no items', () => {
+ createWrapper();
+
+ expect(findNavItems().length).toBe(0);
+ });
+
+ it('renders one nav item per item', () => {
+ createWrapper({
+ props: {
+ items: mockItems,
+ },
+ });
+
+ expect(findNavItems().length).not.toBe(0);
+ expect(findNavItems().length).toBe(mockItems.length);
+ });
+
+ it('passes the correct props to the nav items', () => {
+ createWrapper({
+ props: {
+ items: mockItems,
+ },
+ });
+ const firstNavItem = findNavItems().at(0);
+
+ expect(firstNavItem.props('item')).toEqual(firstMockedProject);
+ });
+
+ it('renders the `view-all-items` slot', () => {
+ const testId = 'view-all-items';
+ createWrapper({
+ slots: {
+ 'view-all-items': {
+ template: `<div data-testid="${testId}" />`,
+ },
+ },
+ });
+
+ expect(wrapper.findByTestId(testId).exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/projects_list_spec.js b/spec/frontend/super_sidebar/components/projects_list_spec.js
new file mode 100644
index 00000000000..cdc003b14e0
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/projects_list_spec.js
@@ -0,0 +1,82 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import ProjectsList from '~/super_sidebar/components/projects_list.vue';
+import SearchResults from '~/super_sidebar/components/search_results.vue';
+import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue';
+import NavItem from '~/super_sidebar/components/nav_item.vue';
+import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants';
+
+const username = 'root';
+const viewAllLink = '/path/to/projects';
+const storageKey = `${username}/frequent-projects`;
+
+describe('ProjectsList component', () => {
+ let wrapper;
+
+ const findSearchResults = () => wrapper.findComponent(SearchResults);
+ const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList);
+ const findViewAllLink = () => wrapper.findComponent(NavItem);
+
+ const itRendersViewAllItem = () => {
+ it('renders the "View all..." item', () => {
+ expect(findViewAllLink().props('item')).toEqual({
+ icon: 'project',
+ link: viewAllLink,
+ title: s__('Navigation|View all projects'),
+ });
+ });
+ };
+
+ const createWrapper = (props = {}) => {
+ wrapper = shallowMountExtended(ProjectsList, {
+ propsData: {
+ username,
+ viewAllLink,
+ ...props,
+ },
+ });
+ };
+
+ describe('when displaying search results', () => {
+ const searchResults = ['A search result'];
+
+ beforeEach(() => {
+ createWrapper({
+ isSearch: true,
+ searchResults,
+ });
+ });
+
+ it('renders the search results component', () => {
+ expect(findSearchResults().exists()).toBe(true);
+ expect(findFrequentItemsList().exists()).toBe(false);
+ });
+
+ it('passes the correct props to the search results component', () => {
+ expect(findSearchResults().props()).toEqual({
+ title: s__('Navigation|Projects'),
+ noResultsText: s__('Navigation|No project matches found'),
+ searchResults,
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+
+ describe('when displaying frequent projects', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it('passes the correct props to the frequent items list', () => {
+ expect(findFrequentItemsList().props()).toEqual({
+ title: s__('Navigation|Frequent projects'),
+ storageKey,
+ maxItems: MAX_FREQUENT_PROJECTS_COUNT,
+ pristineText: s__('Navigation|Projects you visit often will appear here.'),
+ });
+ });
+
+ itRendersViewAllItem();
+ });
+});
diff --git a/spec/frontend/super_sidebar/components/search_results_spec.js b/spec/frontend/super_sidebar/components/search_results_spec.js
new file mode 100644
index 00000000000..dd48935c138
--- /dev/null
+++ b/spec/frontend/super_sidebar/components/search_results_spec.js
@@ -0,0 +1,57 @@
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { s__ } from '~/locale';
+import SearchResults from '~/super_sidebar/components/search_results.vue';
+import ItemsList from '~/super_sidebar/components/items_list.vue';
+
+const title = s__('Navigation|PROJECTS');
+const noResultsText = s__('Navigation|No project matches found');
+
+describe('SearchResults component', () => {
+ let wrapper;
+
+ const findListTitle = () => wrapper.findByTestId('list-title');
+ const findItemsList = () => wrapper.findComponent(ItemsList);
+ const findEmptyText = () => wrapper.findByTestId('empty-text');
+
+ const createWrapper = ({ props = {} } = {}) => {
+ wrapper = shallowMountExtended(SearchResults, {
+ propsData: {
+ title,
+ noResultsText,
+ ...props,
+ },
+ });
+ };
+
+ describe('default state', () => {
+ beforeEach(() => {
+ createWrapper();
+ });
+
+ it("renders the list's title", () => {
+ expect(findListTitle().text()).toBe(title);
+ });
+
+ it('renders the empty text', () => {
+ expect(findEmptyText().exists()).toBe(true);
+ expect(findEmptyText().text()).toBe(noResultsText);
+ });
+ });
+
+ describe('when displaying search results', () => {
+ it('shows search results', () => {
+ const searchResults = [{ id: 1 }];
+ createWrapper({ props: { isSearch: true, searchResults } });
+
+ expect(findItemsList().props('items')[0]).toEqual(searchResults[0]);
+ });
+
+ it('shows the no results text if search results are empty', () => {
+ const searchResults = [];
+ createWrapper({ props: { isSearch: true, searchResults } });
+
+ expect(findItemsList().props('items').length).toEqual(0);
+ expect(findEmptyText().text()).toBe(noResultsText);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
index 8cb773ddfa3..681ff6c8dd3 100644
--- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js
@@ -63,6 +63,18 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync);
const findContentEditor = () => wrapper.findComponent(ContentEditor);
+ const enableContentEditor = async () => {
+ findMarkdownField().vm.$emit('enableContentEditor');
+ await nextTick();
+ await waitForPromises();
+ };
+
+ const enableMarkdownEditor = async () => {
+ findContentEditor().vm.$emit('enableMarkdownEditor');
+ await nextTick();
+ await waitForPromises();
+ };
+
beforeEach(() => {
window.uploads_path = 'uploads';
mock = new MockAdapter(axios);
@@ -90,6 +102,18 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
});
+ it('enables content editor switcher when contentEditorEnabled prop is true', () => {
+ buildWrapper({ propsData: { enableContentEditor: true } });
+
+ expect(findMarkdownField().text()).toContain('Rich text');
+ });
+
+ it('hides content editor switcher when contentEditorEnabled prop is false', () => {
+ buildWrapper({ propsData: { enableContentEditor: false } });
+
+ expect(findMarkdownField().text()).not.toContain('Rich text');
+ });
+
it('passes down any additional props to markdown field component', () => {
const propsData = {
line: { text: 'hello world', richText: 'hello world' },
@@ -110,6 +134,36 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
});
+ describe('disabled', () => {
+ it('disables markdown field when disabled prop is true', () => {
+ buildWrapper({ propsData: { disabled: true } });
+
+ expect(findMarkdownField().find('textarea').attributes('disabled')).toBe('disabled');
+ });
+
+ it('enables markdown field when disabled prop is false', () => {
+ buildWrapper({ propsData: { disabled: false } });
+
+ expect(findMarkdownField().find('textarea').attributes('disabled')).toBe(undefined);
+ });
+
+ it('disables content editor when disabled prop is true', async () => {
+ buildWrapper({ propsData: { disabled: true } });
+
+ await enableContentEditor();
+
+ expect(findContentEditor().props('editable')).toBe(false);
+ });
+
+ it('enables content editor when disabled prop is false', async () => {
+ buildWrapper({ propsData: { disabled: false } });
+
+ await enableContentEditor();
+
+ expect(findContentEditor().props('editable')).toBe(true);
+ });
+ });
+
describe('autosize', () => {
it('autosizes the textarea when the value changes', async () => {
buildWrapper();
@@ -129,12 +183,10 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
it('does not autosize the textarea if markdown editor is disabled', async () => {
buildWrapper();
- findMarkdownField().vm.$emit('enableContentEditor');
+ await enableContentEditor();
wrapper.setProps({ value: 'Lots of newlines\n\n\n\n\n\n\nMore content\n\n\nand newlines' });
- await nextTick();
- await waitForPromises();
expect(Autosize.update).not.toHaveBeenCalled();
});
});
@@ -200,9 +252,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
it(`emits ${EDITING_MODE_CONTENT_EDITOR} event when enableContentEditor emitted from markdown editor`, async () => {
buildWrapper();
- findMarkdownField().vm.$emit('enableContentEditor');
-
- await nextTick();
+ await enableContentEditor();
expect(wrapper.emitted(EDITING_MODE_CONTENT_EDITOR)).toHaveLength(1);
});
@@ -212,11 +262,8 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
stubs: { ContentEditor: stubComponent(ContentEditor) },
});
- findMarkdownField().vm.$emit('enableContentEditor');
-
- await nextTick();
-
- findContentEditor().vm.$emit('enableMarkdownEditor');
+ await enableContentEditor();
+ await enableMarkdownEditor();
expect(wrapper.emitted(EDITING_MODE_MARKDOWN_FIELD)).toHaveLength(1);
});
@@ -262,9 +309,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
describe(`when markdown field triggers enableContentEditor event`, () => {
- beforeEach(() => {
+ beforeEach(async () => {
buildWrapper();
- findMarkdownField().vm.$emit('enableContentEditor');
+ await enableContentEditor();
});
it('displays the content editor', () => {
@@ -300,9 +347,9 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
describe(`when editingMode is ${EDITING_MODE_CONTENT_EDITOR}`, () => {
- beforeEach(() => {
+ beforeEach(async () => {
buildWrapper({ propsData: { autosaveKey: 'issue/1234' } });
- findMarkdownField().vm.$emit('enableContentEditor');
+ await enableContentEditor();
});
describe('when autofocus is true', () => {
@@ -343,9 +390,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => {
});
describe(`when richText editor triggers enableMarkdownEditor event`, () => {
- beforeEach(() => {
- findContentEditor().vm.$emit('enableMarkdownEditor');
- });
+ beforeEach(enableMarkdownEditor);
it('hides the content editor', () => {
expect(findContentEditor().exists()).toBe(false);
diff --git a/spec/helpers/artifacts_helper_spec.rb b/spec/helpers/artifacts_helper_spec.rb
index cf48f0ecc39..7c577cbf11c 100644
--- a/spec/helpers/artifacts_helper_spec.rb
+++ b/spec/helpers/artifacts_helper_spec.rb
@@ -17,6 +17,7 @@ RSpec.describe ArtifactsHelper, feature_category: :build_artifacts do
it 'returns expected data' do
expect(subject).to include({
project_path: project.full_path,
+ project_id: project.id,
artifacts_management_feedback_image_path: match_asset_path('illustrations/chat-bubble-sm.svg')
})
end
diff --git a/spec/support/helpers/content_editor_helpers.rb b/spec/support/helpers/content_editor_helpers.rb
new file mode 100644
index 00000000000..c12fd1fbbd7
--- /dev/null
+++ b/spec/support/helpers/content_editor_helpers.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module ContentEditorHelpers
+ def switch_to_content_editor
+ click_button _('Viewing markdown')
+ click_button _('Rich text')
+ end
+
+ def type_in_content_editor(keys)
+ find(content_editor_testid).send_keys keys
+ end
+
+ def open_insert_media_dropdown
+ page.find('svg[data-testid="media-icon"]').click
+ end
+
+ def set_source_editor_content(content)
+ find('.js-gfm-input').set content
+ end
+
+ def expect_formatting_menu_to_be_visible
+ expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+
+ def expect_formatting_menu_to_be_hidden
+ expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
+ end
+
+ def expect_media_bubble_menu_to_be_visible
+ expect(page).to have_css('[data-testid="media-bubble-menu"]')
+ end
+
+ def upload_asset(fixture_name)
+ attach_file('content_editor_image', Rails.root.join('spec', 'fixtures', fixture_name), make_visible: true)
+ end
+
+ def wait_until_hidden_field_is_updated(value)
+ expect(page).to have_field(with: value, type: 'hidden')
+ end
+
+ def display_media_bubble_menu(media_element_selector, fixture_file)
+ upload_asset fixture_file
+
+ wait_for_requests
+
+ expect(page).to have_css(media_element_selector)
+
+ page.find(media_element_selector).click
+ end
+
+ def click_edit_diagram_button
+ page.find('[data-testid="edit-diagram"]').click
+ end
+
+ def expect_drawio_editor_is_opened
+ expect(page).to have_css('#drawio-frame', visible: :hidden)
+ end
+end
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
index 05b5b8cac08..7582e67efbd 100644
--- a/spec/support/shared_examples/features/content_editor_shared_examples.rb
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -1,54 +1,9 @@
# frozen_string_literal: true
RSpec.shared_examples 'edits content using the content editor' do
- let(:content_editor_testid) { '[data-testid="content-editor"] [contenteditable].ProseMirror' }
-
- def switch_to_content_editor
- click_button _('Viewing markdown')
- click_button _('Rich text')
- end
-
- def type_in_content_editor(keys)
- find(content_editor_testid).send_keys keys
- end
-
- def open_insert_media_dropdown
- page.find('svg[data-testid="media-icon"]').click
- end
-
- def set_source_editor_content(content)
- find('.js-gfm-input').set content
- end
-
- def expect_formatting_menu_to_be_visible
- expect(page).to have_css('[data-testid="formatting-bubble-menu"]')
- end
-
- def expect_formatting_menu_to_be_hidden
- expect(page).not_to have_css('[data-testid="formatting-bubble-menu"]')
- end
-
- def expect_media_bubble_menu_to_be_visible
- expect(page).to have_css('[data-testid="media-bubble-menu"]')
- end
+ include ContentEditorHelpers
- def upload_asset(fixture_name)
- attach_file('content_editor_image', Rails.root.join('spec', 'fixtures', fixture_name), make_visible: true)
- end
-
- def wait_until_hidden_field_is_updated(value)
- expect(page).to have_field('wiki[content]', with: value, type: 'hidden')
- end
-
- def display_media_bubble_menu(media_element_selector, fixture_file)
- upload_asset fixture_file
-
- wait_for_requests
-
- expect(page).to have_css(media_element_selector)
-
- page.find(media_element_selector).click
- end
+ let(:content_editor_testid) { '[data-testid="content-editor"] [contenteditable].ProseMirror' }
it 'saves page content in local storage if the user navigates away' do
switch_to_content_editor
@@ -62,16 +17,6 @@ RSpec.shared_examples 'edits content using the content editor' do
refresh
expect(page).to have_text('Typing text in the content editor')
-
- refresh # also retained after second refresh
-
- expect(page).to have_text('Typing text in the content editor')
-
- click_link 'Cancel' # draft is deleted on cancel
-
- page.go_back
-
- expect(page).not_to have_text('Typing text in the content editor')
end
describe 'formatting bubble menu' do
@@ -117,33 +62,6 @@ RSpec.shared_examples 'edits content using the content editor' do
end
end
- describe 'diagrams.net editor' do
- def click_edit_diagram_button
- page.find('[data-testid="edit-diagram"]').click
- end
-
- def expect_drawio_editor_is_opened
- expect(page).to have_css('#drawio-frame', visible: :hidden)
- end
-
- before do
- switch_to_content_editor
-
- open_insert_media_dropdown
- end
-
- it 'displays correct media bubble menu with edit diagram button' do
- display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'diagram.drawio.svg'
-
- expect_formatting_menu_to_be_hidden
- expect_media_bubble_menu_to_be_visible
-
- click_edit_diagram_button
-
- expect_drawio_editor_is_opened
- end
- end
-
describe 'code block' do
before do
visit(profile_preferences_path)
@@ -255,7 +173,7 @@ RSpec.shared_examples 'edits content using the content editor' do
before do
if defined?(project)
create(:issue, project: project, title: 'My Cool Linked Issue')
- create(:merge_request, source_project: project, title: 'My Cool Merge Request')
+ create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request')
create(:label, project: project, title: 'My Cool Label')
create(:milestone, project: project, title: 'My Cool Milestone')
@@ -264,7 +182,7 @@ RSpec.shared_examples 'edits content using the content editor' do
project = create(:project, group: group)
create(:issue, project: project, title: 'My Cool Linked Issue')
- create(:merge_request, source_project: project, title: 'My Cool Merge Request')
+ create(:merge_request, source_project: project, source_branch: 'branch-1', title: 'My Cool Merge Request')
create(:group_label, group: group, title: 'My Cool Label')
create(:milestone, group: group, title: 'My Cool Milestone')
@@ -281,7 +199,9 @@ RSpec.shared_examples 'edits content using the content editor' do
expect(find(suggestions_dropdown)).to have_text('abc123')
expect(find(suggestions_dropdown)).to have_text('all')
- expect(find(suggestions_dropdown)).to have_text('Group Members (2)')
+ expect(find(suggestions_dropdown)).to have_text('Group Members')
+
+ type_in_content_editor 'bc'
send_keys [:arrow_down, :enter]
@@ -362,3 +282,24 @@ RSpec.shared_examples 'edits content using the content editor' do
end
end
end
+
+RSpec.shared_examples 'inserts diagrams.net diagram using the content editor' do
+ include ContentEditorHelpers
+
+ before do
+ switch_to_content_editor
+
+ open_insert_media_dropdown
+ end
+
+ it 'displays correct media bubble menu with edit diagram button' do
+ display_media_bubble_menu '[data-testid="content_editor_editablebox"] img[src]', 'diagram.drawio.svg'
+
+ expect_formatting_menu_to_be_hidden
+ expect_media_bubble_menu_to_be_visible
+
+ click_edit_diagram_button
+
+ expect_drawio_editor_is_opened
+ end
+end
diff --git a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
index 0334187e4b1..c1e4185e058 100644
--- a/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
+++ b/spec/support/shared_examples/features/wiki/user_updates_wiki_page_shared_examples.rb
@@ -150,6 +150,7 @@ RSpec.shared_examples 'User updates wiki page' do
end
it_behaves_like 'edits content using the content editor'
+ it_behaves_like 'inserts diagrams.net diagram using the content editor'
it_behaves_like 'autocompletes items'
end