diff options
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 Binary files differnew file mode 100644 index 00000000000..09b30af91d0 --- /dev/null +++ b/doc/user/project/repository/branches/img/view_branch_protections_v15_10.png 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 |