diff options
Diffstat (limited to 'app/assets/javascripts/issuable_show')
8 files changed, 591 insertions, 0 deletions
diff --git a/app/assets/javascripts/issuable_show/components/issuable_body.vue b/app/assets/javascripts/issuable_show/components/issuable_body.vue new file mode 100644 index 00000000000..e6a05c1ab8b --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_body.vue @@ -0,0 +1,103 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +import IssuableTitle from './issuable_title.vue'; +import IssuableDescription from './issuable_description.vue'; +import IssuableEditForm from './issuable_edit_form.vue'; + +export default { + components: { + GlLink, + TimeAgoTooltip, + IssuableTitle, + IssuableDescription, + IssuableEditForm, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + editFormVisible: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + }, + computed: { + isUpdated() { + return Boolean(this.issuable.updatedAt); + }, + updatedBy() { + return this.issuable.updatedBy; + }, + }, +}; +</script> + +<template> + <div class="issue-details issuable-details"> + <div class="detail-page-description content-block"> + <issuable-edit-form + v-if="editFormVisible" + :issuable="issuable" + :enable-autocomplete="enableAutocomplete" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + > + <template #edit-form-actions="issuableMeta"> + <slot name="edit-form-actions" v-bind="issuableMeta"></slot> + </template> + </issuable-edit-form> + <template v-else> + <issuable-title + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + @edit-issuable="$emit('edit-issuable', $event)" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + </issuable-title> + <issuable-description v-if="issuable.descriptionHtml" :issuable="issuable" /> + <small v-if="isUpdated" class="edited-text gl-font-sm!"> + {{ __('Edited') }} + <time-ago-tooltip :time="issuable.updatedAt" tooltip-placement="bottom" /> + <span v-if="updatedBy"> + {{ __('by') }} + <gl-link :href="updatedBy.webUrl" class="author-link gl-font-sm!"> + <span>{{ updatedBy.name }}</span> + </gl-link> + </span> + </small> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_description.vue b/app/assets/javascripts/issuable_show/components/issuable_description.vue new file mode 100644 index 00000000000..091a4be5bd8 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_description.vue @@ -0,0 +1,31 @@ +<script> +import $ from 'jquery'; +import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui'; +import '~/behaviors/markdown/render_gfm'; + +export default { + directives: { + SafeHtml, + }, + props: { + issuable: { + type: Object, + required: true, + }, + }, + mounted() { + this.renderGFM(); + }, + methods: { + renderGFM() { + $(this.$refs.gfmContainer).renderGFM(); + }, + }, +}; +</script> + +<template> + <div class="description"> + <div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue new file mode 100644 index 00000000000..7b9a83a740f --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_edit_form.vue @@ -0,0 +1,135 @@ +<script> +import $ from 'jquery'; +import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; + +import Autosave from '~/autosave'; +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; + +import eventHub from '../event_hub'; + +export default { + components: { + GlForm, + GlFormGroup, + GlFormInput, + MarkdownField, + }, + props: { + issuable: { + type: Object, + required: true, + }, + enableAutocomplete: { + type: Boolean, + required: true, + }, + descriptionPreviewPath: { + type: String, + required: true, + }, + descriptionHelpPath: { + type: String, + required: true, + }, + }, + data() { + const { title, description } = this.issuable; + + return { + title, + description, + }; + }, + created() { + eventHub.$on('update.issuable', this.resetAutosave); + eventHub.$on('close.form', this.resetAutosave); + }, + mounted() { + this.initAutosave(); + }, + beforeDestroy() { + eventHub.$off('update.issuable', this.resetAutosave); + eventHub.$off('close.form', this.resetAutosave); + }, + methods: { + initAutosave() { + const { titleInput, descriptionInput } = this.$refs; + + if (!titleInput || !descriptionInput) return; + + this.autosaveTitle = new Autosave($(titleInput.$el), [ + document.location.pathname, + document.location.search, + 'title', + ]); + + this.autosaveDescription = new Autosave($(descriptionInput.$el), [ + document.location.pathname, + document.location.search, + 'description', + ]); + }, + resetAutosave() { + this.autosaveTitle.reset(); + this.autosaveDescription.reset(); + }, + }, +}; +</script> + +<template> + <gl-form> + <gl-form-group + data-testid="title" + :label="__('Title')" + :label-sr-only="true" + label-for="issuable-title" + class="col-12" + > + <gl-form-input + id="issuable-title" + ref="titleInput" + v-model.trim="title" + :placeholder="__('Title')" + :aria-label="__('Title')" + :autofocus="true" + class="qa-title-input" + /> + </gl-form-group> + <gl-form-group + data-testid="description" + :label="__('Description')" + :label-sr-only="true" + label-for="issuable-description" + class="col-12 common-note-form" + > + <markdown-field + :markdown-preview-path="descriptionPreviewPath" + :markdown-docs-path="descriptionHelpPath" + :enable-autocomplete="enableAutocomplete" + :textarea-value="description" + > + <template #textarea> + <textarea + id="issuable-description" + ref="descriptionInput" + v-model="description" + :data-supports-quick-actions="enableAutocomplete" + :aria-label="__('Description')" + :placeholder="__('Write a comment or drag your files hereā¦')" + class="note-textarea js-gfm-input js-autosize markdown-area + qa-description-textarea" + dir="auto" + ></textarea> + </template> + </markdown-field> + </gl-form-group> + <div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 clearfix"> + <slot + name="edit-form-actions" + :issuable-title="title" + :issuable-description="description" + ></slot> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_header.vue b/app/assets/javascripts/issuable_show/components/issuable_header.vue new file mode 100644 index 00000000000..3815c50cac6 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_header.vue @@ -0,0 +1,120 @@ +<script> +import { GlIcon, GlButton, GlTooltipDirective, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; + +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlIcon, + GlButton, + GlAvatarLink, + GlAvatarLabeled, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + createdAt: { + type: String, + required: true, + }, + author: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: false, + default: '', + }, + blocked: { + type: Boolean, + required: false, + default: false, + }, + confidential: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + authorId() { + return getIdFromGraphQLId(`${this.author.id}`); + }, + }, + mounted() { + this.toggleSidebarButtonEl = document.querySelector('.js-toggle-right-sidebar-button'); + }, + methods: { + handleRightSidebarToggleClick() { + if (this.toggleSidebarButtonEl) { + this.toggleSidebarButtonEl.dispatchEvent(new Event('click')); + } + }, + }, +}; +</script> + +<template> + <div class="detail-page-header"> + <div class="detail-page-header-body"> + <div data-testid="status" class="issuable-status-box status-box" :class="statusBadgeClass"> + <gl-icon v-if="statusIcon" :name="statusIcon" class="d-block d-sm-none" /> + <span class="d-none d-sm-block"><slot name="status-badge"></slot></span> + </div> + <div class="issuable-meta gl-display-flex gl-align-items-center"> + <div class="gl-display-inline-block"> + <div v-if="blocked" data-testid="blocked" class="issuable-warning-icon inline"> + <gl-icon name="lock" :aria-label="__('Blocked')" /> + </div> + <div v-if="confidential" data-testid="confidential" class="issuable-warning-icon inline"> + <gl-icon name="eye-slash" :aria-label="__('Confidential')" /> + </div> + </div> + <span> + {{ __('Opened') }} + <time-ago-tooltip data-testid="startTimeItem" :time="createdAt" /> + {{ __('by') }} + </span> + <gl-avatar-link + data-testid="avatar" + :data-user-id="authorId" + :data-username="author.username" + :data-name="author.name" + :href="author.webUrl" + target="_blank" + class="js-user-link gl-ml-2" + > + <gl-avatar-labeled + :size="24" + :src="author.avatarUrl" + :label="author.name" + class="d-none d-sm-inline-flex gl-ml-1" + /> + <strong class="author d-sm-none d-inline">@{{ author.username }}</strong> + </gl-avatar-link> + </div> + <gl-button + data-testid="sidebar-toggle" + icon="chevron-double-lg-left" + class="d-block d-sm-none gutter-toggle issuable-gutter-toggle" + :aria-label="__('Expand sidebar')" + @click="handleRightSidebarToggleClick" + /> + </div> + <div + data-testid="header-actions" + class="detail-page-header-actions js-issuable-actions js-issuable-buttons gl-display-flex gl-display-md-block" + > + <slot name="header-actions"></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_show_root.vue b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue new file mode 100644 index 00000000000..b41f5e270a8 --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_show_root.vue @@ -0,0 +1,98 @@ +<script> +import IssuableSidebar from '~/issuable_sidebar/components/issuable_sidebar_root.vue'; + +import IssuableHeader from './issuable_header.vue'; +import IssuableBody from './issuable_body.vue'; + +export default { + components: { + IssuableSidebar, + IssuableHeader, + IssuableBody, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: false, + default: '', + }, + statusIcon: { + type: String, + required: false, + default: '', + }, + enableEdit: { + type: Boolean, + required: false, + default: false, + }, + enableAutocomplete: { + type: Boolean, + required: false, + default: false, + }, + editFormVisible: { + type: Boolean, + required: false, + default: false, + }, + descriptionPreviewPath: { + type: String, + required: false, + default: '', + }, + descriptionHelpPath: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> + +<template> + <div class="issuable-show-container"> + <issuable-header + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :blocked="issuable.blocked" + :confidential="issuable.confidential" + :created-at="issuable.createdAt" + :author="issuable.author" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + <template #header-actions> + <slot name="header-actions"></slot> + </template> + </issuable-header> + <issuable-body + :issuable="issuable" + :status-badge-class="statusBadgeClass" + :status-icon="statusIcon" + :enable-edit="enableEdit" + :enable-autocomplete="enableAutocomplete" + :edit-form-visible="editFormVisible" + :description-preview-path="descriptionPreviewPath" + :description-help-path="descriptionHelpPath" + @edit-issuable="$emit('edit-issuable', $event)" + > + <template #status-badge> + <slot name="status-badge"></slot> + </template> + <template #edit-form-actions="actionsProps"> + <slot name="edit-form-actions" v-bind="actionsProps"></slot> + </template> + </issuable-body> + <issuable-sidebar @sidebar-toggle="$emit('sidebar-toggle', $event)"> + <template #right-sidebar-items="sidebarProps"> + <slot name="right-sidebar-items" v-bind="sidebarProps"></slot> + </template> + </issuable-sidebar> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/components/issuable_title.vue b/app/assets/javascripts/issuable_show/components/issuable_title.vue new file mode 100644 index 00000000000..d3b42fd2ffb --- /dev/null +++ b/app/assets/javascripts/issuable_show/components/issuable_title.vue @@ -0,0 +1,96 @@ +<script> +import { + GlIcon, + GlButton, + GlIntersectionObserver, + GlTooltipDirective, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlButton, + GlIntersectionObserver, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + props: { + issuable: { + type: Object, + required: true, + }, + statusBadgeClass: { + type: String, + required: true, + }, + statusIcon: { + type: String, + required: true, + }, + enableEdit: { + type: Boolean, + required: true, + }, + }, + data() { + return { + stickyTitleVisible: false, + }; + }, + methods: { + handleTitleAppear() { + this.stickyTitleVisible = false; + }, + handleTitleDisappear() { + this.stickyTitleVisible = true; + }, + }, +}; +</script> + +<template> + <div> + <div class="title-container"> + <h2 v-safe-html="issuable.titleHtml" class="title qa-title" dir="auto"></h2> + <gl-button + v-if="enableEdit" + v-gl-tooltip.bottom + :title="__('Edit title and description')" + icon="pencil" + class="btn-edit js-issuable-edit qa-edit-button" + @click="$emit('edit-issuable', $event)" + /> + </div> + <gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear"> + <transition name="issuable-header-slide"> + <div + v-if="stickyTitleVisible" + class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" + data-testid="header" + > + <div + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5" + > + <p + data-testid="status" + class="issuable-status-box status-box gl-my-0" + :class="statusBadgeClass" + > + <gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" /> + <span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span> + </p> + <p + class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" + :title="issuable.title" + > + {{ issuable.title }} + </p> + </div> + </div> + </transition> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/issuable_show/constants.js b/app/assets/javascripts/issuable_show/constants.js new file mode 100644 index 00000000000..346f45c7d90 --- /dev/null +++ b/app/assets/javascripts/issuable_show/constants.js @@ -0,0 +1,5 @@ +export const IssuableType = { + Issue: 'issue', + Incident: 'incident', + TestCase: 'test_case', +}; diff --git a/app/assets/javascripts/issuable_show/event_hub.js b/app/assets/javascripts/issuable_show/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/issuable_show/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); |