Commit c6ee59da authored by Nathan Friend's avatar Nathan Friend

Add ability to create release from existing tag

This commit updates the New Release page to allow a new release to be
created based on an existing tag. Previously, this ability was only
available through the API.
parent 5b7c554f
...@@ -86,12 +86,11 @@ export default { ...@@ -86,12 +86,11 @@ export default {
]; ];
}, },
}, },
mounted() { async mounted() {
// eslint-disable-next-line promise/catch-or-return await this.initializeRelease();
this.initializeRelease().then(() => {
// Focus the first non-disabled input element // Focus the first non-disabled input or button element
this.$el.querySelector('input:enabled').focus(); this.$el.querySelector('input:enabled, button:enabled').focus();
});
}, },
methods: { methods: {
...mapActions('detail', [ ...mapActions('detail', [
......
<script> <script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui'; import { GlFormGroup, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale'; import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue'; import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_TAGS } from '~/ref/constants';
import FormFieldContainer from './form_field_container.vue'; import FormFieldContainer from './form_field_container.vue';
export default { export default {
name: 'TagFieldNew', name: 'TagFieldNew',
components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer }, components: {
GlFormGroup,
RefSelector,
FormFieldContainer,
GlDropdownItem,
GlSprintf,
},
data() { data() {
return { return {
// Keeps track of whether or not the user has interacted with // Keeps track of whether or not the user has interacted with
// the input field. This is used to avoid showing validation // the input field. This is used to avoid showing validation
// errors immediately when the page loads. // errors immediately when the page loads.
isInputDirty: false, isInputDirty: false,
showCreateFrom: true,
}; };
}, },
computed: { computed: {
...@@ -26,6 +35,12 @@ export default { ...@@ -26,6 +35,12 @@ export default {
}, },
set(tagName) { set(tagName) {
this.updateReleaseTagName(tagName); this.updateReleaseTagName(tagName);
// This setter is used by the `v-model` on the `RefSelector`.
// When this is called, the selection originated from the
// dropdown list of existing tag names, so we know the tag
// already exists and don't need to show the "create from" input
this.showCreateFrom = false;
}, },
}, },
createFromModel: { createFromModel: {
...@@ -51,12 +66,28 @@ export default { ...@@ -51,12 +66,28 @@ export default {
markInputAsDirty() { markInputAsDirty() {
this.isInputDirty = true; this.isInputDirty = true;
}, },
createTagClicked(newTagName) {
this.updateReleaseTagName(newTagName);
// This method is called when the user selects the "create tag"
// option, so the tag does not already exist. Because of this,
// we need to show the "create from" input.
this.showCreateFrom = true;
},
}, },
translations: { translations: {
tagName: {
noRefSelected: __('No tag selected'),
dropdownHeader: __('Tag name'),
searchPlaceholder: __('Search or create tag'),
},
createFrom: {
noRefSelected: __('No source selected'), noRefSelected: __('No source selected'),
searchPlaceholder: __('Search branches, tags, and commits'), searchPlaceholder: __('Search branches, tags, and commits'),
dropdownHeader: __('Select source'), dropdownHeader: __('Select source'),
}, },
},
tagNameEnabledRefTypes: [REF_TYPE_TAGS],
}; };
</script> </script>
<template> <template>
...@@ -69,17 +100,34 @@ export default { ...@@ -69,17 +100,34 @@ export default {
:invalid-feedback="__('Tag name is required')" :invalid-feedback="__('Tag name is required')"
> >
<form-field-container> <form-field-container>
<gl-form-input <ref-selector
:id="tagNameInputId" :id="tagNameInputId"
v-model="tagName" v-model="tagName"
:project-id="projectId"
:translations="$options.translations.tagName"
:enabled-ref-types="$options.tagNameEnabledRefTypes"
:state="!showTagNameValidationError" :state="!showTagNameValidationError"
type="text" @hide.once="markInputAsDirty"
class="form-control" >
@blur.once="markInputAsDirty" <template #footer="{ isLoading, matches, query }">
/> <gl-dropdown-item
v-if="!isLoading && matches && matches.tags.totalCount === 0"
is-check-item
:is-checked="tagName === query"
@click="createTagClicked(query)"
>
<gl-sprintf :message="__('Create tag %{tagName}')">
<template #tagName>
<b>{{ query }}</b>
</template>
</gl-sprintf>
</gl-dropdown-item>
</template>
</ref-selector>
</form-field-container> </form-field-container>
</gl-form-group> </gl-form-group>
<gl-form-group <gl-form-group
v-if="showCreateFrom"
:label="__('Create from')" :label="__('Create from')"
:label-for="createFromSelectorId" :label-for="createFromSelectorId"
data-testid="create-from-field" data-testid="create-from-field"
...@@ -89,7 +137,7 @@ export default { ...@@ -89,7 +137,7 @@ export default {
:id="createFromSelectorId" :id="createFromSelectorId"
v-model="createFromModel" v-model="createFromModel"
:project-id="projectId" :project-id="projectId"
:translations="$options.translations" :translations="$options.translations.createFrom"
/> />
</form-field-container> </form-field-container>
<template #description> <template #description>
......
---
title: Allow release to be created on existing tag through the UI
merge_request: 55697
author:
type: added
...@@ -66,14 +66,11 @@ To create a new release through the GitLab UI: ...@@ -66,14 +66,11 @@ To create a new release through the GitLab UI:
1. Navigate to **Project overview > Releases** and click the **New release** 1. Navigate to **Project overview > Releases** and click the **New release**
button. button.
1. In the [**Tag name**](#tag-name) box, enter a name. 1. Open the [**Tag name**](#tag-name) dropdown. Select an existing tag or type
in a new tag name. Selecting an existing tag that is already associated with
Creating a release based on an existing tag using the user a release will result in a validation error.
interface is not yet supported. However, this is possible using the 1. If creating a new tag, open the **Create from** dropdown. Select a
[Releases API](../../../api/releases/index.md#create-a-release). branch, tag, or commit SHA to use when creating the new tag.
1. In the **Create from** list, select a branch, tag, or commit SHA to use when
creating the new tag.
1. Optionally, fill out any additional information about the release, such as its 1. Optionally, fill out any additional information about the release, such as its
[title](#title), [milestones](#associate-milestones-with-a-release), [title](#title), [milestones](#associate-milestones-with-a-release),
[release notes](#release-notes-description), or [assets links](#links). [release notes](#release-notes-description), or [assets links](#links).
......
...@@ -8721,6 +8721,9 @@ msgstr "" ...@@ -8721,6 +8721,9 @@ msgstr ""
msgid "Create snippet" msgid "Create snippet"
msgstr "" msgstr ""
msgid "Create tag %{tagName}"
msgstr ""
msgid "Create wildcard: %{searchTerm}" msgid "Create wildcard: %{searchTerm}"
msgstr "" msgstr ""
...@@ -20619,6 +20622,9 @@ msgstr "" ...@@ -20619,6 +20622,9 @@ msgstr ""
msgid "No start date" msgid "No start date"
msgstr "" msgstr ""
msgid "No tag selected"
msgstr ""
msgid "No template" msgid "No template"
msgstr "" msgstr ""
...@@ -26326,6 +26332,9 @@ msgstr "" ...@@ -26326,6 +26332,9 @@ msgstr ""
msgid "Search milestones" msgid "Search milestones"
msgstr "" msgstr ""
msgid "Search or create tag"
msgstr ""
msgid "Search or filter results..." msgid "Search or filter results..."
msgstr "" msgstr ""
......
...@@ -33,7 +33,7 @@ RSpec.describe 'User creates release', :js do ...@@ -33,7 +33,7 @@ RSpec.describe 'User creates release', :js do
end end
it 'defaults the "Create from" dropdown to the project\'s default branch' do it 'defaults the "Create from" dropdown to the project\'s default branch' do
expect(page.find('.ref-selector button')).to have_content(project.default_branch) expect(page.find('[data-testid="create-from-field"] .ref-selector button')).to have_content(project.default_branch)
end end
context 'when the "Save release" button is clicked', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297507' do context 'when the "Save release" button is clicked', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/297507' do
......
import { GlFormInput } from '@gitlab/ui'; import { GlDropdownItem } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import RefSelector from '~/ref/components/ref_selector.vue'; import Vue from 'vue';
import TagFieldNew from '~/releases/components/tag_field_new.vue'; import TagFieldNew from '~/releases/components/tag_field_new.vue';
import createStore from '~/releases/stores'; import createStore from '~/releases/stores';
import createDetailModule from '~/releases/stores/modules/detail'; import createDetailModule from '~/releases/stores/modules/detail';
...@@ -8,6 +8,25 @@ import createDetailModule from '~/releases/stores/modules/detail'; ...@@ -8,6 +8,25 @@ import createDetailModule from '~/releases/stores/modules/detail';
const TEST_TAG_NAME = 'test-tag-name'; const TEST_TAG_NAME = 'test-tag-name';
const TEST_PROJECT_ID = '1234'; const TEST_PROJECT_ID = '1234';
const TEST_CREATE_FROM = 'test-create-from'; const TEST_CREATE_FROM = 'test-create-from';
const NONEXISTENT_TAG_NAME = 'nonexistent-tag';
// A mock version of the RefSelector component that simulates
// a scenario where the users has searched for "nonexistent-tag"
// and the component has found no tags that match.
const RefSelectorStub = Vue.component('RefSelectorStub', {
data() {
return {
footerSlotProps: {
isLoading: false,
matches: {
tags: { totalCount: 0 },
},
query: NONEXISTENT_TAG_NAME,
},
};
},
template: '<div><slot name="footer" v-bind="footerSlotProps"></slot></div>',
});
describe('releases/components/tag_field_new', () => { describe('releases/components/tag_field_new', () => {
let store; let store;
...@@ -17,7 +36,7 @@ describe('releases/components/tag_field_new', () => { ...@@ -17,7 +36,7 @@ describe('releases/components/tag_field_new', () => {
wrapper = mountFn(TagFieldNew, { wrapper = mountFn(TagFieldNew, {
store, store,
stubs: { stubs: {
RefSelector: true, RefSelector: RefSelectorStub,
}, },
}); });
}; };
...@@ -47,11 +66,12 @@ describe('releases/components/tag_field_new', () => { ...@@ -47,11 +66,12 @@ describe('releases/components/tag_field_new', () => {
}); });
const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]');
const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput); const findTagNameDropdown = () => findTagNameFormGroup().find(RefSelectorStub);
const findTagNameInput = () => findTagNameFormGroup().find('input');
const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]'); const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]');
const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector); const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelectorStub);
const findCreateNewTagOption = () => wrapper.find(GlDropdownItem);
describe('"Tag name" field', () => { describe('"Tag name" field', () => {
describe('rendering and behavior', () => { describe('rendering and behavior', () => {
...@@ -61,14 +81,37 @@ describe('releases/components/tag_field_new', () => { ...@@ -61,14 +81,37 @@ describe('releases/components/tag_field_new', () => {
expect(findTagNameFormGroup().attributes().label).toBe('Tag name'); expect(findTagNameFormGroup().attributes().label).toBe('Tag name');
}); });
describe('when the user updates the field', () => { describe('when the user selects a new tag name', () => {
beforeEach(async () => {
findCreateNewTagOption().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it("updates the store's release.tagName property", () => { it("updates the store's release.tagName property", () => {
expect(store.state.detail.release.tagName).toBe(NONEXISTENT_TAG_NAME);
});
it('hides the "Create from" field', () => {
expect(findCreateFromFormGroup().exists()).toBe(true);
});
});
describe('when the user selects an existing tag name', () => {
const updatedTagName = 'updated-tag-name'; const updatedTagName = 'updated-tag-name';
findTagNameGlInput().vm.$emit('input', updatedTagName);
return wrapper.vm.$nextTick().then(() => { beforeEach(async () => {
findTagNameDropdown().vm.$emit('input', updatedTagName);
await wrapper.vm.$nextTick();
});
it("updates the store's release.tagName property", () => {
expect(store.state.detail.release.tagName).toBe(updatedTagName); expect(store.state.detail.release.tagName).toBe(updatedTagName);
}); });
it('shows the "Create from" field', () => {
expect(findCreateFromFormGroup().exists()).toBe(false);
}); });
}); });
}); });
...@@ -83,41 +126,39 @@ describe('releases/components/tag_field_new', () => { ...@@ -83,41 +126,39 @@ describe('releases/components/tag_field_new', () => {
* @param {'shown' | 'hidden'} state The expected state of the validation message. * @param {'shown' | 'hidden'} state The expected state of the validation message.
* Should be passed either 'shown' or 'hidden' * Should be passed either 'shown' or 'hidden'
*/ */
const expectValidationMessageToBe = (state) => { const expectValidationMessageToBe = async (state) => {
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(findTagNameFormGroup().element).toHaveClass( expect(findTagNameFormGroup().element).toHaveClass(
state === 'shown' ? 'is-invalid' : 'is-valid', state === 'shown' ? 'is-invalid' : 'is-valid',
); );
expect(findTagNameFormGroup().element).not.toHaveClass( expect(findTagNameFormGroup().element).not.toHaveClass(
state === 'shown' ? 'is-valid' : 'is-invalid', state === 'shown' ? 'is-valid' : 'is-invalid',
); );
});
}; };
describe('when the user has not yet interacted with the component', () => { describe('when the user has not yet interacted with the component', () => {
it('does not display a validation error', () => { it('does not display a validation error', async () => {
findTagNameInput().setValue(''); findTagNameDropdown().vm.$emit('input', '');
return expectValidationMessageToBe('hidden'); await expectValidationMessageToBe('hidden');
}); });
}); });
describe('when the user has interacted with the component and the value is not empty', () => { describe('when the user has interacted with the component and the value is not empty', () => {
it('does not display validation error', () => { it('does not display validation error', async () => {
findTagNameInput().trigger('blur'); findTagNameDropdown().vm.$emit('hide');
return expectValidationMessageToBe('hidden'); await expectValidationMessageToBe('hidden');
}); });
}); });
describe('when the user has interacted with the component and the value is empty', () => { describe('when the user has interacted with the component and the value is empty', () => {
it('displays a validation error', () => { it('displays a validation error', async () => {
const tagNameInput = findTagNameInput(); findTagNameDropdown().vm.$emit('input', '');
findTagNameDropdown().vm.$emit('hide');
tagNameInput.setValue(''); await expectValidationMessageToBe('shown');
tagNameInput.trigger('blur');
return expectValidationMessageToBe('shown');
}); });
}); });
}); });
...@@ -131,14 +172,14 @@ describe('releases/components/tag_field_new', () => { ...@@ -131,14 +172,14 @@ describe('releases/components/tag_field_new', () => {
}); });
describe('when the user selects a git ref', () => { describe('when the user selects a git ref', () => {
it("updates the store's createFrom property", () => { it("updates the store's createFrom property", async () => {
const updatedCreateFrom = 'update-create-from'; const updatedCreateFrom = 'update-create-from';
findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); findCreateFromDropdown().vm.$emit('input', updatedCreateFrom);
return wrapper.vm.$nextTick().then(() => { await wrapper.vm.$nextTick();
expect(store.state.detail.createFrom).toBe(updatedCreateFrom); expect(store.state.detail.createFrom).toBe(updatedCreateFrom);
}); });
}); });
}); });
});
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment