diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-01 12:08:00 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-04-01 12:08:00 +0000 |
commit | 1a0d6dbdc2ac3047f4953a359ef27ba6e26074ae (patch) | |
tree | ddb78a8a0d1350dc767f049a21e0f7d37edaa82c /app | |
parent | b11f7057d067885619ee3e513751f180b2e8ad85 (diff) | |
download | gitlab-ce-1a0d6dbdc2ac3047f4953a359ef27ba6e26074ae.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
18 files changed, 354 insertions, 27 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 14381f63e4b..797eaf629bf 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -41,6 +41,8 @@ const Api = { createBranchPath: '/api/:version/projects/:id/repository/branches', releasesPath: '/api/:version/projects/:id/releases', releasePath: '/api/:version/projects/:id/releases/:tag_name', + releaseLinksPath: '/api/:version/projects/:id/releases/:tag_name/assets/links', + releaseLinkPath: '/api/:version/projects/:id/releases/:tag_name/assets/links/:link_id', mergeRequestsPipeline: '/api/:version/projects/:id/merge_requests/:merge_request_iid/pipelines', adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', @@ -460,6 +462,23 @@ const Api = { return axios.put(url, release); }, + createReleaseLink(projectPath, tagName, link) { + const url = Api.buildUrl(this.releaseLinksPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':tag_name', encodeURIComponent(tagName)); + + return axios.post(url, link); + }, + + deleteReleaseLink(projectPath, tagName, linkId) { + const url = Api.buildUrl(this.releaseLinkPath) + .replace(':id', encodeURIComponent(projectPath)) + .replace(':tag_name', encodeURIComponent(tagName)) + .replace(':link_id', encodeURIComponent(linkId)); + + return axios.delete(url); + }, + adminStatistics() { const url = Api.buildUrl(this.adminStatisticsPath); return axios.get(url); diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit.vue index 6f4baaa5d74..4fa0e96217a 100644 --- a/app/assets/javascripts/releases/components/app_edit.vue +++ b/app/assets/javascripts/releases/components/app_edit.vue @@ -7,6 +7,8 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; +import AssetLinksForm from './asset_links_form.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'ReleaseEditApp', @@ -16,10 +18,12 @@ export default { GlButton, GlLink, MarkdownField, + AssetLinksForm, }, directives: { autofocusonshow, }, + mixins: [glFeatureFlagsMixin()], computed: { ...mapState('detail', [ 'isFetchingRelease', @@ -80,6 +84,9 @@ export default { cancelPath() { return getParameterByName(BACK_URL_PARAM) || this.releasesPagePath; }, + showAssetLinksForm() { + return this.glFeatures.releaseAssetLinkEditing; + }, }, created() { this.fetchRelease(); @@ -153,6 +160,8 @@ export default { </div> </gl-form-group> + <asset-links-form v-if="showAssetLinksForm" /> + <div class="d-flex pt-3"> <gl-button class="mr-auto js-submit-button" diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue new file mode 100644 index 00000000000..f4c92477775 --- /dev/null +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -0,0 +1,124 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { + GlSprintf, + GlLink, + GlFormGroup, + GlButton, + GlIcon, + GlTooltipDirective, + GlFormInput, +} from '@gitlab/ui'; + +export default { + name: 'AssetLinksForm', + components: { GlSprintf, GlLink, GlFormGroup, GlButton, GlIcon, GlFormInput }, + directives: { GlTooltip: GlTooltipDirective }, + computed: { + ...mapState('detail', ['release', 'releaseAssetsDocsPath']), + }, + created() { + this.addEmptyAssetLink(); + }, + methods: { + ...mapActions('detail', [ + 'addEmptyAssetLink', + 'updateAssetLinkUrl', + 'updateAssetLinkName', + 'removeAssetLink', + ]), + onAddAnotherClicked() { + this.addEmptyAssetLink(); + }, + onRemoveClicked(linkId) { + this.removeAssetLink(linkId); + }, + onUrlInput(linkIdToUpdate, newUrl) { + this.updateAssetLinkUrl({ linkIdToUpdate, newUrl }); + }, + onLinkTitleInput(linkIdToUpdate, newName) { + this.updateAssetLinkName({ linkIdToUpdate, newName }); + }, + }, +}; +</script> + +<template> + <div class="d-flex flex-column release-assets-links-form"> + <h2 class="text-4">{{ __('Release assets') }}</h2> + <p class="m-0"> + <gl-sprintf + :message=" + __( + 'Add %{linkStart}assets%{linkEnd} to your Release. GitLab automatically includes read-only assets, like source code and release evidence.', + ) + " + > + <template #link="{ content }"> + <gl-link + :href="releaseAssetsDocsPath" + target="_blank" + :aria-label="__('Release assets documentation')" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + <h3 class="text-3">{{ __('Links') }}</h3> + <p> + {{ + __( + 'Point to any links you like: documentation, built binaries, or other related materials. These can be internal or external links from your GitLab instance.', + ) + }} + </p> + <div + v-for="(link, index) in release.assets.links" + :key="link.id" + class="d-flex flex-column flex-sm-row align-items-stretch align-items-sm-end" + > + <gl-form-group + class="url-field form-group flex-grow-1 mr-sm-4" + :label="__('URL')" + :label-for="`asset-url-${index}`" + > + <gl-form-input + :id="`asset-url-${index}`" + :value="link.url" + type="text" + class="form-control" + @change="onUrlInput(link.id, $event)" + /> + </gl-form-group> + + <gl-form-group + class="link-title-field flex-grow-1 mr-sm-4" + :label="__('Link title')" + :label-for="`asset-link-name-${index}`" + > + <gl-form-input + :id="`asset-link-name-${index}`" + :value="link.name" + type="text" + class="form-control" + @change="onLinkTitleInput(link.id, $event)" + /> + </gl-form-group> + + <gl-button + v-gl-tooltip + class="mb-5 mb-sm-3 flex-grow-0 flex-shrink-0 remove-button" + :aria-label="__('Remove asset link')" + :title="__('Remove asset link')" + @click="onRemoveClicked(link.id)" + > + <gl-icon class="m-0" name="remove" /> + <span class="d-inline d-sm-none">{{ __('Remove asset link') }}</span> + </gl-button> + </div> + <gl-button variant="link" class="align-self-end mb-5 mb-sm-0" @click="onAddAnotherClicked"> + {{ __('Add another link') }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 1b77f01368e..7b84c18242c 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -41,20 +41,74 @@ export const receiveUpdateReleaseError = ({ commit }, error) => { createFlash(s__('Release|Something went wrong while saving the release details')); }; -export const updateRelease = ({ dispatch, state }) => { +export const updateRelease = ({ dispatch, state, getters }) => { dispatch('requestUpdateRelease'); - return api - .updateRelease(state.projectId, state.tagName, { - name: state.release.name, - description: state.release.description, - }) - .then(() => dispatch('receiveUpdateReleaseSuccess')) - .catch(error => { - dispatch('receiveUpdateReleaseError', error); - }); + const { release } = state; + + return ( + api + .updateRelease(state.projectId, state.tagName, { + name: release.name, + description: release.description, + }) + + /** + * Currently, we delete all existing links and then + * recreate new ones on each edit. This is because the + * REST API doesn't support bulk updating of Release links, + * and updating individual links can lead to validation + * race conditions (in particular, the "URLs must be unique") + * constraint. + * + * This isn't ideal since this is no longer an atomic + * operation - parts of it can fail while others succeed, + * leaving the Release in an inconsistent state. + * + * This logic should be refactored to use GraphQL once + * https://gitlab.com/gitlab-org/gitlab/-/issues/208702 + * is closed. + */ + + .then(() => { + // Delete all links currently associated with this Release + return Promise.all( + getters.releaseLinksToDelete.map(l => + api.deleteReleaseLink(state.projectId, release.tagName, l.id), + ), + ); + }) + .then(() => { + // Create a new link for each link in the form + return Promise.all( + getters.releaseLinksToCreate.map(l => + api.createReleaseLink(state.projectId, release.tagName, l), + ), + ); + }) + .then(() => dispatch('receiveUpdateReleaseSuccess')) + .catch(error => { + dispatch('receiveUpdateReleaseError', error); + }) + ); }; export const navigateToReleasesPage = ({ state }) => { redirectTo(state.releasesPagePath); }; + +export const addEmptyAssetLink = ({ commit }) => { + commit(types.ADD_EMPTY_ASSET_LINK); +}; + +export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => { + commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl }); +}; + +export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => { + commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName }); +}; + +export const removeAssetLink = ({ commit }, linkIdToRemove) => { + commit(types.REMOVE_ASSET_LINK, linkIdToRemove); +}; diff --git a/app/assets/javascripts/releases/stores/modules/detail/getters.js b/app/assets/javascripts/releases/stores/modules/detail/getters.js new file mode 100644 index 00000000000..562284dc48d --- /dev/null +++ b/app/assets/javascripts/releases/stores/modules/detail/getters.js @@ -0,0 +1,24 @@ +/** + * @returns {Boolean} `true` if the release link is empty, i.e. it has + * empty (or whitespace-only) values for both `url` and `name`. + * Otherwise, `false`. + */ +const isEmptyReleaseLink = l => !/\S/.test(l.url) && !/\S/.test(l.name); + +/** Returns all release links that aren't empty */ +export const releaseLinksToCreate = state => { + if (!state.release) { + return []; + } + + return state.release.assets.links.filter(l => !isEmptyReleaseLink(l)); +}; + +/** Returns all release links that should be deleted */ +export const releaseLinksToDelete = state => { + if (!state.originalRelease) { + return []; + } + + return state.originalRelease.assets.links; +}; diff --git a/app/assets/javascripts/releases/stores/modules/detail/index.js b/app/assets/javascripts/releases/stores/modules/detail/index.js index b4430cff2ab..40fdb04f2eb 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/index.js +++ b/app/assets/javascripts/releases/stores/modules/detail/index.js @@ -1,10 +1,12 @@ import * as actions from './actions'; +import * as getters from './getters'; import mutations from './mutations'; import createState from './state'; export default initialState => ({ namespaced: true, actions, + getters, mutations, state: createState(initialState), }); diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js index 51c0590012a..04944b76e42 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -8,3 +8,8 @@ export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const REQUEST_UPDATE_RELEASE = 'REQUEST_UPDATE_RELEASE'; export const RECEIVE_UPDATE_RELEASE_SUCCESS = 'RECEIVE_UPDATE_RELEASE_SUCCESS'; export const RECEIVE_UPDATE_RELEASE_ERROR = 'RECEIVE_UPDATE_RELEASE_ERROR'; + +export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK'; +export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL'; +export const UPDATE_ASSET_LINK_NAME = 'UPDATE_ASSET_LINK_NAME'; +export const REMOVE_ASSET_LINK = 'REMOVE_ASSET_LINK'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index 913db6c2b2a..3d97e3a75c2 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -1,4 +1,9 @@ import * as types from './mutation_types'; +import { uniqueId, cloneDeep } from 'lodash'; + +const findReleaseLink = (release, id) => { + return release.assets.links.find(l => l.id === id); +}; export default { [types.REQUEST_RELEASE](state) { @@ -8,6 +13,7 @@ export default { state.fetchError = undefined; state.isFetchingRelease = false; state.release = data; + state.originalRelease = Object.freeze(cloneDeep(state.release)); }, [types.RECEIVE_RELEASE_ERROR](state, error) { state.fetchError = error; @@ -33,4 +39,26 @@ export default { state.updateError = error; state.isUpdatingRelease = false; }, + + [types.ADD_EMPTY_ASSET_LINK](state) { + state.release.assets.links.push({ + id: uniqueId('new-link-'), + url: '', + name: '', + }); + }, + + [types.UPDATE_ASSET_LINK_URL](state, { linkIdToUpdate, newUrl }) { + const linkToUpdate = findReleaseLink(state.release, linkIdToUpdate); + linkToUpdate.url = newUrl; + }, + + [types.UPDATE_ASSET_LINK_NAME](state, { linkIdToUpdate, newName }) { + const linkToUpdate = findReleaseLink(state.release, linkIdToUpdate); + linkToUpdate.name = newName; + }, + + [types.REMOVE_ASSET_LINK](state, linkIdToRemove) { + state.release.assets.links = state.release.assets.links.filter(l => l.id !== linkIdToRemove); + }, }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index a19e8d044e2..b513e1bed79 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -5,6 +5,7 @@ export default ({ markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, + releaseAssetsDocsPath, }) => ({ projectId, tagName, @@ -12,9 +13,18 @@ export default ({ markdownDocsPath, markdownPreviewPath, updateReleaseApiDocsPath, + releaseAssetsDocsPath, + /** The Release object */ release: null, + /** + * A deep clone of the Release object above. + * Used when editing this Release so that + * changes can be computed. + */ + originalRelease: null, + isFetchingRelease: false, fetchError: null, diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index ffa3f2c3364..989013df8d4 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -8,6 +8,7 @@ module Groups before_action :authorize_update_max_artifacts_size!, only: [:update] before_action do push_frontend_feature_flag(:new_variables_ui, @group) + push_frontend_feature_flag(:ajax_new_deploy_token, @group) end before_action :define_variables, only: [:show, :create_deploy_token] @@ -42,13 +43,30 @@ module Groups end def create_deploy_token - @new_deploy_token = Groups::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute - - if @new_deploy_token.persisted? - flash.now[:notice] = s_('DeployTokens|Your new group deploy token has been created.') + result = Projects::DeployTokens::CreateService.new(@group, current_user, deploy_token_params).execute + @new_deploy_token = result[:deploy_token] + + if result[:status] == :success + respond_to do |format| + format.json do + # IMPORTANT: It's a security risk to expose the token value more than just once here! + json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json + render json: json, status: result[:http_status] + end + format.html do + flash.now[:notice] = s_('DeployTokens|Your new group deploy token has been created.') + render :show + end + end + else + respond_to do |format| + format.json { render json: { message: result[:message] }, status: result[:http_status] } + format.html do + flash.now[:alert] = result[:message] + render :show + end + end end - - render 'show' end private diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index fc60f42095c..010e9411c7d 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -9,6 +9,7 @@ class Projects::ReleasesController < Projects::ApplicationController push_frontend_feature_flag(:release_issue_summary, project, default_enabled: true) push_frontend_feature_flag(:release_evidence_collection, project, default_enabled: true) push_frontend_feature_flag(:release_show_page, project, default_enabled: true) + push_frontend_feature_flag(:release_asset_link_editing, project) end before_action :authorize_update_release!, only: %i[edit update] diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index aac6ecb07e4..5feb3e019c2 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -7,6 +7,7 @@ module Projects before_action :define_variables before_action do push_frontend_feature_flag(:new_variables_ui, @project) + push_frontend_feature_flag(:ajax_new_deploy_token, @project) end def show @@ -47,13 +48,30 @@ module Projects end def create_deploy_token - @new_deploy_token = Projects::DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute + result = Projects::DeployTokens::CreateService.new(@project, current_user, deploy_token_params).execute + @new_deploy_token = result[:deploy_token] - if @new_deploy_token.persisted? - flash.now[:notice] = s_('DeployTokens|Your new project deploy token has been created.') + if result[:status] == :success + respond_to do |format| + format.json do + # IMPORTANT: It's a security risk to expose the token value more than just once here! + json = API::Entities::DeployTokenWithToken.represent(@new_deploy_token).as_json + render json: json, status: result[:http_status] + end + format.html do + flash.now[:notice] = s_('DeployTokens|Your new project deploy token has been created.') + render :show + end + end + else + respond_to do |format| + format.json { render json: { message: result[:message] }, status: result[:http_status] } + format.html do + flash.now[:alert] = result[:message] + render :show + end + end end - - render 'show' end private diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 410ad645cd9..d5e6c4783c1 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -14,6 +14,7 @@ # milestone_title: string # release_tag: string # author_id: integer +# author_username: string # assignee_id: integer # search: string # in: 'title', 'description', or a string joining them with comma diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index 6fbef800faa..af51427dc91 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -8,8 +8,8 @@ module ReleasesHelper image_path(IMAGE_PATH) end - def help_page - help_page_path(DOCUMENTATION_PATH) + def help_page(anchor: nil) + help_page_path(DOCUMENTATION_PATH, anchor: anchor) end def data_for_releases_page @@ -29,7 +29,8 @@ module ReleasesHelper markdown_preview_path: preview_markdown_path(@project), markdown_docs_path: help_page_path('user/markdown'), releases_page_path: project_releases_path(@project, anchor: @release.tag), - update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release') + update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'), + release_assets_docs_path: help_page(anchor: 'release-assets') } end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 99aeca17699..e694963eac0 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -318,6 +318,7 @@ class ProjectPolicy < BasePolicy enable :read_pod_logs enable :destroy_deploy_token enable :read_prometheus_alerts + enable :admin_terraform_state end rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror diff --git a/app/services/groups/deploy_tokens/create_service.rb b/app/services/groups/deploy_tokens/create_service.rb index 8c42b56ebb0..d747dabcd3c 100644 --- a/app/services/groups/deploy_tokens/create_service.rb +++ b/app/services/groups/deploy_tokens/create_service.rb @@ -6,7 +6,13 @@ module Groups include DeployTokenMethods def execute - create_deploy_token_for(@group, params) + deploy_token = create_deploy_token_for(@group, params) + + if deploy_token.persisted? + success(deploy_token: deploy_token, http_status: :ok) + else + error(deploy_token.errors.full_messages.to_sentence, :bad_request) + end end end end diff --git a/app/services/projects/deploy_tokens/create_service.rb b/app/services/projects/deploy_tokens/create_service.rb index 51cb68dfb10..e943b2489ac 100644 --- a/app/services/projects/deploy_tokens/create_service.rb +++ b/app/services/projects/deploy_tokens/create_service.rb @@ -6,7 +6,13 @@ module Projects include DeployTokenMethods def execute - create_deploy_token_for(@project, params) + deploy_token = create_deploy_token_for(@project, params) + + if deploy_token.persisted? + success(deploy_token: deploy_token, http_status: :ok) + else + error(deploy_token.errors.full_messages.to_sentence, :bad_request) + end end end end diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 99e259ba944..c4e82d8e157 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -1,7 +1,7 @@ %p.profile-settings-content = s_("DeployTokens|Pick a name for the application, and we'll give you a unique deploy token.") -= form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post do |f| += form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: Feature.enabled?(:ajax_new_deploy_token, group_or_project) do |f| = form_errors(token) .form-group |