diff options
Diffstat (limited to 'app')
14 files changed, 186 insertions, 35 deletions
diff --git a/app/assets/javascripts/blob/template_selector.js b/app/assets/javascripts/blob/template_selector.js index 02216e4e93d..6cfe354d277 100644 --- a/app/assets/javascripts/blob/template_selector.js +++ b/app/assets/javascripts/blob/template_selector.js @@ -27,11 +27,16 @@ export default class TemplateSelector { search: { fields: ['name'], }, - clicked: options => this.fetchFileTemplate(options), + clicked: options => this.onDropdownClicked(options), text: item => item.name, }); } + // Subclasses can override this method to conditionally prevent fetching file templates + onDropdownClicked(options) { + this.fetchFileTemplate(options); + } + initAutosizeUpdateEvent() { this.autosizeUpdateEvent = document.createEvent('Event'); this.autosizeUpdateEvent.initEvent('autosize:update', true, false); @@ -81,6 +86,10 @@ export default class TemplateSelector { } } + getEditorContent() { + return this.editor.getValue(); + } + startLoadingSpinner() { this.$dropdownIcon.addClass('fa-spinner fa-spin').removeClass('fa-chevron-down'); } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 437c4941fda..4e1b4f2652c 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -717,6 +717,7 @@ GitLabDropdown = (function() { selectedObject = this.renderedData[groupName][selectedIndex]; } else { selectedIndex = el.closest('li').index(); + this.selectedIndex = selectedIndex; selectedObject = this.renderedData[selectedIndex]; } } diff --git a/app/assets/javascripts/pages/projects/issues/form.js b/app/assets/javascripts/pages/projects/issues/form.js index 2205a7bafe3..96e47187fed 100644 --- a/app/assets/javascripts/pages/projects/issues/form.js +++ b/app/assets/javascripts/pages/projects/issues/form.js @@ -15,7 +15,9 @@ export default () => { new IssuableForm($('.issue-form')); new LabelsSelect(); new MilestoneSelect(); - new IssuableTemplateSelectors(); + new IssuableTemplateSelectors({ + warnTemplateOverride: true, + }); initSuggestions(); }; diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 8f0dc8554e2..e51ab79a51d 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -16,5 +16,7 @@ export default () => { new IssuableForm($('.merge-request-form')); new LabelsSelect(); new MilestoneSelect(); - new IssuableTemplateSelectors(); + new IssuableTemplateSelectors({ + warnTemplateOverride: true, + }); }; diff --git a/app/assets/javascripts/releases/components/release_block.vue b/app/assets/javascripts/releases/components/release_block.vue index 7b6bd9913a8..921ada91544 100644 --- a/app/assets/javascripts/releases/components/release_block.vue +++ b/app/assets/javascripts/releases/components/release_block.vue @@ -6,6 +6,9 @@ import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { __, n__, sprintf } from '../../locale'; +import { slugify } from '~/lib/utils/text_utility'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import { scrollToElement } from '~/lib/utils/common_utils'; export default { name: 'ReleaseBlock', @@ -26,7 +29,15 @@ export default { default: () => ({}), }, }, + data() { + return { + isHighlighted: false, + }; + }, computed: { + id() { + return slugify(this.release.tag_name); + }, releasedTimeAgo() { return sprintf(__('released %{time}'), { time: this.timeFormated(this.release.released_at), @@ -62,10 +73,21 @@ export default { return n__('Milestone', 'Milestones', this.release.milestones.length); }, }, + mounted() { + const hash = getLocationHash(); + if (hash && slugify(hash) === this.id) { + this.isHighlighted = true; + setTimeout(() => { + this.isHighlighted = false; + }, 2000); + + scrollToElement(this.$el); + } + }, }; </script> <template> - <div :id="release.tag_name" class="card"> + <div :id="id" :class="{ 'bg-line-target-blue': isHighlighted }" class="card release-block"> <div class="card-body"> <h2 class="card-title mt-0"> {{ release.name }} diff --git a/app/assets/javascripts/templates/issuable_template_selector.js b/app/assets/javascripts/templates/issuable_template_selector.js index 78609ce0610..78a1c4fa8a8 100644 --- a/app/assets/javascripts/templates/issuable_template_selector.js +++ b/app/assets/javascripts/templates/issuable_template_selector.js @@ -8,10 +8,13 @@ import { __ } from '~/locale'; export default class IssuableTemplateSelector extends TemplateSelector { constructor(...args) { super(...args); + this.projectPath = this.dropdown.data('projectPath'); this.namespacePath = this.dropdown.data('namespacePath'); this.issuableType = this.$dropdownContainer.data('issuableType'); this.titleInput = $(`#${this.issuableType}_title`); + this.templateWarningEl = $('.js-issuable-template-warning'); + this.warnTemplateOverride = args[0].warnTemplateOverride; const initialQuery = { name: this.dropdown.data('selected'), @@ -24,14 +27,61 @@ export default class IssuableTemplateSelector extends TemplateSelector { }); $('.no-template', this.dropdown.parent()).on('click', () => { - this.currentTemplate.content = ''; - this.setInputValueToTemplateContent(); - $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template')); + this.reset(); + }); + + this.templateWarningEl.find('.js-close-btn').on('click', () => { + if (this.previousSelectedIndex) { + this.dropdown.data('glDropdown').selectRowAtIndex(this.previousSelectedIndex); + } else { + this.reset(); + } + + this.templateWarningEl.addClass('hidden'); + }); + + this.templateWarningEl.find('.js-override-template').on('click', () => { + this.requestFile(this.overridingTemplate); + this.setSelectedIndex(); + + this.templateWarningEl.addClass('hidden'); + this.overridingTemplate = null; }); } + reset() { + if (this.currentTemplate) { + this.currentTemplate.content = ''; + } + + this.setInputValueToTemplateContent(); + $('.dropdown-toggle-text', this.dropdown).text(__('Choose a template')); + this.previousSelectedIndex = null; + } + + setSelectedIndex() { + this.previousSelectedIndex = this.dropdown.data('glDropdown').selectedIndex; + } + + onDropdownClicked(query) { + const content = this.getEditorContent(); + const isContentUnchanged = + content === '' || (this.currentTemplate && content === this.currentTemplate.content); + + if (!this.warnTemplateOverride || isContentUnchanged) { + super.onDropdownClicked(query); + this.setSelectedIndex(); + + return; + } + + this.overridingTemplate = query.selectedObj; + this.templateWarningEl.removeClass('hidden'); + } + requestFile(query) { this.startLoadingSpinner(); + Api.issueTemplate( this.namespacePath, this.projectPath, diff --git a/app/assets/javascripts/templates/issuable_template_selectors.js b/app/assets/javascripts/templates/issuable_template_selectors.js index 50e58ec5c46..443b3084113 100644 --- a/app/assets/javascripts/templates/issuable_template_selectors.js +++ b/app/assets/javascripts/templates/issuable_template_selectors.js @@ -4,7 +4,7 @@ import $ from 'jquery'; import IssuableTemplateSelector from './issuable_template_selector'; export default class IssuableTemplateSelectors { - constructor({ $dropdowns, editor } = {}) { + constructor({ $dropdowns, editor, warnTemplateOverride } = {}) { this.$dropdowns = $dropdowns || $('.js-issuable-selector'); this.editor = editor || this.initEditor(); @@ -16,6 +16,7 @@ export default class IssuableTemplateSelectors { wrapper: $dropdown.closest('.js-issuable-selector-wrap'), dropdown: $dropdown, editor: this.editor, + warnTemplateOverride, }); }); } diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue index bb6921225c2..1873e09c370 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue @@ -211,7 +211,7 @@ export default { <template v-else> <review-app-link :link="deploymentExternalUrl" - css-class="js-deploy-url js-deploy-url-feature-flag deploy-link btn btn-default btn-sm inline" + css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" /> </template> <visual-review-app-link diff --git a/app/assets/stylesheets/components/release_block.scss b/app/assets/stylesheets/components/release_block.scss new file mode 100644 index 00000000000..7e82d0960d7 --- /dev/null +++ b/app/assets/stylesheets/components/release_block.scss @@ -0,0 +1,3 @@ +.release-block { + transition: background-color 1s linear; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 4cd6763e7d7..922051ab0e9 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -55,6 +55,10 @@ background-color: $gray-light; } +.bg-line-target-blue { + background: $line-target-blue; +} + .text-break-word { word-break: break-all; } @@ -210,18 +214,26 @@ li.note { @mixin message($background-color, $border-color, $text-color) { border-left: 4px solid $border-color; color: $text-color; - padding: 10px; - margin-bottom: 10px; - background: $background-color; - padding-left: 20px; + padding: $gl-padding $gl-padding-24; + margin-bottom: $gl-padding-12; + background-color: $background-color; &.centered { text-align: center; } + + .close { + svg { + width: $gl-font-size-large; + height: $gl-font-size-large; + } + + color: inherit; + } } .warning_message { - @include message($orange-100, $orange-200, $orange-700); + @include message($orange-100, $orange-200, $orange-800); } .danger_message { diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 7fa290610aa..aed95b4601b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -25,7 +25,7 @@ module Ci belongs_to :merge_request, class_name: 'MergeRequest' belongs_to :external_pull_request - has_internal_id :iid, scope: :project, presence: false, init: ->(s) do + has_internal_id :iid, scope: :project, presence: false, ensure_if: -> { !importing? }, init: ->(s) do s&.project&.all_pipelines&.maximum(:iid) || s&.project&.all_pipelines&.count end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index 95de11a72bf..b510129b35d 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -27,53 +27,73 @@ module AtomicInternalId extend ActiveSupport::Concern class_methods do - def has_internal_id(column, scope:, init:, presence: true) # rubocop:disable Naming/PredicateName + def has_internal_id(column, scope:, init:, ensure_if: nil, presence: true) # rubocop:disable Naming/PredicateName # We require init here to retain the ability to recalculate in the absence of a # InternaLId record (we may delete records in `internal_ids` for example). raise "has_internal_id requires a init block, none given." unless init + raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) - before_validation :"ensure_#{scope}_#{column}!", on: :create + before_validation :"track_#{scope}_#{column}!", on: :create + before_validation :"ensure_#{scope}_#{column}!", on: :create, if: ensure_if validates column, presence: presence define_method("ensure_#{scope}_#{column}!") do - scope_value = association(scope).reader + scope_value = internal_id_read_scope(scope) value = read_attribute(column) - return value unless scope_value - scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value } - usage = self.class.table_name.to_sym - - if value.present? && (@iid_needs_tracking || Feature.enabled?(:iid_always_track, default_enabled: true)) - # The value was set externally, e.g. by the user - # We update the InternalId record to keep track of the greatest value. - InternalId.track_greatest(self, scope_attrs, usage, value, init) - - @iid_needs_tracking = false - elsif !value.present? + if value.nil? # We don't have a value yet and use a InternalId record to generate # the next value. - value = InternalId.generate_next(self, scope_attrs, usage, init) + value = InternalId.generate_next( + self, + internal_id_scope_attrs(scope), + internal_id_scope_usage, + init) write_attribute(column, value) end value end + define_method("track_#{scope}_#{column}!") do + iid_always_track = Feature.enabled?(:iid_always_track, default_enabled: true) + return unless @internal_id_needs_tracking || iid_always_track + + @internal_id_needs_tracking = false + + scope_value = internal_id_read_scope(scope) + value = read_attribute(column) + return unless scope_value + + if value.present? + # The value was set externally, e.g. by the user + # We update the InternalId record to keep track of the greatest value. + InternalId.track_greatest( + self, + internal_id_scope_attrs(scope), + internal_id_scope_usage, + value, + init) + end + end + define_method("#{column}=") do |value| super(value).tap do |v| # Indicate the iid was set from externally - @iid_needs_tracking = true + @internal_id_needs_tracking = true end end define_method("reset_#{scope}_#{column}") do if value = read_attribute(column) - scope_value = association(scope).reader - scope_attrs = { scope_value.class.table_name.singularize.to_sym => scope_value } - usage = self.class.table_name.to_sym + did_reset = InternalId.reset( + self, + internal_id_scope_attrs(scope), + internal_id_scope_usage, + value) - if InternalId.reset(self, scope_attrs, usage, value) + if did_reset write_attribute(column, nil) end end @@ -82,4 +102,18 @@ module AtomicInternalId end end end + + def internal_id_scope_attrs(scope) + scope_value = internal_id_read_scope(scope) + + { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value + end + + def internal_id_scope_usage + self.class.table_name.to_sym + end + + def internal_id_read_scope(scope) + association(scope).reader + end end diff --git a/app/views/shared/form_elements/_apply_template_warning.html.haml b/app/views/shared/form_elements/_apply_template_warning.html.haml new file mode 100644 index 00000000000..9027264d221 --- /dev/null +++ b/app/views/shared/form_elements/_apply_template_warning.html.haml @@ -0,0 +1,14 @@ +.form-group.row.js-template-warning.mb-0.hidden.js-issuable-template-warning + .offset-sm-2.col-sm-10 + + .warning_message.mb-0{ role: 'alert' } + %btn.js-close-btn.close{ type: "button", "aria-hidden": true, "aria-label": _("Close") } + = sprite_icon("close") + + %p + = _("Applying a template will replace the existing issue description. Any changes you have made will be lost.") + + %button.js-override-template.btn.btn-warning.mr-2{ type: 'button' } + = _("Apply template") + %button.js-cancel-btn.btn.btn-inverted{ type: 'button' } + = _("Cancel") diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 04a70e406ca..5e2b5f95ee3 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -19,6 +19,7 @@ = render 'shared/issuable/form/title', issuable: issuable, form: form, has_wip_commits: commits && commits.detect(&:work_in_progress?) #js-suggestions{ data: { project_path: @project.full_path } } += render 'shared/form_elements/apply_template_warning' = render 'shared/form_elements/description', model: issuable, form: form, project: project - if issuable.respond_to?(:confidential) |