diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-27 06:10:19 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-27 06:10:19 +0300 |
commit | 0a0dcc392ca69b7f0567bff6bc1040ded035a11b (patch) | |
tree | 1a892633e20f593140612e700a31c4460b2a08c0 /app/assets/javascripts | |
parent | 272c39ac05e0d68444114aed58ef2b44e1af30d6 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts')
23 files changed, 438 insertions, 142 deletions
diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index 6cae6b24f20..1ecef44ab8f 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -87,6 +87,7 @@ export const REPORTED_CONTENT_I18N = { comment: s__('AbuseReport|Reported comment'), issue: s__('AbuseReport|Reported issue'), merge_request: s__('AbuseReport|Reported merge request'), + epic: s__('AbuseReport|Reported epic'), unknown: s__('AbuseReport|Reported content'), }, viewScreenshot: s__('AbuseReport|View screenshot'), @@ -96,6 +97,7 @@ export const REPORTED_CONTENT_I18N = { comment: s__('AbuseReport|Go to comment'), issue: s__('AbuseReport|Go to issue'), merge_request: s__('AbuseReport|Go to merge request'), + epic: s__('AbuseReport|Go to epic'), unknown: s__('AbuseReport|Go to content'), }, reportedBy: s__('AbuseReport|Reported by'), diff --git a/app/assets/javascripts/ci/admin/jobs_table/constants.js b/app/assets/javascripts/ci/admin/jobs_table/constants.js index ff0efdb1f5b..86c9ab53e75 100644 --- a/app/assets/javascripts/ci/admin/jobs_table/constants.js +++ b/app/assets/javascripts/ci/admin/jobs_table/constants.js @@ -26,9 +26,6 @@ export const DEFAULT_FIELDS_ADMIN = [ { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, { key: 'pipeline', label: __('Pipeline'), columnClass: 'gl-w-10p' }, - { key: 'stage', label: __('Stage'), columnClass: 'gl-w-10p' }, - { key: 'name', label: __('Name'), columnClass: 'gl-w-15p' }, - { key: 'duration', label: __('Duration'), columnClass: 'gl-w-15p' }, { key: 'actions', label: '', columnClass: 'gl-w-10p' }, ]; diff --git a/app/assets/javascripts/ci/common/private/job_action_component.vue b/app/assets/javascripts/ci/common/private/job_action_component.vue index f649750ce8a..b0fa724d450 100644 --- a/app/assets/javascripts/ci/common/private/job_action_component.vue +++ b/app/assets/javascripts/ci/common/private/job_action_component.vue @@ -120,7 +120,7 @@ export default { :class="cssClass" :disabled="isDisabled" class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" - data-testid="ci-action-component" + data-testid="ci-action-button" @click.stop="onClickAction" > <div diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue index b435eb283fd..fbdfc7c9c6a 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue @@ -35,9 +35,6 @@ export default { jobRef() { return this.job?.refName; }, - jobRefPath() { - return this.job?.refPath; - }, jobTags() { return this.job.tags; }, @@ -72,61 +69,60 @@ export default { <template> <div> <div class="gl-text-truncate gl-p-3 gl-mt-n3 gl-mx-n3 gl-mb-n2"> - <gl-link - v-if="canReadJob" - class="gl-text-blue-600!" - :href="jobPath" - data-testid="job-id-link" - > - {{ jobId }} - </gl-link> - - <span v-else data-testid="job-id-limited-access">{{ jobId }}</span> - <gl-icon v-if="jobStuck" v-gl-tooltip="$options.i18n.stuckText" name="warning" :size="$options.iconSize" + class="gl-mr-2" data-testid="stuck-icon" /> - <div - class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2" + <gl-link + v-if="canReadJob" + class="gl-text-blue-600!" + :href="jobPath" + data-testid="job-id-link" > - <div - v-if="jobRef" - class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" - > - <gl-icon - v-if="createdByTag" - name="label" - :size="$options.iconSize" - data-testid="label-icon" - /> - <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> - <gl-link - class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" - :href="job.refPath" - data-testid="job-ref" - >{{ job.refName }}</gl-link - > - </div> + <span class="gl-text-truncate"> + <span data-testid="job-name">{{ jobId }}: {{ job.name }}</span> + </span> + </gl-link> - <span v-else>{{ __('none') }}</span> - <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> - <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> - <gl-link - class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" - :href="job.commitPath" - data-testid="job-sha" - >{{ job.shortSha }}</gl-link - > - </div> + <span v-else data-testid="job-id-limited-access">{{ jobId }}: {{ job.name }}</span> + </div> + + <div + class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-1" + > + <div v-if="jobRef" class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-26 gl-text-truncate"> + <gl-icon + v-if="createdByTag" + name="label" + :size="$options.iconSize" + data-testid="label-icon" + /> + <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.refPath" + data-testid="job-ref" + >{{ job.refName }}</gl-link + > + </div> + <span v-else>{{ __('none') }}</span> + <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> + <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.commitPath" + data-testid="job-sha" + >{{ job.shortSha }}</gl-link + > </div> </div> - <div> + <div class="gl-mt-2"> <gl-badge v-for="tag in jobTags" :key="tag" @@ -136,7 +132,6 @@ export default { > {{ tag }} </gl-badge> - <gl-badge v-if="triggered" variant="info" diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue index 18d68ee8a29..945674153c4 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue @@ -1,8 +1,12 @@ <script> import { GlAvatar, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; export default { + i18n: { + stageLabel: s__('Jobs|Stage'), + }, components: { GlAvatar, GlLink, @@ -36,21 +40,22 @@ export default { <template> <div> - <div class="gl-p-3 gl-mt-n3"> - <gl-link - class="gl-text-truncate gl-ml-n3 gl-text-gray-500!" - :href="pipelinePath" - data-testid="pipeline-id" - > + <div class="gl-p-3 gl-mt-n3 gl-mx-n3"> + <gl-link class="gl-text-truncate" :href="pipelinePath" data-testid="pipeline-id"> {{ pipelineId }} </gl-link> + + <span class="gl-text-secondary"> + <span>{{ __('created by') }}</span> + <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> + <gl-avatar :src="pipelineUserAvatar" :size="16" /> + </gl-link> + <span v-else>{{ __('API') }}</span> + </span> </div> - <div class="gl-font-sm gl-text-secondary gl-mt-n2"> - <span>{{ __('created by') }}</span> - <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> - <gl-avatar :src="pipelineUserAvatar" :size="16" /> - </gl-link> - <span v-else>{{ __('API') }}</span> + + <div v-if="job.stage" class="gl-text-truncate gl-font-sm gl-text-secondary gl-mt-1"> + <span data-testid="job-stage-name">{{ $options.i18n.stageLabel }}: {{ job.stage.name }}</span> </div> </div> </template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue index dbf1dfe7a29..a2b6a430138 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue @@ -1,12 +1,14 @@ <script> import { GlIcon } from '@gitlab/ui'; import { formatTime } from '~/lib/utils/datetime_utility'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { iconSize: 12, components: { + CiBadgeLink, GlIcon, TimeAgoTooltip, }, @@ -36,17 +38,16 @@ export default { <template> <div> - <div v-if="duration" data-testid="job-duration"> - <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> - {{ durationFormatted }} - </div> - <div - v-if="finishedTime" - :class="{ 'gl-mt-2': hasDurationAndFinishedTime }" - data-testid="job-finished-time" - > - <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> - <time-ago-tooltip :time="finishedTime" /> + <ci-badge-link :status="job.detailedStatus" /> + <div class="gl-font-sm gl-text-secondary gl-mt-2 gl-ml-3"> + <div v-if="duration" data-testid="job-duration"> + <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> + {{ durationFormatted }} + </div> + <div v-if="finishedTime" data-testid="job-finished-time"> + <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> + <time-ago-tooltip :time="finishedTime" /> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue index 23100a3f3db..d81d19cfd52 100644 --- a/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue @@ -1,12 +1,11 @@ <script> import { GlTable } from '@gitlab/ui'; import { s__ } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; -import { DEFAULT_FIELDS } from '../constants'; +import { JOBS_DEFAULT_FIELDS } from '../constants'; import ActionsCell from './job_cells/actions_cell.vue'; -import DurationCell from './job_cells/duration_cell.vue'; +import StatusCell from './job_cells/status_cell.vue'; import JobCell from './job_cells/job_cell.vue'; import PipelineCell from './job_cells/pipeline_cell.vue'; @@ -16,13 +15,12 @@ export default { }, components: { ActionsCell, - CiBadgeLink, - DurationCell, - GlTable, + StatusCell, JobCell, PipelineCell, ProjectCell, RunnerCell, + GlTable, }, props: { jobs: { @@ -32,7 +30,7 @@ export default { tableFields: { type: Array, required: false, - default: () => DEFAULT_FIELDS, + default: () => JOBS_DEFAULT_FIELDS, }, admin: { type: Boolean, @@ -64,7 +62,7 @@ export default { </template> <template #cell(status)="{ item }"> - <ci-badge-link :status="item.detailedStatus" /> + <status-cell :job="item" /> </template> <template #cell(job)="{ item }"> @@ -75,28 +73,20 @@ export default { <pipeline-cell :job="item" /> </template> - <template v-if="admin" #cell(project)="{ item }"> - <project-cell :job="item" /> - </template> - - <template v-if="admin" #cell(runner)="{ item }"> - <runner-cell :job="item" /> - </template> - <template #cell(stage)="{ item }"> <div class="gl-text-truncate"> - <span v-if="item.stage" data-testid="job-stage-name">{{ item.stage.name }}</span> + <span v-if="item.stage" data-testid="job-stage-name" class="gl-text-secondary">{{ + item.stage.name + }}</span> </div> </template> - <template #cell(name)="{ item }"> - <div class="gl-text-truncate"> - <span data-testid="job-name">{{ item.name }}</span> - </div> + <template v-if="admin" #cell(project)="{ item }"> + <project-cell :job="item" /> </template> - <template #cell(duration)="{ item }"> - <duration-cell :job="item" /> + <template v-if="admin" #cell(runner)="{ item }"> + <runner-cell :job="item" /> </template> <template #cell(coverage)="{ item }"> diff --git a/app/assets/javascripts/ci/jobs_page/constants.js b/app/assets/javascripts/ci/jobs_page/constants.js index 1b572e60c58..dec355ddff6 100644 --- a/app/assets/javascripts/ci/jobs_page/constants.js +++ b/app/assets/javascripts/ci/jobs_page/constants.js @@ -29,6 +29,7 @@ export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); /* Table constants */ +/* There is another field list based on this one in app/assets/javascripts/ci/admin/jobs_table/constants.js */ export const DEFAULT_FIELDS = [ { key: 'status', @@ -38,7 +39,7 @@ export const DEFAULT_FIELDS = [ { key: 'job', label: __('Job'), - columnClass: 'gl-w-20p', + columnClass: 'gl-w-quarter', }, { key: 'pipeline', @@ -51,16 +52,6 @@ export const DEFAULT_FIELDS = [ columnClass: 'gl-w-10p', }, { - key: 'name', - label: __('Name'), - columnClass: 'gl-w-15p', - }, - { - key: 'duration', - label: __('Duration'), - columnClass: 'gl-w-15p', - }, - { key: 'coverage', label: __('Coverage'), tdClass: 'gl-display-none! gl-lg-display-table-cell!', @@ -69,8 +60,10 @@ export const DEFAULT_FIELDS = [ { key: 'actions', label: '', + tdClass: 'gl-text-right', columnClass: 'gl-w-10p', }, ]; +export const JOBS_DEFAULT_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'stage'); export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue index 7538ad87af8..ec8f30e94b4 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue @@ -65,7 +65,7 @@ export default { <div :id="computedJobId" class="ci-job-dropdown-container dropdown dropright" - data-qa-selector="job_dropdown_container" + data-testid="job-dropdown-container" > <button type="button" @@ -90,7 +90,7 @@ export default { <ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown" - data-qa-selector="jobs_dropdown_menu" + data-testid="jobs-dropdown-menu" > <li class="scrollable-menu"> <ul> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue index 4298052d1c0..6e295de5e67 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -312,7 +312,6 @@ export default { <div :id="computedJobId" class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width" - data-qa-selector="job_item_container" > <component :is="nameComponent" @@ -326,7 +325,6 @@ export default { :href="detailsPath" class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" :data-testid="testId" - data-qa-selector="job_link" @click="jobItemClick" @mouseout="hideTooltips" > @@ -356,7 +354,6 @@ export default { class="gl-mr-1" :should-trigger-click="shouldTriggerActionClick" :with-confirmation-modal="withConfirmationModal" - data-qa-selector="job_action_button" @actionButtonClicked="handleConfirmationModalPreferences" @pipelineActionRequestComplete="pipelineActionRequestComplete" @showActionConfirmationModal="showActionConfirmationModal" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue index d6adaf78da4..9ce1c912c49 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue @@ -233,7 +233,7 @@ export default { ref="linkedPipeline" class="gl-h-full gl-display-flex! gl-px-2" :class="flexDirection" - data-qa-selector="linked_pipeline_container" + data-testid="linked-pipeline-container" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > @@ -247,11 +247,7 @@ export default { <div class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal" > - <span - class="gl-text-truncate" - data-testid="downstream-title" - data-qa-selector="downstream_title_content" - > + <span class="gl-text-truncate" data-testid="downstream-title-content"> {{ downstreamTitle }} </span> <div class="gl-text-truncate"> @@ -294,7 +290,6 @@ export default { :icon="expandedIcon" :aria-label="expandBtnText" data-testid="expand-pipeline-button" - data-qa-selector="expand_linked_pipeline_button" @mouseover="setExpandBtnActiveState(true)" @mouseout="setExpandBtnActiveState(false)" @focus="setExpandBtnActiveState(true)" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue index 1401bdba5ca..6030adc96ad 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue @@ -179,6 +179,7 @@ export default { { 'gl-opacity-3': isFadedOut(group.name) }, 'gl-transition-duration-slow gl-transition-timing-function-ease', ]" + data-testid="job-item-container" @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" @setSkipRetryModal="$emit('setSkipRetryModal')" /> diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index ec7be3a3ffc..2244936dd5d 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -396,11 +396,7 @@ export default { </div> </gl-alert> <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> - <div - v-else - class="gl-display-flex gl-justify-content-space-between gl-flex-wrap" - data-qa-selector="pipeline_details_header" - > + <div v-else class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> <div> <h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3> <h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title"> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue index 7b38e838033..3595ab631df 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -7,7 +7,6 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import { WORK_ITEMS_TYPE_MAP, - WORK_ITEM_TYPE_ENUM_TASK, I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, sprintfWorkItem, } from '../../constants'; @@ -29,7 +28,7 @@ export default { childrenType: { type: String, required: false, - default: WORK_ITEM_TYPE_ENUM_TASK, + default: '', }, childrenIds: { type: Array, @@ -53,7 +52,7 @@ export default { return { fullPath: this.fullPath, searchTerm: this.search?.title || this.search, - types: [this.childrenType], + types: this.childrenType ? [this.childrenType] : [], in: this.search ? 'TITLE' : undefined, }; }, @@ -106,6 +105,7 @@ export default { }, handleFocus() { this.searchStarted = true; + this.$emit('searching', true); }, handleMouseOver() { this.timeout = setTimeout(() => { @@ -115,11 +115,22 @@ export default { handleMouseOut() { clearTimeout(this.timeout); }, + handleBlur() { + this.$emit('searching', false); + }, + focusInputText() { + this.$nextTick(() => { + if (this.areWorkItemsToAddValid) { + this.$refs.tokenSelector.$el.querySelector('input[type="text"]').focus(); + } + }); + }, }, }; </script> <template> <gl-token-selector + ref="tokenSelector" v-model="workItemsToAdd" :dropdown-items="availableWorkItems" :loading="isLoading" @@ -131,13 +142,14 @@ export default { @focus="handleFocus" @mouseover.native="handleMouseOver" @mouseout.native="handleMouseOut" + @token-add="focusInputText" + @token-remove="focusInputText" + @blur="handleBlur" > - <template #token-content="{ token }"> - {{ token.title }} - </template> + <template #token-content="{ token }"> {{ token.iid }} {{ token.title }} </template> <template #dropdown-item-content="{ dropdownItem }"> <div class="gl-display-flex"> - <div class="gl-text-secondary gl-mr-4">{{ getIdFromGraphQLId(dropdownItem.id) }}</div> + <div class="gl-text-secondary gl-font-sm gl-mr-4">{{ dropdownItem.iid }}</div> <div class="gl-text-truncate">{{ dropdownItem.title }}</div> </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index edecd7addcc..b0a6a3f39d5 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -605,8 +605,10 @@ export default { /> <work-item-relationships v-if="showWorkItemLinkedItems" + :work-item-id="workItem.id" :work-item-iid="workItemIid" :work-item-full-path="workItem.project.fullPath" + :work-item-type="workItem.workItemType.name" @showModal="openInModal" /> <work-item-notes diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue index 55440e1603c..456dee8dab1 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links_form.vue @@ -225,7 +225,6 @@ export default { this.error = null; }, addChild() { - this.searchStarted = false; this.$apollo .mutate({ mutation: updateWorkItemMutation, diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue new file mode 100644 index 00000000000..757c186e2e9 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue @@ -0,0 +1,245 @@ +<script> +import { produce } from 'immer'; +import { GlFormGroup, GlForm, GlFormRadioGroup, GlButton, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import WorkItemTokenInput from '../shared/work_item_token_input.vue'; +import addLinkedItemsMutation from '../../graphql/add_linked_items.mutation.graphql'; +import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; +import { + LINK_ITEM_FORM_HEADER_LABEL, + WIDGET_TYPE_LINKED_ITEMS, + LINKED_ITEM_TYPE_VALUE, +} from '../../constants'; + +export default { + components: { + GlForm, + GlButton, + GlFormGroup, + GlFormRadioGroup, + GlAlert, + WorkItemTokenInput, + }, + props: { + workItemId: { + type: String, + required: false, + default: null, + }, + workItemIid: { + type: String, + required: false, + default: null, + }, + workItemFullPath: { + type: String, + required: false, + default: null, + }, + workItemType: { + type: String, + required: false, + default: null, + }, + childrenIds: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + linkedItemType: LINKED_ITEM_TYPE_VALUE.RELATED, + linkedItemTypes: [ + { + text: this.$options.i18n.relatedToLabel, + value: LINKED_ITEM_TYPE_VALUE.RELATED, + }, + { + text: this.$options.i18n.blockingLabel, + value: LINKED_ITEM_TYPE_VALUE.BLOCKS, + }, + { + text: this.$options.i18n.blockedByLabel, + value: LINKED_ITEM_TYPE_VALUE.BLOCKED_BY, + }, + ], + workItemsToAdd: [], + error: null, + showWorkItemsToAddInvalidMessage: false, + isSubmitting: false, + searchInProgress: false, + }; + }, + computed: { + linkItemFormHeaderLabel() { + return LINK_ITEM_FORM_HEADER_LABEL[this.workItemType]; + }, + workItemsToAddInvalidMessage() { + return this.$options.i18n.addChildErrorMessage; + }, + isSubmitButtonDisabled() { + return this.workItemsToAdd.length <= 0 || !this.areWorkItemsToAddValid; + }, + areWorkItemsToAddValid() { + return this.workItemsToAdd.length < 4; + }, + errorMessage() { + return !this.areWorkItemsToAddValid ? this.$options.i18n.max3ItemsErrorMessage : ''; + }, + }, + methods: { + async linkWorkItem() { + try { + if (this.searchInProgress) { + return; + } + this.isSubmitting = true; + const { + data: { + workItemAddLinkedItems: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: addLinkedItemsMutation, + variables: { + input: { + id: this.workItemId, + linkType: this.linkedItemType, + workItemsIds: this.workItemsToAdd.map((wi) => wi.id), + }, + }, + update: ( + cache, + { + data: { + workItemAddLinkedItems: { workItem }, + }, + }, + ) => { + const queryArgs = { + query: workItemByIidQuery, + variables: { fullPath: this.workItemFullPath, iid: this.workItemIid }, + }; + const sourceData = cache.readQuery(queryArgs); + + if (!sourceData) { + return; + } + + cache.writeQuery({ + ...queryArgs, + data: produce(sourceData, (draftState) => { + const linkedItemsWidget = draftState.workspace.workItems.nodes[0].widgets?.find( + (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS, + ); + + linkedItemsWidget.linkedItems = workItem.widgets?.find( + (widget) => widget.type === WIDGET_TYPE_LINKED_ITEMS, + ).linkedItems; + }), + }); + }, + }); + + if (errors.length > 0) { + [this.error] = errors; + return; + } + + this.workItemsToAdd = []; + this.unsetError(); + this.showWorkItemsToAddInvalidMessage = false; + this.linkedItemType = LINKED_ITEM_TYPE_VALUE.RELATED; + this.$emit('submitted'); + } catch (e) { + this.error = this.$options.i18n.addLinkedItemErrorMessage; + } finally { + this.isSubmitting = false; + } + }, + unsetError() { + this.error = null; + }, + }, + i18n: { + addButtonLabel: __('Add'), + relatedToLabel: s__('WorkItem|relates to'), + blockingLabel: s__('WorkItem|blocks'), + blockedByLabel: s__('WorkItem|is blocked by'), + max3ItemsNoteLabel: s__('WorkItem|Add a maximum of 3 items at a time.'), + linkItemInputLabel: s__('WorkItem|the following item(s)'), + addLinkedItemErrorMessage: s__( + 'WorkItem|Something went wrong when trying to link a item. Please try again.', + ), + max3ItemsErrorMessage: s__('WorkItem|Only 3 items can be added at a time.'), + }, +}; +</script> + +<template> + <gl-form + class="gl-new-card-add-form" + data-testid="link-work-item-form" + @submit.stop.prevent="linkWorkItem" + > + <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> + {{ error }} + </gl-alert> + <gl-form-group + :label="linkItemFormHeaderLabel" + label-for="linked-item-type-radio" + label-class="label-bold" + class="gl-mb-3" + > + <gl-form-radio-group + id="linked-item-type-radio" + v-model="linkedItemType" + :options="linkedItemTypes" + :checked="linkedItemType" + /> + </gl-form-group> + <p class="gl-font-weight-bold gl-mb-2"> + {{ $options.i18n.linkItemInputLabel }} + </p> + <div class="gl-mb-5"> + <work-item-token-input + v-model="workItemsToAdd" + class="gl-mb-2" + :parent-work-item-id="workItemId" + :children-ids="childrenIds" + :are-work-items-to-add-valid="areWorkItemsToAddValid" + :full-path="workItemFullPath" + :max-selection-limit="3" + @searching="searchInProgress = $event" + /> + <div v-if="errorMessage" class="gl-mb-2 gl-text-red-500"> + {{ $options.i18n.max3ItemsErrorMessage }} + </div> + <div v-if="!errorMessage" data-testid="max-work-item-note" class="gl-text-gray-500"> + {{ $options.i18n.max3ItemsNoteLabel }} + </div> + <div + v-if="showWorkItemsToAddInvalidMessage" + class="gl-text-red-500" + data-testid="work-items-invalid" + > + {{ workItemsToAddInvalidMessage }} + </div> + </div> + <gl-button + data-testid="link-work-item-button" + category="primary" + variant="confirm" + size="small" + type="submit" + :disabled="isSubmitButtonDisabled" + :loading="isSubmitting" + class="gl-mr-2" + > + {{ $options.i18n.addButtonLabel }} + </gl-button> + <gl-button category="secondary" size="small" @click="$emit('cancel')"> + {{ s__('WorkItem|Cancel') }} + </gl-button> + </gl-form> +</template> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue index cbe830f9565..279e6ad01b3 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue @@ -33,7 +33,7 @@ export default { }; </script> <template> - <div> + <div data-testid="work-item-linked-items-list"> <h4 v-if="heading" data-testid="work-items-list-heading" diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue index 4f6879e9605..d32e3d3a5e5 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue @@ -8,6 +8,7 @@ import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemRelationshipList from './work_item_relationship_list.vue'; +import WorkItemAddRelationshipForm from './work_item_add_relationship_form.vue'; export default { components: { @@ -16,8 +17,14 @@ export default { GlButton, WidgetWrapper, WorkItemRelationshipList, + WorkItemAddRelationshipForm, }, props: { + workItemId: { + type: String, + required: false, + default: null, + }, workItemIid: { type: String, required: true, @@ -26,6 +33,11 @@ export default { type: String, required: true, }, + workItemType: { + type: String, + required: false, + default: null, + }, }, apollo: { workItem: { @@ -74,13 +86,13 @@ export default { linksRelatesTo: [], linksIsBlockedBy: [], linksBlocks: [], + isShownLinkItemForm: false, widgetName: 'linkeditems', }; }, computed: { - canUpdate() { - // This will be false untill we implement remove item mutation - return false; + canAdminWorkItemLink() { + return this.workItem?.userPermissions?.adminWorkItemLink; }, isLoading() { return this.$apollo.queries.workItem.loading; @@ -91,11 +103,22 @@ export default { linkedWorkItems() { return this.linkedWorkItemsWidget?.linkedItems?.nodes || []; }, + childrenIds() { + return this.linkedWorkItems.map((item) => item.workItem.id); + }, linkedWorkItemsCount() { return this.linkedWorkItems.length; }, isEmptyRelatedWorkItems() { - return !this.error && this.linkedWorkItems.length === 0; + return !this.isShownLinkItemForm && !this.error && this.linkedWorkItems.length === 0; + }, + }, + methods: { + showLinkItemForm() { + this.isShownLinkItemForm = true; + }, + hideLinkItemForm() { + this.isShownLinkItemForm = false; }, }, i18n: { @@ -131,12 +154,28 @@ export default { </div> </template> <template #header-right> - <gl-button size="small" class="gl-ml-3"> + <gl-button + v-if="canAdminWorkItemLink" + data-testid="link-item-add-button" + size="small" + class="gl-ml-3" + @click="showLinkItemForm" + > <slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot> </gl-button> </template> <template #body> <div class="gl-new-card-content"> + <work-item-add-relationship-form + v-if="isShownLinkItemForm" + :work-item-id="workItemId" + :work-item-iid="workItemIid" + :work-item-full-path="workItemFullPath" + :children-ids="childrenIds" + :work-item-type="workItemType" + @submitted="hideLinkItemForm" + @cancel="hideLinkItemForm" + /> <gl-loading-icon v-if="isLoading" color="dark" class="gl-my-2" /> <template v-else> <div v-if="isEmptyRelatedWorkItems" data-testid="links-empty"> @@ -154,7 +193,7 @@ export default { :linked-items="linksBlocks" :heading="$options.i18n.blockingTitle" :work-item-full-path="workItemFullPath" - :can-update="canUpdate" + :can-update="false" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" /> <work-item-relationship-list @@ -166,7 +205,7 @@ export default { :linked-items="linksIsBlockedBy" :heading="$options.i18n.blockedByTitle" :work-item-full-path="workItemFullPath" - :can-update="canUpdate" + :can-update="false" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" /> <work-item-relationship-list @@ -174,7 +213,7 @@ export default { :linked-items="linksRelatesTo" :heading="$options.i18n.relatedToTitle" :work-item-full-path="workItemFullPath" - :can-update="canUpdate" + :can-update="false" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" /> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 2b118247426..04ef2a65aab 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -113,7 +113,7 @@ export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__( ); export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => { - const workItemType = workItemTypeArg || s__('WorkItem|Work item'); + const workItemType = workItemTypeArg || s__('WorkItem|item'); return capitalizeFirstCharacter( sprintf(msg, { workItemType: workItemType.toLocaleLowerCase(), @@ -186,8 +186,11 @@ export const WORK_ITEM_NAME_TO_ICON_MAP = { Issue: 'issue-type-issue', Task: 'issue-type-task', Objective: 'issue-type-objective', + Incident: 'issue-type-incident', // eslint-disable-next-line @gitlab/require-i18n-strings 'Key Result': 'issue-type-keyresult', + // eslint-disable-next-line @gitlab/require-i18n-strings + 'Test Case': 'issue-type-test-case', }; export const FORM_TYPES = { @@ -262,3 +265,15 @@ export const LINKED_CATEGORIES_MAP = { IS_BLOCKED_BY: 'is_blocked_by', BLOCKS: 'blocks', }; + +export const LINKED_ITEM_TYPE_VALUE = { + RELATED: 'RELATED', + BLOCKED_BY: 'BLOCKED_BY', + BLOCKS: 'BLOCKS', +}; + +export const LINK_ITEM_FORM_HEADER_LABEL = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: s__('WorkItem|The current objective'), + [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: s__('WorkItem|The current key result'), + [WORK_ITEM_TYPE_VALUE_TASK]: s__('WorkItem|The current task'), +}; diff --git a/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql b/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql new file mode 100644 index 00000000000..ba12c7f9b51 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/add_linked_items.mutation.graphql @@ -0,0 +1,10 @@ +#import "./work_item.fragment.graphql" + +mutation addLinkedItems($input: WorkItemAddLinkedItemsInput!) { + workItemAddLinkedItems(input: $input) { + workItem { + ...WorkItem + } + errors + } +} diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql index 7d63af448d4..2be436aa8c2 100644 --- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -9,6 +9,7 @@ query projectWorkItems( workItems(search: $searchTerm, types: $types, in: $in) { nodes { id + iid title state confidential diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index 1ae5617f04d..fac99310890 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -33,6 +33,7 @@ fragment WorkItem on WorkItem { adminParentLink setWorkItemMetadata createNote + adminWorkItemLink } widgets { ...WorkItemWidgets |