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 {
];
},
},
mounted() {
// eslint-disable-next-line promise/catch-or-return
this.initializeRelease().then(() => {
// Focus the first non-disabled input element
this.$el.querySelector('input:enabled').focus();
});
async mounted() {
await this.initializeRelease();
// Focus the first non-disabled input or button element
this.$el.querySelector('input:enabled, button:enabled').focus();
},
methods: {
...mapActions('detail', [
......
<script>
import { GlFormGroup, GlFormInput } from '@gitlab/ui';
import { GlFormGroup, GlDropdownItem, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { mapState, mapActions, mapGetters } from 'vuex';
import { __ } from '~/locale';
import RefSelector from '~/ref/components/ref_selector.vue';
import { REF_TYPE_TAGS } from '~/ref/constants';
import FormFieldContainer from './form_field_container.vue';
export default {
name: 'TagFieldNew',
components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer },
components: {
GlFormGroup,
RefSelector,
FormFieldContainer,
GlDropdownItem,
GlSprintf,
},
data() {
return {
// Keeps track of whether or not the user has interacted with
// the input field. This is used to avoid showing validation
// errors immediately when the page loads.
isInputDirty: false,
showCreateFrom: true,
};
},
computed: {
......@@ -26,6 +35,12 @@ export default {
},
set(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: {
......@@ -51,12 +66,28 @@ export default {
markInputAsDirty() {
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: {
tagName: {
noRefSelected: __('No tag selected'),
dropdownHeader: __('Tag name'),
searchPlaceholder: __('Search or create tag'),
},
createFrom: {
noRefSelected: __('No source selected'),
searchPlaceholder: __('Search branches, tags, and commits'),
dropdownHeader: __('Select source'),
},
},
tagNameEnabledRefTypes: [REF_TYPE_TAGS],
};
</script>
<template>
......@@ -69,17 +100,34 @@ export default {
:invalid-feedback="__('Tag name is required')"
>
<form-field-container>
<gl-form-input
<ref-selector
:id="tagNameInputId"
v-model="tagName"
:project-id="projectId"
:translations="$options.translations.tagName"
:enabled-ref-types="$options.tagNameEnabledRefTypes"
:state="!showTagNameValidationError"
type="text"
class="form-control"
@blur.once="markInputAsDirty"
/>
@hide.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>
</gl-form-group>
<gl-form-group
v-if="showCreateFrom"
:label="__('Create from')"
:label-for="createFromSelectorId"
data-testid="create-from-field"
......@@ -89,7 +137,7 @@ export default {
:id="createFromSelectorId"
v-model="createFromModel"
:project-id="projectId"
:translations="$options.translations"
:translations="$options.translations.createFrom"
/>
</form-field-container>
<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:
1. Navigate to **Project overview > Releases** and click the **New release**
button.
1. In the [**Tag name**](#tag-name) box, enter a name.
Creating a release based on an existing tag using the user
interface is not yet supported. However, this is possible using the
[Releases API](../../../api/releases/index.md#create-a-release).
1. In the **Create from** list, select a branch, tag, or commit SHA to use when
creating the new tag.
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
a release will result in a validation error.
1. If creating a new tag, open the **Create from** dropdown. 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
[title](#title), [milestones](#associate-milestones-with-a-release),
[release notes](#release-notes-description), or [assets links](#links).
......
......@@ -8721,6 +8721,9 @@ msgstr ""
msgid "Create snippet"
msgstr ""
msgid "Create tag %{tagName}"
msgstr ""
msgid "Create wildcard: %{searchTerm}"
msgstr ""
......@@ -20619,6 +20622,9 @@ msgstr ""
msgid "No start date"
msgstr ""
msgid "No tag selected"
msgstr ""
msgid "No template"
msgstr ""
......@@ -26326,6 +26332,9 @@ msgstr ""
msgid "Search milestones"
msgstr ""
msgid "Search or create tag"
msgstr ""
msgid "Search or filter results..."
msgstr ""
......
......@@ -33,7 +33,7 @@ RSpec.describe 'User creates release', :js do
end
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
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 RefSelector from '~/ref/components/ref_selector.vue';
import Vue from 'vue';
import TagFieldNew from '~/releases/components/tag_field_new.vue';
import createStore from '~/releases/stores';
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_PROJECT_ID = '1234';
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', () => {
let store;
......@@ -17,7 +36,7 @@ describe('releases/components/tag_field_new', () => {
wrapper = mountFn(TagFieldNew, {
store,
stubs: {
RefSelector: true,
RefSelector: RefSelectorStub,
},
});
};
......@@ -47,11 +66,12 @@ describe('releases/components/tag_field_new', () => {
});
const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]');
const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput);
const findTagNameInput = () => findTagNameFormGroup().find('input');
const findTagNameDropdown = () => findTagNameFormGroup().find(RefSelectorStub);
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('rendering and behavior', () => {
......@@ -61,14 +81,37 @@ describe('releases/components/tag_field_new', () => {
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", () => {
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';
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);
});
it('shows the "Create from" field', () => {
expect(findCreateFromFormGroup().exists()).toBe(false);
});
});
});
......@@ -83,41 +126,39 @@ describe('releases/components/tag_field_new', () => {
* @param {'shown' | 'hidden'} state The expected state of the validation message.
* Should be passed either 'shown' or 'hidden'
*/
const expectValidationMessageToBe = (state) => {
return wrapper.vm.$nextTick().then(() => {
const expectValidationMessageToBe = async (state) => {
await wrapper.vm.$nextTick();
expect(findTagNameFormGroup().element).toHaveClass(
state === 'shown' ? 'is-invalid' : 'is-valid',
);
expect(findTagNameFormGroup().element).not.toHaveClass(
state === 'shown' ? 'is-valid' : 'is-invalid',
);
});
};
describe('when the user has not yet interacted with the component', () => {
it('does not display a validation error', () => {
findTagNameInput().setValue('');
it('does not display a validation error', async () => {
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', () => {
it('does not display validation error', () => {
findTagNameInput().trigger('blur');
it('does not display validation error', async () => {
findTagNameDropdown().vm.$emit('hide');
return expectValidationMessageToBe('hidden');
await expectValidationMessageToBe('hidden');
});
});
describe('when the user has interacted with the component and the value is empty', () => {
it('displays a validation error', () => {
const tagNameInput = findTagNameInput();
it('displays a validation error', async () => {
findTagNameDropdown().vm.$emit('input', '');
findTagNameDropdown().vm.$emit('hide');
tagNameInput.setValue('');
tagNameInput.trigger('blur');
return expectValidationMessageToBe('shown');
await expectValidationMessageToBe('shown');
});
});
});
......@@ -131,14 +172,14 @@ describe('releases/components/tag_field_new', () => {
});
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';
findCreateFromDropdown().vm.$emit('input', updatedCreateFrom);
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
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