diff options
Diffstat (limited to 'app/assets/javascripts/issues')
22 files changed, 428 insertions, 345 deletions
diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 11911adb401..0b424d105b9 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -24,6 +24,7 @@ import axios from '~/lib/utils/axios_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { scrollUp } from '~/lib/utils/scroll_utils'; import { getParameterByName, joinPaths } from '~/lib/utils/url_utility'; +import { helpPagePath } from '~/helpers/help_page_helper'; import { DEFAULT_NONE_ANY, OPERATOR_IS_ONLY, @@ -37,6 +38,7 @@ import { TOKEN_TITLE_ORGANIZATION, TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, + FILTERED_SEARCH_TERM, } from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; @@ -462,6 +464,12 @@ export default { page_before: this.pageParams.beforeCursor ?? undefined, }; }, + issuesHelpPagePath() { + return helpPagePath('user/project/issues/index'); + }, + shouldDisableSomeFilters() { + return this.isAnonymousSearchDisabled && !this.isSignedIn; + }, }, watch: { $route(newValue, oldValue) { @@ -578,13 +586,9 @@ export default { this.issuesError = null; }, handleFilter(filter) { - if (this.isAnonymousSearchDisabled && !this.isSignedIn) { - this.showAnonymousSearchingMessage(); - return; - } + this.setFilterTokens(filter); this.pageParams = getInitialPageParams(this.pageSize); - this.filterTokens = filter; this.$router.push({ query: this.urlParams }); }, @@ -674,6 +678,28 @@ export default { Sentry.captureException(error); }); }, + setFilterTokens(filtersArg) { + const filters = this.removeDisabledSearchTerms(filtersArg); + + this.filterTokens = filters; + + // If we filtered something out, let's show a warning message + if (filters.length < filtersArg.length) { + this.showAnonymousSearchingMessage(); + } + }, + removeDisabledSearchTerms(filters) { + // If we shouldn't disable anything, let's return the same thing + if (!this.shouldDisableSomeFilters) { + return filters; + } + + const filtersWithoutSearchTerms = filters.filter( + (token) => !(token.type === FILTERED_SEARCH_TERM && token.value?.data), + ); + + return filtersWithoutSearchTerms; + }, showAnonymousSearchingMessage() { createFlash({ message: this.$options.i18n.anonymousSearchingMessage, @@ -720,17 +746,9 @@ export default { sortKey = defaultSortKey; } - const isSearchDisabled = - this.isAnonymousSearchDisabled && - !this.isSignedIn && - window.location.search.includes('search='); - - if (isSearchDisabled) { - this.showAnonymousSearchingMessage(); - } - this.exportCsvPathWithQuery = this.getExportCsvPathWithQuery(); - this.filterTokens = isSearchDisabled ? [] : getFilterTokens(window.location.search); + this.setFilterTokens(getFilterTokens(window.location.search)); + this.pageParams = getInitialPageParams( this.pageSize, isPositiveInteger(firstPageSize) ? parseInt(firstPageSize, 10) : undefined, @@ -899,7 +917,9 @@ export default { <template v-else-if="isSignedIn"> <gl-empty-state :title="$options.i18n.noIssuesSignedInTitle" :svg-path="emptyStateSvgPath"> <template #description> - <p>{{ $options.i18n.noIssuesSignedInDescription }}</p> + <gl-link :href="issuesHelpPagePath" target="_blank">{{ + $options.i18n.noIssuesSignedInDescription + }}</gl-link> <p v-if="canCreateProjects"> <strong>{{ $options.i18n.noGroupIssuesSignedInDescription }}</strong> </p> diff --git a/app/assets/javascripts/issues/list/constants.js b/app/assets/javascripts/issues/list/constants.js index 38fe4c33792..27738d7a3e6 100644 --- a/app/assets/javascripts/issues/list/constants.js +++ b/app/assets/javascripts/issues/list/constants.js @@ -41,12 +41,8 @@ export const i18n = { ), noOpenIssuesDescription: __('To keep this project going, create a new issue'), noOpenIssuesTitle: __('There are no open issues'), - noIssuesSignedInDescription: __( - 'Issues can be bugs, tasks or ideas to be discussed. Also, issues are searchable and filterable.', - ), - noIssuesSignedInTitle: __( - 'The Issue Tracker is the place to add things that need to be improved or solved in a project', - ), + noIssuesSignedInDescription: __('Learn more about issues.'), + noIssuesSignedInTitle: __('Use issues to collaborate on ideas, solve problems, and plan work'), noIssuesSignedOutButtonText: __('Register / Sign In'), noIssuesSignedOutDescription: __( 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', @@ -151,6 +147,7 @@ export const TOKEN_TYPE_EPIC = 'epic_id'; export const TOKEN_TYPE_WEIGHT = 'weight'; export const TOKEN_TYPE_CONTACT = 'crm_contact'; export const TOKEN_TYPE_ORGANIZATION = 'crm_organization'; +export const TOKEN_TYPE_HEALTH = 'health_status'; export const TYPE_TOKEN_TASK_OPTION = { icon: 'task-done', title: 'task', value: 'task' }; @@ -327,6 +324,16 @@ export const filters = { }, }, }, + [TOKEN_TYPE_HEALTH]: { + [API_PARAM]: { + [NORMAL_FILTER]: 'healthStatus', + }, + [URL_PARAM]: { + [OPERATOR_IS]: { + [NORMAL_FILTER]: 'health_status', + }, + }, + }, [TOKEN_TYPE_CONTACT]: { [API_PARAM]: { [NORMAL_FILTER]: 'crmContactId', diff --git a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue index a5cba3daafa..149049247fb 100644 --- a/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue +++ b/app/assets/javascripts/issues/related_merge_requests/components/related_merge_requests.vue @@ -65,7 +65,7 @@ export default { <template> <div v-if="isFetchingMergeRequests || (!isFetchingMergeRequests && totalCount)"> - <div class="card card-slim gl-mt-5"> + <div class="card card-slim gl-mt-5 gl-mb-0"> <div class="card-header gl-bg-gray-10"> <div class="card-title gl-relative gl-display-flex gl-align-items-center gl-line-height-20 gl-font-weight-bold gl-m-0" @@ -112,7 +112,7 @@ export default { </div> <div v-if="hasClosingMergeRequest && !isFetchingMergeRequests" - class="issue-closed-by-widget second-block" + class="issue-closed-by-widget second-block gl-mt-3" > {{ closingMergeRequestsText }} </div> diff --git a/app/assets/javascripts/issues/show/components/app.vue b/app/assets/javascripts/issues/show/components/app.vue index c664135f30e..0daf77e03dc 100644 --- a/app/assets/javascripts/issues/show/components/app.vue +++ b/app/assets/javascripts/issues/show/components/app.vue @@ -17,11 +17,11 @@ import eventHub from '../event_hub'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; import Service from '../services/index'; import Store from '../stores'; -import descriptionComponent from './description.vue'; -import editedComponent from './edited.vue'; -import formComponent from './form.vue'; +import DescriptionComponent from './description.vue'; +import EditedComponent from './edited.vue'; +import FormComponent from './form.vue'; import PinnedLinks from './pinned_links.vue'; -import titleComponent from './title.vue'; +import TitleComponent from './title.vue'; export default { WorkspaceType, @@ -29,9 +29,9 @@ export default { GlIcon, GlBadge, GlIntersectionObserver, - titleComponent, - editedComponent, - formComponent, + TitleComponent, + EditedComponent, + FormComponent, PinnedLinks, ConfidentialityBadge, }, @@ -51,20 +51,11 @@ export default { required: true, type: Boolean, }, - canDestroy: { - required: true, - type: Boolean, - }, showInlineEditButton: { type: Boolean, required: false, default: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, enableAutocomplete: { type: Boolean, required: false, @@ -181,7 +172,7 @@ export default { type: Object, required: false, default: () => { - return descriptionComponent; + return DescriptionComponent; }, }, showTitleBorder: { @@ -494,14 +485,12 @@ export default { :endpoint="endpoint" :form-state="formState" :initial-description-text="initialDescriptionText" - :can-destroy="canDestroy" :issuable-templates="issuableTemplates" :markdown-docs-path="markdownDocsPath" :markdown-preview-path="markdownPreviewPath" :project-path="projectPath" :project-id="projectId" :project-namespace="projectNamespace" - :show-delete-button="showDeleteButton" :can-attach-file="canAttachFile" :enable-autocomplete="enableAutocomplete" :issuable-type="issuableType" diff --git a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue index 47b09bd6aa0..f86ee11e64b 100644 --- a/app/assets/javascripts/issues/show/components/delete_issue_modal.vue +++ b/app/assets/javascripts/issues/show/components/delete_issue_modal.vue @@ -13,7 +13,8 @@ export default { props: { issuePath: { type: String, - required: true, + required: false, + default: '', }, issueType: { type: String, diff --git a/app/assets/javascripts/issues/show/components/description.vue b/app/assets/javascripts/issues/show/components/description.vue index a6747d67611..5c2a154362f 100644 --- a/app/assets/javascripts/issues/show/components/description.vue +++ b/app/assets/javascripts/issues/show/components/description.vue @@ -7,6 +7,7 @@ import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_WORK_ITEM } from '~/graphql_shared/constants'; import createFlash from '~/flash'; import { IssuableType } from '~/issues/constants'; +import { isMetaKey } from '~/lib/utils/common_utils'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { __, s__, sprintf } from '~/locale'; @@ -20,6 +21,8 @@ import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_ite import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { + sprintfWorkItem, + I18N_WORK_ITEM_ERROR_CREATING, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME, WIDGET_TYPE_DESCRIPTION, @@ -226,6 +229,7 @@ export default { }, createDragIconElement() { const container = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property container.innerHTML = `<svg class="drag-icon s14 gl-icon gl-cursor-grab gl-visibility-hidden" role="img" aria-hidden="true"> <use href="${gon.sprite_icons}#drag-vertical"></use> </svg>`; @@ -330,6 +334,9 @@ export default { this.addHoverListeners(taskLink, workItemId); taskLink.classList.add('gl-link'); taskLink.addEventListener('click', (e) => { + if (isMetaKey(e)) { + return; + } e.preventDefault(); this.openWorkItemDetailModal(taskLink); this.workItemId = workItemId; @@ -358,6 +365,7 @@ export default { ); button.id = `js-task-button-${index}`; this.taskButtons.push(button.id); + // eslint-disable-next-line no-unsanitized/property button.innerHTML = ` <svg data-testid="ellipsis_v-icon" role="img" aria-hidden="true" class="dropdown-icon gl-icon s14"> <use href="${gon.sprite_icons}#doc-new"></use> @@ -460,7 +468,7 @@ export default { this.openWorkItemDetailModal(el); } catch (error) { createFlash({ - message: s__('WorkItem|Something went wrong when creating a work item. Please try again'), + message: sprintfWorkItem(I18N_WORK_ITEM_ERROR_CREATING, workItemTypes.TASK), error, captureError: true, }); diff --git a/app/assets/javascripts/issues/show/components/edit_actions.vue b/app/assets/javascripts/issues/show/components/edit_actions.vue index 358b53bd131..120034b8d67 100644 --- a/app/assets/javascripts/issues/show/components/edit_actions.vue +++ b/app/assets/javascripts/issues/show/components/edit_actions.vue @@ -1,12 +1,10 @@ <script> -import { GlButton, GlModalDirective } from '@gitlab/ui'; -import { uniqueId } from 'lodash'; -import { __, sprintf } from '~/locale'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; import Tracking from '~/tracking'; import eventHub from '../event_hub'; import updateMixin from '../mixins/update'; import getIssueStateQuery from '../queries/get_issue_state.query.graphql'; -import DeleteIssueModal from './delete_issue_modal.vue'; const issuableTypes = { issue: __('Issue'), @@ -18,18 +16,10 @@ const trackingMixin = Tracking.mixin({ label: 'delete_issue' }); export default { components: { - DeleteIssueModal, GlButton, }, - directives: { - GlModal: GlModalDirective, - }, mixins: [trackingMixin, updateMixin], props: { - canDestroy: { - type: Boolean, - required: true, - }, endpoint: { required: true, type: String, @@ -38,11 +28,6 @@ export default { type: Object, required: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, issuableType: { type: String, required: true, @@ -53,7 +38,6 @@ export default { deleteLoading: false, skipApollo: false, issueState: {}, - modalId: uniqueId('delete-issuable-modal-'), }; }, apollo: { @@ -68,17 +52,9 @@ export default { }, }, computed: { - deleteIssuableButtonText() { - return sprintf(__('Delete %{issuableType}'), { - issuableType: this.typeToShow.toLowerCase(), - }); - }, isSubmitEnabled() { return this.formState.title.trim() !== ''; }, - shouldShowDeleteButton() { - return this.canDestroy && this.showDeleteButton && this.typeToShow; - }, typeToShow() { const { issueState, issuableType } = this; const type = issueState.issueType ?? issuableType; @@ -89,52 +65,26 @@ export default { closeForm() { eventHub.$emit('close.form'); }, - deleteIssuable() { - this.deleteLoading = true; - eventHub.$emit('delete.issuable'); - }, }, }; </script> <template> - <div class="gl-mt-3 gl-mb-3 gl-display-flex gl-justify-content-space-between"> - <div> - <gl-button - :loading="formState.updateLoading" - :disabled="formState.updateLoading || !isSubmitEnabled" - category="primary" - variant="confirm" - class="gl-mr-3" - data-testid="issuable-save-button" - type="submit" - @click.prevent="updateIssuable" - > - {{ __('Save changes') }} - </gl-button> - <gl-button data-testid="issuable-cancel-button" @click="closeForm"> - {{ __('Cancel') }} - </gl-button> - </div> - <div v-if="shouldShowDeleteButton"> - <gl-button - v-gl-modal="modalId" - :loading="deleteLoading" - :disabled="deleteLoading" - category="secondary" - variant="danger" - data-testid="issuable-delete-button" - @click="track('click_button')" - > - {{ deleteIssuableButtonText }} - </gl-button> - <delete-issue-modal - :issue-path="endpoint" - :issue-type="typeToShow" - :modal-id="modalId" - :title="deleteIssuableButtonText" - @delete="deleteIssuable" - /> - </div> + <div class="gl-mt-3 gl-mb-3 gl-display-flex"> + <gl-button + :loading="formState.updateLoading" + :disabled="formState.updateLoading || !isSubmitEnabled" + category="primary" + variant="confirm" + class="gl-mr-3" + data-testid="issuable-save-button" + type="submit" + @click.prevent="updateIssuable" + > + {{ __('Save changes') }} + </gl-button> + <gl-button data-testid="issuable-cancel-button" @click="closeForm"> + {{ __('Cancel') }} + </gl-button> </div> </template> diff --git a/app/assets/javascripts/issues/show/components/edited.vue b/app/assets/javascripts/issues/show/components/edited.vue index 41cc3964055..4c5f783cd66 100644 --- a/app/assets/javascripts/issues/show/components/edited.vue +++ b/app/assets/javascripts/issues/show/components/edited.vue @@ -1,10 +1,10 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ -import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { - timeAgoTooltip, + TimeAgoTooltip, }, props: { updatedAt: { diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index f45af47374a..c2ab7c4f298 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -1,11 +1,11 @@ <script> -import markdownField from '~/vue_shared/components/markdown/field.vue'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import updateMixin from '../../mixins/update'; export default { components: { - markdownField, + MarkdownField, }, mixins: [updateMixin], props: { diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue index e2c12edf46d..f479c8ae78d 100644 --- a/app/assets/javascripts/issues/show/components/form.vue +++ b/app/assets/javascripts/issues/show/components/form.vue @@ -22,10 +22,6 @@ export default { LockedWarning, }, props: { - canDestroy: { - type: Boolean, - required: true, - }, endpoint: { type: String, required: true, @@ -63,11 +59,6 @@ export default { type: String, required: true, }, - showDeleteButton: { - type: Boolean, - required: false, - default: true, - }, canAttachFile: { type: Boolean, required: false, @@ -231,12 +222,6 @@ export default { :enable-autocomplete="enableAutocomplete" /> - <edit-actions - :endpoint="endpoint" - :form-state="formState" - :can-destroy="canDestroy" - :show-delete-button="showDeleteButton" - :issuable-type="issuableType" - /> + <edit-actions :endpoint="endpoint" :form-state="formState" :issuable-type="issuableType" /> </form> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index 77d13fe085a..aa7b9805b5f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -26,4 +26,15 @@ export const timelineListI18n = Object.freeze({ 'Incident|Something went wrong while deleting the incident timeline event.', ), deleteModal: s__('Incident|Are you sure you want to delete this event?'), + editError: s__('Incident|Error updating incident timeline event: %{error}'), + editErrorGeneric: s__( + 'Incident|Something went wrong while updating the incident timeline event.', + ), +}); + +export const timelineItemI18n = Object.freeze({ + delete: __('Delete'), + edit: __('Edit'), + moreActions: __('More actions'), + timeUTC: __('%{time} UTC'), }); diff --git a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue index c902895702e..6bb72e82778 100644 --- a/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue +++ b/app/assets/javascripts/issues/show/components/incidents/create_timeline_event.vue @@ -1,6 +1,7 @@ <script> import { produce } from 'immer'; import { sortBy } from 'lodash'; +import { GlIcon } from '@gitlab/ui'; import { sprintf } from '~/locale'; import { createAlert } from '~/flash'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -16,6 +17,7 @@ export default { i18n: timelineFormI18n, components: { TimelineEventsForm, + GlIcon, }, inject: ['fullPath', 'issuableId'], props: { @@ -31,9 +33,6 @@ export default { clearForm() { this.$refs.eventForm.clear(); }, - focusDate() { - this.$refs.eventForm.focusDate(); - }, updateCache(store, { data }) { const { timelineEvent: event, errors } = data?.timelineEventCreate || {}; @@ -107,11 +106,23 @@ export default { </script> <template> - <timeline-events-form - ref="eventForm" - :is-event-processed="createTimelineEventActive" - :has-timeline-events="hasTimelineEvents" - @save-event="createIncidentTimelineEvent" - @cancel="$emit('hide-new-timeline-events-form')" - /> + <div + class="create-timeline-event gl-relative gl-display-flex gl-align-items-start" + :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" + > + <div + v-if="hasTimelineEvents" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + > + <gl-icon name="comment" class="note-icon" /> + </div> + <timeline-events-form + ref="eventForm" + :class="{ 'gl-border-gray-50 gl-border-t': hasTimelineEvents }" + :is-event-processed="createTimelineEventActive" + show-save-and-add + @save-event="createIncidentTimelineEvent" + @cancel="$emit('hide-new-timeline-events-form')" + /> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue new file mode 100644 index 00000000000..60fa8cb949b --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/edit_timeline_event.vue @@ -0,0 +1,47 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import TimelineEventsForm from './timeline_events_form.vue'; + +export default { + name: 'EditTimelineEvent', + components: { + TimelineEventsForm, + GlIcon, + }, + props: { + event: { + type: Object, + required: true, + validator: (item) => ['occurredAt', 'note'].every((key) => item[key]), + }, + editTimelineEventActive: { + type: Boolean, + required: true, + }, + }, + methods: { + saveEvent(eventDetails) { + this.$emit('handle-save-edit', { ...eventDetails, id: this.event.id }, false); + }, + }, +}; +</script> + +<template> + <div class="gl-relative gl-display-flex gl-align-items-center"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" + > + <gl-icon name="comment" class="note-icon" /> + </div> + <timeline-events-form + ref="eventForm" + class="timeline-event-border" + :is-event-processed="editTimelineEventActive" + :previous-occurred-at="event.occurredAt" + :previous-note="event.note" + @save-event="saveEvent" + @cancel="$emit('hide-edit')" + /> + </div> +</template> diff --git a/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql new file mode 100644 index 00000000000..54f036268cc --- /dev/null +++ b/app/assets/javascripts/issues/show/components/incidents/graphql/queries/edit_timeline_event.mutation.graphql @@ -0,0 +1,13 @@ +mutation UpdateTimelineEvent($input: TimelineEventUpdateInput!) { + timelineEventUpdate(input: $input) { + timelineEvent { + id + note + noteHtml + action + occurredAt + createdAt + } + errors + } +} diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue index 0d84fabb1be..b7ae18372ab 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_form.vue @@ -1,9 +1,9 @@ <script> -import { GlDatepicker, GlFormInput, GlFormGroup, GlButton, GlIcon } from '@gitlab/ui'; +import { GlDatepicker, GlFormInput, GlFormGroup, GlButton } from '@gitlab/ui'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { timelineFormI18n } from './constants'; -import { getUtcShiftedDateNow } from './utils'; +import { getUtcShiftedDate } from './utils'; export default { name: 'TimelineEventsForm', @@ -15,6 +15,7 @@ export default { 'task-list', 'collapsible-section', 'table', + 'attach-file', 'full-screen', ], components: { @@ -23,175 +24,168 @@ export default { GlFormInput, GlFormGroup, GlButton, - GlIcon, }, i18n: timelineFormI18n, directives: { autofocusonshow, }, props: { - hasTimelineEvents: { + showSaveAndAdd: { type: Boolean, - required: true, + required: false, + default: false, }, isEventProcessed: { type: Boolean, required: true, }, + previousOccurredAt: { + type: String, + required: false, + default: null, + }, + previousNote: { + type: String, + required: false, + default: '', + }, }, data() { - // if occurredAt is undefined, returns "now" in UTC - const placeholderDate = getUtcShiftedDateNow(); + // if occurredAt is null, returns "now" in UTC + const placeholderDate = getUtcShiftedDate(this.previousOccurredAt); return { - timelineText: '', + timelineText: this.previousNote, placeholderDate, hourPickerInput: placeholderDate.getHours(), minutePickerInput: placeholderDate.getMinutes(), - datepickerTextInput: null, + datePickerInput: placeholderDate, }; }, computed: { - occurredAt() { - const [years, months, days] = this.datepickerTextInput.split('-'); + occurredAtString() { + const year = this.datePickerInput.getFullYear(); + const month = this.datePickerInput.getMonth(); + const day = this.datePickerInput.getDate(); + const utcDate = new Date( - Date.UTC(years, months - 1, days, this.hourPickerInput, this.minutePickerInput), + Date.UTC(year, month, day, this.hourPickerInput, this.minutePickerInput), ); return utcDate.toISOString(); }, }, + mounted() { + this.focusDate(); + }, methods: { clear() { - const utcShiftedDateNow = getUtcShiftedDateNow(); - this.placeholderDate = utcShiftedDateNow; - this.hourPickerInput = utcShiftedDateNow.getHours(); - this.minutePickerInput = utcShiftedDateNow.getMinutes(); + const newPlaceholderDate = getUtcShiftedDate(); + this.datePickerInput = newPlaceholderDate; + this.hourPickerInput = newPlaceholderDate.getHours(); + this.minutePickerInput = newPlaceholderDate.getMinutes(); this.timelineText = ''; }, focusDate() { - this.$refs.datepicker.$el.focus(); + this.$refs.datepicker.$el.querySelector('input').focus(); }, handleSave(addAnotherEvent) { - const eventDetails = { + const event = { note: this.timelineText, - occurredAt: this.occurredAt, + occurredAt: this.occurredAtString, }; - this.$emit('save-event', eventDetails, addAnotherEvent); + this.$emit('save-event', event, addAnotherEvent); }, }, }; </script> <template> - <div - class="gl-relative gl-display-flex gl-align-items-center" - :class="{ 'timeline-entry-vertical-line': hasTimelineEvents }" - > - <div - v-if="hasTimelineEvents" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-align-self-start gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-z-index-1" - > - <gl-icon name="comment" class="note-icon" /> - </div> - <form class="gl-flex-grow-1 gl-border-gray-50" :class="{ 'gl-border-t': hasTimelineEvents }"> - <div - class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row datetime-picker" - > - <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> - <gl-datepicker id="incident-date" #default="{ formattedDate }" v-model="placeholderDate"> + <form class="gl-flex-grow-1 gl-border-gray-50"> + <div class="gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row"> + <gl-form-group :label="__('Date')" class="gl-mt-5 gl-mr-5"> + <gl-datepicker id="incident-date" ref="datepicker" v-model="datePickerInput" /> + </gl-form-group> + <div class="gl-display-flex gl-mt-5"> + <gl-form-group :label="__('Time')"> + <div class="gl-display-flex"> + <label label-for="timeline-input-hours" class="sr-only"></label> <gl-form-input - id="incident-date" - ref="datepicker" - v-model="datepickerTextInput" - data-testid="input-datepicker" - class="gl-datepicker-input gl-pr-7!" - :value="formattedDate" - :placeholder="__('YYYY-MM-DD')" - @keydown.enter="onKeydown" + id="timeline-input-hours" + v-model="hourPickerInput" + data-testid="input-hours" + size="xs" + type="number" + min="00" + max="23" /> - </gl-datepicker> - </gl-form-group> - <div class="gl-display-flex gl-mt-5"> - <gl-form-group :label="__('Time')"> - <div class="gl-display-flex"> - <label label-for="timeline-input-hours" class="sr-only"></label> - <gl-form-input - id="timeline-input-hours" - v-model="hourPickerInput" - data-testid="input-hours" - size="xs" - type="number" - min="00" - max="23" - /> - <label label-for="timeline-input-minutes" class="sr-only"></label> - <gl-form-input - id="timeline-input-minutes" - v-model="minutePickerInput" - class="gl-ml-3" - data-testid="input-minutes" - size="xs" - type="number" - min="00" - max="59" - /> - </div> - </gl-form-group> - <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> - </div> - </div> - <div class="common-note-form"> - <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> - <markdown-field - :can-attach-file="false" - :add-spacing-classes="false" - :show-comment-tool-bar="false" - :textarea-value="timelineText" - :restricted-tool-bar-items="$options.restrictedToolBarItems" - markdown-docs-path="" - :enable-preview="false" - class="bordered-box gl-mt-0" - > - <template #textarea> - <textarea - v-model="timelineText" - class="note-textarea js-gfm-input js-autosize markdown-area" - data-testid="input-note" - dir="auto" - data-supports-quick-actions="false" - :aria-label="$options.i18n.description" - :placeholder="$options.i18n.areaPlaceholder" - > - </textarea> - </template> - </markdown-field> + <label label-for="timeline-input-minutes" class="sr-only"></label> + <gl-form-input + id="timeline-input-minutes" + v-model="minutePickerInput" + class="gl-ml-3" + data-testid="input-minutes" + size="xs" + type="number" + min="00" + max="59" + /> + </div> </gl-form-group> + <p class="gl-ml-3 gl-align-self-end gl-line-height-32">{{ __('UTC') }}</p> </div> - <gl-form-group class="gl-mb-0"> - <gl-button - variant="confirm" - category="primary" - class="gl-mr-3" - :loading="isEventProcessed" - @click="handleSave(false)" - > - {{ $options.i18n.save }} - </gl-button> - <gl-button - variant="confirm" - category="secondary" - class="gl-mr-3 gl-ml-n2" - :loading="isEventProcessed" - @click="handleSave(true)" + </div> + <div class="common-note-form"> + <gl-form-group class="gl-mb-3" :label="$options.i18n.areaLabel"> + <markdown-field + :can-attach-file="false" + :add-spacing-classes="false" + :show-comment-tool-bar="false" + :textarea-value="timelineText" + :restricted-tool-bar-items="$options.restrictedToolBarItems" + markdown-docs-path="" + :enable-preview="false" + class="bordered-box gl-mt-0" > - {{ $options.i18n.saveAndAdd }} - </gl-button> - <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> - {{ $options.i18n.cancel }} - </gl-button> - <div class="gl-border-b gl-pt-5"></div> + <template #textarea> + <textarea + v-model="timelineText" + class="note-textarea js-gfm-input js-autosize markdown-area" + data-testid="input-note" + dir="auto" + data-supports-quick-actions="false" + :aria-label="$options.i18n.description" + :placeholder="$options.i18n.areaPlaceholder" + > + </textarea> + </template> + </markdown-field> </gl-form-group> - </form> - </div> + </div> + <gl-form-group class="gl-mb-0"> + <gl-button + variant="confirm" + category="primary" + class="gl-mr-3" + :loading="isEventProcessed" + @click="handleSave(false)" + > + {{ $options.i18n.save }} + </gl-button> + <gl-button + v-if="showSaveAndAdd" + variant="confirm" + category="secondary" + class="gl-mr-3 gl-ml-n2" + :loading="isEventProcessed" + @click="handleSave(true)" + > + {{ $options.i18n.saveAndAdd }} + </gl-button> + <gl-button class="gl-ml-n2" :disabled="isEventProcessed" @click="$emit('cancel')"> + {{ $options.i18n.cancel }} + </gl-button> + <div class="timeline-event-bottom-border"></div> + </gl-form-group> + </form> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index 6175c9969ec..cbf3c387fa3 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,25 +1,13 @@ <script> -import { - GlButton, - GlDropdown, - GlDropdownItem, - GlIcon, - GlSafeHtmlDirective, - GlSprintf, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; +import { timelineItemI18n } from './constants'; import { getEventIcon } from './utils'; export default { name: 'IncidentTimelineEventListItem', - i18n: { - delete: __('Delete'), - moreActions: __('More actions'), - timeUTC: __('%{time} UTC'), - }, + i18n: timelineItemI18n, components: { - GlButton, GlDropdown, GlDropdownItem, GlIcon, @@ -28,12 +16,8 @@ export default { directives: { SafeHtml: GlSafeHtmlDirective, }, - inject: ['canUpdate'], + inject: ['canUpdateTimelineEvent'], props: { - isLastItem: { - type: Boolean, - required: true, - }, occurredAt: { type: String, required: true, @@ -58,43 +42,41 @@ export default { }; </script> <template> - <li - class="timeline-entry timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!" - > - <div class="gl-display-flex gl-align-items-center"> - <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-n2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" - > - <gl-icon :name="getEventIcon(action)" class="note-icon" /> + <div class="gl-display-flex gl-align-items-start"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-bg-white gl-text-gray-200 gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-full gl-mt-2 gl-mr-3 gl-w-8 gl-h-8 gl-p-3 gl-z-index-1" + > + <gl-icon :name="getEventIcon(action)" class="note-icon" /> + </div> + <div + class="timeline-event-note timeline-event-border gl-w-full gl-display-flex gl-flex-direction-row" + data-testid="event-text-container" + > + <div> + <strong class="gl-font-lg" data-testid="event-time"> + <gl-sprintf :message="$options.i18n.timeUTC"> + <template #time>{{ time }}</template> + </gl-sprintf> + </strong> + <div v-safe-html="noteHtml"></div> </div> - <div - class="timeline-event-note gl-w-full gl-display-flex gl-flex-direction-row" - :class="{ 'gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid': !isLastItem }" - data-testid="event-text-container" + <gl-dropdown + v-if="canUpdateTimelineEvent" + right + class="event-note-actions gl-ml-auto gl-align-self-start" + icon="ellipsis_v" + text-sr-only + :text="$options.i18n.moreActions" + category="tertiary" + no-caret > - <div> - <strong class="gl-font-lg" data-testid="event-time"> - <gl-sprintf :message="$options.i18n.timeUTC"> - <template #time>{{ time }}</template> - </gl-sprintf> - </strong> - <div v-safe-html="noteHtml"></div> - </div> - <gl-dropdown - v-if="canUpdate" - right - class="event-note-actions gl-ml-auto gl-align-self-center" - icon="ellipsis_v" - text-sr-only - :text="$options.i18n.moreActions" - category="tertiary" - no-caret - > - <gl-dropdown-item @click="$emit('delete')"> - <gl-button>{{ $options.i18n.delete }}</gl-button> - </gl-dropdown-item> - </gl-dropdown> - </div> + <gl-dropdown-item @click="$emit('edit')"> + {{ $options.i18n.edit }} + </gl-dropdown-item> + <gl-dropdown-item @click="$emit('delete')"> + {{ $options.i18n.delete }} + </gl-dropdown-item> + </gl-dropdown> </div> - </li> + </div> </template> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue index 80ac1c372cd..321b7ccc14a 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_list.vue @@ -5,7 +5,9 @@ import { sprintf } from '~/locale'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import { ignoreWhilePending } from '~/lib/utils/ignore_while_pending'; import IncidentTimelineEventItem from './timeline_events_item.vue'; +import EditTimelineEvent from './edit_timeline_event.vue'; import deleteTimelineEvent from './graphql/queries/delete_timeline_event.mutation.graphql'; +import editTimelineEvent from './graphql/queries/edit_timeline_event.mutation.graphql'; import { timelineListI18n } from './constants'; export default { @@ -13,6 +15,7 @@ export default { i18n: timelineListI18n, components: { IncidentTimelineEventItem, + EditTimelineEvent, }, props: { timelineEventLoading: { @@ -26,6 +29,9 @@ export default { default: () => [], }, }, + data() { + return { eventToEdit: null, editTimelineEventActive: false }; + }, computed: { dateGroupedEvents() { const groupedEvents = new Map(); @@ -44,11 +50,12 @@ export default { }, }, methods: { - isLastItem(groups, groupIndex, events, eventIndex) { - if (groupIndex < groups.size - 1) { - return false; - } - return eventIndex === events.length - 1; + handleEditSelection(event) { + this.eventToEdit = event.id; + this.$emit('hide-new-incident-timeline-event-form'); + }, + hideEdit() { + this.eventToEdit = null; }, handleDelete: ignoreWhilePending(async function handleDelete(event) { const msg = this.$options.i18n.deleteModal; @@ -85,6 +92,38 @@ export default { createAlert({ message: this.$options.i18n.deleteErrorGeneric, captureError: true, error }); } }), + handleSaveEdit(eventDetails) { + this.editTimelineEventActive = true; + return this.$apollo + .mutate({ + mutation: editTimelineEvent, + variables: { + input: { + id: eventDetails.id, + note: eventDetails.note, + occurredAt: eventDetails.occurredAt, + }, + }, + }) + .then(({ data }) => { + this.editTimelineEventActive = false; + const errors = data.timelineEventUpdate?.errors; + if (errors.length) { + createAlert({ + message: sprintf(this.$options.i18n.editError, { error: errors.join('. ') }, false), + }); + } else { + this.hideEdit(); + } + }) + .catch((error) => { + createAlert({ + message: this.$options.i18n.editErrorGeneric, + captureError: true, + error, + }); + }); + }, }, }; </script> @@ -92,9 +131,10 @@ export default { <template> <div class="issuable-discussion incident-timeline-events"> <div - v-for="([eventDate, events], groupIndex) in dateGroupedEvents" + v-for="[eventDate, events] in dateGroupedEvents" :key="eventDate" data-testid="timeline-group" + class="timeline-group" > <div class="gl-pb-3 gl-border-gray-50 gl-border-1 gl-border-b-solid"> <strong class="gl-font-size-h2" data-testid="event-date">{{ eventDate }}</strong> @@ -103,15 +143,25 @@ export default { <li v-for="(event, eventIndex) in events" :key="eventIndex" - class="timeline-entry-vertical-line note system-note note-wrapper gl-my-2! gl-pr-0!" + class="timeline-entry-vertical-line timeline-entry note system-note note-wrapper gl-my-2! gl-pr-0!" > + <edit-timeline-event + v-if="eventToEdit === event.id" + :key="`edit-${event.id}`" + ref="eventForm" + :event="event" + :edit-timeline-event-active="editTimelineEventActive" + @handle-save-edit="handleSaveEdit" + @hide-edit="hideEdit()" + /> <incident-timeline-event-item + v-else :key="event.id" :action="event.action" :occurred-at="event.occurredAt" :note-html="event.noteHtml" - :is-last-item="isLastItem(dateGroupedEvents, groupIndex, events, eventIndex)" @delete="handleDelete(event)" + @edit="handleEditSelection(event)" /> </li> </ul> diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue index 7c2a7878c58..5f70d9acac9 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_tab.vue @@ -3,10 +3,10 @@ import { GlButton, GlEmptyState, GlLoadingIcon, GlTab } from '@gitlab/ui'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPE_ISSUE } from '~/graphql_shared/constants'; import { fetchPolicies } from '~/lib/graphql'; +import notesEventHub from '~/notes/event_hub'; import getTimelineEvents from './graphql/queries/get_timeline_events.query.graphql'; import { displayAndLogError } from './utils'; import { timelineTabI18n } from './constants'; - import CreateTimelineEvent from './create_timeline_event.vue'; import IncidentTimelineEventsList from './timeline_events_list.vue'; @@ -20,7 +20,7 @@ export default { IncidentTimelineEventsList, }, i18n: timelineTabI18n, - inject: ['canUpdate', 'fullPath', 'issuableId'], + inject: ['canUpdateTimelineEvent', 'fullPath', 'issuableId'], data() { return { isEventFormVisible: false, @@ -56,15 +56,21 @@ export default { return !this.timelineEventLoading && !this.hasTimelineEvents; }, }, + mounted() { + notesEventHub.$on('comment-promoted-to-timeline-event', this.refreshTimelineEvents); + }, + destroyed() { + notesEventHub.$off('comment-promoted-to-timeline-event', this.refreshTimelineEvents); + }, methods: { + refreshTimelineEvents() { + this.$apollo.queries.timelineEvents.refetch(); + }, hideEventForm() { this.isEventFormVisible = false; }, - async showEventForm() { - this.$refs.createEventForm.clearForm(); + showEventForm() { this.isEventFormVisible = true; - await this.$nextTick(); - this.$refs.createEventForm.focusDate(); }, }, }; @@ -85,14 +91,19 @@ export default { @hide-new-timeline-events-form="hideEventForm" /> <create-timeline-event - v-show="isEventFormVisible" + v-if="isEventFormVisible" ref="createEventForm" :has-timeline-events="hasTimelineEvents" class="timeline-event-note timeline-event-note-form" :class="{ 'gl-pl-0': !hasTimelineEvents }" @hide-new-timeline-events-form="hideEventForm" /> - <gl-button v-if="canUpdate" variant="default" class="gl-mb-3 gl-mt-7" @click="showEventForm"> + <gl-button + v-if="canUpdateTimelineEvent" + variant="default" + class="gl-mb-3 gl-mt-7" + @click="showEventForm" + > {{ $options.i18n.addEventButton }} </gl-button> </gl-tab> diff --git a/app/assets/javascripts/issues/show/components/incidents/utils.js b/app/assets/javascripts/issues/show/components/incidents/utils.js index cf790a11b67..5a009debd75 100644 --- a/app/assets/javascripts/issues/show/components/incidents/utils.js +++ b/app/assets/javascripts/issues/show/components/incidents/utils.js @@ -21,13 +21,14 @@ export const getEventIcon = (actionName) => { }; /** - * Returns a date shifted by the current timezone offset. Allows - * date.getHours() and similar to return UTC values. - * + * Returns a date shifted by the current timezone offset set to now + * by default but can accept an existing date as an ISO date string + * @param {string} ISOString * @returns {Date} */ -export const getUtcShiftedDateNow = () => { - const date = new Date(); +export const getUtcShiftedDate = (ISOString = null) => { + const date = ISOString ? new Date(ISOString) : new Date(); date.setMinutes(date.getMinutes() + date.getTimezoneOffset()); + return date; }; diff --git a/app/assets/javascripts/issues/show/graphql.js b/app/assets/javascripts/issues/show/graphql.js index 5b8630f7d63..deee034f9d1 100644 --- a/app/assets/javascripts/issues/show/graphql.js +++ b/app/assets/javascripts/issues/show/graphql.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { defaultClient } from '~/sidebar/graphql'; +import { defaultClient } from '~/graphql_shared/issuable_client'; Vue.use(VueApollo); diff --git a/app/assets/javascripts/issues/show/index.js b/app/assets/javascripts/issues/show/index.js index 459a3804837..e5eed9f6b79 100644 --- a/app/assets/javascripts/issues/show/index.js +++ b/app/assets/javascripts/issues/show/index.js @@ -32,6 +32,7 @@ export function initIncidentApp(issueData = {}) { const { canCreateIncident, canUpdate, + canUpdateTimelineEvent, iid, issuableId, projectNamespace, @@ -51,6 +52,7 @@ export function initIncidentApp(issueData = {}) { provide: { issueType: INCIDENT_TYPE, canCreateIncident, + canUpdateTimelineEvent, canUpdate, fullPath, iid, diff --git a/app/assets/javascripts/issues/show/utils/update_description.js b/app/assets/javascripts/issues/show/utils/update_description.js index c5811290e61..aeb547b9194 100644 --- a/app/assets/javascripts/issues/show/utils/update_description.js +++ b/app/assets/javascripts/issues/show/utils/update_description.js @@ -13,6 +13,7 @@ const updateDescription = (descriptionHtml = '', details) => { } const placeholder = document.createElement('div'); + // eslint-disable-next-line no-unsanitized/property placeholder.innerHTML = descriptionHtml; const newDetails = placeholder.getElementsByTagName('details'); |