diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-20 18:42:06 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-08-20 18:42:06 +0000 |
commit | 6e4e1050d9dba2b7b2523fdd1768823ab85feef4 (patch) | |
tree | 78be5963ec075d80116a932011d695dd33910b4e /app | |
parent | 1ce776de4ae122aba3f349c02c17cebeaa8ecf07 (diff) | |
download | gitlab-ce-6e4e1050d9dba2b7b2523fdd1768823ab85feef4.tar.gz |
Add latest changes from gitlab-org/gitlab@13-3-stable-ee
Diffstat (limited to 'app')
1855 files changed, 23844 insertions, 10272 deletions
diff --git a/app/assets/images/mailers/approval/icon-merge-request-gray.gif b/app/assets/images/mailers/approval/icon-merge-request-gray.gif Binary files differnew file mode 100644 index 00000000000..6eef39d3b1e --- /dev/null +++ b/app/assets/images/mailers/approval/icon-merge-request-gray.gif diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue new file mode 100644 index 00000000000..78a575ffe96 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_trigger.vue @@ -0,0 +1,49 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; + +export default { + components: { + GlButton, + }, + props: { + commitsEmpty: { + type: Boolean, + required: false, + default: false, + }, + contextCommitsEmpty: { + type: Boolean, + required: true, + }, + }, + computed: { + buttonText() { + return this.contextCommitsEmpty || this.commitsEmpty + ? s__('AddContextCommits|Add previously merged commits') + : s__('AddContextCommits|Add/remove'); + }, + }, + methods: { + openModal() { + eventHub.$emit('openModal'); + }, + }, +}; +</script> + +<template> + <gl-button + :class="[ + { + 'ml-3': !contextCommitsEmpty, + 'mt-3': !commitsEmpty && contextCommitsEmpty, + }, + ]" + :variant="commitsEmpty ? 'info' : 'default'" + @click="openModal" + > + {{ buttonText }} + </gl-button> +</template> diff --git a/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue new file mode 100644 index 00000000000..cb9aa50fa68 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue @@ -0,0 +1,279 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlModal, GlTabs, GlTab, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; +import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; +import { s__ } from '~/locale'; +import eventHub from '../event_hub'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { + findCommitIndex, + setCommitStatus, + removeIfReadyToBeRemoved, + removeIfPresent, +} from '../utils'; + +export default { + components: { + GlModal, + GlTabs, + GlTab, + ReviewTabContainer, + GlSearchBoxByType, + GlSprintf, + }, + props: { + contextCommitsPath: { + type: String, + required: true, + }, + targetBranch: { + type: String, + required: true, + }, + mergeRequestIid: { + type: Number, + required: true, + }, + projectId: { + type: Number, + required: true, + }, + }, + computed: { + ...mapState([ + 'tabIndex', + 'isLoadingCommits', + 'commits', + 'commitsLoadingError', + 'isLoadingContextCommits', + 'contextCommits', + 'contextCommitsLoadingError', + 'selectedCommits', + 'searchText', + 'toRemoveCommits', + ]), + currentTabIndex: { + get() { + return this.tabIndex; + }, + set(newTabIndex) { + this.setTabIndex(newTabIndex); + }, + }, + selectedCommitsCount() { + return this.selectedCommits.filter(selectedCommit => selectedCommit.isSelected).length; + }, + shouldPurge() { + return this.selectedCommitsCount !== this.selectedCommits.length; + }, + uniqueCommits() { + return this.selectedCommits.filter( + selectedCommit => + selectedCommit.isSelected && + findCommitIndex(this.contextCommits, selectedCommit.short_id) === -1, + ); + }, + disableSaveButton() { + // We should have a minimum of one commit selected and that should not be in the context commits list or we should have a context commit to delete + return ( + (this.selectedCommitsCount.length === 0 || this.uniqueCommits.length === 0) && + this.toRemoveCommits.length === 0 + ); + }, + }, + watch: { + tabIndex(newTabIndex) { + this.handleTabChange(newTabIndex); + }, + }, + mounted() { + eventHub.$on('openModal', this.openModal); + this.setBaseConfig({ + contextCommitsPath: this.contextCommitsPath, + mergeRequestIid: this.mergeRequestIid, + projectId: this.projectId, + }); + }, + beforeDestroy() { + eventHub.$off('openModal', this.openModal); + clearTimeout(this.timeout); + this.timeout = null; + }, + methods: { + ...mapActions([ + 'setBaseConfig', + 'setTabIndex', + 'searchCommits', + 'setCommits', + 'createContextCommits', + 'fetchContextCommits', + 'removeContextCommits', + 'setSelectedCommits', + 'setSearchText', + 'setToRemoveCommits', + 'resetModalState', + ]), + focusSearch() { + this.$refs.searchInput.focusInput(); + }, + openModal() { + this.searchCommits(); + this.fetchContextCommits(); + this.$root.$emit('bv::show::modal', 'add-review-item'); + }, + handleTabChange(tabIndex) { + if (tabIndex === 0) { + this.focusSearch(); + if (this.shouldPurge) { + this.setSelectedCommits( + [...this.commits, ...this.selectedCommits].filter(commit => commit.isSelected), + ); + } + } + }, + handleSearchCommits(value) { + // We only call the service, if we have 3 characters or we don't have any characters + if (value.length >= 3) { + clearTimeout(this.timeout); + this.timeout = setTimeout(() => { + this.searchCommits(value); + }, 500); + } else if (value.length === 0) { + this.searchCommits(); + } + this.setSearchText(value); + }, + handleCommitRowSelect(event) { + const index = event[0]; + const selected = event[1]; + const tempCommit = this.tabIndex === 0 ? this.commits[index] : this.selectedCommits[index]; + const commitIndex = findCommitIndex(this.commits, tempCommit.short_id); + const tempCommits = setCommitStatus(this.commits, commitIndex, selected); + const selectedCommitIndex = findCommitIndex(this.selectedCommits, tempCommit.short_id); + let tempSelectedCommits = setCommitStatus( + this.selectedCommits, + selectedCommitIndex, + selected, + ); + + if (selected) { + // If user deselects a commit which is already present in previously merged commits, then user adds it again. + // Then the state is neutral, so we remove it from the list + this.setToRemoveCommits( + removeIfReadyToBeRemoved(this.toRemoveCommits, tempCommit.short_id), + ); + } else { + // If user is present in first tab and deselects a commit, remove it directly + if (this.tabIndex === 0) { + tempSelectedCommits = removeIfPresent(tempSelectedCommits, tempCommit.short_id); + } + + // If user deselects a commit which is already present in previously merged commits, we keep track of it in a list to remove + const contextCommitsIndex = findCommitIndex(this.contextCommits, tempCommit.short_id); + if (contextCommitsIndex !== -1) { + this.setToRemoveCommits([...this.toRemoveCommits, tempCommit.short_id]); + } + } + + this.setCommits({ commits: tempCommits }); + this.setSelectedCommits([ + ...tempSelectedCommits, + ...tempCommits.filter(commit => commit.isSelected), + ]); + }, + handleCreateContextCommits() { + if (this.uniqueCommits.length > 0 && this.toRemoveCommits.length > 0) { + return Promise.all([ + this.createContextCommits({ commits: this.uniqueCommits }), + this.removeContextCommits(), + ]).then(values => { + if (values[0] || values[1]) { + window.location.reload(); + } + if (!values[0] && !values[1]) { + createFlash( + s__('ContextCommits|Failed to create/remove context commits. Please try again.'), + ); + } + }); + } else if (this.uniqueCommits.length > 0) { + return this.createContextCommits({ commits: this.uniqueCommits, forceReload: true }); + } + + return this.removeContextCommits(true); + }, + handleModalClose() { + this.resetModalState(); + clearTimeout(this.timeout); + }, + handleModalHide() { + this.resetModalState(); + clearTimeout(this.timeout); + }, + }, +}; +</script> + +<template> + <gl-modal + ref="modal" + cancel-variant="light" + size="md" + body-class="add-review-item pt-0" + :scrollable="true" + :ok-title="__('Save changes')" + modal-id="add-review-item" + :title="__('Add or remove previously merged commits')" + :ok-disabled="disableSaveButton" + @shown="focusSearch" + @ok="handleCreateContextCommits" + @cancel="handleModalClose" + @close="handleModalClose" + @hide="handleModalHide" + > + <gl-tabs v-model="currentTabIndex" content-class="pt-0"> + <gl-tab> + <template #title> + <gl-sprintf :message="__(`Commits in %{codeStart}${targetBranch}%{codeEnd}`)"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </template> + <div class="mt-2"> + <gl-search-box-by-type + ref="searchInput" + :placeholder="__(`Search by commit title or SHA`)" + @input="handleSearchCommits" + /> + <review-tab-container + :is-loading="isLoadingCommits" + :loading-error="commitsLoadingError" + :loading-failed-text="__('Unable to load commits. Try again later.')" + :commits="commits" + :empty-list-text="__('Your search didn\'t match any commits. Try a different query.')" + @handleCommitSelect="handleCommitRowSelect" + /> + </div> + </gl-tab> + <gl-tab> + <template #title> + {{ __('Selected commits') }} + <span class="badge badge-pill">{{ selectedCommitsCount }}</span> + </template> + <review-tab-container + :is-loading="isLoadingContextCommits" + :loading-error="contextCommitsLoadingError" + :loading-failed-text="__('Unable to load commits. Try again later.')" + :commits="selectedCommits" + :empty-list-text=" + __( + 'Commits you select appear here. Go to the first tab and select commits to add to this merge request.', + ) + " + @handleCommitSelect="handleCommitRowSelect" + /> + </gl-tab> + </gl-tabs> + </gl-modal> +</template> diff --git a/app/assets/javascripts/add_context_commits_modal/components/review_tab_container.vue b/app/assets/javascripts/add_context_commits_modal/components/review_tab_container.vue new file mode 100644 index 00000000000..36e3449ff27 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/components/review_tab_container.vue @@ -0,0 +1,57 @@ +<script> +import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; +import CommitItem from '~/diffs/components/commit_item.vue'; +import { __ } from '~/locale'; + +export default { + components: { + GlLoadingIcon, + GlAlert, + CommitItem, + }, + props: { + isLoading: { + type: Boolean, + required: true, + }, + loadingError: { + type: Boolean, + required: true, + }, + loadingFailedText: { + type: String, + required: true, + }, + commits: { + type: Array, + required: true, + }, + emptyListText: { + type: String, + required: false, + default: __('No commits present here'), + }, + }, +}; +</script> +<template> + <gl-loading-icon v-if="isLoading" size="lg" class="mt-3" /> + <gl-alert v-else-if="loadingError" variant="danger" :dismissible="false" class="mt-3"> + {{ loadingFailedText }} + </gl-alert> + <div v-else-if="commits.length === 0" class="text-center mt-4"> + <span>{{ emptyListText }}</span> + </div> + <div v-else> + <ul class="content-list commit-list flex-list"> + <commit-item + v-for="(commit, index) in commits" + :key="commit.id" + :is-selectable="true" + :commit="commit" + :checked="commit.isSelected" + @handleCheckboxChange="$emit('handleCommitSelect', [index, $event])" + /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/import_projects/event_hub.js b/app/assets/javascripts/add_context_commits_modal/event_hub.js index e31806ad199..e31806ad199 100644 --- a/app/assets/javascripts/import_projects/event_hub.js +++ b/app/assets/javascripts/add_context_commits_modal/event_hub.js diff --git a/app/assets/javascripts/add_context_commits_modal/index.js b/app/assets/javascripts/add_context_commits_modal/index.js new file mode 100644 index 00000000000..b5cd111fabc --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/index.js @@ -0,0 +1,64 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createStore from './store'; +import AddContextCommitsModalTrigger from './components/add_context_commits_modal_trigger.vue'; +import AddContextCommitsModalWrapper from './components/add_context_commits_modal_wrapper.vue'; + +export default function initAddContextCommitsTriggers() { + const addContextCommitsModalTriggerEl = document.querySelector('.add-review-item-modal-trigger'); + const addContextCommitsModalWrapperEl = document.querySelector('.add-review-item-modal-wrapper'); + + if (addContextCommitsModalTriggerEl || addContextCommitsModalWrapperEl) { + // eslint-disable-next-line no-new + new Vue({ + el: addContextCommitsModalTriggerEl, + data() { + const { commitsEmpty, contextCommitsEmpty } = this.$options.el.dataset; + return { + commitsEmpty: parseBoolean(commitsEmpty), + contextCommitsEmpty: parseBoolean(contextCommitsEmpty), + }; + }, + render(createElement) { + return createElement(AddContextCommitsModalTrigger, { + props: { + commitsEmpty: this.commitsEmpty, + contextCommitsEmpty: this.contextCommitsEmpty, + }, + }); + }, + }); + + const store = createStore(); + + // eslint-disable-next-line no-new + new Vue({ + el: addContextCommitsModalWrapperEl, + store, + data() { + const { + contextCommitsPath, + targetBranch, + mergeRequestIid, + projectId, + } = this.$options.el.dataset; + return { + contextCommitsPath, + targetBranch, + mergeRequestIid: Number(mergeRequestIid), + projectId: Number(projectId), + }; + }, + render(createElement) { + return createElement(AddContextCommitsModalWrapper, { + props: { + contextCommitsPath: this.contextCommitsPath, + targetBranch: this.targetBranch, + mergeRequestIid: this.mergeRequestIid, + projectId: this.projectId, + }, + }); + }, + }); + } +} diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js new file mode 100644 index 00000000000..d23955182b2 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -0,0 +1,134 @@ +import _ from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { s__ } from '~/locale'; +import Api from '~/api'; +import * as types from './mutation_types'; + +export const setBaseConfig = ({ commit }, options) => { + commit(types.SET_BASE_CONFIG, options); +}; + +export const setTabIndex = ({ commit }, tabIndex) => commit(types.SET_TABINDEX, tabIndex); + +export const searchCommits = ({ dispatch, commit, state }, searchText) => { + commit(types.FETCH_COMMITS); + + let params = {}; + if (searchText) { + params = { + params: { + search: searchText, + per_page: 40, + }, + }; + } + + return axios + .get(state.contextCommitsPath, params) + .then(({ data }) => { + let commits = data.map(o => ({ ...o, isSelected: false })); + commits = commits.map(c => { + const isPresent = state.selectedCommits.find( + selectedCommit => selectedCommit.short_id === c.short_id && selectedCommit.isSelected, + ); + if (isPresent) { + return { ...c, isSelected: true }; + } + return c; + }); + if (!searchText) { + dispatch('setCommits', { commits: [...commits, ...state.contextCommits] }); + } else { + dispatch('setCommits', { commits }); + } + }) + .catch(() => { + commit(types.FETCH_COMMITS_ERROR); + }); +}; + +export const setCommits = ({ commit }, { commits: data, silentAddition = false }) => { + let commits = _.uniqBy(data, 'short_id'); + commits = _.orderBy(data, c => new Date(c.committed_date), ['desc']); + if (silentAddition) { + commit(types.SET_COMMITS_SILENT, commits); + } else { + commit(types.SET_COMMITS, commits); + } +}; + +export const createContextCommits = ({ state }, { commits, forceReload = false }) => + Api.createContextCommits(state.projectId, state.mergeRequestIid, { + commits: commits.map(commit => commit.short_id), + }) + .then(() => { + if (forceReload) { + window.location.reload(); + } + + return true; + }) + .catch(() => { + if (forceReload) { + createFlash(s__('ContextCommits|Failed to create context commits. Please try again.')); + } + + return false; + }); + +export const fetchContextCommits = ({ dispatch, commit, state }) => { + commit(types.FETCH_CONTEXT_COMMITS); + return Api.allContextCommits(state.projectId, state.mergeRequestIid) + .then(({ data }) => { + const contextCommits = data.map(o => ({ ...o, isSelected: true })); + dispatch('setContextCommits', contextCommits); + dispatch('setCommits', { + commits: [...state.commits, ...contextCommits], + silentAddition: true, + }); + dispatch('setSelectedCommits', contextCommits); + }) + .catch(() => { + commit(types.FETCH_CONTEXT_COMMITS_ERROR); + }); +}; + +export const setContextCommits = ({ commit }, data) => { + commit(types.SET_CONTEXT_COMMITS, data); +}; + +export const removeContextCommits = ({ state }, forceReload = false) => + Api.removeContextCommits(state.projectId, state.mergeRequestIid, { + commits: state.toRemoveCommits, + }) + .then(() => { + if (forceReload) { + window.location.reload(); + } + + return true; + }) + .catch(() => { + if (forceReload) { + createFlash(s__('ContextCommits|Failed to delete context commits. Please try again.')); + } + + return false; + }); + +export const setSelectedCommits = ({ commit }, selected) => { + let selectedCommits = _.uniqBy(selected, 'short_id'); + selectedCommits = _.orderBy( + selectedCommits, + selectedCommit => new Date(selectedCommit.committed_date), + ['desc'], + ); + commit(types.SET_SELECTED_COMMITS, selectedCommits); +}; + +export const setSearchText = ({ commit }, searchText) => commit(types.SET_SEARCH_TEXT, searchText); + +export const setToRemoveCommits = ({ commit }, data) => commit(types.SET_TO_REMOVE_COMMITS, data); + +export const resetModalState = ({ commit }) => commit(types.RESET_MODAL_STATE); diff --git a/app/assets/javascripts/add_context_commits_modal/store/index.js b/app/assets/javascripts/add_context_commits_modal/store/index.js new file mode 100644 index 00000000000..0bf3441379b --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/store/index.js @@ -0,0 +1,15 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + namespaced: true, + state: state(), + actions, + mutations, + }); diff --git a/app/assets/javascripts/add_context_commits_modal/store/mutation_types.js b/app/assets/javascripts/add_context_commits_modal/store/mutation_types.js new file mode 100644 index 00000000000..eda82f3984d --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/store/mutation_types.js @@ -0,0 +1,20 @@ +export const SET_BASE_CONFIG = 'SET_BASE_CONFIG'; + +export const SET_TABINDEX = 'SET_TABINDEX'; + +export const FETCH_COMMITS = 'FETCH_COMMITS'; +export const SET_COMMITS = 'SET_COMMITS'; +export const SET_COMMITS_SILENT = 'SET_COMMITS_SILENT'; +export const FETCH_COMMITS_ERROR = 'FETCH_COMMITS_ERROR'; + +export const FETCH_CONTEXT_COMMITS = 'FETCH_CONTEXT_COMMITS'; +export const SET_CONTEXT_COMMITS = 'SET_CONTEXT_COMMITS'; +export const FETCH_CONTEXT_COMMITS_ERROR = 'FETCH_CONTEXT_COMMITS_ERROR'; + +export const SET_SELECTED_COMMITS = 'SET_SELECTED_COMMITS'; + +export const SET_SEARCH_TEXT = 'SET_SEARCH_TEXT'; + +export const SET_TO_REMOVE_COMMITS = 'SET_TO_REMOVE_COMMITS'; + +export const RESET_MODAL_STATE = 'RESET_MODAL_STATE'; diff --git a/app/assets/javascripts/add_context_commits_modal/store/mutations.js b/app/assets/javascripts/add_context_commits_modal/store/mutations.js new file mode 100644 index 00000000000..8a3da0ca248 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/store/mutations.js @@ -0,0 +1,56 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_BASE_CONFIG](state, options) { + Object.assign(state, { ...options }); + }, + [types.SET_TABINDEX](state, tabIndex) { + state.tabIndex = tabIndex; + }, + [types.FETCH_COMMITS](state) { + state.isLoadingCommits = true; + state.commitsLoadingError = false; + }, + [types.SET_COMMITS](state, commits) { + state.commits = commits; + state.isLoadingCommits = false; + state.commitsLoadingError = false; + }, + [types.SET_COMMITS_SILENT](state, commits) { + state.commits = commits; + }, + [types.FETCH_COMMITS_ERROR](state) { + state.commitsLoadingError = true; + state.isLoadingCommits = false; + }, + [types.FETCH_CONTEXT_COMMITS](state) { + state.isLoadingContextCommits = true; + state.contextCommitsLoadingError = false; + }, + [types.SET_CONTEXT_COMMITS](state, contextCommits) { + state.contextCommits = contextCommits; + state.isLoadingContextCommits = false; + state.contextCommitsLoadingError = false; + }, + [types.FETCH_CONTEXT_COMMITS_ERROR](state) { + state.contextCommitsLoadingError = true; + state.isLoadingContextCommits = false; + }, + [types.SET_SELECTED_COMMITS](state, commits) { + state.selectedCommits = commits; + }, + [types.SET_SEARCH_TEXT](state, searchText) { + state.searchText = searchText; + }, + [types.SET_TO_REMOVE_COMMITS](state, commits) { + state.toRemoveCommits = commits; + }, + [types.RESET_MODAL_STATE](state) { + state.tabIndex = 0; + state.commits = []; + state.contextCommits = []; + state.selectedCommits = []; + state.toRemoveCommits = []; + state.searchText = ''; + }, +}; diff --git a/app/assets/javascripts/add_context_commits_modal/store/state.js b/app/assets/javascripts/add_context_commits_modal/store/state.js new file mode 100644 index 00000000000..37239adccbb --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + contextCommitsPath: '', + tabIndex: 0, + isLoadingCommits: false, + commits: [], + commitsLoadingError: false, + selectedCommits: [], + isLoadingContextCommits: false, + contextCommits: [], + contextCommitsLoadingError: false, + searchText: '', + toRemoveCommits: [], +}); diff --git a/app/assets/javascripts/add_context_commits_modal/utils.js b/app/assets/javascripts/add_context_commits_modal/utils.js new file mode 100644 index 00000000000..3495ee17cd3 --- /dev/null +++ b/app/assets/javascripts/add_context_commits_modal/utils.js @@ -0,0 +1,32 @@ +export const findCommitIndex = (commits, commitShortId) => { + return commits.findIndex(commit => commit.short_id === commitShortId); +}; + +export const setCommitStatus = (commits, commitIndex, selected) => { + const tempCommits = [...commits]; + tempCommits[commitIndex] = { + ...tempCommits[commitIndex], + isSelected: selected, + }; + return tempCommits; +}; + +export const removeIfReadyToBeRemoved = (toRemoveCommits, commitShortId) => { + const tempToRemoveCommits = [...toRemoveCommits]; + const isPresentInToRemove = tempToRemoveCommits.indexOf(commitShortId); + if (isPresentInToRemove !== -1) { + tempToRemoveCommits.splice(isPresentInToRemove, 1); + } + + return tempToRemoveCommits; +}; + +export const removeIfPresent = (selectedCommits, commitShortId) => { + const tempSelectedCommits = [...selectedCommits]; + const selectedCommitsIndex = findCommitIndex(tempSelectedCommits, commitShortId); + if (selectedCommitsIndex !== -1) { + tempSelectedCommits.splice(selectedCommitsIndex, 1); + } + + return tempSelectedCommits; +}; diff --git a/app/assets/javascripts/admin/statistics_panel/store/actions.js b/app/assets/javascripts/admin/statistics_panel/store/actions.js index 537025f524c..dd04e492388 100644 --- a/app/assets/javascripts/admin/statistics_panel/store/actions.js +++ b/app/assets/javascripts/admin/statistics_panel/store/actions.js @@ -1,6 +1,6 @@ import Api from '~/api'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import * as types from './mutation_types'; @@ -23,6 +23,3 @@ export const receiveStatisticsError = ({ commit }, error) => { commit(types.RECEIVE_STATISTICS_ERROR, error); createFlash(s__('AdminDashboard|Error loading the statistics. Please try again')); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/admin/statistics_panel/store/getters.js b/app/assets/javascripts/admin/statistics_panel/store/getters.js index 24437bc76bf..2aa34b8f38e 100644 --- a/app/assets/javascripts/admin/statistics_panel/store/getters.js +++ b/app/assets/javascripts/admin/statistics_panel/store/getters.js @@ -3,6 +3,7 @@ * and returns an array of the following form: * [{ key: "forks", label: "Forks", value: 50 }] */ +// eslint-disable-next-line import/prefer-default-export export const getStatistics = state => labels => Object.keys(labels).map(key => { const result = { @@ -12,6 +13,3 @@ export const getStatistics = state => labels => }; return result; }); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/alert_management/components/alert_details.vue b/app/assets/javascripts/alert_management/components/alert_details.vue index 0731349630c..5d260fcc200 100644 --- a/app/assets/javascripts/alert_management/components/alert_details.vue +++ b/app/assets/javascripts/alert_management/components/alert_details.vue @@ -35,13 +35,24 @@ export default { errorMsg: s__( 'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.', ), - fullAlertDetailsTitle: s__('AlertManagement|Alert details'), - overviewTitle: s__('AlertManagement|Overview'), - metricsTitle: s__('AlertManagement|Metrics'), reportedAt: s__('AlertManagement|Reported %{when}'), reportedAtWithTool: s__('AlertManagement|Reported %{when} by %{tool}'), }, severityLabels: ALERTS_SEVERITY_LABELS, + tabsConfig: [ + { + id: 'overview', + title: s__('AlertManagement|Overview'), + }, + { + id: 'fullDetails', + title: s__('AlertManagement|Alert details'), + }, + { + id: 'metrics', + title: s__('AlertManagement|Metrics'), + }, + ], components: { GlBadge, GlAlert, @@ -102,8 +113,8 @@ export default { errored: false, sidebarStatus: false, isErrorDismissed: false, - createIssueError: '', - issueCreationInProgress: false, + createIncidentError: '', + incidentCreationInProgress: false, sidebarErrorMessage: '', }; }, @@ -119,6 +130,18 @@ export default { showErrorMsg() { return this.errored && !this.isErrorDismissed; }, + activeTab() { + return this.$route.params.tabId || this.$options.tabsConfig[0].id; + }, + currentTabIndex: { + get() { + return this.$options.tabsConfig.findIndex(tab => tab.id === this.activeTab); + }, + set(tabIdx) { + const tabId = this.$options.tabsConfig[tabIdx].id; + this.$router.replace({ name: 'tab', params: { tabId } }); + }, + }, }, mounted() { this.trackPageViews(); @@ -149,8 +172,8 @@ export default { this.errored = true; this.sidebarErrorMessage = errorMessage; }, - createIssue() { - this.issueCreationInProgress = true; + createIncident() { + this.incidentCreationInProgress = true; this.$apollo .mutate({ @@ -162,18 +185,18 @@ export default { }) .then(({ data: { createAlertIssue: { errors, issue } } }) => { if (errors?.length) { - [this.createIssueError] = errors; - this.issueCreationInProgress = false; + [this.createIncidentError] = errors; + this.incidentCreationInProgress = false; } else if (issue) { - visitUrl(this.issuePath(issue.iid)); + visitUrl(this.incidentPath(issue.iid)); } }) .catch(error => { - this.createIssueError = error; - this.issueCreationInProgress = false; + this.createIncidentError = error; + this.incidentCreationInProgress = false; }); }, - issuePath(issueId) { + incidentPath(issueId) { return joinPaths(this.projectIssuesPath, issueId); }, trackPageViews() { @@ -190,12 +213,12 @@ export default { <p v-html="sidebarErrorMessage || $options.i18n.errorMsg"></p> </gl-alert> <gl-alert - v-if="createIssueError" + v-if="createIncidentError" variant="danger" - data-testid="issueCreationError" - @dismiss="createIssueError = null" + data-testid="incidentCreationError" + @dismiss="createIncidentError = null" > - {{ createIssueError }} + {{ createIncidentError }} </gl-alert> <div v-if="loading"><gl-loading-icon size="lg" class="gl-mt-5" /></div> <div @@ -204,19 +227,12 @@ export default { :class="{ 'pr-sm-8': sidebarStatus }" > <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid flex-column flex-sm-row" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-px-1 py-3 py-md-4 gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-flex-direction-column gl-sm-flex-direction-row" > - <div - data-testid="alert-header" - class="gl-display-flex gl-align-items-center gl-justify-content-center" - > - <div - class="gl-display-inline-flex gl-align-items-center gl-justify-content-space-between" - > - <gl-badge class="gl-mr-3"> - <strong>{{ s__('AlertManagement|Alert') }}</strong> - </gl-badge> - </div> + <div data-testid="alert-header"> + <gl-badge class="gl-mr-3"> + <strong>{{ s__('AlertManagement|Alert') }}</strong> + </gl-badge> <span> <gl-sprintf :message="reportedAtMessage"> <template #when> @@ -228,24 +244,24 @@ export default { </div> <gl-button v-if="alert.issueIid" - class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button" - data-testid="viewIssueBtn" - :href="issuePath(alert.issueIid)" + class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button" + data-testid="viewIncidentBtn" + :href="incidentPath(alert.issueIid)" category="primary" variant="success" > - {{ s__('AlertManagement|View issue') }} + {{ s__('AlertManagement|View incident') }} </gl-button> <gl-button v-else - class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-issue-button" - data-testid="createIssueBtn" - :loading="issueCreationInProgress" + class="gl-mt-3 mt-sm-0 align-self-center align-self-sm-baseline alert-details-incident-button" + data-testid="createIncidentBtn" + :loading="incidentCreationInProgress" category="primary" variant="success" - @click="createIssue()" + @click="createIncident()" > - {{ s__('AlertManagement|Create issue') }} + {{ s__('AlertManagement|Create incident') }} </gl-button> <gl-button :aria-label="__('Toggle sidebar')" @@ -264,8 +280,8 @@ export default { > <h2 data-testid="title">{{ alert.title }}</h2> </div> - <gl-tabs v-if="alert" data-testid="alertDetailsTabs"> - <gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle"> + <gl-tabs v-if="alert" v-model="currentTabIndex" data-testid="alertDetailsTabs"> + <gl-tab :data-testid="$options.tabsConfig[0].id" :title="$options.tabsConfig[0].title"> <div v-if="alert.severity" class="gl-mt-3 gl-mb-5 gl-display-flex"> <div class="gl-font-weight-bold gl-w-13 gl-text-right gl-pr-3"> {{ s__('AlertManagement|Severity') }}: @@ -308,6 +324,12 @@ export default { </div> <div class="gl-pl-2" data-testid="service">{{ alert.service }}</div> </div> + <div v-if="alert.runbook" class="gl-my-5 gl-display-flex"> + <div class="bold gl-w-13 gl-text-right gl-pr-3"> + {{ s__('AlertManagement|Runbook') }}: + </div> + <div class="gl-pl-2" data-testid="runbook">{{ alert.runbook }}</div> + </div> <template> <div v-if="alert.notes.nodes" class="issuable-discussion py-5"> <ul class="notes main-notes-list timeline"> @@ -316,7 +338,7 @@ export default { </div> </template> </gl-tab> - <gl-tab data-testid="fullDetailsTab" :title="$options.i18n.fullAlertDetailsTitle"> + <gl-tab :data-testid="$options.tabsConfig[1].id" :title="$options.tabsConfig[1].title"> <gl-table class="alert-management-details-table" :items="[{ key: 'Value', ...alert }]" @@ -332,7 +354,7 @@ export default { </template> </gl-table> </gl-tab> - <gl-tab data-testId="metricsTab" :title="$options.i18n.metricsTitle"> + <gl-tab :data-testid="$options.tabsConfig[2].id" :title="$options.tabsConfig[2].title"> <alert-metrics :dashboard-url="alert.metricsDashboardUrl" /> </gl-tab> </gl-tabs> diff --git a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue index 13b6a8e6653..68443166f40 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_empty_state.vue @@ -1,6 +1,7 @@ <script> -import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { GlEmptyState, GlButton, GlLink } from '@gitlab/ui'; import { s__ } from '~/locale'; +import alertsHelpUrlQuery from '../graphql/queries/alert_help_url.query.graphql'; export default { i18n: { @@ -25,6 +26,12 @@ export default { components: { GlEmptyState, GlButton, + GlLink, + }, + apollo: { + alertsHelpUrl: { + query: alertsHelpUrlQuery, + }, }, props: { enableAlertManagementPath: { @@ -50,6 +57,11 @@ export default { default: '', }, }, + data() { + return { + alertsHelpUrl: '', + }; + }, computed: { emptyState() { return { @@ -71,13 +83,9 @@ export default { <template #description> <div class="gl-display-block"> <span>{{ emptyState.info }}</span> - <a - v-if="!opsgenieMvcEnabled" - href="/help/user/project/operations/alert_management.html" - target="_blank" - > + <gl-link v-if="!opsgenieMvcEnabled" :href="alertsHelpUrl" target="_blank"> {{ $options.i18n.moreInformation }} - </a> + </gl-link> </div> <div v-if="alertsCanBeEnabled" class="gl-display-block center gl-pt-4"> <gl-button category="primary" variant="success" :href="emptyState.link"> diff --git a/app/assets/javascripts/alert_management/components/alert_management_table.vue b/app/assets/javascripts/alert_management/components/alert_management_table.vue index 7dd3d7b5dc3..92fd85c6217 100644 --- a/app/assets/javascripts/alert_management/components/alert_management_table.vue +++ b/app/assets/javascripts/alert_management/components/alert_management_table.vue @@ -12,8 +12,8 @@ import { GlSearchBoxByType, GlSprintf, } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; import { debounce, trim } from 'lodash'; +import { __, s__ } from '~/locale'; import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { fetchPolicies } from '~/lib/graphql'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -60,15 +60,15 @@ export default { { key: 'severity', label: s__('AlertManagement|Severity'), - tdClass: `${tdClass} rounded-top text-capitalize`, thClass: `${thClass} gl-w-eighth`, + tdClass: `${tdClass} rounded-top text-capitalize sortable-cell`, sortable: true, }, { key: 'startedAt', label: s__('AlertManagement|Start time'), thClass: `${thClass} js-started-at w-15p`, - tdClass, + tdClass: `${tdClass} sortable-cell`, sortable: true, }, { @@ -81,7 +81,7 @@ export default { key: 'eventCount', label: s__('AlertManagement|Events'), thClass: `${thClass} text-right gl-w-12`, - tdClass: `${tdClass} text-md-right`, + tdClass: `${tdClass} text-md-right sortable-cell`, sortable: true, }, { @@ -89,7 +89,6 @@ export default { label: s__('AlertManagement|Issue'), thClass: 'gl-w-12 gl-pointer-events-none', tdClass, - sortable: false, }, { key: 'assignees', @@ -99,9 +98,9 @@ export default { }, { key: 'status', - thClass: `${thClass} w-15p`, label: s__('AlertManagement|Status'), - tdClass: `${tdClass} rounded-bottom`, + thClass: `${thClass} w-15p`, + tdClass: `${tdClass} rounded-bottom sortable-cell`, sortable: true, }, ], @@ -169,7 +168,7 @@ export default { }; }, error() { - this.errored = true; + this.hasError = true; }, }, alertsCount: { @@ -188,10 +187,9 @@ export default { data() { return { searchTerm: '', - errored: false, + hasError: false, errorMessage: '', isAlertDismissed: false, - isErrorAlertDismissed: false, sort: 'STARTED_AT_DESC', statusFilter: [], filteredByStatus: '', @@ -204,16 +202,13 @@ export default { computed: { showNoAlertsMsg() { return ( - !this.errored && + !this.hasError && !this.loading && this.alertsCount?.all === 0 && !this.searchTerm && !this.isAlertDismissed ); }, - showErrorMsg() { - return this.errored && !this.isErrorAlertDismissed; - }, loading() { return this.$apollo.queries.alerts.loading; }, @@ -307,11 +302,11 @@ export default { }; }, handleAlertError(errorMessage) { - this.errored = true; + this.hasError = true; this.errorMessage = errorMessage; }, dismissError() { - this.isErrorAlertDismissed = true; + this.hasError = false; this.errorMessage = ''; }, }, @@ -319,7 +314,7 @@ export default { </script> <template> <div> - <div class="alert-management-list"> + <div class="incident-management-list"> <gl-alert v-if="showNoAlertsMsg" @dismiss="isAlertDismissed = true"> <gl-sprintf :message="$options.i18n.noAlertsMsg"> <template #link="{ content }"> @@ -333,16 +328,14 @@ export default { </template> </gl-sprintf> </gl-alert> - <gl-alert - v-if="showErrorMsg" - variant="danger" - data-testid="alert-error" - @dismiss="dismissError" - > + <gl-alert v-if="hasError" variant="danger" data-testid="alert-error" @dismiss="dismissError"> <p v-html="errorMessage || $options.i18n.errorMsg"></p> </gl-alert> - <gl-tabs content-class="gl-p-0" @input="filterAlertsByStatus"> + <gl-tabs + content-class="gl-p-0 gl-border-b-solid gl-border-b-1 gl-border-gray-100" + @input="filterAlertsByStatus" + > <gl-tab v-for="tab in $options.statusTabs" :key="tab.status"> <template slot="title"> <span>{{ tab.title }}</span> diff --git a/app/assets/javascripts/alert_management/components/alert_status.vue b/app/assets/javascripts/alert_management/components/alert_status.vue index 9b726fe2944..8531ca1374e 100644 --- a/app/assets/javascripts/alert_management/components/alert_status.vue +++ b/app/assets/javascripts/alert_management/components/alert_status.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; import { trackAlertStatusUpdateOptions } from '../constants'; @@ -18,8 +18,8 @@ export default { RESOLVED: s__('AlertManagement|Resolved'), }, components: { - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlButton, }, props: { @@ -91,7 +91,7 @@ export default { <template> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown + <gl-deprecated-dropdown ref="dropdown" right :text="$options.statuses[alert.status]" @@ -112,7 +112,7 @@ export default { /> </div> <div class="dropdown-content dropdown-body"> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="(label, field) in $options.statuses" :key="field" data-testid="statusDropdownItem" @@ -122,8 +122,8 @@ export default { @click="updateAlertStatus(label)" > {{ label }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </div> - </gl-dropdown> + </gl-deprecated-dropdown> </div> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue index df07038151e..0a1478ef5fe 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignee.vue @@ -1,9 +1,9 @@ <script> -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem } from '@gitlab/ui'; export default { components: { - GlDropdownItem, + GlDeprecatedDropdownItem, }, props: { user: { @@ -24,7 +24,7 @@ export default { </script> <template> - <gl-dropdown-item + <gl-deprecated-dropdown-item :key="user.username" data-testid="assigneeDropdownItem" class="assignee-dropdown-item gl-vertical-align-middle" @@ -47,5 +47,5 @@ export default { </strong> <span class="dropdown-menu-user-username"> {{ user.username }}</span> </span> - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </template> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue index cb32a5ffd4f..4af5c83b30c 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_assignees.vue @@ -1,20 +1,20 @@ <script> import { GlIcon, - GlDropdown, - GlDropdownDivider, - GlDropdownHeader, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, GlLoadingIcon, GlTooltip, GlButton, GlSprintf, } from '@gitlab/ui'; +import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { s__, __ } from '~/locale'; import alertSetAssignees from '../../graphql/mutations/alert_set_assignees.mutation.graphql'; import SidebarAssignee from './sidebar_assignee.vue'; -import { debounce } from 'lodash'; const DATA_REFETCH_DELAY = 250; @@ -33,10 +33,10 @@ export default { }, components: { GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlDropdownHeader, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownHeader, GlLoadingIcon, GlTooltip, GlButton, @@ -213,7 +213,7 @@ export default { </p> <div class="dropdown dropdown-menu-selectable" :class="dropdownClass"> - <gl-dropdown + <gl-deprecated-dropdown ref="dropdown" :text="assignedUser" class="w-100" @@ -243,18 +243,18 @@ export default { </div> <div class="dropdown-content dropdown-body"> <template v-if="userListValid"> - <gl-dropdown-item + <gl-deprecated-dropdown-item :active="!userName" active-class="is-active" @click="updateAlertAssignees('')" > {{ __('Unassigned') }} - </gl-dropdown-item> - <gl-dropdown-divider /> + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-divider /> - <gl-dropdown-header class="mt-0"> + <gl-deprecated-dropdown-header class="mt-0"> {{ __('Assignee') }} - </gl-dropdown-header> + </gl-deprecated-dropdown-header> <sidebar-assignee v-for="user in sortedUsers" :key="user.username" @@ -263,17 +263,17 @@ export default { @update-alert-assignees="updateAlertAssignees" /> </template> - <gl-dropdown-item v-else-if="userListEmpty"> + <gl-deprecated-dropdown-item v-else-if="userListEmpty"> {{ __('No Matching Results') }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> <gl-loading-icon v-else /> </div> - </gl-dropdown> + </gl-deprecated-dropdown> </div> <gl-loading-icon v-if="isUpdating" :inline="true" /> <p v-else-if="!isDropdownShowing" class="value gl-m-0" :class="{ 'no-value': !userName }"> - <span v-if="userName" class="gl-text-gray-700" data-testid="assigned-users">{{ + <span v-if="userName" class="gl-text-gray-500" data-testid="assigned-users">{{ assignedUser }}</span> <span v-else class="gl-display-flex gl-align-items-center"> diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue index fd40b5d9f65..70902a204f8 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_header.vue @@ -27,7 +27,7 @@ export default { <template> <div class="block gl-display-flex gl-justify-content-space-between"> <span class="issuable-header-text hide-collapsed"> - {{ __('To Do') }} + {{ __('To-Do') }} </span> <sidebar-todo v-if="!sidebarCollapsed" diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue index 44a81aba828..0a2bad5510b 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_status.vue @@ -107,7 +107,7 @@ export default { > <span v-if="$options.statuses[alert.status]" - class="gl-text-gray-700" + class="gl-text-gray-500" data-testid="status" >{{ $options.statuses[alert.status] }}</span > diff --git a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue index 7d3135ad50d..5bd69a1f0ec 100644 --- a/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue +++ b/app/assets/javascripts/alert_management/components/sidebar/sidebar_todo.vue @@ -1,13 +1,14 @@ <script> import { s__ } from '~/locale'; import Todo from '~/sidebar/components/todo_toggle/todo.vue'; -import axios from '~/lib/utils/axios_utils'; -import createAlertTodo from '../../graphql/mutations/alert_todo_create.graphql'; +import createAlertTodo from '../../graphql/mutations/alert_todo_create.mutation.graphql'; +import todoMarkDone from '../../graphql/mutations/alert_todo_mark_done.mutation.graphql'; +import alertQuery from '../../graphql/queries/details.query.graphql'; export default { i18n: { UPDATE_ALERT_TODO_ERROR: s__( - 'AlertManagement|There was an error while updating the To Do of the alert.', + 'AlertManagement|There was an error while updating the To-Do of the alert.', ), }, components: { @@ -30,14 +31,24 @@ export default { data() { return { isUpdating: false, - isTodo: false, - todo: '', }; }, computed: { alertID() { return parseInt(this.alert.iid, 10); }, + firstToDoId() { + return this.alert?.todos?.nodes[0]?.id; + }, + hasPendingTodos() { + return this.alert?.todos?.nodes.length > 0; + }, + getAlertQueryVariables() { + return { + fullPath: this.projectPath, + alertId: this.alert.iid, + }; + }, }, methods: { updateToDoCount(add) { @@ -51,11 +62,7 @@ export default { return document.dispatchEvent(headerTodoEvent); }, - toggleTodo() { - if (this.todo) { - return this.markAsDone(); - } - + addToDo() { this.isUpdating = true; return this.$apollo .mutate({ @@ -65,24 +72,14 @@ export default { projectPath: this.projectPath, }, }) - .then(({ data: { alertTodoCreate: { todo = {}, errors = [] } } = {} } = {}) => { + .then(({ data: { errors = [] } }) => { if (errors[0]) { - return this.$emit( - 'alert-error', - `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${errors[0]}.`, - ); + return this.throwError(errors[0]); } - - this.todo = todo.id; return this.updateToDoCount(true); }) .catch(() => { - this.$emit( - 'alert-error', - `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${s__( - 'AlertManagement|Please try again.', - )}`, - ); + this.throwError(); }) .finally(() => { this.isUpdating = false; @@ -90,20 +87,45 @@ export default { }, markAsDone() { this.isUpdating = true; - - return axios - .delete(`/dashboard/todos/${this.todo.split('/').pop()}`) - .then(() => { - this.todo = ''; + return this.$apollo + .mutate({ + mutation: todoMarkDone, + variables: { + id: this.firstToDoId, + }, + update: this.updateCache, + }) + .then(({ data: { errors = [] } }) => { + if (errors[0]) { + return this.throwError(errors[0]); + } return this.updateToDoCount(false); }) .catch(() => { - this.$emit('alert-error', this.$options.i18n.UPDATE_ALERT_TODO_ERROR); + this.throwError(); }) .finally(() => { this.isUpdating = false; }); }, + updateCache(store) { + const data = store.readQuery({ + query: alertQuery, + variables: this.getAlertQueryVariables, + }); + + data.project.alertManagementAlerts.nodes[0].todos.nodes.shift(); + + store.writeQuery({ + query: alertQuery, + variables: this.getAlertQueryVariables, + data, + }); + }, + throwError(err = '') { + const error = err || s__('AlertManagement|Please try again.'); + this.$emit('alert-error', `${this.$options.i18n.UPDATE_ALERT_TODO_ERROR} ${error}`); + }, }, }; </script> @@ -114,10 +136,10 @@ export default { data-testid="alert-todo-button" :collapsed="sidebarCollapsed" :issuable-id="alertID" - :is-todo="todo !== ''" + :is-todo="hasPendingTodos" :is-action-active="isUpdating" issuable-type="alert" - @toggleTodo="toggleTodo" + @toggleTodo="hasPendingTodos ? markAsDone() : addToDo()" /> </div> </template> diff --git a/app/assets/javascripts/alert_management/details.js b/app/assets/javascripts/alert_management/details.js index 2820bcb9665..dccf990f0b4 100644 --- a/app/assets/javascripts/alert_management/details.js +++ b/app/assets/javascripts/alert_management/details.js @@ -1,7 +1,8 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; +import createRouter from './router'; import AlertDetails from './components/alert_details.vue'; import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql'; @@ -10,6 +11,7 @@ Vue.use(VueApollo); export default selector => { const domEl = document.querySelector(selector); const { alertId, projectPath, projectIssuesPath, projectId } = domEl.dataset; + const router = createRouter(); const resolvers = { Mutation: { @@ -54,6 +56,7 @@ export default selector => { components: { AlertDetails, }, + router, render(createElement) { return createElement('alert-details', {}); }, diff --git a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql index 18fab429164..0712ff12c23 100644 --- a/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql +++ b/app/assets/javascripts/alert_management/graphql/fragments/detail_item.fragment.graphql @@ -11,6 +11,12 @@ fragment AlertDetailItem on AlertManagementAlert { updatedAt endedAt details + runbook + todos { + nodes { + id + } + } notes { nodes { ...AlertNote diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql deleted file mode 100644 index cdf3d763302..00000000000 --- a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.graphql +++ /dev/null @@ -1,11 +0,0 @@ -mutation($projectPath: ID!, $iid: String!) { - alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) { - errors - alert { - iid - } - todo { - id - } - } -} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.mutation.graphql new file mode 100644 index 00000000000..ac9858c104f --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_create.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/detail_item.fragment.graphql" + +mutation alertTodoCreate($projectPath: ID!, $iid: String!) { + alertTodoCreate(input: { iid: $iid, projectPath: $projectPath }) { + errors + alert { + ...AlertDetailItem + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_mark_done.mutation.graphql b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_mark_done.mutation.graphql new file mode 100644 index 00000000000..4d59b4d94cd --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/mutations/alert_todo_mark_done.mutation.graphql @@ -0,0 +1,8 @@ +mutation todoMarkDone($id: ID!) { + todoMarkDone(input: { id: $id }) { + errors + todo { + id + } + } +} diff --git a/app/assets/javascripts/alert_management/graphql/queries/alert_help_url.query.graphql b/app/assets/javascripts/alert_management/graphql/queries/alert_help_url.query.graphql new file mode 100644 index 00000000000..05a8bc7c736 --- /dev/null +++ b/app/assets/javascripts/alert_management/graphql/queries/alert_help_url.query.graphql @@ -0,0 +1,3 @@ +query alertsHelpUrl { + alertsHelpUrl @client +} diff --git a/app/assets/javascripts/alert_management/list.js b/app/assets/javascripts/alert_management/list.js index 3f78ca66a59..e180ab5f7e3 100644 --- a/app/assets/javascripts/alert_management/list.js +++ b/app/assets/javascripts/alert_management/list.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import createDefaultClient from '~/lib/graphql'; import { defaultDataIdFromObject } from 'apollo-cache-inmemory'; +import createDefaultClient from '~/lib/graphql'; import { parseBoolean } from '~/lib/utils/common_utils'; import AlertManagementList from './components/alert_management_list_wrapper.vue'; @@ -16,6 +16,7 @@ export default () => { enableAlertManagementPath, emptyAlertSvgPath, populatingAlertsHelpUrl, + alertsHelpUrl, opsgenieMvcTargetUrl, } = domEl.dataset; let { alertManagementEnabled, userCanEnableAlertManagement, opsgenieMvcEnabled } = domEl.dataset; @@ -41,6 +42,12 @@ export default () => { ), }); + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + alertsHelpUrl, + }, + }); + return new Vue({ el: selector, apolloProvider, diff --git a/app/assets/javascripts/alert_management/router.js b/app/assets/javascripts/alert_management/router.js new file mode 100644 index 00000000000..5687fe4e0f5 --- /dev/null +++ b/app/assets/javascripts/alert_management/router.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +import { joinPaths } from '~/lib/utils/url_utility'; + +Vue.use(VueRouter); + +export default function createRouter(base) { + return new VueRouter({ + mode: 'hash', + base: joinPaths(gon.relative_url_root || '', base), + routes: [{ path: '/:tabId', name: 'tab' }], + }); +} diff --git a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue index a2d94fb8083..c5e213d7dc9 100644 --- a/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue +++ b/app/assets/javascripts/alerts_service_settings/components/alerts_service_form.vue @@ -12,7 +12,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; import axios from '~/lib/utils/axios_utils'; import { s__, __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; export default { i18n: { @@ -180,9 +180,11 @@ export default { /> </span> </div> - <gl-button v-gl-modal.authKeyModal class="mt-2" :disabled="isDisabled">{{ - $options.RESET_KEY - }}</gl-button> + <span class="gl-display-flex gl-justify-content-end"> + <gl-button v-gl-modal.authKeyModal class="gl-mt-2" :disabled="isDisabled">{{ + $options.RESET_KEY + }}</gl-button> + </span> <gl-modal modal-id="authKeyModal" :title="$options.RESET_KEY" diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index 18c9f82f052..f0bb8b0a90f 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -51,52 +51,26 @@ export default { 'gl-modal': GlModalDirective, }, mixins: [glFeatureFlagsMixin()], - props: { - prometheus: { - type: Object, - required: true, - validator: ({ activated }) => { - return activated !== undefined; - }, - }, - generic: { - type: Object, - required: true, - validator: ({ formPath }) => { - return formPath !== undefined; - }, - }, - opsgenie: { - type: Object, - required: true, - }, - }, + inject: ['prometheus', 'generic', 'opsgenie'], data() { return { - activated: { - generic: this.generic.activated, - prometheus: this.prometheus.activated, - opsgenie: this.opsgenie?.activated, - }, loading: false, - authorizationKey: { - generic: this.generic.initialAuthorizationKey, - prometheus: this.prometheus.prometheusAuthorizationKey, - }, selectedEndpoint: serviceOptions[0].value, options: serviceOptions, - targetUrl: null, + active: false, + authKey: '', + targetUrl: '', feedback: { variant: 'danger', - feedbackMessage: null, + feedbackMessage: '', isFeedbackDismissed: false, }, - serverError: null, testAlert: { json: null, error: null, }, canSaveForm: false, + serverError: null, }; }, computed: { @@ -123,24 +97,24 @@ export default { case 'generic': { return { url: this.generic.url, - authKey: this.authorizationKey.generic, - active: this.activated.generic, - resetKey: this.resetGenericKey.bind(this), + authKey: this.generic.authorizationKey, + activated: this.generic.activated, + resetKey: this.resetKey.bind(this), }; } case 'prometheus': { return { url: this.prometheus.prometheusUrl, - authKey: this.authorizationKey.prometheus, - active: this.activated.prometheus, - resetKey: this.resetPrometheusKey.bind(this), + authKey: this.prometheus.authorizationKey, + activated: this.prometheus.activated, + resetKey: this.resetKey.bind(this, 'prometheus'), targetUrl: this.prometheus.prometheusApiUrl, }; } case 'opsgenie': { return { targetUrl: this.opsgenie.opsgenieMvcTargetUrl, - active: this.activated.opsgenie, + activated: this.opsgenie.activated, }; } default: { @@ -164,7 +138,7 @@ export default { return this.testAlert.error === null; }, canTestAlert() { - return this.selectedService.active && this.testAlert.json !== null; + return this.active && this.testAlert.json !== null; }, canSaveConfig() { return !this.loading && this.canSaveForm; @@ -187,19 +161,21 @@ export default { }, mounted() { if ( - this.activated.prometheus || - this.activated.generic || + this.prometheus.activated || + this.generic.activated || !this.opsgenie.opsgenieMvcIsAvailable ) { this.removeOpsGenieOption(); - } else if (this.activated.opsgenie) { + } else if (this.opsgenie.activated) { this.setOpsgenieAsDefault(); } + this.active = this.selectedService.activated; + this.authKey = this.selectedService.authKey ?? ''; }, methods: { - createUserErrorMessage(errors) { + createUserErrorMessage(errors = { error: [''] }) { // eslint-disable-next-line prefer-destructuring - this.serverError = Object.values(errors)[0][0]; + this.serverError = errors.error[0]; }, setOpsgenieAsDefault() { this.options = this.options.map(el => { @@ -224,41 +200,38 @@ export default { resetFormValues() { this.testAlert.json = null; this.targetUrl = this.selectedService.targetUrl; + this.active = this.selectedService.activated; }, dismissFeedback() { this.serverError = null; this.feedback = { ...this.feedback, feedbackMessage: null }; this.isFeedbackDismissed = false; }, - resetGenericKey() { - return service - .updateGenericKey({ endpoint: this.generic.formPath, params: { service: { token: '' } } }) + resetKey(key) { + const fn = key === 'prometheus' ? this.resetPrometheusKey() : this.resetGenericKey(); + + return fn .then(({ data: { token } }) => { - this.authorizationKey.generic = token; + this.authKey = token; this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); }) .catch(() => { this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); }); }, + resetGenericKey() { + this.dismissFeedback(); + return service.updateGenericKey({ + endpoint: this.generic.formPath, + params: { service: { token: '' } }, + }); + }, resetPrometheusKey() { - return service - .updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath }) - .then(({ data: { token } }) => { - this.authorizationKey.prometheus = token; - this.setFeedback({ feedbackMessage: this.$options.i18n.authKeyRest, variant: 'success' }); - }) - .catch(() => { - this.setFeedback({ feedbackMessage: this.$options.i18n.errorKeyMsg, variant: 'danger' }); - }); + return service.updatePrometheusKey({ endpoint: this.prometheus.prometheusResetKeyPath }); }, toggleService(value) { this.canSaveForm = true; - if (this.isPrometheus) { - this.activated.prometheus = value; - } else { - this.activated[this.selectedEndpoint] = value; - } + this.active = value; }, toggle(value) { return this.isPrometheus ? this.togglePrometheusActive(value) : this.toggleActivated(value); @@ -273,7 +246,7 @@ export default { : { service: { active: value } }, }) .then(() => { - this.activated[this.selectedEndpoint] = value; + this.active = value; this.toggleSuccess(value); if (!this.isOpsgenie && value) { @@ -316,7 +289,7 @@ export default { }, }) .then(() => { - this.activated.prometheus = value; + this.active = value; this.toggleSuccess(value); this.removeOpsGenieOption(); }) @@ -358,6 +331,7 @@ export default { }, validateTestAlert() { this.loading = true; + this.dismissFeedback(); this.validateJson(); return service .updateTestAlert({ @@ -382,7 +356,8 @@ export default { }); }, onSubmit() { - this.toggle(this.selectedService.active); + this.dismissFeedback(); + this.toggle(this.active); }, onReset() { this.testAlert.json = null; @@ -391,7 +366,7 @@ export default { if (this.canSaveForm) { this.canSaveForm = false; - this.activated[this.selectedEndpoint] = this[this.selectedEndpoint].activated; + this.active = this.selectedService.activated; } }, }, @@ -409,7 +384,7 @@ export default { variant="danger" category="primary" class="gl-display-block gl-mt-3" - @click="toggle(selectedService.active)" + @click="toggle(active)" > {{ __('Save anyway') }} </gl-button> @@ -435,7 +410,7 @@ export default { data-testid="alert-settings-select" @change="resetFormValues" /> - <span class="gl-text-gray-400"> + <span class="gl-text-gray-200"> <gl-sprintf :message="$options.i18n.integrationsInfo"> <template #link="{ content }"> <gl-link @@ -457,7 +432,7 @@ export default { id="activated" :disabled-input="loading" :is-loading="loading" - :value="selectedService.active" + :value="active" @change="toggleService" /> </gl-form-group> @@ -472,9 +447,9 @@ export default { v-model="targetUrl" type="url" :placeholder="baseUrlPlaceholder" - :disabled="!selectedService.active" + :disabled="!active" /> - <span class="gl-text-gray-400"> + <span class="gl-text-gray-200"> {{ $options.i18n.apiBaseUrlHelpText }} </span> </gl-form-group> @@ -489,7 +464,7 @@ export default { /> </template> </gl-form-input-group> - <span class="gl-text-gray-400"> + <span class="gl-text-gray-200"> {{ prometheusInfo }} </span> </gl-form-group> @@ -498,21 +473,16 @@ export default { label-for="authorization-key" label-class="label-bold" > - <gl-form-input-group - id="authorization-key" - class="gl-mb-2" - readonly - :value="selectedService.authKey" - > + <gl-form-input-group id="authorization-key" class="gl-mb-2" readonly :value="authKey"> <template #append> <clipboard-button - :text="selectedService.authKey || ''" + :text="authKey" :title="$options.i18n.copyToClipboard" class="gl-m-0!" /> </template> </gl-form-input-group> - <gl-button v-gl-modal.authKeyModal :disabled="!selectedService.active" class="gl-mt-3">{{ + <gl-button v-gl-modal.authKeyModal :disabled="!active" class="gl-mt-3">{{ $options.i18n.resetKey }}</gl-button> <gl-modal @@ -534,18 +504,23 @@ export default { <gl-form-textarea id="alert-json" v-model.trim="testAlert.json" - :disabled="!selectedService.active" + :disabled="!active" :state="jsonIsValid" :placeholder="$options.i18n.alertJsonPlaceholder" rows="6" max-rows="10" /> </gl-form-group> - <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ - $options.i18n.testAlertInfo - }}</gl-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button :disabled="!canTestAlert" @click="validateTestAlert">{{ + $options.i18n.testAlertInfo + }}</gl-button> + </div> </template> <div class="footer-block row-content-block gl-display-flex gl-justify-content-space-between"> + <gl-button category="primary" :disabled="!canSaveConfig" @click="onReset"> + {{ __('Cancel') }} + </gl-button> <gl-button variant="success" category="primary" @@ -554,9 +529,6 @@ export default { > {{ __('Save changes') }} </gl-button> - <gl-button variant="default" category="primary" :disabled="!canSaveConfig" @click="onReset"> - {{ __('Cancel') }} - </gl-button> </div> </gl-form> </div> diff --git a/app/assets/javascripts/alerts_settings/constants.js b/app/assets/javascripts/alerts_settings/constants.js index d15e8619df4..fc669995875 100644 --- a/app/assets/javascripts/alerts_settings/constants.js +++ b/app/assets/javascripts/alerts_settings/constants.js @@ -35,7 +35,9 @@ export const i18n = { testAlertSuccess: s__( 'AlertSettings|Test alert sent successfully. If you have made other changes, please save them now.', ), - authKeyRest: s__('AlertSettings|Authorization key has been successfully reset'), + authKeyRest: s__( + 'AlertSettings|Authorization key has been successfully reset. Please save your changes now.', + ), }; export const serviceOptions = [ diff --git a/app/assets/javascripts/alerts_settings/index.js b/app/assets/javascripts/alerts_settings/index.js index a4c2bf6b18e..8d1d342d229 100644 --- a/app/assets/javascripts/alerts_settings/index.js +++ b/app/assets/javascripts/alerts_settings/index.js @@ -31,37 +31,37 @@ export default el => { const opsgenieMvcActivated = parseBoolean(opsgenieMvcEnabled); const opsgenieMvcIsAvailable = parseBoolean(opsgenieMvcAvailable); - const props = { - prometheus: { - activated: prometheusIsActivated, - prometheusUrl, - prometheusAuthorizationKey, - prometheusFormPath, - prometheusResetKeyPath, - prometheusApiUrl, - }, - generic: { - alertsSetupUrl, - alertsUsageUrl, - activated: genericActivated, - formPath, - initialAuthorizationKey: authorizationKey, - url, - }, - opsgenie: { - formPath: opsgenieMvcFormPath, - activated: opsgenieMvcActivated, - opsgenieMvcTargetUrl, - opsgenieMvcIsAvailable, - }, - }; - return new Vue({ el, + provide: { + prometheus: { + activated: prometheusIsActivated, + prometheusUrl, + authorizationKey: prometheusAuthorizationKey, + prometheusFormPath, + prometheusResetKeyPath, + prometheusApiUrl, + }, + generic: { + alertsSetupUrl, + alertsUsageUrl, + activated: genericActivated, + formPath, + authorizationKey, + url, + }, + opsgenie: { + formPath: opsgenieMvcFormPath, + activated: opsgenieMvcActivated, + opsgenieMvcTargetUrl, + opsgenieMvcIsAvailable, + }, + }, + components: { + AlertSettingsForm, + }, render(createElement) { - return createElement(AlertSettingsForm, { - props, - }); + return createElement('alert-settings-form'); }, }); }; diff --git a/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js b/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js deleted file mode 100644 index ff8b4c56321..00000000000 --- a/app/assets/javascripts/analytics/cycle_analytics/mixins/filter_mixins.js +++ /dev/null @@ -1 +0,0 @@ -export default {}; diff --git a/app/assets/javascripts/analytics/product_analytics/activity_charts_bundle.js b/app/assets/javascripts/analytics/product_analytics/activity_charts_bundle.js new file mode 100644 index 00000000000..d1f4b537b11 --- /dev/null +++ b/app/assets/javascripts/analytics/product_analytics/activity_charts_bundle.js @@ -0,0 +1,28 @@ +import Vue from 'vue'; +import ActivityChart from './components/activity_chart.vue'; + +export default () => { + const containers = document.querySelectorAll('.js-project-analytics-chart'); + + if (!containers) { + return false; + } + + return containers.forEach(container => { + const { chartData } = container.dataset; + const formattedData = JSON.parse(chartData); + + return new Vue({ + el: container, + provide: { + formattedData, + }, + components: { + ActivityChart, + }, + render(createElement) { + return createElement('activity-chart'); + }, + }); + }); +}; diff --git a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue new file mode 100644 index 00000000000..a475ff8fd25 --- /dev/null +++ b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue @@ -0,0 +1,42 @@ +<script> +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { s__ } from '~/locale'; + +export default { + i18n: { + noDataMsg: s__( + 'ProductAnalytics|There is no data for this type of chart currently. Please see the Setup tab if you have not configured the product analytics tool already.', + ), + }, + components: { + GlColumnChart, + }, + inject: { + formattedData: { + default: {}, + }, + }, + computed: { + seriesData() { + return { + full: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]), + }; + }, + }, +}; +</script> + +<template> + <div class="gl-xs-w-full"> + <gl-column-chart + v-if="formattedData.keys" + :data="seriesData" + :x-axis-title="__('Value')" + :y-axis-title="__('Number of events')" + :x-axis-type="'category'" + /> + <p v-else data-testid="noActivityChartData"> + {{ $options.i18n.noDataMsg }} + </p> + </div> +</template> diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index c84e73ccdb4..1d8fb1fc5a6 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -1,6 +1,6 @@ import axios from './lib/utils/axios_utils'; import { joinPaths } from './lib/utils/url_utility'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; const DEFAULT_PER_PAGE = 20; @@ -9,6 +9,7 @@ const Api = { groupsPath: '/api/:version/groups.json', groupPath: '/api/:version/groups/:id', groupMembersPath: '/api/:version/groups/:id/members', + groupMilestonesPath: '/api/:version/groups/:id/milestones', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', groupPackagesPath: '/api/:version/groups/:id/packages', @@ -55,10 +56,14 @@ const Api = { adminStatisticsPath: '/api/:version/application/statistics', pipelineSinglePath: '/api/:version/projects/:id/pipelines/:pipeline_id', pipelinesPath: '/api/:version/projects/:id/pipelines/', + createPipelinePath: '/api/:version/projects/:id/pipeline', environmentsPath: '/api/:version/projects/:id/environments', + contextCommitsPath: + '/api/:version/projects/:id/merge_requests/:merge_request_iid/context_commits', rawFilePath: '/api/:version/projects/:id/repository/files/:path/raw', issuePath: '/api/:version/projects/:id/issues/:issue_iid', tagsPath: '/api/:version/projects/:id/repository/tags', + freezePeriodsPath: '/api/:version/projects/:id/freeze_periods', group(groupId, callback = () => {}) { const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); @@ -106,6 +111,17 @@ const Api = { }); }, + groupMilestones(id, options) { + const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url, { + params: { + per_page: DEFAULT_PER_PAGE, + ...options, + }, + }); + }, + // Return groups list. Filtered by query groups(query, options, callback = () => {}) { const url = Api.buildUrl(Api.groupsPath); @@ -528,6 +544,12 @@ const Api = { return axios.get(url); }, + createRelease(projectPath, release) { + const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(projectPath)); + + return axios.post(url, release); + }, + updateRelease(projectPath, tagName, release) { const url = Api.buildUrl(this.releasePath) .replace(':id', encodeURIComponent(projectPath)) @@ -575,11 +597,45 @@ const Api = { }); }, + createPipeline(id, data) { + const url = Api.buildUrl(this.createPipelinePath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data, { + headers: { + 'Content-Type': 'application/json', + }, + }); + }, + environments(id) { const url = Api.buildUrl(this.environmentsPath).replace(':id', encodeURIComponent(id)); return axios.get(url); }, + createContextCommits(id, mergeRequestIid, data) { + const url = Api.buildUrl(this.contextCommitsPath) + .replace(':id', encodeURIComponent(id)) + .replace(':merge_request_iid', mergeRequestIid); + + return axios.post(url, data); + }, + + allContextCommits(id, mergeRequestIid) { + const url = Api.buildUrl(this.contextCommitsPath) + .replace(':id', encodeURIComponent(id)) + .replace(':merge_request_iid', mergeRequestIid); + + return axios.get(url); + }, + + removeContextCommits(id, mergeRequestIid, data) { + const url = Api.buildUrl(this.contextCommitsPath) + .replace(':id', id) + .replace(':merge_request_iid', mergeRequestIid); + + return axios.delete(url, { data }); + }, + getRawFile(id, path, params = { ref: 'master' }) { const url = Api.buildUrl(this.rawFilePath) .replace(':id', encodeURIComponent(id)) @@ -616,6 +672,18 @@ const Api = { }); }, + freezePeriods(id) { + const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id)); + + return axios.get(url); + }, + + createFreezePeriod(id, freezePeriod = {}) { + const url = Api.buildUrl(this.freezePeriodsPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, freezePeriod); + }, + buildUrl(url) { return joinPaths(gon.relative_url_root || '', url.replace(':version', gon.api_version)); }, diff --git a/app/assets/javascripts/awards_handler.js b/app/assets/javascripts/awards_handler.js index 0e83ba3d528..cb71047e00c 100644 --- a/app/assets/javascripts/awards_handler.js +++ b/app/assets/javascripts/awards_handler.js @@ -7,7 +7,7 @@ import Cookies from 'js-cookie'; import { __ } from './locale'; import { updateTooltipTitle } from './lib/utils/common_utils'; import { isInVueNoteablePage } from './lib/utils/dom_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import * as Emoji from '~/emoji'; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 4145a4a4145..08834df0a9b 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -2,7 +2,7 @@ import { escape, debounce } from 'lodash'; import { mapActions, mapState } from 'vuex'; import { GlLoadingIcon, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import createEmptyBadge from '../empty_badge'; @@ -184,7 +184,7 @@ export default { @input="debouncedPreview" /> <div class="invalid-feedback">{{ s__('Badges|Please fill in a valid URL') }}</div> - <span class="form-text text-muted"> {{ badgeLinkUrlExample }} </span> + <span class="form-text text-muted">{{ badgeLinkUrlExample }}</span> </div> <div class="form-group"> @@ -199,7 +199,7 @@ export default { @input="debouncedPreview" /> <div class="invalid-feedback">{{ s__('Badges|Please fill in a valid URL') }}</div> - <span class="form-text text-muted"> {{ badgeImageUrlExample }} </span> + <span class="form-text text-muted">{{ badgeImageUrlExample }}</span> </div> <div class="form-group"> @@ -210,22 +210,26 @@ export default { :image-url="renderedImageUrl" :link-url="renderedLinkUrl" /> - <p v-show="isRendering"><gl-loading-icon :inline="true" /></p> + <p v-show="isRendering"> + <gl-loading-icon :inline="true" /> + </p> <p v-show="!renderedBadge && !isRendering" class="disabled-content"> {{ s__('Badges|No image to preview') }} </p> </div> - <div v-if="isEditing" class="row-content-block"> + <div v-if="isEditing" class="row-content-block gl-display-flex gl-justify-content-end"> + <button class="btn btn-cancel gl-mr-4" type="button" @click="onCancel"> + {{ __('Cancel') }} + </button> <loading-button :loading="isSaving" :label="s__('Badges|Save changes')" type="submit" container-class="btn btn-success" /> - <button class="btn btn-cancel" type="button" @click="onCancel">{{ __('Cancel') }}</button> </div> - <div v-else class="form-group"> + <div v-else class="gl-display-flex gl-justify-content-end form-group"> <loading-button :loading="isSaving" :label="s__('Badges|Add badge')" diff --git a/app/assets/javascripts/badges/components/badge_settings.vue b/app/assets/javascripts/badges/components/badge_settings.vue index 531f84ad272..531742e49e3 100644 --- a/app/assets/javascripts/badges/components/badge_settings.vue +++ b/app/assets/javascripts/badges/components/badge_settings.vue @@ -1,6 +1,7 @@ <script> import { mapState, mapActions } from 'vuex'; -import createFlash from '~/flash'; +import { GlSprintf } from '@gitlab/ui'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import Badge from './badge.vue'; @@ -14,14 +15,15 @@ export default { BadgeForm, BadgeList, GlModal: DeprecatedModal2, + GlSprintf, + }, + i18n: { + deleteModalText: s__( + 'Badges|You are going to delete this badge. Deleted badges %{strongStart}cannot%{strongEnd} be restored.', + ), }, computed: { ...mapState(['badgeInModal', 'isEditing']), - deleteModalText() { - return s__( - 'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.', - ); - }, }, methods: { ...mapActions(['deleteBadge']), @@ -54,7 +56,13 @@ export default { :link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''" /> </div> - <p v-html="deleteModalText"></p> + <p> + <gl-sprintf :message="$options.i18n.deleteModalText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> </gl-modal> <badge-form v-show="isEditing" :is-editing="true" /> diff --git a/app/assets/javascripts/batch_comments/components/draft_note.vue b/app/assets/javascripts/batch_comments/components/draft_note.vue index 4c100ec7335..39c1b8decee 100644 --- a/app/assets/javascripts/batch_comments/components/draft_note.vue +++ b/app/assets/javascripts/batch_comments/components/draft_note.vue @@ -1,15 +1,17 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; import NoteableNote from '~/notes/components/noteable_note.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import PublishButton from './publish_button.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { NoteableNote, PublishButton, - LoadingButton, + GlButton, }, + mixins: [glFeatureFlagsMixin()], props: { draft: { type: Object, @@ -64,14 +66,27 @@ export default { handleNotEditing() { this.isEditingDraft = false; }, + handleMouseEnter(draft) { + if (this.glFeatures.multilineComments && draft.position) { + this.setSelectedCommentPositionHover(draft.position.line_range); + } + }, + handleMouseLeave(draft) { + // Even though position isn't used here we still don't want to unecessarily call a mutation + // The lack of position tells us that highlighting is irrelevant in this context + if (this.glFeatures.multilineComments && draft.position) { + this.setSelectedCommentPositionHover(); + } + }, }, }; </script> <template> <article + role="article" class="draft-note-component note-wrapper" - @mouseenter="setSelectedCommentPositionHover(draft.position.line_range)" - @mouseleave="setSelectedCommentPositionHover()" + @mouseenter="handleMouseEnter(draft)" + @mouseleave="handleMouseLeave(draft)" > <ul class="notes draft-notes"> <noteable-note @@ -100,18 +115,15 @@ export default { ></div> <p class="draft-note-actions d-flex"> - <publish-button - :show-count="true" - :should-publish="false" - class="btn btn-success btn-inverted gl-mr-3" - /> - <loading-button + <publish-button :show-count="true" :should-publish="false" category="secondary" /> + <gl-button ref="publishNowButton" :loading="isPublishingDraft(draft.id) || isPublishing" - :label="__('Add comment now')" - container-class="btn btn-inverted" + class="gl-ml-3" @click="publishNow" - /> + > + {{ __('Add comment now') }} + </gl-button> </p> </template> </article> diff --git a/app/assets/javascripts/batch_comments/components/drafts_count.vue b/app/assets/javascripts/batch_comments/components/drafts_count.vue index f1180760c4d..7a8482ac341 100644 --- a/app/assets/javascripts/batch_comments/components/drafts_count.vue +++ b/app/assets/javascripts/batch_comments/components/drafts_count.vue @@ -1,15 +1,19 @@ <script> import { mapGetters } from 'vuex'; +import { GlBadge } from '@gitlab/ui'; export default { + components: { + GlBadge, + }, computed: { ...mapGetters('batchComments', ['draftsCount']), }, }; </script> <template> - <span class="drafts-count-component"> - <span class="drafts-count-number">{{ draftsCount }}</span> + <gl-badge size="sm" variant="success"> + {{ draftsCount }} <span class="sr-only"> {{ n__('draft', 'drafts', draftsCount) }} </span> - </span> + </gl-badge> </template> diff --git a/app/assets/javascripts/batch_comments/components/preview_item.vue b/app/assets/javascripts/batch_comments/components/preview_item.vue index 3162a83f099..982fb01f49a 100644 --- a/app/assets/javascripts/batch_comments/components/preview_item.vue +++ b/app/assets/javascripts/batch_comments/components/preview_item.vue @@ -1,10 +1,10 @@ <script> import { mapActions, mapGetters } from 'vuex'; +import { GlSprintf } from '@gitlab/ui'; import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import resolvedStatusMixin from '../mixins/resolved_status'; -import { GlSprintf } from '@gitlab/ui'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getStartLineNumber, diff --git a/app/assets/javascripts/batch_comments/components/publish_button.vue b/app/assets/javascripts/batch_comments/components/publish_button.vue index f4dc0f04dc3..0c79e185f06 100644 --- a/app/assets/javascripts/batch_comments/components/publish_button.vue +++ b/app/assets/javascripts/batch_comments/components/publish_button.vue @@ -1,12 +1,12 @@ <script> import { mapActions, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import DraftsCount from './drafts_count.vue'; export default { components: { - LoadingButton, + GlButton, DraftsCount, }, props: { @@ -20,6 +20,16 @@ export default { required: false, default: __('Finish review'), }, + category: { + type: String, + required: false, + default: 'primary', + }, + variant: { + type: String, + required: false, + default: 'success', + }, shouldPublish: { type: Boolean, required: true, @@ -42,14 +52,14 @@ export default { </script> <template> - <loading-button + <gl-button :loading="isPublishing" - container-class="btn btn-success js-publish-draft-button qa-submit-review" + class="js-publish-draft-button qa-submit-review" + :category="category" + :variant="variant" @click="onClick" > - <span> - {{ label }} - <drafts-count v-if="showCount" /> - </span> - </loading-button> + {{ label }} + <drafts-count v-if="showCount" /> + </gl-button> </template> diff --git a/app/assets/javascripts/batch_comments/components/review_bar.vue b/app/assets/javascripts/batch_comments/components/review_bar.vue index b0e8b806701..2d7b86d2431 100644 --- a/app/assets/javascripts/batch_comments/components/review_bar.vue +++ b/app/assets/javascripts/batch_comments/components/review_bar.vue @@ -1,13 +1,12 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; -import { GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import PreviewDropdown from './preview_dropdown.vue'; export default { components: { - LoadingButton, + GlButton, GlModal, PreviewDropdown, }, @@ -48,12 +47,13 @@ export default { <nav class="review-bar-component"> <div class="review-bar-content qa-review-bar"> <preview-dropdown /> - <loading-button + <gl-button v-gl-modal="$options.modalId" :loading="isDiscarding" - :label="__('Discard review')" class="qa-discard-review float-right" - /> + > + {{ __('Discard review') }} + </gl-button> </div> </nav> <gl-modal diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index 1ef012696c5..d9b92113103 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -1,4 +1,4 @@ -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { scrollToElement } from '~/lib/utils/common_utils'; import service from '../../../services/drafts_service'; @@ -146,6 +146,3 @@ export const expandAllDiscussions = ({ dispatch, state }) => export const toggleResolveDiscussion = ({ commit }, draftId) => { commit(types.TOGGLE_RESOLVE_DISCUSSION, draftId); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js index 43f43c983aa..22ae6c2e970 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/getters.js @@ -82,6 +82,3 @@ export const isPublishingDraft = state => draftId => state.currentlyPublishingDrafts.indexOf(draftId) !== -1; export const sortedDrafts = state => [...state.drafts].sort((a, b) => a.id > b.id); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js index bbcfa50ba35..ce5b63df19c 100644 --- a/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/copy_as_gfm.js @@ -180,6 +180,10 @@ export class CopyAsGFM { }) .catch(() => {}); } + + static quoted(markdown) { + return `> ${markdown.split('\n').join('\n> ')}`; + } } // Export CopyAsGFM as a global for rspec to access diff --git a/app/assets/javascripts/behaviors/markdown/render_math.js b/app/assets/javascripts/behaviors/markdown/render_math.js index b5dbdbb7e86..03d9955f8fc 100644 --- a/app/assets/javascripts/behaviors/markdown/render_math.js +++ b/app/assets/javascripts/behaviors/markdown/render_math.js @@ -1,4 +1,4 @@ -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { s__, sprintf } from '~/locale'; // Renders math using KaTeX in any element with the diff --git a/app/assets/javascripts/behaviors/markdown/render_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_mermaid.js index 94033e914ef..cb0e6345059 100644 --- a/app/assets/javascripts/behaviors/markdown/render_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_mermaid.js @@ -1,7 +1,7 @@ -import flash from '~/flash'; import $ from 'jquery'; -import { __, sprintf } from '~/locale'; import { once } from 'lodash'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import { __, sprintf } from '~/locale'; // Renders diagrams and flowcharts from text using Mermaid in any element with the // `js-render-mermaid` class. diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index ca91400eac7..84bf22586a9 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; // MarkdownPreview diff --git a/app/assets/javascripts/behaviors/quick_submit.js b/app/assets/javascripts/behaviors/quick_submit.js index 530ab0bd4d9..49eab3e4f09 100644 --- a/app/assets/javascripts/behaviors/quick_submit.js +++ b/app/assets/javascripts/behaviors/quick_submit.js @@ -72,7 +72,7 @@ $(document).on( $this.tooltip({ container: 'body', - html: 'true', + html: true, placement: 'top', title, trigger: 'manual', diff --git a/app/assets/javascripts/blob/balsamiq_viewer.js b/app/assets/javascripts/blob/balsamiq_viewer.js index 2e537d8c000..fc86f630c4e 100644 --- a/app/assets/javascripts/blob/balsamiq_viewer.js +++ b/app/assets/javascripts/blob/balsamiq_viewer.js @@ -1,4 +1,4 @@ -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import BalsamiqViewer from './balsamiq/balsamiq_viewer'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/blob/components/blob_content_error.vue b/app/assets/javascripts/blob/components/blob_content_error.vue index 44dc4a6c727..7344b9cdff5 100644 --- a/app/assets/javascripts/blob/components/blob_content_error.vue +++ b/app/assets/javascripts/blob/components/blob_content_error.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlSprintf, GlLink } from '@gitlab/ui'; +import { __ } from '~/locale'; import { BLOB_RENDER_ERRORS } from './constants'; export default { diff --git a/app/assets/javascripts/blob/components/blob_edit_content.vue b/app/assets/javascripts/blob/components/blob_edit_content.vue index 056b4ea4aa8..26ba7b98a39 100644 --- a/app/assets/javascripts/blob/components/blob_edit_content.vue +++ b/app/assets/javascripts/blob/components/blob_edit_content.vue @@ -1,6 +1,12 @@ <script> -import { initEditorLite } from '~/blob/utils'; import { debounce } from 'lodash'; +import { initEditorLite } from '~/blob/utils'; +import { + SNIPPET_MARK_BLOBS_CONTENT, + SNIPPET_MARK_EDIT_APP_START, + SNIPPET_MEASURE_BLOBS_CONTENT, + SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, +} from '~/performance_constants'; export default { props: { @@ -14,6 +20,13 @@ export default { required: false, default: '', }, + // This is used to help uniquely create a monaco model + // even if two blob's share a file path. + fileGlobalId: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -30,17 +43,33 @@ export default { el: this.$refs.editor, blobPath: this.fileName, blobContent: this.value, + blobGlobalId: this.fileGlobalId, + }); + + this.editor.onChangeContent(debounce(this.onFileChange.bind(this), 250)); + + window.requestAnimationFrame(() => { + if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) { + performance.mark(SNIPPET_MARK_BLOBS_CONTENT); + performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT); + performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_EDIT_APP_START); + } }); }, + beforeDestroy() { + this.editor.dispose(); + }, methods: { - triggerFileChange: debounce(function debouncedFileChange() { + onFileChange() { this.$emit('input', this.editor.getValue()); - }, 250), + }, }, }; </script> <template> <div class="file-content code"> - <pre id="editor" ref="editor" data-editor-loading @keyup="triggerFileChange">{{ value }}</pre> + <div id="editor" ref="editor" data-editor-loading> + <pre class="editor-loading-content">{{ value }}</pre> + </div> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_edit_header.vue b/app/assets/javascripts/blob/components/blob_edit_header.vue index e1e1d76f721..2cbbbddceeb 100644 --- a/app/assets/javascripts/blob/components/blob_edit_header.vue +++ b/app/assets/javascripts/blob/components/blob_edit_header.vue @@ -1,9 +1,10 @@ <script> -import { GlFormInput } from '@gitlab/ui'; +import { GlFormInput, GlButton } from '@gitlab/ui'; export default { components: { GlFormInput, + GlButton, }, inheritAttrs: false, props: { @@ -11,6 +12,16 @@ export default { type: String, required: true, }, + canDelete: { + type: Boolean, + required: false, + default: true, + }, + showDelete: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -21,17 +32,27 @@ export default { </script> <template> <div class="js-file-title file-title-flex-parent"> - <gl-form-input - id="snippet_file_name" - v-model="name" - :placeholder=" - s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby') - " - name="snippet_file_name" - class="form-control js-snippet-file-name" - type="text" - v-bind="$attrs" - @change="$emit('input', name)" - /> + <div class="gl-display-flex gl-align-items-center gl-w-full"> + <gl-form-input + v-model="name" + :placeholder=" + s__('Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby') + " + name="snippet_file_name" + class="form-control js-snippet-file-name" + type="text" + v-bind="$attrs" + @change="$emit('input', name)" + /> + <gl-button + v-if="showDelete" + class="gl-ml-4" + variant="danger" + category="secondary" + :disabled="!canDelete" + @click="$emit('delete')" + >{{ s__('Snippets|Delete file') }}</gl-button + > + </div> </div> </template> diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 76c5779f3ae..fd40c51fec1 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -71,7 +71,7 @@ export default { </template> </blob-filepath> - <div class="file-actions d-none d-sm-flex"> + <div class="gl-display-none gl-display-sm-flex"> <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" /> <slot name="actions"></slot> diff --git a/app/assets/javascripts/blob/components/blob_header_default_actions.vue b/app/assets/javascripts/blob/components/blob_header_default_actions.vue index 62fef108b47..daade611651 100644 --- a/app/assets/javascripts/blob/components/blob_header_default_actions.vue +++ b/app/assets/javascripts/blob/components/blob_header_default_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, @@ -10,9 +10,8 @@ import { export default { components: { - GlIcon, GlButtonGroup, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -48,7 +47,7 @@ export default { </script> <template> <gl-button-group> - <gl-deprecated-button + <gl-button v-if="!hasRenderError" v-gl-tooltip.hover :aria-label="$options.BTN_COPY_CONTENTS_TITLE" @@ -56,26 +55,29 @@ export default { :disabled="copyDisabled" data-clipboard-target="#blob-code-content" data-testid="copyContentsButton" - > - <gl-icon name="copy-to-clipboard" :size="14" /> - </gl-deprecated-button> - <gl-deprecated-button + icon="copy-to-clipboard" + category="primary" + variant="default" + /> + <gl-button v-gl-tooltip.hover :aria-label="$options.BTN_RAW_TITLE" :title="$options.BTN_RAW_TITLE" :href="rawPath" target="_blank" - > - <gl-icon name="doc-code" :size="14" /> - </gl-deprecated-button> - <gl-deprecated-button + icon="doc-code" + category="primary" + variant="default" + /> + <gl-button v-gl-tooltip.hover :aria-label="$options.BTN_DOWNLOAD_TITLE" :title="$options.BTN_DOWNLOAD_TITLE" :href="downloadUrl" target="_blank" - > - <gl-icon name="download" :size="14" /> - </gl-deprecated-button> + icon="download" + category="primary" + variant="default" + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index 5b15fe2d7cc..902dd0b8eec 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -1,5 +1,5 @@ <script> -import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { RICH_BLOB_VIEWER, RICH_BLOB_VIEWER_TITLE, @@ -9,7 +9,6 @@ import { export default { components: { - GlIcon, GlButtonGroup, GlButton, }, @@ -52,19 +51,21 @@ export default { :title="$options.SIMPLE_BLOB_VIEWER_TITLE" :selected="isSimpleViewer" :class="{ active: isSimpleViewer }" + icon="code" + category="primary" + variant="default" @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)" - > - <gl-icon name="code" :size="14" /> - </gl-button> + /> <gl-button v-gl-tooltip.hover :aria-label="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE" :selected="isRichViewer" :class="{ active: isRichViewer }" + icon="document" + category="primary" + variant="default" @click="switchToViewer($options.RICH_BLOB_VIEWER)" - > - <gl-icon name="document" :size="14" /> - </gl-button> + /> </gl-button-group> </template> diff --git a/app/assets/javascripts/blob/file_template_mediator.js b/app/assets/javascripts/blob/file_template_mediator.js index d2c0ef330e4..4409d7a33cc 100644 --- a/app/assets/javascripts/blob/file_template_mediator.js +++ b/app/assets/javascripts/blob/file_template_mediator.js @@ -1,12 +1,13 @@ import $ from 'jquery'; import Api from '~/api'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import FileTemplateTypeSelector from './template_selectors/type_selector'; import BlobCiYamlSelector from './template_selectors/ci_yaml_selector'; import DockerfileSelector from './template_selectors/dockerfile_selector'; import GitignoreSelector from './template_selectors/gitignore_selector'; import LicenseSelector from './template_selectors/license_selector'; +import MetricsDashboardSelector from './template_selectors/metrics_dashboard_selector'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import initPopover from '~/blob/suggest_gitlab_ci_yml'; @@ -30,6 +31,7 @@ export default class FileTemplateMediator { this.templateSelectors = [ GitignoreSelector, BlobCiYamlSelector, + MetricsDashboardSelector, DockerfileSelector, LicenseSelector, ].map(TemplateSelectorClass => new TemplateSelectorClass({ mediator: this })); diff --git a/app/assets/javascripts/blob/notebook/notebook_viewer.vue b/app/assets/javascripts/blob/notebook/notebook_viewer.vue index b1713989997..ea33d621d47 100644 --- a/app/assets/javascripts/blob/notebook/notebook_viewer.vue +++ b/app/assets/javascripts/blob/notebook/notebook_viewer.vue @@ -1,7 +1,7 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import notebookLab from '~/notebook/index.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; export default { components: { diff --git a/app/assets/javascripts/blob/openapi/index.js b/app/assets/javascripts/blob/openapi/index.js index a6f28de799f..12cc2be8246 100644 --- a/app/assets/javascripts/blob/openapi/index.js +++ b/app/assets/javascripts/blob/openapi/index.js @@ -1,5 +1,5 @@ import { SwaggerUIBundle } from 'swagger-ui-dist'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; export default () => { diff --git a/app/assets/javascripts/blob/pdf/pdf_viewer.vue b/app/assets/javascripts/blob/pdf/pdf_viewer.vue index 64fc832ee54..96d6f500960 100644 --- a/app/assets/javascripts/blob/pdf/pdf_viewer.vue +++ b/app/assets/javascripts/blob/pdf/pdf_viewer.vue @@ -1,6 +1,6 @@ <script> -import PdfLab from '../../pdf/index.vue'; import { GlLoadingIcon } from '@gitlab/ui'; +import PdfLab from '../../pdf/index.vue'; export default { components: { diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index 3ccd84037a7..90eafb75758 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; -import { sprintf, s__, __ } from '~/locale'; import Cookies from 'js-cookie'; +import { sprintf, s__, __ } from '~/locale'; import { glEmojiTag } from '~/emoji'; import Tracking from '~/tracking'; @@ -11,8 +11,12 @@ export default { beginnerLink: 'https://about.gitlab.com/blog/2018/01/22/a-beginners-guide-to-continuous-integration/', exampleLink: 'https://docs.gitlab.com/ee/ci/examples/', + codeQualityLink: 'https://docs.gitlab.com/ee/user/project/merge_requests/code_quality.html', bodyMessage: s__( - 'MR widget|The pipeline will now run automatically every time you commit code. Pipelines are useful for deploying static web pages, detecting vulnerabilities in dependencies, static or dynamic application security testing (SAST and DAST), and so much more!', + `MR widget|The pipeline will test your code on every commit. A %{codeQualityLinkStart}code quality report%{codeQualityLinkEnd} will appear in your merge requests to warn you about potential code degradations.`, + ), + helpMessage: s__( + `MR widget|Take a look at our %{beginnerLinkStart}Beginner's Guide to Continuous Integration%{beginnerLinkEnd} and our %{exampleLinkStart}examples of GitLab CI/CD%{exampleLinkEnd} to learn more.`, ), modalTitle: sprintf( __("That's it, well done!%{celebrate}"), @@ -75,15 +79,15 @@ export default { modal-id="success-pipeline-modal-id-not-used" > <p> - {{ $options.bodyMessage }} + <gl-sprintf :message="$options.bodyMessage"> + <template #codeQualityLink="{content}"> + <gl-link :href="$options.codeQualityLink" target="_blank" class="font-size-inherit">{{ + content + }}</gl-link> + </template> + </gl-sprintf> </p> - <gl-sprintf - :message=" - s__(`MR widget|Take a look at our %{beginnerLinkStart}Beginner's Guide to Continuous Integration%{beginnerLinkEnd} - and our %{exampleLinkStart}examples of GitLab CI/CD%{exampleLinkEnd} - to see all the cool stuff you can do with it.`) - " - > + <gl-sprintf :message="$options.helpMessage"> <template #beginnerLink="{content}"> <gl-link :href="$options.beginnerLink" target="_blank"> {{ content }} @@ -105,7 +109,7 @@ export default { :data-track-event="$options.trackEvent" :data-track-label="trackLabel" > - {{ __('Go to Pipelines') }} + {{ __('See your pipeline in action') }} </a> </template> </gl-modal> diff --git a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue index 932b6e8a0f7..aff6a56cb0b 100644 --- a/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue +++ b/app/assets/javascripts/blob/suggest_gitlab_ci_yml/components/popover.vue @@ -1,5 +1,5 @@ <script> -import { GlPopover, GlSprintf, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; +import { GlPopover, GlSprintf, GlButton } from '@gitlab/ui'; import { parseBoolean, scrollToElement, setCookie, getCookie } from '~/lib/utils/common_utils'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -29,8 +29,7 @@ export default { components: { GlPopover, GlSprintf, - GlIcon, - GlDeprecatedButton, + GlButton, }, mixins: [trackingMixin], props: { @@ -112,18 +111,17 @@ export default { <template #title> <span v-html="suggestTitle"></span> <span class="ml-auto"> - <gl-deprecated-button + <gl-button :aria-label="__('Close')" class="btn-blank" name="dismiss" + icon="close" :data-track-property="humanAccess" :data-track-value="$options.dismissTrackValue" :data-track-event="$options.clickTrackValue" :data-track-label="trackLabel" @click="onDismiss" - > - <gl-icon name="close" aria-hidden="true" /> - </gl-deprecated-button> + /> </span> </template> diff --git a/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js b/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js new file mode 100644 index 00000000000..b4accaadfa3 --- /dev/null +++ b/app/assets/javascripts/blob/template_selectors/metrics_dashboard_selector.js @@ -0,0 +1,28 @@ +import FileTemplateSelector from '../file_template_selector'; + +export default class MetricsDashboardSelector extends FileTemplateSelector { + constructor({ mediator }) { + super(mediator); + this.config = { + key: 'metrics-dashboard-yaml', + name: '.metrics-dashboard.yml', + pattern: /(.metrics-dashboard.yml)/, + type: 'metrics_dashboard_ymls', + dropdown: '.js-metrics-dashboard-selector', + wrapper: '.js-metrics-dashboard-selector-wrap', + }; + } + + initDropdown() { + this.$dropdown.glDropdown({ + data: this.$dropdown.data('data'), + filterable: true, + selectable: true, + search: { + fields: ['name'], + }, + clicked: options => this.reportSelectionName(options), + text: item => item.name, + }); + } +} diff --git a/app/assets/javascripts/blob/utils.js b/app/assets/javascripts/blob/utils.js index 840a3dbe450..a0211c8bb8e 100644 --- a/app/assets/javascripts/blob/utils.js +++ b/app/assets/javascripts/blob/utils.js @@ -1,14 +1,17 @@ import Editor from '~/editor/editor_lite'; -export function initEditorLite({ el, blobPath, blobContent }) { +export function initEditorLite({ el, ...args }) { if (!el) { throw new Error(`"el" parameter is required to initialize Editor`); } - const editor = new Editor(); + const editor = new Editor({ + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + }); editor.createInstance({ el, - blobPath, - blobContent, + ...args, }); return editor; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index b18faea628a..05ee8e49eb1 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; import eventHub from '../../notes/event_hub'; diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 7e5be8454fe..e22c9b0d4c4 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { BLOB_EDITOR_ERROR, BLOB_PREVIEW_ERROR } from './constants'; import TemplateSelectorMediator from '../blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index 3178bda93b8..384a386d69c 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -1,7 +1,28 @@ +import ListIssue from 'ee_else_ce/boards/models/issue'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + export function getMilestone() { return null; } +export function formatListIssues(listIssues) { + return listIssues.nodes.reduce((map, list) => { + return { + ...map, + [list.id]: list.issues.nodes.map( + i => + new ListIssue({ + ...i, + id: getIdFromGraphQLId(i.id), + labels: i.labels?.nodes || [], + assignees: i.assignees?.nodes || [], + }), + ), + }; + }, {}); +} + export default { getMilestone, + formatListIssues, }; diff --git a/app/assets/javascripts/boards/components/board_column.vue b/app/assets/javascripts/boards/components/board_column.vue index 0ed7579e8e1..dae24338e45 100644 --- a/app/assets/javascripts/boards/components/board_column.vue +++ b/app/assets/javascripts/boards/components/board_column.vue @@ -1,10 +1,10 @@ <script> import Sortable from 'sortablejs'; import isWipLimitsOn from 'ee_else_ce/boards/mixins/is_wip_limits'; +import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import Tooltip from '~/vue_shared/directives/tooltip'; import EmptyComponent from '~/vue_shared/components/empty_component'; import BoardBlankState from './board_blank_state.vue'; -import BoardListHeader from 'ee_else_ce/boards/components/board_list_header.vue'; import BoardList from './board_list.vue'; import boardsStore from '../stores/boards_store'; import eventHub from '../eventhub'; diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 6ac7fdce6a7..c42295792f1 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -1,8 +1,8 @@ <script> import { mapState } from 'vuex'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { components: { @@ -42,7 +42,7 @@ export default { }, }, computed: { - ...mapState(['isShowingEpicsSwimlanes']), + ...mapState(['isShowingEpicsSwimlanes', 'boardLists']), isSwimlanesOn() { return this.glFeatures.boardsWithSwimlanes && this.isShowingEpicsSwimlanes; }, @@ -73,11 +73,12 @@ export default { <epics-swimlanes v-else ref="swimlanes" - :lists="lists" + :lists="boardLists" :can-admin-list="canAdminList" :disabled="disabled" :board-id="boardId" :group-id="groupId" + :root-path="rootPath" /> </div> </template> diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index fbe221041c1..231059b895e 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -1,6 +1,6 @@ <script> import { __ } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 4270ad5783d..1a26782f6f0 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -6,7 +6,7 @@ import boardCard from './board_card.vue'; import eventHub from '../eventhub'; import boardsStore from '../stores/boards_store'; import { sprintf, __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getBoardSortableDefaultOptions, sortableStart, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 02a04cb4e46..bafe07afb48 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -241,7 +241,7 @@ export default { v-if="isSwimlanesHeader && !list.isExpanded" ref="collapsedInfo" aria-hidden="true" - class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-700" + class="board-header-collapsed-info-icon gl-mt-2 gl-cursor-pointer gl-text-gray-500" > <gl-icon name="information" /> </span> @@ -282,7 +282,7 @@ export default { <div v-if="showBoardListAndBoardInfo" class="issue-count-badge gl-display-inline-flex gl-pr-0 no-drag text-secondary" - :class="{ 'gl-display-none': !list.isExpanded && isSwimlanesHeader }" + :class="{ 'gl-display-none!': !list.isExpanded && isSwimlanesHeader }" > <span class="gl-display-inline-flex"> <gl-tooltip :target="() => $refs.issueCount" :title="issuesTooltipLabel" /> diff --git a/app/assets/javascripts/boards/components/board_new_issue.vue b/app/assets/javascripts/boards/components/board_new_issue.vue index 02ac45f8ef9..34e8438ba4c 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.vue +++ b/app/assets/javascripts/boards/components/board_new_issue.vue @@ -119,7 +119,7 @@ export default { autocomplete="off" /> <project-select v-if="groupId" :group-id="groupId" :list="list" /> - <div class="clearfix prepend-top-10"> + <div class="clearfix gl-mt-3"> <gl-button ref="submit-button" :disabled="disabled" diff --git a/app/assets/javascripts/boards/components/board_settings_sidebar.vue b/app/assets/javascripts/boards/components/board_settings_sidebar.vue new file mode 100644 index 00000000000..3149762ecdf --- /dev/null +++ b/app/assets/javascripts/boards/components/board_settings_sidebar.vue @@ -0,0 +1,92 @@ +<script> +import { GlDrawer, GlLabel } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { __ } from '~/locale'; +import boardsStore from '~/boards/stores/boards_store'; +import eventHub from '~/sidebar/event_hub'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import { inactiveId } from '~/boards/constants'; + +// NOTE: need to revisit how we handle headerHeight, because we have so many different header and footer options. +export default { + headerHeight: process.env.NODE_ENV === 'development' ? '75px' : '40px', + listSettingsText: __('List settings'), + assignee: 'assignee', + milestone: 'milestone', + label: 'label', + labelListText: __('Label'), + components: { + GlDrawer, + GlLabel, + BoardSettingsSidebarWipLimit: () => + import('ee_component/boards/components/board_settings_wip_limit.vue'), + BoardSettingsListTypes: () => + import('ee_component/boards/components/board_settings_list_types.vue'), + }, + computed: { + ...mapState(['activeId']), + activeList() { + /* + Warning: Though a computed property it is not reactive because we are + referencing a List Model class. Reactivity only applies to plain JS objects + */ + return boardsStore.state.lists.find(({ id }) => id === this.activeId); + }, + isSidebarOpen() { + return this.activeId !== inactiveId; + }, + activeListLabel() { + return this.activeList.label; + }, + boardListType() { + return this.activeList.type || null; + }, + listTypeTitle() { + return this.$options.labelListText; + }, + }, + created() { + eventHub.$on('sidebar.closeAll', this.closeSidebar); + }, + beforeDestroy() { + eventHub.$off('sidebar.closeAll', this.closeSidebar); + }, + methods: { + ...mapActions(['setActiveId']), + closeSidebar() { + this.setActiveId(inactiveId); + }, + showScopedLabels(label) { + return boardsStore.scopedLabels.enabled && isScopedLabel(label); + }, + }, +}; +</script> + +<template> + <gl-drawer + class="js-board-settings-sidebar" + :open="isSidebarOpen" + :header-height="$options.headerHeight" + @close="closeSidebar" + > + <template #header>{{ $options.listSettingsText }}</template> + <template v-if="isSidebarOpen"> + <div v-if="boardListType === $options.label"> + <label class="js-list-label gl-display-block">{{ listTypeTitle }}</label> + <gl-label + :title="activeListLabel.title" + :background-color="activeListLabel.color" + :scoped="showScopedLabels(activeListLabel)" + /> + </div> + + <board-settings-list-types + v-else + :active-list="activeList" + :board-list-type="boardListType" + /> + <board-settings-sidebar-wip-limit :max-issue-count="activeList.maxIssueCount" /> + </template> + </gl-drawer> +</template> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 056a7b48212..3790c494085 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Vue from 'vue'; import { GlLabel } from '@gitlab/ui'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { sprintf, __ } from '~/locale'; import Sidebar from '~/right_sidebar'; import eventHub from '~/sidebar/event_hub'; diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index dbe3e0790f6..48f6ba6cfc7 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -3,10 +3,10 @@ import { throttle } from 'lodash'; import { GlLoadingIcon, GlSearchBoxByType, - GlDropdown, - GlDropdownDivider, - GlDropdownHeader, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, } from '@gitlab/ui'; import httpStatusCodes from '~/lib/utils/http_status'; @@ -26,10 +26,10 @@ export default { BoardForm, GlLoadingIcon, GlSearchBoxByType, - GlDropdown, - GlDropdownDivider, - GlDropdownHeader, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, }, props: { currentBoard: { @@ -235,7 +235,7 @@ export default { <template> <div class="boards-switcher js-boards-selector gl-mr-3"> <span class="boards-selector-wrapper js-boards-selector-wrapper"> - <gl-dropdown + <gl-deprecated-dropdown data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle js-dropdown-toggle" menu-class="flex-column dropdown-extended-height" @@ -248,9 +248,9 @@ export default { </div> </div> - <gl-dropdown-header class="mt-0"> + <gl-deprecated-dropdown-header class="mt-0"> <gl-search-box-by-type ref="searchBox" v-model="filterTerm" /> - </gl-dropdown-header> + </gl-deprecated-dropdown-header> <div v-if="!loading" @@ -259,26 +259,26 @@ export default { class="dropdown-content flex-fill" @scroll.passive="throttledSetScrollFade" > - <gl-dropdown-item + <gl-deprecated-dropdown-item v-show="filteredBoards.length === 0" class="no-pointer-events text-secondary" > {{ s__('IssueBoards|No matching boards found') }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> <h6 v-if="showRecentSection" class="dropdown-bold-header my-0"> {{ __('Recent') }} </h6> <template v-if="showRecentSection"> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="recentBoard in recentBoards" :key="`recent-${recentBoard.id}`" class="js-dropdown-item" :href="`${boardBaseUrl}/${recentBoard.id}`" > {{ recentBoard.name }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </template> <hr v-if="showRecentSection" class="my-1" /> @@ -287,21 +287,21 @@ export default { {{ __('All') }} </h6> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="otherBoard in filteredBoards" :key="otherBoard.id" class="js-dropdown-item" :href="`${boardBaseUrl}/${otherBoard.id}`" > {{ otherBoard.name }} - </gl-dropdown-item> - <gl-dropdown-item v-if="hasMissingBoards" class="small unclickable"> + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-item v-if="hasMissingBoards" class="small unclickable"> {{ s__( 'IssueBoards|Some of your boards are hidden, activate a license to see them again.', ) }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </div> <div @@ -313,25 +313,25 @@ export default { <gl-loading-icon v-if="loading" /> <div v-if="canAdminBoard"> - <gl-dropdown-divider /> + <gl-deprecated-dropdown-divider /> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-if="multipleIssueBoardsAvailable" data-qa-selector="create_new_board_button" @click.prevent="showPage('new')" > {{ s__('IssueBoards|Create new board') }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-if="showDelete" class="text-danger js-delete-board" @click.prevent="showPage('delete')" > {{ s__('IssueBoards|Delete board') }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </div> - </gl-dropdown> + </gl-deprecated-dropdown> <board-form v-if="currentPage" diff --git a/app/assets/javascripts/boards/components/issue_due_date.vue b/app/assets/javascripts/boards/components/issue_due_date.vue index 1d70c635c18..4add5ee646a 100644 --- a/app/assets/javascripts/boards/components/issue_due_date.vue +++ b/app/assets/javascripts/boards/components/issue_due_date.vue @@ -87,11 +87,7 @@ export default { <template> <span> <span ref="issueDueDate" :class="cssClass" class="board-card-info card-number"> - <icon - :class="{ 'text-danger': isPastDue }" - class="board-card-info-icon align-top" - name="calendar" - /> + <icon :class="{ 'text-danger': isPastDue }" class="board-card-info-icon" name="calendar" /> <time :class="{ 'text-danger': isPastDue }" datetime="date" class="board-card-info-text">{{ body }}</time> diff --git a/app/assets/javascripts/boards/components/issue_time_estimate.vue b/app/assets/javascripts/boards/components/issue_time_estimate.vue index 5c33ba9461c..e8b7689da13 100644 --- a/app/assets/javascripts/boards/components/issue_time_estimate.vue +++ b/app/assets/javascripts/boards/components/issue_time_estimate.vue @@ -34,10 +34,9 @@ export default { <template> <span> <span ref="issueTimeEstimate" class="board-card-info card-number"> - <icon name="hourglass" class="board-card-info-icon align-top" /><time - class="board-card-info-text" - >{{ timeEstimate }}</time - > + <icon name="hourglass" class="board-card-info-icon" /><time class="board-card-info-text">{{ + timeEstimate + }}</time> </span> <gl-tooltip :target="() => $refs.issueTimeEstimate" diff --git a/app/assets/javascripts/boards/components/modal/footer.vue b/app/assets/javascripts/boards/components/modal/footer.vue index 5f100c617a0..c4953dda793 100644 --- a/app/assets/javascripts/boards/components/modal/footer.vue +++ b/app/assets/javascripts/boards/components/modal/footer.vue @@ -1,6 +1,6 @@ <script> import footerEEMixin from 'ee_else_ce/boards/mixins/modal_footer'; -import Flash from '../../../flash'; +import { deprecatedCreateFlash as Flash } from '../../../flash'; import { __, n__ } from '../../../locale'; import ListsDropdown from './lists_dropdown.vue'; import ModalStore from '../../stores/modal_store'; diff --git a/app/assets/javascripts/boards/components/modal/header.vue b/app/assets/javascripts/boards/components/modal/header.vue index 8eae8e4726f..573284d2b44 100644 --- a/app/assets/javascripts/boards/components/modal/header.vue +++ b/app/assets/javascripts/boards/components/modal/header.vue @@ -67,7 +67,7 @@ export default { </h2> </header> <modal-tabs v-if="!loading && issuesCount > 0" /> - <div v-if="showSearch" class="d-flex append-bottom-10"> + <div v-if="showSearch" class="d-flex gl-mb-3"> <modal-filters :store="filter" /> <button ref="selectAllBtn" diff --git a/app/assets/javascripts/boards/components/modal/tabs.vue b/app/assets/javascripts/boards/components/modal/tabs.vue index ed67206218e..a71fda9d7c5 100644 --- a/app/assets/javascripts/boards/components/modal/tabs.vue +++ b/app/assets/javascripts/boards/components/modal/tabs.vue @@ -19,7 +19,7 @@ export default { }; </script> <template> - <div class="top-area prepend-top-10 append-bottom-10"> + <div class="top-area gl-mt-3 gl-mb-3"> <ul class="nav-links issues-state-filters"> <li :class="{ active: activeTab == 'all' }"> <a href="#" role="button" @click.prevent="changeTab('all')"> diff --git a/app/assets/javascripts/boards/components/new_list_dropdown.js b/app/assets/javascripts/boards/components/new_list_dropdown.js index 229bb82152b..2b9fdf11b37 100644 --- a/app/assets/javascripts/boards/components/new_list_dropdown.js +++ b/app/assets/javascripts/boards/components/new_list_dropdown.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import CreateLabelDropdown from '../../create_label'; import boardsStore from '../stores/boards_store'; diff --git a/app/assets/javascripts/boards/components/project_select.vue b/app/assets/javascripts/boards/components/project_select.vue index 8fd377938b4..598e92726c1 100644 --- a/app/assets/javascripts/boards/components/project_select.vue +++ b/app/assets/javascripts/boards/components/project_select.vue @@ -64,10 +64,10 @@ export default { this.groupId, term, { - search_namespaces: true, with_issues_enabled: true, with_shared: false, include_subgroups: true, + order_by: 'similarity', ...additionalAttrs, }, projects => { @@ -97,7 +97,7 @@ export default { <template> <div> - <label class="label-bold prepend-top-10">{{ __('Project') }}</label> + <label class="label-bold gl-mt-3">{{ __('Project') }}</label> <div ref="projectsDropdown" class="dropdown dropdown-projects"> <button class="dropdown-menu-toggle wide" diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue index 71e5d8058da..4e5a6609042 100644 --- a/app/assets/javascripts/boards/components/sidebar/remove_issue.vue +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.vue @@ -1,10 +1,14 @@ <script> +import { GlButton } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import Flash from '../../../flash'; +import { deprecatedCreateFlash as Flash } from '../../../flash'; import { __ } from '../../../locale'; import boardsStore from '../../stores/boards_store'; export default { + components: { + GlButton, + }, props: { issue: { type: Object, @@ -75,8 +79,8 @@ export default { </script> <template> <div class="block list"> - <button class="btn btn-default btn-block" type="button" @click="removeIssue"> + <gl-button variant="default" category="secondary" block="block" @click="removeIssue"> {{ __('Remove from board') }} - </button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index f577a168e75..35c52558cac 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -13,7 +13,7 @@ export const ListType = { blank: 'blank', }; -export const inactiveListId = 0; +export const inactiveId = 0; export default { BoardType, diff --git a/app/assets/javascripts/boards/eventhub.js b/app/assets/javascripts/boards/eventhub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/boards/eventhub.js +++ b/app/assets/javascripts/boards/eventhub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/boards/filtered_search_boards.js b/app/assets/javascripts/boards/filtered_search_boards.js index ca85e54eb89..b7966dd869d 100644 --- a/app/assets/javascripts/boards/filtered_search_boards.js +++ b/app/assets/javascripts/boards/filtered_search_boards.js @@ -1,6 +1,6 @@ import IssuableFilteredSearchTokenKeys from 'ee_else_ce/filtered_search/issuable_filtered_search_token_keys'; -import FilteredSearchContainer from '../filtered_search/container'; import FilteredSearchManager from 'ee_else_ce/filtered_search/filtered_search_manager'; +import FilteredSearchContainer from '../filtered_search/container'; import boardsStore from './stores/boards_store'; export default class FilteredSearchBoards extends FilteredSearchManager { @@ -10,6 +10,7 @@ export default class FilteredSearchBoards extends FilteredSearchManager { isGroupDecendent: true, stateFiltersSelector: '.issues-state-filters', isGroup: IS_EE, + useDefaultState: false, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 5b4a1d262dd..971edd71eec 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -4,7 +4,6 @@ import { mapActions } from 'vuex'; import 'ee_else_ce/boards/models/issue'; import 'ee_else_ce/boards/models/list'; -import BoardContent from '~/boards/components/board_content.vue'; import BoardSidebar from 'ee_else_ce/boards/components/board_sidebar'; import initNewListDropdown from 'ee_else_ce/boards/components/new_list_dropdown'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; @@ -19,8 +18,9 @@ import { } from 'ee_else_ce/boards/ee_functions'; import VueApollo from 'vue-apollo'; +import BoardContent from '~/boards/components/board_content.vue'; import createDefaultClient from '~/lib/graphql'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; import './models/label'; import './models/assignee'; @@ -83,8 +83,7 @@ export default () => { Board: () => import('ee_else_ce/boards/components/board_column.vue'), BoardSidebar, BoardAddIssuesModal, - BoardSettingsSidebar: () => - import('ee_component/boards/components/board_settings_sidebar.vue'), + BoardSettingsSidebar: () => import('~/boards/components/board_settings_sidebar.vue'), }, store, apolloProvider, @@ -118,7 +117,7 @@ export default () => { boardId: this.boardId, fullPath: $boardApp.dataset.fullPath, }; - this.setEndpoints(endpoints); + this.setInitialBoardData({ ...endpoints, boardType: this.parent }); boardsStore.setEndpoints(endpoints); boardsStore.rootPath = this.boardsEndpoint; @@ -190,7 +189,7 @@ export default () => { } }, methods: { - ...mapActions(['setEndpoints']), + ...mapActions(['setInitialBoardData']), updateTokens() { this.filterManager.updateTokens(); }, diff --git a/app/assets/javascripts/boards/models/list.js b/app/assets/javascripts/boards/models/list.js index 2aa92f86125..b8b30c958a9 100644 --- a/app/assets/javascripts/boards/models/list.js +++ b/app/assets/javascripts/boards/models/list.js @@ -1,12 +1,11 @@ /* eslint-disable no-underscore-dangle, class-methods-use-this */ - -import ListIssue from 'ee_else_ce/boards/models/issue'; import { __ } from '~/locale'; import ListLabel from './label'; import ListAssignee from './assignee'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import boardsStore from '../stores/boards_store'; import ListMilestone from './milestone'; +import 'ee_else_ce/boards/models/issue'; const TYPES = { backlog: { @@ -61,7 +60,9 @@ class List { this.title = this.milestone.title; } - if (!typeInfo.isBlank && this.id) { + // doNotFetchIssues is a temporary workaround until issues are fetched using GraphQL on issue boards + // Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/229416 + if (!typeInfo.isBlank && this.id && !obj.doNotFetchIssues) { this.getIssues().catch(() => { // TODO: handle request error }); @@ -100,12 +101,6 @@ class List { return boardsStore.newListIssue(this, issue); } - createIssues(data) { - data.forEach(issueObj => { - this.addIssue(new ListIssue(issueObj)); - }); - } - addMultipleIssues(issues, listFrom, newIndex) { boardsStore.addMultipleListIssues(this, issues, listFrom, newIndex); } diff --git a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql index 5b532906f6a..8abd79332fb 100644 --- a/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql +++ b/app/assets/javascripts/boards/queries/board_list_shared.fragment.graphql @@ -4,6 +4,7 @@ fragment BoardListShared on BoardList { position listType collapsed + maxIssueCount label { id title diff --git a/app/assets/javascripts/boards/queries/group_lists_issues.query.graphql b/app/assets/javascripts/boards/queries/group_lists_issues.query.graphql new file mode 100644 index 00000000000..724c7884c58 --- /dev/null +++ b/app/assets/javascripts/boards/queries/group_lists_issues.query.graphql @@ -0,0 +1,18 @@ +#import "./issue.fragment.graphql" + +query GroupListIssues($fullPath: ID!, $boardId: ID!) { + group(fullPath: $fullPath) { + board(id: $boardId) { + lists { + nodes { + id + issues { + nodes { + ...IssueNode + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/boards/queries/issue.fragment.graphql b/app/assets/javascripts/boards/queries/issue.fragment.graphql new file mode 100644 index 00000000000..89d56b895a4 --- /dev/null +++ b/app/assets/javascripts/boards/queries/issue.fragment.graphql @@ -0,0 +1,31 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + +fragment IssueNode on Issue { + id + iid + title + referencePath: reference(full: true) + dueDate + timeEstimate + weight + confidential + webUrl + subscribed + blocked + epic { + id + } + assignees { + nodes { + ...User + } + } + labels { + nodes { + id + title + color + description + } + } +} diff --git a/app/assets/javascripts/boards/queries/project_lists_issues.query.graphql b/app/assets/javascripts/boards/queries/project_lists_issues.query.graphql new file mode 100644 index 00000000000..149b76848ef --- /dev/null +++ b/app/assets/javascripts/boards/queries/project_lists_issues.query.graphql @@ -0,0 +1,18 @@ +#import "./issue.fragment.graphql" + +query ProjectListIssues($fullPath: ID!, $boardId: ID!) { + project(fullPath: $fullPath) { + board(id: $boardId) { + lists { + nodes { + id + issues { + nodes { + ...IssueNode + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 08fedb14dff..b4be7546252 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,4 +1,11 @@ import * as types from './mutation_types'; +import createDefaultClient from '~/lib/graphql'; +import { BoardType } from '~/boards/constants'; +import { formatListIssues } from '../boards_util'; +import groupListsIssuesQuery from '../queries/group_lists_issues.query.graphql'; +import projectListsIssuesQuery from '../queries/project_lists_issues.query.graphql'; + +const gqlClient = createDefaultClient(); const notImplemented = () => { /* eslint-disable-next-line @gitlab/require-i18n-strings */ @@ -6,8 +13,12 @@ const notImplemented = () => { }; export default { - setEndpoints: ({ commit }, endpoints) => { - commit(types.SET_ENDPOINTS, endpoints); + setInitialBoardData: ({ commit }, data) => { + commit(types.SET_INITIAL_BOARD_DATA, data); + }, + + setActiveId({ commit }, id) { + commit(types.SET_ACTIVE_ID, id); }, fetchLists: () => { @@ -34,6 +45,32 @@ export default { notImplemented(); }, + fetchIssuesForAllLists: ({ state, commit }) => { + commit(types.REQUEST_ISSUES_FOR_ALL_LISTS); + + const { endpoints, boardType } = state; + const { fullPath, boardId } = endpoints; + + const query = boardType === BoardType.group ? groupListsIssuesQuery : projectListsIssuesQuery; + + const variables = { + fullPath, + boardId: `gid://gitlab/Board/${boardId}`, + }; + + return gqlClient + .query({ + query, + variables, + }) + .then(({ data }) => { + const { lists } = data[boardType]?.board; + const listIssues = formatListIssues(lists); + commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS, listIssues); + }) + .catch(() => commit(types.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE)); + }, + moveIssue: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index da7d2e19ec1..30c71d64085 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -1,6 +1,6 @@ /* eslint-disable no-shadow, no-param-reassign,consistent-return */ /* global List */ - +/* global ListIssue */ import $ from 'jquery'; import { sortBy } from 'lodash'; import Vue from 'vue'; @@ -81,7 +81,7 @@ const boardsStore = { showPage(page) { this.state.currentPage = page; }, - addList(listObj) { + updateListPosition(listObj) { const listType = listObj.listType || listObj.list_type; let { position } = listObj; if (listType === ListType.closed) { @@ -91,6 +91,10 @@ const boardsStore = { } const list = new List({ ...listObj, position }); + return list; + }, + addList(listObj) { + const list = this.updateListPosition(listObj); this.state.lists = sortBy([...this.state.lists, list], 'position'); return list; }, @@ -641,7 +645,9 @@ const boardsStore = { list.issues = []; } - list.createIssues(data.issues); + data.issues.forEach(issueObj => { + list.addIssue(new ListIssue(issueObj)); + }); return data; }); @@ -848,19 +854,28 @@ const boardsStore = { }, refreshIssueData(issue, obj) { - issue.id = obj.id; - issue.iid = obj.iid; - issue.title = obj.title; - issue.confidential = obj.confidential; - issue.dueDate = obj.due_date; - issue.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; - issue.referencePath = obj.reference_path; - issue.path = obj.real_path; - issue.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; + // issue.id = obj.id; + // issue.iid = obj.iid; + // issue.title = obj.title; + // issue.confidential = obj.confidential; + // issue.dueDate = obj.due_date || obj.dueDate; + // issue.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; + // issue.referencePath = obj.reference_path || obj.referencePath; + // issue.path = obj.real_path || obj.webUrl; + // issue.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint; + // issue.project_id = obj.project_id; + // issue.timeEstimate = obj.time_estimate || obj.timeEstimate; + // issue.assignableLabelsEndpoint = obj.assignable_labels_endpoint; + // issue.blocked = obj.blocked; + // issue.epic = obj.epic; + + const convertedObj = convertObjectPropsToCamelCase(obj, { + dropKeys: ['issue_sidebar_endpoint', 'real_path', 'webUrl'], + }); + convertedObj.sidebarInfoEndpoint = obj.issue_sidebar_endpoint; + issue.path = obj.real_path || obj.webUrl; issue.project_id = obj.project_id; - issue.timeEstimate = obj.time_estimate; - issue.assignableLabelsEndpoint = obj.assignable_labels_endpoint; - issue.blocked = obj.blocked; + Object.assign(issue, convertedObj); if (obj.project) { issue.project = new IssueProject(obj.project); diff --git a/app/assets/javascripts/boards/stores/mutation_types.js b/app/assets/javascripts/boards/stores/mutation_types.js index fcdfa6799b6..0f96dc2e287 100644 --- a/app/assets/javascripts/boards/stores/mutation_types.js +++ b/app/assets/javascripts/boards/stores/mutation_types.js @@ -1,4 +1,4 @@ -export const SET_ENDPOINTS = 'SET_ENDPOINTS'; +export const SET_INITIAL_BOARD_DATA = 'SET_INITIAL_BOARD_DATA'; export const REQUEST_ADD_LIST = 'REQUEST_ADD_LIST'; export const RECEIVE_ADD_LIST_SUCCESS = 'RECEIVE_ADD_LIST_SUCCESS'; export const RECEIVE_ADD_LIST_ERROR = 'RECEIVE_ADD_LIST_ERROR'; @@ -8,6 +8,9 @@ export const RECEIVE_UPDATE_LIST_ERROR = 'RECEIVE_UPDATE_LIST_ERROR'; export const REQUEST_REMOVE_LIST = 'REQUEST_REMOVE_LIST'; export const RECEIVE_REMOVE_LIST_SUCCESS = 'RECEIVE_REMOVE_LIST_SUCCESS'; export const RECEIVE_REMOVE_LIST_ERROR = 'RECEIVE_REMOVE_LIST_ERROR'; +export const REQUEST_ISSUES_FOR_ALL_LISTS = 'REQUEST_ISSUES_FOR_ALL_LISTS'; +export const RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS = 'RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS'; +export const RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE = 'RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE'; export const REQUEST_ADD_ISSUE = 'REQUEST_ADD_ISSUE'; export const RECEIVE_ADD_ISSUE_SUCCESS = 'RECEIVE_ADD_ISSUE_SUCCESS'; export const RECEIVE_ADD_ISSUE_ERROR = 'RECEIVE_ADD_ISSUE_ERROR'; @@ -19,3 +22,4 @@ export const RECEIVE_UPDATE_ISSUE_SUCCESS = 'RECEIVE_UPDATE_ISSUE_SUCCESS'; export const RECEIVE_UPDATE_ISSUE_ERROR = 'RECEIVE_UPDATE_ISSUE_ERROR'; export const SET_CURRENT_PAGE = 'SET_CURRENT_PAGE'; export const TOGGLE_EMPTY_STATE = 'TOGGLE_EMPTY_STATE'; +export const SET_ACTIVE_ID = 'SET_ACTIVE_ID'; diff --git a/app/assets/javascripts/boards/stores/mutations.js b/app/assets/javascripts/boards/stores/mutations.js index e4459cdcc07..ca9b911ce5b 100644 --- a/app/assets/javascripts/boards/stores/mutations.js +++ b/app/assets/javascripts/boards/stores/mutations.js @@ -6,8 +6,14 @@ const notImplemented = () => { }; export default { - [mutationTypes.SET_ENDPOINTS]: (state, endpoints) => { + [mutationTypes.SET_INITIAL_BOARD_DATA]: (state, data) => { + const { boardType, ...endpoints } = data; state.endpoints = endpoints; + state.boardType = boardType; + }, + + [mutationTypes.SET_ACTIVE_ID](state, id) { + state.activeId = id; }, [mutationTypes.REQUEST_ADD_LIST]: () => { @@ -46,6 +52,20 @@ export default { notImplemented(); }, + [mutationTypes.REQUEST_ISSUES_FOR_ALL_LISTS]: state => { + state.isLoadingIssues = true; + }, + + [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS]: (state, listIssues) => { + state.issuesByListId = listIssues; + state.isLoadingIssues = false; + }, + + [mutationTypes.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE]: state => { + state.listIssueFetchFailure = true; + state.isLoadingIssues = false; + }, + [mutationTypes.REQUEST_ADD_ISSUE]: () => { notImplemented(); }, diff --git a/app/assets/javascripts/boards/stores/state.js b/app/assets/javascripts/boards/stores/state.js index aca93c4d7c6..cb6930774ed 100644 --- a/app/assets/javascripts/boards/stores/state.js +++ b/app/assets/javascripts/boards/stores/state.js @@ -1,7 +1,11 @@ -import { inactiveListId } from '~/boards/constants'; +import { inactiveId } from '~/boards/constants'; export default () => ({ endpoints: {}, + boardType: null, isShowingLabels: true, - activeListId: inactiveListId, + activeId: inactiveId, + issuesByListId: {}, + isLoadingIssues: false, + listIssueFetchFailure: false, }); diff --git a/app/assets/javascripts/branches/components/divergence_graph.vue b/app/assets/javascripts/branches/components/divergence_graph.vue index 36fff370ea1..deaed694b46 100644 --- a/app/assets/javascripts/branches/components/divergence_graph.vue +++ b/app/assets/javascripts/branches/components/divergence_graph.vue @@ -65,7 +65,7 @@ export default { </template> <template v-else> <graph-bar :count="behindCount" :max-commits="maxCommits" position="left" /> - <div class="graph-separator pull-left mt-1"></div> + <div class="graph-separator float-left mt-1"></div> <graph-bar :count="aheadCount" :max-commits="maxCommits" position="right" /> </template> </div> diff --git a/app/assets/javascripts/branches/components/graph_bar.vue b/app/assets/javascripts/branches/components/graph_bar.vue index 83da41ca097..21cbcac820a 100644 --- a/app/assets/javascripts/branches/components/graph_bar.vue +++ b/app/assets/javascripts/branches/components/graph_bar.vue @@ -56,7 +56,7 @@ export default { </script> <template> - <div :class="{ full: isFullWidth }" class="position-relative pull-left pt-1 graph-side h-100"> + <div :class="{ full: isFullWidth }" class="position-relative float-left pt-1 graph-side h-100"> <div :style="style" :class="[roundedClass, positionSideClass]" diff --git a/app/assets/javascripts/branches/divergence_graph.js b/app/assets/javascripts/branches/divergence_graph.js index 303735a1807..89e9d3fcb62 100644 --- a/app/assets/javascripts/branches/divergence_graph.js +++ b/app/assets/javascripts/branches/divergence_graph.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import { __ } from '../locale'; -import createFlash from '../flash'; +import { deprecatedCreateFlash as createFlash } from '../flash'; import axios from '../lib/utils/axios_utils'; import DivergenceGraph from './components/divergence_graph.vue'; diff --git a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js index 470649e63fb..b8bf363fc9d 100644 --- a/app/assets/javascripts/ci_variable_list/ajax_variable_list.js +++ b/app/assets/javascripts/ci_variable_list/ajax_variable_list.js @@ -1,7 +1,7 @@ import { escape } from 'lodash'; import axios from '../lib/utils/axios_utils'; import { s__ } from '../locale'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import { parseBoolean } from '../lib/utils/common_utils'; import statusCodes from '../lib/utils/http_status'; import VariableList from './ci_variable_list'; diff --git a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue index 175e89a454b..d22fef27964 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_environments_dropdown.vue @@ -1,20 +1,20 @@ <script> import { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, GlSearchBoxByType, GlIcon, } from '@gitlab/ui'; -import { __, sprintf } from '~/locale'; import { mapGetters } from 'vuex'; +import { __, sprintf } from '~/locale'; export default { name: 'CiEnvironmentsDropdown', components: { - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, GlSearchBoxByType, GlIcon, }, @@ -66,9 +66,9 @@ export default { }; </script> <template> - <gl-dropdown :text="value"> + <gl-deprecated-dropdown :text="value"> <gl-search-box-by-type v-model.trim="searchTerm" class="m-2" /> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="environment in filteredResults" :key="environment" @click="selectEnvironment(environment)" @@ -79,15 +79,15 @@ export default { class="vertical-align-middle" /> {{ environment }} - </gl-dropdown-item> - <gl-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-item v-if="!filteredResults.length" ref="noMatchingResults">{{ __('No matching results') - }}</gl-dropdown-item> + }}</gl-deprecated-dropdown-item> <template v-if="shouldRenderCreateButton"> - <gl-dropdown-divider /> - <gl-dropdown-item @click="createClicked"> + <gl-deprecated-dropdown-divider /> + <gl-deprecated-dropdown-item @click="createClicked"> {{ composedCreateButtonLabel }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </template> - </gl-dropdown> + </gl-deprecated-dropdown> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue index 0ba58430de1..fbf19847e9d 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_modal.vue @@ -3,7 +3,6 @@ import { GlAlert, GlButton, GlCollapse, - GlDeprecatedButton, GlFormCheckbox, GlFormCombobox, GlFormGroup, @@ -39,7 +38,6 @@ export default { GlAlert, GlButton, GlCollapse, - GlDeprecatedButton, GlFormCheckbox, GlFormCombobox, GlFormGroup, @@ -210,6 +208,7 @@ export default { v-model="key" :token-list="$options.tokenList" :label-text="__('Key')" + data-qa-selector="ci_variable_key_field" /> <gl-form-group v-else :label="__('Key')" label-for="ci-variable-key"> @@ -242,7 +241,7 @@ export default { <gl-form-group :label="__('Type')" label-for="ci-variable-type" - class="w-50 append-right-15" + class="w-50 gl-mr-5" :class="{ 'w-100': isGroup }" > <gl-form-select id="ci-variable-type" v-model="variable_type" :options="typeOptions" /> @@ -339,24 +338,25 @@ export default { </gl-alert> </gl-collapse> <template #modal-footer> - <gl-deprecated-button @click="hideModal">{{ __('Cancel') }}</gl-deprecated-button> - <gl-deprecated-button + <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> + <gl-button v-if="variableBeingEdited" ref="deleteCiVariable" - category="secondary" variant="danger" + category="secondary" data-qa-selector="ci_variable_delete_button" @click="deleteVarAndClose" - >{{ __('Delete variable') }}</gl-deprecated-button + >{{ __('Delete variable') }}</gl-button > - <gl-deprecated-button + <gl-button ref="updateOrAddVariable" :disabled="!canSubmit" variant="success" + category="primary" data-qa-selector="ci_variable_save_button" @click="updateOrAddVariable" >{{ modalActionText }} - </gl-deprecated-button> + </gl-button> </template> </gl-modal> </template> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue index 07b0d55bd4c..431819124c2 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_popover.vue @@ -1,12 +1,11 @@ <script> -import { GlPopover, GlIcon, GlDeprecatedButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; export default { maxTextLength: 95, components: { GlPopover, - GlIcon, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -39,16 +38,18 @@ export default { <template> <div id="popover-container"> <gl-popover :target="target" triggers="hover" placement="top" container="popover-container"> - <div class="d-flex justify-content-between position-relative"> - <div class="pr-5 w-100 ci-popover-value">{{ displayValue }}</div> - <gl-deprecated-button + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div class="ci-popover-value gl-pr-3"> + {{ displayValue }} + </div> + <gl-button v-gl-tooltip - class="btn-transparent btn-clipboard position-absolute position-top-0 position-right-0" + category="tertiary" + icon="copy-to-clipboard" :title="tooltipText" :data-clipboard-text="value" - > - <gl-icon name="copy-to-clipboard" /> - </gl-deprecated-button> + :aria-label="__('Copy to clipboard')" + /> </div> </gl-popover> </div> diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue index ed1240c247f..12bc5ad3549 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_settings.vue @@ -1,7 +1,7 @@ <script> +import { mapState, mapActions } from 'vuex'; import CiVariableModal from './ci_variable_modal.vue'; import CiVariableTable from './ci_variable_table.vue'; -import { mapState, mapActions } from 'vuex'; export default { components: { diff --git a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue index 7b703c5ede1..018704bff74 100644 --- a/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci_variable_list/components/ci_variable_table.vue @@ -1,7 +1,7 @@ <script> -import { GlTable, GlDeprecatedButton, GlModalDirective, GlIcon } from '@gitlab/ui'; -import { s__, __ } from '~/locale'; +import { GlTable, GlButton, GlModalDirective, GlIcon } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; +import { s__, __ } from '~/locale'; import { ADD_CI_VARIABLE_MODAL_ID } from '../constants'; import CiVariablePopover from './ci_variable_popover.vue'; @@ -51,7 +51,7 @@ export default { ], components: { GlTable, - GlDeprecatedButton, + GlButton, GlIcon, CiVariablePopover, }, @@ -147,14 +147,14 @@ export default { </div> </template> <template #cell(actions)="{ item }"> - <gl-deprecated-button + <gl-button ref="edit-ci-variable" v-gl-modal-directive="$options.modalId" + icon="pencil" + :aria-label="__('Edit')" data-qa-selector="edit_ci_variable_button" @click="editVariable(item)" - > - <gl-icon :size="$options.iconSize" name="pencil" /> - </gl-deprecated-button> + /> </template> <template #empty> <p ref="empty-variables" class="text-center empty-variables text-plain"> @@ -166,20 +166,21 @@ export default { class="ci-variable-actions d-flex justify-content-end" :class="{ 'justify-content-center': !tableIsNotEmpty }" > - <gl-deprecated-button + <gl-button v-if="tableIsNotEmpty" ref="secret-value-reveal-button" data-qa-selector="reveal_ci_variable_value_button" class="gl-mr-3" @click="toggleValues(!valuesHidden)" - >{{ valuesButtonText }}</gl-deprecated-button + >{{ valuesButtonText }}</gl-button > - <gl-deprecated-button + <gl-button ref="add-ci-variable" v-gl-modal-directive="$options.modalId" data-qa-selector="add_ci_variable_button" variant="success" - >{{ __('Add Variable') }}</gl-deprecated-button + category="primary" + >{{ __('Add Variable') }}</gl-button > </div> </div> diff --git a/app/assets/javascripts/ci_variable_list/store/actions.js b/app/assets/javascripts/ci_variable_list/store/actions.js index 60c7a480769..e3e9dac0a79 100644 --- a/app/assets/javascripts/ci_variable_list/store/actions.js +++ b/app/assets/javascripts/ci_variable_list/store/actions.js @@ -1,7 +1,7 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { prepareDataForApi, prepareDataForDisplay, prepareEnvironments } from './utils'; diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 83bdea15e62..92517203972 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -4,23 +4,15 @@ import { GlToast } from '@gitlab/ui'; import AccessorUtilities from '~/lib/utils/accessor'; import PersistentUserCallout from '../persistent_user_callout'; import { s__, sprintf } from '../locale'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import Poll from '../lib/utils/poll'; import initSettingsPanels from '../settings_panels'; import eventHub from './event_hub'; -import { - APPLICATION_STATUS, - INGRESS, - INGRESS_DOMAIN_SUFFIX, - CROSSPLANE, - KNATIVE, - FLUENTD, -} from './constants'; +import { APPLICATION_STATUS, CROSSPLANE, KNATIVE, FLUENTD } from './constants'; import ClustersService from './services/clusters_service'; import ClustersStore from './stores/clusters_store'; import Applications from './components/applications.vue'; import RemoveClusterConfirmation from './components/remove_cluster_confirmation.vue'; -import setupToggleButtons from '../toggle_buttons'; import initProjectSelectDropdown from '~/project_select'; import initServerlessSurveyBanner from '~/serverless/survey_banner'; @@ -68,6 +60,7 @@ export default class Clusters { deployBoardsHelpPath, cloudRunHelpPath, clusterId, + ciliumHelpPath, } = document.querySelector('.js-edit-cluster-form').dataset; this.clusterId = clusterId; @@ -84,6 +77,7 @@ export default class Clusters { clustersHelpPath, deployBoardsHelpPath, cloudRunHelpPath, + ciliumHelpPath, ); this.store.setManagePrometheusPath(managePrometheusPath); this.store.updateStatus(clusterStatus); @@ -119,19 +113,11 @@ export default class Clusters { this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); this.tokenField = document.querySelector('.js-cluster-token'); - this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text'); - this.ingressDomainSnippet = - this.ingressDomainHelpText && - this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet'); initProjectSelectDropdown(); Clusters.initDismissableCallout(); initSettingsPanels(); - const toggleButtonsContainer = document.querySelector('.js-cluster-enable-toggle-area'); - if (toggleButtonsContainer) { - setupToggleButtons(toggleButtonsContainer); - } this.initApplications(clusterType); this.initEnvironments(); @@ -184,6 +170,7 @@ export default class Clusters { providerType: this.state.providerType, preInstalledKnative: this.state.preInstalledKnative, rbac: this.state.rbac, + ciliumHelpPath: this.state.ciliumHelpPath, }, }); }, @@ -329,13 +316,6 @@ export default class Clusters { this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason); - if (this.ingressDomainHelpText) { - this.toggleIngressDomainHelpText( - prevApplicationMap[INGRESS], - this.store.state.applications[INGRESS], - ); - } - if (this.store.state.applications[KNATIVE]?.status === APPLICATION_STATUS.INSTALLED) { initServerlessSurveyBanner(); } @@ -507,13 +487,6 @@ export default class Clusters { }); } - toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) { - if (externalIp !== newExternalIp) { - this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp); - this.ingressDomainSnippet.textContent = `${newExternalIp}${INGRESS_DOMAIN_SUFFIX}`; - } - } - saveKnativeDomain(data) { const appId = data.id; this.store.updateApplication(appId); diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue index ba6de41e025..c86db28515f 100644 --- a/app/assets/javascripts/clusters/components/application_row.vue +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -52,6 +52,11 @@ export default { required: false, default: false, }, + installable: { + type: Boolean, + required: false, + default: true, + }, uninstallable: { type: Boolean, required: false, @@ -141,6 +146,7 @@ export default { return ( this.status === APPLICATION_STATUS.NOT_INSTALLABLE || this.status === APPLICATION_STATUS.INSTALLABLE || + this.status === APPLICATION_STATUS.UNINSTALLED || this.isUnknownStatus ); }, @@ -164,14 +170,20 @@ export default { return !this.status || this.isInstalling; }, installButtonDisabled() { + // Applications installed through the management project can + // only be installed through the CI pipeline. Installation should + // be disable in all states. + if (!this.installable) return true; + // Avoid the potential for the real-time data to say APPLICATION_STATUS.INSTALLABLE but // we already made a request to install and are just waiting for the real-time // to sync up. + if (this.isInstalling) return true; + + if (!this.isKnownStatus) return false; + return ( - ((this.status !== APPLICATION_STATUS.INSTALLABLE && - this.status !== APPLICATION_STATUS.ERROR) || - this.isInstalling) && - this.isKnownStatus + this.status !== APPLICATION_STATUS.INSTALLABLE && this.status !== APPLICATION_STATUS.ERROR ); }, installButtonLabel() { @@ -335,7 +347,7 @@ export default { <div> <slot name="description"></slot> </div> - <div v-if="hasError" class="cluster-application-error text-danger prepend-top-10"> + <div v-if="hasError" class="cluster-application-error text-danger gl-mt-3"> <p class="js-cluster-application-general-error-message gl-mb-0"> {{ generalErrorDescription }} </p> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 214906021ad..039237042ea 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -1,8 +1,6 @@ <script> -import helmInstallIllustration from '@gitlab/svgs/dist/illustrations/kubernetes-installation.svg'; import { GlLoadingIcon, GlSprintf, GlLink } from '@gitlab/ui'; import gitlabLogo from 'images/cluster_app_logos/gitlab.png'; -import helmLogo from 'images/cluster_app_logos/helm.png'; import jupyterhubLogo from 'images/cluster_app_logos/jupyterhub.png'; import kubernetesLogo from 'images/cluster_app_logos/kubernetes.png'; import certManagerLogo from 'images/cluster_app_logos/cert_manager.png'; @@ -88,18 +86,13 @@ export default { required: false, default: false, }, + ciliumHelpPath: { + type: String, + required: false, + default: '', + }, }, computed: { - managedAppsLocalTillerEnabled() { - return Boolean(gon.features?.managedAppsLocalTiller); - }, - helmInstalled() { - return ( - this.managedAppsLocalTillerEnabled || - this.applications.helm.status === APPLICATION_STATUS.INSTALLED || - this.applications.helm.status === APPLICATION_STATUS.UPDATED - ); - }, ingressId() { return INGRESS; }, @@ -157,7 +150,6 @@ export default { }, logos: { gitlabLogo, - helmLogo, jupyterhubLogo, kubernetesLogo, certManagerLogo, @@ -167,7 +159,6 @@ export default { elasticStackLogo, fluentdLogo, }, - helmInstallIllustration, }; </script> @@ -175,46 +166,12 @@ export default { <section id="cluster-applications"> <p class="gl-mb-0"> {{ - s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster. - Helm Tiller is required to install any of the following applications.`) + s__(`ClusterIntegration|Choose which applications to install on your Kubernetes cluster.`) }} <gl-link :href="helpPath">{{ __('More information') }}</gl-link> </p> - <div class="cluster-application-list prepend-top-10"> - <application-row - v-if="!managedAppsLocalTillerEnabled" - id="helm" - :logo-url="$options.logos.helmLogo" - :title="applications.helm.title" - :status="applications.helm.status" - :status-reason="applications.helm.statusReason" - :request-status="applications.helm.requestStatus" - :request-reason="applications.helm.requestReason" - :installed="applications.helm.installed" - :install-failed="applications.helm.installFailed" - :uninstallable="applications.helm.uninstallable" - :uninstall-successful="applications.helm.uninstallSuccessful" - :uninstall-failed="applications.helm.uninstallFailed" - class="rounded-top" - title-link="https://docs.helm.sh/" - > - <template #description> - {{ - s__(`ClusterIntegration|Helm streamlines installing - and managing Kubernetes applications. - Tiller runs inside of your Kubernetes Cluster, - and manages releases of your charts.`) - }} - </template> - </application-row> - <div v-show="!helmInstalled" class="cluster-application-warning"> - <div class="svg-container" v-html="$options.helmInstallIllustration"></div> - {{ - s__(`ClusterIntegration|You must first install Helm Tiller before - installing the applications below`) - }} - </div> + <div class="cluster-application-list gl-mt-3"> <application-row :id="ingressId" :logo-url="$options.logos.kubernetesLogo" @@ -232,7 +189,6 @@ export default { :uninstallable="applications.ingress.uninstallable" :uninstall-successful="applications.ingress.uninstallSuccessful" :uninstall-failed="applications.ingress.uninstallFailed" - :disabled="!helmInstalled" :updateable="false" title-link="https://kubernetes.io/docs/concepts/services-networking/ingress/" > @@ -335,7 +291,6 @@ export default { :uninstallable="applications.cert_manager.uninstallable" :uninstall-successful="applications.cert_manager.uninstallSuccessful" :uninstall-failed="applications.cert_manager.uninstallFailed" - :disabled="!helmInstalled" title-link="https://cert-manager.readthedocs.io/en/latest/#" > <template #description> @@ -393,7 +348,6 @@ export default { :uninstallable="applications.prometheus.uninstallable" :uninstall-successful="applications.prometheus.uninstallSuccessful" :uninstall-failed="applications.prometheus.uninstallFailed" - :disabled="!helmInstalled" title-link="https://prometheus.io/docs/introduction/overview/" > <template #description> @@ -433,7 +387,6 @@ export default { :uninstallable="applications.runner.uninstallable" :uninstall-successful="applications.runner.uninstallSuccessful" :uninstall-failed="applications.runner.uninstallFailed" - :disabled="!helmInstalled" title-link="https://docs.gitlab.com/runner/" > <template #description> @@ -459,7 +412,6 @@ export default { :uninstall-successful="applications.crossplane.uninstallSuccessful" :uninstall-failed="applications.crossplane.uninstallFailed" :install-application-request-params="{ stack: applications.crossplane.stack }" - :disabled="!helmInstalled" title-link="https://crossplane.io" > <template #description> @@ -504,7 +456,6 @@ export default { :uninstall-successful="applications.jupyter.uninstallSuccessful" :uninstall-failed="applications.jupyter.uninstallFailed" :install-application-request-params="{ hostname: applications.jupyter.hostname }" - :disabled="!helmInstalled" title-link="https://jupyterhub.readthedocs.io/en/stable/" > <template #description> @@ -570,7 +521,6 @@ export default { :uninstall-successful="applications.knative.uninstallSuccessful" :uninstall-failed="applications.knative.uninstallFailed" :updateable="false" - :disabled="!helmInstalled" v-bind="applications.knative" title-link="https://github.com/knative/docs" > @@ -592,7 +542,7 @@ export default { </p> <knative-domain-editor - v-if="(knative.installed || (helmInstalled && rbac)) && !preInstalledKnative" + v-if="(knative.installed || rbac) && !preInstalledKnative" :knative="knative" :ingress-dns-help-path="ingressDnsHelpPath" @save="saveKnativeDomain" @@ -629,7 +579,6 @@ export default { :uninstallable="applications.elastic_stack.uninstallable" :uninstall-successful="applications.elastic_stack.uninstallSuccessful" :uninstall-failed="applications.elastic_stack.uninstallFailed" - :disabled="!helmInstalled" title-link="https://gitlab.com/gitlab-org/charts/elastic-stack" > <template #description> @@ -663,7 +612,6 @@ export default { :uninstallable="applications.fluentd.uninstallable" :uninstall-successful="applications.fluentd.uninstallSuccessful" :uninstall-failed="applications.fluentd.uninstallFailed" - :disabled="!helmInstalled" :updateable="false" title-link="https://github.com/helm/charts/tree/master/stable/fluentd" > @@ -687,6 +635,39 @@ export default { /> </template> </application-row> + + <div class="gl-mt-7 gl-border-1 gl-border-t-solid gl-border-gray-100"> + <!-- This empty div serves as a separator. The applications below can be externally installed using a cluster-management project. --> + </div> + + <application-row + id="cilium" + :title="applications.cilium.title" + :logo-url="$options.logos.gitlabLogo" + :status="applications.cilium.status" + :status-reason="applications.cilium.statusReason" + :installable="applications.cilium.installable" + :uninstallable="applications.cilium.uninstallable" + :installed="applications.cilium.installed" + :install-failed="applications.cilium.installFailed" + :title-link="ciliumHelpPath" + > + <template #description> + <p data-testid="ciliumDescription"> + <gl-sprintf + :message=" + s__( + 'ClusterIntegration|Protect your clusters with GitLab Container Network Policies by enforcing how pods communicate with each other and other network endpoints. %{linkStart}Learn more about configuring Network Policies here.%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="ciliumHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </template> + </application-row> </div> </section> </template> diff --git a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue index 6b99bb09504..c816fc56d7a 100644 --- a/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue +++ b/app/assets/javascripts/clusters/components/crossplane_provider_stack.vue @@ -1,12 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; import { s__ } from '../../locale'; export default { name: 'CrossplaneProviderStack', components: { - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlIcon, }, props: { @@ -67,17 +67,21 @@ export default { <label> {{ s__('ClusterIntegration|Enabled stack') }} </label> - <gl-dropdown + <gl-deprecated-dropdown :disabled="crossplane.installed" :text="dropdownText" toggle-class="dropdown-menu-toggle gl-field-error-outline" class="w-100" :class="{ 'gl-show-field-errors': validationError }" > - <gl-dropdown-item v-for="stack in stacks" :key="stack.code" @click="selectStack(stack)"> + <gl-deprecated-dropdown-item + v-for="stack in stacks" + :key="stack.code" + @click="selectStack(stack)" + > <span class="ml-1">{{ stack.name }}</span> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> <span v-if="validationError" class="gl-field-error">{{ validationError }}</span> <p class="form-text text-muted"> {{ s__(`You must select a stack for configuring your cloud provider. Learn more about`) }} diff --git a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue index 20f6210aba8..e6001b11296 100644 --- a/app/assets/javascripts/clusters/components/fluentd_output_settings.vue +++ b/app/assets/javascripts/clusters/components/fluentd_output_settings.vue @@ -1,15 +1,15 @@ <script> -import { __ } from '~/locale'; -import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; import { GlAlert, GlDeprecatedButton, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlFormCheckbox, } from '@gitlab/ui'; -import eventHub from '~/clusters/event_hub'; import { mapValues } from 'lodash'; +import { __ } from '~/locale'; +import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; +import eventHub from '~/clusters/event_hub'; const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; @@ -17,8 +17,8 @@ export default { components: { GlAlert, GlDeprecatedButton, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlFormCheckbox, }, props: { @@ -203,15 +203,15 @@ export default { <label for="fluentd-protocol"> <strong>{{ s__('ClusterIntegration|SIEM Protocol') }}</strong> </label> - <gl-dropdown :text="protocolName" class="w-100"> - <gl-dropdown-item + <gl-deprecated-dropdown :text="protocolName" class="w-100"> + <gl-deprecated-dropdown-item v-for="(value, index) in protocols" :key="index" @click="selectProtocol(value.toLowerCase())" > {{ value }} - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> <div class="form-group flex flex-wrap"> <gl-form-checkbox :checked="wafLogEnabled" @input="wafLogChanged"> diff --git a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue index 87c3225085f..5e8e1a76182 100644 --- a/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue +++ b/app/assets/javascripts/clusters/components/ingress_modsecurity_settings.vue @@ -1,19 +1,19 @@ <script> import { escape } from 'lodash'; -import { s__, __ } from '../../locale'; -import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants'; import { GlAlert, GlSprintf, GlLink, GlToggle, GlDeprecatedButton, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlIcon, } from '@gitlab/ui'; -import eventHub from '~/clusters/event_hub'; import modSecurityLogo from 'images/cluster_app_logos/gitlab.png'; +import { s__, __ } from '../../locale'; +import { APPLICATION_STATUS, INGRESS, LOGGING_MODE, BLOCKING_MODE } from '~/clusters/constants'; +import eventHub from '~/clusters/event_hub'; const { UPDATING, UNINSTALLING, INSTALLING, INSTALLED, UPDATED } = APPLICATION_STATUS; @@ -26,8 +26,8 @@ export default { GlLink, GlToggle, GlDeprecatedButton, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlIcon, }, props: { @@ -221,11 +221,15 @@ export default { </strong> </p> </div> - <gl-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> - <gl-dropdown-item v-for="(mode, key) in modes" :key="key" @click="selectMode(key)"> + <gl-deprecated-dropdown :text="modSecurityModeName" :disabled="saveButtonDisabled"> + <gl-deprecated-dropdown-item + v-for="(mode, key) in modes" + :key="key" + @click="selectMode(key)" + > {{ mode.name }} - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> </div> <div v-if="showButtons" class="mt-3"> diff --git a/app/assets/javascripts/clusters/components/knative_domain_editor.vue b/app/assets/javascripts/clusters/components/knative_domain_editor.vue index ac61cd8e242..1236d2a46c9 100644 --- a/app/assets/javascripts/clusters/components/knative_domain_editor.vue +++ b/app/assets/javascripts/clusters/components/knative_domain_editor.vue @@ -1,8 +1,8 @@ <script> import { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownItem, GlLoadingIcon, GlSearchBoxByType, GlSprintf, @@ -20,9 +20,9 @@ export default { LoadingButton, ClipboardButton, GlLoadingIcon, - GlDropdown, - GlDropdownDivider, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownItem, GlSearchBoxByType, GlSprintf, }, @@ -121,7 +121,7 @@ export default { <strong>{{ s__('ClusterIntegration|Knative Domain Name:') }}</strong> </label> - <gl-dropdown + <gl-deprecated-dropdown v-if="showDomainsDropdown" :text="domainDropdownText" toggle-class="dropdown-menu-toggle" @@ -132,16 +132,16 @@ export default { :placeholder="s__('ClusterIntegration|Search domains')" class="m-2" /> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="domain in filteredDomains" :key="domain.id" @click="selectDomain(domain)" > <span class="ml-1">{{ domain.domain }}</span> - </gl-dropdown-item> + </gl-deprecated-dropdown-item> <template v-if="searchQuery"> - <gl-dropdown-divider /> - <gl-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> + <gl-deprecated-dropdown-divider /> + <gl-deprecated-dropdown-item key="custom-domain" @click="selectCustomDomain(searchQuery)"> <span class="ml-1"> <gl-sprintf :message="s__('ClusterIntegration|Use %{query}')"> <template #query> @@ -149,9 +149,9 @@ export default { </template> </gl-sprintf> </span> - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </template> - </gl-dropdown> + </gl-deprecated-dropdown> <input v-else diff --git a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue index 45f2dd48961..3e3b102f0aa 100644 --- a/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue +++ b/app/assets/javascripts/clusters/components/remove_cluster_confirmation.vue @@ -1,7 +1,7 @@ <script> import { escape } from 'lodash'; +import { GlModal, GlButton, GlDeprecatedButton, GlFormInput, GlSprintf } from '@gitlab/ui'; import SplitButton from '~/vue_shared/components/split_button.vue'; -import { GlModal, GlButton, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; import csrf from '~/lib/utils/csrf'; @@ -30,6 +30,7 @@ export default { GlButton, GlDeprecatedButton, GlFormInput, + GlSprintf, }, props: { clusterPath: { @@ -67,18 +68,6 @@ export default { ) : s__('ClusterIntegration|You are about to remove your cluster integration.'); }, - warningToBeRemoved() { - return s__(`ClusterIntegration| - This will permanently delete the following resources: - <ul> - <li>All installed applications and related resources</li> - <li>The <code>gitlab-managed-apps</code> namespace</li> - <li>Any project namespaces</li> - <li><code>clusterroles</code></li> - <li><code>clusterrolebindings</code></li> - </ul> - `); - }, confirmationTextLabel() { return sprintf( this.confirmCleanup @@ -118,7 +107,7 @@ export default { </script> <template> - <div> + <div class="gl-display-flex gl-justify-content-end"> <split-button v-if="canCleanupResources" :action-items="$options.splitButtonActionItems" @@ -144,9 +133,29 @@ export default { > <template> <p>{{ warningMessage }}</p> - <div v-if="confirmCleanup" v-html="warningToBeRemoved"></div> + <div v-if="confirmCleanup"> + {{ s__('ClusterIntegration|This will permanently delete the following resources:') }} + <ul> + <li> + {{ s__('ClusterIntegration|All installed applications and related resources') }} + </li> + <li> + <gl-sprintf :message="s__('ClusterIntegration|The %{gitlabNamespace} namespace')"> + <template #gitlabNamespace> + <!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings --> + <code>{{ 'gitlab-managed-apps' }}</code> + </template> + </gl-sprintf> + </li> + <li>{{ s__('ClusterIntegration|Any project namespaces') }}</li> + <!-- eslint-disable @gitlab/vue-require-i18n-strings --> + <li><code>clusterroles</code></li> + <li><code>clusterrolebindings</code></li> + <!-- eslint-enable @gitlab/vue-require-i18n-strings --> + </ul> + </div> <strong v-html="confirmationTextLabel"></strong> - <form ref="form" :action="clusterPath" method="post" class="append-bottom-20"> + <form ref="form" :action="clusterPath" method="post" class="gl-mb-5"> <input ref="method" type="hidden" name="_method" value="delete" /> <input :value="csrfToken" type="hidden" name="authenticity_token" /> <input ref="cleanup" type="hidden" name="cleanup" value="true" /> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js index 60e179c54eb..e2227c61cee 100644 --- a/app/assets/javascripts/clusters/constants.js +++ b/app/assets/javascripts/clusters/constants.js @@ -25,6 +25,7 @@ export const APPLICATION_STATUS = { UNINSTALL_ERRORED: 'uninstall_errored', ERROR: 'errored', PRE_INSTALLED: 'pre_installed', + UNINSTALLED: 'uninstalled', }; /* diff --git a/app/assets/javascripts/clusters/forms/components/integration_form.vue b/app/assets/javascripts/clusters/forms/components/integration_form.vue new file mode 100644 index 00000000000..53e004b4fc0 --- /dev/null +++ b/app/assets/javascripts/clusters/forms/components/integration_form.vue @@ -0,0 +1,163 @@ +<script> +import { + GlFormGroup, + GlFormInput, + GlToggle, + GlTooltipDirective, + GlSprintf, + GlLink, + GlButton, +} from '@gitlab/ui'; +import { mapState } from 'vuex'; + +export default { + components: { + GlFormGroup, + GlToggle, + GlFormInput, + GlSprintf, + GlLink, + GlButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + autoDevopsHelpPath: { + type: String, + }, + externalEndpointHelpPath: { + type: String, + }, + }, + data() { + return { + toggleEnabled: true, + envScope: '*', + baseDomainField: '', + externalIp: '', + }; + }, + computed: { + ...mapState([ + 'enabled', + 'editable', + 'environmentScope', + 'baseDomain', + 'applicationIngressExternalIp', + ]), + canSubmit() { + return ( + this.enabled !== this.toggleEnabled || + this.environmentScope !== this.envScope || + this.baseDomain !== this.baseDomainField + ); + }, + }, + mounted() { + this.toggleEnabled = this.enabled; + this.envScope = this.environmentScope; + this.baseDomainField = this.baseDomain; + this.externalIp = this.applicationIngressExternalIp; + }, +}; +</script> + +<template> + <div class="d-flex gl-flex-direction-column"> + <gl-form-group> + <div class="gl-display-flex gl-align-items-center"> + <h4 class="gl-pr-3 gl-m-0">{{ s__('ClusterIntegration|GitLab Integration') }}</h4> + + <div class="js-cluster-enable-toggle-area"> + <gl-toggle + id="toggleCluster" + v-model="toggleEnabled" + v-gl-tooltip:tooltipcontainer + name="cluster[enabled]" + class="gl-mb-0 js-project-feature-toggle" + data-qa-selector="integration_status_toggle" + aria-describedby="toggleCluster" + :disabled="!editable" + :title=" + s__( + 'ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.', + ) + " + /> + </div> + </div> + </gl-form-group> + + <gl-form-group + :label="s__('ClusterIntegration|Environment scope')" + label-size="sm" + label-for="cluster_environment_scope" + :description=" + s__('ClusterIntegration|Choose which of your environments will use this cluster.') + " + > + <gl-form-input + id="cluster_environment_scope" + v-model="envScope" + name="cluster[environment_scope]" + class="col-md-6" + type="text" + /> + </gl-form-group> + + <gl-form-group + :label="s__('ClusterIntegration|Base domain')" + label-size="sm" + label-for="cluster_base_domain" + > + <gl-form-input + id="cluster_base_domain" + v-model="baseDomainField" + name="cluster[base_domain]" + data-qa-selector="base_domain_field" + class="col-md-6" + type="text" + /> + <div class="form-text text-muted inline"> + <gl-sprintf + :message=" + s__( + 'ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{linkStart}Auto DevOps.%{linkEnd} The domain should have a wildcard DNS configured matching the domain. ', + ) + " + > + <template #link="{ content }"> + <gl-link :href="autoDevopsHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + <div v-if="applicationIngressExternalIp" class="js-ingress-domain-help-text inline"> + {{ s__('ClusterIntegration|Alternatively, ') }} + <gl-sprintf :message="s__('ClusterIntegration|%{externalIp}.nip.io')"> + <template #externalIp>{{ externalIp }}</template> + </gl-sprintf> + {{ s__('ClusterIntegration|can be used instead of a custom domain. ') }} + </div> + <gl-sprintf + class="inline" + :message="s__('ClusterIntegration|%{linkStart}More information%{linkEnd}')" + > + <template #link="{ content }"> + <gl-link :href="externalEndpointHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> + </gl-form-group> + <div v-if="editable" class="form group gl-display-flex gl-justify-content-end"> + <gl-button + category="primary" + variant="success" + type="submit" + :disabled="!canSubmit" + :aria-disabled="!canSubmit" + data-qa-selector="save_changes_button" + >{{ s__('ClusterIntegration|Save changes') }}</gl-button + > + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters/forms/show/index.js b/app/assets/javascripts/clusters/forms/show/index.js new file mode 100644 index 00000000000..47a3016c777 --- /dev/null +++ b/app/assets/javascripts/clusters/forms/show/index.js @@ -0,0 +1,27 @@ +import Vue from 'vue'; +import IntegrationForm from '../components/integration_form.vue'; +import { createStore } from '../stores'; + +export default () => { + const entryPoint = document.querySelector('#js-cluster-integration-form'); + + if (!entryPoint) { + return; + } + + const { autoDevopsHelpPath, externalEndpointHelpPath } = entryPoint.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: entryPoint, + store: createStore(entryPoint.dataset), + provide: { + autoDevopsHelpPath, + externalEndpointHelpPath, + }, + + render(createElement) { + return createElement(IntegrationForm, {}); + }, + }); +}; diff --git a/app/assets/javascripts/clusters/forms/stores/index.js b/app/assets/javascripts/clusters/forms/stores/index.js new file mode 100644 index 00000000000..ae082c07f26 --- /dev/null +++ b/app/assets/javascripts/clusters/forms/stores/index.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = initialState => + new Vuex.Store({ + state: state(initialState), + }); + +export default createStore; diff --git a/app/assets/javascripts/clusters/forms/stores/state.js b/app/assets/javascripts/clusters/forms/stores/state.js new file mode 100644 index 00000000000..2a96590b5e7 --- /dev/null +++ b/app/assets/javascripts/clusters/forms/stores/state.js @@ -0,0 +1,13 @@ +import { parseBoolean } from '../../../lib/utils/common_utils'; + +export default (initialState = {}) => { + return { + enabled: parseBoolean(initialState.enabled), + editable: parseBoolean(initialState.editable), + environmentScope: initialState.environmentScope, + baseDomain: initialState.baseDomain, + applicationIngressExternalIp: initialState.applicationIngressExternalIp, + autoDevopsHelpPath: initialState.autoDevopsHelpPath, + externalEndpointHelpPath: initialState.externalEndpointHelpPath, + }; +}; diff --git a/app/assets/javascripts/clusters/services/application_state_machine.js b/app/assets/javascripts/clusters/services/application_state_machine.js index 6af9b10f12f..683b0e18534 100644 --- a/app/assets/javascripts/clusters/services/application_state_machine.js +++ b/app/assets/javascripts/clusters/services/application_state_machine.js @@ -14,6 +14,7 @@ const { UNINSTALLING, UNINSTALL_ERRORED, PRE_INSTALLED, + UNINSTALLED, } = APPLICATION_STATUS; const applicationStateMachine = { @@ -67,6 +68,9 @@ const applicationStateMachine = { [PRE_INSTALLED]: { target: PRE_INSTALLED, }, + [UNINSTALLED]: { + target: UNINSTALLED, + }, }, }, [NOT_INSTALLABLE]: { @@ -87,9 +91,17 @@ const applicationStateMachine = { [NOT_INSTALLABLE]: { target: NOT_INSTALLABLE, }, - // This is possible in artificial environments for E2E testing [INSTALLED]: { target: INSTALLED, + effects: { + installFailed: false, + }, + }, + [UNINSTALLED]: { + target: UNINSTALLED, + effects: { + installFailed: false, + }, }, }, }, @@ -125,6 +137,15 @@ const applicationStateMachine = { uninstallSuccessful: false, }, }, + [UNINSTALLED]: { + target: UNINSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, }, }, [PRE_INSTALLED]: { @@ -180,6 +201,19 @@ const applicationStateMachine = { }, }, }, + [UNINSTALLED]: { + on: { + [INSTALLED]: { + target: INSTALLED, + }, + [ERROR]: { + target: INSTALLABLE, + effects: { + installFailed: true, + }, + }, + }, + }, }; /** diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js index 9d354e66661..53868b7c02d 100644 --- a/app/assets/javascripts/clusters/stores/clusters_store.js +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -23,6 +23,7 @@ const applicationInitialState = { status: null, statusReason: null, requestReason: null, + installable: true, installed: false, installFailed: false, uninstallable: false, @@ -114,6 +115,11 @@ export default class ClusterStore { ciliumLogEnabled: null, isEditingSettings: false, }, + cilium: { + ...applicationInitialState, + title: s__('ClusterIntegration|GitLab Container Network Policies'), + installable: false, + }, }, environments: [], fetchingEnvironments: false, @@ -129,6 +135,7 @@ export default class ClusterStore { clustersHelpPath, deployBoardsHelpPath, cloudRunHelpPath, + ciliumHelpPath, ) { this.state.helpPath = helpPath; this.state.ingressHelpPath = ingressHelpPath; @@ -138,6 +145,7 @@ export default class ClusterStore { this.state.clustersHelpPath = clustersHelpPath; this.state.deployBoardsHelpPath = deployBoardsHelpPath; this.state.cloudRunHelpPath = cloudRunHelpPath; + this.state.ciliumHelpPath = ciliumHelpPath; } setManagePrometheusPath(managePrometheusPath) { diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 7e9b720d269..09d7c0329a9 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -231,7 +231,7 @@ export default { <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> - <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-400">{{ + <small v-else class="gl-font-sm gl-font-style-italic gl-text-gray-200">{{ __('Unknown') }}</small> </template> diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index dddcfb3d975..ff711877621 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,10 +1,10 @@ +import * as Sentry from '@sentry/browser'; import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { MAX_REQUESTS } from '../constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; const allNodesPresent = (clusters, retryCount) => { @@ -76,6 +76,3 @@ export const fetchClusters = ({ state, commit, dispatch }) => { export const setPage = ({ commit }, page) => { commit(types.SET_PAGE, page); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.vue b/app/assets/javascripts/commit/pipelines/pipelines_table.vue index 23a842fab4c..1526d994770 100644 --- a/app/assets/javascripts/commit/pipelines/pipelines_table.vue +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlModal, GlLink } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import PipelinesService from '~/pipelines/services/pipelines_service'; import PipelineStore from '~/pipelines/stores/pipelines_store'; @@ -12,8 +12,10 @@ import CIPaginationMixin from '~/vue_shared/mixins/ci_pagination_api_mixin'; export default { components: { TablePagination, - GlDeprecatedButton, + GlButton, GlLoadingIcon, + GlModal, + GlLink, }, mixins: [pipelinesMixin, CIPaginationMixin], props: { @@ -38,11 +40,21 @@ export default { required: false, default: 'child', }, - canRunPipeline: { + canCreatePipelineInTargetProject: { type: Boolean, required: false, default: false, }, + sourceProjectFullPath: { + type: String, + required: false, + default: '', + }, + targetProjectFullPath: { + type: String, + required: false, + default: '', + }, projectId: { type: String, required: false, @@ -63,6 +75,7 @@ export default { state: store.state, page: getParameterByName('page') || '1', requestData: {}, + modalId: 'create-pipeline-for-fork-merge-request-modal', }; }, @@ -75,13 +88,28 @@ export default { }, /** * The Run Pipeline button can only be rendered when: - * - In MR view - we use `canRunPipeline` for that purpose + * - In MR view - we use `canCreatePipelineInTargetProject` for that purpose * - If the latest pipeline has the `detached_merge_request_pipeline` flag * * @returns {Boolean} */ canRenderPipelineButton() { - return this.canRunPipeline && this.latestPipelineDetachedFlag; + return this.latestPipelineDetachedFlag; + }, + isForkMergeRequest() { + return this.sourceProjectFullPath !== this.targetProjectFullPath; + }, + isLatestPipelineCreatedInTargetProject() { + const latest = this.state.pipelines[0]; + + return latest?.project?.full_path === `/${this.targetProjectFullPath}`; + }, + shouldShowSecurityWarning() { + return ( + this.canCreatePipelineInTargetProject && + this.isForkMergeRequest && + !this.isLatestPipelineCreatedInTargetProject + ); }, /** * Checks if either `detached_merge_request_pipeline` or @@ -148,6 +176,13 @@ export default { mergeRequestId: this.mergeRequestId, }); }, + tryRunPipeline() { + if (!this.shouldShowSecurityWarning) { + this.onClickRunPipeline(); + } else { + this.$refs.modal.show(); + } + }, }, }; </script> @@ -171,16 +206,53 @@ export default { <div v-else-if="shouldRenderTable" class="table-holder"> <div v-if="canRenderPipelineButton" class="nav justify-content-end"> - <gl-deprecated-button - v-if="canRenderPipelineButton" + <gl-button variant="success" - class="js-run-mr-pipeline prepend-top-10 btn-wide-on-xs" + class="js-run-mr-pipeline gl-mt-3 btn-wide-on-xs" :disabled="state.isRunningMergeRequestPipeline" - @click="onClickRunPipeline" + @click="tryRunPipeline" > <gl-loading-icon v-if="state.isRunningMergeRequestPipeline" inline /> {{ s__('Pipelines|Run Pipeline') }} - </gl-deprecated-button> + </gl-button> + + <gl-modal + :id="modalId" + ref="modal" + :modal-id="modalId" + :title="s__('Pipelines|Are you sure you want to run this pipeline?')" + :ok-title="s__('Pipelines|Run Pipeline')" + ok-variant="danger" + @ok="onClickRunPipeline" + > + <p> + {{ + s__( + 'Pipelines|This pipeline will run code originating from a forked project merge request. This means that the code can potentially have security considerations like exposing CI variables.', + ) + }} + </p> + <p> + {{ + s__( + "Pipelines|It is recommended the code is reviewed thoroughly before running this pipeline with the parent project's CI resource.", + ) + }} + </p> + <p> + {{ + s__( + 'Pipelines|If you are unsure, please ask a project maintainer to review it for you.', + ) + }} + </p> + <gl-link + href="/help/ci/merge_request_pipelines/index.html#create-pipelines-in-the-parent-project-for-merge-requests-from-a-forked-project" + target="_blank" + > + {{ s__('Pipelines|More Information') }} + </gl-link> + </gl-modal> </div> <pipelines-table-component diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 4539b9a39ef..34322755fe9 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import { capitalizeFirstCharacter } from './lib/utils/text_utility'; export default function initCompareAutocomplete(limitTo = null, clickHandler = () => {}) { diff --git a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue index 92a5423d5ea..b8163ecfab2 100644 --- a/app/assets/javascripts/confidential_merge_request/components/dropdown.vue +++ b/app/assets/javascripts/confidential_merge_request/components/dropdown.vue @@ -1,12 +1,12 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, Icon, }, props: { @@ -38,7 +38,7 @@ export default { </script> <template> - <gl-dropdown toggle-class="d-flex align-items-center w-100" class="w-100"> + <gl-deprecated-dropdown toggle-class="d-flex align-items-center w-100" class="w-100"> <template #button-content> <span class="str-truncated-100 mr-2"> <icon name="lock" /> @@ -46,13 +46,17 @@ export default { </span> <icon name="chevron-down" class="ml-auto" /> </template> - <gl-dropdown-item v-for="project in projects" :key="project.id" @click="selectProject(project)"> + <gl-deprecated-dropdown-item + v-for="project in projects" + :key="project.id" + @click="selectProject(project)" + > <icon name="mobile-issue-close" :class="{ icon: project.id !== selectedProject.id }" class="js-active-project-check" /> <span class="ml-1">{{ project.name }}</span> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </template> diff --git a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue index f2853564f94..88459d5962e 100644 --- a/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue +++ b/app/assets/javascripts/confidential_merge_request/components/project_form_group.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlSprintf } from '@gitlab/ui'; import { __ } from '../../locale'; -import createFlash from '../../flash'; +import { deprecatedCreateFlash as createFlash } from '../../flash'; import Api from '../../api'; import state from '../state'; import Dropdown from './dropdown.vue'; diff --git a/app/assets/javascripts/contributors/stores/actions.js b/app/assets/javascripts/contributors/stores/actions.js index 4138ff24f1d..393af932fb0 100644 --- a/app/assets/javascripts/contributors/stores/actions.js +++ b/app/assets/javascripts/contributors/stores/actions.js @@ -1,8 +1,9 @@ -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import service from '../services/contributors_service'; import * as types from './mutation_types'; +// eslint-disable-next-line import/prefer-default-export export const fetchChartData = ({ commit }, endpoint) => { commit(types.SET_LOADING_STATE, true); @@ -15,6 +16,3 @@ export const fetchChartData = ({ commit }, endpoint) => { }) .catch(() => flash(__('An error occurred while loading chart data'))); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/contributors/stores/getters.js b/app/assets/javascripts/contributors/stores/getters.js index 9b0def9b3ca..9022179d6c7 100644 --- a/app/assets/javascripts/contributors/stores/getters.js +++ b/app/assets/javascripts/contributors/stores/getters.js @@ -28,6 +28,3 @@ export const parsedData = state => { byAuthorEmail, }; }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js index e96e6d6e4f8..caf2729a4c7 100644 --- a/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/eks_cluster/store/actions.js @@ -1,7 +1,7 @@ import * as types from './mutation_types'; import { setAWSConfig } from '../services/aws_services_facade'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; const getErrorMessage = data => { diff --git a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue index b0bec10f64d..979628d683d 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue +++ b/app/assets/javascripts/create_cluster/gke_cluster/components/gke_project_id_dropdown.vue @@ -1,12 +1,16 @@ <script> -import { escape } from 'lodash'; import { mapState, mapGetters, mapActions } from 'vuex'; -import { s__, sprintf } from '~/locale'; +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; import gkeDropdownMixin from './gke_dropdown_mixin'; export default { name: 'GkeProjectIdDropdown', + components: { + GlSprintf, + GlLink, + }, mixins: [gkeDropdownMixin], props: { docsUrl: { @@ -46,31 +50,23 @@ export default { return s__('ClusterIntegration|Select project'); }, helpText() { - let message; if (this.hasErrors) { return this.errorMessage; } if (!this.items) { - message = - 'ClusterIntegration|We were unable to fetch any projects. Ensure that you have a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'; + return s__( + 'ClusterIntegration|We were unable to fetch any projects. Ensure that you have a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.', + ); } - message = - this.items && this.items.length - ? 'ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.' - : 'ClusterIntegration|To create a cluster, first create a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'; - - return sprintf( - s__(message), - { - docsLinkEnd: ' <i class="fa fa-external-link" aria-hidden="true"></i></a>', - docsLinkStart: `<a href="${escape( - this.docsUrl, - )}" target="_blank" rel="noopener noreferrer">`, - }, - false, - ); + return this.items.length + ? s__( + 'ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.', + ) + : s__( + 'ClusterIntegration|To create a cluster, first create a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.', + ); }, errorMessage() { if (!this.projectHasBillingEnabled) { @@ -80,21 +76,13 @@ export default { ); } - return sprintf( - s__( - 'This project does not have billing enabled. To create a cluster, <a href=%{linkToBilling} target="_blank" rel="noopener noreferrer">enable billing <i class="fa fa-external-link" aria-hidden="true"></i></a> and try again.', - ), - { - linkToBilling: - 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', - }, - false, + return s__( + 'ClusterIntegration|This project does not have billing enabled. To create a cluster, %{linkToBillingStart}enable billing%{linkToBillingEnd} and try again.', ); } - return sprintf( - s__('ClusterIntegration|An error occurred while trying to fetch your projects: %{error}'), - { error: this.gapiError }, + return s__( + 'ClusterIntegration|An error occurred while trying to fetch your projects: %{error}', ); }, }, @@ -182,7 +170,28 @@ export default { 'text-muted': !hasErrors, }" class="form-text" - v-html="helpText" - ></span> + > + <gl-sprintf :message="helpText"> + <template #linkToBilling="{ content }"> + <gl-link + :href=" + 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral' + " + target="_blank" + >{{ content }} <i class="fa fa-external-link" aria-hidden="true"></i + ></gl-link> + </template> + + <template #docsLink="{ content }"> + <gl-link :href="docsUrl" target="_blank" + >{{ content }} <i class="fa fa-external-link" aria-hidden="true"></i + ></gl-link> + </template> + + <template #error> + {{ gapiError }} + </template> + </gl-sprintf> + </span> </div> </template> diff --git a/app/assets/javascripts/create_cluster/gke_cluster/index.js b/app/assets/javascripts/create_cluster/gke_cluster/index.js index 5a64eb09cad..b9316353072 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/index.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/index.js @@ -1,6 +1,6 @@ /* global gapi */ import Vue from 'vue'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; import GkeZoneDropdown from './components/gke_zone_dropdown.vue'; import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; diff --git a/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js index f05ad7773a2..f0c41d1d230 100644 --- a/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js +++ b/app/assets/javascripts/create_cluster/gke_cluster/store/actions.js @@ -90,6 +90,3 @@ export const fetchMachineTypes = ({ commit, state }) => mutation: types.SET_MACHINE_TYPES, payloadKey: 'items', }); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index 801566d2f2f..010a6b073f9 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -1,7 +1,7 @@ /* eslint-disable no-new */ import { debounce } from 'lodash'; import axios from './lib/utils/axios_utils'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; import { __, sprintf } from './locale'; diff --git a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue index f60be52d6ca..9c28801306c 100644 --- a/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue +++ b/app/assets/javascripts/custom_metrics/components/custom_metrics_form.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import csrf from '~/lib/utils/csrf'; import CustomMetricsFormFields from './custom_metrics_form_fields.vue'; @@ -10,7 +10,7 @@ export default { components: { CustomMetricsFormFields, DeleteCustomMetricModal, - GlDeprecatedButton, + GlButton, }, props: { customMetricsPath: { @@ -76,15 +76,10 @@ export default { @formValidation="formValidation" /> <div class="form-actions"> - <gl-deprecated-button variant="success" :disabled="!formIsValid" @click="submit"> + <gl-button variant="success" category="primary" :disabled="!formIsValid" @click="submit"> {{ saveButtonText }} - </gl-deprecated-button> - <gl-deprecated-button - variant="secondary" - class="float-right" - :href="editProjectServicePath" - >{{ __('Cancel') }}</gl-deprecated-button - > + </gl-button> + <gl-button class="float-right" :href="editProjectServicePath">{{ __('Cancel') }}</gl-button> <delete-custom-metric-modal v-if="metricPersisted" :delete-metric-url="customMetricsPath" diff --git a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue index d61e6995551..dc13f409462 100644 --- a/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue +++ b/app/assets/javascripts/cycle_analytics/components/stage_review_component.vue @@ -1,4 +1,5 @@ <script> +import { GlIcon } from '@gitlab/ui'; import userAvatarImage from '../../vue_shared/components/user_avatar/user_avatar_image.vue'; import limitWarning from './limit_warning_component.vue'; import totalTime from './total_time_component.vue'; @@ -10,6 +11,7 @@ export default { totalTime, limitWarning, icon, + GlIcon, }, props: { items: { @@ -52,7 +54,8 @@ export default { </span> <template v-if="mergeRequest.state === 'closed'"> <span class="merge-request-state"> - <i class="fa fa-ban" aria-hidden="true"> </i> {{ mergeRequest.state.toUpperCase() }} + <gl-icon name="cancel" class="gl-vertical-align-text-bottom" /> + {{ __('CLOSED') }} </span> </template> <template v-else> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index f609ca5f22d..f6bad5dce41 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -2,8 +2,7 @@ import $ from 'jquery'; import Vue from 'vue'; import Cookies from 'js-cookie'; import { GlEmptyState, GlLoadingIcon } from '@gitlab/ui'; -import filterMixins from 'ee_else_ce/analytics/cycle_analytics/mixins/filter_mixins'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import { __ } from '~/locale'; import Translate from '../vue_shared/translate'; import banner from './components/banner.vue'; @@ -45,7 +44,6 @@ export default () => { import('ee_component/analytics/shared/components/date_range_dropdown.vue'), 'stage-nav-item': stageNavItem, }, - mixins: [filterMixins], data() { return { store: CycleAnalyticsStore, diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue new file mode 100644 index 00000000000..afc1c2cda8e --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -0,0 +1,149 @@ +<script> +import { GlFormGroup, GlFormInput, GlModal, GlSprintf, GlLink } from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import { isValidCron } from 'cron-validator'; +import { mapComputed } from '~/vuex_shared/bindings'; +import { __ } from '~/locale'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; + +export default { + components: { + GlFormGroup, + GlFormInput, + GlModal, + GlSprintf, + GlLink, + TimezoneDropdown, + }, + modalOptions: { + ref: 'modal', + modalId: 'deploy-freeze-modal', + title: __('Add deploy freeze'), + actionCancel: { + text: __('Cancel'), + }, + static: true, + lazy: true, + }, + translations: { + cronPlaceholder: __('* * * * *'), + cronSyntaxInstructions: __( + 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}', + ), + }, + computed: { + ...mapState([ + 'projectId', + 'selectedTimezone', + 'timezoneData', + 'freezeStartCron', + 'freezeEndCron', + ]), + ...mapComputed([ + { key: 'freezeStartCron', updateFn: 'setFreezeStartCron' }, + { key: 'freezeEndCron', updateFn: 'setFreezeEndCron' }, + ]), + addDeployFreezeButton() { + return { + text: __('Add deploy freeze'), + attributes: [ + { variant: 'success' }, + { + disabled: + !isValidCron(this.freezeStartCron) || + !isValidCron(this.freezeEndCron) || + !this.selectedTimezone, + }, + ], + }; + }, + invalidFreezeStartCron() { + return this.invalidCronMessage(this.freezeStartCronState); + }, + freezeStartCronState() { + return Boolean(!this.freezeStartCron || isValidCron(this.freezeStartCron)); + }, + invalidFreezeEndCron() { + return this.invalidCronMessage(this.freezeEndCronState); + }, + freezeEndCronState() { + return Boolean(!this.freezeEndCron || isValidCron(this.freezeEndCron)); + }, + timezone: { + get() { + return this.selectedTimezone; + }, + set(selectedTimezone) { + this.setSelectedTimezone(selectedTimezone); + }, + }, + }, + methods: { + ...mapActions(['addFreezePeriod', 'setSelectedTimezone', 'resetModal']), + resetModalHandler() { + this.resetModal(); + }, + invalidCronMessage(validCronState) { + if (!validCronState) { + return __('This Cron pattern is invalid'); + } + return ''; + }, + }, +}; +</script> + +<template> + <gl-modal + v-bind="$options.modalOptions" + :action-primary="addDeployFreezeButton" + @primary="addFreezePeriod" + @canceled="resetModalHandler" + > + <p> + <gl-sprintf :message="$options.translations.cronSyntaxInstructions"> + <template #cronSyntax="{ content }"> + <gl-link href="https://crontab.guru/" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + + <gl-form-group + :label="__('Freeze start')" + label-for="deploy-freeze-start" + :invalid-feedback="invalidFreezeStartCron" + :state="freezeStartCronState" + > + <gl-form-input + id="deploy-freeze-start" + v-model="freezeStartCron" + class="gl-font-monospace!" + data-qa-selector="deploy_freeze_start_field" + :placeholder="this.$options.translations.cronPlaceholder" + :state="freezeStartCronState" + trim + /> + </gl-form-group> + + <gl-form-group + :label="__('Freeze end')" + label-for="deploy-freeze-end" + :invalid-feedback="invalidFreezeEndCron" + :state="freezeEndCronState" + > + <gl-form-input + id="deploy-freeze-end" + v-model="freezeEndCron" + class="gl-font-monospace!" + data-qa-selector="deploy_freeze_end_field" + :placeholder="this.$options.translations.cronPlaceholder" + :state="freezeEndCronState" + trim + /> + </gl-form-group> + + <gl-form-group :label="__('Cron time zone')" label-for="cron-time-zone-dropdown"> + <timezone-dropdown v-model="timezone" :timezone-data="timezoneData" /> + </gl-form-group> + </gl-modal> +</template> diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue new file mode 100644 index 00000000000..fc2ed10f3ca --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_settings.vue @@ -0,0 +1,18 @@ +<script> +import DeployFreezeTable from './deploy_freeze_table.vue'; +import DeployFreezeModal from './deploy_freeze_modal.vue'; + +export default { + components: { + DeployFreezeTable, + DeployFreezeModal, + }, +}; +</script> + +<template> + <div> + <deploy-freeze-table /> + <deploy-freeze-modal /> + </div> +</template> diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue new file mode 100644 index 00000000000..159f5ddd755 --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_table.vue @@ -0,0 +1,83 @@ +<script> +import { GlTable, GlButton, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { s__, __ } from '~/locale'; + +export default { + fields: [ + { + key: 'freezeStart', + label: s__('DeployFreeze|Freeze start'), + }, + { + key: 'freezeEnd', + label: s__('DeployFreeze|Freeze end'), + }, + { + key: 'cronTimezone', + label: s__('DeployFreeze|Time zone'), + }, + ], + translations: { + addDeployFreeze: __('Add deploy freeze'), + }, + components: { + GlTable, + GlButton, + GlSprintf, + }, + directives: { + GlModal: GlModalDirective, + }, + computed: { + ...mapState(['freezePeriods']), + tableIsNotEmpty() { + return this.freezePeriods?.length > 0; + }, + }, + mounted() { + this.fetchFreezePeriods(); + }, + methods: { + ...mapActions(['fetchFreezePeriods']), + }, +}; +</script> + +<template> + <div class="deploy-freeze-table"> + <gl-table + data-testid="deploy-freeze-table" + :items="freezePeriods" + :fields="$options.fields" + show-empty + stacked="lg" + > + <template #empty> + <p data-testid="empty-freeze-periods" class="gl-text-center text-plain"> + <gl-sprintf + :message=" + s__( + 'DeployFreeze|No deploy freezes exist for this project. To add one, click %{strongStart}Add deploy freeze%{strongEnd}', + ) + " + > + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </p> + </template> + </gl-table> + <div class="gl-display-flex gl-justify-content-center"> + <gl-button + v-gl-modal.deploy-freeze-modal + data-testid="add-deploy-freeze" + category="primary" + variant="success" + > + {{ $options.translations.addDeployFreeze }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/deploy_freeze/index.js b/app/assets/javascripts/deploy_freeze/index.js new file mode 100644 index 00000000000..fd3f52b6da1 --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/index.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import DeployFreezeSettings from './components/deploy_freeze_settings.vue'; +import createStore from './store'; + +export default () => { + const el = document.getElementById('js-deploy-freeze-table'); + + const { projectId, timezoneData } = el.dataset; + + const store = createStore({ + projectId, + timezoneData: JSON.parse(timezoneData), + }); + + return new Vue({ + el, + store, + render(createElement) { + return createElement(DeployFreezeSettings); + }, + }); +}; diff --git a/app/assets/javascripts/deploy_freeze/store/actions.js b/app/assets/javascripts/deploy_freeze/store/actions.js new file mode 100644 index 00000000000..2fbbba5a128 --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/store/actions.js @@ -0,0 +1,63 @@ +import * as types from './mutation_types'; +import Api from '~/api'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { __ } from '~/locale'; + +export const requestAddFreezePeriod = ({ commit }) => { + commit(types.REQUEST_ADD_FREEZE_PERIOD); +}; + +export const receiveAddFreezePeriodSuccess = ({ commit }) => { + commit(types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS); +}; + +export const receiveAddFreezePeriodError = ({ commit }, error) => { + commit(types.RECEIVE_ADD_FREEZE_PERIOD_ERROR, error); +}; + +export const addFreezePeriod = ({ state, dispatch, commit }) => { + dispatch('requestAddFreezePeriod'); + + return Api.createFreezePeriod(state.projectId, { + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }) + .then(() => { + dispatch('receiveAddFreezePeriodSuccess'); + commit(types.RESET_MODAL); + dispatch('fetchFreezePeriods'); + }) + .catch(error => { + createFlash(__('Error: Unable to create deploy freeze')); + dispatch('receiveAddFreezePeriodError', error); + }); +}; + +export const fetchFreezePeriods = ({ commit, state }) => { + commit(types.REQUEST_FREEZE_PERIODS); + + return Api.freezePeriods(state.projectId) + .then(({ data }) => { + commit(types.RECEIVE_FREEZE_PERIODS_SUCCESS, data); + }) + .catch(() => { + createFlash(__('There was an error fetching the deploy freezes.')); + }); +}; + +export const setSelectedTimezone = ({ commit }, timezone) => { + commit(types.SET_SELECTED_TIMEZONE, timezone); +}; + +export const setFreezeStartCron = ({ commit }, { freezeStartCron }) => { + commit(types.SET_FREEZE_START_CRON, freezeStartCron); +}; + +export const setFreezeEndCron = ({ commit }, { freezeEndCron }) => { + commit(types.SET_FREEZE_END_CRON, freezeEndCron); +}; + +export const resetModal = ({ commit }) => { + commit(types.RESET_MODAL); +}; diff --git a/app/assets/javascripts/deploy_freeze/store/index.js b/app/assets/javascripts/deploy_freeze/store/index.js new file mode 100644 index 00000000000..ca7ea8c783c --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/store/index.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export default initialState => + new Vuex.Store({ + actions, + mutations, + state: createState(initialState), + }); diff --git a/app/assets/javascripts/deploy_freeze/store/mutation_types.js b/app/assets/javascripts/deploy_freeze/store/mutation_types.js new file mode 100644 index 00000000000..47a4874a5cf --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/store/mutation_types.js @@ -0,0 +1,12 @@ +export const REQUEST_FREEZE_PERIODS = 'REQUEST_FREEZE_PERIODS'; +export const RECEIVE_FREEZE_PERIODS_SUCCESS = 'RECEIVE_FREEZE_PERIODS_SUCCESS'; + +export const REQUEST_ADD_FREEZE_PERIOD = 'REQUEST_ADD_FREEZE_PERIOD'; +export const RECEIVE_ADD_FREEZE_PERIOD_SUCCESS = 'RECEIVE_ADD_FREEZE_PERIOD_SUCCESS'; +export const RECEIVE_ADD_FREEZE_PERIOD_ERROR = 'RECEIVE_ADD_FREEZE_PERIOD_ERROR'; + +export const SET_SELECTED_TIMEZONE = 'SET_SELECTED_TIMEZONE'; +export const SET_FREEZE_START_CRON = 'SET_FREEZE_START_CRON'; +export const SET_FREEZE_END_CRON = 'SET_FREEZE_END_CRON'; + +export const RESET_MODAL = 'RESET_MODAL'; diff --git a/app/assets/javascripts/deploy_freeze/store/mutations.js b/app/assets/javascripts/deploy_freeze/store/mutations.js new file mode 100644 index 00000000000..89ce1dc5428 --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/store/mutations.js @@ -0,0 +1,54 @@ +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import * as types from './mutation_types'; + +const formatTimezoneName = (freezePeriod, timezoneList) => + convertObjectPropsToCamelCase({ + ...freezePeriod, + cron_timezone: timezoneList.find(tz => tz.identifier === freezePeriod.cron_timezone)?.name, + }); + +export default { + [types.REQUEST_FREEZE_PERIODS](state) { + state.isLoading = true; + }, + + [types.RECEIVE_FREEZE_PERIODS_SUCCESS](state, freezePeriods) { + state.isLoading = false; + state.freezePeriods = freezePeriods.map(freezePeriod => + formatTimezoneName(freezePeriod, state.timezoneData), + ); + }, + + [types.REQUEST_ADD_FREEZE_PERIOD](state) { + state.isLoading = true; + }, + + [types.RECEIVE_ADD_FREEZE_PERIOD_SUCCESS](state) { + state.isLoading = false; + }, + + [types.RECEIVE_ADD_FREEZE_PERIOD_ERROR](state, error) { + state.isLoading = false; + state.error = error; + }, + + [types.SET_SELECTED_TIMEZONE](state, timezone) { + state.selectedTimezone = timezone.formattedTimezone; + state.selectedTimezoneIdentifier = timezone.identifier; + }, + + [types.SET_FREEZE_START_CRON](state, freezeStartCron) { + state.freezeStartCron = freezeStartCron; + }, + + [types.SET_FREEZE_END_CRON](state, freezeEndCron) { + state.freezeEndCron = freezeEndCron; + }, + + [types.RESET_MODAL](state) { + state.freezeStartCron = ''; + state.freezeEndCron = ''; + state.selectedTimezone = ''; + state.selectedTimezoneIdentifier = ''; + }, +}; diff --git a/app/assets/javascripts/deploy_freeze/store/state.js b/app/assets/javascripts/deploy_freeze/store/state.js new file mode 100644 index 00000000000..4cc38c097b6 --- /dev/null +++ b/app/assets/javascripts/deploy_freeze/store/state.js @@ -0,0 +1,17 @@ +export default ({ + projectId, + freezePeriods = [], + timezoneData = [], + selectedTimezone = '', + selectedTimezoneIdentifier = '', + freezeStartCron = '', + freezeEndCron = '', +}) => ({ + projectId, + freezePeriods, + timezoneData, + selectedTimezone, + selectedTimezoneIdentifier, + freezeStartCron, + freezeEndCron, +}); diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 1b8668b533e..a03a7114b40 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,7 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import { s__ } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; import eventHub from '../eventhub'; import DeployKeysService from '../service'; diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index 1fd902c9ed7..37686dd5a46 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -1,11 +1,12 @@ <script> -import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import { s__, __ } from '~/locale'; export default { name: 'DeleteButton', components: { - GlDeprecatedButton, + GlButton, GlModal, }, directives: { @@ -25,40 +26,78 @@ export default { buttonVariant: { type: String, required: false, - default: '', + default: 'info', + }, + buttonCategory: { + type: String, + required: false, + default: 'primary', + }, + buttonIcon: { + type: String, + required: false, + default: undefined, + }, + buttonSize: { + type: String, + required: false, + default: 'medium', }, hasSelectedDesigns: { type: Boolean, required: false, default: true, }, + loading: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { modalId: uniqueId('design-deletion-confirmation-'), }; }, + modal: { + title: s__('DesignManagement|Are you sure you want to archive the selected designs?'), + actionPrimary: { + text: s__('DesignManagement|Archive designs'), + attributes: { variant: 'warning' }, + }, + actionCancel: { + text: __('Cancel'), + }, + }, }; </script> <template> - <div> + <div class="gl-display-flex gl-align-items-center gl-h-full"> <gl-modal :modal-id="modalId" - :title="s__('DesignManagement|Delete designs confirmation')" - :ok-title="s__('DesignManagement|Delete')" - ok-variant="danger" + :title="$options.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" @ok="$emit('deleteSelectedDesigns')" > - <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p> + <p> + {{ + s__( + 'DesignManagement|Archived designs will still be available in previous versions of the design collection.', + ) + }} + </p> </gl-modal> - <gl-deprecated-button + <gl-button v-gl-modal-directive="modalId" :variant="buttonVariant" - :disabled="isDeleting || !hasSelectedDesigns" + :category="buttonCategory" + :size="buttonSize" :class="buttonClass" - > - <slot></slot> - </gl-deprecated-button> + :loading="loading" + :icon="buttonIcon" + :disabled="isDeleting || !hasSelectedDesigns" + /> </div> </template> diff --git a/app/assets/javascripts/design_management/components/design_destroyer.vue b/app/assets/javascripts/design_management/components/design_destroyer.vue index 62460ca551c..7ae569216f0 100644 --- a/app/assets/javascripts/design_management/components/design_destroyer.vue +++ b/app/assets/javascripts/design_management/components/design_destroyer.vue @@ -13,13 +13,14 @@ export default { type: Array, required: true, }, + }, + inject: { projectPath: { - type: String, - required: true, + default: '', }, iid: { - type: String, - required: true, + from: 'issueIid', + defaut: '', }, }, computed: { diff --git a/app/assets/javascripts/design_management/components/design_note_pin.vue b/app/assets/javascripts/design_management/components/design_note_pin.vue index 0811397fbad..2b5e62c2870 100644 --- a/app/assets/javascripts/design_management/components/design_note_pin.vue +++ b/app/assets/javascripts/design_management/components/design_note_pin.vue @@ -1,11 +1,11 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; export default { name: 'DesignNotePin', components: { - Icon, + GlIcon, }, props: { position: { @@ -47,13 +47,13 @@ export default { 'btn-transparent comment-indicator': isNewNote, 'js-image-badge badge badge-pill': !isNewNote, }" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0" type="button" @mousedown="$emit('mousedown', $event)" @mouseup="$emit('mouseup', $event)" @click="$emit('click', $event)" > - <icon v-if="isNewNote" name="image-comment-dark" /> + <gl-icon v-if="isNewNote" name="image-comment-dark" :size="24" /> <template v-else> {{ label }} </template> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 4aaf43e3a5b..6a20517eed7 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -230,10 +230,10 @@ export default { </button> </template> <template v-if="discussion.resolved" #resolvedStatus> - <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> + <p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> {{ __('Resolved by') }} <gl-link - class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color" + class="gl-text-gray-500 gl-text-decoration-none gl-font-sm link-inherit-color" :href="discussion.resolvedBy.webUrl" target="_blank" >{{ discussion.resolvedBy.name }}</gl-link diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index b1f3a43a66d..172e61920ef 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -60,7 +60,7 @@ export default { }, mounted() { if (this.isNoteLinked) { - this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); } }, methods: { @@ -80,7 +80,7 @@ export default { </script> <template> - <timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form"> + <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form"> <user-avatar-link :link-href="author.webUrl" :img-src="author.avatarUrl" diff --git a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue index 46c73e3eea8..2e366282de3 100644 --- a/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue +++ b/app/assets/javascripts/design_management/components/design_notes/toggle_replies_widget.vue @@ -52,18 +52,18 @@ export default { {{ toggleText }} </gl-button> <template v-if="collapsed"> - <span class="gl-text-gray-700">{{ __('Last reply by') }}</span> + <span class="gl-text-gray-500">{{ __('Last reply by') }}</span> <gl-link :href="lastReply.author.webUrl" target="_blank" - class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" + class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" > {{ lastReply.author.name }} </gl-link> <time-ago-tooltip :time="lastReply.createdAt" tooltip-placement="bottom" - class="gl-text-gray-700" + class="gl-text-gray-500" /> </template> </li> diff --git a/app/assets/javascripts/design_management/components/design_sidebar.vue b/app/assets/javascripts/design_management/components/design_sidebar.vue index 333ad2557e8..e5a3590877e 100644 --- a/app/assets/javascripts/design_management/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management/components/design_sidebar.vue @@ -1,8 +1,8 @@ <script> -import { s__ } from '~/locale'; import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; @@ -48,7 +48,7 @@ export default { }; }, discussionParticipants() { - return extractParticipants(this.issue.participants); + return extractParticipants(this.issue.participants.nodes); }, resolvedDiscussions() { return this.discussions.filter(discussion => discussion.resolved); @@ -94,7 +94,7 @@ export default { {{ issue.title }} </h2> <a - class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" :href="issue.webUrl" >{{ issue.webPath }}</a > @@ -132,7 +132,7 @@ export default { data-testid="resolved-comments" :icon="resolvedCommentsToggleIcon" variant="link" - class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4" @click="$emit('toggleResolvedComments')" >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }}) </gl-button> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index eaa641d85d6..292b6e09055 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -74,7 +74,7 @@ export default { deletion: { name: 'file-deletion-solid', classes: 'text-danger-500', - tooltip: __('Deleted in this version'), + tooltip: __('Archived in this version'), }, }; @@ -127,10 +127,10 @@ export default { params: { id: filename }, query: $route.query, }" - class="card cursor-pointer text-plain js-design-list-item design-list-item" + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" > <div class="card-body p-0 d-flex-center overflow-hidden position-relative"> - <div v-if="icon.name" class="design-event position-absolute"> + <div v-if="icon.name" data-testid="designEvent" class="design-event position-absolute"> <span :title="icon.tooltip" :aria-label="icon.tooltip"> <icon :name="icon.name" :size="18" :class="icon.classes" /> </span> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue index bf62a8f66a6..afca8ed2c6f 100644 --- a/app/assets/javascripts/design_management_new/components/toolbar/pagination.vue +++ b/app/assets/javascripts/design_management/components/toolbar/design_navigation.vue @@ -1,14 +1,15 @@ <script> /* global Mousetrap */ import 'mousetrap'; +import { GlButton, GlButtonGroup } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; -import PaginationButton from './pagination_button.vue'; import allDesignsMixin from '../../mixins/all_designs'; import { DESIGN_ROUTE_NAME } from '../../router/constants'; export default { components: { - PaginationButton, + GlButton, + GlButtonGroup, }, mixins: [allDesignsMixin], props: { @@ -31,12 +32,12 @@ export default { }); }, previousDesign() { - if (!this.designsCount) return null; + if (this.currentIndex === 0) return null; return this.designs[this.currentIndex - 1]; }, nextDesign() { - if (!this.designsCount) return null; + if (this.currentIndex + 1 === this.designsCount) return null; return this.designs[this.currentIndex + 1]; }, @@ -65,19 +66,21 @@ export default { <template> <div v-if="designsCount" class="d-flex align-items-center"> {{ paginationText }} - <div class="btn-group ml-3 mr-3"> - <pagination-button - :design="previousDesign" + <gl-button-group class="ml-3 mr-3"> + <gl-button + :disabled="!previousDesign" :title="s__('DesignManagement|Go to previous design')" - icon-name="angle-left" + icon="angle-left" class="js-previous-design" + @click="navigateToDesign(previousDesign)" /> - <pagination-button - :design="nextDesign" + <gl-button + :disabled="!nextDesign" :title="s__('DesignManagement|Go to next design')" - icon-name="angle-right" + icon="angle-right" class="js-next-design" + @click="navigateToDesign(nextDesign)" /> - </div> + </gl-button-group> </div> </template> diff --git a/app/assets/javascripts/design_management/components/toolbar/index.vue b/app/assets/javascripts/design_management/components/toolbar/index.vue index b998dfc47b8..a1cb57123ab 100644 --- a/app/assets/javascripts/design_management/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management/components/toolbar/index.vue @@ -1,20 +1,18 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton, GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; -import Pagination from './pagination.vue'; +import DesignNavigation from './design_navigation.vue'; import DeleteButton from '../delete_button.vue'; import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; -import appDataQuery from '../../graphql/queries/app_data.query.graphql'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; export default { components: { - Icon, - Pagination, + GlButton, + GlIcon, + DesignNavigation, DeleteButton, - GlDeprecatedButton, }, mixins: [timeagoMixin], props: { @@ -55,19 +53,17 @@ export default { permissions: { createDesign: false, }, - projectPath: '', - issueIid: null, }; }, - apollo: { - appData: { - query: appDataQuery, - manual: true, - result({ data: { projectPath, issueIid } }) { - this.projectPath = projectPath; - this.issueIid = issueIid; - }, + inject: { + projectPath: { + default: '', }, + issueIid: { + default: '', + }, + }, + apollo: { permissions: { query: permissionsQuery, variables() { @@ -95,32 +91,36 @@ export default { </script> <template> - <header class="d-flex p-2 bg-white align-items-center js-design-header"> - <router-link - :to="{ - name: $options.DESIGNS_ROUTE_NAME, - query: $route.query, - }" - :aria-label="s__('DesignManagement|Go back to designs')" - class="mr-3 text-plain d-flex justify-content-center align-items-center" - > - <icon :size="18" name="close" /> - </router-link> - <div class="overflow-hidden d-flex align-items-center"> - <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> - <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + <header + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-bg-white gl-py-4 gl-pl-4 js-design-header" + > + <div class="gl-display-flex gl-align-items-center"> + <router-link + :to="{ + name: $options.DESIGNS_ROUTE_NAME, + query: $route.query, + }" + :aria-label="s__('DesignManagement|Go back to designs')" + data-testid="close-design" + class="gl-mr-5 gl-display-flex gl-align-items-center gl-justify-content-center text-plain" + > + <gl-icon name="close" /> + </router-link> + <div class="overflow-hidden d-flex align-items-center"> + <h2 class="m-0 str-truncated-100 gl-font-base">{{ filename }}</h2> + <small v-if="updatedAt" class="text-secondary">{{ updatedText }}</small> + </div> </div> - <pagination :id="id" class="ml-auto flex-shrink-0" /> - <gl-deprecated-button :href="image" class="mr-2"> - <icon :size="18" name="download" /> - </gl-deprecated-button> + <design-navigation :id="id" class="ml-auto flex-shrink-0" /> + <gl-button :href="image" icon="download" /> <delete-button v-if="isLatestVersion && canDeleteDesign" + class="gl-ml-3" :is-deleting="isDeleting" - button-variant="danger" + button-variant="warning" + button-icon="archive" + button-category="secondary" @deleteSelectedDesigns="$emit('delete')" - > - <icon :size="18" name="remove" /> - </delete-button> + /> </header> </template> diff --git a/app/assets/javascripts/design_management/components/upload/button.vue b/app/assets/javascripts/design_management/components/upload/button.vue index 68555104a3c..c76041c74a8 100644 --- a/app/assets/javascripts/design_management/components/upload/button.vue +++ b/app/assets/javascripts/design_management/components/upload/button.vue @@ -1,10 +1,10 @@ <script> -import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; export default { components: { - GlDeprecatedButton, + GlButton, GlLoadingIcon, }, directives: { @@ -30,7 +30,7 @@ export default { <template> <div> - <gl-deprecated-button + <gl-button v-gl-tooltip.hover :title=" s__( @@ -38,12 +38,13 @@ export default { ) " :disabled="isSaving" - variant="success" + variant="default" + size="small" @click="openFileUpload" > {{ s__('DesignManagement|Upload designs') }} <gl-loading-icon v-if="isSaving" inline class="ml-1" /> - </gl-deprecated-button> + </gl-button> <input ref="fileUpload" diff --git a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue index 33261134c15..7254b7cd16a 100644 --- a/app/assets/javascripts/design_management/components/upload/design_dropzone.vue +++ b/app/assets/javascripts/design_management/components/upload/design_dropzone.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; import { isValidDesignFile } from '../../utils/design_management_utils'; @@ -12,6 +12,17 @@ export default { GlLink, GlSprintf, }, + props: { + hasDesigns: { + type: Boolean, + required: true, + }, + isDraggingDesign: { + type: Boolean, + required: false, + default: false, + }, + }, data() { return { dragCounter: 0, @@ -22,6 +33,12 @@ export default { dragging() { return this.dragCounter !== 0; }, + iconStyles() { + return { + size: this.hasDesigns ? 24 : 16, + class: this.hasDesigns ? 'gl-mb-2' : 'gl-mr-3 gl-text-gray-500', + }; + }, }, methods: { isValidUpload(files) { @@ -76,25 +93,21 @@ export default { > <slot> <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" @click="openFileUpload" > - <div class="d-flex-center flex-column text-center"> - <gl-icon name="doc-new" :size="48" class="mb-4" /> - <p> - <gl-sprintf - :message=" - __( - '%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.', - ) - " - > - <template #lineOne="{ content }" - ><span class="d-block">{{ content }}</span> - </template> - + <div + :class="{ 'gl-flex-direction-column': hasDesigns }" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center" + data-testid="dropzone-area" + > + <gl-icon name="upload" :size="iconStyles.size" :class="iconStyles.class" /> + <p class="gl-mb-0"> + <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} designs to attach')"> <template #link="{ content }"> - <gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link> + <gl-link @click.stop="openFileUpload"> + {{ content }} + </gl-link> </template> </gl-sprintf> </p> @@ -113,11 +126,11 @@ export default { </slot> <transition name="design-dropzone-fade"> <div - v-show="dragging" + v-show="dragging && !isDraggingDesign" class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" > <div v-show="!isDragDataValid" class="mw-50 text-center"> - <h3>{{ __('Oh no!') }}</h3> + <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3> <span>{{ __( 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', @@ -125,7 +138,7 @@ export default { }}</span> </div> <div v-show="isDragDataValid" class="mw-50 text-center"> - <h3>{{ __('Incoming!') }}</h3> + <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3> <span>{{ __('Drop your designs to start your upload.') }}</span> </div> </div> diff --git a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue index 993eac6f37f..a03982cb91b 100644 --- a/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management/components/upload/design_version_dropdown.vue @@ -1,13 +1,14 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlNewDropdown, GlNewDropdownItem, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import allVersionsMixin from '../../mixins/all_versions'; import { findVersionId } from '../../utils/design_management_utils'; export default { components: { - GlDropdown, - GlDropdownItem, + GlNewDropdown, + GlNewDropdownItem, + GlSprintf, }, mixins: [allVersionsMixin], computed: { @@ -18,7 +19,7 @@ export default { if (!this.queryVersion) return 0; const idx = this.allVersions.findIndex( - version => this.findVersionId(version.node.id) === this.queryVersion, + version => this.findVersionId(version.id) === this.queryVersion, ); // if the currentVersionId isn't a valid version (i.e. not in allVersions) @@ -29,48 +30,52 @@ export default { if (this.queryVersion) return this.queryVersion; const currentVersion = this.allVersions[this.currentVersionIdx]; - return this.findVersionId(currentVersion.node.id); + return this.findVersionId(currentVersion.id); }, dropdownText() { if (this.isLatestVersion) { - return __('Showing Latest Version'); + return __('Showing latest version'); } // allVersions is sorted in reverse chronological order (latest first) const currentVersionNumber = this.allVersions.length - this.currentVersionIdx; - return sprintf(__('Showing Version #%{versionNumber}'), { + return sprintf(__('Showing version #%{versionNumber}'), { versionNumber: currentVersionNumber, }); }, }, methods: { findVersionId, + routeToVersion(versionId) { + this.$router.push({ + path: this.$route.path, + query: { version: this.findVersionId(versionId) }, + }); + }, + versionText(versionId) { + if (this.findVersionId(versionId) === this.latestVersionId) { + return __('Version %{versionNumber} (latest)'); + } + return __('Version %{versionNumber}'); + }, }, }; </script> <template> - <gl-dropdown :text="dropdownText" variant="link" class="design-version-dropdown"> - <gl-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id"> - <router-link - class="d-flex js-version-link" - :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }" - > - <div class="flex-grow-1 ml-2"> - <div> - <strong - >{{ __('Version') }} {{ allVersions.length - index }} - <span v-if="findVersionId(version.node.id) === latestVersionId" - >({{ __('latest') }})</span - > - </strong> - </div> - </div> - <i - v-if="findVersionId(version.node.id) === currentVersionId" - class="fa fa-check pull-right" - ></i> - </router-link> - </gl-dropdown-item> - </gl-dropdown> + <gl-new-dropdown :text="dropdownText" size="small"> + <gl-new-dropdown-item + v-for="(version, index) in allVersions" + :key="version.id" + :is-check-item="true" + :is-checked="findVersionId(version.id) === currentVersionId" + @click="routeToVersion(version.id)" + > + <gl-sprintf :message="versionText(version.id)"> + <template #versionNumber> + {{ allVersions.length - index }} + </template> + </gl-sprintf> + </gl-new-dropdown-item> + </gl-new-dropdown> </template> diff --git a/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql index c8ade328120..0b8400ac040 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/create_image_diff_note.mutation.graphql @@ -8,10 +8,8 @@ mutation createImageDiffNote($input: CreateImageDiffNoteInput!) { id replyId notes { - edges { - node { - ...DesignNote - } + nodes { + ...DesignNote } } } diff --git a/app/assets/javascripts/design_management/graphql/mutations/move_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/move_design.mutation.graphql new file mode 100644 index 00000000000..144b2729999 --- /dev/null +++ b/app/assets/javascripts/design_management/graphql/mutations/move_design.mutation.graphql @@ -0,0 +1,18 @@ +#import "../fragments/design_list.fragment.graphql" + +mutation DesignManagementMove( + $id: DesignManagementDesignID! + $previous: DesignManagementDesignID + $next: DesignManagementDesignID +) { + designManagementMove(input: { id: $id, previous: $previous, next: $next }) { + designCollection { + designs { + nodes { + ...DesignListItem + } + } + } + errors + } +} diff --git a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql index d694e6558a0..84aeb374351 100644 --- a/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql +++ b/app/assets/javascripts/design_management/graphql/mutations/upload_design.mutation.graphql @@ -5,11 +5,9 @@ mutation uploadDesign($files: [Upload!]!, $projectPath: ID!, $iid: ID!) { designs { ...DesignItem versions { - edges { - node { - id - sha - } + nodes { + id + sha } } } diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql index 07a9af55787..ab987dda525 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design.query.graphql @@ -7,19 +7,15 @@ query getDesign($fullPath: ID!, $iid: String!, $atVersion: ID, $filenames: [Stri issue(iid: $iid) { designCollection { designs(atVersion: $atVersion, filenames: $filenames) { - edges { - node { - ...DesignItem - issue { - title - webPath - webUrl - participants { - edges { - node { - ...Author - } - } + nodes { + ...DesignItem + issue { + title + webPath + webUrl + participants { + nodes { + ...Author } } } diff --git a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql index 121a50555b3..96efa8e8242 100644 --- a/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql +++ b/app/assets/javascripts/design_management/graphql/queries/get_design_list.query.graphql @@ -7,17 +7,13 @@ query getDesignList($fullPath: ID!, $iid: String!, $atVersion: ID) { issue(iid: $iid) { designCollection { designs(atVersion: $atVersion) { - edges { - node { - ...DesignListItem - } + nodes { + ...DesignListItem } } versions { - edges { - node { - ...VersionListItem - } + nodes { + ...VersionListItem } } } diff --git a/app/assets/javascripts/design_management/index.js b/app/assets/javascripts/design_management/index.js index 1fc5779515a..20c9cacf83f 100644 --- a/app/assets/javascripts/design_management/index.js +++ b/app/assets/javascripts/design_management/index.js @@ -1,32 +1,15 @@ -// This application is being moved, please do not touch this files -// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details - -import $ from 'jquery'; import Vue from 'vue'; import createRouter from './router'; import App from './components/app.vue'; import apolloProvider from './graphql'; -import getDesignListQuery from './graphql/queries/get_design_list.query.graphql'; -import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants'; export default () => { - const el = document.querySelector('.js-design-management'); - const badge = document.querySelector('.js-designs-count'); + const el = document.querySelector('.js-design-management-new'); const { issueIid, projectPath, issuePath } = el.dataset; const router = createRouter(issuePath); - $('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => { - if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) { - router.push({ name: DESIGNS_ROUTE_NAME }); - } else if (id === 'discussion') { - router.push({ name: ROOT_ROUTE_NAME }); - } - }); - apolloProvider.clients.defaultClient.cache.writeData({ data: { - projectPath, - issueIid, activeDiscussion: { __typename: 'ActiveDiscussion', id: null, @@ -35,25 +18,14 @@ export default () => { }, }); - apolloProvider.clients.defaultClient - .watchQuery({ - query: getDesignListQuery, - variables: { - fullPath: projectPath, - iid: issueIid, - atVersion: null, - }, - }) - .subscribe(({ data }) => { - if (badge) { - badge.textContent = data.project.issue.designCollection.designs.edges.length; - } - }); - return new Vue({ el, router, apolloProvider, + provide: { + projectPath, + issueIid, + }, render(createElement) { return createElement(App); }, diff --git a/app/assets/javascripts/design_management/mixins/all_designs.js b/app/assets/javascripts/design_management/mixins/all_designs.js index f7d6551c46c..0c2858bb14b 100644 --- a/app/assets/javascripts/design_management/mixins/all_designs.js +++ b/app/assets/javascripts/design_management/mixins/all_designs.js @@ -1,8 +1,7 @@ import { propertyOf } from 'lodash'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; -import { extractNodes } from '../utils/design_management_utils'; import allVersionsMixin from './all_versions'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; @@ -19,9 +18,15 @@ export default { }; }, update: data => { - const designEdges = propertyOf(data)(['project', 'issue', 'designCollection', 'designs']); - if (designEdges) { - return extractNodes(designEdges); + const designNodes = propertyOf(data)([ + 'project', + 'issue', + 'designCollection', + 'designs', + 'nodes', + ]); + if (designNodes) { + return designNodes; } return []; }, diff --git a/app/assets/javascripts/design_management/mixins/all_versions.js b/app/assets/javascripts/design_management/mixins/all_versions.js index 3966fe71732..7a094f23378 100644 --- a/app/assets/javascripts/design_management/mixins/all_versions.js +++ b/app/assets/javascripts/design_management/mixins/all_versions.js @@ -1,17 +1,8 @@ import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; -import appDataQuery from '../graphql/queries/app_data.query.graphql'; import { findVersionId } from '../utils/design_management_utils'; export default { apollo: { - appData: { - query: appDataQuery, - manual: true, - result({ data: { projectPath, issueIid } }) { - this.projectPath = projectPath; - this.issueIid = issueIid; - }, - }, allVersions: { query: getDesignListQuery, variables() { @@ -21,7 +12,15 @@ export default { atVersion: null, }; }, - update: data => data.project.issue.designCollection.versions.edges, + update: data => data.project.issue.designCollection.versions.nodes, + }, + }, + inject: { + projectPath: { + default: '', + }, + issueIid: { + default: '', }, }, computed: { @@ -29,7 +28,7 @@ export default { return ( this.$route.query.version && this.allVersions && - this.allVersions.some(version => version.node.id.endsWith(this.$route.query.version)) + this.allVersions.some(version => version.id.endsWith(this.$route.query.version)) ); }, designsVersion() { @@ -39,7 +38,7 @@ export default { }, latestVersionId() { const latestVersion = this.allVersions[0]; - return latestVersion && findVersionId(latestVersion.node.id); + return latestVersion && findVersionId(latestVersion.id); }, isLatestVersion() { if (this.allVersions.length > 0) { @@ -55,8 +54,6 @@ export default { data() { return { allVersions: [], - projectPath: '', - issueIid: null, }; }, }; diff --git a/app/assets/javascripts/design_management/pages/design/index.vue b/app/assets/javascripts/design_management/pages/design/index.vue index 9a959222e22..17b72e73127 100644 --- a/app/assets/javascripts/design_management/pages/design/index.vue +++ b/app/assets/javascripts/design_management/pages/design/index.vue @@ -2,7 +2,7 @@ import Mousetrap from 'mousetrap'; import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import allVersionsMixin from '../../mixins/all_versions'; import Toolbar from '../../components/toolbar/index.vue'; @@ -12,7 +12,6 @@ import DesignPresentation from '../../components/design_presentation.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; import DesignSidebar from '../../components/design_sidebar.vue'; import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; -import appDataQuery from '../../graphql/queries/app_data.query.graphql'; import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; @@ -62,22 +61,12 @@ export default { design: {}, comment: '', annotationCoordinates: null, - projectPath: '', errorMessage: '', - issueIid: '', scale: 1, resolvedDiscussionsExpanded: false, }; }, apollo: { - appData: { - query: appDataQuery, - manual: true, - result({ data: { projectPath, issueIid } }) { - this.projectPath = projectPath; - this.issueIid = issueIid; - }, - }, design: { query: getDesignQuery, // We want to see cached design version if we have one, and fetch newer version on the background to update discussions diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index d14a1fc8c1c..cd68e9d6c5b 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -1,6 +1,7 @@ <script> -import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; +import VueDraggable from 'vuedraggable'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import UploadButton from '../components/upload/button.vue'; import DeleteButton from '../components/delete_button.vue'; @@ -9,6 +10,7 @@ import DesignDestroyer from '../components/design_destroyer.vue'; import DesignVersionDropdown from '../components/upload/design_version_dropdown.vue'; import DesignDropzone from '../components/upload/design_dropzone.vue'; import uploadDesignMutation from '../graphql/mutations/upload_design.mutation.graphql'; +import moveDesignMutation from '../graphql/mutations/move_design.mutation.graphql'; import permissionsQuery from '../graphql/queries/design_permissions.query.graphql'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; import allDesignsMixin from '../mixins/all_designs'; @@ -16,13 +18,18 @@ import { UPLOAD_DESIGN_ERROR, EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, + MOVE_DESIGN_ERROR, designUploadSkippedWarning, designDeletionError, } from '../utils/error_messages'; -import { updateStoreAfterUploadDesign } from '../utils/cache_update'; +import { + updateStoreAfterUploadDesign, + updateDesignsOnStoreAfterReorder, +} from '../utils/cache_update'; import { designUploadOptimisticResponse, isValidDesignFile, + moveDesignOptimisticResponse, } from '../utils/design_management_utils'; import { getFilename } from '~/lib/utils/file_upload'; import { DESIGNS_ROUTE_NAME } from '../router/constants'; @@ -33,13 +40,14 @@ export default { components: { GlLoadingIcon, GlAlert, - GlDeprecatedButton, + GlButton, UploadButton, Design, DesignDestroyer, DesignVersionDropdown, DeleteButton, DesignDropzone, + VueDraggable, }, mixins: [allDesignsMixin], apollo: { @@ -61,6 +69,8 @@ export default { }, filesToBeSaved: [], selectedDesigns: [], + isDraggingDesign: false, + reorderedDesigns: null, }; }, computed: { @@ -96,9 +106,19 @@ export default { ? s__('DesignManagement|Deselect all') : s__('DesignManagement|Select all'); }, + isDesignListEmpty() { + return !this.isSaving && !this.hasDesigns; + }, + designDropzoneWrapperClass() { + return this.isDesignListEmpty + ? 'col-12' + : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3'; + }, }, mounted() { - this.toggleOnPasteListener(this.$route.name); + if (this.$route.path === '/designs') { + this.$el.scrollIntoView(); + } }, methods: { resetFilesToBeSaved() { @@ -238,56 +258,97 @@ export default { this.onUploadDesign([newFile]); } }, - toggleOnPasteListener(route) { - if (route === DESIGNS_ROUTE_NAME) { - document.addEventListener('paste', this.onDesignPaste); - } else { - document.removeEventListener('paste', this.onDesignPaste); + toggleOnPasteListener() { + document.addEventListener('paste', this.onDesignPaste); + }, + toggleOffPasteListener() { + document.removeEventListener('paste', this.onDesignPaste); + }, + designMoveVariables(newIndex, element) { + const variables = { + id: element.id, + }; + if (newIndex > 0) { + variables.previous = this.reorderedDesigns[newIndex - 1].id; + } + if (newIndex < this.reorderedDesigns.length - 1) { + variables.next = this.reorderedDesigns[newIndex + 1].id; } + return variables; + }, + reorderDesigns({ moved: { newIndex, element } }) { + this.$apollo + .mutate({ + mutation: moveDesignMutation, + variables: this.designMoveVariables(newIndex, element), + update: (store, { data: { designManagementMove } }) => { + return updateDesignsOnStoreAfterReorder( + store, + designManagementMove, + this.projectQueryBody, + ); + }, + optimisticResponse: moveDesignOptimisticResponse(this.reorderedDesigns), + }) + .catch(() => { + createFlash(MOVE_DESIGN_ERROR); + }); + }, + onDesignMove(designs) { + this.reorderedDesigns = designs; }, }, beforeRouteUpdate(to, from, next) { - this.toggleOnPasteListener(to.name); this.selectedDesigns = []; next(); }, - beforeRouteLeave(to, from, next) { - this.toggleOnPasteListener(to.name); - next(); + dragOptions: { + animation: 200, + ghostClass: 'gl-visibility-hidden', }, }; </script> <template> - <div> + <div + data-testid="designs-root" + class="gl-mt-5" + @mouseenter="toggleOnPasteListener" + @mouseleave="toggleOffPasteListener" + > <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> - <div class="d-flex justify-content-between align-items-center w-100"> - <design-version-dropdown /> - <div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]"> - <gl-deprecated-button + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"> + <div> + <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> + <design-version-dropdown /> + </div> + <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex gl-align-items-center"> + <gl-button v-if="isLatestVersion" variant="link" - class="mr-2 js-select-all" + size="small" + class="gl-mr-3 js-select-all" @click="toggleDesignsSelection" - >{{ selectAllButtonText }}</gl-deprecated-button - > + >{{ selectAllButtonText }} + </gl-button> <design-destroyer #default="{ mutate, loading }" :filenames="selectedDesigns" - :project-path="projectPath" - :iid="issueIid" @done="onDesignDelete" @error="onDesignDeleteError" > <delete-button v-if="isLatestVersion" :is-deleting="loading" - button-class="btn-danger btn-inverted mr-2" + button-variant="warning" + button-category="secondary" + button-class="gl-mr-3" + button-size="small" + :loading="loading" :has-selected-designs="hasSelectedDesigns" @deleteSelectedDesigns="mutate()" > - {{ s__('DesignManagement|Delete selected') }} - <gl-loading-icon v-if="loading" inline class="ml-1" /> + {{ s__('DesignManagement|Archive selected') }} </delete-button> </design-destroyer> <upload-button v-if="canCreateDesign" :is-saving="isSaving" @upload="onUploadDesign" /> @@ -299,14 +360,35 @@ export default { <gl-alert v-else-if="error" variant="danger" :dismissible="false"> {{ __('An error occurred while loading designs. Please try again.') }} </gl-alert> - <ol v-else class="list-unstyled row"> - <li class="col-md-6 col-lg-4 mb-3"> - <design-dropzone class="design-list-item" @change="onUploadDesign" /> - </li> - <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3"> - <design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)" - ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" - /></design-dropzone> + <vue-draggable + v-else + :value="designs" + :disabled="!isLatestVersion" + v-bind="$options.dragOptions" + tag="ol" + draggable=".js-design-tile" + class="list-unstyled row" + @start="isDraggingDesign = true" + @end="isDraggingDesign = false" + @change="reorderDesigns" + @input="onDesignMove" + > + <li + v-for="design in designs" + :key="design.id" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" + > + <design-dropzone + :has-designs="hasDesigns" + :is-dragging-design="isDraggingDesign" + @change="onExistingDesignDropzoneChange($event, design.filename)" + > + <design + v-bind="design" + :is-uploading="isDesignToBeSaved(design.filename)" + class="gl-bg-white" + /> + </design-dropzone> <input v-if="canSelectDesign(design.filename)" @@ -316,7 +398,17 @@ export default { @change="changeSelectedDesigns(design.filename)" /> </li> - </ol> + <template #header> + <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> + <design-dropzone + :is-dragging-design="isDraggingDesign" + :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" + :has-designs="hasDesigns" + @change="onUploadDesign" + /> + </li> + </template> + </vue-draggable> </div> <router-view :key="$route.fullPath" /> </div> diff --git a/app/assets/javascripts/design_management/router/constants.js b/app/assets/javascripts/design_management/router/constants.js index abeef520e33..dd2ee8d8689 100644 --- a/app/assets/javascripts/design_management/router/constants.js +++ b/app/assets/javascripts/design_management/router/constants.js @@ -1,3 +1,2 @@ -export const ROOT_ROUTE_NAME = 'root'; export const DESIGNS_ROUTE_NAME = 'designs'; export const DESIGN_ROUTE_NAME = 'design'; diff --git a/app/assets/javascripts/design_management/router/index.js b/app/assets/javascripts/design_management/router/index.js index 7494da002c8..cbeb2f7ce42 100644 --- a/app/assets/javascripts/design_management/router/index.js +++ b/app/assets/javascripts/design_management/router/index.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import Vue from 'vue'; import VueRouter from 'vue-router'; import routes from './routes'; @@ -16,9 +15,7 @@ export default function createRouter(base) { }); const pageEl = getPageLayoutElement(); - router.beforeEach(({ meta: { el }, name }, _, next) => { - $(`#${el}`).tab('show'); - + router.beforeEach(({ name }, _, next) => { // apply a fullscreen layout style in Design View (a.k.a design detail) if (pageEl) { if (name === DESIGN_ROUTE_NAME) { diff --git a/app/assets/javascripts/design_management/router/routes.js b/app/assets/javascripts/design_management/router/routes.js index 788910e5514..d888b856611 100644 --- a/app/assets/javascripts/design_management/router/routes.js +++ b/app/assets/javascripts/design_management/router/routes.js @@ -1,44 +1,29 @@ import Home from '../pages/index.vue'; import DesignDetail from '../pages/design/index.vue'; -import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; +import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; export default [ { - name: ROOT_ROUTE_NAME, + name: DESIGNS_ROUTE_NAME, path: '/', component: Home, - meta: { - el: 'discussion', - }, + alias: '/designs', }, { - name: DESIGNS_ROUTE_NAME, - path: '/designs', - component: Home, - meta: { - el: 'designs', - }, - children: [ + name: DESIGN_ROUTE_NAME, + path: '/designs/:id', + component: DesignDetail, + beforeEnter( { - name: DESIGN_ROUTE_NAME, - path: ':id', - component: DesignDetail, - meta: { - el: 'designs', - }, - beforeEnter( - { - params: { id }, - }, - from, - next, - ) { - if (typeof id === 'string') { - next(); - } - }, - props: ({ params: { id } }) => ({ id }), + params: { id }, }, - ], + _, + next, + ) { + if (typeof id === 'string') { + next(); + } + }, + props: ({ params: { id } }) => ({ id }), }, ]; diff --git a/app/assets/javascripts/design_management/utils/cache_update.js b/app/assets/javascripts/design_management/utils/cache_update.js index 24b374b79fd..b79df9d01d5 100644 --- a/app/assets/javascripts/design_management/utils/cache_update.js +++ b/app/assets/javascripts/design_management/utils/cache_update.js @@ -1,6 +1,7 @@ /* eslint-disable @gitlab/require-i18n-strings */ -import createFlash from '~/flash'; +import { groupBy } from 'lodash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { extractCurrentDiscussion, extractDesign } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, @@ -12,10 +13,10 @@ import { const deleteDesignsFromStore = (store, query, selectedDesigns) => { const data = store.readQuery(query); - const changedDesigns = data.project.issue.designCollection.designs.edges.filter( - ({ node }) => !selectedDesigns.includes(node.filename), + const changedDesigns = data.project.issue.designCollection.designs.nodes.filter( + node => !selectedDesigns.includes(node.filename), ); - data.project.issue.designCollection.designs.edges = [...changedDesigns]; + data.project.issue.designCollection.designs.nodes = [...changedDesigns]; store.writeQuery({ ...query, @@ -34,11 +35,10 @@ const addNewVersionToStore = (store, query, version) => { if (!version) return; const data = store.readQuery(query); - const newEdge = { node: version, __typename: 'DesignVersionEdge' }; - data.project.issue.designCollection.versions.edges = [ - newEdge, - ...data.project.issue.designCollection.versions.edges, + data.project.issue.designCollection.versions.nodes = [ + version, + ...data.project.issue.designCollection.versions.nodes, ]; store.writeQuery({ @@ -59,18 +59,15 @@ const addDiscussionCommentToStore = (store, createNote, query, queryVariables, d design.notesCount += 1; if ( - !design.issue.participants.edges.some( - participant => participant.node.username === createNote.note.author.username, + !design.issue.participants.nodes.some( + participant => participant.username === createNote.note.author.username, ) ) { - design.issue.participants.edges = [ - ...design.issue.participants.edges, + design.issue.participants.nodes = [ + ...design.issue.participants.nodes, { - __typename: 'UserEdge', - node: { - __typename: 'User', - ...createNote.note.author, - }, + __typename: 'User', + ...createNote.note.author, }, ]; } @@ -108,18 +105,15 @@ const addImageDiffNoteToStore = (store, createImageDiffNote, query, variables) = const notesCount = design.notesCount + 1; design.discussions.nodes = [...design.discussions.nodes, newDiscussion]; if ( - !design.issue.participants.edges.some( - participant => participant.node.username === createImageDiffNote.note.author.username, + !design.issue.participants.nodes.some( + participant => participant.username === createImageDiffNote.note.author.username, ) ) { - design.issue.participants.edges = [ - ...design.issue.participants.edges, + design.issue.participants.nodes = [ + ...design.issue.participants.nodes, { - __typename: 'UserEdge', - node: { - __typename: 'User', - ...createImageDiffNote.note.author, - }, + __typename: 'User', + ...createImageDiffNote.note.author, }, ]; } @@ -166,42 +160,37 @@ const updateImageDiffNoteInStore = (store, updateImageDiffNote, query, variables const addNewDesignToStore = (store, designManagementUpload, query) => { const data = store.readQuery(query); - const newDesigns = data.project.issue.designCollection.designs.edges.reduce((acc, design) => { - if (!acc.find(d => d.filename === design.node.filename)) { - acc.push(design.node); - } - - return acc; - }, designManagementUpload.designs); + const currentDesigns = data.project.issue.designCollection.designs.nodes; + const existingDesigns = groupBy(currentDesigns, 'filename'); + const newDesigns = currentDesigns.concat( + designManagementUpload.designs.filter(d => !existingDesigns[d.filename]), + ); let newVersionNode; const findNewVersions = designManagementUpload.designs.find(design => design.versions); if (findNewVersions) { - const findNewVersionsEdges = findNewVersions.versions.edges; + const findNewVersionsNodes = findNewVersions.versions.nodes; - if (findNewVersionsEdges && findNewVersionsEdges.length) { - newVersionNode = [findNewVersionsEdges[0]]; + if (findNewVersionsNodes && findNewVersionsNodes.length) { + newVersionNode = [findNewVersionsNodes[0]]; } } const newVersions = [ ...(newVersionNode || []), - ...data.project.issue.designCollection.versions.edges, + ...data.project.issue.designCollection.versions.nodes, ]; const updatedDesigns = { __typename: 'DesignCollection', designs: { __typename: 'DesignConnection', - edges: newDesigns.map(design => ({ - __typename: 'DesignEdge', - node: design, - })), + nodes: newDesigns, }, versions: { __typename: 'DesignVersionConnection', - edges: newVersions, + nodes: newVersions, }, }; @@ -213,6 +202,15 @@ const addNewDesignToStore = (store, designManagementUpload, query) => { }); }; +const moveDesignInStore = (store, designManagementMove, query) => { + const data = store.readQuery(query); + data.project.issue.designCollection.designs = designManagementMove.designCollection.designs; + store.writeQuery({ + ...query, + data, + }); +}; + const onError = (data, message) => { createFlash(message); throw new Error(data.errors); @@ -274,3 +272,11 @@ export const updateStoreAfterUploadDesign = (store, data, query) => { addNewDesignToStore(store, data, query); } }; + +export const updateDesignsOnStoreAfterReorder = (store, data, query) => { + if (hasErrors(data)) { + createFlash(data.errors[0]); + } else { + moveDesignInStore(store, data, query); + } +}; diff --git a/app/assets/javascripts/design_management/utils/design_management_utils.js b/app/assets/javascripts/design_management/utils/design_management_utils.js index 22705cf67a1..da8f89ff960 100644 --- a/app/assets/javascripts/design_management/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management/utils/design_management_utils.js @@ -5,17 +5,7 @@ export const isValidDesignFile = ({ type }) => (type.match(VALID_DESIGN_FILE_MIMETYPE.regex) || []).length > 0; /** - * Returns formatted array that doesn't contain - * `edges`->`node` nesting - * - * @param {Array} elements - */ - -export const extractNodes = elements => elements.edges.map(({ node }) => node); - -/** - * Returns formatted array of discussions that doesn't contain - * `edges`->`node` nesting for child notes + * Returns formatted array of discussions * * @param {Array} discussions */ @@ -40,9 +30,9 @@ export const findVersionId = id => (id.match('::Version/(.+$)') || [])[1]; export const findNoteId = id => (id.match('DiffNote/(.+$)') || [])[1]; -export const extractDesigns = data => data.project.issue.designCollection.designs.edges; +export const extractDesigns = data => data.project.issue.designCollection.designs.nodes; -export const extractDesign = data => (extractDesigns(data) || [])[0]?.node; +export const extractDesign = data => (extractDesigns(data) || [])[0]; /** * Generates optimistic response for a design upload mutation @@ -72,13 +62,10 @@ export const designUploadOptimisticResponse = files => { }, versions: { __typename: 'DesignVersionConnection', - edges: { - __typename: 'DesignVersionEdge', - node: { - __typename: 'DesignVersion', - id: -uniqueId(), - sha: -uniqueId(), - }, + nodes: { + __typename: 'DesignVersion', + id: -uniqueId(), + sha: -uniqueId(), }, }, })); @@ -98,7 +85,8 @@ export const designUploadOptimisticResponse = files => { /** * Generates optimistic response for a design upload mutation - * @param {Array<File>} files + * @param {Object} note + * @param {Object} position */ export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({ // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 @@ -117,12 +105,33 @@ export const updateImageDiffNoteOptimisticResponse = (note, { position }) => ({ }, }); +/** + * Generates optimistic response for a design upload mutation + * @param {Array} designs + */ +export const moveDesignOptimisticResponse = designs => ({ + // False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26 + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + designManagementMove: { + __typename: 'DesignManagementMovePayload', + designCollection: { + __typename: 'DesignCollection', + designs: { + __typename: 'DesignConnection', + nodes: designs, + }, + }, + errors: [], + }, +}); + const normalizeAuthor = author => ({ ...author, web_url: author.webUrl, avatar_url: author.avatarUrl, }); -export const extractParticipants = users => users.edges.map(({ node }) => normalizeAuthor(node)); +export const extractParticipants = users => users.map(node => normalizeAuthor(node)); export const getPageLayoutElement = () => document.querySelector('.layout-page'); diff --git a/app/assets/javascripts/design_management/utils/error_messages.js b/app/assets/javascripts/design_management/utils/error_messages.js index 7666c726c2f..c815b11737d 100644 --- a/app/assets/javascripts/design_management/utils/error_messages.js +++ b/app/assets/javascripts/design_management/utils/error_messages.js @@ -40,6 +40,10 @@ export const EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE = __( 'You must upload a file with the same file name when dropping onto an existing design.', ); +export const MOVE_DESIGN_ERROR = __( + 'Something went wrong when reordering designs. Please try again', +); + const MAX_SKIPPED_FILES_LISTINGS = 5; const oneDesignSkippedMessage = filename => @@ -69,7 +73,7 @@ const someDesignsSkippedMessage = skippedFiles => { export const designDeletionError = ({ singular = true } = {}) => { const design = singular ? __('a design') : __('designs'); - return sprintf(s__('Could not delete %{design}. Please try again.'), { + return sprintf(s__('Could not archive %{design}. Please try again.'), { design, }); }; diff --git a/app/assets/javascripts/design_management_new/components/app.vue b/app/assets/javascripts/design_management_legacy/components/app.vue index 98240aef810..98240aef810 100644 --- a/app/assets/javascripts/design_management_new/components/app.vue +++ b/app/assets/javascripts/design_management_legacy/components/app.vue diff --git a/app/assets/javascripts/design_management_new/components/delete_button.vue b/app/assets/javascripts/design_management_legacy/components/delete_button.vue index 77e1b97a227..1fd902c9ed7 100644 --- a/app/assets/javascripts/design_management_new/components/delete_button.vue +++ b/app/assets/javascripts/design_management_legacy/components/delete_button.vue @@ -1,12 +1,11 @@ <script> -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; import { uniqueId } from 'lodash'; -import { s__ } from '~/locale'; export default { name: 'DeleteButton', components: { - GlButton, + GlDeprecatedButton, GlModal, }, directives: { @@ -26,12 +25,7 @@ export default { buttonVariant: { type: String, required: false, - default: 'info', - }, - buttonSize: { - type: String, - required: false, - default: 'medium', + default: '', }, hasSelectedDesigns: { type: Boolean, @@ -44,38 +38,27 @@ export default { modalId: uniqueId('design-deletion-confirmation-'), }; }, - modal: { - title: s__('DesignManagement|Delete designs confirmation'), - actionPrimary: { - text: s__('Delete'), - attributes: { variant: 'danger' }, - }, - actionCancel: { - text: s__('Cancel'), - }, - }, }; </script> <template> - <div class="gl-display-flex gl-align-items-center gl-h-full"> + <div> <gl-modal :modal-id="modalId" - :title="$options.modal.title" - :action-primary="$options.modal.actionPrimary" - :action-cancel="$options.modal.actionCancel" + :title="s__('DesignManagement|Delete designs confirmation')" + :ok-title="s__('DesignManagement|Delete')" + ok-variant="danger" @ok="$emit('deleteSelectedDesigns')" > <p>{{ s__('DesignManagement|Are you sure you want to delete the selected designs?') }}</p> </gl-modal> - <gl-button + <gl-deprecated-button v-gl-modal-directive="modalId" :variant="buttonVariant" - :size="buttonSize" - :class="buttonClass" :disabled="isDeleting || !hasSelectedDesigns" + :class="buttonClass" > <slot></slot> - </gl-button> + </gl-deprecated-button> </div> </template> diff --git a/app/assets/javascripts/design_management_new/components/design_destroyer.vue b/app/assets/javascripts/design_management_legacy/components/design_destroyer.vue index 7ae569216f0..62460ca551c 100644 --- a/app/assets/javascripts/design_management_new/components/design_destroyer.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_destroyer.vue @@ -13,14 +13,13 @@ export default { type: Array, required: true, }, - }, - inject: { projectPath: { - default: '', + type: String, + required: true, }, iid: { - from: 'issueIid', - defaut: '', + type: String, + required: true, }, }, computed: { diff --git a/app/assets/javascripts/design_management_new/components/design_note_pin.vue b/app/assets/javascripts/design_management_legacy/components/design_note_pin.vue index 0811397fbad..2b5e62c2870 100644 --- a/app/assets/javascripts/design_management_new/components/design_note_pin.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_note_pin.vue @@ -1,11 +1,11 @@ <script> +import { GlIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; export default { name: 'DesignNotePin', components: { - Icon, + GlIcon, }, props: { position: { @@ -47,13 +47,13 @@ export default { 'btn-transparent comment-indicator': isNewNote, 'js-image-badge badge badge-pill': !isNewNote, }" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center" + class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0" type="button" @mousedown="$emit('mousedown', $event)" @mouseup="$emit('mouseup', $event)" @click="$emit('click', $event)" > - <icon v-if="isNewNote" name="image-comment-dark" /> + <gl-icon v-if="isNewNote" name="image-comment-dark" :size="24" /> <template v-else> {{ label }} </template> diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/design_discussion.vue index 4aaf43e3a5b..6a20517eed7 100644 --- a/app/assets/javascripts/design_management_new/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/design_discussion.vue @@ -230,10 +230,10 @@ export default { </button> </template> <template v-if="discussion.resolved" #resolvedStatus> - <p class="gl-text-gray-700 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> + <p class="gl-text-gray-500 gl-font-sm gl-m-0 gl-mt-5" data-testid="resolved-message"> {{ __('Resolved by') }} <gl-link - class="gl-text-gray-700 gl-text-decoration-none gl-font-sm link-inherit-color" + class="gl-text-gray-500 gl-text-decoration-none gl-font-sm link-inherit-color" :href="discussion.resolvedBy.webUrl" target="_blank" >{{ discussion.resolvedBy.name }}</gl-link diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/design_note.vue index 172e61920ef..b1f3a43a66d 100644 --- a/app/assets/javascripts/design_management_new/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/design_note.vue @@ -60,7 +60,7 @@ export default { }, mounted() { if (this.isNoteLinked) { - this.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); + this.$refs.anchor.$el.scrollIntoView({ behavior: 'smooth', inline: 'start' }); } }, methods: { @@ -80,7 +80,7 @@ export default { </script> <template> - <timeline-entry-item :id="`note_${noteAnchorId}`" class="design-note note-form"> + <timeline-entry-item :id="`note_${noteAnchorId}`" ref="anchor" class="design-note note-form"> <user-avatar-link :link-href="author.webUrl" :img-src="author.avatarUrl" diff --git a/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/design_reply_form.vue index 969034909f2..969034909f2 100644 --- a/app/assets/javascripts/design_management_new/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/design_reply_form.vue diff --git a/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue b/app/assets/javascripts/design_management_legacy/components/design_notes/toggle_replies_widget.vue index 46c73e3eea8..2e366282de3 100644 --- a/app/assets/javascripts/design_management_new/components/design_notes/toggle_replies_widget.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_notes/toggle_replies_widget.vue @@ -52,18 +52,18 @@ export default { {{ toggleText }} </gl-button> <template v-if="collapsed"> - <span class="gl-text-gray-700">{{ __('Last reply by') }}</span> + <span class="gl-text-gray-500">{{ __('Last reply by') }}</span> <gl-link :href="lastReply.author.webUrl" target="_blank" - class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" + class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-ml-2 gl-mr-2" > {{ lastReply.author.name }} </gl-link> <time-ago-tooltip :time="lastReply.createdAt" tooltip-placement="bottom" - class="gl-text-gray-700" + class="gl-text-gray-500" /> </template> </li> diff --git a/app/assets/javascripts/design_management_new/components/design_overlay.vue b/app/assets/javascripts/design_management_legacy/components/design_overlay.vue index 926e7c74802..926e7c74802 100644 --- a/app/assets/javascripts/design_management_new/components/design_overlay.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_overlay.vue diff --git a/app/assets/javascripts/design_management_new/components/design_presentation.vue b/app/assets/javascripts/design_management_legacy/components/design_presentation.vue index 84dbb2809d9..84dbb2809d9 100644 --- a/app/assets/javascripts/design_management_new/components/design_presentation.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_presentation.vue diff --git a/app/assets/javascripts/design_management_new/components/design_scaler.vue b/app/assets/javascripts/design_management_legacy/components/design_scaler.vue index 55dee74bef5..55dee74bef5 100644 --- a/app/assets/javascripts/design_management_new/components/design_scaler.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_scaler.vue diff --git a/app/assets/javascripts/design_management_new/components/design_sidebar.vue b/app/assets/javascripts/design_management_legacy/components/design_sidebar.vue index 333ad2557e8..622120e2008 100644 --- a/app/assets/javascripts/design_management_new/components/design_sidebar.vue +++ b/app/assets/javascripts/design_management_legacy/components/design_sidebar.vue @@ -1,8 +1,8 @@ <script> -import { s__ } from '~/locale'; import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; import { GlCollapse, GlButton, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { parseBoolean } from '~/lib/utils/common_utils'; import updateActiveDiscussionMutation from '../graphql/mutations/update_active_discussion.mutation.graphql'; import { extractDiscussions, extractParticipants } from '../utils/design_management_utils'; import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '../constants'; @@ -94,7 +94,7 @@ export default { {{ issue.title }} </h2> <a - class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" :href="issue.webUrl" >{{ issue.webPath }}</a > @@ -132,7 +132,7 @@ export default { data-testid="resolved-comments" :icon="resolvedCommentsToggleIcon" variant="link" - class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4" @click="$emit('toggleResolvedComments')" >{{ $options.resolveCommentsToggleText }} ({{ resolvedDiscussions.length }}) </gl-button> diff --git a/app/assets/javascripts/design_management_new/components/image.vue b/app/assets/javascripts/design_management_legacy/components/image.vue index 91b7b576e0c..91b7b576e0c 100644 --- a/app/assets/javascripts/design_management_new/components/image.vue +++ b/app/assets/javascripts/design_management_legacy/components/image.vue diff --git a/app/assets/javascripts/design_management_new/components/list/item.vue b/app/assets/javascripts/design_management_legacy/components/list/item.vue index b19aef9c22d..13c703b8a88 100644 --- a/app/assets/javascripts/design_management_new/components/list/item.vue +++ b/app/assets/javascripts/design_management_legacy/components/list/item.vue @@ -127,10 +127,10 @@ export default { params: { id: filename }, query: $route.query, }" - class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + class="card cursor-pointer text-plain js-design-list-item design-list-item" > <div class="card-body p-0 d-flex-center overflow-hidden position-relative"> - <div v-if="icon.name" class="design-event position-absolute"> + <div v-if="icon.name" data-testid="designEvent" class="design-event position-absolute"> <span :title="icon.tooltip" :aria-label="icon.tooltip"> <icon :name="icon.name" :size="18" :class="icon.classes" /> </span> diff --git a/app/assets/javascripts/design_management_new/components/toolbar/index.vue b/app/assets/javascripts/design_management_legacy/components/toolbar/index.vue index 0b51035e83e..b998dfc47b8 100644 --- a/app/assets/javascripts/design_management_new/components/toolbar/index.vue +++ b/app/assets/javascripts/design_management_legacy/components/toolbar/index.vue @@ -6,6 +6,7 @@ import timeagoMixin from '~/vue_shared/mixins/timeago'; import Pagination from './pagination.vue'; import DeleteButton from '../delete_button.vue'; import permissionsQuery from '../../graphql/queries/design_permissions.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; import { DESIGNS_ROUTE_NAME } from '../../router/constants'; export default { @@ -54,17 +55,19 @@ export default { permissions: { createDesign: false, }, + projectPath: '', + issueIid: null, }; }, - inject: { - projectPath: { - default: '', - }, - issueIid: { - default: '', - }, - }, apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, permissions: { query: permissionsQuery, variables() { @@ -99,7 +102,6 @@ export default { query: $route.query, }" :aria-label="s__('DesignManagement|Go back to designs')" - data-testid="close-design" class="mr-3 text-plain d-flex justify-content-center align-items-center" > <icon :size="18" name="close" /> diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination.vue b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination.vue index bf62a8f66a6..bf62a8f66a6 100644 --- a/app/assets/javascripts/design_management/components/toolbar/pagination.vue +++ b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination.vue diff --git a/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination_button.vue index f00ecefca01..f00ecefca01 100644 --- a/app/assets/javascripts/design_management/components/toolbar/pagination_button.vue +++ b/app/assets/javascripts/design_management_legacy/components/toolbar/pagination_button.vue diff --git a/app/assets/javascripts/design_management_new/components/upload/button.vue b/app/assets/javascripts/design_management_legacy/components/upload/button.vue index de8a38334ac..68555104a3c 100644 --- a/app/assets/javascripts/design_management_new/components/upload/button.vue +++ b/app/assets/javascripts/design_management_legacy/components/upload/button.vue @@ -1,10 +1,10 @@ <script> -import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { GlDeprecatedButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { VALID_DESIGN_FILE_MIMETYPE } from '../../constants'; export default { components: { - GlButton, + GlDeprecatedButton, GlLoadingIcon, }, directives: { @@ -30,7 +30,7 @@ export default { <template> <div> - <gl-button + <gl-deprecated-button v-gl-tooltip.hover :title=" s__( @@ -39,12 +39,11 @@ export default { " :disabled="isSaving" variant="success" - size="small" @click="openFileUpload" > {{ s__('DesignManagement|Upload designs') }} <gl-loading-icon v-if="isSaving" inline class="ml-1" /> - </gl-button> + </gl-deprecated-button> <input ref="fileUpload" diff --git a/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue b/app/assets/javascripts/design_management_legacy/components/upload/design_dropzone.vue index 7b829d63330..e435c84c959 100644 --- a/app/assets/javascripts/design_management_new/components/upload/design_dropzone.vue +++ b/app/assets/javascripts/design_management_legacy/components/upload/design_dropzone.vue @@ -1,6 +1,6 @@ <script> import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import uploadDesignMutation from '../../graphql/mutations/upload_design.mutation.graphql'; import { UPLOAD_DESIGN_INVALID_FILETYPE_ERROR } from '../../utils/error_messages'; import { isValidDesignFile } from '../../utils/design_management_utils'; @@ -12,12 +12,6 @@ export default { GlLink, GlSprintf, }, - props: { - hasDesigns: { - type: Boolean, - required: true, - }, - }, data() { return { dragCounter: 0, @@ -82,21 +76,25 @@ export default { > <slot> <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" @click="openFileUpload" > - <div - :class="{ 'gl-flex-direction-column': hasDesigns }" - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center" - data-testid="dropzone-area" - > - <gl-icon name="upload" :size="24" :class="hasDesigns ? 'gl-mb-2' : 'gl-mr-4'" /> - <p class="gl-font-weight-bold gl-mb-0"> - <gl-sprintf :message="__('Drop or %{linkStart}upload%{linkEnd} Designs to attach')"> + <div class="d-flex-center flex-column text-center"> + <gl-icon name="doc-new" :size="48" class="mb-4" /> + <p> + <gl-sprintf + :message=" + __( + '%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}.', + ) + " + > + <template #lineOne="{ content }" + ><span class="d-block">{{ content }}</span> + </template> + <template #link="{ content }"> - <gl-link class="gl-font-weight-normal" @click.stop="openFileUpload"> - {{ content }} - </gl-link> + <gl-link class="h-100 w-100" @click.stop="openFileUpload">{{ content }}</gl-link> </template> </gl-sprintf> </p> @@ -119,7 +117,7 @@ export default { class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" > <div v-show="!isDragDataValid" class="mw-50 text-center"> - <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Oh no!') }}</h3> + <h3>{{ __('Oh no!') }}</h3> <span>{{ __( 'You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico.', @@ -127,7 +125,7 @@ export default { }}</span> </div> <div v-show="isDragDataValid" class="mw-50 text-center"> - <h3 :class="{ 'gl-font-base gl-display-inline': !hasDesigns }">{{ __('Incoming!') }}</h3> + <h3>{{ __('Incoming!') }}</h3> <span>{{ __('Drop your designs to start your upload.') }}</span> </div> </div> diff --git a/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue b/app/assets/javascripts/design_management_legacy/components/upload/design_version_dropdown.vue index 5299d0ce09e..879d2523848 100644 --- a/app/assets/javascripts/design_management_new/components/upload/design_version_dropdown.vue +++ b/app/assets/javascripts/design_management_legacy/components/upload/design_version_dropdown.vue @@ -1,13 +1,13 @@ <script> -import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import allVersionsMixin from '../../mixins/all_versions'; import { findVersionId } from '../../utils/design_management_utils'; export default { components: { - GlNewDropdown, - GlNewDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, }, mixins: [allVersionsMixin], computed: { @@ -50,8 +50,8 @@ export default { </script> <template> - <gl-new-dropdown :text="dropdownText" size="small" class="design-version-dropdown"> - <gl-new-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id"> + <gl-deprecated-dropdown :text="dropdownText" variant="link" class="design-version-dropdown"> + <gl-deprecated-dropdown-item v-for="(version, index) in allVersions" :key="version.node.id"> <router-link class="d-flex js-version-link" :to="{ path: $route.path, query: { version: findVersionId(version.node.id) } }" @@ -68,9 +68,9 @@ export default { </div> <i v-if="findVersionId(version.node.id) === currentVersionId" - class="fa fa-check pull-right" + class="fa fa-check float-right gl-mr-2" ></i> </router-link> - </gl-new-dropdown-item> - </gl-new-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </template> diff --git a/app/assets/javascripts/design_management_new/constants.js b/app/assets/javascripts/design_management_legacy/constants.js index 21ff361a277..21ff361a277 100644 --- a/app/assets/javascripts/design_management_new/constants.js +++ b/app/assets/javascripts/design_management_legacy/constants.js diff --git a/app/assets/javascripts/design_management_new/graphql.js b/app/assets/javascripts/design_management_legacy/graphql.js index fae337aa75b..fae337aa75b 100644 --- a/app/assets/javascripts/design_management_new/graphql.js +++ b/app/assets/javascripts/design_management_legacy/graphql.js diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/design.fragment.graphql index 4b1703e41c3..4b1703e41c3 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/design.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/design.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/design_list.fragment.graphql index bc3132f9b42..bc3132f9b42 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/design_list.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/design_list.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/design_note.fragment.graphql index 26edd2c0be1..26edd2c0be1 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/design_note.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/design_note.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/diff_refs.fragment.graphql index 984a55814b0..984a55814b0 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/diff_refs.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/diff_refs.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/discussion_resolved_status.fragment.graphql index 7483b508721..7483b508721 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/discussion_resolved_status.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/discussion_resolved_status.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/note_permissions.fragment.graphql index c243e39f3d3..c243e39f3d3 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/note_permissions.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/note_permissions.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql b/app/assets/javascripts/design_management_legacy/graphql/fragments/version.fragment.graphql index 7eb40b12f51..7eb40b12f51 100644 --- a/app/assets/javascripts/design_management_new/graphql/fragments/version.fragment.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/fragments/version.fragment.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/create_image_diff_note.mutation.graphql index c8ade328120..c8ade328120 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/create_image_diff_note.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/create_note.mutation.graphql index 184ee6955dc..184ee6955dc 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/create_note.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/create_note.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/destroy_design.mutation.graphql index 0b3cf636cdb..0b3cf636cdb 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/destroy_design.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/destroy_design.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/toggle_resolve_discussion.mutation.graphql index 1157fc05d5f..1157fc05d5f 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/toggle_resolve_discussion.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql index a24b6737159..a24b6737159 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/update_image_diff_note.mutation.graphql index 5562ca9d89f..5562ca9d89f 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/update_image_diff_note.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/update_image_diff_note.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/update_note.mutation.graphql index b995e99fb6a..b995e99fb6a 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/update_note.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/update_note.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql b/app/assets/javascripts/design_management_legacy/graphql/mutations/upload_design.mutation.graphql index d694e6558a0..d694e6558a0 100644 --- a/app/assets/javascripts/design_management_new/graphql/mutations/upload_design.mutation.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/mutations/upload_design.mutation.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql b/app/assets/javascripts/design_management_legacy/graphql/queries/active_discussion.query.graphql index 111023cea68..111023cea68 100644 --- a/app/assets/javascripts/design_management_new/graphql/queries/active_discussion.query.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/queries/active_discussion.query.graphql diff --git a/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql b/app/assets/javascripts/design_management_legacy/graphql/queries/app_data.query.graphql index e1269761206..e1269761206 100644 --- a/app/assets/javascripts/design_management/graphql/queries/app_data.query.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/queries/app_data.query.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql b/app/assets/javascripts/design_management_legacy/graphql/queries/design_permissions.query.graphql index a87b256dc95..a87b256dc95 100644 --- a/app/assets/javascripts/design_management_new/graphql/queries/design_permissions.query.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/queries/design_permissions.query.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql b/app/assets/javascripts/design_management_legacy/graphql/queries/get_design.query.graphql index 07a9af55787..07a9af55787 100644 --- a/app/assets/javascripts/design_management_new/graphql/queries/get_design.query.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/queries/get_design.query.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql b/app/assets/javascripts/design_management_legacy/graphql/queries/get_design_list.query.graphql index 121a50555b3..121a50555b3 100644 --- a/app/assets/javascripts/design_management_new/graphql/queries/get_design_list.query.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/queries/get_design_list.query.graphql diff --git a/app/assets/javascripts/design_management_new/graphql/typedefs.graphql b/app/assets/javascripts/design_management_legacy/graphql/typedefs.graphql index fdbad4a90e0..fdbad4a90e0 100644 --- a/app/assets/javascripts/design_management_new/graphql/typedefs.graphql +++ b/app/assets/javascripts/design_management_legacy/graphql/typedefs.graphql diff --git a/app/assets/javascripts/design_management_legacy/index.js b/app/assets/javascripts/design_management_legacy/index.js new file mode 100644 index 00000000000..1fc5779515a --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/index.js @@ -0,0 +1,61 @@ +// This application is being moved, please do not touch this files +// Please see https://gitlab.com/gitlab-org/gitlab/-/issues/14744#note_364468096 for details + +import $ from 'jquery'; +import Vue from 'vue'; +import createRouter from './router'; +import App from './components/app.vue'; +import apolloProvider from './graphql'; +import getDesignListQuery from './graphql/queries/get_design_list.query.graphql'; +import { DESIGNS_ROUTE_NAME, ROOT_ROUTE_NAME } from './router/constants'; + +export default () => { + const el = document.querySelector('.js-design-management'); + const badge = document.querySelector('.js-designs-count'); + const { issueIid, projectPath, issuePath } = el.dataset; + const router = createRouter(issuePath); + + $('.js-issue-tabs').on('shown.bs.tab', ({ target: { id } }) => { + if (id === 'designs' && router.currentRoute.name === ROOT_ROUTE_NAME) { + router.push({ name: DESIGNS_ROUTE_NAME }); + } else if (id === 'discussion') { + router.push({ name: ROOT_ROUTE_NAME }); + } + }); + + apolloProvider.clients.defaultClient.cache.writeData({ + data: { + projectPath, + issueIid, + activeDiscussion: { + __typename: 'ActiveDiscussion', + id: null, + source: null, + }, + }, + }); + + apolloProvider.clients.defaultClient + .watchQuery({ + query: getDesignListQuery, + variables: { + fullPath: projectPath, + iid: issueIid, + atVersion: null, + }, + }) + .subscribe(({ data }) => { + if (badge) { + badge.textContent = data.project.issue.designCollection.designs.edges.length; + } + }); + + return new Vue({ + el, + router, + apolloProvider, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/design_management_new/mixins/all_designs.js b/app/assets/javascripts/design_management_legacy/mixins/all_designs.js index f7d6551c46c..544429928d2 100644 --- a/app/assets/javascripts/design_management_new/mixins/all_designs.js +++ b/app/assets/javascripts/design_management_legacy/mixins/all_designs.js @@ -1,5 +1,5 @@ import { propertyOf } from 'lodash'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; import { extractNodes } from '../utils/design_management_utils'; diff --git a/app/assets/javascripts/design_management_new/mixins/all_versions.js b/app/assets/javascripts/design_management_legacy/mixins/all_versions.js index 99e2ee9561c..3966fe71732 100644 --- a/app/assets/javascripts/design_management_new/mixins/all_versions.js +++ b/app/assets/javascripts/design_management_legacy/mixins/all_versions.js @@ -1,8 +1,17 @@ import getDesignListQuery from '../graphql/queries/get_design_list.query.graphql'; +import appDataQuery from '../graphql/queries/app_data.query.graphql'; import { findVersionId } from '../utils/design_management_utils'; export default { apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, allVersions: { query: getDesignListQuery, variables() { @@ -15,14 +24,6 @@ export default { update: data => data.project.issue.designCollection.versions.edges, }, }, - inject: { - projectPath: { - default: '', - }, - issueIid: { - default: '', - }, - }, computed: { hasValidVersion() { return ( @@ -54,6 +55,8 @@ export default { data() { return { allVersions: [], + projectPath: '', + issueIid: null, }; }, }; diff --git a/app/assets/javascripts/design_management_new/pages/design/index.vue b/app/assets/javascripts/design_management_legacy/pages/design/index.vue index 47f5e3a786f..2ada9eff8c6 100644 --- a/app/assets/javascripts/design_management_new/pages/design/index.vue +++ b/app/assets/javascripts/design_management_legacy/pages/design/index.vue @@ -2,7 +2,7 @@ import Mousetrap from 'mousetrap'; import { GlLoadingIcon, GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import allVersionsMixin from '../../mixins/all_versions'; import Toolbar from '../../components/toolbar/index.vue'; @@ -12,6 +12,7 @@ import DesignPresentation from '../../components/design_presentation.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; import DesignSidebar from '../../components/design_sidebar.vue'; import getDesignQuery from '../../graphql/queries/get_design.query.graphql'; +import appDataQuery from '../../graphql/queries/app_data.query.graphql'; import createImageDiffNoteMutation from '../../graphql/mutations/create_image_diff_note.mutation.graphql'; import updateImageDiffNoteMutation from '../../graphql/mutations/update_image_diff_note.mutation.graphql'; import updateActiveDiscussionMutation from '../../graphql/mutations/update_active_discussion.mutation.graphql'; @@ -61,12 +62,22 @@ export default { design: {}, comment: '', annotationCoordinates: null, + projectPath: '', errorMessage: '', + issueIid: '', scale: 1, resolvedDiscussionsExpanded: false, }; }, apollo: { + appData: { + query: appDataQuery, + manual: true, + result({ data: { projectPath, issueIid } }) { + this.projectPath = projectPath; + this.issueIid = issueIid; + }, + }, design: { query: getDesignQuery, // We want to see cached design version if we have one, and fetch newer version on the background to update discussions diff --git a/app/assets/javascripts/design_management_new/pages/index.vue b/app/assets/javascripts/design_management_legacy/pages/index.vue index 700fa903a9c..66008a193ce 100644 --- a/app/assets/javascripts/design_management_new/pages/index.vue +++ b/app/assets/javascripts/design_management_legacy/pages/index.vue @@ -1,6 +1,6 @@ <script> -import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { GlLoadingIcon, GlDeprecatedButton, GlAlert } from '@gitlab/ui'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import UploadButton from '../components/upload/button.vue'; import DeleteButton from '../components/delete_button.vue'; @@ -33,7 +33,7 @@ export default { components: { GlLoadingIcon, GlAlert, - GlButton, + GlDeprecatedButton, UploadButton, Design, DesignDestroyer, @@ -96,14 +96,6 @@ export default { ? s__('DesignManagement|Deselect all') : s__('DesignManagement|Select all'); }, - isDesignListEmpty() { - return !this.isSaving && !this.hasDesigns; - }, - designDropzoneWrapperClass() { - return this.isDesignListEmpty - ? 'col-12' - : 'gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3'; - }, }, mounted() { this.toggleOnPasteListener(this.$route.name); @@ -246,55 +238,51 @@ export default { this.onUploadDesign([newFile]); } }, - toggleOnPasteListener() { - document.addEventListener('paste', this.onDesignPaste); - }, - toggleOffPasteListener() { - document.removeEventListener('paste', this.onDesignPaste); + toggleOnPasteListener(route) { + if (route === DESIGNS_ROUTE_NAME) { + document.addEventListener('paste', this.onDesignPaste); + } else { + document.removeEventListener('paste', this.onDesignPaste); + } }, }, beforeRouteUpdate(to, from, next) { + this.toggleOnPasteListener(to.name); this.selectedDesigns = []; next(); }, + beforeRouteLeave(to, from, next) { + this.toggleOnPasteListener(to.name); + next(); + }, }; </script> <template> - <div - data-testid="designs-root" - class="gl-mt-5" - :class="{ 'designs-root': !isDesignListEmpty }" - @mouseenter="toggleOnPasteListener" - @mouseleave="toggleOffPasteListener" - > + <div> <header v-if="showToolbar" class="row-content-block border-top-0 p-2 d-flex"> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full"> - <div> - <span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span> - <design-version-dropdown /> - </div> - <div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex"> - <gl-button + <div class="d-flex justify-content-between align-items-center w-100"> + <design-version-dropdown /> + <div :class="['qa-selector-toolbar', { 'd-flex': hasDesigns, 'd-none': !hasDesigns }]"> + <gl-deprecated-button v-if="isLatestVersion" variant="link" - size="small" - class="gl-mr-2 js-select-all" + class="mr-2 js-select-all" @click="toggleDesignsSelection" - >{{ selectAllButtonText }} - </gl-button> + >{{ selectAllButtonText }}</gl-deprecated-button + > <design-destroyer #default="{ mutate, loading }" :filenames="selectedDesigns" + :project-path="projectPath" + :iid="issueIid" @done="onDesignDelete" @error="onDesignDeleteError" > <delete-button v-if="isLatestVersion" :is-deleting="loading" - button-variant="danger" - button-class="gl-mr-4" - button-size="small" + button-class="btn-danger btn-inverted mr-2" :has-selected-designs="hasSelectedDesigns" @deleteSelectedDesigns="mutate()" > @@ -312,22 +300,11 @@ export default { {{ __('An error occurred while loading designs. Please try again.') }} </gl-alert> <ol v-else class="list-unstyled row"> - <span - v-if="isDesignListEmpty && !allVersions.length" - class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4" - >{{ s__('DesignManagement|Designs') }}</span - > - <li :class="designDropzoneWrapperClass" data-testid="design-dropzone-wrapper"> - <design-dropzone - :class="{ 'design-list-item design-list-item-new': !isDesignListEmpty }" - :has-designs="hasDesigns" - @change="onUploadDesign" - /> + <li class="col-md-6 col-lg-4 mb-3"> + <design-dropzone class="design-list-item" @change="onUploadDesign" /> </li> - <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-3 gl-mb-3"> - <design-dropzone - :has-designs="hasDesigns" - @change="onExistingDesignDropzoneChange($event, design.filename)" + <li v-for="design in designs" :key="design.id" class="col-md-6 col-lg-4 mb-3"> + <design-dropzone @change="onExistingDesignDropzoneChange($event, design.filename)" ><design v-bind="design" :is-uploading="isDesignToBeSaved(design.filename)" /></design-dropzone> diff --git a/app/assets/javascripts/design_management_new/router/constants.js b/app/assets/javascripts/design_management_legacy/router/constants.js index dd2ee8d8689..abeef520e33 100644 --- a/app/assets/javascripts/design_management_new/router/constants.js +++ b/app/assets/javascripts/design_management_legacy/router/constants.js @@ -1,2 +1,3 @@ +export const ROOT_ROUTE_NAME = 'root'; export const DESIGNS_ROUTE_NAME = 'designs'; export const DESIGN_ROUTE_NAME = 'design'; diff --git a/app/assets/javascripts/design_management_new/router/index.js b/app/assets/javascripts/design_management_legacy/router/index.js index 40e2d35bc40..28a81ed0278 100644 --- a/app/assets/javascripts/design_management_new/router/index.js +++ b/app/assets/javascripts/design_management_legacy/router/index.js @@ -1,8 +1,9 @@ +import $ from 'jquery'; import Vue from 'vue'; import VueRouter from 'vue-router'; import routes from './routes'; import { DESIGN_ROUTE_NAME } from './constants'; -import { getPageLayoutElement } from '~/design_management_new/utils/design_management_utils'; +import { getPageLayoutElement } from '~/design_management_legacy/utils/design_management_utils'; import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '../constants'; Vue.use(VueRouter); @@ -15,7 +16,9 @@ export default function createRouter(base) { }); const pageEl = getPageLayoutElement(); - router.beforeEach(({ name }, _, next) => { + router.beforeEach(({ meta: { el }, name }, _, next) => { + $(`#${el}`).tab('show'); + // apply a fullscreen layout style in Design View (a.k.a design detail) if (pageEl) { if (name === DESIGN_ROUTE_NAME) { diff --git a/app/assets/javascripts/design_management_legacy/router/routes.js b/app/assets/javascripts/design_management_legacy/router/routes.js new file mode 100644 index 00000000000..788910e5514 --- /dev/null +++ b/app/assets/javascripts/design_management_legacy/router/routes.js @@ -0,0 +1,44 @@ +import Home from '../pages/index.vue'; +import DesignDetail from '../pages/design/index.vue'; +import { ROOT_ROUTE_NAME, DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; + +export default [ + { + name: ROOT_ROUTE_NAME, + path: '/', + component: Home, + meta: { + el: 'discussion', + }, + }, + { + name: DESIGNS_ROUTE_NAME, + path: '/designs', + component: Home, + meta: { + el: 'designs', + }, + children: [ + { + name: DESIGN_ROUTE_NAME, + path: ':id', + component: DesignDetail, + meta: { + el: 'designs', + }, + beforeEnter( + { + params: { id }, + }, + from, + next, + ) { + if (typeof id === 'string') { + next(); + } + }, + props: ({ params: { id } }) => ({ id }), + }, + ], + }, +]; diff --git a/app/assets/javascripts/design_management_new/utils/cache_update.js b/app/assets/javascripts/design_management_legacy/utils/cache_update.js index 24b374b79fd..5ba6f84c413 100644 --- a/app/assets/javascripts/design_management_new/utils/cache_update.js +++ b/app/assets/javascripts/design_management_legacy/utils/cache_update.js @@ -1,6 +1,6 @@ /* eslint-disable @gitlab/require-i18n-strings */ -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { extractCurrentDiscussion, extractDesign } from './design_management_utils'; import { ADD_IMAGE_DIFF_NOTE_ERROR, diff --git a/app/assets/javascripts/design_management_new/utils/design_management_utils.js b/app/assets/javascripts/design_management_legacy/utils/design_management_utils.js index 22705cf67a1..22705cf67a1 100644 --- a/app/assets/javascripts/design_management_new/utils/design_management_utils.js +++ b/app/assets/javascripts/design_management_legacy/utils/design_management_utils.js diff --git a/app/assets/javascripts/design_management_new/utils/error_messages.js b/app/assets/javascripts/design_management_legacy/utils/error_messages.js index 7666c726c2f..7666c726c2f 100644 --- a/app/assets/javascripts/design_management_new/utils/error_messages.js +++ b/app/assets/javascripts/design_management_legacy/utils/error_messages.js diff --git a/app/assets/javascripts/design_management_new/utils/tracking.js b/app/assets/javascripts/design_management_legacy/utils/tracking.js index b3ecc1453a6..b3ecc1453a6 100644 --- a/app/assets/javascripts/design_management_new/utils/tracking.js +++ b/app/assets/javascripts/design_management_legacy/utils/tracking.js diff --git a/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue b/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue deleted file mode 100644 index f00ecefca01..00000000000 --- a/app/assets/javascripts/design_management_new/components/toolbar/pagination_button.vue +++ /dev/null @@ -1,48 +0,0 @@ -<script> -import Icon from '~/vue_shared/components/icon.vue'; -import { DESIGN_ROUTE_NAME } from '../../router/constants'; - -export default { - components: { - Icon, - }, - props: { - design: { - type: Object, - required: false, - default: null, - }, - title: { - type: String, - required: true, - }, - iconName: { - type: String, - required: true, - }, - }, - computed: { - designLink() { - if (!this.design) return {}; - - return { - name: DESIGN_ROUTE_NAME, - params: { id: this.design.filename }, - query: this.$route.query, - }; - }, - }, -}; -</script> - -<template> - <router-link - :to="designLink" - :disabled="!design" - :class="{ disabled: !design }" - :aria-label="title" - class="btn btn-default" - > - <icon :name="iconName" /> - </router-link> -</template> diff --git a/app/assets/javascripts/design_management_new/index.js b/app/assets/javascripts/design_management_new/index.js deleted file mode 100644 index 20c9cacf83f..00000000000 --- a/app/assets/javascripts/design_management_new/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import Vue from 'vue'; -import createRouter from './router'; -import App from './components/app.vue'; -import apolloProvider from './graphql'; - -export default () => { - const el = document.querySelector('.js-design-management-new'); - const { issueIid, projectPath, issuePath } = el.dataset; - const router = createRouter(issuePath); - - apolloProvider.clients.defaultClient.cache.writeData({ - data: { - activeDiscussion: { - __typename: 'ActiveDiscussion', - id: null, - source: null, - }, - }, - }); - - return new Vue({ - el, - router, - apolloProvider, - provide: { - projectPath, - issueIid, - }, - render(createElement) { - return createElement(App); - }, - }); -}; diff --git a/app/assets/javascripts/design_management_new/router/routes.js b/app/assets/javascripts/design_management_new/router/routes.js deleted file mode 100644 index d888b856611..00000000000 --- a/app/assets/javascripts/design_management_new/router/routes.js +++ /dev/null @@ -1,29 +0,0 @@ -import Home from '../pages/index.vue'; -import DesignDetail from '../pages/design/index.vue'; -import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from './constants'; - -export default [ - { - name: DESIGNS_ROUTE_NAME, - path: '/', - component: Home, - alias: '/designs', - }, - { - name: DESIGN_ROUTE_NAME, - path: '/designs/:id', - component: DesignDetail, - beforeEnter( - { - params: { id }, - }, - _, - next, - ) { - if (typeof id === 'string') { - next(); - } - }, - props: ({ params: { id } }) => ({ id }), - }, -]; diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index a343138a9e1..9497ea7bb4f 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { getLocationHash } from './lib/utils/url_utility'; import FilesCommentButton from './files_comment_button'; diff --git a/app/assets/javascripts/diff_notes/components/resolve_btn.js b/app/assets/javascripts/diff_notes/components/resolve_btn.js index 87e7dd18e0c..0943712d0c5 100644 --- a/app/assets/javascripts/diff_notes/components/resolve_btn.js +++ b/app/assets/javascripts/diff_notes/components/resolve_btn.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Vue from 'vue'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import { sprintf, __ } from '~/locale'; const ResolveBtn = Vue.extend({ diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 27990b0a45e..d6975963977 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -1,7 +1,7 @@ /* global CommentsStore */ import Vue from 'vue'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import { __ } from '~/locale'; window.gl = window.gl || {}; diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 1e524882d5f..5062006424e 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,9 +1,10 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon, GlButtonGroup, GlButton } from '@gitlab/ui'; +import { GlLoadingIcon, GlButtonGroup, GlButton, GlAlert } from '@gitlab/ui'; import Mousetrap from 'mousetrap'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import PanelResizer from '~/vue_shared/components/panel_resizer.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; @@ -38,6 +39,7 @@ export default { PanelResizer, GlButtonGroup, GlButton, + GlAlert, }, mixins: [glFeatureFlagsMixin()], props: { @@ -127,7 +129,16 @@ export default { emailPatchPath: state => state.diffs.emailPatchPath, retrievingBatches: state => state.diffs.retrievingBatches, }), - ...mapState('diffs', ['showTreeList', 'isLoading', 'startVersion', 'currentDiffFileId']), + ...mapState('diffs', [ + 'showTreeList', + 'isLoading', + 'startVersion', + 'currentDiffFileId', + 'isTreeLoaded', + 'conflictResolutionPath', + 'canMerge', + 'hasConflicts', + ]), ...mapGetters('diffs', ['isParallelView', 'currentDiffIndex']), ...mapGetters(['isNotesFetched', 'getNoteableData']), diffs() { @@ -155,6 +166,9 @@ export default { isLimitedContainer() { return !this.showTreeList && !this.isParallelView && !this.isFluidLayout; }, + isDiffHead() { + return parseBoolean(getParameterByName('diff_head')); + }, }, watch: { commit(newCommit, oldCommit) { @@ -400,12 +414,12 @@ export default { <template> <div v-show="shouldShow"> - <div v-if="isLoading" class="loading"><gl-loading-icon size="lg" /></div> + <div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> <compare-versions :merge-request-diffs="mergeRequestDiffs" :is-limited-container="isLimitedContainer" - :diff-files-length="diffFilesLength" + :diff-files-count-text="numTotalFiles" /> <hidden-files-warning @@ -417,6 +431,49 @@ export default { /> <div + v-if="isDiffHead && hasConflicts" + :class="{ + [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, + }" + > + <gl-alert + :dismissible="false" + :title="__('There are merge conflicts')" + variant="warning" + class="w-100 mb-3" + > + <p class="mb-1"> + {{ __('The comparison view may be inaccurate due to merge conflicts.') }} + </p> + <p class="mb-0"> + {{ + __( + 'Resolve these conflicts or ask someone with write access to this repository to merge it locally.', + ) + }} + </p> + <template #actions> + <gl-button + v-if="conflictResolutionPath" + :href="conflictResolutionPath" + variant="info" + class="mr-3 gl-alert-action" + > + {{ __('Resolve conflicts') }} + </gl-button> + <gl-button + v-if="canMerge" + class="gl-alert-action" + data-toggle="modal" + data-target="#modal_merge_info" + > + {{ __('Merge locally') }} + </gl-button> + </template> + </gl-alert> + </div> + + <div :data-can-create-note="getNoteableData.current_user.can_create_note" class="files d-flex" > @@ -441,7 +498,7 @@ export default { [CENTERED_LIMITED_CONTAINER_CLASSES]: isLimitedContainer, }" > - <commit-widget v-if="commit" :commit="commit" /> + <commit-widget v-if="commit" :commit="commit" :collapsible="false" /> <div v-if="isBatchLoading" class="loading"><gl-loading-icon size="lg" /></div> <template v-else-if="renderDiffFiles"> <diff-file diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 99bc1b5c040..274a4027e62 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -52,10 +52,25 @@ export default { }, mixins: [glFeatureFlagsMixin()], props: { + isSelectable: { + type: Boolean, + required: false, + default: false, + }, commit: { type: Object, required: true, }, + checked: { + type: Boolean, + required: false, + default: false, + }, + collapsible: { + type: Boolean, + required: false, + default: true, + }, }, computed: { author() { @@ -78,6 +93,10 @@ export default { authorAvatar() { return this.author.avatar_url || this.commit.author_gravatar_url; }, + commitDescription() { + // Strip the newline at the beginning + return this.commit.description_html.replace(/^
/, ''); + }, nextCommitUrl() { return this.commit.next_commit_id ? setUrlParams({ commit_id: this.commit.next_commit_id }) @@ -104,14 +123,23 @@ export default { </script> <template> - <li class="commit flex-row js-toggle-container"> - <user-avatar-link - :link-href="authorUrl" - :img-src="authorAvatar" - :img-alt="authorName" - :img-size="40" - class="avatar-cell d-none d-sm-block" - /> + <li :class="{ 'js-toggle-container': collapsible }" class="commit flex-row"> + <div class="d-flex align-items-center align-self-start"> + <input + v-if="isSelectable" + class="mr-2" + type="checkbox" + :checked="checked" + @change="$emit('handleCheckboxChange', $event.target.checked)" + /> + <user-avatar-link + :link-href="authorUrl" + :img-src="authorAvatar" + :img-alt="authorName" + :img-size="40" + class="avatar-cell d-none d-sm-block" + /> + </div> <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> <a @@ -123,7 +151,7 @@ export default { <span class="commit-row-message d-block d-sm-none">· {{ commit.short_id }}</span> <button - v-if="commit.description_html" + v-if="commit.description_html && collapsible" class="text-expander js-toggle-button" type="button" :aria-label="__('Toggle commit description')" @@ -144,8 +172,9 @@ export default { <pre v-if="commit.description_html" - class="commit-row-description js-toggle-content gl-mb-3" - v-html="commit.description_html" + :class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }" + class="commit-row-description gl-mb-3 text-dark" + v-html="commitDescription" ></pre> </div> <div class="commit-actions flex-row d-none d-sm-flex"> diff --git a/app/assets/javascripts/diffs/components/commit_widget.vue b/app/assets/javascripts/diffs/components/commit_widget.vue index 31ed003cc0f..5c7e84bd87c 100644 --- a/app/assets/javascripts/diffs/components/commit_widget.vue +++ b/app/assets/javascripts/diffs/components/commit_widget.vue @@ -23,15 +23,20 @@ export default { type: Object, required: true, }, + collapsible: { + type: Boolean, + required: false, + default: true, + }, }, }; </script> <template> - <div class="info-well w-100"> + <div class="info-well mw-100 mx-0"> <div class="well-segment"> <ul class="blob-commit-info"> - <commit-item :commit="commit" /> + <commit-item :commit="commit" :collapsible="collapsible" /> </ul> </div> </div> diff --git a/app/assets/javascripts/diffs/components/compare_versions.vue b/app/assets/javascripts/diffs/components/compare_versions.vue index 6f6fa312865..35e4527af69 100644 --- a/app/assets/javascripts/diffs/components/compare_versions.vue +++ b/app/assets/javascripts/diffs/components/compare_versions.vue @@ -32,9 +32,10 @@ export default { required: false, default: false, }, - diffFilesLength: { - type: Number, - required: true, + diffFilesCountText: { + type: String, + required: false, + default: null, }, }, computed: { @@ -119,7 +120,7 @@ export default { </div> <div class="inline-parallel-buttons d-none d-md-flex ml-auto"> <diff-stats - :diff-files-length="diffFilesLength" + :diff-files-count-text="diffFilesCountText" :added-lines="addedLines" :removed-lines="removedLines" /> diff --git a/app/assets/javascripts/diffs/components/diff_content.vue b/app/assets/javascripts/diffs/components/diff_content.vue index 741462a849c..087a558efdc 100644 --- a/app/assets/javascripts/diffs/components/diff_content.vue +++ b/app/assets/javascripts/diffs/components/diff_content.vue @@ -147,7 +147,7 @@ export default { slot="image-overlay" :discussions="imageDiscussions" :file-hash="diffFileHash" - :can-comment="getNoteableData.current_user.can_create_note" + :can-comment="getNoteableData.current_user.can_create_note && !diffFile.brokenSymlink" /> <div v-if="showNotesContainer" class="note-container"> <user-avatar-link diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 46ed76450c4..e5e63bdcb43 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions } from 'vuex'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index 00d36c0b978..eace673c2d7 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -2,8 +2,9 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { escape } from 'lodash'; import { GlLoadingIcon } from '@gitlab/ui'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, sprintf } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { hasDiff } from '~/helpers/diffs_helper'; import eventHub from '../../notes/event_hub'; import DiffFileHeader from './diff_file_header.vue'; @@ -16,6 +17,7 @@ export default { DiffContent, GlLoadingIcon, }, + mixins: [glFeatureFlagsMixin()], props: { file: { type: Object, @@ -89,8 +91,25 @@ export default { this.setFileCollapsed({ filePath: this.file.file_path, collapsed: newVal }); }, + 'file.file_hash': { + handler: function watchFileHash() { + if ( + this.glFeatures.autoExpandCollapsedDiffs && + this.viewDiffsFileByFile && + this.file.viewer.collapsed + ) { + this.isCollapsed = false; + this.handleLoadCollapsedDiff(); + } else { + this.isCollapsed = this.file.viewer.collapsed || false; + } + }, + immediate: true, + }, 'file.viewer.collapsed': function setIsCollapsed(newVal) { - this.isCollapsed = newVal; + if (!this.viewDiffsFileByFile && !this.glFeatures.autoExpandCollapsedDiffs) { + this.isCollapsed = newVal; + } }, }, created() { @@ -148,6 +167,7 @@ export default { :id="file.file_hash" :class="{ 'is-active': currentDiffFileId === file.file_hash, + 'comments-disabled': Boolean(file.brokenSymlink), }" :data-path="file.new_path" class="diff-file file-holder" diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index d2f49bd0020..700e6302102 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -148,7 +148,7 @@ export default { <template> <div class="content discussion-form discussion-form-container discussion-notes"> - <div v-if="glFeatures.multilineComments" class="gl-mb-3 gl-text-gray-700 gl-pb-3"> + <div v-if="glFeatures.multilineComments" class="gl-mb-3 gl-text-gray-500 gl-pb-3"> <multiline-comment-form v-model="commentLineStart" :line="line" @@ -172,7 +172,7 @@ export default { :diff-file="diffFile" :show-suggest-popover="showSuggestPopover" save-button-title="Comment" - class="diff-comment-form prepend-top-10" + class="diff-comment-form gl-mt-3" @handleFormUpdateAddToReview="addToReview" @cancelForm="handleCancelCommentForm" @handleFormUpdate="handleSaveNote" diff --git a/app/assets/javascripts/diffs/components/diff_stats.vue b/app/assets/javascripts/diffs/components/diff_stats.vue index 0234fc4f40e..439d8097e56 100644 --- a/app/assets/javascripts/diffs/components/diff_stats.vue +++ b/app/assets/javascripts/diffs/components/diff_stats.vue @@ -1,7 +1,7 @@ <script> +import { isNumber } from 'lodash'; import Icon from '~/vue_shared/components/icon.vue'; import { n__ } from '~/locale'; -import { isNumber } from 'lodash'; export default { components: { Icon }, @@ -14,18 +14,21 @@ export default { type: Number, required: true, }, - diffFilesLength: { - type: Number, + diffFilesCountText: { + type: String, required: false, default: null, }, }, computed: { + diffFilesLength() { + return parseInt(this.diffFilesCountText, 10); + }, filesText() { return n__('file', 'files', this.diffFilesLength); }, isCompareVersionsHeader() { - return Boolean(this.diffFilesLength); + return Boolean(this.diffFilesCountText); }, hasDiffFiles() { return isNumber(this.diffFilesLength) && this.diffFilesLength >= 0; @@ -44,7 +47,7 @@ export default { > <div v-if="hasDiffFiles" class="diff-stats-group"> <icon name="doc-code" class="diff-stats-icon text-secondary" /> - <span class="text-secondary bold">{{ diffFilesLength }} {{ filesText }}</span> + <span class="text-secondary bold">{{ diffFilesCountText }} {{ filesText }}</span> </div> <div class="diff-stats-group cgreen d-flex align-items-center" diff --git a/app/assets/javascripts/diffs/components/diff_table_cell.vue b/app/assets/javascripts/diffs/components/diff_table_cell.vue index 198113e330a..49982a81372 100644 --- a/app/assets/javascripts/diffs/components/diff_table_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_table_cell.vue @@ -1,8 +1,9 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlIcon } from '@gitlab/ui'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { getParameterByName, parseBoolean } from '~/lib/utils/common_utils'; import DiffGutterAvatars from './diff_gutter_avatars.vue'; +import { __ } from '~/locale'; import { CONTEXT_LINE_TYPE, LINE_POSITION_RIGHT, @@ -18,6 +19,9 @@ export default { DiffGutterAvatars, GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { line: { type: Object, @@ -123,6 +127,24 @@ export default { lineNumber() { return this.lineType === OLD_LINE_TYPE ? this.line.old_line : this.line.new_line; }, + addCommentTooltip() { + const brokenSymlinks = this.line.commentsDisabled; + let tooltip = __('Add a comment to this line'); + + if (brokenSymlinks) { + if (brokenSymlinks.wasSymbolic || brokenSymlinks.isSymbolic) { + tooltip = __( + 'Commenting on symbolic links that replace or are replaced by files is currently not supported.', + ); + } else if (brokenSymlinks.wasReal || brokenSymlinks.isReal) { + tooltip = __( + 'Commenting on files that replace or are replaced by symbolic links is currently not supported.', + ); + } + } + + return tooltip; + }, }, mounted() { this.unwatchShouldShowCommentButton = this.$watch('shouldShowCommentButton', newVal => { @@ -146,17 +168,24 @@ export default { <template> <td ref="td" :class="classNameMap"> - <button - v-if="shouldRenderCommentButton" - v-show="shouldShowCommentButton" - ref="addDiffNoteButton" - type="button" - class="add-diff-note js-add-diff-note-button qa-diff-comment" - title="Add a comment to this line" - @click="handleCommentButton" + <span + ref="addNoteTooltip" + v-gl-tooltip + class="add-diff-note tooltip-wrapper" + :title="addCommentTooltip" > - <gl-icon :size="12" name="comment" /> - </button> + <button + v-if="shouldRenderCommentButton" + v-show="shouldShowCommentButton" + ref="addDiffNoteButton" + type="button" + class="add-diff-note note-button js-add-diff-note-button qa-diff-comment" + :disabled="line.commentsDisabled" + @click="handleCommentButton" + > + <gl-icon :size="12" name="comment" /> + </button> + </span> <a v-if="lineNumber" ref="lineNumberRef" diff --git a/app/assets/javascripts/diffs/components/hidden_files_warning.vue b/app/assets/javascripts/diffs/components/hidden_files_warning.vue index ad0ca4fa402..baf7471582a 100644 --- a/app/assets/javascripts/diffs/components/hidden_files_warning.vue +++ b/app/assets/javascripts/diffs/components/hidden_files_warning.vue @@ -26,7 +26,7 @@ export default { <div class="alert alert-warning"> <h4> {{ __('Too many changes to show.') }} - <div class="pull-right"> + <div class="float-right"> <a :href="plainDiffPath" class="btn btn-sm"> {{ __('Plain diff') }} </a> <a :href="emailPatchPath" class="btn btn-sm"> {{ __('Email patch') }} </a> </div> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index e3dd882f3dc..447136036ee 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -36,6 +36,9 @@ export const LENGTH_OF_AVATAR_TOOLTIP = 17; export const LINES_TO_BE_RENDERED_DIRECTLY = 100; export const MAX_LINES_TO_BE_RENDERED = 2000; +export const DIFF_FILE_SYMLINK_MODE = '120000'; +export const DIFF_FILE_DELETED_MODE = '0'; + export const MR_TREE_SHOW_KEY = 'mr_tree_show'; export const TREE_TYPE = 'tree'; diff --git a/app/assets/javascripts/diffs/diff_file.js b/app/assets/javascripts/diffs/diff_file.js new file mode 100644 index 00000000000..717c4a79ef9 --- /dev/null +++ b/app/assets/javascripts/diffs/diff_file.js @@ -0,0 +1,28 @@ +import { DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE } from './constants'; + +function fileSymlinkInformation(file, fileList) { + const duplicates = fileList.filter(iteratedFile => iteratedFile.file_hash === file.file_hash); + const includesSymlink = duplicates.some(iteratedFile => { + return [iteratedFile.a_mode, iteratedFile.b_mode].includes(DIFF_FILE_SYMLINK_MODE); + }); + const brokenSymlinkScenario = duplicates.length > 1 && includesSymlink; + + return ( + brokenSymlinkScenario && { + replaced: file.b_mode === DIFF_FILE_DELETED_MODE, + wasSymbolic: file.a_mode === DIFF_FILE_SYMLINK_MODE, + isSymbolic: file.b_mode === DIFF_FILE_SYMLINK_MODE, + wasReal: ![DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE].includes(file.a_mode), + isReal: ![DIFF_FILE_SYMLINK_MODE, DIFF_FILE_DELETED_MODE].includes(file.b_mode), + } + ); +} + +/* eslint-disable-next-line import/prefer-default-export */ +export function prepareRawDiffFile({ file, allFiles }) { + Object.assign(file, { + brokenSymlink: fileSymlinkInformation(file, allFiles), + }); + + return file; +} diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index 76ff67ab861..06a138b1e13 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; +import Cookies from 'js-cookie'; import { parseBoolean } from '~/lib/utils/common_utils'; import FindFile from '~/vue_shared/components/file_finder/index.vue'; import eventHub from '../notes/event_hub'; import diffsApp from './components/app.vue'; import { TREE_LIST_STORAGE_KEY, DIFF_WHITESPACE_COOKIE_NAME } from './constants'; -import Cookies from 'js-cookie'; export default function initDiffsApp(store) { const fileFinderEl = document.getElementById('js-diff-file-finder'); diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index fcc4a8160f4..d5581474c9b 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -3,7 +3,7 @@ import Cookies from 'js-cookie'; import Poll from '~/lib/utils/poll'; import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, s__ } from '~/locale'; import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils'; import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility'; @@ -743,14 +743,14 @@ export function moveToNeighboringCommit({ dispatch, state }, { direction }) { } } -export const setCurrentDiffFileIdFromNote = ({ commit, rootGetters }, noteId) => { +export const setCurrentDiffFileIdFromNote = ({ commit, state, rootGetters }, noteId) => { const note = rootGetters.notesById[noteId]; if (!note) return; const fileHash = rootGetters.getDiscussion(note.discussion_id).diff_file?.file_hash; - if (fileHash) { + if (fileHash && state.diffFiles.some(f => f.file_hash === fileHash)) { commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); } }; @@ -761,6 +761,3 @@ export const navigateToDiffFileIndex = ({ commit, state }, index) => { commit(types.UPDATE_CURRENT_DIFF_FILE_ID, fileHash); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/diffs/store/getters.js b/app/assets/javascripts/diffs/store/getters.js index 047caed1e63..a24894b8d6b 100644 --- a/app/assets/javascripts/diffs/store/getters.js +++ b/app/assets/javascripts/diffs/store/getters.js @@ -74,7 +74,6 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = discussion => discussion.diff_discussion && discussion.diff_file.file_hash === diff.file_hash, ) || []; -// prevent babel-plugin-rewire from generating an invalid default during karma∂ tests export const getDiffFileByHash = state => fileHash => state.diffFiles.find(file => file.file_hash === fileHash); @@ -130,6 +129,3 @@ export const fileLineCoverage = state => (file, line) => { */ export const currentDiffIndex = state => Math.max(0, state.diffFiles.findIndex(diff => diff.file_hash === state.currentDiffFileId)); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/diffs/store/modules/diff_state.js b/app/assets/javascripts/diffs/store/modules/diff_state.js index 1f165dd4971..d31a600e354 100644 --- a/app/assets/javascripts/diffs/store/modules/diff_state.js +++ b/app/assets/javascripts/diffs/store/modules/diff_state.js @@ -15,6 +15,7 @@ const whiteSpaceFromCookie = Cookies.get(DIFF_WHITESPACE_COOKIE_NAME); export default () => ({ isLoading: true, + isTreeLoaded: false, isBatchLoading: false, retrievingBatches: false, addedLines: null, diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 7e89d041c21..0d41f1c2178 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -323,6 +323,7 @@ export default { [types.SET_TREE_DATA](state, { treeEntries, tree }) { state.treeEntries = treeEntries; state.tree = tree; + state.isTreeLoaded = true; }, [types.SET_RENDER_TREE_LIST](state, renderTreeList) { state.renderTreeList = renderTreeList; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index bc85dd0a1d4..f014cddda32 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -18,6 +18,7 @@ import { SHOW_WHITESPACE, NO_SHOW_WHITESPACE, } from '../constants'; +import { prepareRawDiffFile } from '../diff_file'; export function findDiffFile(files, match, matchKey = 'file_hash') { return files.find(file => file[matchKey] === match); @@ -294,9 +295,10 @@ function cleanRichText(text) { return text ? text.replace(/^[+ -]/, '') : undefined; } -function prepareLine(line) { +function prepareLine(line, file) { if (!line.alreadyPrepared) { Object.assign(line, { + commentsDisabled: file.brokenSymlink, rich_text: cleanRichText(line.rich_text), discussionsExpanded: true, discussions: [], @@ -330,7 +332,7 @@ export function prepareLineForRenamedFile({ line, diffViewType, diffFile, index old_line: lineNumber, }; - prepareLine(cleanLine); // WARNING: In-Place Mutations! + prepareLine(cleanLine, diffFile); // WARNING: In-Place Mutations! if (diffViewType === PARALLEL_DIFF_VIEW_TYPE) { return { @@ -348,19 +350,19 @@ function prepareDiffFileLines(file) { const parallelLines = file.parallel_diff_lines; let parallelLinesCount = 0; - inlineLines.forEach(prepareLine); + inlineLines.forEach(line => prepareLine(line, file)); // WARNING: In-Place Mutations! parallelLines.forEach((line, index) => { Object.assign(line, { line_code: getLineCode(line, index) }); if (line.left) { parallelLinesCount += 1; - prepareLine(line.left); + prepareLine(line.left, file); // WARNING: In-Place Mutations! } if (line.right) { parallelLinesCount += 1; - prepareLine(line.right); + prepareLine(line.right, file); // WARNING: In-Place Mutations! } }); @@ -407,6 +409,7 @@ function deduplicateFilesList(files) { export function prepareDiffData(diff, priorFiles = []) { const cleanedFiles = (diff.diff_files || []) + .map((file, index, allFiles) => prepareRawDiffFile({ file, allFiles })) .map(ensureBasicDiffFileLines) .map(prepareDiffFileLines) .map(finalizeDiffFile); @@ -477,6 +480,10 @@ export function getDiffPositionByLineCode(diffFiles, useSingleDiffStyle) { // This method will check whether the discussion is still applicable // to the diff line in question regarding different versions of the MR export function isDiscussionApplicableToLine({ discussion, diffPosition, latestDiff }) { + if (!diffPosition) { + return false; + } + const { line_code, ...dp } = diffPosition; // Removing `line_range` from diffPosition because the backend does not // yet consistently return this property. This check can be removed, diff --git a/app/assets/javascripts/dropzone_input.js b/app/assets/javascripts/dropzone_input.js index 9a0b85bd610..f65e22a31c5 100644 --- a/app/assets/javascripts/dropzone_input.js +++ b/app/assets/javascripts/dropzone_input.js @@ -70,7 +70,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { headers: csrf.headers, previewContainer: false, ...config, - processing: () => $('.div-dropzone-alert').alert('close'), dragover: () => { $mdArea.addClass('is-dropzone-hover'); form.find('.div-dropzone-hover').css('opacity', 0.7); @@ -245,8 +244,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { $uploadingErrorMessage.html(message); }; - const closeAlertMessage = () => form.find('.div-dropzone-alert').alert('close'); - const insertToTextArea = (filename, url) => { const $child = $(child); const textarea = $child.get(0); @@ -266,7 +263,6 @@ export default function dropzoneInput(form, config = { parallelUploads: 2 }) { formData.append('file', item, filename); showSpinner(); - closeAlertMessage(); axios .post(uploadsPath, formData) diff --git a/app/assets/javascripts/editor/editor_lite.js b/app/assets/javascripts/editor/editor_lite.js index 551ffbabaef..0af0c3ecdcf 100644 --- a/app/assets/javascripts/editor/editor_lite.js +++ b/app/assets/javascripts/editor/editor_lite.js @@ -3,6 +3,7 @@ import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; import languages from '~/ide/lib/languages'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import { registerLanguages } from '~/ide/utils'; +import { joinPaths } from '~/lib/utils/url_utility'; import { clearDomElement } from './utils'; export default class Editor { @@ -30,7 +31,16 @@ export default class Editor { monacoEditor.setTheme(theme ? themeName : DEFAULT_THEME); } - createInstance({ el = undefined, blobPath = '', blobContent = '' } = {}) { + /** + * Creates a monaco instance with the given options. + * + * @param {Object} options Options used to initialize monaco. + * @param {Element} options.el The element which will be used to create the monacoEditor. + * @param {string} options.blobPath The path used as the URI of the model. Monaco uses the extension of this path to determine the language. + * @param {string} options.blobContent The content to initialize the monacoEditor. + * @param {string} options.blobGlobalId This is used to help globally identify monaco instances that are created with the same blobPath. + */ + createInstance({ el = undefined, blobPath = '', blobContent = '', blobGlobalId = '' } = {}) { if (!el) return; this.editorEl = el; this.blobContent = blobContent; @@ -38,11 +48,9 @@ export default class Editor { clearDomElement(this.editorEl); - this.model = monacoEditor.createModel( - this.blobContent, - undefined, - new Uri('gitlab', false, this.blobPath), - ); + const uriFilePath = joinPaths('gitlab', blobGlobalId, blobPath); + + this.model = monacoEditor.createModel(this.blobContent, undefined, Uri.file(uriFilePath)); monacoEditor.onDidCreateEditor(this.renderEditor.bind(this)); @@ -51,6 +59,11 @@ export default class Editor { } dispose() { + if (this.model) { + this.model.dispose(); + this.model = null; + } + return this.instance && this.instance.dispose(); } @@ -58,6 +71,10 @@ export default class Editor { delete this.editorEl.dataset.editorLoading; } + onChangeContent(fn) { + return this.model.onDidChangeContent(fn); + } + updateModelLanguage(path) { if (path === this.blobPath) return; this.blobPath = path; diff --git a/app/assets/javascripts/environments/components/enable_review_app_button.vue b/app/assets/javascripts/environments/components/enable_review_app_button.vue index f86072696c2..8fbbc5189bf 100644 --- a/app/assets/javascripts/environments/components/enable_review_app_button.vue +++ b/app/assets/javascripts/environments/components/enable_review_app_button.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import { s__ } from '~/locale'; export default { components: { - GlDeprecatedButton, + GlButton, GlLink, GlModal, GlSprintf, @@ -44,7 +44,7 @@ export default { </script> <template> <div> - <gl-deprecated-button + <gl-button v-gl-modal="$options.modalInfo.id" variant="info" category="secondary" @@ -52,7 +52,7 @@ export default { class="js-enable-review-app-button" > {{ s__('Environments|Enable review app') }} - </gl-deprecated-button> + </gl-button> <gl-modal :modal-id="$options.modalInfo.id" :title="$options.modalInfo.title" diff --git a/app/assets/javascripts/environments/components/environment_external_url.vue b/app/assets/javascripts/environments/components/environment_external_url.vue index af537cfb991..793f7bf0681 100644 --- a/app/assets/javascripts/environments/components/environment_external_url.vue +++ b/app/assets/javascripts/environments/components/environment_external_url.vue @@ -1,6 +1,5 @@ <script> -import { GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; /** @@ -8,7 +7,7 @@ import { s__ } from '~/locale'; */ export default { components: { - Icon, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -27,15 +26,14 @@ export default { }; </script> <template> - <a + <gl-button v-gl-tooltip :title="title" :aria-label="title" :href="externalUrl" - class="btn external-url" + class="external-url" target="_blank" + icon="external-link" rel="noopener noreferrer nofollow" - > - <icon name="external-link" /> - </a> + /> </template> diff --git a/app/assets/javascripts/environments/components/environment_stop.vue b/app/assets/javascripts/environments/components/environment_stop.vue index 99f50b499d0..c63d54d586d 100644 --- a/app/assets/javascripts/environments/components/environment_stop.vue +++ b/app/assets/javascripts/environments/components/environment_stop.vue @@ -5,16 +5,13 @@ */ import $ from 'jquery'; -import { GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; import { s__ } from '~/locale'; import eventHub from '../event_hub'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; export default { components: { - Icon, - LoadingButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -55,16 +52,16 @@ export default { }; </script> <template> - <loading-button + <gl-button v-gl-tooltip :loading="isLoading" :title="title" :aria-label="title" - container-class="btn btn-danger d-none d-sm-none d-md-block" + icon="stop" + category="primary" + variant="danger" data-toggle="modal" data-target="#stop-environment-modal" @click="onClick" - > - <icon name="stop" /> - </loading-button> + /> </template> diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index d26bd14a937..f0e74d96f09 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -1,6 +1,6 @@ <script> import { GlDeprecatedButton } from '@gitlab/ui'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { s__ } from '~/locale'; import emptyState from './empty_state.vue'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index ab1818e61fa..1bf705dcda2 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -202,7 +202,7 @@ export default { /> <div :key="`sub-div-${i}`"> - <div class="text-center prepend-top-10"> + <div class="text-center gl-mt-3"> <a :href="folderUrl(model)" class="btn btn-default"> {{ s__('Environments|Show all') }} </a> diff --git a/app/assets/javascripts/environments/mixins/environments_mixin.js b/app/assets/javascripts/environments/mixins/environments_mixin.js index f2b464464e9..9b0301bba07 100644 --- a/app/assets/javascripts/environments/mixins/environments_mixin.js +++ b/app/assets/javascripts/environments/mixins/environments_mixin.js @@ -7,7 +7,7 @@ import EnvironmentsStore from 'ee_else_ce/environments/stores/environments_store import Poll from '../../lib/utils/poll'; import { getParameterByName } from '../../lib/utils/common_utils'; import { s__ } from '../../locale'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import eventHub from '../event_hub'; import EnvironmentsService from '../services/environments_service'; diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 52444d2c493..3d1fdc4f168 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -1,6 +1,5 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import createFlash from '~/flash'; import { GlButton, GlFormInput, @@ -9,10 +8,11 @@ import { GlBadge, GlAlert, GlSprintf, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, } from '@gitlab/ui'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __, sprintf, n__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; @@ -43,9 +43,9 @@ export default { GlBadge, GlAlert, GlSprintf, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, TimeAgoTooltip, }, directives: { @@ -331,38 +331,38 @@ export default { </gl-button> </form> </div> - <gl-dropdown + <gl-deprecated-dropdown text="Options" class="error-details-options d-md-none" right :disabled="issueUpdateInProgress" > - <gl-dropdown-item + <gl-deprecated-dropdown-item data-qa-selector="update_ignore_status_button" @click="onIgnoreStatusUpdate" - >{{ ignoreBtnLabel }}</gl-dropdown-item + >{{ ignoreBtnLabel }}</gl-deprecated-dropdown-item > - <gl-dropdown-item + <gl-deprecated-dropdown-item data-qa-selector="update_resolve_status_button" @click="onResolveStatusUpdate" - >{{ resolveBtnLabel }}</gl-dropdown-item + >{{ resolveBtnLabel }}</gl-deprecated-dropdown-item > - <gl-dropdown-divider /> - <gl-dropdown-item + <gl-deprecated-dropdown-divider /> + <gl-deprecated-dropdown-item v-if="error.gitlabIssuePath" data-qa-selector="view_issue_button" :href="error.gitlabIssuePath" variant="success" - >{{ __('View issue') }}</gl-dropdown-item + >{{ __('View issue') }}</gl-deprecated-dropdown-item > - <gl-dropdown-item + <gl-deprecated-dropdown-item v-if="!error.gitlabIssuePath" :loading="issueCreationInProgress" data-qa-selector="create_issue_button" @click="createIssue" - >{{ __('Create issue') }}</gl-dropdown-item + >{{ __('Create issue') }}</gl-deprecated-dropdown-item > - </gl-dropdown> + </gl-deprecated-dropdown> </div> </div> <div> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue index 0e3fd70b17b..db61957d452 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_actions.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlIcon, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlIcon, GlButtonGroup, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; const IGNORED = 'ignored'; @@ -10,7 +10,7 @@ const statusValidation = [IGNORED, RESOLVED, UNRESOLVED]; export default { components: { - GlDeprecatedButton, + GlButton, GlIcon, GlButtonGroup, }, @@ -44,37 +44,37 @@ export default { <template> <div> - <gl-button-group class="flex-column flex-md-row ml-0 ml-md-n4"> - <gl-deprecated-button + <gl-button-group class="gl-flex-direction-column flex-md-row gl-ml-0 ml-md-n4"> + <gl-button :key="ignoreBtn.status" :ref="`${ignoreBtn.title.toLowerCase()}Error`" v-gl-tooltip.hover - class="d-block mb-2 mb-md-0 w-100" + class="gl-display-block gl-mb-4 mb-md-0 gl-w-full" :title="ignoreBtn.title" @click="$emit('update-issue-status', { errorId: error.id, status: ignoreBtn.status })" > - <gl-icon class="d-none d-md-inline m-0" :name="ignoreBtn.icon" :size="12" /> + <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="ignoreBtn.icon" :size="12" /> <span class="d-md-none">{{ ignoreBtn.title }}</span> - </gl-deprecated-button> - <gl-deprecated-button + </gl-button> + <gl-button :key="resolveBtn.status" :ref="`${resolveBtn.title.toLowerCase()}Error`" v-gl-tooltip.hover - class="d-block mb-2 mb-md-0 w-100" + class="gl-display-block gl-mb-4 mb-md-0 gl-w-full" :title="resolveBtn.title" @click="$emit('update-issue-status', { errorId: error.id, status: resolveBtn.status })" > - <gl-icon class="d-none d-md-inline m-0" :name="resolveBtn.icon" :size="12" /> + <gl-icon class="gl-display-none d-md-inline gl-m-0" :name="resolveBtn.icon" :size="12" /> <span class="d-md-none">{{ resolveBtn.title }}</span> - </gl-deprecated-button> + </gl-button> </gl-button-group> - <gl-deprecated-button + <gl-button :href="detailsLink" - category="secondary" + category="primary" variant="info" - class="d-block d-md-none mb-2 mb-md-0" + class="gl-display-block d-md-none gl-mb-4 mb-md-0" > {{ __('More details') }} - </gl-deprecated-button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue index 62a73e21096..da41dc4c9d9 100644 --- a/app/assets/javascripts/error_tracking/components/error_tracking_list.vue +++ b/app/assets/javascripts/error_tracking/components/error_tracking_list.vue @@ -2,22 +2,22 @@ import { mapActions, mapState } from 'vuex'; import { GlEmptyState, - GlDeprecatedButton, + GlButton, GlIcon, GlLink, GlLoadingIcon, GlTable, GlFormInput, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, GlTooltipDirective, GlPagination, } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; import AccessorUtils from '~/lib/utils/accessor'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { __ } from '~/locale'; -import { isEmpty } from 'lodash'; import ErrorTrackingActions from './error_tracking_actions.vue'; import Tracking from '~/tracking'; import { trackErrorListViewsOptions, trackErrorStatusUpdateOptions } from '../utils'; @@ -71,10 +71,10 @@ export default { }, components: { GlEmptyState, - GlDeprecatedButton, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, + GlButton, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, GlIcon, GlLink, GlLoadingIcon, @@ -233,7 +233,7 @@ export default { > <div class="search-box flex-fill mb-1 mb-md-0"> <div class="filtered-search-box mb-0"> - <gl-dropdown + <gl-deprecated-dropdown :text="__('Recent searches')" class="filtered-search-history-dropdown-wrapper" toggle-class="filtered-search-history-dropdown-toggle-button" @@ -243,19 +243,19 @@ export default { {{ __('This feature requires local storage to be enabled') }} </div> <template v-else-if="recentSearches.length > 0"> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="searchQuery in recentSearches" :key="searchQuery" @click="setSearchText(searchQuery)" >{{ searchQuery }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches" + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-divider /> + <gl-deprecated-dropdown-item ref="clearRecentSearches" @click="clearRecentSearches" >{{ __('Clear recent searches') }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </template> <div v-else class="px-3">{{ __("You don't have any recent searches") }}</div> - </gl-dropdown> + </gl-deprecated-dropdown> <div class="filtered-search-input-container flex-fill"> <gl-form-input v-model="errorSearchQuery" @@ -267,27 +267,26 @@ export default { /> </div> <div class="gl-search-box-by-type-right-icons"> - <gl-deprecated-button + <gl-button v-if="errorSearchQuery.length > 0" v-gl-tooltip.hover :title="__('Clear')" class="clear-search text-secondary" name="clear" + icon="close" @click="errorSearchQuery = ''" - > - <gl-icon name="close" :size="12" /> - </gl-deprecated-button> + /> </div> </div> </div> - <gl-dropdown + <gl-deprecated-dropdown :text="$options.statusFilters[statusFilter]" class="status-dropdown mx-md-1 mb-1 mb-md-0" menu-class="dropdown" :disabled="loading" > - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="(label, status) in $options.statusFilters" :key="status" @click="filterErrors(status, label)" @@ -300,16 +299,16 @@ export default { /> {{ label }} </span> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> - <gl-dropdown + <gl-deprecated-dropdown :text="$options.sortFields[sortField]" left :disabled="loading" menu-class="dropdown" > - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="(label, field) in $options.sortFields" :key="field" @click="sortByField(field)" @@ -322,8 +321,8 @@ export default { /> {{ label }} </span> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> <div v-if="loading" class="py-3"> diff --git a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue index c22f34b5a8d..c6825d7af45 100644 --- a/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue +++ b/app/assets/javascripts/error_tracking/components/stacktrace_entry.vue @@ -99,7 +99,7 @@ export default { <gl-sprintf v-if="errorFn" :message="__('%{spanStart}in%{spanEnd} %{errorFn}')"> <template #span="{content}"> - <span class="gl-text-gray-400">{{ content }} </span> + <span class="gl-text-gray-200">{{ content }} </span> </template> <template #errorFn> <strong>{{ errorFn }} </strong> @@ -108,7 +108,7 @@ export default { <gl-sprintf :message="__('%{spanStart}at line%{spanEnd} %{errorLine}%{errorColumn}')"> <template #span="{content}"> - <span class="gl-text-gray-400">{{ content }} </span> + <span class="gl-text-gray-200">{{ content }} </span> </template> <template #errorLine> <strong>{{ errorLine }}</strong> diff --git a/app/assets/javascripts/error_tracking/store/actions.js b/app/assets/javascripts/error_tracking/store/actions.js index 05554b2b566..b52405248d8 100644 --- a/app/assets/javascripts/error_tracking/store/actions.js +++ b/app/assets/javascripts/error_tracking/store/actions.js @@ -1,6 +1,6 @@ import service from '../services'; import * as types from './mutation_types'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -34,5 +34,3 @@ export const updateIgnoreStatus = ({ commit, dispatch }, params) => { commit(types.SET_UPDATING_IGNORE_STATUS, false); }); }; - -export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/actions.js b/app/assets/javascripts/error_tracking/store/details/actions.js index 5914a79f092..28806b3915c 100644 --- a/app/assets/javascripts/error_tracking/store/details/actions.js +++ b/app/assets/javascripts/error_tracking/store/details/actions.js @@ -1,6 +1,6 @@ import service from '../../services'; import * as types from './mutation_types'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; @@ -10,6 +10,7 @@ const stopPolling = poll => { if (poll) poll.stop(); }; +// eslint-disable-next-line import/prefer-default-export export function startPollingStacktrace({ commit }, endpoint) { stackTracePoll = new Poll({ resource: service, @@ -32,5 +33,3 @@ export function startPollingStacktrace({ commit }, endpoint) { stackTracePoll.makeRequest(); } - -export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/details/getters.js b/app/assets/javascripts/error_tracking/store/details/getters.js index a36c84dc28c..f2778fbb2c7 100644 --- a/app/assets/javascripts/error_tracking/store/details/getters.js +++ b/app/assets/javascripts/error_tracking/store/details/getters.js @@ -1,6 +1,5 @@ +// eslint-disable-next-line import/prefer-default-export export const stacktrace = state => state.stacktraceData.stack_trace_entries ? state.stacktraceData.stack_trace_entries.reverse() : []; - -export default () => {}; diff --git a/app/assets/javascripts/error_tracking/store/list/actions.js b/app/assets/javascripts/error_tracking/store/list/actions.js index 94cf444d2e4..a242c0e4236 100644 --- a/app/assets/javascripts/error_tracking/store/list/actions.js +++ b/app/assets/javascripts/error_tracking/store/list/actions.js @@ -1,6 +1,6 @@ import Service from '../../services'; import * as types from './mutation_types'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Poll from '~/lib/utils/poll'; import { __ } from '~/locale'; @@ -102,5 +102,3 @@ export const fetchPaginatedResults = ({ commit, dispatch }, cursor) => { export const removeIgnoredResolvedErrors = ({ commit }, error) => { commit(types.REMOVE_IGNORED_RESOLVED_ERRORS, error); }; - -export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/components/app.vue b/app/assets/javascripts/error_tracking_settings/components/app.vue index 7ae847e6105..db90ac1c740 100644 --- a/app/assets/javascripts/error_tracking_settings/components/app.vue +++ b/app/assets/javascripts/error_tracking_settings/components/app.vue @@ -1,11 +1,11 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import ProjectDropdown from './project_dropdown.vue'; import ErrorTrackingForm from './error_tracking_form.vue'; export default { - components: { ProjectDropdown, ErrorTrackingForm, GlDeprecatedButton }, + components: { ProjectDropdown, ErrorTrackingForm, GlButton }, props: { initialApiHost: { type: String, @@ -92,13 +92,15 @@ export default { @select-project="updateSelectedProject" /> </div> - <gl-deprecated-button - :disabled="settingsLoading" - class="js-error-tracking-button" - variant="success" - @click="handleSubmit" - > - {{ __('Save changes') }} - </gl-deprecated-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + :disabled="settingsLoading" + class="js-error-tracking-button" + variant="success" + @click="handleSubmit" + > + {{ __('Save changes') }} + </gl-button> + </div> </div> </template> diff --git a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue index 11fd06fb40b..561b2565880 100644 --- a/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue +++ b/app/assets/javascripts/error_tracking_settings/components/project_dropdown.vue @@ -1,11 +1,11 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { getDisplayName } from '../utils'; export default { components: { - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, }, props: { dropdownLabel: { @@ -52,7 +52,7 @@ export default { <div :class="{ 'gl-show-field-errors': isProjectInvalid }"> <label class="label-bold" for="project-dropdown">{{ __('Project') }}</label> <div class="row"> - <gl-dropdown + <gl-deprecated-dropdown id="project-dropdown" class="col-8 col-md-9 gl-pr-0" :disabled="!hasProjects" @@ -60,14 +60,14 @@ export default { toggle-class="dropdown-menu-toggle w-100 gl-field-error-outline" :text="dropdownLabel" > - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="project in projects" :key="`${project.organizationSlug}.${project.slug}`" class="w-100" @click="$emit('select-project', project)" - >{{ getDisplayName(project) }}</gl-dropdown-item + >{{ getDisplayName(project) }}</gl-deprecated-dropdown-item > - </gl-dropdown> + </gl-deprecated-dropdown> </div> <p v-if="isProjectInvalid" class="js-project-dropdown-error gl-field-error"> {{ invalidProjectLabel }} diff --git a/app/assets/javascripts/error_tracking_settings/store/actions.js b/app/assets/javascripts/error_tracking_settings/store/actions.js index 3f1ac426278..27433178c8e 100644 --- a/app/assets/javascripts/error_tracking_settings/store/actions.js +++ b/app/assets/javascripts/error_tracking_settings/store/actions.js @@ -1,7 +1,7 @@ import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { transformFrontendSettings } from '../utils'; import * as types from './mutation_types'; @@ -89,6 +89,3 @@ export const updateSelectedProject = ({ commit }, selectedProject) => { export const setInitialState = ({ commit }, data) => { commit(types.SET_INITIAL_STATE, data); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/store/getters.js b/app/assets/javascripts/error_tracking_settings/store/getters.js index e27fe9c079e..a02a4310ab9 100644 --- a/app/assets/javascripts/error_tracking_settings/store/getters.js +++ b/app/assets/javascripts/error_tracking_settings/store/getters.js @@ -39,6 +39,3 @@ export const projectSelectionLabel = state => { } return s__('ErrorTracking|To enable project selection, enter a valid Auth Token'); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/error_tracking_settings/utils.js b/app/assets/javascripts/error_tracking_settings/utils.js index 450e8728121..9a09702a030 100644 --- a/app/assets/javascripts/error_tracking_settings/utils.js +++ b/app/assets/javascripts/error_tracking_settings/utils.js @@ -14,5 +14,3 @@ export const transformFrontendSettings = ({ apiHost, enabled, token, selectedPro }; export const getDisplayName = project => `${project.organizationName} | ${project.slug}`; - -export default () => {}; diff --git a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js index fd9433b625c..cfadfb26db2 100644 --- a/app/assets/javascripts/feature_highlight/feature_highlight_helper.js +++ b/app/assets/javascripts/feature_highlight/feature_highlight_helper.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import axios from '../lib/utils/axios_utils'; import { __ } from '../locale'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import LazyLoader from '../lazy_loader'; import { togglePopover } from '../shared/popover'; diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 9440015b32e..80f78c154ee 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -1,21 +1,55 @@ import { __ } from '~/locale'; export default IssuableTokenKeys => { - const wipToken = { - formattedKey: __('WIP'), - key: 'wip', - type: 'string', - param: '', - symbol: '', - icon: 'admin', - tag: __('Yes or No'), - lowercaseValueOnSubmit: true, - uppercaseTokenName: true, - capitalizeTokenValue: true, + const draftToken = { + token: { + formattedKey: __('Draft'), + key: 'draft', + type: 'string', + param: '', + symbol: '', + icon: 'admin', + tag: __('Yes or No'), + lowercaseValueOnSubmit: true, + capitalizeTokenValue: true, + }, + conditions: [ + { + url: 'wip=yes', + // eslint-disable-next-line @gitlab/require-i18n-strings + replacementUrl: 'draft=yes', + tokenKey: 'draft', + value: __('Yes'), + operator: '=', + }, + { + url: 'wip=no', + // eslint-disable-next-line @gitlab/require-i18n-strings + replacementUrl: 'draft=no', + tokenKey: 'draft', + value: __('No'), + operator: '=', + }, + { + url: 'not[wip]=yes', + replacementUrl: 'not[draft]=yes', + tokenKey: 'draft', + value: __('Yes'), + operator: '!=', + }, + { + url: 'not[wip]=no', + replacementUrl: 'not[draft]=no', + tokenKey: 'draft', + value: __('No'), + operator: '!=', + }, + ], }; - IssuableTokenKeys.tokenKeys.push(wipToken); - IssuableTokenKeys.tokenKeysWithAlternative.push(wipToken); + IssuableTokenKeys.tokenKeys.push(draftToken.token); + IssuableTokenKeys.tokenKeysWithAlternative.push(draftToken.token); + IssuableTokenKeys.conditions.push(...draftToken.conditions); const targetBranchToken = { formattedKey: __('Target-Branch'), diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 692b41da965..49bd3cda127 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -106,7 +106,7 @@ export default class AvailableDropdownMappings { gl: DropdownEmoji, element: this.container.querySelector('#js-dropdown-my-reaction'), }, - wip: { + draft: { reference: null, gl: DropdownNonUser, element: this.container.querySelector('#js-dropdown-wip'), diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index e2909333d74..0c4abc14494 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -20,8 +20,18 @@ export default { }, }, computed: { + /** + * Both Epic and Roadmap pages share same recents store + * and with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421 + * Roadmap started using `GlFilteredSearch` which is not compatible + * with string tokens stored in recents, so this is a temporary + * fix by ignoring non-string recents while in Epic page. + */ + compatibleItems() { + return this.items.filter(item => typeof item === 'string'); + }, processedItems() { - return this.items.map(item => { + return this.compatibleItems.map(item => { const { tokens, searchToken } = FilteredSearchTokenizer.processTokens( item, this.allowedKeys, @@ -41,7 +51,7 @@ export default { }); }, hasItems() { - return this.items.length > 0; + return this.compatibleItems.length > 0; }, }, methods: { @@ -84,9 +94,7 @@ export default { <span class="value">{{ token.suffix }}</span> </span> </span> - <span class="filtered-search-history-dropdown-search-token"> - {{ item.searchToken }} - </span> + <span class="filtered-search-history-dropdown-search-token">{{ item.searchToken }}</span> </button> </li> <li class="divider"></li> diff --git a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js index 92a64ab60db..4652dfe71c3 100644 --- a/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js +++ b/app/assets/javascripts/filtered_search/dropdown_ajax_filter.js @@ -1,4 +1,4 @@ -import createFlash from '../flash'; +import { deprecatedCreateFlash as createFlash } from '../flash'; import AjaxFilter from '../droplab/plugins/ajax_filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; import DropdownUtils from './dropdown_utils'; diff --git a/app/assets/javascripts/filtered_search/dropdown_emoji.js b/app/assets/javascripts/filtered_search/dropdown_emoji.js index adeea0ed5f6..1e3679b9e3c 100644 --- a/app/assets/javascripts/filtered_search/dropdown_emoji.js +++ b/app/assets/javascripts/filtered_search/dropdown_emoji.js @@ -1,4 +1,4 @@ -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/dropdown_non_user.js b/app/assets/javascripts/filtered_search/dropdown_non_user.js index a2312de289d..bfa9f4a57ca 100644 --- a/app/assets/javascripts/filtered_search/dropdown_non_user.js +++ b/app/assets/javascripts/filtered_search/dropdown_non_user.js @@ -1,4 +1,4 @@ -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import Ajax from '../droplab/plugins/ajax'; import Filter from '../droplab/plugins/filter'; import FilteredSearchDropdown from './filtered_search_dropdown'; diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 108cc8d3a78..3e4a9880134 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -3,7 +3,7 @@ import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searche import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import { visitUrl } from '../lib/utils/url_utility'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; import RecentSearchesStore from './stores/recent_searches_store'; @@ -29,6 +29,7 @@ export default class FilteredSearchManager { isGroup = false, isGroupAncestor = true, isGroupDecendent = false, + useDefaultState = false, filteredSearchTokenKeys = IssuableFilteredSearchTokenKeys, stateFiltersSelector = '.issues-state-filters', placeholder = __('Search or filter results...'), @@ -37,6 +38,7 @@ export default class FilteredSearchManager { this.isGroup = isGroup; this.isGroupAncestor = isGroupAncestor; this.isGroupDecendent = isGroupDecendent; + this.useDefaultState = useDefaultState; this.states = ['opened', 'closed', 'merged', 'all']; this.page = page; @@ -724,8 +726,13 @@ export default class FilteredSearchManager { search(state = null) { const paths = []; const { tokens, searchToken } = this.getSearchTokens(); - const currentState = state || getParameterByName('state') || 'opened'; - paths.push(`state=${currentState}`); + let currentState = state || getParameterByName('state'); + if (!currentState && this.useDefaultState) { + currentState = 'opened'; + } + if (this.states.includes(currentState)) { + paths.push(`state=${currentState}`); + } tokens.forEach(token => { const condition = this.filteredSearchTokenKeys.searchByConditionKeyValue( @@ -743,7 +750,7 @@ export default class FilteredSearchManager { let tokenPath = ''; if (condition) { - tokenPath = condition.url; + tokenPath = condition.replacementUrl || condition.url; } else { let tokenValue = token.value; diff --git a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js index 5298e20557d..f0951f6b177 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js +++ b/app/assets/javascripts/filtered_search/filtered_search_visual_tokens.js @@ -1,5 +1,5 @@ import VisualTokenValue from './visual_token_value'; -import { objectToQueryString } from '~/lib/utils/common_utils'; +import { objectToQueryString, spriteIcon } from '~/lib/utils/common_utils'; import FilteredSearchContainer from './container'; export default class FilteredSearchVisualTokens { @@ -84,7 +84,7 @@ export default class FilteredSearchVisualTokens { <div class="value-container"> <div class="${capitalizeTokenValue ? 'text-capitalize' : ''} value"></div> <div class="remove-token" role="button"> - <i class="fa fa-close"></i> + ${spriteIcon('close', 's16 close-icon')} </div> </div> </div> diff --git a/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js b/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js new file mode 100644 index 00000000000..ceeb71c4eec --- /dev/null +++ b/app/assets/javascripts/filtered_search/group_runners_filtered_search_token_keys.js @@ -0,0 +1,27 @@ +import { __ } from '~/locale'; +import FilteredSearchTokenKeys from './filtered_search_token_keys'; + +const tokenKeys = [ + { + formattedKey: __('Status'), + key: 'status', + type: 'string', + param: 'status', + symbol: '', + icon: 'messages', + tag: 'status', + }, + { + formattedKey: __('Type'), + key: 'type', + type: 'string', + param: 'type', + symbol: '', + icon: 'cube', + tag: 'type', + }, +]; + +const GroupRunnersFilteredSearchTokenKeys = new FilteredSearchTokenKeys(tokenKeys); + +export default GroupRunnersFilteredSearchTokenKeys; diff --git a/app/assets/javascripts/filtered_search/visual_token_value.js b/app/assets/javascripts/filtered_search/visual_token_value.js index b1e6c4142e9..f73646da6d1 100644 --- a/app/assets/javascripts/filtered_search/visual_token_value.js +++ b/app/assets/javascripts/filtered_search/visual_token_value.js @@ -4,7 +4,7 @@ import FilteredSearchContainer from '~/filtered_search/container'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; import AjaxCache from '~/lib/utils/ajax_cache'; import DropdownUtils from '~/filtered_search/dropdown_utils'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import UsersCache from '~/lib/utils/users_cache'; import { __ } from '~/locale'; import * as Emoji from '~/emoji'; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 74c00d21535..7697d97cb2c 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/browser'; import { escape } from 'lodash'; import { spriteIcon } from './lib/utils/common_utils'; @@ -74,7 +75,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => { * @param {Function} clickHandler Method to call when action is clicked on * @param {Boolean} fadeTransition Boolean to determine whether to fade the alert out */ -const createFlash = function createFlash( +const deprecatedCreateFlash = function deprecatedCreateFlash( message, type = FLASH_TYPES.ALERT, parent = document, @@ -109,12 +110,69 @@ const createFlash = function createFlash( return flashContainer; }; +/* + * Flash banner supports different types of Flash configurations + * along with ability to provide actionConfig which can be used to show + * additional action or link on banner next to message + * + * @param {Object} options Options to control the flash message + * @param {String} options.message Flash message text + * @param {String} options.type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default) + * @param {Object} options.parent Reference to parent element under which Flash needs to appear + * @param {Object} options.actonConfig Map of config to show action on banner + * @param {String} href URL to which action config should point to (default: '#') + * @param {String} title Title of action + * @param {Function} clickHandler Method to call when action is clicked on + * @param {Boolean} options.fadeTransition Boolean to determine whether to fade the alert out + * @param {Boolean} options.captureError Boolean to determine whether to send error to sentry + * @param {Object} options.error Error to be captured in sentry + */ +const createFlash = function createFlash({ + message, + type = FLASH_TYPES.ALERT, + parent = document, + actionConfig = null, + fadeTransition = true, + addBodyClass = false, + captureError = false, + error = null, +}) { + const flashContainer = parent.querySelector('.flash-container'); + + if (!flashContainer) return null; + + flashContainer.innerHTML = createFlashEl(message, type); + + const flashEl = flashContainer.querySelector(`.flash-${type}`); + + if (actionConfig) { + flashEl.insertAdjacentHTML('beforeend', createAction(actionConfig)); + + if (actionConfig.clickHandler) { + flashEl + .querySelector('.flash-action') + .addEventListener('click', e => actionConfig.clickHandler(e)); + } + } + + removeFlashClickListener(flashEl, fadeTransition); + + flashContainer.classList.add('gl-display-block'); + + if (addBodyClass) document.body.classList.add('flash-shown'); + + if (captureError && error) Sentry.captureException(error); + + return flashContainer; +}; + export { createFlash as default, + deprecatedCreateFlash, createFlashEl, createAction, hideFlash, removeFlashClickListener, FLASH_TYPES, }; -window.Flash = createFlash; +window.Flash = deprecatedCreateFlash; diff --git a/app/assets/javascripts/frequent_items/store/actions.js b/app/assets/javascripts/frequent_items/store/actions.js index ba62ab67e50..d4756e2ea6a 100644 --- a/app/assets/javascripts/frequent_items/store/actions.js +++ b/app/assets/javascripts/frequent_items/store/actions.js @@ -76,6 +76,3 @@ export const setSearchQuery = ({ commit, dispatch }, query) => { dispatch('fetchFrequentItems'); } }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/frequent_items/store/getters.js b/app/assets/javascripts/frequent_items/store/getters.js index 00165db6684..73e66643f06 100644 --- a/app/assets/javascripts/frequent_items/store/getters.js +++ b/app/assets/javascripts/frequent_items/store/getters.js @@ -1,4 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export export const hasSearchQuery = state => state.searchQuery !== ''; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/frequent_items/utils.js b/app/assets/javascripts/frequent_items/utils.js index d4b7ffdcbe1..112e8eaaf17 100644 --- a/app/assets/javascripts/frequent_items/utils.js +++ b/app/assets/javascripts/frequent_items/utils.js @@ -1,6 +1,6 @@ import { take } from 'lodash'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; import { FREQUENT_ITEMS, HOUR_IN_MS } from './constants'; export const isMobile = () => ['md', 'sm', 'xs'].includes(bp.getBreakpointSize()); @@ -52,7 +52,7 @@ export const sanitizeItem = item => { return {}; } - return { [key]: sanitize(item[key].toString(), { allowedTags: [] }) }; + return { [key]: sanitize(item[key].toString(), { ALLOWED_TAGS: [] }) }; }; return { diff --git a/app/assets/javascripts/gpg_badges.js b/app/assets/javascripts/gpg_badges.js index 96051b612b5..099c46f4b8d 100644 --- a/app/assets/javascripts/gpg_badges.js +++ b/app/assets/javascripts/gpg_badges.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { parseQueryStringIntoObject } from '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; export default class GpgBadges { diff --git a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue index 5295b2197d5..59327e36f5f 100644 --- a/app/assets/javascripts/grafana_integration/components/grafana_integration.vue +++ b/app/assets/javascripts/grafana_integration/components/grafana_integration.vue @@ -1,11 +1,11 @@ <script> -import { GlDeprecatedButton, GlFormGroup, GlFormInput, GlFormCheckbox } from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormInput, GlFormCheckbox } from '@gitlab/ui'; import { mapState, mapActions } from 'vuex'; import Icon from '~/vue_shared/components/icon.vue'; export default { components: { - GlDeprecatedButton, + GlButton, GlFormCheckbox, GlFormGroup, GlFormInput, @@ -56,9 +56,9 @@ export default { <section id="grafana" class="settings no-animate js-grafana-integration"> <div class="settings-header"> <h3 class="js-section-header h4"> - {{ s__('GrafanaIntegration|Grafana Authentication') }} + {{ s__('GrafanaIntegration|Grafana authentication') }} </h3> - <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__('GrafanaIntegration|Embed Grafana charts in GitLab issues.') }} </p> @@ -93,9 +93,11 @@ export default { </a> </p> </gl-form-group> - <gl-deprecated-button variant="success" @click="updateGrafanaIntegration"> - {{ __('Save Changes') }} - </gl-deprecated-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button variant="success" category="primary" @click="updateGrafanaIntegration"> + {{ __('Save Changes') }} + </gl-button> + </div> </form> </div> </section> diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js index d83f1e0831c..d28e59925d4 100644 --- a/app/assets/javascripts/grafana_integration/store/actions.js +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import * as mutationTypes from './mutation_types'; diff --git a/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql new file mode 100644 index 00000000000..22bcefbecd3 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/fragments/pageInfoCursorsOnly.fragment.graphql @@ -0,0 +1,4 @@ +fragment PageInfo on PageInfo { + startCursor + endCursor +} diff --git a/app/assets/javascripts/group.js b/app/assets/javascripts/group.js index ec8a238192a..a840e995860 100644 --- a/app/assets/javascripts/group.js +++ b/app/assets/javascripts/group.js @@ -1,6 +1,6 @@ import { slugify } from './lib/utils/text_utility'; import fetchGroupPathAvailability from '~/pages/groups/new/fetch_group_path_availability'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; export default class Group { diff --git a/app/assets/javascripts/group_label_subscription.js b/app/assets/javascripts/group_label_subscription.js index 9b74560f914..8c6c0714ee8 100644 --- a/app/assets/javascripts/group_label_subscription.js +++ b/app/assets/javascripts/group_label_subscription.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; const tooltipTitles = { group: __('Unsubscribe at group level'), diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index 985ea5a9019..ac4c12dda24 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -53,7 +53,7 @@ export default { :aria-label="leaveBtnTitle" data-container="body" data-placement="bottom" - class="leave-group btn btn-xs no-expand gl-text-gray-700 gl-ml-5" + class="leave-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" @click.prevent="onLeaveGroup" > <icon name="leave" class="position-top-0" /> @@ -66,7 +66,7 @@ export default { :aria-label="editBtnTitle" data-container="body" data-placement="bottom" - class="edit-group btn btn-xs no-expand gl-text-gray-700 gl-ml-5" + class="edit-group btn btn-xs no-expand gl-text-gray-500 gl-ml-5" > <icon name="settings" class="position-top-0 align-middle" /> </a> diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue index ffe4b18dea1..e09df8a5d26 100644 --- a/app/assets/javascripts/groups/components/item_stats.vue +++ b/app/assets/javascripts/groups/components/item_stats.vue @@ -1,5 +1,6 @@ <script> import { GlBadge } from '@gitlab/ui'; +import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; import timeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { ITEM_TYPE, @@ -8,7 +9,6 @@ import { PROJECT_VISIBILITY_TYPE, } from '../constants'; import itemStatsValue from './item_stats_value.vue'; -import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal'; export default { components: { @@ -44,7 +44,7 @@ export default { </script> <template> - <div class="stats gl-text-gray-700"> + <div class="stats gl-text-gray-500"> <item-stats-value v-if="isGroup" :title="__('Subgroups')" diff --git a/app/assets/javascripts/groups_select.js b/app/assets/javascripts/groups_select.js index 4daa8c60e58..4c50bb3a9ac 100644 --- a/app/assets/javascripts/groups_select.js +++ b/app/assets/javascripts/groups_select.js @@ -1,7 +1,7 @@ import $ from 'jquery'; +import { escape } from 'lodash'; import axios from './lib/utils/axios_utils'; import Api from './api'; -import { escape } from 'lodash'; import { normalizeHeaders } from './lib/utils/common_utils'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js index 5f85ee58779..5e345321013 100644 --- a/app/assets/javascripts/helpers/monitor_helper.js +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -49,7 +49,7 @@ const multiMetricLabel = metricAttributes => { * @param {Object} metricAttributes - Default metric attribute values (e.g. method, instance) * @returns {String} The formatted query label */ -const getSeriesLabel = (queryLabel, metricAttributes) => { +export const getSeriesLabel = (queryLabel, metricAttributes) => { return ( singleAttributeLabel(queryLabel, metricAttributes) || templatedLabel(queryLabel, metricAttributes) || @@ -63,7 +63,6 @@ const getSeriesLabel = (queryLabel, metricAttributes) => { * @param {Object} defaultConfig - Default chart config values (e.g. lineStyle, name) * @returns {Array} The formatted values */ -// eslint-disable-next-line import/prefer-default-export export const makeDataSeries = (queryResults, defaultConfig) => queryResults.map(result => { return { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 59a32dd477e..bbcb866c758 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -1,7 +1,7 @@ <script> import { mapActions } from 'vuex'; -import { sprintf, __ } from '~/locale'; import { GlModal } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/form.vue b/app/assets/javascripts/ide/components/commit_sidebar/form.vue index 3bba4fbc906..9342ab87c1a 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/form.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/form.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { n__, __ } from '~/locale'; import { GlModal } from '@gitlab/ui'; +import { n__, __ } from '~/locale'; import LoadingButton from '~/vue_shared/components/loading_button.vue'; import CommitMessageField from './message_field.vue'; import Actions from './actions.vue'; @@ -138,7 +138,7 @@ export default { @input="updateCommitMessage" @submit="commit" /> - <div class="clearfix prepend-top-15"> + <div class="clearfix gl-mt-5"> <actions /> <loading-button :loading="submitCommitLoading" diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index 5cff1079eb0..d1422a506e7 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,7 +1,7 @@ <script> import { mapActions } from 'vuex'; -import { __, sprintf } from '~/locale'; import { GlModal } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue index 03304337839..1b257ca11cc 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_collapsed.vue @@ -84,7 +84,7 @@ export default { :title="additionsTooltip" data-container="body" data-placement="left" - class="append-bottom-10" + class="gl-mb-3" > <icon :name="additionIconName" :size="18" :class="addedFilesIconClass" /> </div> @@ -94,7 +94,7 @@ export default { :title="modifiedTooltip" data-container="body" data-placement="left" - class="prepend-top-10 append-bottom-10" + class="gl-mt-3 gl-mb-3" > <icon :name="modifiedIconName" :size="18" :class="modifiedFilesClass" /> </div> diff --git a/app/assets/javascripts/ide/components/ide_project_header.vue b/app/assets/javascripts/ide/components/ide_project_header.vue index 36bc7c70196..36891505230 100644 --- a/app/assets/javascripts/ide/components/ide_project_header.vue +++ b/app/assets/javascripts/ide/components/ide_project_header.vue @@ -20,7 +20,11 @@ export default { <project-avatar-default :project="project" :size="48" /> <span class="ide-sidebar-project-title"> <span class="sidebar-context-title"> {{ project.name }} </span> - <span class="sidebar-context-title text-secondary"> + <span + class="sidebar-context-title text-secondary" + data-qa-selector="project_path_content" + :data-qa-project-path="project.path_with_namespace" + > {{ project.path_with_namespace }} </span> </span> diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index fe0167942b8..44986c8c575 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,8 +1,8 @@ <script> import { mapActions, mapState, mapGetters } from 'vuex'; -import flash from '~/flash'; -import { __, sprintf, s__ } from '~/locale'; import { GlModal } from '@gitlab/ui'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import { __, sprintf, s__ } from '~/locale'; import { modalTypes } from '../../constants'; import { trimPathComponents, getPathParent } from '../../utils'; diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index ac445a1d9f1..d22d430cb4a 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -1,7 +1,7 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { @@ -167,7 +167,7 @@ export default { }, mounted() { if (!this.editor) { - this.editor = Editor.create(this.editorOptions); + this.editor = Editor.create(this.$store, this.editorOptions); } this.initEditor(); diff --git a/app/assets/javascripts/ide/ide_router.js b/app/assets/javascripts/ide/ide_router.js index 152f77effa3..82cf8d7a10a 100644 --- a/app/assets/javascripts/ide/ide_router.js +++ b/app/assets/javascripts/ide/ide_router.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import IdeRouter from '~/ide/ide_router_extension'; import { joinPaths } from '~/lib/utils/url_utility'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { syncRouterAndStore } from './sync_router_and_store'; diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 850cfcb05e3..7c767009de5 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import { mapActions } from 'vuex'; -import Translate from '~/vue_shared/translate'; import { identity } from 'lodash'; +import Translate from '~/vue_shared/translate'; import ide from './components/ide.vue'; -import store from './stores'; +import { createStore } from './stores'; import { createRouter } from './ide_router'; import { parseBoolean } from '../lib/utils/common_utils'; import { resetServiceWorkersPublicPath } from '../lib/utils/webpack'; @@ -32,6 +32,7 @@ export function initIde(el, options = {}) { if (!el) return null; const { rootComponent = ide, extendStore = identity } = options; + const store = createStore(); const router = createRouter(store); return new Vue({ diff --git a/app/assets/javascripts/ide/lib/editor.js b/app/assets/javascripts/ide/lib/editor.js index 6e90968f008..f061fcb1259 100644 --- a/app/assets/javascripts/ide/lib/editor.js +++ b/app/assets/javascripts/ide/lib/editor.js @@ -1,6 +1,5 @@ import { debounce } from 'lodash'; import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor'; -import store from '../stores'; import DecorationsController from './decorations/controller'; import DirtyDiffController from './diff/controller'; import Disposable from './common/disposable'; @@ -20,14 +19,14 @@ function setupThemes() { } export default class Editor { - static create(options = {}) { + static create(...args) { if (!this.editorInstance) { - this.editorInstance = new Editor(options); + this.editorInstance = new Editor(...args); } return this.editorInstance; } - constructor(options = {}) { + constructor(store, options = {}) { this.currentModel = null; this.instance = null; this.dirtyDiffController = null; @@ -42,6 +41,7 @@ export default class Editor { ...defaultDiffEditorOptions, ...options, }; + this.store = store; setupThemes(); registerLanguages(...languages); @@ -215,6 +215,7 @@ export default class Editor { } addCommands() { + const { store } = this; const getKeyCode = key => { const monacoKeyMod = key.indexOf('KEY_') === 0; diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index c881f1221e5..b083dc6325f 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import * as types from './mutation_types'; import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 3fdfdc5422b..547665b49c6 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -1,4 +1,4 @@ -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import service from '../../services'; import * as types from '../mutation_types'; diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index d172bb31ae5..51e9bf6a84c 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -1,5 +1,5 @@ import { escape } from 'lodash'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __, sprintf } from '~/locale'; import service from '../../services'; import api from '../../../api'; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index 18c466cc93d..324c5b0c6e4 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -33,5 +33,3 @@ export const createStoreOptions = () => ({ }); export const createStore = () => new Vuex.Store(createStoreOptions()); - -export default createStore(); diff --git a/app/assets/javascripts/ide/stores/modules/branches/actions.js b/app/assets/javascripts/ide/stores/modules/branches/actions.js index f90c2d77f2b..c46289f77e2 100644 --- a/app/assets/javascripts/ide/stores/modules/branches/actions.js +++ b/app/assets/javascripts/ide/stores/modules/branches/actions.js @@ -32,5 +32,3 @@ export const fetchBranches = ({ dispatch, rootGetters }, { search = '' }) => { }; export const resetBranches = ({ commit }) => commit(types.RESET_BRANCHES); - -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index 005bd0240e2..277e6923f17 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -1,5 +1,5 @@ import { sprintf, __ } from '~/locale'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as rootTypes from '../../mutation_types'; import { createCommitPayload, createNewMergeRequestUrl } from '../../utils'; diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index 453df8d7e0c..4a407aea557 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -18,10 +18,12 @@ export const templateTypes = () => [ name: __('Dockerfile'), key: 'dockerfiles', }, + { + name: '.metrics-dashboard.yml', + key: 'metrics_dashboard_ymls', + }, ]; export const showFileTemplatesBar = (_, getters, rootState) => name => getters.templateTypes.find(t => t.name === name) && rootState.currentActivityView === leftSidebarViews.edit.name; - -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js index 8b5f7558654..6a1a0de033e 100644 --- a/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js +++ b/app/assets/javascripts/ide/stores/modules/merge_requests/actions.js @@ -41,5 +41,3 @@ export const fetchMergeRequests = ( }; export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS); - -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js index 9862c556c2e..86b889546b0 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/actions.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/actions.js @@ -149,5 +149,3 @@ export const resetLatestPipeline = ({ commit }) => { commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, null); commit(types.SET_DETAIL_JOB, null); }; - -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js index 1d127d915d7..eb3cc027494 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/getters.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/getters.js @@ -20,5 +20,3 @@ export const failedJobsCount = state => ); export const jobsCount = state => state.stages.reduce((acc, stage) => acc + stage.jobs.length, 0); - -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js index 112b3794114..5c13b5d74f2 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/index.js @@ -2,4 +2,3 @@ export * from './setup'; export * from './checks'; export * from './session_controls'; export * from './session_status'; -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js index d3dcb9dd125..f20f7fc9cd6 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_controls.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import * as types from '../mutation_types'; import * as messages from '../messages'; import * as terminalService from '../../../../services/terminals'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js index 59ba1605c47..d715d555aa9 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/actions/session_status.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import * as types from '../mutation_types'; import * as messages from '../messages'; import { isEndingStatus } from '../utils'; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/getters.js b/app/assets/javascripts/ide/stores/modules/terminal/getters.js index 6d64ee4ab6e..ef98547ccc4 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/getters.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/getters.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export export const allCheck = state => { const checks = Object.values(state.checks); @@ -15,5 +16,3 @@ export const allCheck = state => { message, }; }; - -export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/terminal/messages.js b/app/assets/javascripts/ide/stores/modules/terminal/messages.js index 38c5a8a28d8..bf35ce0f0bc 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/messages.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/messages.js @@ -21,7 +21,7 @@ export const EMPTY_RUNNERS = __( 'Configure GitLab runners to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', ); export const ERROR_CONFIG = __( - 'Configure a <code>.gitlab-webide.yml</code> file in the <code>.gitlab</code> directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', + 'Configure a %{codeStart}.gitlab-webide.yml%{codeEnd} file in the %{codeStart}.gitlab%{codeEnd} directory to start using the Web Terminal. %{helpStart}Learn more.%{helpEnd}', ); export const ERROR_PERMISSION = __( 'You do not have permission to run the Web Terminal. Please contact a project administrator.', @@ -34,6 +34,8 @@ export const configCheckError = (status, helpUrl) => { { helpStart: `<a href="${escape(helpUrl)}" target="_blank">`, helpEnd: '</a>', + codeStart: '<code>', + codeEnd: '</code>', }, false, ); diff --git a/app/assets/javascripts/ide/utils.js b/app/assets/javascripts/ide/utils.js index 9ec7b2c06ce..58a6712c232 100644 --- a/app/assets/javascripts/ide/utils.js +++ b/app/assets/javascripts/ide/utils.js @@ -1,6 +1,6 @@ -import { SIDE_LEFT, SIDE_RIGHT } from './constants'; import { languages } from 'monaco-editor'; import { flatten } from 'lodash'; +import { SIDE_LEFT, SIDE_RIGHT } from './constants'; const toLowerCase = x => x.toLowerCase(); diff --git a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue index f673a0e42dc..bc8aa522596 100644 --- a/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue +++ b/app/assets/javascripts/import_projects/components/bitbucket_status_table.vue @@ -9,6 +9,7 @@ export default { GlSprintf, GlLink, }, + inheritAttrs: false, props: { providerTitle: { type: String, @@ -28,7 +29,7 @@ export default { }; </script> <template> - <import-projects-table :provider-title="providerTitle"> + <import-projects-table :provider-title="providerTitle" v-bind="$attrs"> <template #actions> <slot name="actions"></slot> </template> diff --git a/app/assets/javascripts/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_projects/components/import_projects_table.vue index 6a467fb8c6a..72fdaca7e24 100644 --- a/app/assets/javascripts/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_projects/components/import_projects_table.vue @@ -3,10 +3,12 @@ import { throttle } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; import ImportedProjectTableRow from './imported_project_table_row.vue'; import ProviderRepoTableRow from './provider_repo_table_row.vue'; import IncompatibleRepoTableRow from './incompatible_repo_table_row.vue'; -import eventHub from '../event_hub'; +import PageQueryParamSync from './page_query_param_sync.vue'; +import { isProjectImportable } from '../utils'; const reposFetchThrottleDelay = 1000; @@ -16,8 +18,10 @@ export default { ImportedProjectTableRow, ProviderRepoTableRow, IncompatibleRepoTableRow, + PageQueryParamSync, GlLoadingIcon, GlButton, + PaginationLinks, }, props: { providerTitle: { @@ -29,23 +33,37 @@ export default { required: false, default: true, }, + paginatable: { + type: Boolean, + required: false, + default: false, + }, }, computed: { - ...mapState([ - 'importedProjects', - 'providerRepos', - 'incompatibleRepos', - 'isLoadingRepos', - 'filter', - ]), + ...mapState(['filter', 'repositories', 'namespaces', 'defaultTargetNamespace', 'pageInfo']), ...mapGetters([ + 'isLoading', 'isImportingAnyRepo', - 'hasProviderRepos', - 'hasImportedProjects', + 'hasImportableRepos', 'hasIncompatibleRepos', ]), + availableNamespaces() { + const serializedNamespaces = this.namespaces.map(({ fullPath }) => ({ + id: fullPath, + text: fullPath, + })); + + return [ + { text: __('Groups'), children: serializedNamespaces }, + { + text: __('Users'), + children: [{ id: this.defaultTargetNamespace, text: this.defaultTargetNamespace }], + }, + ]; + }, + importAllButtonText() { return this.hasIncompatibleRepos ? __('Import all compatible repositories') @@ -64,7 +82,8 @@ export default { }, mounted() { - return this.fetchRepos(); + this.fetchNamespaces(); + this.fetchRepos(); }, beforeDestroy() { @@ -75,17 +94,14 @@ export default { methods: { ...mapActions([ 'fetchRepos', - 'fetchReposFiltered', - 'fetchJobs', + 'fetchNamespaces', 'stopJobsPolling', 'clearJobsEtagPoll', 'setFilter', + 'importAll', + 'setPage', ]), - importAll() { - eventHub.$emit('importAll'); - }, - handleFilterInput({ target }) { this.setFilter(target.value); }, @@ -93,79 +109,90 @@ export default { throttledFetchRepos: throttle(function fetch() { this.fetchRepos(); }, reposFetchThrottleDelay), + + isProjectImportable, }, }; </script> <template> <div> + <page-query-param-sync :page="pageInfo.page" @popstate="setPage" /> + <p class="light text-nowrap mt-2"> {{ s__('ImportProjects|Select the projects you want to import') }} </p> <template v-if="hasIncompatibleRepos"> - <slot name="incompatible-repos-warning"> </slot> + <slot name="incompatible-repos-warning"></slot> </template> - <div - v-if="!isLoadingRepos" - class="d-flex justify-content-between align-items-end flex-wrap mb-3" - > - <gl-button - variant="success" - :loading="isImportingAnyRepo" - :disabled="!hasProviderRepos" - type="button" - @click="importAll" - > - {{ importAllButtonText }} - </gl-button> - <slot name="actions"></slot> - <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent> - <input - :value="filter" - data-qa-selector="githubish_import_filter_field" - class="form-control" - name="filter" - :placeholder="__('Filter your projects by name')" - autofocus - size="40" - @input="handleFilterInput($event)" - @keyup.enter="throttledFetchRepos" - /> - </form> - </div> <gl-loading-icon - v-if="isLoadingRepos" + v-if="isLoading" class="js-loading-button-icon import-projects-loading-icon" size="md" /> - <div - v-else-if="hasProviderRepos || hasImportedProjects || hasIncompatibleRepos" - class="table-responsive" - > - <table class="table import-table"> - <thead> - <th class="import-jobs-from-col">{{ fromHeaderText }}</th> - <th class="import-jobs-to-col">{{ __('To GitLab') }}</th> - <th class="import-jobs-status-col">{{ __('Status') }}</th> - <th class="import-jobs-cta-col"></th> - </thead> - <tbody> - <imported-project-table-row - v-for="project in importedProjects" - :key="project.id" - :project="project" - /> - <provider-repo-table-row v-for="repo in providerRepos" :key="repo.id" :repo="repo" /> - <incompatible-repo-table-row - v-for="repo in incompatibleRepos" - :key="repo.id" - :repo="repo" + <template v-if="!isLoading"> + <div class="d-flex justify-content-between align-items-end flex-wrap mb-3"> + <gl-button + variant="success" + :loading="isImportingAnyRepo" + :disabled="!hasImportableRepos" + type="button" + @click="importAll" + >{{ importAllButtonText }}</gl-button + > + <slot name="actions"></slot> + <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent> + <input + :value="filter" + data-qa-selector="githubish_import_filter_field" + class="form-control" + name="filter" + :placeholder="__('Filter your projects by name')" + autofocus + size="40" + @input="handleFilterInput($event)" + @keyup.enter="throttledFetchRepos" /> - </tbody> - </table> - </div> - <div v-else class="text-center"> - <strong>{{ emptyStateText }}</strong> - </div> + </form> + </div> + <div v-if="repositories.length" class="table-responsive"> + <table class="table import-table"> + <thead> + <th class="import-jobs-from-col">{{ fromHeaderText }}</th> + <th class="import-jobs-to-col">{{ __('To GitLab') }}</th> + <th class="import-jobs-status-col">{{ __('Status') }}</th> + <th class="import-jobs-cta-col"></th> + </thead> + <tbody> + <template v-for="repo in repositories"> + <incompatible-repo-table-row + v-if="repo.importSource.incompatible" + :key="repo.importSource.id" + :repo="repo" + /> + <provider-repo-table-row + v-else-if="isProjectImportable(repo)" + :key="repo.importSource.id" + :repo="repo" + :available-namespaces="availableNamespaces" + /> + <imported-project-table-row v-else :key="repo.importSource.id" :project="repo" /> + </template> + </tbody> + </table> + </div> + <div v-else class="text-center"> + <strong>{{ emptyStateText }}</strong> + </div> + <pagination-links + v-if="paginatable" + align="center" + class="gl-mt-3" + :page-info="pageInfo" + :prev-page="pageInfo.page - 1" + :next-page="repositories.length && pageInfo.page + 1" + :change="setPage" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue index ab2bd87ee9f..50e735b4478 100644 --- a/app/assets/javascripts/import_projects/components/imported_project_table_row.vue +++ b/app/assets/javascripts/import_projects/components/imported_project_table_row.vue @@ -1,4 +1,5 @@ <script> +import { GlIcon } from '@gitlab/ui'; import ImportStatus from './import_status.vue'; import { STATUSES } from '../constants'; @@ -6,6 +7,7 @@ export default { name: 'ImportedProjectTableRow', components: { ImportStatus, + GlIcon, }, props: { project: { @@ -16,7 +18,7 @@ export default { computed: { displayFullPath() { - return this.project.fullPath.replace(/^\//, ''); + return this.project.importedProject.fullPath.replace(/^\//, ''); }, isFinished() { @@ -27,28 +29,30 @@ export default { </script> <template> - <tr class="js-imported-project import-row"> + <tr class="import-row"> <td> <a - :href="project.providerLink" + :href="project.importSource.providerLink" rel="noreferrer noopener" target="_blank" - class="js-provider-link" - > - {{ project.importSource }} + data-testid="providerLink" + >{{ project.importSource.fullName }} + <gl-icon v-if="project.importSource.providerLink" name="external-link" /> </a> </td> - <td class="js-full-path">{{ displayFullPath }}</td> - <td><import-status :status="project.importStatus" /></td> + <td data-testid="fullPath">{{ displayFullPath }}</td> + <td> + <import-status :status="project.importStatus" /> + </td> <td> <a v-if="isFinished" - class="btn btn-default js-go-to-project" - :href="project.fullPath" + class="btn btn-default" + data-testid="goToProject" + :href="project.importedProject.fullPath" rel="noreferrer noopener" target="_blank" - > - {{ __('Go to project') }} + >{{ __('Go to project') }} </a> </td> </tr> diff --git a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue index fa2fb439eac..3140585ccd7 100644 --- a/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/incompatible_repo_table_row.vue @@ -1,9 +1,10 @@ <script> -import { GlBadge } from '@gitlab/ui'; +import { GlIcon, GlBadge } from '@gitlab/ui'; export default { components: { GlBadge, + GlIcon, }, props: { repo: { @@ -17,8 +18,9 @@ export default { <template> <tr class="import-row"> <td> - <a :href="repo.providerLink" rel="noreferrer noopener" target="_blank"> - {{ repo.fullName }} + <a :href="repo.importSource.providerLink" rel="noreferrer noopener" target="_blank" + >{{ repo.importSource.fullName }} + <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </a> </td> <td></td> diff --git a/app/assets/javascripts/import_projects/components/page_query_param_sync.vue b/app/assets/javascripts/import_projects/components/page_query_param_sync.vue new file mode 100644 index 00000000000..5ba3d70f5d0 --- /dev/null +++ b/app/assets/javascripts/import_projects/components/page_query_param_sync.vue @@ -0,0 +1,39 @@ +<script> +import { queryToObject, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; + +export default { + props: { + page: { + type: Number, + required: true, + }, + }, + + watch: { + page(newPage) { + updateHistory({ + url: setUrlParams({ + page: newPage === 1 ? null : newPage, + }), + }); + }, + }, + + created() { + window.addEventListener('popstate', this.updatePage); + }, + + beforeDestroy() { + window.removeEventListener('popstate', this.updatePage); + }, + + methods: { + updatePage() { + const page = parseInt(queryToObject(window.location.search).page, 10) || 1; + this.$emit('popstate', page); + }, + }, + + render: () => null, +}; +</script> diff --git a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue index 63524d61146..d8cffc6a7d5 100644 --- a/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_projects/components/provider_repo_table_row.vue @@ -1,9 +1,8 @@ <script> import { mapState, mapGetters, mapActions } from 'vuex'; +import { GlIcon } from '@gitlab/ui'; import Select2Select from '~/vue_shared/components/select2_select.vue'; import { __ } from '~/locale'; -import eventHub from '../event_hub'; -import { STATUSES } from '../constants'; import ImportStatus from './import_status.vue'; export default { @@ -11,25 +10,26 @@ export default { components: { Select2Select, ImportStatus, + GlIcon, }, props: { repo: { type: Object, required: true, }, - }, - - data() { - return { - targetNamespace: this.$store.state.defaultTargetNamespace, - newName: this.repo.sanitizedName, - }; + availableNamespaces: { + type: Array, + required: true, + }, }, computed: { - ...mapState(['namespaces', 'reposBeingImported', 'ciCdOnly']), + ...mapState(['ciCdOnly']), + ...mapGetters(['getImportTarget']), - ...mapGetters(['namespaceSelectOptions']), + importTarget() { + return this.getImportTarget(this.repo.importSource.id); + }, importButtonText() { return this.ciCdOnly ? __('Connect') : __('Import'); @@ -37,37 +37,36 @@ export default { select2Options() { return { - data: this.namespaceSelectOptions, - containerCssClass: - 'import-namespace-select js-namespace-select qa-project-namespace-select w-auto', + data: this.availableNamespaces, + containerCssClass: 'import-namespace-select qa-project-namespace-select w-auto', }; }, - isLoadingImport() { - return this.reposBeingImported.includes(this.repo.id); + targetNamespaceSelect: { + get() { + return this.importTarget.targetNamespace; + }, + set(value) { + this.updateImportTarget({ targetNamespace: value }); + }, }, - status() { - return this.isLoadingImport ? STATUSES.SCHEDULING : STATUSES.NONE; + newNameInput: { + get() { + return this.importTarget.newName; + }, + set(value) { + this.updateImportTarget({ newName: value }); + }, }, }, - created() { - eventHub.$on('importAll', this.importRepo); - }, - - beforeDestroy() { - eventHub.$off('importAll', this.importRepo); - }, - methods: { - ...mapActions(['fetchImport']), - - importRepo() { - return this.fetchImport({ - newName: this.newName, - targetNamespace: this.targetNamespace, - repo: this.repo, + ...mapActions(['fetchImport', 'setImportTarget']), + updateImportTarget(changedValues) { + this.setImportTarget({ + repoId: this.repo.importSource.id, + importTarget: { ...this.importTarget, ...changedValues }, }); }, }, @@ -75,35 +74,39 @@ export default { </script> <template> - <tr class="qa-project-import-row js-provider-repo import-row"> + <tr class="qa-project-import-row import-row"> <td> <a - :href="repo.providerLink" + :href="repo.importSource.providerLink" rel="noreferrer noopener" target="_blank" - class="js-provider-link" - > - {{ repo.fullName }} + data-testid="providerLink" + >{{ repo.importSource.fullName }} + <gl-icon v-if="repo.importSource.providerLink" name="external-link" /> </a> </td> <td class="d-flex flex-wrap flex-lg-nowrap"> - <select2-select v-model="targetNamespace" :options="select2Options" /> - <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center" - >/</span - > - <input - v-model="newName" - type="text" - class="form-control import-project-name-input js-new-name qa-project-path-field" - /> + <template v-if="repo.target">{{ repo.target }}</template> + <template v-else> + <select2-select v-model="targetNamespaceSelect" :options="select2Options" /> + <span class="px-2 import-slash-divider d-flex justify-content-center align-items-center" + >/</span + > + <input + v-model="newNameInput" + type="text" + class="form-control import-project-name-input qa-project-path-field" + /> + </template> + </td> + <td> + <import-status :status="repo.importStatus" /> </td> - <td><import-status :status="status" /></td> <td> <button - v-if="!isLoadingImport" type="button" - class="qa-import-button js-import-button btn btn-default" - @click="importRepo" + class="qa-import-button btn btn-default" + @click="fetchImport(repo.importSource.id)" > {{ importButtonText }} </button> diff --git a/app/assets/javascripts/import_projects/index.js b/app/assets/javascripts/import_projects/index.js index 68ba04aa9dd..79fbd58e355 100644 --- a/app/assets/javascripts/import_projects/index.js +++ b/app/assets/javascripts/import_projects/index.js @@ -2,28 +2,44 @@ import Vue from 'vue'; import Translate from '../vue_shared/translate'; import ImportProjectsTable from './components/import_projects_table.vue'; import { parseBoolean } from '../lib/utils/common_utils'; +import { queryToObject } from '../lib/utils/url_utility'; import createStore from './store'; Vue.use(Translate); export function initStoreFromElement(element) { const { - reposPath, - provider, + ciCdOnly, canSelectNamespace, + provider, + + reposPath, jobsPath, importPath, - ciCdOnly, + namespacesPath, + paginatable, } = element.dataset; + const params = queryToObject(document.location.search); + const page = parseInt(params.page ?? 1, 10); + return createStore({ - reposPath, - provider, - jobsPath, - importPath, - defaultTargetNamespace: gon.current_username, - ciCdOnly: parseBoolean(ciCdOnly), - canSelectNamespace: parseBoolean(canSelectNamespace), + initialState: { + defaultTargetNamespace: gon.current_username, + ciCdOnly: parseBoolean(ciCdOnly), + canSelectNamespace: parseBoolean(canSelectNamespace), + provider, + pageInfo: { + page, + }, + }, + endpoints: { + reposPath, + jobsPath, + importPath, + namespacesPath, + }, + hasPagination: parseBoolean(paginatable), }); } @@ -31,6 +47,7 @@ export function initPropsFromElement(element) { return { providerTitle: element.dataset.providerTitle, filterable: parseBoolean(element.dataset.filterable), + paginatable: parseBoolean(element.dataset.paginatable), }; } diff --git a/app/assets/javascripts/import_projects/store/actions.js b/app/assets/javascripts/import_projects/store/actions.js index 8d8d33f5972..af410f411d8 100644 --- a/app/assets/javascripts/import_projects/store/actions.js +++ b/app/assets/javascripts/import_projects/store/actions.js @@ -1,41 +1,86 @@ import Visibility from 'visibilityjs'; import * as types from './mutation_types'; -import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { isProjectImportable } from '../utils'; +import { + convertObjectPropsToCamelCase, + normalizeHeaders, + parseIntPagination, +} from '~/lib/utils/common_utils'; import Poll from '~/lib/utils/poll'; -import { visitUrl } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { visitUrl, objectToQuery } from '~/lib/utils/url_utility'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; -import { jobsPathWithFilter, reposPathWithFilter } from './getters'; let eTagPoll; const hasRedirectInError = e => e?.response?.data?.error?.redirect; const redirectToUrlInError = e => visitUrl(e.response.data.error.redirect); +const pathWithParams = ({ path, ...params }) => { + const filteredParams = Object.fromEntries( + Object.entries(params).filter(([, value]) => value !== ''), + ); + const queryString = objectToQuery(filteredParams); + return queryString ? `${path}?${queryString}` : path; +}; + +const isRequired = () => { + // eslint-disable-next-line @gitlab/require-i18n-strings + throw new Error('param is required'); +}; -export const clearJobsEtagPoll = () => { +const clearJobsEtagPoll = () => { eTagPoll = null; }; -export const stopJobsPolling = () => { + +const stopJobsPolling = () => { if (eTagPoll) eTagPoll.stop(); }; -export const restartJobsPolling = () => { + +const restartJobsPolling = () => { if (eTagPoll) eTagPoll.restart(); }; -export const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); +const setFilter = ({ commit }, filter) => commit(types.SET_FILTER, filter); + +const setImportTarget = ({ commit }, { repoId, importTarget }) => + commit(types.SET_IMPORT_TARGET, { repoId, importTarget }); + +const importAll = ({ state, dispatch }) => { + return Promise.all( + state.repositories + .filter(isProjectImportable) + .map(r => dispatch('fetchImport', r.importSource.id)), + ); +}; -export const fetchRepos = ({ state, dispatch, commit }) => { +const fetchReposFactory = ({ reposPath = isRequired(), hasPagination }) => ({ + state, + dispatch, + commit, +}) => { dispatch('stopJobsPolling'); commit(types.REQUEST_REPOS); - const { provider } = state; + const { provider, filter } = state; return axios - .get(reposPathWithFilter(state)) - .then(({ data }) => - commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), + .get( + pathWithParams({ + path: reposPath, + filter, + page: hasPagination ? state.pageInfo.page.toString() : '', + }), ) + .then(({ data, headers }) => { + const normalizedHeaders = normalizeHeaders(headers); + + if ('X-PAGE' in normalizedHeaders) { + commit(types.SET_PAGE_INFO, parseIntPagination(normalizedHeaders)); + } + + commit(types.RECEIVE_REPOS_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })); + }) .then(() => dispatch('fetchJobs')) .catch(e => { if (hasRedirectInError(e)) { @@ -52,24 +97,26 @@ export const fetchRepos = ({ state, dispatch, commit }) => { }); }; -export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo }) => { - if (!state.reposBeingImported.includes(repo.id)) { - commit(types.REQUEST_IMPORT, repo.id); - } +const fetchImportFactory = (importPath = isRequired()) => ({ state, commit, getters }, repoId) => { + const { ciCdOnly } = state; + const importTarget = getters.getImportTarget(repoId); + + commit(types.REQUEST_IMPORT, { repoId, importTarget }); + const { newName, targetNamespace } = importTarget; return axios - .post(state.importPath, { - ci_cd_only: state.ciCdOnly, + .post(importPath, { + repo_id: repoId, + ci_cd_only: ciCdOnly, new_name: newName, - repo_id: repo.id, target_namespace: targetNamespace, }) - .then(({ data }) => + .then(({ data }) => { commit(types.RECEIVE_IMPORT_SUCCESS, { importedProject: convertObjectPropsToCamelCase(data, { deep: true }), - repoId: repo.id, - }), - ) + repoId, + }); + }) .catch(e => { const serverErrorMessage = e?.response?.data?.errors; const flashMessage = serverErrorMessage @@ -84,14 +131,11 @@ export const fetchImport = ({ state, commit }, { newName, targetNamespace, repo createFlash(flashMessage); - commit(types.RECEIVE_IMPORT_ERROR, repo.id); + commit(types.RECEIVE_IMPORT_ERROR, repoId); }); }; -export const receiveJobsSuccess = ({ commit }, updatedProjects) => - commit(types.RECEIVE_JOBS_SUCCESS, updatedProjects); - -export const fetchJobs = ({ state, commit, dispatch }) => { +export const fetchJobsFactory = (jobsPath = isRequired()) => ({ state, commit, dispatch }) => { const { filter } = state; if (eTagPoll) { @@ -101,7 +145,7 @@ export const fetchJobs = ({ state, commit, dispatch }) => { eTagPoll = new Poll({ resource: { - fetchJobs: () => axios.get(jobsPathWithFilter(state)), + fetchJobs: () => axios.get(pathWithParams({ path: jobsPath, filter })), }, method: 'fetchJobs', successCallback: ({ data }) => @@ -129,5 +173,39 @@ export const fetchJobs = ({ state, commit, dispatch }) => { }); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +const fetchNamespacesFactory = (namespacesPath = isRequired()) => ({ commit }) => { + commit(types.REQUEST_NAMESPACES); + axios + .get(namespacesPath) + .then(({ data }) => + commit(types.RECEIVE_NAMESPACES_SUCCESS, convertObjectPropsToCamelCase(data, { deep: true })), + ) + .catch(() => { + createFlash(s__('ImportProjects|Requesting namespaces failed')); + + commit(types.RECEIVE_NAMESPACES_ERROR); + }); +}; + +const setPage = ({ state, commit, dispatch }, page) => { + if (page === state.pageInfo.page) { + return null; + } + + commit(types.SET_PAGE, page); + return dispatch('fetchRepos'); +}; + +export default ({ endpoints = isRequired(), hasPagination }) => ({ + clearJobsEtagPoll, + stopJobsPolling, + restartJobsPolling, + setFilter, + setImportTarget, + importAll, + setPage, + fetchRepos: fetchReposFactory({ reposPath: endpoints.reposPath, hasPagination }), + fetchImport: fetchImportFactory(endpoints.importPath), + fetchJobs: fetchJobsFactory(endpoints.jobsPath), + fetchNamespaces: fetchNamespacesFactory(endpoints.namespacesPath), +}); diff --git a/app/assets/javascripts/import_projects/store/getters.js b/app/assets/javascripts/import_projects/store/getters.js index e6eb8f523de..7d529c94d7d 100644 --- a/app/assets/javascripts/import_projects/store/getters.js +++ b/app/assets/javascripts/import_projects/store/getters.js @@ -1,29 +1,27 @@ -import { __ } from '~/locale'; +import { STATUSES } from '../constants'; -export const namespaceSelectOptions = state => { - const serializedNamespaces = state.namespaces.map(({ fullPath }) => ({ - id: fullPath, - text: fullPath, - })); +export const isLoading = state => state.isLoadingRepos || state.isLoadingNamespaces; - return [ - { text: __('Groups'), children: serializedNamespaces }, - { - text: __('Users'), - children: [{ id: state.defaultTargetNamespace, text: state.defaultTargetNamespace }], - }, - ]; -}; +export const isImportingAnyRepo = state => + state.repositories.some(repo => + [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes(repo.importStatus), + ); -export const isImportingAnyRepo = state => state.reposBeingImported.length > 0; +export const hasIncompatibleRepos = state => + state.repositories.some(repo => repo.importSource.incompatible); -export const hasProviderRepos = state => state.providerRepos.length > 0; +export const hasImportableRepos = state => + state.repositories.some(repo => repo.importStatus === STATUSES.NONE); -export const hasImportedProjects = state => state.importedProjects.length > 0; +export const getImportTarget = state => repoId => { + if (state.customImportTargets[repoId]) { + return state.customImportTargets[repoId]; + } -export const hasIncompatibleRepos = state => state.incompatibleRepos.length > 0; + const repo = state.repositories.find(r => r.importSource.id === repoId); -export const reposPathWithFilter = ({ reposPath, filter = '' }) => - filter ? `${reposPath}?filter=${filter}` : reposPath; -export const jobsPathWithFilter = ({ jobsPath, filter = '' }) => - filter ? `${jobsPath}?filter=${filter}` : jobsPath; + return { + newName: repo.importSource.sanitizedName, + targetNamespace: state.defaultTargetNamespace, + }; +}; diff --git a/app/assets/javascripts/import_projects/store/index.js b/app/assets/javascripts/import_projects/store/index.js index 29deb7868ba..7ba12f81eb9 100644 --- a/app/assets/javascripts/import_projects/store/index.js +++ b/app/assets/javascripts/import_projects/store/index.js @@ -1,18 +1,16 @@ import Vue from 'vue'; import Vuex from 'vuex'; import state from './state'; -import * as actions from './actions'; +import actionsFactory from './actions'; import * as getters from './getters'; import mutations from './mutations'; Vue.use(Vuex); -export { state, actions, getters, mutations }; - -export default initialState => +export default ({ initialState, endpoints, hasPagination }) => new Vuex.Store({ state: { ...state(), ...initialState }, - actions, + actions: actionsFactory({ endpoints, hasPagination }), mutations, getters, }); diff --git a/app/assets/javascripts/import_projects/store/mutation_types.js b/app/assets/javascripts/import_projects/store/mutation_types.js index a23b7eef986..6adf5e59cff 100644 --- a/app/assets/javascripts/import_projects/store/mutation_types.js +++ b/app/assets/javascripts/import_projects/store/mutation_types.js @@ -2,6 +2,10 @@ export const REQUEST_REPOS = 'REQUEST_REPOS'; export const RECEIVE_REPOS_SUCCESS = 'RECEIVE_REPOS_SUCCESS'; export const RECEIVE_REPOS_ERROR = 'RECEIVE_REPOS_ERROR'; +export const REQUEST_NAMESPACES = 'REQUEST_NAMESPACES'; +export const RECEIVE_NAMESPACES_SUCCESS = 'RECEIVE_NAMESPACES_SUCCESS'; +export const RECEIVE_NAMESPACES_ERROR = 'RECEIVE_NAMESPACES_ERROR'; + export const REQUEST_IMPORT = 'REQUEST_IMPORT'; export const RECEIVE_IMPORT_SUCCESS = 'RECEIVE_IMPORT_SUCCESS'; export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; @@ -9,3 +13,9 @@ export const RECEIVE_IMPORT_ERROR = 'RECEIVE_IMPORT_ERROR'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const SET_FILTER = 'SET_FILTER'; + +export const SET_IMPORT_TARGET = 'SET_IMPORT_TARGET'; + +export const SET_PAGE = 'SET_PAGE'; + +export const SET_PAGE_INFO = 'SET_PAGE_INFO'; diff --git a/app/assets/javascripts/import_projects/store/mutations.js b/app/assets/javascripts/import_projects/store/mutations.js index ec62d0640ef..b3dbef896a6 100644 --- a/app/assets/javascripts/import_projects/store/mutations.js +++ b/app/assets/javascripts/import_projects/store/mutations.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import * as types from './mutation_types'; +import { STATUSES } from '../constants'; export default { [types.SET_FILTER](state, filter) { @@ -12,48 +13,103 @@ export default { [types.RECEIVE_REPOS_SUCCESS]( state, - { importedProjects, providerRepos, incompatibleRepos, namespaces }, + { importedProjects, providerRepos, incompatibleRepos = [] }, ) { + // Normalizing structure to support legacy backend format + // See https://gitlab.com/gitlab-org/gitlab/-/issues/27370#note_379034091 for details + state.isLoadingRepos = false; - state.importedProjects = importedProjects; - state.providerRepos = providerRepos; - state.incompatibleRepos = incompatibleRepos ?? []; - state.namespaces = namespaces; + state.repositories = [ + ...importedProjects.map(({ importSource, providerLink, importStatus, ...project }) => ({ + importSource: { + id: `finished-${project.id}`, + fullName: importSource, + sanitizedName: project.name, + providerLink, + }, + importStatus, + importedProject: project, + })), + ...providerRepos.map(project => ({ + importSource: project, + importStatus: STATUSES.NONE, + importedProject: null, + })), + ...incompatibleRepos.map(project => ({ + importSource: { ...project, incompatible: true }, + importStatus: STATUSES.NONE, + importedProject: null, + })), + ]; }, [types.RECEIVE_REPOS_ERROR](state) { state.isLoadingRepos = false; }, - [types.REQUEST_IMPORT](state, repoId) { - state.reposBeingImported.push(repoId); + [types.REQUEST_IMPORT](state, { repoId, importTarget }) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importStatus = STATUSES.SCHEDULING; + existingRepo.importedProject = { + fullPath: `/${importTarget.targetNamespace}/${importTarget.newName}`, + }; }, [types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }) { - const existingRepoIndex = state.reposBeingImported.indexOf(repoId); - if (state.reposBeingImported.includes(repoId)) - state.reposBeingImported.splice(existingRepoIndex, 1); + const { importStatus, ...project } = importedProject; - const providerRepoIndex = state.providerRepos.findIndex( - providerRepo => providerRepo.id === repoId, - ); - state.providerRepos.splice(providerRepoIndex, 1); - state.importedProjects.unshift(importedProject); + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importStatus = importStatus; + existingRepo.importedProject = project; }, [types.RECEIVE_IMPORT_ERROR](state, repoId) { - const repoIndex = state.reposBeingImported.indexOf(repoId); - if (state.reposBeingImported.includes(repoId)) state.reposBeingImported.splice(repoIndex, 1); + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + existingRepo.importStatus = STATUSES.NONE; + existingRepo.importedProject = null; }, [types.RECEIVE_JOBS_SUCCESS](state, updatedProjects) { updatedProjects.forEach(updatedProject => { - const existingProject = state.importedProjects.find( - importedProject => importedProject.id === updatedProject.id, - ); - - Vue.set(existingProject, 'importStatus', updatedProject.importStatus); + const repo = state.repositories.find(p => p.importedProject?.id === updatedProject.id); + if (repo) { + repo.importStatus = updatedProject.importStatus; + } }); }, + + [types.REQUEST_NAMESPACES](state) { + state.isLoadingNamespaces = true; + }, + + [types.RECEIVE_NAMESPACES_SUCCESS](state, namespaces) { + state.isLoadingNamespaces = false; + state.namespaces = namespaces; + }, + + [types.RECEIVE_NAMESPACES_ERROR](state) { + state.isLoadingNamespaces = false; + }, + + [types.SET_IMPORT_TARGET](state, { repoId, importTarget }) { + const existingRepo = state.repositories.find(r => r.importSource.id === repoId); + + if ( + importTarget.targetNamespace === state.defaultTargetNamespace && + importTarget.newName === existingRepo.importSource.sanitizedName + ) { + Vue.delete(state.customImportTargets, repoId); + } else { + Vue.set(state.customImportTargets, repoId, importTarget); + } + }, + + [types.SET_PAGE_INFO](state, pageInfo) { + state.pageInfo = pageInfo; + }, + + [types.SET_PAGE](state, page) { + state.pageInfo.page = page; + }, }; diff --git a/app/assets/javascripts/import_projects/store/state.js b/app/assets/javascripts/import_projects/store/state.js index 0418d735b1d..3318181e4af 100644 --- a/app/assets/javascripts/import_projects/store/state.js +++ b/app/assets/javascripts/import_projects/store/state.js @@ -1,17 +1,13 @@ export default () => ({ - reposPath: '', - importPath: '', - jobsPath: '', - currentProjectId: '', provider: '', - currentUsername: '', - importedProjects: [], - providerRepos: [], - incompatibleRepos: [], + repositories: [], namespaces: [], - reposBeingImported: [], + customImportTargets: {}, isLoadingRepos: false, - canSelectNamespace: false, + isLoadingNamespaces: false, ciCdOnly: false, filter: '', + pageInfo: { + page: 1, + }, }); diff --git a/app/assets/javascripts/import_projects/utils.js b/app/assets/javascripts/import_projects/utils.js new file mode 100644 index 00000000000..c2a2d5a607d --- /dev/null +++ b/app/assets/javascripts/import_projects/utils.js @@ -0,0 +1,7 @@ +import { STATUSES } from './constants'; + +// Will be expanded in future +// eslint-disable-next-line import/prefer-default-export +export function isProjectImportable(project) { + return project.importStatus === STATUSES.NONE && !project.importSource.incompatible; +} diff --git a/app/assets/javascripts/importer_status.js b/app/assets/javascripts/importer_status.js index f44c5c3d289..349ca14b4e8 100644 --- a/app/assets/javascripts/importer_status.js +++ b/app/assets/javascripts/importer_status.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import { escape } from 'lodash'; import { __, sprintf } from './locale'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import { parseBoolean } from './lib/utils/common_utils'; class ImporterStatus { diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue new file mode 100644 index 00000000000..46852e4ddd9 --- /dev/null +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -0,0 +1,407 @@ +<script> +import { + GlLoadingIcon, + GlTable, + GlAlert, + GlAvatarsInline, + GlAvatarLink, + GlAvatar, + GlTooltipDirective, + GlButton, + GlSearchBoxByType, + GlIcon, + GlPagination, + GlTabs, + GlTab, + GlBadge, + GlEmptyState, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { convertToSnakeCase } from '~/lib/utils/text_utility'; +import { s__ } from '~/locale'; +import { mergeUrlParams, joinPaths, visitUrl } from '~/lib/utils/url_utility'; +import getIncidents from '../graphql/queries/get_incidents.query.graphql'; +import getIncidentsCountByStatus from '../graphql/queries/get_count_by_status.query.graphql'; +import { I18N, DEFAULT_PAGE_SIZE, INCIDENT_SEARCH_DELAY, INCIDENT_STATUS_TABS } from '../constants'; + +const TH_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' }; +const tdClass = + 'table-col gl-display-flex d-md-table-cell gl-align-items-center gl-white-space-nowrap'; +const thClass = 'gl-hover-bg-blue-50'; +const bodyTrClass = + 'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-cursor-pointer gl-hover-bg-blue-50 gl-hover-border-b-solid gl-hover-border-blue-200'; + +const initialPaginationState = { + currentPage: 1, + prevPageCursor: '', + nextPageCursor: '', + firstPageSize: DEFAULT_PAGE_SIZE, + lastPageSize: null, +}; + +export default { + i18n: I18N, + statusTabs: INCIDENT_STATUS_TABS, + fields: [ + { + key: 'title', + label: s__('IncidentManagement|Incident'), + thClass: `gl-pointer-events-none gl-w-half`, + tdClass, + }, + { + key: 'createdAt', + label: s__('IncidentManagement|Date created'), + thClass, + tdClass: `${tdClass} sortable-cell`, + sortable: true, + thAttr: TH_TEST_ID, + }, + { + key: 'assignees', + label: s__('IncidentManagement|Assignees'), + thClass: 'gl-pointer-events-none', + tdClass, + }, + ], + components: { + GlLoadingIcon, + GlTable, + GlAlert, + GlAvatarsInline, + GlAvatarLink, + GlAvatar, + GlButton, + TimeAgoTooltip, + GlSearchBoxByType, + GlIcon, + GlPagination, + GlTabs, + GlTab, + PublishedCell: () => import('ee_component/incidents/components/published_cell.vue'), + GlBadge, + GlEmptyState, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: [ + 'projectPath', + 'newIssuePath', + 'incidentTemplateName', + 'incidentType', + 'issuePath', + 'publishedAvailable', + 'emptyListSvgPath', + ], + apollo: { + incidents: { + query: getIncidents, + variables() { + return { + searchTerm: this.searchTerm, + status: this.statusFilter, + projectPath: this.projectPath, + issueTypes: ['INCIDENT'], + sort: this.sort, + firstPageSize: this.pagination.firstPageSize, + lastPageSize: this.pagination.lastPageSize, + prevPageCursor: this.pagination.prevPageCursor, + nextPageCursor: this.pagination.nextPageCursor, + }; + }, + update({ project: { issues: { nodes = [], pageInfo = {} } = {} } = {} }) { + return { + list: nodes, + pageInfo, + }; + }, + error() { + this.errored = true; + }, + }, + incidentsCount: { + query: getIncidentsCountByStatus, + variables() { + return { + searchTerm: this.searchTerm, + projectPath: this.projectPath, + issueTypes: ['INCIDENT'], + }; + }, + update(data) { + return data.project?.issueStatusCounts; + }, + }, + }, + data() { + return { + errored: false, + isErrorAlertDismissed: false, + redirecting: false, + searchTerm: '', + pagination: initialPaginationState, + incidents: {}, + sort: 'created_desc', + sortBy: 'createdAt', + sortDesc: true, + statusFilter: '', + filteredByStatus: '', + }; + }, + computed: { + showErrorMsg() { + return this.errored && !this.isErrorAlertDismissed; + }, + loading() { + return this.$apollo.queries.incidents.loading; + }, + hasIncidents() { + return this.incidents?.list?.length; + }, + incidentsForCurrentTab() { + return this.incidentsCount?.[this.filteredByStatus.toLowerCase()] ?? 0; + }, + showPaginationControls() { + return Boolean( + this.incidents?.pageInfo?.hasNextPage || this.incidents?.pageInfo?.hasPreviousPage, + ); + }, + prevPage() { + return Math.max(this.pagination.currentPage - 1, 0); + }, + nextPage() { + const nextPage = this.pagination.currentPage + 1; + return nextPage > Math.ceil(this.incidentsForCurrentTab / DEFAULT_PAGE_SIZE) + ? null + : nextPage; + }, + tbodyTrClass() { + return { + [bodyTrClass]: !this.loading && this.hasIncidents, + }; + }, + newIncidentPath() { + return mergeUrlParams( + { + issuable_template: this.incidentTemplateName, + 'issue[issue_type]': this.incidentType, + }, + this.newIssuePath, + ); + }, + availableFields() { + return this.publishedAvailable + ? [ + ...this.$options.fields, + ...[ + { + key: 'published', + label: s__('IncidentManagement|Published'), + thClass: 'gl-pointer-events-none', + }, + ], + ] + : this.$options.fields; + }, + isEmpty() { + return !this.incidents.list?.length; + }, + }, + methods: { + onInputChange: debounce(function debounceSearch(input) { + const trimmedInput = input.trim(); + if (trimmedInput !== this.searchTerm) { + this.searchTerm = trimmedInput; + } + }, INCIDENT_SEARCH_DELAY), + filterIncidentsByStatus(tabIndex) { + const { filters, status } = this.$options.statusTabs[tabIndex]; + this.statusFilter = filters; + this.filteredByStatus = status; + }, + hasAssignees(assignees) { + return Boolean(assignees.nodes?.length); + }, + navigateToIncidentDetails({ iid }) { + return visitUrl(joinPaths(this.issuePath, iid)); + }, + handlePageChange(page) { + const { startCursor, endCursor } = this.incidents.pageInfo; + + if (page > this.pagination.currentPage) { + this.pagination = { + ...initialPaginationState, + nextPageCursor: endCursor, + currentPage: page, + }; + } else { + this.pagination = { + lastPageSize: DEFAULT_PAGE_SIZE, + firstPageSize: null, + prevPageCursor: startCursor, + nextPageCursor: '', + currentPage: page, + }; + } + }, + resetPagination() { + this.pagination = initialPaginationState; + }, + fetchSortedData({ sortBy, sortDesc }) { + const sortingDirection = sortDesc ? 'desc' : 'asc'; + const sortingColumn = convertToSnakeCase(sortBy).replace(/_.*/, ''); + + this.sort = `${sortingColumn}_${sortingDirection}`; + }, + }, +}; +</script> +<template> + <div class="incident-management-list"> + <gl-alert v-if="showErrorMsg" variant="danger" @dismiss="isErrorAlertDismissed = true"> + {{ $options.i18n.errorMsg }} + </gl-alert> + + <div + class="incident-management-list-header gl-display-flex gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-gray-100" + > + <gl-tabs content-class="gl-p-0" @input="filterIncidentsByStatus"> + <gl-tab v-for="tab in $options.statusTabs" :key="tab.status" :data-testid="tab.status"> + <template #title> + <span>{{ tab.title }}</span> + <gl-badge v-if="incidentsCount" pill size="sm" class="gl-tab-counter-badge"> + {{ incidentsCount[tab.status.toLowerCase()] }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + + <gl-button + v-if="!isEmpty" + class="gl-my-3 gl-mr-5 create-incident-button" + data-testid="createIncidentBtn" + data-qa-selector="create_incident_button" + :loading="redirecting" + :disabled="redirecting" + category="primary" + variant="success" + :href="newIncidentPath" + @click="redirecting = true" + > + {{ $options.i18n.createIncidentBtnLabel }} + </gl-button> + </div> + + <div class="gl-bg-gray-10 gl-p-5 gl-border-b-solid gl-border-b-1 gl-border-gray-100"> + <gl-search-box-by-type + :value="searchTerm" + class="gl-bg-white" + :placeholder="$options.i18n.searchPlaceholder" + @input="onInputChange" + /> + </div> + + <h4 class="gl-display-block d-md-none my-3"> + {{ s__('IncidentManagement|Incidents') }} + </h4> + <gl-table + :items="incidents.list || []" + :fields="availableFields" + :show-empty="true" + :busy="loading" + stacked="md" + :tbody-tr-class="tbodyTrClass" + :no-local-sorting="true" + :sort-direction="'desc'" + :sort-desc.sync="sortDesc" + :sort-by.sync="sortBy" + sort-icon-left + fixed + @row-clicked="navigateToIncidentDetails" + @sort-changed="fetchSortedData" + > + <template #cell(title)="{ item }"> + <div :class="{ 'gl-display-flex gl-align-items-center': item.state === 'closed' }"> + <div class="gl-max-w-full text-truncate" :title="item.title">{{ item.title }}</div> + <gl-icon + v-if="item.state === 'closed'" + name="issue-close" + class="gl-mx-1 gl-fill-blue-500 gl-flex-shrink-0" + :size="16" + data-testid="incident-closed" + /> + </div> + </template> + + <template #cell(createdAt)="{ item }"> + <time-ago-tooltip :time="item.createdAt" /> + </template> + + <template #cell(assignees)="{ item }"> + <div data-testid="incident-assignees"> + <template v-if="hasAssignees(item.assignees)"> + <gl-avatars-inline + :avatars="item.assignees.nodes" + :collapsed="true" + :max-visible="4" + :avatar-size="24" + badge-tooltip-prop="name" + :badge-tooltip-max-chars="100" + > + <template #avatar="{ avatar }"> + <gl-avatar-link + :key="avatar.username" + v-gl-tooltip + target="_blank" + :href="avatar.webUrl" + :title="avatar.name" + > + <gl-avatar :src="avatar.avatarUrl" :label="avatar.name" :size="24" /> + </gl-avatar-link> + </template> + </gl-avatars-inline> + </template> + <template v-else> + {{ $options.i18n.unassigned }} + </template> + </div> + </template> + + <template v-if="publishedAvailable" #cell(published)="{ item }"> + <published-cell + :status-page-published-incident="item.statusPagePublishedIncident" + :un-published="$options.i18n.unPublished" + /> + </template> + <template #table-busy> + <gl-loading-icon size="lg" color="dark" class="mt-3" /> + </template> + + <template #empty> + <gl-empty-state + v-if="!errored" + :title="$options.i18n.emptyState.title" + :svg-path="emptyListSvgPath" + :description="$options.i18n.emptyState.description" + :primary-button-link="newIncidentPath" + :primary-button-text="$options.i18n.createIncidentBtnLabel" + /> + <span v-else> + {{ $options.i18n.noIncidents }} + </span> + </template> + </gl-table> + + <gl-pagination + v-if="showPaginationControls" + :value="pagination.currentPage" + :prev-page="prevPage" + :next-page="nextPage" + align="center" + class="gl-pagination gl-mt-3" + @input="handlePageChange" + /> + </div> +</template> diff --git a/app/assets/javascripts/incidents/constants.js b/app/assets/javascripts/incidents/constants.js new file mode 100644 index 00000000000..02d8172533d --- /dev/null +++ b/app/assets/javascripts/incidents/constants.js @@ -0,0 +1,37 @@ +import { s__, __ } from '~/locale'; + +export const I18N = { + errorMsg: s__('IncidentManagement|There was an error displaying the incidents.'), + noIncidents: s__('IncidentManagement|No incidents to display.'), + unassigned: s__('IncidentManagement|Unassigned'), + createIncidentBtnLabel: s__('IncidentManagement|Create incident'), + unPublished: s__('IncidentManagement|Unpublished'), + searchPlaceholder: __('Search results…'), + emptyState: { + title: s__('IncidentManagement|Display your incidents in a dedicated view'), + description: s__( + 'IncidentManagement|All alerts promoted to incidents will automatically be displayed within the list. You can also create a new incident using the button below.', + ), + }, +}; + +export const INCIDENT_STATUS_TABS = [ + { + title: s__('IncidentManagement|Open'), + status: 'OPENED', + filters: 'opened', + }, + { + title: s__('IncidentManagement|Closed'), + status: 'CLOSED', + filters: 'closed', + }, + { + title: s__('IncidentManagement|All'), + status: 'ALL', + filters: 'all', + }, +]; + +export const INCIDENT_SEARCH_DELAY = 300; +export const DEFAULT_PAGE_SIZE = 10; diff --git a/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql new file mode 100644 index 00000000000..0b784b104a8 --- /dev/null +++ b/app/assets/javascripts/incidents/graphql/queries/get_count_by_status.query.graphql @@ -0,0 +1,9 @@ +query getIncidentsCountByStatus($searchTerm: String, $projectPath: ID!, $issueTypes: [IssueType!]) { + project(fullPath: $projectPath) { + issueStatusCounts(search: $searchTerm, types: $issueTypes) { + all + opened + closed + } + } +} diff --git a/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql new file mode 100644 index 00000000000..0f56e8640bd --- /dev/null +++ b/app/assets/javascripts/incidents/graphql/queries/get_incidents.query.graphql @@ -0,0 +1,52 @@ +query getIncidents( + $projectPath: ID! + $issueTypes: [IssueType!] + $sort: IssueSort + $status: IssuableState + $firstPageSize: Int + $lastPageSize: Int + $prevPageCursor: String = "" + $nextPageCursor: String = "" + $searchTerm: String +) { + project(fullPath: $projectPath) { + issues( + search: $searchTerm + types: $issueTypes + sort: $sort + state: $status + first: $firstPageSize + last: $lastPageSize + after: $nextPageCursor + before: $prevPageCursor + ) { + nodes { + iid + title + createdAt + state + labels { + nodes { + title + color + } + } + assignees { + nodes { + name + username + avatarUrl + webUrl + } + } + statusPagePublishedIncident + } + pageInfo { + hasNextPage + endCursor + hasPreviousPage + startCursor + } + } + } +} diff --git a/app/assets/javascripts/incidents/list.js b/app/assets/javascripts/incidents/list.js new file mode 100644 index 00000000000..7505d07449c --- /dev/null +++ b/app/assets/javascripts/incidents/list.js @@ -0,0 +1,44 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import IncidentsList from './components/incidents_list.vue'; + +Vue.use(VueApollo); +export default () => { + const selector = '#js-incidents'; + + const domEl = document.querySelector(selector); + const { + projectPath, + newIssuePath, + incidentTemplateName, + incidentType, + issuePath, + publishedAvailable, + emptyListSvgPath, + } = domEl.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el: selector, + provide: { + projectPath, + incidentTemplateName, + incidentType, + newIssuePath, + issuePath, + publishedAvailable, + emptyListSvgPath, + }, + apolloProvider, + components: { + IncidentsList, + }, + render(createElement) { + return createElement('incidents-list'); + }, + }); +}; diff --git a/app/assets/javascripts/incidents_settings/components/alerts_form.vue b/app/assets/javascripts/incidents_settings/components/alerts_form.vue index a394f404ee1..5872ac39c96 100644 --- a/app/assets/javascripts/incidents_settings/components/alerts_form.vue +++ b/app/assets/javascripts/incidents_settings/components/alerts_form.vue @@ -123,17 +123,18 @@ export default { <span>{{ $options.i18n.sendEmail.label }}</span> </gl-form-checkbox> </gl-form-group> - - <gl-button - ref="submitBtn" - data-qa-selector="save_changes_button" - :disabled="loading" - variant="success" - type="submit" - class="js-no-auto-disable" - > - {{ $options.i18n.saveBtnLabel }} - </gl-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + ref="submitBtn" + data-qa-selector="save_changes_button" + :disabled="loading" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> + </div> </form> </div> </template> diff --git a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue index 0623c275c5a..d6e963c6f4f 100644 --- a/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue +++ b/app/assets/javascripts/incidents_settings/components/incidents_settings_tabs.vue @@ -2,7 +2,6 @@ import { GlButton, GlTabs, GlTab } from '@gitlab/ui'; import AlertsSettingsForm from './alerts_form.vue'; import PagerDutySettingsForm from './pagerduty_form.vue'; -import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { INTEGRATION_TABS_CONFIG, I18N_INTEGRATION_TABS } from '../constants'; export default { @@ -13,17 +12,8 @@ export default { AlertsSettingsForm, PagerDutySettingsForm, }, - mixins: [glFeatureFlagMixin()], tabs: INTEGRATION_TABS_CONFIG, i18n: I18N_INTEGRATION_TABS, - methods: { - isFeatureFlagEnabled(tab) { - if (tab.featureFlag) { - return this.glFeatures[tab.featureFlag]; - } - return true; - }, - }, }; </script> @@ -34,9 +24,9 @@ export default { class="settings no-animate qa-incident-management-settings" > <div class="settings-header"> - <h3 ref="sectionHeader" class="h4"> + <h4 ref="sectionHeader" class="gl-my-3! gl-py-1"> {{ $options.i18n.headerText }} - </h3> + </h4> <gl-button ref="toggleBtn" class="js-settings-toggle">{{ $options.i18n.expandBtnLabel }}</gl-button> @@ -49,7 +39,7 @@ export default { <gl-tabs> <gl-tab v-for="(tab, index) in $options.tabs" - v-if="tab.active && isFeatureFlagEnabled(tab)" + v-if="tab.active" :key="`${tab.title}_${index}`" :title="tab.title" > diff --git a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue index 027848db6e9..8b608d9f391 100644 --- a/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue +++ b/app/assets/javascripts/incidents_settings/components/pagerduty_form.vue @@ -11,9 +11,9 @@ import { GlModal, GlModalDirective, } from '@gitlab/ui'; +import { isEqual } from 'lodash'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { I18N_PAGERDUTY_SETTINGS_FORM, CONFIGURE_PAGERDUTY_WEBHOOK_DOCS_LINK } from '../constants'; -import { isEqual } from 'lodash'; export default { components: { @@ -135,7 +135,7 @@ export default { </template> </gl-form-input-group> - <div class="gl-text-gray-400 gl-pt-2"> + <div class="gl-text-gray-200 gl-pt-2"> <gl-sprintf :message="$options.i18n.webhookUrl.helpText"> <template #docsLink> <gl-link @@ -149,15 +149,17 @@ export default { </template> </gl-sprintf> </div> - <gl-button - v-gl-modal.resetWebhookModal - class="gl-mt-3" - :disabled="loading" - :loading="resettingWebhook" - data-testid="webhook-reset-btn" - > - {{ $options.i18n.webhookUrl.resetWebhookUrl }} - </gl-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + v-gl-modal.resetWebhookModal + class="gl-mt-3" + :disabled="loading" + :loading="resettingWebhook" + data-testid="webhook-reset-btn" + > + {{ $options.i18n.webhookUrl.resetWebhookUrl }} + </gl-button> + </div> <gl-modal modal-id="resetWebhookModal" :title="$options.i18n.webhookUrl.resetWebhookUrl" @@ -168,16 +170,17 @@ export default { {{ $options.i18n.webhookUrl.restKeyInfo }} </gl-modal> </gl-form-group> - - <gl-button - ref="submitBtn" - :disabled="isSaveDisabled" - variant="success" - type="submit" - class="js-no-auto-disable" - > - {{ $options.i18n.saveBtnLabel }} - </gl-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button + ref="submitBtn" + :disabled="isSaveDisabled" + variant="success" + type="submit" + class="js-no-auto-disable" + > + {{ $options.i18n.saveBtnLabel }} + </gl-button> + </div> </form> </div> </template> diff --git a/app/assets/javascripts/incidents_settings/constants.js b/app/assets/javascripts/incidents_settings/constants.js index b443c237f0f..77f7ee2c4a3 100644 --- a/app/assets/javascripts/incidents_settings/constants.js +++ b/app/assets/javascripts/incidents_settings/constants.js @@ -11,7 +11,6 @@ export const INTEGRATION_TABS_CONFIG = [ title: s__('IncidentSettings|PagerDuty integration'), component: 'PagerDutySettingsForm', active: true, - featureFlag: 'pagerdutyWebhook', }, { title: s__('IncidentSettings|Grafana integration'), @@ -47,7 +46,7 @@ export const I18N_ALERT_SETTINGS_FORM = { export const NO_ISSUE_TEMPLATE_SELECTED = { key: '', name: __('No template selected') }; export const TAKING_INCIDENT_ACTION_DOCS_LINK = - '/help/user/project/integrations/prometheus#taking-action-on-incidents-ultimate'; + '/help/operations/metrics/alerts#trigger-actions-from-alerts'; export const ISSUE_TEMPLATES_DOCS_LINK = '/help/user/project/description_templates#creating-issue-templates'; diff --git a/app/assets/javascripts/incidents_settings/incidents_settings_service.js b/app/assets/javascripts/incidents_settings/incidents_settings_service.js index bd4f5bb8820..f0e692f9cbe 100644 --- a/app/assets/javascripts/incidents_settings/incidents_settings_service.js +++ b/app/assets/javascripts/incidents_settings/incidents_settings_service.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { ERROR_MSG } from './constants'; export default class IncidentsSettingsService { diff --git a/app/assets/javascripts/integrations/edit/components/active_toggle.vue b/app/assets/javascripts/integrations/edit/components/active_toggle.vue index a3087c8958e..e6a96600539 100644 --- a/app/assets/javascripts/integrations/edit/components/active_toggle.vue +++ b/app/assets/javascripts/integrations/edit/components/active_toggle.vue @@ -1,8 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import eventHub from '../event_hub'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { GlFormGroup, GlToggle } from '@gitlab/ui'; +import eventHub from '../event_hub'; export default { name: 'ActiveToggle', @@ -10,7 +9,6 @@ export default { GlFormGroup, GlToggle, }, - mixins: [glFeatureFlagsMixin()], props: { initialActivated: { type: Boolean, @@ -40,28 +38,13 @@ export default { </script> <template> - <div v-if="glFeatures.integrationFormRefactor"> - <gl-form-group :label="__('Enable integration')" label-for="service[active]"> - <gl-toggle - v-model="activated" - name="service[active]" - class="gl-display-block gl-line-height-0" - :disabled="isInheriting" - @change="onToggle" - /> - </gl-form-group> - </div> - <div v-else> - <div class="form-group row" role="group"> - <label for="service[active]" class="col-form-label col-sm-2">{{ __('Active') }}</label> - <div class="col-sm-10 pt-1"> - <gl-toggle - v-model="activated" - name="service[active]" - :disabled="isInheriting" - @change="onToggle" - /> - </div> - </div> - </div> + <gl-form-group :label="__('Enable integration')" label-for="service[active]"> + <gl-toggle + v-model="activated" + name="service[active]" + class="gl-display-block gl-line-height-0" + :disabled="isInheriting" + @change="onToggle" + /> + </gl-form-group> </template> diff --git a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue index 6053d11e6da..090381b8da4 100644 --- a/app/assets/javascripts/integrations/edit/components/dynamic_field.vue +++ b/app/assets/javascripts/integrations/edit/components/dynamic_field.vue @@ -1,9 +1,9 @@ <script> import { mapGetters } from 'vuex'; -import eventHub from '../event_hub'; import { capitalize, lowerCase, isEmpty } from 'lodash'; -import { __, sprintf } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; +import eventHub from '../event_hub'; +import { __, sprintf } from '~/locale'; export default { name: 'DynamicField', diff --git a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue index 5444cd5a712..5a1f86718b0 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_issues_fields.vue @@ -1,5 +1,4 @@ <script> -import eventHub from '../event_hub'; import { GlFormGroup, GlFormCheckbox, @@ -9,6 +8,7 @@ import { GlButton, GlCard, } from '@gitlab/ui'; +import eventHub from '../event_hub'; export default { name: 'JiraIssuesFields', diff --git a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue index 1d3354c6651..08f24ce8ab6 100644 --- a/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/jira_trigger_fields.vue @@ -1,8 +1,7 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { mapGetters } from 'vuex'; -import { s__ } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormRadio } from '@gitlab/ui'; +import { s__ } from '~/locale'; const commentDetailOptions = [ { @@ -26,7 +25,6 @@ export default { GlFormCheckbox, GlFormRadio, }, - mixins: [glFeatureFlagsMixin()], props: { initialTriggerCommit: { type: Boolean, @@ -65,7 +63,7 @@ export default { </script> <template> - <div v-if="glFeatures.integrationFormRefactor"> + <div> <gl-form-group :label="__('Trigger')" label-for="service[trigger]" @@ -130,73 +128,4 @@ export default { </gl-form-radio> </gl-form-group> </div> - - <div v-else class="form-group row pt-2" role="group"> - <label for="service[trigger]" class="col-form-label col-sm-2 pt-0">{{ __('Trigger') }}</label> - <div class="col-sm-10"> - <label class="weight-normal mb-2"> - {{ - s__( - 'Integrations|When a Jira issue is mentioned in a commit or merge request a remote link and comment (if enabled) will be created.', - ) - }} - </label> - - <input name="service[commit_events]" type="hidden" :value="triggerCommit || false" /> - <gl-form-checkbox v-model="triggerCommit" :disabled="isInheriting"> - {{ __('Commit') }} - </gl-form-checkbox> - - <input - name="service[merge_requests_events]" - type="hidden" - :value="triggerMergeRequest || false" - /> - <gl-form-checkbox v-model="triggerMergeRequest" :disabled="isInheriting"> - {{ __('Merge request') }} - </gl-form-checkbox> - - <div - v-show="triggerCommit || triggerMergeRequest" - class="mt-4" - data-testid="comment-settings" - > - <label> - {{ s__('Integrations|Comment settings:') }} - </label> - <input - name="service[comment_on_event_enabled]" - type="hidden" - :value="enableComments || false" - /> - <gl-form-checkbox v-model="enableComments" :disabled="isInheriting"> - {{ s__('Integrations|Enable comments') }} - </gl-form-checkbox> - - <div v-show="enableComments" class="mt-4" data-testid="comment-detail"> - <label> - {{ s__('Integrations|Comment detail:') }} - </label> - <input - v-if="isInheriting" - name="service[comment_detail]" - type="hidden" - :value="commentDetail" - /> - <gl-form-radio - v-for="commentDetailOption in commentDetailOptions" - :key="commentDetailOption.value" - v-model="commentDetail" - :value="commentDetailOption.value" - :disabled="isInheriting" - > - {{ commentDetailOption.label }} - <template #help> - {{ commentDetailOption.help }} - </template> - </gl-form-radio> - </div> - </div> - </div> - </div> </template> diff --git a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue index 0ae2f267434..accfc26974c 100644 --- a/app/assets/javascripts/integrations/edit/components/override_dropdown.vue +++ b/app/assets/javascripts/integrations/edit/components/override_dropdown.vue @@ -1,11 +1,11 @@ <script> -import { s__ } from '~/locale'; import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; const dropdownOptions = [ { value: false, - text: s__('Integrations|Use instance level settings'), + text: s__('Integrations|Use default settings'), }, { value: true, @@ -48,7 +48,7 @@ export default { <div class="gl-display-flex gl-justify-content-space-between gl-align-items-baseline gl-py-4 gl-mt-5 gl-mb-6 gl-border-t-1 gl-border-t-solid gl-border-b-1 gl-border-b-solid gl-border-gray-100" > - <span>{{ s__('Integrations|This integration has multiple settings available.') }}</span> + <span>{{ s__('Integrations|Default settings are inherited from the instance level.') }}</span> <input name="service[inherit_from_id]" :value="override ? '' : inheritFromId" type="hidden" /> <gl-new-dropdown :text="selected.text"> <gl-new-dropdown-item diff --git a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue index bb1e0d9d360..32878c6afa4 100644 --- a/app/assets/javascripts/integrations/edit/components/trigger_fields.vue +++ b/app/assets/javascripts/integrations/edit/components/trigger_fields.vue @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; import { startCase } from 'lodash'; -import { __ } from '~/locale'; import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; +import { __ } from '~/locale'; const typeWithPlaceholder = { SLACK: 'slack', diff --git a/app/assets/javascripts/integrations/integration_settings_form.js b/app/assets/javascripts/integrations/integration_settings_form.js index 837409a91ca..1135065b06c 100644 --- a/app/assets/javascripts/integrations/integration_settings_form.js +++ b/app/assets/javascripts/integrations/integration_settings_form.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import axios from '../lib/utils/axios_utils'; -import flash from '../flash'; +import { deprecatedCreateFlash as flash } from '../flash'; import { __ } from '~/locale'; import initForm from './edit'; import eventHub from './edit/event_hub'; diff --git a/app/assets/javascripts/issuable_bulk_update_actions.js b/app/assets/javascripts/issuable_bulk_update_actions.js index d968e9e5235..c7806fc17fc 100644 --- a/app/assets/javascripts/issuable_bulk_update_actions.js +++ b/app/assets/javascripts/issuable_bulk_update_actions.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { difference, intersection, union } from 'lodash'; import axios from './lib/utils/axios_utils'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; import { __ } from './locale'; export default { diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index cf780556c8d..2dcf5e6a0d6 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -48,7 +48,19 @@ export default class IssuableForm { this.renderWipExplanation = this.renderWipExplanation.bind(this); this.resetAutosave = this.resetAutosave.bind(this); this.handleSubmit = this.handleSubmit.bind(this); - this.wipRegex = /^\s*(\[WIP\]\s*|WIP:\s*|WIP\s+)+\s*/i; + /* eslint-disable @gitlab/require-i18n-strings */ + this.wipRegex = new RegExp( + '^\\s*(' + // Line start, then any amount of leading whitespace + 'draft\\s-\\s' + // Draft_-_ where "_" are *exactly* one whitespace + '|\\[(draft|wip)\\]\\s*' + // [Draft] or [WIP] and any following whitespace + '|(draft|wip):\\s*' + // Draft: or WIP: and any following whitespace + '|(draft|wip)\\s+' + // Draft_ or WIP_ where "_" is at least one whitespace + '|\\(draft\\)\\s*' + // (Draft) and any following whitespace + ')+' + // At least one repeated match of the preceding parenthetical + '\\s*', // Any amount of trailing whitespace + 'i', // Match any case(s) + ); + /* eslint-enable @gitlab/require-i18n-strings */ this.gfmAutoComplete = new GfmAutoComplete( gl.GfmAutoComplete && gl.GfmAutoComplete.dataSources, @@ -131,9 +143,18 @@ export default class IssuableForm { workInProgress() { return this.wipRegex.test(this.titleField.val()); } + titlePrefixContainsDraft() { + const prefix = this.titleField.val().match(this.wipRegex); + + return prefix && prefix[0].match(/draft/i); + } renderWipExplanation() { if (this.workInProgress()) { + // These strings are not "translatable" (the code is hard-coded to look for them) + this.$wipExplanation.find('code')[0].textContent = this.titlePrefixContainsDraft() + ? 'Draft' /* eslint-disable-line @gitlab/require-i18n-strings */ + : 'WIP'; this.$wipExplanation.show(); return this.$noWipExplanation.hide(); } @@ -156,7 +177,7 @@ export default class IssuableForm { } addWip() { - this.titleField.val(`WIP: ${this.titleField.val()}`); + this.titleField.val(`Draft: ${this.titleField.val()}`); } initTargetBranchDropdown() { diff --git a/app/assets/javascripts/issuable_index.js b/app/assets/javascripts/issuable_index.js index f3f8b6ec715..e888e481fe5 100644 --- a/app/assets/javascripts/issuable_index.js +++ b/app/assets/javascripts/issuable_index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import { s__, __ } from './locale'; import issuableInitBulkUpdateSidebar from './issuable_init_bulk_update_sidebar'; diff --git a/app/assets/javascripts/issuables_list/components/issuable.vue b/app/assets/javascripts/issuables_list/components/issuable.vue index b7f4292a126..4fc614f8da4 100644 --- a/app/assets/javascripts/issuables_list/components/issuable.vue +++ b/app/assets/javascripts/issuables_list/components/issuable.vue @@ -23,6 +23,8 @@ import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import { isScopedLabel } from '~/lib/utils/common_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { convertToCamelCase } from '~/lib/utils/text_utility'; + export default { i18n: { openedAgo: __('opened %{timeAgoString} by %{user}'), @@ -34,6 +36,8 @@ export default { GlLabel, GlIcon, GlSprintf, + IssueHealthStatus: () => + import('ee_component/related_items_tree/components/issue_health_status.vue'), }, directives: { GlTooltip, @@ -85,9 +89,6 @@ export default { dueDateWords() { return this.dueDate ? dateInWords(this.dueDate, true) : undefined; }, - hasNoComments() { - return !this.userNotesCount; - }, isOverdue() { return this.dueDate ? this.dueDate < new Date() : false; }, @@ -148,34 +149,59 @@ export default { time_ago: escape(getTimeago().format(this.issuable.updated_at)), }); }, - userNotesCount() { - return this.issuable.user_notes_count; - }, issuableMeta() { return [ { key: 'merge-requests', + visible: this.issuable.merge_requests_count > 0, value: this.issuable.merge_requests_count, title: __('Related merge requests'), - class: 'js-merge-requests', + dataTestId: 'merge-requests', + class: 'js-merge-requests icon-merge-request-unmerged', icon: 'merge-request', }, { key: 'upvotes', + visible: this.issuable.upvotes > 0, value: this.issuable.upvotes, title: __('Upvotes'), - class: 'js-upvotes', + dataTestId: 'upvotes', + class: 'js-upvotes issuable-upvotes', icon: 'thumb-up', }, { key: 'downvotes', + visible: this.issuable.downvotes > 0, value: this.issuable.downvotes, title: __('Downvotes'), - class: 'js-downvotes', + dataTestId: 'downvotes', + class: 'js-downvotes issuable-downvotes', icon: 'thumb-down', }, + { + key: 'blocking-issues', + visible: this.issuable.blocking_issues_count > 0, + value: this.issuable.blocking_issues_count, + title: __('Blocking issues'), + dataTestId: 'blocking-issues', + href: `${this.issuable.web_url}#related-issues`, + icon: 'issue-block', + }, + { + key: 'comments-count', + visible: !this.isJiraIssue, + value: this.issuable.user_notes_count, + title: __('Comments'), + dataTestId: 'notes-count', + href: `${this.issuable.web_url}#notes`, + class: { 'no-comments': !this.issuable.user_notes_count, 'issuable-comments': true }, + icon: 'comments', + }, ]; }, + healthStatus() { + return convertToCamelCase(this.issuable.health_status); + }, }, mounted() { // TODO: Refactor user popover to use its own component instead of @@ -202,6 +228,9 @@ export default { selected: ev.target.checked, }); }, + issuableMetaComponent(href) { + return href ? 'gl-link' : 'span'; + }, }, confidentialTooltipText: __('Confidential'), @@ -215,11 +244,14 @@ export default { :data-id="issuable.id" :data-labels="labelIdsString" :data-url="issuable.web_url" + data-qa-selector="issue_container" + :data-qa-issue-title="issuable.title" > - <div class="d-flex"> + <div class="gl-display-flex"> <!-- Bulk edit checkbox --> - <div v-if="isBulkEditing" class="mr-2"> + <div v-if="isBulkEditing" class="gl-mr-3"> <input + :id="`selected_issue_${issuable.id}`" :checked="selected" class="selected-issuable" type="checkbox" @@ -230,7 +262,7 @@ export default { <!-- Issuable info container --> <!-- Issuable main info --> - <div class="flex-grow-1"> + <div class="gl-flex-grow-1"> <div class="title"> <span class="issue-title-text"> <gl-icon @@ -242,22 +274,28 @@ export default { :title="$options.confidentialTooltipText" :aria-label="$options.confidentialTooltipText" /> - <gl-link :href="issuable.web_url" :target="linkTarget" data-testid="issuable-title"> - {{ issuable.title }} - <gl-icon + <gl-link + :href="issuable.web_url" + :target="linkTarget" + data-testid="issuable-title" + data-qa-selector="issue_link" + >{{ issuable.title + }}<gl-icon v-if="isJiraIssue" name="external-link" - class="gl-vertical-align-text-bottom" + class="gl-vertical-align-text-bottom gl-ml-2" /> </gl-link> </span> - <span v-if="issuable.has_tasks" class="ml-1 task-status d-none d-sm-inline-block"> - {{ issuable.task_status }} - </span> + <span + v-if="issuable.has_tasks" + class="gl-ml-2 task-status gl-display-none d-sm-inline-block" + >{{ issuable.task_status }}</span + > </div> <div class="issuable-info"> - <span class="js-ref-path"> + <span class="js-ref-path gl-mr-4 mr-sm-0"> <span v-if="isJiraIssue" class="svg-container jira-logo-container" @@ -267,7 +305,7 @@ export default { {{ referencePath }} </span> - <span data-testid="openedByMessage" class="d-none d-sm-inline-block mr-1"> + <span data-testid="openedByMessage" class="gl-display-none d-sm-inline-block gl-mr-4"> · <gl-sprintf :message="isJiraIssue ? $options.i18n.openedAgoJira : $options.i18n.openedAgo" @@ -281,9 +319,8 @@ export default { v-bind="popoverDataAttrs" :href="issuableAuthor.web_url" :target="linkTarget" + >{{ issuableAuthor.name }}</gl-link > - {{ issuableAuthor.name }} - </gl-link> </template> </gl-sprintf> </span> @@ -291,18 +328,18 @@ export default { <gl-link v-if="issuable.milestone" v-gl-tooltip - class="d-none d-sm-inline-block mr-1 js-milestone" + class="gl-display-none d-sm-inline-block gl-mr-4 js-milestone milestone" :href="milestoneLink" :title="milestoneTooltipText" > - <i class="fa fa-clock-o"></i> + <gl-icon name="clock" class="s16 gl-vertical-align-text-bottom" /> {{ issuable.milestone.title }} </gl-link> <span v-if="dueDate" v-gl-tooltip - class="d-none d-sm-inline-block mr-1 js-due-date" + class="gl-display-none d-sm-inline-block gl-mr-4 js-due-date" :class="{ cred: isOverdue }" :title="__('Due date')" > @@ -310,6 +347,24 @@ export default { {{ dueDateWords }} </span> + <span + v-if="hasWeight" + v-gl-tooltip + :title="__('Weight')" + class="gl-display-none d-sm-inline-block gl-mr-4" + data-testid="weight" + data-qa-selector="issuable_weight_content" + > + <gl-icon name="weight" class="align-text-bottom" /> + {{ issuable.weight }} + </span> + + <issue-health-status + v-if="issuable.health_status" + :health-status="healthStatus" + class="gl-mr-4 issuable-tag-valign" + /> + <gl-label v-for="label in issuable.labels" :key="label.id" @@ -321,61 +376,44 @@ export default { :title="label.name" :scoped="isScoped(label)" size="sm" - class="mr-1" + class="gl-mr-2 issuable-tag-valign" >{{ label.name }}</gl-label > - - <span - v-if="hasWeight" - v-gl-tooltip - :title="__('Weight')" - class="d-none d-sm-inline-block js-weight" - data-testid="weight" - > - <gl-icon name="weight" class="align-text-bottom" /> - {{ issuable.weight }} - </span> </div> </div> <!-- Issuable meta --> - <div class="flex-shrink-0 d-flex flex-column align-items-end justify-content-center"> - <div class="controls d-flex"> + <div + class="gl-flex-shrink-0 gl-display-flex gl-flex-direction-column align-items-end gl-justify-content-center" + > + <div class="controls gl-display-flex"> <span v-if="isJiraIssue" data-testid="issuable-status">{{ issuable.status }}</span> <span v-else-if="isClosed" class="issuable-status">{{ __('CLOSED') }}</span> <issue-assignees :assignees="issuable.assignees" - class="align-items-center d-flex ml-2" + class="gl-align-items-center gl-display-flex gl-ml-3" :icon-size="16" - img-css-classes="mr-1" + img-css-classes="gl-mr-2!" :max-visible="4" /> <template v-for="meta in issuableMeta"> <span - v-if="meta.value" + v-if="meta.visible" :key="meta.key" v-gl-tooltip - :class="['d-none d-sm-inline-block ml-2 vertical-align-middle', meta.class]" + class="gl-display-none gl-display-sm-flex gl-align-items-center gl-ml-3" + :class="meta.class" + :data-testid="meta.dataTestId" :title="meta.title" > - <gl-icon v-if="meta.icon" :name="meta.icon" /> - {{ meta.value }} + <component :is="issuableMetaComponent(meta.href)" :href="meta.href"> + <gl-icon v-if="meta.icon" :name="meta.icon" /> + {{ meta.value }} + </component> </span> </template> - - <gl-link - v-if="!isJiraIssue" - v-gl-tooltip - class="ml-2 js-notes" - :href="`${issuable.web_url}#notes`" - :title="__('Comments')" - :class="{ 'no-comments': hasNoComments }" - > - <gl-icon name="comments" class="gl-vertical-align-text-bottom" /> - {{ userNotesCount }} - </gl-link> </div> <div v-gl-tooltip class="issuable-updated-at" :title="updatedDateString"> {{ updatedDateAgo }} diff --git a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue index 21aeb2ca143..fecb7353efb 100644 --- a/app/assets/javascripts/issuables_list/components/issuables_list_app.vue +++ b/app/assets/javascripts/issuables_list/components/issuables_list_app.vue @@ -1,7 +1,12 @@ <script> import { toNumber, omit } from 'lodash'; -import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; -import flash from '~/flash'; +import { + GlEmptyState, + GlPagination, + GlSkeletonLoading, + GlSafeHtmlDirective as SafeHtml, +} from '@gitlab/ui'; +import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { scrollToElement, @@ -23,9 +28,13 @@ import { } from '../constants'; import { setUrlParams } from '~/lib/utils/url_utility'; import issueableEventHub from '../eventhub'; +import { emptyStateHelper } from '../service_desk_helper'; export default { LOADING_LIST_ITEMS_LENGTH, + directives: { + SafeHtml, + }, components: { GlEmptyState, GlPagination, @@ -39,15 +48,9 @@ export default { required: false, default: false, }, - createIssuePath: { - type: String, - required: false, - default: '', - }, - emptySvgPath: { - type: String, - required: false, - default: '', + emptyStateMeta: { + type: Object, + required: true, }, endpoint: { type: String, @@ -94,26 +97,40 @@ export default { emptyState() { if (this.issuables.length) { return {}; // Empty state shouldn't be shown here - } else if (this.hasFilters) { + } + + if (this.isServiceDesk) { + return emptyStateHelper(this.emptyStateMeta); + } + + if (this.hasFilters) { return { title: __('Sorry, your filter produced no results'), + svgPath: this.emptyStateMeta.svgPath, description: __('To widen your search, change or remove filters above'), + primaryLink: this.emptyStateMeta.createIssuePath, + primaryText: __('New issue'), }; - } else if (this.filters.state === 'opened') { + } + + if (this.filters.state === 'opened') { return { title: __('There are no open issues'), + svgPath: this.emptyStateMeta.svgPath, description: __('To keep this project going, create a new issue'), - primaryLink: this.createIssuePath, + primaryLink: this.emptyStateMeta.createIssuePath, primaryText: __('New issue'), }; } else if (this.filters.state === 'closed') { return { title: __('There are no closed issues'), + svgPath: this.emptyStateMeta.svgPath, }; } return { title: __('There are no issues to show'), + svgPath: this.emptyStateMeta.svgPath, description: __( 'The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project.', ), @@ -155,6 +172,9 @@ export default { nextPage: this.paginationNext, }; }, + isServiceDesk() { + return this.type === 'service_desk'; + }, isJira() { return this.type === 'jira'; }, @@ -356,7 +376,13 @@ export default { </ul> <div v-else-if="issuables.length"> <div v-if="isBulkEditing" class="issue px-3 py-3 border-bottom border-light"> - <input type="checkbox" :checked="allIssuablesSelected" class="mr-2" @click="onSelectAll" /> + <input + id="check-all-issues" + type="checkbox" + :checked="allIssuablesSelected" + class="mr-2" + @click="onSelectAll" + /> <strong>{{ __('Select all') }}</strong> </div> <ul @@ -386,10 +412,13 @@ export default { <gl-empty-state v-else :title="emptyState.title" - :description="emptyState.description" - :svg-path="emptySvgPath" + :svg-path="emptyState.svgPath" :primary-button-link="emptyState.primaryLink" :primary-button-text="emptyState.primaryText" - /> + > + <template #description> + <div v-safe-html="emptyState.description"></div> + </template> + </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/issuables_list/index.js b/app/assets/javascripts/issuables_list/index.js index 40252c10d5f..fa23d6c0eed 100644 --- a/app/assets/javascripts/issuables_list/index.js +++ b/app/assets/javascripts/issuables_list/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import IssuableListRootApp from './components/issuable_list_root_app.vue'; import IssuablesListApp from './components/issuables_list_app.vue'; @@ -41,7 +41,7 @@ function mountIssuablesListApp() { } document.querySelectorAll('.js-issuables-list').forEach(el => { - const { canBulkEdit, ...data } = el.dataset; + const { canBulkEdit, emptyStateMeta = {}, ...data } = el.dataset; return new Vue({ el, @@ -49,6 +49,10 @@ function mountIssuablesListApp() { return createElement(IssuablesListApp, { props: { ...data, + emptyStateMeta: + Object.keys(emptyStateMeta).length !== 0 + ? convertObjectPropsToCamelCase(JSON.parse(emptyStateMeta)) + : {}, canBulkEdit: Boolean(canBulkEdit), }, }); diff --git a/app/assets/javascripts/issuables_list/service_desk_helper.js b/app/assets/javascripts/issuables_list/service_desk_helper.js new file mode 100644 index 00000000000..4b4a38c2205 --- /dev/null +++ b/app/assets/javascripts/issuables_list/service_desk_helper.js @@ -0,0 +1,55 @@ +import { __ } from '~/locale'; + +/** + * Returns the attributes used for gl-empty-state in the Service Desk issues list. + */ +// eslint-disable-next-line import/prefer-default-export +export function emptyStateHelper(emptyStateMeta) { + const { isServiceDeskSupported, svgPath, serviceDeskHelpPage } = emptyStateMeta; + + if (isServiceDeskSupported) { + const title = __( + 'Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab', + ); + const commonMessage = __( + 'Those emails automatically become issues (with the comments becoming the email conversation) listed here.', + ); + const commonDescription = ` + <span>${commonMessage}</span> + <a href="${serviceDeskHelpPage}">${__('Read more')}</a>`; + + if (emptyStateMeta.canEditProjectSettings && emptyStateMeta.isServiceDeskEnabled) { + return { + title, + svgPath, + description: `<p>${__('Have your users email')} <code>${ + emptyStateMeta.serviceDeskAddress + }</code></p> ${commonDescription}`, + }; + } + + if (emptyStateMeta.canEditProjectSettings && !emptyStateMeta.isServiceDeskEnabled) { + return { + title, + svgPath, + description: commonDescription, + primaryLink: emptyStateMeta.editProjectPage, + primaryText: __('Turn on Service Desk'), + }; + } + + return { + title, + svgPath, + description: commonDescription, + }; + } + + return { + title: __('Service Desk is enabled but not yet active'), + svgPath, + description: __('You must set up incoming email before it becomes active.'), + primaryLink: emptyStateMeta.incomingEmailHelpPage, + primaryText: __('More information'), + }; +} diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index a01faeb1c8d..f1b37525a6d 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import { addDelimiter } from './lib/utils/text_utility'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; import IssuablesHelper from './helpers/issuables_helper'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index bcf5dc2aaaf..992d87a969f 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -2,7 +2,7 @@ import { GlIcon, GlIntersectionObserver } from '@gitlab/ui'; import Visibility from 'visibilityjs'; import { __, s__, sprintf } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import Poll from '~/lib/utils/poll'; import eventHub from '../event_hub'; diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index f2462e50093..abb63f606ae 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,7 +1,7 @@ <script> import $ from 'jquery'; import { s__, sprintf } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import animateMixin from '../mixins/animate'; import TaskList from '../../task_list'; import recaptchaModalImplementor from '../../vue_shared/mixins/recaptcha_modal_implementor'; diff --git a/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue b/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue deleted file mode 100644 index b6816be9eb8..00000000000 --- a/app/assets/javascripts/issue_show/components/issuable_header_warnings.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { mapState } from 'vuex'; -import Icon from '~/vue_shared/components/icon.vue'; - -export default { - components: { - Icon, - }, - computed: { - ...mapState({ - confidential: ({ noteableData }) => noteableData.confidential, - dicussionLocked: ({ noteableData }) => noteableData.discussion_locked, - }), - }, -}; -</script> - -<template> - <div class="gl-display-inline-block"> - <div v-if="confidential" class="issuable-warning-icon inline"> - <icon class="icon" name="eye-slash" data-testid="confidential" /> - </div> - - <div v-if="dicussionLocked" class="issuable-warning-icon inline"> - <icon class="icon" name="lock" data-testid="locked" /> - </div> - </div> -</template> diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index fe4ff133145..e170d338408 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,8 +1,6 @@ import Vue from 'vue'; import issuableApp from './components/app.vue'; -import IssuableHeaderWarnings from './components/issuable_header_warnings.vue'; import { parseIssuableData } from './utils/parse_data'; -import { store } from '~/notes/stores'; export default function initIssueableApp() { return new Vue({ @@ -17,13 +15,3 @@ export default function initIssueableApp() { }, }); } - -export function issuableHeaderWarnings() { - return new Vue({ - el: document.getElementById('js-issuable-header-warnings'), - store, - render(createElement) { - return createElement(IssuableHeaderWarnings); - }, - }); -} diff --git a/app/assets/javascripts/issue_show/utils/parse_data.js b/app/assets/javascripts/issue_show/utils/parse_data.js index 05e384adad3..8cd1c1b0e56 100644 --- a/app/assets/javascripts/issue_show/utils/parse_data.js +++ b/app/assets/javascripts/issue_show/utils/parse_data.js @@ -1,4 +1,4 @@ -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; export const parseIssuableData = () => { try { diff --git a/app/assets/javascripts/jira_import/components/jira_import_app.vue b/app/assets/javascripts/jira_import/components/jira_import_app.vue index 60ddacd49dd..0f690d17da9 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_app.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_app.vue @@ -1,11 +1,7 @@ <script> -import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { last } from 'lodash'; -import { __ } from '~/locale'; import getJiraImportDetailsQuery from '../queries/get_jira_import_details.query.graphql'; -import getJiraUserMappingMutation from '../queries/get_jira_user_mapping.mutation.graphql'; -import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; -import { addInProgressImportToStore } from '../utils/cache_update'; import { isInProgress, extractJiraProjectsOptions } from '../utils/jira_import_utils'; import JiraImportForm from './jira_import_form.vue'; import JiraImportProgress from './jira_import_progress.vue'; @@ -16,7 +12,6 @@ export default { components: { GlAlert, GlLoadingIcon, - GlSprintf, JiraImportForm, JiraImportProgress, JiraImportSetup, @@ -53,10 +48,7 @@ export default { }, data() { return { - isSubmitting: false, jiraImportDetails: {}, - selectedProject: undefined, - userMappings: [], errorMessage: '', showAlert: false, }; @@ -80,86 +72,7 @@ export default { }, }, }, - computed: { - numberOfPreviousImports() { - return this.jiraImportDetails.imports?.reduce?.( - (acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc), - 0, - ); - }, - hasPreviousImports() { - return this.numberOfPreviousImports > 0; - }, - importLabel() { - return this.selectedProject - ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImports + 1}` - : 'jira-import::KEY-1'; - }, - }, - mounted() { - if (this.isJiraConfigured) { - this.$apollo - .mutate({ - mutation: getJiraUserMappingMutation, - variables: { - input: { - projectPath: this.projectPath, - }, - }, - }) - .then(({ data }) => { - if (data.jiraImportUsers.errors.length) { - this.setAlertMessage(data.jiraImportUsers.errors.join('. ')); - } else { - this.userMappings = data.jiraImportUsers.jiraUsers; - } - }) - .catch(() => this.setAlertMessage(__('There was an error retrieving the Jira users.'))); - } - }, methods: { - initiateJiraImport(project) { - this.isSubmitting = true; - - this.$apollo - .mutate({ - mutation: initiateJiraImportMutation, - variables: { - input: { - jiraProjectKey: project, - projectPath: this.projectPath, - usersMapping: this.userMappings.map(({ gitlabId, jiraAccountId }) => ({ - gitlabId, - jiraAccountId, - })), - }, - }, - update: (store, { data }) => - addInProgressImportToStore(store, data.jiraImportStart, this.projectPath), - }) - .then(({ data }) => { - if (data.jiraImportStart.errors.length) { - this.setAlertMessage(data.jiraImportStart.errors.join('. ')); - } else { - this.selectedProject = undefined; - } - }) - .catch(() => this.setAlertMessage(__('There was an error importing the Jira project.'))) - .finally(() => { - this.isSubmitting = false; - }); - }, - updateMapping(jiraAccountId, gitlabId, gitlabUsername) { - this.userMappings = this.userMappings.map(userMapping => - userMapping.jiraAccountId === jiraAccountId - ? { - ...userMapping, - gitlabId, - gitlabUsername, - } - : userMapping, - ); - }, setAlertMessage(message) { this.errorMessage = message; this.showAlert = true; @@ -168,9 +81,6 @@ export default { this.showAlert = false; }, }, - previousImportsMessage: __( - 'You have imported from this project %{numberOfPreviousImports} times before. Each new import will create duplicate issues.', - ), }; </script> @@ -179,11 +89,6 @@ export default { <gl-alert v-if="showAlert" variant="danger" @dismiss="dismissAlert"> {{ errorMessage }} </gl-alert> - <gl-alert v-if="hasPreviousImports" variant="warning" :dismissible="false"> - <gl-sprintf :message="$options.previousImportsMessage"> - <template #numberOfPreviousImports>{{ numberOfPreviousImports }}</template> - </gl-sprintf> - </gl-alert> <jira-import-setup v-if="!isJiraConfigured" @@ -201,15 +106,12 @@ export default { /> <jira-import-form v-else - v-model="selectedProject" - :import-label="importLabel" - :is-submitting="isSubmitting" :issues-path="issuesPath" + :jira-imports="jiraImportDetails.imports" :jira-projects="jiraImportDetails.projects" :project-id="projectId" - :user-mappings="userMappings" - @initiateJiraImport="initiateJiraImport" - @updateMapping="updateMapping" + :project-path="projectPath" + @error="setAlertMessage" /> </div> </template> diff --git a/app/assets/javascripts/jira_import/components/jira_import_form.vue b/app/assets/javascripts/jira_import/components/jira_import_form.vue index 24bfb49a7d1..b5d17398f3a 100644 --- a/app/assets/javascripts/jira_import/components/jira_import_form.vue +++ b/app/assets/javascripts/jira_import/components/jira_import_form.vue @@ -1,5 +1,6 @@ <script> import { + GlAlert, GlButton, GlNewDropdown, GlNewDropdownItem, @@ -10,15 +11,27 @@ import { GlLabel, GlLoadingIcon, GlSearchBoxByType, + GlSprintf, GlTable, } from '@gitlab/ui'; import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import getJiraUserMappingMutation from '../queries/get_jira_user_mapping.mutation.graphql'; +import initiateJiraImportMutation from '../queries/initiate_jira_import.mutation.graphql'; +import { addInProgressImportToStore } from '../utils/cache_update'; +import { + debounceWait, + dropdownLabel, + previousImportsMessage, + tableConfig, + userMappingMessage, +} from '../utils/constants'; export default { name: 'JiraImportForm', components: { + GlAlert, GlButton, GlNewDropdown, GlNewDropdownItem, @@ -29,35 +42,21 @@ export default { GlLabel, GlLoadingIcon, GlSearchBoxByType, + GlSprintf, GlTable, }, currentUsername: gon.current_username, - dropdownLabel: __('The GitLab user to which the Jira user %{jiraDisplayName} will be mapped'), - tableConfig: [ - { - key: 'jiraDisplayName', - label: __('Jira display name'), - }, - { - key: 'arrow', - label: '', - }, - { - key: 'gitlabUsername', - label: __('GitLab username'), - }, - ], + dropdownLabel, + previousImportsMessage, + tableConfig, + userMappingMessage, props: { - importLabel: { + issuesPath: { type: String, required: true, }, - isSubmitting: { - type: Boolean, - required: true, - }, - issuesPath: { - type: String, + jiraImports: { + type: Array, required: true, }, jiraProjects: { @@ -68,21 +67,19 @@ export default { type: String, required: true, }, - userMappings: { - type: Array, - required: true, - }, - value: { + projectPath: { type: String, - required: false, - default: undefined, + required: true, }, }, data() { return { isFetching: false, + isSubmitting: false, searchTerm: '', + selectedProject: undefined, selectState: null, + userMappings: [], users: [], }; }, @@ -90,13 +87,45 @@ export default { shouldShowNoMatchesFoundText() { return !this.isFetching && this.users.length === 0; }, + numberOfPreviousImports() { + return this.jiraImports?.reduce?.( + (acc, jiraProject) => (jiraProject.jiraProjectKey === this.selectedProject ? acc + 1 : acc), + 0, + ); + }, + hasPreviousImports() { + return this.numberOfPreviousImports > 0; + }, + importLabel() { + return this.selectedProject + ? `jira-import::${this.selectedProject}-${this.numberOfPreviousImports + 1}` + : 'jira-import::KEY-1'; + }, }, watch: { searchTerm: debounce(function debouncedUserSearch() { this.searchUsers(); - }, 500), + }, debounceWait), }, mounted() { + this.$apollo + .mutate({ + mutation: getJiraUserMappingMutation, + variables: { + input: { + projectPath: this.projectPath, + }, + }, + }) + .then(({ data }) => { + if (data.jiraImportUsers.errors.length) { + this.$emit('error', data.jiraImportUsers.errors.join('. ')); + } else { + this.userMappings = data.jiraImportUsers.jiraUsers; + } + }) + .catch(() => this.$emit('error', __('There was an error retrieving the Jira users.'))); + this.searchUsers() .then(data => { this.initialUsers = data; @@ -129,13 +158,54 @@ export default { }, initiateJiraImport(event) { event.preventDefault(); - if (this.value) { + + if (this.selectedProject) { this.hideValidationError(); - this.$emit('initiateJiraImport', this.value); + + this.isSubmitting = true; + + this.$apollo + .mutate({ + mutation: initiateJiraImportMutation, + variables: { + input: { + jiraProjectKey: this.selectedProject, + projectPath: this.projectPath, + usersMapping: this.userMappings.map(({ gitlabId, jiraAccountId }) => ({ + gitlabId, + jiraAccountId, + })), + }, + }, + update: (store, { data }) => + addInProgressImportToStore(store, data.jiraImportStart, this.projectPath), + }) + .then(({ data }) => { + if (data.jiraImportStart.errors.length) { + this.$emit('error', data.jiraImportStart.errors.join('. ')); + } else { + this.selectedProject = undefined; + } + }) + .catch(() => this.$emit('error', __('There was an error importing the Jira project.'))) + .finally(() => { + this.isSubmitting = false; + }); } else { this.showValidationError(); } }, + updateMapping(jiraAccountId, gitlabId, gitlabUsername) { + this.userMappings = this.userMappings.map(userMapping => + userMapping.jiraAccountId === jiraAccountId + ? { + ...userMapping, + gitlabId, + gitlabUsername, + } + : userMapping, + ); + }, hideValidationError() { this.selectState = null; }, @@ -148,8 +218,16 @@ export default { <template> <div> + <gl-alert v-if="hasPreviousImports" variant="warning" :dismissible="false"> + <gl-sprintf :message="$options.previousImportsMessage"> + <template #numberOfPreviousImports>{{ numberOfPreviousImports }}</template> + </gl-sprintf> + </gl-alert> + <h3 class="page-title">{{ __('New Jira import') }}</h3> + <hr /> + <form @submit="initiateJiraImport"> <gl-form-group class="row align-items-center" @@ -160,12 +238,11 @@ export default { > <gl-form-select id="jira-project-select" + v-model="selectedProject" data-qa-selector="jira_project_dropdown" class="mb-2" :options="jiraProjects" :state="selectState" - :value="value" - @change="$emit('input', $event)" /> </gl-form-group> @@ -186,17 +263,7 @@ export default { <h4 class="gl-mb-4">{{ __('Jira-GitLab user mapping template') }}</h4> - <p> - {{ - __( - `Jira users have been matched with similar GitLab users. - This can be overwritten by selecting a GitLab user from the dropdown in the "GitLab - username" column. - If it wasn't possible to match a Jira user with a GitLab user, the dropdown defaults to - the user conducting the import.`, - ) - }} - </p> + <p>{{ $options.userMappingMessage }}</p> <gl-table :fields="$options.tableConfig" :items="userMappings" fixed> <template #cell(arrow)> @@ -221,7 +288,7 @@ export default { v-for="user in users" v-else :key="user.id" - @click="$emit('updateMapping', data.item.jiraAccountId, user.id, user.username)" + @click="updateMapping(data.item.jiraAccountId, user.id, user.username)" > {{ user.username }} ({{ user.name }}) </gl-new-dropdown-item> diff --git a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql index 1f7c52eec58..cca33af342c 100644 --- a/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql +++ b/app/assets/javascripts/jira_import/queries/get_jira_user_mapping.mutation.graphql @@ -5,6 +5,8 @@ mutation($input: JiraImportUsersInput!) { jiraDisplayName jiraEmail gitlabId + gitlabName + gitlabUsername } errors } diff --git a/app/assets/javascripts/jira_import/utils/constants.js b/app/assets/javascripts/jira_import/utils/constants.js new file mode 100644 index 00000000000..6adc3e5306c --- /dev/null +++ b/app/assets/javascripts/jira_import/utils/constants.js @@ -0,0 +1,29 @@ +import { __ } from '~/locale'; + +export const debounceWait = 500; + +export const dropdownLabel = __( + 'The GitLab user to which the Jira user %{jiraDisplayName} will be mapped', +); + +export const previousImportsMessage = __(`You have imported from this project + %{numberOfPreviousImports} times before. Each new import will create duplicate issues.`); + +export const tableConfig = [ + { + key: 'jiraDisplayName', + label: __('Jira display name'), + }, + { + key: 'arrow', + label: '', + }, + { + key: 'gitlabUsername', + label: __('GitLab username'), + }, +]; + +export const userMappingMessage = __(`Jira users have been imported from the configured Jira + instance. They can be mapped by selecting a GitLab user from the dropdown in the "GitLab username" + column. When the form appears, the dropdown defaults to the user conducting the import.`); diff --git a/app/assets/javascripts/jobs/components/artifacts_block.vue b/app/assets/javascripts/jobs/components/artifacts_block.vue index b2f9bf2a348..6183779acd4 100644 --- a/app/assets/javascripts/jobs/components/artifacts_block.vue +++ b/app/assets/javascripts/jobs/components/artifacts_block.vue @@ -48,7 +48,7 @@ export default { ) }}</span> </p> - <div class="btn-group d-flex prepend-top-10" role="group"> + <div class="btn-group d-flex gl-mt-3" role="group"> <gl-link v-if="artifact.keep_path" :href="artifact.keep_path" diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index e2bc413e3ce..0ee8cd6c5ad 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -71,9 +71,9 @@ export default { <div class="col-12"> <div class="text-content"> - <h4 class="js-job-empty-state-title text-center">{{ title }}</h4> + <h4 class="text-center" data-testid="job-empty-state-title">{{ title }}</h4> - <p v-if="content" class="js-job-empty-state-content">{{ content }}</p> + <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> </div> <manual-variables-form v-if="shouldRenderManualVariables" @@ -85,7 +85,8 @@ export default { <gl-link :href="action.path" :data-method="action.method" - class="js-job-empty-state-action btn btn-primary" + class="btn btn-primary" + data-testid="job-empty-state-action" >{{ action.button_title }}</gl-link > </div> diff --git a/app/assets/javascripts/jobs/components/environments_block.vue b/app/assets/javascripts/jobs/components/environments_block.vue index 9166c13a4fb..ec7868d9235 100644 --- a/app/assets/javascripts/jobs/components/environments_block.vue +++ b/app/assets/javascripts/jobs/components/environments_block.vue @@ -1,8 +1,8 @@ <script> import { isEmpty } from 'lodash'; +import { GlSprintf, GlLink } from '@gitlab/ui'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { __ } from '../../locale'; -import { GlSprintf, GlLink } from '@gitlab/ui'; export default { creatingEnvironment: 'creating', diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index f43a058b5f8..e760706c97e 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -198,17 +198,13 @@ export default { </script> <template> <div> - <gl-loading-icon - v-if="isLoading" - size="lg" - class="js-job-loading qa-loading-animation prepend-top-20" - /> + <gl-loading-icon v-if="isLoading" size="lg" class="qa-loading-animation prepend-top-20" /> <template v-else-if="shouldRenderContent"> - <div class="js-job-content build-page"> + <div class="build-page" data-testid="job-content"> <!-- Header Section --> <header> - <div class="js-build-header build-header top-area"> + <div class="build-header top-area"> <ci-header :status="job.status" :item-id="job.id" @@ -230,7 +226,6 @@ export default { <!-- Body Section --> <stuck-block v-if="job.stuck" - class="js-job-stuck" :has-no-runners-for-project="hasRunnersForProject" :tags="job.tags" :runners-path="runnerSettingsUrl" @@ -238,13 +233,11 @@ export default { <unmet-prerequisites-block v-if="hasUnmetPrerequisitesFailure" - class="js-job-failed" :help-path="deploymentHelpUrl" /> <shared-runner v-if="shouldRenderSharedRunnerLimitWarning" - class="js-shared-runner-limit" :quota-used="job.runners.quota.used" :quota-limit="job.runners.quota.limit" :runners-path="runnerHelpUrl" @@ -254,7 +247,6 @@ export default { <environments-block v-if="hasEnvironment" - class="js-job-environment" :deployment-status="job.deployment_status" :deployment-cluster="job.deployment_cluster" :icon-status="job.status" @@ -262,7 +254,7 @@ export default { <erased-block v-if="job.erased_at" - class="js-job-erased-block" + data-testid="job-erased-block" :user="job.erased_by" :erased-at="job.erased_at" /> @@ -270,8 +262,9 @@ export default { <div v-if="job.archived" ref="sticky" - class="js-archived-job gl-mt-3 archived-job" + class="gl-mt-3 archived-job" :class="{ 'sticky-top border-bottom-0': hasTrace }" + data-testid="archived-job" > <icon name="lock" class="align-text-bottom" /> {{ __('This job is archived. Only the complete pipeline can be retried.') }} @@ -305,7 +298,6 @@ export default { <!-- empty state --> <empty-state v-if="!hasTrace" - class="js-job-empty-state" :illustration-path="emptyStateIllustration.image" :illustration-size-class="emptyStateIllustration.size" :title="emptyStateTitle" @@ -323,12 +315,12 @@ export default { <sidebar v-if="shouldRenderContent" - class="js-job-sidebar" :class="{ 'right-sidebar-expanded': isSidebarOpen, 'right-sidebar-collapsed': !isSidebarOpen, }" :runner-help-url="runnerHelpUrl" + data-testid="job-sidebar" /> </div> </template> diff --git a/app/assets/javascripts/jobs/components/job_log_controllers.vue b/app/assets/javascripts/jobs/components/job_log_controllers.vue index a68174d8e1d..4d314eaa106 100644 --- a/app/assets/javascripts/jobs/components/job_log_controllers.vue +++ b/app/assets/javascripts/jobs/components/job_log_controllers.vue @@ -71,13 +71,14 @@ export default { <template> <div class="top-bar"> <!-- truncate information --> - <div class="js-truncated-info truncated-info d-none d-sm-block float-left"> + <div class="truncated-info d-none d-sm-block float-left" data-testid="log-truncated-info"> <template v-if="isTraceSizeVisible"> {{ jobLogSize }} <gl-link v-if="rawPath" :href="rawPath" - class="js-raw-link text-plain text-underline gl-ml-2" + class="text-plain text-underline gl-ml-2" + data-testid="raw-link" >{{ s__('Job|Complete Raw') }}</gl-link > </template> @@ -91,7 +92,8 @@ export default { v-gl-tooltip.body :title="s__('Job|Show complete raw')" :href="rawPath" - class="js-raw-link-controller controllers-buttons" + class="controllers-buttons" + data-testid="job-raw-link-controller" > <icon name="doc-text" /> </gl-link> @@ -102,7 +104,8 @@ export default { :title="s__('Job|Erase job log')" :href="erasePath" :data-confirm="__('Are you sure you want to erase this build?')" - class="js-erase-link controllers-buttons" + class="controllers-buttons" + data-testid="job-log-erase-link" data-method="post" > <icon name="remove" /> @@ -114,7 +117,8 @@ export default { <gl-deprecated-button :disabled="isScrollTopDisabled" type="button" - class="js-scroll-top btn-scroll btn-transparent btn-blank" + class="btn-scroll btn-transparent btn-blank" + data-testid="job-controller-scroll-top" @click="handleScrollToTop" > <icon name="scroll_up" /> @@ -126,6 +130,7 @@ export default { :disabled="isScrollBottomDisabled" class="js-scroll-bottom btn-scroll btn-transparent btn-blank" :class="{ animate: isScrollingDown }" + data-testid="job-controller-scroll-bottom" @click="handleScrollToBottom" v-html="$options.scrollDown" /> diff --git a/app/assets/javascripts/jobs/components/manual_variables_form.vue b/app/assets/javascripts/jobs/components/manual_variables_form.vue index d83c598dd48..9236624a191 100644 --- a/app/assets/javascripts/jobs/components/manual_variables_form.vue +++ b/app/assets/javascripts/jobs/components/manual_variables_form.vue @@ -100,7 +100,7 @@ export default { }; </script> <template> - <div class="js-manual-vars-form col-12"> + <div class="col-12" data-testid="manual-vars-form"> <label>{{ s__('CiVariables|Variables') }}</label> <div class="ci-table"> diff --git a/app/assets/javascripts/jobs/components/sidebar.vue b/app/assets/javascripts/jobs/components/sidebar.vue index 83ba528cfa2..517da16dcf8 100644 --- a/app/assets/javascripts/jobs/components/sidebar.vue +++ b/app/assets/javascripts/jobs/components/sidebar.vue @@ -147,7 +147,8 @@ export default { <gl-link v-if="job.new_issue_path" :href="job.new_issue_path" - class="js-new-issue btn btn-success btn-inverted float-left mr-2" + class="btn btn-success btn-inverted float-left mr-2" + data-testid="job-new-issue" >{{ __('New issue') }}</gl-link > <gl-link diff --git a/app/assets/javascripts/jobs/components/stuck_block.vue b/app/assets/javascripts/jobs/components/stuck_block.vue index b69e6f9686f..8e8202246a2 100644 --- a/app/assets/javascripts/jobs/components/stuck_block.vue +++ b/app/assets/javascripts/jobs/components/stuck_block.vue @@ -1,10 +1,13 @@ <script> -import { GlLink } from '@gitlab/ui'; +import { GlAlert, GlBadge, GlLink } from '@gitlab/ui'; +import { s__ } from '../../locale'; /** * Renders Stuck Runners block for job's view. */ export default { components: { + GlAlert, + GlBadge, GlLink, }, props: { @@ -22,35 +25,50 @@ export default { required: true, }, }, + computed: { + hasNoRunnersWithCorrespondingTags() { + return this.tags.length > 0; + }, + stuckData() { + if (this.hasNoRunnersWithCorrespondingTags) { + return { + text: s__(`Job|This job is stuck because you don't have + any active runners online or available with any of these tags assigned to them:`), + dataTestId: 'job-stuck-with-tags', + showTags: true, + }; + } else if (this.hasNoRunnersForProject) { + return { + text: s__(`Job|This job is stuck because the project + doesn't have any runners online assigned to it.`), + dataTestId: 'job-stuck-no-runners', + showTags: false, + }; + } + + return { + text: s__(`Job|This job is stuck because you don't + have any active runners that can run this job.`), + dataTestId: 'job-stuck-no-active-runners', + showTags: false, + }; + }, + }, }; </script> <template> - <div class="bs-callout bs-callout-warning"> - <p v-if="tags.length" class="js-stuck-with-tags gl-mb-0"> - {{ - s__(`This job is stuck because you don't have - any active runners online or available with any of these tags assigned to them:`) - }} - <span v-for="(tag, index) in tags" :key="index" class="badge badge-primary gl-mr-2"> - {{ tag }} - </span> + <gl-alert variant="warning" :dismissible="false"> + <p class="gl-mb-0" :data-testid="stuckData.dataTestId"> + {{ stuckData.text }} + <template v-if="stuckData.showTags"> + <gl-badge v-for="tag in tags" :key="tag" variant="info"> + {{ tag }} + </gl-badge> + </template> </p> - <p v-else-if="hasNoRunnersForProject" class="js-stuck-no-runners gl-mb-0"> - {{ - s__(`Job|This job is stuck because the project - doesn't have any runners online assigned to it.`) - }} - </p> - <p v-else class="js-stuck-no-active-runner gl-mb-0"> - {{ - s__(`This job is stuck because you don't - have any active runners that can run this job.`) - }} - </p> - {{ __('Go to project') }} - <gl-link v-if="runnersPath" :href="runnersPath" class="js-runners-path"> + <gl-link v-if="runnersPath" :href="runnersPath"> {{ __('CI settings') }} </gl-link> - </div> + </gl-alert> </template> diff --git a/app/assets/javascripts/jobs/store/actions.js b/app/assets/javascripts/jobs/store/actions.js index 4bd8d6f58a6..1e4b5e986db 100644 --- a/app/assets/javascripts/jobs/store/actions.js +++ b/app/assets/javascripts/jobs/store/actions.js @@ -3,7 +3,7 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import Poll from '~/lib/utils/poll'; import { setFaviconOverlay, resetFavicon } from '~/lib/utils/common_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { canScroll, @@ -247,6 +247,3 @@ export const triggerManualJob = ({ state }, variables) => { }) .catch(() => flash(__('An error occurred while triggering the job.'))); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 3f02f924eed..dc4a3578a86 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -46,6 +46,3 @@ export const isScrollingDown = state => isScrolledToBottom() && !state.isTraceCo export const hasRunnersForProject = state => state.job.runners.available && !state.job.runners.online; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/label_manager.js b/app/assets/javascripts/label_manager.js index 5dcc719f7c3..4922166acd0 100644 --- a/app/assets/javascripts/label_manager.js +++ b/app/assets/javascripts/label_manager.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Sortable from 'sortablejs'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 63c4ad3c410..1fb8e270e0e 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -3,12 +3,12 @@ /* global ListLabel */ import $ from 'jquery'; -import { difference, isEqual, escape, sortBy, template } from 'lodash'; +import { difference, isEqual, escape, sortBy, template, union } from 'lodash'; import { sprintf, s__, __ } from './locale'; import axios from './lib/utils/axios_utils'; import IssuableBulkUpdateActions from './issuable_bulk_update_actions'; import CreateLabelDropdown from './create_label'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import ModalStore from './boards/stores/modal_store'; import boardsStore from './boards/stores/boards_store'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -477,13 +477,11 @@ export default class LabelsSelect { const linkOpenTag = '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>" class="gl-link gl-label-link has-tooltip" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>">'; - const spanOpenTag = - '<span class="gl-label-text" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">'; const labelTemplate = template( [ '<span class="gl-label">', linkOpenTag, - spanOpenTag, + '<span class="gl-label-text <%= labelTextClass({ label, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>;">', '<%- label.title %>', '</span>', '</a>', @@ -491,18 +489,24 @@ export default class LabelsSelect { ].join(''), ); - const rightLabelTextColor = ({ label, escapeStr }) => { - return escapeStr(label.text_color === '#FFFFFF' ? label.color : label.text_color); + const labelTextClass = ({ label, escapeStr }) => { + return escapeStr( + label.text_color === '#FFFFFF' ? 'gl-label-text-light' : 'gl-label-text-dark', + ); + }; + + const rightLabelTextClass = ({ label, escapeStr }) => { + return escapeStr(label.text_color === '#333333' ? labelTextClass({ label, escapeStr }) : ''); }; const scopedLabelTemplate = template( [ '<span class="gl-label gl-label-scoped" style="color: <%= escapeStr(label.color) %>; --label-inset-border: inset 0 0 0 2px <%= escapeStr(label.color) %>;">', linkOpenTag, - spanOpenTag, + '<span class="gl-label-text <%= labelTextClass({ label, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>;">', '<%- label.title.slice(0, label.title.lastIndexOf("::")) %>', '</span>', - '<span class="gl-label-text" style="color: <%= rightLabelTextColor({ label, escapeStr }) %>;">', + '<span class="gl-label-text <%= rightLabelTextClass({ label, escapeStr }) %>">', '<%- label.title.slice(label.title.lastIndexOf("::") + 2) %>', '</span>', '</a>', @@ -526,9 +530,9 @@ export default class LabelsSelect { [ '<% labels.forEach(function(label){ %>', '<% if (isScopedLabel(label) && enableScopedLabels) { %>', - '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, rightLabelTextColor, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', + '<%= scopedLabelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, labelTextClass, rightLabelTextClass, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', '<% } else { %>', - '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, labelTextClass, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', '<% } %>', '<% }); %>', ].join(''), @@ -537,7 +541,8 @@ export default class LabelsSelect { return tpl({ ...tplData, labelTemplate, - rightLabelTextColor, + labelTextClass, + rightLabelTextClass, scopedLabelTemplate, tooltipTitleTemplate, isScopedLabel, @@ -560,15 +565,15 @@ export default class LabelsSelect { IssuableBulkUpdateActions.willUpdateLabels = true; } // eslint-disable-next-line class-methods-use-this - setDropdownData($dropdown, isChecking, labelId) { + setDropdownData($dropdown, isMarking, labelId) { let userCheckedIds = $dropdown.data('user-checked') || []; let userUncheckedIds = $dropdown.data('user-unchecked') || []; - if (isChecking) { - userCheckedIds = userCheckedIds.concat(labelId); + if (isMarking) { + userCheckedIds = union(userCheckedIds, [labelId]); userUncheckedIds = difference(userUncheckedIds, [labelId]); } else { - userUncheckedIds = userUncheckedIds.concat(labelId); + userUncheckedIds = union(userUncheckedIds, [labelId]); userCheckedIds = difference(userCheckedIds, [labelId]); } diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 4314e5e1afb..0ecf3301250 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,6 +1,7 @@ import $ from 'jquery'; import ContextualSidebar from './contextual_sidebar'; import initFlyOutNav from './fly_out_nav'; +import initWhatsNew from '~/whats_new'; function hideEndFade($scrollingTabs) { $scrollingTabs.each(function scrollTabsLoop() { @@ -20,6 +21,7 @@ export default function initLayoutNav() { contextualSidebar.bindEvents(); initFlyOutNav(); + initWhatsNew(); // We need to init it on DomContentLoaded as others could also call it $(document).on('init.scrolling-tabs', () => { diff --git a/app/assets/javascripts/lib/chrome_84_icon_fix.js b/app/assets/javascripts/lib/chrome_84_icon_fix.js new file mode 100644 index 00000000000..60497186c19 --- /dev/null +++ b/app/assets/javascripts/lib/chrome_84_icon_fix.js @@ -0,0 +1,78 @@ +import { debounce } from 'lodash'; + +/* + Chrome and Edge 84 have a bug relating to icon sprite svgs + https://bugs.chromium.org/p/chromium/issues/detail?id=1107442 + + If the SVG is loaded, under certain circumstances the icons are not + shown. We load our sprite icons with JS and add them to the body. + Then we iterate over all the `use` elements and replace their reference + to that svg which we added internally. In order to avoid id conflicts, + those are renamed with a unique prefix. + + We do that once the DOMContentLoaded fired and otherwise we use a + mutation observer to re-trigger this logic. + + In order to not have a big impact on performance or to cause flickering + of of content, + + 1. we only do it for each svg once + 2. we debounce the event handler and just do it in a requestIdleCallback + + Before we tried to do it with the library svg4everybody and it had a big + performance impact. See: + https://gitlab.com/gitlab-org/quality/performance/-/issues/312 + */ +document.addEventListener('DOMContentLoaded', async () => { + const GITLAB_SVG_PREFIX = 'chrome-issue-230433-gitlab-svgs'; + const FILE_ICON_PREFIX = 'chrome-issue-230433-file-icons'; + const SKIP_ATTRIBUTE = 'data-replaced-by-chrome-issue-230433'; + + const fixSVGs = () => { + requestIdleCallback(() => { + document.querySelectorAll(`use:not([${SKIP_ATTRIBUTE}])`).forEach(use => { + const href = use?.getAttribute('href') ?? use?.getAttribute('xlink:href') ?? ''; + + if (href.includes(window.gon.sprite_icons)) { + use.removeAttribute('xlink:href'); + use.setAttribute('href', `#${GITLAB_SVG_PREFIX}-${href.split('#')[1]}`); + } else if (href.includes(window.gon.sprite_file_icons)) { + use.removeAttribute('xlink:href'); + use.setAttribute('href', `#${FILE_ICON_PREFIX}-${href.split('#')[1]}`); + } + + use.setAttribute(SKIP_ATTRIBUTE, 'true'); + }); + }); + }; + + const watchForNewSVGs = () => { + const observer = new MutationObserver(debounce(fixSVGs, 200)); + observer.observe(document.querySelector('body'), { + childList: true, + attributes: false, + subtree: true, + }); + }; + + const retrieveIconSprites = async (url, prefix) => { + const div = document.createElement('div'); + div.classList.add('hidden'); + const result = await fetch(url); + div.innerHTML = await result.text(); + div.querySelectorAll('[id]').forEach(node => { + node.setAttribute('id', `${prefix}-${node.getAttribute('id')}`); + }); + document.body.append(div); + }; + + if (window.gon && window.gon.sprite_icons) { + await retrieveIconSprites(window.gon.sprite_icons, GITLAB_SVG_PREFIX); + if (window.gon.sprite_file_icons) { + await retrieveIconSprites(window.gon.sprite_file_icons, FILE_ICON_PREFIX); + } + + fixSVGs(); + watchForNewSVGs(); + } +}); diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index b6c41ffa7ab..4fed121779e 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -14,7 +14,7 @@ export const fetchPolicies = { }; export default (resolvers = {}, config = {}) => { - let uri = `${gon.relative_url_root}/api/graphql`; + let uri = `${gon.relative_url_root || ''}/api/graphql`; if (config.baseUrl) { // Prepend baseUrl and ensure that `///` are replaced with `/` diff --git a/app/assets/javascripts/lib/utils/axios_startup_calls.js b/app/assets/javascripts/lib/utils/axios_startup_calls.js index cb2e8a76c08..a047cebc8ab 100644 --- a/app/assets/javascripts/lib/utils/axios_startup_calls.js +++ b/app/assets/javascripts/lib/utils/axios_startup_calls.js @@ -34,14 +34,17 @@ const setupAxiosStartupCalls = axios => { }); // eslint-disable-next-line promise/no-nesting - return res.json().then(data => ({ - data, - status: res.status, - statusText: res.statusText, - headers: fetchHeaders, - config: req, - request: req, - })); + return res + .clone() + .json() + .then(data => ({ + data, + status: res.status, + statusText: res.statusText, + headers: fetchHeaders, + config: req, + request: req, + })); }); } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 8bf9a281151..bcf302cc262 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -4,12 +4,12 @@ import { GlBreakpointInstance as breakpointInstance } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; +import { isFunction } from 'lodash'; +import Cookies from 'js-cookie'; import axios from './axios_utils'; import { getLocationHash } from './url_utility'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; -import { isFunction } from 'lodash'; -import Cookies from 'js-cookie'; export const getPagePath = (index = 0) => { const page = $('body').attr('data-page') || ''; diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 6e02fc1eb91..e26b63fbb85 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -689,3 +689,37 @@ export const approximateDuration = (seconds = 0) => { } return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); }; + +/** + * A utility function which helps creating a date object + * for a specific date. Accepts the year, month and day + * returning a date object for the given params. + * + * @param {Int} year the full year as a number i.e. 2020 + * @param {Int} month the month index i.e. January => 0 + * @param {Int} day the day as a number i.e. 23 + * + * @return {Date} the date object from the params + */ +export const dateFromParams = (year, month, day) => { + const date = new Date(); + + date.setFullYear(year); + date.setMonth(month); + date.setDate(day); + + return date; +}; + +/** + * A utility function which computes the difference in seconds + * between 2 dates. + * + * @param {Date} startDate the start sate + * @param {Date} endDate the end date + * + * @return {Int} the difference in seconds + */ +export const differenceInSeconds = (startDate, endDate) => { + return (endDate.getTime() - startDate.getTime()) / 1000; +}; diff --git a/app/assets/javascripts/lib/utils/highlight.js b/app/assets/javascripts/lib/utils/highlight.js index b1dd562f63a..32553af9af3 100644 --- a/app/assets/javascripts/lib/utils/highlight.js +++ b/app/assets/javascripts/lib/utils/highlight.js @@ -1,5 +1,5 @@ import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; /** * Wraps substring matches with HTML `<span>` elements. @@ -24,7 +24,7 @@ export default function highlight(string, match = '', matchPrefix = '<b>', match return string; } - const sanitizedValue = sanitize(string.toString(), { allowedTags: [] }); + const sanitizedValue = sanitize(string.toString(), { ALLOWED_TAGS: [] }); // occurrences is an array of character indices that should be // highlighted in the original string, i.e. [3, 4, 5, 7] diff --git a/app/assets/javascripts/lib/utils/http_status.js b/app/assets/javascripts/lib/utils/http_status.js index 08a77966bbd..7132986a7e6 100644 --- a/app/assets/javascripts/lib/utils/http_status.js +++ b/app/assets/javascripts/lib/utils/http_status.js @@ -22,6 +22,7 @@ const httpStatusCodes = { CONFLICT: 409, GONE: 410, UNPROCESSABLE_ENTITY: 422, + INTERNAL_SERVER_ERROR: 500, SERVICE_UNAVAILABLE: 503, }; diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index 8e5420e87ea..2a8b1759e54 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,4 +1,2 @@ -/* eslint-disable @gitlab/require-i18n-strings */ - export const ESC_KEY = 'Escape'; -export const ESC_KEY_IE11 = 'Esc'; // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key +export const ENTER_KEY = 'Enter'; diff --git a/app/assets/javascripts/lib/utils/poll.js b/app/assets/javascripts/lib/utils/poll.js index a900ff34bf5..7f0c65868c2 100644 --- a/app/assets/javascripts/lib/utils/poll.js +++ b/app/assets/javascripts/lib/utils/poll.js @@ -46,6 +46,19 @@ import { normalizeHeaders } from './common_utils'; * 4. If HTTP response is 200, we poll. * 5. If HTTP response is different from 200, we stop polling. * + * @example + * // With initial delay (for, for example, reducing unnecessary requests) + * + * const poll = new Poll({ + * resource: this.service, + * method: 'fetchNotes', + * successCallback: () => {}, + * errorCallback: () => {}, + * }); + * + * // Performs the first request in 2.5s and then uses the `Poll-Interval` header. + * poll.makeDelayedRequest(2500); + * */ export default class Poll { constructor(options = {}) { @@ -74,6 +87,10 @@ export default class Poll { this.options.successCallback(response); } + makeDelayedRequest(delay = 0) { + this.timeoutID = setTimeout(() => this.makeRequest(), delay); + } + makeRequest() { const { resource, method, data, errorCallback, notificationCallback } = this.options; diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js index 4d25ee9e4bd..8d23d177410 100644 --- a/app/assets/javascripts/lib/utils/text_markdown.js +++ b/app/assets/javascripts/lib/utils/text_markdown.js @@ -308,9 +308,11 @@ export function addMarkdownListeners(form) { .off('click') .on('click', function() { const $this = $(this); + const tag = this.dataset.mdTag; + return updateText({ textArea: $this.closest('.md-area').find('textarea'), - tag: $this.data('mdTag'), + tag, cursorOffset: $this.data('mdCursorOffset'), blockTag: $this.data('mdBlock'), wrap: !$this.data('mdPrepend'), diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index c6c34b831ee..8077570158a 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -71,29 +71,56 @@ export function getParameterValues(sParam, url = window.location) { * * @param {Object} params - url keys and value to merge * @param {String} url + * @param {Object} options + * @param {Boolean} options.spreadArrays - split array values into separate key/value-pairs */ -export function mergeUrlParams(params, url) { +export function mergeUrlParams(params, url, options = {}) { + const { spreadArrays = false } = options; const re = /^([^?#]*)(\?[^#]*)?(.*)/; - const merged = {}; + let merged = {}; const [, fullpath, query, fragment] = url.match(re); if (query) { - query + merged = query .substr(1) .split('&') - .forEach(part => { + .reduce((memo, part) => { if (part.length) { const kv = part.split('='); - merged[decodeUrlParameter(kv[0])] = decodeUrlParameter(kv.slice(1).join('=')); + let key = decodeUrlParameter(kv[0]); + const value = decodeUrlParameter(kv.slice(1).join('=')); + if (spreadArrays && key.endsWith('[]')) { + key = key.slice(0, -2); + if (!Array.isArray(memo[key])) { + return { ...memo, [key]: [value] }; + } + memo[key].push(value); + + return memo; + } + + return { ...memo, [key]: value }; } - }); + + return memo; + }, {}); } Object.assign(merged, params); const newQuery = Object.keys(merged) .filter(key => merged[key] !== null) - .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(merged[key])}`) + .map(key => { + let value = merged[key]; + const encodedKey = encodeURIComponent(key); + if (spreadArrays && Array.isArray(value)) { + value = merged[key] + .map(arrayValue => encodeURIComponent(arrayValue)) + .join(`&${encodedKey}[]=`); + return `${encodedKey}[]=${value}`; + } + return `${encodedKey}=${encodeURIComponent(value)}`; + }) .join('&'); if (newQuery) { diff --git a/app/assets/javascripts/logs/components/environment_logs.vue b/app/assets/javascripts/logs/components/environment_logs.vue index f37f48aa431..97b96cb5839 100644 --- a/app/assets/javascripts/logs/components/environment_logs.vue +++ b/app/assets/javascripts/logs/components/environment_logs.vue @@ -5,10 +5,10 @@ import { GlSprintf, GlIcon, GlAlert, - GlDropdown, - GlDropdownHeader, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, GlInfiniteScroll, } from '@gitlab/ui'; @@ -25,10 +25,10 @@ export default { GlSprintf, GlIcon, GlAlert, - GlDropdown, - GlDropdownHeader, - GlDropdownItem, - GlDropdownDivider, + GlDeprecatedDropdown, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, + GlDeprecatedDropdownDivider, GlInfiniteScroll, LogSimpleFilters, LogAdvancedFilters, @@ -174,16 +174,16 @@ export default { <div class="top-bar d-md-flex border bg-secondary-50 pt-2 pr-1 pb-0 pl-2"> <div class="flex-grow-0"> - <gl-dropdown + <gl-deprecated-dropdown id="environments-dropdown" :text="environments.current || managedApps.current" :disabled="environments.isLoading" class="mb-2 gl-h-32 pr-2 d-flex d-md-block js-environments-dropdown" > - <gl-dropdown-header class="gl-text-center"> + <gl-deprecated-dropdown-header class="gl-text-center"> {{ s__('Environments|Environments') }} - </gl-dropdown-header> - <gl-dropdown-item + </gl-deprecated-dropdown-header> + <gl-deprecated-dropdown-item v-for="env in environments.options" :key="env.id" @click="showEnvironment(env.name)" @@ -195,12 +195,12 @@ export default { /> <div class="gl-flex-grow-1">{{ env.name }}</div> </div> - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-header class="gl-text-center"> + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-divider /> + <gl-deprecated-dropdown-header class="gl-text-center"> {{ s__('Environments|Managed apps') }} - </gl-dropdown-header> - <gl-dropdown-item + </gl-deprecated-dropdown-header> + <gl-deprecated-dropdown-item v-for="app in managedApps.options" :key="app.id" @click="showManagedApp(app.name)" @@ -212,8 +212,8 @@ export default { /> <div class="gl-flex-grow-1">{{ app.name }}</div> </div> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> <log-advanced-filters diff --git a/app/assets/javascripts/logs/components/log_simple_filters.vue b/app/assets/javascripts/logs/components/log_simple_filters.vue index 21fe1695624..2e1270b5428 100644 --- a/app/assets/javascripts/logs/components/log_simple_filters.vue +++ b/app/assets/javascripts/logs/components/log_simple_filters.vue @@ -1,14 +1,19 @@ <script> -import { s__ } from '~/locale'; import { mapActions, mapState } from 'vuex'; -import { GlIcon, GlDropdown, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; +import { + GlIcon, + GlDeprecatedDropdown, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { components: { GlIcon, - GlDropdown, - GlDropdownHeader, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, }, props: { disabled: { @@ -39,22 +44,22 @@ export default { </script> <template> <div> - <gl-dropdown + <gl-deprecated-dropdown ref="podsDropdown" :text="podDropdownText" :disabled="disabled" class="mb-2 gl-h-32 pr-2 d-flex d-md-block flex-grow-0 qa-pods-dropdown" > - <gl-dropdown-header class="text-center"> + <gl-deprecated-dropdown-header class="text-center"> {{ s__('Environments|Select pod') }} - </gl-dropdown-header> + </gl-deprecated-dropdown-header> - <gl-dropdown-item v-if="!pods.options.length" disabled> + <gl-deprecated-dropdown-item v-if="!pods.options.length" disabled> <span ref="noPodsMsg" class="text-muted"> {{ s__('Environments|No pods to display') }} </span> - </gl-dropdown-item> - <gl-dropdown-item + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-item v-for="podName in pods.options" :key="podName" class="text-nowrap" @@ -67,7 +72,7 @@ export default { /> <div class="flex-grow-1">{{ podName }}</div> </div> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> </template> diff --git a/app/assets/javascripts/logs/stores/actions.js b/app/assets/javascripts/logs/stores/actions.js index 0edd825b6e9..623516f349d 100644 --- a/app/assets/javascripts/logs/stores/actions.js +++ b/app/assets/javascripts/logs/stores/actions.js @@ -200,6 +200,3 @@ export const dismissRequestLogsError = ({ commit }) => { export const dismissInvalidTimeRangeWarning = ({ commit }) => { commit(types.HIDE_TIME_RANGE_INVALID_WARNING); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/logs/stores/mutations.js b/app/assets/javascripts/logs/stores/mutations.js index be22204d88d..147f562057f 100644 --- a/app/assets/javascripts/logs/stores/mutations.js +++ b/app/assets/javascripts/logs/stores/mutations.js @@ -112,7 +112,9 @@ export default { }, // Managed apps data [types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, apps) { - state.managedApps.options = apps; + state.managedApps.options = apps.filter( + ({ gitlab_managed_apps_logs_path }) => gitlab_managed_apps_logs_path, // eslint-disable-line babel/camelcase + ); state.managedApps.isLoading = false; }, [types.RECEIVE_MANAGED_APPS_DATA_ERROR](state) { diff --git a/app/assets/javascripts/logs/utils.js b/app/assets/javascripts/logs/utils.js index 8479eeb3b59..8e537a4025f 100644 --- a/app/assets/javascripts/logs/utils.js +++ b/app/assets/javascripts/logs/utils.js @@ -1,5 +1,5 @@ -import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import dateFormat from 'dateformat'; +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; import { dateFormatMask } from './constants'; /** diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 3f85295a5ed..1572e82a66c 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -9,6 +9,8 @@ import './commons'; import './behaviors'; // lib/utils +import applyGitLabUIConfig from '@gitlab/ui/dist/config'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import { handleLocationHash, addSelectOnFocusBehaviour, @@ -19,9 +21,7 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; // everything else import loadAwardsHandler from './awards_handler'; -import applyGitLabUIConfig from '@gitlab/ui/dist/config'; -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import Flash, { removeFlashClickListener } from './flash'; +import { deprecatedCreateFlash as Flash, removeFlashClickListener } from './flash'; import './gl_dropdown'; import initTodoToggle from './header'; import initImporterStatus from './importer_status'; @@ -156,6 +156,9 @@ function deferredInitialisation() { }); loadAwardsHandler(); + + // Adding a helper class to activate animations only after all is rendered + setTimeout(() => $body.addClass('page-initialised'), 1000); } document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/maintenance_mode_settings/components/app.vue b/app/assets/javascripts/maintenance_mode_settings/components/app.vue index 7798c443914..11d154ed9d1 100644 --- a/app/assets/javascripts/maintenance_mode_settings/components/app.vue +++ b/app/assets/javascripts/maintenance_mode_settings/components/app.vue @@ -1,5 +1,5 @@ <script> -import { GlToggle, GlFormGroup, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui'; +import { GlToggle, GlFormGroup, GlFormTextarea, GlButton } from '@gitlab/ui'; export default { name: 'MaintenanceModeSettingsApp', @@ -7,7 +7,7 @@ export default { GlToggle, GlFormGroup, GlFormTextarea, - GlDeprecatedButton, + GlButton, }, data() { return { @@ -38,7 +38,7 @@ export default { /> </gl-form-group> <div class="mt-4"> - <gl-deprecated-button variant="success">{{ __('Save changes') }}</gl-deprecated-button> + <gl-button variant="success" category="primary">{{ __('Save changes') }}</gl-button> </div> </article> </template> diff --git a/app/assets/javascripts/manual_ordering.js b/app/assets/javascripts/manual_ordering.js index 683fe8b0b14..559efa4c66c 100644 --- a/app/assets/javascripts/manual_ordering.js +++ b/app/assets/javascripts/manual_ordering.js @@ -1,6 +1,6 @@ import Sortable from 'sortablejs'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getBoardSortableDefaultOptions, sortableStart, diff --git a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js index 70b18d9728d..3a67d0ad64a 100644 --- a/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js +++ b/app/assets/javascripts/merge_conflicts/components/diff_file_editor.js @@ -3,7 +3,7 @@ import Vue from 'vue'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import getModeByFileExtension from '~/lib/utils/ace_utils'; diff --git a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js index d8d203e0616..a5a930572e1 100644 --- a/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js +++ b/app/assets/javascripts/merge_conflicts/merge_conflicts_bundle.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; -import createFlash from '../flash'; +import { deprecatedCreateFlash as createFlash } from '../flash'; import initIssuableSidebar from '../init_issuable_sidebar'; import './merge_conflict_store'; import MergeConflictsService from './merge_conflict_service'; diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index a90e4e32d34..8322d36faee 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import TaskList from './task_list'; import MergeRequestTabs from './merge_request_tabs'; import IssuablesHelper from './helpers/issuables_helper'; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index e9b7b56a160..94b6ba7b1ce 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -5,7 +5,7 @@ import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import Cookies from 'js-cookie'; import createEventHub from '~/helpers/event_hub_factory'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import BlobForkSuggestion from './blob/blob_fork_suggestion'; import initChangesDropdown from './init_changes_dropdown'; import { @@ -21,6 +21,7 @@ import { localTimeAgo } from './lib/utils/datetime_utility'; import syntaxHighlight from './syntax_highlight'; import Notes from './notes'; import { polyfillSticky } from './lib/utils/sticky'; +import initAddContextCommitsTriggers from './add_context_commits_modal'; import { __ } from './locale'; // MergeRequestTabs @@ -166,8 +167,6 @@ export default class MergeRequestTabs { if (this.setUrl) { this.setCurrentAction(action); } - - this.eventHub.$emit('MergeRequestTabChange', this.getCurrentAction()); } } } @@ -251,6 +250,8 @@ export default class MergeRequestTabs { } } } + + this.eventHub.$emit('MergeRequestTabChange', action); } scrollToElement(container) { @@ -340,6 +341,7 @@ export default class MergeRequestTabs { this.scrollToElement('#commits'); this.toggleLoading(false); + initAddContextCommitsTriggers(); }) .catch(() => { this.toggleLoading(false); @@ -358,7 +360,11 @@ export default class MergeRequestTabs { emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath, errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath, autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath, - canRunPipeline: true, + canCreatePipelineInTargetProject: Boolean( + mrWidgetData?.can_create_pipeline_in_target_project, + ), + sourceProjectFullPath: mrWidgetData?.source_project_full_path || '', + targetProjectFullPath: mrWidgetData?.target_project_full_path || '', projectId: pipelineTableViewEl.dataset.projectId, mergeRequestId: mrWidgetData ? mrWidgetData.iid : null, }, diff --git a/app/assets/javascripts/milestone.js b/app/assets/javascripts/milestone.js index 6aaba4e7c74..20d9fb82554 100644 --- a/app/assets/javascripts/milestone.js +++ b/app/assets/javascripts/milestone.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import { mouseenter, debouncedMouseleave, togglePopover } from './shared/popover'; import { __ } from './locale'; diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue index 19148d6184f..d0179ab5509 100644 --- a/app/assets/javascripts/milestones/project_milestone_combobox.vue +++ b/app/assets/javascripts/milestones/project_milestone_combobox.vue @@ -8,10 +8,10 @@ import { GlSearchBoxByType, GlIcon, } from '@gitlab/ui'; +import { intersection, debounce } from 'lodash'; import { __, sprintf } from '~/locale'; import Api from '~/api'; -import createFlash from '~/flash'; -import { intersection, debounce } from 'lodash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; export default { components: { diff --git a/app/assets/javascripts/mini_pipeline_graph_dropdown.js b/app/assets/javascripts/mini_pipeline_graph_dropdown.js index b39ad764f01..d4283701367 100644 --- a/app/assets/javascripts/mini_pipeline_graph_dropdown.js +++ b/app/assets/javascripts/mini_pipeline_graph_dropdown.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; diff --git a/app/assets/javascripts/mirrors/mirror_repos.js b/app/assets/javascripts/mirrors/mirror_repos.js index 5401fb7b6ec..cc787613c52 100644 --- a/app/assets/javascripts/mirrors/mirror_repos.js +++ b/app/assets/javascripts/mirrors/mirror_repos.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import { __ } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import SSHMirror from './ssh_mirror'; diff --git a/app/assets/javascripts/mirrors/ssh_mirror.js b/app/assets/javascripts/mirrors/ssh_mirror.js index 986785fdfbe..eecfaa76168 100644 --- a/app/assets/javascripts/mirrors/ssh_mirror.js +++ b/app/assets/javascripts/mirrors/ssh_mirror.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import { escape } from 'lodash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { backOff } from '~/lib/utils/common_utils'; import AUTH_METHOD from './constants'; diff --git a/app/assets/javascripts/monitoring/components/alert_widget.vue b/app/assets/javascripts/monitoring/components/alert_widget.vue index 5562981fe1c..909ae2980d2 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget.vue @@ -1,12 +1,12 @@ <script> import { GlBadge, GlLoadingIcon, GlModalDirective, GlIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import { values, get } from 'lodash'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import AlertWidgetForm from './alert_widget_form.vue'; import AlertsService from '../services/alerts_service'; import { alertsValidator, queriesValidator } from '../validators'; import { OPERATORS } from '../constants'; -import { values, get } from 'lodash'; export default { components: { @@ -174,8 +174,8 @@ export default { handleSetApiAction(apiAction) { this.apiAction = apiAction; }, - handleCreate({ operator, threshold, prometheus_metric_id }) { - const newAlert = { operator, threshold, prometheus_metric_id }; + handleCreate({ operator, threshold, prometheus_metric_id, runbookUrl }) { + const newAlert = { operator, threshold, prometheus_metric_id, runbookUrl }; this.isLoading = true; this.service .createAlert(newAlert) @@ -189,8 +189,8 @@ export default { this.isLoading = false; }); }, - handleUpdate({ alert, operator, threshold }) { - const updatedAlert = { operator, threshold }; + handleUpdate({ alert, operator, threshold, runbookUrl }) { + const updatedAlert = { operator, threshold, runbookUrl }; this.isLoading = true; this.service .updateAlert(alert, updatedAlert) diff --git a/app/assets/javascripts/monitoring/components/alert_widget_form.vue b/app/assets/javascripts/monitoring/components/alert_widget_form.vue index b2d7ca0c4e0..5fa0da53a04 100644 --- a/app/assets/javascripts/monitoring/components/alert_widget_form.vue +++ b/app/assets/javascripts/monitoring/components/alert_widget_form.vue @@ -7,8 +7,8 @@ import { GlButtonGroup, GlFormGroup, GlFormInput, - GlDropdown, - GlDropdownItem, + GlNewDropdown as GlDropdown, + GlNewDropdownItem as GlDropdownItem, GlModal, GlTooltipDirective, } from '@gitlab/ui'; @@ -88,6 +88,7 @@ export default { operator: null, threshold: null, prometheusMetricId: null, + runbookUrl: null, selectedAlert: {}, alertQuery: '', }; @@ -116,7 +117,8 @@ export default { this.operator && this.threshold === Number(this.threshold) && (this.operator !== this.selectedAlert.operator || - this.threshold !== this.selectedAlert.threshold) + this.threshold !== this.selectedAlert.threshold || + this.runbookUrl !== this.selectedAlert.runbookUrl) ); }, submitAction() { @@ -153,13 +155,17 @@ export default { const existingAlert = this.alertsToManage[existingAlertPath]; if (existingAlert) { + const { operator, threshold, runbookUrl } = existingAlert; + this.selectedAlert = existingAlert; - this.operator = existingAlert.operator; - this.threshold = existingAlert.threshold; + this.operator = operator; + this.threshold = threshold; + this.runbookUrl = runbookUrl; } else { this.selectedAlert = {}; this.operator = this.operators.greaterThan; this.threshold = null; + this.runbookUrl = null; } this.prometheusMetricId = queryId; @@ -168,13 +174,13 @@ export default { this.resetAlertData(); this.$emit('cancel'); }, - handleSubmit(e) { - e.preventDefault(); + handleSubmit() { this.$emit(this.submitAction, { alert: this.selectedAlert.alert_path, operator: this.operator, threshold: this.threshold, prometheus_metric_id: this.prometheusMetricId, + runbookUrl: this.runbookUrl, }); }, handleShown() { @@ -189,6 +195,7 @@ export default { this.threshold = null; this.prometheusMetricId = null; this.selectedAlert = {}; + this.runbookUrl = null; }, getAlertFormActionTrackingOption() { const label = `${this.submitAction}_alert`; @@ -217,7 +224,7 @@ export default { :modal-id="modalId" :ok-variant="submitAction === 'delete' ? 'danger' : 'success'" :ok-disabled="formDisabled" - @ok="handleSubmit" + @ok.prevent="handleSubmit" @hidden="handleHidden" @shown="handleShown" > @@ -247,7 +254,7 @@ export default { <gl-dropdown id="alert-query-dropdown" :text="queryDropdownLabel" - toggle-class="dropdown-menu-toggle qa-alert-query-dropdown" + toggle-class="dropdown-menu-toggle gl-border-1! qa-alert-query-dropdown" > <gl-dropdown-item v-for="query in relevantQueries" @@ -259,7 +266,7 @@ export default { </gl-dropdown-item> </gl-dropdown> </gl-form-group> - <gl-button-group class="mb-2" :label="s__('PrometheusAlerts|Operator')"> + <gl-button-group class="mb-3" :label="s__('PrometheusAlerts|Operator')"> <gl-deprecated-button :class="{ active: operator === operators.greaterThan }" :disabled="formDisabled" @@ -294,6 +301,19 @@ export default { data-qa-selector="alert_threshold_field" /> </gl-form-group> + <gl-form-group + :label="s__('PrometheusAlerts|Runbook URL (optional)')" + label-for="alert-runbook" + > + <gl-form-input + id="alert-runbook" + v-model="runbookUrl" + :disabled="formDisabled" + data-testid="alertRunbookField" + type="text" + :placeholder="s__('PrometheusAlerts|https://gitlab.com/gitlab-com/runbooks')" + /> + </gl-form-group> </div> <template #modal-ok> <gl-link diff --git a/app/assets/javascripts/monitoring/components/charts/gauge.vue b/app/assets/javascripts/monitoring/components/charts/gauge.vue new file mode 100644 index 00000000000..63fa60bbdf0 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/gauge.vue @@ -0,0 +1,122 @@ +<script> +import { GlResizeObserverDirective } from '@gitlab/ui'; +import { GlGaugeChart } from '@gitlab/ui/dist/charts'; +import { isFinite, isArray, isInteger } from 'lodash'; +import { graphDataValidatorForValues } from '../../utils'; +import { getValidThresholds } from './options'; +import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; + +export default { + components: { + GlGaugeChart, + }, + directives: { + GlResizeObserverDirective, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, true), + }, + }, + data() { + return { + width: 0, + }; + }, + computed: { + rangeValues() { + let min = 0; + let max = 100; + + const { minValue, maxValue } = this.graphData; + + const isValidMinMax = () => { + return isFinite(minValue) && isFinite(maxValue) && minValue < maxValue; + }; + + if (isValidMinMax()) { + min = minValue; + max = maxValue; + } + + return { + min, + max, + }; + }, + validThresholds() { + const { mode, values } = this.graphData?.thresholds || {}; + const range = this.rangeValues; + + if (!isArray(values)) { + return []; + } + + return getValidThresholds({ mode, range, values }); + }, + queryResult() { + return this.graphData?.metrics[0]?.result[0]?.value[1]; + }, + splitValue() { + const { split } = this.graphData; + const defaultValue = 10; + + return isInteger(split) && split > 0 ? split : defaultValue; + }, + textValue() { + const formatFromPanel = this.graphData.format; + const defaultFormat = SUPPORTED_FORMATS.engineering; + const format = SUPPORTED_FORMATS[formatFromPanel] ?? defaultFormat; + const { queryResult } = this; + + const formatter = getFormatter(format); + + return isFinite(queryResult) ? formatter(queryResult) : '--'; + }, + thresholdsValue() { + /** + * If there are no valid thresholds, a default threshold + * will be set at 90% of the gauge arcs' max value + */ + const { min, max } = this.rangeValues; + + const defaultThresholdValue = [(max - min) * 0.95]; + return this.validThresholds.length ? this.validThresholds : defaultThresholdValue; + }, + value() { + /** + * The gauge chart gitlab-ui component expects a value + * of type number. + * + * So, if the query result is undefined, + * we pass the gauge chart a value of NaN. + */ + return this.queryResult || NaN; + }, + }, + methods: { + onResize() { + if (!this.$refs.gaugeChart) return; + const { width } = this.$refs.gaugeChart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> +<template> + <div v-gl-resize-observer-directive="onResize"> + <gl-gauge-chart + ref="gaugeChart" + v-bind="$attrs" + :value="value" + :min="rangeValues.min" + :max="rangeValues.max" + :thresholds="thresholdsValue" + :text="textValue" + :split-number="splitValue" + :width="width" + /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue index ddb44f7b1be..7003e2d37cf 100644 --- a/app/assets/javascripts/monitoring/components/charts/heatmap.vue +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -36,7 +36,7 @@ export default { ); }, xAxisName() { - return this.graphData.x_label || ''; + return this.graphData.xLabel || ''; }, yAxisName() { return this.graphData.y_label || ''; diff --git a/app/assets/javascripts/monitoring/components/charts/options.js b/app/assets/javascripts/monitoring/components/charts/options.js index 42252dd5897..0cd4a02311c 100644 --- a/app/assets/javascripts/monitoring/components/charts/options.js +++ b/app/assets/javascripts/monitoring/components/charts/options.js @@ -1,6 +1,8 @@ +import { isFinite, uniq, sortBy, includes } from 'lodash'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; import { __, s__ } from '~/locale'; import { formatDate, timezones, formats } from '../../format_date'; +import { thresholdModeTypes } from '../../constants'; const yAxisBoundaryGap = [0.1, 0.1]; /** @@ -109,3 +111,65 @@ export const getTooltipFormatter = ({ const formatter = getFormatter(format); return num => formatter(num, precision); }; + +// Thresholds + +/** + * + * Used to find valid thresholds for the gauge chart + * + * An array of thresholds values is + * - duplicate values are removed; + * - filtered for invalid values; + * - sorted in ascending order; + * - only first two values are used. + */ +export const getValidThresholds = ({ mode, range = {}, values = [] } = {}) => { + const supportedModes = [thresholdModeTypes.ABSOLUTE, thresholdModeTypes.PERCENTAGE]; + const { min, max } = range; + + /** + * return early if min and max have invalid values + * or mode has invalid value + */ + if (!isFinite(min) || !isFinite(max) || min >= max || !includes(supportedModes, mode)) { + return []; + } + + const uniqueThresholds = uniq(values); + + const numberThresholds = uniqueThresholds.filter(threshold => isFinite(threshold)); + + const validThresholds = numberThresholds.filter(threshold => { + let isValid; + + if (mode === thresholdModeTypes.PERCENTAGE) { + isValid = threshold > 0 && threshold < 100; + } else if (mode === thresholdModeTypes.ABSOLUTE) { + isValid = threshold > min && threshold < max; + } + + return isValid; + }); + + const transformedThresholds = validThresholds.map(threshold => { + let transformedThreshold; + + if (mode === 'percentage') { + transformedThreshold = (threshold / 100) * (max - min); + } else { + transformedThreshold = threshold; + } + + return transformedThreshold; + }); + + const sortedThresholds = sortBy(transformedThresholds); + + const reducedThresholdsArray = + sortedThresholds.length > 2 + ? [sortedThresholds[0], sortedThresholds[1]] + : [...sortedThresholds]; + + return reducedThresholdsArray; +}; diff --git a/app/assets/javascripts/monitoring/components/charts/single_stat.vue b/app/assets/javascripts/monitoring/components/charts/single_stat.vue index 106c76a97dc..a8ab41ebf26 100644 --- a/app/assets/javascripts/monitoring/components/charts/single_stat.vue +++ b/app/assets/javascripts/monitoring/components/charts/single_stat.vue @@ -50,7 +50,7 @@ export default { } formatter = getFormatter(SUPPORTED_FORMATS.number); - return `${formatter(this.queryResult, defaultPrecision)}${this.queryInfo.unit}`; + return `${formatter(this.queryResult, defaultPrecision)}${this.queryInfo.unit ?? ''}`; }, graphTitle() { return this.queryInfo.label; diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index f2add429a80..054111c203e 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,6 +1,6 @@ <script> -import { omit, throttle } from 'lodash'; -import { GlLink, GlDeprecatedButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; +import { isEmpty, omit, throttle } from 'lodash'; +import { GlLink, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui'; import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; import { s__ } from '~/locale'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; @@ -25,7 +25,6 @@ export default { GlAreaChart, GlLineChart, GlTooltip, - GlDeprecatedButton, GlChartSeriesLabel, GlLink, Icon, @@ -45,6 +44,11 @@ export default { required: false, default: () => ({}), }, + timeRange: { + type: Object, + required: false, + default: () => ({}), + }, seriesConfig: { type: Object, required: false, @@ -174,10 +178,17 @@ export default { chartOptions() { const { yAxis, xAxis } = this.option; const option = omit(this.option, ['series', 'yAxis', 'xAxis']); + const xAxisBounds = isEmpty(this.timeRange) + ? {} + : { + min: this.timeRange.start, + max: this.timeRange.end, + }; const timeXAxis = { ...getTimeAxisOptions({ timezone: this.timezone }), ...xAxis, + ...xAxisBounds, }; const dataYAxis = { diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index bde62275797..24aa7b3f504 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -2,12 +2,12 @@ import { mapActions, mapState, mapGetters } from 'vuex'; import VueDraggable from 'vuedraggable'; import Mousetrap from 'mousetrap'; -import { GlIcon, GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; import DashboardHeader from './dashboard_header.vue'; import DashboardPanel from './dashboard_panel.vue'; import { s__ } from '~/locale'; -import createFlash from '~/flash'; -import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { ESC_KEY } from '~/lib/utils/keys'; import { mergeUrlParams, updateHistory } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -34,7 +34,6 @@ export default { DashboardHeader, DashboardPanel, Icon, - GlIcon, GlButton, GraphGroup, EmptyState, @@ -48,11 +47,6 @@ export default { TrackEvent: TrackEventDirective, }, props: { - externalDashboardUrl: { - type: String, - required: false, - default: '', - }, hasMetrics: { type: Boolean, required: false, @@ -72,10 +66,6 @@ export default { type: String, required: true, }, - addDashboardDocumentationPath: { - type: String, - required: true, - }, settingsPath: { type: String, required: true, @@ -320,7 +310,7 @@ export default { }, onKeyup(event) { const { key } = event; - if (key === ESC_KEY || key === ESC_KEY_IE11) { + if (key === ESC_KEY) { this.clearExpandedPanel(); } }, @@ -398,7 +388,8 @@ export default { }, }, i18n: { - goBackLabel: s__('Metrics|Go back (Esc)'), + collapsePanelLabel: s__('Metrics|Collapse panel'), + collapsePanelTooltip: s__('Metrics|Collapse panel (Esc)'), }, }; </script> @@ -409,14 +400,11 @@ export default { v-if="showHeader" ref="prometheusGraphsHeader" class="prometheus-graphs-header d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 border-bottom bg-gray-light" - :add-dashboard-documentation-path="addDashboardDocumentationPath" :default-branch="defaultBranch" :rearrange-panels-available="rearrangePanelsAvailable" :custom-metrics-available="customMetricsAvailable" :custom-metrics-path="customMetricsPath" :validate-query-path="validateQueryPath" - :external-dashboard-url="externalDashboardUrl" - :has-metrics="hasMetrics" :is-rearranging-panels="isRearrangingPanels" :selected-time-range="selectedTimeRange" @dateTimePickerInvalid="onDateTimePickerInvalid" @@ -441,14 +429,10 @@ export default { ref="goBackBtn" v-gl-tooltip class="mr-3 my-3" - :title="$options.i18n.goBackLabel" + :title="$options.i18n.collapsePanelTooltip" @click="onGoBack" > - <gl-icon - name="arrow-left" - :aria-label="$options.i18n.goBackLabel" - class="text-secondary" - /> + {{ $options.i18n.collapsePanelLabel }} </gl-button> </template> </dashboard-panel> diff --git a/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue new file mode 100644 index 00000000000..68afa2ace01 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_actions_menu.vue @@ -0,0 +1,291 @@ +<script> +import { mapState, mapGetters, mapActions } from 'vuex'; +import { + GlDeprecatedButton, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, + GlModal, + GlIcon, + GlModalDirective, + GlTooltipDirective, +} from '@gitlab/ui'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import { PANEL_NEW_PAGE } from '../router/constants'; +import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; +import CreateDashboardModal from './create_dashboard_modal.vue'; +import { s__ } from '~/locale'; +import invalidUrl from '~/lib/utils/invalid_url'; +import { redirectTo } from '~/lib/utils/url_utility'; +import TrackEventDirective from '~/vue_shared/directives/track_event'; +import { getAddMetricTrackingOptions } from '../utils'; + +export default { + components: { + GlDeprecatedButton, + GlNewDropdown, + GlNewDropdownDivider, + GlNewDropdownItem, + GlModal, + GlIcon, + DuplicateDashboardModal, + CreateDashboardModal, + CustomMetricsFormFields, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + TrackEvent: TrackEventDirective, + }, + props: { + addingMetricsAvailable: { + type: Boolean, + required: false, + default: false, + }, + customMetricsPath: { + type: String, + required: false, + default: invalidUrl, + }, + validateQueryPath: { + type: String, + required: false, + default: invalidUrl, + }, + defaultBranch: { + type: String, + required: true, + }, + isOotbDashboard: { + type: Boolean, + required: true, + }, + }, + data() { + return { customMetricsFormIsValid: null }; + }, + computed: { + ...mapState('monitoringDashboard', [ + 'projectPath', + 'isUpdatingStarredValue', + 'addDashboardDocumentationPath', + ]), + ...mapGetters('monitoringDashboard', ['selectedDashboard']), + isOutOfTheBoxDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard; + }, + isMenuItemEnabled() { + return { + addPanel: !this.isOotbDashboard, + createDashboard: Boolean(this.projectPath), + editDashboard: this.selectedDashboard?.can_edit, + }; + }, + isMenuItemShown() { + return { + duplicateDashboard: this.isOutOfTheBoxDashboard, + }; + }, + newPanelPageLocation() { + // Retains params/query if any + const { params, query } = this.$route ?? {}; + return { name: PANEL_NEW_PAGE, params, query }; + }, + }, + methods: { + ...mapActions('monitoringDashboard', ['toggleStarredValue']), + setFormValidity(isValid) { + this.customMetricsFormIsValid = isValid; + }, + hideAddMetricModal() { + this.$refs.addMetricModal.hide(); + }, + getAddMetricTrackingOptions, + submitCustomMetricsForm() { + this.$refs.customMetricsForm.submit(); + }, + selectDashboard(dashboard) { + // Once the sidebar See metrics link is updated to the new URL, + // this sort of hardcoding will not be necessary. + // https://gitlab.com/gitlab-org/gitlab/-/issues/229277 + const baseURL = `${this.projectPath}/-/metrics`; + const dashboardPath = encodeURIComponent( + dashboard.out_of_the_box_dashboard ? dashboard.path : dashboard.display_name, + ); + redirectTo(`${baseURL}/${dashboardPath}`); + }, + }, + + modalIds: { + addMetric: 'addMetric', + createDashboard: 'createDashboard', + duplicateDashboard: 'duplicateDashboard', + }, + i18n: { + actionsMenu: s__('Metrics|More actions'), + duplicateDashboard: s__('Metrics|Duplicate current dashboard'), + starDashboard: s__('Metrics|Star dashboard'), + unstarDashboard: s__('Metrics|Unstar dashboard'), + addMetric: s__('Metrics|Add metric'), + addPanel: s__('Metrics|Add panel'), + addPanelInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'), + editDashboardInfo: s__('Metrics|Duplicate this dashboard to add panel or edit dashboard YAML.'), + editDashboard: s__('Metrics|Edit dashboard YAML'), + createDashboard: s__('Metrics|Create new dashboard'), + }, +}; +</script> + +<template> + <!-- + This component should be replaced with a variant developed + as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 + The variant will create a dropdown with an icon, no text and no caret + --> + <gl-new-dropdown + v-gl-tooltip + data-testid="actions-menu" + data-qa-selector="actions_menu_dropdown" + right + no-caret + toggle-class="gl-px-3!" + :title="$options.i18n.actionsMenu" + > + <template #button-content> + <gl-icon class="gl-mr-0!" name="ellipsis_v" /> + </template> + + <template v-if="addingMetricsAvailable"> + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.addMetric" + data-qa-selector="add_metric_button" + data-testid="add-metric-item" + > + {{ $options.i18n.addMetric }} + </gl-new-dropdown-item> + <gl-modal + ref="addMetricModal" + :modal-id="$options.modalIds.addMetric" + :title="$options.i18n.addMetric" + data-testid="add-metric-modal" + > + <form ref="customMetricsForm" :action="customMetricsPath" method="post"> + <custom-metrics-form-fields + :validate-query-path="validateQueryPath" + form-operation="post" + @formValidation="setFormValidity" + /> + </form> + <div slot="modal-footer"> + <gl-deprecated-button @click="hideAddMetricModal"> + {{ __('Cancel') }} + </gl-deprecated-button> + <gl-deprecated-button + v-track-event="getAddMetricTrackingOptions()" + data-testid="add-metric-modal-submit-button" + :disabled="!customMetricsFormIsValid" + variant="success" + @click="submitCustomMetricsForm" + > + {{ __('Save changes') }} + </gl-deprecated-button> + </div> + </gl-modal> + </template> + + <gl-new-dropdown-item + v-if="isMenuItemEnabled.addPanel" + data-testid="add-panel-item-enabled" + :to="newPanelPageLocation" + > + {{ $options.i18n.addPanel }} + </gl-new-dropdown-item> + + <!-- + wrapper for tooltip as button can be `disabled` + https://bootstrap-vue.org/docs/components/tooltip#disabled-elements + --> + <div v-else v-gl-tooltip :title="$options.i18n.addPanelInfo"> + <gl-new-dropdown-item + :alt="$options.i18n.addPanelInfo" + :to="newPanelPageLocation" + data-testid="add-panel-item-disabled" + disabled + class="gl-cursor-not-allowed" + > + <span class="gl-text-gray-400">{{ $options.i18n.addPanel }}</span> + </gl-new-dropdown-item> + </div> + + <gl-new-dropdown-item + v-if="isMenuItemEnabled.editDashboard" + :href="selectedDashboard ? selectedDashboard.project_blob_path : null" + data-qa-selector="edit_dashboard_button_enabled" + data-testid="edit-dashboard-item-enabled" + > + {{ $options.i18n.editDashboard }} + </gl-new-dropdown-item> + + <!-- + wrapper for tooltip as button can be `disabled` + https://bootstrap-vue.org/docs/components/tooltip#disabled-elements + --> + <div v-else v-gl-tooltip :title="$options.i18n.editDashboardInfo"> + <gl-new-dropdown-item + :alt="$options.i18n.editDashboardInfo" + :href="selectedDashboard ? selectedDashboard.project_blob_path : null" + data-testid="edit-dashboard-item-disabled" + disabled + class="gl-cursor-not-allowed" + > + <span class="gl-text-gray-400">{{ $options.i18n.editDashboard }}</span> + </gl-new-dropdown-item> + </div> + + <template v-if="isMenuItemShown.duplicateDashboard"> + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.duplicateDashboard" + data-testid="duplicate-dashboard-item" + > + {{ $options.i18n.duplicateDashboard }} + </gl-new-dropdown-item> + + <duplicate-dashboard-modal + :default-branch="defaultBranch" + :modal-id="$options.modalIds.duplicateDashboard" + data-testid="duplicate-dashboard-modal" + @dashboardDuplicated="selectDashboard" + /> + </template> + + <gl-new-dropdown-item + v-if="selectedDashboard" + data-testid="star-dashboard-item" + :disabled="isUpdatingStarredValue" + @click="toggleStarredValue()" + > + {{ selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard }} + </gl-new-dropdown-item> + + <gl-new-dropdown-divider /> + + <gl-new-dropdown-item + v-gl-modal="$options.modalIds.createDashboard" + data-testid="create-dashboard-item" + :disabled="!isMenuItemEnabled.createDashboard" + :class="{ 'monitoring-actions-item-disabled': !isMenuItemEnabled.createDashboard }" + > + {{ $options.i18n.createDashboard }} + </gl-new-dropdown-item> + + <template v-if="isMenuItemEnabled.createDashboard"> + <create-dashboard-modal + data-testid="create-dashboard-modal" + :add-dashboard-documentation-path="addDashboardDocumentationPath" + :modal-id="$options.modalIds.createDashboard" + :project-path="projectPath" + /> + </template> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_header.vue b/app/assets/javascripts/monitoring/components/dashboard_header.vue index fe6ca3a2a07..6a7bf81c643 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_header.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_header.vue @@ -3,23 +3,14 @@ import { debounce } from 'lodash'; import { mapActions, mapState, mapGetters } from 'vuex'; import { GlButton, - GlIcon, - GlDeprecatedButton, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, GlNewDropdown, - GlNewDropdownDivider, - GlNewDropdownItem, - GlModal, GlLoadingIcon, + GlNewDropdownItem, + GlNewDropdownHeader, GlSearchBoxByType, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; import { mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; import Icon from '~/vue_shared/components/icon.vue'; @@ -27,11 +18,9 @@ import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_p import DashboardsDropdown from './dashboards_dropdown.vue'; import RefreshButton from './refresh_button.vue'; -import CreateDashboardModal from './create_dashboard_modal.vue'; -import DuplicateDashboardModal from './duplicate_dashboard_modal.vue'; +import ActionsMenu from './dashboard_actions_menu.vue'; -import TrackEventDirective from '~/vue_shared/directives/track_event'; -import { getAddMetricTrackingOptions, timeRangeToUrl } from '../utils'; +import { timeRangeToUrl } from '../utils'; import { timeRanges } from '~/vue_shared/constants'; import { timezones } from '../format_date'; @@ -39,30 +28,22 @@ export default { components: { Icon, GlButton, - GlIcon, - GlDeprecatedButton, - GlDropdown, - GlLoadingIcon, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, GlNewDropdown, - GlNewDropdownDivider, + GlLoadingIcon, GlNewDropdownItem, + GlNewDropdownHeader, + GlSearchBoxByType, - GlModal, - CustomMetricsFormFields, DateTimePicker, DashboardsDropdown, RefreshButton, - DuplicateDashboardModal, - CreateDashboardModal, + + ActionsMenu, }, directives: { GlModal: GlModalDirective, GlTooltip: GlTooltipDirective, - TrackEvent: TrackEventDirective, }, props: { defaultBranch: { @@ -89,16 +70,6 @@ export default { required: false, default: invalidUrl, }, - externalDashboardUrl: { - type: String, - required: false, - default: '', - }, - hasMetrics: { - type: Boolean, - required: false, - default: true, - }, isRearrangingPanels: { type: Boolean, required: true, @@ -107,32 +78,20 @@ export default { type: Object, required: true, }, - addDashboardDocumentationPath: { - type: String, - required: true, - }, - }, - data() { - return { - formIsValid: null, - }; }, computed: { ...mapState('monitoringDashboard', [ 'emptyState', 'environmentsLoading', 'currentEnvironmentName', - 'isUpdatingStarredValue', 'dashboardTimezone', 'projectPath', 'canAccessOperationsSettings', 'operationsSettingsPath', 'currentDashboard', + 'externalDashboardUrl', ]), ...mapGetters('monitoringDashboard', ['selectedDashboard', 'filteredEnvironments']), - isOutOfTheBoxDashboard() { - return this.selectedDashboard?.out_of_the_box_dashboard; - }, shouldShowEmptyState() { return Boolean(this.emptyState); }, @@ -146,24 +105,27 @@ export default { // Custom metrics only avaialble on system dashboards because // they are stored in the database. This can be improved. See: // https://gitlab.com/gitlab-org/gitlab/-/issues/28241 - this.selectedDashboard?.system_dashboard + this.selectedDashboard?.out_of_the_box_dashboard ); }, showRearrangePanelsBtn() { return !this.shouldShowEmptyState && this.rearrangePanelsAvailable; }, + environmentDropdownText() { + return this.currentEnvironmentName ?? ''; + }, displayUtc() { return this.dashboardTimezone === timezones.UTC; }, - shouldShowActionsMenu() { - return Boolean(this.projectPath); - }, shouldShowSettingsButton() { return this.canAccessOperationsSettings && this.operationsSettingsPath; }, + isOOTBDashboard() { + return this.selectedDashboard?.out_of_the_box_dashboard ?? false; + }, }, methods: { - ...mapActions('monitoringDashboard', ['filterEnvironments', 'toggleStarredValue']), + ...mapActions('monitoringDashboard', ['filterEnvironments']), selectDashboard(dashboard) { // Once the sidebar See metrics link is updated to the new URL, // this sort of hardcoding will not be necessary. @@ -187,16 +149,6 @@ export default { toggleRearrangingPanels() { this.$emit('setRearrangingPanels', !this.isRearrangingPanels); }, - setFormValidity(isValid) { - this.formIsValid = isValid; - }, - hideAddMetricModal() { - this.$refs.addMetricModal.hide(); - }, - getAddMetricTrackingOptions, - submitCustomMetricsForm() { - this.$refs.customMetricsForm.submit(); - }, getEnvironmentPath(environment) { // Once the sidebar See metrics link is updated to the new URL, // this sort of hardcoding will not be necessary. @@ -209,16 +161,6 @@ export default { return mergeUrlParams({ environment }, url); }, }, - modalIds: { - addMetric: 'addMetric', - createDashboard: 'createDashboard', - duplicateDashboard: 'duplicateDashboard', - }, - i18n: { - starDashboard: s__('Metrics|Star dashboard'), - unstarDashboard: s__('Metrics|Unstar dashboard'), - addMetric: s__('Metrics|Add metric'), - }, timeRanges, }; </script> @@ -232,7 +174,6 @@ export default { class="flex-grow-1" toggle-class="dropdown-menu-toggle" :default-branch="defaultBranch" - :modal-id="$options.modalIds.duplicateDashboard" @selectDashboard="selectDashboard" /> </div> @@ -240,39 +181,30 @@ export default { <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> <div class="mb-2 pr-2 d-flex d-sm-block"> - <gl-dropdown + <gl-new-dropdown id="monitor-environments-dropdown" ref="monitorEnvironmentsDropdown" class="flex-grow-1" data-qa-selector="environments_dropdown" toggle-class="dropdown-menu-toggle" menu-class="monitor-environment-dropdown-menu" - :text="currentEnvironmentName" + :text="environmentDropdownText" > <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-environment-dropdown-header text-center"> - {{ __('Environment') }} - </gl-dropdown-header> - <gl-dropdown-divider /> - <gl-search-box-by-type - ref="monitorEnvironmentsDropdownSearch" - class="m-2" - @input="debouncedEnvironmentsSearch" - /> - <gl-loading-icon - v-if="environmentsLoading" - ref="monitorEnvironmentsDropdownLoading" - :inline="true" - /> + <gl-new-dropdown-header>{{ __('Environment') }}</gl-new-dropdown-header> + <gl-search-box-by-type class="m-2" @input="debouncedEnvironmentsSearch" /> + + <gl-loading-icon v-if="environmentsLoading" :inline="true" /> <div v-else class="flex-fill overflow-auto"> - <gl-dropdown-item + <gl-new-dropdown-item v-for="environment in filteredEnvironments" :key="environment.id" - :active="environment.name === currentEnvironmentName" - active-class="is-active" + :is-check-item="true" + :is-checked="environment.name === currentEnvironmentName" :href="getEnvironmentPath(environment.id)" - >{{ environment.name }}</gl-dropdown-item > + {{ environment.name }} + </gl-new-dropdown-item> </div> <div v-show="shouldShowEnvironmentsDropdownNoMatchedMsg" @@ -282,7 +214,7 @@ export default { {{ __('No matching results') }} </div> </div> - </gl-dropdown> + </gl-new-dropdown> </div> <div class="mb-2 pr-2 d-flex d-sm-block"> @@ -305,163 +237,56 @@ export default { <div class="flex-grow-1"></div> <div class="d-sm-flex"> - <div v-if="selectedDashboard" class="mb-2 mr-2 d-flex"> - <!-- - wrapper for tooltip as button can be `disabled` - https://bootstrap-vue.org/docs/components/tooltip#disabled-elements - --> - <div - v-gl-tooltip - class="flex-grow-1" - :title=" - selectedDashboard.starred ? $options.i18n.unstarDashboard : $options.i18n.starDashboard - " - > - <gl-deprecated-button - ref="toggleStarBtn" - class="w-100" - :disabled="isUpdatingStarredValue" - variant="default" - @click="toggleStarredValue()" - > - <gl-icon :name="selectedDashboard.starred ? 'star' : 'star-o'" /> - </gl-deprecated-button> - </div> - </div> - <div v-if="showRearrangePanelsBtn" class="mb-2 mr-2 d-flex"> - <gl-deprecated-button + <gl-button :pressed="isRearrangingPanels" variant="default" class="flex-grow-1 js-rearrange-button" @click="toggleRearrangingPanels" > {{ __('Arrange charts') }} - </gl-deprecated-button> - </div> - <div v-if="addingMetricsAvailable" class="mb-2 mr-2 d-flex d-sm-block"> - <gl-deprecated-button - ref="addMetricBtn" - v-gl-modal="$options.modalIds.addMetric" - variant="outline-success" - data-qa-selector="add_metric_button" - class="flex-grow-1" - > - {{ $options.i18n.addMetric }} - </gl-deprecated-button> - <gl-modal - ref="addMetricModal" - :modal-id="$options.modalIds.addMetric" - :title="$options.i18n.addMetric" - > - <form ref="customMetricsForm" :action="customMetricsPath" method="post"> - <custom-metrics-form-fields - :validate-query-path="validateQueryPath" - form-operation="post" - @formValidation="setFormValidity" - /> - </form> - <div slot="modal-footer"> - <gl-deprecated-button @click="hideAddMetricModal"> - {{ __('Cancel') }} - </gl-deprecated-button> - <gl-deprecated-button - ref="submitCustomMetricsFormBtn" - v-track-event="getAddMetricTrackingOptions()" - :disabled="!formIsValid" - variant="success" - @click="submitCustomMetricsForm" - > - {{ __('Save changes') }} - </gl-deprecated-button> - </div> - </gl-modal> - </div> - - <div - v-if="selectedDashboard && selectedDashboard.can_edit" - class="mb-2 mr-2 d-flex d-sm-block" - > - <gl-deprecated-button - class="flex-grow-1 js-edit-link" - :href="selectedDashboard.project_blob_path" - data-qa-selector="edit_dashboard_button" - > - {{ __('Edit dashboard') }} - </gl-deprecated-button> + </gl-button> </div> <div v-if="externalDashboardUrl && externalDashboardUrl.length" class="mb-2 mr-2 d-flex d-sm-block" > - <gl-deprecated-button + <gl-button class="flex-grow-1 js-external-dashboard-link" - variant="primary" + variant="info" + category="primary" :href="externalDashboardUrl" target="_blank" rel="noopener noreferrer" > {{ __('View full dashboard') }} <icon name="external-link" /> - </gl-deprecated-button> + </gl-button> </div> - <!-- This separator should be displayed only if at least one of the action menu or settings button are displayed --> - <span - v-if="shouldShowActionsMenu || shouldShowSettingsButton" - aria-hidden="true" - class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block" - ></span> + <div class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> + <actions-menu + :adding-metrics-available="addingMetricsAvailable" + :custom-metrics-path="customMetricsPath" + :validate-query-path="validateQueryPath" + :default-branch="defaultBranch" + :is-ootb-dashboard="isOOTBDashboard" + /> + </div> - <div v-if="shouldShowActionsMenu" class="gl-mb-3 gl-mr-3 d-flex d-sm-block"> - <gl-new-dropdown - v-gl-tooltip - right - class="gl-flex-grow-1" - data-testid="actions-menu" - :title="s__('Metrics|Create dashboard')" - :icon="'plus-square'" - > - <gl-new-dropdown-item - v-gl-modal="$options.modalIds.createDashboard" - data-testid="action-create-dashboard" - >{{ s__('Metrics|Create new dashboard') }}</gl-new-dropdown-item - > + <template v-if="shouldShowSettingsButton"> + <span aria-hidden="true" class="gl-pl-3 border-left gl-mb-3 d-none d-sm-block"></span> - <create-dashboard-modal - data-testid="create-dashboard-modal" - :add-dashboard-documentation-path="addDashboardDocumentationPath" - :modal-id="$options.modalIds.createDashboard" - :project-path="projectPath" + <div class="mb-2 mr-2 d-flex d-sm-block"> + <gl-button + v-gl-tooltip + data-testid="metrics-settings-button" + icon="settings" + :href="operationsSettingsPath" + :title="s__('Metrics|Metrics Settings')" /> - - <template v-if="isOutOfTheBoxDashboard"> - <gl-new-dropdown-divider /> - <gl-new-dropdown-item - ref="duplicateDashboardItem" - v-gl-modal="$options.modalIds.duplicateDashboard" - data-testid="action-duplicate-dashboard" - > - {{ s__('Metrics|Duplicate current dashboard') }} - </gl-new-dropdown-item> - </template> - </gl-new-dropdown> - </div> - - <div v-if="shouldShowSettingsButton" class="mb-2 mr-2 d-flex d-sm-block"> - <gl-button - v-gl-tooltip - data-testid="metrics-settings-button" - icon="settings" - :href="operationsSettingsPath" - :title="s__('Metrics|Metrics Settings')" - /> - </div> + </div> + </template> </div> - <duplicate-dashboard-modal - :default-branch="defaultBranch" - :modal-id="$options.modalIds.duplicateDashboard" - @dashboardDuplicated="selectDashboard" - /> </div> </template> diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel.vue b/app/assets/javascripts/monitoring/components/dashboard_panel.vue index 3e3c8408de3..278858d3a94 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel.vue @@ -1,20 +1,23 @@ <script> import { mapState } from 'vuex'; -import { pickBy } from 'lodash'; -import invalidUrl from '~/lib/utils/invalid_url'; -import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; +import { mapValues, pickBy } from 'lodash'; import { GlResizeObserverDirective, GlIcon, + GlLink, GlLoadingIcon, GlNewDropdown as GlDropdown, GlNewDropdownItem as GlDropdownItem, GlNewDropdownDivider as GlDropdownDivider, GlModal, GlModalDirective, + GlSprintf, GlTooltip, GlTooltipDirective, } from '@gitlab/ui'; +import invalidUrl from '~/lib/utils/invalid_url'; +import { convertToFixedRange } from '~/lib/utils/datetime_range'; +import { relativePathToAbsolute, getBaseURL, visitUrl, isSafeURL } from '~/lib/utils/url_utility'; import { __, n__ } from '~/locale'; import { panelTypes } from '../constants'; @@ -22,6 +25,7 @@ import MonitorEmptyChart from './charts/empty_chart.vue'; import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorAnomalyChart from './charts/anomaly.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; +import MonitorGaugeChart from './charts/gauge.vue'; import MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorColumnChart from './charts/column.vue'; import MonitorBarChart from './charts/bar.vue'; @@ -30,6 +34,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import AlertWidget from './alert_widget.vue'; import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; +import { graphDataToCsv } from '../csv_export'; const events = { timeRangeZoom: 'timerangezoom', @@ -41,12 +46,14 @@ export default { MonitorEmptyChart, AlertWidget, GlIcon, + GlLink, GlLoadingIcon, GlTooltip, GlDropdown, GlDropdownItem, GlDropdownDivider, GlModal, + GlSprintf, }, directives: { GlResizeObserver: GlResizeObserverDirective, @@ -128,6 +135,15 @@ export default { return getters[`${this.namespace}/selectedDashboard`]; }, }), + fixedCurrentTimeRange() { + // convertToFixedRange throws an error if the time range + // is not properly set. + try { + return convertToFixedRange(this.timeRange); + } catch { + return {}; + } + }, title() { return this.graphData?.title || ''; }, @@ -148,13 +164,10 @@ export default { return null; }, csvText() { - const chartData = this.graphData?.metrics[0].result[0].values || []; - const yLabel = this.graphData.y_label; - const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/require-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); + if (this.graphData) { + return graphDataToCsv(this.graphData); + } + return null; }, downloadCsv() { const data = new Blob([this.csvText], { type: 'text/plain' }); @@ -172,6 +185,9 @@ export default { if (this.isPanelType(panelTypes.SINGLE_STAT)) { return MonitorSingleStatChart; } + if (this.isPanelType(panelTypes.GAUGE_CHART)) { + return MonitorGaugeChart; + } if (this.isPanelType(panelTypes.HEATMAP)) { return MonitorHeatmapChart; } @@ -217,7 +233,8 @@ export default { return ( this.isPanelType(panelTypes.AREA_CHART) || this.isPanelType(panelTypes.LINE_CHART) || - this.isPanelType(panelTypes.SINGLE_STAT) + this.isPanelType(panelTypes.SINGLE_STAT) || + this.isPanelType(panelTypes.GAUGE_CHART) ); }, editCustomMetricLink() { @@ -328,6 +345,19 @@ export default { this.$refs.copyChartLink.$el.firstChild.click(); } }, + getAlertRunbooks(queries) { + const hasRunbook = alert => Boolean(alert.runbookUrl); + const graphAlertsWithRunbooks = pickBy(this.getGraphAlerts(queries), hasRunbook); + const alertToRunbookTransform = alert => { + const alertQuery = queries.find(query => query.metricId === alert.metricId); + return { + key: alert.metricId, + href: alert.runbookUrl, + label: alertQuery.label, + }; + }; + return mapValues(graphAlertsWithRunbooks, alertToRunbookTransform); + }, }, panelTypes, }; @@ -364,15 +394,21 @@ export default { data-qa-selector="prometheus_graph_widgets" > <div data-testid="dropdown-wrapper" class="d-flex align-items-center"> + <!-- + This component should be replaced with a variant developed + as part of https://gitlab.com/gitlab-org/gitlab-ui/-/issues/936 + The variant will create a dropdown with an icon, no text and no caret + --> <gl-dropdown v-gl-tooltip - toggle-class="shadow-none border-0" + toggle-class="gl-px-3!" + no-caret data-qa-selector="prometheus_widgets_dropdown" right :title="__('More actions')" > - <template slot="button-content"> - <gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" /> + <template #button-content> + <gl-icon class="gl-mr-0!" name="ellipsis_v" /> </template> <gl-dropdown-item v-if="expandBtnAvailable" @@ -423,6 +459,25 @@ export default { > {{ __('Alerts') }} </gl-dropdown-item> + <gl-dropdown-item + v-for="runbook in getAlertRunbooks(graphData.metrics)" + :key="runbook.key" + :href="safeUrl(runbook.href)" + data-testid="runbookLink" + target="_blank" + rel="noopener noreferrer" + > + <span class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <span> + <gl-sprintf :message="s__('Metrics|View runbook - %{label}')"> + <template #label> + {{ runbook.label }} + </template> + </gl-sprintf> + </span> + <gl-icon name="external-link" /> + </span> + </gl-dropdown-item> <template v-if="graphData.links && graphData.links.length"> <gl-dropdown-divider /> @@ -465,6 +520,7 @@ export default { :thresholds="getGraphAlertValues(graphData.metrics)" :group-id="groupId" :timezone="dashboardTimezone" + :time-range="fixedCurrentTimeRange" v-bind="$attrs" v-on="$listeners" @datazoom="onDatazoom" diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue new file mode 100644 index 00000000000..88d5a35146f --- /dev/null +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -0,0 +1,199 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { + GlCard, + GlForm, + GlFormGroup, + GlFormTextarea, + GlButton, + GlSprintf, + GlAlert, + GlTooltipDirective, +} from '@gitlab/ui'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; +import { timeRanges } from '~/vue_shared/constants'; +import DashboardPanel from './dashboard_panel.vue'; + +const initialYml = `title: Go heap size +type: area-chart +y_axis: + format: 'bytes' +metrics: + - metric_id: 'go_memstats_alloc_bytes_1' + query_range: 'go_memstats_alloc_bytes' +`; + +export default { + components: { + GlCard, + GlForm, + GlFormGroup, + GlFormTextarea, + GlButton, + GlSprintf, + GlAlert, + DashboardPanel, + DateTimePicker, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + data() { + return { + yml: initialYml, + }; + }, + computed: { + ...mapState('monitoringDashboard', [ + 'panelPreviewIsLoading', + 'panelPreviewError', + 'panelPreviewGraphData', + 'panelPreviewTimeRange', + 'panelPreviewIsShown', + 'projectPath', + 'addDashboardDocumentationPath', + ]), + }, + methods: { + ...mapActions('monitoringDashboard', [ + 'fetchPanelPreview', + 'fetchPanelPreviewMetrics', + 'setPanelPreviewTimeRange', + ]), + onSubmit() { + this.fetchPanelPreview(this.yml); + }, + onDateTimePickerInput(timeRange) { + this.setPanelPreviewTimeRange(timeRange); + // refetch data only if preview has been clicked + // and there are no errors + if (this.panelPreviewIsShown && !this.panelPreviewError) { + this.fetchPanelPreviewMetrics(); + } + }, + onRefresh() { + // refetch data only if preview has been clicked + // and there are no errors + if (this.panelPreviewIsShown && !this.panelPreviewError) { + this.fetchPanelPreviewMetrics(); + } + }, + }, + timeRanges, +}; +</script> +<template> + <div class="prometheus-panel-builder"> + <div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3"> + <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3"> + <template #header> + <h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2> + </template> + <template #default> + <p>{{ s__('Metrics|Define panel YAML below to preview panel.') }}</p> + <gl-form @submit.prevent="onSubmit"> + <gl-form-group :label="s__('Metrics|Panel YAML')" label-for="panel-yml-input"> + <gl-form-textarea + id="panel-yml-input" + v-model="yml" + class="gl-h-200! gl-font-monospace! gl-font-size-monospace!" + /> + </gl-form-group> + <div class="gl-text-right"> + <gl-button + ref="clipboardCopyBtn" + variant="success" + category="secondary" + :data-clipboard-text="yml" + class="gl-xs-w-full gl-xs-mb-3" + @click="$toast.show(s__('Metrics|Panel YAML copied'))" + > + {{ s__('Metrics|Copy YAML') }} + </gl-button> + <gl-button + type="submit" + variant="success" + :disabled="panelPreviewIsLoading" + class="js-no-auto-disable gl-xs-w-full" + > + {{ s__('Metrics|Preview panel') }} + </gl-button> + </div> + </gl-form> + </template> + </gl-card> + + <gl-card + class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3" + body-class="gl-display-flex gl-flex-direction-column" + > + <template #header> + <h2 class="gl-font-size-h2 gl-my-3"> + {{ s__('Metrics|2. Paste panel YAML into dashboard') }} + </h2> + </template> + <template #default> + <div + class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-justify-content-center" + > + <p> + {{ s__('Metrics|Copy and paste the panel YAML into your dashboard YAML file.') }} + <br /> + <gl-sprintf + :message=" + s__( + 'Metrics|Dashboard files can be found in %{codeStart}.gitlab/dashboards%{codeEnd} at the root of this project.', + ) + " + > + <template #code="{content}"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + </div> + + <div class="gl-text-right"> + <gl-button + ref="viewDocumentationBtn" + category="secondary" + class="gl-xs-w-full gl-xs-mb-3" + variant="info" + target="_blank" + :href="addDashboardDocumentationPath" + > + {{ s__('Metrics|View documentation') }} + </gl-button> + <gl-button + ref="openRepositoryBtn" + variant="success" + :href="projectPath" + class="gl-xs-w-full" + > + {{ s__('Metrics|Open repository') }} + </gl-button> + </div> + </template> + </gl-card> + </div> + + <gl-alert v-if="panelPreviewError" variant="warning" :dismissible="false"> + {{ panelPreviewError }} + </gl-alert> + <date-time-picker + ref="dateTimePicker" + class="gl-flex-grow-1 preview-date-time-picker gl-xs-mb-3" + :value="panelPreviewTimeRange" + :options="$options.timeRanges" + @input="onDateTimePickerInput" + /> + <gl-button + v-gl-tooltip + data-testid="previewRefreshButton" + icon="retry" + :title="s__('Metrics|Refresh Prometheus data')" + @click="onRefresh" + /> + <dashboard-panel :graph-data="panelPreviewGraphData" /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue index 574f48a72fe..aed27b5ea51 100644 --- a/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue +++ b/app/assets/javascripts/monitoring/components/dashboards_dropdown.vue @@ -1,11 +1,11 @@ <script> -import { mapState, mapActions, mapGetters } from 'vuex'; +import { mapState, mapGetters } from 'vuex'; import { GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownHeader, + GlNewDropdownDivider, GlSearchBoxByType, GlModalDirective, } from '@gitlab/ui'; @@ -17,10 +17,10 @@ const events = { export default { components: { GlIcon, - GlDropdown, - GlDropdownItem, - GlDropdownHeader, - GlDropdownDivider, + GlNewDropdown, + GlNewDropdownItem, + GlNewDropdownHeader, + GlNewDropdownDivider, GlSearchBoxByType, }, directives: { @@ -31,10 +31,6 @@ export default { type: String, required: true, }, - modalId: { - type: String, - required: true, - }, }, data() { return { @@ -44,9 +40,6 @@ export default { computed: { ...mapState('monitoringDashboard', ['allDashboards']), ...mapGetters('monitoringDashboard', ['selectedDashboard']), - isOutOfTheBoxDashboard() { - return this.selectedDashboard?.out_of_the_box_dashboard; - }, selectedDashboardText() { return this.selectedDashboard?.display_name; }, @@ -70,7 +63,6 @@ export default { }, }, methods: { - ...mapActions('monitoringDashboard', ['duplicateSystemDashboard']), dashboardDisplayName(dashboard) { return dashboard.display_name || dashboard.path || ''; }, @@ -81,16 +73,13 @@ export default { }; </script> <template> - <gl-dropdown + <gl-new-dropdown toggle-class="dropdown-menu-toggle" menu-class="monitor-dashboard-dropdown-menu" :text="selectedDashboardText" > <div class="d-flex flex-column overflow-hidden"> - <gl-dropdown-header class="monitor-dashboard-dropdown-header text-center">{{ - __('Dashboard') - }}</gl-dropdown-header> - <gl-dropdown-divider /> + <gl-new-dropdown-header>{{ __('Dashboard') }}</gl-new-dropdown-header> <gl-search-box-by-type ref="monitorDashboardsDropdownSearch" v-model="searchTerm" @@ -98,33 +87,36 @@ export default { /> <div class="flex-fill overflow-auto"> - <gl-dropdown-item + <gl-new-dropdown-item v-for="dashboard in starredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboardPath" - active-class="is-active" + :is-check-item="true" + :is-checked="dashboard.path === selectedDashboardPath" @click="selectDashboard(dashboard)" > - <div class="d-flex"> - {{ dashboardDisplayName(dashboard) }} - <gl-icon class="text-muted ml-auto" name="star" /> + <div class="gl-display-flex"> + <div class="gl-flex-grow-1 gl-min-w-0"> + <div class="gl-word-break-all"> + {{ dashboardDisplayName(dashboard) }} + </div> + </div> + <gl-icon class="text-muted gl-flex-shrink-0" name="star" /> </div> - </gl-dropdown-item> - - <gl-dropdown-divider + </gl-new-dropdown-item> + <gl-new-dropdown-divider v-if="starredDashboards.length && nonStarredDashboards.length" ref="starredListDivider" /> - <gl-dropdown-item + <gl-new-dropdown-item v-for="dashboard in nonStarredDashboards" :key="dashboard.path" - :active="dashboard.path === selectedDashboardPath" - active-class="is-active" + :is-check-item="true" + :is-checked="dashboard.path === selectedDashboardPath" @click="selectDashboard(dashboard)" > {{ dashboardDisplayName(dashboard) }} - </gl-dropdown-item> + </gl-new-dropdown-item> </div> <div @@ -134,18 +126,6 @@ export default { > {{ __('No matching results') }} </div> - - <!-- - This Duplicate Dashboard item will be removed from the dashboards dropdown - in https://gitlab.com/gitlab-org/gitlab/-/issues/223223 - --> - <template v-if="isOutOfTheBoxDashboard"> - <gl-dropdown-divider /> - - <gl-dropdown-item v-gl-modal="modalId" data-testid="duplicateDashboardItem"> - {{ s__('Metrics|Duplicate dashboard') }} - </gl-dropdown-item> - </template> </div> - </gl-dropdown> + </gl-new-dropdown> </template> diff --git a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue index 001cd0d47f1..db5b853d451 100644 --- a/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue +++ b/app/assets/javascripts/monitoring/components/duplicate_dashboard_form.vue @@ -1,7 +1,7 @@ <script> -import { __, s__, sprintf } from '~/locale'; import { GlFormGroup, GlFormInput, GlFormRadioGroup, GlFormTextarea } from '@gitlab/ui'; import { escape as esc } from 'lodash'; +import { __, s__, sprintf } from '~/locale'; const defaultFileName = dashboard => dashboard.path.split('/').reverse()[0]; diff --git a/app/assets/javascripts/monitoring/components/group_empty_state.vue b/app/assets/javascripts/monitoring/components/group_empty_state.vue index dee4e5998ee..9cf492dd537 100644 --- a/app/assets/javascripts/monitoring/components/group_empty_state.vue +++ b/app/assets/javascripts/monitoring/components/group_empty_state.vue @@ -1,6 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { GlEmptyState } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import { metricStates } from '../constants'; export default { diff --git a/app/assets/javascripts/monitoring/components/links_section.vue b/app/assets/javascripts/monitoring/components/links_section.vue index 98b07d17694..ca1e9c4d0d4 100644 --- a/app/assets/javascripts/monitoring/components/links_section.vue +++ b/app/assets/javascripts/monitoring/components/links_section.vue @@ -23,7 +23,7 @@ export default { class="gl-mb-1 gl-mr-5 gl-display-flex gl-display-sm-block gl-hover-text-blue-600-children gl-word-break-all" > <gl-link :href="link.url" class="gl-text-gray-900 gl-text-decoration-none!" - ><gl-icon name="link" class="gl-text-gray-700 gl-vertical-align-text-bottom gl-mr-2" />{{ + ><gl-icon name="link" class="gl-text-gray-500 gl-vertical-align-text-bottom gl-mr-2" />{{ link.title }} </gl-link> diff --git a/app/assets/javascripts/monitoring/components/refresh_button.vue b/app/assets/javascripts/monitoring/components/refresh_button.vue index 5481806c3e0..0e9605450ed 100644 --- a/app/assets/javascripts/monitoring/components/refresh_button.vue +++ b/app/assets/javascripts/monitoring/components/refresh_button.vue @@ -1,7 +1,6 @@ <script> -import { n__, __ } from '~/locale'; +import Visibility from 'visibilityjs'; import { mapActions } from 'vuex'; - import { GlButtonGroup, GlButton, @@ -10,6 +9,9 @@ import { GlNewDropdownDivider, GlTooltipDirective, } from '@gitlab/ui'; +import { n__, __ } from '~/locale'; + +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; const makeInterval = (length = 0, unit = 's') => { const shortLabel = `${length}${unit}`; @@ -53,6 +55,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], data() { return { refreshInterval: null, @@ -60,6 +63,12 @@ export default { }; }, computed: { + disableMetricDashboardRefreshRate() { + // Can refresh rates impact performance? + // Add "negative" feature flag called `disable_metric_dashboard_refresh_rate` + // See more at: https://gitlab.com/gitlab-org/gitlab/-/issues/229831 + return this.glFeatures.disableMetricDashboardRefreshRate; + }, dropdownText() { return this.refreshInterval?.shortLabel ?? __('Off'); }, @@ -90,7 +99,8 @@ export default { }; this.stopAutoRefresh(); - if (document.hidden) { + + if (Visibility.hidden()) { // Inactive tab? Skip fetch and schedule again schedule(); } else { @@ -142,7 +152,12 @@ export default { icon="retry" @click="refresh" /> - <gl-new-dropdown v-gl-tooltip :title="s__('Metrics|Set refresh rate')" :text="dropdownText"> + <gl-new-dropdown + v-if="!disableMetricDashboardRefreshRate" + v-gl-tooltip + :title="s__('Metrics|Set refresh rate')" + :text="dropdownText" + > <gl-new-dropdown-item :is-check-item="true" :is-checked="refreshInterval === null" diff --git a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue index 4e48292c48d..5563a27301d 100644 --- a/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue +++ b/app/assets/javascripts/monitoring/components/variables/dropdown_field.vue @@ -1,11 +1,11 @@ <script> -import { GlFormGroup, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlFormGroup, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; export default { components: { GlFormGroup, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, }, props: { name: { @@ -41,13 +41,16 @@ export default { </script> <template> <gl-form-group :label="label"> - <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')"> - <gl-dropdown-item + <gl-deprecated-dropdown + toggle-class="dropdown-menu-toggle" + :text="text || s__('Metrics|Select a value')" + > + <gl-deprecated-dropdown-item v-for="val in options.values" :key="val.value" @click="onUpdate(val.value)" - >{{ val.text }}</gl-dropdown-item + >{{ val.text }}</gl-deprecated-dropdown-item > - </gl-dropdown> + </gl-deprecated-dropdown> </gl-form-group> </template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index afeb3318eb9..81ad3137b8b 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -87,6 +87,10 @@ export const panelTypes = { */ SINGLE_STAT: 'single-stat', /** + * Gauge + */ + GAUGE_CHART: 'gauge', + /** * Heatmap */ HEATMAP: 'heatmap', @@ -213,7 +217,7 @@ export const annotationsSymbolIcon = 'path://m5 229 5 8h-10z'; * This technical debt is being tracked here * https://gitlab.com/gitlab-org/gitlab/-/issues/214671 */ -export const DEFAULT_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; +export const OVERVIEW_DASHBOARD_PATH = 'config/prometheus/common_metrics.yml'; /** * GitLab provide metrics dashboards that are available to a user once @@ -272,3 +276,8 @@ export const keyboardShortcutKeys = { DOWNLOAD_CSV: 'd', CHART_COPY: 'c', }; + +export const thresholdModeTypes = { + ABSOLUTE: 'absolute', + PERCENTAGE: 'percentage', +}; diff --git a/app/assets/javascripts/monitoring/csv_export.js b/app/assets/javascripts/monitoring/csv_export.js new file mode 100644 index 00000000000..734e8dc07a7 --- /dev/null +++ b/app/assets/javascripts/monitoring/csv_export.js @@ -0,0 +1,147 @@ +import { getSeriesLabel } from '~/helpers/monitor_helper'; + +/** + * Returns a label for a header of the csv. + * + * Includes double quotes ("") in case the header includes commas or other separator. + * + * @param {String} axisLabel + * @param {String} metricLabel + * @param {Object} metricAttributes + */ +const csvHeader = (axisLabel, metricLabel, metricAttributes = {}) => + `${axisLabel} > ${getSeriesLabel(metricLabel, metricAttributes)}`; + +/** + * Returns an array with the header labels given a list of metrics + * + * ``` + * metrics = [ + * { + * label: "..." // user-defined label + * result: [ + * { + * metric: { ... } // metricAttributes + * }, + * ... + * ] + * }, + * ... + * ] + * ``` + * + * When metrics have a `label` or `metricAttributes`, they are + * used to generate the column name. + * + * @param {String} axisLabel - Main label + * @param {Array} metrics - Metrics with results + */ +const csvMetricHeaders = (axisLabel, metrics) => + metrics.flatMap(({ label, result }) => + // The `metric` in a `result` is a map of `metricAttributes` + // contains key-values to identify the series, rename it + // here for clarity. + result.map(({ metric: metricAttributes }) => { + return csvHeader(axisLabel, label, metricAttributes); + }), + ); + +/** + * Returns a (flat) array with all the values arrays in each + * metric and series + * + * ``` + * metrics = [ + * { + * result: [ + * { + * values: [ ... ] // `values` + * }, + * ... + * ] + * }, + * ... + * ] + * ``` + * + * @param {Array} metrics - Metrics with results + */ +const csvMetricValues = metrics => + metrics.flatMap(({ result }) => result.map(res => res.values || [])); + +/** + * Returns headers and rows for csv, sorted by their timestamp. + * + * { + * headers: ["timestamp", "<col_1_name>", "col_2_name"], + * rows: [ + * [ <timestamp>, <col_1_value>, <col_2_value> ], + * [ <timestamp>, <col_1_value>, <col_2_value> ] + * ... + * ] + * } + * + * @param {Array} metricHeaders + * @param {Array} metricValues + */ +const csvData = (metricHeaders, metricValues) => { + const rowsByTimestamp = {}; + + metricValues.forEach((values, colIndex) => { + values.forEach(([timestamp, value]) => { + if (!rowsByTimestamp[timestamp]) { + rowsByTimestamp[timestamp] = []; + } + // `value` should be in the right column + rowsByTimestamp[timestamp][colIndex] = value; + }); + }); + + const rows = Object.keys(rowsByTimestamp) + .sort() + .map(timestamp => { + // force each row to have the same number of entries + rowsByTimestamp[timestamp].length = metricHeaders.length; + // add timestamp as the first entry + return [timestamp, ...rowsByTimestamp[timestamp]]; + }); + + // Escape double quotes and enclose headers: + // "If double-quotes are used to enclose fields, then a double-quote + // appearing inside a field must be escaped by preceding it with + // another double quote." + // https://tools.ietf.org/html/rfc4180#page-2 + const headers = metricHeaders.map(header => `"${header.replace(/"/g, '""')}"`); + + return { + headers: ['timestamp', ...headers], + rows, + }; +}; + +/** + * Returns dashboard panel's data in a string in CSV format + * + * @param {Object} graphData - Panel contents + * @returns {String} + */ +// eslint-disable-next-line import/prefer-default-export +export const graphDataToCsv = graphData => { + const delimiter = ','; + const br = '\r\n'; + const { metrics = [], y_label: axisLabel } = graphData; + + const metricsWithResults = metrics.filter(metric => metric.result); + const metricHeaders = csvMetricHeaders(axisLabel, metricsWithResults); + const metricValues = csvMetricValues(metricsWithResults); + const { headers, rows } = csvData(metricHeaders, metricValues); + + if (rows.length === 0) { + return ''; + } + + const headerLine = headers.join(delimiter) + br; + const lines = rows.map(row => row.join(delimiter)); + + return headerLine + lines.join(br) + br; +}; diff --git a/app/assets/javascripts/monitoring/pages/panel_new_page.vue b/app/assets/javascripts/monitoring/pages/panel_new_page.vue new file mode 100644 index 00000000000..8ff6adb47ca --- /dev/null +++ b/app/assets/javascripts/monitoring/pages/panel_new_page.vue @@ -0,0 +1,45 @@ +<script> +import { mapState } from 'vuex'; +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { DASHBOARD_PAGE } from '../router/constants'; +import DashboardPanelBuilder from '../components/dashboard_panel_builder.vue'; + +export default { + components: { + GlButton, + DashboardPanelBuilder, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + computed: { + ...mapState('monitoringDashboard', ['panelPreviewYml']), + dashboardPageLocation() { + return { + ...this.$route, + name: DASHBOARD_PAGE, + }; + }, + }, + i18n: { + backToDashboard: s__('Metrics|Back to dashboard'), + }, +}; +</script> +<template> + <div class="gl-mt-5"> + <div class="gl-display-flex gl-align-items-baseline gl-mb-5"> + <gl-button + v-gl-tooltip + icon="go-back" + :to="dashboardPageLocation" + :aria-label="$options.i18n.backToDashboard" + :title="$options.i18n.backToDashboard" + class="gl-mr-5" + /> + <h1 class="gl-font-size-h1 gl-my-0">{{ s__('Metrics|Add panel') }}</h1> + </div> + <dashboard-panel-builder /> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql index 27b49860b8a..32b982ff195 100644 --- a/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getAnnotations.query.graphql @@ -5,6 +5,7 @@ query getAnnotations( $startingFrom: Time! ) { project(fullPath: $projectPath) { + id environments(name: $environmentName) { nodes { id diff --git a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql index 17cd1b2c342..48d0a780fc7 100644 --- a/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql +++ b/app/assets/javascripts/monitoring/queries/getEnvironments.query.graphql @@ -1,5 +1,6 @@ query getEnvironments($projectPath: ID!, $search: String, $states: [String!]) { project(fullPath: $projectPath) { + id data: environments(search: $search, states: $states) { environments: nodes { name diff --git a/app/assets/javascripts/monitoring/requests/index.js b/app/assets/javascripts/monitoring/requests/index.js new file mode 100644 index 00000000000..28064361768 --- /dev/null +++ b/app/assets/javascripts/monitoring/requests/index.js @@ -0,0 +1,46 @@ +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import { backOff } from '~/lib/utils/common_utils'; +import { PROMETHEUS_TIMEOUT } from '../constants'; + +const cancellableBackOffRequest = makeRequestCallback => + backOff((next, stop) => { + makeRequestCallback() + .then(resp => { + if (resp.status === statusCodes.NO_CONTENT) { + next(); + } else { + stop(resp); + } + }) + // If the request is cancelled by axios + // then consider it as noop so that its not + // caught by subsequent catches + .catch(thrown => (axios.isCancel(thrown) ? undefined : stop(thrown))); + }, PROMETHEUS_TIMEOUT); + +export const getDashboard = (dashboardEndpoint, params) => + cancellableBackOffRequest(() => axios.get(dashboardEndpoint, { params })).then( + axiosResponse => axiosResponse.data, + ); + +export const getPrometheusQueryData = (prometheusEndpoint, params, opts) => + cancellableBackOffRequest(() => axios.get(prometheusEndpoint, { params, ...opts })) + .then(axiosResponse => axiosResponse.data) + .then(prometheusResponse => prometheusResponse.data) + .catch(error => { + // Prometheus returns errors in specific cases + // https://prometheus.io/docs/prometheus/latest/querying/api/#format-overview + const { response = {} } = error; + if ( + response.status === statusCodes.BAD_REQUEST || + response.status === statusCodes.UNPROCESSABLE_ENTITY || + response.status === statusCodes.SERVICE_UNAVAILABLE + ) { + const { data } = response; + if (data?.status === 'error' && data?.error) { + throw new Error(data.error); + } + } + throw error; + }); diff --git a/app/assets/javascripts/monitoring/router/constants.js b/app/assets/javascripts/monitoring/router/constants.js index fedfebe33e9..7834c14a65d 100644 --- a/app/assets/javascripts/monitoring/router/constants.js +++ b/app/assets/javascripts/monitoring/router/constants.js @@ -1,4 +1,7 @@ -export const BASE_DASHBOARD_PAGE = 'dashboard'; -export const CUSTOM_DASHBOARD_PAGE = 'custom_dashboard'; +export const DASHBOARD_PAGE = 'dashboard'; +export const PANEL_NEW_PAGE = 'panel_new'; -export default {}; +export default { + DASHBOARD_PAGE, + PANEL_NEW_PAGE, +}; diff --git a/app/assets/javascripts/monitoring/router/routes.js b/app/assets/javascripts/monitoring/router/routes.js index 4b82791178a..cc43fd8622a 100644 --- a/app/assets/javascripts/monitoring/router/routes.js +++ b/app/assets/javascripts/monitoring/router/routes.js @@ -1,6 +1,7 @@ import DashboardPage from '../pages/dashboard_page.vue'; +import PanelNewPage from '../pages/panel_new_page.vue'; -import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; +import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from './constants'; /** * Because the cluster health page uses the dashboard @@ -11,13 +12,13 @@ import { BASE_DASHBOARD_PAGE, CUSTOM_DASHBOARD_PAGE } from './constants'; */ export default [ { - name: BASE_DASHBOARD_PAGE, - path: '/', - component: DashboardPage, + name: PANEL_NEW_PAGE, + path: '/:dashboard(.+)?/panel/new', + component: PanelNewPage, }, { - name: CUSTOM_DASHBOARD_PAGE, - path: '/:dashboard(.*)', + name: DASHBOARD_PAGE, + path: '/:dashboard(.+)?', component: DashboardPage, }, ]; diff --git a/app/assets/javascripts/monitoring/services/alerts_service.js b/app/assets/javascripts/monitoring/services/alerts_service.js index 4b7337972fe..a67675f1a3d 100644 --- a/app/assets/javascripts/monitoring/services/alerts_service.js +++ b/app/assets/javascripts/monitoring/services/alerts_service.js @@ -1,28 +1,39 @@ import axios from '~/lib/utils/axios_utils'; +const mapAlert = ({ runbook_url, ...alert }) => { + return { runbookUrl: runbook_url, ...alert }; +}; + export default class AlertsService { constructor({ alertsEndpoint }) { this.alertsEndpoint = alertsEndpoint; } getAlerts() { - return axios.get(this.alertsEndpoint).then(resp => resp.data); + return axios.get(this.alertsEndpoint).then(resp => mapAlert(resp.data)); } - createAlert({ prometheus_metric_id, operator, threshold }) { + createAlert({ prometheus_metric_id, operator, threshold, runbookUrl }) { return axios - .post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold }) - .then(resp => resp.data); + .post(this.alertsEndpoint, { + prometheus_metric_id, + operator, + threshold, + runbook_url: runbookUrl, + }) + .then(resp => mapAlert(resp.data)); } // eslint-disable-next-line class-methods-use-this readAlert(alertPath) { - return axios.get(alertPath).then(resp => resp.data); + return axios.get(alertPath).then(resp => mapAlert(resp.data)); } // eslint-disable-next-line class-methods-use-this - updateAlert(alertPath, { operator, threshold }) { - return axios.put(alertPath, { operator, threshold }).then(resp => resp.data); + updateAlert(alertPath, { operator, threshold, runbookUrl }) { + return axios + .put(alertPath, { operator, threshold, runbook_url: runbookUrl }) + .then(resp => mapAlert(resp.data)); } // eslint-disable-next-line class-methods-use-this diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index a441882a47d..16a685305dc 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { gqClient, @@ -13,16 +13,14 @@ import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; -import statusCodes from '../../lib/utils/http_status'; -import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; +import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; +import { getDashboard, getPrometheusQueryData } from '../requests'; -import { - PROMETHEUS_TIMEOUT, - ENVIRONMENT_AVAILABLE_STATE, - DEFAULT_DASHBOARD_PATH, - VARIABLE_TYPES, -} from '../constants'; +import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; + +const axiosCancelToken = axios.CancelToken; +let cancelTokenSource; function prometheusMetricQueryParams(timeRange) { const { start, end } = convertToFixedRange(timeRange); @@ -38,29 +36,18 @@ function prometheusMetricQueryParams(timeRange) { }; } -function backOffRequest(makeRequestCallback) { - return backOff((next, stop) => { - makeRequestCallback() - .then(resp => { - if (resp.status === statusCodes.NO_CONTENT) { - next(); - } else { - stop(resp); - } - }) - .catch(stop); - }, PROMETHEUS_TIMEOUT); -} - -function getPrometheusQueryData(prometheusEndpoint, params) { - return backOffRequest(() => axios.get(prometheusEndpoint, { params })) - .then(res => res.data) - .then(response => { - if (response.status === 'error') { - throw new Error(response.error); - } - return response.data; - }); +/** + * Extract error messages from API or HTTP request errors. + * + * - API errors are in `error.response.data.message` + * - HTTP (axios) errors are in `error.messsage` + * + * @param {Object} error + * @returns {String} User friendly error message + */ +function extractErrorMessage(error) { + const message = error?.response?.data?.message; + return message ?? error.message; } // Setup @@ -126,8 +113,7 @@ export const fetchDashboard = ({ state, commit, dispatch, getters }) => { params.dashboard = getters.fullDashboardPath; } - return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) - .then(resp => resp.data) + return getDashboard(state.dashboardEndpoint, params) .then(response => { dispatch('receiveMetricsDashboardSuccess', { response }); /** @@ -329,7 +315,7 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { export const fetchAnnotations = ({ state, dispatch, getters }) => { const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH; return gqClient .mutate({ mutation: getAnnotations, @@ -362,12 +348,12 @@ export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_AN export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => { /** - * Normally, the default dashboard won't throw any validation warnings. + * Normally, the overview dashboard won't throw any validation warnings. * - * However, if a bug sneaks into the default dashboard making it invalid, + * However, if a bug sneaks into the overview dashboard making it invalid, * this might come handy for our clients */ - const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || OVERVIEW_DASHBOARD_PATH; return gqClient .mutate({ mutation: getDashboardValidationWarnings, @@ -484,12 +470,10 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery if (variable.type === VARIABLE_TYPES.metric_label_values) { const { prometheusEndpointPath, label } = variable.options; - const optionsRequest = backOffRequest(() => - axios.get(prometheusEndpointPath, { - params: { start_time, end_time }, - }), - ) - .then(({ data }) => data.data) + const optionsRequest = getPrometheusQueryData(prometheusEndpointPath, { + start_time, + end_time, + }) .then(data => { commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data }); }) @@ -507,5 +491,59 @@ export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQuery return Promise.all(optionsRequests); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +// Panel Builder + +export const setPanelPreviewTimeRange = ({ commit }, timeRange) => { + commit(types.SET_PANEL_PREVIEW_TIME_RANGE, timeRange); +}; + +export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml) => { + if (!panelPreviewYml) { + return null; + } + + commit(types.SET_PANEL_PREVIEW_IS_SHOWN, true); + commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml); + + return axios + .post(state.panelPreviewEndpoint, { panel_yaml: panelPreviewYml }) + .then(({ data }) => { + commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data); + + dispatch('fetchPanelPreviewMetrics'); + }) + .catch(error => { + commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, extractErrorMessage(error)); + }); +}; + +export const fetchPanelPreviewMetrics = ({ state, commit }) => { + if (cancelTokenSource) { + cancelTokenSource.cancel(); + } + cancelTokenSource = axiosCancelToken.source(); + + const defaultQueryParams = prometheusMetricQueryParams(state.panelPreviewTimeRange); + + state.panelPreviewGraphData.metrics.forEach((metric, index) => { + commit(types.REQUEST_PANEL_PREVIEW_METRIC_RESULT, { index }); + + const params = { ...defaultQueryParams }; + if (metric.step) { + params.step = metric.step; + } + return getPrometheusQueryData(metric.prometheusEndpointPath, params, { + cancelToken: cancelTokenSource.token, + }) + .then(data => { + commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS, { index, data }); + }) + .catch(error => { + Sentry.captureException(error); + + commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE, { index, error }); + // Continue to throw error so the panel builder can notify using createFlash + throw error; + }); + }); +}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/actions.js b/app/assets/javascripts/monitoring/stores/embed_group/actions.js index cbe0950d954..4a7572bdbd9 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/actions.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/actions.js @@ -1,5 +1,4 @@ import * as types from './mutation_types'; +// eslint-disable-next-line import/prefer-default-export export const addModule = ({ commit }, data) => commit(types.ADD_MODULE, data); - -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/getters.js b/app/assets/javascripts/monitoring/stores/embed_group/getters.js index 9b08cf762c1..096d8d03096 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/getters.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/getters.js @@ -1,4 +1,3 @@ +// eslint-disable-next-line import/prefer-default-export export const metricsWithData = (state, getters, rootState, rootGetters) => state.modules.map(module => rootGetters[`${module}/metricsWithData`]().length); - -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js b/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js index e7a425d3623..7fd3f0f8647 100644 --- a/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/embed_group/mutation_types.js @@ -1,3 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export export const ADD_MODULE = 'ADD_MODULE'; - -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index 3aa711a0509..8ed83cf02fe 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -170,6 +170,3 @@ export const getCustomVariablesParams = state => */ export const fullDashboardPath = state => normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index d408628fc4d..1d7279912cc 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -46,3 +46,17 @@ export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL'; + +// Panel preview +export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW'; +export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS'; +export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE'; + +export const REQUEST_PANEL_PREVIEW_METRIC_RESULT = 'REQUEST_PANEL_PREVIEW_METRIC_RESULT'; +export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS = + 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS'; +export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE = + 'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE'; + +export const SET_PANEL_PREVIEW_TIME_RANGE = 'SET_PANEL_PREVIEW_TIME_RANGE'; +export const SET_PANEL_PREVIEW_IS_SHOWN = 'SET_PANEL_PREVIEW_IS_SHOWN'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 744441c8935..09a5861b475 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; -import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils'; +import { mapToDashboardViewModel, mapPanelToViewModel, normalizeQueryResponseData } from './utils'; import httpStatusCodes from '~/lib/utils/http_status'; -import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; +import { BACKOFF_TIMEOUT } from '~/lib/utils/common_utils'; import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; import { optionsFromSeriesData } from './variable_mapping'; @@ -53,6 +53,14 @@ const emptyStateFromError = error => { return metricStates.UNKNOWN_ERROR; }; +export const metricStateFromData = data => { + if (data?.result?.length) { + const result = normalizeQueryResponseData(data); + return { state: metricStates.OK, result: Object.freeze(result) }; + } + return { state: metricStates.NO_DATA, result: null }; +}; + export default { /** * Dashboard panels structure and global state @@ -154,17 +162,11 @@ export default { }, [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { const metric = findMetricInDashboard(metricId, state.dashboard); - metric.loading = false; + const metricState = metricStateFromData(data); - if (!data.result || data.result.length === 0) { - metric.state = metricStates.NO_DATA; - metric.result = null; - } else { - const result = normalizeQueryResponseData(data); - - metric.state = metricStates.OK; - metric.result = Object.freeze(result); - } + metric.loading = false; + metric.state = metricState.state; + metric.result = metricState.result; }, [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { const metric = findMetricInDashboard(metricId, state.dashboard); @@ -218,4 +220,54 @@ export default { // Add new options with assign to ensure Vue reactivity Object.assign(variable.options, { values }); }, + + [types.REQUEST_PANEL_PREVIEW](state, panelPreviewYml) { + state.panelPreviewIsLoading = true; + + state.panelPreviewYml = panelPreviewYml; + state.panelPreviewGraphData = null; + state.panelPreviewError = null; + }, + [types.RECEIVE_PANEL_PREVIEW_SUCCESS](state, payload) { + state.panelPreviewIsLoading = false; + + state.panelPreviewGraphData = mapPanelToViewModel(payload); + state.panelPreviewError = null; + }, + [types.RECEIVE_PANEL_PREVIEW_FAILURE](state, error) { + state.panelPreviewIsLoading = false; + + state.panelPreviewGraphData = null; + state.panelPreviewError = error; + }, + + [types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](state, { index }) { + const metric = state.panelPreviewGraphData.metrics[index]; + + metric.loading = true; + if (!metric.result) { + metric.state = metricStates.LOADING; + } + }, + [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](state, { index, data }) { + const metric = state.panelPreviewGraphData.metrics[index]; + const metricState = metricStateFromData(data); + + metric.loading = false; + metric.state = metricState.state; + metric.result = metricState.result; + }, + [types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](state, { index, error }) { + const metric = state.panelPreviewGraphData.metrics[index]; + + metric.loading = false; + metric.state = emptyStateFromError(error); + metric.result = null; + }, + [types.SET_PANEL_PREVIEW_TIME_RANGE](state, timeRange) { + state.panelPreviewTimeRange = timeRange; + }, + [types.SET_PANEL_PREVIEW_IS_SHOWN](state, isPreviewShown) { + state.panelPreviewIsShown = isPreviewShown; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 89738756ffe..ef8b1adb624 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,12 +1,14 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { timezones } from '../format_date'; import { dashboardEmptyStates } from '../constants'; +import { defaultTimeRange } from '~/vue_shared/constants'; export default () => ({ // API endpoints deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, dashboardsEndpoint: invalidUrl, + panelPreviewEndpoint: invalidUrl, // Dashboard request parameters timeRange: null, @@ -59,6 +61,15 @@ export default () => ({ * via the dashboard yml file. */ links: [], + + // Panel editor / builder + panelPreviewYml: '', + panelPreviewIsLoading: false, + panelPreviewGraphData: null, + panelPreviewError: null, + panelPreviewTimeRange: defaultTimeRange, + panelPreviewIsShown: false, + // Other project data dashboardTimezone: timezones.LOCAL, annotations: [], @@ -69,9 +80,11 @@ export default () => ({ currentEnvironmentName: null, // GitLab paths to other pages + externalDashboardUrl: '', projectPath: null, operationsSettingsPath: '', logsPath: invalidUrl, + addDashboardDocumentationPath: '', // static paths customDashboardBasePath: '', diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 51562593ee8..df7f22e622f 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -176,7 +176,11 @@ export const mapPanelToViewModel = ({ field, metrics = [], links = [], + min_value, max_value, + split, + thresholds, + format, }) => { // Both `x_axis.name` and `x_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/210521 @@ -195,7 +199,11 @@ export const mapPanelToViewModel = ({ yAxis, xAxis, field, + minValue: min_value, maxValue: max_value, + split, + thresholds, + format, links: links.map(mapLinksToViewModel), metrics: mapToMetricsViewModel(metrics), }; @@ -465,9 +473,9 @@ export const addPrefixToCustomVariableParams = name => `variables[${name}]`; * metrics dashboard to work with custom dashboard file names instead * of the entire path. * - * If dashboard is empty, it is the default dashboard. + * If dashboard is empty, it is the overview dashboard. * If dashboard is set, it usually is a custom dashboard unless - * explicitly it is set to default dashboard path. + * explicitly it is set to overview dashboard path. * * @param {String} dashboard dashboard path * @param {String} dashboardPrefix custom dashboard directory prefix diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 0c6fcad9dd0..92bbce498d5 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -24,13 +24,16 @@ export const stateAndPropsFromDataset = (dataset = {}) => { deploymentsEndpoint, dashboardEndpoint, dashboardsEndpoint, + panelPreviewEndpoint, dashboardTimezone, canAccessOperationsSettings, operationsSettingsPath, projectPath, logsPath, + externalDashboardUrl, currentEnvironmentName, customDashboardBasePath, + addDashboardDocumentationPath, ...dataProps } = dataset; @@ -45,13 +48,16 @@ export const stateAndPropsFromDataset = (dataset = {}) => { deploymentsEndpoint, dashboardEndpoint, dashboardsEndpoint, + panelPreviewEndpoint, dashboardTimezone, canAccessOperationsSettings, operationsSettingsPath, projectPath, logsPath, + externalDashboardUrl, currentEnvironmentName, customDashboardBasePath, + addDashboardDocumentationPath, }, dataProps, }; diff --git a/app/assets/javascripts/monitoring/validators.js b/app/assets/javascripts/monitoring/validators.js index cd426f1a221..c6b323f6360 100644 --- a/app/assets/javascripts/monitoring/validators.js +++ b/app/assets/javascripts/monitoring/validators.js @@ -1,3 +1,12 @@ +import { isSafeURL } from '~/lib/utils/url_utility'; + +const isRunbookUrlValid = runbookUrl => { + if (!runbookUrl) { + return true; + } + return isSafeURL(runbookUrl); +}; + // Prop validator for alert information, expecting an object like the example below. // // { @@ -8,6 +17,7 @@ // query: "rate(http_requests_total[5m])[30m:1m]", // threshold: 0.002, // title: "Core Usage (Total)", +// runbookUrl: "https://www.gitlab.com/my-project/-/wikis/runbook" // } // } export function alertsValidator(value) { @@ -19,7 +29,8 @@ export function alertsValidator(value) { alert.metricId && typeof alert.metricId === 'string' && alert.operator && - typeof alert.threshold === 'number' + typeof alert.threshold === 'number' && + isRunbookUrlValid(alert.runbookUrl) ); }); } diff --git a/app/assets/javascripts/mr_notes/init_notes.js b/app/assets/javascripts/mr_notes/init_notes.js index fcde9bf7849..2be7cc951fc 100644 --- a/app/assets/javascripts/mr_notes/init_notes.js +++ b/app/assets/javascripts/mr_notes/init_notes.js @@ -3,8 +3,9 @@ import Vue from 'vue'; import { mapActions, mapState, mapGetters } from 'vuex'; import store from '~/mr_notes/stores'; import notesApp from '../notes/components/notes_app.vue'; -import discussionKeyboardNavigator from '../notes/components/discussion_keyboard_navigator.vue'; +import discussionNavigator from '../notes/components/discussion_navigator.vue'; import initWidget from '../vue_merge_request_widget'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default () => { // eslint-disable-next-line no-new @@ -20,6 +21,7 @@ export default () => { const noteableData = JSON.parse(notesDataset.noteableData); noteableData.noteableType = notesDataset.noteableType; noteableData.targetType = notesDataset.targetType; + noteableData.discussion_locked = parseBoolean(notesDataset.isLocked); return { noteableData, @@ -69,11 +71,11 @@ export default () => { }, }, render(createElement) { - // NOTE: Even though `discussionKeyboardNavigator` is added to the `notes-app`, + // NOTE: Even though `discussionNavigator` is added to the `notes-app`, // it adds a global key listener so it works on the diffs tab as well. // If we create a single Vue app for all of the MR tabs, we should move this // up the tree, to the root. - return createElement(discussionKeyboardNavigator, [ + return createElement(discussionNavigator, [ createElement('notes-app', { props: { noteableData: this.noteableData, diff --git a/app/assets/javascripts/mr_notes/stores/getters.js b/app/assets/javascripts/mr_notes/stores/getters.js index e48cfcd9564..245443d7ecf 100644 --- a/app/assets/javascripts/mr_notes/stores/getters.js +++ b/app/assets/javascripts/mr_notes/stores/getters.js @@ -1,3 +1,8 @@ +// Note: this getter is important because +// `noteableData` is namespaced under `notes` for `~/mr_notes/stores` +// while `noteableData` is directly available as `state.noteableData` for `~/notes/stores` +export const getNoteableData = state => state.notes.noteableData; + export default { isLoggedIn(state, getters) { return Boolean(getters.getUserData.id); diff --git a/app/assets/javascripts/mr_tabs_popover/components/popover.vue b/app/assets/javascripts/mr_tabs_popover/components/popover.vue deleted file mode 100644 index 30455709149..00000000000 --- a/app/assets/javascripts/mr_tabs_popover/components/popover.vue +++ /dev/null @@ -1,69 +0,0 @@ -<script> -import { GlPopover, GlDeprecatedButton, GlLink } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import axios from '~/lib/utils/axios_utils'; - -export default { - components: { - GlPopover, - GlDeprecatedButton, - GlLink, - Icon, - }, - props: { - dismissEndpoint: { - type: String, - required: true, - }, - featureId: { - type: String, - required: true, - }, - }, - data() { - return { - showPopover: false, - }; - }, - mounted() { - setTimeout(() => { - this.showPopover = true; - }, 2000); - }, - methods: { - onDismiss() { - this.showPopover = false; - - axios.post(this.dismissEndpoint, { - feature_name: this.featureId, - }); - }, - }, -}; -</script> - -<template> - <gl-popover target="#diffs-tab" placement="bottom" :show="showPopover"> - <p class="mb-2"> - {{ - __( - 'Now you can access the merge request navigation tabs at the top, where they’re easier to find.', - ) - }} - </p> - <p> - <gl-link href="https://gitlab.com/gitlab-org/gitlab/issues/36125" target="_blank"> - {{ __('More information and share feedback') }} - <icon name="external-link" :size="10" /> - </gl-link> - </p> - <gl-deprecated-button - variant="primary" - size="sm" - data-qa-selector="dismiss_popover_button" - @click="onDismiss" - > - {{ __('Got it') }} - </gl-deprecated-button> - </gl-popover> -</template> diff --git a/app/assets/javascripts/mr_tabs_popover/index.js b/app/assets/javascripts/mr_tabs_popover/index.js deleted file mode 100644 index 9ee0ba046f0..00000000000 --- a/app/assets/javascripts/mr_tabs_popover/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; -import Popover from './components/popover.vue'; - -export default el => - new Vue({ - el, - render(createElement) { - return createElement(Popover, { - props: { dismissEndpoint: el.dataset.dismissEndpoint, featureId: el.dataset.featureId }, - }); - }, - }); diff --git a/app/assets/javascripts/namespaces/leave_by_url.js b/app/assets/javascripts/namespaces/leave_by_url.js index b817d38960c..bf77617d516 100644 --- a/app/assets/javascripts/namespaces/leave_by_url.js +++ b/app/assets/javascripts/namespaces/leave_by_url.js @@ -1,4 +1,4 @@ -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { __, sprintf } from '~/locale'; import { getParameterByName } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/network/branch_graph.js b/app/assets/javascripts/network/branch_graph.js index 3cc95168ba1..3ea597a08d3 100644 --- a/app/assets/javascripts/network/branch_graph.js +++ b/app/assets/javascripts/network/branch_graph.js @@ -40,9 +40,6 @@ export default class BranchGraph { } prepareData(days, commits) { - let c = 0; - let j = 0; - let len = 0; this.days = days; this.commits = commits; this.collectParents(); @@ -53,38 +50,33 @@ export default class BranchGraph { this.r = Raphael(this.element.get(0), cw, ch); this.top = this.r.set(); this.barHeight = Math.max(this.graphHeight, this.unitTime * this.days.length + 320); - const ref = this.commits; - for (j = 0, len = ref.length; j < len; j += 1) { - c = ref[j]; - if (c.id in this.parents) { - c.isParent = true; + this.commits = this.commits.reduce((acc, commit) => { + const updatedCommit = commit; + if (commit.id in this.parents) { + updatedCommit.isParent = true; } - this.preparedCommits[c.id] = c; - this.markCommit(c); - } + acc.push(updatedCommit); + this.preparedCommits[commit.id] = commit; + this.markCommit(commit); + return acc; + }, []); return this.collectColors(); } collectParents() { - let j = 0; - let l = 0; - let len = 0; - let len1 = 0; const ref = this.commits; const results = []; - for (j = 0, len = ref.length; j < len; j += 1) { - const c = ref[j]; + ref.forEach(c => { this.mtime = Math.max(this.mtime, c.time); this.mspace = Math.max(this.mspace, c.space); const ref1 = c.parents; const results1 = []; - for (l = 0, len1 = ref1.length; l < len1; l += 1) { - const p = ref1[l]; + ref1.forEach(p => { this.parents[p[0]] = true; results1.push((this.mspace = Math.max(this.mspace, p[1]))); - } + }); results.push(results1); - } + }); return results; } @@ -114,7 +106,6 @@ export default class BranchGraph { fill: '#444', }); const ref = this.days; - for (mm = 0, len = ref.length; mm < len; mm += 1) { const day = ref[mm]; if (cuday !== day[0] || cumonth !== day[1]) { @@ -295,7 +286,6 @@ export default class BranchGraph { const { r } = this; const ref = commit.parents; const results = []; - for (i = 0, len = ref.length; i < len; i += 1) { const parent = ref[i]; const parentCommit = this.preparedCommits[parent[0]]; diff --git a/app/assets/javascripts/notebook/cells/markdown.vue b/app/assets/javascripts/notebook/cells/markdown.vue index fcb09ea90db..fa1afdcd16f 100644 --- a/app/assets/javascripts/notebook/cells/markdown.vue +++ b/app/assets/javascripts/notebook/cells/markdown.vue @@ -1,6 +1,6 @@ <script> import marked from 'marked'; -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; import katex from 'katex'; import Prompt from './prompt.vue'; @@ -104,65 +104,58 @@ export default { return sanitize(marked(this.cell.source.join('').replace(/\\/g, '\\\\')), { // allowedTags from GitLab's inline HTML guidelines // https://docs.gitlab.com/ee/user/markdown.html#inline-html - allowedTags: [ + ALLOWED_TAGS: [ + 'a', + 'abbr', + 'b', + 'blockquote', + 'br', + 'code', + 'dd', + 'del', + 'div', + 'dl', + 'dt', + 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'h7', - 'h8', - 'br', - 'b', + 'hr', 'i', - 'strong', - 'em', - 'a', - 'pre', - 'code', 'img', - 'tt', - 'div', 'ins', - 'del', - 'sup', - 'sub', - 'p', - 'ol', - 'ul', - 'table', - 'thead', - 'tbody', - 'tfoot', - 'blockquote', - 'dl', - 'dt', - 'dd', 'kbd', + 'li', + 'ol', + 'p', + 'pre', 'q', - 'samp', - 'var', - 'hr', - 'ruby', - 'rt', 'rp', - 'li', - 'tr', - 'td', - 'th', + 'rt', + 'ruby', 's', - 'strike', + 'samp', 'span', - 'abbr', - 'abbr', + 'strike', + 'strong', + 'sub', 'summary', + 'sup', + 'table', + 'tbody', + 'td', + 'tfoot', + 'th', + 'thead', + 'tr', + 'tt', + 'ul', + 'var', ], - allowedAttributes: { - '*': ['class', 'style'], - a: ['href'], - img: ['src'], - }, + ALLOWED_ATTR: ['class', 'style', 'href', 'src'], }); }, }, diff --git a/app/assets/javascripts/notebook/cells/output/html.vue b/app/assets/javascripts/notebook/cells/output/html.vue index 8dc2d73af9b..b36761993ea 100644 --- a/app/assets/javascripts/notebook/cells/output/html.vue +++ b/app/assets/javascripts/notebook/cells/output/html.vue @@ -1,5 +1,5 @@ <script> -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; import Prompt from '../prompt.vue'; export default { @@ -23,10 +23,7 @@ export default { computed: { sanitizedOutput() { return sanitize(this.rawCode, { - allowedTags: sanitize.defaults.allowedTags.concat(['img', 'svg']), - allowedAttributes: { - img: ['src'], - }, + ALLOWED_ATTR: ['src'], }); }, showOutput() { diff --git a/app/assets/javascripts/notes.js b/app/assets/javascripts/notes.js index f4982507adb..3940b4b4724 100644 --- a/app/assets/javascripts/notes.js +++ b/app/assets/javascripts/notes.js @@ -22,7 +22,7 @@ import AjaxCache from '~/lib/utils/ajax_cache'; import syntaxHighlight from '~/syntax_highlight'; import axios from './lib/utils/axios_utils'; import { getLocationHash } from './lib/utils/url_utility'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; import { defaultAutocompleteConfig } from './gfm_auto_complete'; import CommentTypeToggle from './comment_type_toggle'; import GLForm from './gl_form'; @@ -1336,11 +1336,12 @@ export default class Notes { toggleCommitList(e) { const $element = $(e.currentTarget); const $closestSystemCommitList = $element.siblings('.system-note-commit-list'); + const $svgChevronUpElement = $element.find('svg.js-chevron-up'); + const $svgChevronDownElement = $element.find('svg.js-chevron-down'); + + $svgChevronUpElement.toggleClass('gl-display-none'); + $svgChevronDownElement.toggleClass('gl-display-none'); - $element - .find('.fa') - .toggleClass('fa-angle-down') - .toggleClass('fa-angle-up'); $closestSystemCommitList.toggleClass('hide-shade'); } diff --git a/app/assets/javascripts/notes/components/comment_form.vue b/app/assets/javascripts/notes/components/comment_form.vue index ac93d3df654..7cfff98e9f7 100644 --- a/app/assets/javascripts/notes/components/comment_form.vue +++ b/app/assets/javascripts/notes/components/comment_form.vue @@ -6,7 +6,7 @@ import Autosize from 'autosize'; import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import Autosave from '../../autosave'; import { capitalizeFirstCharacter, diff --git a/app/assets/javascripts/notes/components/discussion_actions.vue b/app/assets/javascripts/notes/components/discussion_actions.vue index 251199f1778..878a748e99a 100644 --- a/app/assets/javascripts/notes/components/discussion_actions.vue +++ b/app/assets/javascripts/notes/components/discussion_actions.vue @@ -3,6 +3,7 @@ import ReplyPlaceholder from './discussion_reply_placeholder.vue'; import ResolveDiscussionButton from './discussion_resolve_button.vue'; import ResolveWithIssueButton from './discussion_resolve_with_issue_button.vue'; import JumpToNextDiscussionButton from './discussion_jump_to_next_button.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'DiscussionActions', @@ -12,6 +13,7 @@ export default { ResolveWithIssueButton, JumpToNextDiscussionButton, }, + mixins: [glFeatureFlagsMixin()], props: { discussion: { type: Object, @@ -36,6 +38,9 @@ export default { }, }, computed: { + hideJumpToNextUnresolvedInThreads() { + return this.glFeatures.hideJumpToNextUnresolvedInThreads; + }, resolvableNotes() { return this.discussion.notes.filter(x => x.resolvable); }, @@ -70,7 +75,11 @@ export default { /> </div> <div - v-if="discussion.resolvable && shouldShowJumpToNextDiscussion" + v-if=" + !hideJumpToNextUnresolvedInThreads && + discussion.resolvable && + shouldShowJumpToNextDiscussion + " class="btn-group discussion-actions ml-sm-2" > <jump-to-next-discussion-button :from-discussion-id="discussion.id" /> diff --git a/app/assets/javascripts/notes/components/discussion_filter_note.vue b/app/assets/javascripts/notes/components/discussion_filter_note.vue index 25ff49fbd0f..8dc4b43d69a 100644 --- a/app/assets/javascripts/notes/components/discussion_filter_note.vue +++ b/app/assets/javascripts/notes/components/discussion_filter_note.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import { __, sprintf } from '~/locale'; @@ -7,7 +7,7 @@ import notesEventHub from '../event_hub'; export default { components: { - GlDeprecatedButton, + GlButton, Icon, }, computed: { @@ -40,12 +40,12 @@ export default { <div class="timeline-content"> <div ref="timelineContent" v-html="timelineContent"></div> <div class="discussion-filter-actions mt-2"> - <gl-deprecated-button ref="showAllActivity" variant="default" @click="selectFilter(0)"> + <gl-button ref="showAllActivity" variant="default" @click="selectFilter(0)"> {{ __('Show all activity') }} - </gl-deprecated-button> - <gl-deprecated-button ref="showComments" variant="default" @click="selectFilter(1)"> + </gl-button> + <gl-button ref="showComments" variant="default" @click="selectFilter(1)"> {{ __('Show comments only') }} - </gl-deprecated-button> + </gl-button> </div> </div> </li> diff --git a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue b/app/assets/javascripts/notes/components/discussion_navigator.vue index 2dc222d08f9..facc53e27a6 100644 --- a/app/assets/javascripts/notes/components/discussion_keyboard_navigator.vue +++ b/app/assets/javascripts/notes/components/discussion_navigator.vue @@ -2,9 +2,13 @@ /* global Mousetrap */ import 'mousetrap'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; +import eventHub from '~/notes/event_hub'; export default { mixins: [discussionNavigation], + created() { + eventHub.$on('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); + }, mounted() { Mousetrap.bind('n', this.jumpToNextDiscussion); Mousetrap.bind('p', this.jumpToPreviousDiscussion); @@ -12,6 +16,8 @@ export default { beforeDestroy() { Mousetrap.unbind('n'); Mousetrap.unbind('p'); + + eventHub.$off('jumpToFirstUnresolvedDiscussion', this.jumpToFirstUnresolvedDiscussion); }, render() { return this.$slots.default; diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 458da5cf67f..a1e887c47d0 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -9,6 +9,7 @@ import NoteableNote from './noteable_note.vue'; import ToggleRepliesWidget from './toggle_replies_widget.vue'; import NoteEditedText from './note_edited_text.vue'; import DiscussionNotesRepliesWrapper from './discussion_notes_replies_wrapper.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'DiscussionNotes', @@ -17,6 +18,7 @@ export default { NoteEditedText, DiscussionNotesRepliesWrapper, }, + mixins: [glFeatureFlagsMixin()], props: { discussion: { type: Object, @@ -93,6 +95,18 @@ export default { componentData(note) { return note.isPlaceholderNote ? note.notes[0] : note; }, + handleMouseEnter(discussion) { + if (this.glFeatures.multilineComments && discussion.position) { + this.setSelectedCommentPositionHover(discussion.position.line_range); + } + }, + handleMouseLeave(discussion) { + // Even though position isn't used here we still don't want to unecessarily call a mutation + // The lack of position tells us that highlighting is irrelevant in this context + if (this.glFeatures.multilineComments && discussion.position) { + this.setSelectedCommentPositionHover(); + } + }, }, }; </script> @@ -101,8 +115,8 @@ export default { <div class="discussion-notes"> <ul class="notes" - @mouseenter="setSelectedCommentPositionHover(discussion.position.line_range)" - @mouseleave="setSelectedCommentPositionHover()" + @mouseenter="handleMouseEnter(discussion)" + @mouseleave="handleMouseLeave(discussion)" > <template v-if="shouldGroupReplies"> <component diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index e5f78b1c7de..cace382ccd6 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -1,12 +1,10 @@ <script> -import { GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; export default { name: 'ResolveWithIssueButton', components: { - Icon, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -22,13 +20,12 @@ export default { <template> <div class="btn-group" role="group"> - <gl-deprecated-button + <gl-button v-gl-tooltip :href="url" :title="s__('MergeRequests|Resolve this thread in a new issue')" class="new-issue-for-discussion discussion-create-issue-btn" - > - <icon name="issue-new" /> - </gl-deprecated-button> + icon="issue-new" + /> </div> </template> diff --git a/app/assets/javascripts/notes/components/note_actions.vue b/app/assets/javascripts/notes/components/note_actions.vue index 7615b0518b7..a8ae7fb48f0 100644 --- a/app/assets/javascripts/notes/components/note_actions.vue +++ b/app/assets/javascripts/notes/components/note_actions.vue @@ -1,13 +1,13 @@ <script> -import { __ } from '~/locale'; import { mapGetters } from 'vuex'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status'; import Icon from '~/vue_shared/components/icon.vue'; import ReplyButton from './note_actions/reply_button.vue'; import eventHub from '~/sidebar/event_hub'; import Api from '~/api'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; export default { name: 'NoteActions', diff --git a/app/assets/javascripts/notes/components/note_awards_list.vue b/app/assets/javascripts/notes/components/note_awards_list.vue index 5181b5f26ee..cf3e991986c 100644 --- a/app/assets/javascripts/notes/components/note_awards_list.vue +++ b/app/assets/javascripts/notes/components/note_awards_list.vue @@ -1,7 +1,7 @@ <script> import { mapActions, mapGetters } from 'vuex'; import AwardsList from '~/vue_shared/components/awards_list.vue'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import { __ } from '~/locale'; export default { diff --git a/app/assets/javascripts/notes/components/note_header.vue b/app/assets/javascripts/notes/components/note_header.vue index 81812ee2279..9ded5ab648e 100644 --- a/app/assets/javascripts/notes/components/note_header.vue +++ b/app/assets/javascripts/notes/components/note_header.vue @@ -191,7 +191,7 @@ export default { name="eye-slash" :size="14" :title="s__('Notes|Private comments are accessible by internal staff only')" - class="gl-ml-1 gl-text-gray-800 align-middle" + class="gl-ml-1 gl-text-gray-700 align-middle" /> <slot name="extra-controls"></slot> <i diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index 7fe50d36c0c..b4176c6063b 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -7,7 +7,7 @@ import { clearDraft, getDiscussionReplyKey } from '~/lib/utils/autosave'; import icon from '~/vue_shared/components/icon.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import DraftNote from '~/batch_comments/components/draft_note.vue'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import diffDiscussionHeader from './diff_discussion_header.vue'; import noteSignedOutWidget from './note_signed_out_widget.vue'; @@ -149,9 +149,14 @@ export default { 'removePlaceholderNotes', 'toggleResolveNote', 'removeConvertedDiscussion', + 'expandDiscussion', ]), showReplyForm() { this.isReplying = true; + + if (!this.discussion.expanded) { + this.expandDiscussion({ discussionId: this.discussion.id }); + } }, cancelReplyForm(shouldConfirm, isDirty) { if (shouldConfirm && isDirty) { diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 9bf8cffe940..ce771e67cbb 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -7,7 +7,7 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { truncateSha } from '~/lib/utils/text_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import { __, s__, sprintf } from '../../locale'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import userAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import noteHeader from './note_header.vue'; import noteActions from './note_actions.vue'; @@ -23,7 +23,6 @@ import { commentLineOptions, formatLineRange, } from './multiline_comment_utils'; -import MultilineCommentForm from './multiline_comment_form.vue'; export default { name: 'NoteableNote', @@ -34,7 +33,6 @@ export default { noteActions, NoteBody, TimelineEntryItem, - MultilineCommentForm, }, mixins: [noteable, resolvable, glFeatureFlagsMixin()], props: { @@ -147,14 +145,17 @@ export default { return getEndLineNumber(this.lineRange); }, showMultiLineComment() { - if (!this.glFeatures.multilineComments || !this.discussionRoot) return false; - if (this.isEditing) return true; + if ( + !this.glFeatures.multilineComments || + !this.discussionRoot || + this.startLineNumber.length === 0 || + this.endLineNumber.length === 0 + ) + return false; return this.line && this.startLineNumber !== this.endLineNumber; }, commentLineOptions() { - if (!this.diffFile || !this.line) return []; - const sideA = this.line.type === 'new' ? 'right' : 'left'; const sideB = sideA === 'left' ? 'right' : 'left'; const lines = this.diffFile.highlighted_diff_lines.length @@ -207,6 +208,7 @@ export default { 'scrollToNoteIfNeeded', 'updateAssignees', 'setSelectedCommentPositionHover', + 'updateDiscussionPosition', ]), editHandler() { this.isEditing = true; @@ -249,8 +251,13 @@ export default { ...this.note.position, }; - if (this.commentLineStart && this.line) + if (this.discussionRoot && this.commentLineStart && this.line) { position.line_range = formatLineRange(this.commentLineStart, this.line); + this.updateDiscussionPosition({ + discussionId: this.note.discussion_id, + position, + }); + } this.$emit('handleUpdateNote', { note: this.note, @@ -337,28 +344,19 @@ export default { :data-note-id="note.id" class="note note-wrapper qa-noteable-note-item" > - <div v-if="showMultiLineComment" data-testid="multiline-comment"> - <multiline-comment-form - v-if="isEditing && note.position" - v-model="commentLineStart" - :line="line" - :comment-line-options="commentLineOptions" - :line-range="note.position.line_range" - class="gl-mb-3 gl-text-gray-700 gl-pb-3" - /> - <div - v-else - class="gl-mb-3 gl-text-gray-700 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" - > - <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> - <template #startLine> - <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> - </template> - <template #endLine> - <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span> - </template> - </gl-sprintf> - </div> + <div + v-if="showMultiLineComment" + data-testid="multiline-comment" + class="gl-mb-3 gl-text-gray-500 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-pb-3" + > + <gl-sprintf :message="__('Comment on lines %{startLine} to %{endLine}')"> + <template #startLine> + <span :class="getLineClasses(startLineNumber)">{{ startLineNumber }}</span> + </template> + <template #endLine> + <span :class="getLineClasses(endLineNumber)">{{ endLineNumber }}</span> + </template> + </gl-sprintf> </div> <div v-once class="timeline-icon"> <user-avatar-link diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue index faa6006945d..fb18be9386e 100644 --- a/app/assets/javascripts/notes/components/notes_app.vue +++ b/app/assets/javascripts/notes/components/notes_app.vue @@ -1,7 +1,7 @@ <script> import { mapGetters, mapActions } from 'vuex'; import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import * as constants from '../constants'; import eventHub from '../event_hub'; import noteableNote from './noteable_note.vue'; @@ -136,6 +136,8 @@ export default { } window.addEventListener('hashchange', this.handleHashChanged); + + eventHub.$on('notesApp.updateIssuableConfidentiality', this.setConfidentiality); }, updated() { this.$nextTick(() => { @@ -146,6 +148,7 @@ export default { beforeDestroy() { this.stopPolling(); window.removeEventListener('hashchange', this.handleHashChanged); + eventHub.$off('notesApp.updateIssuableConfidentiality', this.setConfidentiality); }, methods: { ...mapActions([ @@ -164,6 +167,7 @@ export default { 'startTaskList', 'convertToDiscussion', 'stopPolling', + 'setConfidentiality', ]), discussionIsIndividualNoteAndNotConverted(discussion) { return discussion.individual_note && !this.convertedDisscussionIds.includes(discussion.id); diff --git a/app/assets/javascripts/notes/event_hub.js b/app/assets/javascripts/notes/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/notes/event_hub.js +++ b/app/assets/javascripts/notes/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index ba814649078..7bf465482b3 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -20,6 +20,11 @@ document.addEventListener('DOMContentLoaded', () => { noteableData.noteableType = notesDataset.noteableType; noteableData.targetType = notesDataset.targetType; + if (noteableData.discussion_locked === null) { + // discussion_locked has never been set for this issuable. + // set to `false` for safety. + noteableData.discussion_locked = false; + } if (parsedUserData) { currentUserData = { diff --git a/app/assets/javascripts/notes/mixins/diff_line_note_form.js b/app/assets/javascripts/notes/mixins/diff_line_note_form.js index 9a2e86aeed2..c4a42eb1a98 100644 --- a/app/assets/javascripts/notes/mixins/diff_line_note_form.js +++ b/app/assets/javascripts/notes/mixins/diff_line_note_form.js @@ -1,7 +1,7 @@ import { mapActions, mapGetters, mapState } from 'vuex'; import { getDraftReplyFormData, getDraftFormData } from '~/batch_comments/utils'; import { TEXT_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import { clearDraft } from '~/lib/utils/autosave'; import { formatLineRange } from '~/notes/components/multiline_comment_utils'; diff --git a/app/assets/javascripts/notes/mixins/discussion_navigation.js b/app/assets/javascripts/notes/mixins/discussion_navigation.js index 889883a23d0..61298a15c5d 100644 --- a/app/assets/javascripts/notes/mixins/discussion_navigation.js +++ b/app/assets/javascripts/notes/mixins/discussion_navigation.js @@ -78,7 +78,7 @@ function handleDiscussionJump(self, fn, discussionId = self.currentDiscussionId) const isDiffView = window.mrTabs.currentAction === 'diffs'; const targetId = fn(discussionId, isDiffView); const discussion = self.getDiscussion(targetId); - const discussionFilePath = discussion.diff_file?.file_path; + const discussionFilePath = discussion?.diff_file?.file_path; if (discussionFilePath) { self.scrollToFile(discussionFilePath); @@ -113,6 +113,14 @@ export default { handleDiscussionJump(this, this.previousUnresolvedDiscussionId); }, + jumpToFirstUnresolvedDiscussion() { + this.setCurrentDiscussionId(null) + .then(() => { + this.jumpToNextDiscussion(); + }) + .catch(() => {}); + }, + /** * Go to the next discussion from the given discussionId * @param {String} discussionId The id we are jumping from diff --git a/app/assets/javascripts/notes/mixins/resolvable.js b/app/assets/javascripts/notes/mixins/resolvable.js index 16b7598ee09..087b5828cce 100644 --- a/app/assets/javascripts/notes/mixins/resolvable.js +++ b/app/assets/javascripts/notes/mixins/resolvable.js @@ -1,4 +1,4 @@ -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { __ } from '~/locale'; export default { diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js index 5b2ab255557..f6069b509e8 100644 --- a/app/assets/javascripts/notes/stores/actions.js +++ b/app/assets/javascripts/notes/stores/actions.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import Visibility from 'visibilityjs'; import axios from '~/lib/utils/axios_utils'; import TaskList from '../../task_list'; -import Flash from '../../flash'; +import { deprecatedCreateFlash as Flash } from '../../flash'; import Poll from '../../lib/utils/poll'; import * as types from './mutation_types'; import * as utils from './utils'; @@ -13,32 +13,35 @@ import sidebarTimeTrackingEventHub from '../../sidebar/event_hub'; import { isInViewport, scrollToElement, isInMRPage } from '../../lib/utils/common_utils'; import { mergeUrlParams } from '../../lib/utils/url_utility'; import mrWidgetEventHub from '../../vue_merge_request_widget/event_hub'; -import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql'; +import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; +import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; import { __, sprintf } from '~/locale'; import Api from '~/api'; let eTagPoll; -export const updateConfidentialityOnIssue = ({ commit, getters }, { confidential, fullPath }) => { - const { iid } = getters.getNoteableData; +export const updateLockedAttribute = ({ commit, getters }, { locked, fullPath }) => { + const { iid, targetType } = getters.getNoteableData; return utils.gqClient .mutate({ - mutation: updateIssueConfidentialMutation, + mutation: targetType === 'issue' ? updateIssueLockMutation : updateMergeRequestLockMutation, variables: { input: { projectPath: fullPath, iid: String(iid), - confidential, + locked, }, }, }) .then(({ data }) => { - const { - issueSetConfidential: { issue }, - } = data; + const discussionLocked = + targetType === 'issue' + ? data.issueSetLocked.issue.discussionLocked + : data.mergeRequestSetLocked.mergeRequest.discussionLocked; - commit(types.SET_ISSUE_CONFIDENTIAL, issue.confidential); + commit(types.SET_ISSUABLE_LOCK, discussionLocked); }); }; @@ -683,5 +686,32 @@ export const updateAssignees = ({ commit }, assignees) => { commit(types.UPDATE_ASSIGNEES, assignees); }; -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; +export const updateDiscussionPosition = ({ commit }, updatedPosition) => { + commit(types.UPDATE_DISCUSSION_POSITION, updatedPosition); +}; + +export const updateConfidentialityOnIssuable = ( + { getters, commit }, + { confidential, fullPath }, +) => { + const { iid } = getters.getNoteableData; + + return utils.gqClient + .mutate({ + mutation: updateIssueConfidentialMutation, + variables: { + input: { + projectPath: fullPath, + iid: String(iid), + confidential, + }, + }, + }) + .then(({ data }) => { + const { + issueSetConfidential: { issue }, + } = data; + + setConfidentiality({ commit }, issue.confidential); + }); +}; diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 85997b44bcc..7d60fbffb10 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -194,7 +194,9 @@ export const findUnresolvedDiscussionIdNeighbor = (state, getters) => ({ diffOrder, step, }) => { - const ids = getters.unresolvedDiscussionsIdsOrdered(diffOrder); + const diffIds = getters.unresolvedDiscussionsIdsOrdered(diffOrder); + const dateIds = getters.unresolvedDiscussionsIdsOrdered(false); + const ids = diffIds.length ? diffIds : dateIds; const index = ids.indexOf(discussionId) + step; if (index < 0 && step < 0) { @@ -229,6 +231,3 @@ export const getDiscussion = state => discussionId => state.discussions.find(discussion => discussion.id === discussionId); export const commentsDisabled = state => state.commentsDisabled; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/notes/stores/mutation_types.js b/app/assets/javascripts/notes/stores/mutation_types.js index f2236b18beb..eb3447291bc 100644 --- a/app/assets/javascripts/notes/stores/mutation_types.js +++ b/app/assets/javascripts/notes/stores/mutation_types.js @@ -12,6 +12,7 @@ export const SHOW_PLACEHOLDER_NOTE = 'SHOW_PLACEHOLDER_NOTE'; export const TOGGLE_AWARD = 'TOGGLE_AWARD'; export const UPDATE_NOTE = 'UPDATE_NOTE'; export const UPDATE_DISCUSSION = 'UPDATE_DISCUSSION'; +export const UPDATE_DISCUSSION_POSITION = 'UPDATE_DISCUSSION_POSITION'; export const SET_DISCUSSION_DIFF_LINES = 'SET_DISCUSSION_DIFF_LINES'; export const SET_NOTES_FETCHED_STATE = 'SET_NOTES_FETCHED_STATE'; export const SET_NOTES_LOADING_STATE = 'SET_NOTES_LOADING_STATE'; @@ -42,6 +43,7 @@ export const REOPEN_ISSUE = 'REOPEN_ISSUE'; export const TOGGLE_STATE_BUTTON_LOADING = 'TOGGLE_STATE_BUTTON_LOADING'; export const TOGGLE_BLOCKED_ISSUE_WARNING = 'TOGGLE_BLOCKED_ISSUE_WARNING'; export const SET_ISSUE_CONFIDENTIAL = 'SET_ISSUE_CONFIDENTIAL'; +export const SET_ISSUABLE_LOCK = 'SET_ISSUABLE_LOCK'; // Description version export const REQUEST_DESCRIPTION_VERSION = 'REQUEST_DESCRIPTION_VERSION'; diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index e5f1c11fb35..aa078f00569 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -99,6 +99,10 @@ export default { state.noteableData.confidential = data; }, + [types.SET_ISSUABLE_LOCK](state, locked) { + state.noteableData.discussion_locked = locked; + }, + [types.SET_USER_DATA](state, data) { Object.assign(state, { userData: data }); }, @@ -274,6 +278,11 @@ export default { Object.assign(selectedDiscussion, { ...note }); }, + [types.UPDATE_DISCUSSION_POSITION](state, { discussionId, position }) { + const selectedDiscussion = state.discussions.find(disc => disc.id === discussionId); + if (selectedDiscussion) Object.assign(selectedDiscussion.position, { ...position }); + }, + [types.CLOSE_ISSUE](state) { Object.assign(state.noteableData, { state: constants.CLOSED }); }, diff --git a/app/assets/javascripts/notifications_dropdown.js b/app/assets/javascripts/notifications_dropdown.js index 07e69fa297a..47fb5b271d1 100644 --- a/app/assets/javascripts/notifications_dropdown.js +++ b/app/assets/javascripts/notifications_dropdown.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; import { __ } from '~/locale'; export default function notificationsDropdown() { diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js index fa27994f598..0d1b95f75f8 100644 --- a/app/assets/javascripts/notifications_form.js +++ b/app/assets/javascripts/notifications_form.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; export default class NotificationsForm { constructor() { diff --git a/app/assets/javascripts/onboarding_issues/index.js b/app/assets/javascripts/onboarding_issues/index.js index 5a6f952ffdf..27f2f7f0e9d 100644 --- a/app/assets/javascripts/onboarding_issues/index.js +++ b/app/assets/javascripts/onboarding_issues/index.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import { parseBoolean, getCookie, setCookie, removeCookie } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import Tracking from '~/tracking'; const COOKIE_NAME = 'onboarding_issues_settings'; @@ -94,8 +94,12 @@ export const showLearnGitLabProjectPopover = () => { if (!el) return; const options = { - content: __( - 'Go to <strong>Issues</strong> > <strong>Boards</strong> to access your personalized learning issue board.', + content: sprintf( + __( + 'Go to %{strongStart}Issues%{strongEnd} > %{strongStart}Boards%{strongEnd} to access your personalized learning issue board.', + ), + { strongStart: '<strong>', strongEnd: '</strong>' }, + false, ), }; @@ -111,8 +115,12 @@ export const showLearnGitLabIssuesPopover = () => { if (!el) return; const options = { - content: __( - 'Go to <strong>Issues</strong> > <strong>Boards</strong> to access your personalized learning issue board.', + content: sprintf( + __( + 'Go to %{strongStart}Issues%{strongEnd} > %{strongStart}Boards%{strongEnd} to access your personalized learning issue board.', + ), + { strongStart: '<strong>', strongEnd: '</strong>' }, + false, ), }; diff --git a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue index 42c9d876595..d83146d2f5e 100644 --- a/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue +++ b/app/assets/javascripts/operation_settings/components/form_group/dashboard_timezone.vue @@ -1,7 +1,7 @@ <script> -import { s__ } from '~/locale'; import { mapState, mapActions } from 'vuex'; import { GlFormGroup, GlFormSelect } from '@gitlab/ui'; +import { s__ } from '~/locale'; import { timezones } from '~/monitoring/format_date'; export default { diff --git a/app/assets/javascripts/operation_settings/components/metrics_settings.vue b/app/assets/javascripts/operation_settings/components/metrics_settings.vue index 77c356e5a7f..9df6a412930 100644 --- a/app/assets/javascripts/operation_settings/components/metrics_settings.vue +++ b/app/assets/javascripts/operation_settings/components/metrics_settings.vue @@ -1,12 +1,12 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlDeprecatedButton, GlLink } from '@gitlab/ui'; +import { GlButton, GlLink } from '@gitlab/ui'; import ExternalDashboard from './form_group/external_dashboard.vue'; import DashboardTimezone from './form_group/dashboard_timezone.vue'; export default { components: { - GlDeprecatedButton, + GlButton, GlLink, ExternalDashboard, DashboardTimezone, @@ -32,9 +32,9 @@ export default { <section class="settings no-animate"> <div class="settings-header"> <h3 class="js-section-header h4"> - {{ s__('MetricsSettings|Metrics Dashboard') }} + {{ s__('MetricsSettings|Metrics dashboard') }} </h3> - <gl-deprecated-button class="js-settings-toggle">{{ __('Expand') }}</gl-deprecated-button> + <gl-button class="js-settings-toggle">{{ __('Expand') }}</gl-button> <p class="js-section-sub-header"> {{ s__('MetricsSettings|Manage Metrics Dashboard settings.') }} <gl-link :href="helpPage">{{ __('Learn more') }}</gl-link> @@ -44,9 +44,11 @@ export default { <form> <dashboard-timezone /> <external-dashboard /> - <gl-deprecated-button variant="success" @click="saveChanges"> - {{ __('Save Changes') }} - </gl-deprecated-button> + <div class="gl-display-flex gl-justify-content-end"> + <gl-button variant="success" category="primary" @click="saveChanges"> + {{ __('Save Changes') }} + </gl-button> + </div> </form> </div> </section> diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js index 122acb6bdcf..1d3adeefbd8 100644 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -1,6 +1,6 @@ import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; import * as mutationTypes from './mutation_types'; @@ -37,6 +37,3 @@ export const receiveSaveChangesError = (_, error) => { createFlash(`${__('There was an error saving your changes.')} ${message}`, 'alert'); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/packages/details/components/additional_metadata.vue b/app/assets/javascripts/packages/details/components/additional_metadata.vue new file mode 100644 index 00000000000..a3de6dd46c7 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/additional_metadata.vue @@ -0,0 +1,98 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import DetailsRow from '~/registry/shared/components/details_row.vue'; +import { generateConanRecipe } from '../utils'; +import { PackageType } from '../../shared/constants'; + +export default { + i18n: { + sourceText: s__('PackageRegistry|Source project located at %{link}'), + licenseText: s__('PackageRegistry|License information located at %{link}'), + recipeText: s__('PackageRegistry|Recipe: %{recipe}'), + appGroup: s__('PackageRegistry|App group: %{group}'), + appName: s__('PackageRegistry|App name: %{name}'), + }, + components: { + DetailsRow, + GlLink, + GlSprintf, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + }, + computed: { + conanRecipe() { + return generateConanRecipe(this.packageEntity); + }, + showMetadata() { + const visibilityConditions = { + [PackageType.NUGET]: this.packageEntity.nuget_metadatum, + [PackageType.CONAN]: this.packageEntity.conan_metadatum, + [PackageType.MAVEN]: this.packageEntity.maven_metadatum, + }; + return visibilityConditions[this.packageEntity.package_type]; + }, + }, +}; +</script> + +<template> + <div v-if="showMetadata"> + <h3 class="gl-font-lg" data-testid="title">{{ __('Additional Metadata') }}</h3> + + <div class="gl-bg-gray-50 gl-inset-border-1-gray-100 gl-rounded-base" data-testid="main"> + <template v-if="packageEntity.nuget_metadatum"> + <details-row icon="project" padding="gl-p-4" dashed data-testid="nuget-source"> + <gl-sprintf :message="$options.i18n.sourceText"> + <template #link> + <gl-link :href="packageEntity.nuget_metadatum.project_url" target="_blank">{{ + packageEntity.nuget_metadatum.project_url + }}</gl-link> + </template> + </gl-sprintf> + </details-row> + <details-row icon="license" padding="gl-p-4" data-testid="nuget-license"> + <gl-sprintf :message="$options.i18n.licenseText"> + <template #link> + <gl-link :href="packageEntity.nuget_metadatum.license_url" target="_blank">{{ + packageEntity.nuget_metadatum.license_url + }}</gl-link> + </template> + </gl-sprintf> + </details-row> + </template> + + <details-row + v-else-if="packageEntity.conan_metadatum" + icon="information-o" + padding="gl-p-4" + data-testid="conan-recipe" + > + <gl-sprintf :message="$options.i18n.recipeText"> + <template #recipe>{{ conanRecipe }}</template> + </gl-sprintf> + </details-row> + + <template v-else-if="packageEntity.maven_metadatum"> + <details-row icon="information-o" padding="gl-p-4" dashed data-testid="maven-app"> + <gl-sprintf :message="$options.i18n.appName"> + <template #name> + <strong>{{ packageEntity.maven_metadatum.app_name }}</strong> + </template> + </gl-sprintf> + </details-row> + <details-row icon="information-o" padding="gl-p-4" data-testid="maven-group"> + <gl-sprintf :message="$options.i18n.appGroup"> + <template #group> + <strong>{{ packageEntity.maven_metadatum.app_group }}</strong> + </template> + </gl-sprintf> + </details-row> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/app.vue b/app/assets/javascripts/packages/details/components/app.vue new file mode 100644 index 00000000000..dbb5f7be0a0 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/app.vue @@ -0,0 +1,289 @@ +<script> +import { + GlBadge, + GlButton, + GlModal, + GlModalDirective, + GlTooltipDirective, + GlLink, + GlEmptyState, + GlTab, + GlTabs, + GlTable, + GlSprintf, +} from '@gitlab/ui'; +import { mapActions, mapState } from 'vuex'; +import Tracking from '~/tracking'; +import PackageHistory from './package_history.vue'; +import PackageTitle from './package_title.vue'; +import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; +import PackageListRow from '../../shared/components/package_list_row.vue'; +import DependencyRow from './dependency_row.vue'; +import AdditionalMetadata from './additional_metadata.vue'; +import InstallationCommands from './installation_commands.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import { __, s__ } from '~/locale'; +import { PackageType, TrackingActions } from '../../shared/constants'; +import { packageTypeToTrackCategory } from '../../shared/utils'; + +export default { + name: 'PackagesApp', + components: { + GlBadge, + GlButton, + GlEmptyState, + GlLink, + GlModal, + GlTab, + GlTabs, + GlTable, + FileIcon, + GlSprintf, + PackageTitle, + PackagesListLoader, + PackageListRow, + DependencyRow, + PackageHistory, + AdditionalMetadata, + InstallationCommands, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModal: GlModalDirective, + }, + mixins: [timeagoMixin, Tracking.mixin()], + trackingActions: { ...TrackingActions }, + computed: { + ...mapState([ + 'projectName', + 'packageEntity', + 'packageFiles', + 'isLoading', + 'canDelete', + 'destroyPath', + 'svgPath', + 'npmPath', + 'npmHelpPath', + ]), + isValidPackage() { + return Boolean(this.packageEntity.name); + }, + canDeletePackage() { + return this.canDelete && this.destroyPath; + }, + filesTableRows() { + return this.packageFiles.map(x => ({ + name: x.file_name, + downloadPath: x.download_path, + size: this.formatSize(x.size), + created: x.created_at, + })); + }, + tracking() { + return { + category: packageTypeToTrackCategory(this.packageEntity.package_type), + }; + }, + hasVersions() { + return this.packageEntity.versions?.length > 0; + }, + packageDependencies() { + return this.packageEntity.dependency_links || []; + }, + showDependencies() { + return this.packageEntity.package_type === PackageType.NUGET; + }, + showFiles() { + return this.packageEntity?.package_type !== PackageType.COMPOSER; + }, + }, + methods: { + ...mapActions(['fetchPackageVersions']), + formatSize(size) { + return numberToHumanSize(size); + }, + cancelDelete() { + this.$refs.deleteModal.hide(); + }, + getPackageVersions() { + if (!this.packageEntity.versions) { + this.fetchPackageVersions(); + } + }, + }, + i18n: { + deleteModalTitle: s__(`PackageRegistry|Delete Package Version`), + deleteModalContent: s__( + `PackageRegistry|You are about to delete version %{version} of %{name}. Are you sure?`, + ), + }, + filesTableHeaderFields: [ + { + key: 'name', + label: __('Name'), + tdClass: 'd-flex align-items-center', + }, + { + key: 'size', + label: __('Size'), + }, + { + key: 'created', + label: __('Created'), + class: 'text-right', + }, + ], +}; +</script> + +<template> + <gl-empty-state + v-if="!isValidPackage" + :title="s__('PackageRegistry|Unable to load package')" + :description="s__('PackageRegistry|There was a problem fetching the details for this package.')" + :svg-path="svgPath" + /> + + <div v-else class="packages-app"> + <div class="detail-page-header d-flex justify-content-between flex-column flex-sm-row"> + <package-title /> + + <div class="mt-sm-2"> + <gl-button + v-if="canDeletePackage" + v-gl-modal="'delete-modal'" + class="js-delete-button" + variant="danger" + category="primary" + data-qa-selector="delete_button" + > + {{ __('Delete') }} + </gl-button> + </div> + </div> + + <gl-tabs> + <gl-tab :title="__('Detail')"> + <div data-qa-selector="package_information_content"> + <package-history :package-entity="packageEntity" :project-name="projectName" /> + + <installation-commands + :package-entity="packageEntity" + :npm-path="npmPath" + :npm-help-path="npmHelpPath" + /> + + <additional-metadata :package-entity="packageEntity" /> + </div> + + <template v-if="showFiles"> + <h3 class="gl-font-lg gl-mt-5">{{ __('Files') }}</h3> + <gl-table + :fields="$options.filesTableHeaderFields" + :items="filesTableRows" + tbody-tr-class="js-file-row" + > + <template #cell(name)="{ item }"> + <gl-link + :href="item.downloadPath" + class="js-file-download gl-relative" + @click="track($options.trackingActions.PULL_PACKAGE)" + > + <file-icon + :file-name="item.name" + css-classes="gl-relative file-icon" + class="gl-mr-1 gl-relative" + /> + <span class="gl-relative">{{ item.name }}</span> + </gl-link> + </template> + + <template #cell(created)="{ item }"> + <span v-gl-tooltip :title="tooltipTitle(item.created)">{{ + timeFormatted(item.created) + }}</span> + </template> + </gl-table> + </template> + </gl-tab> + + <gl-tab v-if="showDependencies" title-item-class="js-dependencies-tab"> + <template #title> + <span>{{ __('Dependencies') }}</span> + <gl-badge size="sm" data-testid="dependencies-badge">{{ + packageDependencies.length + }}</gl-badge> + </template> + + <template v-if="packageDependencies.length > 0"> + <dependency-row + v-for="(dep, index) in packageDependencies" + :key="index" + :dependency="dep" + /> + </template> + + <p v-else class="gl-mt-3" data-testid="no-dependencies-message"> + {{ s__('PackageRegistry|This NuGet package has no dependencies.') }} + </p> + </gl-tab> + + <gl-tab + :title="__('Other versions')" + title-item-class="js-versions-tab" + @click="getPackageVersions" + > + <template v-if="isLoading && !hasVersions"> + <packages-list-loader /> + </template> + + <template v-else-if="hasVersions"> + <package-list-row + v-for="v in packageEntity.versions" + :key="v.id" + :package-entity="{ name: packageEntity.name, ...v }" + :package-link="v.id.toString()" + :disable-delete="true" + :show-package-type="false" + /> + </template> + + <p v-else class="gl-mt-3" data-testid="no-versions-message"> + {{ s__('PackageRegistry|There are no other versions of this package.') }} + </p> + </gl-tab> + </gl-tabs> + + <gl-modal ref="deleteModal" class="js-delete-modal" modal-id="delete-modal"> + <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template> + <gl-sprintf :message="$options.i18n.deleteModalContent"> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + </gl-sprintf> + + <div slot="modal-footer" class="w-100"> + <div class="float-right"> + <gl-button @click="cancelDelete()">{{ __('Cancel') }}</gl-button> + <gl-button + ref="modal-delete-button" + data-method="delete" + :to="destroyPath" + variant="danger" + category="primary" + data-qa-selector="delete_modal_button" + @click="track($options.trackingActions.DELETE_PACKAGE)" + > + {{ __('Delete') }} + </gl-button> + </div> + </div> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/code_instruction.vue b/app/assets/javascripts/packages/details/components/code_instruction.vue new file mode 100644 index 00000000000..0719ddfcd2b --- /dev/null +++ b/app/assets/javascripts/packages/details/components/code_instruction.vue @@ -0,0 +1,63 @@ +<script> +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Tracking from '~/tracking'; +import { TrackingLabels } from '../constants'; + +export default { + name: 'CodeInstruction', + components: { + ClipboardButton, + }, + mixins: [ + Tracking.mixin({ + label: TrackingLabels.CODE_INSTRUCTION, + }), + ], + props: { + instruction: { + type: String, + required: true, + }, + copyText: { + type: String, + required: true, + }, + multiline: { + type: Boolean, + required: false, + default: false, + }, + trackingAction: { + type: String, + required: false, + default: '', + }, + }, + methods: { + trackCopy() { + if (this.trackingAction) { + this.track(this.trackingAction); + } + }, + }, +}; +</script> + +<template> + <div v-if="!multiline" class="input-group gl-mb-3"> + <input + :value="instruction" + type="text" + class="form-control monospace js-instruction-input" + readonly + @copy="trackCopy" + /> + <span class="input-group-append js-instruction-button" @click="trackCopy"> + <clipboard-button :text="instruction" :title="copyText" class="input-group-text" /> + </span> + </div> + + <div v-else> + <pre class="js-instruction-pre" @copy="trackCopy">{{ instruction }}</pre> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/composer_installation.vue b/app/assets/javascripts/packages/details/components/composer_installation.vue new file mode 100644 index 00000000000..1934da149ce --- /dev/null +++ b/app/assets/javascripts/packages/details/components/composer_installation.vue @@ -0,0 +1,60 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'ComposerInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['composerHelpPath']), + ...mapGetters(['composerRegistryInclude', 'composerPackageInclude']), + }, + i18n: { + registryInclude: s__('PackageRegistry|composer.json registry include'), + copyRegistryInclude: s__('PackageRegistry|Copy registry include'), + packageInclude: s__('PackageRegistry|composer.json require package include'), + copyPackageInclude: s__('PackageRegistry|Copy require package include'), + infoLine: s__( + 'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base" data-testid="registry-include-title"> + {{ $options.i18n.registryInclude }} + </h4> + + <code-instruction + :instruction="composerRegistryInclude" + :copy-text="$options.i18n.copyRegistryInclude" + :tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND" + /> + <h4 class="gl-font-base" data-testid="package-include-title"> + {{ $options.i18n.packageInclude }} + </h4> + <code-instruction + :instruction="composerPackageInclude" + :copy-text="$options.i18n.copyPackageInclude" + :tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND" + /> + <span data-testid="help-text"> + <gl-sprintf :message="$options.i18n.infoLine"> + <template #link="{ content }"> + <gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </span> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/conan_installation.vue b/app/assets/javascripts/packages/details/components/conan_installation.vue new file mode 100644 index 00000000000..cff7d73f1e8 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/conan_installation.vue @@ -0,0 +1,56 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'ConanInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['conanHelpPath']), + ...mapGetters(['conanInstallationCommand', 'conanSetupCommand']), + }, + i18n: { + helpText: s__( + 'PackageRegistry|For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Conan Command') }} + </h4> + + <code-instruction + :instruction="conanInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Conan Command')" + :tracking-action="$options.trackingActions.COPY_CONAN_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Add Conan Remote') }} + </h4> + <code-instruction + :instruction="conanSetupCommand" + :copy-text="s__('PackageRegistry|Copy Conan Setup Command')" + :tracking-action="$options.trackingActions.COPY_CONAN_SETUP_COMMAND" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="conanHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/dependency_row.vue b/app/assets/javascripts/packages/details/components/dependency_row.vue new file mode 100644 index 00000000000..788673d2881 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/dependency_row.vue @@ -0,0 +1,35 @@ +<script> +export default { + name: 'DependencyRow', + props: { + dependency: { + type: Object, + required: true, + }, + }, + computed: { + showVersion() { + return Boolean(this.dependency.version_pattern); + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row"> + <div class="table-section section-50"> + <strong class="gl-text-body">{{ dependency.name }}</strong> + <span v-if="dependency.target_framework" data-testid="target-framework" + >({{ dependency.target_framework }})</span + > + </div> + + <div + v-if="showVersion" + class="table-section section-50 gl-display-flex justify-content-md-end" + data-testid="version-pattern" + > + <span class="gl-text-body">{{ dependency.version_pattern }}</span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/history_element.vue b/app/assets/javascripts/packages/details/components/history_element.vue new file mode 100644 index 00000000000..8a51c1528cf --- /dev/null +++ b/app/assets/javascripts/packages/details/components/history_element.vue @@ -0,0 +1,35 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; + +export default { + name: 'HistoryElement', + components: { + GlIcon, + TimelineEntryItem, + }, + + props: { + icon: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <timeline-entry-item class="system-note note-wrapper gl-mb-6!"> + <div class="timeline-icon"> + <gl-icon :name="icon" /> + </div> + <div class="timeline-content"> + <div class="note-header"> + <span> + <slot></slot> + </span> + </div> + <div class="note-body"></div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/packages/details/components/installation_commands.vue b/app/assets/javascripts/packages/details/components/installation_commands.vue new file mode 100644 index 00000000000..138103020a7 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/installation_commands.vue @@ -0,0 +1,53 @@ +<script> +import ConanInstallation from './conan_installation.vue'; +import MavenInstallation from './maven_installation.vue'; +import NpmInstallation from './npm_installation.vue'; +import NugetInstallation from './nuget_installation.vue'; +import PypiInstallation from './pypi_installation.vue'; +import ComposerInstallation from './composer_installation.vue'; +import { PackageType } from '../../shared/constants'; + +export default { + name: 'InstallationCommands', + components: { + [PackageType.CONAN]: ConanInstallation, + [PackageType.MAVEN]: MavenInstallation, + [PackageType.NPM]: NpmInstallation, + [PackageType.NUGET]: NugetInstallation, + [PackageType.PYPI]: PypiInstallation, + [PackageType.COMPOSER]: ComposerInstallation, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + npmPath: { + type: String, + required: false, + default: '', + }, + npmHelpPath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + installationComponent() { + return this.$options.components[this.packageEntity.package_type]; + }, + }, +}; +</script> + +<template> + <div v-if="installationComponent"> + <component + :is="installationComponent" + :name="packageEntity.name" + :registry-url="npmPath" + :help-url="npmHelpPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/maven_installation.vue b/app/assets/javascripts/packages/details/components/maven_installation.vue new file mode 100644 index 00000000000..d6641c886a0 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/maven_installation.vue @@ -0,0 +1,84 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'MavenInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['mavenHelpPath']), + ...mapGetters(['mavenInstallationXml', 'mavenInstallationCommand', 'mavenSetupXml']), + }, + i18n: { + xmlText: s__( + `PackageRegistry|Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block.`, + ), + setupText: s__( + `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file.`, + ), + helpText: s__( + 'PackageRegistry|For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Maven XML') }} + </h4> + <p> + <gl-sprintf :message="$options.i18n.xmlText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <code-instruction + :instruction="mavenInstallationXml" + :copy-text="s__('PackageRegistry|Copy Maven XML')" + multiline + :tracking-action="$options.trackingActions.COPY_MAVEN_XML" + /> + + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Maven Command') }} + </h4> + <code-instruction + :instruction="mavenInstallationCommand" + :copy-text="s__('PackageRegistry|Copy Maven command')" + :tracking-action="$options.trackingActions.COPY_MAVEN_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <p> + <gl-sprintf :message="$options.i18n.setupText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <code-instruction + :instruction="mavenSetupXml" + :copy-text="s__('PackageRegistry|Copy Maven registry XML')" + multiline + :tracking-action="$options.trackingActions.COPY_MAVEN_SETUP" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="mavenHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/npm_installation.vue b/app/assets/javascripts/packages/details/components/npm_installation.vue new file mode 100644 index 00000000000..d7ff8428370 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/npm_installation.vue @@ -0,0 +1,80 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { NpmManager, TrackingActions } from '../constants'; + +export default { + name: 'NpmInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['npmHelpPath']), + ...mapGetters(['npmInstallationCommand', 'npmSetupCommand']), + npmCommand() { + return this.npmInstallationCommand(NpmManager.NPM); + }, + npmSetup() { + return this.npmSetupCommand(NpmManager.NPM); + }, + yarnCommand() { + return this.npmInstallationCommand(NpmManager.YARN); + }, + yarnSetupCommand() { + return this.npmSetupCommand(NpmManager.YARN); + }, + }, + i18n: { + helpText: s__( + 'PackageRegistry|You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base">{{ s__('PackageRegistry|npm command') }}</h4> + + <code-instruction + :instruction="npmCommand" + :copy-text="s__('PackageRegistry|Copy npm command')" + :tracking-action="$options.trackingActions.COPY_NPM_INSTALL_COMMAND" + /> + + <h4 class="gl-font-base">{{ s__('PackageRegistry|yarn command') }}</h4> + <code-instruction + :instruction="yarnCommand" + :copy-text="s__('PackageRegistry|Copy yarn command')" + :tracking-action="$options.trackingActions.COPY_YARN_INSTALL_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + + <h4 class="gl-font-base">{{ s__('PackageRegistry|npm command') }}</h4> + <code-instruction + :instruction="npmSetup" + :copy-text="s__('PackageRegistry|Copy npm setup command')" + :tracking-action="$options.trackingActions.COPY_NPM_SETUP_COMMAND" + /> + + <h4 class="gl-font-base">{{ s__('PackageRegistry|yarn command') }}</h4> + <code-instruction + :instruction="yarnSetupCommand" + :copy-text="s__('PackageRegistry|Copy yarn setup command')" + :tracking-action="$options.trackingActions.COPY_YARN_SETUP_COMMAND" + /> + + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="npmHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/nuget_installation.vue b/app/assets/javascripts/packages/details/components/nuget_installation.vue new file mode 100644 index 00000000000..150b6e3ab0f --- /dev/null +++ b/app/assets/javascripts/packages/details/components/nuget_installation.vue @@ -0,0 +1,55 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'NugetInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['nugetHelpPath']), + ...mapGetters(['nugetInstallationCommand', 'nugetSetupCommand']), + }, + i18n: { + helpText: s__( + 'PackageRegistry|For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|NuGet Command') }} + </h4> + <code-instruction + :instruction="nugetInstallationCommand" + :copy-text="s__('PackageRegistry|Copy NuGet Command')" + :tracking-action="$options.trackingActions.COPY_NUGET_INSTALL_COMMAND" + /> + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Add NuGet Source') }} + </h4> + + <code-instruction + :instruction="nugetSetupCommand" + :copy-text="s__('PackageRegistry|Copy NuGet Setup Command')" + :tracking-action="$options.trackingActions.COPY_NUGET_SETUP_COMMAND" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="nugetHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/package_history.vue b/app/assets/javascripts/packages/details/components/package_history.vue new file mode 100644 index 00000000000..96ce106884d --- /dev/null +++ b/app/assets/javascripts/packages/details/components/package_history.vue @@ -0,0 +1,114 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryElement from './history_element.vue'; + +export default { + name: 'PackageHistory', + i18n: { + createdOn: s__('PackageRegistry|%{name} version %{version} was created %{datetime}'), + updatedAtText: s__('PackageRegistry|%{name} version %{version} was updated %{datetime}'), + commitText: s__('PackageRegistry|Commit %{link} on branch %{branch}'), + pipelineText: s__('PackageRegistry|Pipeline %{link} triggered %{datetime} by %{author}'), + publishText: s__('PackageRegistry|Published to the %{project} Package Registry %{datetime}'), + }, + components: { + GlLink, + GlSprintf, + HistoryElement, + TimeAgoTooltip, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + projectName: { + type: String, + required: true, + }, + }, + data() { + return { + showDescription: false, + }; + }, + computed: { + packagePipeline() { + return this.packageEntity.pipeline?.id ? this.packageEntity.pipeline : null; + }, + }, +}; +</script> + +<template> + <div class="issuable-discussion"> + <h3 class="gl-font-lg" data-testid="title">{{ __('History') }}</h3> + <ul class="timeline main-notes-list notes gl-mb-4" data-testid="timeline"> + <history-element icon="clock" data-testid="created-on"> + <gl-sprintf :message="$options.i18n.createdOn"> + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.created_at" /> + </template> + </gl-sprintf> + </history-element> + <history-element icon="pencil" data-testid="updated-at"> + <gl-sprintf :message="$options.i18n.updatedAtText"> + <template #name> + <strong>{{ packageEntity.name }}</strong> + </template> + <template #version> + <strong>{{ packageEntity.version }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.updated_at" /> + </template> + </gl-sprintf> + </history-element> + <template v-if="packagePipeline"> + <history-element icon="commit" data-testid="commit"> + <gl-sprintf :message="$options.i18n.commitText"> + <template #link> + <gl-link :href="packagePipeline.project.commit_url">{{ + packagePipeline.sha + }}</gl-link> + </template> + <template #branch> + <strong>{{ packagePipeline.ref }}</strong> + </template> + </gl-sprintf> + </history-element> + <history-element icon="pipeline" data-testid="pipeline"> + <gl-sprintf :message="$options.i18n.pipelineText"> + <template #link> + <gl-link :href="packagePipeline.project.pipeline_url" + >#{{ packagePipeline.id }}</gl-link + > + </template> + <template #datetime> + <time-ago-tooltip :time="packagePipeline.created_at" /> + </template> + <template #author>{{ packagePipeline.user.name }}</template> + </gl-sprintf> + </history-element> + </template> + <history-element icon="package" data-testid="published"> + <gl-sprintf :message="$options.i18n.publishText"> + <template #project> + <strong>{{ projectName }}</strong> + </template> + <template #datetime> + <time-ago-tooltip :time="packageEntity.created_at" /> + </template> + </gl-sprintf> + </history-element> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/package_title.vue b/app/assets/javascripts/packages/details/components/package_title.vue new file mode 100644 index 00000000000..d07883e3e7a --- /dev/null +++ b/app/assets/javascripts/packages/details/components/package_title.vue @@ -0,0 +1,112 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import { GlAvatar, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import PackageTags from '../../shared/components/package_tags.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { __ } from '~/locale'; + +export default { + name: 'PackageTitle', + components: { + GlAvatar, + GlIcon, + GlLink, + GlSprintf, + PackageTags, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + computed: { + ...mapState(['packageEntity', 'packageFiles']), + ...mapGetters(['packageTypeDisplay', 'packagePipeline', 'packageIcon']), + hasTagsToDisplay() { + return Boolean(this.packageEntity.tags && this.packageEntity.tags.length); + }, + totalSize() { + return numberToHumanSize(this.packageFiles.reduce((acc, p) => acc + p.size, 0)); + }, + }, + i18n: { + packageInfo: __('v%{version} published %{timeAgo}'), + }, +}; +</script> + +<template> + <div class="gl-flex-direction-column"> + <div class="gl-display-flex"> + <gl-avatar + v-if="packageIcon" + :src="packageIcon" + shape="rect" + class="gl-align-self-center gl-mr-4" + data-testid="package-icon" + /> + + <div class="gl-display-flex gl-flex-direction-column"> + <h1 class="gl-font-size-h1 gl-mt-3 gl-mb-2"> + {{ packageEntity.name }} + </h1> + + <div class="gl-display-flex gl-align-items-center gl-text-gray-500"> + <gl-icon name="eye" class="gl-mr-3" /> + <gl-sprintf :message="$options.i18n.packageInfo"> + <template #version> + {{ packageEntity.version }} + </template> + + <template #timeAgo> + <span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)"> + {{ timeFormatted(packageEntity.created_at) }} + </span> + </template> + </gl-sprintf> + </div> + </div> + </div> + + <div class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3"> + <div v-if="packageTypeDisplay" class="gl-display-flex gl-align-items-center gl-mr-5"> + <gl-icon name="package" class="gl-text-gray-500 gl-mr-3" /> + <span data-testid="package-type" class="gl-font-weight-bold">{{ packageTypeDisplay }}</span> + </div> + + <div v-if="hasTagsToDisplay" class="gl-display-flex gl-align-items-center gl-mr-5"> + <package-tags :tag-display-limit="1" :tags="packageEntity.tags" /> + </div> + + <div v-if="packagePipeline" class="gl-display-flex gl-align-items-center gl-mr-5"> + <gl-icon name="review-list" class="gl-text-gray-500 gl-mr-3" /> + <gl-link + data-testid="pipeline-project" + :href="packagePipeline.project.web_url" + class="gl-font-weight-bold text-truncate" + > + {{ packagePipeline.project.name }} + </gl-link> + </div> + + <div + v-if="packagePipeline" + data-testid="package-ref" + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <gl-icon name="branch" class="gl-text-gray-500 gl-mr-3" /> + <span + v-gl-tooltip + class="gl-font-weight-bold text-truncate mw-xs" + :title="packagePipeline.ref" + >{{ packagePipeline.ref }}</span + > + </div> + + <div class="gl-display-flex gl-align-items-center gl-mr-5"> + <gl-icon name="disk" class="gl-text-gray-500 gl-mr-3" /> + <span data-testid="package-size" class="gl-font-weight-bold">{{ totalSize }}</span> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/components/pypi_installation.vue b/app/assets/javascripts/packages/details/components/pypi_installation.vue new file mode 100644 index 00000000000..f1c619fd6d3 --- /dev/null +++ b/app/assets/javascripts/packages/details/components/pypi_installation.vue @@ -0,0 +1,68 @@ +<script> +import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mapGetters, mapState } from 'vuex'; +import { s__ } from '~/locale'; +import CodeInstruction from './code_instruction.vue'; +import { TrackingActions } from '../constants'; + +export default { + name: 'PyPiInstallation', + components: { + CodeInstruction, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['pypiHelpPath']), + ...mapGetters(['pypiPipCommand', 'pypiSetupCommand']), + }, + i18n: { + setupText: s__( + `PackageRegistry|If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file.`, + ), + helpText: s__( + 'PackageRegistry|For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}.', + ), + }, + trackingActions: { ...TrackingActions }, +}; +</script> + +<template> + <div> + <h3 class="gl-font-lg">{{ __('Installation') }}</h3> + + <h4 class="gl-font-base"> + {{ s__('PackageRegistry|Pip Command') }} + </h4> + + <code-instruction + :instruction="pypiPipCommand" + :copy-text="s__('PackageRegistry|Copy Pip command')" + data-testid="pip-command" + :tracking-action="$options.trackingActions.COPY_PIP_INSTALL_COMMAND" + /> + + <h3 class="gl-font-lg">{{ __('Registry setup') }}</h3> + <p> + <gl-sprintf :message="$options.i18n.setupText"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <code-instruction + :instruction="pypiSetupCommand" + :copy-text="s__('PackageRegistry|Copy .pypirc content')" + data-testid="pypi-setup-content" + multiline + :tracking-action="$options.trackingActions.COPY_PYPI_SETUP_COMMAND" + /> + <gl-sprintf :message="$options.i18n.helpText"> + <template #link="{ content }"> + <gl-link :href="pypiHelpPath" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> +</template> diff --git a/app/assets/javascripts/packages/details/constants.js b/app/assets/javascripts/packages/details/constants.js new file mode 100644 index 00000000000..c6e1b388132 --- /dev/null +++ b/app/assets/javascripts/packages/details/constants.js @@ -0,0 +1,47 @@ +import { s__ } from '~/locale'; + +export const TrackingLabels = { + CODE_INSTRUCTION: 'code_instruction', + CONAN_INSTALLATION: 'conan_installation', + MAVEN_INSTALLATION: 'maven_installation', + NPM_INSTALLATION: 'npm_installation', + NUGET_INSTALLATION: 'nuget_installation', + PYPI_INSTALLATION: 'pypi_installation', + COMPOSER_INSTALLATION: 'composer_installation', +}; + +export const TrackingActions = { + INSTALLATION: 'installation', + REGISTRY_SETUP: 'registry_setup', + + COPY_CONAN_COMMAND: 'copy_conan_command', + COPY_CONAN_SETUP_COMMAND: 'copy_conan_setup_command', + + COPY_MAVEN_XML: 'copy_maven_xml', + COPY_MAVEN_COMMAND: 'copy_maven_command', + COPY_MAVEN_SETUP: 'copy_maven_setup_xml', + + COPY_NPM_INSTALL_COMMAND: 'copy_npm_install_command', + COPY_NPM_SETUP_COMMAND: 'copy_npm_setup_command', + + COPY_YARN_INSTALL_COMMAND: 'copy_yarn_install_command', + COPY_YARN_SETUP_COMMAND: 'copy_yarn_setup_command', + + COPY_NUGET_INSTALL_COMMAND: 'copy_nuget_install_command', + COPY_NUGET_SETUP_COMMAND: 'copy_nuget_setup_command', + + COPY_PIP_INSTALL_COMMAND: 'copy_pip_install_command', + COPY_PYPI_SETUP_COMMAND: 'copy_pypi_setup_command', + + COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND: 'copy_composer_registry_include_command', + COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND: 'copy_composer_package_include_command', +}; + +export const NpmManager = { + NPM: 'npm', + YARN: 'yarn', +}; + +export const FETCH_PACKAGE_VERSIONS_ERROR = s__( + 'PackageRegistry|Unable to fetch package version information.', +); diff --git a/app/assets/javascripts/packages/details/index.js b/app/assets/javascripts/packages/details/index.js new file mode 100644 index 00000000000..233da3e4a99 --- /dev/null +++ b/app/assets/javascripts/packages/details/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import PackagesApp from './components/app.vue'; +import Translate from '~/vue_shared/translate'; +import createStore from './store'; + +Vue.use(Translate); + +export default () => { + const el = document.querySelector('#js-vue-packages-detail'); + const { package: packageJson, canDelete: canDeleteStr, ...rest } = el.dataset; + const packageEntity = JSON.parse(packageJson); + const canDelete = canDeleteStr === 'true'; + + const store = createStore({ + packageEntity, + packageFiles: packageEntity.package_files, + canDelete, + ...rest, + }); + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + PackagesApp, + }, + store, + render(createElement) { + return createElement('packages-app'); + }, + }); +}; diff --git a/app/assets/javascripts/packages/details/store/actions.js b/app/assets/javascripts/packages/details/store/actions.js new file mode 100644 index 00000000000..cda80056e19 --- /dev/null +++ b/app/assets/javascripts/packages/details/store/actions.js @@ -0,0 +1,23 @@ +import Api from '~/api'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { FETCH_PACKAGE_VERSIONS_ERROR } from '../constants'; +import * as types from './mutation_types'; + +export default ({ commit, state }) => { + commit(types.SET_LOADING, true); + + const { project_id, id } = state.packageEntity; + + return Api.projectPackage(project_id, id) + .then(({ data }) => { + if (data.versions) { + commit(types.SET_PACKAGE_VERSIONS, data.versions.reverse()); + } + }) + .catch(() => { + createFlash(FETCH_PACKAGE_VERSIONS_ERROR); + }) + .finally(() => { + commit(types.SET_LOADING, false); + }); +}; diff --git a/app/assets/javascripts/packages/details/store/getters.js b/app/assets/javascripts/packages/details/store/getters.js new file mode 100644 index 00000000000..d1814d506ad --- /dev/null +++ b/app/assets/javascripts/packages/details/store/getters.js @@ -0,0 +1,115 @@ +import { generateConanRecipe } from '../utils'; +import { PackageType } from '../../shared/constants'; +import { getPackageTypeLabel } from '../../shared/utils'; +import { NpmManager } from '../constants'; + +export const packagePipeline = ({ packageEntity }) => { + return packageEntity?.pipeline || null; +}; + +export const packageTypeDisplay = ({ packageEntity }) => { + return getPackageTypeLabel(packageEntity.package_type); +}; + +export const packageIcon = ({ packageEntity }) => { + if (packageEntity.package_type === PackageType.NUGET) { + return packageEntity.nuget_metadatum?.icon_url || null; + } + + return null; +}; + +export const conanInstallationCommand = ({ packageEntity }) => { + const recipe = generateConanRecipe(packageEntity); + + // eslint-disable-next-line @gitlab/require-i18n-strings + return `conan install ${recipe} --remote=gitlab`; +}; + +export const conanSetupCommand = ({ conanPath }) => + // eslint-disable-next-line @gitlab/require-i18n-strings + `conan remote add gitlab ${conanPath}`; + +export const mavenInstallationXml = ({ packageEntity = {} }) => { + const { + app_group: appGroup = '', + app_name: appName = '', + app_version: appVersion = '', + } = packageEntity.maven_metadatum; + + return `<dependency> + <groupId>${appGroup}</groupId> + <artifactId>${appName}</artifactId> + <version>${appVersion}</version> +</dependency>`; +}; + +export const mavenInstallationCommand = ({ packageEntity = {} }) => { + const { + app_group: group = '', + app_name: name = '', + app_version: version = '', + } = packageEntity.maven_metadatum; + + return `mvn dependency:get -Dartifact=${group}:${name}:${version}`; +}; + +export const mavenSetupXml = ({ mavenPath }) => `<repositories> + <repository> + <id>gitlab-maven</id> + <url>${mavenPath}</url> + </repository> +</repositories> + +<distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>${mavenPath}</url> + </repository> + + <snapshotRepository> + <id>gitlab-maven</id> + <url>${mavenPath}</url> + </snapshotRepository> +</distributionManagement>`; + +export const npmInstallationCommand = ({ packageEntity }) => (type = NpmManager.NPM) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + const instruction = type === NpmManager.NPM ? 'npm i' : 'yarn add'; + + return `${instruction} ${packageEntity.name}`; +}; + +export const npmSetupCommand = ({ packageEntity, npmPath }) => (type = NpmManager.NPM) => { + const scope = packageEntity.name.substring(0, packageEntity.name.indexOf('/')); + + if (type === NpmManager.NPM) { + return `echo ${scope}:registry=${npmPath} >> .npmrc`; + } + + return `echo \\"${scope}:registry\\" \\"${npmPath}\\" >> .yarnrc`; +}; + +export const nugetInstallationCommand = ({ packageEntity }) => + `nuget install ${packageEntity.name} -Source "GitLab"`; + +export const nugetSetupCommand = ({ nugetPath }) => + `nuget source Add -Name "GitLab" -Source "${nugetPath}" -UserName <your_username> -Password <your_token>`; + +export const pypiPipCommand = ({ pypiPath, packageEntity }) => + // eslint-disable-next-line @gitlab/require-i18n-strings + `pip install ${packageEntity.name} --index-url ${pypiPath}`; + +export const pypiSetupCommand = ({ pypiSetupPath }) => `[gitlab] +repository = ${pypiSetupPath} +username = __token__ +password = <your personal access token>`; + +export const composerRegistryInclude = ({ composerPath }) => { + const base = { type: 'composer', url: composerPath }; + return JSON.stringify(base); +}; +export const composerPackageInclude = ({ packageEntity }) => { + const base = { [packageEntity.name]: packageEntity.version }; + return JSON.stringify(base); +}; diff --git a/app/assets/javascripts/packages/details/store/index.js b/app/assets/javascripts/packages/details/store/index.js new file mode 100644 index 00000000000..9687eb98544 --- /dev/null +++ b/app/assets/javascripts/packages/details/store/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import fetchPackageVersions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default (initialState = {}) => + new Vuex.Store({ + actions: { + fetchPackageVersions, + }, + getters, + mutations, + state: { + isLoading: false, + ...initialState, + }, + }); diff --git a/app/assets/javascripts/packages/details/store/mutation_types.js b/app/assets/javascripts/packages/details/store/mutation_types.js new file mode 100644 index 00000000000..340d668819c --- /dev/null +++ b/app/assets/javascripts/packages/details/store/mutation_types.js @@ -0,0 +1,2 @@ +export const SET_LOADING = 'SET_LOADING'; +export const SET_PACKAGE_VERSIONS = 'SET_PACKAGE_VERSIONS'; diff --git a/app/assets/javascripts/packages/details/store/mutations.js b/app/assets/javascripts/packages/details/store/mutations.js new file mode 100644 index 00000000000..e113638311b --- /dev/null +++ b/app/assets/javascripts/packages/details/store/mutations.js @@ -0,0 +1,14 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_LOADING](state, isLoading) { + state.isLoading = isLoading; + }, + + [types.SET_PACKAGE_VERSIONS](state, versions) { + state.packageEntity = { + ...state.packageEntity, + versions, + }; + }, +}; diff --git a/app/assets/javascripts/packages/details/utils.js b/app/assets/javascripts/packages/details/utils.js new file mode 100644 index 00000000000..454c83c9ccd --- /dev/null +++ b/app/assets/javascripts/packages/details/utils.js @@ -0,0 +1,23 @@ +import { TrackingActions } from './constants'; + +export const trackInstallationTabChange = { + methods: { + trackInstallationTabChange(tabIndex) { + const action = tabIndex === 0 ? TrackingActions.INSTALLATION : TrackingActions.REGISTRY_SETUP; + this.track(action, { label: this.trackingLabel }); + }, + }, +}; + +export function generateConanRecipe(packageEntity = {}) { + const { + name = '', + version = '', + conan_metadatum: { + package_username: packageUsername = '', + package_channel: packageChannel = '', + } = {}, + } = packageEntity; + + return `${name}/${version}@${packageUsername}/${packageChannel}`; +} diff --git a/app/assets/javascripts/packages/list/coming_soon/helpers.js b/app/assets/javascripts/packages/list/coming_soon/helpers.js new file mode 100644 index 00000000000..5b6a4b3aa87 --- /dev/null +++ b/app/assets/javascripts/packages/list/coming_soon/helpers.js @@ -0,0 +1,55 @@ +/** + * Context: + * https://gitlab.com/gitlab-org/gitlab/-/issues/198524 + * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/29491 + * + */ + +/** + * Constants + * + * LABEL_NAMES - an array of labels to filter issues in the GraphQL query + * WORKFLOW_PREFIX - the prefix for workflow labels + * ACCEPTING_CONTRIBUTIONS_TITLE - the accepting contributions label + */ +export const LABEL_NAMES = ['Package::Coming soon']; +const WORKFLOW_PREFIX = 'workflow::'; +const ACCEPTING_CONTRIBUTIONS_TITLE = 'accepting merge requests'; + +const setScoped = (label, scoped) => (label ? { ...label, scoped } : label); + +/** + * Finds workflow:: scoped labels and returns the first or null. + * @param {Object[]} labels Labels from the issue + */ +export const findWorkflowLabel = (labels = []) => + labels.find(l => l.title.toLowerCase().includes(WORKFLOW_PREFIX.toLowerCase())); + +/** + * Determines if an issue is accepting community contributions by checking if + * the "Accepting merge requests" label is present. + * @param {Object[]} labels + */ +export const findAcceptingContributionsLabel = (labels = []) => + labels.find(l => l.title.toLowerCase() === ACCEPTING_CONTRIBUTIONS_TITLE.toLowerCase()); + +/** + * Formats the GraphQL response into the format that the view template expects. + * @param {Object} data GraphQL response + */ +export const toViewModel = data => { + // This just flatterns the issues -> nodes and labels -> nodes hierarchy + // into an array of objects. + const issues = (data.project?.issues?.nodes || []).map(i => ({ + ...i, + labels: (i.labels?.nodes || []).map(node => node), + })); + + return issues.map(x => ({ + ...x, + labels: [ + setScoped(findWorkflowLabel(x.labels), true), + setScoped(findAcceptingContributionsLabel(x.labels), false), + ].filter(Boolean), + })); +}; diff --git a/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue b/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue new file mode 100644 index 00000000000..766402d3619 --- /dev/null +++ b/app/assets/javascripts/packages/list/coming_soon/packages_coming_soon.vue @@ -0,0 +1,172 @@ +<script> +import { + GlAlert, + GlEmptyState, + GlIcon, + GlLabel, + GlLink, + GlSkeletonLoader, + GlSprintf, +} from '@gitlab/ui'; +import { ApolloQuery } from 'vue-apollo'; +import Tracking from '~/tracking'; +import { TrackingActions } from '../../shared/constants'; +import { s__ } from '~/locale'; +import comingSoonIssuesQuery from './queries/issues.graphql'; +import { toViewModel, LABEL_NAMES } from './helpers'; + +export default { + name: 'ComingSoon', + components: { + GlAlert, + GlEmptyState, + GlIcon, + GlLabel, + GlLink, + GlSkeletonLoader, + GlSprintf, + ApolloQuery, + }, + mixins: [Tracking.mixin()], + props: { + illustration: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + suggestedContributionsPath: { + type: String, + required: true, + }, + }, + computed: { + variables() { + return { + projectPath: this.projectPath, + labelNames: LABEL_NAMES, + }; + }, + }, + mounted() { + this.track(TrackingActions.COMING_SOON_REQUESTED); + }, + methods: { + onIssueLinkClick(issueIid, label) { + this.track(TrackingActions.COMING_SOON_LIST, { + label, + value: issueIid, + }); + }, + onDocsLinkClick() { + this.track(TrackingActions.COMING_SOON_HELP); + }, + }, + loadingRows: 5, + i18n: { + alertTitle: s__('PackageRegistry|Upcoming package managers'), + alertIntro: s__( + "PackageRegistry|Is your favorite package manager missing? We'd love your help in building first-class support for it into GitLab! %{contributionLinkStart}Visit the contribution documentation%{contributionLinkEnd} to learn more about how to build support for new package managers into GitLab. Below is a list of package managers that are on our radar.", + ), + emptyStateTitle: s__('PackageRegistry|No upcoming issues'), + emptyStateDescription: s__('PackageRegistry|There are no upcoming issues to display.'), + }, + comingSoonIssuesQuery, + toViewModel, +}; +</script> + +<template> + <apollo-query + :query="$options.comingSoonIssuesQuery" + :variables="variables" + :update="$options.toViewModel" + > + <template #default="{ result: { data }, isLoading }"> + <div> + <gl-alert :title="$options.i18n.alertTitle" :dismissible="false" variant="tip"> + <gl-sprintf :message="$options.i18n.alertIntro"> + <template #contributionLink="{ content }"> + <gl-link + :href="suggestedContributionsPath" + target="_blank" + @click="onDocsLinkClick" + >{{ content }}</gl-link + > + </template> + </gl-sprintf> + </gl-alert> + </div> + + <div v-if="isLoading" class="gl-display-flex gl-flex-direction-column"> + <gl-skeleton-loader + v-for="index in $options.loadingRows" + :key="index" + :width="1000" + :height="80" + preserve-aspect-ratio="xMinYMax meet" + > + <rect width="700" height="10" x="0" y="16" rx="4" /> + <rect width="60" height="10" x="0" y="45" rx="4" /> + <rect width="60" height="10" x="70" y="45" rx="4" /> + </gl-skeleton-loader> + </div> + + <template v-else-if="data && data.length"> + <div + v-for="issue in data" + :key="issue.iid" + data-testid="issue-row" + class="gl-responsive-table-row gl-flex-direction-column gl-align-items-baseline" + > + <div class="table-section section-100 gl-white-space-normal text-truncate"> + <gl-link + data-testid="issue-title-link" + :href="issue.webUrl" + class="gl-text-gray-900 gl-font-weight-bold" + @click="onIssueLinkClick(issue.iid, issue.title)" + > + {{ issue.title }} + </gl-link> + </div> + + <div class="table-section section-100 gl-white-space-normal mt-md-3"> + <div class="gl-display-flex gl-text-gray-400"> + <gl-icon name="issues" class="gl-mr-2" /> + <gl-link + data-testid="issue-id-link" + :href="issue.webUrl" + class="gl-text-gray-400 gl-mr-5" + @click="onIssueLinkClick(issue.iid, issue.title)" + >#{{ issue.iid }}</gl-link + > + + <div v-if="issue.milestone" class="gl-display-flex gl-align-items-center gl-mr-5"> + <gl-icon name="clock" class="gl-mr-2" /> + <span data-testid="milestone">{{ issue.milestone.title }}</span> + </div> + + <gl-label + v-for="label in issue.labels" + :key="label.title" + class="gl-mr-3" + size="sm" + :background-color="label.color" + :title="label.title" + :scoped="Boolean(label.scoped)" + /> + </div> + </div> + </div> + </template> + + <gl-empty-state v-else :title="$options.i18n.emptyStateTitle" :svg-path="illustration"> + <template #description> + <p>{{ $options.i18n.emptyStateDescription }}</p> + </template> + </gl-empty-state> + </template> + </apollo-query> +</template> diff --git a/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql b/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql new file mode 100644 index 00000000000..36c27d9ad70 --- /dev/null +++ b/app/assets/javascripts/packages/list/coming_soon/queries/issues.graphql @@ -0,0 +1,20 @@ +query getComingSoonIssues($projectPath: ID!, $labelNames: [String]) { + project(fullPath: $projectPath) { + issues(state: opened, labelName: $labelNames) { + nodes { + iid + title + webUrl + labels { + nodes { + title + color + } + } + milestone { + title + } + } + } + } +} diff --git a/app/assets/javascripts/packages/list/components/packages_filter.vue b/app/assets/javascripts/packages/list/components/packages_filter.vue new file mode 100644 index 00000000000..17398071217 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/packages_filter.vue @@ -0,0 +1,21 @@ +<script> +import { GlSearchBoxByClick } from '@gitlab/ui'; +import { mapActions } from 'vuex'; + +export default { + components: { + GlSearchBoxByClick, + }, + methods: { + ...mapActions(['setFilter']), + }, +}; +</script> + +<template> + <gl-search-box-by-click + :placeholder="s__('PackageRegistry|Filter by name')" + @submit="$emit('filter')" + @input="setFilter" + /> +</template> diff --git a/app/assets/javascripts/packages/list/components/packages_list.vue b/app/assets/javascripts/packages/list/components/packages_list.vue new file mode 100644 index 00000000000..b26c6087e14 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/packages_list.vue @@ -0,0 +1,129 @@ +<script> +import { mapState, mapGetters } from 'vuex'; +import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { s__ } from '~/locale'; +import { TrackingActions } from '../../shared/constants'; +import { packageTypeToTrackCategory } from '../../shared/utils'; +import PackagesListLoader from '../../shared/components/packages_list_loader.vue'; +import PackagesListRow from '../../shared/components/package_list_row.vue'; + +export default { + components: { + GlPagination, + GlModal, + GlSprintf, + PackagesListLoader, + PackagesListRow, + }, + mixins: [Tracking.mixin()], + data() { + return { + itemToBeDeleted: null, + }; + }, + computed: { + ...mapState({ + perPage: state => state.pagination.perPage, + totalItems: state => state.pagination.total, + page: state => state.pagination.page, + isGroupPage: state => state.config.isGroupPage, + isLoading: 'isLoading', + }), + ...mapGetters({ list: 'getList' }), + currentPage: { + get() { + return this.page; + }, + set(value) { + this.$emit('page:changed', value); + }, + }, + isListEmpty() { + return !this.list || this.list.length === 0; + }, + modalAction() { + return s__('PackageRegistry|Delete package'); + }, + deletePackageName() { + return this.itemToBeDeleted?.name ?? ''; + }, + tracking() { + const category = this.itemToBeDeleted + ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type) + : undefined; + return { + category, + }; + }, + }, + methods: { + setItemToBeDeleted(item) { + this.itemToBeDeleted = { ...item }; + this.track(TrackingActions.REQUEST_DELETE_PACKAGE); + this.$refs.packageListDeleteModal.show(); + }, + deleteItemConfirmation() { + this.$emit('package:delete', this.itemToBeDeleted); + this.track(TrackingActions.DELETE_PACKAGE); + this.itemToBeDeleted = null; + }, + deleteItemCanceled() { + this.track(TrackingActions.CANCEL_DELETE_PACKAGE); + this.itemToBeDeleted = null; + }, + }, + i18n: { + deleteModalContent: s__( + 'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?', + ), + }, +}; +</script> + +<template> + <div class="d-flex flex-column"> + <slot v-if="isListEmpty && !isLoading" name="empty-state"></slot> + + <div v-else-if="isLoading"> + <packages-list-loader :is-group="isGroupPage" /> + </div> + + <template v-else> + <div data-qa-selector="packages-table"> + <packages-list-row + v-for="packageEntity in list" + :key="packageEntity.id" + :package-entity="packageEntity" + :package-link="packageEntity._links.web_path" + :is-group="isGroupPage" + @packageToDelete="setItemToBeDeleted" + /> + </div> + + <gl-pagination + v-model="currentPage" + :per-page="perPage" + :total-items="totalItems" + align="center" + class="w-100 mt-2" + /> + + <gl-modal + ref="packageListDeleteModal" + modal-id="confirm-delete-pacakge" + ok-variant="danger" + @ok="deleteItemConfirmation" + @cancel="deleteItemCanceled" + > + <template #modal-title>{{ modalAction }}</template> + <template #modal-ok>{{ modalAction }}</template> + <gl-sprintf :message="$options.i18n.deleteModalContent"> + <template #name> + <strong>{{ deletePackageName }}</strong> + </template> + </gl-sprintf> + </gl-modal> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages/list/components/packages_list_app.vue b/app/assets/javascripts/packages/list/components/packages_list_app.vue new file mode 100644 index 00000000000..ef242ea5f75 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/packages_list_app.vue @@ -0,0 +1,111 @@ +<script> +import { mapActions, mapState } from 'vuex'; +import { GlEmptyState, GlTab, GlTabs, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import PackageFilter from './packages_filter.vue'; +import PackageList from './packages_list.vue'; +import PackageSort from './packages_sort.vue'; +import { PACKAGE_REGISTRY_TABS } from '../constants'; +import PackagesComingSoon from '../coming_soon/packages_coming_soon.vue'; + +export default { + components: { + GlEmptyState, + GlTab, + GlTabs, + GlLink, + GlSprintf, + PackageFilter, + PackageList, + PackageSort, + PackagesComingSoon, + }, + computed: { + ...mapState({ + emptyListIllustration: state => state.config.emptyListIllustration, + emptyListHelpUrl: state => state.config.emptyListHelpUrl, + comingSoon: state => state.config.comingSoon, + filterQuery: state => state.filterQuery, + selectedType: state => state.selectedType, + }), + tabsToRender() { + return PACKAGE_REGISTRY_TABS; + }, + }, + mounted() { + this.requestPackagesList(); + }, + methods: { + ...mapActions(['requestPackagesList', 'requestDeletePackage', 'setSelectedType']), + onPageChanged(page) { + return this.requestPackagesList({ page }); + }, + onPackageDeleteRequest(item) { + return this.requestDeletePackage(item); + }, + tabChanged(index) { + const selected = PACKAGE_REGISTRY_TABS[index]; + + if (selected !== this.selectedType) { + this.setSelectedType(selected); + this.requestPackagesList(); + } + }, + emptyStateTitle({ title, type }) { + if (this.filterQuery) { + return s__('PackageRegistry|Sorry, your filter produced no results'); + } + + if (type) { + return sprintf(s__('PackageRegistry|There are no %{packageType} packages yet'), { + packageType: title, + }); + } + + return s__('PackageRegistry|There are no packages yet'); + }, + }, + i18n: { + widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'), + noResults: s__( + 'PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab.', + ), + }, +}; +</script> + +<template> + <gl-tabs @input="tabChanged"> + <template #tabs-end> + <div class="d-flex align-self-center ml-md-auto py-1 py-md-0"> + <package-filter class="mr-1" @filter="requestPackagesList" /> + <package-sort @sort:changed="requestPackagesList" /> + </div> + </template> + + <gl-tab v-for="(tab, index) in tabsToRender" :key="index" :title="tab.title"> + <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest"> + <template #empty-state> + <gl-empty-state :title="emptyStateTitle(tab)" :svg-path="emptyListIllustration"> + <template #description> + <gl-sprintf v-if="filterQuery" :message="$options.i18n.widenFilters" /> + <gl-sprintf v-else :message="$options.i18n.noResults"> + <template #noPackagesLink="{content}"> + <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + </template> + </package-list> + </gl-tab> + + <gl-tab v-if="comingSoon" :title="__('Coming soon')" lazy> + <packages-coming-soon + :illustration="emptyListIllustration" + :project-path="comingSoon.projectPath" + :suggested-contributions-path="comingSoon.suggestedContributions" + /> + </gl-tab> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/packages/list/components/packages_sort.vue b/app/assets/javascripts/packages/list/components/packages_sort.vue new file mode 100644 index 00000000000..fa8f4f39d54 --- /dev/null +++ b/app/assets/javascripts/packages/list/components/packages_sort.vue @@ -0,0 +1,60 @@ +<script> +import { GlSorting, GlSortingItem } from '@gitlab/ui'; +import { mapState, mapActions } from 'vuex'; +import { ASCENDING_ODER, DESCENDING_ORDER } from '../constants'; +import getTableHeaders from '../utils'; + +export default { + name: 'PackageSort', + components: { + GlSorting, + GlSortingItem, + }, + computed: { + ...mapState({ + isGroupPage: state => state.config.isGroupPage, + orderBy: state => state.sorting.orderBy, + sort: state => state.sorting.sort, + }), + sortText() { + const field = this.sortableFields.find(s => s.orderBy === this.orderBy); + return field ? field.label : ''; + }, + sortableFields() { + return getTableHeaders(this.isGroupPage); + }, + isSortAscending() { + return this.sort === ASCENDING_ODER; + }, + }, + methods: { + ...mapActions(['setSorting']), + onDirectionChange() { + const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER; + this.setSorting({ sort }); + this.$emit('sort:changed'); + }, + onSortItemClick(item) { + this.setSorting({ orderBy: item }); + this.$emit('sort:changed'); + }, + }, +}; +</script> + +<template> + <gl-sorting + :text="sortText" + :is-ascending="isSortAscending" + @sortDirectionChange="onDirectionChange" + > + <gl-sorting-item + v-for="item in sortableFields" + ref="packageListSortItem" + :key="item.key" + @click="onSortItemClick(item.orderBy)" + > + {{ item.label }} + </gl-sorting-item> + </gl-sorting> +</template> diff --git a/app/assets/javascripts/packages/list/constants.js b/app/assets/javascripts/packages/list/constants.js new file mode 100644 index 00000000000..510d04965cb --- /dev/null +++ b/app/assets/javascripts/packages/list/constants.js @@ -0,0 +1,101 @@ +import { __, s__ } from '~/locale'; +import { PackageType } from '../shared/constants'; + +export const FETCH_PACKAGES_LIST_ERROR_MESSAGE = __( + 'Something went wrong while fetching the packages list.', +); +export const FETCH_PACKAGE_ERROR_MESSAGE = __('Something went wrong while fetching the package.'); +export const DELETE_PACKAGE_ERROR_MESSAGE = __('Something went wrong while deleting the package.'); +export const DELETE_PACKAGE_SUCCESS_MESSAGE = __('Package deleted successfully'); + +export const DEFAULT_PAGE = 1; +export const DEFAULT_PAGE_SIZE = 20; + +export const GROUP_PAGE_TYPE = 'groups'; + +export const LIST_KEY_NAME = 'name'; +export const LIST_KEY_PROJECT = 'project_path'; +export const LIST_KEY_VERSION = 'version'; +export const LIST_KEY_PACKAGE_TYPE = 'package_type'; +export const LIST_KEY_CREATED_AT = 'created_at'; +export const LIST_KEY_ACTIONS = 'actions'; + +export const LIST_LABEL_NAME = __('Name'); +export const LIST_LABEL_PROJECT = __('Project'); +export const LIST_LABEL_VERSION = __('Version'); +export const LIST_LABEL_PACKAGE_TYPE = __('Type'); +export const LIST_LABEL_CREATED_AT = __('Created'); +export const LIST_LABEL_ACTIONS = ''; + +export const LIST_ORDER_BY_PACKAGE_TYPE = 'type'; + +export const ASCENDING_ODER = 'asc'; +export const DESCENDING_ORDER = 'desc'; + +// The following is not translated because it is used to build a JavaScript exception error message +export const MISSING_DELETE_PATH_ERROR = 'Missing delete_api_path link'; + +export const TABLE_HEADER_FIELDS = [ + { + key: LIST_KEY_NAME, + label: LIST_LABEL_NAME, + orderBy: LIST_KEY_NAME, + class: ['text-left'], + }, + { + key: LIST_KEY_PROJECT, + label: LIST_LABEL_PROJECT, + orderBy: LIST_KEY_PROJECT, + class: ['text-left'], + }, + { + key: LIST_KEY_VERSION, + label: LIST_LABEL_VERSION, + orderBy: LIST_KEY_VERSION, + class: ['text-center'], + }, + { + key: LIST_KEY_PACKAGE_TYPE, + label: LIST_LABEL_PACKAGE_TYPE, + orderBy: LIST_ORDER_BY_PACKAGE_TYPE, + class: ['text-center'], + }, + { + key: LIST_KEY_CREATED_AT, + label: LIST_LABEL_CREATED_AT, + orderBy: LIST_KEY_CREATED_AT, + class: ['text-center'], + }, +]; + +export const PACKAGE_REGISTRY_TABS = [ + { + title: __('All'), + type: null, + }, + { + title: s__('PackageRegistry|Composer'), + type: PackageType.COMPOSER, + }, + { + title: s__('PackageRegistry|Conan'), + type: PackageType.CONAN, + }, + + { + title: s__('PackageRegistry|Maven'), + type: PackageType.MAVEN, + }, + { + title: s__('PackageRegistry|NPM'), + type: PackageType.NPM, + }, + { + title: s__('PackageRegistry|NuGet'), + type: PackageType.NUGET, + }, + { + title: s__('PackageRegistry|PyPi'), + type: PackageType.PYPI, + }, +]; diff --git a/app/assets/javascripts/packages/list/packages_list_app_bundle.js b/app/assets/javascripts/packages/list/packages_list_app_bundle.js new file mode 100644 index 00000000000..2f240cff143 --- /dev/null +++ b/app/assets/javascripts/packages/list/packages_list_app_bundle.js @@ -0,0 +1,31 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import Translate from '~/vue_shared/translate'; +import { createStore } from './stores'; +import PackagesListApp from './components/packages_list_app.vue'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-vue-packages-list'); + const store = createStore(); + store.dispatch('setInitialState', el.dataset); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + store, + apolloProvider, + components: { + PackagesListApp, + }, + render(createElement) { + return createElement('packages-list-app'); + }, + }); +}; diff --git a/app/assets/javascripts/packages/list/stores/actions.js b/app/assets/javascripts/packages/list/stores/actions.js new file mode 100644 index 00000000000..0ed24aee2c5 --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/actions.js @@ -0,0 +1,73 @@ +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import * as types from './mutation_types'; +import { + FETCH_PACKAGES_LIST_ERROR_MESSAGE, + DELETE_PACKAGE_ERROR_MESSAGE, + DELETE_PACKAGE_SUCCESS_MESSAGE, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + MISSING_DELETE_PATH_ERROR, +} from '../constants'; +import { getNewPaginationPage } from '../utils'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); +export const setLoading = ({ commit }, data) => commit(types.SET_MAIN_LOADING, data); +export const setSorting = ({ commit }, data) => commit(types.SET_SORTING, data); +export const setSelectedType = ({ commit }, data) => commit(types.SET_SELECTED_TYPE, data); +export const setFilter = ({ commit }, data) => commit(types.SET_FILTER, data); + +export const receivePackagesListSuccess = ({ commit }, { data, headers }) => { + commit(types.SET_PACKAGE_LIST_SUCCESS, data); + commit(types.SET_PAGINATION, headers); +}; + +export const requestPackagesList = ({ dispatch, state }, params = {}) => { + dispatch('setLoading', true); + + const { page = DEFAULT_PAGE, per_page = DEFAULT_PAGE_SIZE } = params; + const { sort, orderBy } = state.sorting; + + const type = state.selectedType?.type?.toLowerCase(); + const nameFilter = state.filterQuery?.toLowerCase(); + const packageFilters = { package_type: type, package_name: nameFilter }; + + const apiMethod = state.config.isGroupPage ? 'groupPackages' : 'projectPackages'; + + return Api[apiMethod](state.config.resourceId, { + params: { page, per_page, sort, order_by: orderBy, ...packageFilters }, + }) + .then(({ data, headers }) => { + dispatch('receivePackagesListSuccess', { data, headers }); + }) + .catch(() => { + createFlash(FETCH_PACKAGES_LIST_ERROR_MESSAGE); + }) + .finally(() => { + dispatch('setLoading', false); + }); +}; + +export const requestDeletePackage = ({ dispatch, state }, { _links }) => { + if (!_links || !_links.delete_api_path) { + createFlash(DELETE_PACKAGE_ERROR_MESSAGE); + const error = new Error(MISSING_DELETE_PATH_ERROR); + return Promise.reject(error); + } + + dispatch('setLoading', true); + return axios + .delete(_links.delete_api_path) + .then(() => { + const { page: currentPage, perPage, total } = state.pagination; + const page = getNewPaginationPage(currentPage, perPage, total - 1); + + dispatch('requestPackagesList', { page }); + createFlash(DELETE_PACKAGE_SUCCESS_MESSAGE, 'success'); + }) + .catch(() => { + dispatch('setLoading', false); + createFlash(DELETE_PACKAGE_ERROR_MESSAGE); + }); +}; diff --git a/app/assets/javascripts/packages/list/stores/getters.js b/app/assets/javascripts/packages/list/stores/getters.js new file mode 100644 index 00000000000..0af7e453f19 --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/getters.js @@ -0,0 +1,5 @@ +import { LIST_KEY_PROJECT } from '../constants'; +import { beautifyPath } from '../../shared/utils'; + +export default state => + state.packages.map(p => ({ ...p, projectPathName: beautifyPath(p[LIST_KEY_PROJECT]) })); diff --git a/app/assets/javascripts/packages/list/stores/index.js b/app/assets/javascripts/packages/list/stores/index.js new file mode 100644 index 00000000000..1d6a4bf831d --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import getList from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state, + getters: { + getList, + }, + actions, + mutations, + }); + +export default createStore(); diff --git a/app/assets/javascripts/packages/list/stores/mutation_types.js b/app/assets/javascripts/packages/list/stores/mutation_types.js new file mode 100644 index 00000000000..a5a584ccf1f --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/mutation_types.js @@ -0,0 +1,8 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +export const SET_PACKAGE_LIST_SUCCESS = 'SET_PACKAGE_LIST_SUCCESS'; +export const SET_PAGINATION = 'SET_PAGINATION'; +export const SET_MAIN_LOADING = 'SET_MAIN_LOADING'; +export const SET_SORTING = 'SET_SORTING'; +export const SET_SELECTED_TYPE = 'SET_SELECTED_TYPE'; +export const SET_FILTER = 'SET_FILTER'; diff --git a/app/assets/javascripts/packages/list/stores/mutations.js b/app/assets/javascripts/packages/list/stores/mutations.js new file mode 100644 index 00000000000..a47ba356c0a --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/mutations.js @@ -0,0 +1,45 @@ +import * as types from './mutation_types'; +import { + parseIntPagination, + normalizeHeaders, + convertObjectPropsToCamelCase, +} from '~/lib/utils/common_utils'; +import { GROUP_PAGE_TYPE } from '../constants'; + +export default { + [types.SET_INITIAL_STATE](state, config) { + const { comingSoonJson, ...rest } = config; + const comingSoonObj = JSON.parse(comingSoonJson); + + state.config = { + ...rest, + comingSoon: comingSoonObj && convertObjectPropsToCamelCase(comingSoonObj), + isGroupPage: config.pageType === GROUP_PAGE_TYPE, + }; + }, + + [types.SET_PACKAGE_LIST_SUCCESS](state, packages) { + state.packages = packages; + }, + + [types.SET_MAIN_LOADING](state, isLoading) { + state.isLoading = isLoading; + }, + + [types.SET_PAGINATION](state, headers) { + const normalizedHeaders = normalizeHeaders(headers); + state.pagination = parseIntPagination(normalizedHeaders); + }, + + [types.SET_SORTING](state, sorting) { + state.sorting = { ...state.sorting, ...sorting }; + }, + + [types.SET_SELECTED_TYPE](state, type) { + state.selectedType = type; + }, + + [types.SET_FILTER](state, query) { + state.filterQuery = query; + }, +}; diff --git a/app/assets/javascripts/packages/list/stores/state.js b/app/assets/javascripts/packages/list/stores/state.js new file mode 100644 index 00000000000..18ab2390b87 --- /dev/null +++ b/app/assets/javascripts/packages/list/stores/state.js @@ -0,0 +1,57 @@ +import { PACKAGE_REGISTRY_TABS } from '../constants'; + +export default () => ({ + /** + * Determine if the component is loading data from the API + */ + isLoading: false, + /** + * configuration object, set once at store creation with the following structure + * { + * resourceId: String, + * pageType: String, + * emptyListIllustration: String, + * emptyListHelpUrl: String, + * comingSoon: { projectPath: String, suggestedContributions : String } | null; + * } + */ + config: {}, + /** + * Each object in `packages` has the following structure: + * { + * id: String + * name: String, + * version: String, + * package_type: String // endpoint to request the list + * } + */ + packages: [], + /** + * Pagination object has the following structure: + * { + * perPage: Number, + * page: Number + * total: Number + * } + */ + pagination: {}, + /** + * Sorting object has the following structure: + * { + * sort: String, + * orderBy: String + * } + */ + sorting: { + sort: 'desc', + orderBy: 'created_at', + }, + /** + * The search query that is used to filter packages by name + */ + filterQuery: '', + /** + * The selected TAB of the package types tabs + */ + selectedType: PACKAGE_REGISTRY_TABS[0], +}); diff --git a/app/assets/javascripts/packages/list/utils.js b/app/assets/javascripts/packages/list/utils.js new file mode 100644 index 00000000000..98d78db8706 --- /dev/null +++ b/app/assets/javascripts/packages/list/utils.js @@ -0,0 +1,25 @@ +import { LIST_KEY_PROJECT, TABLE_HEADER_FIELDS } from './constants'; + +export default isGroupPage => + TABLE_HEADER_FIELDS.filter(f => f.key !== LIST_KEY_PROJECT || isGroupPage); + +/** + * A small util function that works out if the delete action has deleted the + * last item on the current paginated page and if so, returns the previous + * page. This ensures the user won't end up on an empty paginated page. + * + * @param {number} currentPage The current page the user is on + * @param {number} perPage Number of items to display per page + * @param {number} totalPackages The total number of items + */ +export const getNewPaginationPage = (currentPage, perPage, totalItems) => { + if (totalItems <= perPage) { + return 1; + } + + if (currentPage > 1 && (currentPage - 1) * perPage >= totalItems) { + return currentPage - 1; + } + + return currentPage; +}; diff --git a/app/assets/javascripts/packages/shared/components/package_list_row.vue b/app/assets/javascripts/packages/shared/components/package_list_row.vue new file mode 100644 index 00000000000..e000279b794 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/package_list_row.vue @@ -0,0 +1,139 @@ +<script> +import { GlButton, GlIcon, GlLink, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import PackageTags from './package_tags.vue'; +import PublishMethod from './publish_method.vue'; +import { getPackageTypeLabel } from '../utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + name: 'PackageListRow', + components: { + GlButton, + GlIcon, + GlLink, + GlSprintf, + PackageTags, + PublishMethod, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + packageEntity: { + type: Object, + required: true, + }, + packageLink: { + type: String, + required: true, + }, + disableDelete: { + type: Boolean, + default: false, + required: false, + }, + isGroup: { + type: Boolean, + default: false, + required: false, + }, + showPackageType: { + type: Boolean, + default: true, + required: false, + }, + }, + computed: { + packageType() { + return getPackageTypeLabel(this.packageEntity.package_type); + }, + hasPipeline() { + return Boolean(this.packageEntity.pipeline); + }, + hasProjectLink() { + return Boolean(this.packageEntity.project_path); + }, + }, +}; +</script> + +<template> + <div class="gl-responsive-table-row" data-qa-selector="packages-row"> + <div class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap"> + <div class="d-flex align-items-center mr-2"> + <gl-link + :href="packageLink" + data-qa-selector="package_link" + class="text-dark font-weight-bold mb-md-1" + > + {{ packageEntity.name }} + </gl-link> + + <package-tags + v-if="packageEntity.tags && packageEntity.tags.length" + class="gl-ml-3" + :tags="packageEntity.tags" + hide-label + :tag-display-limit="1" + /> + </div> + + <div class="d-flex text-secondary text-truncate mt-md-2"> + <span>{{ packageEntity.version }}</span> + + <div v-if="hasPipeline" class="d-none d-md-inline-block ml-1"> + <gl-sprintf :message="s__('PackageRegistry|published by %{author}')"> + <template #author>{{ packageEntity.pipeline.user.name }}</template> + </gl-sprintf> + </div> + + <div v-if="hasProjectLink" class="d-flex align-items-center"> + <gl-icon name="review-list" class="text-secondary ml-2 mr-1" /> + + <gl-link + data-testid="packages-row-project" + :href="`/${packageEntity.project_path}`" + class="text-secondary" + >{{ packageEntity.projectPathName }}</gl-link + > + </div> + + <div v-if="showPackageType" class="d-flex align-items-center" data-testid="package-type"> + <gl-icon name="package" class="text-secondary ml-2 mr-1" /> + <span>{{ packageType }}</span> + </div> + </div> + </div> + + <div + class="table-section d-flex flex-md-column justify-content-between align-items-md-end" + :class="disableDelete ? 'section-50' : 'section-40'" + > + <publish-method :package-entity="packageEntity" :is-group="isGroup" /> + + <div class="text-secondary order-0 order-md-1 mt-md-2"> + <gl-sprintf :message="__('Created %{timestamp}')"> + <template #timestamp> + <span v-gl-tooltip :title="tooltipTitle(packageEntity.created_at)"> + {{ timeFormatted(packageEntity.created_at) }} + </span> + </template> + </gl-sprintf> + </div> + </div> + + <div v-if="!disableDelete" class="table-section section-10 d-flex justify-content-end"> + <gl-button + data-testid="action-delete" + icon="remove" + category="primary" + variant="danger" + :title="s__('PackageRegistry|Remove package')" + :aria-label="s__('PackageRegistry|Remove package')" + :disabled="!packageEntity._links.delete_api_path" + @click="$emit('packageToDelete', packageEntity)" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/components/package_tags.vue b/app/assets/javascripts/packages/shared/components/package_tags.vue new file mode 100644 index 00000000000..391f53c225b --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/package_tags.vue @@ -0,0 +1,108 @@ +<script> +import { GlBadge, GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + name: 'PackageTags', + components: { + GlBadge, + GlIcon, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tagDisplayLimit: { + type: Number, + required: false, + default: 2, + }, + tags: { + type: Array, + required: true, + default: () => [], + }, + hideLabel: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + tagCount() { + return this.tags.length; + }, + tagsToRender() { + return this.tags.slice(0, this.tagDisplayLimit); + }, + moreTagsDisplay() { + return Math.max(0, this.tags.length - this.tagDisplayLimit); + }, + moreTagsTooltip() { + if (this.moreTagsDisplay) { + return this.tags + .slice(this.tagDisplayLimit) + .map(x => x.name) + .join(', '); + } + + return ''; + }, + tagsDisplay() { + return n__('%d tag', '%d tags', this.tagCount); + }, + }, + methods: { + tagBadgeClass(index) { + return { + 'gl-display-none': true, + 'gl-display-flex': this.tagCount === 1, + 'd-md-flex': this.tagCount > 1, + 'gl-mr-2': index !== this.tagsToRender.length - 1, + 'gl-ml-3': !this.hideLabel && index === 0, + }; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <div v-if="!hideLabel" data-testid="tagLabel" class="gl-display-flex gl-align-items-center"> + <gl-icon name="labels" class="gl-text-gray-500 gl-mr-3" /> + <span class="gl-font-weight-bold">{{ tagsDisplay }}</span> + </div> + + <gl-badge + v-for="(tag, index) in tagsToRender" + :key="index" + data-testid="tagBadge" + :class="tagBadgeClass(index)" + variant="info" + >{{ tag.name }}</gl-badge + > + + <gl-badge + v-if="moreTagsDisplay" + v-gl-tooltip + data-testid="moreBadge" + variant="muted" + :title="moreTagsTooltip" + class="gl-display-none d-md-flex gl-ml-2" + ><gl-sprintf :message="__('+%{tags} more')"> + <template #tags> + {{ moreTagsDisplay }} + </template> + </gl-sprintf></gl-badge + > + + <gl-badge + v-if="moreTagsDisplay && hideLabel" + data-testid="moreBadge" + variant="muted" + class="d-md-none gl-ml-2" + >{{ tagsDisplay }}</gl-badge + > + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/components/packages_list_loader.vue b/app/assets/javascripts/packages/shared/components/packages_list_loader.vue new file mode 100644 index 00000000000..cd9ef74d467 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/packages_list_loader.vue @@ -0,0 +1,86 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, + props: { + isGroup: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + desktopShapes() { + return this.isGroup ? this.$options.shapes.groups : this.$options.shapes.projects; + }, + desktopHeight() { + return this.isGroup ? 38 : 54; + }, + mobileHeight() { + return this.isGroup ? 160 : 170; + }, + }, + shapes: { + groups: [ + { type: 'rect', width: '100', height: '10', x: '0', y: '15' }, + { type: 'rect', width: '100', height: '10', x: '195', y: '15' }, + { type: 'rect', width: '60', height: '10', x: '475', y: '15' }, + { type: 'rect', width: '60', height: '10', x: '675', y: '15' }, + { type: 'rect', width: '100', height: '10', x: '900', y: '15' }, + ], + projects: [ + { type: 'rect', width: '220', height: '10', x: '0', y: '20' }, + { type: 'rect', width: '60', height: '10', x: '305', y: '20' }, + { type: 'rect', width: '60', height: '10', x: '535', y: '20' }, + { type: 'rect', width: '100', height: '10', x: '760', y: '20' }, + { type: 'rect', width: '30', height: '30', x: '970', y: '10', ref: 'button-loader' }, + ], + }, + rowsToRender: { + mobile: 5, + desktop: 20, + }, +}; +</script> + +<template> + <div> + <div class="d-xs-flex flex-column d-md-none"> + <gl-skeleton-loader + v-for="index in $options.rowsToRender.mobile" + :key="index" + :width="500" + :height="mobileHeight" + preserve-aspect-ratio="xMinYMax meet" + > + <rect width="500" height="10" x="0" y="15" rx="4" /> + <rect width="500" height="10" x="0" y="45" rx="4" /> + <rect width="500" height="10" x="0" y="75" rx="4" /> + <rect width="500" height="10" x="0" y="105" rx="4" /> + <rect v-if="isGroup" width="500" height="10" x="0" y="135" rx="4" /> + <rect v-else width="30" height="30" x="470" y="135" rx="4" /> + </gl-skeleton-loader> + </div> + + <div class="d-none d-md-flex flex-column"> + <gl-skeleton-loader + v-for="index in $options.rowsToRender.desktop" + :key="index" + :width="1000" + :height="desktopHeight" + preserve-aspect-ratio="xMinYMax meet" + > + <component + :is="r.type" + v-for="(r, rIndex) in desktopShapes" + :key="rIndex" + rx="4" + v-bind="r" + /> + </gl-skeleton-loader> + </div> + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/components/publish_method.vue b/app/assets/javascripts/packages/shared/components/publish_method.vue new file mode 100644 index 00000000000..1e18562a421 --- /dev/null +++ b/app/assets/javascripts/packages/shared/components/publish_method.vue @@ -0,0 +1,61 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { getCommitLink } from '../utils'; + +export default { + name: 'PublishMethod', + components: { + ClipboardButton, + GlIcon, + GlLink, + }, + props: { + packageEntity: { + type: Object, + required: true, + }, + isGroup: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + hasPipeline() { + return Boolean(this.packageEntity.pipeline); + }, + packageShaShort() { + return this.packageEntity.pipeline?.sha.substring(0, 8); + }, + linkToCommit() { + return getCommitLink(this.packageEntity, this.isGroup); + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1"> + <template v-if="hasPipeline"> + <gl-icon name="git-merge" class="mr-1" /> + <strong ref="pipeline-ref" class="mr-1 text-dark">{{ packageEntity.pipeline.ref }}</strong> + + <gl-icon name="commit" class="mr-1" /> + <gl-link ref="pipeline-sha" :href="linkToCommit" class="mr-1">{{ packageShaShort }}</gl-link> + + <clipboard-button + :text="packageEntity.pipeline.sha" + :title="__('Copy commit SHA')" + css-class="border-0 text-secondary py-0 px-1" + /> + </template> + + <template v-else> + <gl-icon name="upload" class="mr-1" /> + <strong ref="manual-ref" class="text-dark">{{ + s__('PackageRegistry|Manually Published') + }}</strong> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages/shared/constants.js b/app/assets/javascripts/packages/shared/constants.js new file mode 100644 index 00000000000..279c2959fa9 --- /dev/null +++ b/app/assets/javascripts/packages/shared/constants.js @@ -0,0 +1,24 @@ +export const PackageType = { + CONAN: 'conan', + MAVEN: 'maven', + NPM: 'npm', + NUGET: 'nuget', + PYPI: 'pypi', + COMPOSER: 'composer', +}; + +export const TrackingActions = { + DELETE_PACKAGE: 'delete_package', + REQUEST_DELETE_PACKAGE: 'request_delete_package', + CANCEL_DELETE_PACKAGE: 'cancel_delete_package', + PULL_PACKAGE: 'pull_package', + COMING_SOON_REQUESTED: 'activate_coming_soon_requested', + COMING_SOON_LIST: 'click_coming_soon_issue_link', + COMING_SOON_HELP: 'click_coming_soon_documentation_link', +}; + +export const TrackingCategories = { + [PackageType.MAVEN]: 'MavenPackages', + [PackageType.NPM]: 'NpmPackages', + [PackageType.CONAN]: 'ConanPackages', +}; diff --git a/app/assets/javascripts/packages/shared/utils.js b/app/assets/javascripts/packages/shared/utils.js new file mode 100644 index 00000000000..a0c7389651d --- /dev/null +++ b/app/assets/javascripts/packages/shared/utils.js @@ -0,0 +1,36 @@ +import { s__ } from '~/locale'; +import { PackageType, TrackingCategories } from './constants'; + +export const packageTypeToTrackCategory = type => + // eslint-disable-next-line @gitlab/require-i18n-strings + `UI::${TrackingCategories[type]}`; + +export const beautifyPath = path => (path ? path.split('/').join(' / ') : ''); + +export const getPackageTypeLabel = packageType => { + switch (packageType) { + case PackageType.CONAN: + return s__('PackageType|Conan'); + case PackageType.MAVEN: + return s__('PackageType|Maven'); + case PackageType.NPM: + return s__('PackageType|NPM'); + case PackageType.NUGET: + return s__('PackageType|NuGet'); + case PackageType.PYPI: + return s__('PackageType|PyPi'); + case PackageType.COMPOSER: + return s__('PackageType|Composer'); + + default: + return null; + } +}; + +export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGroup = false) => { + if (isGroup) { + return `/${projectPath}/commit/${pipeline.sha}`; + } + + return `../commit/${pipeline.sha}`; +}; diff --git a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js index 413045d960e..e7b468f039f 100644 --- a/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js +++ b/app/assets/javascripts/pages/admin/application_settings/payload_previewer.js @@ -1,6 +1,6 @@ import axios from '../../../lib/utils/axios_utils'; import { __ } from '../../../locale'; -import flash from '../../../flash'; +import { deprecatedCreateFlash as flash } from '../../../flash'; export default class PayloadPreviewer { constructor(trigger, container) { diff --git a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js index f64e0bbbfda..a75f5d318a0 100644 --- a/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js +++ b/app/assets/javascripts/pages/admin/broadcast_messages/broadcast_message.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { debounce } from 'lodash'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import { textColorForBackground } from '~/lib/utils/color_utils'; diff --git a/app/assets/javascripts/pages/admin/clusters/show/index.js b/app/assets/javascripts/pages/admin/clusters/show/index.js index ccf631b2c53..f87da6c7074 100644 --- a/app/assets/javascripts/pages/admin/clusters/show/index.js +++ b/app/assets/javascripts/pages/admin/clusters/show/index.js @@ -1,7 +1,9 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initClusterHealth from '~/pages/projects/clusters/show/cluster_health'; +import initIntegrationForm from '~/clusters/forms/show'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new initClusterHealth(); + initIntegrationForm(); }); diff --git a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue index eb03baf4894..120512bf15e 100644 --- a/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue +++ b/app/assets/javascripts/pages/admin/jobs/index/components/stop_jobs_modal.vue @@ -1,6 +1,6 @@ <script> import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { redirectTo } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/pages/admin/runners/index.js b/app/assets/javascripts/pages/admin/runners/index.js index ce8fd18b6a2..e60c6133c7c 100644 --- a/app/assets/javascripts/pages/admin/runners/index.js +++ b/app/assets/javascripts/pages/admin/runners/index.js @@ -6,5 +6,6 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ADMIN_RUNNERS, filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, + useDefaultState: true, }); }); diff --git a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue index 71df677c7fd..e09b8e1bdd5 100644 --- a/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue +++ b/app/assets/javascripts/pages/admin/users/components/delete_user_modal.vue @@ -1,12 +1,12 @@ <script> import { escape } from 'lodash'; -import { GlModal, GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; +import { GlModal, GlButton, GlFormInput } from '@gitlab/ui'; import { s__, sprintf } from '~/locale'; export default { components: { GlModal, - GlDeprecatedButton, + GlButton, GlFormInput, }, props: { @@ -122,15 +122,18 @@ export default { </form> </template> <template #modal-footer> - <gl-deprecated-button variant="secondary" @click="onCancel">{{ - s__('Cancel') - }}</gl-deprecated-button> - <gl-deprecated-button :disabled="!canSubmit" variant="warning" @click="onSecondaryAction"> + <gl-button @click="onCancel">{{ s__('Cancel') }}</gl-button> + <gl-button + :disabled="!canSubmit" + category="primary" + variant="warning" + @click="onSecondaryAction" + > {{ secondaryAction }} - </gl-deprecated-button> - <gl-deprecated-button :disabled="!canSubmit" variant="danger" @click="onSubmit">{{ + </gl-button> + <gl-button :disabled="!canSubmit" category="primary" variant="danger" @click="onSubmit">{{ action - }}</gl-deprecated-button> + }}</gl-button> </template> </gl-modal> </template> diff --git a/app/assets/javascripts/pages/dashboard/issues/index.js b/app/assets/javascripts/pages/dashboard/issues/index.js index 2ffeed8a584..71cdaf45052 100644 --- a/app/assets/javascripts/pages/dashboard/issues/index.js +++ b/app/assets/javascripts/pages/dashboard/issues/index.js @@ -8,6 +8,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); projectSelect(); diff --git a/app/assets/javascripts/pages/dashboard/merge_requests/index.js b/app/assets/javascripts/pages/dashboard/merge_requests/index.js index 24d7b592948..10df18c85e7 100644 --- a/app/assets/javascripts/pages/dashboard/merge_requests/index.js +++ b/app/assets/javascripts/pages/dashboard/merge_requests/index.js @@ -10,6 +10,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); projectSelect(); diff --git a/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue new file mode 100644 index 00000000000..6b907f31a37 --- /dev/null +++ b/app/assets/javascripts/pages/dashboard/projects/index/components/customize_homepage_banner.vue @@ -0,0 +1,66 @@ +<script> +import { GlBanner } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlBanner, + }, + inject: { + svgPath: { + default: '', + }, + preferencesBehaviorPath: { + default: '', + }, + calloutsPath: { + default: '', + }, + calloutsFeatureId: { + default: '', + }, + }, + i18n: { + title: s__('CustomizeHomepageBanner|Do you want to customize this page?'), + body: s__( + 'CustomizeHomepageBanner|This page shows a list of your projects by default but it can be changed to show projects\' activity, groups, your to-do list, assigned issues, assigned merge requests, and more. You can change this under "Homepage content" in your preferences', + ), + button_text: s__('CustomizeHomepageBanner|Go to preferences'), + }, + data() { + return { + visible: true, + }; + }, + methods: { + handleClose() { + axios + .post(this.calloutsPath, { + feature_name: this.calloutsFeatureId, + }) + .catch(e => { + // eslint-disable-next-line @gitlab/require-i18n-strings, no-console + console.error('Failed to dismiss banner.', e); + }); + + this.visible = false; + }, + }, +}; +</script> + +<template> + <gl-banner + v-if="visible" + :title="$options.i18n.title" + :button-text="$options.i18n.button_text" + :button-link="preferencesBehaviorPath" + :svg-path="svgPath" + @close="handleClose" + > + <p> + {{ $options.i18n.body }} + </p> + </gl-banner> +</template> diff --git a/app/assets/javascripts/pages/dashboard/projects/index.js b/app/assets/javascripts/pages/dashboard/projects/index/index.js index 01001d4f3ff..b3c95f4ac1f 100644 --- a/app/assets/javascripts/pages/dashboard/projects/index.js +++ b/app/assets/javascripts/pages/dashboard/projects/index/index.js @@ -1,5 +1,8 @@ import ProjectsList from '~/projects_list'; +import initCustomizeHomepageBanner from './init_customize_homepage_banner'; document.addEventListener('DOMContentLoaded', () => { new ProjectsList(); // eslint-disable-line no-new + + initCustomizeHomepageBanner(); }); diff --git a/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js b/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js new file mode 100644 index 00000000000..c0735dde1da --- /dev/null +++ b/app/assets/javascripts/pages/dashboard/projects/index/init_customize_homepage_banner.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import CustomizeHomepageBanner from './components/customize_homepage_banner.vue'; + +export default () => { + const el = document.querySelector('.js-customize-homepage-banner'); + + if (!el) { + return false; + } + + return new Vue({ + el, + provide: { ...el.dataset }, + render: createElement => createElement(CustomizeHomepageBanner), + }); +}; diff --git a/app/assets/javascripts/pages/dashboard/todos/index/todos.js b/app/assets/javascripts/pages/dashboard/todos/index/todos.js index 5230bdf9cdd..f76b4b44452 100644 --- a/app/assets/javascripts/pages/dashboard/todos/index/todos.js +++ b/app/assets/javascripts/pages/dashboard/todos/index/todos.js @@ -7,7 +7,7 @@ import UsersSelect from '~/users_select'; import { isMetaClick } from '~/lib/utils/common_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; export default class Todos { diff --git a/app/assets/javascripts/pages/groups/clusters/index.js b/app/assets/javascripts/pages/groups/clusters/index.js index 4d04c37caa7..9f466e0d60a 100644 --- a/app/assets/javascripts/pages/groups/clusters/index.js +++ b/app/assets/javascripts/pages/groups/clusters/index.js @@ -1,5 +1,7 @@ import initCreateCluster from '~/create_cluster/init_create_cluster'; +import initIntegrationForm from '~/clusters/forms/show/index'; document.addEventListener('DOMContentLoaded', () => { initCreateCluster(document, gon); + initIntegrationForm(); }); diff --git a/app/assets/javascripts/pages/groups/issues/index.js b/app/assets/javascripts/pages/groups/issues/index.js index 4f15f5ec58c..2496003919a 100644 --- a/app/assets/javascripts/pages/groups/issues/index.js +++ b/app/assets/javascripts/pages/groups/issues/index.js @@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, isGroupDecendent: true, + useDefaultState: true, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); projectSelect(); diff --git a/app/assets/javascripts/pages/groups/merge_requests/index.js b/app/assets/javascripts/pages/groups/merge_requests/index.js index 13c5c350c24..71c67ac74ed 100644 --- a/app/assets/javascripts/pages/groups/merge_requests/index.js +++ b/app/assets/javascripts/pages/groups/merge_requests/index.js @@ -14,6 +14,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, isGroupDecendent: true, + useDefaultState: true, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, }); projectSelect(); diff --git a/app/assets/javascripts/pages/groups/new/group_path_validator.js b/app/assets/javascripts/pages/groups/new/group_path_validator.js index d2684b6af59..83b38b0f1a5 100644 --- a/app/assets/javascripts/pages/groups/new/group_path_validator.js +++ b/app/assets/javascripts/pages/groups/new/group_path_validator.js @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import InputValidator from '~/validators/input_validator'; import fetchGroupPathAvailability from './fetch_group_path_availability'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; const debounceTimeoutDuration = 1000; diff --git a/app/assets/javascripts/pages/groups/packages/index/index.js b/app/assets/javascripts/pages/groups/packages/index/index.js new file mode 100644 index 00000000000..4836900aa28 --- /dev/null +++ b/app/assets/javascripts/pages/groups/packages/index/index.js @@ -0,0 +1,7 @@ +import initPackageList from '~/packages/list/packages_list_app_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('js-vue-packages-list')) { + initPackageList(); + } +}); diff --git a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js index 23283f46a5d..add483843df 100644 --- a/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/groups/settings/ci_cd/show/index.js @@ -2,7 +2,7 @@ import initSettingsPanels from '~/settings_panels'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import initVariableList from '~/ci_variable_list'; import initFilteredSearch from '~/pages/search/init_filtered_search'; -import AdminRunnersFilteredSearchTokenKeys from '~/filtered_search/admin_runners_filtered_search_token_keys'; +import GroupRunnersFilteredSearchTokenKeys from '~/filtered_search/group_runners_filtered_search_token_keys'; import { FILTERED_SEARCH } from '~/pages/constants'; document.addEventListener('DOMContentLoaded', () => { @@ -11,8 +11,9 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ADMIN_RUNNERS, - filteredSearchTokenKeys: AdminRunnersFilteredSearchTokenKeys, + filteredSearchTokenKeys: GroupRunnersFilteredSearchTokenKeys, anchor: FILTERED_SEARCH.GROUP_RUNNERS_ANCHOR, + useDefaultState: false, }); if (gon.features.newVariablesUi) { diff --git a/app/assets/javascripts/pages/import/bitbucket/status/index.js b/app/assets/javascripts/pages/import/bitbucket/status/index.js index 52b5adb79d1..2a5432ce09d 100644 --- a/app/assets/javascripts/pages/import/bitbucket/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket/status/index.js @@ -7,13 +7,13 @@ document.addEventListener('DOMContentLoaded', () => { if (!mountElement) return undefined; const store = initStoreFromElement(mountElement); - const props = initPropsFromElement(mountElement); + const attrs = initPropsFromElement(mountElement); return new Vue({ el: mountElement, store, render(createElement) { - return createElement(BitbucketStatusTable, { props }); + return createElement(BitbucketStatusTable, { attrs }); }, }); }); diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue index e01c7b80e1a..35ae9d8419f 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/components/bitbucket_server_status_table.vue @@ -7,11 +7,8 @@ export default { BitbucketStatusTable, GlButton, }, + inheritAttrs: false, props: { - providerTitle: { - type: String, - required: true, - }, reconfigurePath: { type: String, required: true, @@ -20,7 +17,7 @@ export default { }; </script> <template> - <bitbucket-status-table :provider-title="providerTitle"> + <bitbucket-status-table v-bind="$attrs"> <template #actions> <gl-button variant="info" class="gl-ml-3" data-method="post" :href="reconfigurePath">{{ __('Reconfigure') diff --git a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js index 88455c9b7b9..a44fc4e6b29 100644 --- a/app/assets/javascripts/pages/import/bitbucket_server/status/index.js +++ b/app/assets/javascripts/pages/import/bitbucket_server/status/index.js @@ -7,14 +7,16 @@ document.addEventListener('DOMContentLoaded', () => { if (!mountElement) return undefined; const store = initStoreFromElement(mountElement); - const props = initPropsFromElement(mountElement); + const attrs = initPropsFromElement(mountElement); const { reconfigurePath } = mountElement.dataset; return new Vue({ el: mountElement, store, render(createElement) { - return createElement(BitbucketServerStatusTable, { props: { ...props, reconfigurePath } }); + return createElement(BitbucketServerStatusTable, { + attrs: { ...attrs, reconfigurePath }, + }); }, }); }); diff --git a/app/assets/javascripts/pages/import/manifest/status/index.js b/app/assets/javascripts/pages/import/manifest/status/index.js new file mode 100644 index 00000000000..dcd84f0faf9 --- /dev/null +++ b/app/assets/javascripts/pages/import/manifest/status/index.js @@ -0,0 +1,7 @@ +import mountImportProjectsTable from '~/import_projects'; + +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('import-projects-mount-element'); + + mountImportProjectsTable(mountElement); +}); diff --git a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue index 58dba41277d..5be8e6697a2 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/delete_milestone_modal.vue @@ -1,7 +1,7 @@ <script> import axios from '~/lib/utils/axios_utils'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; import { n__, s__, sprintf } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue index e18732d0fd5..0dc54b612ba 100644 --- a/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue +++ b/app/assets/javascripts/pages/milestones/shared/components/promote_milestone_modal.vue @@ -1,6 +1,6 @@ <script> import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/pages/profiles/show/index.js b/app/assets/javascripts/pages/profiles/show/index.js index 74ab1bc13a9..60510eac384 100644 --- a/app/assets/javascripts/pages/profiles/show/index.js +++ b/app/assets/javascripts/pages/profiles/show/index.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import emojiRegex from 'emoji-regex'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import EmojiMenu from './emoji_menu'; import { __ } from '~/locale'; import * as Emoji from '~/emoji'; diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index e5e4670a5d7..cb7198e9789 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -54,13 +54,9 @@ document.addEventListener('DOMContentLoaded', () => { new Vue({ el: successPipelineEl, render(createElement) { - const { commitCookie, goToPipelinesPath, humanAccess } = this.$el.dataset; - return createElement(PipelineTourSuccessModal, { props: { - goToPipelinesPath, - commitCookie, - humanAccess, + ...successPipelineEl.dataset, }, }); }, diff --git a/app/assets/javascripts/pages/projects/clusters/show/index.js b/app/assets/javascripts/pages/projects/clusters/show/index.js index d20e2c19583..a05ea8ae845 100644 --- a/app/assets/javascripts/pages/projects/clusters/show/index.js +++ b/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -1,9 +1,11 @@ import ClustersBundle from '~/clusters/clusters_bundle'; import initGkeNamespace from '~/create_cluster/gke_cluster_namespace'; import initClusterHealth from './cluster_health'; +import initIntegrationForm from '~/clusters/forms/show'; document.addEventListener('DOMContentLoaded', () => { new ClustersBundle(); // eslint-disable-line no-new initGkeNamespace(); initClusterHealth(); + initIntegrationForm(); }); diff --git a/app/assets/javascripts/pages/projects/commit/show/index.js b/app/assets/javascripts/pages/projects/commit/show/index.js index 0eb6f231839..a245af72d93 100644 --- a/app/assets/javascripts/pages/projects/commit/show/index.js +++ b/app/assets/javascripts/pages/projects/commit/show/index.js @@ -7,23 +7,47 @@ import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import MiniPipelineGraph from '~/mini_pipeline_graph_dropdown'; import initNotes from '~/init_notes'; import initChangesDropdown from '~/init_changes_dropdown'; -import initDiffNotes from '~/diff_notes/diff_notes_bundle'; import { fetchCommitMergeRequests } from '~/commit_merge_requests'; import '~/sourcegraph/load'; +import { handleLocationHash } from '~/lib/utils/common_utils'; +import axios from '~/lib/utils/axios_utils'; +import syntaxHighlight from '~/syntax_highlight'; +import flash from '~/flash'; +import { __ } from '~/locale'; document.addEventListener('DOMContentLoaded', () => { const hasPerfBar = document.querySelector('.with-performance-bar'); const performanceHeight = hasPerfBar ? 35 : 0; - new Diff(); - new ZenMode(); - new ShortcutsNavigation(); - new MiniPipelineGraph({ - container: '.js-commit-pipeline-graph', - }).bindEvents(); - initNotes(); - initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); - // eslint-disable-next-line no-jquery/no-load - $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); - fetchCommitMergeRequests(); - initDiffNotes(); + const filesContainer = $('.js-diffs-batch'); + const initAfterPageLoad = () => { + new Diff(); + new ZenMode(); + new ShortcutsNavigation(); + new MiniPipelineGraph({ + container: '.js-commit-pipeline-graph', + }).bindEvents(); + initNotes(); + initChangesDropdown(document.querySelector('.navbar-gitlab').offsetHeight + performanceHeight); + // eslint-disable-next-line no-jquery/no-load + $('.commit-info.branches').load(document.querySelector('.js-commit-box').dataset.commitPath); + fetchCommitMergeRequests(); + }; + + if (filesContainer.length) { + const batchPath = filesContainer.data('diffFilesPath'); + + axios + .get(batchPath) + .then(({ data }) => { + filesContainer.html($(data.html)); + syntaxHighlight(filesContainer); + handleLocationHash(); + initAfterPageLoad(); + }) + .catch(() => { + flash(__('An error occurred while retrieving diff files')); + }); + } else { + initAfterPageLoad(); + } }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index e65c18c07a9..7eeb0c852e5 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -7,7 +7,7 @@ import dirtySubmitFactory from '~/dirty_submit/dirty_submit_factory'; import initFilePickers from '~/file_pickers'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; -import initProjectRemoveModal from '~/projects/project_remove_modal'; +import initProjectDeleteButton from '~/projects/project_delete_button'; import UserCallout from '~/user_callout'; import initServiceDesk from '~/projects/settings_service_desk'; @@ -15,7 +15,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilePickers(); initConfirmDangerModal(); initSettingsPanels(); - initProjectRemoveModal(); + initProjectDeleteButton(); mountBadgeSettings(PROJECT_BADGE); new UserCallout({ className: 'js-service-desk-callout' }); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue index 77753521342..11ece478d36 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list.vue @@ -2,7 +2,7 @@ import { GlTabs, GlTab, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import ForkGroupsListItem from './fork_groups_list_item.vue'; export default { diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue index 792c2f3db34..b4816fa2cb3 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_groups_list_item.vue @@ -35,7 +35,7 @@ export default { }, }, data() { - return { namespaces: null }; + return { namespaces: null, isForking: false }; }, computed: { @@ -67,6 +67,13 @@ export default { }, }, + methods: { + fork() { + this.isForking = true; + this.$refs.form.submit(); + }, + }, + i18n: { hasReachedProjectLimitMessage: __('You have reached your project limit'), insufficientPermissionsMessage: __( @@ -124,14 +131,17 @@ export default { > <template v-else> <div ref="selectButtonWrapper"> - <form method="POST" :action="group.fork_path"> + <form ref="form" method="POST" :action="group.fork_path"> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> <gl-button type="submit" - class="gl-h-7 gl-text-decoration-none!" + class="gl-h-7" :data-qa-name="group.full_name" + category="secondary" variant="success" :disabled="isSelectButtonDisabled" + :loading="isForking" + @click="fork" >{{ __('Select') }}</gl-button > </form> diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index d80e27e9156..79485859738 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -1,3 +1,23 @@ -import ProjectFork from '~/project_fork'; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import ForkGroupsList from './components/fork_groups_list.vue'; -document.addEventListener('DOMContentLoaded', () => new ProjectFork()); +document.addEventListener('DOMContentLoaded', () => { + const mountElement = document.getElementById('fork-groups-mount-element'); + + const { endpoint, canCreateProject } = mountElement.dataset; + + const hasReachedProjectLimit = !parseBoolean(canCreateProject); + + return new Vue({ + el: mountElement, + render(h) { + return h(ForkGroupsList, { + props: { + endpoint, + hasReachedProjectLimit, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/pages/projects/graphs/charts/index.js b/app/assets/javascripts/pages/projects/graphs/charts/index.js index 03504fba1ae..09b440d1413 100644 --- a/app/assets/javascripts/pages/projects/graphs/charts/index.js +++ b/app/assets/javascripts/pages/projects/graphs/charts/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { __ } from '~/locale'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { __ } from '~/locale'; import CodeCoverage from '../components/code_coverage.vue'; import SeriesDataMixin from './series_data_mixin'; diff --git a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue index 39d6df33a85..5d59880d497 100644 --- a/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue +++ b/app/assets/javascripts/pages/projects/graphs/components/code_coverage.vue @@ -1,9 +1,15 @@ <script> -import { GlAlert, GlDropdown, GlDropdownItem, GlIcon, GlSprintf } from '@gitlab/ui'; +import { + GlAlert, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlIcon, + GlSprintf, +} from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import dateFormat from 'dateformat'; -import axios from '~/lib/utils/axios_utils'; import { get } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; @@ -11,8 +17,8 @@ export default { components: { GlAlert, GlAreaChart, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlIcon, GlSprintf, }, @@ -134,8 +140,8 @@ export default { {{ __('It seems that there is currently no available data for code coverage') }} </span> </gl-alert> - <gl-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> - <gl-dropdown-item + <gl-deprecated-dropdown v-if="canShowData" :text="selectedDailyCoverageName"> + <gl-deprecated-dropdown-item v-for="({ group_name }, index) in dailyCoverageData" :key="index" :value="group_name" @@ -151,8 +157,8 @@ export default { {{ group_name }} </span> </div> - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> <gl-area-chart v-if="!isLoading" diff --git a/app/assets/javascripts/pages/projects/incidents/index/index.js b/app/assets/javascripts/pages/projects/incidents/index/index.js new file mode 100644 index 00000000000..c37ae862a85 --- /dev/null +++ b/app/assets/javascripts/pages/projects/incidents/index/index.js @@ -0,0 +1,5 @@ +import IncidentsList from '~/incidents/list'; + +document.addEventListener('DOMContentLoaded', () => { + IncidentsList(); +}); diff --git a/app/assets/javascripts/pages/projects/issues/index/index.js b/app/assets/javascripts/pages/projects/issues/index/index.js index a66b665d152..1711d122080 100644 --- a/app/assets/javascripts/pages/projects/issues/index/index.js +++ b/app/assets/javascripts/pages/projects/issues/index/index.js @@ -17,6 +17,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.ISSUES, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); new IssuableIndex(ISSUABLE_INDEX.ISSUE); diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js index 72003b61c8a..fc0922d9112 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/filtered_search.js @@ -9,6 +9,7 @@ export default class FilteredSearchServiceDesk extends FilteredSearchManager { super({ page: 'service_desk', filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); this.supportBotData = supportBotData; diff --git a/app/assets/javascripts/pages/projects/issues/service_desk/index.js b/app/assets/javascripts/pages/projects/issues/service_desk/index.js index 56054f5fc80..9304d9b6832 100644 --- a/app/assets/javascripts/pages/projects/issues/service_desk/index.js +++ b/app/assets/javascripts/pages/projects/issues/service_desk/index.js @@ -1,11 +1,17 @@ import FilteredSearchServiceDesk from './filtered_search'; +import initIssuablesList from '~/issuables_list'; document.addEventListener('DOMContentLoaded', () => { const supportBotData = JSON.parse( document.querySelector('.js-service-desk-issues').dataset.supportBot, ); - const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + if (document.querySelector('.filtered-search')) { + const filteredSearchManager = new FilteredSearchServiceDesk(supportBotData); + filteredSearchManager.setup(); + } - filteredSearchManager.setup(); + if (gon.features?.vueIssuablesList) { + initIssuablesList(); + } }); diff --git a/app/assets/javascripts/pages/projects/issues/show.js b/app/assets/javascripts/pages/projects/issues/show.js index 32f77465347..5ac6c17e09d 100644 --- a/app/assets/javascripts/pages/projects/issues/show.js +++ b/app/assets/javascripts/pages/projects/issues/show.js @@ -3,24 +3,26 @@ import Issue from '~/issue'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ZenMode from '~/zen_mode'; import '~/notes/index'; -import initIssueableApp, { issuableHeaderWarnings } from '~/issue_show'; +import { store } from '~/notes/stores'; +import initIssueableApp from '~/issue_show'; +import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; import initSentryErrorStackTraceApp from '~/sentry_error_stack_trace'; import initRelatedMergeRequestsApp from '~/related_merge_requests'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; export default function() { initIssueableApp(); + initIssuableHeaderWarning(store); initSentryErrorStackTraceApp(); initRelatedMergeRequestsApp(); - issuableHeaderWarnings(); - import(/* webpackChunkName: 'design_management' */ '~/design_management') + // This will be removed when we remove the `design_management_moved` feature flag + // See https://gitlab.com/gitlab-org/gitlab/-/issues/223197 + import(/* webpackChunkName: 'design_management' */ '~/design_management_legacy') .then(module => module.default()) .catch(() => {}); - // This will be removed when we remove the `design_management_moved` feature flag - // See https://gitlab.com/gitlab-org/gitlab/-/issues/223197 - import(/* webpackChunkName: 'design_management' */ '~/design_management_new') + import(/* webpackChunkName: 'design_management' */ '~/design_management') .then(module => module.default()) .catch(() => {}); diff --git a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue index 08078fa6b62..f58e4909a08 100644 --- a/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue +++ b/app/assets/javascripts/pages/projects/labels/components/promote_label_modal.vue @@ -1,7 +1,7 @@ <script> -import { escape } from 'lodash'; +import { GlSprintf } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -10,6 +10,7 @@ import eventHub from '../event_hub'; export default { components: { GlModal: DeprecatedModal2, + GlSprintf, }, props: { url: { @@ -45,20 +46,6 @@ export default { }, ); }, - title() { - const label = `<span - class="label color-label" - style="background-color: ${this.labelColor}; color: ${this.labelTextColor};" - >${escape(this.labelTitle)}</span>`; - - return sprintf( - s__('Labels|<span>Promote label</span> %{labelTitle} <span>to Group Label?</span>'), - { - labelTitle: label, - }, - false, - ); - }, }, methods: { onSubmit() { @@ -90,7 +77,27 @@ export default { footer-primary-button-variant="warning" @submit="onSubmit" > - <div slot="title" class="modal-title-with-label" v-html="title"></div> + <div slot="title" class="modal-title-with-label"> + <gl-sprintf + :message=" + s__( + 'Labels|%{spanStart}Promote label%{spanEnd} %{labelTitle} %{spanStart}to Group Label?%{spanEnd}', + ) + " + > + <template #labelTitle> + <span + class="label color-label" + :style="`background-color: ${labelColor}; color: ${labelTextColor};`" + > + {{ labelTitle }} + </span> + </template> + <template #span="{ content }" + ><span>{{ content }}</span></template + > + </gl-sprintf> + </div> {{ text }} </gl-modal> diff --git a/app/assets/javascripts/pages/projects/merge_requests/index/index.js b/app/assets/javascripts/pages/projects/merge_requests/index/index.js index 8f93cbb2a42..ce0b5c80927 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/index/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/index/index.js @@ -13,6 +13,7 @@ document.addEventListener('DOMContentLoaded', () => { initFilteredSearch({ page: FILTERED_SEARCH.MERGE_REQUESTS, filteredSearchTokenKeys: IssuableFilteredSearchTokenKeys, + useDefaultState: true, }); new IssuableIndex(ISSUABLE_INDEX.MERGE_REQUEST); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js index c4cc667710a..25abb80cfae 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request_show.js @@ -6,7 +6,6 @@ import howToMerge from '~/how_to_merge'; import initPipelines from '~/commit/pipelines/pipelines_bundle'; import initVueIssuableSidebarApp from '~/issuable_sidebar/sidebar_bundle'; import initSourcegraph from '~/sourcegraph'; -import initPopover from '~/mr_tabs_popover'; export default function() { new ZenMode(); // eslint-disable-line no-new @@ -20,10 +19,4 @@ export default function() { handleLocationHash(); howToMerge(); initSourcegraph(); - - const tabHighlightEl = document.querySelector('.js-tabs-feature-highlight'); - - if (tabHighlightEl) { - initPopover(tabHighlightEl); - } } diff --git a/app/assets/javascripts/pages/projects/merge_requests/show/index.js b/app/assets/javascripts/pages/projects/merge_requests/show/index.js index 4708970efef..29ebf656fe1 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/show/index.js +++ b/app/assets/javascripts/pages/projects/merge_requests/show/index.js @@ -2,6 +2,8 @@ import initMrNotes from '~/mr_notes'; import { initReviewBar } from '~/batch_comments'; import initSidebarBundle from '~/sidebar/sidebar_bundle'; import initShow from '../init_merge_request_show'; +import initIssuableHeaderWarning from '~/vue_shared/components/issuable/init_issuable_header_warning'; +import store from '~/mr_notes/stores'; document.addEventListener('DOMContentLoaded', () => { initShow(); @@ -10,4 +12,5 @@ document.addEventListener('DOMContentLoaded', () => { } initMrNotes(); initReviewBar(); + initIssuableHeaderWarning(store); }); diff --git a/app/assets/javascripts/pages/projects/new/index.js b/app/assets/javascripts/pages/projects/new/index.js index e17059dd55a..637ed28a758 100644 --- a/app/assets/javascripts/pages/projects/new/index.js +++ b/app/assets/javascripts/pages/projects/new/index.js @@ -1,7 +1,7 @@ import initProjectVisibilitySelector from '../../../project_visibility'; import initProjectNew from '../../../projects/project_new'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { diff --git a/app/assets/javascripts/pages/projects/packages/packages/index/index.js b/app/assets/javascripts/pages/projects/packages/packages/index/index.js new file mode 100644 index 00000000000..4836900aa28 --- /dev/null +++ b/app/assets/javascripts/pages/projects/packages/packages/index/index.js @@ -0,0 +1,7 @@ +import initPackageList from '~/packages/list/packages_list_app_bundle'; + +document.addEventListener('DOMContentLoaded', () => { + if (document.getElementById('js-vue-packages-list')) { + initPackageList(); + } +}); diff --git a/app/assets/javascripts/pages/projects/packages/packages/show/index.js b/app/assets/javascripts/pages/projects/packages/packages/show/index.js new file mode 100644 index 00000000000..1fde4ddfc1d --- /dev/null +++ b/app/assets/javascripts/pages/projects/packages/packages/show/index.js @@ -0,0 +1,3 @@ +import initPackageDetail from '~/packages/details/'; + +document.addEventListener('DOMContentLoaded', initPackageDetail); diff --git a/app/assets/javascripts/pages/projects/pipelines/new/index.js b/app/assets/javascripts/pages/projects/pipelines/new/index.js index b0b077a5e4c..d5563143f0c 100644 --- a/app/assets/javascripts/pages/projects/pipelines/new/index.js +++ b/app/assets/javascripts/pages/projects/pipelines/new/index.js @@ -1,12 +1,19 @@ import $ from 'jquery'; import NewBranchForm from '~/new_branch_form'; import setupNativeFormVariableList from '~/ci_variable_list/native_form_variable_list'; +import initNewPipeline from '~/pipeline_new/index'; document.addEventListener('DOMContentLoaded', () => { - new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new + const el = document.getElementById('js-new-pipeline'); - setupNativeFormVariableList({ - container: $('.js-ci-variable-list-section'), - formField: 'variables_attributes', - }); + if (el) { + initNewPipeline(); + } else { + new NewBranchForm($('.js-new-pipeline-form')); // eslint-disable-line no-new + + setupNativeFormVariableList({ + container: $('.js-ci-variable-list-section'), + formField: 'variables_attributes', + }); + } }); diff --git a/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js b/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js new file mode 100644 index 00000000000..0539d318471 --- /dev/null +++ b/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js @@ -0,0 +1,3 @@ +import initActivityCharts from '~/analytics/product_analytics/activity_charts_bundle'; + +document.addEventListener('DOMContentLoaded', () => initActivityCharts()); diff --git a/app/assets/javascripts/pages/projects/project.js b/app/assets/javascripts/pages/projects/project.js index 739ae1cea16..bb285635425 100644 --- a/app/assets/javascripts/pages/projects/project.js +++ b/app/assets/javascripts/pages/projects/project.js @@ -6,7 +6,7 @@ import { __ } from '~/locale'; import { mergeUrlParams } from '~/lib/utils/url_utility'; import { serializeForm } from '~/lib/utils/forms'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import projectSelect from '../../project_select'; export default class Project { diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index e08d0407245..ab2a7c099c4 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -3,6 +3,7 @@ import SecretValues from '~/behaviors/secret_values'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; import initVariableList from '~/ci_variable_list'; +import initDeployFreeze from '~/deploy_freeze'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -40,4 +41,5 @@ document.addEventListener('DOMContentLoaded', () => { }); registrySettingsApp(); + initDeployFreeze(); }); diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index 3e02893f24c..eff45bad603 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -14,7 +14,7 @@ export default () => { new ProtectedTagEditList(); initDeployKeys(); initSettingsPanels(); - new ProtectedBranchCreate(); + new ProtectedBranchCreate({ hasLicense: false }); new ProtectedBranchEditList(); new DueDateSelectors(); fileUpload('.js-choose-file', '.js-object-map-input'); diff --git a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js index fcbd81416f2..f69ca6e27b3 100644 --- a/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js +++ b/app/assets/javascripts/pages/projects/shared/permissions/mixins/settings_pannel_mixin.js @@ -13,7 +13,6 @@ export default { if (value === 0) { this.containerRegistryEnabled = false; - this.lfsEnabled = false; } } else if (oldValue === 0) { this.mergeRequestsAccessLevel = value; diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index c65cc3e4c57..fd522b975a6 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -1,3 +1,4 @@ +import initTree from 'ee_else_ce/repository'; import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import NotificationsForm from '~/notifications_form'; @@ -9,7 +10,6 @@ import leaveByUrl from '~/namespaces/leave_by_url'; import Star from '../../../star'; import notificationsDropdown from '../../../notifications_dropdown'; import { showLearnGitLabProjectPopover } from '~/onboarding_issues'; -import initTree from 'ee_else_ce/repository'; document.addEventListener('DOMContentLoaded', () => { initReadMore(); diff --git a/app/assets/javascripts/pages/projects/tree/show/index.js b/app/assets/javascripts/pages/projects/tree/show/index.js index 78a4ea23f1a..b19abda2821 100644 --- a/app/assets/javascripts/pages/projects/tree/show/index.js +++ b/app/assets/javascripts/pages/projects/tree/show/index.js @@ -1,8 +1,8 @@ import $ from 'jquery'; +import initTree from 'ee_else_ce/repository'; import initBlob from '~/blob_edit/blob_bundle'; import ShortcutsNavigation from '../../../../behaviors/shortcuts/shortcuts_navigation'; import NewCommitForm from '../../../../new_commit_form'; -import initTree from 'ee_else_ce/repository'; document.addEventListener('DOMContentLoaded', () => { new ShortcutsNavigation(); // eslint-disable-line no-new diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 4b4b0555bb2..a33d11f3613 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -1,20 +1,9 @@ import LengthValidator from '~/pages/sessions/new/length_validator'; import UsernameValidator from '~/pages/sessions/new/username_validator'; import NoEmojiValidator from '~/emoji/no_emoji_validator'; -import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new new LengthValidator(); // eslint-disable-line no-new new NoEmojiValidator(); // eslint-disable-line no-new }); - -document.addEventListener('SnowplowInitialized', () => { - if (gon.tracking_data) { - const { category, action } = gon.tracking_data; - - if (category && action) { - Tracking.event(category, action); - } - } -}); diff --git a/app/assets/javascripts/pages/search/init_filtered_search.js b/app/assets/javascripts/pages/search/init_filtered_search.js index b331a2bee6a..eaed3246d06 100644 --- a/app/assets/javascripts/pages/search/init_filtered_search.js +++ b/app/assets/javascripts/pages/search/init_filtered_search.js @@ -6,6 +6,7 @@ export default ({ isGroup, isGroupAncestor, isGroupDecendent, + useDefaultState, stateFiltersSelector, anchor, }) => { @@ -16,6 +17,7 @@ export default ({ isGroup, isGroupAncestor, isGroupDecendent, + useDefaultState, filteredSearchTokenKeys, stateFiltersSelector, anchor, diff --git a/app/assets/javascripts/pages/search/show/search.js b/app/assets/javascripts/pages/search/show/search.js index 4050f2f13f1..cc2128490ec 100644 --- a/app/assets/javascripts/pages/search/show/search.js +++ b/app/assets/javascripts/pages/search/show/search.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import '~/gl_dropdown'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import Api from '~/api'; import { __ } from '~/locale'; import Project from '~/pages/projects/project'; diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 66ee2d9303f..55bc93a2b13 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -5,7 +5,6 @@ import NoEmojiValidator from '../../../emoji/no_emoji_validator'; import SigninTabsMemoizer from './signin_tabs_memoizer'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; -import Tracking from '~/tracking'; document.addEventListener('DOMContentLoaded', () => { new UsernameValidator(); // eslint-disable-line no-new @@ -21,16 +20,3 @@ document.addEventListener('DOMContentLoaded', () => { // redirected to sign-in after attempting to access a protected URL that included a fragment. preserveUrlFragment(window.location.hash); }); - -export default function trackData() { - if (gon.tracking_data) { - const tab = document.querySelector(".new-session-tabs a[href='#register-pane']"); - const { category, action, ...data } = gon.tracking_data; - - tab.addEventListener('click', () => { - Tracking.event(category, action, data); - }); - } -} - -trackData(); diff --git a/app/assets/javascripts/pages/sessions/new/username_validator.js b/app/assets/javascripts/pages/sessions/new/username_validator.js index ecb5e677290..62f6e3fb84f 100644 --- a/app/assets/javascripts/pages/sessions/new/username_validator.js +++ b/app/assets/javascripts/pages/sessions/new/username_validator.js @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import InputValidator from '~/validators/input_validator'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; const debounceTimeoutDuration = 1000; diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js index 70e9333456d..eb0a5efe75c 100644 --- a/app/assets/javascripts/pages/users/activity_calendar.js +++ b/app/assets/javascripts/pages/users/activity_calendar.js @@ -5,7 +5,7 @@ import { select } from 'd3-selection'; import dateFormat from 'dateformat'; import { getDayName, getDayDifference } from '~/lib/utils/datetime_utility'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { n__, s__, __ } from '~/locale'; const d3 = { select, scaleLinear, scaleThreshold }; diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index dafd800099c..9d66c784750 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -5,7 +5,7 @@ import Activities from '~/activities'; import { localTimeAgo } from '~/lib/utils/datetime_utility'; import AjaxCache from '~/lib/utils/ajax_cache'; import { __ } from '~/locale'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import ActivityCalendar from './activity_calendar'; import UserOverviewBlock from './user_overview_block'; diff --git a/app/assets/javascripts/performance_constants.js b/app/assets/javascripts/performance_constants.js new file mode 100644 index 00000000000..1a53b925aa4 --- /dev/null +++ b/app/assets/javascripts/performance_constants.js @@ -0,0 +1,12 @@ +// +// SNIPPET namespace +// + +// marks +export const SNIPPET_MARK_VIEW_APP_START = 'snippet-view-app-start'; +export const SNIPPET_MARK_EDIT_APP_START = 'snippet-edit-app-start'; +export const SNIPPET_MARK_BLOBS_CONTENT = 'snippet-blobs-content-finished'; + +// Measures +export const SNIPPET_MEASURE_BLOBS_CONTENT = 'snippet-blobs-content'; +export const SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP = 'snippet-blobs-content-within-app'; diff --git a/app/assets/javascripts/persistent_user_callout.js b/app/assets/javascripts/persistent_user_callout.js index b8a1397d8f6..eded64127b6 100644 --- a/app/assets/javascripts/persistent_user_callout.js +++ b/app/assets/javascripts/persistent_user_callout.js @@ -1,7 +1,7 @@ import { parseBoolean } from './lib/utils/common_utils'; import axios from './lib/utils/axios_utils'; import { __ } from './locale'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; const DEFERRED_LINK_CLASS = 'deferred-link'; diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index f4fe605f0a2..ef4d5338046 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -5,7 +5,6 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-users-over-license-callout', '.js-admin-licensed-user-count-threshold', '.js-buy-pipeline-minutes-notification-callout', - '.js-alerts-moved-alert', '.js-token-expiry-callout', ]; diff --git a/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue new file mode 100644 index 00000000000..e079603a5d4 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/components/pipeline_new_form.vue @@ -0,0 +1,247 @@ +<script> +import Vue from 'vue'; +import { uniqueId } from 'lodash'; +import { + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlFormSelect, + GlLink, + GlNewDropdown, + GlNewDropdownItem, + GlSearchBoxByType, + GlSprintf, +} from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import Api from '~/api'; +import { redirectTo } from '~/lib/utils/url_utility'; +import { VARIABLE_TYPE, FILE_TYPE } from '../constants'; + +export default { + typeOptions: [ + { value: VARIABLE_TYPE, text: __('Variable') }, + { value: FILE_TYPE, text: __('File') }, + ], + variablesDescription: s__( + 'Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default.', + ), + formElementClasses: 'gl-mr-3 gl-mb-3 table-section section-15', + errorTitle: __('The form contains the following error:'), + components: { + GlAlert, + GlButton, + GlForm, + GlFormGroup, + GlFormInput, + GlFormSelect, + GlLink, + GlNewDropdown, + GlNewDropdownItem, + GlSearchBoxByType, + GlSprintf, + }, + props: { + pipelinesPath: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + refs: { + type: Array, + required: true, + }, + settingsLink: { + type: String, + required: true, + }, + fileParams: { + type: Object, + required: false, + default: () => ({}), + }, + refParam: { + type: String, + required: false, + default: '', + }, + variableParams: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + searchTerm: '', + refValue: this.refParam, + variables: {}, + error: false, + }; + }, + computed: { + filteredRefs() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.refs.filter(ref => ref.toLowerCase().includes(lowerCasedSearchTerm)); + }, + variablesLength() { + return Object.keys(this.variables).length; + }, + }, + created() { + if (this.variableParams) { + this.setVariableParams(VARIABLE_TYPE, this.variableParams); + } + + if (this.fileParams) { + this.setVariableParams(FILE_TYPE, this.fileParams); + } + + this.addEmptyVariable(); + }, + methods: { + addEmptyVariable() { + this.variables[uniqueId('var')] = { + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }; + }, + setVariableParams(type, paramsObj) { + Object.entries(paramsObj).forEach(([key, value]) => { + this.variables[uniqueId('var')] = { + key, + value, + variable_type: type, + }; + }); + }, + setRefSelected(ref) { + this.refValue = ref; + }, + isSelected(ref) { + return ref === this.refValue; + }, + insertNewVariable() { + Vue.set(this.variables, uniqueId('var'), { + variable_type: VARIABLE_TYPE, + key: '', + value: '', + }); + }, + removeVariable(key) { + Vue.delete(this.variables, key); + }, + + canRemove(index) { + return index < this.variablesLength - 1; + }, + createPipeline() { + const filteredVariables = Object.values(this.variables).filter( + ({ key, value }) => key !== '' && value !== '', + ); + + return Api.createPipeline(this.projectId, { + ref: this.refValue, + variables: filteredVariables, + }) + .then(({ data }) => redirectTo(data.web_url)) + .catch(err => { + this.error = err.response.data.message.base; + }); + }, + }, +}; +</script> + +<template> + <gl-form @submit.prevent="createPipeline"> + <gl-alert + v-if="error" + :title="$options.errorTitle" + :dismissible="false" + variant="danger" + class="gl-mb-4" + >{{ error }}</gl-alert + > + <gl-form-group :label="s__('Pipeline|Run for')"> + <gl-new-dropdown :text="refValue" block> + <gl-search-box-by-type + v-model.trim="searchTerm" + :placeholder="__('Search branches and tags')" + class="gl-p-2" + /> + <gl-new-dropdown-item + v-for="(ref, index) in filteredRefs" + :key="index" + class="gl-font-monospace" + is-check-item + :is-checked="isSelected(ref)" + @click="setRefSelected(ref)" + > + {{ ref }} + </gl-new-dropdown-item> + </gl-new-dropdown> + + <template #description> + <div> + {{ s__('Pipeline|Existing branch name or tag') }} + </div></template + > + </gl-form-group> + + <gl-form-group :label="s__('Pipeline|Variables')"> + <div + v-for="(value, key, index) in variables" + :key="key" + class="gl-display-flex gl-align-items-center gl-mb-4 gl-pb-2 gl-border-b-solid gl-border-gray-200 gl-border-b-1 gl-flex-direction-column gl-md-flex-direction-row" + data-testid="ci-variable-row" + > + <gl-form-select + v-model="variables[key].variable_type" + :class="$options.formElementClasses" + :options="$options.typeOptions" + /> + <gl-form-input + v-model="variables[key].key" + :placeholder="s__('CiVariables|Input variable key')" + :class="$options.formElementClasses" + data-testid="pipeline-form-ci-variable-key" + @change.once="insertNewVariable()" + /> + <gl-form-input + v-model="variables[key].value" + :placeholder="s__('CiVariables|Input variable value')" + class="gl-mr-5 gl-mb-3 table-section section-15" + /> + <gl-button + v-if="canRemove(index)" + icon="issue-close" + class="gl-mb-3" + data-testid="remove-ci-variable-row" + @click="removeVariable(key)" + /> + </div> + + <template #description + ><gl-sprintf :message="$options.variablesDescription"> + <template #link="{ content }"> + <gl-link :href="settingsLink">{{ content }}</gl-link> + </template> + </gl-sprintf></template + > + </gl-form-group> + <div + class="gl-border-t-solid gl-border-gray-100 gl-border-t-1 gl-p-5 gl-bg-gray-10 gl-display-flex gl-justify-content-space-between" + > + <gl-button type="submit" category="primary" variant="success">{{ + s__('Pipeline|Run Pipeline') + }}</gl-button> + <gl-button :href="pipelinesPath">{{ __('Cancel') }}</gl-button> + </div> + </gl-form> +</template> diff --git a/app/assets/javascripts/pipeline_new/constants.js b/app/assets/javascripts/pipeline_new/constants.js new file mode 100644 index 00000000000..b4ab1143f60 --- /dev/null +++ b/app/assets/javascripts/pipeline_new/constants.js @@ -0,0 +1,2 @@ +export const VARIABLE_TYPE = 'env_var'; +export const FILE_TYPE = 'file'; diff --git a/app/assets/javascripts/pipeline_new/index.js b/app/assets/javascripts/pipeline_new/index.js new file mode 100644 index 00000000000..1c4812c2e0e --- /dev/null +++ b/app/assets/javascripts/pipeline_new/index.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; +import PipelineNewForm from './components/pipeline_new_form.vue'; + +export default () => { + const el = document.getElementById('js-new-pipeline'); + const { + projectId, + pipelinesPath, + refParam, + varParam, + fileParam, + refNames, + settingsLink, + } = el?.dataset; + + const variableParams = JSON.parse(varParam); + const fileParams = JSON.parse(fileParam); + const refs = JSON.parse(refNames); + + return new Vue({ + el, + render(createElement) { + return createElement(PipelineNewForm, { + props: { + projectId, + pipelinesPath, + refParam, + variableParams, + fileParams, + refs, + settingsLink, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 85163a666e2..8487da3d621 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -1,8 +1,9 @@ <script> -import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlEmptyState, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; +import { fetchPolicies } from '~/lib/graphql'; +import getDagVisData from '../../graphql/queries/get_dag_vis_data.query.graphql'; import DagGraph from './dag_graph.vue'; import DagAnnotations from './dag_annotations.vue'; import { @@ -23,35 +24,68 @@ export default { DagAnnotations, DagGraph, GlAlert, - GlLink, GlSprintf, GlEmptyState, GlButton, }, - props: { - graphUrl: { - type: String, - required: false, - default: '', + inject: { + dagDocPath: { + default: null, }, emptySvgPath: { - type: String, - required: true, default: '', }, - dagDocPath: { - type: String, - required: true, + pipelineIid: { + default: '', + }, + pipelineProjectPath: { default: '', }, }, + apollo: { + graphData: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getDagVisData, + variables() { + return { + projectPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + const { + stages: { nodes: stages }, + } = data.project.pipeline; + + const unwrappedGroups = stages + .map(({ name, groups: { nodes: groups } }) => { + return groups.map(group => { + return { category: name, ...group }; + }); + }) + .flat(2); + + const nodes = unwrappedGroups.map(group => { + const jobs = group.jobs.nodes.map(({ name, needs }) => { + return { name, needs: needs.nodes.map(need => need.name) }; + }); + + return { ...group, jobs }; + }); + + return nodes; + }, + error() { + this.reportFailure(LOAD_FAILURE); + }, + }, + }, data() { return { annotationsMap: {}, failureType: null, graphData: null, showFailureAlert: false, - showBetaInfo: true, hasNoDependentJobs: false, }; }, @@ -72,11 +106,6 @@ export default { button: __('Learn more about job dependencies'), }, computed: { - betaMessage() { - return __( - 'This feature is currently in beta. We invite you to %{linkStart}give feedback%{linkEnd}.', - ); - }, failure() { switch (this.failureType) { case LOAD_FAILURE: @@ -97,32 +126,20 @@ export default { default: return { text: this.$options.errorTexts[DEFAULT], - vatiant: 'danger', + variant: 'danger', }; } }, + processedData() { + return this.processGraphData(this.graphData); + }, shouldDisplayAnnotations() { return !isEmpty(this.annotationsMap); }, shouldDisplayGraph() { - return Boolean(!this.showFailureAlert && this.graphData); + return Boolean(!this.showFailureAlert && !this.hasNoDependentJobs && this.graphData); }, }, - mounted() { - const { processGraphData, reportFailure } = this; - - if (!this.graphUrl) { - reportFailure(); - return; - } - - axios - .get(this.graphUrl) - .then(response => { - processGraphData(response.data); - }) - .catch(() => reportFailure(LOAD_FAILURE)); - }, methods: { addAnnotationToMap({ uid, source, target }) { this.$set(this.annotationsMap, uid, { source, target }); @@ -131,32 +148,29 @@ export default { let parsed; try { - parsed = parseData(data.stages); + parsed = parseData(data); } catch { this.reportFailure(PARSE_FAILURE); - return; + return {}; } if (parsed.links.length === 1) { this.reportFailure(UNSUPPORTED_DATA); - return; + return {}; } // If there are no links, we don't report failure // as it simply means the user does not use job dependencies if (parsed.links.length === 0) { this.hasNoDependentJobs = true; - return; + return {}; } - this.graphData = parsed; + return parsed; }, hideAlert() { this.showFailureAlert = false; }, - hideBetaInfo() { - this.showBetaInfo = false; - }, removeAnnotationFromMap({ uid }) { this.$delete(this.annotationsMap, uid); }, @@ -188,20 +202,11 @@ export default { {{ failure.text }} </gl-alert> - <gl-alert v-if="showBetaInfo" @dismiss="hideBetaInfo"> - <gl-sprintf :message="betaMessage"> - <template #link="{ content }"> - <gl-link href="https://gitlab.com/gitlab-org/gitlab/-/issues/220368" target="_blank"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> <div class="gl-relative"> <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> <dag-graph v-if="shouldDisplayGraph" - :graph-data="graphData" + :graph-data="processedData" @onFailure="reportFailure" @update-annotation="updateAnnotation" /> @@ -228,7 +233,7 @@ export default { </p> </div> </template> - <template #actions> + <template v-if="dagDocPath" #actions> <gl-button :href="dagDocPath" target="__blank" variant="success"> {{ $options.emptyStateTexts.button }} </gl-button> diff --git a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js index 3234f80ee91..1ed415688f2 100644 --- a/app/assets/javascripts/pipelines/components/dag/parsing_utils.js +++ b/app/assets/javascripts/pipelines/components/dag/parsing_utils.js @@ -5,14 +5,16 @@ import { uniqWith, isEqual } from 'lodash'; received from the endpoint into the format the d3 graph expects. Input is of the form: - [stages] - stages: {name, groups} - groups: [{ name, size, jobs }] - name is a group name; in the case that the group has one job, it is - also the job name - size is the number of parallel jobs - jobs: [{ name, needs}] - job name is either the same as the group name or group x/y + [nodes] + nodes: [{category, name, jobs, size}] + category is the stage name + name is a group name; in the case that the group has one job, it is + also the job name + size is the number of parallel jobs + jobs: [{ name, needs}] + job name is either the same as the group name or group x/y + needs: [job-names] + needs is an array of job-name strings Output is of the form: { nodes: [node], links: [link] } @@ -20,30 +22,17 @@ import { uniqWith, isEqual } from 'lodash'; link: { source, target, value }, with source & target being node names and value being a constant - We create nodes, create links, and then dedupe the links, so that in the case where + We create nodes in the GraphQL update function, and then here we create the node dictionary, + then create links, and then dedupe the links, so that in the case where job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link from job 1 to job 2 then another from job 2 to job 4. - CREATE NODES - stage.name -> node.category - stage.group.name -> node.name (this is the group name if there are parallel jobs) - stage.group.jobs -> node.jobs - stage.group.size -> node.size - CREATE LINKS - stages.groups.name -> target - stages.groups.needs.each -> source (source is the name of the group, not the parallel job) + nodes.name -> target + nodes.name.needs.each -> source (source is the name of the group, not the parallel job) 10 -> value (constant) */ -export const createNodes = data => { - return data.flatMap(({ groups, name }) => { - return groups.map(group => { - return { ...group, category: name }; - }); - }); -}; - export const createNodeDict = nodes => { return nodes.reduce((acc, node) => { const newNode = { @@ -62,13 +51,6 @@ export const createNodeDict = nodes => { }, {}); }; -export const createNodesStructure = data => { - const nodes = createNodes(data); - const nodeDict = createNodeDict(nodes); - - return { nodes, nodeDict }; -}; - export const makeLinksFromNodes = (nodes, nodeDict) => { const constantLinkValue = 10; // all links are the same weight return nodes @@ -126,8 +108,8 @@ export const filterByAncestors = (links, nodeDict) => return !allAncestors.includes(source); }); -export const parseData = data => { - const { nodes, nodeDict } = createNodesStructure(data); +export const parseData = nodes => { + const nodeDict = createNodeDict(nodes); const allLinks = makeLinksFromNodes(nodes, nodeDict); const filteredLinks = filterByAncestors(allLinks, nodeDict); const links = uniqWith(filteredLinks, isEqual); diff --git a/app/assets/javascripts/pipelines/components/graph/action_component.vue b/app/assets/javascripts/pipelines/components/graph/action_component.vue index c5e95036f4f..137455bcae1 100644 --- a/app/assets/javascripts/pipelines/components/graph/action_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/action_component.vue @@ -1,9 +1,9 @@ <script> -import { GlTooltipDirective, GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton, GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; import { dasherize } from '~/lib/utils/text_utility'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; /** @@ -19,7 +19,7 @@ import Icon from '~/vue_shared/components/icon.vue'; export default { components: { Icon, - GlDeprecatedButton, + GlButton, GlLoadingIcon, }, directives: { @@ -82,16 +82,16 @@ export default { }; </script> <template> - <gl-deprecated-button + <gl-button :id="`js-ci-action-${link}`" v-gl-tooltip="{ boundary: 'viewport' }" :title="tooltipText" :class="cssClass" :disabled="isDisabled" - class="js-ci-action btn btn-blank btn-transparent ci-action-icon-container ci-action-icon-wrapper d-flex align-items-center justify-content-center" + class="js-ci-action ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" @click="onClickAction" > <gl-loading-icon v-if="isLoading" class="js-action-icon-loading" /> <icon v-else :name="actionIcon" /> - </gl-deprecated-button> + </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 6b890688a48..f5bf6a6ed34 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -170,7 +170,7 @@ export default { v-for="(stage, index) in graph" :key="stage.name" :class="{ - 'has-upstream prepend-left-64': hasUpstream(index), + 'has-upstream gl-ml-11': hasUpstream(index), 'has-only-one-job': hasOnlyOneJob(stage), 'gl-mr-26': shouldAddRightMargin(index), }" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index 733553e02c0..f0a8f9f7ab7 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,5 +1,5 @@ <script> -import { GlLoadingIcon, GlTooltipDirective, GlDeprecatedButton } from '@gitlab/ui'; +import { GlTooltipDirective, GlButton } from '@gitlab/ui'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; import { __, sprintf } from '~/locale'; @@ -9,8 +9,7 @@ export default { }, components: { CiStatus, - GlLoadingIcon, - GlDeprecatedButton, + GlButton, }, props: { pipeline: { @@ -95,26 +94,21 @@ export default { @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > - <gl-deprecated-button + <gl-button :id="buttonId" v-gl-tooltip :title="tooltipText" - class="js-linked-pipeline-content linked-pipeline-content" + class="linked-pipeline-content" data-qa-selector="linked_pipeline_button" :class="`js-pipeline-expand-${pipeline.id}`" + :loading="pipeline.isLoading" @click="onClickLinkedPipeline" > - <gl-loading-icon v-if="pipeline.isLoading" class="js-linked-pipeline-loading d-inline" /> - <ci-status - v-else - :status="pipelineStatus" - css-classes="position-top-0" - class="js-linked-pipeline-status" - /> + <ci-status v-if="!pipeline.isLoading" :status="pipelineStatus" css-classes="gl-top-0" /> <span class="str-truncated"> {{ downstreamTitle }} • #{{ pipeline.id }} </span> <div class="gl-pt-2"> <span class="badge badge-primary" data-testid="downstream-pipeline-label">{{ label }}</span> </div> - </gl-deprecated-button> + </gl-button> </li> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index c4dfd3382a2..d82885ff8de 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -27,7 +27,7 @@ export default { computed: { columnClass() { const positionValues = { - right: 'prepend-left-64', + right: 'gl-ml-11', left: 'gl-mr-7', }; return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index dff642161db..c7b72be36ad 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -1,7 +1,6 @@ <script> -import { GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlLoadingIcon, GlModal, GlModalDirective, GlButton } from '@gitlab/ui'; import ciHeader from '~/vue_shared/components/header_ci_component.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '../event_hub'; import { __ } from '~/locale'; @@ -13,7 +12,7 @@ export default { ciHeader, GlLoadingIcon, GlModal, - LoadingButton, + GlButton, }, directives: { GlModal: GlModalDirective, @@ -77,35 +76,43 @@ export default { :user="pipeline.user" item-name="Pipeline" > - <loading-button + <gl-button v-if="pipeline.retry_path" :loading="isRetrying" :disabled="isRetrying" - class="js-retry-button btn btn-inverted-secondary" - container-class="d-inline" - :label="__('Retry')" + data-testid="retryButton" + category="secondary" + variant="info" @click="retryPipeline()" - /> + > + {{ __('Retry') }} + </gl-button> - <loading-button + <gl-button v-if="pipeline.cancel_path" :loading="isCanceling" :disabled="isCanceling" - class="js-btn-cancel-pipeline btn btn-danger" - container-class="d-inline" - :label="__('Cancel running')" + data-testid="cancelPipeline" + class="gl-ml-3" + category="primary" + variant="danger" @click="cancelPipeline()" - /> + > + {{ __('Cancel running') }} + </gl-button> - <loading-button + <gl-button v-if="pipeline.delete_path" v-gl-modal="$options.DELETE_MODAL_ID" :loading="isDeleting" :disabled="isDeleting" - class="js-btn-delete-pipeline btn btn-danger btn-inverted" - container-class="d-inline" - :label="__('Delete')" - /> + data-testid="deletePipeline" + class="gl-ml-3" + category="secondary" + variant="danger" + > + {{ __('Delete') }} + </gl-button> </ci-header> <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3 gl-mb-3" /> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 74ada6a4d15..fe8e3bd2b78 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,10 +1,10 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; export default { name: 'PipelinesEmptyState', components: { - GlDeprecatedButton, + GlButton, }, props: { helpPagePath: { @@ -43,13 +43,14 @@ export default { </p> <div class="text-center"> - <gl-deprecated-button + <gl-button :href="helpPagePath" - variant="primary" + variant="info" + category="primary" class="js-get-started-pipelines" > {{ s__('Pipelines|Get started with Pipelines') }} - </gl-deprecated-button> + </gl-button> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue index 2905b2ca26f..f0614298bd3 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_url.vue @@ -1,27 +1,15 @@ <script> -import { GlLink, GlTooltipDirective } from '@gitlab/ui'; -import { escape } from 'lodash'; +import { GlLink, GlPopover, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { SCHEDULE_ORIGIN } from '../../constants'; -import { __, sprintf } from '~/locale'; -import popover from '~/vue_shared/directives/popover'; - -const popoverTitle = sprintf( - escape( - __( - `This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}`, - ), - ), - { strongStart: '<b>', strongEnd: '</b>' }, - false, -); export default { components: { GlLink, + GlPopover, + GlSprintf, }, directives: { GlTooltip: GlTooltipDirective, - popover, }, props: { pipeline: { @@ -44,23 +32,6 @@ export default { isScheduled() { return this.pipeline.source === SCHEDULE_ORIGIN; }, - popoverOptions() { - return { - html: true, - trigger: 'focus', - placement: 'top', - title: `<div class="autodevops-title"> - ${popoverTitle} - </div>`, - content: `<a - class="autodevops-link" - href="${this.autoDevopsHelpPath}" - target="_blank" - rel="noopener noreferrer nofollow"> - ${escape(__('Learn more about Auto DevOps'))} - </a>`, - }; - }, }, }; </script> @@ -114,13 +85,42 @@ export default { </span> <gl-link v-if="pipeline.flags.auto_devops" - v-popover="popoverOptions" + :id="`pipeline-url-autodevops-${pipeline.id}`" tabindex="0" class="js-pipeline-url-autodevops badge badge-info autodevops-badge" data-testid="pipeline-url-autodevops" role="button" >{{ __('Auto DevOps') }}</gl-link > + <gl-popover + :target="`pipeline-url-autodevops-${pipeline.id}`" + triggers="focus" + placement="top" + > + <template #title> + <div class="autodevops-title"> + <gl-sprintf + :message=" + __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', + ) + " + > + <template #strong="{content}"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </div> + </template> + <gl-link + class="autodevops-link" + :href="autoDevopsHelpPath" + target="_blank" + rel="noopener noreferrer nofollow" + > + {{ __('Learn more about Auto DevOps') }} + </gl-link> + </gl-popover> <span v-if="pipeline.flags.stuck" class="js-pipeline-url-stuck badge badge-warning" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index 0c531650fd2..2dfc6485d85 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -1,7 +1,7 @@ <script> import { isEqual } from 'lodash'; import { __, s__ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import PipelinesService from '../../services/pipelines_service'; import pipelinesMixin from '../../mixins/pipelines'; import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue index 3009ca7a775..098efe68b83 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_actions.vue @@ -1,7 +1,7 @@ <script> import { GlDeprecatedButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { s__, __, sprintf } from '~/locale'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; import Icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue index 0505a8668d1..29345f33367 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_filtered_search.vue @@ -1,11 +1,11 @@ <script> import { GlFilteredSearch } from '@gitlab/ui'; +import { map } from 'lodash'; import { __, s__ } from '~/locale'; import PipelineTriggerAuthorToken from './tokens/pipeline_trigger_author_token.vue'; import PipelineBranchNameToken from './tokens/pipeline_branch_name_token.vue'; import PipelineStatusToken from './tokens/pipeline_status_token.vue'; import PipelineTagNameToken from './tokens/pipeline_tag_name_token.vue'; -import { map } from 'lodash'; export default { userType: 'username', diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue index 99492bd8357..d992a4b7752 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/stage.vue @@ -15,7 +15,7 @@ import $ from 'jquery'; import { GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import eventHub from '../../event_hub'; import Icon from '~/vue_shared/components/icon.vue'; diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue index b6eff2931d3..60cb697f1af 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue @@ -1,9 +1,9 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; import Api from '~/api'; import { FETCH_BRANCH_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; -import createFlash from '~/flash'; -import { debounce } from 'lodash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue index 64de6d2a053..d6ba5fcca85 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue @@ -1,9 +1,9 @@ <script> import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; import Api from '~/api'; import { FETCH_TAG_ERROR_MESSAGE, FILTER_PIPELINES_SEARCH_DELAY } from '../../../constants'; -import createFlash from '~/flash'; -import { debounce } from 'lodash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; export default { components: { diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue index b5aeb3fe9e0..dfa6d8c13a5 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue @@ -3,12 +3,12 @@ import { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, + GlDeprecatedDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; -import Api from '~/api'; -import createFlash from '~/flash'; import { debounce } from 'lodash'; +import Api from '~/api'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { ANY_TRIGGER_AUTHOR, FETCH_AUTHOR_ERROR_MESSAGE, @@ -21,7 +21,7 @@ export default { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, + GlDeprecatedDropdownDivider, GlLoadingIcon, }, props: { @@ -94,7 +94,7 @@ export default { <gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{ $options.anyTriggerAuthor }}</gl-filtered-search-suggestion> - <gl-dropdown-divider /> + <gl-deprecated-dropdown-divider /> <gl-loading-icon v-if="loading" /> <template v-else> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue index 8746784aa57..bc1d22e2976 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_reports.vue @@ -14,7 +14,7 @@ export default { TestSummaryTable, }, computed: { - ...mapState(['hasFullReport', 'isLoading', 'selectedSuiteIndex', 'testReports']), + ...mapState(['isLoading', 'selectedSuiteIndex', 'testReports']), ...mapGetters(['getSelectedSuite']), showSuite() { return this.selectedSuiteIndex !== null; @@ -29,7 +29,7 @@ export default { }, methods: { ...mapActions([ - 'fetchFullReport', + 'fetchTestSuite', 'fetchSummary', 'setSelectedSuiteIndex', 'removeSelectedSuiteIndex', @@ -40,10 +40,8 @@ export default { summaryTableRowClick(index) { this.setSelectedSuiteIndex(index); - // Fetch the full report when the user clicks to see more details - if (!this.hasFullReport) { - this.fetchFullReport(); - } + // Fetch the test suite when the user clicks to see more details + this.fetchTestSuite(index); }, beforeEnterTransition() { document.documentElement.style.overflowX = 'hidden'; diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index d57b1466177..478073e44d1 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -1,8 +1,8 @@ <script> import { mapGetters } from 'vuex'; +import { GlTooltipDirective, GlFriendlyWrap } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; import { __ } from '~/locale'; -import { GlTooltipDirective } from '@gitlab/ui'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { @@ -10,6 +10,7 @@ export default { components: { Icon, SmartVirtualList, + GlFriendlyWrap, }, directives: { GlTooltip: GlTooltipDirective, @@ -29,6 +30,7 @@ export default { }, maxShownRows: 30, typicalRowHeight: 75, + wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'], }; </script> @@ -71,23 +73,19 @@ export default { > <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> - <div - v-gl-tooltip - :title="testCase.classname" - class="table-mobile-content pr-md-1 text-truncate" - > - {{ testCase.classname }} + <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break"> + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.classname" /> </div> </div> <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> - <div - v-gl-tooltip - :title="testCase.name" - class="table-mobile-content pr-md-1 text-truncate" - > - {{ testCase.name }} + <div class="table-mobile-content pr-md-1 gl-overflow-wrap-break"> + <gl-friendly-wrap + data-testid="caseName" + :symbols="$options.wrapSymbols" + :text="testCase.name" + /> </div> </div> diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue index 6cfb795595d..e774fe06fbe 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_summary_table.vue @@ -1,7 +1,7 @@ <script> import { mapGetters } from 'vuex'; -import { s__ } from '~/locale'; import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; export default { diff --git a/app/assets/javascripts/pipelines/event_hub.js b/app/assets/javascripts/pipelines/event_hub.js index 0948c2e5352..e31806ad199 100644 --- a/app/assets/javascripts/pipelines/event_hub.js +++ b/app/assets/javascripts/pipelines/event_hub.js @@ -1,3 +1,3 @@ -import Vue from 'vue'; +import createEventHub from '~/helpers/event_hub_factory'; -export default new Vue(); +export default createEventHub(); diff --git a/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql new file mode 100644 index 00000000000..c73b186739e --- /dev/null +++ b/app/assets/javascripts/pipelines/graphql/queries/get_dag_vis_data.query.graphql @@ -0,0 +1,27 @@ +query getDagVisData($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + pipeline(iid: $iid) { + stages { + nodes { + name + groups { + nodes { + name + size + jobs { + nodes { + name + needs { + nodes { + name + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js index f987c8f1dd4..886a8a78448 100644 --- a/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js +++ b/app/assets/javascripts/pipelines/mixins/graph_pipeline_bundle_mixin.js @@ -1,4 +1,4 @@ -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; export default { diff --git a/app/assets/javascripts/pipelines/mixins/pipelines.js b/app/assets/javascripts/pipelines/mixins/pipelines.js index 7710a96e5fb..e31545bba5c 100644 --- a/app/assets/javascripts/pipelines/mixins/pipelines.js +++ b/app/assets/javascripts/pipelines/mixins/pipelines.js @@ -1,7 +1,7 @@ import Visibility from 'visibilityjs'; import { GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Poll from '~/lib/utils/poll'; import EmptyState from '../components/pipelines_list/empty_state.vue'; import SvgBlankState from '../components/pipelines_list/blank_state.vue'; diff --git a/app/assets/javascripts/pipelines/pipeline_details_bundle.js b/app/assets/javascripts/pipelines/pipeline_details_bundle.js index f1102a9bddf..c57be7c75b0 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_bundle.js +++ b/app/assets/javascripts/pipelines/pipeline_details_bundle.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import Translate from '~/vue_shared/translate'; import { __ } from '~/locale'; import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; import pipelineGraph from './components/graph/graph_component.vue'; -import Dag from './components/dag/dag.vue'; +import createDagApp from './pipeline_details_dag'; import GraphBundleMixin from './mixins/graph_pipeline_bundle_mixin'; import PipelinesMediator from './pipeline_details_mediator'; import pipelineHeader from './components/header_component.vue'; @@ -92,48 +92,15 @@ const createPipelineHeaderApp = mediator => { }); }; -const createPipelinesTabs = testReportsStore => { - const tabsElement = document.querySelector('.pipelines-tabs'); - - if (tabsElement) { - const fetchReportsAction = 'fetchFullReport'; - const isTestTabActive = Boolean( - document.querySelector('.pipelines-tabs > li > a.test-tab.active'), - ); - - if (isTestTabActive) { - testReportsStore.dispatch(fetchReportsAction); - } else { - const tabClickHandler = e => { - if (e.target.className === 'test-tab') { - testReportsStore.dispatch(fetchReportsAction); - tabsElement.removeEventListener('click', tabClickHandler); - } - }; - - tabsElement.addEventListener('click', tabClickHandler); - } - } -}; - const createTestDetails = () => { - if (!window.gon?.features?.junitPipelineView) { - return; - } - const el = document.querySelector('#js-pipeline-tests-detail'); - const { fullReportEndpoint, summaryEndpoint, countEndpoint } = el?.dataset || {}; + const { summaryEndpoint, suiteEndpoint } = el?.dataset || {}; const testReportsStore = createTestReportsStore({ - fullReportEndpoint, - summaryEndpoint: summaryEndpoint || countEndpoint, - useBuildSummaryReport: window.gon?.features?.buildReportSummary, + summaryEndpoint, + suiteEndpoint, }); - if (!window.gon?.features?.buildReportSummary) { - createPipelinesTabs(testReportsStore); - } - // eslint-disable-next-line no-new new Vue({ el, @@ -147,32 +114,6 @@ const createTestDetails = () => { }); }; -const createDagApp = () => { - if (!window.gon?.features?.dagPipelineTab) { - return; - } - - const el = document.querySelector('#js-pipeline-dag-vue'); - const { pipelineDataPath, emptySvgPath, dagDocPath } = el?.dataset; - - // eslint-disable-next-line no-new - new Vue({ - el, - components: { - Dag, - }, - render(createElement) { - return createElement('dag', { - props: { - graphUrl: pipelineDataPath, - emptySvgPath, - dagDocPath, - }, - }); - }, - }); -}; - export default () => { const { dataset } = document.querySelector('.js-pipeline-details-vue'); const mediator = new PipelinesMediator({ endpoint: dataset.endpoint }); diff --git a/app/assets/javascripts/pipelines/pipeline_details_dag.js b/app/assets/javascripts/pipelines/pipeline_details_dag.js new file mode 100644 index 00000000000..dc03b457265 --- /dev/null +++ b/app/assets/javascripts/pipelines/pipeline_details_dag.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import Dag from './components/dag/dag.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +const createDagApp = () => { + if (!window.gon?.features?.dagPipelineTab) { + return; + } + + const el = document.querySelector('#js-pipeline-dag-vue'); + const { pipelineProjectPath, pipelineIid, emptySvgPath, dagDocPath } = el?.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + components: { + Dag, + }, + apolloProvider, + provide: { + pipelineProjectPath, + pipelineIid, + emptySvgPath, + dagDocPath, + }, + render(createElement) { + return createElement('dag', {}); + }, + }); +}; + +export default createDagApp; diff --git a/app/assets/javascripts/pipelines/pipeline_details_mediator.js b/app/assets/javascripts/pipelines/pipeline_details_mediator.js index f3387f00fc1..d487970aed7 100644 --- a/app/assets/javascripts/pipelines/pipeline_details_mediator.js +++ b/app/assets/javascripts/pipelines/pipeline_details_mediator.js @@ -1,6 +1,6 @@ import Visibility from 'visibilityjs'; import PipelineStore from './stores/pipeline_store'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import Poll from '../lib/utils/poll'; import { __ } from '../locale'; import PipelineService from './services/pipeline_service'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/actions.js b/app/assets/javascripts/pipelines/stores/test_reports/actions.js index ccacb9f7e97..f10bbeec77c 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/actions.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/actions.js @@ -1,46 +1,46 @@ import axios from '~/lib/utils/axios_utils'; import * as types from './mutation_types'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; export const fetchSummary = ({ state, commit, dispatch }) => { - // If we do this without the build_report_summary feature flag enabled - // it causes a race condition for toggleLoading and ruins the loading - // state in the application - if (state.useBuildSummaryReport) { - dispatch('toggleLoading'); - } + dispatch('toggleLoading'); return axios .get(state.summaryEndpoint) .then(({ data }) => { commit(types.SET_SUMMARY, data); - - if (!state.useBuildSummaryReport) { - // Set the tab counter badge to total_count - // This is temporary until we can server-side render that count number - // (see https://gitlab.com/gitlab-org/gitlab/-/issues/223134) - document.querySelector('.js-test-report-badge-counter').innerHTML = data.total_count || 0; - } }) .catch(() => { createFlash(s__('TestReports|There was an error fetching the summary.')); }) .finally(() => { - if (state.useBuildSummaryReport) { - dispatch('toggleLoading'); - } + dispatch('toggleLoading'); }); }; -export const fetchFullReport = ({ state, commit, dispatch }) => { +export const fetchTestSuite = ({ state, commit, dispatch }, index) => { + const { hasFullSuite } = state.testReports?.test_suites?.[index] || {}; + // We don't need to fetch the suite if we have the information already + if (hasFullSuite) { + return Promise.resolve(); + } + dispatch('toggleLoading'); + const { name = '', build_ids = [] } = state.testReports?.test_suites?.[index] || {}; + // Replacing `/:suite_name.json` with the name of the suite. Including the extra characters + // to ensure that we replace exactly the template part of the URL string + const endpoint = state.suiteEndpoint?.replace( + '/:suite_name.json', + `/${encodeURIComponent(name)}.json`, + ); + return axios - .get(state.fullReportEndpoint) - .then(({ data }) => commit(types.SET_REPORTS, data)) + .get(endpoint, { params: { build_ids } }) + .then(({ data }) => commit(types.SET_SUITE, { suite: data, index })) .catch(() => { - createFlash(s__('TestReports|There was an error fetching the test reports.')); + createFlash(s__('TestReports|There was an error fetching the test suite.')); }) .finally(() => { dispatch('toggleLoading'); @@ -52,6 +52,3 @@ export const setSelectedSuiteIndex = ({ commit }, data) => export const removeSelectedSuiteIndex = ({ commit }) => commit(types.SET_SELECTED_SUITE_INDEX, null); export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/getters.js b/app/assets/javascripts/pipelines/stores/test_reports/getters.js index 877762b77c9..6c670806cc4 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/getters.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/getters.js @@ -16,6 +16,3 @@ export const getSuiteTests = state => { const { test_cases: testCases = [] } = getSelectedSuite(state); return testCases.sort(sortTestCases).map(addIconStatus); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js index 76405557b51..52345888cb0 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutation_types.js @@ -1,4 +1,4 @@ -export const SET_REPORTS = 'SET_REPORTS'; export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; export const SET_SUMMARY = 'SET_SUMMARY'; +export const SET_SUITE = 'SET_SUITE'; export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js index 2531ab1e87c..3652a12a6ba 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/mutations.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/mutations.js @@ -1,16 +1,41 @@ import * as types from './mutation_types'; export default { - [types.SET_REPORTS](state, testReports) { - Object.assign(state, { testReports, hasFullReport: true }); + [types.SET_SUITE](state, { suite = {}, index = null }) { + state.testReports.test_suites[index] = { ...suite, hasFullSuite: true }; }, [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) { Object.assign(state, { selectedSuiteIndex }); }, - [types.SET_SUMMARY](state, summary) { - Object.assign(state, { testReports: { ...state.testReports, ...summary } }); + [types.SET_SUMMARY](state, testReports) { + const { total } = testReports; + state.testReports = { + ...testReports, + + /* + TLDR; this is a temporary mapping that will be updated once + test suites have the new data schema + + The backend is in the middle of updating the data schema + to have a `total` object containing the total data values. + The test suites don't have the new schema, but the summary + does. Currently the `test_summary.vue` component takes both + the summary and a test suite depending on what is being viewed. + This is a temporary change to map the new schema to the old until + we can update the schema for the test suites also. + Since test suites is an array, it is easier to just map the summary + to the old schema instead of mapping every test suite to the new. + */ + + total_time: total.time, + total_count: total.count, + success_count: total.success, + failed_count: total.failed, + skipped_count: total.skipped, + error_count: total.error, + }; }, [types.TOGGLE_LOADING](state) { diff --git a/app/assets/javascripts/pipelines/stores/test_reports/state.js b/app/assets/javascripts/pipelines/stores/test_reports/state.js index bcf5c147916..af79521d68a 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/state.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/state.js @@ -1,13 +1,7 @@ -export default ({ - fullReportEndpoint = '', - summaryEndpoint = '', - useBuildSummaryReport = false, -}) => ({ +export default ({ summaryEndpoint = '', suiteEndpoint = '' }) => ({ summaryEndpoint, - fullReportEndpoint, + suiteEndpoint, testReports: {}, selectedSuiteIndex: null, - hasFullReport: false, isLoading: false, - useBuildSummaryReport, }); diff --git a/app/assets/javascripts/pipelines/utils.js b/app/assets/javascripts/pipelines/utils.js index 9dbc8073d3a..2e08001f6b3 100644 --- a/app/assets/javascripts/pipelines/utils.js +++ b/app/assets/javascripts/pipelines/utils.js @@ -1,8 +1,7 @@ import { pickBy } from 'lodash'; import { SUPPORTED_FILTER_PARAMETERS } from './constants'; +// eslint-disable-next-line import/prefer-default-export export const validateParams = params => { return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); }; - -export default () => {}; diff --git a/app/assets/javascripts/profile/account/components/delete_account_modal.vue b/app/assets/javascripts/profile/account/components/delete_account_modal.vue index aeb69fb1c05..605859cfb6a 100644 --- a/app/assets/javascripts/profile/account/components/delete_account_modal.vue +++ b/app/assets/javascripts/profile/account/components/delete_account_modal.vue @@ -100,6 +100,7 @@ Once you confirm %{deleteAccount}, it cannot be undone or recovered.`), name="password" class="form-control" type="password" + data-qa-selector="password_confirmation_field" aria-labelledby="input-label" /> <input diff --git a/app/assets/javascripts/profile/account/components/update_username.vue b/app/assets/javascripts/profile/account/components/update_username.vue index feb83e07607..58025381cb2 100644 --- a/app/assets/javascripts/profile/account/components/update_username.vue +++ b/app/assets/javascripts/profile/account/components/update_username.vue @@ -3,7 +3,7 @@ import { escape } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import DeprecatedModal2 from '~/vue_shared/components/deprecated_modal_2.vue'; import { s__, sprintf } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; export default { components: { diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 21cc27cb1ce..6822fa8f7c7 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; -import flash from '../flash'; +import { deprecatedCreateFlash as flash } from '../flash'; import { parseBoolean } from '~/lib/utils/common_utils'; import TimezoneDropdown, { formatTimezone, diff --git a/app/assets/javascripts/project_find_file.js b/app/assets/javascripts/project_find_file.js index a31034361a8..70fce4a4d09 100644 --- a/app/assets/javascripts/project_find_file.js +++ b/app/assets/javascripts/project_find_file.js @@ -2,10 +2,10 @@ import $ from 'jquery'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; import axios from '~/lib/utils/axios_utils'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; // highlight text(awefwbwgtc -> <b>a</b>wefw<b>b</b>wgt<b>c</b> ) diff --git a/app/assets/javascripts/project_fork.js b/app/assets/javascripts/project_fork.js deleted file mode 100644 index f5cd1c3cc3e..00000000000 --- a/app/assets/javascripts/project_fork.js +++ /dev/null @@ -1,9 +0,0 @@ -import $ from 'jquery'; - -export default () => { - $('.js-fork-thumbnail').on('click', function forkThumbnailClicked() { - if ($(this).hasClass('disabled')) return false; - - return $('.js-fork-content').toggleClass('hidden'); - }); -}; diff --git a/app/assets/javascripts/project_label_subscription.js b/app/assets/javascripts/project_label_subscription.js index 5395e14cc79..12c77b09b64 100644 --- a/app/assets/javascripts/project_label_subscription.js +++ b/app/assets/javascripts/project_label_subscription.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; const tooltipTitles = { group: { diff --git a/app/assets/javascripts/project_select.js b/app/assets/javascripts/project_select.js index 2b2c365dd54..788553636f9 100644 --- a/app/assets/javascripts/project_select.js +++ b/app/assets/javascripts/project_select.js @@ -54,11 +54,11 @@ const projectSelect = () => { this.groupId, query.term, { - search_namespaces: true, with_issues_enabled: this.withIssuesEnabled, with_merge_requests_enabled: this.withMergeRequestsEnabled, with_shared: this.withShared, include_subgroups: this.includeProjectsInSubgroups, + order_by: 'similarity', }, projectsCallback, ); diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index f0832bd36a5..927501748a5 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,7 +1,7 @@ import * as Sentry from '@sentry/browser'; import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/projects/components/project_delete_button.vue b/app/assets/javascripts/projects/components/project_delete_button.vue new file mode 100644 index 00000000000..4b27c5e3d30 --- /dev/null +++ b/app/assets/javascripts/projects/components/project_delete_button.vue @@ -0,0 +1,52 @@ +<script> +import { GlAlert, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; +import SharedDeleteButton from './shared/delete_button.vue'; + +export default { + components: { + GlSprintf, + GlAlert, + SharedDeleteButton, + }, + props: { + confirmPhrase: { + type: String, + required: true, + }, + formPath: { + type: String, + required: true, + }, + }, + strings: { + alertTitle: __('You are about to permanently delete this project'), + alertBody: __( + 'Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its respositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc.', + ), + modalBody: __( + "This action cannot be undone. You will lose the project's respository and all conent: issues, merge requests, etc.", + ), + }, +}; +</script> + +<template> + <shared-delete-button v-bind="{ confirmPhrase, formPath }"> + <template #modal-body> + <gl-alert + class="gl-mb-5" + variant="danger" + :title="$options.strings.alertTitle" + :dismissible="false" + > + <gl-sprintf :message="$options.strings.alertBody"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </gl-alert> + <p>{{ $options.strings.modalBody }}</p> + </template> + </shared-delete-button> +</template> diff --git a/app/assets/javascripts/projects/components/remove_modal.vue b/app/assets/javascripts/projects/components/remove_modal.vue deleted file mode 100644 index 37f58efcb30..00000000000 --- a/app/assets/javascripts/projects/components/remove_modal.vue +++ /dev/null @@ -1,108 +0,0 @@ -<script> -import { GlModal, GlModalDirective, GlSprintf, GlFormInput, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; -import { rstrip } from '~/lib/utils/common_utils'; -import csrf from '~/lib/utils/csrf'; - -export default { - components: { - GlModal, - GlSprintf, - GlFormInput, - GlButton, - }, - directives: { - GlModal: GlModalDirective, - }, - props: { - confirmPhrase: { - type: String, - required: true, - }, - warningMessage: { - type: String, - required: true, - }, - formPath: { - type: String, - required: true, - }, - }, - data() { - return { - userInput: null, - }; - }, - computed: { - buttonDisabled() { - return rstrip(this.userInput) !== this.confirmPhrase; - }, - csrfToken() { - return csrf.token; - }, - }, - methods: { - submitForm() { - this.$refs.form.submit(); - }, - }, - strings: { - removeProject: __('Remove project'), - title: __('Confirmation required'), - confirm: __('Confirm'), - dataLoss: __( - 'This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention.', - ), - confirmText: __('Please type %{phrase_code} to proceed or close this modal to cancel.'), - }, - modalId: 'remove-project-modal', -}; -</script> - -<template> - <form ref="form" :action="formPath" method="post"> - <input type="hidden" name="_method" value="delete" /> - <input :value="csrfToken" type="hidden" name="authenticity_token" /> - <gl-button v-gl-modal="$options.modalId" category="primary" variant="danger">{{ - $options.strings.removeProject - }}</gl-button> - <gl-modal - ref="removeModal" - :modal-id="$options.modalId" - size="sm" - ok-variant="danger" - footer-class="bg-gray-light gl-p-5" - > - <template #modal-title>{{ $options.strings.title }}</template> - <template #modal-footer> - <div class="gl-w-full gl-display-flex gl-just-content-start gl-m-0"> - <gl-button - :disabled="buttonDisabled" - category="primary" - variant="danger" - @click="submitForm" - > - {{ $options.strings.confirm }} - </gl-button> - </div> - </template> - <div> - <p class="gl-text-red-500 gl-font-weight-bold">{{ warningMessage }}</p> - <p class="gl-mb-0">{{ $options.strings.dataLoss }}</p> - <p> - <gl-sprintf :message="$options.strings.confirmText"> - <template #phrase_code> - <code>{{ confirmPhrase }}</code> - </template> - </gl-sprintf> - </p> - <gl-form-input - id="confirm_name_input" - v-model="userInput" - name="confirm_name_input" - type="text" - /> - </div> - </gl-modal> - </form> -</template> diff --git a/app/assets/javascripts/projects/components/shared/delete_button.vue b/app/assets/javascripts/projects/components/shared/delete_button.vue new file mode 100644 index 00000000000..e3f4500d404 --- /dev/null +++ b/app/assets/javascripts/projects/components/shared/delete_button.vue @@ -0,0 +1,101 @@ +<script> +import { uniqueId } from 'lodash'; +import { GlModal, GlModalDirective, GlFormInput, GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import csrf from '~/lib/utils/csrf'; + +export default { + components: { + GlModal, + GlFormInput, + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + confirmPhrase: { + type: String, + required: true, + }, + formPath: { + type: String, + required: true, + }, + }, + data() { + return { + userInput: null, + modalId: uniqueId('delete-project-modal-'), + }; + }, + computed: { + confirmDisabled() { + return this.userInput !== this.confirmPhrase; + }, + csrfToken() { + return csrf.token; + }, + modalActionProps() { + return { + primary: { + text: __('Yes, delete project'), + attributes: [{ variant: 'danger' }, { disabled: this.confirmDisabled }], + }, + cancel: { + text: __('Cancel, keep project'), + }, + }; + }, + }, + methods: { + submitForm() { + this.$refs.form.submit(); + }, + }, + strings: { + deleteProject: __('Delete project'), + title: __('Delete project. Are you ABSOLUTELY SURE?'), + confirmText: __('Please type the following to confirm:'), + }, +}; +</script> + +<template> + <form ref="form" :action="formPath" method="post"> + <input type="hidden" name="_method" value="delete" /> + <input :value="csrfToken" type="hidden" name="authenticity_token" /> + + <gl-button v-gl-modal="modalId" category="primary" variant="danger">{{ + $options.strings.deleteProject + }}</gl-button> + + <gl-modal + ref="removeModal" + :modal-id="modalId" + size="sm" + ok-variant="danger" + footer-class="gl-bg-gray-10 gl-p-5" + title-class="gl-text-red-500" + :action-primary="modalActionProps.primary" + :action-cancel="modalActionProps.cancel" + @ok="submitForm" + > + <template #modal-title>{{ $options.strings.title }}</template> + <div> + <slot name="modal-body"></slot> + <p class="gl-mb-1">{{ $options.strings.confirmText }}</p> + <p> + <code>{{ confirmPhrase }}</code> + </p> + <gl-form-input + id="confirm_name_input" + v-model="userInput" + name="confirm_name_input" + type="text" + /> + <slot name="modal-footer"></slot> + </div> + </gl-modal> + </form> +</template> diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue index e553599357c..ee4a00dbc75 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/app.vue @@ -1,7 +1,7 @@ <script> +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; import WelcomePage from './welcome.vue'; import LegacyContainer from './legacy_container.vue'; -import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import blankProjectIllustration from '../illustrations/blank-project.svg'; diff --git a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue index ea22818da0e..cd9a72996cf 100644 --- a/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue +++ b/app/assets/javascripts/projects/experiment_new_project_creation/components/welcome.vue @@ -1,6 +1,6 @@ <script> -import Tracking from '~/tracking'; import { GlPopover } from '@gitlab/ui'; +import Tracking from '~/tracking'; import LegacyContainer from './legacy_container.vue'; const trackingMixin = Tracking.mixin(gon.tracking_data); diff --git a/app/assets/javascripts/projects/pipelines/charts/components/app.vue b/app/assets/javascripts/projects/pipelines/charts/components/app.vue index cdf03a5013f..0777dddfc19 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/app.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/app.vue @@ -1,7 +1,7 @@ <script> import dateFormat from 'dateformat'; -import { __, sprintf } from '~/locale'; import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import { __, sprintf } from '~/locale'; import { getDateInPast } from '~/lib/utils/datetime_utility'; import StatisticsList from './statistics_list.vue'; import PipelinesAreaChart from './pipelines_area_chart.vue'; diff --git a/app/assets/javascripts/projects/project_remove_modal.js b/app/assets/javascripts/projects/project_delete_button.js index dbdad1bf6f1..aa7fc31d307 100644 --- a/app/assets/javascripts/projects/project_remove_modal.js +++ b/app/assets/javascripts/projects/project_delete_button.js @@ -1,21 +1,20 @@ import Vue from 'vue'; -import RemoveProjectModal from './components/remove_modal.vue'; +import ProjectDeleteButton from './components/project_delete_button.vue'; -export default (selector = '#js-confirm-project-remove') => { +export default (selector = '#js-project-delete-button') => { const el = document.querySelector(selector); if (!el) return; - const { formPath, confirmPhrase, warningMessage } = el.dataset; + const { confirmPhrase, formPath } = el.dataset; // eslint-disable-next-line no-new new Vue({ el, render(createElement) { - return createElement(RemoveProjectModal, { + return createElement(ProjectDeleteButton, { props: { confirmPhrase, - warningMessage, formPath, }, }); diff --git a/app/assets/javascripts/projects/project_new.js b/app/assets/javascripts/projects/project_new.js index ebf745fd046..ec0a83b5736 100644 --- a/app/assets/javascripts/projects/project_new.js +++ b/app/assets/javascripts/projects/project_new.js @@ -1,7 +1,7 @@ import $ from 'jquery'; +import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; import { addSelectOnFocusBehaviour } from '../lib/utils/common_utils'; import { convertToTitleCase, humanize, slugify } from '../lib/utils/text_utility'; -import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates'; let hasUserDefinedProjectPath = false; let hasUserDefinedProjectName = false; diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js new file mode 100644 index 00000000000..4dbf6675357 --- /dev/null +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -0,0 +1,524 @@ +/* eslint-disable no-underscore-dangle, class-methods-use-this */ +import { escape, find, countBy } from 'lodash'; +import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import { n__, s__, __ } from '~/locale'; +import { LEVEL_TYPES, LEVEL_ID_PROP, ACCESS_LEVEL_NONE } from './constants'; + +export default class AccessDropdown { + constructor(options) { + const { $dropdown, accessLevel, accessLevelsData, hasLicense = true } = options; + this.options = options; + this.hasLicense = hasLicense; + this.groups = []; + this.accessLevel = accessLevel; + this.accessLevelsData = accessLevelsData.roles; + this.$dropdown = $dropdown; + this.$wrap = this.$dropdown.closest(`.${this.accessLevel}-container`); + this.usersPath = '/-/autocomplete/users.json'; + this.groupsPath = '/-/autocomplete/project_groups.json'; + this.defaultLabel = this.$dropdown.data('defaultLabel'); + + this.setSelectedItems([]); + this.persistPreselectedItems(); + + this.noOneObj = this.accessLevelsData.find(level => level.id === ACCESS_LEVEL_NONE); + + this.initDropdown(); + } + + initDropdown() { + const { onSelect, onHide } = this.options; + this.$dropdown.glDropdown({ + data: this.getData.bind(this), + selectable: true, + filterable: true, + filterRemote: true, + multiSelect: this.$dropdown.hasClass('js-multiselect'), + renderRow: this.renderRow.bind(this), + toggleLabel: this.toggleLabel.bind(this), + hidden() { + if (onHide) { + onHide(); + } + }, + clicked: options => { + const { $el, e } = options; + const item = options.selectedObj; + + e.preventDefault(); + + if (!this.hasLicense) { + // We're not multiselecting quite yet with FOSS: + // remove all preselected items before selecting this item + // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37499 + this.accessLevelsData.forEach(level => { + this.removeSelectedItem(level); + }); + } + + if ($el.is('.is-active')) { + if (this.noOneObj) { + if (item.id === this.noOneObj.id && this.hasLicense) { + // remove all others selected items + this.accessLevelsData.forEach(level => { + if (level.id !== item.id) { + this.removeSelectedItem(level); + } + }); + + // remove selected item visually + this.$wrap.find(`.item-${item.type}`).removeClass('is-active'); + } else { + const $noOne = this.$wrap.find( + `.is-active.item-${item.type}[data-role-id="${this.noOneObj.id}"]`, + ); + if ($noOne.length) { + $noOne.removeClass('is-active'); + this.removeSelectedItem(this.noOneObj); + } + } + } + + // make element active right away + $el.addClass(`is-active item-${item.type}`); + + // Add "No one" + this.addSelectedItem(item); + } else { + this.removeSelectedItem(item); + } + + if (onSelect) { + onSelect(item, $el, this); + } + }, + }); + + this.$dropdown.find('.dropdown-toggle-text').text(this.toggleLabel()); + } + + persistPreselectedItems() { + const itemsToPreselect = this.$dropdown.data('preselectedItems'); + + if (!itemsToPreselect || !itemsToPreselect.length) { + return; + } + + const persistedItems = itemsToPreselect.map(item => { + const persistedItem = { ...item }; + persistedItem.persisted = true; + return persistedItem; + }); + + this.setSelectedItems(persistedItems); + } + + setSelectedItems(items = []) { + this.items = items; + } + + getSelectedItems() { + return this.items.filter(item => !item._destroy); + } + + getAllSelectedItems() { + return this.items; + } + + // Return dropdown as input data ready to submit + getInputData() { + const selectedItems = this.getAllSelectedItems(); + + const accessLevels = selectedItems.map(item => { + const obj = {}; + + if (typeof item.id !== 'undefined') { + obj.id = item.id; + } + + if (typeof item._destroy !== 'undefined') { + obj._destroy = item._destroy; + } + + if (item.type === LEVEL_TYPES.ROLE) { + obj.access_level = item.access_level; + } else if (item.type === LEVEL_TYPES.USER) { + obj.user_id = item.user_id; + } else if (item.type === LEVEL_TYPES.GROUP) { + obj.group_id = item.group_id; + } + + return obj; + }); + + return accessLevels; + } + + addSelectedItem(selectedItem) { + let itemToAdd = {}; + + let index = -1; + let alreadyAdded = false; + const selectedItems = this.getAllSelectedItems(); + + // Compare IDs based on selectedItem.type + selectedItems.forEach((item, i) => { + let comparator; + switch (selectedItem.type) { + case LEVEL_TYPES.ROLE: + comparator = LEVEL_ID_PROP.ROLE; + // If the item already exists, just use it + if (item[comparator] === selectedItem.id) { + alreadyAdded = true; + } + break; + case LEVEL_TYPES.GROUP: + comparator = LEVEL_ID_PROP.GROUP; + break; + case LEVEL_TYPES.USER: + comparator = LEVEL_ID_PROP.USER; + break; + default: + break; + } + + if (selectedItem.id === item[comparator]) { + index = i; + } + }); + + if (alreadyAdded) { + return; + } + + if (index !== -1 && selectedItems[index]._destroy) { + delete selectedItems[index]._destroy; + return; + } + + itemToAdd.type = selectedItem.type; + + if (selectedItem.type === LEVEL_TYPES.USER) { + itemToAdd = { + user_id: selectedItem.id, + name: selectedItem.name || '_name1', + username: selectedItem.username || '_username1', + avatar_url: selectedItem.avatar_url || '_avatar_url1', + type: LEVEL_TYPES.USER, + }; + } else if (selectedItem.type === LEVEL_TYPES.ROLE) { + itemToAdd = { + access_level: selectedItem.id, + type: LEVEL_TYPES.ROLE, + }; + } else if (selectedItem.type === LEVEL_TYPES.GROUP) { + itemToAdd = { + group_id: selectedItem.id, + type: LEVEL_TYPES.GROUP, + }; + } + + this.items.push(itemToAdd); + } + + removeSelectedItem(itemToDelete) { + let index = -1; + const selectedItems = this.getAllSelectedItems(); + + // To find itemToDelete on selectedItems, first we need the index + selectedItems.every((item, i) => { + if (item.type !== itemToDelete.type) { + return true; + } + + if (item.type === LEVEL_TYPES.USER && item.user_id === itemToDelete.id) { + index = i; + } else if (item.type === LEVEL_TYPES.ROLE && item.access_level === itemToDelete.id) { + index = i; + } else if (item.type === LEVEL_TYPES.GROUP && item.group_id === itemToDelete.id) { + index = i; + } + + // Break once we have index set + return !(index > -1); + }); + + // if ItemToDelete is not really selected do nothing + if (index === -1) { + return; + } + + if (selectedItems[index].persisted) { + // If we toggle an item that has been already marked with _destroy + if (selectedItems[index]._destroy) { + delete selectedItems[index]._destroy; + } else { + selectedItems[index]._destroy = '1'; + } + } else { + selectedItems.splice(index, 1); + } + } + + toggleLabel() { + const currentItems = this.getSelectedItems(); + const $dropdownToggleText = this.$dropdown.find('.dropdown-toggle-text'); + + if (currentItems.length === 0) { + $dropdownToggleText.addClass('is-default'); + return this.defaultLabel; + } + + $dropdownToggleText.removeClass('is-default'); + + if (currentItems.length === 1 && currentItems[0].type === LEVEL_TYPES.ROLE) { + const roleData = this.accessLevelsData.find(data => data.id === currentItems[0].access_level); + return roleData.text; + } + + const labelPieces = []; + const counts = countBy(currentItems, item => item.type); + + if (counts[LEVEL_TYPES.ROLE] > 0) { + labelPieces.push(n__('1 role', '%d roles', counts[LEVEL_TYPES.ROLE])); + } + + if (counts[LEVEL_TYPES.USER] > 0) { + labelPieces.push(n__('1 user', '%d users', counts[LEVEL_TYPES.USER])); + } + + if (counts[LEVEL_TYPES.GROUP] > 0) { + labelPieces.push(n__('1 group', '%d groups', counts[LEVEL_TYPES.GROUP])); + } + + return labelPieces.join(', '); + } + + getData(query, callback) { + if (this.hasLicense) { + Promise.all([ + this.getUsers(query), + this.groupsData ? Promise.resolve(this.groupsData) : this.getGroups(), + ]) + .then(([usersResponse, groupsResponse]) => { + this.groupsData = groupsResponse; + callback(this.consolidateData(usersResponse.data, groupsResponse.data)); + }) + .catch(() => Flash(__('Failed to load groups & users.'))); + } else { + callback(this.consolidateData()); + } + } + + consolidateData(usersResponse = [], groupsResponse = []) { + let consolidatedData = []; + + // ID property is handled differently locally from the server + // + // For Groups + // In dropdown: `id` + // For submit: `group_id` + // + // For Roles + // In dropdown: `id` + // For submit: `access_level` + // + // For Users + // In dropdown: `id` + // For submit: `user_id` + + /* + * Build roles + */ + const roles = this.accessLevelsData.map(level => { + /* eslint-disable no-param-reassign */ + // This re-assignment is intentional as + // level.type property is being used in removeSelectedItem() + // for comparision, and accessLevelsData is provided by + // gon.create_access_levels which doesn't have `type` included. + // See this discussion https://gitlab.com/gitlab-org/gitlab/merge_requests/1629#note_31285823 + level.type = LEVEL_TYPES.ROLE; + return level; + }); + + if (roles.length) { + consolidatedData = consolidatedData.concat( + [{ type: 'header', content: s__('AccessDropdown|Roles') }], + roles, + ); + } + + if (this.hasLicense) { + const map = []; + const selectedItems = this.getSelectedItems(); + /* + * Build groups + */ + const groups = groupsResponse.map(group => ({ + ...group, + type: LEVEL_TYPES.GROUP, + })); + + /* + * Build users + */ + const users = selectedItems + .filter(item => item.type === LEVEL_TYPES.USER) + .map(item => { + // Save identifiers for easy-checking more later + map.push(LEVEL_TYPES.USER + item.user_id); + + return { + id: item.user_id, + name: item.name, + username: item.username, + avatar_url: item.avatar_url, + type: LEVEL_TYPES.USER, + }; + }); + + // Has to be checked against server response + // because the selected item can be in filter results + usersResponse.forEach(response => { + // Add is it has not been added + if (map.indexOf(LEVEL_TYPES.USER + response.id) === -1) { + const user = { ...response }; + user.type = LEVEL_TYPES.USER; + users.push(user); + } + }); + + if (groups.length) { + if (roles.length) { + consolidatedData = consolidatedData.concat([{ type: 'divider' }]); + } + + consolidatedData = consolidatedData.concat( + [{ type: 'header', content: s__('AccessDropdown|Groups') }], + groups, + ); + } + + if (users.length) { + consolidatedData = consolidatedData.concat( + [{ type: 'divider' }], + [{ type: 'header', content: s__('AccessDropdown|Users') }], + users, + ); + } + } + + return consolidatedData; + } + + getUsers(query) { + return axios.get(this.buildUrl(gon.relative_url_root, this.usersPath), { + params: { + search: query, + per_page: 20, + active: true, + project_id: gon.current_project_id, + push_code: true, + }, + }); + } + + getGroups() { + return axios.get(this.buildUrl(gon.relative_url_root, this.groupsPath), { + params: { + project_id: gon.current_project_id, + }, + }); + } + + buildUrl(urlRoot, url) { + let newUrl; + if (urlRoot != null) { + newUrl = urlRoot.replace(/\/$/, '') + url; + } + return newUrl; + } + + renderRow(item) { + let criteria = {}; + let groupRowEl; + + // Dectect if the current item is already saved so we can add + // the `is-active` class so the item looks as marked + switch (item.type) { + case LEVEL_TYPES.USER: + criteria = { user_id: item.id }; + break; + case LEVEL_TYPES.ROLE: + criteria = { access_level: item.id }; + break; + case LEVEL_TYPES.GROUP: + criteria = { group_id: item.id }; + break; + default: + break; + } + + const isActive = find(this.getSelectedItems(), criteria) ? 'is-active' : ''; + + switch (item.type) { + case LEVEL_TYPES.USER: + groupRowEl = this.userRowHtml(item, isActive); + break; + case LEVEL_TYPES.ROLE: + groupRowEl = this.roleRowHtml(item, isActive); + break; + case LEVEL_TYPES.GROUP: + groupRowEl = this.groupRowHtml(item, isActive); + break; + default: + groupRowEl = ''; + break; + } + + return groupRowEl; + } + + userRowHtml(user, isActive) { + const isActiveClass = isActive || ''; + + return ` + <li> + <a href="#" class="${isActiveClass}"> + <img src="${user.avatar_url}" class="avatar avatar-inline" width="30"> + <strong class="dropdown-menu-user-full-name">${escape(user.name)}</strong> + <span class="dropdown-menu-user-username">${user.username}</span> + </a> + </li> + `; + } + + groupRowHtml(group, isActive) { + const isActiveClass = isActive || ''; + const avatarEl = group.avatar_url + ? `<img src="${group.avatar_url}" class="avatar avatar-inline" width="30">` + : ''; + + return ` + <li> + <a href="#" class="${isActiveClass}"> + ${avatarEl} + <span class="dropdown-menu-group-groupname">${group.name}</span> + </a> + </li> + `; + } + + roleRowHtml(role, isActive) { + const isActiveClass = isActive || ''; + + return ` + <li> + <a href="#" class="${isActiveClass} item-${role.type}" data-role-id="${role.id}"> + ${role.text} + </a> + </li> + `; + } +} diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js new file mode 100644 index 00000000000..fadb1f4f178 --- /dev/null +++ b/app/assets/javascripts/projects/settings/constants.js @@ -0,0 +1,13 @@ +export const LEVEL_TYPES = { + ROLE: 'role', + USER: 'user', + GROUP: 'group', +}; + +export const LEVEL_ID_PROP = { + ROLE: 'access_level', + USER: 'user_id', + GROUP: 'group_id', +}; + +export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 43c20fea43e..0b7433d6aaa 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -1,5 +1,5 @@ <script> -import { GlDeprecatedButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -13,7 +13,7 @@ export default { }, components: { ClipboardButton, - GlDeprecatedButton, + GlButton, GlFormSelect, GlToggle, GlLoadingIcon, @@ -157,12 +157,14 @@ export default { }} </span> </template> - <gl-deprecated-button + <gl-button variant="success" + class="gl-mt-5" :disabled="isTemplateSaving" @click="onSaveTemplate" - >{{ __('Save template') }}</gl-deprecated-button > + {{ __('Save template') }} + </gl-button> </div> </div> </div> diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue index 571d305a50c..e691f675e59 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status_component.vue @@ -3,7 +3,7 @@ import Visibility from 'visibilityjs'; import { GlLoadingIcon } from '@gitlab/ui'; import ciIcon from '~/vue_shared/components/ci_icon.vue'; import Poll from '~/lib/utils/poll'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { __, s__, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import CommitPipelineService from '../services/commit_pipeline_service'; diff --git a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue index 941a05583ad..05e769f5fc8 100644 --- a/app/assets/javascripts/prometheus_alerts/components/reset_key.vue +++ b/app/assets/javascripts/prometheus_alerts/components/reset_key.vue @@ -1,20 +1,14 @@ <script> -import { - GlDeprecatedButton, - GlFormGroup, - GlFormInput, - GlModal, - GlModalDirective, -} from '@gitlab/ui'; +import { GlButton, GlFormGroup, GlFormInput, GlModal, GlModalDirective } from '@gitlab/ui'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; export default { copyToClipboard: __('Copy'), components: { - GlDeprecatedButton, + GlButton, GlFormGroup, GlFormInput, GlModal, @@ -131,20 +125,13 @@ export default { ) }} </gl-modal> - <gl-deprecated-button - v-gl-modal.authKeyModal - class="js-reset-auth-key" - :disabled="disabled" - >{{ __('Reset key') }}</gl-deprecated-button - > + <gl-button v-gl-modal.authKeyModal class="js-reset-auth-key" :disabled="disabled">{{ + __('Reset key') + }}</gl-button> </template> - <gl-deprecated-button - v-else - :disabled="disabled" - class="js-reset-auth-key" - @click="resetKey" - >{{ __('Generate key') }}</gl-deprecated-button - > + <gl-button v-else :disabled="disabled" class="js-reset-auth-key" @click="resetKey">{{ + __('Generate key') + }}</gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js index 59d47ae4155..7fc1b18bf71 100644 --- a/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js +++ b/app/assets/javascripts/prometheus_metrics/prometheus_metrics.js @@ -87,16 +87,15 @@ export default class PrometheusMetrics { if (totalMonitoredMetrics === 0) { const emptyCommonMetricsText = sprintf( - s__( - 'PrometheusService|<p class="text-tertiary">No <a href="%{docsUrl}">common metrics</a> were found</p>', - ), + s__('PrometheusService|No %{docsUrlStart}common metrics%{docsUrlEnd} were found'), { - docsUrl: this.helpMetricsPath, + docsUrlStart: `<a href="${this.helpMetricsPath}">`, + docsUrlEnd: '</a>', }, false, ); this.$monitoredMetricsEmpty.empty(); - this.$monitoredMetricsEmpty.append(emptyCommonMetricsText); + this.$monitoredMetricsEmpty.append(`<p class="text-tertiary">${emptyCommonMetricsText}</p>`); this.showMonitoringMetricsPanelState(PANEL_STATE.EMPTY); } else { const metricsCountText = sprintf( diff --git a/app/assets/javascripts/protected_branches/constants.js b/app/assets/javascripts/protected_branches/constants.js new file mode 100644 index 00000000000..a17ae6811b7 --- /dev/null +++ b/app/assets/javascripts/protected_branches/constants.js @@ -0,0 +1,18 @@ +export const ACCESS_LEVELS = { + MERGE: 'merge_access_levels', + PUSH: 'push_access_levels', +}; + +export const LEVEL_TYPES = { + ROLE: 'role', + USER: 'user', + GROUP: 'group', +}; + +export const LEVEL_ID_PROP = { + ROLE: 'access_level', + USER: 'user_id', + GROUP: 'group_id', +}; + +export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js deleted file mode 100644 index 41e295387ae..00000000000 --- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js +++ /dev/null @@ -1,28 +0,0 @@ -import { __ } from '~/locale'; - -export default class ProtectedBranchAccessDropdown { - constructor(options) { - this.options = options; - this.initDropdown(); - } - - initDropdown() { - const { $dropdown, data, onSelect } = this.options; - $dropdown.glDropdown({ - data, - selectable: true, - inputId: $dropdown.data('inputId'), - fieldName: $dropdown.data('fieldName'), - toggleLabel(item, $el) { - if ($el.is('.is-active')) { - return item.text; - } - return __('Select'); - }, - clicked(options) { - options.e.preventDefault(); - onSelect(); - }, - }); - } -} diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js index 16ecd5523d6..5ccffe9700e 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_create.js +++ b/app/assets/javascripts/protected_branches/protected_branch_create.js @@ -1,41 +1,62 @@ import $ from 'jquery'; -import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; -import CreateItemDropdown from '../create_item_dropdown'; -import AccessorUtilities from '../lib/utils/accessor'; +import AccessDropdown from '~/projects/settings/access_dropdown'; +import axios from '~/lib/utils/axios_utils'; +import AccessorUtilities from '~/lib/utils/accessor'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import CreateItemDropdown from '~/create_item_dropdown'; +import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; import { __ } from '~/locale'; export default class ProtectedBranchCreate { - constructor() { + constructor(options) { + this.hasLicense = options.hasLicense; + this.$form = $('.js-new-protected-branch'); this.isLocalStorageAvailable = AccessorUtilities.isLocalStorageAccessSafe(); this.currentProjectUserDefaults = {}; this.buildDropdowns(); + this.$codeOwnerToggle = this.$form.find('.js-code-owner-toggle'); + this.bindEvents(); + } + + bindEvents() { + if (this.hasLicense) { + this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this)); + } + this.$form.on('submit', this.onFormSubmit.bind(this)); + } + + onCodeOwnerToggleClick() { + this.$codeOwnerToggle.toggleClass('is-checked'); } buildDropdowns() { const $allowedToMergeDropdown = this.$form.find('.js-allowed-to-merge'); const $allowedToPushDropdown = this.$form.find('.js-allowed-to-push'); - const $protectedBranchDropdown = this.$form.find('.js-protected-branch-select'); // Cache callback this.onSelectCallback = this.onSelect.bind(this); // Allowed to Merge dropdown - this.protectedBranchMergeAccessDropdown = new ProtectedBranchAccessDropdown({ + this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({ $dropdown: $allowedToMergeDropdown, - data: gon.merge_access_levels, + accessLevelsData: gon.merge_access_levels, onSelect: this.onSelectCallback, + accessLevel: ACCESS_LEVELS.MERGE, + hasLicense: this.hasLicense, }); // Allowed to Push dropdown - this.protectedBranchPushAccessDropdown = new ProtectedBranchAccessDropdown({ + this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({ $dropdown: $allowedToPushDropdown, - data: gon.push_access_levels, + accessLevelsData: gon.push_access_levels, onSelect: this.onSelectCallback, + accessLevel: ACCESS_LEVELS.PUSH, + hasLicense: this.hasLicense, }); this.createItemDropdown = new CreateItemDropdown({ - $dropdown: $protectedBranchDropdown, + $dropdown: this.$form.find('.js-protected-branch-select'), defaultToggleLabel: __('Protected Branch'), fieldName: 'protected_branch[name]', onSelect: this.onSelectCallback, @@ -43,26 +64,66 @@ export default class ProtectedBranchCreate { }); } - // This will run after clicked callback + // Enable submit button after selecting an option onSelect() { - // Enable submit button - const $branchInput = this.$form.find('input[name="protected_branch[name]"]'); - const $allowedToMergeInput = this.$form.find( - 'input[name="protected_branch[merge_access_levels_attributes][0][access_level]"]', - ); - const $allowedToPushInput = this.$form.find( - 'input[name="protected_branch[push_access_levels_attributes][0][access_level]"]', - ); - const completedForm = !( - $branchInput.val() && - $allowedToMergeInput.length && - $allowedToPushInput.length + const $allowedToMerge = this[`${ACCESS_LEVELS.MERGE}_dropdown`].getSelectedItems(); + const $allowedToPush = this[`${ACCESS_LEVELS.PUSH}_dropdown`].getSelectedItems(); + const toggle = !( + this.$form.find('input[name="protected_branch[name]"]').val() && + $allowedToMerge.length && + $allowedToPush.length ); - this.$form.find('input[type="submit"]').prop('disabled', completedForm); + this.$form.find('input[type="submit"]').attr('disabled', toggle); } static getProtectedBranches(term, callback) { callback(gon.open_branches); } + + getFormData() { + const formData = { + authenticity_token: this.$form.find('input[name="authenticity_token"]').val(), + protected_branch: { + name: this.$form.find('input[name="protected_branch[name]"]').val(), + code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'), + }, + }; + + Object.keys(ACCESS_LEVELS).forEach(level => { + const accessLevel = ACCESS_LEVELS[level]; + const selectedItems = this[`${accessLevel}_dropdown`].getSelectedItems(); + const levelAttributes = []; + + selectedItems.forEach(item => { + if (item.type === LEVEL_TYPES.USER) { + levelAttributes.push({ + user_id: item.user_id, + }); + } else if (item.type === LEVEL_TYPES.ROLE) { + levelAttributes.push({ + access_level: item.access_level, + }); + } else if (item.type === LEVEL_TYPES.GROUP) { + levelAttributes.push({ + group_id: item.group_id, + }); + } + }); + + formData.protected_branch[`${accessLevel}_attributes`] = levelAttributes; + }); + + return formData; + } + + onFormSubmit(e) { + e.preventDefault(); + + axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData()) + .then(() => { + window.location.reload(); + }) + .catch(() => Flash(__('Failed to protect the branch'))); + } } diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 08d8c9919dd..1f079123081 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -1,78 +1,165 @@ -import flash from '../flash'; -import axios from '../lib/utils/axios_utils'; -import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown'; +import { find } from 'lodash'; +import AccessDropdown from '~/projects/settings/access_dropdown'; +import axios from '~/lib/utils/axios_utils'; +import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; +import { deprecatedCreateFlash as flash } from '../flash'; import { __ } from '~/locale'; export default class ProtectedBranchEdit { constructor(options) { + this.hasLicense = options.hasLicense; + + this.$wraps = {}; + this.hasChanges = false; this.$wrap = options.$wrap; this.$allowedToMergeDropdown = this.$wrap.find('.js-allowed-to-merge'); this.$allowedToPushDropdown = this.$wrap.find('.js-allowed-to-push'); - this.onSelectCallback = this.onSelect.bind(this); + this.$codeOwnerToggle = this.$wrap.find('.js-code-owner-toggle'); + + this.$wraps[ACCESS_LEVELS.MERGE] = this.$allowedToMergeDropdown.closest( + `.${ACCESS_LEVELS.MERGE}-container`, + ); + this.$wraps[ACCESS_LEVELS.PUSH] = this.$allowedToPushDropdown.closest( + `.${ACCESS_LEVELS.PUSH}-container`, + ); this.buildDropdowns(); + this.bindEvents(); + } + + bindEvents() { + if (this.hasLicense) { + this.$codeOwnerToggle.on('click', this.onCodeOwnerToggleClick.bind(this)); + } + } + + onCodeOwnerToggleClick() { + this.$codeOwnerToggle.toggleClass('is-checked'); + this.$codeOwnerToggle.prop('disabled', true); + + const formData = { + code_owner_approval_required: this.$codeOwnerToggle.hasClass('is-checked'), + }; + + this.updateCodeOwnerApproval(formData); + } + + updateCodeOwnerApproval(formData) { + axios + .patch(this.$wrap.data('url'), { + protected_branch: formData, + }) + .then(() => { + this.$codeOwnerToggle.prop('disabled', false); + }) + .catch(() => { + flash(__('Failed to update branch!')); + }); } buildDropdowns() { // Allowed to merge dropdown - this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({ + this[`${ACCESS_LEVELS.MERGE}_dropdown`] = new AccessDropdown({ + accessLevel: ACCESS_LEVELS.MERGE, + accessLevelsData: gon.merge_access_levels, $dropdown: this.$allowedToMergeDropdown, - data: gon.merge_access_levels, - onSelect: this.onSelectCallback, + onSelect: this.onSelectOption.bind(this), + onHide: this.onDropdownHide.bind(this), + hasLicense: this.hasLicense, }); // Allowed to push dropdown - this.protectedBranchAccessDropdown = new ProtectedBranchAccessDropdown({ + this[`${ACCESS_LEVELS.PUSH}_dropdown`] = new AccessDropdown({ + accessLevel: ACCESS_LEVELS.PUSH, + accessLevelsData: gon.push_access_levels, $dropdown: this.$allowedToPushDropdown, - data: gon.push_access_levels, - onSelect: this.onSelectCallback, + onSelect: this.onSelectOption.bind(this), + onHide: this.onDropdownHide.bind(this), + hasLicense: this.hasLicense, }); } - onSelect() { - const $allowedToMergeInput = this.$wrap.find( - `input[name="${this.$allowedToMergeDropdown.data('fieldName')}"]`, - ); - const $allowedToPushInput = this.$wrap.find( - `input[name="${this.$allowedToPushDropdown.data('fieldName')}"]`, - ); + onSelectOption() { + this.hasChanges = true; + } - // Do not update if one dropdown has not selected any option - if (!($allowedToMergeInput.length && $allowedToPushInput.length)) return; + onDropdownHide() { + if (!this.hasChanges) { + return; + } - this.$allowedToMergeDropdown.disable(); - this.$allowedToPushDropdown.disable(); + this.hasChanges = true; + this.updatePermissions(); + } + + updatePermissions() { + const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => { + const accessLevelName = ACCESS_LEVELS[level]; + const inputData = this[`${accessLevelName}_dropdown`].getInputData(accessLevelName); + acc[`${accessLevelName}_attributes`] = inputData; + + return acc; + }, {}); axios .patch(this.$wrap.data('url'), { - protected_branch: { - merge_access_levels_attributes: [ - { - id: this.$allowedToMergeDropdown.data('accessLevelId'), - access_level: $allowedToMergeInput.val(), - }, - ], - push_access_levels_attributes: [ - { - id: this.$allowedToPushDropdown.data('accessLevelId'), - access_level: $allowedToPushInput.val(), - }, - ], - }, + protected_branch: formData, }) - .then(() => { + .then(({ data }) => { + this.hasChanges = false; + + Object.keys(ACCESS_LEVELS).forEach(level => { + const accessLevelName = ACCESS_LEVELS[level]; + + // The data coming from server will be the new persisted *state* for each dropdown + this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`); + }); this.$allowedToMergeDropdown.enable(); this.$allowedToPushDropdown.enable(); }) .catch(() => { this.$allowedToMergeDropdown.enable(); this.$allowedToPushDropdown.enable(); - - flash( - __('Failed to update branch!'), - 'alert', - document.querySelector('.js-protected-branches-list'), - ); + flash(__('Failed to update branch!')); }); } + + setSelectedItemsToDropdown(items = [], dropdownName) { + const itemsToAdd = items.map(currentItem => { + if (currentItem.user_id) { + // Do this only for users for now + // get the current data for selected items + const selectedItems = this[dropdownName].getSelectedItems(); + const currentSelectedItem = find(selectedItems, { + user_id: currentItem.user_id, + }); + + return { + id: currentItem.id, + user_id: currentItem.user_id, + type: LEVEL_TYPES.USER, + persisted: true, + name: currentSelectedItem.name, + username: currentSelectedItem.username, + avatar_url: currentSelectedItem.avatar_url, + }; + } else if (currentItem.group_id) { + return { + id: currentItem.id, + group_id: currentItem.group_id, + type: LEVEL_TYPES.GROUP, + persisted: true, + }; + } + + return { + id: currentItem.id, + access_level: currentItem.access_level, + type: LEVEL_TYPES.ROLE, + persisted: true, + }; + }); + + this[dropdownName].setSelectedItems(itemsToAdd); + } } diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js index 10253c0febc..6ab9a126e76 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit_list.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit_list.js @@ -13,6 +13,7 @@ export default class ProtectedBranchEditList { this.$wrap.find('.js-protected-branch-edit-form').each((i, el) => { new ProtectedBranchEdit({ $wrap: $(el), + hasLicense: false, }); }); } diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js index 70bfd71abce..157ac1c7ebd 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -1,4 +1,4 @@ -import flash from '../flash'; +import { deprecatedCreateFlash as flash } from '../flash'; import axios from '../lib/utils/axios_utils'; import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/ref/components/ref_results_section.vue b/app/assets/javascripts/ref/components/ref_results_section.vue index 32e916052c4..c8f5c66b0c1 100644 --- a/app/assets/javascripts/ref/components/ref_results_section.vue +++ b/app/assets/javascripts/ref/components/ref_results_section.vue @@ -111,7 +111,7 @@ export default { <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column"> <span class="gl-font-monospace">{{ item.name }}</span> - <span class="gl-text-gray-600">{{ item.subtitle }}</span> + <span class="gl-text-gray-400">{{ item.subtitle }}</span> </div> <gl-badge v-if="item.default" size="sm" variant="info">{{ diff --git a/app/assets/javascripts/ref/components/ref_selector.vue b/app/assets/javascripts/ref/components/ref_selector.vue index 012a391a3da..e388604ed92 100644 --- a/app/assets/javascripts/ref/components/ref_selector.vue +++ b/app/assets/javascripts/ref/components/ref_selector.vue @@ -74,6 +74,18 @@ export default { return !this.showBranchesSection && !this.showTagsSection && !this.showCommitsSection; }, }, + watch: { + // Keep the Vuex store synchronized if the parent + // component updates the selected ref through v-model + value: { + immediate: true, + handler() { + if (this.value !== this.selectedRef) { + this.setSelectedRef(this.value); + } + }, + }, + }, created() { this.setProjectId(this.projectId); this.search(this.query); @@ -95,9 +107,9 @@ export default { </script> <template> - <gl-new-dropdown class="ref-selector" @shown="focusSearchBox"> + <gl-new-dropdown v-bind="$attrs" class="ref-selector" @shown="focusSearchBox"> <template slot="button-content"> - <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-600" data-testid="button-content"> + <span class="gl-flex-grow-1 gl-ml-2 gl-text-gray-400" data-testid="button-content"> <span v-if="selectedRef" class="gl-font-monospace">{{ selectedRef }}</span> <span v-else>{{ i18n.noRefSelected }}</span> </span> diff --git a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue index 51ba2337db6..8ec5cebbe8e 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/details_page/tags_list_row.vue @@ -7,7 +7,7 @@ import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { formatDate } from '~/lib/utils/datetime_utility'; import DeleteButton from '../delete_button.vue'; import ListItem from '../list_item.vue'; -import DetailsRow from './details_row.vue'; +import DetailsRow from '~/registry/shared/components/details_row.vue'; import { REMOVE_TAG_BUTTON_TITLE, DIGEST_LABEL, diff --git a/app/assets/javascripts/registry/explorer/components/list_item.vue b/app/assets/javascripts/registry/explorer/components/list_item.vue index 7b5afe8fd9d..c57645cc3a1 100644 --- a/app/assets/javascripts/registry/explorer/components/list_item.vue +++ b/app/assets/javascripts/registry/explorer/components/list_item.vue @@ -70,7 +70,7 @@ export default { </div> <div class="gl-display-flex gl-flex-direction-column gl-flex-fill-1"> <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-black-normal gl-font-weight-bold" + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-body gl-font-weight-bold" > <div class="gl-display-flex gl-align-items-center"> <slot name="left-primary"></slot> @@ -88,7 +88,7 @@ export default { </div> </div> <div - class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-500" + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-300" > <div> <slot name="left-secondary"></slot> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue index 8b06797c0ae..36a46ed58f4 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/cli_commands.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; import { mapGetters } from 'vuex'; import Tracking from '~/tracking'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -15,7 +15,7 @@ import { export default { components: { - GlDropdown, + GlDeprecatedDropdown, GlFormGroup, GlFormInputGroup, ClipboardButton, @@ -36,7 +36,7 @@ export default { }; </script> <template> - <gl-dropdown + <gl-deprecated-dropdown :text="$options.i18n.dropdownTitle" variant="primary" size="sm" @@ -99,5 +99,5 @@ export default { </gl-form-group> </form> </li> - </gl-dropdown> + </gl-deprecated-dropdown> </template> diff --git a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue index 2874d89d913..102311c6062 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/image_list_row.vue @@ -71,7 +71,7 @@ export default { > <template #left-primary> <router-link - class="gl-text-black-normal gl-font-weight-bold" + class="gl-text-body gl-font-weight-bold" data-testid="detailsLink" :to="{ name: 'details', params: { id: encodedItem } }" > @@ -82,7 +82,7 @@ export default { :disabled="item.deleting" :text="item.location" :title="item.location" - css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500" + css-class="btn-default btn-transparent btn-clipboard gl-text-gray-300" /> <gl-icon v-if="item.failedDelete" diff --git a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue index d4ff84447bb..c7b4fd5f4b4 100644 --- a/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue +++ b/app/assets/javascripts/registry/explorer/components/list_page/registry_header.vue @@ -96,7 +96,7 @@ export default { </div> <div v-if="imagesCount" - class="gl-display-flex gl-align-items-center gl-mt-1 gl-mb-3 gl-text-gray-700" + class="gl-display-flex gl-align-items-center gl-mt-1 gl-mb-3 gl-text-gray-500" data-testid="subheader" > <span class="gl-mr-3" data-testid="images-count"> diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index cf811156704..b697bca6259 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -112,7 +112,7 @@ export default { </script> <template> - <div v-gl-resize-observer="handleResize" class="gl-my-3 gl-w-full slide-enter-to-element"> + <div v-gl-resize-observer="handleResize" class="gl-my-3"> <delete-alert v-model="deleteAlertType" :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath" diff --git a/app/assets/javascripts/registry/explorer/pages/index.vue b/app/assets/javascripts/registry/explorer/pages/index.vue index 709a163d56d..4ac0bca84c1 100644 --- a/app/assets/javascripts/registry/explorer/pages/index.vue +++ b/app/assets/javascripts/registry/explorer/pages/index.vue @@ -4,8 +4,6 @@ export default {}; <template> <div> - <transition name="slide"> - <router-view ref="router-view" /> - </transition> + <router-view ref="router-view" /> </div> </template> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 1d353651c38..81e47073fe9 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -130,7 +130,7 @@ export default { </script> <template> - <div class="w-100 slide-enter-from-element"> + <div> <gl-alert v-if="showDeleteAlert" :variant="deleteAlertType" diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 3d73ffbd23f..9125f573aa4 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import * as types from './mutation_types'; import { FETCH_IMAGES_LIST_ERROR_MESSAGE, @@ -102,5 +102,3 @@ export const requestDeleteImage = ({ commit }, image) => { commit(types.SET_MAIN_LOADING, false); }); }; - -export default () => {}; diff --git a/app/assets/javascripts/registry/settings/store/actions.js b/app/assets/javascripts/registry/settings/store/actions.js index be1f62334fa..0530a870ecc 100644 --- a/app/assets/javascripts/registry/settings/store/actions.js +++ b/app/assets/javascripts/registry/settings/store/actions.js @@ -28,6 +28,3 @@ export const saveSettings = ({ dispatch, state }) => { ) .finally(() => dispatch('toggleLoading')); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue b/app/assets/javascripts/registry/shared/components/details_row.vue index c4358b83e23..2e245fadead 100644 --- a/app/assets/javascripts/registry/explorer/components/details_page/details_row.vue +++ b/app/assets/javascripts/registry/shared/components/details_row.vue @@ -10,13 +10,29 @@ export default { type: String, required: true, }, + padding: { + type: String, + default: 'gl-py-2', + required: false, + }, + dashed: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + borderClass() { + return this.dashed ? 'gl-border-b-solid gl-border-gray-100 gl-border-b-1' : ''; + }, }, }; </script> <template> <div - class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all" + class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-word-break-all" + :class="[padding, borderClass]" > <gl-icon :name="icon" class="gl-mr-4" /> <span> diff --git a/app/assets/javascripts/related_merge_requests/store/actions.js b/app/assets/javascripts/related_merge_requests/store/actions.js index 69abeaaf7db..65f77f2fe19 100644 --- a/app/assets/javascripts/related_merge_requests/store/actions.js +++ b/app/assets/javascripts/related_merge_requests/store/actions.js @@ -1,5 +1,5 @@ import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import * as types from './mutation_types'; @@ -32,6 +32,3 @@ export const fetchMergeRequests = ({ state, dispatch }) => { createFlash(s__('Something went wrong while fetching related merge requests.')); }); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/releases/components/app_edit.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 01dd0638023..7b7c80a6269 100644 --- a/app/assets/javascripts/releases/components/app_edit.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -1,18 +1,17 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; import { GlButton, GlFormInput, GlFormGroup } from '@gitlab/ui'; -import { escape } from 'lodash'; import { __, sprintf } from '~/locale'; 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'; import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; +import TagField from './tag_field.vue'; export default { - name: 'ReleaseEditApp', + name: 'ReleaseEditNewApp', components: { GlFormInput, GlFormGroup, @@ -20,9 +19,7 @@ export default { MarkdownField, AssetLinksForm, MilestoneCombobox, - }, - directives: { - autofocusonshow, + TagField, }, mixins: [glFeatureFlagsMixin()], computed: { @@ -39,9 +36,9 @@ export default { 'manageMilestonesPath', 'projectId', ]), - ...mapGetters('detail', ['isValid']), + ...mapGetters('detail', ['isValid', 'isExistingRelease']), showForm() { - return !this.isFetchingRelease && !this.fetchError; + return Boolean(!this.isFetchingRelease && !this.fetchError && this.release); }, subtitleText() { return sprintf( @@ -55,23 +52,6 @@ export default { false, ); }, - tagName() { - return this.$store.state.detail.release.tagName; - }, - tagNameHintText() { - return sprintf( - __( - 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', - ), - { - linkStart: `<a href="${escape( - this.updateReleaseApiDocsPath, - )}" target="_blank" rel="noopener noreferrer">`, - linkEnd: '</a>', - }, - false, - ); - }, releaseTitle: { get() { return this.$store.state.detail.release.name; @@ -102,7 +82,10 @@ export default { showAssetLinksForm() { return this.glFeatures.releaseAssetLinkEditing; }, - isSaveChangesDisabled() { + saveButtonLabel() { + return this.isExistingRelease ? __('Save changes') : __('Create release'); + }, + isFormSubmissionDisabled() { return this.isUpdatingRelease || !this.isValid; }, milestoneComboboxExtraLinks() { @@ -118,53 +101,45 @@ export default { ]; }, }, - created() { - this.fetchRelease(); + 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(); + }); }, methods: { ...mapActions('detail', [ - 'fetchRelease', - 'updateRelease', + 'initializeRelease', + 'saveRelease', 'updateReleaseTitle', 'updateReleaseNotes', 'updateReleaseMilestones', ]), + submitForm() { + if (!this.isFormSubmissionDisabled) { + this.saveRelease(); + } + }, }, }; </script> <template> <div class="d-flex flex-column"> <p class="pt-3 js-subtitle-text" v-html="subtitleText"></p> - <form v-if="showForm" @submit.prevent="updateRelease()"> - <gl-form-group> - <div class="row"> - <div class="col-md-6 col-lg-5 col-xl-4"> - <label for="git-ref">{{ __('Tag name') }}</label> - <gl-form-input - id="git-ref" - v-model="tagName" - type="text" - class="form-control" - aria-describedby="tag-name-help" - disabled - /> - </div> - </div> - <div id="tag-name-help" class="form-text text-muted" v-html="tagNameHintText"></div> - </gl-form-group> + <form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm"> + <tag-field /> <gl-form-group> <label for="release-title">{{ __('Release title') }}</label> <gl-form-input id="release-title" ref="releaseTitleInput" v-model="releaseTitle" - v-autofocusonshow - autofocus type="text" class="form-control" /> </gl-form-group> - <gl-form-group class="w-50"> + <gl-form-group class="w-50" @keydown.enter.prevent.capture> <label>{{ __('Milestones') }}</label> <div class="d-flex flex-column col-md-6 col-sm-10 pl-0"> <milestone-combobox @@ -182,7 +157,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" :add-spacing-classes="false" - class="prepend-top-10 append-bottom-10" + class="gl-mt-3 gl-mb-3" > <template #textarea> <textarea @@ -193,8 +168,6 @@ export default { data-supports-quick-actions="false" :aria-label="__('Release notes')" :placeholder="__('Write your release notes or drag your files here…')" - @keydown.meta.enter="updateRelease()" - @keydown.ctrl.enter="updateRelease()" ></textarea> </template> </markdown-field> @@ -209,10 +182,11 @@ export default { category="primary" variant="success" type="submit" - :aria-label="__('Save changes')" - :disabled="isSaveChangesDisabled" - >{{ __('Save changes') }}</gl-button + :disabled="isFormSubmissionDisabled" + data-testid="submit-button" > + {{ saveButtonLabel }} + </gl-button> <gl-button :href="cancelPath" class="js-cancel-button">{{ __('Cancel') }}</gl-button> </div> </form> diff --git a/app/assets/javascripts/releases/components/app_new.vue b/app/assets/javascripts/releases/components/app_new.vue deleted file mode 100644 index 563f76b3281..00000000000 --- a/app/assets/javascripts/releases/components/app_new.vue +++ /dev/null @@ -1,9 +0,0 @@ -<script> -export default { - name: 'ReleaseNewApp', - components: {}, -}; -</script> -<template> - <div></div> -</template> diff --git a/app/assets/javascripts/releases/components/asset_links_form.vue b/app/assets/javascripts/releases/components/asset_links_form.vue index d0d1485d8e7..07fab840067 100644 --- a/app/assets/javascripts/releases/components/asset_links_form.vue +++ b/app/assets/javascripts/releases/components/asset_links_form.vue @@ -49,6 +49,12 @@ export default { this.removeAssetLink(linkId); this.ensureAtLeastOneLink(); }, + updateUrl(link, newUrl) { + this.updateAssetLinkUrl({ linkIdToUpdate: link.id, newUrl }); + }, + updateName(link, newName) { + this.updateAssetLinkName({ linkIdToUpdate: link.id, newName }); + }, hasDuplicateUrl(link) { return Boolean(this.getLinkErrors(link).isDuplicate); }, @@ -138,7 +144,9 @@ export default { type="text" class="form-control" :state="isUrlValid(link)" - @change="updateAssetLinkUrl({ linkIdToUpdate: link.id, newUrl: $event })" + @change="updateUrl(link, $event)" + @keydown.ctrl.enter="updateUrl(link, $event.target.value)" + @keydown.meta.enter="updateUrl(link, $event.target.value)" /> <template #invalid-feedback> <span v-if="hasEmptyUrl(link)" class="invalid-feedback d-inline"> @@ -175,7 +183,9 @@ export default { type="text" class="form-control" :state="isNameValid(link)" - @change="updateAssetLinkName({ linkIdToUpdate: link.id, newName: $event })" + @change="updateName(link, $event)" + @keydown.ctrl.enter="updateName(link, $event.target.value)" + @keydown.meta.enter="updateName(link, $event.target.value)" /> <template #invalid-feedback> <span v-if="hasEmptyName(link)" class="invalid-feedback d-inline"> diff --git a/app/assets/javascripts/releases/components/form_field_container.vue b/app/assets/javascripts/releases/components/form_field_container.vue new file mode 100644 index 00000000000..19e275315a0 --- /dev/null +++ b/app/assets/javascripts/releases/components/form_field_container.vue @@ -0,0 +1,12 @@ +<script> +export default { + name: 'FormFieldContainer', +}; +</script> +<template> + <div class="row"> + <div class="col-md-6 col-lg-5 col-xl-4 gl-display-flex gl-flex-direction-column"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/releases/components/release_block_assets.vue b/app/assets/javascripts/releases/components/release_block_assets.vue index ab29ceb0ce6..9583f5737df 100644 --- a/app/assets/javascripts/releases/components/release_block_assets.vue +++ b/app/assets/javascripts/releases/components/release_block_assets.vue @@ -1,10 +1,10 @@ <script> import { GlTooltipDirective, GlLink, GlButton, GlCollapse, GlIcon, GlBadge } from '@gitlab/ui'; +import { difference, get } from 'lodash'; import Icon from '~/vue_shared/components/icon.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ASSET_LINK_TYPE } from '../constants'; import { __, s__, sprintf } from '~/locale'; -import { difference, get } from 'lodash'; export default { name: 'ReleaseBlockAssets', @@ -138,7 +138,7 @@ export default { :aria-label="$options.externalLinkTooltipText" :title="$options.externalLinkTooltipText" data-testid="external-link-indicator" - class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-600" + class="gl-ml-2 gl-flex-shrink-0 gl-flex-grow-0 gl-text-gray-400" /> </gl-link> </li> diff --git a/app/assets/javascripts/releases/components/release_block_author.vue b/app/assets/javascripts/releases/components/release_block_author.vue index 94f2b1795f0..72c578068cd 100644 --- a/app/assets/javascripts/releases/components/release_block_author.vue +++ b/app/assets/javascripts/releases/components/release_block_author.vue @@ -1,6 +1,6 @@ <script> -import { __, sprintf } from '~/locale'; import { GlSprintf } from '@gitlab/ui'; +import { __, sprintf } from '~/locale'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; export default { diff --git a/app/assets/javascripts/releases/components/release_block_milestone_info.vue b/app/assets/javascripts/releases/components/release_block_milestone_info.vue index b16ae400d6b..deff673cc17 100644 --- a/app/assets/javascripts/releases/components/release_block_milestone_info.vue +++ b/app/assets/javascripts/releases/components/release_block_milestone_info.vue @@ -7,9 +7,9 @@ import { GlTooltipDirective, GlSprintf, } from '@gitlab/ui'; +import { sum } from 'lodash'; import { __, n__, sprintf } from '~/locale'; import { MAX_MILESTONES_TO_DISPLAY } from '../constants'; -import { sum } from 'lodash'; export default { name: 'ReleaseBlockMilestoneInfo', diff --git a/app/assets/javascripts/releases/components/tag_field.vue b/app/assets/javascripts/releases/components/tag_field.vue new file mode 100644 index 00000000000..ed8d6e62926 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_field.vue @@ -0,0 +1,20 @@ +<script> +import { mapGetters } from 'vuex'; +import TagFieldExisting from './tag_field_existing.vue'; +import TagFieldNew from './tag_field_new.vue'; + +export default { + components: { + TagFieldExisting, + TagFieldNew, + }, + computed: { + ...mapGetters('detail', ['isExistingRelease']), + }, +}; +</script> + +<template> + <tag-field-existing v-if="isExistingRelease" /> + <tag-field-new v-else /> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_existing.vue b/app/assets/javascripts/releases/components/tag_field_existing.vue new file mode 100644 index 00000000000..b84e713df26 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_field_existing.vue @@ -0,0 +1,51 @@ +<script> +import { mapState } from 'vuex'; +import { uniqueId } from 'lodash'; +import { GlFormGroup, GlFormInput, GlLink, GlSprintf } from '@gitlab/ui'; +import FormFieldContainer from './form_field_container.vue'; + +export default { + name: 'TagFieldExisting', + components: { GlFormGroup, GlFormInput, GlSprintf, GlLink, FormFieldContainer }, + computed: { + ...mapState('detail', ['release', 'updateReleaseApiDocsPath']), + inputId() { + return uniqueId('tag-name-input-'); + }, + helpId() { + return uniqueId('tag-name-help-'); + }, + }, +}; +</script> +<template> + <gl-form-group :label="__('Tag name')" :label-for="inputId"> + <form-field-container> + <gl-form-input + :id="inputId" + :value="release.tagName" + type="text" + class="form-control" + :aria-describedby="helpId" + disabled + /> + </form-field-container> + <template #description> + <div :id="helpId" data-testid="tag-name-help"> + <gl-sprintf + :message=" + __( + 'Changing a Release tag is only supported via Releases API. %{linkStart}More information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="updateReleaseApiDocsPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + </template> + </gl-form-group> +</template> diff --git a/app/assets/javascripts/releases/components/tag_field_new.vue b/app/assets/javascripts/releases/components/tag_field_new.vue new file mode 100644 index 00000000000..4779feae886 --- /dev/null +++ b/app/assets/javascripts/releases/components/tag_field_new.vue @@ -0,0 +1,100 @@ +<script> +import { mapState, mapActions, mapGetters } from 'vuex'; +import { GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import FormFieldContainer from './form_field_container.vue'; + +export default { + name: 'TagFieldNew', + components: { GlFormGroup, GlFormInput, RefSelector, FormFieldContainer }, + 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, + }; + }, + computed: { + ...mapState('detail', ['projectId', 'release', 'createFrom']), + ...mapGetters('detail', ['validationErrors']), + tagName: { + get() { + return this.release.tagName; + }, + set(tagName) { + this.updateReleaseTagName(tagName); + }, + }, + createFromModel: { + get() { + return this.createFrom; + }, + set(createFrom) { + this.updateCreateFrom(createFrom); + }, + }, + showTagNameValidationError() { + return this.isInputDirty && this.validationErrors.isTagNameEmpty; + }, + tagNameInputId() { + return uniqueId('tag-name-input-'); + }, + createFromSelectorId() { + return uniqueId('create-from-selector-'); + }, + }, + methods: { + ...mapActions('detail', ['updateReleaseTagName', 'updateCreateFrom']), + markInputAsDirty() { + this.isInputDirty = true; + }, + }, + translations: { + noRefSelected: __('No source selected'), + searchPlaceholder: __('Search branches, tags, and commits'), + dropdownHeader: __('Select source'), + }, +}; +</script> +<template> + <div> + <gl-form-group + :label="__('Tag name')" + :label-for="tagNameInputId" + data-testid="tag-name-field" + :state="!showTagNameValidationError" + :invalid-feedback="__('Tag name is required')" + > + <form-field-container> + <gl-form-input + :id="tagNameInputId" + v-model="tagName" + :state="!showTagNameValidationError" + type="text" + class="form-control" + @blur.once="markInputAsDirty" + /> + </form-field-container> + </gl-form-group> + <gl-form-group + :label="__('Create from')" + :label-for="createFromSelectorId" + data-testid="create-from-field" + > + <form-field-container> + <ref-selector + :id="createFromSelectorId" + v-model="createFromModel" + :project-id="projectId" + :translations="$options.translations" + /> + </form-field-container> + <template #description> + {{ __('Existing branch name, tag, or commit SHA') }} + </template> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/releases/mount_edit.js b/app/assets/javascripts/releases/mount_edit.js index 44530e4961a..c7385b3c57f 100644 --- a/app/assets/javascripts/releases/mount_edit.js +++ b/app/assets/javascripts/releases/mount_edit.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ReleaseEditApp from './components/app_edit.vue'; +import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; import createDetailModule from './stores/modules/detail'; @@ -18,6 +18,6 @@ export default () => { return new Vue({ el, store, - render: h => h(ReleaseEditApp), + render: h => h(ReleaseEditNewApp), }); }; diff --git a/app/assets/javascripts/releases/mount_new.js b/app/assets/javascripts/releases/mount_new.js index eb02c194c59..68003f6a346 100644 --- a/app/assets/javascripts/releases/mount_new.js +++ b/app/assets/javascripts/releases/mount_new.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import ReleaseNewApp from './components/app_new.vue'; +import ReleaseEditNewApp from './components/app_edit_new.vue'; import createStore from './stores'; import createDetailModule from './stores/modules/detail'; @@ -10,11 +10,14 @@ export default () => { modules: { detail: createDetailModule(el.dataset), }, + featureFlags: { + releaseShowPage: Boolean(gon.features?.releaseShowPage), + }, }); return new Vue({ el, store, - render: h => h(ReleaseNewApp), + render: h => h(ReleaseEditNewApp), }); }; diff --git a/app/assets/javascripts/releases/stores/modules/detail/actions.js b/app/assets/javascripts/releases/stores/modules/detail/actions.js index 2026eeba880..5b682a0ab0f 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/actions.js +++ b/app/assets/javascripts/releases/stores/modules/detail/actions.js @@ -1,74 +1,116 @@ import * as types from './mutation_types'; import api from '~/api'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import { redirectTo } from '~/lib/utils/url_utility'; -import { - convertObjectPropsToCamelCase, - convertObjectPropsToSnakeCase, -} from '~/lib/utils/common_utils'; - -export const requestRelease = ({ commit }) => commit(types.REQUEST_RELEASE); -export const receiveReleaseSuccess = ({ commit }, data) => - commit(types.RECEIVE_RELEASE_SUCCESS, data); -export const receiveReleaseError = ({ commit }, error) => { - commit(types.RECEIVE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while getting the release details')); +import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; + +export const initializeRelease = ({ commit, dispatch, getters }) => { + if (getters.isExistingRelease) { + // When editing an existing release, + // fetch the release object from the API + return dispatch('fetchRelease'); + } + + // When creating a new release, initialize the + // store with an empty release object + commit(types.INITIALIZE_EMPTY_RELEASE); + return Promise.resolve(); }; -export const fetchRelease = ({ dispatch, state }) => { - dispatch('requestRelease'); +export const fetchRelease = ({ commit, state }) => { + commit(types.REQUEST_RELEASE); return api .release(state.projectId, state.tagName) .then(({ data }) => { - const release = { - ...data, - milestones: data.milestones || [], - }; - - dispatch('receiveReleaseSuccess', convertObjectPropsToCamelCase(release, { deep: true })); + commit(types.RECEIVE_RELEASE_SUCCESS, apiJsonToRelease(data)); }) .catch(error => { - dispatch('receiveReleaseError', error); + commit(types.RECEIVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while getting the release details')); }); }; +export const updateReleaseTagName = ({ commit }, tagName) => + commit(types.UPDATE_RELEASE_TAG_NAME, tagName); + +export const updateCreateFrom = ({ commit }, createFrom) => + commit(types.UPDATE_CREATE_FROM, createFrom); + export const updateReleaseTitle = ({ commit }, title) => commit(types.UPDATE_RELEASE_TITLE, title); + export const updateReleaseNotes = ({ commit }, notes) => commit(types.UPDATE_RELEASE_NOTES, notes); + export const updateReleaseMilestones = ({ commit }, milestones) => commit(types.UPDATE_RELEASE_MILESTONES, milestones); -export const requestUpdateRelease = ({ commit }) => commit(types.REQUEST_UPDATE_RELEASE); -export const receiveUpdateReleaseSuccess = ({ commit, state, rootState }) => { - commit(types.RECEIVE_UPDATE_RELEASE_SUCCESS); - redirectTo( - rootState.featureFlags.releaseShowPage ? state.release._links.self : state.releasesPagePath, - ); +export const addEmptyAssetLink = ({ commit }) => { + commit(types.ADD_EMPTY_ASSET_LINK); }; -export const receiveUpdateReleaseError = ({ commit }, error) => { - commit(types.RECEIVE_UPDATE_RELEASE_ERROR, error); - createFlash(s__('Release|Something went wrong while saving the release details')); + +export const updateAssetLinkUrl = ({ commit }, { linkIdToUpdate, newUrl }) => { + commit(types.UPDATE_ASSET_LINK_URL, { linkIdToUpdate, newUrl }); }; -export const updateRelease = ({ dispatch, state, getters }) => { - dispatch('requestUpdateRelease'); +export const updateAssetLinkName = ({ commit }, { linkIdToUpdate, newName }) => { + commit(types.UPDATE_ASSET_LINK_NAME, { linkIdToUpdate, newName }); +}; - const { release } = state; - const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; +export const updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => { + commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType }); +}; + +export const removeAssetLink = ({ commit }, linkIdToRemove) => { + commit(types.REMOVE_ASSET_LINK, linkIdToRemove); +}; + +export const receiveSaveReleaseSuccess = ({ commit, state, rootState }, release) => { + commit(types.RECEIVE_SAVE_RELEASE_SUCCESS); + redirectTo(rootState.featureFlags.releaseShowPage ? release._links.self : state.releasesPagePath); +}; + +export const saveRelease = ({ commit, dispatch, getters }) => { + commit(types.REQUEST_SAVE_RELEASE); - const updatedRelease = convertObjectPropsToSnakeCase( + dispatch(getters.isExistingRelease ? 'updateRelease' : 'createRelease'); +}; + +export const createRelease = ({ commit, dispatch, state, getters }) => { + const apiJson = releaseToApiJson( { - name: release.name, - description: release.description, - milestones, + ...state.release, + assets: { + links: getters.releaseLinksToCreate, + }, }, - { deep: true }, + state.createFrom, ); + return api + .createRelease(state.projectId, apiJson) + .then(({ data }) => { + dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(data)); + }) + .catch(error => { + commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while creating a new release')); + }); +}; + +export const updateRelease = ({ commit, dispatch, state, getters }) => { + const apiJson = releaseToApiJson({ + ...state.release, + assets: { + links: getters.releaseLinksToCreate, + }, + }); + + let updatedRelease = null; + return ( api - .updateRelease(state.projectId, state.tagName, updatedRelease) + .updateRelease(state.projectId, state.tagName, apiJson) /** * Currently, we delete all existing links and then @@ -86,54 +128,31 @@ export const updateRelease = ({ dispatch, state, getters }) => { * https://gitlab.com/gitlab-org/gitlab/-/issues/208702 * is closed. */ + .then(({ data }) => { + // Save this response since we need it later in the Promise chain + updatedRelease = data; - .then(() => { // Delete all links currently associated with this Release return Promise.all( getters.releaseLinksToDelete.map(l => - api.deleteReleaseLink(state.projectId, release.tagName, l.id), + api.deleteReleaseLink(state.projectId, state.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, - convertObjectPropsToSnakeCase(l, { deep: true }), - ), + apiJson.assets.links.map(l => + api.createReleaseLink(state.projectId, state.release.tagName, l), ), ); }) - .then(() => dispatch('receiveUpdateReleaseSuccess')) + .then(() => { + dispatch('receiveSaveReleaseSuccess', apiJsonToRelease(updatedRelease)); + }) .catch(error => { - dispatch('receiveUpdateReleaseError', error); + commit(types.RECEIVE_SAVE_RELEASE_ERROR, error); + createFlash(s__('Release|Something went wrong while saving the release details')); }) ); }; - -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 updateAssetLinkType = ({ commit }, { linkIdToUpdate, newType }) => { - commit(types.UPDATE_ASSET_LINK_TYPE, { linkIdToUpdate, newType }); -}; - -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 index 84dc2fca4be..809ed075c16 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/getters.js +++ b/app/assets/javascripts/releases/stores/modules/detail/getters.js @@ -2,6 +2,14 @@ import { isEmpty } from 'lodash'; import { hasContent } from '~/lib/utils/text_utility'; /** + * @returns {Boolean} `true` if the app is editing an existing release. + * `false` if the app is creating a new release. + */ +export const isExistingRelease = state => { + return Boolean(state.tagName); +}; + +/** * @param {Object} link The link to test * @returns {Boolean} `true` if the release link is empty, i.e. it has * empty (or whitespace-only) values for both `url` and `name`. @@ -39,6 +47,10 @@ export const validationErrors = state => { return errors; } + if (!state.release.tagName?.trim?.().length) { + errors.isTagNameEmpty = true; + } + // Each key of this object is a URL, and the value is an // array of Release link objects that share this URL. // This is used for detecting duplicate URLs. @@ -88,5 +100,6 @@ export const validationErrors = state => { /** Returns whether or not the release object is valid */ export const isValid = (_state, getters) => { - return Object.values(getters.validationErrors.assets.links).every(isEmpty); + const errors = getters.validationErrors; + return Object.values(errors.assets.links).every(isEmpty) && !errors.isTagNameEmpty; }; 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 7b694120126..7784e0cc741 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutation_types.js @@ -1,14 +1,18 @@ +export const INITIALIZE_EMPTY_RELEASE = 'INITIALIZE_EMPTY_RELEASE'; + export const REQUEST_RELEASE = 'REQUEST_RELEASE'; export const RECEIVE_RELEASE_SUCCESS = 'RECEIVE_RELEASE_SUCCESS'; export const RECEIVE_RELEASE_ERROR = 'RECEIVE_RELEASE_ERROR'; +export const UPDATE_RELEASE_TAG_NAME = 'UPDATE_RELEASE_TAG_NAME'; +export const UPDATE_CREATE_FROM = 'UPDATE_CREATE_FROM'; export const UPDATE_RELEASE_TITLE = 'UPDATE_RELEASE_TITLE'; export const UPDATE_RELEASE_NOTES = 'UPDATE_RELEASE_NOTES'; export const UPDATE_RELEASE_MILESTONES = 'UPDATE_RELEASE_MILESTONES'; -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 REQUEST_SAVE_RELEASE = 'REQUEST_SAVE_RELEASE'; +export const RECEIVE_SAVE_RELEASE_SUCCESS = 'RECEIVE_SAVE_RELEASE_SUCCESS'; +export const RECEIVE_SAVE_RELEASE_ERROR = 'RECEIVE_SAVE_RELEASE_ERROR'; export const ADD_EMPTY_ASSET_LINK = 'ADD_EMPTY_ASSET_LINK'; export const UPDATE_ASSET_LINK_URL = 'UPDATE_ASSET_LINK_URL'; diff --git a/app/assets/javascripts/releases/stores/modules/detail/mutations.js b/app/assets/javascripts/releases/stores/modules/detail/mutations.js index ca544151323..750f496665d 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/mutations.js +++ b/app/assets/javascripts/releases/stores/modules/detail/mutations.js @@ -1,5 +1,5 @@ -import * as types from './mutation_types'; import { uniqueId, cloneDeep } from 'lodash'; +import * as types from './mutation_types'; import { DEFAULT_ASSET_LINK_TYPE } from '../../../constants'; const findReleaseLink = (release, id) => { @@ -7,6 +7,18 @@ const findReleaseLink = (release, id) => { }; export default { + [types.INITIALIZE_EMPTY_RELEASE](state) { + state.release = { + tagName: null, + name: '', + description: '', + milestones: [], + assets: { + links: [], + }, + }; + }, + [types.REQUEST_RELEASE](state) { state.isFetchingRelease = true; }, @@ -22,6 +34,12 @@ export default { state.release = undefined; }, + [types.UPDATE_RELEASE_TAG_NAME](state, tagName) { + state.release.tagName = tagName; + }, + [types.UPDATE_CREATE_FROM](state, createFrom) { + state.createFrom = createFrom; + }, [types.UPDATE_RELEASE_TITLE](state, title) { state.release.name = title; }, @@ -33,14 +51,14 @@ export default { state.release.milestones = milestones; }, - [types.REQUEST_UPDATE_RELEASE](state) { + [types.REQUEST_SAVE_RELEASE](state) { state.isUpdatingRelease = true; }, - [types.RECEIVE_UPDATE_RELEASE_SUCCESS](state) { + [types.RECEIVE_SAVE_RELEASE_SUCCESS](state) { state.updateError = undefined; state.isUpdatingRelease = false; }, - [types.RECEIVE_UPDATE_RELEASE_ERROR](state, error) { + [types.RECEIVE_SAVE_RELEASE_ERROR](state, error) { state.updateError = error; state.isUpdatingRelease = false; }, diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js index 966c1c00ef5..a46e750df53 100644 --- a/app/assets/javascripts/releases/stores/modules/detail/state.js +++ b/app/assets/javascripts/releases/stores/modules/detail/state.js @@ -6,9 +6,9 @@ export default ({ releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, + releasesPagePath, tagName = null, - releasesPagePath = null, defaultBranch = null, }) => ({ projectId, @@ -18,10 +18,16 @@ export default ({ releaseAssetsDocsPath, manageMilestonesPath, newMilestonePath, + releasesPagePath, + /** + * The name of the tag associated with the release, provided by the backend. + * When creating a new release, this value is null. + */ tagName, - releasesPagePath, + defaultBranch, + createFrom: defaultBranch, /** The Release object */ release: null, diff --git a/app/assets/javascripts/releases/stores/modules/list/actions.js b/app/assets/javascripts/releases/stores/modules/list/actions.js index 06d13890a9d..90fba319e9f 100644 --- a/app/assets/javascripts/releases/stores/modules/list/actions.js +++ b/app/assets/javascripts/releases/stores/modules/list/actions.js @@ -1,5 +1,5 @@ import * as types from './mutation_types'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import api from '~/api'; import { @@ -43,6 +43,3 @@ export const receiveReleasesError = ({ commit }) => { commit(types.RECEIVE_RELEASES_ERROR); createFlash(__('An error occurred while fetching the releases. Please try again.')); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js new file mode 100644 index 00000000000..842a423b142 --- /dev/null +++ b/app/assets/javascripts/releases/util.js @@ -0,0 +1,41 @@ +import { + convertObjectPropsToCamelCase, + convertObjectPropsToSnakeCase, +} from '~/lib/utils/common_utils'; + +/** + * Converts a release object into a JSON object that can sent to the public + * API to create or update a release. + * @param {Object} release The release object to convert + * @param {string} createFrom The ref to create a new tag from, if necessary + */ +export const releaseToApiJson = (release, createFrom = null) => { + const name = release.name?.trim().length > 0 ? release.name.trim() : null; + + const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; + + return convertObjectPropsToSnakeCase( + { + name, + tagName: release.tagName, + ref: createFrom, + description: release.description, + milestones, + assets: release.assets, + }, + { deep: true }, + ); +}; + +/** + * Converts a JSON release object returned by the Release API + * into the structure this Vue application can work with. + * @param {Object} json The JSON object received from the release API + */ +export const apiJsonToRelease = json => { + const release = convertObjectPropsToCamelCase(json, { deep: true }); + + release.milestones = release.milestones || []; + + return release; +}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/actions.js b/app/assets/javascripts/reports/accessibility_report/store/actions.js index 446cfd79984..bb502020a06 100644 --- a/app/assets/javascripts/reports/accessibility_report/store/actions.js +++ b/app/assets/javascripts/reports/accessibility_report/store/actions.js @@ -74,6 +74,3 @@ export const receiveReportError = ({ commit, dispatch }) => { commit(types.RECEIVE_REPORT_ERROR); dispatch('stopPolling'); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/reports/accessibility_report/store/getters.js b/app/assets/javascripts/reports/accessibility_report/store/getters.js index 9aff427e644..312b333a771 100644 --- a/app/assets/javascripts/reports/accessibility_report/store/getters.js +++ b/app/assets/javascripts/reports/accessibility_report/store/getters.js @@ -43,6 +43,3 @@ export const shouldRenderIssuesList = state => export const unresolvedIssues = state => state.report.existing_errors; export const resolvedIssues = state => state.report.resolved_errors; export const newIssues = state => state.report.new_errors; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue index b8a8cb940e7..47f04019595 100644 --- a/app/assets/javascripts/reports/components/grouped_test_reports_app.vue +++ b/app/assets/javascripts/reports/components/grouped_test_reports_app.vue @@ -1,12 +1,12 @@ <script> import { mapActions, mapGetters, mapState } from 'vuex'; +import { GlButton } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import { componentNames } from './issue_body'; import ReportSection from './report_section.vue'; import SummaryRow from './summary_row.vue'; import IssuesList from './issues_list.vue'; import Modal from './modal.vue'; -import { GlButton } from '@gitlab/ui'; import createStore from '../store'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { summaryTextBuilder, reportTextBuilder, statusIcon } from '../store/utils'; @@ -56,7 +56,7 @@ export default { return `${this.pipelinePath}/test_report`; }, showViewFullReport() { - return Boolean(this.glFeatures.junitPipelineView) && this.pipelinePath.length; + return this.pipelinePath.length; }, }, created() { @@ -116,6 +116,7 @@ export default { <template v-if="showViewFullReport" #actionButtons> <gl-button :href="testTabURL" + target="_blank" icon="external-link" data-testid="group-test-reports-full-link" class="gl-mr-3" diff --git a/app/assets/javascripts/reports/components/modal.vue b/app/assets/javascripts/reports/components/modal.vue index 78c355ecb76..ca95db6c826 100644 --- a/app/assets/javascripts/reports/components/modal.vue +++ b/app/assets/javascripts/reports/components/modal.vue @@ -33,7 +33,7 @@ export default { v-for="(field, key, index) in modalData" v-if="field.value" :key="index" - class="row prepend-top-10 append-bottom-10" + class="row gl-mt-3 gl-mb-3" > <strong class="col-sm-3 text-right"> {{ field.text }}: </strong> diff --git a/app/assets/javascripts/reports/components/modal_open_name.vue b/app/assets/javascripts/reports/components/modal_open_name.vue index 4f81cee2a38..78e1fcb205b 100644 --- a/app/assets/javascripts/reports/components/modal_open_name.vue +++ b/app/assets/javascripts/reports/components/modal_open_name.vue @@ -1,7 +1,12 @@ <script> +import { GlTooltipDirective, GlResizeObserverDirective } from '@gitlab/ui'; import { mapActions } from 'vuex'; export default { + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserverDirective, + }, props: { issue: { type: Object, @@ -13,19 +18,32 @@ export default { required: true, }, }, + data: () => ({ + tooltipTitle: '', + }), + mounted() { + this.updateTooltipTitle(); + }, methods: { ...mapActions(['openModal']), handleIssueClick() { const { issue, status, openModal } = this; openModal({ issue, status }); }, + updateTooltipTitle() { + // Only show the tooltip if the text is truncated with an ellipsis. + this.tooltipTitle = this.$el.offsetWidth < this.$el.scrollWidth ? this.issue.title : ''; + }, }, }; </script> <template> <button - type="button" - class="btn-link btn-blank text-left break-link vulnerability-name-button" + v-gl-tooltip="{ boundary: 'viewport' }" + v-gl-resize-observer-directive="updateTooltipTitle" + class="btn-link gl-text-truncate" + :aria-label="s__('Reports|Vulnerability Name')" + :title="tooltipTitle" @click="handleIssueClick()" > {{ issue.title }} diff --git a/app/assets/javascripts/reports/store/actions.js b/app/assets/javascripts/reports/store/actions.js index db8ab5ccb80..c5860db6601 100644 --- a/app/assets/javascripts/reports/store/actions.js +++ b/app/assets/javascripts/reports/store/actions.js @@ -85,6 +85,3 @@ export const openModal = ({ dispatch }, payload) => { }; export const setModalData = ({ commit }, payload) => commit(types.SET_ISSUE_MODAL_DATA, payload); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/reports/store/getters.js b/app/assets/javascripts/reports/store/getters.js index 95266194acb..6345be69f6f 100644 --- a/app/assets/javascripts/reports/store/getters.js +++ b/app/assets/javascripts/reports/store/getters.js @@ -1,5 +1,6 @@ import { LOADING, ERROR, SUCCESS, STATUS_FAILED } from '../constants'; +// eslint-disable-next-line import/prefer-default-export export const summaryStatus = state => { if (state.isLoading) { return LOADING; @@ -11,6 +12,3 @@ export const summaryStatus = state => { return SUCCESS; }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue index 45c343c3f7f..368fa029d07 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/breadcrumbs.vue @@ -1,12 +1,17 @@ <script> -import { GlDropdown, GlDropdownDivider, GlDropdownHeader, GlDropdownItem } from '@gitlab/ui'; +import { + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, +} from '@gitlab/ui'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { __ } from '../../locale'; import Icon from '../../vue_shared/components/icon.vue'; import getRefMixin from '../mixins/get_ref'; -import getProjectShortPath from '../queries/getProjectShortPath.query.graphql'; -import getProjectPath from '../queries/getProjectPath.query.graphql'; -import getPermissions from '../queries/getPermissions.query.graphql'; +import projectShortPathQuery from '../queries/project_short_path.query.graphql'; +import projectPathQuery from '../queries/project_path.query.graphql'; +import permissionsQuery from '../queries/permissions.query.graphql'; const ROW_TYPES = { header: 'header', @@ -15,21 +20,21 @@ const ROW_TYPES = { export default { components: { - GlDropdown, - GlDropdownDivider, - GlDropdownHeader, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownHeader, + GlDeprecatedDropdownItem, Icon, }, apollo: { projectShortPath: { - query: getProjectShortPath, + query: projectShortPathQuery, }, projectPath: { - query: getProjectPath, + query: projectPathQuery, }, userPermissions: { - query: getPermissions, + query: permissionsQuery, variables() { return { projectPath: this.projectPath, @@ -221,11 +226,11 @@ export default { getComponent(type) { switch (type) { case ROW_TYPES.divider: - return 'gl-dropdown-divider'; + return 'gl-deprecated-dropdown-divider'; case ROW_TYPES.header: - return 'gl-dropdown-header'; + return 'gl-deprecated-dropdown-header'; default: - return 'gl-dropdown-item'; + return 'gl-deprecated-dropdown-item'; } }, }, @@ -241,7 +246,7 @@ export default { </router-link> </li> <li v-if="renderAddToTreeDropdown" class="breadcrumb-item"> - <gl-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1"> + <gl-deprecated-dropdown toggle-class="add-to-tree qa-add-to-tree ml-1"> <template #button-content> <span class="sr-only">{{ __('Add to tree') }}</span> <icon name="plus" :size="16" class="float-left" /> @@ -252,7 +257,7 @@ export default { {{ item.text }} </component> </template> - </gl-dropdown> + </gl-deprecated-dropdown> </li> </ol> </nav> diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index c5c99d56e2a..3337ce6c6df 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -8,8 +8,8 @@ import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue'; import CiIcon from '../../vue_shared/components/ci_icon.vue'; import ClipboardButton from '../../vue_shared/components/clipboard_button.vue'; import getRefMixin from '../mixins/get_ref'; -import getProjectPath from '../queries/getProjectPath.query.graphql'; -import pathLastCommit from '../queries/pathLastCommit.query.graphql'; +import projectPathQuery from '../queries/project_path.query.graphql'; +import pathLastCommitQuery from '../queries/path_last_commit.query.graphql'; export default { components: { @@ -28,10 +28,10 @@ export default { mixins: [getRefMixin], apollo: { projectPath: { - query: getProjectPath, + query: projectPathQuery, }, commit: { - query: pathLastCommit, + query: pathLastCommitQuery, variables() { return { projectPath: this.projectPath, @@ -102,7 +102,7 @@ export default { <template v-else-if="commit"> <user-avatar-link v-if="commit.author" - :link-href="commit.author.webUrl" + :link-href="commit.author.webPath" :img-src="commit.author.avatarUrl" :img-size="40" class="avatar-cell" @@ -118,13 +118,13 @@ export default { <div class="commit-detail flex-list"> <div class="commit-content qa-commit-content"> <gl-link - :href="commit.webUrl" + :href="commit.webPath" :class="{ 'font-italic': !commit.message }" class="commit-row-message item-title" v-html="commit.titleHtml" /> <gl-deprecated-button - v-if="commit.description" + v-if="commit.descriptionHtml" :class="{ open: showDescription }" :aria-label="__('Show commit description')" class="text-expander" @@ -135,7 +135,7 @@ export default { <div class="committer"> <gl-link v-if="commit.author" - :href="commit.author.webUrl" + :href="commit.author.webPath" class="commit-author-link js-user-link" > {{ commit.author.name }} @@ -147,11 +147,11 @@ export default { <timeago-tooltip :time="commit.authoredDate" tooltip-placement="bottom" /> </div> <pre - v-if="commit.description" + v-if="commit.descriptionHtml" :class="{ 'd-block': showDescription }" class="commit-row-description gl-mb-3" - >{{ commit.description }}</pre - > + v-html="commit.descriptionHtml" + ></pre> </div> <div class="commit-actions flex-row"> <div v-if="commit.signatureHtml" v-html="commit.signatureHtml"></div> diff --git a/app/assets/javascripts/repository/components/preview/index.vue b/app/assets/javascripts/repository/components/preview/index.vue index f96523bb497..013092ffefd 100644 --- a/app/assets/javascripts/repository/components/preview/index.vue +++ b/app/assets/javascripts/repository/components/preview/index.vue @@ -3,15 +3,15 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { GlLink, GlLoadingIcon } from '@gitlab/ui'; import { handleLocationHash } from '~/lib/utils/common_utils'; -import getReadmeQuery from '../../queries/getReadme.query.graphql'; +import readmeQuery from '../../queries/readme.query.graphql'; export default { apollo: { readme: { - query: getReadmeQuery, + query: readmeQuery, variables() { return { - url: this.blob.webUrl, + url: this.blob.webPath, }; }, loadingKey: 'loading', @@ -51,7 +51,7 @@ export default { <div class="js-file-title file-title-flex-parent"> <div class="file-header-content"> <i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i> - <gl-link :href="blob.webUrl"> + <gl-link :href="blob.webPath"> <strong>{{ blob.name }}</strong> </gl-link> </div> diff --git a/app/assets/javascripts/repository/components/table/index.vue b/app/assets/javascripts/repository/components/table/index.vue index 5e0ad7acdfd..d0cc617d755 100644 --- a/app/assets/javascripts/repository/components/table/index.vue +++ b/app/assets/javascripts/repository/components/table/index.vue @@ -2,7 +2,7 @@ import { GlSkeletonLoading } from '@gitlab/ui'; import { sprintf, __ } from '../../../locale'; import getRefMixin from '../../mixins/get_ref'; -import getProjectPath from '../../queries/getProjectPath.query.graphql'; +import projectPathQuery from '../../queries/project_path.query.graphql'; import TableHeader from './header.vue'; import TableRow from './row.vue'; import ParentRow from './parent_row.vue'; @@ -17,7 +17,7 @@ export default { mixins: [getRefMixin], apollo: { projectPath: { - query: getProjectPath, + query: projectPathQuery, }, }, props: { @@ -96,7 +96,7 @@ export default { :name="entry.name" :path="entry.flatPath" :type="entry.type" - :url="entry.webUrl" + :url="entry.webUrl || entry.webPath" :mode="entry.mode" :submodule-tree-url="entry.treeUrl" :lfs-oid="entry.lfsOid" diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 615e329f415..d2fef6693e2 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -12,7 +12,7 @@ import { escapeFileUrl } from '~/lib/utils/url_utility'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import getRefMixin from '../../mixins/get_ref'; -import getCommit from '../../queries/getCommit.query.graphql'; +import commitQuery from '../../queries/commit.query.graphql'; export default { components: { @@ -29,7 +29,7 @@ export default { }, apollo: { commit: { - query: getCommit, + query: commitQuery, variables() { return { fileName: this.name, diff --git a/app/assets/javascripts/repository/components/tree_content.vue b/app/assets/javascripts/repository/components/tree_content.vue index 59ba1caa8c9..fe3065a2145 100644 --- a/app/assets/javascripts/repository/components/tree_content.vue +++ b/app/assets/javascripts/repository/components/tree_content.vue @@ -1,24 +1,28 @@ <script> -import createFlash from '~/flash'; +import { GlButton } from '@gitlab/ui'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '../../locale'; import FileTable from './table/index.vue'; import getRefMixin from '../mixins/get_ref'; -import getFiles from '../queries/getFiles.query.graphql'; -import getProjectPath from '../queries/getProjectPath.query.graphql'; +import filesQuery from '../queries/files.query.graphql'; +import projectPathQuery from '../queries/project_path.query.graphql'; import FilePreview from './preview/index.vue'; import { readmeFile } from '../utils/readme'; +const LIMIT = 1000; const PAGE_SIZE = 100; +export const INITIAL_FETCH_COUNT = LIMIT / PAGE_SIZE; export default { components: { FileTable, FilePreview, + GlButton, }, mixins: [getRefMixin], apollo: { projectPath: { - query: getProjectPath, + query: projectPathQuery, }, }, props: { @@ -43,12 +47,19 @@ export default { blobs: [], }, isLoadingFiles: false, + isOverLimit: false, + clickedShowMore: false, + pageSize: PAGE_SIZE, + fetchCounter: 0, }; }, computed: { readme() { return readmeFile(this.entries.blobs); }, + hasShowMore() { + return !this.clickedShowMore && this.fetchCounter === INITIAL_FETCH_COUNT; + }, }, watch: { @@ -70,13 +81,13 @@ export default { return this.$apollo .query({ - query: getFiles, + query: filesQuery, variables: { projectPath: this.projectPath, ref: this.ref, path: this.path || '/', nextPageCursor: this.nextPageCursor, - pageSize: PAGE_SIZE, + pageSize: this.pageSize, }, }) .then(({ data }) => { @@ -96,7 +107,11 @@ export default { if (pageInfo?.hasNextPage) { this.nextPageCursor = pageInfo.endCursor; - this.fetchFiles(); + this.fetchCounter += 1; + if (this.fetchCounter < INITIAL_FETCH_COUNT || this.clickedShowMore) { + this.fetchFiles(); + this.clickedShowMore = false; + } } }) .catch(error => { @@ -112,6 +127,10 @@ export default { .concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo) .find(({ hasNextPage }) => hasNextPage); }, + showMore() { + this.clickedShowMore = true; + this.fetchFiles(); + }, }, }; </script> @@ -124,6 +143,19 @@ export default { :is-loading="isLoadingFiles" :loading-path="loadingPath" /> + <div + v-if="hasShowMore" + class="gl-border-1 gl-border-gray-100 gl-rounded-base gl-border-t-none gl-border-b-solid gl-border-l-solid gl-border-r-solid gl-rounded-top-right-none gl-rounded-top-left-none gl-mt-n1" + > + <gl-button + variant="link" + class="gl-display-flex gl-w-full gl-py-4!" + :loading="isLoadingFiles" + @click="showMore" + > + {{ s__('ProjectFileTree|Show more') }} + </gl-button> + </div> <file-preview v-if="readme" :blob="readme" /> </div> </template> diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 4f80ab4ff5d..187bbfed125 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { escapeFileUrl } from '../lib/utils/url_utility'; import createRouter from './router'; import App from './components/app.vue'; import Breadcrumbs from './components/breadcrumbs.vue'; @@ -109,7 +110,7 @@ export default function setupVueRepositoryList() { return h(TreeActionLink, { props: { path: `${historyLink}/${ - this.$route.params.path ? encodeURIComponent(this.$route.params.path) : '' + this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' }`, text: __('History'), }, diff --git a/app/assets/javascripts/repository/log_tree.js b/app/assets/javascripts/repository/log_tree.js index cef17bf7acb..704dd88aabe 100644 --- a/app/assets/javascripts/repository/log_tree.js +++ b/app/assets/javascripts/repository/log_tree.js @@ -1,8 +1,8 @@ import { normalizeData } from 'ee_else_ce/repository/utils/commit'; import axios from '~/lib/utils/axios_utils'; -import getCommits from './queries/getCommits.query.graphql'; -import getProjectPath from './queries/getProjectPath.query.graphql'; -import getRef from './queries/getRef.query.graphql'; +import commitsQuery from './queries/commits.query.graphql'; +import projectPathQuery from './queries/project_path.query.graphql'; +import refQuery from './queries/ref.query.graphql'; let fetchpromise; let resolvers = []; @@ -22,8 +22,8 @@ export function fetchLogsTree(client, path, offset, resolver = null) { if (fetchpromise) return fetchpromise; - const { projectPath } = client.readQuery({ query: getProjectPath }); - const { escapedRef } = client.readQuery({ query: getRef }); + const { projectPath } = client.readQuery({ query: projectPathQuery }); + const { escapedRef } = client.readQuery({ query: refQuery }); fetchpromise = axios .get( @@ -36,10 +36,10 @@ export function fetchLogsTree(client, path, offset, resolver = null) { ) .then(({ data, headers }) => { const headerLogsOffset = headers['more-logs-offset']; - const { commits } = client.readQuery({ query: getCommits }); + const { commits } = client.readQuery({ query: commitsQuery }); const newCommitData = [...commits, ...normalizeData(data, path)]; client.writeQuery({ - query: getCommits, + query: commitsQuery, data: { commits: newCommitData }, }); diff --git a/app/assets/javascripts/repository/mixins/get_ref.js b/app/assets/javascripts/repository/mixins/get_ref.js index 99d19b77c35..1f1880a48c7 100644 --- a/app/assets/javascripts/repository/mixins/get_ref.js +++ b/app/assets/javascripts/repository/mixins/get_ref.js @@ -1,9 +1,9 @@ -import getRef from '../queries/getRef.query.graphql'; +import refQuery from '../queries/ref.query.graphql'; export default { apollo: { ref: { - query: getRef, + query: refQuery, manual: true, result({ data, loading }) { if (!loading) { diff --git a/app/assets/javascripts/repository/mixins/preload.js b/app/assets/javascripts/repository/mixins/preload.js index cb6c2294679..cb1d7f3aac9 100644 --- a/app/assets/javascripts/repository/mixins/preload.js +++ b/app/assets/javascripts/repository/mixins/preload.js @@ -1,12 +1,12 @@ -import getFiles from '../queries/getFiles.query.graphql'; +import filesQuery from '../queries/files.query.graphql'; import getRefMixin from './get_ref'; -import getProjectPath from '../queries/getProjectPath.query.graphql'; +import projectPathQuery from '../queries/project_path.query.graphql'; export default { mixins: [getRefMixin], apollo: { projectPath: { - query: getProjectPath, + query: projectPathQuery, }, }, data() { @@ -21,7 +21,7 @@ export default { return this.$apollo .query({ - query: getFiles, + query: filesQuery, variables: { projectPath: this.projectPath, ref: this.ref, diff --git a/app/assets/javascripts/repository/queries/getCommit.query.graphql b/app/assets/javascripts/repository/queries/commit.query.graphql index e4aeaaff8fe..e4aeaaff8fe 100644 --- a/app/assets/javascripts/repository/queries/getCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/commit.query.graphql diff --git a/app/assets/javascripts/repository/queries/getCommits.query.graphql b/app/assets/javascripts/repository/queries/commits.query.graphql index 0976b8f32d7..0976b8f32d7 100644 --- a/app/assets/javascripts/repository/queries/getCommits.query.graphql +++ b/app/assets/javascripts/repository/queries/commits.query.graphql diff --git a/app/assets/javascripts/repository/queries/getFiles.query.graphql b/app/assets/javascripts/repository/queries/files.query.graphql index feb89df0492..9e9f5303dd4 100644 --- a/app/assets/javascripts/repository/queries/getFiles.query.graphql +++ b/app/assets/javascripts/repository/queries/files.query.graphql @@ -22,7 +22,7 @@ query getFiles( edges { node { ...TreeEntry - webUrl + webPath } } pageInfo { @@ -46,7 +46,7 @@ query getFiles( node { ...TreeEntry mode - webUrl + webPath lfsOid } } diff --git a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql b/app/assets/javascripts/repository/queries/path_last_commit.query.graphql index f54f09fd647..51f3f790a5d 100644 --- a/app/assets/javascripts/repository/queries/pathLastCommit.query.graphql +++ b/app/assets/javascripts/repository/queries/path_last_commit.query.graphql @@ -6,16 +6,16 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) { sha title titleHtml - description + descriptionHtml message - webUrl + webPath authoredDate authorName authorGravatar author { name avatarUrl - webUrl + webPath } signatureHtml pipelines(ref: $ref, first: 1) { diff --git a/app/assets/javascripts/repository/queries/getPermissions.query.graphql b/app/assets/javascripts/repository/queries/permissions.query.graphql index 092fa44e2d0..092fa44e2d0 100644 --- a/app/assets/javascripts/repository/queries/getPermissions.query.graphql +++ b/app/assets/javascripts/repository/queries/permissions.query.graphql diff --git a/app/assets/javascripts/repository/queries/getProjectPath.query.graphql b/app/assets/javascripts/repository/queries/project_path.query.graphql index 74e73e07577..74e73e07577 100644 --- a/app/assets/javascripts/repository/queries/getProjectPath.query.graphql +++ b/app/assets/javascripts/repository/queries/project_path.query.graphql diff --git a/app/assets/javascripts/repository/queries/getProjectShortPath.query.graphql b/app/assets/javascripts/repository/queries/project_short_path.query.graphql index 34eb26598c2..34eb26598c2 100644 --- a/app/assets/javascripts/repository/queries/getProjectShortPath.query.graphql +++ b/app/assets/javascripts/repository/queries/project_short_path.query.graphql diff --git a/app/assets/javascripts/repository/queries/getReadme.query.graphql b/app/assets/javascripts/repository/queries/readme.query.graphql index cf056330133..cf056330133 100644 --- a/app/assets/javascripts/repository/queries/getReadme.query.graphql +++ b/app/assets/javascripts/repository/queries/readme.query.graphql diff --git a/app/assets/javascripts/repository/queries/getRef.query.graphql b/app/assets/javascripts/repository/queries/ref.query.graphql index 91afb751626..91afb751626 100644 --- a/app/assets/javascripts/repository/queries/getRef.query.graphql +++ b/app/assets/javascripts/repository/queries/ref.query.graphql diff --git a/app/assets/javascripts/right_sidebar.js b/app/assets/javascripts/right_sidebar.js index 0bb33de0234..8bebd16ace7 100644 --- a/app/assets/javascripts/right_sidebar.js +++ b/app/assets/javascripts/right_sidebar.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; -import flash from './flash'; +import { deprecatedCreateFlash as flash } from './flash'; import axios from './lib/utils/axios_utils'; import { sprintf, s__, __ } from './locale'; diff --git a/app/assets/javascripts/search_autocomplete.js b/app/assets/javascripts/search_autocomplete.js index 05e0b9e7089..990c8faf253 100644 --- a/app/assets/javascripts/search_autocomplete.js +++ b/app/assets/javascripts/search_autocomplete.js @@ -12,6 +12,7 @@ import { getProjectSlug, spriteIcon, } from './lib/utils/common_utils'; +import Tracking from '~/tracking'; /** * Search input in top navigation bar. @@ -355,6 +356,15 @@ export class SearchAutocomplete { if (!this.dropdown.hasClass('show')) { this.loadingSuggestions = false; this.dropdownToggle.dropdown('toggle'); + + const trackEvent = 'click_search_bar'; + const trackCategory = undefined; // will be default set in event method + + Tracking.event(trackCategory, trackEvent, { + label: 'main_navigation', + property: 'navigation', + }); + return this.searchInput.removeClass('js-autocomplete-disabled'); } } diff --git a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue index 807f10bd9c6..1530e9a15b5 100644 --- a/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue +++ b/app/assets/javascripts/sentry_error_stack_trace/components/sentry_error_stack_trace.vue @@ -1,7 +1,7 @@ <script> -import Stacktrace from '~/error_tracking/components/stacktrace.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { mapActions, mapState, mapGetters } from 'vuex'; +import Stacktrace from '~/error_tracking/components/stacktrace.vue'; export default { name: 'SentryErrorStackTrace', diff --git a/app/assets/javascripts/serverless/components/empty_state.vue b/app/assets/javascripts/serverless/components/empty_state.vue index 2683805f2f7..49922ad8e6c 100644 --- a/app/assets/javascripts/serverless/components/empty_state.vue +++ b/app/assets/javascripts/serverless/components/empty_state.vue @@ -1,40 +1,38 @@ <script> +import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { mapState } from 'vuex'; + export default { - props: { - clustersPath: { - type: String, - required: true, - }, - helpPath: { - type: String, - required: true, - }, + components: { + GlEmptyState, + GlLink, + GlSprintf, + }, + computed: { + ...mapState(['clustersPath', 'emptyImagePath', 'helpPath']), }, }; </script> <template> - <div class="row empty-state js-empty-state"> - <div class="col-12"> - <div class="text-content"> - <h4 class="state-title text-center"> - {{ s__('Serverless|Getting started with serverless') }} - </h4> - <p class="state-description"> - {{ - s__(`Serverless| In order to start using functions as a service, - you must first install Knative on your Kubernetes cluster.`) - }} - - <a :href="helpPath"> {{ __('More information') }} </a> - </p> - - <div class="text-center"> - <a :href="clustersPath" class="btn btn-success"> - {{ s__('Serverless|Install Knative') }} - </a> - </div> - </div> - </div> - </div> + <gl-empty-state + :svg-path="emptyImagePath" + :title="s__('Serverless|Getting started with serverless')" + :primary-button-link="clustersPath" + :primary-button-text="s__('Serverless|Install Knative')" + > + <template #description> + <gl-sprintf + :message=" + s__( + 'Serverless|In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. %{linkStart}More information%{linkEnd}', + ) + " + > + <template #link="{ content }"> + <gl-link :href="helpPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> </template> diff --git a/app/assets/javascripts/serverless/components/function_details.vue b/app/assets/javascripts/serverless/components/function_details.vue index 53c78b93254..6ab44eec5cd 100644 --- a/app/assets/javascripts/serverless/components/function_details.vue +++ b/app/assets/javascripts/serverless/components/function_details.vue @@ -23,14 +23,6 @@ export default { required: false, default: false, }, - clustersPath: { - type: String, - required: true, - }, - helpPath: { - type: String, - required: true, - }, }, data() { return { @@ -96,8 +88,6 @@ export default { <area-chart v-if="hasPrometheusData" :graph-data="graphData" :container-width="elWidth" /> <missing-prometheus v-if="!hasPrometheus || hasPrometheusMissingData" - :help-path="helpPath" - :clusters-path="clustersPath" :missing-data="hasPrometheusMissingData" /> </section> diff --git a/app/assets/javascripts/serverless/components/functions.vue b/app/assets/javascripts/serverless/components/functions.vue index 8fa48134f1f..c44a14f1785 100644 --- a/app/assets/javascripts/serverless/components/functions.vue +++ b/app/assets/javascripts/serverless/components/functions.vue @@ -1,6 +1,6 @@ <script> import { mapState, mapActions, mapGetters } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; import EnvironmentRow from './environment_row.vue'; import EmptyState from './empty_state.vue'; @@ -10,24 +10,11 @@ export default { components: { EnvironmentRow, EmptyState, + GlLink, GlLoadingIcon, }, - props: { - clustersPath: { - type: String, - required: true, - }, - helpPath: { - type: String, - required: true, - }, - statusPath: { - type: String, - required: true, - }, - }, computed: { - ...mapState(['installed', 'isLoading', 'hasFunctionData']), + ...mapState(['installed', 'isLoading', 'hasFunctionData', 'helpPath', 'statusPath']), ...mapGetters(['getFunctions']), checkingInstalled() { @@ -118,14 +105,14 @@ export default { }} </p> <div class="text-center"> - <a :href="helpPath" class="btn btn-success"> - {{ s__('Serverless|Learn more about Serverless') }} - </a> + <gl-link :href="helpPath" class="btn btn-success">{{ + s__('Serverless|Learn more about Serverless') + }}</gl-link> </div> </div> </div> </div> - <empty-state v-else :clusters-path="clustersPath" :help-path="helpPath" /> + <empty-state v-else /> </section> </template> diff --git a/app/assets/javascripts/serverless/components/missing_prometheus.vue b/app/assets/javascripts/serverless/components/missing_prometheus.vue index 6c29f7c89ff..0d2c9f5151c 100644 --- a/app/assets/javascripts/serverless/components/missing_prometheus.vue +++ b/app/assets/javascripts/serverless/components/missing_prometheus.vue @@ -1,5 +1,6 @@ <script> import { GlDeprecatedButton, GlLink } from '@gitlab/ui'; +import { mapState } from 'vuex'; import { s__ } from '../../locale'; export default { @@ -8,20 +9,13 @@ export default { GlLink, }, props: { - clustersPath: { - type: String, - required: true, - }, - helpPath: { - type: String, - required: true, - }, missingData: { type: Boolean, required: true, }, }, computed: { + ...mapState(['clustersPath', 'helpPath']), missingStateClass() { return this.missingData ? 'missing-prometheus-state' : 'empty-prometheus-state'; }, diff --git a/app/assets/javascripts/serverless/serverless_bundle.js b/app/assets/javascripts/serverless/serverless_bundle.js index ed3b633d766..24a9b4ac521 100644 --- a/app/assets/javascripts/serverless/serverless_bundle.js +++ b/app/assets/javascripts/serverless/serverless_bundle.js @@ -6,6 +6,9 @@ import { createStore } from './store'; export default class Serverless { constructor() { if (document.querySelector('.js-serverless-function-details-page') != null) { + const entryPointData = document.querySelector('.js-serverless-function-details-page').dataset; + const store = createStore(entryPointData); + const { serviceName, serviceDescription, @@ -15,9 +18,7 @@ export default class Serverless { servicePodcount, serviceMetricsUrl, prometheus, - clustersPath, - helpPath, - } = document.querySelector('.js-serverless-function-details-page').dataset; + } = entryPointData; const el = document.querySelector('#js-serverless-function-details'); const service = { @@ -32,35 +33,26 @@ export default class Serverless { this.functionDetails = new Vue({ el, - store: createStore(), + store, render(createElement) { return createElement(FunctionDetails, { props: { func: service, hasPrometheus: prometheus !== undefined, - clustersPath, - helpPath, }, }); }, }); } else { - const { statusPath, clustersPath, helpPath } = document.querySelector( - '.js-serverless-functions-page', - ).dataset; + const entryPointData = document.querySelector('.js-serverless-functions-page').dataset; + const store = createStore(entryPointData); const el = document.querySelector('#js-serverless-functions'); this.functions = new Vue({ el, - store: createStore(), + store, render(createElement) { - return createElement(Functions, { - props: { - clustersPath, - helpPath, - statusPath, - }, - }); + return createElement(Functions); }, }); } diff --git a/app/assets/javascripts/serverless/store/actions.js b/app/assets/javascripts/serverless/store/actions.js index a0a9fdf7ace..b9d57138efa 100644 --- a/app/assets/javascripts/serverless/store/actions.js +++ b/app/assets/javascripts/serverless/store/actions.js @@ -2,7 +2,7 @@ import * as types from './mutation_types'; import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import { backOff } from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { MAX_REQUESTS, CHECKING_INSTALLED, TIMEOUT } from '../constants'; @@ -123,6 +123,3 @@ export const fetchMetrics = ({ dispatch }, { metricsPath, hasPrometheus }) => { createFlash(error); }); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/serverless/store/getters.js b/app/assets/javascripts/serverless/store/getters.js index 071f663d9d2..c9b1d22799a 100644 --- a/app/assets/javascripts/serverless/store/getters.js +++ b/app/assets/javascripts/serverless/store/getters.js @@ -5,6 +5,3 @@ export const hasPrometheusMissingData = state => state.hasPrometheus && !state.h // Convert the function list into a k/v grouping based on the environment scope export const getFunctions = state => translate(state.functions); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/serverless/store/index.js b/app/assets/javascripts/serverless/store/index.js index 5f72060633e..6f32d85201e 100644 --- a/app/assets/javascripts/serverless/store/index.js +++ b/app/assets/javascripts/serverless/store/index.js @@ -7,12 +7,12 @@ import createState from './state'; Vue.use(Vuex); -export const createStore = () => +export const createStore = (entryPointData = {}) => new Vuex.Store({ actions, getters, mutations, - state: createState(), + state: createState(entryPointData), }); -export default createStore(); +export default createStore; diff --git a/app/assets/javascripts/serverless/store/state.js b/app/assets/javascripts/serverless/store/state.js index fdd29299749..353bfcf3fed 100644 --- a/app/assets/javascripts/serverless/store/state.js +++ b/app/assets/javascripts/serverless/store/state.js @@ -1,14 +1,22 @@ -export default () => ({ +export default ( + initialState = { clustersPath: null, helpPath: null, emptyImagePath: null, statusPath: null }, +) => ({ + clustersPath: initialState.clustersPath, error: null, + helpPath: initialState.helpPath, installed: 'checking', isLoading: true, // functions functions: [], hasFunctionData: true, + statusPath: initialState.statusPath, // function_details hasPrometheus: true, hasPrometheusData: false, graphData: {}, + + // empty_state + emptyImagePath: initialState.emptyImagePath, }); diff --git a/app/assets/javascripts/serverless/survey_banner.vue b/app/assets/javascripts/serverless/survey_banner.vue index a0a90fa5e80..18ab8315840 100644 --- a/app/assets/javascripts/serverless/survey_banner.vue +++ b/app/assets/javascripts/serverless/survey_banner.vue @@ -1,7 +1,7 @@ <script> import Cookies from 'js-cookie'; -import { parseBoolean } from '~/lib/utils/common_utils'; import { GlBanner } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; export default { components: { diff --git a/app/assets/javascripts/serverless/utils.js b/app/assets/javascripts/serverless/utils.js index 8b9e96ce9aa..1bf03ea8d42 100644 --- a/app/assets/javascripts/serverless/utils.js +++ b/app/assets/javascripts/serverless/utils.js @@ -18,6 +18,3 @@ export const translate = functions => }), {}, ); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue index d5ae9b04090..cb047530c17 100644 --- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue +++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue @@ -2,7 +2,7 @@ import $ from 'jquery'; import GfmAutoComplete from 'ee_else_ce/gfm_auto_complete'; import { GlModal, GlTooltipDirective } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { __, s__ } from '~/locale'; import Api from '~/api'; diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 0906d5abec3..14c14d0bad1 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -1,5 +1,5 @@ <script> -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import eventHub from '~/sidebar/event_hub'; import Store from '~/sidebar/stores/sidebar_store'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; diff --git a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue index 0987603cafd..c6f7d5e44ad 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -1,12 +1,10 @@ <script> -import { mapState, mapActions } from 'vuex'; -import { __ } from '~/locale'; -import Flash from '~/flash'; +import { mapState } from 'vuex'; +import { __, sprintf } from '~/locale'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; import eventHub from '~/sidebar/event_hub'; import EditForm from './edit_form.vue'; -import recaptchaModalImplementor from '~/vue_shared/mixins/recaptcha_modal_implementor'; export default { components: { @@ -16,7 +14,6 @@ export default { directives: { tooltip, }, - mixins: [recaptchaModalImplementor], props: { fullPath: { required: true, @@ -26,9 +23,10 @@ export default { required: true, type: Boolean, }, - service: { - required: true, - type: Object, + issuableType: { + required: false, + type: String, + default: 'issue', }, }, data() { @@ -37,45 +35,36 @@ export default { }; }, computed: { - ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }), + ...mapState({ + confidential: ({ noteableData, confidential }) => { + if (noteableData) { + return noteableData.confidential; + } + return Boolean(confidential); + }, + }), confidentialityIcon() { return this.confidential ? 'eye-slash' : 'eye'; }, tooltipLabel() { return this.confidential ? __('Confidential') : __('Not confidential'); }, + confidentialText() { + return sprintf(__('This %{issuableType} is confidential'), { + issuableType: this.issuableType, + }); + }, }, created() { - eventHub.$on('updateConfidentialAttribute', this.updateConfidentialAttribute); eventHub.$on('closeConfidentialityForm', this.toggleForm); }, beforeDestroy() { - eventHub.$off('updateConfidentialAttribute', this.updateConfidentialAttribute); eventHub.$off('closeConfidentialityForm', this.toggleForm); }, methods: { - ...mapActions(['setConfidentiality']), toggleForm() { this.edit = !this.edit; }, - closeForm() { - this.edit = false; - }, - updateConfidentialAttribute() { - // TODO: rm when FF is defaulted to on. - const confidential = !this.confidential; - this.service - .update('issue', { confidential }) - .then(({ data }) => this.checkForSpam(data)) - .then(() => window.location.reload()) - .catch(error => { - if (error.name === 'SpamError') { - this.openRecaptcha(); - } else { - Flash(__('Something went wrong trying to change the confidentiality of this issue')); - } - }); - }, }, }; </script> @@ -109,7 +98,12 @@ export default { > </div> <div class="value sidebar-item-value hide-collapsed"> - <edit-form v-if="edit" :is-confidential="confidential" :full-path="fullPath" /> + <edit-form + v-if="edit" + :confidential="confidential" + :full-path="fullPath" + :issuable-type="issuableType" + /> <div v-if="!confidential" class="no-value sidebar-item-value" data-testid="not-confidential"> <icon :size="16" name="eye" aria-hidden="true" class="sidebar-item-icon inline" /> {{ __('Not confidential') }} @@ -121,10 +115,8 @@ export default { aria-hidden="true" class="sidebar-item-icon inline is-active" /> - {{ __('This issue is confidential') }} + {{ confidentialText }} </div> </div> - - <recaptcha-modal v-if="showRecaptcha" :html="recaptchaHTML" @close="closeRecaptcha" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue index 9dd4f04acdb..17e44cf0e1d 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form.vue @@ -1,13 +1,15 @@ <script> +import { GlSprintf } from '@gitlab/ui'; import editFormButtons from './edit_form_buttons.vue'; -import { s__ } from '../../../locale'; +import { __ } from '../../../locale'; export default { components: { editFormButtons, + GlSprintf, }, props: { - isConfidential: { + confidential: { required: true, type: Boolean, }, @@ -15,16 +17,20 @@ export default { required: true, type: String, }, + issuableType: { + required: true, + type: String, + }, }, computed: { confidentialityOnWarning() { - return s__( - 'confidentiality|You are going to turn on the confidentiality. This means that only team members with <strong>at least Reporter access</strong> are able to see and leave comments on the issue.', + return __( + 'You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}.', ); }, confidentialityOffWarning() { - return s__( - 'confidentiality|You are going to turn off the confidentiality. This means <strong>everyone</strong> will be able to see and leave a comment on this issue.', + return __( + 'You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}.', ); }, }, @@ -35,9 +41,23 @@ export default { <div class="dropdown show"> <div class="dropdown-menu sidebar-item-warning-message"> <div> - <p v-if="!isConfidential" v-html="confidentialityOnWarning"></p> - <p v-else v-html="confidentialityOffWarning"></p> - <edit-form-buttons :full-path="fullPath" /> + <p v-if="!confidential"> + <gl-sprintf :message="confidentialityOnWarning"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #issuableType>{{ issuableType }}</template> + </gl-sprintf> + </p> + <p v-else> + <gl-sprintf :message="confidentialityOffWarning"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + <template #issuableType>{{ issuableType }}</template> + </gl-sprintf> + </p> + <edit-form-buttons :full-path="fullPath" :confidential="confidential" /> </div> </div> </div> diff --git a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue index 80928649a03..86bfacbfb9e 100644 --- a/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/confidential/edit_form_buttons.vue @@ -1,22 +1,24 @@ <script> import $ from 'jquery'; import { GlLoadingIcon } from '@gitlab/ui'; -import { mapActions, mapState } from 'vuex'; +import { mapActions } from 'vuex'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import eventHub from '../../event_hub'; export default { components: { GlLoadingIcon, }, - mixins: [glFeatureFlagsMixin()], props: { fullPath: { required: true, type: String, }, + confidential: { + required: true, + type: Boolean, + }, }, data() { return { @@ -24,7 +26,6 @@ export default { }; }, computed: { - ...mapState({ confidential: ({ noteableData }) => noteableData.confidential }), toggleButtonText() { if (this.isLoading) { return __('Applying'); @@ -34,7 +35,7 @@ export default { }, }, methods: { - ...mapActions(['updateConfidentialityOnIssue']), + ...mapActions(['updateConfidentialityOnIssuable']), closeForm() { eventHub.$emit('closeConfidentialityForm'); $(this.$el).trigger('hidden.gl.dropdown'); @@ -43,18 +44,19 @@ export default { this.isLoading = true; const confidential = !this.confidential; - if (this.glFeatures.confidentialApolloSidebar) { - this.updateConfidentialityOnIssue({ confidential, fullPath: this.fullPath }) - .catch(() => { - Flash(__('Something went wrong trying to change the confidentiality of this issue')); - }) - .finally(() => { - this.closeForm(); - this.isLoading = false; - }); - } else { - eventHub.$emit('updateConfidentialAttribute'); - } + this.updateConfidentialityOnIssuable({ confidential, fullPath: this.fullPath }) + .then(() => { + eventHub.$emit('updateIssuableConfidentiality', confidential); + }) + .catch(err => { + Flash( + err || __('Something went wrong trying to change the confidentiality of this issue'), + ); + }) + .finally(() => { + this.closeForm(); + this.isLoading = false; + }); }, }, }; diff --git a/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql b/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql index 2459aa346c9..5caf5f6b555 100644 --- a/app/assets/javascripts/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql +++ b/app/assets/javascripts/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql @@ -3,5 +3,6 @@ mutation updateIssueConfidential($input: IssueSetConfidentialInput!) { issue { confidential } + errors } } diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index 630da751704..65b51169420 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -1,40 +1,20 @@ <script> +import { GlSprintf } from '@gitlab/ui'; import editFormButtons from './edit_form_buttons.vue'; -import issuableMixin from '../../../vue_shared/mixins/issuable'; -import { __, sprintf } from '../../../locale'; export default { components: { editFormButtons, + GlSprintf, }, - mixins: [issuableMixin], props: { isLocked: { required: true, type: Boolean, }, - - updateLockedAttribute: { + issuableDisplayName: { required: true, - type: Function, - }, - }, - computed: { - lockWarning() { - return sprintf( - __( - 'Lock this %{issuableDisplayName}? Only <strong>project members</strong> will be able to comment.', - ), - { issuableDisplayName: this.issuableDisplayName }, - ); - }, - unlockWarning() { - return sprintf( - __( - 'Unlock this %{issuableDisplayName}? <strong>Everyone</strong> will be able to comment.', - ), - { issuableDisplayName: this.issuableDisplayName }, - ); + type: String, }, }, }; @@ -42,12 +22,38 @@ export default { <template> <div class="dropdown show"> - <div class="dropdown-menu sidebar-item-warning-message"> - <p v-if="isLocked" class="text" v-html="unlockWarning"></p> + <div class="dropdown-menu sidebar-item-warning-message" data-testid="warning-text"> + <p v-if="isLocked" class="text"> + <gl-sprintf + :message=" + __( + 'Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment.', + ) + " + > + <template #issuableDisplayName>{{ issuableDisplayName }}</template> + <template #strong="{ content }" + ><strong>{{ content }}</strong></template + > + </gl-sprintf> + </p> - <p v-else class="text" v-html="lockWarning"></p> + <p v-else class="text"> + <gl-sprintf + :message=" + __( + 'Lock this %{issuableDisplayName}? Only %{strongStart}project members%{strongEnd} will be able to comment.', + ) + " + > + <template #issuableDisplayName>{{ issuableDisplayName }}</template> + <template #strong="{ content }" + ><strong>{{ content }}</strong></template + > + </gl-sprintf> + </p> - <edit-form-buttons :is-locked="isLocked" :update-locked-attribute="updateLockedAttribute" /> + <edit-form-buttons :is-locked="isLocked" :issuable-display-name="issuableDisplayName" /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue index 2e85ded8ade..ea7230ae488 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form_buttons.vue @@ -1,39 +1,63 @@ <script> import $ from 'jquery'; -import { __ } from '~/locale'; +import { GlLoadingIcon } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import { __, sprintf } from '../../../locale'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import eventHub from '../../event_hub'; export default { + components: { + GlLoadingIcon, + }, + inject: ['fullPath'], props: { isLocked: { required: true, type: Boolean, }, - - updateLockedAttribute: { + issuableDisplayName: { required: true, - type: Function, + type: String, }, }, - + data() { + return { + isLoading: false, + }; + }, computed: { buttonText() { - return this.isLocked ? __('Unlock') : __('Lock'); - }, + if (this.isLoading) { + return __('Applying'); + } - toggleLock() { - return !this.isLocked; + return this.isLocked ? __('Unlock') : __('Lock'); }, }, - methods: { + ...mapActions(['updateLockedAttribute']), closeForm() { eventHub.$emit('closeLockForm'); $(this.$el).trigger('hidden.gl.dropdown'); }, submitForm() { - this.closeForm(); - this.updateLockedAttribute(this.toggleLock); + this.isLoading = true; + + this.updateLockedAttribute({ + locked: !this.isLocked, + fullPath: this.fullPath, + }) + .catch(() => { + const flashMessage = __( + 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', + ); + Flash(sprintf(flashMessage, { issuableDisplayName: this.issuableDisplayName })); + }) + .finally(() => { + this.closeForm(); + this.isLoading = false; + }); }, }, }; @@ -45,7 +69,14 @@ export default { {{ __('Cancel') }} </button> - <button type="button" class="btn btn-close" @click.prevent="submitForm"> + <button + type="button" + data-testid="lock-toggle" + class="btn btn-close" + :disabled="isLoading" + @click.prevent="submitForm" + > + <gl-loading-icon v-if="isLoading" inline /> {{ buttonText }} </button> </div> diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue new file mode 100644 index 00000000000..1b4968fabf6 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -0,0 +1,129 @@ +<script> +import { mapGetters } from 'vuex'; +import { __ } from '~/locale'; +import tooltip from '~/vue_shared/directives/tooltip'; +import Icon from '~/vue_shared/components/icon.vue'; +import eventHub from '~/sidebar/event_hub'; +import editForm from './edit_form.vue'; + +export default { + issue: 'issue', + locked: { + icon: 'lock', + class: 'value', + iconClass: 'is-active', + displayText: __('Locked'), + }, + unlocked: { + class: ['no-value hide-collapsed'], + icon: 'lock-open', + iconClass: '', + displayText: __('Unlocked'), + }, + components: { + editForm, + Icon, + }, + + directives: { + tooltip, + }, + + props: { + isEditable: { + required: true, + type: Boolean, + }, + }, + data() { + return { + isLockDialogOpen: false, + }; + }, + computed: { + ...mapGetters(['getNoteableData']), + issuableDisplayName() { + const isInIssuePage = this.getNoteableData.targetType === this.$options.issue; + return isInIssuePage ? __('issue') : __('merge request'); + }, + isLocked() { + return this.getNoteableData.discussion_locked; + }, + lockStatus() { + return this.isLocked ? this.$options.locked : this.$options.unlocked; + }, + + tooltipLabel() { + return this.isLocked ? __('Locked') : __('Unlocked'); + }, + }, + + created() { + eventHub.$on('closeLockForm', this.toggleForm); + }, + + beforeDestroy() { + eventHub.$off('closeLockForm', this.toggleForm); + }, + + methods: { + toggleForm() { + if (this.isEditable) { + this.isLockDialogOpen = !this.isLockDialogOpen; + } + }, + }, +}; +</script> + +<template> + <div class="block issuable-sidebar-item lock"> + <div + v-tooltip + :title="tooltipLabel" + class="sidebar-collapsed-icon" + data-testid="sidebar-collapse-icon" + data-container="body" + data-placement="left" + data-boundary="viewport" + @click="toggleForm" + > + <icon :name="lockStatus.icon" class="sidebar-item-icon is-active" /> + </div> + + <div class="title hide-collapsed"> + {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} + <a + v-if="isEditable" + class="float-right lock-edit" + href="#" + data-testid="edit-link" + data-track-event="click_edit_button" + data-track-label="right_sidebar" + data-track-property="lock_issue" + @click.prevent="toggleForm" + > + {{ __('Edit') }} + </a> + </div> + + <div class="value sidebar-item-value hide-collapsed"> + <edit-form + v-if="isLockDialogOpen" + data-testid="edit-form" + :is-locked="isLocked" + :issuable-display-name="issuableDisplayName" + /> + + <div data-testid="lock-status" class="sidebar-item-value" :class="lockStatus.class"> + <icon + :size="16" + :name="lockStatus.icon" + class="sidebar-item-icon" + :class="lockStatus.iconClass" + /> + {{ lockStatus.displayText }} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue deleted file mode 100644 index 728f655d33d..00000000000 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ /dev/null @@ -1,140 +0,0 @@ -<script> -import { __, sprintf } from '~/locale'; -import Flash from '~/flash'; -import tooltip from '~/vue_shared/directives/tooltip'; -import issuableMixin from '~/vue_shared/mixins/issuable'; -import Icon from '~/vue_shared/components/icon.vue'; -import eventHub from '~/sidebar/event_hub'; -import editForm from './edit_form.vue'; - -export default { - components: { - editForm, - Icon, - }, - - directives: { - tooltip, - }, - - mixins: [issuableMixin], - - props: { - isLocked: { - required: true, - type: Boolean, - }, - - isEditable: { - required: true, - type: Boolean, - }, - - mediator: { - required: true, - type: Object, - validator(mediatorObject) { - return mediatorObject.service && mediatorObject.service.update && mediatorObject.store; - }, - }, - }, - - computed: { - lockIcon() { - return this.isLocked ? 'lock' : 'lock-open'; - }, - - isLockDialogOpen() { - return this.mediator.store.isLockDialogOpen; - }, - - tooltipLabel() { - return this.isLocked ? __('Locked') : __('Unlocked'); - }, - }, - - created() { - eventHub.$on('closeLockForm', this.toggleForm); - }, - - beforeDestroy() { - eventHub.$off('closeLockForm', this.toggleForm); - }, - - methods: { - toggleForm() { - if (this.isEditable) { - this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; - } - }, - updateLockedAttribute(locked) { - this.mediator.service - .update(this.issuableType, { - discussion_locked: locked, - }) - .then(() => window.location.reload()) - .catch(() => - Flash( - sprintf( - __( - 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', - ), - { - issuableDisplayName: this.issuableDisplayName, - }, - ), - ), - ); - }, - }, -}; -</script> - -<template> - <div class="block issuable-sidebar-item lock"> - <div - v-tooltip - :title="tooltipLabel" - class="sidebar-collapsed-icon" - data-container="body" - data-placement="left" - data-boundary="viewport" - @click="toggleForm" - > - <icon :name="lockIcon" class="sidebar-item-icon is-active" /> - </div> - - <div class="title hide-collapsed"> - {{ sprintf(__('Lock %{issuableDisplayName}'), { issuableDisplayName: issuableDisplayName }) }} - <a - v-if="isEditable" - class="float-right lock-edit" - href="#" - data-track-event="click_edit_button" - data-track-label="right_sidebar" - data-track-property="lock_issue" - @click.prevent="toggleForm" - > - {{ __('Edit') }} - </a> - </div> - - <div class="value sidebar-item-value hide-collapsed"> - <edit-form - v-if="isLockDialogOpen" - :is-locked="isLocked" - :update-locked-attribute="updateLockedAttribute" - :issuable-type="issuableType" - /> - - <div v-if="isLocked" class="value sidebar-item-value"> - <icon :size="16" name="lock" class="sidebar-item-icon inline is-active" /> - {{ __('Locked') }} - </div> - - <div v-else class="no-value sidebar-item-value hide-collapsed"> - <icon :size="16" name="lock-open" class="sidebar-item-icon inline" /> {{ __('Unlocked') }} - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql new file mode 100644 index 00000000000..2a1bcdf7136 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateIssueLocked($input: IssueSetLockedInput!) { + issueSetLocked(input: $input) { + issue { + discussionLocked + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql new file mode 100644 index 00000000000..8590c8e71a6 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql @@ -0,0 +1,8 @@ +mutation updateMergeRequestLocked($input: MergeRequestSetLockedInput!) { + mergeRequestSetLocked(input: $input) { + mergeRequest { + discussionLocked + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue index 91fe5fc50a9..ee1c98e9d69 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/sidebar_subscriptions.vue @@ -1,6 +1,6 @@ <script> import Store from '../../stores/sidebar_store'; -import Flash from '../../../flash'; +import { deprecatedCreateFlash as Flash } from '../../../flash'; import { __ } from '../../../locale'; import subscriptions from './subscriptions.vue'; diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue index 5cf574e1387..67a8f11b760 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.vue @@ -1,4 +1,5 @@ <script> +import { GlIcon } from '@gitlab/ui'; import TimeTrackingHelpState from './help_state.vue'; import TimeTrackingCollapsedState from './collapsed_state.vue'; import TimeTrackingSpentOnlyPane from './spent_only_pane.vue'; @@ -11,6 +12,7 @@ import eventHub from '../../event_hub'; export default { name: 'IssuableTimeTracker', components: { + GlIcon, TimeTrackingCollapsedState, TimeTrackingEstimateOnlyPane, TimeTrackingSpentOnlyPane, @@ -111,7 +113,7 @@ export default { class="close-help-button float-right" @click="toggleHelpState(false)" > - <i class="fa fa-close" aria-hidden="true"> </i> + <gl-icon name="close" /> </div> </div> <div class="time-tracking-content hide-collapsed"> diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 3b7df369237..5281c03ab3f 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -6,7 +6,7 @@ import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; const MARK_TEXT = __('Mark as done'); -const TODO_TEXT = __('Add a To Do'); +const TODO_TEXT = __('Add a To-Do'); export default { directives: { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 2c108835c36..015219200db 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -5,13 +5,14 @@ import SidebarTimeTracking from './components/time_tracking/sidebar_time_trackin import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue'; import SidebarMoveIssue from './lib/sidebar_move_issue'; -import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue'; +import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import sidebarParticipants from './components/participants/sidebar_participants.vue'; import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue'; import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; import { store } from '~/notes/stores'; import { isInIssuePage } from '~/lib/utils/common_utils'; +import mergeRequestStore from '~/mr_notes/stores'; Vue.use(Translate); Vue.use(VueApollo); @@ -79,24 +80,28 @@ function mountConfidentialComponent(mediator) { }); } -function mountLockComponent(mediator) { +function mountLockComponent() { const el = document.getElementById('js-lock-entry-point'); - - if (!el) return; + const { fullPath } = getSidebarOptions(); const dataNode = document.getElementById('js-lock-issue-data'); const initialData = JSON.parse(dataNode.innerHTML); - const LockComp = Vue.extend(LockIssueSidebar); - - new LockComp({ - propsData: { - isLocked: initialData.is_locked, - isEditable: initialData.is_editable, - mediator, - issuableType: isInIssuePage() ? 'issue' : 'merge_request', - }, - }).$mount(el); + return el + ? new Vue({ + el, + store: isInIssuePage() ? store : mergeRequestStore, + provide: { + fullPath, + }, + render: createElement => + createElement(IssuableLockForm, { + props: { + isEditable: initialData.is_editable, + }, + }), + }) + : undefined; } function mountParticipantsComponent(mediator) { diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js index 3b8903b4a4c..8714bea1729 100644 --- a/app/assets/javascripts/sidebar/services/sidebar_service.js +++ b/app/assets/javascripts/sidebar/services/sidebar_service.js @@ -1,7 +1,7 @@ -import axios from '~/lib/utils/axios_utils'; -import createGqClient, { fetchPolicies } from '~/lib/graphql'; import sidebarDetailsQuery from 'ee_else_ce/sidebar/queries/sidebarDetails.query.graphql'; import sidebarDetailsForHealthStatusFeatureFlagQuery from 'ee_else_ce/sidebar/queries/sidebarDetailsForHealthStatusFeatureFlag.query.graphql'; +import axios from '~/lib/utils/axios_utils'; +import createGqClient, { fetchPolicies } from '~/lib/graphql'; export const gqClient = createGqClient( {}, diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js index 34621fc1036..8f1f76a2e02 100644 --- a/app/assets/javascripts/sidebar/sidebar_mediator.js +++ b/app/assets/javascripts/sidebar/sidebar_mediator.js @@ -1,6 +1,6 @@ import Store from 'ee_else_ce/sidebar/stores/sidebar_store'; import { visitUrl } from '../lib/utils/url_utility'; -import Flash from '../flash'; +import { deprecatedCreateFlash as Flash } from '../flash'; import Service from './services/sidebar_service'; import { __ } from '~/locale'; diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 2d505c4c96b..586d1e62c2f 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -3,7 +3,7 @@ import $ from 'jquery'; import { __ } from './locale'; import axios from './lib/utils/axios_utils'; -import createFlash from './flash'; +import { deprecatedCreateFlash as createFlash } from './flash'; import FilesCommentButton from './files_comment_button'; import initImageDiffHelper from './image_diff/helpers/init_image_diff'; import syntaxHighlight from './syntax_highlight'; diff --git a/app/assets/javascripts/snippets/components/edit.vue b/app/assets/javascripts/snippets/components/edit.vue index c01f9524ca8..6e3a670dc38 100644 --- a/app/assets/javascripts/snippets/components/edit.vue +++ b/app/assets/javascripts/snippets/components/edit.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlLoadingIcon } from '@gitlab/ui'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { __, sprintf } from '~/locale'; import TitleField from '~/vue_shared/components/form/title.vue'; import { redirectTo } from '~/lib/utils/url_utility'; @@ -14,19 +14,17 @@ import { SNIPPET_VISIBILITY_PRIVATE, SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR, - SNIPPET_BLOB_ACTION_CREATE, - SNIPPET_BLOB_ACTION_UPDATE, - SNIPPET_BLOB_ACTION_MOVE, } from '../constants'; -import SnippetBlobEdit from './snippet_blob_edit.vue'; +import SnippetBlobActionsEdit from './snippet_blob_actions_edit.vue'; import SnippetVisibilityEdit from './snippet_visibility_edit.vue'; import SnippetDescriptionEdit from './snippet_description_edit.vue'; +import { SNIPPET_MARK_EDIT_APP_START } from '~/performance_constants'; export default { components: { SnippetDescriptionEdit, SnippetVisibilityEdit, - SnippetBlobEdit, + SnippetBlobActionsEdit, TitleField, FormFooterActions, GlButton, @@ -55,25 +53,20 @@ export default { }, data() { return { - blobsActions: {}, isUpdating: false, newSnippet: false, + actions: [], }; }, computed: { - getActionsEntries() { - return Object.values(this.blobsActions); + hasBlobChanges() { + return this.actions.length > 0; }, - allBlobsHaveContent() { - const entries = this.getActionsEntries; - return entries.length > 0 && !entries.find(action => !action.content); - }, - allBlobChangesRegistered() { - const entries = this.getActionsEntries; - return entries.length > 0 && !entries.find(action => action.action === ''); + hasValidBlobs() { + return this.actions.every(x => x.filePath && x.content); }, updatePrevented() { - return this.snippet.title === '' || !this.allBlobsHaveContent || this.isUpdating; + return this.snippet.title === '' || !this.hasValidBlobs || this.isUpdating; }, isProjectSnippet() { return Boolean(this.projectPath); @@ -84,7 +77,7 @@ export default { title: this.snippet.title, description: this.snippet.description, visibilityLevel: this.snippet.visibilityLevel, - files: this.getActionsEntries.filter(entry => entry.action !== ''), + blobActions: this.actions, }; }, saveButtonLabel() { @@ -95,7 +88,7 @@ export default { }, cancelButtonHref() { if (this.newSnippet) { - return this.projectPath ? `/${this.projectPath}/snippets` : `/snippets`; + return this.projectPath ? `/${this.projectPath}/-/snippets` : `/-/snippets`; } return this.snippet.webUrl; }, @@ -106,6 +99,9 @@ export default { return `${this.isProjectSnippet ? 'project' : 'personal'}_snippet_description`; }, }, + beforeCreate() { + performance.mark(SNIPPET_MARK_EDIT_APP_START); + }, created() { window.addEventListener('beforeunload', this.onBeforeUnload); }, @@ -116,48 +112,11 @@ export default { onBeforeUnload(e = {}) { const returnValue = __('Are you sure you want to lose unsaved changes?'); - if (!this.allBlobChangesRegistered) return undefined; + if (!this.hasBlobChanges || this.isUpdating) return undefined; Object.assign(e, { returnValue }); return returnValue; }, - updateBlobActions(args = {}) { - // `_constants` is the internal prop that - // should not be sent to the mutation. Hence we filter it out from - // the argsToUpdateAction that is the data-basis for the mutation. - const { _constants: blobConstants, ...argsToUpdateAction } = args; - const { previousPath, filePath, content } = argsToUpdateAction; - let actionEntry = this.blobsActions[blobConstants.id] || {}; - let tunedActions = { - action: '', - previousPath, - }; - - if (this.newSnippet) { - // new snippet, hence new blob - tunedActions = { - action: SNIPPET_BLOB_ACTION_CREATE, - previousPath: '', - }; - } else if (previousPath && filePath) { - // renaming of a blob + renaming & content update - const renamedToOriginal = filePath === blobConstants.originalPath; - tunedActions = { - action: renamedToOriginal ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE, - previousPath: !renamedToOriginal ? blobConstants.originalPath : '', - }; - } else if (content !== blobConstants.originalContent) { - // content update only - tunedActions = { - action: SNIPPET_BLOB_ACTION_UPDATE, - previousPath: '', - }; - } - - actionEntry = { ...actionEntry, ...argsToUpdateAction, ...tunedActions }; - - this.$set(this.blobsActions, blobConstants.id, actionEntry); - }, flashAPIFailure(err) { const defaultErrorMsg = this.newSnippet ? SNIPPET_CREATE_MUTATION_ERROR @@ -214,7 +173,6 @@ export default { if (errors.length) { this.flashAPIFailure(errors[0]); } else { - this.originalContent = this.content; redirectTo(baseObj.snippet.webUrl); } }) @@ -222,6 +180,9 @@ export default { this.flashAPIFailure(e); }); }, + updateActions(actions) { + this.actions = actions; + }, }, newSnippetSchema: { title: '', @@ -257,15 +218,7 @@ export default { :markdown-preview-path="markdownPreviewPath" :markdown-docs-path="markdownDocsPath" /> - <template v-if="blobs.length"> - <snippet-blob-edit - v-for="blob in blobs" - :key="blob.name" - :blob="blob" - @blob-updated="updateBlobActions" - /> - </template> - <snippet-blob-edit v-else @blob-updated="updateBlobActions" /> + <snippet-blob-actions-edit :init-blobs="blobs" @actions="updateActions" /> <snippet-visibility-edit v-model="snippet.visibilityLevel" diff --git a/app/assets/javascripts/snippets/components/show.vue b/app/assets/javascripts/snippets/components/show.vue index 0779e87e6b6..ca41fd0a2b1 100644 --- a/app/assets/javascripts/snippets/components/show.vue +++ b/app/assets/javascripts/snippets/components/show.vue @@ -1,13 +1,16 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import SnippetHeader from './snippet_header.vue'; import SnippetTitle from './snippet_title.vue'; import SnippetBlob from './snippet_blob_view.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; import { getSnippetMixin } from '../mixins/snippets'; import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; +import { SNIPPET_MARK_VIEW_APP_START } from '~/performance_constants'; + export default { components: { BlobEmbeddable, @@ -15,12 +18,19 @@ export default { SnippetTitle, GlLoadingIcon, SnippetBlob, + CloneDropdownButton, }, mixins: [getSnippetMixin], computed: { embeddable() { return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; }, + canBeCloned() { + return Boolean(this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo); + }, + }, + beforeCreate() { + performance.mark(SNIPPET_MARK_VIEW_APP_START); }, }; </script> @@ -35,10 +45,17 @@ export default { <template v-else> <snippet-header :snippet="snippet" /> <snippet-title :snippet="snippet" /> - <blob-embeddable v-if="embeddable" class="gl-mb-5" :url="snippet.webUrl" /> - <div v-for="blob in blobs" :key="blob.path"> - <snippet-blob :snippet="snippet" :blob="blob" /> + <div class="gl-display-flex gl-justify-content-end gl-mb-5"> + <blob-embeddable v-if="embeddable" class="gl-flex-fill-1" :url="snippet.webUrl" /> + <clone-dropdown-button + v-if="canBeCloned" + class="gl-ml-3" + :ssh-link="snippet.sshUrlToRepo" + :http-link="snippet.httpUrlToRepo" + data-qa-selector="clone_button" + /> </div> + <snippet-blob v-for="blob in blobs" :key="blob.path" :snippet="snippet" :blob="blob" /> </template> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue new file mode 100644 index 00000000000..55cd13a6930 --- /dev/null +++ b/app/assets/javascripts/snippets/components/snippet_blob_actions_edit.vue @@ -0,0 +1,156 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; +import { s__, sprintf } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import SnippetBlobEdit from './snippet_blob_edit.vue'; +import { SNIPPET_MAX_BLOBS } from '../constants'; +import { createBlob, decorateBlob, diffAll } from '../utils/blob'; + +export default { + components: { + SnippetBlobEdit, + GlButton, + }, + mixins: [glFeatureFlagsMixin()], + props: { + initBlobs: { + type: Array, + required: true, + }, + }, + data() { + return { + // This is a dictionary (by .id) of the original blobs and + // is used as the baseline for calculating diffs + // (e.g., what has been deleted, changed, renamed, etc.) + blobsOrig: {}, + // This is a dictionary (by .id) of the current blobs and + // is updated as the user makes changes. + blobs: {}, + // This is a list of blob ID's in order how they should be + // presented. + blobIds: [], + }; + }, + computed: { + actions() { + return diffAll(this.blobs, this.blobsOrig); + }, + count() { + return this.blobIds.length; + }, + addLabel() { + return sprintf(s__('Snippets|Add another file %{num}/%{total}'), { + num: this.count, + total: SNIPPET_MAX_BLOBS, + }); + }, + canDelete() { + return this.count > 1; + }, + canAdd() { + return this.count < SNIPPET_MAX_BLOBS; + }, + hasMultiFilesEnabled() { + return this.glFeatures.snippetMultipleFiles; + }, + filesLabel() { + return this.hasMultiFilesEnabled ? s__('Snippets|Files') : s__('Snippets|File'); + }, + firstInputId() { + const blobId = this.blobIds[0]; + + if (!blobId) { + return ''; + } + + return `${blobId}_file_path`; + }, + }, + watch: { + actions: { + immediate: true, + handler(val) { + this.$emit('actions', val); + }, + }, + }, + created() { + const blobs = this.initBlobs.map(decorateBlob); + const blobsById = blobs.reduce((acc, x) => Object.assign(acc, { [x.id]: x }), {}); + + this.blobsOrig = blobsById; + this.blobs = cloneDeep(blobsById); + this.blobIds = blobs.map(x => x.id); + + // Show 1 empty blob if none exist + if (!this.blobIds.length) { + this.addBlob(); + } + }, + methods: { + updateBlobContent(id, content) { + const origBlob = this.blobsOrig[id]; + const blob = this.blobs[id]; + + blob.content = content; + + // If we've received content, but we haven't loaded the content before + // then this is also the original content. + if (origBlob && !origBlob.isLoaded) { + blob.isLoaded = true; + origBlob.isLoaded = true; + origBlob.content = content; + } + }, + updateBlobFilePath(id, path) { + const blob = this.blobs[id]; + + blob.path = path; + }, + addBlob() { + const blob = createBlob(); + + this.$set(this.blobs, blob.id, blob); + this.blobIds.push(blob.id); + }, + deleteBlob(id) { + this.blobIds = this.blobIds.filter(x => x !== id); + this.$delete(this.blobs, id); + }, + updateBlob(id, args) { + if ('content' in args) { + this.updateBlobContent(id, args.content); + } + if ('path' in args) { + this.updateBlobFilePath(id, args.path); + } + }, + }, +}; +</script> +<template> + <div class="form-group file-editor"> + <label :for="firstInputId">{{ filesLabel }}</label> + <snippet-blob-edit + v-for="(blobId, index) in blobIds" + :key="blobId" + :class="{ 'gl-mt-3': index > 0 }" + :blob="blobs[blobId]" + :can-delete="canDelete" + :show-delete="hasMultiFilesEnabled" + @blob-updated="updateBlob(blobId, $event)" + @delete="deleteBlob(blobId)" + /> + <gl-button + v-if="hasMultiFilesEnabled" + :disabled="!canAdd" + data-testid="add_button" + class="gl-my-3" + variant="dashed" + @click="addBlob" + >{{ addLabel }}</gl-button + > + </div> +</template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue index 3c2dbfff6e1..ff03432f942 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_edit.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_edit.vue @@ -1,19 +1,13 @@ <script> +import { GlLoadingIcon } from '@gitlab/ui'; import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; import { getBaseURL, joinPaths } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; import { SNIPPET_BLOB_CONTENT_FETCH_ERROR } from '~/snippets/constants'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { sprintf } from '~/locale'; -function localId() { - return Math.floor((1 + Math.random()) * 0x10000) - .toString(16) - .substring(1); -} - export default { components: { BlobHeaderEdit, @@ -24,49 +18,35 @@ export default { props: { blob: { type: Object, + required: true, + }, + canDelete: { + type: Boolean, required: false, - default: null, - validator: ({ rawPath }) => Boolean(rawPath), + default: true, }, - }, - data() { - return { - id: localId(), - filePath: this.blob?.path || '', - previousPath: '', - originalPath: this.blob?.path || '', - content: this.blob?.content || '', - originalContent: '', - isContentLoading: this.blob, - }; - }, - watch: { - filePath(filePath, previousPath) { - this.previousPath = previousPath; - this.notifyAboutUpdates({ previousPath }); + showDelete: { + type: Boolean, + required: false, + default: false, }, - content() { - this.notifyAboutUpdates(); + }, + computed: { + inputId() { + return `${this.blob.id}_file_path`; }, }, mounted() { - if (this.blob) { + if (!this.blob.isLoaded) { this.fetchBlobContent(); } }, methods: { + onDelete() { + this.$emit('delete'); + }, notifyAboutUpdates(args = {}) { - const { filePath, previousPath } = args; - this.$emit('blob-updated', { - filePath: filePath || this.filePath, - previousPath: previousPath || this.previousPath, - content: this.content, - _constants: { - originalPath: this.originalPath, - originalContent: this.originalContent, - id: this.id, - }, - }); + this.$emit('blob-updated', args); }, fetchBlobContent() { const baseUrl = getBaseURL(); @@ -75,33 +55,39 @@ export default { axios .get(url) .then(res => { - this.originalContent = res.data; - this.content = res.data; + this.notifyAboutUpdates({ content: res.data }); }) - .catch(e => this.flashAPIFailure(e)) - .finally(() => { - this.isContentLoading = false; - }); + .catch(e => this.flashAPIFailure(e)); }, flashAPIFailure(err) { Flash(sprintf(SNIPPET_BLOB_CONTENT_FETCH_ERROR, { err })); - this.isContentLoading = false; }, }, }; </script> <template> - <div class="form-group file-editor"> - <label>{{ s__('Snippets|File') }}</label> - <div class="file-holder snippet"> - <blob-header-edit v-model="filePath" data-qa-selector="file_name_field" /> - <gl-loading-icon - v-if="isContentLoading" - :label="__('Loading snippet')" - size="lg" - class="loading-animation prepend-top-20 append-bottom-20" - /> - <blob-content-edit v-else v-model="content" :file-name="filePath" /> - </div> + <div class="file-holder snippet"> + <blob-header-edit + :id="inputId" + :value="blob.path" + data-qa-selector="file_name_field" + :can-delete="canDelete" + :show-delete="showDelete" + @input="notifyAboutUpdates({ path: $event })" + @delete="onDelete" + /> + <gl-loading-icon + v-if="!blob.isLoaded" + :label="__('Loading snippet')" + size="lg" + class="loading-animation prepend-top-20 append-bottom-20" + /> + <blob-content-edit + v-else + :value="blob.content" + :file-global-id="blob.id" + :file-name="blob.path" + @input="notifyAboutUpdates({ content: $event })" + /> </div> </template> diff --git a/app/assets/javascripts/snippets/components/snippet_blob_view.vue b/app/assets/javascripts/snippets/components/snippet_blob_view.vue index afd038eef58..b38be5bb9a4 100644 --- a/app/assets/javascripts/snippets/components/snippet_blob_view.vue +++ b/app/assets/javascripts/snippets/components/snippet_blob_view.vue @@ -1,7 +1,6 @@ <script> import BlobHeader from '~/blob/components/blob_header.vue'; import BlobContent from '~/blob/components/blob_content.vue'; -import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; import GetBlobContent from '../queries/snippet.blob.content.query.graphql'; @@ -16,7 +15,6 @@ export default { components: { BlobHeader, BlobContent, - CloneDropdownButton, }, apollo: { blobContent: { @@ -27,8 +25,9 @@ export default { rich: this.activeViewerType === RICH_BLOB_VIEWER, }; }, - update: data => - data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData, + update(data) { + return this.onContentUpdate(data); + }, result() { if (this.activeViewerType === RICH_BLOB_VIEWER) { this.blob.richViewer.renderError = null; @@ -66,9 +65,6 @@ export default { const { richViewer, simpleViewer } = this.blob; return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer; }, - canBeCloned() { - return this.snippet.sshUrlToRepo || this.snippet.httpUrlToRepo; - }, hasRenderError() { return Boolean(this.viewer.renderError); }, @@ -81,6 +77,12 @@ export default { this.$apollo.queries.blobContent.skip = false; this.$apollo.queries.blobContent.refetch(); }, + onContentUpdate(data) { + const { path: blobPath } = this.blob; + const { blobs } = data.snippets.edges[0].node; + const updatedBlobData = blobs.find(blob => blob.path === blobPath); + return updatedBlobData.richData || updatedBlobData.plainData; + }, }, BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE, @@ -93,17 +95,7 @@ export default { :active-viewer-type="viewer.type" :has-render-error="hasRenderError" @viewer-changed="switchViewer" - > - <template #actions> - <clone-dropdown-button - v-if="canBeCloned" - class="gl-mr-3" - :ssh-link="snippet.sshUrlToRepo" - :http-link="snippet.httpUrlToRepo" - data-qa-selector="clone_button" - /> - </template> - </blob-header> + /> <blob-content :loading="isContentLoading" :content="blobContent" diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 707e2b0ea30..ed087dcfaf9 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -1,5 +1,4 @@ <script> -import { __ } from '~/locale'; import { GlAvatar, GlIcon, @@ -7,11 +6,12 @@ import { GlModal, GlAlert, GlLoadingIcon, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlButton, GlTooltipDirective, } from '@gitlab/ui'; +import { __ } from '~/locale'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql'; @@ -26,8 +26,8 @@ export default { GlModal, GlAlert, GlLoadingIcon, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, TimeAgoTooltip, GlButton, }, @@ -68,6 +68,11 @@ export default { snippetHasBinary() { return Boolean(this.snippet.blobs.find(blob => blob.binary)); }, + authoredMessage() { + return this.snippet.author + ? __('Authored %{timeago} by %{author}') + : __('Authored %{timeago}'); + }, personalSnippetActions() { return [ { @@ -91,8 +96,8 @@ export default { condition: this.canCreateSnippet, text: __('New snippet'), href: this.snippet.project - ? `${this.snippet.project.webUrl}/snippets/new` - : '/snippets/new', + ? `${this.snippet.project.webUrl}/-/snippets/new` + : '/-/snippets/new', variant: 'success', category: 'secondary', cssClass: 'ml-2', @@ -130,7 +135,9 @@ export default { }, methods: { redirectToSnippets() { - window.location.pathname = `${this.snippet.project?.fullPath || 'dashboard'}/snippets`; + window.location.pathname = this.snippet.project + ? `${this.snippet.project.fullPath}/-/snippets` + : 'dashboard/snippets'; }, closeDeleteModal() { this.$refs.deleteModal.hide(); @@ -176,8 +183,8 @@ export default { </span> <gl-icon :name="visibilityLevelIcon" :size="14" /> </div> - <div class="creator"> - <gl-sprintf :message="__('Authored %{timeago} by %{author}')"> + <div class="creator" data-testid="authored-message"> + <gl-sprintf :message="authoredMessage"> <template #timeago> <time-ago-tooltip :time="snippet.createdAt" @@ -221,17 +228,17 @@ export default { </template> </div> <div class="d-block d-sm-none dropdown"> - <gl-dropdown :text="__('Options')" class="w-100" toggle-class="text-center"> - <gl-dropdown-item + <gl-deprecated-dropdown :text="__('Options')" class="w-100" toggle-class="text-center"> + <gl-deprecated-dropdown-item v-for="(action, index) in personalSnippetActions" :key="index" :disabled="action.disabled" :title="action.title" :href="action.href" @click="action.click ? action.click() : undefined" - >{{ action.text }}</gl-dropdown-item + >{{ action.text }}</gl-deprecated-dropdown-item > - </gl-dropdown> + </gl-deprecated-dropdown> </div> </div> diff --git a/app/assets/javascripts/snippets/constants.js b/app/assets/javascripts/snippets/constants.js index 99ee698408d..12b83525bf7 100644 --- a/app/assets/javascripts/snippets/constants.js +++ b/app/assets/javascripts/snippets/constants.js @@ -30,3 +30,6 @@ export const SNIPPET_BLOB_CONTENT_FETCH_ERROR = __("Can't fetch content for the export const SNIPPET_BLOB_ACTION_CREATE = 'create'; export const SNIPPET_BLOB_ACTION_UPDATE = 'update'; export const SNIPPET_BLOB_ACTION_MOVE = 'move'; +export const SNIPPET_BLOB_ACTION_DELETE = 'delete'; + +export const SNIPPET_MAX_BLOBS = 10; diff --git a/app/assets/javascripts/snippets/index.js b/app/assets/javascripts/snippets/index.js index 1c79492957d..bb5e7d6e3f0 100644 --- a/app/assets/javascripts/snippets/index.js +++ b/app/assets/javascripts/snippets/index.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import Translate from '~/vue_shared/translate'; import VueApollo from 'vue-apollo'; +import Translate from '~/vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; import SnippetsShow from './components/show.vue'; @@ -38,5 +38,3 @@ export const SnippetShowInit = () => { export const SnippetEditInit = () => { appFactory(document.getElementById('js-snippet-edit'), SnippetsEdit); }; - -export default () => {}; diff --git a/app/assets/javascripts/snippets/mixins/snippets.js b/app/assets/javascripts/snippets/mixins/snippets.js index 91331cdf339..3f5d64a768f 100644 --- a/app/assets/javascripts/snippets/mixins/snippets.js +++ b/app/assets/javascripts/snippets/mixins/snippets.js @@ -2,6 +2,7 @@ import GetSnippetQuery from '../queries/snippet.query.graphql'; const blobsDefault = []; +// eslint-disable-next-line import/prefer-default-export export const getSnippetMixin = { apollo: { snippet: { @@ -39,5 +40,3 @@ export const getSnippetMixin = { }, }, }; - -export default () => {}; diff --git a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql index 889a88dd93c..8f1f16b76c2 100644 --- a/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql +++ b/app/assets/javascripts/snippets/queries/snippet.blob.content.query.graphql @@ -3,7 +3,8 @@ query SnippetBlobContent($ids: [ID!], $rich: Boolean!) { edges { node { id - blob { + blobs { + path richData @include(if: $rich) plainData @skip(if: $rich) } diff --git a/app/assets/javascripts/snippets/utils/blob.js b/app/assets/javascripts/snippets/utils/blob.js new file mode 100644 index 00000000000..fd5ff9a3d2e --- /dev/null +++ b/app/assets/javascripts/snippets/utils/blob.js @@ -0,0 +1,66 @@ +import { uniqueId } from 'lodash'; +import { + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_UPDATE, + SNIPPET_BLOB_ACTION_MOVE, + SNIPPET_BLOB_ACTION_DELETE, +} from '../constants'; + +const createLocalId = () => uniqueId('blob_local_'); + +export const decorateBlob = blob => ({ + ...blob, + id: createLocalId(), + isLoaded: false, + content: '', +}); + +export const createBlob = () => ({ + id: createLocalId(), + content: '', + path: '', + isLoaded: true, +}); + +const diff = ({ content, path }, origBlob) => { + if (!origBlob) { + return { + action: SNIPPET_BLOB_ACTION_CREATE, + previousPath: path, + content, + filePath: path, + }; + } else if (origBlob.path !== path || origBlob.content !== content) { + return { + action: origBlob.path === path ? SNIPPET_BLOB_ACTION_UPDATE : SNIPPET_BLOB_ACTION_MOVE, + previousPath: origBlob.path, + content, + filePath: path, + }; + } + + return null; +}; + +/** + * This function returns an array of diff actions (to be sent to the BE) based on the current vs. original blobs + * + * @param {Object} blobs + * @param {Object} origBlobs + */ +export const diffAll = (blobs, origBlobs) => { + const deletedEntries = Object.values(origBlobs) + .filter(x => !blobs[x.id]) + .map(({ path, content }) => ({ + action: SNIPPET_BLOB_ACTION_DELETE, + previousPath: path, + filePath: path, + content, + })); + + const newEntries = Object.values(blobs) + .map(blob => diff(blob, origBlobs[blob.id])) + .filter(x => x); + + return [...deletedEntries, ...newEntries]; +}; diff --git a/app/assets/javascripts/star.js b/app/assets/javascripts/star.js index 97afeecd8ac..64842ae7f8d 100644 --- a/app/assets/javascripts/star.js +++ b/app/assets/javascripts/star.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; import { __, s__ } from './locale'; import { spriteIcon } from './lib/utils/common_utils'; import axios from './lib/utils/axios_utils'; diff --git a/app/assets/javascripts/static_site_editor/components/app.vue b/app/assets/javascripts/static_site_editor/components/app.vue index 98240aef810..365fc7ce6e9 100644 --- a/app/assets/javascripts/static_site_editor/components/app.vue +++ b/app/assets/javascripts/static_site_editor/components/app.vue @@ -1,3 +1,13 @@ +<script> +export default { + props: { + mergeRequestsIllustrationPath: { + type: String, + required: true, + }, + }, +}; +</script> <template> - <router-view /> + <router-view :merge-requests-illustration-path="mergeRequestsIllustrationPath" /> </template> diff --git a/app/assets/javascripts/static_site_editor/components/edit_area.vue b/app/assets/javascripts/static_site_editor/components/edit_area.vue index 84a16f327d9..53fbb2a330d 100644 --- a/app/assets/javascripts/static_site_editor/components/edit_area.vue +++ b/app/assets/javascripts/static_site_editor/components/edit_area.vue @@ -7,6 +7,8 @@ import parseSourceFile from '~/static_site_editor/services/parse_source_file'; import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/constants'; import { DEFAULT_IMAGE_UPLOAD_PATH } from '../constants'; import imageRepository from '../image_repository'; +import formatter from '../services/formatter'; +import templater from '../services/templater'; export default { components: { @@ -43,7 +45,7 @@ export default { data() { return { saveable: false, - parsedSource: parseSourceFile(this.content), + parsedSource: parseSourceFile(this.preProcess(true, this.content)), editorMode: EDITOR_TYPES.wysiwyg, isModified: false, }; @@ -58,20 +60,30 @@ export default { }, }, methods: { + preProcess(isWrap, value) { + const formattedContent = formatter(value); + const templatedContent = isWrap + ? templater.wrap(formattedContent) + : templater.unwrap(formattedContent); + return templatedContent; + }, onInputChange(newVal) { this.parsedSource.sync(newVal, this.isWysiwygMode); this.isModified = this.parsedSource.isModified(); }, onModeChange(mode) { this.editorMode = mode; - this.$refs.editor.resetInitialValue(this.editableContent); + + const preProcessedContent = this.preProcess(this.isWysiwygMode, this.editableContent); + this.$refs.editor.resetInitialValue(preProcessedContent); }, onUploadImage({ file, imageUrl }) { this.$options.imageRepository.add(file, imageUrl); }, onSubmit() { + const preProcessedContent = this.preProcess(false, this.parsedSource.content()); this.$emit('submit', { - content: this.parsedSource.content(), + content: preProcessedContent, images: this.$options.imageRepository.getAll(), }); }, diff --git a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue b/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue deleted file mode 100644 index dd907570114..00000000000 --- a/app/assets/javascripts/static_site_editor/components/saved_changes_message.vue +++ /dev/null @@ -1,79 +0,0 @@ -<script> -import { isString } from 'lodash'; - -import { GlLink, GlButton } from '@gitlab/ui'; - -const validateUrlAndLabel = value => isString(value.label) && isString(value.url); - -export default { - components: { - GlLink, - GlButton, - }, - props: { - branch: { - type: Object, - required: true, - validator: validateUrlAndLabel, - }, - commit: { - type: Object, - required: true, - validator: validateUrlAndLabel, - }, - mergeRequest: { - type: Object, - required: true, - validator: validateUrlAndLabel, - }, - returnUrl: { - type: String, - required: false, - default: '', - }, - }, -}; -</script> - -<template> - <div> - <div class="border-bottom pb-4"> - <h3>{{ s__('StaticSiteEditor|Success!') }}</h3> - <p> - {{ - s__( - 'StaticSiteEditor|Your changes have been submitted and a merge request has been created. The changes won’t be visible on the site until the merge request has been accepted.', - ) - }} - </p> - <div class="d-flex justify-content-end"> - <gl-button v-if="returnUrl" ref="returnToSiteButton" :href="returnUrl">{{ - s__('StaticSiteEditor|Return to site') - }}</gl-button> - <gl-button ref="mergeRequestButton" class="ml-2" :href="mergeRequest.url" variant="success"> - {{ s__('StaticSiteEditor|View merge request') }} - </gl-button> - </div> - </div> - - <div class="pt-2"> - <h4>{{ s__('StaticSiteEditor|Summary of changes') }}</h4> - <ul> - <li> - {{ s__('StaticSiteEditor|You created a new branch:') }} - <gl-link ref="branchLink" :href="branch.url">{{ branch.label }}</gl-link> - </li> - <li> - {{ s__('StaticSiteEditor|You created a merge request:') }} - <gl-link ref="mergeRequestLink" :href="mergeRequest.url">{{ - mergeRequest.label - }}</gl-link> - </li> - <li> - {{ s__('StaticSiteEditor|You added a commit:') }} - <gl-link ref="commitLink" :href="commit.url">{{ commit.label }}</gl-link> - </li> - </ul> - </div> - </div> -</template> diff --git a/app/assets/javascripts/static_site_editor/image_repository.js b/app/assets/javascripts/static_site_editor/image_repository.js index 541d581bda8..02285ccdba3 100644 --- a/app/assets/javascripts/static_site_editor/image_repository.js +++ b/app/assets/javascripts/static_site_editor/image_repository.js @@ -1,5 +1,5 @@ import { __ } from '~/locale'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import { getBinary } from './services/image_service'; const imageRepository = () => { diff --git a/app/assets/javascripts/static_site_editor/index.js b/app/assets/javascripts/static_site_editor/index.js index 12aa301e02f..b7e5ea4eee3 100644 --- a/app/assets/javascripts/static_site_editor/index.js +++ b/app/assets/javascripts/static_site_editor/index.js @@ -5,7 +5,14 @@ import createRouter from './router'; import createApolloProvider from './graphql'; const initStaticSiteEditor = el => { - const { isSupportedContent, path: sourcePath, baseUrl, namespace, project } = el.dataset; + const { + isSupportedContent, + path: sourcePath, + baseUrl, + namespace, + project, + mergeRequestsIllustrationPath, + } = el.dataset; const { current_username: username } = window.gon; const returnUrl = el.dataset.returnUrl || null; @@ -26,7 +33,11 @@ const initStaticSiteEditor = el => { App, }, render(createElement) { - return createElement('app'); + return createElement('app', { + props: { + mergeRequestsIllustrationPath, + }, + }); }, }); }; diff --git a/app/assets/javascripts/static_site_editor/pages/home.vue b/app/assets/javascripts/static_site_editor/pages/home.vue index 156b815e07a..eef2bd88f0e 100644 --- a/app/assets/javascripts/static_site_editor/pages/home.vue +++ b/app/assets/javascripts/static_site_editor/pages/home.vue @@ -6,7 +6,7 @@ import SubmitChangesError from '../components/submit_changes_error.vue'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; import sourceContentQuery from '../graphql/queries/source_content.query.graphql'; import submitContentChangesMutation from '../graphql/mutations/submit_content_changes.mutation.graphql'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Tracking from '~/tracking'; import { LOAD_CONTENT_ERROR, TRACKING_ACTION_INITIALIZE_EDITOR } from '../constants'; import { SUCCESS_ROUTE } from '../router/constants'; diff --git a/app/assets/javascripts/static_site_editor/pages/success.vue b/app/assets/javascripts/static_site_editor/pages/success.vue index 123683b2833..f0d597d7c9b 100644 --- a/app/assets/javascripts/static_site_editor/pages/success.vue +++ b/app/assets/javascripts/static_site_editor/pages/success.vue @@ -1,12 +1,21 @@ <script> +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { s__, __, sprintf } from '~/locale'; + import savedContentMetaQuery from '../graphql/queries/saved_content_meta.query.graphql'; import appDataQuery from '../graphql/queries/app_data.query.graphql'; -import SavedChangesMessage from '../components/saved_changes_message.vue'; import { HOME_ROUTE } from '../router/constants'; export default { components: { - SavedChangesMessage, + GlEmptyState, + GlButton, + }, + props: { + mergeRequestsIllustrationPath: { + type: String, + required: true, + }, }, apollo: { savedContentMeta: { @@ -16,20 +25,65 @@ export default { query: appDataQuery, }, }, + computed: { + updatedFileDescription() { + const { sourcePath } = this.appData; + + return sprintf(s__('Update %{sourcePath} file'), { sourcePath }); + }, + }, created() { if (!this.savedContentMeta) { this.$router.push(HOME_ROUTE); } }, + title: s__('StaticSiteEditor|Your merge request has been created'), + primaryButtonText: __('View merge request'), + returnToSiteBtnText: s__('StaticSiteEditor|Return to site'), + mergeRequestInstructionsHeading: s__( + 'StaticSiteEditor|To see your changes live you will need to do the following things:', + ), + addTitleInstruction: s__('StaticSiteEditor|1. Add a clear title to describe the change.'), + addDescriptionInstruction: s__( + 'StaticSiteEditor|2. Add a description to explain why the change is being made.', + ), + assignMergeRequestInstruction: s__( + 'StaticSiteEditor|3. Assign a person to review and accept the merge request.', + ), }; </script> <template> - <div v-if="savedContentMeta" class="container"> - <saved-changes-message - :branch="savedContentMeta.branch" - :commit="savedContentMeta.commit" - :merge-request="savedContentMeta.mergeRequest" - :return-url="appData.returnUrl" - /> + <div + v-if="savedContentMeta" + class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column" + > + <div class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"> + <div class="container gl-py-4"> + <gl-button + v-if="appData.returnUrl" + ref="returnToSiteButton" + class="gl-mr-5" + :href="appData.returnUrl" + >{{ $options.returnToSiteBtnText }}</gl-button + > + <strong> + {{ updatedFileDescription }} + </strong> + </div> + </div> + <gl-empty-state + class="gl-my-9" + :primary-button-text="$options.primaryButtonText" + :title="$options.title" + :primary-button-link="savedContentMeta.mergeRequest.url" + :svg-path="mergeRequestsIllustrationPath" + > + <template #description> + <p>{{ $options.mergeRequestInstructionsHeading }}</p> + <p>{{ $options.addTitleInstruction }}</p> + <p>{{ $options.addDescriptionInstruction }}</p> + <p>{{ $options.assignMergeRequestInstruction }}</p> + </template> + </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/static_site_editor/services/formatter.js b/app/assets/javascripts/static_site_editor/services/formatter.js new file mode 100644 index 00000000000..92d5e8a5df8 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/formatter.js @@ -0,0 +1,14 @@ +const removeOrphanedBrTags = source => { + /* Until the underlying Squire editor of Toast UI Editor resolves duplicate `<br>` tags, this + `replace` solution will clear out orphaned `<br>` tags that it generates. Additionally, + it cleans up orphaned `<br>` tags in the source markdown document that should be new lines. + https://gitlab.com/gitlab-org/gitlab/-/issues/227602#note_380765330 + */ + return source.replace(/\n^<br>$/gm, ''); +}; + +const format = source => { + return removeOrphanedBrTags(source); +}; + +export default format; diff --git a/app/assets/javascripts/static_site_editor/services/templater.js b/app/assets/javascripts/static_site_editor/services/templater.js new file mode 100644 index 00000000000..a1c1bb6b8d6 --- /dev/null +++ b/app/assets/javascripts/static_site_editor/services/templater.js @@ -0,0 +1,89 @@ +/** + * The purpose of this file is to modify Markdown source such that templated code (embedded ruby currently) can be temporarily wrapped and unwrapped in codeblocks: + * 1. `wrap()`: temporarily wrap in codeblocks (useful for a WYSIWYG editing experience) + * 2. `unwrap()`: undo the temporarily wrapped codeblocks (useful for Markdown editing experience and saving edits) + * + * Without this `templater`, the templated code is otherwise interpreted as Markdown content resulting in loss of spacing, indentation, escape characters, etc. + * + */ + +const ticks = '```'; +const marker = 'sse'; +const wrapPrefix = `${ticks} ${marker}\n`; // Space intentional due to https://github.com/nhn/tui.editor/blob/6bcec75c69028570d93d973aa7533090257eaae0/libs/to-mark/src/renderer.gfm.js#L26 +const wrapPostfix = `\n${ticks}`; +const markPrefix = `${marker}-${Date.now()}`; + +const reHelpers = { + template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`, + openTag: '<[a-zA-Z]+.*?>', + closeTag: '</.+>', +}; +const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm'); +const rePreexistingCodeBlocks = new RegExp(`(^${ticks}.*\\n(.|\\s)+?${ticks}$)`, 'gm'); +const reHtmlMarkup = new RegExp( + `^((${reHelpers.openTag}){1}(${reHelpers.template})*(${reHelpers.closeTag}){1})$`, + 'gm', +); +const reEmbeddedRubyBlock = new RegExp(`(^<%(${reHelpers.template})+%>$)`, 'gm'); +const reEmbeddedRubyInline = new RegExp(`(^.*[<|<]%(${reHelpers.template})+$)`, 'gm'); + +const patternGroups = { + ignore: [rePreexistingCodeBlocks], + // Order is intentional (general to specific) where HTML markup is marked first, then ERB blocks, then inline ERB + // Order in combo with the `mark()` algorithm is used to mitigate potential duplicate pattern matches (ERB nested in HTML for example) + allow: [reHtmlMarkup, reEmbeddedRubyBlock, reEmbeddedRubyInline], +}; + +const mark = (source, groups) => { + let text = source; + let id = 0; + const hash = {}; + + Object.entries(groups).forEach(([groupKey, group]) => { + group.forEach(pattern => { + const matches = text.match(pattern); + if (matches) { + matches.forEach(match => { + const key = `${markPrefix}-${groupKey}-${id}`; + text = text.replace(match, key); + hash[key] = match; + id += 1; + }); + } + }); + }); + + return { text, hash }; +}; + +const unmark = (text, hash) => { + let source = text; + + Object.entries(hash).forEach(([key, value]) => { + const newVal = key.includes('ignore') ? value : `${wrapPrefix}${value}${wrapPostfix}`; + source = source.replace(key, newVal); + }); + + return source; +}; + +const unwrap = source => { + let text = source; + const matches = text.match(reTemplated); + + if (matches) { + matches.forEach(match => { + const initial = match.replace(`${wrapPrefix}`, '').replace(`${wrapPostfix}`, ''); + text = text.replace(match, initial); + }); + } + + return text; +}; + +const wrap = source => { + const { text, hash } = mark(unwrap(source), patternGroups); + return unmark(text, hash); +}; + +export default { wrap, unwrap }; diff --git a/app/assets/javascripts/task_list.js b/app/assets/javascripts/task_list.js index 5172a1ef3d6..f2b05946a08 100644 --- a/app/assets/javascripts/task_list.js +++ b/app/assets/javascripts/task_list.js @@ -2,7 +2,7 @@ import $ from 'jquery'; import 'deckar01-task_list'; import { __ } from '~/locale'; import axios from './lib/utils/axios_utils'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; export default class TaskList { constructor(options = {}) { diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js index bcb44bf7acf..10a9d29e694 100644 --- a/app/assets/javascripts/toggle_buttons.js +++ b/app/assets/javascripts/toggle_buttons.js @@ -1,5 +1,5 @@ import $ from 'jquery'; -import Flash from './flash'; +import { deprecatedCreateFlash as Flash } from './flash'; import { __ } from './locale'; import { parseBoolean } from './lib/utils/common_utils'; diff --git a/app/assets/javascripts/usage_ping_consent.js b/app/assets/javascripts/usage_ping_consent.js index 1e7a5fb19c2..a69620c1762 100644 --- a/app/assets/javascripts/usage_ping_consent.js +++ b/app/assets/javascripts/usage_ping_consent.js @@ -1,6 +1,6 @@ import $ from 'jquery'; import axios from './lib/utils/axios_utils'; -import Flash, { hideFlash } from './flash'; +import { deprecatedCreateFlash as Flash, hideFlash } from './flash'; import { parseBoolean } from './lib/utils/common_utils'; import { __ } from './locale'; diff --git a/app/assets/javascripts/user_popovers.js b/app/assets/javascripts/user_popovers.js index 290de55e6f9..c8f95dac48e 100644 --- a/app/assets/javascripts/user_popovers.js +++ b/app/assets/javascripts/user_popovers.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; import UsersCache from './lib/utils/users_cache'; import UserPopover from './vue_shared/components/user_popover/user_popover.vue'; diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index f72de8c2f4d..e45b0de9083 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -4,14 +4,14 @@ import $ from 'jquery'; import { escape, template, uniqBy } from 'lodash'; -import axios from '../lib/utils/axios_utils'; -import { s__, __, sprintf } from '../locale'; -import ModalStore from '../boards/stores/modal_store'; -import { parseBoolean } from '../lib/utils/common_utils'; import { AJAX_USERS_SELECT_OPTIONS_MAP, AJAX_USERS_SELECT_PARAMS_MAP, } from 'ee_else_ce/users_select/constants'; +import axios from '../lib/utils/axios_utils'; +import { s__, __, sprintf } from '../locale'; +import ModalStore from '../boards/stores/modal_store'; +import { parseBoolean } from '../lib/utils/common_utils'; import { getAjaxUsersSelectOptions, getAjaxUsersSelectParams } from './utils'; // TODO: remove eventHub hack after code splitting refactor diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 0f9d1b8395b..7297f8f8677 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -1,6 +1,6 @@ <script> import { GlButton } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { s__ } from '~/locale'; import eventHub from '../../event_hub'; import approvalsMixin from '../../mixins/approvals'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue index 66af0c5a83e..24cd9d6428d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue @@ -1,10 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import { - OPTIONAL, - OPTIONAL_CAN_APPROVE, -} from '~/vue_merge_request_widget/components/approvals/messages'; export default { components: { @@ -25,17 +21,12 @@ export default { default: '', }, }, - computed: { - message() { - return this.canApprove ? OPTIONAL_CAN_APPROVE : OPTIONAL; - }, - }, }; </script> <template> <div class="d-flex align-items-center"> - <span class="text-muted">{{ message }}</span> + <span class="text-muted">{{ s__('mrWidget|Approval is optional') }}</span> <gl-link v-if="canApprove && helpPath" v-gl-tooltip diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js index 1d9368f71aa..0538c38307b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/messages.js @@ -7,5 +7,3 @@ export const FETCH_ERROR = s__( export const APPROVE_ERROR = s__('mrWidget|An error occurred while submitting your approval.'); export const UNAPPROVE_ERROR = s__('mrWidget|An error occurred while removing your approval.'); export const APPROVED_MESSAGE = s__('mrWidget|Merge request approved.'); -export const OPTIONAL_CAN_APPROVE = s__('mrWidget|No approval required; you can still approve'); -export const OPTIONAL = s__('mrWidget|No approval required'); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue index 573fc388cca..af0b4087d46 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_actions.vue @@ -1,7 +1,7 @@ <script> import { GlIcon } from '@gitlab/ui'; import { __, s__ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import MRWidgetService from '../../services/mr_widget_service'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue index bce25ca20ec..b12250d1d1c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -60,28 +60,24 @@ export default { :main-action-link="deploymentExternalUrl" filter-key="path" > - <template slot="mainAction" slot-scope="slotProps"> + <template #mainAction="{ className }"> <review-app-link :display="appButtonText" :link="deploymentExternalUrl" - :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" + :css-class="`deploy-link js-deploy-url inline ${className}`" /> </template> - <template slot="result" slot-scope="slotProps"> + <template #result="{ result }"> <gl-link - :href="slotProps.result.external_url" + :href="result.external_url" target="_blank" rel="noopener noreferrer nofollow" class="js-deploy-url-menu-item menu-item" > - <strong class="str-truncated-100 gl-mb-0 d-block"> - {{ slotProps.result.path }} - </strong> + <strong class="str-truncated-100 gl-mb-0 d-block">{{ result.path }}</strong> - <p class="text-secondary str-truncated-100 gl-mb-0 d-block"> - {{ slotProps.result.external_url }} - </p> + <p class="text-secondary str-truncated-100 gl-mb-0 d-block">{{ result.external_url }}</p> </gl-link> </template> </filtered-search-dropdown> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue index fd999540f4a..c368399ed6f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_expandable_section.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; /** * Renders header section with icon and expand button diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index a096eb1a1fe..7326bd0804d 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -84,9 +84,6 @@ export default { hasCommitInfo() { return this.pipeline.commit && Object.keys(this.pipeline.commit).length > 0; }, - isTriggeredByMergeRequest() { - return Boolean(this.pipeline.merge_request); - }, isMergeRequestPipeline() { return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index de01821a292..936fdc9aff5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -2,28 +2,36 @@ import { GlLink, GlSprintf, GlButton } from '@gitlab/ui'; import MrWidgetIcon from './mr_widget_icon.vue'; import Tracking from '~/tracking'; -import { s__ } from '~/locale'; +import DismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; +import { + SP_TRACK_LABEL, + SP_LINK_TRACK_EVENT, + SP_SHOW_TRACK_EVENT, + SP_LINK_TRACK_VALUE, + SP_SHOW_TRACK_VALUE, + SP_HELP_CONTENT, + SP_HELP_URL, + SP_ICON_NAME, +} from '../constants'; const trackingMixin = Tracking.mixin(); -const TRACK_LABEL = 'no_pipeline_noticed'; export default { name: 'MRWidgetSuggestPipeline', - iconName: 'status_notfound', - trackLabel: TRACK_LABEL, - linkTrackValue: 30, - linkTrackEvent: 'click_link', - showTrackValue: 10, - showTrackEvent: 'click_button', - helpContent: s__( - `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`, - ), - helpURL: 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/', + SP_ICON_NAME, + SP_TRACK_LABEL, + SP_LINK_TRACK_EVENT, + SP_SHOW_TRACK_EVENT, + SP_LINK_TRACK_VALUE, + SP_SHOW_TRACK_VALUE, + SP_HELP_CONTENT, + SP_HELP_URL, components: { GlLink, GlSprintf, GlButton, MrWidgetIcon, + DismissibleContainer, }, mixins: [trackingMixin], props: { @@ -39,11 +47,19 @@ export default { type: String, required: true, }, + userCalloutsPath: { + type: String, + required: true, + }, + userCalloutFeatureId: { + type: String, + required: true, + }, }, computed: { tracking() { return { - label: TRACK_LABEL, + label: SP_TRACK_LABEL, property: this.humanAccess, }; }, @@ -54,9 +70,14 @@ export default { }; </script> <template> - <div class="mr-widget-body mr-pipeline-suggest gl-mb-3"> - <div class="gl-display-flex gl-align-items-center"> - <mr-widget-icon :name="$options.iconName" /> + <dismissible-container + class="mr-widget-body mr-pipeline-suggest gl-mb-3" + :path="userCalloutsPath" + :feature-id="userCalloutFeatureId" + @dismiss="$emit('dismiss')" + > + <template #title> + <mr-widget-icon :name="$options.SP_ICON_NAME" /> <div> <gl-sprintf :message=" @@ -76,18 +97,18 @@ export default { class="gl-ml-1" data-testid="add-pipeline-link" :data-track-property="humanAccess" - :data-track-value="$options.linkTrackValue" - :data-track-event="$options.linkTrackEvent" - :data-track-label="$options.trackLabel" + :data-track-value="$options.SP_LINK_TRACK_VALUE" + :data-track-event="$options.SP_LINK_TRACK_EVENT" + :data-track-label="$options.SP_TRACK_LABEL" > {{ content }} </gl-link> </template> </gl-sprintf> </div> - </div> + </template> <div class="row"> - <div class="col-md-5 order-md-last col-12 gl-mt-5 mt-md-n3 svg-content svg-225"> + <div class="col-md-5 order-md-last col-12 gl-mt-5 mt-md-n1 pt-md-1 svg-content svg-225"> <img data-testid="pipeline-image" :src="pipelineSvgPath" /> </div> <div class="col-md-7 order-md-first col-12"> @@ -96,11 +117,11 @@ export default { {{ s__('mrWidget|Are you adding technical debt or code vulnerabilities?') }} </strong> <p class="gl-mt-2"> - <gl-sprintf :message="$options.helpContent"> + <gl-sprintf :message="$options.SP_HELP_CONTENT"> <template #link="{ content }"> <gl-link data-testid="help" - :href="$options.helpURL" + :href="$options.SP_HELP_URL" target="_blank" class="font-size-inherit" >{{ content }} @@ -115,14 +136,14 @@ export default { variant="info" :href="pipelinePath" :data-track-property="humanAccess" - :data-track-value="$options.showTrackValue" - :data-track-event="$options.showTrackEvent" - :data-track-label="$options.trackLabel" + :data-track-value="$options.SP_SHOW_TRACK_VALUE" + :data-track-event="$options.SP_SHOW_TRACK_EVENT" + :data-track-label="$options.SP_TRACK_LABEL" > {{ __('Show me how to add a pipeline') }} </gl-button> </div> </div> </div> - </div> + </dismissible-container> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue index a0e76b151f7..dab0540f44e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/source_branch_removal_status.vue @@ -1,22 +1,37 @@ <script> +import { GlSprintf } from '@gitlab/ui'; import tooltip from '../../vue_shared/directives/tooltip'; import { __ } from '../../locale'; export default { + i18n: { + removesBranchText: __('%{strongStart}Deletes%{strongEnd} source branch'), + tooltipTitle: __('A user with write access to the source branch selected this option'), + }, + components: { + GlSprintf, + }, directives: { tooltip, }, - created() { - this.removesBranchText = __('<strong>Deletes</strong> source branch'); - this.tooltipTitle = __('A user with write access to the source branch selected this option'); - }, }; </script> <template> <p v-once class="mr-info-list mr-links gl-mb-0"> - <span class="status-text" v-html="removesBranchText"> </span> - <i v-tooltip :title="tooltipTitle" :aria-label="tooltipTitle" class="fa fa-question-circle"> + <span class="status-text"> + <gl-sprintf :message="$options.i18n.removesBranchText"> + <template #strong="{ content }"> + <strong>{{ content }}</strong> + </template> + </gl-sprintf> + </span> + <i + v-tooltip + :title="$options.i18n.tooltipTitle" + :aria-label="$options.i18n.tooltipTitle" + class="fa fa-question-circle" + > </i> </p> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue index b6722de5277..f17e409d996 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/commit_message_dropdown.vue @@ -1,10 +1,10 @@ <script> -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; export default { components: { - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, }, props: { commits: { @@ -18,20 +18,20 @@ export default { <template> <div> - <gl-dropdown + <gl-deprecated-dropdown right text="Use an existing commit message" variant="link" class="mr-commit-dropdown" > - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="commit in commits" :key="commit.short_id" class="text-nowrap text-truncate" @click="$emit('input', commit.message)" > <span class="monospace mr-2">{{ commit.short_id }}</span> {{ commit.title }} - </gl-dropdown-item> - </gl-dropdown> + </gl-deprecated-dropdown-item> + </gl-deprecated-dropdown> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue index f02e0ac84da..12f65a4c235 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue @@ -1,6 +1,6 @@ <script> import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge'; -import Flash from '../../../flash'; +import { deprecatedCreateFlash as Flash } from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import MrWidgetAuthor from '../mr_widget_author.vue'; import eventHub from '../../event_hub'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue index 1a6e186a371..166700dbcbf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_merged.vue @@ -1,7 +1,7 @@ <script> /* eslint-disable @gitlab/vue-require-i18n-strings */ import { GlLoadingIcon } from '@gitlab/ui'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import tooltip from '~/vue_shared/directives/tooltip'; import { s__, __ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue index c3cc30a1a6f..794c994bffe 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -4,7 +4,7 @@ import { escape } from 'lodash'; import simplePoll from '../../../lib/utils/simple_poll'; import eventHub from '../../event_hub'; import statusIcon from '../mr_widget_status_icon.vue'; -import Flash from '../../../flash'; +import { deprecatedCreateFlash as Flash } from '../../../flash'; import { __, sprintf } from '~/locale'; export default { diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index cc43135f50a..930a2b68d8e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -8,14 +8,23 @@ import simplePoll from '~/lib/utils/simple_poll'; import { __, sprintf } from '~/locale'; import MergeRequest from '../../../merge_request'; import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests'; -import Flash from '../../../flash'; +import { deprecatedCreateFlash as Flash } from '../../../flash'; import statusIcon from '../mr_widget_status_icon.vue'; import eventHub from '../../event_hub'; import SquashBeforeMerge from './squash_before_merge.vue'; import CommitsHeader from './commits_header.vue'; import CommitEdit from './commit_edit.vue'; import CommitMessageDropdown from './commit_message_dropdown.vue'; -import { AUTO_MERGE_STRATEGIES } from '../../constants'; +import { AUTO_MERGE_STRATEGIES, DANGER, INFO, WARNING } from '../../constants'; + +const PIPELINE_RUNNING_STATE = 'running'; +const PIPELINE_FAILED_STATE = 'failed'; +const PIPELINE_PENDING_STATE = 'pending'; +const PIPELINE_SUCCESS_STATE = 'success'; + +const MERGE_FAILED_STATUS = 'failed'; +const MERGE_SUCCESS_STATUS = 'success'; +const MERGE_HOOK_VALIDATION_ERROR_STATUS = 'hook_validation_error'; export default { name: 'ReadyToMerge', @@ -29,6 +38,8 @@ export default { GlSprintf, GlLink, GlDeprecatedButton, + MergeTrainHelperText: () => + import('ee_component/vue_merge_request_widget/components/merge_train_helper_text.vue'), MergeImmediatelyConfirmationDialog: () => import( 'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue' @@ -60,35 +71,45 @@ export default { const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr; if ((hasCI && !ciStatus) || this.hasPipelineMustSucceedConflict) { - return 'failed'; - } else if (this.isAutoMergeAvailable) { - return 'pending'; - } else if (!pipeline) { - return 'success'; - } else if (isPipelineFailed) { - return 'failed'; + return PIPELINE_FAILED_STATE; + } + + if (this.isAutoMergeAvailable) { + return PIPELINE_PENDING_STATE; + } + + if (pipeline && isPipelineFailed) { + return PIPELINE_FAILED_STATE; } - return 'success'; + return PIPELINE_SUCCESS_STATE; }, mergeButtonVariant() { - if (this.status === 'failed') { - return 'danger'; - } else if (this.status === 'pending') { - return 'info'; + if (this.status === PIPELINE_FAILED_STATE) { + return DANGER; } - return 'success'; + + if (this.status === PIPELINE_PENDING_STATE) { + return INFO; + } + + return PIPELINE_SUCCESS_STATE; }, iconClass() { + if (this.shouldRenderMergeTrainHelperText && !this.mr.preventMerge) { + return PIPELINE_RUNNING_STATE; + } + if ( - this.status === 'failed' || + this.status === PIPELINE_FAILED_STATE || !this.commitMessage.length || !this.mr.isMergeAllowed || this.mr.preventMerge ) { - return 'warning'; + return WARNING; } - return 'success'; + + return PIPELINE_SUCCESS_STATE; }, mergeButtonText() { if (this.isMergingImmediately) { @@ -167,11 +188,13 @@ export default { .merge(options) .then(res => res.data) .then(data => { - const hasError = data.status === 'failed' || data.status === 'hook_validation_error'; + const hasError = + data.status === MERGE_FAILED_STATUS || + data.status === MERGE_HOOK_VALIDATION_ERROR_STATUS; if (AUTO_MERGE_STRATEGIES.includes(data.status)) { eventHub.$emit('MRWidgetUpdateRequested'); - } else if (data.status === 'success') { + } else if (data.status === MERGE_SUCCESS_STATUS) { this.initiateMergePolling(); } else if (hasError) { eventHub.$emit('FailedToMerge', data.merge_error); @@ -269,7 +292,7 @@ export default { <template> <div> - <div class="mr-widget-body media"> + <div class="mr-widget-body media" :class="{ 'gl-pb-3': shouldRenderMergeTrainHelperText }"> <status-icon :status="iconClass" /> <div class="media-body"> <div class="mr-widget-body-controls media space-children"> @@ -358,6 +381,7 @@ export default { <div v-if="hasPipelineMustSucceedConflict" class="gl-display-flex gl-align-items-center" + data-testid="pipeline-succeed-conflict" > <gl-sprintf :message="pipelineMustSucceedConflictText" /> <gl-link @@ -379,6 +403,13 @@ export default { </div> </div> </div> + <merge-train-helper-text + v-if="shouldRenderMergeTrainHelperText" + :pipeline-id="mr.pipeline.id" + :pipeline-link="mr.pipeline.path" + :merge-train-length="mr.mergeTrainsCount" + :merge-train-when-pipeline-succeeds-docs-path="mr.mergeTrainWhenPipelineSucceedsDocsPath" + /> <template v-if="shouldShowMergeControls"> <div v-if="mr.ffOnlyEnabled" class="mr-fast-forward-message"> {{ __('Fast-forward merge without a merge commit') }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue index efd58341a2d..3cf7dc3c4d1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/squash_before_merge.vue @@ -38,7 +38,7 @@ export default { <div class="inline"> <label v-tooltip - :class="{ 'gl-text-gray-600': isDisabled }" + :class="{ 'gl-text-gray-400': isDisabled }" data-testid="squashLabel" :data-title="tooltipTitle" > diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue index d4a5fdb4b97..78e69b9ff9b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/unresolved_discussions.vue @@ -1,10 +1,13 @@ <script> +import { GlButton } from '@gitlab/ui'; import statusIcon from '../mr_widget_status_icon.vue'; +import notesEventHub from '~/notes/event_hub'; export default { name: 'UnresolvedDiscussions', components: { statusIcon, + GlButton, }, props: { mr: { @@ -12,23 +15,39 @@ export default { required: true, }, }, + methods: { + jumpToFirstUnresolvedDiscussion() { + notesEventHub.$emit('jumpToFirstUnresolvedDiscussion'); + }, + }, }; </script> <template> - <div class="mr-widget-body media"> + <div class="mr-widget-body media gl-flex-wrap"> <status-icon :show-disabled-button="true" status="warning" /> - <div class="media-body space-children"> - <span class="bold"> - {{ s__('mrWidget|There are unresolved threads. Please resolve these threads') }} - </span> - <a + <div class="media-body"> + <span class="gl-ml-3 gl-font-weight-bold gl-display-block gl-w-100">{{ + s__('mrWidget|Before this can be merged, one or more threads must be resolved.') + }}</span> + <gl-button + data-testid="jump-to-first" + class="gl-ml-3" + size="small" + icon="comment-next" + @click="jumpToFirstUnresolvedDiscussion" + > + {{ s__('mrWidget|Jump to first unresolved thread') }} + </gl-button> + <gl-button v-if="mr.createIssueToResolveDiscussionsPath" :href="mr.createIssueToResolveDiscussionsPath" - class="btn btn-default btn-sm js-create-issue" + class="js-create-issue gl-ml-3" + size="small" + icon="issue-new" > - {{ s__('mrWidget|Create an issue to resolve them later') }} - </a> + {{ s__('mrWidget|Resolve all threads in new issue') }} + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 118caac84b9..44668170fe4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -1,8 +1,13 @@ <script> import $ from 'jquery'; -import { GlDeprecatedButton } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; -import createFlash from '~/flash'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; +import getStateQuery from '../../queries/get_state.query.graphql'; +import workInProgressQuery from '../../queries/states/work_in_progress.query.graphql'; +import removeWipMutation from '../../queries/toggle_wip.mutation.graphql'; import StatusIcon from '../mr_widget_status_icon.vue'; import tooltip from '../../../vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; @@ -11,72 +16,151 @@ export default { name: 'WorkInProgress', components: { StatusIcon, - GlDeprecatedButton, + GlButton, }, directives: { tooltip, }, + mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], + apollo: { + userPermissions: { + query: workInProgressQuery, + skip() { + return !this.glFeatures.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + update: data => data.project.mergeRequest.userPermissions, + }, + }, props: { mr: { type: Object, required: true }, service: { type: Object, required: true }, }, data() { return { + userPermissions: {}, isMakingRequest: false, }; }, computed: { - wipInfoTooltip() { - return s__( - 'mrWidget|When this merge request is ready, remove the WIP: prefix from the title to allow it to be merged', - ); + canUpdate() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + return this.userPermissions.updateMergeRequest; + } + + return Boolean(this.mr.removeWIPPath); }, }, methods: { - handleRemoveWIP() { + removeWipMutation() { this.isMakingRequest = true; - this.service - .removeWIP() - .then(res => res.data) - .then(data => { - eventHub.$emit('UpdateWidgetData', data); + + this.$apollo + .mutate({ + mutation: removeWipMutation, + variables: { + ...this.mergeRequestQueryVariables, + wip: false, + }, + update( + store, + { + data: { + mergeRequestSetWip: { + errors, + mergeRequest: { workInProgress, title }, + }, + }, + }, + ) { + if (errors?.length) { + createFlash(__('Something went wrong. Please try again.')); + + return; + } + + const data = store.readQuery({ + query: getStateQuery, + variables: this.mergeRequestQueryVariables, + }); + data.project.mergeRequest.workInProgress = workInProgress; + data.project.mergeRequest.title = title; + store.writeQuery({ + query: getStateQuery, + data, + variables: this.mergeRequestQueryVariables, + }); + }, + optimisticResponse: { + // eslint-disable-next-line @gitlab/require-i18n-strings + __typename: 'Mutation', + mergeRequestSetWip: { + __typename: 'MergeRequestSetWipPayload', + errors: [], + mergeRequest: { + __typename: 'MergeRequest', + title: this.mr.title, + workInProgress: false, + }, + }, + }, + }) + .then(({ data: { mergeRequestSetWip: { mergeRequest: { title } } } }) => { createFlash(__('The merge request can now be merged.'), 'notice'); - $('.merge-request .detail-page-description .title').text(this.mr.title); + $('.merge-request .detail-page-description .title').text(title); }) - .catch(() => { + .catch(() => createFlash(__('Something went wrong. Please try again.'))) + .finally(() => { this.isMakingRequest = false; - createFlash(__('Something went wrong. Please try again.')); }); }, + handleRemoveWIP() { + if (this.glFeatures.mergeRequestWidgetGraphql) { + this.removeWipMutation(); + } else { + this.isMakingRequest = true; + this.service + .removeWIP() + .then(res => res.data) + .then(data => { + eventHub.$emit('UpdateWidgetData', data); + createFlash(__('The merge request can now be merged.'), 'notice'); + $('.merge-request .detail-page-description .title').text(this.mr.title); + }) + .catch(() => { + this.isMakingRequest = false; + createFlash(__('Something went wrong. Please try again.')); + }); + } + }, }, }; </script> <template> <div class="mr-widget-body media"> - <status-icon :show-disabled-button="Boolean(mr.removeWIPPath)" status="warning" /> - <div class="media-body space-children"> - <span class="bold"> - {{ __('This is a Work in Progress') }} - <i - v-tooltip - class="fa fa-question-circle" - :title="wipInfoTooltip" - :aria-label="wipInfoTooltip" - > - </i> - </span> - <gl-deprecated-button - v-if="mr.removeWIPPath" - size="sm" - variant="default" + <status-icon :show-disabled-button="canUpdate" status="warning" /> + <div class="media-body"> + <div class="gl-ml-3 float-left"> + <span class="gl-font-weight-bold"> + {{ __('This merge request is still a work in progress.') }} + </span> + <span class="gl-display-block text-muted">{{ + __("Draft merge requests can't be merged.") + }}</span> + </div> + <gl-button + v-if="canUpdate" + size="small" :disabled="isMakingRequest" :loading="isMakingRequest" - class="js-remove-wip" + class="js-remove-wip gl-ml-3" @click="handleRemoveWIP" > - {{ s__('mrWidget|Resolve WIP status') }} - </gl-deprecated-button> + {{ s__('mrWidget|Mark as ready') }} + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue index f6e21dc1ec1..c7d9453a5c9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue @@ -1,6 +1,6 @@ <script> -import { n__ } from '~/locale'; import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { n__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import MrWidgetExpanableSection from '../mr_widget_expandable_section.vue'; import Poll from '~/lib/utils/poll'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue index dc16d46dd8e..b74e036d9d9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/terraform/terraform_plan.vue @@ -1,6 +1,6 @@ <script> -import { s__ } from '~/locale'; import { GlIcon, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; export default { name: 'TerraformPlan', diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 1002bb728a0..77dfbf9d385 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -1,6 +1,9 @@ +import { s__ } from '~/locale'; + export const SUCCESS = 'success'; export const WARNING = 'warning'; export const DANGER = 'danger'; +export const INFO = 'info'; export const WARNING_MESSAGE_CLASS = 'warning_message'; export const DANGER_MESSAGE_CLASS = 'danger_message'; @@ -10,3 +13,15 @@ export const MTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds'; export const MT_MERGE_STRATEGY = 'merge_train'; export const AUTO_MERGE_STRATEGIES = [MWPS_MERGE_STRATEGY, MTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY]; + +// SP - "Suggest Pipelines" +export const SP_TRACK_LABEL = 'no_pipeline_noticed'; +export const SP_LINK_TRACK_EVENT = 'click_link'; +export const SP_SHOW_TRACK_EVENT = 'click_button'; +export const SP_LINK_TRACK_VALUE = 30; +export const SP_SHOW_TRACK_VALUE = 10; +export const SP_HELP_CONTENT = s__( + `mrWidget|Use %{linkStart}CI pipelines to test your code%{linkEnd} by simply adding a GitLab CI configuration file to your project. It only takes a minute to make your code more secure and robust.`, +); +export const SP_HELP_URL = 'https://about.gitlab.com/blog/2019/07/12/guide-to-ci-cd-pipelines/'; +export const SP_ICON_NAME = 'status_notfound'; diff --git a/app/assets/javascripts/vue_merge_request_widget/index.js b/app/assets/javascripts/vue_merge_request_widget/index.js index 068829912bf..87e56dfcbdf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/index.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import MrWidgetOptions from 'ee_else_ce/vue_merge_request_widget/mr_widget_options.vue'; -import Translate from '../vue_shared/translate'; import VueApollo from 'vue-apollo'; +import Translate from '../vue_shared/translate'; import createDefaultClient from '~/lib/graphql'; Vue.use(Translate); diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js b/app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js new file mode 100644 index 00000000000..3a121908f36 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/merge_request_query_variables.js @@ -0,0 +1,10 @@ +export default { + computed: { + mergeRequestQueryVariables() { + return { + projectPath: this.mr.targetProjectFullPath, + iid: `${this.mr.iid}`, + }; + }, + }, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 319b6c333f4..dc8a6b56d58 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -32,5 +32,8 @@ export default { isMergeImmediatelyDangerous() { return false; }, + shouldRenderMergeTrainHelperText() { + return false; + }, }, }; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index cff85fe232d..36a883869f1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -7,7 +7,8 @@ import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps'; import { sprintf, s__, __ } from '~/locale'; import Project from '~/pages/projects/project'; import SmartInterval from '~/smart_interval'; -import createFlash from '../flash'; +import { deprecatedCreateFlash as createFlash } from '../flash'; +import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import Loading from './components/loading.vue'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetSuggestPipeline from './components/mr_widget_suggest_pipeline.vue'; @@ -42,6 +43,7 @@ import GroupedCodequalityReportsApp from '../reports/codequality_report/grouped_ import GroupedTestReportsApp from '../reports/components/grouped_test_reports_app.vue'; import { setFaviconOverlay } from '../lib/utils/common_utils'; import GroupedAccessibilityReportsApp from '../reports/accessibility_report/grouped_accessibility_reports_app.vue'; +import getStateQuery from './queries/get_state.query.graphql'; export default { el: '#js-vue-mr-widget', @@ -83,6 +85,27 @@ export default { GroupedAccessibilityReportsApp, MrWidgetApprovals, }, + apollo: { + state: { + query: getStateQuery, + manual: true, + pollInterval: 10 * 1000, + skip() { + return !this.mr || !window.gon?.features?.mergeRequestWidgetGraphql; + }, + variables() { + return this.mergeRequestQueryVariables; + }, + result({ + data: { + project: { mergeRequest }, + }, + }) { + this.mr.setGraphqlData(mergeRequest); + }, + }, + }, + mixins: [mergeRequestQueryVariablesMixin], props: { mrData: { type: Object, @@ -116,7 +139,12 @@ export default { return this.mr.hasCI || this.hasPipelineMustSucceedConflict; }, shouldSuggestPipelines() { - return gon.features?.suggestPipeline && !this.mr.hasCI && this.mr.mergeRequestAddCiConfigPath; + return ( + gon.features?.suggestPipeline && + !this.mr.hasCI && + this.mr.mergeRequestAddCiConfigPath && + !this.mr.isDismissedSuggestPipeline + ); }, shouldRenderCodeQuality() { return this.mr?.codeclimate?.head_path; @@ -374,6 +402,9 @@ export default { this.stopPolling(); }); }, + dismissSuggestPipelines() { + this.mr.isDismissedSuggestPipeline = true; + }, }, }; </script> @@ -382,10 +413,14 @@ export default { <mr-widget-header :mr="mr" /> <mr-widget-suggest-pipeline v-if="shouldSuggestPipelines" + data-testid="mr-suggest-pipeline" class="mr-widget-workflow" :pipeline-path="mr.mergeRequestAddCiConfigPath" :pipeline-svg-path="mr.pipelinesEmptySvgPath" :human-access="mr.humanAccess.toLowerCase()" + :user-callouts-path="mr.userCalloutsPath" + :user-callout-feature-id="mr.suggestPipelineFeatureId" + @dismiss="dismissSuggestPipelines" /> <mr-widget-pipeline-container v-if="shouldRenderPipelines" diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql new file mode 100644 index 00000000000..488397e7735 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -0,0 +1,8 @@ +query getState($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + title + workInProgress + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql new file mode 100644 index 00000000000..73e205ebf2b --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/states/work_in_progress.query.graphql @@ -0,0 +1,9 @@ +query workInProgressQuery($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + mergeRequest(iid: $iid) { + userPermissions { + updateMergeRequest + } + } + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql new file mode 100644 index 00000000000..37abe5ddf3c --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/queries/toggle_wip.mutation.graphql @@ -0,0 +1,9 @@ +mutation toggleWIPStatus($projectPath: ID!, $iid: String!, $wip: Boolean!) { + mergeRequestSetWip(input: { projectPath: $projectPath, iid: $iid, wip: $wip }) { + mergeRequest { + title + workInProgress + } + errors + } +} diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js index 3648db795f5..419793977f0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/actions.js @@ -69,6 +69,3 @@ export const receiveArtifactsSuccess = ({ commit }, response) => { }; export const receiveArtifactsError = ({ commit }) => commit(types.RECEIVE_ARTIFACTS_ERROR); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js index 8921637b93b..5215d210e1c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/artifacts_list/getters.js @@ -1,5 +1,6 @@ import { s__, n__ } from '~/locale'; +// eslint-disable-next-line import/prefer-default-export export const title = state => { if (state.isLoading) { return s__('BuildArtifacts|Loading artifacts'); @@ -11,6 +12,3 @@ export const title = state => { return n__('View exposed artifact', 'View %d exposed artifacts', state.artifacts.length); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 44e8167d6a3..3bd512c89bf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -1,21 +1,21 @@ import { stateKey } from './state_maps'; -export default function deviseState(data) { - if (data.project_archived) { +export default function deviseState() { + if (this.projectArchived) { return stateKey.archived; - } else if (data.branch_missing) { + } else if (this.branchMissing) { return stateKey.missingBranch; - } else if (!data.commits_count) { + } else if (!this.commitsCount) { return stateKey.nothingToMerge; } else if (this.mergeStatus === 'unchecked' || this.mergeStatus === 'checking') { return stateKey.checking; - } else if (data.has_conflicts) { + } else if (this.hasConflicts) { return stateKey.conflicts; } else if (this.shouldBeRebased) { return stateKey.rebase; } else if (this.onlyAllowMergeIfPipelineSucceeds && this.isPipelineFailed) { return stateKey.pipelineFailed; - } else if (data.work_in_progress) { + } else if (this.workInProgress) { return stateKey.workInProgress; } else if (this.hasMergeableDiscussionsState) { return stateKey.unresolvedDiscussions; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 8b9799d9775..8c98ba1b023 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -60,6 +60,9 @@ export default class MergeRequestStore { this.rebaseInProgress = data.rebase_in_progress; this.mergeRequestDiffsPath = data.diffs_path; this.approvalsWidgetType = data.approvals_widget_type; + this.projectArchived = data.project_archived; + this.branchMissing = data.branch_missing; + this.hasConflicts = data.has_conflicts; if (data.issues_links) { const links = data.issues_links; @@ -90,7 +93,8 @@ export default class MergeRequestStore { this.ffOnlyEnabled = data.ff_only_enabled; this.shouldBeRebased = Boolean(data.should_be_rebased); this.isRemovingSourceBranch = this.isRemovingSourceBranch || false; - this.isOpen = data.state === 'opened'; + this.mergeRequestState = data.state; + this.isOpen = this.mergeRequestState === 'opened'; this.hasMergeableDiscussionsState = data.mergeable_discussions_state === false; this.isSHAMismatch = this.sha !== data.diff_head_sha; this.latestSHA = data.diff_head_sha; @@ -133,6 +137,10 @@ export default class MergeRequestStore { this.mergeCommitPath = data.merge_commit_path; this.canPushToSourceBranch = data.can_push_to_source_branch; + if (data.work_in_progress !== undefined) { + this.workInProgress = data.work_in_progress; + } + const currentUser = data.current_user; this.cherryPickInForkPath = currentUser.cherry_pick_in_fork_path; @@ -143,19 +151,25 @@ export default class MergeRequestStore { this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false; this.canRevertInCurrentMR = currentUser.can_revert_on_current_merge_request || false; - this.setState(data); + this.setState(); + } + + setGraphqlData(data) { + this.workInProgress = data.workInProgress; + + this.setState(); } - setState(data) { + setState() { if (this.mergeOngoing) { this.state = 'merging'; return; } if (this.isOpen) { - this.state = getStateKey.call(this, data); + this.state = getStateKey.call(this); } else { - switch (data.state) { + switch (this.mergeRequestState) { case 'merged': this.state = 'merged'; break; @@ -194,6 +208,9 @@ export default class MergeRequestStore { this.pipelinesEmptySvgPath = data.pipelines_empty_svg_path; this.humanAccess = data.human_access; this.newPipelinePath = data.new_project_pipeline_path; + this.userCalloutsPath = data.user_callouts_path; + this.suggestPipelineFeatureId = data.suggest_pipeline_feature_id; + this.isDismissedSuggestPipeline = data.is_dismissed_suggest_pipeline; // codeclimate const blobPath = data.blob_path || {}; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js index 27f1a4f75d5..9e2b3097499 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/mixins.js @@ -1,3 +1,10 @@ +import { + SNIPPET_MARK_VIEW_APP_START, + SNIPPET_MARK_BLOBS_CONTENT, + SNIPPET_MEASURE_BLOBS_CONTENT, + SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, +} from '~/performance_constants'; + export default { props: { content: { @@ -9,4 +16,13 @@ export default { required: true, }, }, + mounted() { + window.requestAnimationFrame(() => { + if (!performance.getEntriesByName(SNIPPET_MARK_BLOBS_CONTENT).length) { + performance.mark(SNIPPET_MARK_BLOBS_CONTENT); + performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT); + performance.measure(SNIPPET_MEASURE_BLOBS_CONTENT_WITHIN_APP, SNIPPET_MARK_VIEW_APP_START); + } + }); + }, }; diff --git a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue index 1eb05780206..55a6267f9ff 100644 --- a/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/blob_viewers/simple_viewer.vue @@ -1,6 +1,6 @@ <script> -import ViewerMixin from './mixins'; import { GlIcon } from '@gitlab/ui'; +import ViewerMixin from './mixins'; import { HIGHLIGHT_CLASS_NAME } from './constants'; export default { diff --git a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue index ac95c88225e..6f5ea8dcbee 100644 --- a/app/assets/javascripts/vue_shared/components/clone_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/clone_dropdown.vue @@ -45,7 +45,7 @@ export default { }; </script> <template> - <gl-new-dropdown :text="$options.labels.defaultLabel" category="primary" variant="info"> + <gl-new-dropdown right :text="$options.labels.defaultLabel" category="primary" variant="info"> <div class="pb-2 mx-1"> <template v-if="sshLink"> <gl-new-dropdown-header>{{ $options.labels.ssh }}</gl-new-dropdown-header> diff --git a/app/assets/javascripts/vue_shared/components/confirm_modal.vue b/app/assets/javascripts/vue_shared/components/confirm_modal.vue index 52ff906ccec..e7f6cc1abc0 100644 --- a/app/assets/javascripts/vue_shared/components/confirm_modal.vue +++ b/app/assets/javascripts/vue_shared/components/confirm_modal.vue @@ -1,7 +1,7 @@ <script> import { GlModal } from '@gitlab/ui'; -import csrf from '~/lib/utils/csrf'; import { uniqueId } from 'lodash'; +import csrf from '~/lib/utils/csrf'; export default { components: { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue index 47231c4ad39..3bf629d4acb 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/download_viewer.vue @@ -39,7 +39,7 @@ export default { <template> <div class="file-container"> <div class="file-content"> - <p class="prepend-top-10 file-info"> + <p class="gl-mt-3 file-info"> {{ fileName }} <template v-if="fileSize > 0"> ({{ fileSizeReadable }}) diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 1344c766e0e..f9b678e33cd 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -3,9 +3,9 @@ import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { GlSkeletonLoading } from '@gitlab/ui'; +import { forEach, escape } from 'lodash'; import axios from '~/lib/utils/axios_utils'; import { __ } from '~/locale'; -import { forEach, escape } from 'lodash'; const { CancelToken } = axios; let axiosSource; diff --git a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue index ddbb474bab6..3b6b0a91e97 100644 --- a/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/vue_shared/components/date_time_picker/date_time_picker.vue @@ -1,5 +1,11 @@ <script> -import { GlIcon, GlDeprecatedButton, GlDropdown, GlDropdownItem, GlFormGroup } from '@gitlab/ui'; +import { + GlIcon, + GlButton, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, + GlFormGroup, +} from '@gitlab/ui'; import { __, sprintf } from '~/locale'; import { convertToFixedRange, isEqualTimeRanges, findTimeRange } from '~/lib/utils/datetime_range'; @@ -22,9 +28,9 @@ const events = { export default { components: { GlIcon, - GlDeprecatedButton, - GlDropdown, - GlDropdownItem, + GlButton, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlFormGroup, TooltipOnTruncate, DateTimePickerInput, @@ -206,7 +212,8 @@ export default { placement="top" class="d-inline-block" > - <gl-dropdown + <gl-deprecated-dropdown + ref="dropdown" :text="timeWindowText" v-bind="$attrs" class="date-time-picker w-100" @@ -215,7 +222,9 @@ export default { > <template #button-content> <span class="gl-flex-grow-1 text-truncate">{{ timeWindowText }}</span> - <span v-if="utc" class="text-muted gl-font-weight-bold gl-font-sm">{{ __('UTC') }}</span> + <span v-if="utc" class="gl-text-gray-500 gl-font-weight-bold gl-font-sm">{{ + __('UTC') + }}</span> <gl-icon class="gl-dropdown-caret" name="chevron-down" aria-hidden="true" /> </template> @@ -242,10 +251,17 @@ export default { /> </div> <gl-form-group> - <gl-deprecated-button @click="closeDropdown">{{ __('Cancel') }}</gl-deprecated-button> - <gl-deprecated-button variant="success" :disabled="!isValid" @click="setFixedRange()"> + <gl-button data-testid="cancelButton" @click="closeDropdown">{{ + __('Cancel') + }}</gl-button> + <gl-button + variant="success" + category="primary" + :disabled="!isValid" + @click="setFixedRange()" + > {{ __('Apply') }} - </gl-deprecated-button> + </gl-button> </gl-form-group> </gl-form-group> <gl-form-group @@ -256,7 +272,7 @@ export default { <span class="gl-pl-5-deprecated-no-really-do-not-use-me">{{ __('Quick range') }}</span> </template> - <gl-dropdown-item + <gl-deprecated-dropdown-item v-for="(option, index) in options" :key="index" data-qa-selector="quick_range_item" @@ -270,9 +286,9 @@ export default { :class="{ invisible: !isOptionActive(option) }" /> {{ option.label }} - </gl-dropdown-item> + </gl-deprecated-dropdown-item> </gl-form-group> </div> - </gl-dropdown> + </gl-deprecated-dropdown> </tooltip-on-truncate> </template> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_container.vue b/app/assets/javascripts/vue_shared/components/dismissible_container.vue new file mode 100644 index 00000000000..b4227bae09e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dismissible_container.vue @@ -0,0 +1,54 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; + +export default { + components: { + GlIcon, + }, + props: { + path: { + type: String, + required: true, + }, + featureId: { + type: String, + required: true, + }, + }, + methods: { + dismiss() { + axios + .post(this.path, { + feature_name: this.featureId, + }) + .catch(e => { + // eslint-disable-next-line @gitlab/require-i18n-strings, no-console + console.error('Failed to dismiss message.', e); + }); + + this.$emit('dismiss'); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-display-flex gl-align-items-center"> + <slot name="title"></slot> + <div class="ml-auto"> + <button + :aria-label="__('Close')" + class="btn-blank" + type="button" + data-testid="close" + @click="dismiss" + > + <gl-icon name="close" aria-hidden="true" class="gl-text-gray-500" /> + </button> + </div> + </div> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue new file mode 100644 index 00000000000..c7d7c3a1d24 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dismissible_feedback_alert.vue @@ -0,0 +1,66 @@ +<script> +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { slugifyWithUnderscore } from '~/lib/utils/text_utility'; + +export default { + components: { + GlAlert, + GlSprintf, + GlLink, + LocalStorageSync, + }, + props: { + featureName: { + type: String, + required: true, + }, + feedbackLink: { + type: String, + required: true, + }, + }, + data() { + return { + isDismissed: 'false', + }; + }, + computed: { + storageKey() { + return `${slugifyWithUnderscore(this.featureName)}_feedback_dismissed`; + }, + showAlert() { + return this.isDismissed === 'false'; + }, + }, + methods: { + dismissFeedbackAlert() { + this.isDismissed = 'true'; + }, + }, +}; +</script> + +<template> + <div v-show="showAlert"> + <local-storage-sync + :value="isDismissed" + :storage-key="storageKey" + @input="dismissFeedbackAlert" + /> + <gl-alert v-if="showAlert" class="gl-mt-5" @dismiss="dismissFeedbackAlert"> + <gl-sprintf + :message=" + __( + 'We’ve been making changes to %{featureName} and we’d love your feedback %{linkStart}in this issue%{linkEnd} to help us improve the experience.', + ) + " + > + <template #featureName>{{ featureName }}</template> + <template #link="{ content }"> + <gl-link :href="feedbackLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/expand_button.vue b/app/assets/javascripts/vue_shared/components/expand_button.vue index 1f904cd3c6c..546ee56355f 100644 --- a/app/assets/javascripts/vue_shared/components/expand_button.vue +++ b/app/assets/javascripts/vue_shared/components/expand_button.vue @@ -1,7 +1,6 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { __ } from '~/locale'; -import Icon from '~/vue_shared/components/icon.vue'; /** * Port of detail_behavior expand button. @@ -16,8 +15,7 @@ import Icon from '~/vue_shared/components/icon.vue'; export default { name: 'ExpandButton', components: { - GlDeprecatedButton, - Icon, + GlButton, }, data() { return { @@ -41,25 +39,23 @@ export default { </script> <template> <span> - <gl-deprecated-button + <gl-button v-show="isCollapsed" :aria-label="ariaLabel" type="button" class="js-text-expander-prepend text-expander btn-blank" + icon="ellipsis_h" @click="onClick" - > - <icon :size="12" name="ellipsis_h" /> - </gl-deprecated-button> + /> <span v-if="isCollapsed"> <slot name="short"></slot> </span> <span v-if="!isCollapsed"> <slot name="expanded"></slot> </span> - <gl-deprecated-button + <gl-button v-show="!isCollapsed" :aria-label="ariaLabel" type="button" class="js-text-expander-append text-expander btn-blank" + icon="ellipsis_h" @click="onClick" - > - <icon :size="12" name="ellipsis_h" /> - </gl-deprecated-button> + /> </span> </template> diff --git a/app/assets/javascripts/vue_shared/components/file_icon.vue b/app/assets/javascripts/vue_shared/components/file_icon.vue index 7484486d6b4..6190b07962d 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/file_icon.vue @@ -87,7 +87,7 @@ export default { <span> <gl-loading-icon v-if="loading" :inline="true" /> <gl-icon v-else-if="isSymlink" name="symlink" :size="size" /> - <svg v-else-if="!folder" :class="[iconSizeClass, cssClasses]"> + <svg v-else-if="!folder" :key="spriteHref" :class="[iconSizeClass, cssClasses]"> <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> <gl-icon v-else :name="folderIconName" :size="size" class="folder-icon" /> diff --git a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js index 9ecae87c1a9..b70f093e930 100644 --- a/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js +++ b/app/assets/javascripts/vue_shared/components/file_icon/file_icon_map.js @@ -586,5 +586,16 @@ const fileNameIcons = { }; export default function getIconForFile(name) { - return fileNameIcons[name] || fileExtensionIcons[name ? name.split('.').pop() : ''] || ''; + return ( + fileNameIcons[name] || + fileExtensionIcons[ + name + ? name + .split('.') + .pop() + .toLowerCase() + : '' + ] || + '' + ); } diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js index 6665a5754b3..7b3d1d0afd6 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/constants.js @@ -1,8 +1,23 @@ +import { __ } from '~/locale'; + export const ANY_AUTHOR = 'Any'; +export const NO_LABEL = 'No label'; + export const DEBOUNCE_DELAY = 200; export const SortDirection = { descending: 'descending', ascending: 'ascending', }; + +export const defaultMilestones = [ + // eslint-disable-next-line @gitlab/require-i18n-strings + { value: 'None', text: __('None') }, + // eslint-disable-next-line @gitlab/require-i18n-strings + { value: 'Any', text: __('Any') }, + // eslint-disable-next-line @gitlab/require-i18n-strings + { value: 'Upcoming', text: __('Upcoming') }, + // eslint-disable-next-line @gitlab/require-i18n-strings + { value: 'Started', text: __('Started') }, +]; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 04090213218..ee293d37b66 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -8,13 +8,14 @@ import { GlTooltipDirective, } from '@gitlab/ui'; +import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { __ } from '~/locale'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import RecentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; +import { stripQuotes } from './filtered_search_utils'; import { SortDirection } from './constants'; export default { @@ -44,7 +45,8 @@ export default { }, sortOptions: { type: Array, - required: true, + default: () => [], + required: false, }, initialFilterValue: { type: Array, @@ -63,7 +65,7 @@ export default { }, }, data() { - let selectedSortOption = this.sortOptions[0].sortDirection.descending; + let selectedSortOption = this.sortOptions[0]?.sortDirection?.descending; let selectedSortDirection = SortDirection.descending; // Extract correct sortBy value based on initialSortBy @@ -118,6 +120,11 @@ export default { ? __('Sort direction: Ascending') : __('Sort direction: Descending'); }, + filteredRecentSearches() { + return this.recentSearchesStorageKey + ? this.recentSearches.filter(item => typeof item !== 'string') + : undefined; + }, }, watch: { /** @@ -184,6 +191,41 @@ export default { this.recentSearches = resultantSearches; }); }, + /** + * When user hits Enter/Return key while typing tokens, we emit `onFilter` + * event immediately so at that time, we don't want to keep tokens dropdown + * visible on UI so this is essentially a hack which allows us to do that + * until `GlFilteredSearch` natively supports this. + * See this discussion https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36421#note_385729546 + */ + blurSearchInput() { + const searchInputEl = this.$refs.filteredSearchInput.$el.querySelector( + '.gl-filtered-search-token-segment-input', + ); + if (searchInputEl) { + searchInputEl.blur(); + } + }, + /** + * This method removes quotes enclosure from filter values which are + * done by `GlFilteredSearch` internally when filter value contains + * spaces. + */ + removeQuotesEnclosure(filters = []) { + return filters.map(filter => { + if (typeof filter === 'object') { + const valueString = filter.value.data; + return { + ...filter, + value: { + data: stripQuotes(valueString), + operator: filter.value.operator, + }, + }; + } + return filter; + }); + }, handleSortOptionClick(sortBy) { this.selectedSortOption = sortBy; this.$emit('onSort', sortBy.sortDirection[this.selectedSortDirection]); @@ -196,7 +238,7 @@ export default { this.$emit('onSort', this.selectedSortOption.sortDirection[this.selectedSortDirection]); }, handleHistoryItemSelected(filters) { - this.$emit('onFilter', filters); + this.$emit('onFilter', this.removeQuotesEnclosure(filters)); }, handleClearHistory() { const resultantSearches = this.recentSearchesStore.setRecentSearches([]); @@ -217,7 +259,8 @@ export default { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); } - this.$emit('onFilter', filters); + this.blurSearchInput(); + this.$emit('onFilter', this.removeQuotesEnclosure(filters)); }, }, }; @@ -226,10 +269,11 @@ export default { <template> <div class="vue-filtered-search-bar-container d-md-flex"> <gl-filtered-search + ref="filteredSearchInput" v-model="filterValue" :placeholder="searchInputPlaceholder" :available-tokens="tokens" - :history-items="recentSearches" + :history-items="filteredRecentSearches" class="flex-grow-1" @history-item-selected="handleHistoryItemSelected" @clear-history="handleClearHistory" @@ -238,7 +282,7 @@ export default { <template #history-item="{ historyItem }"> <template v-for="(token, index) in historyItem"> <span v-if="typeof token === 'string'" :key="index" class="gl-px-1">"{{ token }}"</span> - <span v-else :key="`${token.type}-${token.value.data}`" class="gl-px-1"> + <span v-else :key="`${index}-${token.type}-${token.value.data}`" class="gl-px-1"> <span v-if="tokenTitles[token.type]" >{{ tokenTitles[token.type] }} :{{ token.value.operator }}</span > @@ -247,7 +291,7 @@ export default { </template> </template> </gl-filtered-search> - <gl-button-group class="sort-dropdown-container d-flex"> + <gl-button-group v-if="selectedSortOption" class="sort-dropdown-container d-flex"> <gl-dropdown :text="selectedSortOption.title" :right="true" class="w-100"> <gl-dropdown-item v-for="sortBy in sortOptions" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js new file mode 100644 index 00000000000..85f7f746b49 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_utils.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export +export const stripQuotes = value => { + return value.includes(' ') ? value.slice(1, -1) : value; +}; diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue index d50649d2581..969e914ef0c 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/author_token.vue @@ -3,12 +3,12 @@ import { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, + GlDeprecatedDropdownDivider, GlLoadingIcon, } from '@gitlab/ui'; import { debounce } from 'lodash'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { __ } from '~/locale'; import { ANY_AUTHOR, DEBOUNCE_DELAY } from '../constants'; @@ -19,7 +19,7 @@ export default { GlFilteredSearchToken, GlAvatar, GlFilteredSearchSuggestion, - GlDropdownDivider, + GlDeprecatedDropdownDivider, GlLoadingIcon, }, props: { @@ -102,7 +102,7 @@ export default { <gl-filtered-search-suggestion :value="$options.anyAuthor"> {{ __('Any') }} </gl-filtered-search-suggestion> - <gl-dropdown-divider /> + <gl-deprecated-dropdown-divider /> <gl-loading-icon v-if="loading" /> <template v-else> <gl-filtered-search-suggestion diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue new file mode 100644 index 00000000000..726a1c49993 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/label_token.vue @@ -0,0 +1,126 @@ +<script> +import { + GlToken, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlNewDropdownDivider as GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { __ } from '~/locale'; + +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +import { stripQuotes } from '../filtered_search_utils'; +import { NO_LABEL, DEBOUNCE_DELAY } from '../constants'; + +export default { + noLabel: NO_LABEL, + components: { + GlToken, + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + labels: this.config.initialLabels || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeLabel() { + return this.labels.find( + label => label.title.toLowerCase() === stripQuotes(this.currentValue), + ); + }, + containerStyle() { + if (this.activeLabel) { + const { color, textColor } = convertObjectPropsToCamelCase(this.activeLabel); + + return { backgroundColor: color, color: textColor }; + } + return {}; + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.labels.length) { + this.fetchLabelBySearchTerm(this.value.data); + } + }, + }, + }, + methods: { + fetchLabelBySearchTerm(searchTerm) { + this.loading = true; + this.config + .fetchLabels(searchTerm) + .then(res => { + // We'd want to avoid doing this check but + // labels.json and /groups/:id/labels & /projects/:id/labels + // return response differently. + this.labels = Array.isArray(res) ? res : res.data; + }) + .catch(() => createFlash(__('There was a problem fetching labels.'))) + .finally(() => { + this.loading = false; + }); + }, + searchLabels: debounce(function debouncedSearch({ data }) { + this.fetchLabelBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchLabels" + > + <template #view-token="{ inputValue, cssClasses, listeners }"> + <gl-token variant="search-value" :class="cssClasses" :style="containerStyle" v-on="listeners" + >~{{ activeLabel ? activeLabel.title : inputValue }}</gl-token + > + </template> + <template #suggestions> + <gl-filtered-search-suggestion :value="$options.noLabel">{{ + __('No label') + }}</gl-filtered-search-suggestion> + <gl-dropdown-divider /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion v-for="label in labels" :key="label.id" :value="label.title"> + <div class="gl-display-flex"> + <span + :style="{ backgroundColor: label.color }" + class="gl-display-inline-block mr-2 p-2" + ></span> + <div>{{ label.title }}</div> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue new file mode 100644 index 00000000000..cf1ac4e718b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue @@ -0,0 +1,110 @@ +<script> +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlNewDropdownDivider as GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; + +import createFlash from '~/flash'; +import { __ } from '~/locale'; + +import { stripQuotes } from '../filtered_search_utils'; +import { defaultMilestones, DEBOUNCE_DELAY } from '../constants'; + +export default { + defaultMilestones, + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + milestones: this.config.initialMilestones || [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeMilestone() { + return this.milestones.find( + milestone => milestone.title.toLowerCase() === stripQuotes(this.currentValue), + ); + }, + }, + watch: { + active: { + immediate: true, + handler(newValue) { + if (!newValue && !this.milestones.length) { + this.fetchMilestoneBySearchTerm(this.value.data); + } + }, + }, + }, + methods: { + fetchMilestoneBySearchTerm(searchTerm = '') { + this.loading = true; + this.config + .fetchMilestones(searchTerm) + .then(({ data }) => { + this.milestones = data; + }) + .catch(() => createFlash(__('There was a problem fetching milestones.'))) + .finally(() => { + this.loading = false; + }); + }, + searchMilestones: debounce(function debouncedSearch({ data }) { + this.fetchMilestoneBySearchTerm(data); + }, DEBOUNCE_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchMilestones" + > + <template #view="{ inputValue }"> + <span>%{{ activeMilestone ? activeMilestone.title : inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="milestone in $options.defaultMilestones" + :key="milestone.value" + :value="milestone.value" + >{{ milestone.text }}</gl-filtered-search-suggestion + > + <gl-dropdown-divider /> + <gl-loading-icon v-if="loading" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="milestone in milestones" + :key="milestone.id" + :value="milestone.title" + > + <div>{{ milestone.title }}</div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/vue_shared/components/gl_mentions.vue b/app/assets/javascripts/vue_shared/components/gl_mentions.vue index 0ef4f1eda27..00bc46257ed 100644 --- a/app/assets/javascripts/vue_shared/components/gl_mentions.vue +++ b/app/assets/javascripts/vue_shared/components/gl_mentions.vue @@ -5,39 +5,102 @@ import axios from '~/lib/utils/axios_utils'; import { spriteIcon } from '~/lib/utils/common_utils'; import SidebarMediator from '~/sidebar/sidebar_mediator'; -/** - * Creates the HTML template for each row of the mentions dropdown. - * - * @param original - An object from the array returned from the `autocomplete_sources/members` API - * @returns {string} - An HTML template - */ -function menuItemTemplate({ original }) { - const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; - - const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} - gl-display-inline-flex! gl-align-items-center gl-justify-content-center`; - - const avatarTag = original.avatar_url - ? `<img - src="${original.avatar_url}" - alt="${original.username}'s avatar" - class="${avatarClasses}"/>` - : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`; - - const name = escape(original.name); - - const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; - - const icon = original.mentionsDisabled - ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3') - : ''; - - return `${avatarTag} - ${original.username} - <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small> - ${icon}`; +const AutoComplete = { + Issues: 'issues', + Labels: 'labels', + Members: 'members', +}; + +function doesCurrentLineStartWith(searchString, fullText, selectionStart) { + const currentLineNumber = fullText.slice(0, selectionStart).split('\n').length; + const currentLine = fullText.split('\n')[currentLineNumber - 1]; + return currentLine.startsWith(searchString); } +const autoCompleteMap = { + [AutoComplete.Issues]: { + filterValues() { + return this[AutoComplete.Issues]; + }, + menuItemTemplate({ original }) { + return `<small>${original.reference || original.iid}</small> ${escape(original.title)}`; + }, + }, + [AutoComplete.Labels]: { + filterValues() { + const fullText = this.$slots.default?.[0]?.elm?.value; + const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; + + if (doesCurrentLineStartWith('/label', fullText, selectionStart)) { + return this.labels.filter(label => !label.set); + } + + if (doesCurrentLineStartWith('/unlabel', fullText, selectionStart)) { + return this.labels.filter(label => label.set); + } + + return this.labels; + }, + menuItemTemplate({ original }) { + return ` + <span class="dropdown-label-box" style="background: ${escape(original.color)};"></span> + ${escape(original.title)}`; + }, + }, + [AutoComplete.Members]: { + filterValues() { + const fullText = this.$slots.default?.[0]?.elm?.value; + const selectionStart = this.$slots.default?.[0]?.elm?.selectionStart; + + // Need to check whether sidebar store assignees has been updated + // in the case where the assignees AJAX response comes after the user does @ autocomplete + const isAssigneesLengthSame = + this.assignees?.length === SidebarMediator.singleton?.store?.assignees?.length; + + if (!this.assignees || !isAssigneesLengthSame) { + this.assignees = + SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; + } + + if (doesCurrentLineStartWith('/assign', fullText, selectionStart)) { + return this.members.filter(member => !this.assignees.includes(member.username)); + } + + if (doesCurrentLineStartWith('/unassign', fullText, selectionStart)) { + return this.members.filter(member => this.assignees.includes(member.username)); + } + + return this.members; + }, + menuItemTemplate({ original }) { + const rectAvatarClass = original.type === 'Group' ? 'rect-avatar' : ''; + + const avatarClasses = `avatar avatar-inline center s26 ${rectAvatarClass} + gl-display-inline-flex! gl-align-items-center gl-justify-content-center`; + + const avatarTag = original.avatar_url + ? `<img + src="${original.avatar_url}" + alt="${original.username}'s avatar" + class="${avatarClasses}"/>` + : `<div class="${avatarClasses}">${original.username.charAt(0).toUpperCase()}</div>`; + + const name = escape(original.name); + + const count = original.count && !original.mentionsDisabled ? ` (${original.count})` : ''; + + const icon = original.mentionsDisabled + ? spriteIcon('notifications-off', 's16 gl-vertical-align-middle gl-ml-3') + : ''; + + return `${avatarTag} + ${original.username} + <small class="gl-text-small gl-font-weight-normal gl-reset-color">${name}${count}</small> + ${icon}`; + }, + }, +}; + export default { name: 'GlMentions', props: { @@ -47,67 +110,64 @@ export default { default: () => gl.GfmAutoComplete?.dataSources || {}, }, }, - data() { - return { - assignees: undefined, - members: undefined, - }; - }, mounted() { + const NON_WORD_OR_INTEGER = /\W|^\d+$/; + this.tribute = new Tribute({ - trigger: '@', - fillAttr: 'username', - lookup: value => value.name + value.username, - menuItemTemplate, - values: this.getMembers, + collection: [ + { + trigger: '#', + lookup: value => value.iid + value.title, + menuItemTemplate: autoCompleteMap[AutoComplete.Issues].menuItemTemplate, + selectTemplate: ({ original }) => original.reference || `#${original.iid}`, + values: this.getValues(AutoComplete.Issues), + }, + { + trigger: '@', + fillAttr: 'username', + lookup: value => value.name + value.username, + menuItemTemplate: autoCompleteMap[AutoComplete.Members].menuItemTemplate, + values: this.getValues(AutoComplete.Members), + }, + { + trigger: '~', + lookup: 'title', + menuItemTemplate: autoCompleteMap[AutoComplete.Labels].menuItemTemplate, + selectTemplate: ({ original }) => + NON_WORD_OR_INTEGER.test(original.title) + ? `~"${original.title}"` + : `~${original.title}`, + values: this.getValues(AutoComplete.Labels), + }, + ], }); - const input = this.$slots.default[0].elm; + const input = this.$slots.default?.[0]?.elm; this.tribute.attach(input); }, beforeDestroy() { - const input = this.$slots.default[0].elm; + const input = this.$slots.default?.[0]?.elm; this.tribute.detach(input); }, methods: { - /** - * Creates the list of users to show in the mentions dropdown. - * - * @param inputText - The text entered by the user in the mentions input field - * @param processValues - Callback function to set the list of users to show in the mentions dropdown - */ - getMembers(inputText, processValues) { - if (this.members) { - processValues(this.getFilteredMembers()); - } else if (this.dataSources.members) { - axios - .get(this.dataSources.members) - .then(response => { - this.members = response.data; - processValues(this.getFilteredMembers()); - }) - .catch(() => {}); - } else { - processValues([]); - } - }, - getFilteredMembers() { - const fullText = this.$slots.default[0].elm.value; - - if (!this.assignees) { - this.assignees = - SidebarMediator.singleton?.store?.assignees?.map(assignee => assignee.username) || []; - } - - if (fullText.startsWith('/assign @')) { - return this.members.filter(member => !this.assignees.includes(member.username)); - } - - if (fullText.startsWith('/unassign @')) { - return this.members.filter(member => this.assignees.includes(member.username)); - } - - return this.members; + getValues(autoCompleteType) { + return (inputText, processValues) => { + if (this[autoCompleteType]) { + const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); + processValues(filteredValues); + } else if (this.dataSources[autoCompleteType]) { + axios + .get(this.dataSources[autoCompleteType]) + .then(response => { + this[autoCompleteType] = response.data; + const filteredValues = autoCompleteMap[autoCompleteType].filterValues.call(this); + processValues(filteredValues); + }) + .catch(() => {}); + } else { + processValues([]); + } + }; }, }, render(createElement) { diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index 2665bb4aa92..2625fcc9d09 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -105,7 +105,7 @@ export default { </template> </section> - <section v-if="$slots.default" class="header-action-buttons"> + <section v-if="$slots.default" data-testid="headerButtons" class="gl-display-flex"> <slot></slot> </section> <gl-deprecated-button diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 80908cbbc9c..68eeadf0f25 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -61,7 +61,12 @@ export default { </script> <template> - <svg :class="[iconSizeClass, iconTestClass]" aria-hidden="true" v-on="$listeners"> + <svg + :key="spriteHref" + :class="[iconSizeClass, iconTestClass]" + aria-hidden="true" + v-on="$listeners" + > <use v-bind="{ 'xlink:href': spriteHref }" /> </svg> </template> diff --git a/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js new file mode 100644 index 00000000000..18bfcc268dc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable/init_issuable_header_warning.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import IssuableHeaderWarnings from './issuable_header_warnings.vue'; + +export default function issuableHeaderWarnings(store) { + return new Vue({ + el: document.getElementById('js-issuable-header-warnings'), + store, + render(createElement) { + return createElement(IssuableHeaderWarnings); + }, + }); +} diff --git a/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue new file mode 100644 index 00000000000..37995b434c4 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/issuable/issuable_header_warnings.vue @@ -0,0 +1,43 @@ +<script> +import { mapGetters } from 'vuex'; +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + }, + computed: { + ...mapGetters(['getNoteableData']), + isLocked() { + return this.getNoteableData.discussion_locked; + }, + isConfidential() { + return this.getNoteableData.confidential; + }, + warningIconsMeta() { + return [ + { + iconName: 'lock', + visible: this.isLocked, + dataTestId: 'locked', + }, + { + iconName: 'eye-slash', + visible: this.isConfidential, + dataTestId: 'confidential', + }, + ]; + }, + }, +}; +</script> + +<template> + <div class="gl-display-inline-block"> + <template v-for="meta in warningIconsMeta"> + <div v-if="meta.visible" :key="meta.iconName" class="issuable-warning-icon inline"> + <gl-icon :name="meta.iconName" :data-testid="meta.dataTestId" class="icon" /> + </div> + </template> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue index 1524b313f9f..3006ba83f98 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue @@ -86,12 +86,13 @@ export default { :img-css-classes="imgCssClasses" :img-src="avatarUrl(assignee)" :img-size="iconSize" - class="js-no-trigger" + class="js-no-trigger author-link" tooltip-placement="bottom" + data-qa-selector="assignee_link" > <span class="js-assignee-tooltip"> <span class="bold d-block">{{ __('Assignee') }}</span> {{ assignee.name }} - <span class="text-white-50">@{{ assignee.username }}</span> + <span v-if="assignee.username" class="text-white-50">@{{ assignee.username }}</span> </span> </user-avatar-link> <span @@ -100,6 +101,7 @@ export default { :title="assigneesCounterTooltip" class="avatar-counter" data-placement="bottom" + data-qa-selector="avatar_counter_content" >{{ assigneeCounterLabel }}</span > </div> diff --git a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue index caf13bc898b..1662e7923b7 100644 --- a/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue +++ b/app/assets/javascripts/vue_shared/components/issue/related_issuable_item.vue @@ -4,6 +4,7 @@ import { GlIcon, GlTooltip, GlTooltipDirective } from '@gitlab/ui'; import { sprintf } from '~/locale'; import IssueMilestone from './issue_milestone.vue'; import IssueAssignees from './issue_assignees.vue'; +import IssueDueDate from '~/boards/components/issue_due_date.vue'; import relatedIssuableMixin from '../../mixins/related_issuable_mixin'; import CiIcon from '../ci_icon.vue'; @@ -15,6 +16,8 @@ export default { CiIcon, GlIcon, GlTooltip, + IssueWeight: () => import('ee_component/boards/components/issue_card_weight.vue'), + IssueDueDate, }, directives: { GlTooltip: GlTooltipDirective, @@ -38,13 +41,6 @@ export default { }, ); }, - heightStyle() { - return { - minHeight: '32px', - width: '0px', - visibility: 'hidden', - }; - }, iconClasses() { return `${this.iconClass} ic-${this.iconName}`; }, @@ -60,7 +56,9 @@ export default { }" class="item-body d-flex align-items-center py-2 px-3" > - <div class="item-contents d-flex align-items-center flex-wrap flex-grow-1 flex-xl-nowrap"> + <div + class="item-contents gl-display-flex gl-align-items-center gl-flex-wrap gl-flex-grow-1 flex-xl-nowrap gl-min-h-7" + > <!-- Title area: Status icon (XL) and title --> <div class="item-title d-flex align-items-xl-center mb-xl-0"> <div ref="iconElementXL"> @@ -125,8 +123,21 @@ export default { /> <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue --> - <slot name="dueDate"></slot> - <slot name="weight"></slot> + <span v-if="weight > 0" class="order-md-1"> + <issue-weight + :weight="weight" + class="item-weight gl-display-flex gl-align-items-center" + tag-name="span" + /> + </span> + + <span v-if="dueDate" class="order-md-1"> + <issue-due-date + :date="dueDate" + tooltip-placement="top" + css-class="item-due-date gl-display-flex gl-align-items-center" + /> + </span> <issue-assignees v-if="hasAssignees" @@ -159,9 +170,5 @@ export default { > <icon :size="16" class="btn-item-remove-icon" name="close" /> </button> - - <!-- This element serves to set the issue card's height at a minimum of 32 px. --> - <!-- It fixes #59594: when the remove button is missing, issues have inconsistent heights. --> - <span :style="heightStyle"></span> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index f954b8eb4f4..6df0119c3db 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -4,7 +4,7 @@ import '~/behaviors/markdown/render_gfm'; import { unescape } from 'lodash'; import { __, sprintf } from '~/locale'; import { stripHtml } from '~/lib/utils/text_utility'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import GLForm from '~/gl_form'; import MarkdownHeader from './header.vue'; import MarkdownToolbar from './toolbar.vue'; @@ -167,11 +167,11 @@ export default { return new GLForm($(this.$refs['gl-form']), { emojis: this.enableAutocomplete, members: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, - issues: this.enableAutocomplete, + issues: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, mergeRequests: this.enableAutocomplete, epics: this.enableAutocomplete, milestones: this.enableAutocomplete, - labels: this.enableAutocomplete, + labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete, snippets: this.enableAutocomplete, }); }, @@ -250,7 +250,7 @@ export default { </gl-mentions> <slot v-else name="textarea"></slot> <a - class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" + class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" href="#" :aria-label="__('Leave zen mode')" > diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 049f5e71849..7e6edcfbd25 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -1,6 +1,8 @@ <script> import $ from 'jquery'; -import { GlPopover, GlDeprecatedButton, GlTooltipDirective } from '@gitlab/ui'; +import { GlPopover, GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { getSelectedFragment } from '~/lib/utils/common_utils'; +import { CopyAsGFM } from '../../../behaviors/markdown/copy_as_gfm'; import ToolbarButton from './toolbar_button.vue'; import Icon from '../icon.vue'; @@ -9,7 +11,7 @@ export default { ToolbarButton, Icon, GlPopover, - GlDeprecatedButton, + GlButton, }, directives: { GlTooltip: GlTooltipDirective, @@ -35,6 +37,11 @@ export default { default: false, }, }, + data() { + return { + tag: '> ', + }; + }, computed: { mdTable() { return [ @@ -81,6 +88,24 @@ export default { handleSuggestDismissed() { this.$emit('handleSuggestDismissed'); }, + handleQuote() { + const documentFragment = getSelectedFragment(); + + if (!documentFragment || !documentFragment.textContent) { + this.tag = '> '; + return; + } + this.tag = ''; + + const transformed = CopyAsGFM.transformGFMSelection(documentFragment); + const area = this.$el.parentNode.querySelector('textarea'); + + CopyAsGFM.nodeToGFM(transformed) + .then(gfm => { + CopyAsGFM.insertPastedText(area, documentFragment.textContent, CopyAsGFM.quoted(gfm)); + }) + .catch(() => {}); + }, }, }; </script> @@ -108,9 +133,10 @@ export default { <toolbar-button tag="*" :button-title="__('Add italic text')" icon="italic" /> <toolbar-button :prepend="true" - tag="> " + :tag="tag" :button-title="__('Insert a quote')" icon="quote" + @click="handleQuote" /> </div> <div class="d-inline-block ml-md-2 ml-0"> @@ -141,9 +167,14 @@ export default { ) }} </p> - <gl-deprecated-button variant="primary" size="sm" @click="handleSuggestDismissed"> + <gl-button + variant="info" + category="primary" + size="sm" + @click="handleSuggestDismissed" + > {{ __('Got it') }} - </gl-deprecated-button> + </gl-button> </gl-popover> </template> <toolbar-button tag="`" tag-block="```" :button-title="__('Insert code')" icon="code" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 9527c5114f2..1216484b35f 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -2,7 +2,7 @@ import Vue from 'vue'; import { __ } from '~/locale'; import SuggestionDiff from './suggestion_diff.vue'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; export default { props: { diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js index dd1da847001..c08659919fa 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/constants.js @@ -1,13 +1,11 @@ import { __ } from '~/locale'; -import { generateToolbarItem } from './services/editor_service'; -import buildCustomHTMLRenderer from './services/build_custom_renderer'; export const CUSTOM_EVENTS = { openAddImageModal: 'gl_openAddImageModal', }; /* eslint-disable @gitlab/require-i18n-strings */ -const TOOLBAR_ITEM_CONFIGS = [ +export const TOOLBAR_ITEM_CONFIGS = [ { icon: 'heading', event: 'openHeadingSelect', classes: 'tui-heading', tooltip: __('Headings') }, { icon: 'bold', command: 'Bold', tooltip: __('Add bold text') }, { icon: 'italic', command: 'Italic', tooltip: __('Add italic text') }, @@ -30,11 +28,6 @@ const TOOLBAR_ITEM_CONFIGS = [ { icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') }, ]; -export const EDITOR_OPTIONS = { - toolbarItems: TOOLBAR_ITEM_CONFIGS.map(config => generateToolbarItem(config)), - customHTMLRenderer: buildCustomHTMLRenderer(), -}; - export const EDITOR_TYPES = { markdown: 'markdown', wysiwyg: 'wysiwyg', diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue index 0a444b2295d..429a4e04110 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue @@ -1,6 +1,6 @@ <script> -import { isSafeURL } from '~/lib/utils/url_utility'; import { GlModal, GlFormGroup, GlFormInput, GlTabs, GlTab } from '@gitlab/ui'; +import { isSafeURL } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { IMAGE_TABS } from '../../constants'; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue index 739f8b502c9..9baa7f286d7 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue @@ -1,6 +1,6 @@ <script> -import { __ } from '~/locale'; import { GlFormGroup } from '@gitlab/ui'; +import { __ } from '~/locale'; import { MAX_FILE_SIZE } from '../../constants'; export default { diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue index baeb98bec75..d96fe46522e 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/rich_content_editor.vue @@ -3,16 +3,11 @@ import 'codemirror/lib/codemirror.css'; import '@toast-ui/editor/dist/toastui-editor.css'; import AddImageModal from './modals/add_image/add_image_modal.vue'; -import { - EDITOR_OPTIONS, - EDITOR_TYPES, - EDITOR_HEIGHT, - EDITOR_PREVIEW_STYLE, - CUSTOM_EVENTS, -} from './constants'; +import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants'; import { registerHTMLToMarkdownRenderer, + getEditorOptions, addCustomEventListener, removeCustomEventListener, addImage, @@ -35,7 +30,7 @@ export default { options: { type: Object, required: false, - default: () => EDITOR_OPTIONS, + default: () => null, }, initialEditType: { type: String, @@ -65,13 +60,13 @@ export default { }; }, computed: { - editorOptions() { - return { ...EDITOR_OPTIONS, ...this.options }; - }, editorInstance() { return this.$refs.editor; }, }, + created() { + this.editorOptions = getEditorOptions(this.options); + }, beforeDestroy() { this.removeListeners(); }, diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js index 70d29b5b3df..a9c5d442f62 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_custom_renderer.js @@ -1,16 +1,18 @@ +import { union, mapValues } from 'lodash'; import renderBlockHtml from './renderers/render_html_block'; import renderKramdownList from './renderers/render_kramdown_list'; import renderKramdownText from './renderers/render_kramdown_text'; import renderIdentifierInstanceText from './renderers/render_identifier_instance_text'; import renderIdentifierParagraph from './renderers/render_identifier_paragraph'; -import renderEmbeddedRubyText from './renderers/render_embedded_ruby_text'; import renderFontAwesomeHtmlInline from './renderers/render_font_awesome_html_inline'; +import renderSoftbreak from './renderers/render_softbreak'; const htmlInlineRenderers = [renderFontAwesomeHtmlInline]; const htmlBlockRenderers = [renderBlockHtml]; const listRenderers = [renderKramdownList]; const paragraphRenderers = [renderIdentifierParagraph]; -const textRenderers = [renderKramdownText, renderEmbeddedRubyText, renderIdentifierInstanceText]; +const textRenderers = [renderKramdownText, renderIdentifierInstanceText]; +const softbreakRenderers = [renderSoftbreak]; const executeRenderer = (renderers, node, context) => { const availableRenderer = renderers.find(renderer => renderer.canRender(node, context)); @@ -18,51 +20,20 @@ const executeRenderer = (renderers, node, context) => { return availableRenderer ? availableRenderer.render(node, context) : context.origin(); }; -const buildCustomRendererFunctions = (customRenderers, defaults) => { - const customTypes = Object.keys(customRenderers).filter(type => !defaults[type]); - const customEntries = customTypes.map(type => { - const fn = (node, context) => executeRenderer(customRenderers[type], node, context); - return [type, fn]; - }); - - return Object.fromEntries(customEntries); -}; - -const buildCustomHTMLRenderer = ( - customRenderers = { htmlBlock: [], htmlInline: [], list: [], paragraph: [], text: [] }, -) => { - const defaults = { - htmlBlock(node, context) { - const allHtmlBlockRenderers = [...customRenderers.htmlBlock, ...htmlBlockRenderers]; - - return executeRenderer(allHtmlBlockRenderers, node, context); - }, - htmlInline(node, context) { - const allHtmlInlineRenderers = [...customRenderers.htmlInline, ...htmlInlineRenderers]; - - return executeRenderer(allHtmlInlineRenderers, node, context); - }, - list(node, context) { - const allListRenderers = [...customRenderers.list, ...listRenderers]; - - return executeRenderer(allListRenderers, node, context); - }, - paragraph(node, context) { - const allParagraphRenderers = [...customRenderers.paragraph, ...paragraphRenderers]; - - return executeRenderer(allParagraphRenderers, node, context); - }, - text(node, context) { - const allTextRenderers = [...customRenderers.text, ...textRenderers]; - - return executeRenderer(allTextRenderers, node, context); - }, +const buildCustomHTMLRenderer = customRenderers => { + const renderersByType = { + ...customRenderers, + htmlBlock: union(htmlBlockRenderers, customRenderers?.htmlBlock), + htmlInline: union(htmlInlineRenderers, customRenderers?.htmlInline), + list: union(listRenderers, customRenderers?.list), + paragraph: union(paragraphRenderers, customRenderers?.paragraph), + text: union(textRenderers, customRenderers?.text), + softbreak: union(softbreakRenderers, customRenderers?.softbreak), }; - return { - ...buildCustomRendererFunctions(customRenderers, defaults), - ...defaults, - }; + return mapValues(renderersByType, renderers => { + return (node, context) => executeRenderer(renderers, node, context); + }); }; export default buildCustomHTMLRenderer; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js index ed04765c871..868ede9426e 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer.js @@ -1,7 +1,12 @@ +/* eslint-disable @gitlab/require-i18n-strings */ import { defaults, repeat } from 'lodash'; const DEFAULTS = { subListIndentSpaces: 4, + unorderedListBulletChar: '-', + incrementListMarker: false, + strong: '*', + emphasis: '_', }; const countIndentSpaces = text => { @@ -11,9 +16,18 @@ const countIndentSpaces = text => { }; const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => { - const { subListIndentSpaces } = defaults(formattingPreferences, DEFAULTS); - // eslint-disable-next-line @gitlab/require-i18n-strings + const { + subListIndentSpaces, + unorderedListBulletChar, + incrementListMarker, + strong, + emphasis, + } = defaults(formattingPreferences, DEFAULTS); const sublistNode = 'LI OL, LI UL'; + const unorderedListItemNode = 'UL LI'; + const orderedListItemNode = 'OL LI'; + const emphasisNode = 'EM, I'; + const strongNode = 'STRONG, B'; return { TEXT_NODE(node) { @@ -47,6 +61,27 @@ const buildHTMLToMarkdownRender = (baseRenderer, formattingPreferences = {}) => return reindentedList; }, + [unorderedListItemNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + + return baseResult.replace(/^(\s*)([*|-])/, `$1${unorderedListBulletChar}`); + }, + [orderedListItemNode](node, subContent) { + const baseResult = baseRenderer.convert(node, subContent); + + return incrementListMarker ? baseResult : baseResult.replace(/^(\s*)\d+?\./, '$11.'); + }, + [emphasisNode](node, subContent) { + const result = baseRenderer.convert(node, subContent); + + return result.replace(/(^[*_]{1}|[*_]{1}$)/g, emphasis); + }, + [strongNode](node, subContent) { + const result = baseRenderer.convert(node, subContent); + const strongSyntax = repeat(strong, 2); + + return result.replace(/^[*_]{2}/, strongSyntax).replace(/[*_]{2}$/, strongSyntax); + }, }; }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js index 6436dcaae64..51ba033dff0 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/editor_service.js @@ -1,6 +1,9 @@ import Vue from 'vue'; +import { defaults } from 'lodash'; import ToolbarItem from '../toolbar_item.vue'; import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer'; +import buildCustomHTMLRenderer from './build_custom_renderer'; +import { TOOLBAR_ITEM_CONFIGS } from '../constants'; const buildWrapper = propsData => { const instance = new Vue({ @@ -54,3 +57,10 @@ export const registerHTMLToMarkdownRenderer = editorApi => { renderer: renderer.constructor.factory(renderer, buildHtmlToMarkdownRenderer(renderer)), }); }; + +export const getEditorOptions = externalOptions => { + return defaults({ + customHTMLRenderer: buildCustomHTMLRenderer(externalOptions?.customRenderers), + toolbarItems: TOOLBAR_ITEM_CONFIGS.map(toolbarItem => generateToolbarItem(toolbarItem)), + }); +}; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js index d96cadafdbb..1dcecd5fb8c 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token.js @@ -34,7 +34,7 @@ export const buildUneditableCloseTokens = (token, tagType = TAG_TYPES.block) => export const buildTextToken = content => buildToken('text', null, { content }); -export const buildUneditableTokens = token => { +export const buildUneditableBlockTokens = token => { return [...buildUneditableOpenTokens(token), buildUneditableCloseToken()]; }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js index 494057fc75b..0e122f598e5 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text.js @@ -1,4 +1,4 @@ -import { buildUneditableTokens } from './build_uneditable_token'; +import { renderUneditableLeaf as render } from './render_utils'; const embeddedRubyRegex = /(^<%.+%>$)/; @@ -6,8 +6,4 @@ const canRender = ({ literal }) => { return embeddedRubyRegex.test(literal); }; -const render = (_, { origin }) => { - return buildUneditableTokens(origin()); -}; - export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js index f5b4502ea3c..4ec45ecd3a7 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph.js @@ -1,4 +1,4 @@ -import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; +import { renderUneditableBranch as render } from './render_utils'; const identifierRegex = /(^\[.+\]: .+)/; @@ -10,7 +10,4 @@ const canRender = (node, context) => { return isIdentifier(context.getChildrenText(node)); }; -const render = (_, { entering, origin }) => - entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); - export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js index 491a26c81d0..949ca0e5c2a 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list.js @@ -1,4 +1,4 @@ -import { buildUneditableOpenTokens, buildUneditableCloseToken } from './build_uneditable_token'; +import { renderUneditableBranch as render } from './render_utils'; const isKramdownTOC = ({ type, literal }) => type === 'text' && literal === 'TOC'; @@ -21,7 +21,4 @@ const canRender = node => { return false; }; -const render = (_, { entering, origin }) => - entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); - export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js index 01384699e4f..0551894918c 100644 --- a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text.js @@ -1,4 +1,4 @@ -import { buildUneditableTokens } from './build_uneditable_token'; +import { renderUneditableLeaf as render } from './render_utils'; const kramdownRegex = /(^{:.+}$)/; @@ -6,8 +6,4 @@ const canRender = ({ literal }) => { return kramdownRegex.test(literal); }; -const render = (_, { origin }) => { - return buildUneditableTokens(origin()); -}; - export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js new file mode 100644 index 00000000000..389ade5f27a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_softbreak.js @@ -0,0 +1,7 @@ +const canRender = node => ['emph', 'strong'].includes(node.parent?.type); +const render = () => ({ + type: 'text', + content: ' ', +}); + +export default { canRender, render }; diff --git a/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js new file mode 100644 index 00000000000..cec6491557b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/rich_content_editor/services/renderers/render_utils.js @@ -0,0 +1,10 @@ +import { + buildUneditableBlockTokens, + buildUneditableOpenTokens, + buildUneditableCloseToken, +} from './build_uneditable_token'; + +export const renderUneditableLeaf = (_, { origin }) => buildUneditableBlockTokens(origin()); + +export const renderUneditableBranch = (_, { entering, origin }) => + entering ? buildUneditableOpenTokens(origin()) : buildUneditableCloseToken(); diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 1be5284fa9c..9b28ce0d881 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -1,6 +1,6 @@ <script> -import { __, s__, sprintf } from '~/locale'; import { GlIcon } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; export default { components: { @@ -78,7 +78,7 @@ export default { <span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span> <gl-icon name="chevron-down" - class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-700" + class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500" :size="16" /> </button> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue index f0a846c4924..6222dfc5853 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue @@ -18,11 +18,11 @@ export default { /> <gl-icon name="search" - class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-500 gl-pointer-events-none" + class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-300 gl-pointer-events-none" /> <gl-icon name="close" - class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-700" + class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-500" /> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue index 69fb2bb4524..91cf5d6bef5 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue @@ -15,7 +15,7 @@ export default { </script> <template> - <div class="title hide-collapsed append-bottom-10"> + <div class="title hide-collapsed gl-mb-3"> {{ __('Labels') }} <template v-if="canEdit"> <gl-loading-icon inline class="align-text-top block-loading" /> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue index cf77aa37d14..c65266fce5a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue @@ -19,6 +19,9 @@ export default { handleButtonClick(e) { if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) { this.toggleDropdownContents(); + } + + if (this.isDropdownVariantStandalone) { e.stopPropagation(); } }, @@ -31,9 +34,9 @@ export default { class="labels-select-dropdown-button js-dropdown-button w-100 text-left" @click="handleButtonClick" > - <span class="dropdown-toggle-text flex-fill"> + <span class="dropdown-toggle-text gl-pointer-events-none flex-fill"> {{ dropdownButtonText }} </span> - <gl-icon name="chevron-down" class="pull-right" /> + <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" /> </gl-button> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue index ef8218b5135..6839354fb3a 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue @@ -9,6 +9,13 @@ export default { DropdownContentsLabelsView, DropdownContentsCreateView, }, + props: { + renderOnTop: { + type: Boolean, + required: false, + default: false, + }, + }, computed: { ...mapState(['showDropdownContentsCreateView']), dropdownContentsView() { @@ -17,6 +24,13 @@ export default { } return 'dropdown-contents-labels-view'; }, + directionStyle() { + if (this.renderOnTop) { + return { bottom: '100%' }; + } + + return {}; + }, }, }; </script> @@ -24,6 +38,7 @@ export default { <template> <div class="labels-select-dropdown-contents w-100 mt-1 mb-3 py-2 rounded-top rounded-bottom position-absolute" + :style="directionStyle" > <component :is="dropdownContentsView" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue index 94671f8a109..55e2fb68275 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue @@ -105,13 +105,13 @@ export default { :disabled="disableCreate" category="primary" variant="success" - class="pull-left d-flex align-items-center" + class="float-left d-flex align-items-center" @click="handleCreateClick" > <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" /> {{ __('Create') }} </gl-button> - <gl-button class="pull-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView"> + <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView"> {{ __('Cancel') }} </gl-button> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue index ef506d00d9a..0b763aa4b72 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue @@ -45,6 +45,16 @@ export default { } return this.labels; }, + showListContainer() { + if (this.isDropdownVariantSidebar) { + return !this.labelsFetchInProgress; + } + + return true; + }, + showNoMatchingResultsMessage() { + return !this.labelsFetchInProgress && !this.visibleLabels.length; + }, }, watch: { searchKey(value) { @@ -132,6 +142,7 @@ export default { <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" + data-testid="dropdown-title" > <span class="flex-grow-1">{{ labelsListTitle }}</span> <gl-button @@ -146,7 +157,12 @@ export default { <div class="dropdown-input" @click.stop="() => {}"> <gl-search-box-by-type v-model="searchKey" :autofocus="true" /> </div> - <div v-show="!labelsFetchInProgress" ref="labelsListContainer" class="dropdown-content"> + <div + v-show="showListContainer" + ref="labelsListContainer" + class="dropdown-content" + data-testid="dropdown-content" + > <smart-virtual-list :length="visibleLabels.length" :remain="$options.LIST_BUFFER_SIZE" @@ -163,12 +179,16 @@ export default { @clickLabel="handleLabelClick(label)" /> </li> - <li v-show="!visibleLabels.length" class="p-2 text-center"> + <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center"> {{ __('No matching results') }} </li> </smart-virtual-list> </div> - <div v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" class="dropdown-footer"> + <div + v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded" + class="dropdown-footer" + data-testid="dropdown-footer" + > <ul class="list-unstyled"> <li v-if="allowLabelCreate"> <gl-link diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue index 081c892e09f..2d6a4a9758c 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue @@ -1,10 +1,10 @@ <script> import { mapState, mapActions } from 'vuex'; -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; export default { components: { - GlDeprecatedButton, + GlButton, GlLoadingIcon, }, props: { @@ -23,16 +23,16 @@ export default { </script> <template> - <div class="title hide-collapsed append-bottom-10"> + <div class="title hide-collapsed gl-mb-3"> {{ __('Labels') }} <template v-if="allowLabelEdit"> <gl-loading-icon v-show="labelsSelectInProgress" inline /> - <gl-deprecated-button + <gl-button variant="link" - class="pull-right js-sidebar-dropdown-toggle" + class="float-right js-sidebar-dropdown-toggle" data-qa-selector="labels_edit_button" @click="toggleDropdownContents" - >{{ __('Edit') }}</gl-deprecated-button + >{{ __('Edit') }}</gl-button > </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue index 258a87e62b9..248e9929833 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue @@ -2,6 +2,7 @@ import $ from 'jquery'; import Vue from 'vue'; import Vuex, { mapState, mapActions, mapGetters } from 'vuex'; +import { isInViewport } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; @@ -100,6 +101,11 @@ export default { default: __('Manage group labels'), }, }, + data() { + return { + contentIsOnViewport: true, + }; + }, computed: { ...mapState(['showDropdownButton', 'showDropdownContents']), ...mapGetters([ @@ -117,6 +123,9 @@ export default { selectedLabels, }); }, + showDropdownContents(showDropdownContents) { + this.setContentIsOnViewport(showDropdownContents); + }, }, mounted() { this.setInitialState({ @@ -203,6 +212,20 @@ export default { handleCollapsedValueClick() { this.$emit('toggleCollapse'); }, + setContentIsOnViewport(showDropdownContents) { + if (!this.isDropdownVariantEmbedded || !showDropdownContents) { + this.contentIsOnViewport = true; + + return; + } + + this.$nextTick(() => { + if (this.$refs.dropdownContents) { + const offset = { top: 100 }; + this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el, offset); + } + }); + }, }, }; </script> @@ -239,6 +262,7 @@ export default { <dropdown-contents v-if="dropdownButtonVisible && showDropdownContents" ref="dropdownContents" + :render-on-top="!contentIsOnViewport" /> </template> </div> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js index e6053628eca..e624bd1eaee 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/actions.js @@ -1,4 +1,4 @@ -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { __ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; import * as types from './mutation_types'; @@ -56,6 +56,3 @@ export const createLabel = ({ state, dispatch }, label) => { export const updateSelectedLabels = ({ commit }, labels) => commit(types.UPDATE_SELECTED_LABELS, { labels }); - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js index e035a866048..5a30e29cad3 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/store/getters.js @@ -50,6 +50,3 @@ export const isDropdownVariantStandalone = state => state.variant === DropdownVa * @param {object} state */ export const isDropdownVariantEmbedded = state => state.variant === DropdownVariant.Embedded; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/vue_shared/components/split_button.vue b/app/assets/javascripts/vue_shared/components/split_button.vue index b11ec8b8838..e9b99c6ea78 100644 --- a/app/assets/javascripts/vue_shared/components/split_button.vue +++ b/app/assets/javascripts/vue_shared/components/split_button.vue @@ -1,15 +1,19 @@ <script> import { isString } from 'lodash'; -import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui'; +import { + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownItem, +} from '@gitlab/ui'; const isValidItem = item => isString(item.eventName) && isString(item.title) && isString(item.description); export default { components: { - GlDropdown, - GlDropdownDivider, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownDivider, + GlDeprecatedDropdownItem, }, props: { @@ -57,7 +61,7 @@ export default { </script> <template> - <gl-dropdown + <gl-deprecated-dropdown :menu-class="`dropdown-menu-selectable ${menuClass}`" split :text="dropdownToggleText" @@ -66,7 +70,7 @@ export default { @click="triggerEvent" > <template v-for="(item, itemIndex) in actionItems"> - <gl-dropdown-item + <gl-deprecated-dropdown-item :key="item.eventName" :active="selectedItem === item" active-class="is-active" @@ -74,12 +78,12 @@ export default { > <strong>{{ item.title }}</strong> <div>{{ item.description }}</div> - </gl-dropdown-item> + </gl-deprecated-dropdown-item> - <gl-dropdown-divider + <gl-deprecated-dropdown-divider v-if="itemIndex < actionItems.length - 1" :key="`${item.eventName}-divider`" /> </template> - </gl-dropdown> + </gl-deprecated-dropdown> </template> diff --git a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue index b1a4f3dccaf..4447a87777a 100644 --- a/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue +++ b/app/assets/javascripts/vue_shared/components/time_ago_tooltip.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; + import timeagoMixin from '../mixins/timeago'; import '../../lib/utils/datetime_utility'; @@ -28,6 +29,11 @@ export default { default: '', }, }, + computed: { + timeAgo() { + return this.timeFormatted(this.time); + }, + }, }; </script> <template> @@ -35,7 +41,7 @@ export default { v-gl-tooltip.viewport="{ placement: tooltipPlacement }" :class="cssClass" :title="tooltipTitle(time)" - v-text="timeFormatted(time)" + :datetime="time" + ><slot :timeAgo="timeAgo">{{ timeAgo }}</slot></time > - </time> </template> diff --git a/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue new file mode 100644 index 00000000000..148bd501a8e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/timezone_dropdown.vue @@ -0,0 +1,102 @@ +<script> +import { GlNewDropdown, GlDeprecatedDropdownItem, GlSearchBoxByType, GlIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; + +export default { + name: 'TimezoneDropdown', + components: { + GlNewDropdown, + GlDeprecatedDropdownItem, + GlSearchBoxByType, + GlIcon, + }, + directives: { + autofocusonshow, + }, + props: { + value: { + type: String, + required: true, + default: '', + }, + timezoneData: { + type: Array, + required: true, + default: () => [], + }, + }, + data() { + return { + searchTerm: '', + }; + }, + tranlations: { + noResultsText: __('No matching results'), + }, + computed: { + timezones() { + return this.timezoneData.map(timezone => ({ + formattedTimezone: this.formatTimezone(timezone), + identifier: timezone.identifier, + })); + }, + filteredResults() { + const lowerCasedSearchTerm = this.searchTerm.toLowerCase(); + return this.timezones.filter(timezone => + timezone.formattedTimezone.toLowerCase().includes(lowerCasedSearchTerm), + ); + }, + selectedTimezoneLabel() { + return this.value || __('Select timezone'); + }, + }, + methods: { + selectTimezone(selectedTimezone) { + this.$emit('input', selectedTimezone); + this.searchTerm = ''; + }, + isSelected(timezone) { + return this.value === timezone.formattedTimezone; + }, + formatUtcOffset(offset) { + const parsed = parseInt(offset, 10); + if (Number.isNaN(parsed) || parsed === 0) { + return `0`; + } + const prefix = offset > 0 ? '+' : '-'; + return `${prefix}${Math.abs(offset / 3600)}`; + }, + formatTimezone(item) { + return `[UTC ${this.formatUtcOffset(item.offset)}] ${item.name}`; + }, + }, +}; +</script> +<template> + <gl-new-dropdown :text="value" block lazy menu-class="gl-w-full!"> + <template #button-content> + <span class="gl-flex-grow-1" :class="{ 'gl-text-gray-300': !value }"> + {{ selectedTimezoneLabel }} + </span> + <gl-icon name="chevron-down" /> + </template> + + <gl-search-box-by-type v-model.trim="searchTerm" v-autofocusonshow autofocus class="gl-m-3" /> + <gl-deprecated-dropdown-item + v-for="timezone in filteredResults" + :key="timezone.formattedTimezone" + @click="selectTimezone(timezone)" + > + <gl-icon + :class="{ invisible: !isSelected(timezone) }" + name="mobile-issue-close" + class="gl-vertical-align-middle" + /> + {{ timezone.formattedTimezone }} + </gl-deprecated-dropdown-item> + <gl-deprecated-dropdown-item v-if="!filteredResults.length" data-testid="noMatchingResults"> + {{ $options.tranlations.noResultsText }} + </gl-deprecated-dropdown-item> + </gl-new-dropdown> +</template> diff --git a/app/assets/javascripts/vue_shared/components/toggle_button.vue b/app/assets/javascripts/vue_shared/components/toggle_button.vue index 1de866bed37..540edc9f61c 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_button.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_button.vue @@ -1,7 +1,6 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import { s__ } from '../../locale'; -import icon from './icon.vue'; const ICON_ON = 'status_success_borderless'; const ICON_OFF = 'status_failed_borderless'; @@ -10,7 +9,7 @@ const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF'); export default { components: { - icon, + GlIcon, GlLoadingIcon, }, @@ -63,18 +62,27 @@ export default { <label class="toggle-wrapper"> <input v-if="name" :name="name" :value="value" type="hidden" /> <button + type="button" + role="switch" + class="project-feature-toggle" :aria-label="ariaLabel" + :aria-checked="value" :class="{ 'is-checked': value, + 'gl-blue-500': value, 'is-disabled': disabledInput, 'is-loading': isLoading, }" - type="button" - class="project-feature-toggle" @click="toggleFeature" > <gl-loading-icon class="loading-icon" /> - <span class="toggle-icon"> <icon :name="toggleIcon" class="toggle-icon-svg" /> </span> + <span class="toggle-icon"> + <gl-icon + :size="18" + :name="toggleIcon" + :class="value ? 'gl-text-blue-500' : 'gl-text-gray-400'" + /> + </span> </button> </label> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue index db378d6f977..e19d659c179 100644 --- a/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue +++ b/app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_list.vue @@ -1,12 +1,12 @@ <script> -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { sprintf, __ } from '~/locale'; import UserAvatarLink from './user_avatar_link.vue'; export default { components: { UserAvatarLink, - GlDeprecatedButton, + GlButton, }, props: { items: { @@ -82,12 +82,12 @@ export default { :img-size="imgSize" /> <template v-if="hasBreakpoint"> - <gl-deprecated-button v-if="hasHiddenItems" variant="link" @click="expand"> + <gl-button v-if="hasHiddenItems" variant="link" @click="expand"> {{ expandText }} - </gl-deprecated-button> - <gl-deprecated-button v-else variant="link" @click="collapse"> + </gl-button> + <gl-button v-else variant="link" @click="collapse"> {{ __('show less') }} - </gl-deprecated-button> + </gl-button> </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue index bd35d3fead9..699e466e848 100644 --- a/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue +++ b/app/assets/javascripts/vue_shared/components/user_popover/user_popover.vue @@ -70,20 +70,20 @@ export default { <h5 class="gl-m-0"> {{ user.name }} </h5> - <span class="gl-text-gray-700">@{{ user.username }}</span> + <span class="gl-text-gray-500">@{{ user.username }}</span> </div> - <div class="gl-text-gray-700"> + <div class="gl-text-gray-500"> <div v-if="user.bio" class="gl-display-flex gl-mb-2"> - <icon name="profile" class="gl-text-gray-600 gl-flex-shrink-0" /> - <span ref="bio" class="ml-1" v-html="user.bioHtml"></span> + <icon name="profile" class="gl-text-gray-400 gl-flex-shrink-0" /> + <span ref="bio" class="gl-ml-2" v-html="user.bioHtml"></span> </div> <div v-if="user.workInformation" class="gl-display-flex gl-mb-2"> - <icon name="work" class="gl-text-gray-600 gl-flex-shrink-0" /> + <icon name="work" class="gl-text-gray-400 gl-flex-shrink-0" /> <span ref="workInformation" class="gl-ml-2">{{ user.workInformation }}</span> </div> </div> - <div v-if="user.location" class="js-location gl-text-gray-700 gl-display-flex"> - <icon name="location" class="gl-text-gray-600 flex-shrink-0" /> + <div v-if="user.location" class="js-location gl-text-gray-500 gl-display-flex"> + <icon name="location" class="gl-text-gray-400 flex-shrink-0" /> <span class="gl-ml-2">{{ user.location }}</span> </div> <div v-if="statusHtml" class="js-user-status gl-mt-3"> diff --git a/app/assets/javascripts/vue_shared/constants.js b/app/assets/javascripts/vue_shared/constants.js index 235beb1f22d..5511145fba2 100644 --- a/app/assets/javascripts/vue_shared/constants.js +++ b/app/assets/javascripts/vue_shared/constants.js @@ -41,13 +41,13 @@ export const timeRanges = [ interval: INTERVALS.hour, }, { - label: __('1 week'), + label: __('7 days'), duration: { seconds: 60 * 60 * 24 * 7 * 1 }, name: 'oneWeek', interval: INTERVALS.day, }, { - label: __('1 month'), + label: __('30 days'), duration: { seconds: 60 * 60 * 24 * 30 }, name: 'oneMonth', interval: INTERVALS.day, diff --git a/app/assets/javascripts/vuex_shared/bindings.js b/app/assets/javascripts/vuex_shared/bindings.js index 817a90f8149..edc31cfa69e 100644 --- a/app/assets/javascripts/vuex_shared/bindings.js +++ b/app/assets/javascripts/vuex_shared/bindings.js @@ -9,6 +9,7 @@ * @param {string} root - the key of the state where to search fo they keys described in list * @returns {Object} a dictionary with all the computed properties generated */ +// eslint-disable-next-line import/prefer-default-export export const mapComputed = (list, defaultUpdateFn, root) => { const result = {}; list.forEach(item => { @@ -32,5 +33,3 @@ export const mapComputed = (list, defaultUpdateFn, root) => { }); return result; }; - -export default () => {}; diff --git a/app/assets/javascripts/vuex_shared/modules/modal/actions.js b/app/assets/javascripts/vuex_shared/modules/modal/actions.js index 7b209909f69..552237e05c5 100644 --- a/app/assets/javascripts/vuex_shared/modules/modal/actions.js +++ b/app/assets/javascripts/vuex_shared/modules/modal/actions.js @@ -15,6 +15,3 @@ export const show = ({ commit }) => { export const hide = ({ commit }) => { commit(types.HIDE); }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; diff --git a/app/assets/javascripts/whats_new/components/app.vue b/app/assets/javascripts/whats_new/components/app.vue new file mode 100644 index 00000000000..d974556cb9e --- /dev/null +++ b/app/assets/javascripts/whats_new/components/app.vue @@ -0,0 +1,29 @@ +<script> +import { mapState, mapActions } from 'vuex'; +import { GlDrawer } from '@gitlab/ui'; + +export default { + components: { + GlDrawer, + }, + computed: { + ...mapState(['open']), + }, + methods: { + ...mapActions(['closeDrawer']), + }, +}; +</script> + +<template> + <div> + <gl-drawer class="mt-6" :open="open" @close="closeDrawer"> + <template #header> + <h4>{{ __("What's new at GitLab") }}</h4> + </template> + <template> + <div></div> + </template> + </gl-drawer> + </div> +</template> diff --git a/app/assets/javascripts/whats_new/components/trigger.vue b/app/assets/javascripts/whats_new/components/trigger.vue new file mode 100644 index 00000000000..e6c48e92888 --- /dev/null +++ b/app/assets/javascripts/whats_new/components/trigger.vue @@ -0,0 +1,19 @@ +<script> +import { mapActions } from 'vuex'; +import { GlButton } from '@gitlab/ui'; + +export default { + components: { + GlButton, + }, + methods: { + ...mapActions(['openDrawer']), + }, +}; +</script> + +<template> + <li> + <gl-button variant="link" @click="openDrawer">{{ __("See what's new at GitLab") }}</gl-button> + </li> +</template> diff --git a/app/assets/javascripts/whats_new/index.js b/app/assets/javascripts/whats_new/index.js new file mode 100644 index 00000000000..c9ee3404d2a --- /dev/null +++ b/app/assets/javascripts/whats_new/index.js @@ -0,0 +1,32 @@ +import Vue from 'vue'; +import App from './components/app.vue'; +import Trigger from './components/trigger.vue'; +import store from './store'; + +export default () => { + // eslint-disable-next-line no-new + new Vue({ + el: document.getElementById('whats-new-app'), + store, + components: { + App, + }, + + render(createElement) { + return createElement('app'); + }, + }); + + // eslint-disable-next-line no-new + new Vue({ + el: document.getElementById('whats-new-trigger'), + store, + components: { + Trigger, + }, + + render(createElement) { + return createElement('trigger'); + }, + }); +}; diff --git a/app/assets/javascripts/whats_new/store/actions.js b/app/assets/javascripts/whats_new/store/actions.js new file mode 100644 index 00000000000..53488413d9e --- /dev/null +++ b/app/assets/javascripts/whats_new/store/actions.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + closeDrawer({ commit }) { + commit(types.CLOSE_DRAWER); + }, + openDrawer({ commit }) { + commit(types.OPEN_DRAWER); + }, +}; diff --git a/app/assets/javascripts/whats_new/store/index.js b/app/assets/javascripts/whats_new/store/index.js new file mode 100644 index 00000000000..aea980060aa --- /dev/null +++ b/app/assets/javascripts/whats_new/store/index.js @@ -0,0 +1,13 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + actions, + mutations, + state, +}); diff --git a/app/assets/javascripts/whats_new/store/mutation_types.js b/app/assets/javascripts/whats_new/store/mutation_types.js new file mode 100644 index 00000000000..daa65230101 --- /dev/null +++ b/app/assets/javascripts/whats_new/store/mutation_types.js @@ -0,0 +1,2 @@ +export const CLOSE_DRAWER = 'CLOSE_DRAWER'; +export const OPEN_DRAWER = 'OPEN_DRAWER'; diff --git a/app/assets/javascripts/whats_new/store/mutations.js b/app/assets/javascripts/whats_new/store/mutations.js new file mode 100644 index 00000000000..f7e84ee81a9 --- /dev/null +++ b/app/assets/javascripts/whats_new/store/mutations.js @@ -0,0 +1,10 @@ +import * as types from './mutation_types'; + +export default { + [types.CLOSE_DRAWER](state) { + state.open = false; + }, + [types.OPEN_DRAWER](state) { + state.open = true; + }, +}; diff --git a/app/assets/javascripts/whats_new/store/state.js b/app/assets/javascripts/whats_new/store/state.js new file mode 100644 index 00000000000..97089a095f1 --- /dev/null +++ b/app/assets/javascripts/whats_new/store/state.js @@ -0,0 +1,3 @@ +export default { + open: false, +}; diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 41fb62c28e6..f5393ef47d6 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -18,8 +18,8 @@ // GitLab UI framework @import 'framework'; -// Font icons -@import 'font-awesome'; +// Custom Fontawesome icons +@import 'fontawesome_custom'; // Page specific styles (issues, projects etc): @import 'pages/**/*'; @@ -51,3 +51,7 @@ @media print { @import 'print'; } + +/* Rules for overriding cloaking in startup-general.scss */ +@import 'startup/cloaking'; +@include cloak-startup-scss(block); diff --git a/app/assets/stylesheets/components/avatar.scss b/app/assets/stylesheets/components/avatar.scss index 6bb7e9d215e..67213eedca8 100644 --- a/app/assets/stylesheets/components/avatar.scss +++ b/app/assets/stylesheets/components/avatar.scss @@ -125,7 +125,7 @@ $identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $i .identicon { text-align: center; vertical-align: top; - color: $gray-800; + color: $gray-700; background-color: $gray-darker; // Sizes diff --git a/app/assets/stylesheets/components/dashboard_skeleton.scss b/app/assets/stylesheets/components/dashboard_skeleton.scss index 64091201221..09ba89c0782 100644 --- a/app/assets/stylesheets/components/dashboard_skeleton.scss +++ b/app/assets/stylesheets/components/dashboard_skeleton.scss @@ -25,7 +25,7 @@ } &-icon { - color: $gray-500; + color: $gray-300; } &-footer { @@ -33,7 +33,7 @@ height: $gl-padding-32; &-arrow { - color: $gray-300; + color: $gray-200; } &-downstream { @@ -41,7 +41,7 @@ } &-extra { - background-color: $gray-400; + background-color: $gray-200; font-size: 10px; line-height: $gl-line-height; width: $gl-padding; diff --git a/app/assets/stylesheets/components/design_management/design.scss b/app/assets/stylesheets/components/design_management/design.scss index 33f03fb5949..80421598966 100644 --- a/app/assets/stylesheets/components/design_management/design.scss +++ b/app/assets/stylesheets/components/design_management/design.scss @@ -31,7 +31,7 @@ border-radius: 50%; &.resolved { - background-color: $gray-700; + background-color: $gray-500; } } } @@ -164,7 +164,7 @@ } &:hover { - border-color: $gray-500; + border-color: $gray-300; } } diff --git a/app/assets/stylesheets/components/design_management/design_list_item.scss b/app/assets/stylesheets/components/design_management/design_list_item.scss index 3a6781b666e..b7f6b2026fe 100644 --- a/app/assets/stylesheets/components/design_management/design_list_item.scss +++ b/app/assets/stylesheets/components/design_management/design_list_item.scss @@ -1,12 +1,3 @@ -.designs-root { - border: 2px dashed transparent; - transition: border $gl-transition-duration-medium $general-hover-transition-curve; - - &:hover { - border-color: $gray-100; - } -} - .design-list-item { height: 280px; text-decoration: none; diff --git a/app/assets/stylesheets/components/related_items_list.scss b/app/assets/stylesheets/components/related_items_list.scss index dd749b4df1a..c0699844387 100644 --- a/app/assets/stylesheets/components/related_items_list.scss +++ b/app/assets/stylesheets/components/related_items_list.scss @@ -81,30 +81,9 @@ $item-remove-button-space: 42px; max-width: 0; } - .status { - &-at-risk { - color: $red-500; - background-color: $red-100; - } - - &-needs-attention { - color: $orange-700; - background-color: $orange-100; - } - - &-on-track { - color: $green-600; - background-color: $green-100; - } - } - - .gl-label-text { - font-weight: $gl-font-weight-bold; - } - .bullet-separator { font-size: 9px; - color: $gray-400; + color: $gray-200; } } @@ -213,6 +192,7 @@ $item-remove-button-space: 42px; margin-right: $gl-padding-4 / 2; line-height: 0; border-color: transparent; + background-color: transparent; color: $gl-text-color-secondary; .related-items-tree & { diff --git a/app/assets/stylesheets/components/rich_content_editor.scss b/app/assets/stylesheets/components/rich_content_editor.scss index 8d31b386d9e..ade1bb2099d 100644 --- a/app/assets/stylesheets/components/rich_content_editor.scss +++ b/app/assets/stylesheets/components/rich_content_editor.scss @@ -20,15 +20,15 @@ .tui-popup-wrapper { @include gl-overflow-hidden; @include gl-rounded-base; - @include gl-border-gray-400; + @include gl-border-gray-200; hr { @include gl-m-0; - @include gl-bg-gray-400; + @include gl-bg-gray-200; } button { - @include gl-text-gray-800; + @include gl-text-gray-700; } } diff --git a/app/assets/stylesheets/fontawesome_custom.scss b/app/assets/stylesheets/fontawesome_custom.scss new file mode 100644 index 00000000000..a2117e9c012 --- /dev/null +++ b/app/assets/stylesheets/fontawesome_custom.scss @@ -0,0 +1,332 @@ +/*! + * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome + * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) + */ + +// stylelint-disable property-no-vendor-prefix +// stylelint-disable at-rule-no-vendor-prefix +// stylelint-disable stylelint-gitlab/duplicate-selectors +// scss-lint:disable MergeableSelector +@font-face { + font-family: 'FontAwesome'; + src: asset-url('fontawesome-webfont.woff2?v=4.7.0') format('woff2'), asset-url('fontawesome-webfont.woff?v=4.7.0') format('woff'); + font-weight: normal; + font-style: normal; +} + +.fa { + display: inline-block; + font: normal normal normal 14px/1 FontAwesome; + font-size: inherit; + text-rendering: auto; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* makes the font 33% larger relative to the icon container */ +.fa-lg { + font-size: 1.33333333em; + line-height: 0.75em; + vertical-align: -15%; +} + +.fa-2x { + font-size: 2em; +} + +.fa-3x { + font-size: 3em; +} + +.fa-4x { + font-size: 4em; +} + +.fa-5x { + font-size: 5em; +} + +.fa-fw { + width: 1.28571429em; + text-align: center; +} + +.fa-spin { + -webkit-animation: fa-spin 2s infinite linear; + animation: fa-spin 2s infinite linear; +} + +@-webkit-keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} + +@keyframes fa-spin { + 0% { + -webkit-transform: rotate(0deg); + transform: rotate(0deg); + } + + 100% { + -webkit-transform: rotate(359deg); + transform: rotate(359deg); + } +} + +.fa-inverse { + color: $white; +} + +.fa-question-circle::before { + content: '\f059'; +} + +.fa-chevron-down::before { + content: '\f078'; +} + +.fa-remove::before, +.fa-times::before { + content: '\f00d'; +} + +.fa-caret-down::before { + content: '\f0d7'; +} + +.fa-check::before { + content: '\f00c'; +} + +.fa-search::before { + content: '\f002'; +} + +.fa-warning::before, +.fa-exclamation-triangle::before { + content: '\f071'; +} + +.fa-external-link::before { + content: '\f08e'; +} + +.fa-spinner::before { + content: '\f110'; +} + +.fa-calendar::before { + content: '\f073'; +} + +.fa-angle-double-right::before { + content: '\f101'; +} + +.fa-trash::before { + content: '\f1f8'; +} + +.fa-angle-double-left::before { + content: '\f100'; +} + +.fa-arrow-left::before { + content: '\f060'; +} + +.fa-trash-o::before { + content: '\f014'; +} + +.fa-caret-right::before { + content: '\f0da'; +} + +.fa-refresh::before { + content: '\f021'; +} + +.fa-chevron-up::before { + content: '\f077'; +} + +.fa-file-text-o::before { + content: '\f0f6'; +} + +.fa-github::before { + content: '\f09b'; +} + +.fa-paperclip::before { + content: '\f0c6'; +} + +.fa-tag::before { + content: '\f02b'; +} + +.fa-arrow-up::before { + content: '\f062'; +} + +.fa-bug::before { + content: '\f188'; +} + +.fa-google::before { + content: '\f1a0'; +} + +.fa-user::before { + content: '\f007'; +} + +.fa-exclamation-circle::before { + content: '\f06a'; +} + +.fa-bell::before { + content: '\f0f3'; +} + +.fa-arrow-down::before { + content: '\f063'; +} + +.fa-bitbucket-square::before { + content: '\f172'; +} + +.fa-file-o::before { + content: '\f016'; +} + +.fa-users::before { + content: '\f0c0'; +} + +.fa-tags::before { + content: '\f02c'; +} + +.fa-lightbulb-o::before { + content: '\f0eb'; +} + +.fa-circle::before { + content: '\f111'; +} + +.fa-bitbucket::before { + content: '\f171'; +} + +.fa-git::before { + content: '\f1d3'; +} + +.fa-folder::before { + content: '\f07b'; +} + +.fa-archive::before { + content: '\f187'; +} + +.fa-thumb-tack::before { + content: '\f08d'; +} + +.fa-fire::before { + content: '\f06d'; +} + +.fa-download::before { + content: '\f019'; +} + +.fa-globe::before { + content: '\f0ac'; +} + +.fa-pause::before { + content: '\f04c'; +} + +.fa-play::before { + content: '\f04b'; +} + +.fa-arrow-right::before { + content: '\f061'; +} + +.fa-user-secret::before { + content: '\f21b'; +} + +.fa-search-plus::before { + content: '\f00e'; +} + +.fa-search-minus::before { + content: '\f010'; +} + +.fa-share::before { + content: '\f064'; +} + +.fa-book::before { + content: '\f02d'; +} + +.fa-times-circle::before { + content: '\f057'; +} + +.fa-skype::before { + content: '\f17e'; +} + +.fa-linkedin-square::before { + content: '\f08c'; +} + +.fa-twitter-square::before { + content: '\f081'; +} + +.fa-unlink::before { + content: '\f127'; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +.sr-only-focusable:active, +.sr-only-focusable:focus { + position: static; + width: auto; + height: auto; + margin: 0; + overflow: visible; + clip: auto; +} diff --git a/app/assets/stylesheets/framework/awards.scss b/app/assets/stylesheets/framework/awards.scss index 86e701604b5..4f09f1a394b 100644 --- a/app/assets/stylesheets/framework/awards.scss +++ b/app/assets/stylesheets/framework/awards.scss @@ -232,7 +232,7 @@ height: $default-icon-size; width: $default-icon-size; border-radius: 50%; - fill: $gray-700; + fill: $gray-500; } } diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss index 5b8a4bf964e..3f1d742ca14 100644 --- a/app/assets/stylesheets/framework/badges.scss +++ b/app/assets/stylesheets/framework/badges.scss @@ -1,7 +1,7 @@ .badge.badge-pill:not(.gl-badge) { font-weight: $gl-font-weight-normal; background-color: $badge-bg; - color: $gray-800; + color: $gray-700; vertical-align: baseline; // Do not use this! diff --git a/app/assets/stylesheets/framework/broadcast_messages.scss b/app/assets/stylesheets/framework/broadcast_messages.scss index f7836213e5c..c1647c16c77 100644 --- a/app/assets/stylesheets/framework/broadcast_messages.scss +++ b/app/assets/stylesheets/framework/broadcast_messages.scss @@ -38,7 +38,7 @@ } .broadcast-message-dismiss { - color: $gray-800; + color: $gray-700; } } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index fd5b3f74c4a..893a494d240 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -274,8 +274,6 @@ svg { height: 15px; width: 15px; - position: relative; - top: 2px; } svg, @@ -495,6 +493,10 @@ } } +// The .btn-svg class is available for legacy icon buttons to +// preserve a 34px height and have 16x16 icons at the same time. +// Once a button is migrated (to the current 32px height) +// please remove this class from the new button. .btn-svg svg { @include btn-svg; } diff --git a/app/assets/stylesheets/framework/ci_variable_list.scss b/app/assets/stylesheets/framework/ci_variable_list.scss index cae7b9b5e46..2204b037f69 100644 --- a/app/assets/stylesheets/framework/ci_variable_list.scss +++ b/app/assets/stylesheets/framework/ci_variable_list.scss @@ -86,7 +86,6 @@ height: $input-height; padding: 0; background: transparent; - border: 0; color: $gl-text-color-secondary; &:hover, @@ -101,7 +100,7 @@ } .group-variable-list { - color: $gray-700; + color: $gray-500; .table-section:not(:first-child) { @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 1abb7a9c06f..00679cf20fa 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -6,7 +6,7 @@ .cdark { color: $common-gray-dark; } .fwhite { fill: $white; } -.fgray { fill: $gray-700; } +.fgray { fill: $gray-500; } .text-plain, .text-plain:hover { @@ -74,7 +74,7 @@ .hint { font-style: italic; - color: $gl-gray-400; + color: $gl-gray-200; } .light { color: $gl-text-color; } @@ -168,7 +168,7 @@ table { } p.time { - color: $gl-gray-400; + color: $gl-gray-200; font-size: 90%; margin: 30px 3px 3px 2px; } @@ -396,15 +396,11 @@ img.emoji { 🚨 Do not use these classes — they are deprecated and being removed. 🚨 See https://gitlab.com/gitlab-org/gitlab/-/issues/217418 for more details. **/ -.prepend-top-10 { margin-top: 10px; } .prepend-top-15 { margin-top: 15px; } .prepend-top-20 { margin-top: 20px; } .prepend-left-15 { margin-left: 15px; } .prepend-left-20 { margin-left: 20px; } -.prepend-left-64 { margin-left: 64px; } -.append-right-15 { margin-right: 15px; } .append-right-20 { margin-right: 20px; } -.append-bottom-10 { margin-bottom: 10px; } .append-bottom-20 { margin-bottom: 20px; } .ml-10 { margin-left: 4.5rem; } .inline { display: inline-block; } @@ -513,7 +509,7 @@ img.emoji { } &.is-dragging { - background-color: $gray-600; + background-color: $gray-400; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 32c276ea6d2..6b742853f8f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -312,6 +312,7 @@ > a, button, + .gl-button.btn-link, .menu-item { @include dropdown-link; } diff --git a/app/assets/stylesheets/framework/feature_highlight.scss b/app/assets/stylesheets/framework/feature_highlight.scss index f9b167669a6..8d411747b28 100644 --- a/app/assets/stylesheets/framework/feature_highlight.scss +++ b/app/assets/stylesheets/framework/feature_highlight.scss @@ -29,12 +29,6 @@ } } -.is-showing-fly-out { - .feature-highlight { - display: none; - } -} - .feature-highlight-popover-content { display: none; diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 8fd507a45bb..ef7d39a5e7e 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -209,7 +209,7 @@ } .doc-versions { - color: $gray-600; + color: $gray-400; &:hover { color: $gray-900; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 8f209d2d99a..ed4281123cd 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -134,20 +134,20 @@ padding-left: 8px; padding-right: 0; - .fa-close { + .close-icon { color: $gl-text-color-secondary; } - &:hover .fa-close { + &:hover .close-icon { color: $gl-text-color; } &.inverted { - .fa-close { + .close-icon { color: $gl-text-color-secondary-inverted; } - &:hover .fa-close { + &:hover .close-icon { color: $gl-text-color-inverted; } } @@ -307,23 +307,6 @@ color: $gl-text-color; border-color: $border-color; } - - svg { - height: 14px; - width: 14px; - vertical-align: middle; - margin-bottom: 4px; - } - - .dropdown-toggle-text { - display: inline-block; - color: inherit; - - .fa { - vertical-align: middle; - color: inherit; - } - } } .filtered-search-history-dropdown { @@ -458,6 +441,23 @@ } .vue-filtered-search-bar-container { + .gl-search-box-by-click { + // Absolute width is needed to prevent flex to grow + // beyond the available width. + .gl-filtered-search-scrollable { + width: 1px; + } + + // There are several styling issues happening while using + // `GlFilteredSearch` in roadmap due to some of our global + // styles which we need to override until those are fixed + // at framework level. + // See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/908 + .input-group-prepend + .gl-filtered-search-scrollable { + border-radius: 0; + } + } + @include media-breakpoint-up(md) { .sort-dropdown-container { margin-left: 10px; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index ec8d5806345..7be676ed83c 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -227,7 +227,7 @@ label { right: 0.8em; top: 50%; transform: translateY(-50%); - color: $gray-600; + color: $gray-400; } .input-md { diff --git a/app/assets/stylesheets/framework/gitlab_theme.scss b/app/assets/stylesheets/framework/gitlab_theme.scss index 288849ba438..97bd6ca6fe2 100644 --- a/app/assets/stylesheets/framework/gitlab_theme.scss +++ b/app/assets/stylesheets/framework/gitlab_theme.scss @@ -295,9 +295,9 @@ body { &.ui-dark { @include gitlab-theme( $gray-200, + $gray-300, $gray-500, $gray-700, - $gray-800, $gray-900, $white ); @@ -305,12 +305,12 @@ body { &.ui-light { @include gitlab-theme( + $gray-500, $gray-700, - $gray-800, - $gray-700, - $gray-700, + $gray-500, + $gray-500, $gray-50, - $gray-700 + $gray-500 ); .navbar-gitlab { @@ -341,7 +341,7 @@ body { .container-fluid { .navbar-toggler, .navbar-toggler:hover { - color: $gray-700; + color: $gray-500; border-left: 1px solid $gray-100; } } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 9ae313db4c1..ec0755b1614 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -34,11 +34,11 @@ .ci-status-icon-preparing { svg { - fill: $gray-500; + fill: $gray-300; } &.add-border { - @include borderless-status-icon($gray-500); + @include borderless-status-icon($gray-300); } } @@ -98,5 +98,5 @@ display: flex; align-items: center; justify-content: center; - color: $gray-700; + color: $gray-500; } diff --git a/app/assets/stylesheets/framework/images.scss b/app/assets/stylesheets/framework/images.scss index 0fae1c7d235..195a66bf9e5 100644 --- a/app/assets/stylesheets/framework/images.scss +++ b/app/assets/stylesheets/framework/images.scss @@ -48,4 +48,12 @@ svg { @include svg-size(#{$svg-size}px); } } + + &.s12 { + vertical-align: -1px; + } + + &.s16 { + vertical-align: -3px; + } } diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index 738150dbd2e..9d67b175294 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -132,10 +132,10 @@ ul.content-list { a { color: $gl-text-color; - } - .member-group-link { - color: $blue-600; + &.inline-link { + color: $blue-600; + } } .description { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 918ca448c21..61e8c0d4718 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -326,8 +326,8 @@ line-height: 1; padding: 0; min-width: 16px; - color: $gray-600; - fill: $gray-600; + color: $gray-400; + fill: $gray-400; .fa { position: relative; diff --git a/app/assets/stylesheets/framework/responsive_tables.scss b/app/assets/stylesheets/framework/responsive_tables.scss index 1878fac1c60..07c3eb19fd4 100644 --- a/app/assets/stylesheets/framework/responsive_tables.scss +++ b/app/assets/stylesheets/framework/responsive_tables.scss @@ -20,7 +20,7 @@ @extend .gl-responsive-table-row-layout; margin-top: 10px; border: 1px solid $border-color; - color: $gray-700; + color: $gray-500; &.gl-responsive-table-row-clickable { &:hover { diff --git a/app/assets/stylesheets/framework/secondary_navigation_elements.scss b/app/assets/stylesheets/framework/secondary_navigation_elements.scss index f85efc63645..1352fa13e1a 100644 --- a/app/assets/stylesheets/framework/secondary_navigation_elements.scss +++ b/app/assets/stylesheets/framework/secondary_navigation_elements.scss @@ -415,7 +415,3 @@ } } } - -.new-project-item-select-button .fa-caret-down { - margin-left: 2px; -} diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index c2ab6f5b8c5..e81ecfb43d5 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -285,7 +285,7 @@ .select2-highlighted { .group-result { .group-path { - color: $gray-800; + color: $gray-700; } } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 9b33ed1b630..4ba9db811b7 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -1,6 +1,5 @@ .content-wrapper { width: 100%; - transition: padding $sidebar-transition-duration; .container-fluid { padding: 0 $gl-padding; @@ -13,6 +12,10 @@ } } +.page-initialised .content-wrapper { + transition: padding $sidebar-transition-duration; +} + .nav-header-btn { padding: 10px $gl-sidebar-padding; color: inherit; @@ -168,7 +171,7 @@ &::before { content: ''; - border-left: 1px solid $gray-500; + border-left: 1px solid $gray-300; position: absolute; top: $gl-padding; bottom: $gl-padding; diff --git a/app/assets/stylesheets/framework/snippets.scss b/app/assets/stylesheets/framework/snippets.scss index dbcb5086d70..4c1c9d15121 100644 --- a/app/assets/stylesheets/framework/snippets.scss +++ b/app/assets/stylesheets/framework/snippets.scss @@ -32,6 +32,10 @@ .snippet-file-content { border-radius: 3px; + + + .snippet-file-content { + @include gl-mt-5; + } } .snippet-header { diff --git a/app/assets/stylesheets/framework/spinner.scss b/app/assets/stylesheets/framework/spinner.scss index b7a99d421c9..d734895c7dc 100644 --- a/app/assets/stylesheets/framework/spinner.scss +++ b/app/assets/stylesheets/framework/spinner.scss @@ -42,7 +42,7 @@ } &.spinner-dark { - @include spinner-color($gray-700); + @include spinner-color($gray-500); } &.spinner-light { diff --git a/app/assets/stylesheets/framework/stacked_progress_bar.scss b/app/assets/stylesheets/framework/stacked_progress_bar.scss index 2d16fdf4ee7..a3037549881 100644 --- a/app/assets/stylesheets/framework/stacked_progress_bar.scss +++ b/app/assets/stylesheets/framework/stacked_progress_bar.scss @@ -24,7 +24,7 @@ .status-unavailable { padding: 0 10px; - color: $gray-700; + color: $gray-500; } .status-green { @@ -40,7 +40,7 @@ color: $gl-gray-dark; &:hover { - background-color: $gray-300; + background-color: $gray-200; } } diff --git a/app/assets/stylesheets/framework/tables.scss b/app/assets/stylesheets/framework/tables.scss index 5bc2874ea05..1f60485aa36 100644 --- a/app/assets/stylesheets/framework/tables.scss +++ b/app/assets/stylesheets/framework/tables.scss @@ -16,7 +16,7 @@ table { * Remove this code as soon as this happens */ &.gl-table { - @include gl-text-gray-700; + @include gl-text-gray-500; } &.table { @@ -60,7 +60,7 @@ table { } &.original-gl-th { - @include gl-text-gray-700; + @include gl-text-gray-500; border-bottom: 1px solid $cycle-analytics-light-gray; } } diff --git a/app/assets/stylesheets/framework/toggle.scss b/app/assets/stylesheets/framework/toggle.scss index 8b131f59cfc..054280f3321 100644 --- a/app/assets/stylesheets/framework/toggle.scss +++ b/app/assets/stylesheets/framework/toggle.scss @@ -31,7 +31,7 @@ height: 24px; cursor: pointer; user-select: none; - background: $gl-gray-400; + background: $gray-400; border-radius: 12px; padding: 3px; transition: all 0.4s ease; @@ -51,26 +51,10 @@ display: block; left: 0; border-radius: 9px; - background: $feature-toggle-color; + background: $white; transition: all 0.2s ease; - - &, - .toggle-icon-svg { - width: $default-icon-size; - height: $default-icon-size; - } - - .toggle-icon-svg { - fill: $gl-gray-400; - } - - .toggle-status-checked { - display: none; - } - - .toggle-status-unchecked { - display: inline; - } + width: $default-icon-size; + height: $default-icon-size; } .loading-icon { @@ -84,10 +68,6 @@ } &.is-loading { - .toggle-icon { - display: none; - } - .loading-icon { display: block; @@ -101,23 +81,22 @@ } &.is-checked { - background: $feature-toggle-color-enabled; + background: $blue-400; .toggle-icon { left: calc(100% - 18px); + } + } - .toggle-icon-svg { - fill: $feature-toggle-color-enabled; - } - - .toggle-status-checked { - display: inline; - } + &.is-checked .toggle-icon .toggle-status-checked, + .toggle-icon .toggle-status-unchecked { + display: inline; + } - .toggle-status-unchecked { - display: none; - } - } + &.is-checked .toggle-icon .toggle-status-unchecked, + &.is-loading .toggle-icon, + .toggle-icon .toggle-status-checked { + display: none; } &.is-disabled { diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index b5b86b807a6..8758fe15870 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -89,10 +89,10 @@ background-color: $gray-10; border-width: 1px; border-style: solid; - border-color: $gray-100 $gray-100 $gray-400; + border-color: $gray-100 $gray-100 $gray-200; border-image: none; border-radius: 3px; - box-shadow: 0 -1px 0 $gray-400 inset; + box-shadow: 0 -1px 0 $gray-200 inset; } h1 { @@ -187,7 +187,7 @@ tr { th { - border-bottom: solid 2px $gray-300; + border-bottom: solid 2px $gray-200; } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 265dceb3c61..69e00f9b2c4 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -165,12 +165,12 @@ $gray-10: #fafafa !default; $gray-50: #f0f0f0 !default; $gray-100: #dbdbdb !default; $gray-200: #bfbfbf !default; -$gray-300: #ccc !default; -$gray-400: #bababa !default; -$gray-500: #a7a7a7 !default; -$gray-600: #919191 !default; -$gray-700: #707070 !default; -$gray-800: #4f4f4f !default; +$gray-300: #999 !default; +$gray-400: #868686 !default; +$gray-500: #666 !default; +$gray-600: #5e5e5e !default; +$gray-700: #525252 !default; +$gray-800: #404040 !default; $gray-900: #303030 !default; $gray-950: #1f1f1f !default; @@ -350,12 +350,12 @@ $gl-font-size-large: 16px; $gl-font-weight-normal: 400; $gl-font-weight-bold: 600; $gl-text-color: $gray-900; -$gl-text-color-secondary: $gray-700; -$gl-text-color-tertiary: $gray-600; +$gl-text-color-secondary: $gray-500; +$gl-text-color-tertiary: $gray-400; $gl-text-color-quaternary: #d6d6d6; $gl-text-color-inverted: $white; $gl-text-color-secondary-inverted: rgba($white, 0.85); -$gl-text-color-disabled: $gray-600; +$gl-text-color-disabled: $gray-400; $gl-grayish-blue: #7f8fa4; $gl-gray-dark: #313236; $gl-gray-light: #5c5c5c; @@ -446,6 +446,8 @@ $context-header-height: 60px; $breadcrumb-min-height: 48px; $home-panel-title-row-height: 64px; $home-panel-avatar-mobile-size: 24px; +$issuable-title-max-width: 350px; +$milestone-title-max-width: 75px; $gl-line-height: 16px; $gl-line-height-18: 18px; $gl-line-height-20: 20px; @@ -637,10 +639,13 @@ $issue-boards-card-shadow: rgba(0, 0, 0, 0.1); They probably should be derived in a smarter way. */ $issue-boards-filter-height: 68px; +$issue-boards-filter-height-md: 110px; +$issue-boards-filter-height-sm: 299px; $issue-boards-breadcrumbs-height-xs: 63px; $issue-board-list-difference-xs: $header-height + $issue-boards-breadcrumbs-height-xs; $issue-board-list-difference-sm: $header-height + $breadcrumb-min-height; -$issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards-filter-height; +$issue-board-list-difference-md: $issue-board-list-difference-sm + $issue-boards-filter-height-md; +$issue-board-list-difference-lg: $issue-board-list-difference-sm + $issue-boards-filter-height; /* The following heights are used in environment_logs.scss and are used for calculation of the log viewer height. */ @@ -748,10 +753,6 @@ $login-brand-holder-color: #888; $project-option-descr-color: #54565b; $project-network-controls-color: #888; -$feature-toggle-color: #fff; -$feature-toggle-text-color: #fff; -$feature-toggle-color-enabled: #4a8bee; - /* * Monitor Charts */ @@ -870,7 +871,7 @@ $popover-max-width: 384px; $popover-box-shadow: 0 2px 3px 1px $gray-100; /* -Issues Analytics +Issue Analytics */ $issues-analytics-popover-boarder-color: rgba(0, 0, 0, 0.15); diff --git a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss index a8d10ea1a29..dfd7fd355a4 100644 --- a/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss +++ b/app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss @@ -38,7 +38,7 @@ } .badge.badge-pill { - color: var(--ide-text-color, $gray-800); + color: var(--ide-text-color, $gray-700); background-color: var(--ide-background, $badge-bg); } diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index a07755724dd..36587ecde3d 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -366,7 +366,7 @@ $ide-commit-header-height: 48px; display: block; margin-left: auto; margin-right: auto; - color: var(--ide-text-color-secondary, $gray-700); + color: var(--ide-text-color-secondary, $gray-500); } .file-status-icon { @@ -689,7 +689,7 @@ $ide-commit-header-height: 48px; border-bottom: 1px solid var(--ide-border-color-alt, $white-dark); svg { - color: var(--ide-text-color-secondary, $gray-700); + color: var(--ide-text-color-secondary, $gray-500); &:focus, &:hover { @@ -721,7 +721,7 @@ $ide-commit-header-height: 48px; &, &:hover { - color: var(--ide-text-color-secondary, $gray-700); + color: var(--ide-text-color-secondary, $gray-500); } } @@ -863,9 +863,6 @@ $ide-commit-header-height: 48px; .ide-external-link { svg { display: none; - position: absolute; - top: 2px; - right: -$gl-padding; } &:hover, @@ -1136,7 +1133,7 @@ $ide-commit-header-height: 48px; .ide-file-icon-holder { display: flex; align-items: center; - color: var(--ide-text-color-secondary, $gray-700); + color: var(--ide-text-color-secondary, $gray-500); } .file-row:active { diff --git a/app/assets/stylesheets/pages/alert_management/details.scss b/app/assets/stylesheets/pages/alert_management/details.scss index 73a4af00c5a..0f889935583 100644 --- a/app/assets/stylesheets/pages/alert_management/details.scss +++ b/app/assets/stylesheets/pages/alert_management/details.scss @@ -16,12 +16,12 @@ &:not(:first-child) { &::before { - color: $gray-700; + color: $gray-500; font-weight: normal !important; } div { - color: $gray-700; + color: $gray-500; } } @@ -35,7 +35,7 @@ } @include media-breakpoint-down(xs) { - .alert-details-issue-button { + .alert-details-incident-button { width: 100%; } } diff --git a/app/assets/stylesheets/pages/alert_management/severity-icons.scss b/app/assets/stylesheets/pages/alert_management/severity-icons.scss index b400e80d5c5..6004697b3e1 100644 --- a/app/assets/stylesheets/pages/alert_management/severity-icons.scss +++ b/app/assets/stylesheets/pages/alert_management/severity-icons.scss @@ -1,4 +1,4 @@ -.alert-management-list, +.incident-management-list, .alert-management-details { .icon-critical { color: $red-800; @@ -21,6 +21,6 @@ } .icon-unknown { - color: $gray-400; + color: $gray-200; } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 049660220df..51a65b88cd0 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -59,6 +59,10 @@ height: calc(100vh - #{$issue-board-list-difference-md}); } + @include media-breakpoint-up(lg) { + height: calc(100vh - #{$issue-board-list-difference-lg}); + } + .with-performance-bar & { height: calc(100vh - #{$issue-board-list-difference-xs} - #{$performance-bar-height}); @@ -69,6 +73,10 @@ @include media-breakpoint-up(md) { height: calc(100vh - #{$issue-board-list-difference-md} - #{$performance-bar-height}); } + + @include media-breakpoint-up(lg) { + height: calc(100vh - #{$issue-board-list-difference-lg} - #{$performance-bar-height}); + } } } @@ -191,7 +199,8 @@ align-items: center; font-size: 1em; border-bottom: 1px solid $gray-100; - padding: $gl-padding-8; + padding: 0 $gl-spacing-scale-3; + height: 3rem; .js-max-issue-size::before { content: '/'; @@ -521,7 +530,7 @@ } &.board-card-weight { - color: $gl-text-color; + color: $gl-text-color-secondary; cursor: pointer; &:hover { @@ -531,8 +540,9 @@ } .board-card-info-icon { - color: $gray-600; + color: $gray-500; margin-right: $gl-padding-4; + vertical-align: text-top; } @include media-breakpoint-down(md) { @@ -584,3 +594,21 @@ .board-header-collapsed-info-icon:hover { color: $gray-900; } + +$epic-icons-spacing: 40px; + +.board-epic-lane { + max-width: calc(100vw - #{$contextual-sidebar-width} - #{$epic-icons-spacing}); + + .page-with-icon-sidebar & { + max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$epic-icons-spacing}); + } + + .page-with-icon-sidebar .is-compact & { + max-width: calc(100vw - #{$contextual-sidebar-collapsed-width} - #{$gutter-width} - #{$epic-icons-spacing}); + } + + .is-compact & { + max-width: calc(100vw - #{$contextual-sidebar-width} - #{$gutter-width} - #{$epic-icons-spacing}); + } +} diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 02c42d5b779..f367d9ea4d8 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -108,7 +108,7 @@ svg { position: relative; - top: 5px; + top: 3px; margin-right: 5px; width: 22px; height: 22px; @@ -275,8 +275,6 @@ overflow: auto; svg { - position: relative; - top: 3px; margin-right: 3px; height: 14px; width: 14px; diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 5a69aa15303..29422c1f7fa 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -166,6 +166,6 @@ .cluster-status-indicator { &.disabled { - background-color: $gray-600; + background-color: $gray-400; } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 9a69afc6044..e6378fd9168 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -388,3 +388,9 @@ display: block; color: $link-color; } + +.add-review-item { + .gl-tab-nav-item { + height: 100%; + } +} diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index fd5b3ff1dd8..a7b93c9eab7 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -992,7 +992,8 @@ table.code { } .frame .badge.badge-pill, -.frame .image-comment-badge { +.frame .image-comment-badge, +.frame .comment-indicator { // Center align badges on the frame transform: translate(-50%, -50%); } diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index fd11d0e3a69..9f9964ac447 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -16,6 +16,11 @@ padding: 0; position: relative; width: 100%; + + .editor-loading-content { + height: 100%; + border: 0; + } } .ace_gutter-cell { @@ -160,9 +165,8 @@ vertical-align: top; @media(max-width: map-get($grid-breakpoints, lg)-1) { - display: block; + display: inline-block; width: 100%; - margin: 5px 0; padding: 0; border-left: 0; } @@ -182,7 +186,8 @@ .gitignore-selector, .gitlab-ci-yml-selector, .dockerfile-selector, - .template-type-selector { + .template-type-selector, + .metrics-dashboard-selector { display: inline-block; vertical-align: top; font-family: $regular_font; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index ab6716903c7..ef7b56ac210 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -98,19 +98,10 @@ } } -.refresh-dashboard-button { - margin-top: 22px; - - @media(max-width: map-get($grid-breakpoints, sm)) { - margin-top: 0; - } -} - .metric-area { opacity: 0.25; } - .rect-text-metric { fill: $white; stroke-width: 1; diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index f9beb6fe037..c4b6cdd703d 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -49,7 +49,7 @@ text { font-weight: bold; font-size: 12px; - fill: $gray-800; + fill: $gray-700; } } } @@ -70,5 +70,5 @@ .animate-flicker { animation: flickerAnimation 1.5s infinite; - fill: $gray-500; + fill: $gray-300; } diff --git a/app/assets/stylesheets/pages/alert_management/list.scss b/app/assets/stylesheets/pages/incident_management_list.scss index e420209b1fc..00ca3cc73e0 100644 --- a/app/assets/stylesheets/pages/alert_management/list.scss +++ b/app/assets/stylesheets/pages/incident_management_list.scss @@ -1,11 +1,11 @@ -.alert-management-list { +.incident-management-list { .new-alert { background-color: $issues-today-bg; } // these styles need to be deleted once GlTable component looks in GitLab same as in @gitlab/ui table { - color: $gray-700; + @include gl-text-gray-500; tr { &:focus { @@ -14,16 +14,15 @@ td, th { - @include gl-pl-9; @include gl-py-5; @include gl-outline-none; @include gl-relative; } th { - background-color: transparent; - font-weight: $gl-font-weight-bold; - color: $gl-gray-600; + @include gl-bg-transparent; + @include gl-font-weight-bold; + @include gl-text-gray-400; &[aria-sort='none']:hover { background-image: url('data:image/svg+xml, %3csvg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="4 0 8 16"%3e %3cpath style="fill: %23BABABA;" fill-rule="evenodd" d="M11.707085,11.7071 L7.999975,15.4142 L4.292875,11.7071 C3.902375,11.3166 3.902375, 10.6834 4.292875,10.2929 C4.683375,9.90237 5.316575,9.90237 5.707075,10.2929 L6.999975, 11.5858 L6.999975,2 C6.999975,1.44771 7.447695,1 7.999975,1 C8.552255,1 8.999975,1.44771 8.999975,2 L8.999975,11.5858 L10.292865,10.2929 C10.683395 ,9.90237 11.316555,9.90237 11.707085,10.2929 C12.097605,10.6834 12.097605,11.3166 11.707085,11.7071 Z"/%3e %3c/svg%3e'); @@ -39,19 +38,37 @@ } } } + + .sortable-cell { + padding-left: calc(0.75rem + 0.65em); + } } } @include media-breakpoint-down(sm) { - .alert-management-table { + table { tr { - border-top: 0; + @include gl-border-t-0; .table-col { min-height: 68px; + } + + &:hover { + @include gl-bg-white; + @include gl-border-none; + } + + th, + td { + @include gl-pt-6; + } + } + &.alert-management-table { + .table-col { &:last-child { - background-color: $gray-10; + @include gl-bg-gray-10; &::before { content: none !important; @@ -63,23 +80,56 @@ } } } + } - &:hover { - background-color: $white; - border-color: $white; - border-bottom-style: none; + .b-table-empty-row { + td { + @include gl-border-b-0; + + div { + text-align: unset !important; + } } } + + .b-table-busy-slot { + td { + @include gl-border-b-0; + + div { + text-align: center !important; + } + } + } + } + } + + .gl-tabs-nav { + border-bottom-width: 0; + + .gl-tab-nav-item { + color: $gl-gray-600; + + > .gl-tab-counter-badge { + color: inherit; + @include gl-font-sm; + background-color: $gray-50; + } } } - .gl-tab-nav-item { - color: $gl-gray-600; + @include media-breakpoint-down(xs) { + .incident-management-list-header { + flex-direction: column-reverse; + } - > .gl-tab-counter-badge { - color: inherit; - @include gl-font-sm; - background-color: $white-normal; + .create-incident-button { + @include gl-w-full; } } + + // TODO: Abstract to `@gitlab/ui` utility set: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/921 + .gl-fill-green-500 { + fill: $green-500; + } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index a7d0d4259ea..2f28361f62c 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -166,6 +166,14 @@ color: inherit; } + // TODO remove this class once we can generate a correct hover utility from `gitlab/ui`, + // see here: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39286#note_396767000 + .btn-link-hover:hover { + * { + @include gl-text-blue-800; + } + } + .issuable-header-text { margin-top: 7px; } @@ -598,18 +606,18 @@ padding: 16px 0; small { - color: $gray-700; + color: $gray-500; } } .edited-text { - color: $gray-700; + color: $gray-500; display: block; margin: 16px 0 0; font-size: 85%; .author-link { - color: $gray-700; + color: $gray-500; } } @@ -804,6 +812,10 @@ } } } + + .milestone { + color: $gray-700; + } } @media(max-width: map-get($grid-breakpoints, lg)-1) { @@ -956,7 +968,7 @@ .sidebar-collapsed-divider { line-height: 5px; font-size: 12px; - color: $gray-700; + color: $gray-500; + .sidebar-collapsed-icon { padding-top: 0; diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 73d2c3ca2f8..e37b26187e7 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -336,11 +336,11 @@ } .label-action { - color: $gray-800; + color: $gray-700; cursor: pointer; svg { - fill: $gray-800; + fill: $gray-700; } &:hover { diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 54bca80194f..2d9a9f3029f 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -180,10 +180,6 @@ word-break: break-all; } - .member-group-link { - display: inline-block; - } - .form-control { width: inherit; } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 5cf2d847405..a6d1fc11c3f 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -760,11 +760,6 @@ $mr-widget-min-height: 69px; color: $gl-text-color; } - .fa-info-circle { - color: $orange-500; - padding-right: 5px; - } - // Shortening button height by 1px to make compare-versions // header 56px and fit into our 8px design grid button { @@ -1010,7 +1005,7 @@ $mr-widget-min-height: 69px; .coverage { font-size: 12px; - color: $gray-700; + color: $gray-500; line-height: initial; } diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 3a210d66420..35a15214f68 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -152,7 +152,7 @@ } .sidebar-item-value & { - fill: $gray-700; + fill: $gray-500; } } @@ -282,7 +282,7 @@ table { display: table; svg { - fill: $gray-700; + fill: $gray-500; } .btn-group { diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 40f0104a2bf..e4e54501627 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -674,7 +674,7 @@ $note-form-margin-left: 72px; .note-headline-meta { .system-note-separator { - color: $gray-700; + color: $gray-500; } .note-timestamp { @@ -727,7 +727,7 @@ $note-form-margin-left: 72px; display: inline-flex; align-items: center; margin-left: 10px; - color: $gray-600; + color: $gray-400; @include notes-media('max', map-get($grid-breakpoints, sm) - 1) { float: none; @@ -820,9 +820,7 @@ $note-form-margin-left: 72px; } } -.add-diff-note { - @include btn-comment-icon; - opacity: 0; +.tooltip-wrapper.add-diff-note { margin-left: -52px; position: absolute; top: 50%; @@ -830,6 +828,18 @@ $note-form-margin-left: 72px; z-index: 10; } +.note-button.add-diff-note { + @include btn-comment-icon; + opacity: 0; + + &[disabled] { + background: $white; + border-color: $gray-200; + color: $gl-gray-400; + cursor: not-allowed; + } +} + .disabled-comment { background-color: $gray-light; border-radius: $border-radius-base; @@ -867,7 +877,7 @@ $note-form-margin-left: 72px; line-height: $gl-line-height; svg { - fill: $gray-700; + fill: $gray-500; } &.discussion-create-issue-btn { @@ -904,7 +914,7 @@ $note-form-margin-left: 72px; border-right: 0; .line-resolve-btn { - color: $gray-700; + color: $gray-500; svg { vertical-align: text-top; @@ -989,11 +999,6 @@ $note-form-margin-left: 72px; } .discussion-filter-container { - .btn > svg { - width: $gl-col-padding; - height: $gl-col-padding; - } - .dropdown-menu { margin-bottom: $gl-padding-4; diff --git a/app/assets/stylesheets/pages/packages.scss b/app/assets/stylesheets/pages/packages.scss new file mode 100644 index 00000000000..8f6eee524e5 --- /dev/null +++ b/app/assets/stylesheets/pages/packages.scss @@ -0,0 +1,11 @@ +.commit-row-description { + border: 0; + border-left: 3px solid $white-dark; +} + +.package-list-table[aria-busy='true'] { + td { + padding-bottom: 0; + padding-top: 0; + } +} diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 57ad9abef4b..fc3b786b365 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -445,6 +445,7 @@ .pipeline-tab-content { display: flex; width: 100%; + min-height: $dropdown-max-height-lg; background-color: $gray-light; padding: $gl-padding 0; overflow: auto; @@ -669,24 +670,13 @@ &.ci-action-icon-wrapper { height: 30px; width: 30px; - background: $white; - border: 1px solid $border-color; border-radius: 100%; display: block; - - &:hover { - background-color: $gray-darker; - border: 1px solid $dropdown-toggle-active-border-color; - - svg { - fill: $gl-text-color; - } - } + padding: 0; + line-height: 0; svg { fill: $gl-text-color-secondary; - position: relative; - top: -1px; } .spinner { @@ -695,7 +685,8 @@ &.play { svg { - left: 2px; + left: 1px; + top: 1px; } } } @@ -804,12 +795,12 @@ &.ci-status-icon-disabled, &.ci-status-icon-not-found, &.ci-status-icon-manual { - @include mini-pipeline-graph-color($white, $gray-700, $gray-800, $gray-900, $gray-950, $black); + @include mini-pipeline-graph-color($white, $gray-500, $gray-700, $gray-900, $gray-950, $black); } &.ci-status-icon-created, &.ci-status-icon-skipped { - @include mini-pipeline-graph-color($white, $gray-100, $gray-300, $gray-500, $gray-600, $gray-700); + @include mini-pipeline-graph-color($white, $gray-100, $gray-200, $gray-300, $gray-400, $gray-500); } } @@ -845,15 +836,12 @@ button.mini-pipeline-graph-dropdown-toggle { &.ci-action-icon-wrapper { height: $ci-action-dropdown-button-size; width: $ci-action-dropdown-button-size; - - background: $white; - border: 1px solid $border-color; border-radius: 50%; display: block; &:hover { + box-shadow: inset 0 0 0 0.0625rem $dropdown-toggle-active-border-color; background-color: $gray-darker; - border: 1px solid $dropdown-toggle-active-border-color; svg { fill: $gl-text-color; @@ -866,7 +854,7 @@ button.mini-pipeline-graph-dropdown-toggle { height: $ci-action-dropdown-svg-size; fill: $gl-text-color-secondary; position: relative; - top: auto; + top: 1px; vertical-align: initial; } } @@ -874,7 +862,7 @@ button.mini-pipeline-graph-dropdown-toggle { // SVGs in the commit widget and mr widget a.ci-action-icon-container.ci-action-icon-wrapper svg { - top: 4px; + top: 5px; } .scrollable-menu { @@ -1052,13 +1040,6 @@ button.mini-pipeline-graph-dropdown-toggle { .text-center { padding-top: 12px; } - - .header-action-buttons { - .btn, - a { - margin-left: 10px; - } - } } .pipelines-container .top-area .nav-controls > .btn:last-child { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 438f6c2546e..d4d6583312c 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -143,8 +143,8 @@ .group-home-panel, .project-home-panel { - padding-top: $gl-padding; - padding-bottom: $gl-padding; + margin-top: $gl-padding; + margin-bottom: $gl-padding; .home-panel-avatar { width: $home-panel-title-row-height; @@ -159,7 +159,7 @@ font-weight: bold; .icon { - font-size: $gl-font-size-large; + vertical-align: -1px; } .home-panel-topic-list { @@ -224,12 +224,6 @@ font-size: $gl-font-size-large; } } - - .notifications-btn { - .fa-bell { - margin-right: 0; - } - } } .nav > .project-buttons { @@ -472,17 +466,9 @@ margin-right: auto; } - a { - display: block; - width: 100%; - height: 100%; - padding-top: $gl-padding; - text-decoration: none; - - &.disabled { - opacity: 0.3; - cursor: not-allowed; - } + a.disabled { + opacity: 0.3; + cursor: not-allowed; } } @@ -839,7 +825,7 @@ } .repository-language-bar-tooltip-share { - color: $gray-400; + color: $gray-200; } pre.light-well { @@ -1538,3 +1524,10 @@ pre.light-well { } } } + +@include media-breakpoint-down(xs) { + .fork-filtered-search { + width: 100%; + margin: $gl-spacing-scale-2 0; + } +} diff --git a/app/assets/stylesheets/pages/prometheus.scss b/app/assets/stylesheets/pages/prometheus.scss index 6461d09bb47..a3b6cbdff25 100644 --- a/app/assets/stylesheets/pages/prometheus.scss +++ b/app/assets/stylesheets/pages/prometheus.scss @@ -48,7 +48,7 @@ &.sortable-chosen .draggable-panel { background: $white; - box-shadow: 0 0 4px $gray-500; + box-shadow: 0 0 4px $gray-300; } .draggable-remove { @@ -56,18 +56,13 @@ .draggable-remove-link { cursor: pointer; - color: $gray-600; + color: $gray-400; background-color: $white; } } } .prometheus-graphs-header { - .monitor-environment-dropdown-header header, - .monitor-dashboard-dropdown-header header { - font-size: $gl-font-size; - } - .monitor-environment-dropdown-menu, .monitor-dashboard-dropdown-menu { &.show { @@ -117,7 +112,7 @@ .prometheus-graph-cursor { position: absolute; - background: $gray-600; + background: $gray-400; width: 1px; } @@ -290,7 +285,7 @@ } > text { - fill: $gray-600; + fill: $gray-400; font-size: 10px; } } @@ -341,3 +336,11 @@ opacity: 0; pointer-events: all; } + +.prometheus-panel-builder { + .preview-date-time-picker { + // same as in .dropdown-menu-toggle + // see app/assets/stylesheets/framework/dropdowns.scss + width: 160px; + } +} diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index 56194f0af67..0c0605b0b3d 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -77,7 +77,7 @@ } &.neutral svg { - color: $gray-700; + color: $gray-500; } .ci-status-icon { diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss index 66d2f76c558..8ed6936475b 100644 --- a/app/assets/stylesheets/pages/runners.scss +++ b/app/assets/stylesheets/pages/runners.scss @@ -44,10 +44,6 @@ .btn-default { color: $gl-text-color-secondary; } - - .fa-pause { - font-size: 11px; - } } @include media-breakpoint-down(md) { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index d8084a20af9..1fc6ad62237 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -249,7 +249,7 @@ input[type='checkbox']:hover { .search-clear { position: absolute; right: 10px; - top: 10px; + top: 9px; padding: 0; color: $gray-darkest; line-height: 0; diff --git a/app/assets/stylesheets/pages/settings.scss b/app/assets/stylesheets/pages/settings.scss index f1df9099d82..b82c638a5b7 100644 --- a/app/assets/stylesheets/pages/settings.scss +++ b/app/assets/stylesheets/pages/settings.scss @@ -183,7 +183,7 @@ .option-description, .option-disabled-reason { - margin-left: 30px; + margin-left: 20px; color: $project-option-descr-color; margin-top: -5px; } @@ -366,7 +366,8 @@ margin-top: 1em; } -.ci-variable-table { +.ci-variable-table, +.deploy-freeze-table { table { thead { border-bottom: 1px solid $white-normal; diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 4f3d6fb0d44..4ad2dcbe92f 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -12,8 +12,6 @@ svg { height: 13px; width: 13px; - position: relative; - top: 2px; overflow: visible; } @@ -38,7 +36,7 @@ } &.ci-preparing { - @include status-color($gray-100, $gray-500, $gray-600); + @include status-color($gray-100, $gray-300, $gray-400); } &.ci-pending, diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index bbb37479fb0..c6f104a024b 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -143,6 +143,10 @@ margin-bottom: 0; } } + + .gl-label-scoped { + --label-inset-border: inset 0 0 0 1px currentColor; + } } @include media-breakpoint-down(sm) { diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index 22b5859e297..b6af395a802 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -138,12 +138,6 @@ } .tree-item { - .file-icon, - .folder-icon { - position: relative; - top: 2px; - } - .link-container { padding: 0; diff --git a/app/assets/stylesheets/pages/users.scss b/app/assets/stylesheets/pages/users.scss index 3b018c1e087..0863b573f75 100644 --- a/app/assets/stylesheets/pages/users.scss +++ b/app/assets/stylesheets/pages/users.scss @@ -25,8 +25,12 @@ } .form-control { - width: 100%; padding-right: 35px; + } + + .search-control-wrap, + .form-control { + width: 100%; @include media-breakpoint-up(sm) { width: 250px; diff --git a/app/assets/stylesheets/startup/_cloaking.scss b/app/assets/stylesheets/startup/_cloaking.scss new file mode 100644 index 00000000000..3c25feb0c5c --- /dev/null +++ b/app/assets/stylesheets/startup/_cloaking.scss @@ -0,0 +1,13 @@ +/** + Prevent flashing of content when using startup.css + */ +@mixin cloak-startup-scss($display) { + // Breadcrumbs and alerts on the top of the page + .content-wrapper > .alert-wrapper, + // Content on pages + #content-body, + // Prevent flashing of haml generated modal contents + .modal-dialog { + display: $display; + } +} diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss new file mode 100644 index 00000000000..2a7a9255ded --- /dev/null +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -0,0 +1,5 @@ +@charset "UTF-8";*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;overflow-y:scroll}header,nav{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Noto Sans",Ubuntu,Cantarell,"Helvetica Neue",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-weight:400;line-height:1.5;color:#303030;text-align:left;background-color:#fff}hr{box-sizing:content-box;height:0;margin-top:.5rem;margin-bottom:.5rem;border:0;border-top:1px solid rgba(0,0,0,.1);overflow:hidden;margin:24px 0;border-top:1px solid #eee}p,ul{margin-top:0;margin-bottom:1rem}ul ul{margin-bottom:0}strong{font-weight:700}a{text-decoration:none;background-color:transparent;color:#1068bf}a:not([href]){color:inherit;text-decoration:none}code{font-family:"Menlo","DejaVu Sans Mono","Liberation Mono","Consolas","Ubuntu Mono","Courier New","andale mono","lucida console",monospace;font-size:90%;word-wrap:break-word;padding:2px 4px;color:#1f1f1f;background-color:#f0f0f0;border-radius:4px}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:baseline;fill:currentColor}button{border-radius:0;text-transform:none}button,input{margin:0;font-family:inherit;font-size:inherit;line-height:inherit;overflow:visible}[type=button]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}[type=search]{outline-offset:-2px}summary{display:list-item;cursor:pointer}[hidden]{display:none!important}.h1,h1{margin-bottom:.25rem;font-weight:600;line-height:1.2;color:#303030;font-size:2.1875rem}.list-unstyled{padding-left:0;list-style:none}a>code{color:inherit}.container{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.container-fluid{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container{max-width:540px}}@media (min-width:768px){.container{max-width:720px}}@media (min-width:992px){.container{max-width:960px}}@media (min-width:1200px){.container{max-width:1140px}}.search form{display:block;padding:.375rem .75rem;font-weight:400;color:#303030;background-color:#fff;background-clip:padding-box;border-radius:.25rem}.search form::-ms-expand{background-color:transparent;border:0}.search form:-moz-focusring{color:transparent;text-shadow:0 0 0 #303030}.search form::placeholder{opacity:1;color:#919191}.search form:disabled{background-color:#fafafa;opacity:1}.form-inline{display:flex;flex-flow:row wrap;align-items:center}@media (min-width:576px){.form-inline .search form,.search .form-inline form{display:inline-block;width:auto;vertical-align:middle}}.btn{display:inline-block;text-align:center;vertical-align:middle;cursor:pointer;user-select:none;border:1px solid transparent;padding:.375rem .75rem;line-height:20px;border-radius:.25rem}.btn:disabled{opacity:.65}.btn-success{color:#fff;background-color:#108548;border-color:#108548}.btn-success:disabled{color:#fff;background-color:#108548;border-color:#108548}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-menu-toggle{color:#fff;background-color:#0b572f;border-color:#094c29}.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.collapse:not(.show){display:none}.dropdown-menu-toggle::after{margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-menu-toggle:empty::after{margin-left:0}.dropdown-menu{left:0;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#303030;text-align:left;list-style:none;background-clip:padding-box;border:1px solid rgba(0,0,0,.15)}.dropdown-menu-right{right:0;left:auto}.divider{height:0;margin:4px 0;overflow:hidden;border-top:1px solid #dbdbdb}.dropdown-menu.show{display:block}.nav{display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.navbar{position:relative;padding:.25rem .5rem}.navbar,.navbar .container,.navbar .container-fluid{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between}.navbar-nav{display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .dropdown-menu{float:none}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{flex-flow:row nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid{flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}.badge,.card{border-radius:.25rem}.card{position:relative;display:flex;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid #dbdbdb}.card>hr{margin-right:0;margin-left:0}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:600;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.close{float:right;font-size:1.5rem;font-weight:600;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}button.close{padding:0;background-color:transparent;border:0;appearance:none}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dbdbdb!important}.rounded{border-radius:.25rem!important}.d-none{display:none!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}@media (min-width:576px){.d-sm-none{display:none!important}}@media (min-width:768px){.d-md-block{display:block!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-block{display:block!important}}@media (min-width:1200px){.d-xl-block{display:block!important}}.float-right{float:right!important}.sr-only{white-space:nowrap}.m-auto{margin:auto!important}.text-nowrap{white-space:nowrap!important}.search form,body{font-size:.875rem}[role=button],button,html [type=button]{cursor:pointer}.h1,h1{margin-top:20px;margin-bottom:10px}input[type=file]{line-height:1}.code>code{background-color:inherit;padding:unset}.hidden{display:none!important;visibility:hidden!important}.dropdown-menu-toggle::after,.hide{display:none}.badge:not(.gl-badge){padding:4px 5px;font-size:12px;font-style:normal;font-weight:400;display:inline-block}.toggle-sidebar-button .collapse-text,.toggle-sidebar-button .icon-chevron-double-lg-left,.toggle-sidebar-button .icon-chevron-double-lg-right{color:#707070}body{text-decoration-skip:ink}.container{padding-top:0;z-index:5}.container .content{margin:0}@media (max-width:575.98px){.container .content{margin-top:20px}.container .container .title{padding-left:15px!important}}.btn{border-radius:4px;font-size:.875rem;font-weight:400;padding:6px 10px;background-color:#fff;border-color:#dbdbdb;color:#303030;white-space:nowrap}.btn:active{box-shadow:none}.btn.active,.btn:active{box-shadow:rgba(0,0,0,.16);background-color:#eaeaea;border-color:#e3e3e3;color:#303030}.btn.btn-sm{padding:4px 10px;font-size:13px;line-height:18px}.btn.btn-success{background-color:#108548;border-color:#217645;color:#fff}.btn.btn-success.active,.btn.btn-success:active{box-shadow:rgba(0,0,0,.16);background-color:#24663b;border-color:#0d532a;color:#fff}.btn svg{height:15px;width:15px;position:relative;top:2px}.btn .fa:not(:last-child),.btn svg:not(:last-child){margin-right:5px}.badge.badge-pill:not(.gl-badge){font-weight:400;background-color:rgba(0,0,0,.07);color:#4f4f4f;vertical-align:baseline}.loading{margin:20px auto;height:40px;color:#555;font-size:32px;text-align:center}.chart{overflow:hidden;height:220px}.center{text-align:center}.flex{display:flex}.dropdown{position:relative}.show.dropdown .dropdown-menu{transform:translateY(0);display:block;min-height:40px;max-height:312px;overflow-y:auto}@media (max-width:575.98px){.show.dropdown .dropdown-menu{width:100%}}.show.dropdown .dropdown-menu-toggle{border-color:#c4c4c4}.search-input-container .dropdown-menu{margin-top:11px}.dropdown-menu,.dropdown-menu-toggle{font-size:14px;background-color:#fff;border:1px solid #dbdbdb;border-radius:.25rem}.dropdown-menu-toggle{color:#303030;text-align:left;white-space:nowrap;padding:6px 25px 6px 10px;position:relative;width:160px;text-overflow:ellipsis;overflow:hidden}.no-outline.dropdown-menu-toggle,.show.dropdown [data-toggle=dropdown]{outline:0}.dropdown-menu-toggle .fa{color:#c4c4c4;position:absolute}.dropdown-menu{display:none;position:absolute;width:auto;top:100%;z-index:300;min-width:240px;max-width:500px;margin-top:4px;margin-bottom:24px;font-weight:400;padding:8px 0;box-shadow:0 2px 4px rgba(0,0,0,.1)}.dropdown-menu ul{margin:0;padding:0}.dropdown-menu li{display:block;text-align:left;list-style:none;padding:0 1px}.dropdown-menu li button,.dropdown-menu li>a{background:0 0;border:0;border-radius:0;box-shadow:none;display:block;font-weight:400;position:relative;padding:8px 12px;color:#303030;line-height:16px;white-space:normal;overflow:hidden;text-align:left;width:100%}.dropdown-menu li button:active,.dropdown-menu li>a:active{background-color:#eee;color:#303030;outline:0;text-decoration:none}.dropdown-menu li button:active .avatar,.dropdown-menu li>a:active .avatar{border-color:#fff}.dropdown-menu li button:active .badge.badge-pill,.dropdown-menu li>a:active .badge.badge-pill{background-color:#d3e7f9}.dropdown-menu .divider{height:1px;margin:.25rem 0;padding:0;background-color:#dbdbdb}.dropdown-menu .badge.badge-pill+span:not(.badge.badge-pill){margin-right:40px}.dropdown-select{width:300px}@media (max-width:767.98px){.dropdown-select{width:100%}}.dropdown-content{max-height:252px;overflow-y:auto}.dropdown-loading{position:absolute;top:0;right:0;bottom:0;left:0;display:none;z-index:9;background-color:rgba(255,255,255,.6);font-size:28px}.dropdown-loading .fa{position:absolute;top:50%;left:50%;margin-top:-14px;margin-left:-14px}@media (max-width:575.98px){.navbar-gitlab li.dropdown{position:static}header.navbar-gitlab .dropdown .dropdown-menu{width:100%;min-width:100%}}@media (max-width:767.98px){.dropdown-menu-toggle{width:100%}}input{border-radius:.25rem;color:#303030;background-color:#fff}.search form{margin:0;padding:4px;width:200px;line-height:24px;height:32px;border:0;border-radius:4px}body.ui-indigo .navbar-gitlab{background-color:#292961}body.ui-indigo .navbar-gitlab .nav>li,body.ui-indigo .navbar-gitlab .navbar-collapse,body.ui-indigo .navbar-gitlab .navbar-sub-nav{color:#d1d1f0}body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler{border-left:1px solid #6868b9}body.ui-indigo .navbar-gitlab .container-fluid .navbar-toggler svg{fill:#d1d1f0}body.ui-indigo .navbar-gitlab .nav>li.active>a,body.ui-indigo .navbar-gitlab .nav>li.dropdown.show>a,body.ui-indigo .navbar-gitlab .navbar-nav>li.active>a,body.ui-indigo .navbar-gitlab .navbar-nav>li.active>button,body.ui-indigo .navbar-gitlab .navbar-nav>li.dropdown.show>a,body.ui-indigo .navbar-gitlab .navbar-nav>li.dropdown.show>button,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.active>a,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.active>button,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.dropdown.show>a,body.ui-indigo .navbar-gitlab .navbar-sub-nav>li.dropdown.show>button{color:#292961;background-color:#fff}body.ui-indigo .navbar-gitlab .nav>li>a.header-user-dropdown-toggle .header-user-avatar{border-color:#d1d1f0}body.ui-indigo .search form{background-color:rgba(209,209,240,.2)}body.ui-indigo .search .search-input::placeholder{color:rgba(209,209,240,.8)}body.ui-indigo .search .search-input-wrap .clear-icon,body.ui-indigo .search .search-input-wrap .search-icon{fill:rgba(209,209,240,.8)}body.ui-indigo .nav-sidebar li.active{box-shadow:inset 4px 0 0 #4b4ba3}body.ui-indigo .nav-sidebar li.active>a,body.ui-indigo .sidebar-top-level-items>li.active .badge.badge-pill{color:#393982}body.ui-indigo .nav-sidebar li.active .nav-icon-container svg{fill:#393982}.navbar-gitlab{padding:0 16px;z-index:1000;margin-bottom:0;min-height:40px;border:0;border-bottom:1px solid #dbdbdb;position:fixed;top:0;left:0;right:0;border-radius:0}.navbar-gitlab .logo-text{line-height:initial}.navbar-gitlab .logo-text svg{width:55px;height:14px;margin:0;fill:#fff}.navbar-gitlab .close-icon{display:none}.navbar-gitlab .header-content{width:100%;display:flex;justify-content:space-between;position:relative;min-height:40px;padding-left:0}.navbar-gitlab .header-content .title-container{display:flex;align-items:stretch;flex:1 1 auto;padding-top:0;overflow:visible}.navbar-gitlab .header-content .title{padding-right:0;color:currentColor;display:flex;position:relative;margin:0;font-size:18px;vertical-align:top;white-space:nowrap}.navbar-gitlab .header-content .title img{height:28px}.navbar-gitlab .header-content .title img+.logo-text{margin-left:8px}.navbar-gitlab .header-content .title a{display:flex;align-items:center;padding:2px 8px;margin:5px 2px 5px -8px;border-radius:4px}.navbar-gitlab .header-content .dropdown.open>a{border-bottom-color:#fff}.navbar-gitlab .header-content .navbar-collapse>ul.nav>li:not(.d-none){margin:0 2px}.navbar-gitlab .navbar-collapse{flex:0 0 auto;border-top:0;padding:0}@media (max-width:575.98px){.navbar-gitlab .navbar-collapse{flex:1 1 auto}}.navbar-gitlab .navbar-collapse .nav{flex-wrap:nowrap}@media (max-width:575.98px){.navbar-gitlab .navbar-collapse .nav>li:not(.d-none) a{margin-left:0}}.navbar-gitlab .container-fluid{padding:0}.navbar-gitlab .container-fluid .user-counter svg{margin-right:3px}.navbar-gitlab .container-fluid .navbar-toggler{position:relative;right:-10px;border-radius:0;min-width:45px;padding:0;margin:8px -7px 8px 0;font-size:14px;text-align:center;color:currentColor}.navbar-gitlab .container-fluid .navbar-toggler.active{color:currentColor;background-color:transparent}@media (max-width:575.98px){.navbar-gitlab .container-fluid .navbar-nav{display:flex;padding-right:10px;flex-direction:row}}.navbar-gitlab .container-fluid .navbar-nav li .badge.badge-pill{box-shadow:none;font-weight:600}@media (max-width:575.98px){.navbar-gitlab .container-fluid .nav>li.header-user{padding-left:10px}}.navbar-gitlab .container-fluid .nav>li>a{will-change:color;margin:4px 0;padding:6px 8px;height:32px}@media (max-width:575.98px){.navbar-gitlab .container-fluid .nav>li>a{padding:0}}.navbar-gitlab .container-fluid .nav>li>a.header-user-dropdown-toggle{margin-left:2px}.navbar-gitlab .container-fluid .nav>li .header-new-dropdown-toggle,.navbar-gitlab .container-fluid .nav>li>a.header-user-dropdown-toggle .header-user-avatar{margin-right:0}.navbar-nav>li>a,.navbar-nav>li>button,.navbar-sub-nav>li>a,.navbar-sub-nav>li>button{display:flex;align-items:center;justify-content:center;padding:6px 8px;margin:4px 2px;font-size:12px;color:currentColor;border-radius:4px;height:32px;font-weight:600}.navbar-nav>li>button,.navbar-sub-nav>li>button{background:0 0;border:0}.navbar-nav .dropdown-menu,.navbar-sub-nav .dropdown-menu{position:absolute}.navbar-sub-nav{display:flex;margin:0 0 0 6px}.btn .caret-down,.caret-down{top:0;height:11px;width:11px;margin-left:4px;fill:currentColor}.header-new .dropdown-menu,.header-user .dropdown-menu{margin-top:4px}.btn-sign-in{background-color:#ebebfa;color:#292961;font-weight:600;line-height:18px;margin:4px 0 4px 2px}.navbar-nav .badge.badge-pill,.title-container .badge.badge-pill{position:inherit;font-weight:400;margin-left:-6px;font-size:11px;color:#fff;padding:0 5px;line-height:12px;border-radius:7px;box-shadow:0 1px 0 rgba(76,78,84,.2)}.navbar-nav .badge.badge-pill.green-badge,.title-container .badge.badge-pill.green-badge{background-color:#108548}.navbar-nav .badge.badge-pill.merge-requests-count,.title-container .badge.badge-pill.merge-requests-count{background-color:#de7e00}.navbar-nav .badge.badge-pill.todos-count,.title-container .badge.badge-pill.todos-count{background-color:#1f75cb}.navbar-nav .canary-badge .badge,.title-container .canary-badge .badge{font-size:12px;line-height:16px;padding:0 .5rem}@media (max-width:575.98px){.navbar-gitlab .container-fluid{font-size:18px}.navbar-gitlab .container-fluid .navbar-nav{table-layout:fixed;width:100%;margin:0;text-align:right}.navbar-gitlab .container-fluid .navbar-collapse{margin-left:-8px;margin-right:-10px}.navbar-gitlab .container-fluid .navbar-collapse .nav>li:not(.d-none){flex:1}.header-user-dropdown-toggle{text-align:center}.header-user-avatar{float:none}}.header-user.show .dropdown-menu{margin-top:4px;color:#303030;left:auto;max-height:445px}.header-user.show .dropdown-menu svg{vertical-align:text-top}.header-user-avatar{float:left;margin-right:5px;border-radius:50%;border:1px solid #f5f5f5}.media{display:flex;align-items:flex-start}.card{margin-bottom:16px}@media (min-width:768px){.page-with-contextual-sidebar{padding-left:50px}}@media (min-width:1200px){.page-with-contextual-sidebar{padding-left:220px}}.context-header{position:relative;margin-right:2px;width:220px}.context-header>a,.context-header>button{font-weight:600;display:flex;width:100%;align-items:center;padding:10px 16px 10px 10px;color:#303030;background-color:transparent;border:0;text-align:left}.context-header .avatar-container{flex:0 0 40px;background-color:#fff}.context-header .sidebar-context-title{overflow:hidden;text-overflow:ellipsis}.context-header .sidebar-context-title.text-secondary{font-weight:400;font-size:.8em}.nav-sidebar{position:fixed;z-index:600;width:220px;top:40px;bottom:0;left:0;background-color:#fafafa;box-shadow:inset -1px 0 0 #dbdbdb;transform:translate3d(0,0,0)}@media (min-width:576px) and (max-width:576px){.nav-sidebar:not(.sidebar-collapsed-desktop){box-shadow:inset -1px 0 0 #dbdbdb,2px 1px 3px rgba(0,0,0,.1)}}.nav-sidebar a{text-decoration:none}.nav-sidebar ul{padding-left:0;list-style:none}.nav-sidebar li{white-space:nowrap}.nav-sidebar li a{display:flex;align-items:center;padding:12px 16px;color:#707070}.nav-sidebar li .nav-item-name{flex:1}.nav-sidebar li.active>a,.sidebar-top-level-items>li.active .badge.badge-pill{font-weight:600}@media (max-width:767.98px){.nav-sidebar{left:-220px}}.nav-sidebar .nav-icon-container{display:flex;margin-right:8px}.nav-sidebar .fly-out-top-item{display:none}.nav-sidebar svg{height:16px;width:16px}@media (min-width:768px) and (max-width:1199px){.nav-sidebar:not(.sidebar-expanded-mobile){width:50px}.nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll{overflow-x:hidden}.nav-sidebar:not(.sidebar-expanded-mobile) .badge.badge-pill:not(.fly-out-badge),.nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name,.nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items>li>a{min-height:45px}.nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item{display:block}.nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container{margin:0 auto}.nav-sidebar:not(.sidebar-expanded-mobile) .context-header{height:60px;width:50px}.nav-sidebar:not(.sidebar-expanded-mobile) .context-header a{padding:10px 4px}.nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items>li .sidebar-sub-level-items:not(.flyout-list),.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .collapse-text,.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-left{display:none}.nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container{margin-right:0}.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button{padding:16px;width:49px}.nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button .icon-chevron-double-lg-right{display:block;margin:0}}.nav-sidebar-inner-scroll{height:100%;width:100%;overflow:auto}.sidebar-sub-level-items{display:none;padding-bottom:8px}.sidebar-sub-level-items>li a{padding:8px 16px 8px 40px}.sidebar-sub-level-items>li.active a,.sidebar-top-level-items>li.active{background:rgba(0,0,0,.04)}.sidebar-top-level-items{margin-bottom:60px}@media (min-width:576px){.sidebar-top-level-items>li>a{margin-right:1px}}.sidebar-top-level-items>li .badge.badge-pill{background-color:rgba(0,0,0,.08);color:#707070}.sidebar-top-level-items>li.active>a{margin-left:4px;padding-left:12px}.sidebar-top-level-items>li.active .sidebar-sub-level-items:not(.is-fly-out-only){display:block}.close-nav-button,.toggle-sidebar-button{width:219px;position:fixed;height:48px;bottom:0;padding:0 16px;background-color:#fafafa;border:0;border-top:1px solid #dbdbdb;color:#707070;display:flex;align-items:center}.close-nav-button svg,.toggle-sidebar-button svg{margin-right:8px}.close-nav-button .icon-chevron-double-lg-right,.toggle-sidebar-button .icon-chevron-double-lg-right{display:none}.collapse-text{white-space:nowrap;overflow:hidden}.fly-out-top-item>a{display:flex}.fly-out-top-item .fly-out-badge{margin-left:8px}.fly-out-top-item-name{flex:1}.close-nav-button{display:none}@media (max-width:767.98px){.close-nav-button{display:flex}.toggle-sidebar-button{display:none}}input::-moz-placeholder{color:#919191;opacity:1}input:-ms-input-placeholder,input::-ms-input-placeholder{color:#919191}svg.s12{width:12px;height:12px}svg.s16{width:16px;height:16px}svg.s18{width:18px;height:18px}.feature-highlight-popover-sub-content{padding:16px 12px}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.color-label{padding:0 .5rem;line-height:16px;border-radius:100px;color:#fff}.label-link{display:inline-flex;vertical-align:text-bottom}.milestones{padding:8px;margin-top:8px;border-radius:4px;background-color:#dbdbdb}.search{margin:0 8px}@media (min-width:1200px){.search form{width:320px}}.search .search-input{border:0;font-size:14px;padding:0 20px 0 0;margin-left:5px;line-height:25px;width:98%;color:#fff;background:0 0}.search .search-input-container{display:flex;position:relative}.search .search-input-wrap{width:100%}.search .search-input-wrap .clear-icon,.search .search-input-wrap .search-icon{position:absolute;right:5px;top:4px}.search .search-input-wrap .search-icon{-moz-user-select:none;user-select:none}.search .search-input-wrap .clear-icon{display:none}.search .search-input-wrap .dropdown{position:static}.search .search-input-wrap .dropdown-menu{left:-5px;max-height:400px;overflow:auto}@media (min-width:1200px){.search .search-input-wrap .dropdown-menu{width:320px}}.search .search-input-wrap .dropdown-content{max-height:382px}.search .identicon{flex-basis:16px;flex-shrink:0;margin-right:4px}.settings{border-top:1px solid #dbdbdb}.settings:first-of-type{margin-top:10px;border:0}.settings+div .settings:first-of-type{margin-top:0;border-top:1px solid #dbdbdb}.avatar,.avatar-container{float:left;margin-right:16px;border-radius:50%;border:1px solid #f5f5f5}.s16.avatar,.s16.avatar-container{width:16px;height:16px;margin-right:8px}.s18.avatar,.s18.avatar-container{width:18px;height:18px;margin-right:8px}.s40.avatar,.s40.avatar-container{width:40px;height:40px;margin-right:8px}.avatar{transition-property:none;width:40px;height:40px;padding:0;background:#fdfdfd;overflow:hidden;border-color:rgba(0,0,0,.1)}.avatar.center{font-size:14px;line-height:1.8em;text-align:center}.avatar.avatar-tile{border-radius:0;border:0}.identicon{text-align:center;vertical-align:top;color:#4f4f4f;background-color:#eee}.identicon.s16{font-size:10px;line-height:16px}.identicon.s40{font-size:16px;line-height:38px}.avatar-container{overflow:hidden;display:flex}.avatar-container a{width:100%;height:100%;display:flex;text-decoration:none}.avatar-container .avatar{border-radius:0;border:0;height:auto;width:100%;margin:0;align-self:center}.avatar-container.s40{min-width:40px;min-height:40px}.rect-avatar,.rect-avatar.s16,.rect-avatar.s18{border-radius:2px}.rect-avatar.s40{border-radius:4px}.tab-width-8{-moz-tab-size:8;tab-size:8}.gl-sr-only{border:0;clip:rect(0,0,0,0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;white-space:nowrap;width:1px}.gl-ml-3{margin-left:.5rem} + +/* Cloaking in order to prevent flickering of content */ +@import 'cloaking'; +@include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 38842ec167e..99a13cc4e44 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -14,12 +14,6 @@ #{'.text-#{$variant}-#{$suffix}'} { color: $color; } - - #{'.hover-text-#{$variant}-#{$suffix}'} { - &:hover { - color: $color; - } - } } } @@ -82,6 +76,10 @@ .gl-h-32 { height: px-to-rem($grid-size * 4); } .gl-h-64 { height: px-to-rem($grid-size * 8); } +// Migrate this to Gitlab UI when FF is removed +// https://gitlab.com/groups/gitlab-org/-/epics/2882 +.gl-h-200\! { height: px-to-rem($grid-size * 25) !important; } + .d-sm-table-column { @include media-breakpoint-up(sm) { display: table-column !important; diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 41a6616d10c..3a5b8b2862e 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -16,7 +16,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController push_frontend_feature_flag(:ci_instance_variables_ui, default_enabled: true) end - VALID_SETTING_PANELS = %w(general integrations repository + VALID_SETTING_PANELS = %w(general repository ci_cd reporting metrics_and_profiling network preferences).freeze @@ -32,12 +32,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def integrations - if Feature.enabled?(:instance_level_integrations) - @integrations = Service.find_or_initialize_instances.sort_by(&:title) - else - set_application_setting - perform_update if submitted? - end + @integrations = Service.find_or_initialize_instances.sort_by(&:title) end def update @@ -225,7 +220,6 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :lets_encrypt_terms_of_service_accepted, :domain_blacklist_file, :raw_blob_request_limit, - :namespace_storage_size_limit, :issues_create_limit, :default_branch_name, disabled_oauth_sign_in_sources: [], diff --git a/app/controllers/admin/integrations_controller.rb b/app/controllers/admin/integrations_controller.rb index 4f3be43d14d..b2d5a2d130c 100644 --- a/app/controllers/admin/integrations_controller.rb +++ b/app/controllers/admin/integrations_controller.rb @@ -12,7 +12,7 @@ class Admin::IntegrationsController < Admin::ApplicationController end def integrations_enabled? - Feature.enabled?(:instance_level_integrations) + true end def scoped_edit_integration_path(integration) diff --git a/app/controllers/admin/services_controller.rb b/app/controllers/admin/services_controller.rb index e0137accd2d..1bc82e98ab8 100644 --- a/app/controllers/admin/services_controller.rb +++ b/app/controllers/admin/services_controller.rb @@ -5,9 +5,6 @@ class Admin::ServicesController < Admin::ApplicationController before_action :service, only: [:edit, :update] before_action :whitelist_query_limiting, only: [:index] - before_action only: :edit do - push_frontend_feature_flag(:integration_form_refactor, default_enabled: true) - end def index @services = Service.find_or_create_templates.sort_by(&:title) diff --git a/app/controllers/clusters/base_controller.rb b/app/controllers/clusters/base_controller.rb index c79a0bb01bc..188805c6106 100644 --- a/app/controllers/clusters/base_controller.rb +++ b/app/controllers/clusters/base_controller.rb @@ -6,10 +6,6 @@ class Clusters::BaseController < ApplicationController skip_before_action :authenticate_user! before_action :authorize_read_cluster! - before_action do - push_frontend_feature_flag(:managed_apps_local_tiller, clusterable, default_enabled: true) - end - helper_method :clusterable private diff --git a/app/controllers/concerns/authenticates_with_two_factor.rb b/app/controllers/concerns/authenticates_with_two_factor.rb index b885e55f902..4b4bcc8d37e 100644 --- a/app/controllers/concerns/authenticates_with_two_factor.rb +++ b/app/controllers/concerns/authenticates_with_two_factor.rb @@ -33,9 +33,7 @@ module AuthenticatesWithTwoFactor end def locked_user_redirect(user) - flash.now[:alert] = locked_user_redirect_alert(user) - - render 'devise/sessions/new' + redirect_to new_user_session_path, alert: locked_user_redirect_alert(user) end def authenticate_with_two_factor @@ -54,7 +52,13 @@ module AuthenticatesWithTwoFactor private def locked_user_redirect_alert(user) - user.access_locked? ? _('Your account is locked.') : _('Invalid Login or password') + if user.access_locked? + _('Your account is locked.') + elsif !user.confirmed? + I18n.t('devise.failure.unconfirmed') + else + _('Invalid Login or password') + end end def clear_two_factor_attempt! diff --git a/app/controllers/concerns/checks_collaboration.rb b/app/controllers/concerns/checks_collaboration.rb index 1fa82f7dcd4..87239facdeb 100644 --- a/app/controllers/concerns/checks_collaboration.rb +++ b/app/controllers/concerns/checks_collaboration.rb @@ -17,7 +17,7 @@ module ChecksCollaboration # used across multiple calls in the view def user_access(project) @user_access ||= {} - @user_access[project] ||= Gitlab::UserAccess.new(current_user, project: project) + @user_access[project] ||= Gitlab::UserAccess.new(current_user, container: project) end # rubocop:enable Gitlab/ModuleWithInstanceVariables end diff --git a/app/controllers/concerns/graceful_timeout_handling.rb b/app/controllers/concerns/graceful_timeout_handling.rb new file mode 100644 index 00000000000..490c0ec3b1d --- /dev/null +++ b/app/controllers/concerns/graceful_timeout_handling.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module GracefulTimeoutHandling + extend ActiveSupport::Concern + + included do + rescue_from ActiveRecord::QueryCanceled do |exception| + raise exception unless request.format.json? + + log_exception(exception) + + render json: { error: _('There is too much data to calculate. Please change your selection.') } + end + end +end diff --git a/app/controllers/concerns/integrations_actions.rb b/app/controllers/concerns/integrations_actions.rb index 46febc44807..9a8e5d14123 100644 --- a/app/controllers/concerns/integrations_actions.rb +++ b/app/controllers/concerns/integrations_actions.rb @@ -8,9 +8,6 @@ module IntegrationsActions before_action :not_found, unless: :integrations_enabled? before_action :integration, only: [:edit, :update, :test] - before_action only: :edit do - push_frontend_feature_flag(:integration_form_refactor, default_enabled: true) - end end def edit diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 4f61e5ed711..89ba2175b60 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -65,7 +65,7 @@ module IssuableCollections def page_count_for_relation(relation, row_count) limit = relation.limit_value.to_f - return 1 if limit.zero? + return 1 if limit == 0 (row_count.to_f / limit).ceil end diff --git a/app/controllers/concerns/packages_access.rb b/app/controllers/concerns/packages_access.rb new file mode 100644 index 00000000000..6df2e064bb2 --- /dev/null +++ b/app/controllers/concerns/packages_access.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module PackagesAccess + extend ActiveSupport::Concern + + included do + before_action :verify_packages_enabled! + before_action :verify_read_package! + end + + private + + def verify_packages_enabled! + render_404 unless Gitlab.config.packages.enabled + end + + def verify_read_package! + authorize_read_package!(project) + end +end diff --git a/app/controllers/concerns/paginated_collection.rb b/app/controllers/concerns/paginated_collection.rb index be84215a9e2..fcee4493314 100644 --- a/app/controllers/concerns/paginated_collection.rb +++ b/app/controllers/concerns/paginated_collection.rb @@ -6,7 +6,7 @@ module PaginatedCollection private def redirect_out_of_range(collection, total_pages = collection.total_pages) - return false if total_pages.zero? + return false if total_pages == 0 out_of_range = collection.current_page > total_pages diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb index b8026c7a01d..a15bf27a22f 100644 --- a/app/controllers/concerns/renders_blob.rb +++ b/app/controllers/concerns/renders_blob.rb @@ -29,6 +29,12 @@ module RendersBlob end def conditionally_expand_blob(blob) - blob.expand! if params[:expanded] == 'true' + conditionally_expand_blobs([blob]) + end + + def conditionally_expand_blobs(blobs) + return unless params[:expanded] == 'true' + + blobs.each { |blob| blob.expand! } end end diff --git a/app/controllers/concerns/send_file_upload.rb b/app/controllers/concerns/send_file_upload.rb index 2f5dc09be4a..7cb19fc7e58 100644 --- a/app/controllers/concerns/send_file_upload.rb +++ b/app/controllers/concerns/send_file_upload.rb @@ -18,7 +18,11 @@ module SendFileUpload send_params.merge!(filename: attachment, disposition: disposition) end - if file_upload.file_storage? + if image_scaling_request?(file_upload) + location = file_upload.file_storage? ? file_upload.path : file_upload.url + headers.store(*Gitlab::Workhorse.send_scaled_image(location, params[:width].to_i)) + head :ok + elsif file_upload.file_storage? send_file file_upload.path, send_params elsif file_upload.class.proxy_download_enabled? || proxy headers.store(*Gitlab::Workhorse.send_url(file_upload.url(**redirect_params))) @@ -37,4 +41,19 @@ module SendFileUpload "application/octet-stream" end end + + private + + def image_scaling_request?(file_upload) + avatar_image_upload?(file_upload) && valid_image_scaling_width? && current_user && + Feature.enabled?(:dynamic_image_resizing, current_user) + end + + def avatar_image_upload?(file_upload) + file_upload.try(:image?) && file_upload.try(:mounted_as)&.to_sym == :avatar + end + + def valid_image_scaling_width? + Avatarable::ALLOWED_IMAGE_SCALER_WIDTHS.include?(params[:width]&.to_i) + end end diff --git a/app/controllers/concerns/snippets_actions.rb b/app/controllers/concerns/snippets_actions.rb index 048b18c5c61..5552fd663f7 100644 --- a/app/controllers/concerns/snippets_actions.rb +++ b/app/controllers/concerns/snippets_actions.rb @@ -55,10 +55,9 @@ module SnippetsActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def show - conditionally_expand_blob(blob) - respond_to do |format| format.html do + conditionally_expand_blob(blob) @note = Note.new(noteable: @snippet, project: @snippet.project) @noteable = @snippet @@ -68,11 +67,14 @@ module SnippetsActions end format.json do + conditionally_expand_blob(blob) render_blob_json(blob) end format.js do if @snippet.embeddable? + conditionally_expand_blobs(blobs) + render 'shared/snippets/show' else head :not_found @@ -109,13 +111,15 @@ module SnippetsActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def blob - return unless snippet + @blob ||= blobs.first + end - @blob ||= if snippet.empty_repo? - snippet.blob - else - snippet.blobs.first - end + def blobs + @blobs ||= if snippet.empty_repo? + [snippet.blob] + else + snippet.blobs + end end # rubocop:enable Gitlab/ModuleWithInstanceVariables @@ -132,6 +136,8 @@ module SnippetsActions end def redirect_if_binary + return if Feature.enabled?(:snippets_binary_blob) + redirect_to gitlab_snippet_path(snippet) if blob&.binary? end end diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index a5182000f5b..5b953fe37d6 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -8,6 +8,8 @@ module WikiActions extend ActiveSupport::Concern included do + before_action { respond_to :html } + before_action :authorize_read_wiki! before_action :authorize_create_wiki!, only: [:edit, :create] before_action :authorize_admin_wiki!, only: :destroy @@ -65,6 +67,8 @@ module WikiActions @ref = params[:version_id] @path = page.path + Gitlab::UsageDataCounters::WikiPageCounter.count(:view) + render 'shared/wikis/show' elsif file_blob send_blob(wiki.repository, file_blob) @@ -107,14 +111,16 @@ module WikiActions # rubocop:disable Gitlab/ModuleWithInstanceVariables def create - @page = WikiPages::CreateService.new(container: container, current_user: current_user, params: wiki_params).execute + response = WikiPages::CreateService.new(container: container, current_user: current_user, params: wiki_params).execute + @page = response.payload[:page] - if page.persisted? + if response.success? redirect_to( wiki_page_path(wiki, page), notice: _('Wiki was successfully updated.') ) else + flash[:alert] = response.message render 'shared/wikis/edit' end rescue Gitlab::Git::Wiki::OperationError => e diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index ad64b6c4f94..91704f030cd 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -9,7 +9,7 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include FiltersEvents prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } - before_action :set_non_archived_param + before_action :set_non_archived_param, only: [:index, :starred] before_action :set_sorting before_action :projects, only: [:index] skip_cross_project_access_check :index, :starred diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index db40b0bed77..4fc2f7b0571 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -9,8 +9,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController before_action :authorize_read_group!, only: :index before_action :find_todos, only: [:index, :destroy_all] - track_unique_visits :index, target_id: 'u_analytics_todos' - def index @sort = params[:sort] @todos = @todos.page(params[:page]) diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index f1f41e67a4c..b3fa089a712 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -80,7 +80,7 @@ class Explore::ProjectsController < Explore::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def preload_associations(projects) - projects.includes(:route, :creator, :group, namespace: [:route, :owner]) + projects.includes(:route, :creator, :group, :project_feature, namespace: [:route, :owner]) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/groups/packages_controller.rb b/app/controllers/groups/packages_controller.rb new file mode 100644 index 00000000000..600acc72e67 --- /dev/null +++ b/app/controllers/groups/packages_controller.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Groups + class PackagesController < Groups::ApplicationController + before_action :verify_packages_enabled! + + private + + def verify_packages_enabled! + render_404 unless group.packages_feature_enabled? + end + end +end diff --git a/app/controllers/groups/releases_controller.rb b/app/controllers/groups/releases_controller.rb new file mode 100644 index 00000000000..500c57a6f3e --- /dev/null +++ b/app/controllers/groups/releases_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Groups + class ReleasesController < Groups::ApplicationController + def index + respond_to do |format| + format.json do + render json: ReleaseSerializer.new.represent(releases) + end + end + end + + private + + def releases + ReleasesFinder + .new(@group, current_user, { include_subgroups: true }) + .execute(preload: false) + .page(params[:page]) + .per(30) + end + end +end diff --git a/app/controllers/groups/variables_controller.rb b/app/controllers/groups/variables_controller.rb index 02b015e8e53..fb639f6e472 100644 --- a/app/controllers/groups/variables_controller.rb +++ b/app/controllers/groups/variables_controller.rb @@ -15,7 +15,12 @@ module Groups end def update - if @group.update(group_variables_params) + update_result = Ci::ChangeVariablesService.new( + container: @group, current_user: current_user, + params: group_variables_params + ).execute + + if update_result respond_to do |format| format.json { render_group_variables } end diff --git a/app/controllers/import/available_namespaces_controller.rb b/app/controllers/import/available_namespaces_controller.rb new file mode 100644 index 00000000000..7983b4f20b5 --- /dev/null +++ b/app/controllers/import/available_namespaces_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Import::AvailableNamespacesController < ApplicationController + def index + render json: NamespaceSerializer.new.represent(current_user.manageable_groups_with_routes) + end +end diff --git a/app/controllers/import/base_controller.rb b/app/controllers/import/base_controller.rb index bc05030f8af..8a7a4c92b37 100644 --- a/app/controllers/import/base_controller.rb +++ b/app/controllers/import/base_controller.rb @@ -41,6 +41,10 @@ class Import::BaseController < ApplicationController raise NotImplementedError end + def extra_representation_opts + {} + end + private def filter_attribute @@ -58,11 +62,11 @@ class Import::BaseController < ApplicationController end def serialized_provider_repos - Import::ProviderRepoSerializer.new(current_user: current_user).represent(importable_repos, provider: provider_name, provider_url: provider_url) + Import::ProviderRepoSerializer.new(current_user: current_user).represent(importable_repos, provider: provider_name, provider_url: provider_url, **extra_representation_opts) end def serialized_incompatible_repos - Import::ProviderRepoSerializer.new(current_user: current_user).represent(incompatible_repos, provider: provider_name, provider_url: provider_url) + Import::ProviderRepoSerializer.new(current_user: current_user).represent(incompatible_repos, provider: provider_name, provider_url: provider_url, **extra_representation_opts) end def serialized_imported_projects diff --git a/app/controllers/import/gitea_controller.rb b/app/controllers/import/gitea_controller.rb index efeff8439e4..4785a71b8a1 100644 --- a/app/controllers/import/gitea_controller.rb +++ b/app/controllers/import/gitea_controller.rb @@ -54,6 +54,16 @@ class Import::GiteaController < Import::GithubController end end + override :client_repos + def client_repos + @client_repos ||= filtered(client.repos) + end + + override :client + def client + @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) + end + override :client_options def client_options { host: provider_url, api_version: 'v1' } diff --git a/app/controllers/import/github_controller.rb b/app/controllers/import/github_controller.rb index ac6b8c06d66..29fe34f0734 100644 --- a/app/controllers/import/github_controller.rb +++ b/app/controllers/import/github_controller.rb @@ -10,6 +10,9 @@ class Import::GithubController < Import::BaseController before_action :provider_auth, only: [:status, :realtime_changes, :create] before_action :expire_etag_cache, only: [:status, :create] + OAuthConfigMissingError = Class.new(StandardError) + + rescue_from OAuthConfigMissingError, with: :missing_oauth_config rescue_from Octokit::Unauthorized, with: :provider_unauthorized rescue_from Octokit::TooManyRequests, with: :provider_rate_limit @@ -22,7 +25,7 @@ class Import::GithubController < Import::BaseController end def callback - session[access_token_key] = client.get_token(params[:code]) + session[access_token_key] = get_token(params[:code]) redirect_to status_import_url end @@ -77,9 +80,7 @@ class Import::GithubController < Import::BaseController override :provider_url def provider_url strong_memoize(:provider_url) do - provider = Gitlab::Auth::OAuth::Provider.config_for('github') - - provider&.dig('url').presence || 'https://github.com' + oauth_config&.dig('url').presence || 'https://github.com' end end @@ -104,11 +105,66 @@ class Import::GithubController < Import::BaseController end def client - @client ||= Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) + @client ||= if Feature.enabled?(:remove_legacy_github_client) + Gitlab::GithubImport::Client.new(session[access_token_key]) + else + Gitlab::LegacyGithubImport::Client.new(session[access_token_key], client_options) + end end def client_repos - @client_repos ||= filtered(client.repos) + @client_repos ||= if Feature.enabled?(:remove_legacy_github_client) + filtered(concatenated_repos) + else + filtered(client.repos) + end + end + + def concatenated_repos + return [] unless client.respond_to?(:each_page) + + client.each_page(:repos).flat_map(&:objects) + end + + def oauth_client + raise OAuthConfigMissingError unless oauth_config + + @oauth_client ||= ::OAuth2::Client.new( + oauth_config.app_id, + oauth_config.app_secret, + oauth_options.merge(ssl: { verify: oauth_config['verify_ssl'] }) + ) + end + + def oauth_config + @oauth_config ||= Gitlab::Auth::OAuth::Provider.config_for('github') + end + + def oauth_options + if oauth_config + oauth_config.dig('args', 'client_options').deep_symbolize_keys + else + OmniAuth::Strategies::GitHub.default_options[:client_options].symbolize_keys + end + end + + def authorize_url + if Feature.enabled?(:remove_legacy_github_client) + oauth_client.auth_code.authorize_url( + redirect_uri: callback_import_url, + scope: 'repo, user, user:email' + ) + else + client.authorize_url(callback_import_url) + end + end + + def get_token(code) + if Feature.enabled?(:remove_legacy_github_client) + oauth_client.auth_code.get_token(code).token + else + client.get_token(code) + end end def verify_import_enabled @@ -116,7 +172,7 @@ class Import::GithubController < Import::BaseController end def go_to_provider_for_permissions - redirect_to client.authorize_url(callback_import_url) + redirect_to authorize_url end def import_enabled? @@ -152,6 +208,12 @@ class Import::GithubController < Import::BaseController alert: _("GitHub API rate limit exceeded. Try again after %{reset_time}") % { reset_time: reset_time } end + def missing_oauth_config + session[access_token_key] = nil + redirect_to new_import_url, + alert: _('Missing OAuth configuration for GitHub.') + end + def access_token_key :"#{provider_name}_access_token" end diff --git a/app/controllers/import/manifest_controller.rb b/app/controllers/import/manifest_controller.rb index 9aec870c6ea..9c47e6d4b0b 100644 --- a/app/controllers/import/manifest_controller.rb +++ b/app/controllers/import/manifest_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Import::ManifestController < Import::BaseController + extend ::Gitlab::Utils::Override + before_action :whitelist_query_limiting, only: [:create] before_action :verify_import_enabled before_action :ensure_import_vars, only: [:create, :status] @@ -8,16 +10,9 @@ class Import::ManifestController < Import::BaseController def new end - # rubocop: disable CodeReuse/ActiveRecord def status - @already_added_projects = find_already_added_projects - already_added_import_urls = @already_added_projects.pluck(:import_url) - - @pending_repositories = repositories.to_a.reject do |repository| - already_added_import_urls.include?(repository[:url]) - end + super end - # rubocop: enable CodeReuse/ActiveRecord def upload group = Group.find(params[:group_id]) @@ -42,8 +37,8 @@ class Import::ManifestController < Import::BaseController end end - def jobs - render json: find_jobs + def realtime_changes + super end def create @@ -54,12 +49,43 @@ class Import::ManifestController < Import::BaseController project = Gitlab::ManifestImport::ProjectCreator.new(repository, group, current_user).execute if project.persisted? - render json: ProjectSerializer.new.represent(project) + render json: ProjectSerializer.new.represent(project, serializer: :import) else render json: { errors: project_save_error(project) }, status: :unprocessable_entity end end + protected + + # rubocop: disable CodeReuse/ActiveRecord + override :importable_repos + def importable_repos + already_added_projects_names = already_added_projects.pluck(:import_url) + + repositories.reject { |repo| already_added_projects_names.include?(repo[:url]) } + end + # rubocop: enable CodeReuse/ActiveRecord + + override :incompatible_repos + def incompatible_repos + [] + end + + override :provider_name + def provider_name + :manifest + end + + override :provider_url + def provider_url + nil + end + + override :extra_representation_opts + def extra_representation_opts + { group_full_path: group.full_path } + end + private def ensure_import_vars @@ -82,15 +108,6 @@ class Import::ManifestController < Import::BaseController find_already_added_projects.to_json(only: [:id], methods: [:import_status]) end - # rubocop: disable CodeReuse/ActiveRecord - def find_already_added_projects - group.all_projects - .where(import_type: 'manifest') - .where(creator_id: current_user) - .with_import_state - end - # rubocop: enable CodeReuse/ActiveRecord - def verify_import_enabled render_404 unless manifest_import_enabled? end diff --git a/app/controllers/invites_controller.rb b/app/controllers/invites_controller.rb index 5bd9ac7f275..29cafbbbdb6 100644 --- a/app/controllers/invites_controller.rb +++ b/app/controllers/invites_controller.rb @@ -4,6 +4,7 @@ class InvitesController < ApplicationController include Gitlab::Utils::StrongMemoize before_action :member + before_action :invite_details skip_before_action :authenticate_user!, only: :decline helper_method :member?, :current_user_matches_invite? @@ -16,9 +17,8 @@ class InvitesController < ApplicationController def accept if member.accept_invite!(current_user) - label, path = source_info(member.source) - - redirect_to path, notice: _("You have been granted %{member_human_access} access to %{label}.") % { member_human_access: member.human_access, label: label } + redirect_to invite_details[:path], notice: _("You have been granted %{member_human_access} access to %{title} %{name}.") % + { member_human_access: member.human_access, title: invite_details[:title], name: invite_details[:name] } else redirect_back_or_default(options: { alert: _("The invitation could not be accepted.") }) end @@ -26,8 +26,6 @@ class InvitesController < ApplicationController def decline if member.decline_invite! - label, _ = source_info(member.source) - path = if current_user dashboard_projects_path @@ -35,7 +33,8 @@ class InvitesController < ApplicationController new_user_session_path end - redirect_to path, notice: _("You have declined the invitation to join %{label}.") % { label: label } + redirect_to path, notice: _("You have declined the invitation to join %{title} %{name}.") % + { title: invite_details[:title], name: invite_details[:name] } else redirect_back_or_default(options: { alert: _("The invitation could not be declined.") }) end @@ -76,24 +75,25 @@ class InvitesController < ApplicationController notice = notice.join(' ') + "." store_location_for :user, request.fullpath - redirect_to new_user_session_path, notice: notice + redirect_to new_user_session_path(invite_email: member.invite_email), notice: notice end - def source_info(source) - case source - when Project - project = member.source - label = "project #{project.full_name}" - path = project_path(project) - when Group - group = member.source - label = "group #{group.name}" - path = group_path(group) - else - label = "who knows what" - path = dashboard_projects_path - end - - [label, path] + def invite_details + @invite_details ||= case @member.source + when Project + { + name: @member.source.full_name, + url: project_url(@member.source), + title: _("project"), + path: project_path(@member.source) + } + when Group + { + name: @member.source.name, + url: group_url(@member.source), + title: _("group"), + path: group_path(@member.source) + } + end end end diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb index 706a4843117..6a393405e4d 100644 --- a/app/controllers/omniauth_callbacks_controller.rb +++ b/app/controllers/omniauth_callbacks_controller.rb @@ -27,6 +27,7 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController user = User.by_login(params[:username]) user&.increment_failed_attempts! + log_failed_login(params[:username], failed_strategy.name) end super @@ -90,6 +91,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController private + def log_failed_login(user, provider) + # overridden in EE + end + def after_omniauth_failure_path_for(scope) if Feature.enabled?(:user_mode_in_session) return new_admin_session_path if current_user_mode.admin_mode_requested? @@ -198,6 +203,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController end def fail_login(user) + log_failed_login(user.username, oauth['provider']) + error_message = user.errors.full_messages.to_sentence redirect_to omniauth_error_path(oauth['provider'], error: error_message) diff --git a/app/controllers/profiles/passwords_controller.rb b/app/controllers/profiles/passwords_controller.rb index d2787c2e450..fccbc29f598 100644 --- a/app/controllers/profiles/passwords_controller.rb +++ b/app/controllers/profiles/passwords_controller.rb @@ -52,7 +52,7 @@ class Profiles::PasswordsController < Profiles::ApplicationController result = Users::UpdateService.new(current_user, password_attributes.merge(user: @user)).execute if result[:status] == :success - flash[:notice] = _('Password was successfully updated. Please login with it') + flash[:notice] = _('Password was successfully updated. Please sign in again.') redirect_to new_user_session_path else @user.reset diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 30f25e8fdaa..21adc032940 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -20,12 +20,8 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController def revoke @personal_access_token = finder.find(params[:id]) - - if @personal_access_token.revoke! - flash[:notice] = _("Revoked personal access token %{personal_access_token_name}!") % { personal_access_token_name: @personal_access_token.name } - else - flash[:alert] = _("Could not revoke personal access token %{personal_access_token_name}.") % { personal_access_token_name: @personal_access_token.name } - end + service = PersonalAccessTokens::RevokeService.new(current_user, token: @personal_access_token).execute + service.success? ? flash[:notice] = service.message : flash[:alert] = service.message redirect_to profile_personal_access_tokens_path end diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index fef3c6cf424..652687932fd 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -108,7 +108,7 @@ class Projects::ArtifactsController < Projects::ApplicationController end def validate_artifacts! - render_404 unless build&.artifacts? + render_404 unless build&.available_artifacts? end def build diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 7f14522e61b..d969e7bf771 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -257,5 +257,3 @@ class Projects::BlobController < Projects::ApplicationController params.permit(:full, :since, :to, :bottom, :unfold, :offset, :indent) end end - -Projects::BlobController.prepend_if_ee('EE::Projects::BlobController') diff --git a/app/controllers/projects/ci/lints_controller.rb b/app/controllers/projects/ci/lints_controller.rb index 73b3eb9c205..c13baaea8c6 100644 --- a/app/controllers/projects/ci/lints_controller.rb +++ b/app/controllers/projects/ci/lints_controller.rb @@ -8,16 +8,30 @@ class Projects::Ci::LintsController < Projects::ApplicationController def create @content = params[:content] - result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options) - - @status = result.valid? - @errors = result.errors - - if result.valid? - @config_processor = result.config - @stages = @config_processor.stages - @builds = @config_processor.builds - @jobs = @config_processor.jobs + @dry_run = params[:dry_run] + + if @dry_run && Gitlab::Ci::Features.lint_creates_pipeline_with_dry_run?(@project) + pipeline = Ci::CreatePipelineService + .new(@project, current_user, ref: @project.default_branch) + .execute(:push, dry_run: true, content: @content) + + @status = pipeline.error_messages.empty? + @stages = pipeline.stages + @errors = pipeline.error_messages.map(&:content) + @warnings = pipeline.warning_messages.map(&:content) + else + result = Gitlab::Ci::YamlProcessor.new_with_validation_errors(@content, yaml_processor_options) + + @status = result.valid? + @errors = result.errors + @warnings = result.warnings + + if result.valid? + @config_processor = result.config + @stages = @config_processor.stages + @builds = @config_processor.builds + @jobs = @config_processor.jobs + end end render :show diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 3f2dc9b09fa..b0c6f3cc6a1 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -15,8 +15,8 @@ class Projects::CommitController < Projects::ApplicationController before_action :authorize_download_code! before_action :authorize_read_pipeline!, only: [:pipelines] before_action :commit - before_action :define_commit_vars, only: [:show, :diff_for_path, :pipelines, :merge_requests] - before_action :define_note_vars, only: [:show, :diff_for_path] + before_action :define_commit_vars, only: [:show, :diff_for_path, :diff_files, :pipelines, :merge_requests] + before_action :define_note_vars, only: [:show, :diff_for_path, :diff_files] before_action :authorize_edit_tree!, only: [:revert, :cherry_pick] BRANCH_SEARCH_LIMIT = 1000 @@ -41,6 +41,10 @@ class Projects::CommitController < Projects::ApplicationController render_diff_for_path(@commit.diffs(diff_options)) end + def diff_files + render json: { html: view_to_html_string('projects/commit/diff_files', diffs: @diffs, environment: @environment) } + end + # rubocop: disable CodeReuse/ActiveRecord def pipelines @pipelines = @commit.pipelines.order(id: :desc) diff --git a/app/controllers/projects/cycle_analytics/events_controller.rb b/app/controllers/projects/cycle_analytics/events_controller.rb index 673f53c221b..c69bf029c73 100644 --- a/app/controllers/projects/cycle_analytics/events_controller.rb +++ b/app/controllers/projects/cycle_analytics/events_controller.rb @@ -4,6 +4,7 @@ module Projects module CycleAnalytics class EventsController < Projects::ApplicationController include CycleAnalyticsParams + include GracefulTimeoutHandling before_action :authorize_read_cycle_analytics! before_action :authorize_read_build!, only: [:test, :staging] diff --git a/app/controllers/projects/cycle_analytics_controller.rb b/app/controllers/projects/cycle_analytics_controller.rb index 898d888c978..ef97bc795f9 100644 --- a/app/controllers/projects/cycle_analytics_controller.rb +++ b/app/controllers/projects/cycle_analytics_controller.rb @@ -5,6 +5,7 @@ class Projects::CycleAnalyticsController < Projects::ApplicationController include ActionView::Helpers::TextHelper include CycleAnalyticsParams include Analytics::UniqueVisitsHelper + include GracefulTimeoutHandling before_action :whitelist_query_limiting, only: [:show] before_action :authorize_read_cycle_analytics! diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index d5da24a76de..71195fdb892 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -13,6 +13,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController authorize_metrics_dashboard! push_frontend_feature_flag(:prometheus_computed_alerts) + push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) end before_action :authorize_read_environment!, except: [:metrics, :additional_metrics, :metrics_dashboard, :metrics_redirect] before_action :authorize_create_environment!, only: [:new, :create] @@ -104,7 +105,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController action_or_env_url = if stop_action - polymorphic_url([project.namespace.becomes(Namespace), project, stop_action]) + polymorphic_url([project, stop_action]) else project_environment_url(project, @environment) end @@ -158,18 +159,14 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def metrics_redirect - environment = project.default_environment - - if environment - redirect_to environment_metrics_path(environment) - else - render :empty_metrics - end + redirect_to project_metrics_dashboard_path(project) end def metrics respond_to do |format| - format.html + format.html do + redirect_to project_metrics_dashboard_path(project, environment: environment ) + end format.json do # Currently, this acts as a hint to load the metrics details into the cache # if they aren't there already diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index b93f6384e0c..41631aea620 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -36,7 +36,19 @@ class Projects::ForksController < Projects::ApplicationController end def new - @namespaces = fork_service.valid_fork_targets - [project.namespace] + respond_to do |format| + format.html do + @own_namespace = current_user.namespace if fork_service.valid_fork_targets.include?(current_user.namespace) + @project = project + end + + format.json do + namespaces = fork_service.valid_fork_targets - [current_user.namespace, project.namespace] + render json: { + namespaces: ForkNamespaceSerializer.new.represent(namespaces, project: project, current_user: current_user) + } + end + end end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb new file mode 100644 index 00000000000..12cc4dde1f4 --- /dev/null +++ b/app/controllers/projects/incidents_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class Projects::IncidentsController < Projects::ApplicationController + before_action :authorize_read_incidents! + + def index + end +end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 12b5a538bc9..2200860a184 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -51,8 +51,10 @@ class Projects::IssuesController < Projects::ApplicationController end before_action only: :show do - push_frontend_feature_flag(:real_time_issue_sidebar, @project) - push_frontend_feature_flag(:confidential_apollo_sidebar, @project) + real_time_feature_flag = :real_time_issue_sidebar + real_time_enabled = Gitlab::ActionCable::Config.in_app? || Feature.enabled?(real_time_feature_flag, @project) + + gon.push({ features: { real_time_feature_flag.to_s.camelize(:lower) => real_time_enabled } }, true) end before_action only: :index do @@ -88,7 +90,7 @@ class Projects::IssuesController < Projects::ApplicationController params[:issue] ||= ActionController::Parameters.new( assignee_ids: "" ) - build_params = issue_params.merge( + build_params = issue_create_params.merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve], confidential: !!Gitlab::Utils.to_boolean(params[:issue][:confidential]) @@ -108,7 +110,7 @@ class Projects::IssuesController < Projects::ApplicationController end def create - create_params = issue_params.merge(spammable_params).merge( + create_params = issue_create_params.merge(spammable_params).merge( merge_request_to_resolve_discussions_of: params[:merge_request_to_resolve_discussions_of], discussion_to_resolve: params[:discussion_to_resolve] ) @@ -291,6 +293,16 @@ class Projects::IssuesController < Projects::ApplicationController ] + [{ label_ids: [], assignee_ids: [], update_task: [:index, :checked, :line_number, :line_source] }] end + def issue_create_params + create_params = %i[ + issue_type + ] + + params.require(:issue).permit( + *create_params + ).merge(issue_params) + end + def reorder_params params.permit(:move_before_id, :move_after_id, :group_full_path) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 98b0abc89e9..bceccc7063b 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -41,7 +41,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def diffs_metadata diffs = @compare.diffs(diff_options) - render json: DiffsMetadataSerializer.new(project: @merge_request.project) + render json: DiffsMetadataSerializer.new(project: @merge_request.project, current_user: current_user) .represent(diffs, additional_attributes) end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 5d4514be838..e77d2f0f5ee 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -31,16 +31,19 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:suggest_pipeline) if experiment_enabled?(:suggest_pipeline) push_frontend_feature_flag(:code_navigation, @project, default_enabled: true) push_frontend_feature_flag(:widget_visibility_polling, @project, default_enabled: true) - push_frontend_feature_flag(:merge_ref_head_comments, @project) + push_frontend_feature_flag(:merge_ref_head_comments, @project, default_enabled: true) push_frontend_feature_flag(:mr_commit_neighbor_nav, @project, default_enabled: true) - push_frontend_feature_flag(:multiline_comments, @project) + push_frontend_feature_flag(:multiline_comments, @project, default_enabled: true) push_frontend_feature_flag(:file_identifier_hash) push_frontend_feature_flag(:batch_suggestions, @project, default_enabled: true) + push_frontend_feature_flag(:auto_expand_collapsed_diffs, @project, default_enabled: true) + push_frontend_feature_flag(:approvals_commented_by, @project, default_enabled: true) + push_frontend_feature_flag(:hide_jump_to_next_unresolved_in_threads, default_enabled: true) + push_frontend_feature_flag(:merge_request_widget_graphql, @project) end before_action do push_frontend_feature_flag(:vue_issuable_sidebar, @project.group) - push_frontend_feature_flag(:junit_pipeline_view, @project.group) end around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions] @@ -80,7 +83,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @note = @project.notes.new(noteable: @merge_request) @noteable = @merge_request - @commits_count = @merge_request.commits_count + @commits_count = @merge_request.commits_count + @merge_request.context_commits_count @issuable_sidebar = serializer.represent(@merge_request, serializer: 'sidebar') @current_user_data = UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json @show_whitespace_default = current_user.nil? || current_user.show_whitespace_in_diffs @@ -114,6 +117,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def commits + # Get context commits from repository + @context_commits = + set_commits_for_rendering( + @merge_request.recent_context_commits + ) + # Get commits from repository # or from cache if already merged @commits = @@ -403,7 +412,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo return access_denied! unless @merge_request.source_branch_exists? access_check = ::Gitlab::UserAccess - .new(current_user, project: @merge_request.source_project) + .new(current_user, container: @merge_request.source_project) .can_push_to_branch?(@merge_request.source_branch) access_denied! unless access_check diff --git a/app/controllers/projects/metrics/dashboards/builder_controller.rb b/app/controllers/projects/metrics/dashboards/builder_controller.rb new file mode 100644 index 00000000000..2ab574d7d10 --- /dev/null +++ b/app/controllers/projects/metrics/dashboards/builder_controller.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Projects + module Metrics + module Dashboards + class BuilderController < Projects::ApplicationController + before_action :authorize_metrics_dashboard! + + def panel_preview + respond_to do |format| + format.json do + if rendered_panel.success? + render json: rendered_panel.payload + else + render json: { message: rendered_panel.message }, status: :unprocessable_entity + end + end + end + end + + private + + def rendered_panel + @panel_preview ||= ::Metrics::Dashboard::PanelPreviewService.new(project, panel_yaml, environment).execute + end + + def panel_yaml + params.require(:panel_yaml) + end + + def environment + @environment ||= + if params[:environment] + project.environments.find(params[:environment]) + else + project.default_environment + end + end + end + end + end +end diff --git a/app/controllers/projects/metrics_dashboard_controller.rb b/app/controllers/projects/metrics_dashboard_controller.rb index 235ee1dfbf2..51307c3665c 100644 --- a/app/controllers/projects/metrics_dashboard_controller.rb +++ b/app/controllers/projects/metrics_dashboard_controller.rb @@ -9,13 +9,14 @@ module Projects before_action :authorize_metrics_dashboard! before_action do push_frontend_feature_flag(:prometheus_computed_alerts) + push_frontend_feature_flag(:disable_metric_dashboard_refresh_rate) end def show if environment render 'projects/environments/metrics' else - render_404 + render 'projects/environments/empty_metrics' end end diff --git a/app/controllers/projects/packages/package_files_controller.rb b/app/controllers/projects/packages/package_files_controller.rb new file mode 100644 index 00000000000..dd6d875cd1e --- /dev/null +++ b/app/controllers/projects/packages/package_files_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Projects + module Packages + class PackageFilesController < ApplicationController + include PackagesAccess + include SendFileUpload + + def download + package_file = project.package_files.find(params[:id]) + + send_upload(package_file.file, attachment: package_file.file_name) + end + end + end +end diff --git a/app/controllers/projects/packages/packages_controller.rb b/app/controllers/projects/packages/packages_controller.rb new file mode 100644 index 00000000000..fc4ef7a01dc --- /dev/null +++ b/app/controllers/projects/packages/packages_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Projects + module Packages + class PackagesController < Projects::ApplicationController + include PackagesAccess + + before_action :authorize_destroy_package!, only: [:destroy] + + def show + @package = project.packages.find(params[:id]) + @package_files = @package.package_files.recent + @maven_metadatum = @package.maven_metadatum + end + + def destroy + @package = project.packages.find(params[:id]) + @package.destroy + + redirect_to project_packages_path(@project), status: :found, notice: _('Package was removed') + end + end + end +end diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index f03274bf32e..1c212964df5 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -3,7 +3,6 @@ module Projects module Pipelines class TestsController < Projects::Pipelines::ApplicationController - before_action :validate_feature_flag! before_action :authorize_read_build! before_action :builds, only: [:show] @@ -29,29 +28,21 @@ module Projects private - def validate_feature_flag! - render_404 unless Feature.enabled?(:build_report_summary, project) - end - # rubocop: disable CodeReuse/ActiveRecord def builds - pipeline.latest_builds.where(id: build_params) + @builds ||= pipeline.latest_builds.for_ids(build_ids).presence || render_404 end - def build_params + def build_ids return [] unless params[:build_ids] params[:build_ids].split(",") end def test_suite - if builds.present? - builds.map do |build| - build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) - end.sum - else - render_404 - end + builds.map do |build| + build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) + end.sum end # rubocop: enable CodeReuse/ActiveRecord end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index d8e11ddd423..bfe23eb1035 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -12,11 +12,10 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] before_action do - push_frontend_feature_flag(:junit_pipeline_view, project) - push_frontend_feature_flag(:build_report_summary, project) push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true) push_frontend_feature_flag(:dag_pipeline_tab, project, default_enabled: true) push_frontend_feature_flag(:pipelines_security_report_summary, project) + push_frontend_feature_flag(:new_pipeline_form) end before_action :ensure_pipeline, only: [:show] @@ -177,8 +176,6 @@ class Projects::PipelinesController < Projects::ApplicationController end def test_report - return unless Feature.enabled?(:junit_pipeline_view, project) - respond_to do |format| format.html do render 'show' @@ -192,12 +189,6 @@ class Projects::PipelinesController < Projects::ApplicationController end end - def test_reports_count - return unless Feature.enabled?(:junit_pipeline_view, project) - - render json: { total_count: pipeline.test_reports_count }.to_json - end - private def serialize_pipelines diff --git a/app/controllers/projects/product_analytics_controller.rb b/app/controllers/projects/product_analytics_controller.rb new file mode 100644 index 00000000000..badd7671dcf --- /dev/null +++ b/app/controllers/projects/product_analytics_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class Projects::ProductAnalyticsController < Projects::ApplicationController + before_action :feature_enabled! + before_action :authorize_read_product_analytics! + before_action :tracker_variables, only: [:setup, :test] + + def index + @events = product_analytics_events.order_by_time.page(params[:page]) + end + + def setup + end + + def test + @event = product_analytics_events.try(:first) + end + + def graphs + @graphs = [] + @timerange = 30 + + requested_graphs = %w(platform os_timezone br_lang doc_charset) + + requested_graphs.each do |graph| + @graphs << ProductAnalytics::BuildGraphService + .new(project, { graph: graph, timerange: @timerange }) + .execute + end + end + + private + + def product_analytics_events + @project.product_analytics_events + end + + def tracker_variables + # We use project id as Snowplow appId + @project_id = @project.id.to_s + + # Snowplow remembers values like appId and platform between reloads. + # That is why we have to rename the tracker with a random integer. + @random = rand(999999) + + # Generate random platform every time a tracker is rendered. + @platform = %w(web mob app)[(@random % 3)] + end + + def feature_enabled! + render_404 unless Feature.enabled?(:product_analytics, @project, default_enabled: false) + end +end diff --git a/app/controllers/projects/prometheus/alerts_controller.rb b/app/controllers/projects/prometheus/alerts_controller.rb index 2c0521edece..c6ae65f7832 100644 --- a/app/controllers/projects/prometheus/alerts_controller.rb +++ b/app/controllers/projects/prometheus/alerts_controller.rb @@ -39,7 +39,7 @@ module Projects render json: serialize_as_json(@alert) else - head :no_content + head :bad_request end end @@ -49,7 +49,7 @@ module Projects render json: serialize_as_json(alert) else - head :no_content + head :bad_request end end @@ -59,14 +59,14 @@ module Projects head :ok else - head :no_content + head :bad_request end end private def alerts_params - params.permit(:operator, :threshold, :environment_id, :prometheus_metric_id) + params.permit(:operator, :threshold, :environment_id, :prometheus_metric_id, :runbook_url) end def notify_service diff --git a/app/controllers/projects/protected_refs_controller.rb b/app/controllers/projects/protected_refs_controller.rb index d9921757502..060403a9cd9 100644 --- a/app/controllers/projects/protected_refs_controller.rb +++ b/app/controllers/projects/protected_refs_controller.rb @@ -62,7 +62,7 @@ class Projects::ProtectedRefsController < Projects::ApplicationController end def access_level_attributes - %i[access_level id] + %i[access_level id _destroy] end end diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index d58755c2655..c48d573edbf 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -29,7 +29,7 @@ class Projects::ReleasesController < Projects::ApplicationController end def new - unless Feature.enabled?(:new_release_page, project) + unless Feature.enabled?(:new_release_page, project, default_enabled: true) redirect_to(new_project_tag_path(@project)) end end diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 6b7e253595c..ca2a19e67b0 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -12,7 +12,6 @@ class Projects::ServicesController < Projects::ApplicationController before_action :set_deprecation_notice_for_prometheus_service, only: [:edit, :update] before_action :redirect_deprecated_prometheus_service, only: [:update] before_action only: :edit do - push_frontend_feature_flag(:integration_form_refactor, default_enabled: true) push_frontend_feature_flag(:jira_issues_integration, @project, { default_enabled: true }) end diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb index d7a6f1b0139..781b850ddfe 100644 --- a/app/controllers/projects/settings/operations_controller.rb +++ b/app/controllers/projects/settings/operations_controller.rb @@ -6,10 +6,6 @@ module Projects before_action :authorize_admin_operations! before_action :authorize_read_prometheus_alerts!, only: [:reset_alerting_token] - before_action do - push_frontend_feature_flag(:pagerduty_webhook, project) - end - respond_to :json, only: [:reset_alerting_token, :reset_pagerduty_token] helper_method :error_tracking_setting @@ -49,7 +45,7 @@ module Projects if result[:status] == :success pagerduty_token = project.incident_management_setting&.pagerduty_token - webhook_url = project_incidents_pagerduty_url(project, token: pagerduty_token) + webhook_url = project_incidents_integrations_pagerduty_url(project, token: pagerduty_token) render json: { pagerduty_webhook_url: webhook_url, pagerduty_token: pagerduty_token } else diff --git a/app/controllers/projects/snippets/blobs_controller.rb b/app/controllers/projects/snippets/blobs_controller.rb index 148fc7c96f8..eaec8600d77 100644 --- a/app/controllers/projects/snippets/blobs_controller.rb +++ b/app/controllers/projects/snippets/blobs_controller.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Projects::Snippets::BlobsController < Projects::Snippets::ApplicationController - include Snippets::BlobsActions + include ::Snippets::BlobsActions end diff --git a/app/controllers/projects/snippets_controller.rb b/app/controllers/projects/snippets_controller.rb index 49840e847f2..632e8db9796 100644 --- a/app/controllers/projects/snippets_controller.rb +++ b/app/controllers/projects/snippets_controller.rb @@ -14,6 +14,10 @@ class Projects::SnippetsController < Projects::Snippets::ApplicationController before_action :authorize_update_snippet!, only: [:edit, :update] before_action :authorize_admin_snippet!, only: [:destroy] + before_action do + push_frontend_feature_flag(:snippet_multiple_files, current_user) + end + def index @snippet_counts = ::Snippets::CountService .new(current_user, project: @project) diff --git a/app/controllers/projects/variables_controller.rb b/app/controllers/projects/variables_controller.rb index 2cc030d18fc..0fd047f90cf 100644 --- a/app/controllers/projects/variables_controller.rb +++ b/app/controllers/projects/variables_controller.rb @@ -12,7 +12,12 @@ class Projects::VariablesController < Projects::ApplicationController end def update - if @project.update(variables_params) + update_result = Ci::ChangeVariablesService.new( + container: @project, current_user: current_user, + params: variables_params + ).execute + + if update_result respond_to do |format| format.json { render_variables } end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a5666cb70ac..ba21fbddde1 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -38,9 +38,16 @@ class ProjectsController < Projects::ApplicationController before_action only: [:new, :create] do frontend_experimentation_tracking_data(:new_create_project_ui, 'click_tab') push_frontend_feature_flag(:new_create_project_ui) if experiment_enabled?(:new_create_project_ui) + end + + before_action only: [:edit] do push_frontend_feature_flag(:service_desk_custom_address, @project) end + before_action only: [:edit] do + push_frontend_feature_flag(:approval_suggestions, @project) + end + layout :determine_layout def index @@ -392,6 +399,7 @@ class ProjectsController < Projects::ApplicationController :initialize_with_readme, :autoclose_referenced_issues, :suggestion_commit_message, + :packages_enabled, :service_desk_enabled, project_feature_attributes: %i[ diff --git a/app/controllers/registrations/experience_levels_controller.rb b/app/controllers/registrations/experience_levels_controller.rb index 97239b1bbac..5bb039bd9ba 100644 --- a/app/controllers/registrations/experience_levels_controller.rb +++ b/app/controllers/registrations/experience_levels_controller.rb @@ -2,9 +2,7 @@ module Registrations class ExperienceLevelsController < ApplicationController - # This will need to be changed to simply 'devise' as part of - # https://gitlab.com/gitlab-org/growth/engineering/issues/64 - layout 'devise_experimental_separate_sign_up_flow' + layout 'devise_experimental_onboarding_issues' before_action :check_experiment_enabled before_action :ensure_namespace_path_param diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb index b1c1fe3ba74..2a865aac767 100644 --- a/app/controllers/registrations_controller.rb +++ b/app/controllers/registrations_controller.rb @@ -17,7 +17,8 @@ class RegistrationsController < Devise::RegistrationsController def new if experiment_enabled?(:signup_flow) - track_experiment_event(:signup_flow, 'start') # We want this event to be tracked when the user is _in_ the experimental group + track_experiment_event(:terms_opt_in, 'start') + @resource = build_resource else redirect_to new_user_session_path(anchor: 'register-pane') @@ -25,8 +26,7 @@ class RegistrationsController < Devise::RegistrationsController end def create - track_experiment_event(:signup_flow, 'end') unless experiment_enabled?(:signup_flow) # We want this event to be tracked when the user is _in_ the control group - + track_experiment_event(:terms_opt_in, 'end') accept_pending_invitations super do |new_user| @@ -62,9 +62,11 @@ class RegistrationsController < Devise::RegistrationsController result = ::Users::SignupService.new(current_user, user_params).execute if result[:status] == :success - track_experiment_event(:signup_flow, 'end') # We want this event to be tracked when the user is _in_ the experimental group + if ::Gitlab.com? && show_onboarding_issues_experiment? + track_experiment_event(:onboarding_issues, 'signed_up') + record_experiment_user(:onboarding_issues) + end - track_experiment_event(:onboarding_issues, 'signed_up') if ::Gitlab.com? && show_onboarding_issues_experiment? return redirect_to new_users_sign_up_group_path if experiment_enabled?(:onboarding_issues) && show_onboarding_issues_experiment? set_flash_message! :notice, :signed_up @@ -178,6 +180,8 @@ class RegistrationsController < Devise::RegistrationsController end def terms_accepted? + return true if experiment_enabled?(:terms_opt_in) + Gitlab::Utils.to_boolean(params[:terms_opt_in]) end diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 6a27d63625e..aa6609bef2a 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -105,7 +105,7 @@ module Repositories access.check(git_command, Gitlab::GitAccess::ANY) if repo_type.project? && !container - @project = @container = access.project + @project = @container = access.container end end diff --git a/app/controllers/repositories/lfs_storage_controller.rb b/app/controllers/repositories/lfs_storage_controller.rb index ec5ca5bbeec..0436b740979 100644 --- a/app/controllers/repositories/lfs_storage_controller.rb +++ b/app/controllers/repositories/lfs_storage_controller.rb @@ -73,9 +73,8 @@ module Repositories # rubocop: enable CodeReuse/ActiveRecord def create_file!(oid, size) - uploaded_file = UploadedFile.from_params( - params, :file, LfsObjectUploader.workhorse_local_upload_path) - return unless uploaded_file + uploaded_file = params[:file] + return unless uploaded_file.is_a?(UploadedFile) LfsObject.create!(oid: oid, size: size, file: uploaded_file) end diff --git a/app/controllers/root_controller.rb b/app/controllers/root_controller.rb index 14469877e14..191134472c2 100644 --- a/app/controllers/root_controller.rb +++ b/app/controllers/root_controller.rb @@ -13,6 +13,7 @@ class RootController < Dashboard::ProjectsController before_action :redirect_unlogged_user, if: -> { current_user.nil? } before_action :redirect_logged_user, if: -> { current_user.present? } + before_action :customize_homepage, only: :index, if: -> { current_user.present? } # We only need to load the projects when the user is logged in but did not # configure a dashboard. In which case we render projects. We can do that straight # from the #index action. @@ -66,6 +67,10 @@ class RootController < Dashboard::ProjectsController root_urls.exclude?(home_page_url) end + + def customize_homepage + @customize_homepage = experiment_enabled?(:customize_homepage) + end end RootController.prepend_if_ee('EE::RootController') diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index ff6d9350a5c..56b6a5201e7 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -6,7 +6,8 @@ class SearchController < ApplicationController include RendersCommits SCOPE_PRELOAD_METHOD = { - projects: :with_web_entity_associations + projects: :with_web_entity_associations, + issues: :with_web_entity_associations }.freeze around_action :allow_gitaly_ref_name_caching @@ -113,4 +114,15 @@ class SearchController < ApplicationController Gitlab::UsageDataCounters::SearchCounter.count(:navbar_searches) end + + def append_info_to_payload(payload) + super + + # Merging to :metadata will ensure these are logged as top level keys + payload[:metadata] || {} + payload[:metadata]['meta.search.group_id'] = params[:group_id] + payload[:metadata]['meta.search.project_id'] = params[:project_id] + payload[:metadata]['meta.search.search'] = params[:search] + payload[:metadata]['meta.search.scope'] = params[:scope] + end end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 9e8075d4bcc..f82212591b6 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -25,7 +25,7 @@ class SessionsController < Devise::SessionsController before_action :store_unauthenticated_sessions, only: [:new] before_action :save_failed_login, if: :action_new_and_failed_login? before_action :load_recaptcha - before_action :frontend_tracking_data, only: [:new] + before_action :set_invite_params, only: [:new] after_action :log_failed_login, if: :action_new_and_failed_login? after_action :verify_known_sign_in, only: [:create] @@ -293,9 +293,8 @@ class SessionsController < Devise::SessionsController end end - def frontend_tracking_data - # We want tracking data pushed to the frontend when the user is _in_ the control group - frontend_experimentation_tracking_data(:signup_flow, 'start') unless experiment_enabled?(:signup_flow) + def set_invite_params + @invite_email = ActionController::Base.helpers.sanitize(params[:invite_email]) end end diff --git a/app/controllers/snippets_controller.rb b/app/controllers/snippets_controller.rb index e68b821459d..486c7f1d028 100644 --- a/app/controllers/snippets_controller.rb +++ b/app/controllers/snippets_controller.rb @@ -17,6 +17,10 @@ class SnippetsController < Snippets::ApplicationController layout 'snippets' + before_action do + push_frontend_feature_flag(:snippet_multiple_files, current_user) + end + def index if params[:username].present? @user = UserFinder.new(params[:username]).find_by_username! diff --git a/app/finders/ci/daily_build_group_report_results_finder.rb b/app/finders/ci/daily_build_group_report_results_finder.rb index 774f08d1ff2..ec41d9d2c45 100644 --- a/app/finders/ci/daily_build_group_report_results_finder.rb +++ b/app/finders/ci/daily_build_group_report_results_finder.rb @@ -14,17 +14,25 @@ module Ci end def execute - return none unless can?(current_user, :read_build_report_results, project) + return none unless query_allowed? + query + end + + protected + + attr_reader :current_user, :project, :ref_path, :start_date, :end_date, :limit + + def query Ci::DailyBuildGroupReportResult.recent_results( query_params, limit: limit ) end - private - - attr_reader :current_user, :project, :ref_path, :start_date, :end_date, :limit + def query_allowed? + can?(current_user, :read_build_report_results, project) + end def query_params { diff --git a/app/finders/concerns/merged_at_filter.rb b/app/finders/concerns/merged_at_filter.rb new file mode 100644 index 00000000000..d2858ba2f88 --- /dev/null +++ b/app/finders/concerns/merged_at_filter.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module MergedAtFilter + private + + # rubocop: disable CodeReuse/ActiveRecord + def by_merged_at(items) + return items unless merged_after || merged_before + + mr_metrics_scope = MergeRequest::Metrics + mr_metrics_scope = mr_metrics_scope.merged_after(merged_after) if merged_after.present? + mr_metrics_scope = mr_metrics_scope.merged_before(merged_before) if merged_before.present? + + scope = items.joins(:metrics).merge(mr_metrics_scope) + scope = target_project_id_filter_on_metrics(scope) if Feature.enabled?(:improved_mr_merged_at_queries) + scope + end + # rubocop: enable CodeReuse/ActiveRecord + + def merged_after + params[:merged_after] + end + + def merged_before + params[:merged_before] + end + + # rubocop: disable CodeReuse/ActiveRecord + def target_project_id_filter_on_metrics(scope) + scope.where(MergeRequest.arel_table[:target_project_id].eq(MergeRequest::Metrics.arel_table[:target_project_id])) + end + # rubocop: enable CodeReuse/ActiveRecord +end diff --git a/app/finders/context_commits_finder.rb b/app/finders/context_commits_finder.rb index f1b3eb43e84..de89a556ee0 100644 --- a/app/finders/context_commits_finder.rb +++ b/app/finders/context_commits_finder.rb @@ -25,7 +25,7 @@ class ContextCommitsFinder if search.present? search_commits else - project.repository.commits(merge_request.source_branch, { limit: limit, offset: offset }) + project.repository.commits(merge_request.target_branch, { limit: limit, offset: offset }) end commits @@ -47,7 +47,7 @@ class ContextCommitsFinder commits = [commit_by_sha] if commit_by_sha end else - commits = project.repository.find_commits_by_message(search, nil, nil, 20) + commits = project.repository.find_commits_by_message(search, merge_request.target_branch, nil, 20) end commits diff --git a/app/finders/design_management/designs_finder.rb b/app/finders/design_management/designs_finder.rb index 10f95520d1e..d9732f6b6f4 100644 --- a/app/finders/design_management/designs_finder.rb +++ b/app/finders/design_management/designs_finder.rb @@ -22,7 +22,9 @@ module DesignManagement items = by_filename(items) items = by_id(items) - items + # TODO: We don't need to pass the project anymore after the feature flag is removed + # https://gitlab.com/gitlab-org/gitlab/-/issues/34382 + items.ordered(issue.project) end private @@ -35,7 +37,8 @@ module DesignManagement issue.designs end - # Returns all designs that existed at a particular design version + # Returns all designs that existed at a particular design version, + # where `nil` means `at-current-version`. def by_visible_at_version(items) items.visible_at_version(params[:visible_at_version]) end diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 949af103eb3..a4b00588368 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -57,7 +57,7 @@ class GroupMembersFinder < UnionFinder members = members.search(params[:search]) if params[:search].present? members = members.sort_by_attribute(params[:sort]) if params[:sort].present? - if can_manage_members && params[:two_factor].present? + if params[:two_factor].present? && can_manage_members members = members.filter_by_2fa(params[:two_factor]) end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 2b2e6b377b4..bbb624f543b 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -25,6 +25,7 @@ # updated_after: datetime # updated_before: datetime # confidential: boolean +# issue_type: array of strings (one of Issue.issue_types) # class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER @@ -73,6 +74,7 @@ class IssuesFinder < IssuableFinder issues = super issues = by_due_date(issues) issues = by_confidential(issues) + issues = by_issue_types(issues) issues end @@ -97,6 +99,14 @@ class IssuesFinder < IssuableFinder items.due_between(Date.today - 2.weeks, (Date.today + 1.month).end_of_month) end end + + def by_issue_types(items) + issue_type_params = Array(params[:issue_types]).map(&:to_s) + return items if issue_type_params.blank? + return Issue.none unless (Issue.issue_types.keys & issue_type_params).sort == issue_type_params.sort + + items.with_issue_type(params[:issue_types]) + end end IssuesFinder.prepend_if_ee('EE::IssuesFinder') diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index e08ed737ca6..ce9137f91bb 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -29,7 +29,12 @@ class MembersFinder def find_members(include_relations) project_members = project.project_members - project_members = project_members.non_invite unless can?(current_user, :admin_project, project) + + if params[:active_without_invites_and_requests].present? + project_members = project_members.active_without_invites_and_requests + else + project_members = project_members.non_invite unless can?(current_user, :admin_project, project) + end return project_members if include_relations == [:direct] @@ -44,6 +49,7 @@ class MembersFinder def filter_members(members) members = members.search(params[:search]) if params[:search].present? members = members.sort_by_attribute(params[:sort]) if params[:sort].present? + members = members.owners_and_maintainers if params[:owners_and_maintainers].present? members end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index d5e6c4783c1..b70d0b7a06a 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -30,8 +30,10 @@ # updated_before: datetime # class MergeRequestsFinder < IssuableFinder + include MergedAtFilter + def self.scalar_params - @scalar_params ||= super + [:wip, :target_branch] + @scalar_params ||= super + [:wip, :draft, :target_branch, :merged_after, :merged_before] end def klass @@ -42,8 +44,9 @@ class MergeRequestsFinder < IssuableFinder items = by_commit(super) items = by_deployment(items) items = by_source_branch(items) - items = by_wip(items) + items = by_draft(items) items = by_target_branch(items) + items = by_merged_at(items) by_source_project_id(items) end @@ -88,20 +91,32 @@ class MergeRequestsFinder < IssuableFinder items.where(source_project_id: source_project_id) end - def by_wip(items) - if params[:wip] == 'yes' + def by_draft(items) + draft_param = params[:draft] || params[:wip] + + if draft_param == 'yes' items.where(wip_match(items.arel_table)) - elsif params[:wip] == 'no' + elsif draft_param == 'no' items.where.not(wip_match(items.arel_table)) else items end end + # WIP is deprecated in favor of Draft. Currently both options are supported def wip_match(table) - table[:title].matches('WIP:%') + items = + table[:title].matches('WIP:%') .or(table[:title].matches('WIP %')) .or(table[:title].matches('[WIP]%')) + + return items unless Feature.enabled?(:merge_request_draft_filter) + + items + .or(table[:title].matches('Draft - %')) + .or(table[:title].matches('Draft:%')) + .or(table[:title].matches('[Draft]%')) + .or(table[:title].matches('(Draft)%')) end def by_deployment(items) diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb index 8f0cdf3b255..16e59b31b36 100644 --- a/app/finders/milestones_finder.rb +++ b/app/finders/milestones_finder.rb @@ -3,6 +3,7 @@ # Search for milestones # # params - Hash +# ids - filters by id. # project_ids: Array of project ids or single project id or ActiveRecord relation. # group_ids: Array of group ids or single group id or ActiveRecord relation. # order - Orders by field default due date asc. @@ -21,6 +22,7 @@ class MilestonesFinder def execute items = Milestone.all + items = by_ids(items) items = by_groups_and_projects(items) items = by_title(items) items = by_search_title(items) @@ -32,6 +34,12 @@ class MilestonesFinder private + def by_ids(items) + return items unless params[:ids].present? + + items.id_in(params[:ids]) + end + def by_groups_and_projects(items) items.for_projects_and_groups(params[:project_ids], params[:group_ids]) end diff --git a/app/finders/personal_access_tokens_finder.rb b/app/finders/personal_access_tokens_finder.rb index e3d5f2ae8de..93f8c520b63 100644 --- a/app/finders/personal_access_tokens_finder.rb +++ b/app/finders/personal_access_tokens_finder.rb @@ -5,12 +5,14 @@ class PersonalAccessTokensFinder delegate :build, :find, :find_by_id, :find_by_token, to: :execute - def initialize(params = {}) + def initialize(params = {}, current_user = nil) @params = params + @current_user = current_user end def execute tokens = PersonalAccessToken.all + tokens = by_current_user(tokens) tokens = by_user(tokens) tokens = by_impersonation(tokens) tokens = by_state(tokens) @@ -20,6 +22,15 @@ class PersonalAccessTokensFinder private + attr_reader :current_user + + def by_current_user(tokens) + return tokens if current_user.nil? || current_user.admin? + return PersonalAccessToken.none unless Ability.allowed?(current_user, :read_user_personal_access_tokens, params[:user]) + + tokens + end + def by_user(tokens) return tokens unless @params[:user] diff --git a/app/finders/releases_finder.rb b/app/finders/releases_finder.rb index 6a754fdb5a1..e961ad4c0ca 100644 --- a/app/finders/releases_finder.rb +++ b/app/finders/releases_finder.rb @@ -1,19 +1,20 @@ # frozen_string_literal: true class ReleasesFinder - attr_reader :project, :current_user, :params + include Gitlab::Utils::StrongMemoize - def initialize(project, current_user = nil, params = {}) - @project = project + attr_reader :parent, :current_user, :params + + def initialize(parent, current_user = nil, params = {}) + @parent = parent @current_user = current_user @params = params end def execute(preload: true) - return Release.none unless Ability.allowed?(current_user, :read_release, project) + return Release.none if projects.empty? - # See https://gitlab.com/gitlab-org/gitlab/-/issues/211988 - releases = project.releases.where.not(tag: nil) # rubocop:disable CodeReuse/ActiveRecord + releases = get_releases releases = by_tag(releases) releases = releases.preloaded if preload releases.sorted @@ -21,6 +22,34 @@ class ReleasesFinder private + def get_releases + Release.where(project_id: projects).where.not(tag: nil) # rubocop: disable CodeReuse/ActiveRecord + end + + def include_subgroups? + params.fetch(:include_subgroups, false) + end + + def projects + strong_memoize(:projects) do + if parent.is_a?(Project) + Ability.allowed?(current_user, :read_release, parent) ? [parent] : [] + elsif parent.is_a?(Group) + accessible_projects + end + end + end + + def accessible_projects + projects = if include_subgroups? + Project.for_group_and_its_subgroups(parent) + else + parent.projects + end + + projects.select { |project| Ability.allowed?(current_user, :read_release, project) } + end + # rubocop: disable CodeReuse/ActiveRecord def by_tag(releases) return releases unless params[:tag].present? diff --git a/app/finders/template_finder.rb b/app/finders/template_finder.rb index 78c8392f1cd..a35376e905e 100644 --- a/app/finders/template_finder.rb +++ b/app/finders/template_finder.rb @@ -6,7 +6,10 @@ class TemplateFinder VENDORED_TEMPLATES = HashWithIndifferentAccess.new( dockerfiles: ::Gitlab::Template::DockerfileTemplate, gitignores: ::Gitlab::Template::GitignoreTemplate, - gitlab_ci_ymls: ::Gitlab::Template::GitlabCiYmlTemplate + gitlab_ci_ymls: ::Gitlab::Template::GitlabCiYmlTemplate, + metrics_dashboard_ymls: ::Gitlab::Template::MetricsDashboardTemplate, + issues: ::Gitlab::Template::IssueTemplate, + merge_requests: ::Gitlab::Template::MergeRequestTemplate ).freeze class << self @@ -34,9 +37,9 @@ class TemplateFinder def execute if params[:name] - vendored_templates.find(params[:name]) + vendored_templates.find(params[:name], project) else - vendored_templates.all + vendored_templates.all(project) end end end diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index a2054f73c9d..f28e1281488 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -10,6 +10,7 @@ # action_id: integer # author_id: integer # project_id; integer +# target_id; integer # state: 'pending' (default) or 'done' # type: 'Issue' or 'MergeRequest' or ['Issue', 'MergeRequest'] # @@ -23,7 +24,7 @@ class TodosFinder NONE = '0' - TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design)).freeze + TODO_TYPES = Set.new(%w(Issue MergeRequest DesignManagement::Design AlertManagement::Alert)).freeze attr_accessor :current_user, :params @@ -47,6 +48,7 @@ class TodosFinder items = by_action(items) items = by_author(items) items = by_state(items) + items = by_target_id(items) items = by_types(items) items = by_group(items) # Filtering by project HAS TO be the last because we use @@ -198,6 +200,12 @@ class TodosFinder items.with_states(params[:state]) end + def by_target_id(items) + return items if params[:target_id].blank? + + items.for_target(params[:target_id]) + end + def by_types(items) if types.any? items.for_type(types) diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 592167a633b..d8967da9f57 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -48,6 +48,13 @@ class GitlabSchema < GraphQL::Schema super(query_str, **kwargs) end + def get_type(type_name) + # This is a backwards compatibility hack to work around an accidentally + # released argument typed as EEIterationID + type_name = type_name.gsub(/^EE/, '') if type_name.end_with?('ID') + super(type_name) + end + def id_from_object(object, _type = nil, _ctx = nil) unless object.respond_to?(:to_global_id) # This is an error in our schema and needs to be solved. So raise a @@ -77,6 +84,8 @@ class GitlabSchema < GraphQL::Schema # will be called. # * All other classes will use `GlobalID#find` def find_by_gid(gid) + return unless gid + if gid.model_class < ApplicationRecord Gitlab::Graphql::Loaders::BatchModelLoader.new(gid.model_class, gid.model_id).find elsif gid.model_class.respond_to?(:lazy_find) @@ -142,6 +151,13 @@ class GitlabSchema < GraphQL::Schema end end end + + # This is a backwards compatibility hack to work around an accidentally + # released argument typed as EE{Type}ID + def get_type(type_name) + type_name = type_name.gsub(/^EE/, '') if type_name.end_with?('ID') + super(type_name) + end end GitlabSchema.prepend_if_ee('EE::GitlabSchema') # rubocop: disable Cop/InjectEnterpriseEditionModule diff --git a/app/graphql/mutations/boards/issues/issue_move_list.rb b/app/graphql/mutations/boards/issues/issue_move_list.rb new file mode 100644 index 00000000000..d4bf47af4cf --- /dev/null +++ b/app/graphql/mutations/boards/issues/issue_move_list.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Issues + class IssueMoveList < Mutations::Issues::Base + graphql_name 'IssueMoveList' + + argument :board_id, GraphQL::ID_TYPE, + required: true, + loads: Types::BoardType, + description: 'Global ID of the board that the issue is in' + + argument :project_path, GraphQL::ID_TYPE, + required: true, + description: 'Project the issue to mutate is in' + + argument :iid, GraphQL::STRING_TYPE, + required: true, + description: 'IID of the issue to mutate' + + argument :from_list_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of the board list that the issue will be moved from' + + argument :to_list_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of the board list that the issue will be moved to' + + argument :move_before_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of issue before which the current issue will be positioned at' + + argument :move_after_id, GraphQL::ID_TYPE, + required: false, + description: 'ID of issue after which the current issue will be positioned at' + + def ready?(**args) + if move_arguments(args).blank? + raise Gitlab::Graphql::Errors::ArgumentError, + 'At least one of the arguments fromListId, toListId, afterId or beforeId is required' + end + + if move_list_arguments(args).one? + raise Gitlab::Graphql::Errors::ArgumentError, + 'Both fromListId and toListId must be present' + end + + super + end + + def resolve(board:, **args) + raise_resource_not_available_error! unless board + authorize_board!(board) + + issue = authorized_find!(project_path: args[:project_path], iid: args[:iid]) + move_params = { id: issue.id, board_id: board.id }.merge(move_arguments(args)) + + move_issue(board, issue, move_params) + + { + issue: issue.reset, + errors: issue.errors.full_messages + } + end + + private + + def move_issue(board, issue, move_params) + service = ::Boards::Issues::MoveService.new(board.resource_parent, current_user, move_params) + + service.execute(issue) + end + + def move_list_arguments(args) + args.slice(:from_list_id, :to_list_id) + end + + def move_arguments(args) + args.slice(:from_list_id, :to_list_id, :move_after_id, :move_before_id) + end + + def authorize_board!(board) + return if Ability.allowed?(current_user, :read_board, board.resource_parent) + + raise_resource_not_available_error! + end + end + end + end +end diff --git a/app/graphql/mutations/boards/lists/base.rb b/app/graphql/mutations/boards/lists/base.rb new file mode 100644 index 00000000000..34b271ba3b8 --- /dev/null +++ b/app/graphql/mutations/boards/lists/base.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Lists + class Base < BaseMutation + include Mutations::ResolvesIssuable + + argument :board_id, ::Types::GlobalIDType[::Board], + required: true, + description: 'The Global ID of the issue board to mutate' + + field :list, + Types::BoardListType, + null: true, + description: 'List of the issue board' + + authorize :admin_list + + private + + def find_object(id:) + GitlabSchema.object_from_id(id, expected_type: ::Board) + end + end + end + end +end diff --git a/app/graphql/mutations/boards/lists/create.rb b/app/graphql/mutations/boards/lists/create.rb new file mode 100644 index 00000000000..4f545709ee9 --- /dev/null +++ b/app/graphql/mutations/boards/lists/create.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Lists + class Create < Base + graphql_name 'BoardListCreate' + + argument :backlog, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Create the backlog list' + + argument :label_id, ::Types::GlobalIDType[::Label], + required: false, + description: 'ID of an existing label' + + def ready?(**args) + if args.slice(*mutually_exclusive_args).size != 1 + arg_str = mutually_exclusive_args.map { |x| x.to_s.camelize(:lower) }.join(' or ') + raise Gitlab::Graphql::Errors::ArgumentError, "one and only one of #{arg_str} is required" + end + + super + end + + def resolve(**args) + board = authorized_find!(id: args[:board_id]) + params = create_list_params(args) + + authorize_list_type_resource!(board, params) + + list = create_list(board, params) + + { + list: list.valid? ? list : nil, + errors: errors_on_object(list) + } + end + + private + + def authorize_list_type_resource!(board, params) + return unless params[:label_id] + + labels = ::Labels::AvailableLabelsService.new(current_user, board.resource_parent, params) + .filter_labels_ids_in_param(:label_id) + + unless labels.present? + raise Gitlab::Graphql::Errors::ArgumentError, 'Label not found!' + end + end + + def create_list(board, params) + create_list_service = + ::Boards::Lists::CreateService.new(board.resource_parent, current_user, params) + + create_list_service.execute(board) + end + + def create_list_params(args) + params = args.slice(*mutually_exclusive_args).with_indifferent_access + params[:label_id] = GitlabSchema.parse_gid(params[:label_id]).model_id if params[:label_id] + + params + end + + def mutually_exclusive_args + [:backlog, :label_id] + end + end + end + end +end diff --git a/app/graphql/mutations/boards/lists/update.rb b/app/graphql/mutations/boards/lists/update.rb new file mode 100644 index 00000000000..7efed3058b3 --- /dev/null +++ b/app/graphql/mutations/boards/lists/update.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Mutations + module Boards + module Lists + class Update < BaseMutation + graphql_name 'UpdateBoardList' + + argument :list_id, GraphQL::ID_TYPE, + required: true, + loads: Types::BoardListType, + description: 'Global ID of the list.' + + argument :position, GraphQL::INT_TYPE, + required: false, + description: 'Position of list within the board' + + argument :collapsed, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Indicates if list is collapsed for this user' + + field :list, + Types::BoardListType, + null: true, + description: 'Mutated list' + + def resolve(list: nil, **args) + raise_resource_not_available_error! unless can_read_list?(list) + update_result = update_list(list, args) + + { + list: update_result[:list], + errors: list.errors.full_messages + } + end + + private + + def update_list(list, args) + service = ::Boards::Lists::UpdateService.new(list.board, current_user, args) + service.execute(list) + end + + def can_read_list?(list) + return false unless list.present? + + Ability.allowed?(current_user, :read_list, list.board) + end + end + end + end +end diff --git a/app/graphql/mutations/concerns/mutations/assignable.rb b/app/graphql/mutations/concerns/mutations/assignable.rb new file mode 100644 index 00000000000..f6f4b744f4e --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/assignable.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Mutations + module Assignable + extend ActiveSupport::Concern + + included do + argument :assignee_usernames, + [GraphQL::STRING_TYPE], + required: true, + description: 'The usernames to assign to the resource. Replaces existing assignees by default.' + + argument :operation_mode, + Types::MutationOperationModeEnum, + required: false, + description: 'The operation to perform. Defaults to REPLACE.' + end + + def resolve(project_path:, iid:, assignee_usernames:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) + resource = authorized_find!(project_path: project_path, iid: iid) + + Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/36098') if resource.is_a?(MergeRequest) + + update_service_class.new( + resource.project, + current_user, + assignee_ids: assignee_ids(resource, assignee_usernames, operation_mode) + ).execute(resource) + + { + resource.class.name.underscore.to_sym => resource, + errors: errors_on_object(resource) + } + end + + private + + def assignee_ids(resource, usernames, operation_mode) + assignee_ids = [] + assignee_ids += resource.assignees.map(&:id) if Types::MutationOperationModeEnum.enum.values_at(:remove, :append).include?(operation_mode) + user_ids = UsersFinder.new(current_user, username: usernames).execute.map(&:id) + + if operation_mode == Types::MutationOperationModeEnum.enum[:remove] + assignee_ids -= user_ids + else + assignee_ids |= user_ids + end + + assignee_ids + end + end +end diff --git a/app/graphql/mutations/concerns/mutations/resolves_subscription.rb b/app/graphql/mutations/concerns/mutations/resolves_subscription.rb new file mode 100644 index 00000000000..e8c5d0d404d --- /dev/null +++ b/app/graphql/mutations/concerns/mutations/resolves_subscription.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module ResolvesSubscription + extend ActiveSupport::Concern + included do + argument :subscribed_state, + GraphQL::BOOLEAN_TYPE, + required: true, + description: 'The desired state of the subscription' + end + + def resolve(project_path:, iid:, subscribed_state:) + resource = authorized_find!(project_path: project_path, iid: iid) + project = resource.project + + resource.set_subscription(current_user, subscribed_state, project) + + { + resource.class.name.underscore.to_sym => resource, + errors: errors_on_object(resource) + } + end + end +end diff --git a/app/graphql/mutations/design_management/move.rb b/app/graphql/mutations/design_management/move.rb new file mode 100644 index 00000000000..0b654447844 --- /dev/null +++ b/app/graphql/mutations/design_management/move.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Mutations + module DesignManagement + class Move < ::Mutations::BaseMutation + graphql_name "DesignManagementMove" + + DesignID = ::Types::GlobalIDType[::DesignManagement::Design] + + argument :id, DesignID, required: true, as: :current_design, + description: "ID of the design to move" + + argument :previous, DesignID, required: false, as: :previous_design, + description: "ID of the immediately preceding design" + + argument :next, DesignID, required: false, as: :next_design, + description: "ID of the immediately following design" + + field :design_collection, Types::DesignManagement::DesignCollectionType, + null: true, + description: "The current state of the collection" + + def ready(*) + raise ::Gitlab::Graphql::Errors::ResourceNotAvailable unless ::Feature.enabled?(:reorder_designs, default_enabled: true) + end + + def resolve(**args) + service = ::DesignManagement::MoveDesignsService.new(current_user, parameters(args)) + + { design_collection: service.collection, errors: service.execute.errors } + end + + private + + def parameters(**args) + args.transform_values { |id| GitlabSchema.find_by_gid(id) }.transform_values(&:sync).tap do |hash| + hash.each { |k, design| not_found(args[k]) unless current_user.can?(:read_design, design) } + end + end + + def not_found(gid) + raise Gitlab::Graphql::Errors::ResourceNotAvailable, "Resource not available: #{gid}" + end + end + end +end diff --git a/app/graphql/mutations/issues/base.rb b/app/graphql/mutations/issues/base.rb index 7c545c3eb00..529d48f3cd0 100644 --- a/app/graphql/mutations/issues/base.rb +++ b/app/graphql/mutations/issues/base.rb @@ -11,7 +11,7 @@ module Mutations argument :iid, GraphQL::STRING_TYPE, required: true, - description: "The iid of the issue to mutate" + description: "The IID of the issue to mutate" field :issue, Types::IssueType, diff --git a/app/graphql/mutations/issues/set_assignees.rb b/app/graphql/mutations/issues/set_assignees.rb new file mode 100644 index 00000000000..a4d1c755b53 --- /dev/null +++ b/app/graphql/mutations/issues/set_assignees.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class SetAssignees < Base + graphql_name 'IssueSetAssignees' + + include Assignable + + def update_service_class + ::Issues::UpdateService + end + end + end +end diff --git a/app/graphql/mutations/issues/set_subscription.rb b/app/graphql/mutations/issues/set_subscription.rb new file mode 100644 index 00000000000..a04c8f5ba2d --- /dev/null +++ b/app/graphql/mutations/issues/set_subscription.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Mutations + module Issues + class SetSubscription < Base + graphql_name 'IssueSetSubscription' + + include ResolvesSubscription + end + end +end diff --git a/app/graphql/mutations/issues/update.rb b/app/graphql/mutations/issues/update.rb index 7f6d9b0f988..cc03d32731b 100644 --- a/app/graphql/mutations/issues/update.rb +++ b/app/graphql/mutations/issues/update.rb @@ -25,6 +25,27 @@ module Mutations required: false, description: copy_field_description(Types::IssueType, :confidential) + argument :locked, + GraphQL::BOOLEAN_TYPE, + as: :discussion_locked, + required: false, + description: copy_field_description(Types::IssueType, :discussion_locked) + + argument :add_label_ids, + [GraphQL::ID_TYPE], + required: false, + description: 'The IDs of labels to be added to the issue.' + + argument :remove_label_ids, + [GraphQL::ID_TYPE], + required: false, + description: 'The IDs of labels to be removed from the issue.' + + argument :milestone_id, + GraphQL::ID_TYPE, + required: false, + description: 'The ID of the milestone to be assigned, milestone will be removed if set to null.' + def resolve(project_path:, iid:, **args) issue = authorized_find!(project_path: project_path, iid: iid) project = issue.project diff --git a/app/graphql/mutations/merge_requests/create.rb b/app/graphql/mutations/merge_requests/create.rb index e210987f259..fd2cd58a5ee 100644 --- a/app/graphql/mutations/merge_requests/create.rb +++ b/app/graphql/mutations/merge_requests/create.rb @@ -27,6 +27,10 @@ module Mutations required: false, description: copy_field_description(Types::MergeRequestType, :description) + argument :labels, [GraphQL::STRING_TYPE], + required: false, + description: copy_field_description(Types::MergeRequestType, :labels) + field :merge_request, Types::MergeRequestType, null: true, @@ -34,18 +38,11 @@ module Mutations authorize :create_merge_request_from - def resolve(project_path:, title:, source_branch:, target_branch:, description: nil) + def resolve(project_path:, **attributes) project = authorized_find!(full_path: project_path) + params = attributes.merge(author_id: current_user.id) - attributes = { - title: title, - source_branch: source_branch, - target_branch: target_branch, - author_id: current_user.id, - description: description - } - - merge_request = ::MergeRequests::CreateService.new(project, current_user, attributes).execute + merge_request = ::MergeRequests::CreateService.new(project, current_user, params).execute { merge_request: merge_request.valid? ? merge_request : nil, diff --git a/app/graphql/mutations/merge_requests/set_assignees.rb b/app/graphql/mutations/merge_requests/set_assignees.rb index de244b62d0f..548c6b55a85 100644 --- a/app/graphql/mutations/merge_requests/set_assignees.rb +++ b/app/graphql/mutations/merge_requests/set_assignees.rb @@ -5,43 +5,10 @@ module Mutations class SetAssignees < Base graphql_name 'MergeRequestSetAssignees' - argument :assignee_usernames, - [GraphQL::STRING_TYPE], - required: true, - description: <<~DESC - The usernames to assign to the merge request. Replaces existing assignees by default. - DESC + include Assignable - argument :operation_mode, - Types::MutationOperationModeEnum, - required: false, - description: <<~DESC - The operation to perform. Defaults to REPLACE. - DESC - - def resolve(project_path:, iid:, assignee_usernames:, operation_mode: Types::MutationOperationModeEnum.enum[:replace]) - Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab/issues/36098') - - merge_request = authorized_find!(project_path: project_path, iid: iid) - project = merge_request.project - - assignee_ids = [] - assignee_ids += merge_request.assignees.map(&:id) if Types::MutationOperationModeEnum.enum.values_at(:remove, :append).include?(operation_mode) - user_ids = UsersFinder.new(current_user, username: assignee_usernames).execute.map(&:id) - - if operation_mode == Types::MutationOperationModeEnum.enum[:remove] - assignee_ids -= user_ids - else - assignee_ids |= user_ids - end - - ::MergeRequests::UpdateService.new(project, current_user, assignee_ids: assignee_ids) - .execute(merge_request) - - { - merge_request: merge_request, - errors: errors_on_object(merge_request) - } + def update_service_class + ::MergeRequests::UpdateService end end end diff --git a/app/graphql/mutations/merge_requests/set_subscription.rb b/app/graphql/mutations/merge_requests/set_subscription.rb index 1535481ab37..7d3c40185c9 100644 --- a/app/graphql/mutations/merge_requests/set_subscription.rb +++ b/app/graphql/mutations/merge_requests/set_subscription.rb @@ -5,22 +5,7 @@ module Mutations class SetSubscription < Base graphql_name 'MergeRequestSetSubscription' - argument :subscribed_state, - GraphQL::BOOLEAN_TYPE, - required: true, - description: 'The desired state of the subscription' - - def resolve(project_path:, iid:, subscribed_state:) - merge_request = authorized_find!(project_path: project_path, iid: iid) - project = merge_request.project - - merge_request.set_subscription(current_user, subscribed_state, project) - - { - merge_request: merge_request, - errors: errors_on_object(merge_request) - } - end + include ResolvesSubscription end end end diff --git a/app/graphql/mutations/notes/update/base.rb b/app/graphql/mutations/notes/update/base.rb index 9a53337f253..8a2a78a29ec 100644 --- a/app/graphql/mutations/notes/update/base.rb +++ b/app/graphql/mutations/notes/update/base.rb @@ -40,7 +40,7 @@ module Mutations end def note_params(_note, args) - { note: args[:body] }.compact + { note: args[:body], confidential: args[:confidential] }.compact end end end diff --git a/app/graphql/mutations/notes/update/note.rb b/app/graphql/mutations/notes/update/note.rb index 03a174fc8d9..ca97dad6ded 100644 --- a/app/graphql/mutations/notes/update/note.rb +++ b/app/graphql/mutations/notes/update/note.rb @@ -8,9 +8,14 @@ module Mutations argument :body, GraphQL::STRING_TYPE, - required: true, + required: false, description: copy_field_description(Types::Notes::NoteType, :body) + argument :confidential, + GraphQL::BOOLEAN_TYPE, + required: false, + description: 'The confidentiality flag of a note. Default is false.' + private def pre_update_checks!(note, _args) diff --git a/app/graphql/mutations/snippets/create.rb b/app/graphql/mutations/snippets/create.rb index 89c21486a74..a068fd806f5 100644 --- a/app/graphql/mutations/snippets/create.rb +++ b/app/graphql/mutations/snippets/create.rb @@ -40,8 +40,8 @@ module Mutations required: false, description: 'The paths to files uploaded in the snippet description' - argument :files, [Types::Snippets::FileInputType], - description: "The snippet files to create", + argument :blob_actions, [Types::Snippets::BlobActionInputType], + description: 'Actions to perform over the snippet repository and blobs', required: false def resolve(args) @@ -85,9 +85,9 @@ module Mutations def create_params(args) args.tap do |create_args| - # We need to rename `files` into `snippet_actions` because + # We need to rename `blob_actions` into `snippet_actions` because # it's the expected key param - create_args[:snippet_actions] = create_args.delete(:files)&.map(&:to_h) + create_args[:snippet_actions] = create_args.delete(:blob_actions)&.map(&:to_h) # We need to rename `uploaded_files` into `files` because # it's the expected key param diff --git a/app/graphql/mutations/snippets/update.rb b/app/graphql/mutations/snippets/update.rb index 8890158b0df..6ff632ec008 100644 --- a/app/graphql/mutations/snippets/update.rb +++ b/app/graphql/mutations/snippets/update.rb @@ -30,8 +30,8 @@ module Mutations description: 'The visibility level of the snippet', required: false - argument :files, [Types::Snippets::FileInputType], - description: 'The snippet files to update', + argument :blob_actions, [Types::Snippets::BlobActionInputType], + description: 'Actions to perform over the snippet repository and blobs', required: false def resolve(args) @@ -56,9 +56,9 @@ module Mutations def update_params(args) args.tap do |update_args| - # We need to rename `files` into `snippet_actions` because + # We need to rename `blob_actions` into `snippet_actions` because # it's the expected key param - update_args[:snippet_actions] = update_args.delete(:files)&.map(&:to_h) + update_args[:snippet_actions] = update_args.delete(:blob_actions)&.map(&:to_h) end end end diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb new file mode 100644 index 00000000000..a7cc367379d --- /dev/null +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class BoardListIssuesResolver < BaseResolver + type Types::IssueType, null: true + + alias_method :list, :object + + def resolve(**args) + service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], { board_id: list.board.id, id: list.id }) + Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute) + end + + # https://gitlab.com/gitlab-org/gitlab/-/issues/235681 + def self.complexity_multiplier(args) + 0.005 + end + end +end diff --git a/app/graphql/resolvers/board_lists_resolver.rb b/app/graphql/resolvers/board_lists_resolver.rb index f8d62ba86af..b1d43934f24 100644 --- a/app/graphql/resolvers/board_lists_resolver.rb +++ b/app/graphql/resolvers/board_lists_resolver.rb @@ -6,12 +6,16 @@ module Resolvers type Types::BoardListType, null: true + argument :id, GraphQL::ID_TYPE, + required: false, + description: 'Find a list by its global ID' + alias_method :board, :object - def resolve(lookahead: nil) + def resolve(lookahead: nil, id: nil) authorize!(board) - lists = board_lists + lists = board_lists(id) if load_preferences?(lookahead) List.preload_preferences_for_user(lists, context[:current_user]) @@ -22,8 +26,13 @@ module Resolvers private - def board_lists - service = Boards::Lists::ListService.new(board.resource_parent, context[:current_user]) + def board_lists(id) + service = Boards::Lists::ListService.new( + board.resource_parent, + context[:current_user], + list_id: extract_list_id(id) + ) + service.execute(board, create_default_lists: false) end @@ -34,5 +43,11 @@ module Resolvers def load_preferences?(lookahead) lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed) end + + def extract_list_id(gid) + return unless gid.present? + + GitlabSchema.parse_gid(gid, expected_type: ::List).model_id + end end end diff --git a/app/graphql/resolvers/ci/pipeline_stages_resolver.rb b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb new file mode 100644 index 00000000000..f9817d8b97b --- /dev/null +++ b/app/graphql/resolvers/ci/pipeline_stages_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + class PipelineStagesResolver < BaseResolver + include LooksAhead + + alias_method :pipeline, :object + + def resolve_with_lookahead + apply_lookahead(pipeline.stages) + end + + def preloads + { + statuses: [:needs] + } + end + end + end +end diff --git a/app/graphql/resolvers/ci_configuration/sast_resolver.rb b/app/graphql/resolvers/ci_configuration/sast_resolver.rb deleted file mode 100644 index e8c42076ea2..00000000000 --- a/app/graphql/resolvers/ci_configuration/sast_resolver.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require "json" - -module Resolvers - module CiConfiguration - class SastResolver < BaseResolver - SAST_UI_SCHEMA_PATH = 'app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json' - - type ::Types::CiConfiguration::Sast::Type, null: true - - def resolve(**args) - Gitlab::Json.parse(File.read(Rails.root.join(SAST_UI_SCHEMA_PATH))) - end - end - end -end diff --git a/app/graphql/resolvers/concerns/issue_resolver_fields.rb b/app/graphql/resolvers/concerns/issue_resolver_fields.rb new file mode 100644 index 00000000000..bf2f510dd89 --- /dev/null +++ b/app/graphql/resolvers/concerns/issue_resolver_fields.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module IssueResolverFields + extend ActiveSupport::Concern + + prepended do + argument :iid, GraphQL::STRING_TYPE, + required: false, + description: 'IID of the issue. For example, "1"' + argument :iids, [GraphQL::STRING_TYPE], + required: false, + description: 'List of IIDs of issues. For example, [1, 2]' + argument :label_name, GraphQL::STRING_TYPE.to_list_type, + required: false, + description: 'Labels applied to this issue' + argument :milestone_title, GraphQL::STRING_TYPE.to_list_type, + required: false, + description: 'Milestone applied to this issue' + argument :assignee_username, GraphQL::STRING_TYPE, + required: false, + description: 'Username of a user assigned to the issue' + argument :assignee_id, GraphQL::STRING_TYPE, + required: false, + description: 'ID of a user assigned to the issues, "none" and "any" values supported' + argument :created_before, Types::TimeType, + required: false, + description: 'Issues created before this date' + argument :created_after, Types::TimeType, + required: false, + description: 'Issues created after this date' + argument :updated_before, Types::TimeType, + required: false, + description: 'Issues updated before this date' + argument :updated_after, Types::TimeType, + required: false, + description: 'Issues updated after this date' + argument :closed_before, Types::TimeType, + required: false, + description: 'Issues closed before this date' + argument :closed_after, Types::TimeType, + required: false, + description: 'Issues closed after this date' + argument :search, GraphQL::STRING_TYPE, + required: false, + description: 'Search query for issue title or description' + argument :types, [Types::IssueTypeEnum], + as: :issue_types, + description: 'Filter issues by the given issue types', + required: false + end + + def resolve(**args) + # The project could have been loaded in batch by `BatchLoader`. + # At this point we need the `id` of the project to query for issues, so + # make sure it's loaded and not `nil` before continuing. + parent = object.respond_to?(:sync) ? object.sync : object + return Issue.none if parent.nil? + + # Will need to be made group & namespace aware with + # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 + args[:iids] ||= [args.delete(:iid)].compact if args[:iid] + args[:attempt_project_search_optimizations] = true if args[:search].present? + + finder = IssuesFinder.new(current_user, args) + + continue_issue_resolve(parent, finder, **args) + end + + class_methods do + def resolver_complexity(args, child_complexity:) + complexity = super + complexity += 2 if args[:labelName] + + complexity + end + end +end diff --git a/app/graphql/resolvers/concerns/resolves_merge_requests.rb b/app/graphql/resolvers/concerns/resolves_merge_requests.rb index 7ed88be52b9..0c01efd4f9a 100644 --- a/app/graphql/resolvers/concerns/resolves_merge_requests.rb +++ b/app/graphql/resolvers/concerns/resolves_merge_requests.rb @@ -38,6 +38,9 @@ module ResolvesMergeRequests assignees: [:assignees], labels: [:labels], author: [:author], + merged_at: [:metrics], + commit_count: [:metrics], + approved_by: [:approver_users], milestone: [:milestone], head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }] } diff --git a/app/graphql/resolvers/design_management/designs_resolver.rb b/app/graphql/resolvers/design_management/designs_resolver.rb index 81f94d5cb30..955ea6304e0 100644 --- a/app/graphql/resolvers/design_management/designs_resolver.rb +++ b/app/graphql/resolvers/design_management/designs_resolver.rb @@ -27,19 +27,20 @@ module Resolvers current_user, ids: design_ids(ids), filenames: filenames, - visible_at_version: version(at_version), - order: :id + visible_at_version: version(at_version) ).execute end private def version(at_version) - GitlabSchema.object_from_id(at_version)&.sync if at_version + return unless at_version + + GitlabSchema.object_from_id(at_version, expected_type: ::DesignManagement::Version)&.sync end def design_ids(ids) - ids&.map { |id| GlobalID.parse(id).model_id } + ids&.map { |id| GlobalID.parse(id, expected_type: ::DesignManagement::Design).model_id } end def issue diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb new file mode 100644 index 00000000000..ac51011eea8 --- /dev/null +++ b/app/graphql/resolvers/group_issues_resolver.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Resolvers + class GroupIssuesResolver < IssuesResolver + argument :include_subgroups, GraphQL::BOOLEAN_TYPE, + required: false, + default_value: false, + description: 'Include issues belonging to subgroups.' + end +end diff --git a/app/graphql/resolvers/group_milestones_resolver.rb b/app/graphql/resolvers/group_milestones_resolver.rb new file mode 100644 index 00000000000..8d34cea4fa1 --- /dev/null +++ b/app/graphql/resolvers/group_milestones_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + class GroupMilestonesResolver < MilestonesResolver + argument :include_descendants, GraphQL::BOOLEAN_TYPE, + required: false, + description: 'Also return milestones in all subgroups and subprojects' + + private + + def parent_id_parameters(args) + return { group_ids: parent.id } unless args[:include_descendants].present? + + { + group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id), + project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user) + } + end + + def group_projects + GroupProjectsFinder.new( + group: parent, + current_user: current_user, + options: { include_subgroups: true } + ).execute + end + end +end diff --git a/app/graphql/resolvers/issue_status_counts_resolver.rb b/app/graphql/resolvers/issue_status_counts_resolver.rb new file mode 100644 index 00000000000..466ca538467 --- /dev/null +++ b/app/graphql/resolvers/issue_status_counts_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class IssueStatusCountsResolver < BaseResolver + prepend IssueResolverFields + + type Types::IssueStatusCountsType, null: true + + def continue_issue_resolve(parent, finder, **args) + Gitlab::IssuablesCountForState.new(finder, parent) + end + end +end diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 9d0535a208f..e2874f6643c 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -2,49 +2,11 @@ module Resolvers class IssuesResolver < BaseResolver - argument :iid, GraphQL::STRING_TYPE, - required: false, - description: 'IID of the issue. For example, "1"' + prepend IssueResolverFields - argument :iids, [GraphQL::STRING_TYPE], - required: false, - description: 'List of IIDs of issues. For example, [1, 2]' argument :state, Types::IssuableStateEnum, required: false, description: 'Current state of this issue' - argument :label_name, GraphQL::STRING_TYPE.to_list_type, - required: false, - description: 'Labels applied to this issue' - argument :milestone_title, GraphQL::STRING_TYPE.to_list_type, - required: false, - description: 'Milestones applied to this issue' - argument :assignee_username, GraphQL::STRING_TYPE, - required: false, - description: 'Username of a user assigned to the issues' - argument :assignee_id, GraphQL::STRING_TYPE, - required: false, - description: 'ID of a user assigned to the issues, "none" and "any" values supported' - argument :created_before, Types::TimeType, - required: false, - description: 'Issues created before this date' - argument :created_after, Types::TimeType, - required: false, - description: 'Issues created after this date' - argument :updated_before, Types::TimeType, - required: false, - description: 'Issues updated before this date' - argument :updated_after, Types::TimeType, - required: false, - description: 'Issues updated after this date' - argument :closed_before, Types::TimeType, - required: false, - description: 'Issues closed before this date' - argument :closed_after, Types::TimeType, - required: false, - description: 'Issues closed after this date' - argument :search, GraphQL::STRING_TYPE, - required: false, - description: 'Search query for issue title or description' argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria', required: false, @@ -56,19 +18,7 @@ module Resolvers label_priority_asc label_priority_desc milestone_due_asc milestone_due_desc].freeze - def resolve(**args) - # The project could have been loaded in batch by `BatchLoader`. - # At this point we need the `id` of the project to query for issues, so - # make sure it's loaded and not `nil` before continuing. - parent = object.respond_to?(:sync) ? object.sync : object - return Issue.none if parent.nil? - - # Will need to be be made group & namespace aware with - # https://gitlab.com/gitlab-org/gitlab-foss/issues/54520 - args[:iids] ||= [args.delete(:iid)].compact if args[:iid] - args[:attempt_project_search_optimizations] = true if args[:search].present? - - finder = IssuesFinder.new(current_user, args) + def continue_issue_resolve(parent, finder, **args) issues = Gitlab::Graphql::Loaders::IssuableLoader.new(parent, finder).batching_find_all if non_stable_cursor_sort?(args[:sort]) @@ -80,13 +30,6 @@ module Resolvers end end - def self.resolver_complexity(args, child_complexity:) - complexity = super - complexity += 2 if args[:labelName] - - complexity - end - def non_stable_cursor_sort?(sort) NON_STABLE_CURSOR_SORTS.include?(sort) end diff --git a/app/graphql/resolvers/merge_requests_resolver.rb b/app/graphql/resolvers/merge_requests_resolver.rb index 3aa52341eec..d15a1ede6fe 100644 --- a/app/graphql/resolvers/merge_requests_resolver.rb +++ b/app/graphql/resolvers/merge_requests_resolver.rb @@ -28,6 +28,12 @@ module Resolvers required: false, as: :label_name, description: 'Array of label names. All resolved merge requests will have all of these labels.' + argument :merged_after, Types::TimeType, + required: false, + description: 'Merge requests merged after this date' + argument :merged_before, Types::TimeType, + required: false, + description: 'Merge requests merged before this date' def self.single ::Resolvers::MergeRequestResolver diff --git a/app/graphql/resolvers/milestone_resolver.rb b/app/graphql/resolvers/milestone_resolver.rb deleted file mode 100644 index bcfbc63c31f..00000000000 --- a/app/graphql/resolvers/milestone_resolver.rb +++ /dev/null @@ -1,71 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - class MilestoneResolver < BaseResolver - include Gitlab::Graphql::Authorize::AuthorizeResource - include TimeFrameArguments - - argument :state, Types::MilestoneStateEnum, - required: false, - description: 'Filter milestones by state' - - argument :include_descendants, GraphQL::BOOLEAN_TYPE, - required: false, - description: 'Return also milestones in all subgroups and subprojects' - - type Types::MilestoneType, null: true - - def resolve(**args) - validate_timeframe_params!(args) - - authorize! - - MilestonesFinder.new(milestones_finder_params(args)).execute - end - - private - - def milestones_finder_params(args) - { - state: args[:state] || 'all', - start_date: args[:start_date], - end_date: args[:end_date] - }.merge(parent_id_parameter(args)) - end - - def parent - @parent ||= object.respond_to?(:sync) ? object.sync : object - end - - def parent_id_parameter(args) - if parent.is_a?(Group) - group_parameters(args) - elsif parent.is_a?(Project) - { project_ids: parent.id } - end - end - - # MilestonesFinder does not check for current_user permissions, - # so for now we need to keep it here. - def authorize! - Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error! - end - - def group_parameters(args) - return { group_ids: parent.id } unless args[:include_descendants].present? - - { - group_ids: parent.self_and_descendants.public_or_visible_to_user(current_user).select(:id), - project_ids: group_projects.with_issues_or_mrs_available_for_user(current_user) - } - end - - def group_projects - GroupProjectsFinder.new( - group: parent, - current_user: current_user, - options: { include_subgroups: true } - ).execute - end - end -end diff --git a/app/graphql/resolvers/milestones_resolver.rb b/app/graphql/resolvers/milestones_resolver.rb new file mode 100644 index 00000000000..5f80506c01b --- /dev/null +++ b/app/graphql/resolvers/milestones_resolver.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Resolvers + class MilestonesResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include TimeFrameArguments + + argument :ids, [GraphQL::ID_TYPE], + required: false, + description: 'Array of global milestone IDs, e.g., "gid://gitlab/Milestone/1"' + + argument :state, Types::MilestoneStateEnum, + required: false, + description: 'Filter milestones by state' + + type Types::MilestoneType, null: true + + def resolve(**args) + validate_timeframe_params!(args) + + authorize! + + MilestonesFinder.new(milestones_finder_params(args)).execute + end + + private + + def milestones_finder_params(args) + { + ids: parse_gids(args[:ids]), + state: args[:state] || 'all', + start_date: args[:start_date], + end_date: args[:end_date] + }.merge(parent_id_parameters(args)) + end + + def parent + synchronized_object + end + + def parent_id_parameters(args) + raise NotImplementedError + end + + # MilestonesFinder does not check for current_user permissions, + # so for now we need to keep it here. + def authorize! + Ability.allowed?(context[:current_user], :read_milestone, parent) || raise_resource_not_available_error! + end + + def parse_gids(gids) + gids&.map { |gid| GitlabSchema.parse_gid(gid, expected_type: Milestone).model_id } + end + end +end diff --git a/app/graphql/resolvers/project_milestones_resolver.rb b/app/graphql/resolvers/project_milestones_resolver.rb new file mode 100644 index 00000000000..976fc300b87 --- /dev/null +++ b/app/graphql/resolvers/project_milestones_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class ProjectMilestonesResolver < MilestonesResolver + argument :include_ancestors, GraphQL::BOOLEAN_TYPE, + required: false, + description: "Also return milestones in the project's parent group and its ancestors" + + private + + def parent_id_parameters(args) + return { project_ids: parent.id } unless args[:include_ancestors].present? && parent.group.present? + + { + group_ids: parent.group.self_and_ancestors.select(:id), + project_ids: parent.id + } + end + end +end diff --git a/app/graphql/resolvers/project_pipeline_resolver.rb b/app/graphql/resolvers/project_pipeline_resolver.rb index 5bafe3dd140..181c1e77109 100644 --- a/app/graphql/resolvers/project_pipeline_resolver.rb +++ b/app/graphql/resolvers/project_pipeline_resolver.rb @@ -10,7 +10,7 @@ module Resolvers def resolve(iid:) BatchLoader::GraphQL.for(iid).batch(key: project) do |iids, loader, args| - args[:key].ci_pipelines.for_iid(iids).each { |pl| loader.call(pl.iid.to_s, pl) } + args[:key].all_pipelines.for_iid(iids).each { |pl| loader.call(pl.iid.to_s, pl) } end end end diff --git a/app/graphql/resolvers/projects/jira_projects_resolver.rb b/app/graphql/resolvers/projects/jira_projects_resolver.rb index 2dc712128cc..ed382ac82d0 100644 --- a/app/graphql/resolvers/projects/jira_projects_resolver.rb +++ b/app/graphql/resolvers/projects/jira_projects_resolver.rb @@ -16,7 +16,14 @@ module Resolvers response = jira_projects(name: name) if response.success? - response.payload[:projects] + projects_array = response.payload[:projects] + + GraphQL::Pagination::ArrayConnection.new( + projects_array, + # override default max_page_size to whatever the size of the response is, + # see https://gitlab.com/gitlab-org/gitlab/-/issues/231394 + args.merge({ max_page_size: projects_array.size }) + ) else raise Gitlab::Graphql::Errors::BaseError, response.message end diff --git a/app/graphql/resolvers/todo_resolver.rb b/app/graphql/resolvers/todo_resolver.rb index cff65321dc0..bd5f8f274cd 100644 --- a/app/graphql/resolvers/todo_resolver.rb +++ b/app/graphql/resolvers/todo_resolver.rb @@ -4,7 +4,7 @@ module Resolvers class TodoResolver < BaseResolver type Types::TodoType, null: true - alias_method :user, :object + alias_method :target, :object argument :action, [Types::TodoActionEnum], required: false, @@ -31,9 +31,10 @@ module Resolvers description: 'The type of the todo' def resolve(**args) - return Todo.none if user != context[:current_user] + return Todo.none unless current_user.present? && target.present? + return Todo.none if target.is_a?(User) && target != current_user - TodosFinder.new(user, todo_finder_params(args)).execute + TodosFinder.new(current_user, todo_finder_params(args)).execute end private @@ -46,6 +47,15 @@ module Resolvers author_id: args[:author_id], action_id: args[:action], project_id: args[:project_id] + }.merge(target_params) + end + + def target_params + return {} unless TodosFinder::TODO_TYPES.include?(target.class.name) + + { + type: target.class.name, + target_id: target.id } end end diff --git a/app/graphql/types/alert_management/alert_type.rb b/app/graphql/types/alert_management/alert_type.rb index 089d2426158..1a0b0685ffe 100644 --- a/app/graphql/types/alert_management/alert_type.rb +++ b/app/graphql/types/alert_management/alert_type.rb @@ -71,7 +71,7 @@ module Types description: 'Number of events of this alert', method: :events - field :details, + field :details, # rubocop:disable Graphql/JSONType GraphQL::Types::JSON, null: true, description: 'Alert details' @@ -94,8 +94,28 @@ module Types field :metrics_dashboard_url, GraphQL::STRING_TYPE, null: true, - description: 'URL for metrics embed for the alert', - resolve: -> (alert, _args, _context) { alert.present.metrics_dashboard_url } + description: 'URL for metrics embed for the alert' + + field :runbook, + GraphQL::STRING_TYPE, + null: true, + description: 'Runbook for the alert as defined in alert details' + + field :todos, + Types::TodoType.connection_type, + null: true, + description: 'Todos of the current user for the alert', + resolver: Resolvers::TodoResolver + + field :details_url, + GraphQL::STRING_TYPE, + null: false, + description: 'The URL of the alert detail page' + + field :prometheus_alert, + Types::PrometheusAlertType, + null: true, + description: 'The alert condition for Prometheus' def notes object.ordered_notes diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index e94ff898807..70c0794fc90 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -3,6 +3,8 @@ module Types # rubocop: disable Graphql/AuthorizeTypes class BoardListType < BaseObject + include Gitlab::Utils::StrongMemoize + graphql_name 'BoardList' description 'Represents a list for an issue board' @@ -19,6 +21,31 @@ module Types field :collapsed, GraphQL::BOOLEAN_TYPE, null: true, description: 'Indicates if list is collapsed for this user', resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) } + field :issues_count, GraphQL::INT_TYPE, null: true, + description: 'Count of issues in the list' + + field :issues, ::Types::IssueType.connection_type, null: true, + description: 'Board issues', + resolver: ::Resolvers::BoardListIssuesResolver + + def issues_count + metadata[:size] + end + + def total_weight + metadata[:total_weight] + end + + def metadata + strong_memoize(:metadata) do + list = self.object + user = context[:current_user] + + Boards::Issues::ListService + .new(list.board.resource_parent, user, board_id: list.board_id, id: list.id) + .metadata + end + end end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/ci/group_type.rb b/app/graphql/types/ci/group_type.rb new file mode 100644 index 00000000000..04c0eb93068 --- /dev/null +++ b/app/graphql/types/ci/group_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class GroupType < BaseObject + graphql_name 'CiGroup' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job group' + field :size, GraphQL::INT_TYPE, null: true, + description: 'Size of the group' + field :jobs, Ci::JobType.connection_type, null: true, + description: 'Jobs in group' + end + end +end diff --git a/app/graphql/types/ci/job_type.rb b/app/graphql/types/ci/job_type.rb new file mode 100644 index 00000000000..4c18f3ffd52 --- /dev/null +++ b/app/graphql/types/ci/job_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class JobType < BaseObject + graphql_name 'CiJob' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the job' + field :needs, JobType.connection_type, null: true, + description: 'Builds that must complete before the jobs run' + end + end +end diff --git a/app/graphql/types/ci/pipeline_config_source_enum.rb b/app/graphql/types/ci/pipeline_config_source_enum.rb new file mode 100644 index 00000000000..48f88c133b4 --- /dev/null +++ b/app/graphql/types/ci/pipeline_config_source_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Ci + class PipelineConfigSourceEnum < BaseEnum + ::Ci::PipelineEnums.config_sources.keys.each do |state_symbol| + value state_symbol.to_s.upcase, value: state_symbol.to_s + end + end + end +end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 32050766e5b..82a9f8495ce 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -5,6 +5,8 @@ module Types class PipelineType < BaseObject graphql_name 'Pipeline' + connection_type_class(Types::CountableConnectionType) + authorize :read_pipeline expose_permissions Types::PermissionTypes::Ci::Pipeline @@ -23,6 +25,8 @@ module Types field :detailed_status, Types::Ci::DetailedStatusType, null: false, description: 'Detailed status of the pipeline', resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) } + field :config_source, PipelineConfigSourceEnum, null: true, + description: "Config source of the pipeline (#{::Ci::PipelineEnums.config_sources.keys.join(', ').upcase})" field :duration, GraphQL::INT_TYPE, null: true, description: 'Duration of the pipeline in seconds' field :coverage, GraphQL::FLOAT_TYPE, null: true, @@ -37,8 +41,13 @@ module Types description: "Timestamp of the pipeline's completion" field :committed_at, Types::TimeType, null: true, description: "Timestamp of the pipeline's commit" - - # TODO: Add triggering user as a type + field :stages, Types::Ci::StageType.connection_type, null: true, + description: 'Stages of the pipeline', + extras: [:lookahead], + resolver: Resolvers::Ci::PipelineStagesResolver + field :user, Types::UserType, null: true, + description: 'Pipeline user', + resolve: -> (pipeline, _args, _context) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, pipeline.user_id).find } end end end diff --git a/app/graphql/types/ci/stage_type.rb b/app/graphql/types/ci/stage_type.rb new file mode 100644 index 00000000000..278c4d4d748 --- /dev/null +++ b/app/graphql/types/ci/stage_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Ci + # rubocop: disable Graphql/AuthorizeTypes + class StageType < BaseObject + graphql_name 'CiStage' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the stage' + field :groups, Ci::GroupType.connection_type, null: true, + description: 'Group of jobs for the stage' + end + end +end diff --git a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb b/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb deleted file mode 100644 index ccd1c7dd0eb..00000000000 --- a/app/graphql/types/ci_configuration/sast/analyzers_entity_type.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Types - module CiConfiguration - module Sast - # rubocop: disable Graphql/AuthorizeTypes - class AnalyzersEntityType < BaseObject - graphql_name 'SastCiConfigurationAnalyzersEntity' - description 'Represents an analyzer entity in SAST CI configuration' - - field :name, GraphQL::STRING_TYPE, null: true, - description: 'Name of the analyzer.' - - field :label, GraphQL::STRING_TYPE, null: true, - description: 'Analyzer label used in the config UI.' - - field :enabled, GraphQL::BOOLEAN_TYPE, null: true, - description: 'Indicates whether an analyzer is enabled.' - - field :description, GraphQL::STRING_TYPE, null: true, - description: 'Analyzer description that is displayed on the form.' - end - end - end -end diff --git a/app/graphql/types/ci_configuration/sast/entity_type.rb b/app/graphql/types/ci_configuration/sast/entity_type.rb deleted file mode 100644 index b61b582ad20..00000000000 --- a/app/graphql/types/ci_configuration/sast/entity_type.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -module Types - module CiConfiguration - module Sast - # rubocop: disable Graphql/AuthorizeTypes - class EntityType < BaseObject - graphql_name 'SastCiConfigurationEntity' - description 'Represents an entity in SAST CI configuration' - - field :field, GraphQL::STRING_TYPE, null: true, - description: 'CI keyword of entity.' - - field :label, GraphQL::STRING_TYPE, null: true, - description: 'Label for entity used in the form.' - - field :type, GraphQL::STRING_TYPE, null: true, - description: 'Type of the field value.' - - field :options, ::Types::CiConfiguration::Sast::OptionsEntityType.connection_type, null: true, - description: 'Different possible values of the field.' - - field :default_value, GraphQL::STRING_TYPE, null: true, - description: 'Default value that is used if value is empty.' - - field :description, GraphQL::STRING_TYPE, null: true, - description: 'Entity description that is displayed on the form.' - - field :value, GraphQL::STRING_TYPE, null: true, - description: 'Current value of the entity.' - end - end - end -end diff --git a/app/graphql/types/ci_configuration/sast/options_entity_type.rb b/app/graphql/types/ci_configuration/sast/options_entity_type.rb deleted file mode 100644 index 86d104a7fda..00000000000 --- a/app/graphql/types/ci_configuration/sast/options_entity_type.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Types - module CiConfiguration - module Sast - # rubocop: disable Graphql/AuthorizeTypes - class OptionsEntityType < BaseObject - graphql_name 'SastCiConfigurationOptionsEntity' - description 'Represents an entity for options in SAST CI configuration' - - field :label, GraphQL::STRING_TYPE, null: true, - description: 'Label of option entity.' - - field :value, GraphQL::STRING_TYPE, null: true, - description: 'Value of option entity.' - end - end - end -end diff --git a/app/graphql/types/ci_configuration/sast/type.rb b/app/graphql/types/ci_configuration/sast/type.rb deleted file mode 100644 index 35d11584ac7..00000000000 --- a/app/graphql/types/ci_configuration/sast/type.rb +++ /dev/null @@ -1,22 +0,0 @@ -# frozen_string_literal: true - -module Types - module CiConfiguration - module Sast - # rubocop: disable Graphql/AuthorizeTypes - class Type < BaseObject - graphql_name 'SastCiConfiguration' - description 'Represents a CI configuration of SAST' - - field :global, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, - description: 'List of global entities related to SAST configuration.' - - field :pipeline, ::Types::CiConfiguration::Sast::EntityType.connection_type, null: true, - description: 'List of pipeline entities related to SAST configuration.' - - field :analyzers, ::Types::CiConfiguration::Sast::AnalyzersEntityType.connection_type, null: true, - description: 'List of analyzers entities attached to SAST configuration.' - end - end - end -end diff --git a/app/graphql/types/commit_type.rb b/app/graphql/types/commit_type.rb index be5165da545..dd4b4c3b114 100644 --- a/app/graphql/types/commit_type.rb +++ b/app/graphql/types/commit_type.rb @@ -17,12 +17,15 @@ module Types markdown_field :title_html, null: true field :description, type: GraphQL::STRING_TYPE, null: true, description: 'Description of the commit message' + markdown_field :description_html, null: true field :message, type: GraphQL::STRING_TYPE, null: true, description: 'Raw commit message' field :authored_date, type: Types::TimeType, null: true, description: 'Timestamp of when the commit was authored' field :web_url, type: GraphQL::STRING_TYPE, null: false, description: 'Web URL of the commit' + field :web_path, type: GraphQL::STRING_TYPE, null: false, + description: 'Web path of the commit' field :signature_html, type: GraphQL::STRING_TYPE, null: true, calls_gitaly: true, description: 'Rendered HTML of the commit signature' field :author_name, type: GraphQL::STRING_TYPE, null: true, diff --git a/app/graphql/types/countable_connection_type.rb b/app/graphql/types/countable_connection_type.rb new file mode 100644 index 00000000000..2538366b786 --- /dev/null +++ b/app/graphql/types/countable_connection_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class CountableConnectionType < GraphQL::Types::Relay::BaseConnection + field :count, Integer, null: false, + description: 'Total count of collection' + + def count + # rubocop: disable CodeReuse/ActiveRecord + relation = object.items + + # sometimes relation is an Array + relation = relation.reorder(nil) if relation.respond_to?(:reorder) + # rubocop: enable CodeReuse/ActiveRecord + + if relation.try(:group_values)&.present? + relation.size.keys.size + else + relation.size + end + end + end +end diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index 34a90006d03..239b26f9c38 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -19,5 +19,10 @@ module Types field :metrics_dashboard, Types::Metrics::DashboardType, null: true, description: 'Metrics dashboard schema for the environment', resolver: Resolvers::Metrics::DashboardResolver + + field :latest_opened_most_severe_alert, + Types::AlertManagement::AlertType, + null: true, + description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.' end end diff --git a/app/graphql/types/group_type.rb b/app/graphql/types/group_type.rb index fd7d9a9ba3d..cc8cd7c01f9 100644 --- a/app/graphql/types/group_type.rb +++ b/app/graphql/types/group_type.rb @@ -47,11 +47,11 @@ module Types Types::IssueType.connection_type, null: true, description: 'Issues of the group', - resolver: Resolvers::IssuesResolver + resolver: Resolvers::GroupIssuesResolver field :milestones, Types::MilestoneType.connection_type, null: true, - description: 'Find milestones', - resolver: Resolvers::MilestoneResolver + description: 'Milestones of the group', + resolver: Resolvers::GroupMilestonesResolver field :boards, Types::BoardType.connection_type, diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb index f2f6d6c6cab..543b7f8e5b2 100644 --- a/app/graphql/types/issuable_state_enum.rb +++ b/app/graphql/types/issuable_state_enum.rb @@ -8,5 +8,6 @@ module Types value 'opened' value 'closed' value 'locked' + value 'all' end end diff --git a/app/graphql/types/issue_connection_type.rb b/app/graphql/types/issue_connection_type.rb deleted file mode 100644 index beed392f01a..00000000000 --- a/app/graphql/types/issue_connection_type.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Types - # rubocop: disable Graphql/AuthorizeTypes - class IssueConnectionType < GraphQL::Types::Relay::BaseConnection - field :count, Integer, null: false, - description: 'Total count of collection' - - def count - object.items.size - end - end -end diff --git a/app/graphql/types/issue_status_counts_type.rb b/app/graphql/types/issue_status_counts_type.rb new file mode 100644 index 00000000000..f2b1ba8e655 --- /dev/null +++ b/app/graphql/types/issue_status_counts_type.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class IssueStatusCountsType < BaseObject + graphql_name 'IssueStatusCountsType' + description "Represents total number of issues for the represented statuses." + + authorize :read_issue + + def self.available_issue_states + @available_issue_states ||= Issue.available_states.keys.push('all') + end + + ::Gitlab::IssuablesCountForState::STATES.each do |state| + next unless available_issue_states.include?(state.downcase) + + field state, + GraphQL::INT_TYPE, + null: true, + description: "Number of issues with status #{state.upcase} for the project" + end + end +end diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 9baa0018999..0a73ce95424 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -4,7 +4,7 @@ module Types class IssueType < BaseObject graphql_name 'Issue' - connection_type_class(Types::IssueConnectionType) + connection_type_class(Types::CountableConnectionType) implements(Types::Notes::NoteableType) @@ -97,6 +97,10 @@ module Types field :design_collection, Types::DesignManagement::DesignCollectionType, null: true, description: 'Collection of design images associated with this issue' + + field :type, Types::IssueTypeEnum, null: true, + method: :issue_type, + description: 'Type of the issue' end end diff --git a/app/graphql/types/issue_type_enum.rb b/app/graphql/types/issue_type_enum.rb new file mode 100644 index 00000000000..7dc45f78c99 --- /dev/null +++ b/app/graphql/types/issue_type_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + class IssueTypeEnum < BaseEnum + graphql_name 'IssueType' + description 'Issue type' + + ::Issue.issue_types.keys.each do |issue_type| + value issue_type.upcase, value: issue_type, description: "#{issue_type.titleize} issue type" + end + end +end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index c194b467363..01b02b7976f 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -4,6 +4,8 @@ module Types class MergeRequestType < BaseObject graphql_name 'MergeRequest' + connection_type_class(Types::CountableConnectionType) + implements(Types::Notes::NoteableType) authorize :read_merge_request @@ -141,6 +143,8 @@ module Types end field :task_completion_status, Types::TaskCompletionStatus, null: false, description: Types::TaskCompletionStatus.description + field :commit_count, GraphQL::INT_TYPE, null: true, + description: 'Number of commits in the merge request' def diff_stats(path: nil) stats = Array.wrap(object.diff_stats&.to_a) @@ -160,5 +164,14 @@ module Types hash.merge!(additions: status.additions, deletions: status.deletions, file_count: 1) { |_, x, y| x + y } end end + + def commit_count + object&.metrics&.commits_count + end + + def approvers + object.approver_users + end end end +Types::MergeRequestType.prepend_if_ee('::EE::Types::MergeRequestType') diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 49d51b626b2..e143d14676e 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -14,12 +14,17 @@ module Types mount_mutation Mutations::AwardEmojis::Add mount_mutation Mutations::AwardEmojis::Remove mount_mutation Mutations::AwardEmojis::Toggle + mount_mutation Mutations::Boards::Issues::IssueMoveList + mount_mutation Mutations::Boards::Lists::Create + mount_mutation Mutations::Boards::Lists::Update mount_mutation Mutations::Branches::Create, calls_gitaly: true mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::Discussions::ToggleResolve + mount_mutation Mutations::Issues::SetAssignees mount_mutation Mutations::Issues::SetConfidential mount_mutation Mutations::Issues::SetLocked mount_mutation Mutations::Issues::SetDueDate + mount_mutation Mutations::Issues::SetSubscription mount_mutation Mutations::Issues::Update mount_mutation Mutations::MergeRequests::Create mount_mutation Mutations::MergeRequests::Update @@ -55,6 +60,7 @@ module Types mount_mutation Mutations::JiraImport::ImportUsers mount_mutation Mutations::DesignManagement::Upload, calls_gitaly: true mount_mutation Mutations::DesignManagement::Delete, calls_gitaly: true + mount_mutation Mutations::DesignManagement::Move mount_mutation Mutations::ContainerExpirationPolicies::Update end end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 2251a0f4e0c..5562db69de6 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -148,6 +148,16 @@ module Types description: 'Issues of the project', resolver: Resolvers::IssuesResolver + field :issue_status_counts, + Types::IssueStatusCountsType, + null: true, + description: 'Counts of issues by status for the project', + resolver: Resolvers::IssueStatusCountsResolver + + field :milestones, Types::MilestoneType.connection_type, null: true, + description: 'Milestones of the project', + resolver: Resolvers::ProjectMilestonesResolver + field :project_members, Types::ProjectMemberType.connection_type, description: 'Members of the project', @@ -159,9 +169,11 @@ module Types description: 'Environments of the project', resolver: Resolvers::EnvironmentsResolver - field :sast_ci_configuration, ::Types::CiConfiguration::Sast::Type, null: true, - description: 'SAST CI configuration for the project', - resolver: ::Resolvers::CiConfiguration::SastResolver + field :environment, + Types::EnvironmentType, + null: true, + description: 'A single environment of the project', + resolver: Resolvers::EnvironmentsResolver.single field :issue, Types::IssueType, diff --git a/app/graphql/types/projects/services/jira_service_type.rb b/app/graphql/types/projects/services/jira_service_type.rb index 8bf85a14cbf..cb0712249e3 100644 --- a/app/graphql/types/projects/services/jira_service_type.rb +++ b/app/graphql/types/projects/services/jira_service_type.rb @@ -13,8 +13,6 @@ module Types field :projects, Types::Projects::Services::JiraProjectType.connection_type, null: true, - connection: false, - extensions: [Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension], description: 'List of all Jira projects fetched through Jira REST API', resolver: Resolvers::Projects::JiraProjectsResolver end diff --git a/app/graphql/types/prometheus_alert_type.rb b/app/graphql/types/prometheus_alert_type.rb new file mode 100644 index 00000000000..1d09a8dbeb7 --- /dev/null +++ b/app/graphql/types/prometheus_alert_type.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + class PrometheusAlertType < BaseObject + graphql_name 'PrometheusAlert' + description 'The alert condition for Prometheus' + + authorize :read_prometheus_alerts + + present_using PrometheusAlertPresenter + + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the alert condition' + + field :humanized_text, + GraphQL::STRING_TYPE, + null: false, + description: 'The human-readable text of the alert condition' + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index b4cbd96bfdb..c04f4da70cf 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -47,6 +47,15 @@ module Types null: false, description: 'Fields related to design management' + field :milestone, ::Types::MilestoneType, + null: true, + description: 'Find a milestone', + resolve: -> (_obj, args, _ctx) { GitlabSchema.find_by_gid(args[:id]) } do + argument :id, ::Types::GlobalIDType[Milestone], + required: true, + description: 'Find a milestone by its ID' + end + field :user, Types::UserType, null: true, description: 'Find a user', diff --git a/app/graphql/types/snippet_type.rb b/app/graphql/types/snippet_type.rb index 73ca3425ded..db98e62c10a 100644 --- a/app/graphql/types/snippet_type.rb +++ b/app/graphql/types/snippet_type.rb @@ -66,7 +66,8 @@ module Types field :blob, type: Types::Snippets::BlobType, description: 'Snippet blob', calls_gitaly: true, - null: false + null: false, + deprecated: { reason: 'Use `blobs`', milestone: '13.3' } field :blobs, type: [Types::Snippets::BlobType], description: 'Snippet blobs', diff --git a/app/graphql/types/snippets/file_input_action_enum.rb b/app/graphql/types/snippets/blob_action_enum.rb index 7785853f3a8..e3f89920f16 100644 --- a/app/graphql/types/snippets/file_input_action_enum.rb +++ b/app/graphql/types/snippets/blob_action_enum.rb @@ -2,9 +2,9 @@ module Types module Snippets - class FileInputActionEnum < BaseEnum - graphql_name 'SnippetFileInputActionEnum' - description 'Type of a snippet file input action' + class BlobActionEnum < BaseEnum + graphql_name 'SnippetBlobActionEnum' + description 'Type of a snippet blob input action' value 'create', value: :create value 'update', value: :update diff --git a/app/graphql/types/snippets/file_input_type.rb b/app/graphql/types/snippets/blob_action_input_type.rb index 85a02c8f493..ccb6ae3f2c1 100644 --- a/app/graphql/types/snippets/file_input_type.rb +++ b/app/graphql/types/snippets/blob_action_input_type.rb @@ -2,11 +2,11 @@ module Types module Snippets - class FileInputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes - graphql_name 'SnippetFileInputType' + class BlobActionInputType < BaseInputObject # rubocop:disable Graphql/AuthorizeTypes + graphql_name 'SnippetBlobActionInputType' description 'Represents an action to perform over a snippet file' - argument :action, Types::Snippets::FileInputActionEnum, + argument :action, Types::Snippets::BlobActionEnum, description: 'Type of input action', required: true diff --git a/app/graphql/types/time_type.rb b/app/graphql/types/time_type.rb index f045a50e672..c31e4873df0 100644 --- a/app/graphql/types/time_type.rb +++ b/app/graphql/types/time_type.rb @@ -7,6 +7,8 @@ module Types def self.coerce_input(value, ctx) Time.parse(value) + rescue ArgumentError, TypeError => e + raise GraphQL::CoercionError, e.message end def self.coerce_result(value, ctx) diff --git a/app/graphql/types/tree/blob_type.rb b/app/graphql/types/tree/blob_type.rb index 36cae756a0d..cc6bf7b4f00 100644 --- a/app/graphql/types/tree/blob_type.rb +++ b/app/graphql/types/tree/blob_type.rb @@ -12,6 +12,8 @@ module Types field :web_url, GraphQL::STRING_TYPE, null: true, description: 'Web URL of the blob' + field :web_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path of the blob' field :lfs_oid, GraphQL::STRING_TYPE, null: true, description: 'LFS ID of the blob', resolve: -> (blob, args, ctx) do diff --git a/app/graphql/types/tree/tree_entry_type.rb b/app/graphql/types/tree/tree_entry_type.rb index 81a7a7e66ae..aff2e025761 100644 --- a/app/graphql/types/tree/tree_entry_type.rb +++ b/app/graphql/types/tree/tree_entry_type.rb @@ -13,6 +13,8 @@ module Types field :web_url, GraphQL::STRING_TYPE, null: true, description: 'Web URL for the tree entry (directory)' + field :web_path, GraphQL::STRING_TYPE, null: true, + description: 'Web path for the tree entry (directory)' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/user_status_type.rb b/app/graphql/types/user_status_type.rb new file mode 100644 index 00000000000..ff277c1f8e8 --- /dev/null +++ b/app/graphql/types/user_status_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class UserStatusType < BaseObject + graphql_name 'UserStatus' + + markdown_field :message_html, null: true, + description: 'HTML of the user status message' + field :message, GraphQL::STRING_TYPE, null: true, + description: 'User status message' + field :emoji, GraphQL::STRING_TYPE, null: true, + description: 'String representation of emoji' + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index ab3c84ea539..cb3575b41d1 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -18,16 +18,22 @@ module Types description: 'Human-readable name of the user' field :state, Types::UserStateEnum, null: false, description: 'State of the user' + field :email, GraphQL::STRING_TYPE, null: true, + description: 'User email' field :avatar_url, GraphQL::STRING_TYPE, null: true, description: "URL of the user's avatar" field :web_url, GraphQL::STRING_TYPE, null: false, description: 'Web URL of the user' + field :web_path, GraphQL::STRING_TYPE, null: false, + description: 'Web path of the user' field :todos, Types::TodoType.connection_type, null: false, resolver: Resolvers::TodoResolver, description: 'Todos of the user' field :group_memberships, Types::GroupMemberType.connection_type, null: true, description: 'Group memberships of the user', method: :group_members + field :status, Types::UserStatusType, null: true, + description: 'User status' field :project_memberships, Types::ProjectMemberType.connection_type, null: true, description: 'Project memberships of the user', method: :project_members diff --git a/app/helpers/active_sessions_helper.rb b/app/helpers/active_sessions_helper.rb index 8fb23f99cb3..b80152777a8 100644 --- a/app/helpers/active_sessions_helper.rb +++ b/app/helpers/active_sessions_helper.rb @@ -20,6 +20,6 @@ module ActiveSessionsHelper 'monitor-o' end - sprite_icon(icon_name, size: 16, css_class: 'gl-mt-2') + sprite_icon(icon_name, css_class: 'gl-mt-2') end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index e8bd5ad9b9b..41b20a1d9a0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -194,6 +194,10 @@ module ApplicationHelper 'https://' + promo_host end + def contact_sales_url + promo_url + '/sales' + end + def support_url Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/' end @@ -231,6 +235,18 @@ module ApplicationHelper "#{request.path}?#{options.compact.to_param}" end + def use_startup_css? + Feature.enabled?(:startup_css) && !Rails.env.test? + end + + def stylesheet_link_tag_defer(path) + if use_startup_css? + stylesheet_link_tag(path, media: "print") + else + stylesheet_link_tag(path, media: "all") + end + end + def outdated_browser? browser.ie? end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index ddaac37e7ed..404700bb25e 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -327,7 +327,8 @@ module ApplicationSettingsHelper :project_download_export_limit, :group_import_limit, :group_export_limit, - :group_download_export_limit + :group_download_export_limit, + :wiki_page_max_content_bytes ] end diff --git a/app/helpers/award_emoji_helper.rb b/app/helpers/award_emoji_helper.rb index 13df53a751b..af9ab93d459 100644 --- a/app/helpers/award_emoji_helper.rb +++ b/app/helpers/award_emoji_helper.rb @@ -12,7 +12,7 @@ module AwardEmojiHelper toggle_award_emoji_project_note_path(@project, awardable.id) end else - url_for([:toggle_award_emoji, @project.namespace.becomes(Namespace), @project, awardable]) + url_for([:toggle_award_emoji, @project, awardable]) end end end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index f4238e7711a..615c834c529 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -48,24 +48,40 @@ module BlobHelper return unless blob = readable_blob(options, path, project, ref) common_classes = "btn btn-primary js-edit-blob ml-2 #{options[:extra_class]}" + data = { track_event: 'click_edit', track_label: 'Edit' } + + if Feature.enabled?(:web_ide_primary_edit, project.group) + common_classes += " btn-inverted" + data[:track_property] = 'secondary' + end edit_button_tag(blob, common_classes, _('Edit'), edit_blob_path(project, ref, path, options), project, - ref) + ref, + data) end def ide_edit_button(project = @project, ref = @ref, path = @path, blob:) return unless blob + common_classes = 'btn btn-primary ide-edit-button ml-2' + data = { track_event: 'click_edit_ide', track_label: 'Web IDE' } + + unless Feature.enabled?(:web_ide_primary_edit, project.group) + common_classes += " btn-inverted" + data[:track_property] = 'secondary' + end + edit_button_tag(blob, - 'btn btn-inverted btn-primary ide-edit-button ml-2', + common_classes, _('Web IDE'), ide_edit_path(project, ref, path), project, - ref) + ref, + data) end def modify_file_button(project = @project, ref = @ref, path = @path, blob:, label:, action:, btn_class:, modal_type:) @@ -184,6 +200,10 @@ module BlobHelper @gitlab_ci_ymls ||= template_dropdown_names(TemplateFinder.build(:gitlab_ci_ymls, project).execute) end + def metrics_dashboard_ymls(project) + @metrics_dashboard_ymls ||= template_dropdown_names(TemplateFinder.build(:metrics_dashboard_ymls, project).execute) + end + def dockerfile_names(project) @dockerfile_names ||= template_dropdown_names(TemplateFinder.build(:dockerfiles, project).execute) end @@ -325,16 +345,16 @@ module BlobHelper button_tag(button_text, class: "#{common_classes} disabled has-tooltip", title: _('You can only edit files when you are on a branch'), data: { container: 'body' }) end - def edit_link_tag(link_text, edit_path, common_classes) - link_to link_text, edit_path, class: "#{common_classes} btn-sm" + def edit_link_tag(link_text, edit_path, common_classes, data) + link_to link_text, edit_path, class: "#{common_classes} btn-sm", data: data end - def edit_button_tag(blob, common_classes, text, edit_path, project, ref) + def edit_button_tag(blob, common_classes, text, edit_path, project, ref, data) if !on_top_of_branch?(project, ref) edit_disabled_button_tag(text, common_classes) # This condition only applies to users who are logged in elsif !current_user || (current_user && can_modify_blob?(blob, project, ref)) - edit_link_tag(text, edit_path, common_classes) + edit_link_tag(text, edit_path, common_classes, data) elsif can?(current_user, :fork_project, project) && can?(current_user, :create_merge_request_in, project) edit_fork_button_tag(common_classes, project, text, edit_blob_fork_params(edit_path)) end @@ -343,7 +363,7 @@ module BlobHelper def show_suggest_pipeline_creation_celebration? experiment_enabled?(:suggest_pipeline) && @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] && - @blob.auxiliary_viewer.valid?(project: @project, sha: @commit.sha, user: current_user) && + @blob.auxiliary_viewer&.valid?(project: @project, sha: @commit.sha, user: current_user) && @project.uses_default_ci_config? && cookies[suggest_pipeline_commit_cookie_name].present? end diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index 60c19e6fecd..2a0242fe2fa 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -8,6 +8,14 @@ module BranchesHelper def protected_branch?(project, branch) ProtectedBranch.protected?(project, branch.name) end + + def access_levels_data(access_levels) + return [] unless access_levels + + access_levels.map do |level| + { id: level.id, type: :role, access_level: level.access_level } + end + end end BranchesHelper.prepend_if_ee('EE::BranchesHelper') diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb new file mode 100644 index 00000000000..749726e0e33 --- /dev/null +++ b/app/helpers/ci/pipelines_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Ci + module PipelinesHelper + def pipeline_warnings(pipeline) + return unless pipeline.warning_messages.any? + + content_tag(:div, class: 'alert alert-warning') do + content_tag(:h4, 'Warning:') << + content_tag(:div) do + pipeline.warning_messages.each do |warning| + concat(markdown(warning.content)) + end + end + end + end + end +end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index c85d2a68f14..b97e847c397 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -24,6 +24,18 @@ module ClustersHelper } end + def js_cluster_form_data(cluster, can_edit) + { + enabled: cluster.enabled?.to_s, + editable: can_edit.to_s, + environment_scope: cluster.environment_scope, + base_domain: cluster.base_domain, + application_ingress_external_ip: cluster.application_ingress_external_ip, + auto_devops_help_path: help_page_path('topics/autodevops/index'), + external_endpoint_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint') + } + end + # This method is depreciated and will be removed when associated HAML files are moved to JavaScript def provider_icon(provider = nil) img_data = js_clusters_list_data.dig(:img_tags, provider&.to_sym) || diff --git a/app/helpers/custom_metrics_helper.rb b/app/helpers/custom_metrics_helper.rb index fbea6d2050f..5ea386e268d 100644 --- a/app/helpers/custom_metrics_helper.rb +++ b/app/helpers/custom_metrics_helper.rb @@ -2,10 +2,8 @@ module CustomMetricsHelper def custom_metrics_data(project, metric) - custom_metrics_path = project.namespace.becomes(::Namespace) - { - 'custom-metrics-path' => url_for([custom_metrics_path, project, metric]), + 'custom-metrics-path' => url_for([project, metric]), 'metric-persisted' => metric.persisted?.to_s, 'edit-project-service-path' => edit_project_service_path(project, PrometheusService), 'validate-query-path' => validate_query_project_prometheus_metrics_path(project), diff --git a/app/helpers/dashboard_helper.rb b/app/helpers/dashboard_helper.rb index 7bf3795d73a..0ba03cd90ea 100644 --- a/app/helpers/dashboard_helper.rb +++ b/app/helpers/dashboard_helper.rb @@ -40,7 +40,7 @@ module DashboardHelper end) if doc_href.present? - link_to_doc = link_to(sprite_icon('question', size: 16), doc_href, + link_to_doc = link_to(sprite_icon('question'), doc_href, class: 'gl-ml-2', title: _('Documentation'), target: '_blank', rel: 'noopener noreferrer') diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index bd400009c96..c4487ae8e4a 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -19,7 +19,7 @@ module EnvironmentHelper end def deployment_path(deployment) - [deployment.project.namespace.becomes(Namespace), deployment.project, deployment.deployable] + [deployment.project, deployment.deployable] end def deployment_link(deployment, text: nil) diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index b522a9dfb4f..39be8ae9f60 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -98,20 +98,21 @@ module EnvironmentsHelper 'deployments-endpoint' => project_environment_deployments_path(project, environment, format: :json), 'alerts-endpoint' => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json), 'operations-settings-path' => project_settings_operations_path(project), - 'can-access-operations-settings' => can?(current_user, :admin_operations, project).to_s + 'can-access-operations-settings' => can?(current_user, :admin_operations, project).to_s, + 'panel-preview-endpoint' => project_metrics_dashboards_builder_path(project, format: :json) } end def static_metrics_data { 'documentation-path' => help_page_path('administration/monitoring/prometheus/index.md'), - 'add-dashboard-documentation-path' => help_page_path('user/project/integrations/prometheus.md', anchor: 'adding-a-new-dashboard-to-your-project'), + 'add-dashboard-documentation-path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), 'empty-getting-started-svg-path' => image_path('illustrations/monitoring/getting_started.svg'), 'empty-loading-svg-path' => image_path('illustrations/monitoring/loading.svg'), 'empty-no-data-svg-path' => image_path('illustrations/monitoring/no_data.svg'), 'empty-no-data-small-svg-path' => image_path('illustrations/chart-empty-state-small.svg'), 'empty-unable-to-connect-svg-path' => image_path('illustrations/monitoring/unable_to_connect.svg'), - 'custom-dashboard-base-path' => Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT + 'custom-dashboard-base-path' => Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT } end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 207230fd92e..0167f2ef698 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -199,8 +199,7 @@ module EventsHelper elsif event.design_note? design_url(event.note_target, anchor: dom_id(event.note)) else - polymorphic_url([event.project.namespace.becomes(Namespace), - event.project, event.note_target], + polymorphic_url([event.project, event.note_target], anchor: dom_id(event.target)) end end diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index ecacde65c10..67bfeb22d92 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -11,7 +11,7 @@ module FormHelper content_tag(:h4, headline) << content_tag(:ul) do messages = model.errors.map do |attribute, message| - message = model.errors.full_message(attribute, message) + message = html_escape_once(model.errors.full_message(attribute, message)).html_safe message = content_tag(:span, message, class: 'str-truncated-100') if truncate.include?(attribute) content_tag(:li, message) diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 04f34f5a3ae..d71e6b4c004 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -100,8 +100,12 @@ module GitlabRoutingHelper toggle_award_emoji_snippet_path(*args) end - def toggle_award_emoji_namespace_project_project_snippet_path(*args) - toggle_award_emoji_namespace_project_snippet_path(*args) + def toggle_award_emoji_project_project_snippet_path(*args) + toggle_award_emoji_project_snippet_path(*args) + end + + def toggle_award_emoji_project_project_snippet_url(*args) + toggle_award_emoji_project_snippet_url(*args) end ## Members @@ -271,7 +275,7 @@ module GitlabRoutingHelper end end - def gitlab_raw_snippet_blob_url(snippet, path, ref = nil) + def gitlab_raw_snippet_blob_url(snippet, path, ref = nil, **options) params = { snippet_id: snippet, ref: ref || snippet.repository.root_ref, @@ -279,26 +283,14 @@ module GitlabRoutingHelper } if snippet.is_a?(ProjectSnippet) - project_snippet_blob_raw_url(snippet.project, params) + project_snippet_blob_raw_url(snippet.project, **params, **options) else - snippet_blob_raw_url(params) + snippet_blob_raw_url(**params, **options) end end - def gitlab_raw_snippet_blob_path(blob, ref = nil) - snippet = blob.container - - params = { - snippet_id: snippet, - ref: ref || blob.repository.root_ref, - path: blob.path - } - - if snippet.is_a?(ProjectSnippet) - project_snippet_blob_raw_path(snippet.project, params) - else - snippet_blob_raw_path(params) - end + def gitlab_raw_snippet_blob_path(snippet, path, ref = nil, **options) + gitlab_raw_snippet_blob_url(snippet, path, ref, only_path: true, **options) end def gitlab_snippet_notes_path(snippet, *args) diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index 49b15cde009..24072d1ab46 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -17,7 +17,7 @@ module GraphHelper end def success_ratio(counts) - return 100 if counts[:failed].zero? + return 100 if counts[:failed] == 0 ratio = (counts[:success].to_f / (counts[:success] + counts[:failed])) * 100 ratio.to_i diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 61c9bd74451..eb80acd869f 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -28,6 +28,7 @@ module GroupsHelper def group_packages_nav_link_paths %w[ + groups/packages#index groups/container_registries#index ] end @@ -129,7 +130,7 @@ module GroupsHelper end def remove_group_message(group) - _("You are going to remove %{group_name}, this will also remove all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % + _("You are going to remove %{group_name}, this will also delete all of its subgroups and projects. Removed groups CANNOT be restored! Are you ABSOLUTELY sure?") % { group_name: group.name } end @@ -157,6 +158,15 @@ module GroupsHelper groups.to_json end + def group_packages_nav? + group_packages_list_nav? || + group_container_registry_nav? + end + + def group_packages_list_nav? + @group.packages_feature_enabled? + end + private def get_group_sidebar_links diff --git a/app/helpers/icons_helper.rb b/app/helpers/icons_helper.rb index add15cc0d12..9957d5c6330 100644 --- a/app/helpers/icons_helper.rb +++ b/app/helpers/icons_helper.rb @@ -6,6 +6,8 @@ module IconsHelper extend self include FontAwesome::Rails::IconHelper + DEFAULT_ICON_SIZE = 16 + # Creates an icon tag given icon name(s) and possible icon modifiers. # # Right now this method simply delegates directly to `fa_icon` from the @@ -21,7 +23,7 @@ module IconsHelper options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) end - def custom_icon(icon_name, size: 16) + def custom_icon(icon_name, size: DEFAULT_ICON_SIZE) # We can't simply do the below, because there are some .erb SVGs. # File.read(Rails.root.join("app/views/shared/icons/_#{icon_name}.svg")).html_safe render "shared/icons/#{icon_name}.svg", size: size @@ -43,7 +45,7 @@ module IconsHelper ActionController::Base.helpers.image_path('file_icons.svg', host: sprite_base_url) end - def sprite_icon(icon_name, size: nil, css_class: nil) + def sprite_icon(icon_name, size: DEFAULT_ICON_SIZE, css_class: nil) if known_sprites&.exclude?(icon_name) exception = ArgumentError.new("#{icon_name} is not a known icon in @gitlab-org/gitlab-svg") Gitlab::ErrorTracking.track_and_raise_for_dev_exception(exception) @@ -52,7 +54,13 @@ module IconsHelper css_classes = [] css_classes << "s#{size}" if size css_classes << "#{css_class}" unless css_class.blank? - content_tag(:svg, content_tag(:use, "", { "xlink:href" => "#{sprite_icon_path}##{icon_name}" } ), class: css_classes.empty? ? nil : css_classes.join(' ')) + + content_tag( + :svg, + content_tag(:use, '', { 'xlink:href' => "#{sprite_icon_path}##{icon_name}" } ), + class: css_classes.empty? ? nil : css_classes.join(' '), + data: { testid: "#{icon_name}-icon" } + ) end def loading_icon(container: false, color: 'orange', size: 'sm', css_class: nil) @@ -94,11 +102,11 @@ module IconsHelper if value icon('circle', class: 'cgreen') else - icon('power-off', class: 'clgray') + sprite_icon('power', css_class: 'clgray') end end - def visibility_level_icon(level, fw: true, options: {}) + def visibility_level_icon(level, options: {}) name = case level when Gitlab::VisibilityLevel::PRIVATE @@ -106,13 +114,12 @@ module IconsHelper when Gitlab::VisibilityLevel::INTERNAL 'shield' else # Gitlab::VisibilityLevel::PUBLIC - 'globe' + 'earth' end - name = [name] - name << "fw" if fw + css_class = options.delete(:class) - icon(name.join(' '), options) + sprite_icon(name, size: DEFAULT_ICON_SIZE, css_class: css_class) end def file_type_icon_class(type, mode, name) diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 1ee67211ab0..329bbb5ad82 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -56,7 +56,7 @@ module ImportHelper link_url = 'https://github.com/settings/tokens' link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url } - _('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } + html_escape(_('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the %{code_open}repo%{code_close} scope, so we can display a list of your public and private repositories which are available to import.')) % { link_start: link_start, link_end: '</a>'.html_safe, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } end def import_configure_github_admin_message diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index c9ba42491f3..0b859a39c4f 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -205,7 +205,7 @@ module IssuablesHelper author_output end - output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!')) + output << content_tag(:span, (sprite_icon('first-contribution', css_class: 'gl-icon gl-vertical-align-middle') if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!')) output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block gl-ml-3") output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none") @@ -247,13 +247,6 @@ module IssuablesHelper html.html_safe end - def issuable_first_contribution_icon - content_tag(:span, class: 'fa-stack') do - concat(icon('certificate', class: "fa-stack-2x")) - concat(content_tag(:strong, '1', class: 'fa-inverse fa-stack-1x')) - end - end - def assigned_issuables_count(issuable_type) case issuable_type when :issues diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 61fe075303c..55170cbfa6b 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -41,7 +41,7 @@ module IssuesHelper end def confidential_icon(issue) - sprite_icon('eye-slash', size: 16, css_class: 'gl-vertical-align-text-bottom') if issue.confidential? + sprite_icon('eye-slash', css_class: 'gl-vertical-align-text-bottom') if issue.confidential? end def award_user_list(awards, current_user, limit: 10) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index caf39741543..1125ecb9b41 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -170,12 +170,6 @@ module MergeRequestsHelper current_user.fork_of(project) end end - - def mr_tabs_position_enabled? - strong_memoize(:mr_tabs_position_enabled) do - Feature.enabled?(:mr_tabs_position, @project, default_enabled: true) - end - end end MergeRequestsHelper.prepend_if_ee('EE::MergeRequestsHelper') diff --git a/app/helpers/mirror_helper.rb b/app/helpers/mirror_helper.rb index 6f6cb91e696..50fc5e521fc 100644 --- a/app/helpers/mirror_helper.rb +++ b/app/helpers/mirror_helper.rb @@ -9,7 +9,7 @@ module MirrorHelper end def mirror_lfs_sync_message - _('The Git LFS objects will <strong>not</strong> be synced.').html_safe + html_escape(_('The Git LFS objects will %{strong_open}not%{strong_close} be synced.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } end end diff --git a/app/helpers/namespace_storage_limit_alert_helper.rb b/app/helpers/namespace_storage_limit_alert_helper.rb new file mode 100644 index 00000000000..d7174c38254 --- /dev/null +++ b/app/helpers/namespace_storage_limit_alert_helper.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module NamespaceStorageLimitAlertHelper + # Overridden in EE + def display_namespace_storage_limit_alert! + end +end + +NamespaceStorageLimitAlertHelper.prepend_if_ee('EE::NamespaceStorageLimitAlertHelper') diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index 782f1d3e759..2ba1d841c2e 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -57,12 +57,14 @@ module NotesHelper def add_diff_note_button(line_code, position, line_type) return if @diff_notes_disabled - button_tag '', - class: 'add-diff-note js-add-diff-note-button', - type: 'submit', name: 'button', - data: diff_view_line_data(line_code, position, line_type), - title: _('Add a comment to this line') do - sprite_icon('comment', size: 12) + content_tag(:span, class: 'add-diff-note tooltip-wrapper') do + button_tag '', + class: 'note-button add-diff-note js-add-diff-note-button', + type: 'submit', name: 'button', + data: diff_view_line_data(line_code, position, line_type), + title: _('Add a comment to this line') do + sprite_icon('comment', size: 12) + end end end @@ -126,7 +128,7 @@ module NotesHelper if @snippet.is_a?(PersonalSnippet) [@note] else - [@project.namespace.becomes(Namespace), @project, @note] + [@project, @note] end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 68dfd008921..9a64fe98f86 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -120,8 +120,4 @@ module NotificationsHelper def can_read_project?(project) can?(current_user, :read_project, project) end - - def notification_event_disabled?(event) - event == :fixed_pipeline && !Gitlab::Ci::Features.pipeline_fixed_notifications? - end end diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 3444773fe88..37e91153710 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -45,7 +45,7 @@ module OperationsHelper send_email: setting.send_email.to_s, pagerduty_active: setting.pagerduty_active.to_s, pagerduty_token: setting.pagerduty_token.to_s, - pagerduty_webhook_url: project_incidents_pagerduty_url(@project, token: setting.pagerduty_token), + pagerduty_webhook_url: project_incidents_integrations_pagerduty_url(@project, token: setting.pagerduty_token), pagerduty_reset_key_path: reset_pagerduty_token_project_settings_operations_path(@project) } end diff --git a/app/helpers/packages_helper.rb b/app/helpers/packages_helper.rb new file mode 100644 index 00000000000..e6ecc403a88 --- /dev/null +++ b/app/helpers/packages_helper.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module PackagesHelper + def package_sort_path(options = {}) + "#{request.path}?#{options.to_param}" + end + + def nuget_package_registry_url(project_id) + expose_url(api_v4_projects_packages_nuget_index_path(id: project_id, format: '.json')) + end + + def package_registry_instance_url(registry_type) + expose_url("api/#{::API::API.version}/packages/#{registry_type}") + end + + def package_registry_project_url(project_id, registry_type = :maven) + project_api_path = expose_path(api_v4_projects_path(id: project_id)) + package_registry_project_path = "#{project_api_path}/packages/#{registry_type}" + expose_url(package_registry_project_path) + end + + def package_from_presenter(package) + presenter = ::Packages::Detail::PackagePresenter.new(package) + + presenter.detail_view.to_json + end + + def pypi_registry_url(project_id) + full_url = expose_url(api_v4_projects_packages_pypi_simple_package_name_path({ id: project_id, package_name: '' }, true)) + full_url.sub!('://', '://__token__:<your_personal_token>@') + end + + def composer_registry_url(group_id) + expose_url(api_v4_group___packages_composer_packages_path(id: group_id, format: '.json')) + end + + def packages_coming_soon_enabled?(resource) + ::Feature.enabled?(:packages_coming_soon, resource) && ::Gitlab.dev_env_or_com? + end + + def packages_coming_soon_data(resource) + return unless packages_coming_soon_enabled?(resource) + + { + project_path: ::Gitlab.com? ? 'gitlab-org/gitlab' : 'gitlab-org/gitlab-test', + suggested_contributions: help_page_path('user/packages/index', anchor: 'suggested-contributions') + } + end + + def packages_list_data(type, resource) + { + resource_id: resource.id, + page_type: type, + empty_list_help_url: help_page_path('administration/packages/index'), + empty_list_illustration: image_path('illustrations/no-packages.svg'), + coming_soon_json: packages_coming_soon_data(resource).to_json + } + end +end diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 271359fcfd1..5a917a02d51 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -71,7 +71,7 @@ module PreferencesHelper def language_choices options_for_select( - Gitlab::I18n::AVAILABLE_LANGUAGES.map(&:reverse).sort, + Gitlab::I18n.selectable_locales.map(&:reverse).sort, current_user.preferred_language ) end diff --git a/app/helpers/product_analytics_helper.rb b/app/helpers/product_analytics_helper.rb new file mode 100644 index 00000000000..b040a8581b2 --- /dev/null +++ b/app/helpers/product_analytics_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ProductAnalyticsHelper + def product_analytics_tracker_url + ProductAnalytics::Tracker::URL + end + + def product_analytics_tracker_collector_url + ProductAnalytics::Tracker::COLLECTOR_URL + end +end diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb index d6e8e738a1c..c2f0b8854e1 100644 --- a/app/helpers/projects/alert_management_helper.rb +++ b/app/helpers/projects/alert_management_helper.rb @@ -5,7 +5,8 @@ module Projects::AlertManagementHelper { 'project-path' => project.full_path, 'enable-alert-management-path' => project_settings_operations_path(project, anchor: 'js-alert-management-settings'), - 'populating-alerts-help-url' => help_page_url('user/project/operations/alert_management.html', anchor: 'enable-alert-management'), + 'alerts-help-url' => help_page_url('operations/incident_management/index.md'), + 'populating-alerts-help-url' => help_page_url('operations/incident_management/index.md', anchor: 'enable-alert-management'), 'empty-alert-svg-path' => image_path('illustrations/alert-management-empty-state.svg'), 'user-can-enable-alert-management' => can?(current_user, :admin_operations, project).to_s, 'alert-management-enabled' => alert_management_enabled?(project).to_s diff --git a/app/helpers/projects/incidents_helper.rb b/app/helpers/projects/incidents_helper.rb new file mode 100644 index 00000000000..e96f0f5a384 --- /dev/null +++ b/app/helpers/projects/incidents_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Projects::IncidentsHelper + def incidents_data(project) + { + 'project-path' => project.full_path, + 'new-issue-path' => new_project_issue_path(project), + 'incident-template-name' => 'incident', + 'incident-type' => 'incident', + 'issue-path' => project_issues_path(project), + 'empty-list-svg-path' => image_path('illustrations/incident-empty-state.svg') + } + end +end + +Projects::IncidentsHelper.prepend_if_ee('EE::Projects::IncidentsHelper') diff --git a/app/helpers/projects/issues/service_desk_helper.rb b/app/helpers/projects/issues/service_desk_helper.rb new file mode 100644 index 00000000000..0f87e5ed837 --- /dev/null +++ b/app/helpers/projects/issues/service_desk_helper.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Projects::Issues::ServiceDeskHelper + def service_desk_meta(project) + empty_state_meta = { + is_service_desk_supported: Gitlab::ServiceDesk.supported?, + is_service_desk_enabled: project.service_desk_enabled?, + can_edit_project_settings: can?(current_user, :admin_project, project) + } + + if Gitlab::ServiceDesk.supported? + empty_state_meta.merge(supported_meta(project)) + else + empty_state_meta.merge(unsupported_meta(project)) + end + end + + private + + def supported_meta(project) + { + service_desk_address: project.service_desk_address, + service_desk_help_page: help_page_path('user/project/service_desk'), + edit_project_page: edit_project_path(project), + svg_path: image_path('illustrations/service_desk_empty.svg') + } + end + + def unsupported_meta(project) + { + incoming_email_help_page: help_page_path('administration/incoming_email', anchor: 'set-it-up'), + svg_path: image_path('illustrations/service-desk-setup.svg') + } + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 840e3ef9daa..1ce4903f8df 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -104,7 +104,7 @@ module ProjectsHelper end def remove_project_message(project) - _("You are going to remove %{project_full_name}. Removed project CANNOT be restored! Are you ABSOLUTELY sure?") % + _("You are going to delete %{project_full_name}. Deleted projects CANNOT be restored! Are you ABSOLUTELY sure?") % { project_full_name: project.full_name } end @@ -184,9 +184,8 @@ module ProjectsHelper end def autodeploy_flash_notice(branch_name) - translation = _("Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}") % - { branch_name: truncate(sanitize(branch_name)), link_to_autodeploy_doc: link_to_autodeploy_doc } - translation.html_safe + html_escape(_("Branch %{branch_name} was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}")) % + { branch_name: tag.strong(truncate(sanitize(branch_name))), link_to_autodeploy_doc: link_to_autodeploy_doc } end def project_list_cache_key(project, pipeline_status: true) @@ -353,14 +352,14 @@ module ProjectsHelper description = if share_with_group && share_with_members - _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.") + _("You can invite a new member to %{project_name} or invite another group.") elsif share_with_group - _("You can invite another group to <strong>%{project_name}</strong>.") + _("You can invite another group to %{project_name}.") elsif share_with_members - _("You can invite a new member to <strong>%{project_name}</strong>.") + _("You can invite a new member to %{project_name}.") end - description.html_safe % { project_name: project.name } + html_escape(description) % { project_name: tag.strong(project.name) } end def metrics_external_dashboard_url @@ -421,6 +420,10 @@ module ProjectsHelper nav_tabs << :operations end + if can_view_product_analytics?(current_user, project) + nav_tabs << :product_analytics + end + tab_ability_map.each do |tab, ability| if can?(current_user, ability, project) nav_tabs << tab @@ -429,9 +432,19 @@ module ProjectsHelper apply_external_nav_tabs(nav_tabs, project) + nav_tabs += package_nav_tabs(project, current_user) + nav_tabs end + def package_nav_tabs(project, current_user) + [].tap do |tabs| + if ::Gitlab.config.packages.enabled && can?(current_user, :read_package, project) + tabs << :packages + end + end + end + def apply_external_nav_tabs(nav_tabs, project) nav_tabs << :external_issue_tracker if project.external_issue_tracker nav_tabs << :external_wiki if project.external_wiki @@ -455,6 +468,7 @@ module ProjectsHelper serverless: :read_cluster, error_tracking: :read_sentry_issue, alert_management: :read_alert_management_alert, + incidents: :read_incidents, labels: :read_label, issues: :read_issue, project_members: :read_project_member, @@ -468,6 +482,11 @@ module ProjectsHelper end end + def can_view_product_analytics?(current_user, project) + Feature.enabled?(:product_analytics, project) && + can?(current_user, :read_product_analytics, project) + end + def search_tab_ability_map @search_tab_ability_map ||= tab_ability_map.merge( blobs: :download_code, @@ -584,6 +603,7 @@ module ProjectsHelper def project_permissions_settings(project) feature = project.project_feature { + packagesEnabled: !!project.packages_enabled, visibilityLevel: project.visibility_level, requestAccessEnabled: !!project.request_access_enabled, issuesAccessLevel: feature.issues_access_level, @@ -604,6 +624,8 @@ module ProjectsHelper def project_permissions_panel_data(project) { + packagesAvailable: ::Gitlab.config.packages.enabled, + packagesHelpPath: help_page_path('user/packages/index'), currentSettings: project_permissions_settings(project), canDisableEmails: can_disable_emails?(project, current_user), canChangeVisibilityLevel: can_change_visibility_level?(project, current_user), @@ -719,9 +741,13 @@ module ProjectsHelper functions error_tracking alert_management + incidents + incident_management user gcp logs + product_analytics + metrics_dashboard ] end @@ -748,7 +774,7 @@ module ProjectsHelper def project_access_token_available?(project) return false if ::Gitlab.com? - ::Feature.enabled?(:resource_access_token, project) + ::Feature.enabled?(:resource_access_token, project, default_enabled: true) end end diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb index a3d944c64cc..f1dff18523f 100644 --- a/app/helpers/releases_helper.rb +++ b/app/helpers/releases_helper.rb @@ -19,7 +19,7 @@ module ReleasesHelper documentation_path: help_page }.tap do |data| if can?(current_user, :create_release, @project) - data[:new_release_path] = if Feature.enabled?(:new_release_page, @project) + data[:new_release_path] = if Feature.enabled?(:new_release_page, @project, default_enabled: true) new_project_release_path(@project) else new_project_tag_path(@project) @@ -37,7 +37,8 @@ module ReleasesHelper def data_for_new_release_page new_edit_pages_shared_data.merge( - default_branch: @project.default_branch + default_branch: @project.default_branch, + releases_page_path: project_releases_path(@project) ) end diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 1b9876b9a6a..377aee1ae9e 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -229,6 +229,7 @@ module SearchHelper opts[:data]['group-id'] = @group.id opts[:data]['labels-endpoint'] = group_labels_path(@group) opts[:data]['milestones-endpoint'] = group_milestones_path(@group) + opts[:data]['releases-endpoint'] = group_releases_path(@group) else opts[:data]['labels-endpoint'] = dashboard_labels_path opts[:data]['milestones-endpoint'] = dashboard_milestones_path diff --git a/app/helpers/services_helper.rb b/app/helpers/services_helper.rb index 1f9cce80bed..e9d39cc8175 100644 --- a/app/helpers/services_helper.rb +++ b/app/helpers/services_helper.rb @@ -35,19 +35,6 @@ module ServicesHelper "#{event}_events" end - def service_event_action_field_name(action) - "#{action}_on_event_enabled" - end - - def event_action_title(action) - case action - when "comment" - s_("ProjectService|Comment") - else - action.humanize - end - end - def service_save_button(disabled: false) button_tag(class: 'btn btn-success', type: 'submit', disabled: disabled, data: { qa_selector: 'save_changes_button' }) do icon('spinner spin', class: 'hidden js-btn-spinner') + @@ -95,10 +82,6 @@ module ServicesHelper end end - def integration_form_refactor? - Feature.enabled?(:integration_form_refactor, @project, default_enabled: true) - end - def integration_form_data(integration) { id: integration.id, @@ -116,23 +99,13 @@ module ServicesHelper end def trigger_events_for_service(integration) - return [] unless integration_form_refactor? - ServiceEventSerializer.new(service: integration).represent(integration.configurable_events).to_json end def fields_for_service(integration) - return [] unless integration_form_refactor? - ServiceFieldSerializer.new(service: integration).represent(integration.global_fields).to_json end - def show_service_trigger_events?(integration) - return false if integration.is_a?(JiraService) || integration_form_refactor? - - integration.configurable_events.present? - end - def project_jira_issues_integration? false end diff --git a/app/helpers/snippets_helper.rb b/app/helpers/snippets_helper.rb index d6a9e447fbc..10c95da394f 100644 --- a/app/helpers/snippets_helper.rb +++ b/app/helpers/snippets_helper.rb @@ -60,9 +60,9 @@ module SnippetsHelper def snippet_badge(snippet) return unless attrs = snippet_badge_attributes(snippet) - css_class, text = attrs + icon_name, text = attrs tag.span(class: %w[badge badge-gray]) do - concat(tag.i(class: ['fa', css_class])) + concat(sprite_icon(icon_name, size: 14, css_class: 'gl-vertical-align-middle')) concat(' ') concat(text) end @@ -70,25 +70,24 @@ module SnippetsHelper def snippet_badge_attributes(snippet) if snippet.private? - ['fa-lock', _('private')] + ['lock', _('private')] end end - def embedded_raw_snippet_button - blob = @snippet.blob + def embedded_raw_snippet_button(snippet, blob) return if blob.empty? || blob.binary? || blob.stored_externally? link_to(external_snippet_icon('doc-code'), - gitlab_raw_snippet_url(@snippet), + gitlab_raw_snippet_blob_url(snippet, blob.path), class: 'btn', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw') end - def embedded_snippet_download_button + def embedded_snippet_download_button(snippet, blob) link_to(external_snippet_icon('download'), - gitlab_raw_snippet_url(@snippet, inline: false), + gitlab_raw_snippet_blob_url(snippet, blob.path, nil, inline: false), class: 'btn', target: '_blank', title: 'Download', diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index ed1b35338ae..de6990041a6 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -238,7 +238,7 @@ module SortingHelper end link_to(url, type: 'button', class: link_class, title: s_('SortOptions|Sort direction')) do - sprite_icon(icon, size: 16) + sprite_icon(icon) end end @@ -581,6 +581,47 @@ module SortingHelper def sort_value_expire_date 'expired_asc' end + + def packages_sort_options_hash + { + sort_value_recently_created => sort_title_created_date, + sort_value_oldest_created => sort_title_created_date, + sort_value_name => sort_title_name, + sort_value_name_desc => sort_title_name, + sort_value_version_desc => sort_title_version, + sort_value_version_asc => sort_title_version, + sort_value_type_desc => sort_title_type, + sort_value_type_asc => sort_title_type, + sort_value_project_name_desc => sort_title_project_name, + sort_value_project_name_asc => sort_title_project_name + } + end + + def packages_reverse_sort_order_hash + { + sort_value_recently_created => sort_value_oldest_created, + sort_value_oldest_created => sort_value_recently_created, + sort_value_name => sort_value_name_desc, + sort_value_name_desc => sort_value_name, + sort_value_version_desc => sort_value_version_asc, + sort_value_version_asc => sort_value_version_desc, + sort_value_type_desc => sort_value_type_asc, + sort_value_type_asc => sort_value_type_desc, + sort_value_project_name_desc => sort_value_project_name_asc, + sort_value_project_name_asc => sort_value_project_name_desc + } + end + + def packages_sort_option_title(sort_value) + packages_sort_options_hash[sort_value] || sort_title_created_date + end + + def packages_sort_direction_button(sort_value) + reverse_sort = packages_reverse_sort_order_hash[sort_value] + url = package_sort_path(sort: reverse_sort) + + sort_direction_button(url, reverse_sort, sort_value) + end end SortingHelper.prepend_if_ee('::EE::SortingHelper') diff --git a/app/helpers/timeboxes_helper.rb b/app/helpers/timeboxes_helper.rb index 0bffdba7349..34919f994ee 100644 --- a/app/helpers/timeboxes_helper.rb +++ b/app/helpers/timeboxes_helper.rb @@ -40,7 +40,7 @@ module TimeboxesHelper opts = { milestone_title: milestone.title, state: state } if @project - polymorphic_path([@project.namespace.becomes(Namespace), @project, type], opts) + polymorphic_path([@project, type], opts) elsif @group polymorphic_url([type, @group], opts) else @@ -155,7 +155,7 @@ module TimeboxesHelper opened = milestone.opened_issues_count closed = milestone.closed_issues_count - return _("Issues") if total.zero? + return _("Issues") if total == 0 content = [] @@ -187,7 +187,7 @@ module TimeboxesHelper def milestone_releases_tooltip_text(milestone) count = milestone.releases.count - return _("Releases") if count.zero? + return _("Releases") if count == 0 n_("%{releases} release", "%{releases} releases", count) % { releases: count } end diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index b9a6cab07a8..9865f7dfbef 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -163,7 +163,8 @@ module TodosHelper { id: '', text: 'Any Type' }, { id: 'Issue', text: 'Issue' }, { id: 'MergeRequest', text: 'Merge Request' }, - { id: 'DesignManagement::Design', text: 'Design' } + { id: 'DesignManagement::Design', text: 'Design' }, + { id: 'AlertManagement::Alert', text: 'Alert' } ] end diff --git a/app/helpers/user_callouts_helper.rb b/app/helpers/user_callouts_helper.rb index 34c8ce51df0..98159369fb1 100644 --- a/app/helpers/user_callouts_helper.rb +++ b/app/helpers/user_callouts_helper.rb @@ -7,13 +7,15 @@ module UserCalloutsHelper SUGGEST_POPOVER_DISMISSED = 'suggest_popover_dismissed' TABS_POSITION_HIGHLIGHT = 'tabs_position_highlight' WEBHOOKS_MOVED = 'webhooks_moved' + CUSTOMIZE_HOMEPAGE = 'customize_homepage' def show_admin_integrations_moved? !user_dismissed?(ADMIN_INTEGRATIONS_MOVED) end def show_gke_cluster_integration_callout?(project) - can?(current_user, :create_cluster, project) && + active_nav_link?(controller: sidebar_operations_paths) && + can?(current_user, :create_cluster, project) && !user_dismissed?(GKE_CLUSTER_INTEGRATION) end @@ -35,14 +37,14 @@ module UserCalloutsHelper !user_dismissed?(SUGGEST_POPOVER_DISMISSED) end - def show_tabs_feature_highlight? - current_user && !user_dismissed?(TABS_POSITION_HIGHLIGHT) && !Rails.env.test? - end - def show_webhooks_moved_alert? !user_dismissed?(WEBHOOKS_MOVED) end + def show_customize_homepage_banner?(customize_homepage) + customize_homepage && !user_dismissed?(CUSTOMIZE_HOMEPAGE) + end + private def user_dismissed?(feature_name, ignore_dismissal_earlier_than = nil) diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index cf2d2d178e1..ad33ac66f38 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -80,7 +80,7 @@ module WikiHelper link_to(wiki_path(wiki, action: :pages, sort: sort, direction: reversed_direction), type: 'button', class: link_class, title: _('Sort direction')) do - sprite_icon("sort-#{icon_class}", size: 16) + sprite_icon("sort-#{icon_class}") end end diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index c327a0bab43..b45755788b8 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -45,6 +45,17 @@ module Emails end end + def access_token_expired_email(user) + return unless user && user.active? + + @user = user + @target_url = profile_personal_access_tokens_url + + Gitlab::I18n.with_locale(@user.preferred_language) do + mail(to: @user.notification_email, subject: subject(_("Your personal access token has expired"))) + end + end + def unknown_sign_in_email(user, ip, time) @user = user @ip = ip diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index fb166fb56b7..75581805b49 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -118,6 +118,7 @@ module AlertManagement end delegate :iid, to: :issue, prefix: true, allow_nil: true + delegate :metrics_dashboard_url, :runbook, :details_url, to: :present scope :for_iid, -> (iid) { where(iid: iid) } scope :for_status, -> (status) { where(status: status) } @@ -136,6 +137,7 @@ module AlertManagement # Descending sort order sorts severity from more critical to less critical. # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior scope :order_severity, -> (sort_order) { order(severity: sort_order == :asc ? :desc : :asc) } + scope :order_severity_with_open_prometheus_alert, -> { open.with_prometheus_alert.order(severity: :asc, started_at: :desc) } # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 9ec407a10a4..91b8bfedcbb 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -42,15 +42,15 @@ class ApplicationRecord < ActiveRecord::Base limit(count) end - def self.safe_find_or_create_by!(*args) - safe_find_or_create_by(*args).tap do |record| + def self.safe_find_or_create_by!(*args, &block) + safe_find_or_create_by(*args, &block).tap do |record| record.validate! unless record.persisted? end end - def self.safe_find_or_create_by(*args) + def self.safe_find_or_create_by(*args, &block) safe_ensure_unique(retries: 1) do - find_or_create_by(*args) + find_or_create_by(*args, &block) end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 25b81aef45f..661b10019ad 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -5,11 +5,14 @@ class ApplicationSetting < ApplicationRecord include CacheMarkdownField include TokenAuthenticatable include ChronicDurationAttribute + include IgnorableColumns + + ignore_column :namespace_storage_size_limit, remove_with: '13.5', remove_after: '2020-09-22' GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ 'Admin Area > Settings > Metrics and profiling > Metrics - Grafana' - add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption, default_enabled: true) ? :optional : :required } + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -272,6 +275,7 @@ class ApplicationSetting < ApplicationRecord numericality: { greater_than_or_equal_to: 0 } validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 } + validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes } validates :email_restrictions, untrusted_regexp: true @@ -362,10 +366,6 @@ class ApplicationSetting < ApplicationRecord length: { maximum: 255 }, allow_blank: true - validates :namespace_storage_size_limit, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :issues_create_limit, numericality: { only_integer: true, greater_than_or_equal_to: 0 } diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 73554ee8457..8bdb80a65b1 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -37,8 +37,8 @@ module ApplicationSettingImplementation { after_sign_up_text: nil, akismet_enabled: false, - allow_local_requests_from_web_hooks_and_services: false, allow_local_requests_from_system_hooks: true, + allow_local_requests_from_web_hooks_and_services: false, asset_proxy_enabled: false, authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand commit_email_hostname: default_commit_email_hostname, @@ -47,10 +47,11 @@ module ApplicationSettingImplementation container_registry_token_expire_delay: 5, container_registry_vendor: '', container_registry_version: '', + custom_http_clone_url_root: nil, default_artifacts_expire_in: '30 days', + default_branch_name: nil, default_branch_protection: Settings.gitlab['default_branch_protection'], default_ci_config_path: nil, - default_branch_name: nil, default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_project_creation: Settings.gitlab['default_project_creation'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], @@ -63,9 +64,9 @@ module ApplicationSettingImplementation dsa_key_restriction: 0, ecdsa_key_restriction: 0, ed25519_key_restriction: 0, - eks_integration_enabled: false, - eks_account_id: nil, eks_access_key_id: nil, + eks_account_id: nil, + eks_integration_enabled: false, eks_secret_access_key: nil, email_restrictions_enabled: false, email_restrictions: nil, @@ -74,6 +75,9 @@ module ApplicationSettingImplementation gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, gravatar_enabled: Settings.gravatar['enabled'], + group_download_export_limit: 1, + group_export_limit: 6, + group_import_limit: 6, help_page_hide_commercial_content: false, help_page_text: nil, hide_third_party_offers: false, @@ -83,46 +87,57 @@ module ApplicationSettingImplementation housekeeping_gc_period: 200, housekeeping_incremental_repack_period: 10, import_sources: Settings.gitlab['import_sources'], + instance_statistics_visibility_private: false, issues_create_limit: 300, local_markdown_version: 0, + login_recaptcha_protection_enabled: false, max_artifacts_size: Settings.artifacts['max_size'], max_attachment_size: Settings.gitlab['max_attachment_size'], max_import_size: 50, + minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH, mirror_available: true, notify_on_unknown_sign_in: true, outbound_local_requests_whitelist: [], password_authentication_enabled_for_git: true, password_authentication_enabled_for_web: Settings.gitlab['signin_enabled'], performance_bar_allowed_group_id: nil, - rsa_key_restriction: 0, plantuml_enabled: false, plantuml_url: nil, polling_interval_multiplier: 1, + productivity_analytics_start_date: Time.current, + project_download_export_limit: 1, project_export_enabled: true, + project_export_limit: 6, + project_import_limit: 6, protected_ci_variables: true, - push_event_hooks_limit: 3, + protected_paths: DEFAULT_PROTECTED_PATHS, push_event_activities_limit: 3, + push_event_hooks_limit: 3, raw_blob_request_limit: 300, recaptcha_enabled: false, - login_recaptcha_protection_enabled: false, repository_checks_enabled: true, - repository_storages: ['default'], repository_storages_weighted: { default: 100 }, + repository_storages: ['default'], require_two_factor_authentication: false, restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], - session_expire_delay: Settings.gitlab['session_expire_delay'], + rsa_key_restriction: 0, send_user_confirmation_email: false, + session_expire_delay: Settings.gitlab['session_expire_delay'], shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], shared_runners_text: nil, sign_in_text: nil, signup_enabled: Settings.gitlab['signup_enabled'], + snippet_size_limit: 50.megabytes, + snowplow_app_id: nil, + snowplow_collector_hostname: nil, + snowplow_cookie_domain: nil, + snowplow_enabled: false, + snowplow_iglu_registry_url: nil, sourcegraph_enabled: false, - sourcegraph_url: nil, sourcegraph_public_only: true, + sourcegraph_url: nil, spam_check_endpoint_enabled: false, spam_check_endpoint_url: nil, - minimum_password_length: DEFAULT_MINIMUM_PASSWORD_LENGTH, - namespace_storage_size_limit: 0, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -130,41 +145,26 @@ module ApplicationSettingImplementation throttle_authenticated_web_enabled: false, throttle_authenticated_web_period_in_seconds: 3600, throttle_authenticated_web_requests_per_period: 7200, - throttle_unauthenticated_enabled: false, - throttle_unauthenticated_period_in_seconds: 3600, - throttle_unauthenticated_requests_per_period: 3600, + throttle_incident_management_notification_enabled: false, + throttle_incident_management_notification_per_period: 3600, + throttle_incident_management_notification_period_in_seconds: 3600, throttle_protected_paths_enabled: false, throttle_protected_paths_in_seconds: 10, throttle_protected_paths_per_period: 60, - protected_paths: DEFAULT_PROTECTED_PATHS, - throttle_incident_management_notification_enabled: false, - throttle_incident_management_notification_period_in_seconds: 3600, - throttle_incident_management_notification_per_period: 3600, + throttle_unauthenticated_enabled: false, + throttle_unauthenticated_period_in_seconds: 3600, + throttle_unauthenticated_requests_per_period: 3600, time_tracking_limit_to_hours: false, two_factor_grace_period: 48, unique_ips_limit_enabled: false, unique_ips_limit_per_user: 10, unique_ips_limit_time_window: 3600, usage_ping_enabled: Settings.gitlab['usage_ping_enabled'], - instance_statistics_visibility_private: false, + usage_stats_set_by_user_id: nil, user_default_external: false, user_default_internal_regex: nil, user_show_add_ssh_key_message: true, - usage_stats_set_by_user_id: nil, - snowplow_collector_hostname: nil, - snowplow_cookie_domain: nil, - snowplow_enabled: false, - snowplow_app_id: nil, - snowplow_iglu_registry_url: nil, - custom_http_clone_url_root: nil, - productivity_analytics_start_date: Time.current, - snippet_size_limit: 50.megabytes, - project_import_limit: 6, - project_export_limit: 6, - project_download_export_limit: 1, - group_import_limit: 6, - group_export_limit: 6, - group_download_export_limit: 1 + wiki_page_max_content_bytes: 50.megabytes } end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 13fc2514f0c..e7cfa30a892 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -5,7 +5,7 @@ class AuditEvent < ApplicationRecord include IgnorableColumns include BulkInsertSafe - PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path].freeze + PARALLEL_PERSISTENCE_COLUMNS = [:author_name, :entity_path, :target_details].freeze ignore_column :updated_at, remove_with: '13.4', remove_after: '2020-09-22' @@ -58,6 +58,12 @@ class AuditEvent < ApplicationRecord end end + def as_json(options = {}) + super(options).tap do |json| + json['ip_address'] = self.ip_address.to_s + end + end + private def default_author_value diff --git a/app/models/audit_event_partitioned.rb b/app/models/audit_event_partitioned.rb new file mode 100644 index 00000000000..672daebd14a --- /dev/null +++ b/app/models/audit_event_partitioned.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# This model is not yet intended to be used. +# It is in a transitioning phase while we are partitioning +# the table on the database-side. +# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/3206 +# for details. +class AuditEventPartitioned < ApplicationRecord + include PartitionedTable + + self.table_name = 'audit_events_part_5fc467ac26' + + partitioned_by :created_at, strategy: :monthly +end diff --git a/app/models/blob.rb b/app/models/blob.rb index 874bf58530e..8a9db8b45ea 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -6,6 +6,8 @@ class Blob < SimpleDelegator include BlobLanguageFromGitAttributes include BlobActiveModel + MODE_SYMLINK = '120000' # The STRING 120000 is the git-reported octal filemode for a symlink + CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6c90645e997..af4e6bb0494 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -73,8 +73,7 @@ module Ci return unless has_environment? strong_memoize(:persisted_environment) do - deployment&.environment || - Environment.find_by(name: expanded_environment_name, project: project) + Environment.find_by(name: expanded_environment_name, project: project) end end @@ -351,7 +350,7 @@ module Ci after_transition any => [:failed] do |build| next unless build.project - if build.retry_failure? + if build.auto_retry_allowed? begin Ci::Build.retry(build, build.user) rescue Gitlab::Access::AccessDeniedError => ex @@ -373,6 +372,10 @@ module Ci end end + def auto_retry_allowed? + auto_retry.allowed? + end + def detailed_status(current_user) Gitlab::Ci::Status::Build::Factory .new(self, current_user) @@ -439,27 +442,6 @@ module Ci pipeline.builds.retried.where(name: self.name).count end - def retry_failure? - max_allowed_retries = nil - max_allowed_retries ||= options_retry_max if retry_on_reason_or_always? - max_allowed_retries ||= DEFAULT_RETRIES.fetch(failure_reason.to_sym, 0) - - max_allowed_retries > 0 && retries_count < max_allowed_retries - end - - def options_retry_max - options_retry[:max] - end - - def options_retry_when - options_retry.fetch(:when, ['always']) - end - - def retry_on_reason_or_always? - options_retry_when.include?(failure_reason.to_s) || - options_retry_when.include?('always') - end - def any_unmet_prerequisites? prerequisites.present? end @@ -474,8 +456,7 @@ module Ci strong_memoize(:expanded_environment_name) do # We're using a persisted expanded environment name in order to avoid # variable expansion per request. - if Feature.enabled?(:ci_persisted_expanded_environment_name, project, default_enabled: true) && - metadata&.expanded_environment_name.present? + if metadata&.expanded_environment_name.present? metadata.expanded_environment_name else ExpandVariables.expand(environment, -> { simple_variables }) @@ -543,8 +524,6 @@ module Ci end end - CI_REGISTRY_USER = 'gitlab-ci-token' - def persisted_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables unless persisted? @@ -556,7 +535,7 @@ module Ci .append(key: 'CI_JOB_TOKEN', value: token.to_s, public: false, masked: true) .append(key: 'CI_BUILD_ID', value: id.to_s) .append(key: 'CI_BUILD_TOKEN', value: token.to_s, public: false, masked: true) - .append(key: 'CI_REGISTRY_USER', value: CI_REGISTRY_USER) + .append(key: 'CI_REGISTRY_USER', value: ::Gitlab::Auth::CI_JOB_USER) .append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true) .append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false) .concat(deploy_token_variables) @@ -615,7 +594,7 @@ module Ci def repo_url return unless token - auth = "gitlab-ci-token:#{token}@" + auth = "#{::Gitlab::Auth::CI_JOB_USER}:#{token}@" project.http_url_to_repo.sub(%r{^https?://}) do |prefix| prefix + auth end @@ -668,6 +647,13 @@ module Ci !artifacts_expired? && artifacts_file&.exists? end + # This method is similar to #artifacts? but it includes the artifacts + # locking mechanics. A new method was created to prevent breaking existing + # behavior and avoid introducing N+1s. + def available_artifacts? + (!artifacts_expired? || pipeline.artifacts_locked?) && job_artifacts_archive&.exists? + end + def artifacts_metadata? artifacts? && artifacts_metadata&.exists? end @@ -878,8 +864,7 @@ module Ci end def multi_build_steps? - options.dig(:release)&.any? && - Gitlab::Ci::Features.release_generation_enabled? + options.dig(:release)&.any? end def hide_secrets(trace) @@ -962,6 +947,12 @@ module Ci private + def auto_retry + strong_memoize(:auto_retry) do + Gitlab::Ci::Build::AutoRetry.new(self) + end + end + def dependencies strong_memoize(:dependencies) do Ci::BuildDependencies.new(self) @@ -1017,19 +1008,6 @@ module Ci end end - # The format of the retry option changed in GitLab 11.5: Before it was - # integer only, after it is a hash. New builds are created with the new - # format, but builds created before GitLab 11.5 and saved in database still - # have the old integer only format. This method returns the retry option - # normalized as a hash in 11.5+ format. - def options_retry - strong_memoize(:options_retry) do - value = options&.dig(:retry) - value = value.is_a?(Integer) ? { max: value } : value.to_h - value.with_indifferent_access - end - end - def has_expiring_artifacts? artifacts_expire_at.present? && artifacts_expire_at > Time.current end diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 0a7a0e0772b..407802baf09 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -75,18 +75,16 @@ module Ci def append(new_data, offset) raise ArgumentError, 'New data is missing' unless new_data - raise ArgumentError, 'Offset is out of range' if offset > size || offset < 0 + raise ArgumentError, 'Offset is out of range' if offset < 0 || offset > size raise ArgumentError, 'Chunk size overflow' if CHUNK_SIZE < (offset + new_data.bytesize) - in_lock(*lock_params) do # Write operation is atomic - unsafe_set_data!(data.byteslice(0, offset) + new_data) - end + in_lock(*lock_params) { unsafe_append_data!(new_data, offset) } schedule_to_persist if full? end def size - data&.bytesize.to_i + @size ||= current_store.size(self) || data&.bytesize end def start_offset @@ -118,7 +116,7 @@ module Ci raise FailedToPersistDataError, 'Data is not fulfilled in a bucket' end - old_store_class = self.class.get_store_class(data_store) + old_store_class = current_store self.raw_data = nil self.data_store = new_store @@ -128,16 +126,33 @@ module Ci end def get_data - self.class.get_store_class(data_store).data(self)&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default - rescue Excon::Error::NotFound - # If the data store is :fog and the file does not exist in the object storage, this method returns nil. + current_store.data(self)&.force_encoding(Encoding::BINARY) # Redis/Database return UTF-8 string as default end def unsafe_set_data!(value) raise ArgumentError, 'New data size exceeds chunk size' if value.bytesize > CHUNK_SIZE - self.class.get_store_class(data_store).set_data(self, value) + current_store.set_data(self, value) + @data = value + @size = value.bytesize + + save! if changed? + end + + def unsafe_append_data!(value, offset) + new_size = value.bytesize + offset + + if new_size > CHUNK_SIZE + raise ArgumentError, 'New data size exceeds chunk size' + end + + current_store.append_data(self, value, offset).then do |stored| + raise ArgumentError, 'Trace appended incorrectly' if stored != new_size + end + + @data = nil + @size = new_size save! if changed? end @@ -156,6 +171,10 @@ module Ci size == CHUNK_SIZE end + def current_store + self.class.get_store_class(data_store) + end + def lock_params ["trace_write:#{build_id}:chunks:#{chunk_index}", { ttl: WRITE_LOCK_TTL, diff --git a/app/models/ci/build_trace_chunks/database.rb b/app/models/ci/build_trace_chunks/database.rb index 73cb8abf381..3b8e23510d9 100644 --- a/app/models/ci/build_trace_chunks/database.rb +++ b/app/models/ci/build_trace_chunks/database.rb @@ -19,8 +19,22 @@ module Ci model.raw_data end - def set_data(model, data) - model.raw_data = data + def set_data(model, new_data) + model.raw_data = new_data + end + + def append_data(model, new_data, offset) + if offset > 0 + truncated_data = data(model).to_s.byteslice(0, offset) + new_data = truncated_data + new_data + end + + model.raw_data = new_data + model.raw_data.to_s.bytesize + end + + def size(model) + data(model).to_s.bytesize end def delete_data(model) diff --git a/app/models/ci/build_trace_chunks/fog.rb b/app/models/ci/build_trace_chunks/fog.rb index a849bd08427..b1e9fd1faeb 100644 --- a/app/models/ci/build_trace_chunks/fog.rb +++ b/app/models/ci/build_trace_chunks/fog.rb @@ -9,10 +9,26 @@ module Ci def data(model) connection.get_object(bucket_name, key(model))[:body] + rescue Excon::Error::NotFound + # If the object does not exist in the object storage, this method returns nil. end - def set_data(model, data) - connection.put_object(bucket_name, key(model), data) + def set_data(model, new_data) + connection.put_object(bucket_name, key(model), new_data) + end + + def append_data(model, new_data, offset) + if offset > 0 + truncated_data = data(model).to_s.byteslice(0, offset) + new_data = truncated_data + new_data + end + + set_data(model, new_data) + new_data.bytesize + end + + def size(model) + data(model).to_s.bytesize end def delete_data(model) diff --git a/app/models/ci/build_trace_chunks/redis.rb b/app/models/ci/build_trace_chunks/redis.rb index c3864f78b01..0ae563f6ce8 100644 --- a/app/models/ci/build_trace_chunks/redis.rb +++ b/app/models/ci/build_trace_chunks/redis.rb @@ -4,6 +4,32 @@ module Ci module BuildTraceChunks class Redis CHUNK_REDIS_TTL = 1.week + LUA_APPEND_CHUNK = <<~EOS.freeze + local key, new_data, offset = KEYS[1], ARGV[1], ARGV[2] + local length = new_data:len() + local expire = #{CHUNK_REDIS_TTL.seconds} + local current_size = redis.call("strlen", key) + offset = tonumber(offset) + + if offset == 0 then + -- overwrite everything + redis.call("set", key, new_data, "ex", expire) + return redis.call("strlen", key) + elseif offset > current_size then + -- offset range violation + return -1 + elseif offset + length >= current_size then + -- efficiently append or overwrite and append + redis.call("expire", key, expire) + return redis.call("setrange", key, offset, new_data) + else + -- append and truncate + local current_data = redis.call("get", key) + new_data = current_data:sub(1, offset) .. new_data + redis.call("set", key, new_data, "ex", expire) + return redis.call("strlen", key) + end + EOS def available? true @@ -21,6 +47,18 @@ module Ci end end + def append_data(model, new_data, offset) + Gitlab::Redis::SharedState.with do |redis| + redis.eval(LUA_APPEND_CHUNK, keys: [key(model)], argv: [new_data, offset]) + end + end + + def size(model) + Gitlab::Redis::SharedState.with do |redis| + redis.strlen(key(model)) + end + end + def delete_data(model) delete_keys([[model.build_id, model.chunk_index]]) end diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb index 779c6c0396f..f0c035635b9 100644 --- a/app/models/ci/group.rb +++ b/app/models/ci/group.rb @@ -24,15 +24,9 @@ module Ci def status strong_memoize(:status) do - if ::Gitlab::Ci::Features.composite_status?(project) - Gitlab::Ci::Status::Composite - .new(@jobs) - .status - else - CommitStatus - .where(id: @jobs) - .legacy_status - end + Gitlab::Ci::Status::Composite + .new(@jobs) + .status end end diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 628749b32cb..e083caa8751 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -14,12 +14,14 @@ module Ci alias_attribute :secret_value, :value validates :key, uniqueness: { - message: "(%{value}) has already been taken" + message: -> (object, data) { _("(%{value}) has already been taken") } } - validates :encrypted_value, length: { - maximum: 1024, - too_long: 'The encrypted value of the provided variable exceeds %{count} bytes. Variables over 700 characters risk exceeding the limit.' + validates :value, length: { + maximum: 10_000, + too_long: -> (object, data) do + _('The value of the provided variable exceeds the %{count} character limit') + end } scope :unprotected, -> { where(protected: false) } diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index dbeba1ece31..75c3ce98c95 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -8,6 +8,8 @@ module Ci include UsageStatistics include Sortable include IgnorableColumns + include Artifactable + include FileStoreMounter extend Gitlab::Ci::Model NotSupportedAdapterError = Class.new(StandardError) @@ -114,7 +116,7 @@ module Ci belongs_to :project belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id - mount_uploader :file, JobArtifactUploader + mount_file_store_uploader JobArtifactUploader validates :file_format, presence: true, unless: :trace?, on: :create validate :validate_supported_file_format!, on: :create @@ -123,8 +125,6 @@ module Ci update_project_statistics project_statistics_name: :build_artifacts_size - after_save :update_file_store, if: :saved_change_to_file? - scope :not_expired, -> { where('expire_at IS NULL OR expire_at > ?', Time.current) } scope :with_files_stored_locally, -> { where(file_store: ::JobArtifactUploader::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) } @@ -200,12 +200,6 @@ module Ci load_performance: 25 ## EE-specific } - enum file_format: { - raw: 1, - zip: 2, - gzip: 3 - }, _suffix: true - # `file_location` indicates where actual files are stored. # Ideally, actual files should be stored in the same directory, and use the same # convention to generate its path. However, sometimes we can't do so due to backward-compatibility. @@ -220,11 +214,6 @@ module Ci hashed_path: 2 } - FILE_FORMAT_ADAPTERS = { - gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream, - raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream - }.freeze - def validate_supported_file_format! return if Feature.disabled?(:drop_license_management_artifact, project, default_enabled: true) @@ -239,12 +228,6 @@ module Ci end end - def update_file_store - # The file.object_store is set during `uploader.store!` - # which happens after object is inserted/updated - self.update_column(:file_store, file.object_store) - end - def self.associated_file_types_for(file_type) return unless file_types.include?(file_type) @@ -284,7 +267,7 @@ module Ci def expire_in=(value) self.expire_at = if value - ChronicDuration.parse(value)&.seconds&.from_now + ::Gitlab::Ci::Build::Artifacts::ExpireInParser.new(value).seconds_from_now end end @@ -303,16 +286,12 @@ module Ci end def self.max_artifact_size(type:, project:) - max_size = if Feature.enabled?(:ci_max_artifact_size_per_type, project, default_enabled: false) - limit_name = "#{PLAN_LIMIT_PREFIX}#{type}" - - project.actual_limits.limit_for( - limit_name, - alternate_limit: -> { project.closest_setting(:max_artifacts_size) } - ) - else - project.closest_setting(:max_artifacts_size) - end + limit_name = "#{PLAN_LIMIT_PREFIX}#{type}" + + max_size = project.actual_limits.limit_for( + limit_name, + alternate_limit: -> { project.closest_setting(:max_artifacts_size) } + ) max_size&.megabytes.to_i end diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb index 250306e2be4..df4368eccd5 100644 --- a/app/models/ci/legacy_stage.rb +++ b/app/models/ci/legacy_stage.rb @@ -32,7 +32,7 @@ module Ci end def status - @status ||= statuses.latest.slow_composite_status(project: project) + @status ||= statuses.latest.composite_status end def detailed_status(current_user) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index d4b439d648f..7762328d274 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -57,12 +57,12 @@ module Ci # the merge request's latest commit. has_many :merge_requests_as_head_pipeline, foreign_key: "head_pipeline_id", class_name: 'MergeRequest' - has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :pending_builds, -> { pending }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :failed_builds, -> { latest.failed }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline - has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :retryable_builds, -> { latest.failed_or_canceled.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :cancelable_statuses, -> { cancelable }, foreign_key: :commit_id, class_name: 'CommitStatus' - has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' - has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build' + has_many :manual_actions, -> { latest.manual_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline + has_many :scheduled_actions, -> { latest.scheduled_actions.includes(:project) }, foreign_key: :commit_id, class_name: 'Ci::Build', inverse_of: :pipeline has_many :auto_canceled_pipelines, class_name: 'Ci::Pipeline', foreign_key: 'auto_canceled_by_id' has_many :auto_canceled_jobs, class_name: 'CommitStatus', foreign_key: 'auto_canceled_by_id' @@ -83,6 +83,7 @@ module Ci has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id has_many :latest_builds_report_results, through: :latest_builds, source: :report_results + has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :pipeline, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent accepts_nested_attributes_for :variables, reject_if: :persisted? @@ -249,14 +250,6 @@ module Ci pipeline.run_after_commit { AutoDevops::DisableWorker.perform_async(pipeline.id) } end - - after_transition any => [:success] do |pipeline| - next unless Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(pipeline.project) - - pipeline.run_after_commit do - Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(pipeline.id) - end - end end scope :internal, -> { where(source: internal_sources) } @@ -416,7 +409,7 @@ module Ci def legacy_stage(name) stage = Ci::LegacyStage.new(self, name: name) - stage unless stage.statuses_count.zero? + stage unless stage.statuses_count == 0 end def ref_exists? @@ -425,40 +418,6 @@ module Ci false end - def ordered_stages - if ::Gitlab::Ci::Features.atomic_processing?(project) - # The `Ci::Stage` contains all up-to date data - # as atomic processing updates all data in-bulk - stages - elsif complete? - # The `Ci::Stage` contains up-to date data only for `completed` pipelines - # this is due to asynchronous processing of pipeline, and stages possibly - # not updated inline with processing of pipeline - stages - else - # In other cases, we need to calculate stages dynamically - legacy_stages - end - end - - def legacy_stages_using_sql - # TODO, this needs refactoring, see gitlab-foss#26481. - stages_query = statuses - .group('stage').select(:stage).order('max(stage_idx)') - - status_sql = statuses.latest.where('stage=sg.stage').legacy_status_sql - - warnings_sql = statuses.latest.select('COUNT(*)') - .where('stage=sg.stage').failed_but_allowed.to_sql - - stages_with_statuses = CommitStatus.from(stages_query, :sg) - .pluck('sg.stage', Arel.sql(status_sql), Arel.sql("(#{warnings_sql})")) - - stages_with_statuses.map do |stage| - Ci::LegacyStage.new(self, Hash[%i[name status warnings].zip(stage)]) - end - end - def legacy_stages_using_composite_status stages = latest_statuses_ordered_by_stage.group_by(&:stage) @@ -477,12 +436,9 @@ module Ci triggered_pipelines.preload(:source_job) end + # TODO: Remove usage of this method in templates def legacy_stages - if ::Gitlab::Ci::Features.composite_status?(project) - legacy_stages_using_composite_status - else - legacy_stages_using_sql - end + legacy_stages_using_composite_status end def valid_commit_sha @@ -665,7 +621,7 @@ module Ci end def has_warnings? - number_of_warnings.positive? + number_of_warnings > 0 end def number_of_warnings @@ -755,10 +711,6 @@ module Ci end end - def update_legacy_status - set_status(latest_builds_status.to_s) - end - def protected_ref? strong_memoize(:protected_ref) { project.protected_for?(git_ref) } end @@ -828,7 +780,7 @@ module Ci return unless started_at seconds = (started_at - created_at).to_i - seconds unless seconds.zero? + seconds unless seconds == 0 end def update_duration @@ -922,12 +874,6 @@ module Ci end end - def test_reports_count - Rails.cache.fetch(['project', project.id, 'pipeline', id, 'test_reports_count'], force: false) do - test_reports.total_count - end - end - def accessibility_reports Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports| builds.latest.with_reports(Ci::JobArtifact.accessibility_reports).each do |build| @@ -1061,10 +1007,6 @@ module Ci @persistent_ref ||= PersistentRef.new(pipeline: self) end - def find_successful_build_ids_by_names(names) - statuses.latest.success.where(name: names).pluck(:id) - end - def cacheable? Ci::PipelineEnums.ci_config_sources.key?(config_source.to_sym) end @@ -1084,8 +1026,6 @@ module Ci end def ensure_ci_ref! - return unless Gitlab::Ci::Features.pipeline_fixed_notifications? - self.ci_ref = Ci::Ref.ensure_for(self) end @@ -1123,12 +1063,6 @@ module Ci end end - def latest_builds_status - return 'failed' unless yaml_errors.blank? - - statuses.latest.slow_composite_status(project: project) || 'skipped' - end - def keep_around_commits return unless project diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb new file mode 100644 index 00000000000..e7f51977ccd --- /dev/null +++ b/app/models/ci/pipeline_artifact.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# This class is being used to persist additional artifacts after a pipeline completes, which is a great place to cache a computed result in object storage + +module Ci + class PipelineArtifact < ApplicationRecord + extend Gitlab::Ci::Model + include Artifactable + include FileStoreMounter + + FILE_STORE_SUPPORTED = [ + ObjectStorage::Store::LOCAL, + ObjectStorage::Store::REMOTE + ].freeze + + FILE_SIZE_LIMIT = 10.megabytes.freeze + + belongs_to :project, class_name: "Project", inverse_of: :pipeline_artifacts + belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_artifacts + + validates :pipeline, :project, :file_format, :file, presence: true + validates :file_store, presence: true, inclusion: { in: FILE_STORE_SUPPORTED } + validates :size, presence: true, numericality: { less_than_or_equal_to: FILE_SIZE_LIMIT } + validates :file_type, presence: true + + mount_file_store_uploader Ci::PipelineArtifactUploader + before_save :set_size, if: :file_changed? + + enum file_type: { + code_coverage: 1 + } + + def set_size + self.size = file.size + end + end +end diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb index 352dc56aac7..9d108ff0fa4 100644 --- a/app/models/ci/pipeline_enums.rb +++ b/app/models/ci/pipeline_enums.rb @@ -63,6 +63,10 @@ module Ci def self.ci_config_sources_values ci_config_sources.values end + + def self.non_ci_config_source_values + config_sources.values - ci_config_sources.values + end end end diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb index 29b44575d65..3d8823728e7 100644 --- a/app/models/ci/ref.rb +++ b/app/models/ci/ref.rb @@ -3,6 +3,7 @@ module Ci class Ref < ApplicationRecord extend Gitlab::Ci::Model + include AfterCommitQueue include Gitlab::OptimisticLocking FAILING_STATUSES = %w[failed broken still_failing].freeze @@ -15,6 +16,7 @@ module Ci transition unknown: :success transition fixed: :success transition %i[failed broken still_failing] => :fixed + transition success: same end event :do_fail do @@ -29,6 +31,14 @@ module Ci state :fixed, value: 3 state :broken, value: 4 state :still_failing, value: 5 + + after_transition any => [:fixed, :success] do |ci_ref| + next unless ::Gitlab::Ci::Features.keep_latest_artifacts_for_ref_enabled?(ci_ref.project) + + ci_ref.run_after_commit do + Ci::PipelineSuccessUnlockArtifactsWorker.perform_async(ci_ref.last_finished_pipeline_id) + end + end end class << self @@ -47,8 +57,6 @@ module Ci end def update_status_by!(pipeline) - return unless Gitlab::Ci::Features.pipeline_fixed_notifications? - retry_lock(self) do next unless last_finished_pipeline_id == pipeline.id diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 1cd6c64841b..00ee45740bd 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -10,7 +10,7 @@ module Ci include TokenAuthenticatable include IgnorableColumns - add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption, default_enabled: true) ? :optional : :required } + add_authentication_token_field :token, encrypted: -> { Feature.enabled?(:ci_runners_tokens_optional_encryption) ? :optional : :required } enum access_level: { not_protected: 0, diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 41215601704..cc6bd1870b9 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -113,7 +113,7 @@ module Ci end def has_warnings? - number_of_warnings.positive? + number_of_warnings > 0 end def number_of_warnings @@ -138,7 +138,7 @@ module Ci end def latest_stage_status - statuses.latest.slow_composite_status(project: project) || 'skipped' + statuses.latest.composite_status || 'skipped' end end end diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb new file mode 100644 index 00000000000..c21759a3c3b --- /dev/null +++ b/app/models/clusters/agent.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Clusters + class Agent < ApplicationRecord + self.table_name = 'cluster_agents' + + belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project + + has_many :agent_tokens, class_name: 'Clusters::AgentToken' + + validates :name, + presence: true, + length: { maximum: 63 }, + uniqueness: { scope: :project_id }, + format: { + with: Gitlab::Regex.cluster_agent_name_regex, + message: Gitlab::Regex.cluster_agent_name_regex_message + } + end +end diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb new file mode 100644 index 00000000000..e9f1ee4e033 --- /dev/null +++ b/app/models/clusters/agent_token.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Clusters + class AgentToken < ApplicationRecord + include TokenAuthenticatable + add_authentication_token_field :token, encrypted: :required + + self.table_name = 'cluster_agent_tokens' + + belongs_to :agent, class_name: 'Clusters::Agent' + + before_save :ensure_token + end +end diff --git a/app/models/clusters/applications/cert_manager.rb b/app/models/clusters/applications/cert_manager.rb index 53c90fa56d5..1efa44c39c5 100644 --- a/app/models/clusters/applications/cert_manager.rb +++ b/app/models/clusters/applications/cert_manager.rb @@ -38,8 +38,7 @@ module Clusters chart: chart, files: files.merge(cluster_issuer_file), preinstall: pre_install_script, - postinstall: post_install_script, - local_tiller_enabled: cluster.local_tiller_enabled? + postinstall: post_install_script ) end @@ -48,8 +47,7 @@ module Clusters name: 'certmanager', rbac: cluster.platform_kubernetes_rbac?, files: files, - postdelete: post_delete_script, - local_tiller_enabled: cluster.local_tiller_enabled? + postdelete: post_delete_script ) end diff --git a/app/models/clusters/applications/crossplane.rb b/app/models/clusters/applications/crossplane.rb index 2e5a8210b3c..420e56c1742 100644 --- a/app/models/clusters/applications/crossplane.rb +++ b/app/models/clusters/applications/crossplane.rb @@ -35,8 +35,7 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files, - local_tiller_enabled: cluster.local_tiller_enabled? + files: files ) end diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index 58ac0c1f188..77996748b81 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -34,8 +34,7 @@ module Clusters repository: repository, files: files, preinstall: migrate_to_3_script, - postinstall: post_install_script, - local_tiller_enabled: cluster.local_tiller_enabled? + postinstall: post_install_script ) end @@ -44,8 +43,7 @@ module Clusters name: 'elastic-stack', rbac: cluster.platform_kubernetes_rbac?, files: files, - postdelete: post_delete_script, - local_tiller_enabled: cluster.local_tiller_enabled? + postdelete: post_delete_script ) end @@ -121,8 +119,7 @@ module Clusters Gitlab::Kubernetes::Helm::DeleteCommand.new( name: 'elastic-stack', rbac: cluster.platform_kubernetes_rbac?, - files: files, - local_tiller_enabled: cluster.local_tiller_enabled? + files: files ).delete_command, Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE) ] diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb index 1bcd39618f6..3fd6e870edc 100644 --- a/app/models/clusters/applications/fluentd.rb +++ b/app/models/clusters/applications/fluentd.rb @@ -32,8 +32,7 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files, - local_tiller_enabled: cluster.local_tiller_enabled? + files: files ) end diff --git a/app/models/clusters/applications/helm.rb b/app/models/clusters/applications/helm.rb index 226a9c26db0..4a1bcac4bb7 100644 --- a/app/models/clusters/applications/helm.rb +++ b/app/models/clusters/applications/helm.rb @@ -52,8 +52,7 @@ module Clusters Gitlab::Kubernetes::Helm::InitCommand.new( name: name, files: files, - rbac: cluster.platform_kubernetes_rbac?, - local_tiller_enabled: cluster.local_tiller_enabled? + rbac: cluster.platform_kubernetes_rbac? ) end @@ -61,8 +60,7 @@ module Clusters Gitlab::Kubernetes::Helm::ResetCommand.new( name: name, files: files, - rbac: cluster.platform_kubernetes_rbac?, - local_tiller_enabled: cluster.local_tiller_enabled? + rbac: cluster.platform_kubernetes_rbac? ) end diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb index a44450ec7a9..1d08f38a2f1 100644 --- a/app/models/clusters/applications/ingress.rb +++ b/app/models/clusters/applications/ingress.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Ingress < ApplicationRecord - VERSION = '1.29.7' + VERSION = '1.40.2' INGRESS_CONTAINER_NAME = 'nginx-ingress-controller' MODSECURITY_LOG_CONTAINER_NAME = 'modsecurity-log' MODSECURITY_MODE_LOGGING = "DetectionOnly" @@ -63,8 +63,7 @@ module Clusters version: VERSION, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files, - local_tiller_enabled: cluster.local_tiller_enabled? + files: files ) end diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb index b737f0f962f..056ea355de6 100644 --- a/app/models/clusters/applications/jupyter.rb +++ b/app/models/clusters/applications/jupyter.rb @@ -45,8 +45,7 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, - repository: repository, - local_tiller_enabled: cluster.local_tiller_enabled? + repository: repository ) end diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb index b55fc3c45fc..3047da12dd9 100644 --- a/app/models/clusters/applications/knative.rb +++ b/app/models/clusters/applications/knative.rb @@ -77,8 +77,7 @@ module Clusters chart: chart, files: files, repository: REPOSITORY, - postinstall: install_knative_metrics, - local_tiller_enabled: cluster.local_tiller_enabled? + postinstall: install_knative_metrics ) end @@ -100,8 +99,7 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, files: files, predelete: delete_knative_services_and_metrics, - postdelete: delete_knative_istio_leftovers, - local_tiller_enabled: cluster.local_tiller_enabled? + postdelete: delete_knative_istio_leftovers ) end diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index 101d782db3a..216bbbc1c5a 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -69,8 +69,7 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, - postinstall: install_knative_metrics, - local_tiller_enabled: cluster.local_tiller_enabled? + postinstall: install_knative_metrics ) end @@ -80,8 +79,7 @@ module Clusters version: version, rbac: cluster.platform_kubernetes_rbac?, chart: chart, - files: files_with_replaced_values(values), - local_tiller_enabled: cluster.local_tiller_enabled? + files: files_with_replaced_values(values) ) end @@ -90,8 +88,7 @@ module Clusters name: name, rbac: cluster.platform_kubernetes_rbac?, files: files, - predelete: delete_knative_istio_metrics, - local_tiller_enabled: cluster.local_tiller_enabled? + predelete: delete_knative_istio_metrics ) end diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 3f0b4edde35..c041f605e6c 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.18.2' + VERSION = '0.19.2' self.table_name = 'clusters_applications_runners' @@ -36,8 +36,7 @@ module Clusters rbac: cluster.platform_kubernetes_rbac?, chart: chart, files: files, - repository: repository, - local_tiller_enabled: cluster.local_tiller_enabled? + repository: repository ) end diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index 7641b6d2a4b..63aebdf1bdb 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -218,6 +218,24 @@ module Clusters provider&.status_name || connection_status.presence || :created end + def connection_error + with_reactive_cache do |data| + data[:connection_error] + end + end + + def node_connection_error + with_reactive_cache do |data| + data[:node_connection_error] + end + end + + def metrics_connection_error + with_reactive_cache do |data| + data[:metrics_connection_error] + end + end + def connection_status with_reactive_cache do |data| data[:connection_status] @@ -233,9 +251,7 @@ module Clusters def calculate_reactive_cache return unless enabled? - gitlab_kubernetes_nodes = Gitlab::Kubernetes::Node.new(self) - - { connection_status: retrieve_connection_status, nodes: gitlab_kubernetes_nodes.all.presence } + connection_data.merge(Gitlab::Kubernetes::Node.new(self).all) end def persisted_applications @@ -341,10 +357,6 @@ module Clusters end end - def local_tiller_enabled? - Feature.enabled?(:managed_apps_local_tiller, clusterable, default_enabled: true) - end - def prometheus_adapter application_prometheus end @@ -395,9 +407,10 @@ module Clusters @instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain end - def retrieve_connection_status + def connection_data result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.core_client.discover } - result[:status] + + { connection_status: result[:status], connection_error: result[:connection_error] }.compact end # To keep backward compatibility with AUTO_DEVOPS_DOMAIN diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index c1f63758906..760576ea1eb 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -15,7 +15,7 @@ module Clusters def set_initial_status return unless not_installable? - self.status = status_states[:installable] if cluster&.application_helm_available? || cluster&.local_tiller_enabled? + self.status = status_states[:installable] end def can_uninstall? diff --git a/app/models/clusters/concerns/application_data.rb b/app/models/clusters/concerns/application_data.rb index ade27e69642..22e597e9747 100644 --- a/app/models/clusters/concerns/application_data.rb +++ b/app/models/clusters/concerns/application_data.rb @@ -7,8 +7,7 @@ module Clusters Gitlab::Kubernetes::Helm::DeleteCommand.new( name: name, rbac: cluster.platform_kubernetes_rbac?, - files: files, - local_tiller_enabled: cluster.local_tiller_enabled? + files: files ) end @@ -21,23 +20,11 @@ module Clusters end def files - @files ||= begin - files = { 'values.yaml': values } - - files.merge!(certificate_files) if use_tiller_ssl? - - files - end + @files ||= { 'values.yaml': values } end private - def use_tiller_ssl? - return false if cluster.local_tiller_enabled? - - cluster.application_helm.has_ssl? - end - def certificate_files { 'ca.pem': ca_cert, diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb index 86d74ed7b1c..95ac95448dd 100644 --- a/app/models/clusters/concerns/application_status.rb +++ b/app/models/clusters/concerns/application_status.rb @@ -79,7 +79,7 @@ module Clusters transition [:scheduled] => :uninstalling end - before_transition any => [:scheduled] do |application, _| + before_transition any => [:scheduled, :installed, :uninstalled] do |application, _| application.status_reason = nil end @@ -97,24 +97,6 @@ module Clusters application.status_reason = status_reason if status_reason end - before_transition any => [:installed, :updated] do |application, transition| - unless application.cluster.local_tiller_enabled? || application.is_a?(Clusters::Applications::Helm) - if transition.event == :make_externally_installed - # If an application is externally installed - # We assume the helm application is externally installed too - helm = application.cluster.application_helm || application.cluster.build_application_helm - - helm.make_externally_installed! - else - # When installing any application we are also performing an update - # of tiller (see Gitlab::Kubernetes::Helm::ClientCommand) so - # therefore we need to reflect that in the database. - - application.cluster.application_helm.update!(version: Gitlab::Kubernetes::Helm::HELM_VERSION) - end - end - end - after_transition any => [:uninstalling], :use_transactions => false do |application, _| application.prepare_uninstall end diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index faf587fb83d..86869361ed8 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -5,6 +5,9 @@ module Clusters class Aws < ApplicationRecord include Gitlab::Utils::StrongMemoize include Clusters::Concerns::ProviderStatus + include IgnorableColumns + + ignore_column :created_by_user_id, remove_with: '13.4', remove_after: '2020-08-22' self.table_name = 'cluster_providers_aws' diff --git a/app/models/commit.rb b/app/models/commit.rb index 53bcdf8165f..4f18ece9e50 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -21,7 +21,6 @@ class Commit participant :committer participant :notes_with_associations - attr_accessor :author attr_accessor :redacted_description_html attr_accessor :redacted_title_html attr_accessor :redacted_full_title_html diff --git a/app/models/commit_collection.rb b/app/models/commit_collection.rb index b8653f47392..07c49ed48e6 100644 --- a/app/models/commit_collection.rb +++ b/app/models/commit_collection.rb @@ -47,7 +47,10 @@ class CommitCollection pipelines = project.ci_pipelines.latest_pipeline_per_commit(map(&:id), ref) each do |commit| - commit.set_latest_pipeline_for_ref(ref, pipelines[commit.id]) + pipeline = pipelines[commit.id] + pipeline&.number_of_warnings # preload number of warnings + + commit.set_latest_pipeline_for_ref(ref, pipeline) end self diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index c85292feb25..8aba74bedbc 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -100,9 +100,7 @@ class CommitStatus < ApplicationRecord # will not be refreshed to pick the change self.processed_will_change! - if !::Gitlab::Ci::Features.atomic_processing?(project) - self.processed = nil - elsif latest? + if latest? self.processed = false # force refresh of all dependent ones elsif retried? self.processed = true # retried are considered to be already processed @@ -164,8 +162,7 @@ class CommitStatus < ApplicationRecord next unless commit_status.project commit_status.run_after_commit do - schedule_stage_and_pipeline_update - + PipelineProcessWorker.perform_async(pipeline_id) ExpireJobCacheWorker.perform_async(id) end end @@ -186,14 +183,6 @@ class CommitStatus < ApplicationRecord select(:name) end - def self.status_for_prior_stages(index, project:) - before_stage(index).latest.slow_composite_status(project: project) || 'success' - end - - def self.status_for_names(names, project:) - where(name: names).latest.slow_composite_status(project: project) || 'success' - end - def self.update_as_processed! # Marks items as processed # we do not increase `lock_version`, as we are the one @@ -286,21 +275,6 @@ class CommitStatus < ApplicationRecord def unrecoverable_failure? script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure? end - - def schedule_stage_and_pipeline_update - if ::Gitlab::Ci::Features.atomic_processing?(project) - # Atomic Processing requires only single Worker - PipelineProcessWorker.perform_async(pipeline_id, [id]) - else - if complete? || manual? - PipelineProcessWorker.perform_async(pipeline_id, [id]) - else - PipelineUpdateWorker.perform_async(pipeline_id) - end - - StageUpdateWorker.perform_async(stage_id) - end - end end CommitStatus.prepend_if_ee('::EE::CommitStatus') diff --git a/app/models/commit_status_enums.rb b/app/models/commit_status_enums.rb index caebff91022..ad90929b8fa 100644 --- a/app/models/commit_status_enums.rb +++ b/app/models/commit_status_enums.rb @@ -23,7 +23,8 @@ module CommitStatusEnums downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, bridge_pipeline_is_child_pipeline: 1_006, - downstream_pipeline_creation_failed: 1_007 + downstream_pipeline_creation_failed: 1_007, + secrets_provider_not_found: 1_008 } end end diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 60de20c3b31..0dd55ab67b5 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -3,6 +3,17 @@ module Avatarable extend ActiveSupport::Concern + ALLOWED_IMAGE_SCALER_WIDTHS = [ + 400, + 200, + 64, + 48, + 40, + 26, + 20, + 16 + ].freeze + included do prepend ShadowMethods include ObjectStorage::BackgroundMove diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 04eb4659469..49fc780f372 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -39,6 +39,10 @@ module CacheMarkdownField context[:markdown_engine] = :common_mark + if Feature.enabled?(:personal_snippet_reference_filters, context[:author]) + context[:user] = self.parent_user + end + context end @@ -132,6 +136,10 @@ module CacheMarkdownField end end + def parent_user + nil + end + included do cattr_reader :cached_markdown_fields do Gitlab::MarkdownCache::FieldData.new diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb new file mode 100644 index 00000000000..54fb9021f2f --- /dev/null +++ b/app/models/concerns/ci/artifactable.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Ci + module Artifactable + extend ActiveSupport::Concern + + FILE_FORMAT_ADAPTERS = { + gzip: Gitlab::Ci::Build::Artifacts::Adapters::GzipStream, + raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream + }.freeze + + included do + enum file_format: { + raw: 1, + zip: 2, + gzip: 3 + }, _suffix: true + end + end +end diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb index 10df5e1a8dc..c8b55e7b39f 100644 --- a/app/models/concerns/ci/contextable.rb +++ b/app/models/concerns/ci/contextable.rb @@ -9,7 +9,7 @@ module Ci ## # Variables in the environment name scope. # - def scoped_variables(environment: expanded_environment_name) + def scoped_variables(environment: expanded_environment_name, dependencies: true) Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.concat(predefined_variables) variables.concat(project.predefined_variables) @@ -18,7 +18,7 @@ module Ci variables.concat(deployment_variables(environment: environment)) variables.concat(yaml_variables) variables.concat(user_variables) - variables.concat(dependency_variables) + variables.concat(dependency_variables) if dependencies variables.concat(secret_instance_variables) variables.concat(secret_group_variables) variables.concat(secret_project_variables(environment: environment)) @@ -45,6 +45,12 @@ module Ci end end + def simple_variables_without_dependencies + strong_memoize(:variables_without_dependencies) do + scoped_variables(environment: nil, dependencies: false).to_runner_variables + end + end + def user_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| break variables if user.blank? @@ -64,7 +70,7 @@ module Ci variables.append(key: 'CI_PIPELINE_TRIGGERED', value: 'true') if trigger_request variables.append(key: 'CI_NODE_INDEX', value: self.options[:instance].to_s) if self.options&.include?(:instance) - variables.append(key: 'CI_NODE_TOTAL', value: (self.options&.dig(:parallel) || 1).to_s) + variables.append(key: 'CI_NODE_TOTAL', value: ci_node_total_value.to_s) # legacy variables variables.append(key: 'CI_BUILD_NAME', value: name) @@ -96,5 +102,13 @@ module Ci def secret_project_variables(environment: persisted_environment) project.ci_variables_for(ref: git_ref, environment: environment) end + + private + + def ci_node_total_value + parallel = self.options&.dig(:parallel) + parallel = parallel.dig(:total) if parallel.is_a?(Hash) + parallel || 1 + end end end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index c52807ec501..1cc2e8a51e3 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -20,60 +20,10 @@ module Ci UnknownStatusError = Class.new(StandardError) class_methods do - def legacy_status_sql - scope_relevant = respond_to?(:exclude_ignored) ? exclude_ignored : all - scope_warnings = respond_to?(:failed_but_allowed) ? failed_but_allowed : none - - builds = scope_relevant.select('count(*)').to_sql - created = scope_relevant.created.select('count(*)').to_sql - success = scope_relevant.success.select('count(*)').to_sql - manual = scope_relevant.manual.select('count(*)').to_sql - scheduled = scope_relevant.scheduled.select('count(*)').to_sql - preparing = scope_relevant.preparing.select('count(*)').to_sql - waiting_for_resource = scope_relevant.waiting_for_resource.select('count(*)').to_sql - pending = scope_relevant.pending.select('count(*)').to_sql - running = scope_relevant.running.select('count(*)').to_sql - skipped = scope_relevant.skipped.select('count(*)').to_sql - canceled = scope_relevant.canceled.select('count(*)').to_sql - warnings = scope_warnings.select('count(*) > 0').to_sql.presence || 'false' - - Arel.sql( - "(CASE - WHEN (#{builds})=(#{skipped}) AND (#{warnings}) THEN 'success' - WHEN (#{builds})=(#{skipped}) THEN 'skipped' - WHEN (#{builds})=(#{success}) THEN 'success' - WHEN (#{builds})=(#{created}) THEN 'created' - WHEN (#{builds})=(#{preparing}) THEN 'preparing' - WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success' - WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled' - WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending' - WHEN (#{running})+(#{pending})>0 THEN 'running' - WHEN (#{waiting_for_resource})>0 THEN 'waiting_for_resource' - WHEN (#{manual})>0 THEN 'manual' - WHEN (#{scheduled})>0 THEN 'scheduled' - WHEN (#{preparing})>0 THEN 'preparing' - WHEN (#{created})>0 THEN 'running' - ELSE 'failed' - END)" - ) - end - - def legacy_status - all.pluck(legacy_status_sql).first - end - - # This method should not be used. - # This method performs expensive calculation of status: - # 1. By plucking all related objects, - # 2. Or executes expensive SQL query - def slow_composite_status(project:) - if ::Gitlab::Ci::Features.composite_status?(project) - Gitlab::Ci::Status::Composite - .new(all, with_allow_failure: columns_hash.key?('allow_failure')) - .status - else - legacy_status - end + def composite_status + Gitlab::Ci::Status::Composite + .new(all, with_allow_failure: columns_hash.key?('allow_failure')) + .status end def started_at diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb new file mode 100644 index 00000000000..a5c7393e8f7 --- /dev/null +++ b/app/models/concerns/counter_attribute.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +# Add capabilities to increment a numeric model attribute efficiently by +# using Redis and flushing the increments asynchronously to the database +# after a period of time (10 minutes). +# When an attribute is incremented by a value, the increment is added +# to a Redis key. Then, FlushCounterIncrementsWorker will execute +# `flush_increments_to_database!` which removes increments from Redis for a +# given model attribute and updates the values in the database. +# +# @example: +# +# class ProjectStatistics +# include CounterAttribute +# +# counter_attribute :commit_count +# counter_attribute :storage_size +# end +# +# To increment the counter we can use the method: +# delayed_increment_counter(:commit_count, 3) +# +module CounterAttribute + extend ActiveSupport::Concern + extend AfterCommitQueue + include Gitlab::ExclusiveLeaseHelpers + + LUA_STEAL_INCREMENT_SCRIPT = <<~EOS.freeze + local increment_key, flushed_key = KEYS[1], KEYS[2] + local increment_value = redis.call("get", increment_key) or 0 + local flushed_value = redis.call("incrby", flushed_key, increment_value) + if flushed_value == 0 then + redis.call("del", increment_key, flushed_key) + else + redis.call("del", increment_key) + end + return flushed_value + EOS + + WORKER_DELAY = 10.minutes + WORKER_LOCK_TTL = 10.minutes + + class_methods do + def counter_attribute(attribute) + counter_attributes << attribute + end + + def counter_attributes + @counter_attributes ||= Set.new + end + end + + # This method must only be called by FlushCounterIncrementsWorker + # because it should run asynchronously and with exclusive lease. + # This will + # 1. temporarily move the pending increment for a given attribute + # to a relative "flushed" Redis key, delete the increment key and return + # the value. If new increments are performed at this point, the increment + # key is recreated as part of `delayed_increment_counter`. + # The "flushed" key is used to ensure that we can keep incrementing + # counters in Redis while flushing existing values. + # 2. then the value is used to update the counter in the database. + # 3. finally the "flushed" key is deleted. + def flush_increments_to_database!(attribute) + lock_key = counter_lock_key(attribute) + + with_exclusive_lease(lock_key) do + increment_key = counter_key(attribute) + flushed_key = counter_flushed_key(attribute) + increment_value = steal_increments(increment_key, flushed_key) + + next if increment_value == 0 + + transaction do + unsafe_update_counters(id, attribute => increment_value) + redis_state { |redis| redis.del(flushed_key) } + end + end + end + + def delayed_increment_counter(attribute, increment) + return if increment == 0 + + run_after_commit_or_now do + if counter_attribute_enabled?(attribute) + redis_state do |redis| + redis.incrby(counter_key(attribute), increment) + end + + FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute) + else + legacy_increment!(attribute, increment) + end + end + + true + end + + def counter_key(attribute) + "project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}" + end + + def counter_flushed_key(attribute) + counter_key(attribute) + ':flushed' + end + + def counter_lock_key(attribute) + counter_key(attribute) + ':lock' + end + + private + + def counter_attribute_enabled?(attribute) + Feature.enabled?(:efficient_counter_attribute, project) && + self.class.counter_attributes.include?(attribute) + end + + def steal_increments(increment_key, flushed_key) + redis_state do |redis| + redis.eval(LUA_STEAL_INCREMENT_SCRIPT, keys: [increment_key, flushed_key]) + end + end + + def legacy_increment!(attribute, increment) + increment!(attribute, increment) + end + + def unsafe_update_counters(id, increments) + self.class.update_counters(id, increments) + end + + def redis_state(&block) + Gitlab::Redis::SharedState.with(&block) + end + + def with_exclusive_lease(lock_key) + in_lock(lock_key, ttl: WORKER_LOCK_TTL) do + yield + end + rescue Gitlab::ExclusiveLeaseHelpers::FailedToObtainLockError + # a worker is already updating the counters + end +end diff --git a/app/models/concerns/file_store_mounter.rb b/app/models/concerns/file_store_mounter.rb new file mode 100644 index 00000000000..9d4463e5297 --- /dev/null +++ b/app/models/concerns/file_store_mounter.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module FileStoreMounter + extend ActiveSupport::Concern + + class_methods do + def mount_file_store_uploader(uploader) + mount_uploader(:file, uploader) + + after_save :update_file_store, if: :saved_change_to_file? + end + end + + private + + def update_file_store + # The file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:file_store, file.object_store) + end +end diff --git a/app/models/concerns/has_wiki.rb b/app/models/concerns/has_wiki.rb index 4dd72216e77..3e7cb940a62 100644 --- a/app/models/concerns/has_wiki.rb +++ b/app/models/concerns/has_wiki.rb @@ -17,7 +17,7 @@ module HasWiki def wiki strong_memoize(:wiki) do - Wiki.for_container(self, self.owner) + Wiki.for_container(self, self.default_owner) end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 715cbd15d93..dd5aedbb760 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -61,11 +61,13 @@ module Issuable end end + has_many :note_authors, -> { distinct }, through: :notes, source: :author + has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent has_many :labels, through: :label_links has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - has_one :metrics + has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true delegate :name, :email, diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index 8f8494a9678..ccb334343ff 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -15,7 +15,7 @@ module Milestoneable validate :milestone_is_valid scope :of_milestones, ->(ids) { where(milestone_id: ids) } - scope :any_milestone, -> { where('milestone_id IS NOT NULL') } + scope :any_milestone, -> { where.not(milestone_id: nil) } scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) } scope :any_release, -> { joins_milestone_releases } diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 1d89a4497d9..d1f04609693 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -3,11 +3,15 @@ # This module makes it possible to handle items as a list, where the order of items can be easily altered # Requirements: # -# - Only works for ActiveRecord models -# - relative_position integer field must present on the model -# - This module uses GROUP BY: the model should have a parent relation, example: project -> issues, project is the parent relation (issues table has a parent_id column) +# The model must have the following named columns: +# - id: integer +# - relative_position: integer # -# Setup like this in the body of your class: +# The model must support a concept of siblings via a child->parent relationship, +# to enable rebalancing and `GROUP BY` in queries. +# - example: project -> issues, project is the parent relation (issues table has a parent_id column) +# +# Two class methods must be defined when including this concern: # # include RelativePositioning # @@ -24,53 +28,167 @@ module RelativePositioning extend ActiveSupport::Concern - MIN_POSITION = 0 - START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2 + STEPS = 10 + IDEAL_DISTANCE = 2**(STEPS - 1) + 1 + + MIN_POSITION = Gitlab::Database::MIN_INT_VALUE + START_POSITION = 0 MAX_POSITION = Gitlab::Database::MAX_INT_VALUE - IDEAL_DISTANCE = 500 - class_methods do - def move_nulls_to_end(objects) - objects = objects.reject(&:relative_position) + MAX_GAP = IDEAL_DISTANCE * 2 + MIN_GAP = 2 - return if objects.empty? + NoSpaceLeft = Class.new(StandardError) - max_relative_position = objects.first.max_relative_position + class_methods do + def move_nulls_to_end(objects) + move_nulls(objects, at_end: true) + end - self.transaction do - objects.each do |object| - relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION) - object.relative_position = relative_position - max_relative_position = relative_position - object.save(touch: false) - end - end + def move_nulls_to_start(objects) + move_nulls(objects, at_end: false) end # This method takes two integer values (positions) and # calculates the position between them. The range is huge as - # the maximum integer value is 2147483647. We are incrementing position by IDEAL_DISTANCE * 2 every time - # when we have enough space. If distance is less than IDEAL_DISTANCE, we are calculating an average number. + # the maximum integer value is 2147483647. + # + # We avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION]. + # + # Then we handle one of three cases: + # - If the gap is too small, we raise NoSpaceLeft + # - If the gap is larger than MAX_GAP, we place the new position at most + # IDEAL_DISTANCE from the edge of the gap. + # - otherwise we place the new position at the midpoint. + # + # The new position will always satisfy: pos_before <= midpoint <= pos_after + # + # As a precondition, the gap between pos_before and pos_after MUST be >= 2. + # If the gap is too small, NoSpaceLeft is raised. + # + # This class method should only be called by instance methods of this module, which + # include handling for minimum gap size. + # + # @raises NoSpaceLeft + # @api private def position_between(pos_before, pos_after) pos_before ||= MIN_POSITION pos_after ||= MAX_POSITION pos_before, pos_after = [pos_before, pos_after].sort - halfway = (pos_after + pos_before) / 2 - distance_to_halfway = pos_after - halfway + gap_width = pos_after - pos_before + midpoint = [pos_after - 1, pos_before + (gap_width / 2)].min - if distance_to_halfway < IDEAL_DISTANCE - halfway - else + if gap_width < MIN_GAP + raise NoSpaceLeft + elsif gap_width > MAX_GAP if pos_before == MIN_POSITION pos_after - IDEAL_DISTANCE elsif pos_after == MAX_POSITION pos_before + IDEAL_DISTANCE else - halfway + midpoint + end + else + midpoint + end + end + + private + + # @api private + def gap_size(object, gaps:, at_end:, starting_from:) + total_width = IDEAL_DISTANCE * gaps + size = if at_end && starting_from + total_width >= MAX_POSITION + (MAX_POSITION - starting_from) / gaps + elsif !at_end && starting_from - total_width <= MIN_POSITION + (starting_from - MIN_POSITION) / gaps + else + IDEAL_DISTANCE + end + + # Shift max elements leftwards if there isn't enough space + return [size, starting_from] if size >= MIN_GAP + + order = at_end ? :desc : :asc + terminus = object + .send(:relative_siblings) # rubocop:disable GitlabSecurity/PublicSend + .where('relative_position IS NOT NULL') + .order(relative_position: order) + .first + + if at_end + terminus.move_sequence_before(true) + max_relative_position = terminus.reset.relative_position + [[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position] + else + terminus.move_sequence_after(true) + min_relative_position = terminus.reset.relative_position + [[(min_relative_position - MIN_POSITION) / gaps, IDEAL_DISTANCE].min, min_relative_position] + end + end + + # @api private + # @param [Array<RelativePositioning>] objects The objects to give positions to. The relative + # order will be preserved (i.e. when this method returns, + # objects.first.relative_position < objects.last.relative_position) + # @param [Boolean] at_end: The placement. + # If `true`, then all objects with `null` positions are placed _after_ + # all siblings with positions. If `false`, all objects with `null` + # positions are placed _before_ all siblings with positions. + # @returns [Number] The number of moved records. + def move_nulls(objects, at_end:) + objects = objects.reject(&:relative_position) + return 0 if objects.empty? + + representative = objects.first + number_of_gaps = objects.size + 1 # 1 at left, one between each, and one at right + position = if at_end + representative.max_relative_position + else + representative.min_relative_position + end + + position ||= START_POSITION # If there are no positioned siblings, start from START_POSITION + + gap, position = gap_size(representative, gaps: number_of_gaps, at_end: at_end, starting_from: position) + + # Raise if we could not make enough space + raise NoSpaceLeft if gap < MIN_GAP + + indexed = objects.each_with_index.to_a + starting_from = at_end ? position : position - (gap * number_of_gaps) + + # Some classes are polymorphic, and not all siblings are in the same table. + by_model = indexed.group_by { |pair| pair.first.class } + + by_model.each do |model, pairs| + model.transaction do + pairs.each_slice(100) do |batch| + # These are known to be integers, one from the DB, and the other + # calculated by us, and thus safe to interpolate + values = batch.map do |obj, i| + pos = starting_from + gap * (i + 1) + obj.relative_position = pos + "(#{obj.id}, #{pos})" + end.join(', ') + + model.connection.exec_query(<<~SQL, "UPDATE #{model.table_name} positions") + WITH cte(cte_id, new_pos) AS ( + SELECT * + FROM (VALUES #{values}) as t (id, pos) + ) + UPDATE #{model.table_name} + SET relative_position = cte.new_pos + FROM cte + WHERE cte_id = id + SQL + end end end + + objects.size end end @@ -82,11 +200,12 @@ module RelativePositioning calculate_relative_position('MAX', &block) end - def prev_relative_position + def prev_relative_position(ignoring: nil) prev_pos = nil if self.relative_position prev_pos = max_relative_position do |relation| + relation = relation.id_not_in(ignoring.id) if ignoring.present? relation.where('relative_position < ?', self.relative_position) end end @@ -94,11 +213,12 @@ module RelativePositioning prev_pos end - def next_relative_position + def next_relative_position(ignoring: nil) next_pos = nil if self.relative_position next_pos = min_relative_position do |relation| + relation = relation.id_not_in(ignoring.id) if ignoring.present? relation.where('relative_position > ?', self.relative_position) end end @@ -110,24 +230,44 @@ module RelativePositioning return move_after(before) unless after return move_before(after) unless before - # If there is no place to insert an item we need to create one by moving the item - # before this and all preceding items until there is a gap before, after = after, before if after.relative_position < before.relative_position - if (after.relative_position - before.relative_position) < 2 - after.move_sequence_before - before.reset + + pos_left = before.relative_position + pos_right = after.relative_position + + if pos_right - pos_left < MIN_GAP + # Not enough room! Make space by shifting all previous elements to the left + # if there is enough space, else to the right + gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend + + if gap.present? + after.move_sequence_before(next_gap: gap) + pos_left -= optimum_delta_for_gap(gap) + else + before.move_sequence_after + pos_right = after.reset.relative_position + end end - self.relative_position = self.class.position_between(before.relative_position, after.relative_position) + new_position = self.class.position_between(pos_left, pos_right) + + self.relative_position = new_position end def move_after(before = self) pos_before = before.relative_position - pos_after = before.next_relative_position + pos_after = before.next_relative_position(ignoring: self) + + if pos_before == MAX_POSITION || gap_too_small?(pos_after, pos_before) + gap = before.send(:find_next_gap_after) # rubocop:disable GitlabSecurity/PublicSend - if pos_after && (pos_after - pos_before) < 2 - before.move_sequence_after - pos_after = before.next_relative_position + if gap.nil? + before.move_sequence_before(true) + pos_before = before.reset.relative_position + else + before.move_sequence_after(next_gap: gap) + pos_after += optimum_delta_for_gap(gap) + end end self.relative_position = self.class.position_between(pos_before, pos_after) @@ -135,80 +275,186 @@ module RelativePositioning def move_before(after = self) pos_after = after.relative_position - pos_before = after.prev_relative_position + pos_before = after.prev_relative_position(ignoring: self) + + if pos_after == MIN_POSITION || gap_too_small?(pos_before, pos_after) + gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend - if pos_before && (pos_after - pos_before) < 2 - after.move_sequence_before - pos_before = after.prev_relative_position + if gap.nil? + after.move_sequence_after(true) + pos_after = after.reset.relative_position + else + after.move_sequence_before(next_gap: gap) + pos_before -= optimum_delta_for_gap(gap) + end end self.relative_position = self.class.position_between(pos_before, pos_after) end def move_to_end - self.relative_position = self.class.position_between(max_relative_position || START_POSITION, MAX_POSITION) + max_pos = max_relative_position + + if max_pos.nil? + self.relative_position = START_POSITION + elsif gap_too_small?(max_pos, MAX_POSITION) + max = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'DESC')).first + max.move_sequence_before(true) + max.reset + self.relative_position = self.class.position_between(max.relative_position, MAX_POSITION) + else + self.relative_position = self.class.position_between(max_pos, MAX_POSITION) + end end def move_to_start - self.relative_position = self.class.position_between(min_relative_position || START_POSITION, MIN_POSITION) + min_pos = min_relative_position + + if min_pos.nil? + self.relative_position = START_POSITION + elsif gap_too_small?(min_pos, MIN_POSITION) + min = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'ASC')).first + min.move_sequence_after(true) + min.reset + self.relative_position = self.class.position_between(MIN_POSITION, min.relative_position) + else + self.relative_position = self.class.position_between(MIN_POSITION, min_pos) + end end # Moves the sequence before the current item to the middle of the next gap - # For example, we have 5 11 12 13 14 15 and the current item is 15 - # This moves the sequence 11 12 13 14 to 8 9 10 11 - def move_sequence_before - next_gap = find_next_gap_before + # For example, we have + # + # 5 . . . . . 11 12 13 14 [15] 16 . 17 + # ----------- + # + # This moves the sequence [11 12 13 14] to [8 9 10 11], so we have: + # + # 5 . . 8 9 10 11 . . . [15] 16 . 17 + # --------- + # + # Creating a gap to the left of the current item. We can understand this as + # dividing the 5 spaces between 5 and 11 into two smaller gaps of 2 and 3. + # + # If `include_self` is true, the current item will also be moved, creating a + # gap to the right of the current item: + # + # 5 . . 8 9 10 11 [14] . . . 16 . 17 + # -------------- + # + # As an optimization, the gap can be precalculated and passed to this method. + # + # @api private + # @raises NoSpaceLeft if the sequence cannot be moved + def move_sequence_before(include_self = false, next_gap: find_next_gap_before) + raise NoSpaceLeft unless next_gap.present? + delta = optimum_delta_for_gap(next_gap) - move_sequence(next_gap[:start], relative_position, -delta) + move_sequence(next_gap[:start], relative_position, -delta, include_self) end # Moves the sequence after the current item to the middle of the next gap - # For example, we have 11 12 13 14 15 21 and the current item is 11 - # This moves the sequence 12 13 14 15 to 15 16 17 18 - def move_sequence_after - next_gap = find_next_gap_after + # For example, we have: + # + # 8 . 10 [11] 12 13 14 15 . . . . . 21 + # ----------- + # + # This moves the sequence [12 13 14 15] to [15 16 17 18], so we have: + # + # 8 . 10 [11] . . . 15 16 17 18 . . 21 + # ----------- + # + # Creating a gap to the right of the current item. We can understand this as + # dividing the 5 spaces between 15 and 21 into two smaller gaps of 3 and 2. + # + # If `include_self` is true, the current item will also be moved, creating a + # gap to the left of the current item: + # + # 8 . 10 . . . [14] 15 16 17 18 . . 21 + # ---------------- + # + # As an optimization, the gap can be precalculated and passed to this method. + # + # @api private + # @raises NoSpaceLeft if the sequence cannot be moved + def move_sequence_after(include_self = false, next_gap: find_next_gap_after) + raise NoSpaceLeft unless next_gap.present? + delta = optimum_delta_for_gap(next_gap) - move_sequence(relative_position, next_gap[:start], delta) + move_sequence(relative_position, next_gap[:start], delta, include_self) end private - # Supposing that we have a sequence of items: 1 5 11 12 13 and the current item is 13 - # This would return: `{ start: 11, end: 5 }` + def gap_too_small?(pos_a, pos_b) + return false unless pos_a && pos_b + + (pos_a - pos_b).abs < MIN_GAP + end + + # Find the first suitable gap to the left of the current position. + # + # Satisfies the relations: + # - gap[:start] <= relative_position + # - abs(gap[:start] - gap[:end]) >= MIN_GAP + # - MIN_POSITION <= gap[:start] <= MAX_POSITION + # - MIN_POSITION <= gap[:end] <= MAX_POSITION + # + # Supposing that the current item is 13, and we have a sequence of items: + # + # 1 . . . 5 . . . . 11 12 [13] 14 . . 17 + # ^---------^ + # + # Then we return: `{ start: 11, end: 5 }` + # + # Here start refers to the end of the gap closest to the current item. def find_next_gap_before items_with_next_pos = scoped_items .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos') .where('relative_position <= ?', relative_position) .order(relative_position: :desc) - find_next_gap(items_with_next_pos).tap do |gap| - gap[:end] ||= MIN_POSITION - end + find_next_gap(items_with_next_pos, MIN_POSITION) end - # Supposing that we have a sequence of items: 13 14 15 20 24 and the current item is 13 - # This would return: `{ start: 15, end: 20 }` + # Find the first suitable gap to the right of the current position. + # + # Satisfies the relations: + # - gap[:start] >= relative_position + # - abs(gap[:start] - gap[:end]) >= MIN_GAP + # - MIN_POSITION <= gap[:start] <= MAX_POSITION + # - MIN_POSITION <= gap[:end] <= MAX_POSITION + # + # Supposing the current item is 13, and that we have a sequence of items: + # + # 9 . . . [13] 14 15 . . . . 20 . . . 24 + # ^---------^ + # + # Then we return: `{ start: 15, end: 20 }` + # + # Here start refers to the end of the gap closest to the current item. def find_next_gap_after items_with_next_pos = scoped_items .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos') .where('relative_position >= ?', relative_position) .order(:relative_position) - find_next_gap(items_with_next_pos).tap do |gap| - gap[:end] ||= MAX_POSITION - end + find_next_gap(items_with_next_pos, MAX_POSITION) end - def find_next_gap(items_with_next_pos) - gap = self.class.from(items_with_next_pos, :items_with_next_pos) - .where('ABS(pos - next_pos) > 1 OR next_pos IS NULL') - .limit(1) - .pluck(:pos, :next_pos) - .first + def find_next_gap(items_with_next_pos, end_is_nil) + gap = self.class + .from(items_with_next_pos, :items) + .where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP) + .limit(1) + .pluck(:pos, :next_pos) + .first + + return if gap.nil? || gap.first == end_is_nil - { start: gap[0], end: gap[1] } + { start: gap.first, end: gap.second || end_is_nil } end def optimum_delta_for_gap(gap) @@ -217,9 +463,10 @@ module RelativePositioning [delta, IDEAL_DISTANCE].min end - def move_sequence(start_pos, end_pos, delta) - scoped_items - .where.not(id: self.id) + def move_sequence(start_pos, end_pos, delta, include_self = false) + relation = include_self ? scoped_items : relative_siblings + + relation .where('relative_position BETWEEN ? AND ?', start_pos, end_pos) .update_all("relative_position = relative_position + #{delta}") end @@ -240,6 +487,10 @@ module RelativePositioning .first&.last end + def relative_siblings(relation = scoped_items) + relation.id_not_in(id) + end + def scoped_items self.class.relative_positioning_query_base(self) end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index c807dcbf418..cbac6a210c7 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -7,7 +7,7 @@ module ShaAttribute def sha_attribute(name) return if ENV['STATIC_VERIFICATION'] - validate_binary_column_exists!(name) unless Rails.env.production? + validate_binary_column_exists!(name) if Rails.env.development? attribute(name, Gitlab::Database::ShaAttribute.new) end @@ -17,18 +17,11 @@ module ShaAttribute # See https://gitlab.com/gitlab-org/gitlab/merge_requests/5502 for more discussion def validate_binary_column_exists!(name) return unless database_exists? - - unless table_exists? - warn "WARNING: sha_attribute #{name.inspect} is invalid since the table doesn't exist - you may need to run database migrations" - return - end + return unless table_exists? column = columns.find { |c| c.name == name.to_s } - unless column - warn "WARNING: sha_attribute #{name.inspect} is invalid since the column doesn't exist - you may need to run database migrations" - return - end + return unless column unless column.type == :binary raise ArgumentError.new("sha_attribute #{name.inspect} is invalid since the column type is not :binary") diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index dddf96837b7..a1e7d06b1c1 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -26,6 +26,7 @@ module TimeTrackable # rubocop:disable Gitlab/ModuleWithInstanceVariables def spend_time(options) @time_spent = options[:duration] + @time_spent_note_id = options[:note_id] @time_spent_user = User.find(options[:user_id]) @spent_at = options[:spent_at] @original_total_time_spent = nil @@ -67,6 +68,7 @@ module TimeTrackable def add_or_subtract_spent_time timelogs.new( time_spent: time_spent, + note_id: @time_spent_note_id, user: @time_spent_user, spent_at: @spent_at ) diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index c52baa0524c..b64a9e4f70b 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -12,7 +12,8 @@ module TriggerableHooks merge_request_hooks: :merge_requests_events, job_hooks: :job_events, pipeline_hooks: :pipeline_events, - wiki_page_hooks: :wiki_page_events + wiki_page_hooks: :wiki_page_events, + deployment_hooks: :deployment_events }.freeze extend ActiveSupport::Concern diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb index c0fa14d3369..a7028e18451 100644 --- a/app/models/concerns/update_project_statistics.rb +++ b/app/models/concerns/update_project_statistics.rb @@ -75,7 +75,7 @@ module UpdateProjectStatistics end def schedule_update_project_statistic(delta) - return if delta.zero? + return if delta == 0 return if project.nil? run_after_commit do diff --git a/app/models/deployment.rb b/app/models/deployment.rb index aa3e3a8f66d..d6508ffceba 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -148,6 +148,7 @@ class Deployment < ApplicationRecord def execute_hooks deployment_data = Gitlab::DataBuilder::Deployment.build(self) + project.execute_hooks(deployment_data, :deployment_hooks) if Feature.enabled?(:deployment_webhooks, project, default_enabled: true) project.execute_services(deployment_data, :deployment_hooks) end diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb index 0dca6333fa1..deda814d689 100644 --- a/app/models/design_management/design.rb +++ b/app/models/design_management/design.rb @@ -9,6 +9,7 @@ module DesignManagement include Referable include Mentionable include WhereComposite + include RelativePositioning belongs_to :project, inverse_of: :designs belongs_to :issue @@ -75,9 +76,23 @@ module DesignManagement join = designs.join(actions) .on(actions[:design_id].eq(designs[:id])) - joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id) + joins(join.join_sources).where(actions[:event].not_eq(deletion)) end + scope :ordered, -> (project) do + # TODO: Always order by relative position after the feature flag is removed + # https://gitlab.com/gitlab-org/gitlab/-/issues/34382 + if Feature.enabled?(:reorder_designs, project, default_enabled: true) + # We need to additionally sort by `id` to support keyset pagination. + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/17788/diffs#note_230875678 + order(:relative_position, :id) + else + in_creation_order + end + end + + scope :in_creation_order, -> { reorder(:id) } + scope :with_filename, -> (filenames) { where(filename: filenames) } scope :on_issue, ->(issue) { where(issue_id: issue) } @@ -87,6 +102,14 @@ module DesignManagement # A design is current if the most recent event is not a deletion scope :current, -> { visible_at_version(nil) } + def self.relative_positioning_query_base(design) + default_scoped.on_issue(design.issue_id) + end + + def self.relative_positioning_parent_column + :issue_id + end + def status if new_design? :new @@ -196,6 +219,17 @@ module DesignManagement project end + def immediately_before?(next_design) + return false if next_design.relative_position <= relative_position + + interloper = self.class.on_issue(issue).where( + "relative_position <@ int4range(?, ?, '()')", + *[self, next_design].map(&:relative_position) + ) + + !interloper.exists? + end + private def head_version diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb index 18d1541e9c7..96d5f4c2419 100644 --- a/app/models/design_management/design_collection.rb +++ b/app/models/design_management/design_collection.rb @@ -10,6 +10,10 @@ module DesignManagement @issue = issue end + def ==(other) + other.is_a?(self.class) && issue == other.issue + end + def find_or_create_design!(filename:) designs.find { |design| design.filename == filename } || designs.safe_find_or_create_by!(project: project, filename: filename) diff --git a/app/models/discussion.rb b/app/models/discussion.rb index e928bb0959a..adcb2217d85 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -23,6 +23,7 @@ class Discussion :resolved_by_id, :system_note_with_references_visible_for?, :resource_parent, + :save, to: :first_note diff --git a/app/models/environment.rb b/app/models/environment.rb index bddc84f10b5..c6a08c996da 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -29,6 +29,7 @@ class Environment < ApplicationRecord has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus' has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline' + has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment before_validation :nullify_external_url before_validation :generate_slug, if: ->(env) { env.slug.blank? } @@ -291,6 +292,10 @@ class Environment < ApplicationRecord !!ENV['USE_SAMPLE_METRICS'] end + def has_opened_alert? + latest_opened_most_severe_alert.present? + end + def metrics prometheus_adapter.query(:environment, self) if has_metrics_and_can_query? end diff --git a/app/models/event.rb b/app/models/event.rb index 56d7742c51a..92609144576 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -8,6 +8,7 @@ class Event < ApplicationRecord include CreatedAtFilterable include Gitlab::Utils::StrongMemoize include UsageStatistics + include ShaAttribute default_scope { reorder(nil) } # rubocop:disable Cop/DefaultScope @@ -48,6 +49,8 @@ class Event < ApplicationRecord RESET_PROJECT_ACTIVITY_INTERVAL = 1.hour REPOSITORY_UPDATED_AT_INTERVAL = 5.minutes + sha_attribute :fingerprint + enum action: ACTIONS, _suffix: true delegate :name, :email, :public_email, :username, to: :author, prefix: true, allow_nil: true @@ -82,6 +85,10 @@ class Event < ApplicationRecord scope :recent, -> { reorder(id: :desc) } scope :for_wiki_page, -> { where(target_type: 'WikiPage::Meta') } scope :for_design, -> { where(target_type: 'DesignManagement::Design') } + scope :for_fingerprint, ->(fingerprint) do + fingerprint.present? ? where(fingerprint: fingerprint) : none + end + scope :for_action, ->(action) { where(action: action) } scope :with_associations, -> do # We're using preload for "push_event_payload" as otherwise the association diff --git a/app/models/experiment.rb b/app/models/experiment.rb new file mode 100644 index 00000000000..25640385536 --- /dev/null +++ b/app/models/experiment.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class Experiment < ApplicationRecord + has_many :experiment_users + has_many :users, through: :experiment_users + has_many :control_group_users, -> { merge(ExperimentUser.control) }, through: :experiment_users, source: :user + has_many :experimental_group_users, -> { merge(ExperimentUser.experimental) }, through: :experiment_users, source: :user + + validates :name, presence: true, uniqueness: true, length: { maximum: 255 } + + def self.add_user(name, group_type, user) + experiment = find_or_create_by(name: name) + + return unless experiment + return if experiment.experiment_users.where(user: user).exists? + + group_type == ::Gitlab::Experimentation::GROUP_CONTROL ? experiment.add_control_user(user) : experiment.add_experimental_user(user) + end + + def add_control_user(user) + control_group_users << user + end + + def add_experimental_user(user) + experimental_group_users << user + end +end diff --git a/app/models/experiment_user.rb b/app/models/experiment_user.rb new file mode 100644 index 00000000000..1571b0c3439 --- /dev/null +++ b/app/models/experiment_user.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ExperimentUser < ApplicationRecord + belongs_to :experiment + belongs_to :user + + enum group_type: { control: 0, experimental: 1 } + + validates :group_type, presence: true +end diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb index 9c6d05f773a..1487a6387f0 100644 --- a/app/models/external_pull_request.rb +++ b/app/models/external_pull_request.rb @@ -63,6 +63,8 @@ class ExternalPullRequest < ApplicationRecord def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_IID', value: pull_request_iid.to_s) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_REPOSITORY', value: source_repository) + variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_REPOSITORY', value: target_repository) variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_SHA', value: source_sha) variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_TARGET_BRANCH_SHA', value: target_sha) variables.append(key: 'CI_EXTERNAL_PULL_REQUEST_SOURCE_BRANCH_NAME', value: source_branch) diff --git a/app/models/group.rb b/app/models/group.rb index c38ddbdf6fb..f8cbaa2495c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -64,6 +64,8 @@ class Group < Namespace has_one :import_state, class_name: 'GroupImportState', inverse_of: :group + has_many :group_deploy_keys_groups, inverse_of: :group + has_many :group_deploy_keys, through: :group_deploy_keys_groups has_many :group_deploy_tokens has_many :deploy_tokens, through: :group_deploy_tokens @@ -172,6 +174,10 @@ class Group < Namespace notification_settings(hierarchy_order: hierarchy_order).where(user: user) end + def packages_feature_enabled? + ::Gitlab.config.packages.enabled + end + def notification_email_for(user) # Finds the closest notification_setting with a `notification_email` notification_settings = notification_settings_for(user, hierarchy_order: :asc) @@ -557,6 +563,10 @@ class Group < Namespace all_projects.update_all(shared_runners_enabled: false) end + def default_owner + owners.first || parent&.default_owner || owner + end + private def update_two_factor_requirement diff --git a/app/models/group_deploy_key.rb b/app/models/group_deploy_key.rb index d1f1aa544cd..160ac28b33b 100644 --- a/app/models/group_deploy_key.rb +++ b/app/models/group_deploy_key.rb @@ -3,9 +3,31 @@ class GroupDeployKey < Key self.table_name = 'group_deploy_keys' + has_many :group_deploy_keys_groups, inverse_of: :group_deploy_key, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :groups, through: :group_deploy_keys_groups + validates :user, presence: true def type 'DeployKey' end + + def group_deploy_keys_group_for(group) + group_deploy_keys_groups.find_by(group: group) + end + + def can_be_edited_for?(user, group) + Ability.allowed?(user, :update_group_deploy_key, self) || + Ability.allowed?( + user, + :update_group_deploy_key_for_group, + group_deploy_keys_group_for(group) + ) + end + + def group_deploy_keys_groups_for_user(user) + group_deploy_keys_groups.select do |group_deploy_keys_group| + Ability.allowed?(user, :read_group, group_deploy_keys_group.group) + end + end end diff --git a/app/models/group_deploy_keys_group.rb b/app/models/group_deploy_keys_group.rb new file mode 100644 index 00000000000..2fbfd2983b4 --- /dev/null +++ b/app/models/group_deploy_keys_group.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class GroupDeployKeysGroup < ApplicationRecord + belongs_to :group, inverse_of: :group_deploy_keys_groups + belongs_to :group_deploy_key, inverse_of: :group_deploy_keys_groups + + validates :group_deploy_key, presence: true + validates :group_deploy_key_id, uniqueness: { scope: [:group_id], message: "already exists in group" } + validates :group_id, presence: true +end diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index 71494b6de4d..2d1bdecc770 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -17,7 +17,8 @@ class ProjectHook < WebHook :merge_request_hooks, :job_hooks, :pipeline_hooks, - :wiki_page_hooks + :wiki_page_hooks, + :deployment_hooks ] belongs_to :project diff --git a/app/models/individual_note_discussion.rb b/app/models/individual_note_discussion.rb index bdfa6dcc6bd..c3ccf44d27e 100644 --- a/app/models/individual_note_discussion.rb +++ b/app/models/individual_note_discussion.rb @@ -17,12 +17,8 @@ class IndividualNoteDiscussion < Discussion noteable.supports_replying_to_individual_notes? end - def convert_to_discussion!(save: false) - first_note.becomes!(Discussion.note_class).to_discussion.tap do - # Save needs to be called on first_note instead of the transformed note - # because of https://gitlab.com/gitlab-org/gitlab-foss/issues/57324 - first_note.save if save - end + def convert_to_discussion! + first_note.becomes!(Discussion.note_class).to_discussion end def reply_attributes diff --git a/app/models/issue.rb b/app/models/issue.rb index 619555f369d..a0003df87e1 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -30,6 +30,8 @@ class Issue < ApplicationRecord SORTING_PREFERENCE_FIELD = :issues_sort belongs_to :project + has_one :namespace, through: :project + belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' belongs_to :iteration, foreign_key: 'sprint_id' @@ -66,6 +68,12 @@ class Issue < ApplicationRecord accepts_nested_attributes_for :sentry_issue validates :project, presence: true + validates :issue_type, presence: true + + enum issue_type: { + issue: 0, + incident: 1 + } alias_attribute :parent_ids, :project_id alias_method :issuing_parent, :project @@ -87,11 +95,18 @@ class Issue < ApplicationRecord scope :order_created_at_desc, -> { reorder(created_at: :desc) } scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) } + scope :with_web_entity_associations, -> { preload(:author, :project) } scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) } scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) } scope :with_alert_management_alerts, -> { joins(:alert_management_alert) } scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) } scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) } + scope :with_api_entity_associations, -> { + preload(:timelogs, :closed_by, :assignees, :author, :notes, :labels, + milestone: { project: [:route, { namespace: :route }] }, + project: [:route, { namespace: :route }]) + } + scope :with_issue_type, ->(types) { where(issue_type: types) } scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } @@ -146,10 +161,6 @@ class Issue < ApplicationRecord issue.closed_at = nil issue.closed_by = nil end - - after_transition any => :closed do |issue| - issue.resolve_associated_alert_management_alert - end end # Alias to state machine .with_state_id method @@ -363,18 +374,6 @@ class Issue < ApplicationRecord @design_collection ||= ::DesignManagement::DesignCollection.new(self) end - def resolve_associated_alert_management_alert - return unless alert_management_alert - return if alert_management_alert.resolve - - Gitlab::AppLogger.warn( - message: 'Cannot resolve an associated Alert Management alert', - issue_id: id, - alert_id: alert_management_alert.id, - alert_errors: alert_management_alert.errors.messages - ) - end - def from_service_desk? author.id == User.support_bot.id end diff --git a/app/models/iteration.rb b/app/models/iteration.rb index 0b59cf047f7..3495f099064 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -4,6 +4,7 @@ class Iteration < ApplicationRecord self.table_name = 'sprints' attr_accessor :skip_future_date_validation + attr_accessor :skip_project_validation STATE_ENUM_MAP = { upcoming: 1, @@ -24,6 +25,7 @@ class Iteration < ApplicationRecord validate :dates_do_not_overlap, if: :start_or_due_dates_changed? validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation + validate :no_project, unless: :skip_project_validation scope :upcoming, -> { with_state(:upcoming) } scope :started, -> { with_state(:started) } @@ -113,6 +115,12 @@ class Iteration < ApplicationRecord errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now end end + + def no_project + return unless project_id.present? + + errors.add(:project_id, s_("is not allowed. We do not currently support project-level iterations")) + end end Iteration.prepend_if_ee('EE::Iteration') diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index 3761484b15d..d60baa299cb 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -5,6 +5,7 @@ class LfsObject < ApplicationRecord include Checksummable include EachBatch include ObjectStorage::BackgroundMove + include FileStoreMounter has_many :lfs_objects_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, -> { distinct }, through: :lfs_objects_projects @@ -15,21 +16,13 @@ class LfsObject < ApplicationRecord validates :oid, presence: true, uniqueness: true - mount_uploader :file, LfsObjectUploader - - after_save :update_file_store, if: :saved_change_to_file? + mount_file_store_uploader LfsObjectUploader def self.not_linked_to_project(project) where('NOT EXISTS (?)', project.lfs_objects_projects.select(1).where('lfs_objects_projects.lfs_object_id = lfs_objects.id')) end - def update_file_store - # The file.object_store is set during `uploader.store!` - # which happens after object is inserted/updated - self.update_column(:file_store, file.object_store) - end - def project_allowed_access?(project) if project.fork_network_member lfs_objects_projects diff --git a/app/models/member.rb b/app/models/member.rb index 36f9741ce01..2c62ea55785 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -86,6 +86,7 @@ class Member < ApplicationRecord scope :owners, -> { active.where(access_level: OWNER) } scope :owners_and_maintainers, -> { active.where(access_level: [OWNER, MAINTAINER]) } scope :with_user, -> (user) { where(user: user) } + scope :preload_user_and_notification_settings, -> { preload(user: :notification_settings) } scope :with_source_id, ->(source_id) { where(source_id: source_id) } scope :including_source, -> { includes(:source) } diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b7885771781..f4c2d568b4d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -40,7 +40,7 @@ class MergeRequest < ApplicationRecord has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) } has_many :merge_request_diffs - has_many :merge_request_context_commits + has_many :merge_request_context_commits, inverse_of: :merge_request has_many :merge_request_context_commit_diff_files, through: :merge_request_context_commits, source: :diff_files has_one :merge_request_diff, @@ -251,17 +251,12 @@ class MergeRequest < ApplicationRecord end scope :join_project, -> { joins(:target_project) } scope :references_project, -> { references(:target_project) } - - PROJECT_ROUTE_AND_NAMESPACE_ROUTE = [ - target_project: [:route, { namespace: :route }], - source_project: [:route, { namespace: :route }] - ].freeze - scope :with_api_entity_associations, -> { - preload(:assignees, :author, :unresolved_notes, :labels, :milestone, - :timelogs, :latest_merge_request_diff, - *PROJECT_ROUTE_AND_NAMESPACE_ROUTE, - metrics: [:latest_closed_by, :merged_by]) + preload_routables + .preload(:assignees, :author, :unresolved_notes, :labels, :milestone, + :timelogs, :latest_merge_request_diff, + target_project: :project_feature, + metrics: [:latest_closed_by, :merged_by]) } scope :by_target_branch_wildcard, ->(wildcard_branch_name) do @@ -269,6 +264,14 @@ class MergeRequest < ApplicationRecord end scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } scope :preload_source_project, -> { preload(:source_project) } + scope :preload_target_project, -> { preload(:target_project) } + scope :preload_routables, -> do + preload(target_project: [:route, { namespace: :route }], + source_project: [:route, { namespace: :route }]) + end + scope :preload_author, -> { preload(:author) } + scope :preload_approved_by_users, -> { preload(:approved_by_users) } + scope :preload_metrics, -> (relation) { preload(metrics: relation) } scope :with_auto_merge_enabled, -> do with_state(:opened).where(auto_merge_enabled: true) @@ -428,7 +431,7 @@ class MergeRequest < ApplicationRecord end def context_commits(limit: nil) - @context_commits ||= merge_request_context_commits.limit(limit).map(&:to_commit) + @context_commits ||= merge_request_context_commits.order_by_committed_date_desc.limit(limit).map(&:to_commit) end def recent_context_commits @@ -1181,12 +1184,12 @@ class MergeRequest < ApplicationRecord end def can_be_merged_by?(user) - access = ::Gitlab::UserAccess.new(user, project: project) + access = ::Gitlab::UserAccess.new(user, container: project) access.can_update_branch?(target_branch) end def can_be_merged_via_command_line_by?(user) - access = ::Gitlab::UserAccess.new(user, project: project) + access = ::Gitlab::UserAccess.new(user, container: project) access.can_push_to_branch?(target_branch) end @@ -1608,7 +1611,12 @@ class MergeRequest < ApplicationRecord override :ensure_metrics def ensure_metrics - MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id).tap do |metrics_record| + # Backward compatibility: some merge request metrics records will not have target_project_id filled in. + # In that case the first `safe_find_or_create_by` will return false. + # The second finder call will be eliminated in https://gitlab.com/gitlab-org/gitlab/-/issues/233507 + metrics_record = MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id, target_project_id: target_project_id) || MergeRequest::Metrics.safe_find_or_create_by(merge_request_id: id) + + metrics_record.tap do |metrics_record| # Make sure we refresh the loaded association object with the newly created/loaded item. # This is needed in order to have the exact functionality than before. # @@ -1618,6 +1626,8 @@ class MergeRequest < ApplicationRecord # merge_request.ensure_metrics # merge_request.metrics # should return the metrics record and not nil # merge_request.metrics.merge_request # should return the same MR record + + metrics_record.target_project_id = target_project_id metrics_record.association(:merge_request).target = self association(:metrics).target = metrics_record end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index ba363019c72..66bff3f5982 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,10 +1,21 @@ # frozen_string_literal: true class MergeRequest::Metrics < ApplicationRecord - belongs_to :merge_request + belongs_to :merge_request, inverse_of: :metrics belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :latest_closed_by, class_name: 'User' belongs_to :merged_by, class_name: 'User' + + before_save :ensure_target_project_id + + scope :merged_after, ->(date) { where(arel_table[:merged_at].gteq(date)) } + scope :merged_before, ->(date) { where(arel_table[:merged_at].lteq(date)) } + + private + + def ensure_target_project_id + self.target_project_id ||= merge_request.target_project_id + end end MergeRequest::Metrics.prepend_if_ee('EE::MergeRequest::Metrics') diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index de97fc33f8d..a2982a5dd73 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -12,6 +12,9 @@ class MergeRequestContextCommit < ApplicationRecord validates :sha, presence: true validates :sha, uniqueness: { message: 'has already been added' } + # Sort by committed date in descending order to ensure latest commits comes on the top + scope :order_by_committed_date_desc, -> { order('committed_date DESC') } + # delete all MergeRequestContextCommit & MergeRequestContextCommitDiffFile for given merge_request & commit SHAs def self.delete_bulk(merge_request, commits) commit_ids = commits.map(&:sha) diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index eb5250d5cf6..b70340a98cd 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -51,14 +51,16 @@ class MergeRequestDiff < ApplicationRecord scope :by_commit_sha, ->(sha) do joins(:merge_request_diff_commits).where(merge_request_diff_commits: { sha: sha }).reorder(nil) end - scope :has_diff_files, -> { where(id: MergeRequestDiffFile.select(:merge_request_diff_id)) } scope :by_project_id, -> (project_id) do joins(:merge_request).where(merge_requests: { target_project_id: project_id }) end scope :recent, -> { order(id: :desc).limit(100) } - scope :files_in_database, -> { has_diff_files.where(stored_externally: [false, nil]) } + + scope :files_in_database, -> do + where(stored_externally: [false, nil]).where(arel_table[:files_count].gt(0)) + end scope :not_latest_diffs, -> do merge_requests = MergeRequest.arel_table @@ -100,14 +102,25 @@ class MergeRequestDiff < ApplicationRecord joins(merge_request: :metrics).where(condition) end - def self.ids_for_external_storage_migration(limit:) - # No point doing any work unless the feature is enabled - return [] unless Gitlab.config.external_diffs.enabled + class << self + def ids_for_external_storage_migration(limit:) + return [] unless Gitlab.config.external_diffs.enabled - case Gitlab.config.external_diffs.when - when 'always' + case Gitlab.config.external_diffs.when + when 'always' + ids_for_external_storage_migration_strategy_always(limit: limit) + when 'outdated' + ids_for_external_storage_migration_strategy_outdated(limit: limit) + else + [] + end + end + + def ids_for_external_storage_migration_strategy_always(limit:) files_in_database.limit(limit).pluck(:id) - when 'outdated' + end + + def ids_for_external_storage_migration_strategy_outdated(limit:) # Outdated is too complex to be a single SQL query, so split into three before = EXTERNAL_DIFF_CUTOFF.ago @@ -129,8 +142,6 @@ class MergeRequestDiff < ApplicationRecord .not_latest_diffs .limit(limit - ids.size) .pluck(:id) - else - [] end end @@ -139,6 +150,7 @@ class MergeRequestDiff < ApplicationRecord # All diff information is collected from repository after object is created. # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? + after_create :set_count_columns after_create_commit :set_as_latest_diff, unless: :importing? after_save :update_external_diff_store @@ -621,7 +633,7 @@ class MergeRequestDiff < ApplicationRecord def save_diffs new_attributes = {} - if compare.commits.size.zero? + if compare.commits.empty? new_attributes[:state] = :empty else diff_collection = compare.diffs(Commit.max_diff_options) @@ -632,6 +644,7 @@ class MergeRequestDiff < ApplicationRecord rows = build_merge_request_diff_files(diff_collection) create_merge_request_diff_files(rows) + self.class.uncached { merge_request_diff_files.reset } end # Set our state to 'overflow' to make the #empty? and #collected? @@ -647,12 +660,14 @@ class MergeRequestDiff < ApplicationRecord def save_commits MergeRequestDiffCommit.create_bulk(self.id, compare.commits.reverse) + self.class.uncached { merge_request_diff_commits.reset } + end - # merge_request_diff_commits.reset is preferred way to reload associated - # objects but it returns cached result for some reason in this case - # we can circumvent that by specifying that we need an uncached reload - commits = self.class.uncached { merge_request_diff_commits.reset } - self.commits_count = commits.size + def set_count_columns + update_columns( + commits_count: merge_request_diff_commits.size, + files_count: merge_request_diff_files.size + ) end def repository diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 58adfd5f70b..55326b9a282 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -127,7 +127,7 @@ class Milestone < ApplicationRecord end def can_be_closed? - active? && issues.opened.count.zero? + active? && issues.opened.count == 0 end def author_id diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 6b5ea0fc3fc..9da454125eb 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -211,7 +211,7 @@ module Network # Visit branching chains leaves.each do |l| - parents = l.parents(@map).select {|p| p.space.zero?} + parents = l.parents(@map).select {|p| p.space == 0} parents.each do |p| place_chain(p, l.time) end @@ -266,14 +266,14 @@ module Network def take_left_leaves(raw_commit) commit = @map[raw_commit.id] leaves = [] - leaves.push(commit) if commit.space.zero? + leaves.push(commit) if commit.space == 0 loop do - return leaves if commit.parents(@map).count.zero? + return leaves if commit.parents(@map).count == 0 commit = commit.parents(@map).first - return leaves unless commit.space.zero? + return leaves unless commit.space == 0 leaves.push(commit) end diff --git a/app/models/note.rb b/app/models/note.rb index 2db7e4e406d..e1fc16818b3 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -61,7 +61,7 @@ class Note < ApplicationRecord attr_accessor :commands_changes # A special role that may be displayed on issuable's discussions - attr_accessor :special_role + attr_reader :special_role default_value_for :system, false @@ -417,7 +417,7 @@ class Note < ApplicationRecord end def can_create_todo? - # Skip system notes, and notes on project snippet + # Skip system notes, and notes on snippets !system? && !for_snippet? end @@ -559,6 +559,10 @@ class Note < ApplicationRecord (!system_note_with_references? || all_referenced_mentionables_allowed?(user)) && system_note_viewable_by?(user) end + def parent_user + noteable.author if for_personal_snippet? + end + private # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception diff --git a/app/models/notification_recipient.rb b/app/models/notification_recipient.rb index 36b7cd64c73..6a6b2bb1b58 100644 --- a/app/models/notification_recipient.rb +++ b/app/models/notification_recipient.rb @@ -52,10 +52,9 @@ class NotificationRecipient when :mention @type == :mention when :participating - %i[failed_pipeline fixed_pipeline].include?(@custom_action) || - %i[participating mention].include?(@type) + participating_custom_action? || participating_or_mention? when :custom - custom_enabled? || %i[participating mention].include?(@type) + custom_enabled? || participating_or_mention? when :watch !excluded_watcher_action? else @@ -175,4 +174,12 @@ class NotificationRecipient .where.not(level: NotificationSetting.levels[:global]) .first end + + def participating_custom_action? + %i[failed_pipeline fixed_pipeline moved_project].include?(@custom_action) + end + + def participating_or_mention? + %i[participating mention].include?(@type) + end end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index c8c1f47c182..c003a20f0fc 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -46,7 +46,8 @@ class NotificationSetting < ApplicationRecord :merge_merge_request, :failed_pipeline, :fixed_pipeline, - :success_pipeline + :success_pipeline, + :moved_project ].freeze # Update unfound_translations.rb when events are changed @@ -96,7 +97,11 @@ class NotificationSetting < ApplicationRecord alias_method :fixed_pipeline?, :fixed_pipeline def event_enabled?(event) - respond_to?(event) && !!public_send(event) # rubocop:disable GitlabSecurity/PublicSend + # We override these two attributes, so we can't use read_attribute + return failed_pipeline if event.to_sym == :failed_pipeline + return fixed_pipeline if event.to_sym == :fixed_pipeline + + has_attribute?(event) && !!read_attribute(event) end def owns_notification_email diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 567b5a14603..4ebd96797db 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -15,6 +15,8 @@ class Packages::PackageFile < ApplicationRecord validates :file, presence: true validates :file_name, presence: true + validates :file_name, uniqueness: { scope: :package }, if: -> { package&.pypi? } + scope :recent, -> { order(id: :desc) } scope :with_file_name, ->(file_name) { where(file_name: file_name) } scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) } @@ -37,20 +39,27 @@ class Packages::PackageFile < ApplicationRecord update_project_statistics project_statistics_name: :packages_size + before_save :update_size_from_file + def update_file_metadata # The file.object_store is set during `uploader.store!` # which happens after object is inserted/updated self.update_column(:file_store, file.object_store) - self.update_column(:size, file.size) unless file.size == self.size end def download_path - Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) if ::Gitlab.ee? + Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) end def local? file_store == ::Packages::PackageFileUploader::Store::LOCAL end + + private + + def update_size_from_file + self.size ||= file.size + end end -Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFileGeo') +Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFile') diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 856496f0941..d071d2d3c89 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -3,6 +3,7 @@ class PagesDomain < ApplicationRecord include Presentable include FromUnion + include AfterCommitQueue VERIFICATION_KEY = 'gitlab-pages-verification-code' VERIFICATION_THRESHOLD = 3.days.freeze @@ -222,6 +223,8 @@ class PagesDomain < ApplicationRecord private def pages_deployed? + return false unless project + # TODO: remove once `pages_metadatum` is migrated # https://gitlab.com/gitlab-org/gitlab/issues/33106 unless project.pages_metadatum @@ -244,8 +247,13 @@ class PagesDomain < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def update_daemon return if usage_serverless? + return unless pages_deployed? - ::Projects::UpdatePagesConfigurationService.new(project).execute + if Feature.enabled?(:async_update_pages_config, project) + run_after_commit { PagesUpdateConfigurationWorker.perform_async(project_id) } + else + Projects::UpdatePagesConfigurationService.new(project).execute + end end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 488ebd531a8..e01cb0530a5 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -19,6 +19,7 @@ class PersonalAccessToken < ApplicationRecord scope :active, -> { where("revoked = false AND (expires_at >= CURRENT_DATE OR expires_at IS NULL)") } scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) } + scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) } scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") } scope :with_impersonation, -> { where(impersonation: true) } scope :without_impersonation, -> { where(impersonation: false) } diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb index 197795dccfe..0915278fb65 100644 --- a/app/models/personal_snippet.rb +++ b/app/models/personal_snippet.rb @@ -3,6 +3,10 @@ class PersonalSnippet < Snippet include WithUploads + def parent_user + author + end + def skip_project_check? true end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index 7a123deb719..a4370eda5ba 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -33,7 +33,7 @@ module Postgresql # If too many replicas are falling behind too much, the availability of a # GitLab instance might suffer. To prevent this from happening we require # at least 1 replica to have data recent enough. - if sizes.any? && too_great.positive? + if sizes.any? && too_great > 0 (sizes.length - too_great) <= 1 else false diff --git a/app/models/product_analytics_event.rb b/app/models/product_analytics_event.rb index 95a2e7a26c4..579ea88c272 100644 --- a/app/models/product_analytics_event.rb +++ b/app/models/product_analytics_event.rb @@ -19,4 +19,12 @@ class ProductAnalyticsEvent < ApplicationRecord scope :timerange, ->(duration, today = Time.zone.today) { where('collector_tstamp BETWEEN ? AND ? ', today - duration + 1, today + 1) } + + def self.count_by_graph(graph, days) + group(graph).timerange(days).count + end + + def as_json_wo_empty + as_json.compact + end end diff --git a/app/models/project.rb b/app/models/project.rb index 3aa0db56404..e1b6a9c41dd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -109,7 +109,6 @@ class Project < ApplicationRecord after_update :update_forks_visibility_level before_destroy :remove_private_deploy_keys - before_destroy :cleanup_chat_names use_fast_destroy :build_trace_chunks @@ -168,7 +167,6 @@ class Project < ApplicationRecord has_one :youtrack_service has_one :custom_issue_tracker_service has_one :bugzilla_service - has_one :gitlab_issue_tracker_service, inverse_of: :project has_one :confluence_service has_one :external_wiki_service has_one :prometheus_service, inverse_of: :project @@ -261,6 +259,7 @@ class Project < ApplicationRecord has_many :clusters, through: :cluster_project, class_name: 'Clusters::Cluster' has_many :kubernetes_namespaces, class_name: 'Clusters::KubernetesNamespace' has_many :management_clusters, class_name: 'Clusters::Cluster', foreign_key: :management_project_id, inverse_of: :management_project + has_many :cluster_agents, class_name: 'Clusters::Agent' has_many :prometheus_metrics has_many :prometheus_alerts, inverse_of: :project @@ -300,6 +299,7 @@ class Project < ApplicationRecord has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project has_many :job_artifacts, class_name: 'Ci::JobArtifact' + has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, class_name: 'Ci::Variable' @@ -339,6 +339,10 @@ class Project < ApplicationRecord has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project has_many :reviews, inverse_of: :project + # Can be too many records. We need to implement delete_all in batches. + # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/228637 + has_many :product_analytics_events, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :project_setting, update_only: true @@ -450,6 +454,16 @@ class Project < ApplicationRecord # Sometimes queries (e.g. using CTEs) require explicit disambiguation with table name scope :projects_order_id_desc, -> { reorder(self.arel_table['id'].desc) } + scope :sorted_by_similarity_desc, -> (search) do + order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [ + { column: arel_table["path"], multiplier: 1 }, + { column: arel_table["name"], multiplier: 0.7 }, + { column: arel_table["description"], multiplier: 0.2 } + ]) + + reorder(order_expression.desc, arel_table['id'].desc) + end + scope :with_packages, -> { joins(:packages) } scope :in_namespace, ->(namespace_ids) { where(namespace_id: namespace_ids) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } @@ -637,6 +651,8 @@ class Project < ApplicationRecord scope :joins_import_state, -> { joins("INNER JOIN project_mirror_data import_state ON import_state.project_id = projects.id") } scope :for_group, -> (group) { where(group: group) } scope :for_group_and_its_subgroups, ->(group) { where(namespace_id: group.self_and_descendants.select(:id)) } + scope :for_repository_storage, -> (repository_storage) { where(repository_storage: repository_storage) } + scope :excluding_repository_storage, -> (repository_storage) { where.not(repository_storage: repository_storage) } class << self # Searches for a list of projects based on the query given in `query`. @@ -838,6 +854,10 @@ class Project < ApplicationRecord auto_devops_config[:scope] != :project && !auto_devops_config[:status] end + def has_packages?(package_type) + packages.where(package_type: package_type).exists? + end + def first_auto_devops_config return namespace.first_auto_devops_config if auto_devops&.enabled.nil? @@ -1103,7 +1123,7 @@ class Project < ApplicationRecord limit = creator.projects_limit error = - if limit.zero? + if limit == 0 _('Personal project creation is not allowed. Please contact your administrator with questions') else _('Your project limit is %{limit} projects! Please contact your administrator to increase it') @@ -1375,6 +1395,16 @@ class Project < ApplicationRecord group || namespace.try(:owner) end + def default_owner + obj = owner + + if obj.respond_to?(:default_owner) + obj.default_owner + else + obj + end + end + def to_ability_name model_name.singular end @@ -1725,7 +1755,7 @@ class Project < ApplicationRecord end def pages_deployed? - Dir.exist?(public_pages_path) + pages_metadatum&.deployed? end def pages_group_url @@ -1758,10 +1788,6 @@ class Project < ApplicationRecord File.join(Settings.pages.path, full_path) end - def public_pages_path - File.join(pages_path, 'public') - end - def pages_available? Gitlab.config.pages.enabled end @@ -1788,7 +1814,6 @@ class Project < ApplicationRecord return unless namespace mark_pages_as_not_deployed unless destroyed? - ::Projects::UpdatePagesConfigurationService.new(self).execute # 1. We rename pages to temporary directory # 2. We wait 5 minutes, due to NFS caching @@ -1926,17 +1951,6 @@ class Project < ApplicationRecord import_export_upload&.export_file end - # Before 12.9 we did not correctly clean up chat names and this causes issues. - # In 12.9, we add a foreign key relationship, but this code is used ensure the chat names are cleaned up while a post - # migration enables the foreign key relationship. - # - # This should be removed in 13.0. - # - # https://gitlab.com/gitlab-org/gitlab/issues/204787 - def cleanup_chat_names - ChatName.where(service: services.select(:id)).delete_all - end - def full_path_slug Gitlab::Utils.slugify(full_path.to_s) end @@ -2466,6 +2480,10 @@ class Project < ApplicationRecord alias_method :service_desk_enabled?, :service_desk_enabled def service_desk_address + service_desk_custom_address || service_desk_incoming_address + end + + def service_desk_incoming_address return unless service_desk_enabled? config = Gitlab.config.incoming_email @@ -2474,6 +2492,16 @@ class Project < ApplicationRecord config.address&.gsub(wildcard, "#{full_path_slug}-#{id}-issue-") end + def service_desk_custom_address + return unless ::Gitlab::ServiceDeskEmail.enabled? + return unless ::Feature.enabled?(:service_desk_custom_address, self) + + key = service_desk_setting&.project_key + return unless key.present? + + ::Gitlab::ServiceDeskEmail.address_for_key("#{full_path_slug}-#{key}") + end + def root_namespace if namespace.has_parent? namespace.root_ancestor @@ -2578,6 +2606,8 @@ class Project < ApplicationRecord namespace != from.namespace when Namespace namespace != from + when User + true end end diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb index b18d9765a57..2b74d9ccd88 100644 --- a/app/models/project_repository_storage_move.rb +++ b/app/models/project_repository_storage_move.rb @@ -29,12 +29,17 @@ class ProjectRepositoryStorageMove < ApplicationRecord transition scheduled: :started end - event :finish do - transition started: :finished + event :finish_replication do + transition started: :replicated + end + + event :finish_cleanup do + transition replicated: :finished end event :do_fail do transition [:initial, :scheduled, :started] => :failed + transition replicated: :cleanup_failed end after_transition initial: :scheduled do |storage_move| @@ -49,7 +54,7 @@ class ProjectRepositoryStorageMove < ApplicationRecord end end - after_transition started: :finished do |storage_move| + after_transition started: :replicated do |storage_move| storage_move.project.update_columns( repository_read_only: false, repository_storage: storage_move.destination_storage_name @@ -65,6 +70,8 @@ class ProjectRepositoryStorageMove < ApplicationRecord state :started, value: 3 state :finished, value: 4 state :failed, value: 5 + state :replicated, value: 6 + state :cleanup_failed, value: 7 end scope :order_created_at_desc, -> { order(created_at: :desc) } diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index fc7a0180786..53bb7b47b41 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -8,13 +8,32 @@ class BuildkiteService < CiService ENDPOINT = "https://buildkite.com" prop_accessor :project_url, :token - boolean_accessor :enable_ssl_verification validates :project_url, presence: true, public_url: true, if: :activated? validates :token, presence: true, if: :activated? after_save :compose_service_hook, if: :activated? + def self.supported_events + %w(push merge_request tag_push) + end + + # This is a stub method to work with deprecated API response + # TODO: remove enable_ssl_verification after 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 + def enable_ssl_verification + true + end + + # Since SSL verification will always be enabled for Buildkite, + # we no longer needs to store the boolean. + # This is a stub method to work with deprecated API param. + # TODO: remove enable_ssl_verification after 14.0 + # https://gitlab.com/gitlab-org/gitlab/-/issues/222808 + def enable_ssl_verification=(_value) + self.properties.delete('enable_ssl_verification') # Remove unused key + end + def webhook_url "#{buildkite_endpoint('webhook')}/deliver/#{webhook_token}" end @@ -22,7 +41,7 @@ class BuildkiteService < CiService def compose_service_hook hook = service_hook || build_service_hook hook.url = webhook_url - hook.enable_ssl_verification = !!enable_ssl_verification + hook.enable_ssl_verification = true hook.save end @@ -49,7 +68,7 @@ class BuildkiteService < CiService end def description - 'Continuous integration and deployments' + 'Buildkite is a platform for running fast, secure, and scalable continuous integration pipelines on your own infrastructure' end def self.to_param @@ -60,15 +79,15 @@ class BuildkiteService < CiService [ { type: 'text', name: 'token', - placeholder: 'Buildkite project GitLab token', required: true }, + title: 'Integration Token', + help: 'This token will be provided when you create a Buildkite pipeline with a GitLab repository', + required: true }, { type: 'text', name: 'project_url', - placeholder: "#{ENDPOINT}/example/project", required: true }, - - { type: 'checkbox', - name: 'enable_ssl_verification', - title: "Enable SSL verification" } + title: 'Pipeline URL', + placeholder: "#{ENDPOINT}/acme-inc/test-pipeline", + required: true } ] end diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb deleted file mode 100644 index b3f44e040bc..00000000000 --- a/app/models/project_services/gitlab_issue_tracker_service.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -class GitlabIssueTrackerService < IssueTrackerService - include Gitlab::Routing - - validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - - default_value_for :default, true - - def title - 'GitLab' - end - - def description - s_('IssueTracker|GitLab issue tracker') - end - - def self.to_param - 'gitlab' - end - - def project_url - project_issues_url(project) - end - - def new_issue_url - new_project_issue_url(project) - end - - def issue_url(iid) - project_issue_url(project, id: iid) - end - - def issue_tracker_path - project_issues_path(project) - end - - def new_issue_path - new_project_issue_path(project) - end - - def issue_path(iid) - project_issue_path(project, id: iid) - end -end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 4ea2ec10f11..36d7026de30 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -8,6 +8,12 @@ class JiraService < IssueTrackerService PROJECTS_PER_PAGE = 50 + # TODO: use jira_service.deployment_type enum when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged + DEPLOYMENT_TYPES = { + server: 'SERVER', + cloud: 'CLOUD' + }.freeze + validates :url, public_url: true, presence: true, if: :activated? validates :api_url, public_url: true, allow_blank: true validates :username, presence: true, if: :activated? @@ -375,7 +381,6 @@ class JiraService < IssueTrackerService def build_entity_url(noteable_type, entity_id) polymorphic_url( [ - self.project.namespace.becomes(Namespace), self.project, noteable_type.to_sym ], diff --git a/app/models/project_services/jira_tracker_data.rb b/app/models/project_services/jira_tracker_data.rb index f24ba8877d2..00b6ab6a70f 100644 --- a/app/models/project_services/jira_tracker_data.rb +++ b/app/models/project_services/jira_tracker_data.rb @@ -7,4 +7,6 @@ class JiraTrackerData < ApplicationRecord attr_encrypted :api_url, encryption_options attr_encrypted :username, encryption_options attr_encrypted :password, encryption_options + + enum deployment_type: { unknown: 0, server: 1, cloud: 2 }, _prefix: :deployment end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb index 997c6eba91a..950cd4f6859 100644 --- a/app/models/project_services/prometheus_service.rb +++ b/app/models/project_services/prometheus_service.rb @@ -97,7 +97,13 @@ class PrometheusService < MonitoringService def prometheus_client return unless should_return_client? - options = { allow_local_requests: allow_local_api_url? } + options = { + allow_local_requests: allow_local_api_url?, + # We should choose more conservative timeouts, but some queries we run are now busting our + # default timeouts, which are stricter. We should make those queries faster instead. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/233109 + timeout: 60 + } if behind_iap? # Adds the Authorization header diff --git a/app/models/prometheus_alert.rb b/app/models/prometheus_alert.rb index 32f9809e538..f0441d4a3cb 100644 --- a/app/models/prometheus_alert.rb +++ b/app/models/prometheus_alert.rb @@ -3,6 +3,7 @@ class PrometheusAlert < ApplicationRecord include Sortable include UsageStatistics + include Presentable OPERATORS_MAP = { lt: "<", @@ -21,7 +22,9 @@ class PrometheusAlert < ApplicationRecord after_save :clear_prometheus_adapter_cache! after_destroy :clear_prometheus_adapter_cache! - validates :environment, :project, :prometheus_metric, presence: true + validates :environment, :project, :prometheus_metric, :threshold, :operator, presence: true + validates :runbook_url, length: { maximum: 255 }, allow_blank: true, + addressable_url: { enforce_sanitization: true, ascii_only: true } validate :require_valid_environment_project! validate :require_valid_metric_project! @@ -59,6 +62,9 @@ class PrometheusAlert < ApplicationRecord "gitlab" => "hook", "gitlab_alert_id" => prometheus_metric_id, "gitlab_prometheus_alert_id" => id + }, + "annotations" => { + "runbook" => runbook_url } } end diff --git a/app/models/raw_usage_data.rb b/app/models/raw_usage_data.rb new file mode 100644 index 00000000000..18cee55d06e --- /dev/null +++ b/app/models/raw_usage_data.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class RawUsageData < ApplicationRecord + validates :payload, presence: true + validates :recorded_at, presence: true, uniqueness: true + + def update_sent_at! + self.update_column(:sent_at, Time.current) if Feature.enabled?(:save_raw_usage_data) + end +end diff --git a/app/models/release.rb b/app/models/release.rb index a0245105cd9..4c9d89105d7 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -18,12 +18,10 @@ class Release < ApplicationRecord has_many :milestones, through: :milestone_releases has_many :evidences, inverse_of: :release, class_name: 'Releases::Evidence' - default_value_for :released_at, allows_nil: false do - Time.zone.now - end - accepts_nested_attributes_for :links, allow_destroy: true + before_create :set_released_at + validates :project, :tag, presence: true validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } @@ -90,6 +88,10 @@ class Release < ApplicationRecord repository.find_tag(tag) end end + + def set_released_at + self.released_at ||= created_at + end end Release.prepend_if_ee('EE::Release') diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index dc7e78a85a9..e1dc3b904b9 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -6,7 +6,7 @@ module Releases belongs_to :release - FILEPATH_REGEX = /\A\/([\-\.\w]+\/?)*[\da-zA-Z]+\z/.freeze + FILEPATH_REGEX = %r{\A/(?:[\-\.\w]+/?)*[\da-zA-Z]+\z}.freeze validates :url, presence: true, addressable_url: { schemes: %w(http https ftp) }, uniqueness: { scope: :release } validates :name, presence: true, uniqueness: { scope: :release } diff --git a/app/models/repository.rb b/app/models/repository.rb index 48e96d4c193..07122db36b3 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -43,7 +43,7 @@ class Repository gitlab_ci_yml branch_names tag_names branch_count tag_count avatar exists? root_ref merged_branch_names has_visible_content? issue_template_names merge_request_template_names - metrics_dashboard_paths xcode_project?).freeze + user_defined_metrics_dashboard_paths xcode_project? has_ambiguous_refs?).freeze # Methods that use cache_method but only memoize the value MEMOIZED_CACHED_METHODS = %i(license).freeze @@ -61,7 +61,7 @@ class Repository avatar: :avatar, issue_template: :issue_template_names, merge_request_template: :merge_request_template_names, - metrics_dashboard: :metrics_dashboard_paths, + metrics_dashboard: :user_defined_metrics_dashboard_paths, xcode_config: :xcode_project? }.freeze @@ -196,6 +196,32 @@ class Repository tag_exists?(ref) && branch_exists?(ref) end + # It's possible for a tag name to be a prefix (including slash) of a branch + # name, or vice versa. For instance, a tag named `foo` means we can't create a + # tag `foo/bar`, but we _can_ create a branch `foo/bar`. + # + # If we know a repository has no refs of this type (which is the common case) + # then separating refs from paths - as in ExtractsRef - can be faster. + # + # This method only checks one level deep, so only prefixes that contain no + # slashes are considered. If a repository has a tag `foo/bar` and a branch + # `foo/bar/baz`, it will return false. + def has_ambiguous_refs? + return false unless branch_names.present? && tag_names.present? + + with_slash, no_slash = (branch_names + tag_names).partition { |ref| ref.include?('/') } + + return false if with_slash.empty? + + prefixes = no_slash.map { |ref| Regexp.escape(ref) }.join('|') + prefix_regex = %r{^#{prefixes}/} + + with_slash.any? do |ref| + prefix_regex.match?(ref) + end + end + cache_method :has_ambiguous_refs? + def expand_ref(ref) if tag_exists?(ref) Gitlab::Git::TAG_REF_PREFIX + ref @@ -286,14 +312,16 @@ class Repository end def expire_tags_cache - expire_method_caches(%i(tag_names tag_count)) + expire_method_caches(%i(tag_names tag_count has_ambiguous_refs?)) @tags = nil + @tag_names_include = nil end def expire_branches_cache - expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content?)) + expire_method_caches(%i(branch_names merged_branch_names branch_count has_visible_content? has_ambiguous_refs?)) @local_branches = nil @branch_exists_memo = nil + @branch_names_include = nil end def expire_statistics_caches @@ -576,10 +604,10 @@ class Repository end cache_method :merge_request_template_names, fallback: [] - def metrics_dashboard_paths - Gitlab::Metrics::Dashboard::Finder.find_all_paths_from_source(project) + def user_defined_metrics_dashboard_paths + Gitlab::Metrics::Dashboard::RepoDashboardFinder.list_dashboards(project) end - cache_method :metrics_dashboard_paths + cache_method :user_defined_metrics_dashboard_paths, fallback: [] def readme head_tree&.readme @@ -852,7 +880,7 @@ class Repository def revert( user, commit, branch_name, message, - start_branch_name: nil, start_project: project) + start_branch_name: nil, start_project: project, dry_run: false) with_cache_hooks do raw_repository.revert( @@ -861,14 +889,15 @@ class Repository branch_name: branch_name, message: message, start_branch_name: start_branch_name, - start_repository: start_project.repository.raw_repository + start_repository: start_project.repository.raw_repository, + dry_run: dry_run ) end end def cherry_pick( user, commit, branch_name, message, - start_branch_name: nil, start_project: project) + start_branch_name: nil, start_project: project, dry_run: false) with_cache_hooks do raw_repository.cherry_pick( @@ -877,7 +906,8 @@ class Repository branch_name: branch_name, message: message, start_branch_name: start_branch_name, - start_repository: start_project.repository.raw_repository + start_repository: start_project.repository.raw_repository, + dry_run: dry_run ) end end diff --git a/app/models/resource_iteration_event.rb b/app/models/resource_iteration_event.rb new file mode 100644 index 00000000000..78d85ea8b95 --- /dev/null +++ b/app/models/resource_iteration_event.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ResourceIterationEvent < ResourceTimeboxEvent + belongs_to :iteration +end diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index 36068cf508b..5fd71612de0 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -1,30 +1,17 @@ # frozen_string_literal: true -class ResourceMilestoneEvent < ResourceEvent +class ResourceMilestoneEvent < ResourceTimeboxEvent include IgnorableColumns - include IssueResourceEvent - include MergeRequestResourceEvent belongs_to :milestone - validate :exactly_one_issuable - scope :include_relations, -> { includes(:user, milestone: [:project, :group]) } - enum action: { - add: 1, - remove: 2 - } - # state is used for issue and merge request states. enum state: Issue.available_states.merge(MergeRequest.available_states) ignore_columns %i[reference reference_html cached_markdown_version], remove_with: '13.1', remove_after: '2020-06-22' - def self.issuable_attrs - %i(issue merge_request).freeze - end - def milestone_title milestone&.title end @@ -32,8 +19,4 @@ class ResourceMilestoneEvent < ResourceEvent def milestone_parent milestone&.parent end - - def issuable - issue || merge_request - end end diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb new file mode 100644 index 00000000000..44f48915425 --- /dev/null +++ b/app/models/resource_timebox_event.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class ResourceTimeboxEvent < ResourceEvent + self.abstract_class = true + + include IssueResourceEvent + include MergeRequestResourceEvent + + validate :exactly_one_issuable + + enum action: { + add: 1, + remove: 2 + } + + def self.issuable_attrs + %i(issue merge_request).freeze + end + + def issuable + issue || merge_request + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 89bde61bfe1..40e7e5552d1 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -10,6 +10,7 @@ class Service < ApplicationRecord include IgnorableColumns ignore_columns %i[title description], remove_with: '13.4', remove_after: '2020-09-22' + ignore_columns %i[default], remove_with: '13.5', remove_after: '2020-10-22' SERVICE_NAMES = %w[ alerts asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord @@ -47,19 +48,20 @@ class Service < ApplicationRecord belongs_to :project, inverse_of: :services has_one :service_hook - validates :project_id, presence: true, unless: -> { template? || instance? } - validates :project_id, absence: true, if: -> { template? || instance? } - validates :type, uniqueness: { scope: :project_id }, unless: -> { template? || instance? }, on: :create + validates :project_id, presence: true, unless: -> { template? || instance? || group_id } + validates :group_id, presence: true, unless: -> { template? || instance? || project_id } + validates :project_id, :group_id, absence: true, if: -> { template? || instance? } + validates :type, uniqueness: { scope: :project_id }, unless: -> { template? || instance? || group_id }, on: :create + validates :type, uniqueness: { scope: :group_id }, unless: -> { template? || instance? || project_id } validates :type, presence: true validates :template, uniqueness: { scope: :type }, if: -> { template? } validates :instance, uniqueness: { scope: :type }, if: -> { instance? } validate :validate_is_instance_or_template + validate :validate_belongs_to_project_or_group - scope :visible, -> { where.not(type: 'GitlabIssueTrackerService') } - scope :issue_trackers, -> { where(category: 'issue_tracker') } + scope :external_issue_trackers, -> { where(category: 'issue_tracker').active } scope :external_wikis, -> { where(type: 'ExternalWikiService').active } scope :active, -> { where(active: true) } - scope :without_defaults, -> { where(default: false) } scope :by_type, -> (type) { where(type: type) } scope :by_active_flag, -> (flag) { where(active: flag) } scope :templates, -> { where(template: true, type: available_services_types) } @@ -77,7 +79,6 @@ class Service < ApplicationRecord scope :wiki_page_hooks, -> { where(wiki_page_events: true, active: true) } scope :deployment_hooks, -> { where(deployment_events: true, active: true) } scope :alert_hooks, -> { where(alert_events: true, active: true) } - scope :external_issue_trackers, -> { issue_trackers.active.without_defaults } scope :deployment, -> { where(category: 'deployment') } default_value_for :category, 'common' @@ -379,6 +380,10 @@ class Service < ApplicationRecord errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance? end + def validate_belongs_to_project_or_group + errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_id && group_id + end + def cache_project_has_external_issue_tracker if project && !project.destroyed? project.cache_has_external_issue_tracker diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb index 94f3a140098..8c72bd5ae7e 100644 --- a/app/models/suggestion.rb +++ b/app/models/suggestion.rb @@ -43,12 +43,12 @@ class Suggestion < ApplicationRecord def inapplicable_reason(cached: true) strong_memoize("inapplicable_reason_#{cached}") do - next :applied if applied? - next :merge_request_merged if noteable.merged? - next :merge_request_closed if noteable.closed? - next :source_branch_deleted unless noteable.source_branch_exists? - next :outdated if outdated?(cached: cached) || !note.active? - next :same_content unless different_content? + next _("Can't apply this suggestion.") if applied? + next _("This merge request was merged. To apply this suggestion, edit this file directly.") if noteable.merged? + next _("This merge request is closed. To apply this suggestion, edit this file directly.") if noteable.closed? + next _("Can't apply as the source branch was deleted.") unless noteable.source_branch_exists? + next outdated_reason if outdated?(cached: cached) || !note.active? + next _("This suggestion already matches its content.") unless different_content? end end @@ -61,7 +61,7 @@ class Suggestion < ApplicationRecord end def single_line? - lines_above.zero? && lines_below.zero? + lines_above == 0 && lines_below == 0 end def target_line @@ -73,4 +73,12 @@ class Suggestion < ApplicationRecord def different_content? from_content != to_content end + + def outdated_reason + if single_line? + _("Can't apply as this line was changed in a more recent version.") + else + _("Can't apply as these lines were changed in a more recent version.") + end + end end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 6ed074b2190..c50b9da1310 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -3,6 +3,7 @@ module Terraform class State < ApplicationRecord include UsageStatistics + include FileStoreMounter DEFAULT = '{"version":1}'.freeze HEX_REGEXP = %r{\A\h+\z}.freeze @@ -17,24 +18,22 @@ module Terraform default_value_for(:uuid, allows_nil: false) { SecureRandom.hex(UUID_LENGTH / 2) } - after_save :update_file_store, if: :saved_change_to_file? - - mount_uploader :file, StateUploader + mount_file_store_uploader StateUploader default_value_for(:file) { CarrierWaveStringFile.new(DEFAULT) } - def update_file_store - # The file.object_store is set during `uploader.store!` - # which happens after object is inserted/updated - self.update_column(:file_store, file.object_store) - end - def file_store super || StateUploader.default_store end + def local? + file_store == ObjectStorage::Store::LOCAL + end + def locked? self.lock_xid.present? end end end + +Terraform::State.prepend_if_ee('EE::Terraform::State') diff --git a/app/models/user.rb b/app/models/user.rb index 643b759e6f4..1a67116c1f2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -104,6 +104,7 @@ class User < ApplicationRecord # Profile has_many :keys, -> { regular_keys }, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :deploy_keys, -> { where(type: 'DeployKey') }, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent + has_many :group_deploy_keys has_many :gpg_keys has_many :emails, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -350,11 +351,25 @@ class User < ApplicationRecord .without_impersonation .expiring_and_not_notified(at).select(1)) end + scope :with_personal_access_tokens_expired_today, -> do + where('EXISTS (?)', + ::PersonalAccessToken + .select(1) + .where('personal_access_tokens.user_id = users.id') + .without_impersonation + .expired_today_and_not_notified) + end scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) } scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) } scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } + def preferred_language + read_attribute('preferred_language') || + I18n.default_locale.to_s.presence_in(Gitlab::I18n::AVAILABLE_LANGUAGES.keys) || + 'en' + end + def active_for_authentication? super && can?(:log_in) end @@ -948,7 +963,7 @@ class User < ApplicationRecord def require_ssh_key? count = Users::KeysCountService.new(self).count - count.zero? && Gitlab::ProtocolAccess.allowed?('ssh') + count == 0 && Gitlab::ProtocolAccess.allowed?('ssh') end # rubocop: enable CodeReuse/ServiceClass diff --git a/app/models/user_callout_enums.rb b/app/models/user_callout_enums.rb index 226c8cd9ab5..5b64befd284 100644 --- a/app/models/user_callout_enums.rb +++ b/app/models/user_callout_enums.rb @@ -18,7 +18,9 @@ module UserCalloutEnums tabs_position_highlight: 10, webhooks_moved: 13, admin_integrations_moved: 15, - personal_access_token_expiry: 21 # EE-only + personal_access_token_expiry: 21, # EE-only + suggest_pipeline: 22, + customize_homepage: 23 } end end diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 4c497cc304c..30273d646cf 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -35,6 +35,7 @@ class Wiki def initialize(container, user = nil) @container = container @user = user + raise ArgumentError, "user must be a User, got #{user.class}" if user && !user.is_a?(User) end def path diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 3dc90edb331..faf3d19d936 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -65,6 +65,7 @@ class WikiPage validates :title, presence: true validates :content, presence: true validate :validate_path_limits, if: :title_changed? + validate :validate_content_size_limit, if: :content_changed? # The GitLab Wiki instance. attr_reader :wiki @@ -97,6 +98,7 @@ class WikiPage def slug attributes[:slug].presence || wiki.wiki.preview_slug(title, format) end + alias_method :id, :slug # required to use build_stubbed alias_method :to_param, :slug @@ -264,8 +266,8 @@ class WikiPage '../shared/wikis/wiki_page' end - def id - page.version.to_s + def sha + page.version&.sha end def title_changed? @@ -282,6 +284,17 @@ class WikiPage end end + def content_changed? + if persisted? + # gollum-lib always converts CRLFs to LFs in Gollum::Wiki#normalize, + # so we need to do the same here. + # Also see https://gitlab.com/gitlab-org/gitlab/-/issues/21431 + raw_content.delete("\r") != page&.text_data + else + raw_content.present? + end + end + # Updates the current @attributes hash by merging a hash of params def update_attributes(attrs) attrs[:title] = process_title(attrs[:title]) if attrs[:title].present? @@ -391,4 +404,15 @@ class WikiPage }) end end + + def validate_content_size_limit + current_value = raw_content.to_s.bytesize + max_size = Gitlab::CurrentSettings.wiki_page_max_content_bytes + return if current_value <= max_size + + errors.add(:content, _('is too long (%{current_value}). The maximum size is %{max_size}.') % { + current_value: ActiveSupport::NumberHelper.number_to_human_size(current_value), + max_size: ActiveSupport::NumberHelper.number_to_human_size(max_size) + }) + end end diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index 0879a740f8a..cc66ad0577d 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -3,7 +3,7 @@ module Ci class BuildPolicy < CommitStatusPolicy condition(:protected_ref) do - access = ::Gitlab::UserAccess.new(@user, project: @subject.project) + access = ::Gitlab::UserAccess.new(@user, container: @subject.project) if @subject.tag? !access.can_create_tag?(@subject.ref) diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 662c29a0973..4d21da0226b 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -42,7 +42,7 @@ module Ci end def ref_protected?(user, project, tag, ref) - access = ::Gitlab::UserAccess.new(user, project: project) + access = ::Gitlab::UserAccess.new(user, container: project) if tag !access.can_create_tag?(ref) diff --git a/app/policies/concerns/crud_policy_helpers.rb b/app/policies/concerns/crud_policy_helpers.rb index d8521ca22cc..029c196cc5f 100644 --- a/app/policies/concerns/crud_policy_helpers.rb +++ b/app/policies/concerns/crud_policy_helpers.rb @@ -13,10 +13,16 @@ module CrudPolicyHelpers def create_update_admin_destroy(name) [ + *create_update_admin(name), + :"destroy_#{name}" + ] + end + + def create_update_admin(name) + [ :"create_#{name}", :"update_#{name}", - :"admin_#{name}", - :"destroy_#{name}" + :"admin_#{name}" ] end end diff --git a/app/policies/concerns/readonly_abilities.rb b/app/policies/concerns/readonly_abilities.rb new file mode 100644 index 00000000000..a267e963541 --- /dev/null +++ b/app/policies/concerns/readonly_abilities.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module ReadonlyAbilities + extend ActiveSupport::Concern + + READONLY_ABILITIES = %i[ + admin_tag + push_code + push_to_delete_protected_branch + request_access + upload_file + resolve_note + create_merge_request_from + create_merge_request_in + award_emoji + ].freeze + + READONLY_FEATURES = %i[ + issue + list + merge_request + label + milestone + snippet + wiki + design + note + pipeline + pipeline_schedule + build + trigger + environment + deployment + commit_status + container_image + pages + cluster + release + ].freeze + + class_methods do + def readonly_abilities + READONLY_ABILITIES + end + + def readonly_features + READONLY_FEATURES + end + end +end + +ReadonlyAbilities::ClassMethods.prepend_if_ee('EE::ReadonlyAbilities::ClassMethods') diff --git a/app/policies/group_deploy_key_policy.rb b/app/policies/group_deploy_key_policy.rb new file mode 100644 index 00000000000..642ed4d79ed --- /dev/null +++ b/app/policies/group_deploy_key_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class GroupDeployKeyPolicy < BasePolicy + with_options scope: :subject, score: 0 + condition(:user_owns_group_deploy_key) { @subject.user_id == @user.id } + + rule { user_owns_group_deploy_key }.enable :update_group_deploy_key +end diff --git a/app/policies/group_deploy_keys_group_policy.rb b/app/policies/group_deploy_keys_group_policy.rb new file mode 100644 index 00000000000..9275d576923 --- /dev/null +++ b/app/policies/group_deploy_keys_group_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class GroupDeployKeysGroupPolicy < BasePolicy + with_options scope: :subject, score: 0 + delegate { @subject.group } + condition(:user_is_group_owner) { @subject.group.has_owner?(@user) } + + rule { user_is_group_owner }.enable :update_group_deploy_key_for_group +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 92cba5f8f7d..3cc1be9dfb7 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -138,6 +138,7 @@ class GroupPolicy < BasePolicy enable :read_group_labels enable :read_group_milestones enable :read_group_merge_requests + enable :read_group_build_report_results end rule { can?(:read_cross_project) & can?(:read_group) }.policy do diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 28baa0d8338..b02bb8621ed 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -35,8 +35,15 @@ class IssuePolicy < IssuablePolicy prevent :destroy_design end + rule { ~can?(:read_design) }.policy do + prevent :move_design + end + rule { locked | moved }.policy do prevent :create_design + prevent :move_design prevent :destroy_design end end + +IssuePolicy.prepend_if_ee('EE::IssuePolicy') diff --git a/app/policies/personal_access_token_policy.rb b/app/policies/personal_access_token_policy.rb new file mode 100644 index 00000000000..1e5404b7822 --- /dev/null +++ b/app/policies/personal_access_token_policy.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class PersonalAccessTokenPolicy < BasePolicy + condition(:is_owner) { user && subject.user_id == user.id } + + rule { (is_owner | admin) & ~blocked }.policy do + enable :read_token + enable :revoke_token + end +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 3a245119cb7..b2432bfa608 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -2,29 +2,7 @@ class ProjectPolicy < BasePolicy include CrudPolicyHelpers - - READONLY_FEATURES_WHEN_ARCHIVED = %i[ - issue - list - merge_request - label - milestone - snippet - wiki - design - note - pipeline - pipeline_schedule - build - trigger - environment - deployment - commit_status - container_image - pages - cluster - release - ].freeze + include ReadonlyAbilities desc "User is a project owner" condition :owner do @@ -124,6 +102,11 @@ class ProjectPolicy < BasePolicy end with_scope :subject + condition(:moving_designs_disabled) do + !::Feature.enabled?(:reorder_designs, @subject, default_enabled: true) + end + + with_scope :subject condition(:service_desk_enabled) { @subject.service_desk_enabled? } # We aren't checking `:read_issue` or `:read_merge_request` in this case @@ -248,6 +231,7 @@ class ProjectPolicy < BasePolicy enable :admin_issue enable :admin_label enable :admin_list + enable :admin_issue_link enable :read_commit_status enable :read_build enable :read_container_image @@ -258,11 +242,13 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_sentry_issue enable :update_sentry_issue + enable :read_incidents enable :read_prometheus enable :read_metrics_dashboard_annotation enable :metrics_dashboard enable :read_confidential_issues enable :read_package + enable :read_product_analytics end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -340,8 +326,10 @@ class ProjectPolicy < BasePolicy enable :read_alert_management_alert enable :update_alert_management_alert enable :create_design + enable :move_design enable :destroy_design enable :read_terraform_state + enable :read_pod_logs end rule { can?(:developer_access) & user_confirmed? }.policy do @@ -381,7 +369,6 @@ class ProjectPolicy < BasePolicy enable :admin_operations enable :read_deploy_token enable :create_deploy_token - enable :read_pod_logs enable :destroy_deploy_token enable :read_prometheus_alerts enable :admin_terraform_state @@ -403,16 +390,9 @@ class ProjectPolicy < BasePolicy rule { can?(:push_code) }.enable :admin_tag rule { archived }.policy do - prevent :push_code - prevent :push_to_delete_protected_branch - prevent :request_access - prevent :upload_file - prevent :resolve_note - prevent :create_merge_request_from - prevent :create_merge_request_in - prevent :award_emoji + prevent(*readonly_abilities) - READONLY_FEATURES_WHEN_ARCHIVED.each do |feature| + readonly_features.each do |feature| prevent(*create_update_admin_destroy(feature)) end end @@ -499,6 +479,8 @@ class ProjectPolicy < BasePolicy enable :read_note enable :read_pipeline enable :read_pipeline_schedule + enable :read_environment + enable :read_deployment enable :read_commit_status enable :read_container_image enable :download_code @@ -563,6 +545,7 @@ class ProjectPolicy < BasePolicy rule { can?(:read_issue) }.policy do enable :read_design enable :read_design_activity + enable :read_issue_link end # Design abilities could also be prevented in the issue policy. @@ -571,6 +554,11 @@ class ProjectPolicy < BasePolicy prevent :read_design_activity prevent :create_design prevent :destroy_design + prevent :move_design + end + + rule { moving_designs_disabled }.policy do + prevent :move_design end rule { read_package_registry_deploy_token }.policy do diff --git a/app/policies/prometheus_alert_policy.rb b/app/policies/prometheus_alert_policy.rb new file mode 100644 index 00000000000..e6b0e6e8c17 --- /dev/null +++ b/app/policies/prometheus_alert_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class PrometheusAlertPolicy < ::BasePolicy + delegate { @subject.project } +end diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb index 301b7d965f5..4c84c8ba690 100644 --- a/app/policies/suggestion_policy.rb +++ b/app/policies/suggestion_policy.rb @@ -4,7 +4,7 @@ class SuggestionPolicy < BasePolicy delegate { @subject.project } condition(:can_push_to_branch) do - Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch) + Gitlab::UserAccess.new(@user, container: @subject.project).can_push_to_branch?(@subject.branch) end rule { can_push_to_branch }.enable :apply_suggestion diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 43f472b4c1d..6ebafca9885 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -20,6 +20,7 @@ class UserPolicy < BasePolicy enable :destroy_user enable :update_user enable :update_user_status + enable :read_user_personal_access_tokens end rule { default }.enable :read_user_profile diff --git a/app/presenters/alert_management/alert_presenter.rb b/app/presenters/alert_management/alert_presenter.rb index a515c70152d..5bfa6dee18b 100644 --- a/app/presenters/alert_management/alert_presenter.rb +++ b/app/presenters/alert_management/alert_presenter.rb @@ -4,6 +4,7 @@ module AlertManagement class AlertPresenter < Gitlab::View::Presenter::Delegated include Gitlab::Utils::StrongMemoize include IncidentManagement::Settings + include ActionView::Helpers::UrlHelper MARKDOWN_LINE_BREAK = " \n".freeze @@ -37,8 +38,18 @@ module AlertManagement MARKDOWN end + def runbook + strong_memoize(:runbook) do + payload&.dig('runbook') + end + end + def metrics_dashboard_url; end + def details_url + details_project_alert_management_url(project, alert.iid) + end + private attr_reader :alert, :project @@ -61,6 +72,7 @@ module AlertManagement metadata << list_item('Monitoring tool', monitoring_tool) if monitoring_tool metadata << list_item('Hosts', host_links) if hosts.any? metadata << list_item('Description', description) if description.present? + metadata << list_item('GitLab alert', details_url) if details_url.present? metadata.join(MARKDOWN_LINE_BREAK) end diff --git a/app/presenters/alert_management/prometheus_alert_presenter.rb b/app/presenters/alert_management/prometheus_alert_presenter.rb index 3bcc98e6784..6b8c8183f08 100644 --- a/app/presenters/alert_management/prometheus_alert_presenter.rb +++ b/app/presenters/alert_management/prometheus_alert_presenter.rb @@ -2,6 +2,12 @@ module AlertManagement class PrometheusAlertPresenter < AlertManagement::AlertPresenter + def runbook + strong_memoize(:runbook) do + payload&.dig('annotations', 'runbook') + end + end + def metrics_dashboard_url alerting_alert.metrics_dashboard_url end diff --git a/app/presenters/blob_presenter.rb b/app/presenters/blob_presenter.rb index e0077db8d5c..cff935d51b5 100644 --- a/app/presenters/blob_presenter.rb +++ b/app/presenters/blob_presenter.rb @@ -18,6 +18,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated Gitlab::Routing.url_helpers.project_blob_url(blob.repository.project, File.join(blob.commit_id, blob.path)) end + def web_path + Gitlab::Routing.url_helpers.project_blob_path(blob.repository.project, File.join(blob.commit_id, blob.path)) + end + private def load_all_blob_data diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 5e35bfc79ef..64461fa9193 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -118,3 +118,5 @@ module Ci end end end + +Ci::BuildRunnerPresenter.prepend_if_ee('EE::Ci::BuildRunnerPresenter') diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index c0da5310ca4..25693af4881 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -80,7 +80,7 @@ module Clusters 'clusters-path': clusterable.index_path, 'dashboard-endpoint': clusterable.metrics_dashboard_path(cluster), 'documentation-path': help_page_path('user/project/clusters/index', anchor: 'monitoring-your-kubernetes-cluster-ultimate'), - 'add-dashboard-documentation-path': help_page_path('user/project/integrations/prometheus.md', anchor: 'adding-a-new-dashboard-to-your-project'), + 'add-dashboard-documentation-path': help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), 'empty-getting-started-svg-path': image_path('illustrations/monitoring/getting_started.svg'), 'empty-loading-svg-path': image_path('illustrations/monitoring/loading.svg'), 'empty-no-data-svg-path': image_path('illustrations/monitoring/no_data.svg'), diff --git a/app/presenters/commit_presenter.rb b/app/presenters/commit_presenter.rb index 9ded00fcb7a..c14dcab6000 100644 --- a/app/presenters/commit_presenter.rb +++ b/app/presenters/commit_presenter.rb @@ -17,10 +17,6 @@ class CommitPresenter < Gitlab::View::Presenter::Delegated commit.pipelines.any? end - def web_url - url_builder.build(commit) - end - def signature_html return unless commit.has_signature? diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index 52811e152a6..eaa7cf848cd 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -19,7 +19,8 @@ class CommitStatusPresenter < Gitlab::View::Presenter::Delegated downstream_bridge_project_not_found: 'This job could not be executed because downstream bridge project could not be found', insufficient_bridge_permissions: 'This job could not be executed because of insufficient permissions to create a downstream pipeline', bridge_pipeline_is_child_pipeline: 'This job belongs to a child pipeline and cannot create further child pipelines', - downstream_pipeline_creation_failed: 'The downstream pipeline could not be created' + downstream_pipeline_creation_failed: 'The downstream pipeline could not be created', + secrets_provider_not_found: 'The secrets provider can not be found' }.freeze private_constant :CALLOUT_FAILURE_MESSAGES diff --git a/app/presenters/event_presenter.rb b/app/presenters/event_presenter.rb index 5657e0b96bc..8f2388c2c31 100644 --- a/app/presenters/event_presenter.rb +++ b/app/presenters/event_presenter.rb @@ -24,7 +24,7 @@ class EventPresenter < Gitlab::View::Presenter::Delegated when Group [event.group, event.target] when Project - [event.project.namespace.becomes(Namespace), event.project, event.target] + [event.project, event.target] else '' end diff --git a/app/presenters/gitlab/blame_presenter.rb b/app/presenters/gitlab/blame_presenter.rb index db2fc52a88b..3c581d4b115 100644 --- a/app/presenters/gitlab/blame_presenter.rb +++ b/app/presenters/gitlab/blame_presenter.rb @@ -76,7 +76,7 @@ module Gitlab end def versions_sprite_icon - @versions_sprite_icon ||= sprite_icon('doc-versions', size: 16, css_class: 'doc-versions align-text-bottom') + @versions_sprite_icon ||= sprite_icon('doc-versions', css_class: 'doc-versions align-text-bottom') end end end diff --git a/app/presenters/issue_presenter.rb b/app/presenters/issue_presenter.rb index 004813d0374..185fcd3e934 100644 --- a/app/presenters/issue_presenter.rb +++ b/app/presenters/issue_presenter.rb @@ -3,10 +3,6 @@ class IssuePresenter < Gitlab::View::Presenter::Delegated presents :issue - def web_url - url_builder.build(issue) - end - def issue_path url_builder.build(issue, only_path: true) end diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index bccf0340749..1ff02412994 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -179,7 +179,7 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated return false unless source_branch_exists? !!::Gitlab::UserAccess - .new(current_user, project: source_project) + .new(current_user, container: source_project) .can_push_to_branch?(source_branch) end @@ -202,10 +202,6 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end - def web_url - Gitlab::UrlBuilder.build(merge_request) - end - def subscribed? merge_request.subscribed?(current_user, merge_request.target_project) end diff --git a/app/presenters/packages/detail/package_presenter.rb b/app/presenters/packages/detail/package_presenter.rb index f6e068302c1..bdb2e34854e 100644 --- a/app/presenters/packages/detail/package_presenter.rb +++ b/app/presenters/packages/detail/package_presenter.rb @@ -22,6 +22,8 @@ module Packages package_detail[:maven_metadatum] = @package.maven_metadatum if @package.maven_metadatum package_detail[:nuget_metadatum] = @package.nuget_metadatum if @package.nuget_metadatum + package_detail[:composer_metadatum] = @package.composer_metadatum if @package.composer_metadatum + package_detail[:conan_metadatum] = @package.conan_metadatum if @package.conan_metadatum package_detail[:dependency_links] = @package.dependency_links.map(&method(:build_dependency_links)) package_detail[:pipeline] = build_pipeline_info(@package.build_info.pipeline) if @package.build_info @@ -49,7 +51,9 @@ module Packages user: build_user_info(pipeline_info.user), project: { name: pipeline_info.project.name, - web_url: pipeline_info.project.web_url + web_url: pipeline_info.project.web_url, + pipeline_url: Gitlab::Routing.url_helpers.project_pipeline_url(pipeline_info.project, pipeline_info), + commit_url: Gitlab::Routing.url_helpers.project_commit_url(pipeline_info.project, pipeline_info.sha) } } end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 4e8dae1d508..86fd405812e 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -16,7 +16,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated MAX_TOPICS_TO_SHOW = 3 def statistic_icon(icon_name = 'plus-square-o') - sprite_icon(icon_name, size: 16, css_class: 'icon gl-mr-2') + sprite_icon(icon_name, css_class: 'icon gl-mr-2') end def statistics_anchors(show_auto_devops_callout:) diff --git a/app/presenters/projects/prometheus/alert_presenter.rb b/app/presenters/projects/prometheus/alert_presenter.rb index 1cf8b202810..49859f27edd 100644 --- a/app/presenters/projects/prometheus/alert_presenter.rb +++ b/app/presenters/projects/prometheus/alert_presenter.rb @@ -77,6 +77,15 @@ module Projects end end + def details_url + return unless am_alert + + ::Gitlab::Routing.url_helpers.details_project_alert_management_url( + project, + am_alert.iid + ) + end + private def alert_title @@ -97,6 +106,7 @@ module Projects metadata << list_item(service.label.humanize, service.value) if service metadata << list_item(monitoring_tool.label.humanize, monitoring_tool.value) if monitoring_tool metadata << list_item(hosts.label.humanize, host_links) if hosts + metadata << list_item('GitLab alert', details_url) if details_url metadata.join(MARKDOWN_LINE_BREAK) end @@ -173,7 +183,7 @@ module Projects { panel_groups: [{ panels: [{ - type: 'line-graph', + type: 'area-chart', title: title, y_label: y_label, metrics: [{ diff --git a/app/presenters/prometheus_alert_presenter.rb b/app/presenters/prometheus_alert_presenter.rb new file mode 100644 index 00000000000..99e24bdcdb9 --- /dev/null +++ b/app/presenters/prometheus_alert_presenter.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class PrometheusAlertPresenter < Gitlab::View::Presenter::Delegated + include ActionView::Helpers::UrlHelper + + presents :prometheus_alert + + def humanized_text + operator_text = + case prometheus_alert.operator + when 'lt' then s_('PrometheusAlerts|is less than') + when 'eq' then s_('PrometheusAlerts|is equal to') + when 'gt' then s_('PrometheusAlerts|exceeded') + end + + "#{operator_text} #{prometheus_alert.threshold}#{prometheus_alert.prometheus_metric.unit}" + end +end diff --git a/app/presenters/snippet_blob_presenter.rb b/app/presenters/snippet_blob_presenter.rb index d27fe751ab7..abe95f5c44d 100644 --- a/app/presenters/snippet_blob_presenter.rb +++ b/app/presenters/snippet_blob_presenter.rb @@ -4,7 +4,6 @@ class SnippetBlobPresenter < BlobPresenter include GitlabRoutingHelper def rich_data - return if blob.binary? return unless blob.rich_viewer render_rich_partial @@ -17,9 +16,11 @@ class SnippetBlobPresenter < BlobPresenter end def raw_path - return gitlab_raw_snippet_blob_path(blob) if snippet_multiple_files? + snippet_blob_raw_route(only_path: true) + end - gitlab_raw_snippet_path(snippet) + def raw_url + snippet_blob_raw_route end private @@ -38,7 +39,7 @@ class SnippetBlobPresenter < BlobPresenter def render_rich_partial renderer.render("projects/blob/viewers/_#{blob.rich_viewer.partial_name}", - locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path }, + locals: { viewer: blob.rich_viewer, blob: blob, blob_raw_path: raw_path, blob_raw_url: raw_url }, layout: false) end @@ -49,4 +50,10 @@ class SnippetBlobPresenter < BlobPresenter ApplicationController.renderer.new('warden' => proxy) end + + def snippet_blob_raw_route(only_path: false) + return gitlab_raw_snippet_blob_url(snippet, blob.path, only_path: only_path) if snippet_multiple_files? + + gitlab_raw_snippet_url(snippet, only_path: only_path) + end end diff --git a/app/presenters/snippet_presenter.rb b/app/presenters/snippet_presenter.rb index 62a90025ce1..d814c4404b6 100644 --- a/app/presenters/snippet_presenter.rb +++ b/app/presenters/snippet_presenter.rb @@ -3,12 +3,8 @@ class SnippetPresenter < Gitlab::View::Presenter::Delegated presents :snippet - def web_url - Gitlab::UrlBuilder.build(snippet) - end - def raw_url - Gitlab::UrlBuilder.build(snippet, raw: true) + url_builder.build(snippet, raw: true) end def ssh_url_to_repo diff --git a/app/presenters/tree_entry_presenter.rb b/app/presenters/tree_entry_presenter.rb index 7bb10cd1455..216b3b0d4c9 100644 --- a/app/presenters/tree_entry_presenter.rb +++ b/app/presenters/tree_entry_presenter.rb @@ -6,4 +6,8 @@ class TreeEntryPresenter < Gitlab::View::Presenter::Delegated def web_url Gitlab::Routing.url_helpers.project_tree_url(tree.repository.project, File.join(tree.commit_id, tree.path)) end + + def web_path + Gitlab::Routing.url_helpers.project_tree_path(tree.repository.project, File.join(tree.commit_id, tree.path)) + end end diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb index 14ef53e9ec8..f201b36346f 100644 --- a/app/presenters/user_presenter.rb +++ b/app/presenters/user_presenter.rb @@ -2,8 +2,4 @@ class UserPresenter < Gitlab::View::Presenter::Delegated presents :user - - def web_url - Gitlab::Routing.url_helpers.user_url(user) - end end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index df1bdc2b7a4..523f1a0f8c6 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -27,11 +27,11 @@ class BuildDetailsEntity < JobEntity end expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do - expose :download_path, if: -> (*) { build.artifacts? } do |build| + expose :download_path, if: -> (*) { build.pipeline.artifacts_locked? || build.artifacts? } do |build| download_project_job_artifacts_path(project, build) end - expose :browse_path, if: -> (*) { build.browsable_artifacts? } do |build| + expose :browse_path, if: -> (*) { build.pipeline.artifacts_locked? || build.browsable_artifacts? } do |build| browse_project_job_artifacts_path(project, build) end @@ -46,6 +46,10 @@ class BuildDetailsEntity < JobEntity expose :expired, if: -> (*) { build.artifacts_expire_at.present? } do |build| build.artifacts_expired? end + + expose :locked do |build| + build.pipeline.artifacts_locked? + end end expose :report_artifacts, @@ -147,7 +151,7 @@ class BuildDetailsEntity < JobEntity end def help_message(docs_url) - _("Please refer to <a href=\"%{docs_url}\">%{docs_url}</a>") % { docs_url: docs_url } + html_escape(_("Please refer to %{docs_url}")) % { docs_url: "<a href=\"#{docs_url}\">#{html_escape(docs_url)}</a>".html_safe } end end diff --git a/app/serializers/cluster_entity.rb b/app/serializers/cluster_entity.rb index a46f2889a96..06e14179238 100644 --- a/app/serializers/cluster_entity.rb +++ b/app/serializers/cluster_entity.rb @@ -20,4 +20,8 @@ class ClusterEntity < Grape::Entity expose :gitlab_managed_apps_logs_path do |cluster| Clusters::ClusterPresenter.new(cluster, current_user: request.current_user).gitlab_managed_apps_logs_path # rubocop: disable CodeReuse/Presenter end + + expose :kubernetes_errors do |cluster| + ClusterErrorEntity.new(cluster) + end end diff --git a/app/serializers/cluster_error_entity.rb b/app/serializers/cluster_error_entity.rb new file mode 100644 index 00000000000..c749537cb94 --- /dev/null +++ b/app/serializers/cluster_error_entity.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ClusterErrorEntity < Grape::Entity + expose :connection_error + expose :metrics_connection_error + expose :node_connection_error +end diff --git a/app/serializers/cluster_serializer.rb b/app/serializers/cluster_serializer.rb index 92363a4942c..a70458d2bcb 100644 --- a/app/serializers/cluster_serializer.rb +++ b/app/serializers/cluster_serializer.rb @@ -11,6 +11,7 @@ class ClusterSerializer < BaseSerializer :enabled, :environment_scope, :gitlab_managed_apps_logs_path, + :kubernetes_errors, :name, :nodes, :path, diff --git a/app/serializers/diffs_metadata_entity.rb b/app/serializers/diffs_metadata_entity.rb index b7024721ea9..8973f23734a 100644 --- a/app/serializers/diffs_metadata_entity.rb +++ b/app/serializers/diffs_metadata_entity.rb @@ -3,4 +3,23 @@ class DiffsMetadataEntity < DiffsEntity unexpose :diff_files expose :raw_diff_files, as: :diff_files, using: DiffFileMetadataEntity + + expose :conflict_resolution_path do |_, options| + presenter(options[:merge_request]).conflict_resolution_path + end + + expose :has_conflicts do |_, options| + options[:merge_request].cannot_be_merged? + end + + expose :can_merge do |_, options| + options[:merge_request].can_be_merged_by?(request.current_user) + end + + private + + def presenter(merge_request) + @presenters ||= {} + @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: request.current_user) # rubocop: disable CodeReuse/Presenter + end end diff --git a/app/serializers/discussion_entity.rb b/app/serializers/discussion_entity.rb index 77881eaba0c..2957205a81c 100644 --- a/app/serializers/discussion_entity.rb +++ b/app/serializers/discussion_entity.rb @@ -72,6 +72,6 @@ class DiscussionEntity < Grape::Entity return unless discussion.diff_discussion? return if discussion.legacy_diff_discussion? - Feature.enabled?(:merge_ref_head_comments, discussion.project) + Feature.enabled?(:merge_ref_head_comments, discussion.project, default_enabled: true) end end diff --git a/app/serializers/environment_entity.rb b/app/serializers/environment_entity.rb index 7da5910a75b..a2bf9716f8f 100644 --- a/app/serializers/environment_entity.rb +++ b/app/serializers/environment_entity.rb @@ -71,6 +71,8 @@ class EnvironmentEntity < Grape::Entity can?(current_user, :destroy_environment, environment) end + expose :has_opened_alert?, if: -> (*) { can_read_alert_management_alert? }, expose_nil: false, as: :has_opened_alert + private alias_method :environment, :object @@ -91,6 +93,10 @@ class EnvironmentEntity < Grape::Entity can?(current_user, :read_pod_logs, environment.project) end + def can_read_alert_management_alert? + can?(current_user, :read_alert_management_alert, environment.project) + end + def cluster_platform_kubernetes? deployment_platform && deployment_platform.is_a?(Clusters::Platforms::Kubernetes) end diff --git a/app/serializers/group_basic_entity.rb b/app/serializers/group_basic_entity.rb new file mode 100644 index 00000000000..24a05100d43 --- /dev/null +++ b/app/serializers/group_basic_entity.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class GroupBasicEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :name + expose :full_path + expose :full_name +end diff --git a/app/serializers/group_deploy_key_entity.rb b/app/serializers/group_deploy_key_entity.rb new file mode 100644 index 00000000000..c0bb0448a51 --- /dev/null +++ b/app/serializers/group_deploy_key_entity.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class GroupDeployKeyEntity < Grape::Entity + expose :id + expose :user_id + expose :title + expose :fingerprint + expose :fingerprint_sha256 + expose :created_at + expose :updated_at + expose :group_deploy_keys_groups, using: GroupDeployKeysGroupEntity do |group_deploy_key| + group_deploy_key.group_deploy_keys_groups_for_user(options[:user]) + end + expose :can_edit do |group_deploy_key| + group_deploy_key.can_be_edited_for?(options[:user], options[:group]) + end +end diff --git a/app/serializers/group_deploy_key_serializer.rb b/app/serializers/group_deploy_key_serializer.rb new file mode 100644 index 00000000000..e7d5f6a77ea --- /dev/null +++ b/app/serializers/group_deploy_key_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class GroupDeployKeySerializer < BaseSerializer + entity GroupDeployKeyEntity +end diff --git a/app/serializers/group_deploy_keys_group_entity.rb b/app/serializers/group_deploy_keys_group_entity.rb new file mode 100644 index 00000000000..f2801dfc112 --- /dev/null +++ b/app/serializers/group_deploy_keys_group_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class GroupDeployKeysGroupEntity < Grape::Entity + expose :can_push + expose :group, using: GroupBasicEntity +end diff --git a/app/serializers/import/bitbucket_server_provider_repo_entity.rb b/app/serializers/import/bitbucket_server_provider_repo_entity.rb index d818cac46cd..7c619cf4ebe 100644 --- a/app/serializers/import/bitbucket_server_provider_repo_entity.rb +++ b/app/serializers/import/bitbucket_server_provider_repo_entity.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class Import::BitbucketServerProviderRepoEntity < Import::BitbucketProviderRepoEntity + expose :id, override: true do |repo| + "#{repo.project_key}/#{repo.slug}" + end + expose :provider_link, override: true do |repo, options| repo.browse_url end diff --git a/app/serializers/import/manifest_provider_repo_entity.rb b/app/serializers/import/manifest_provider_repo_entity.rb new file mode 100644 index 00000000000..5da9aae80a8 --- /dev/null +++ b/app/serializers/import/manifest_provider_repo_entity.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Import::ManifestProviderRepoEntity < Import::BaseProviderRepoEntity + expose :id + expose :full_name, override: true do |repo| + repo[:url] + end + + expose :provider_link, override: true do |repo| + repo[:url] + end + + expose :target do |repo, options| + import_project_target(options[:group_full_path], repo[:path], options[:request].current_user) + end + + private + + def import_project_target(owner, name, user) + namespace = user.can_create_group? ? owner : user.namespace_path + "#{namespace}/#{name}" + end +end diff --git a/app/serializers/import/provider_repo_serializer.rb b/app/serializers/import/provider_repo_serializer.rb index 5a9549d79aa..edd1a260146 100644 --- a/app/serializers/import/provider_repo_serializer.rb +++ b/app/serializers/import/provider_repo_serializer.rb @@ -14,6 +14,8 @@ class Import::ProviderRepoSerializer < BaseSerializer Import::BitbucketServerProviderRepoEntity when :gitlab Import::GitlabProviderRepoEntity + when :manifest + Import::ManifestProviderRepoEntity else raise NotImplementedError end diff --git a/app/serializers/merge_request_poll_widget_entity.rb b/app/serializers/merge_request_poll_widget_entity.rb index a365ebc29c9..99d6211b487 100644 --- a/app/serializers/merge_request_poll_widget_entity.rb +++ b/app/serializers/merge_request_poll_widget_entity.rb @@ -19,9 +19,21 @@ class MergeRequestPollWidgetEntity < Grape::Entity # User entities expose :merge_user, using: UserEntity - expose :actual_head_pipeline, with: PipelineDetailsEntity, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } + expose :actual_head_pipeline, as: :pipeline, if: -> (mr, _) { presenter(mr).can_read_pipeline? } do |merge_request, options| + if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true) + MergeRequests::PipelineEntity.represent(merge_request.actual_head_pipeline, options) + else + PipelineDetailsEntity.represent(merge_request.actual_head_pipeline, options) + end + end - expose :merge_pipeline, with: PipelineDetailsEntity, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} + expose :merge_pipeline, if: ->(mr, _) { mr.merged? && can?(request.current_user, :read_pipeline, mr.target_project)} do |merge_request, options| + if Feature.enabled?(:merge_request_short_pipeline_serializer, merge_request.project, default_enabled: true) + MergeRequests::PipelineEntity.represent(merge_request.merge_pipeline, options) + else + PipelineDetailsEntity.represent(merge_request.merge_pipeline, options) + end + end expose :default_merge_commit_message diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 2a7afb57314..b7b9e7d1036 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -3,6 +3,8 @@ class MergeRequestWidgetEntity < Grape::Entity include RequestAwareEntity + SUGGEST_PIPELINE = 'suggest_pipeline' + expose :id expose :iid @@ -14,6 +16,10 @@ class MergeRequestWidgetEntity < Grape::Entity merge_request.project&.full_path end + expose :can_create_pipeline_in_target_project do |merge_request| + can?(current_user, :create_pipeline, merge_request.target_project) + end + expose :email_patches_path do |merge_request| project_merge_request_path(merge_request.project, merge_request, format: :patch) end @@ -60,6 +66,18 @@ class MergeRequestWidgetEntity < Grape::Entity ) end + expose :user_callouts_path, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request| + user_callouts_path + end + + expose :suggest_pipeline_feature_id, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request| + SUGGEST_PIPELINE + end + + expose :is_dismissed_suggest_pipeline, if: -> (*) { Feature.enabled?(:suggest_pipeline) } do |merge_request| + current_user && current_user.dismissed_callout?(feature_name: SUGGEST_PIPELINE) + end + expose :human_access do |merge_request| merge_request.project.team.human_max_access(current_user&.id) end @@ -119,7 +137,7 @@ class MergeRequestWidgetEntity < Grape::Entity merge_request.source_branch_exists? && merge_request.source_project&.uses_default_ci_config? && !merge_request.source_project.has_ci? && - merge_request.commits_count.positive? && + merge_request.commits_count > 0 && can?(current_user, :read_build, merge_request.source_project) && can?(current_user, :create_pipeline, merge_request.source_project) end diff --git a/app/serializers/merge_requests/pipeline_entity.rb b/app/serializers/merge_requests/pipeline_entity.rb new file mode 100644 index 00000000000..97d7620154e --- /dev/null +++ b/app/serializers/merge_requests/pipeline_entity.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class MergeRequests::PipelineEntity < Grape::Entity + include RequestAwareEntity + + expose :id + expose :active?, as: :active + + expose :path do |pipeline| + project_pipeline_path(pipeline.project, pipeline) + end + + expose :flags do + expose :merge_request_pipeline?, as: :merge_request_pipeline + end + + expose :commit, using: CommitEntity + + expose :details do + expose :name do |pipeline| + pipeline.present.name + end + + expose :detailed_status, as: :status, with: DetailedStatusEntity do |pipeline| + pipeline.detailed_status(request.current_user) + end + + expose :stages, using: StageEntity + end + + # Coverage isn't always necessary (e.g. when displaying project pipelines in + # the UI). Instead of creating an entirely different entity we just allow the + # disabling of this specific field whenever necessary. + expose :coverage, unless: proc { options[:disable_coverage] } + + expose :ref do + expose :branch?, as: :branch + end + + expose :triggered_by_pipeline, as: :triggered_by, with: TriggeredPipelineEntity + expose :triggered_pipelines, as: :triggered, using: TriggeredPipelineEntity +end diff --git a/app/serializers/pipeline_entity.rb b/app/serializers/pipeline_entity.rb index 8333a0bb863..de1e07139ad 100644 --- a/app/serializers/pipeline_entity.rb +++ b/app/serializers/pipeline_entity.rb @@ -36,7 +36,7 @@ class PipelineEntity < Grape::Entity expose :details do expose :detailed_status, as: :status, with: DetailedStatusEntity - expose :ordered_stages, as: :stages, using: StageEntity + expose :stages, using: StageEntity expose :duration expose :finished_at expose :name @@ -85,8 +85,8 @@ class PipelineEntity < Grape::Entity pipeline.failed_builds end - expose :tests_total_count, if: -> (pipeline, _) { Feature.enabled?(:build_report_summary, pipeline.project) } do |pipeline| - pipeline.test_report_summary.total_count + expose :tests_total_count do |pipeline| + pipeline.test_report_summary.total[:count] end private diff --git a/app/serializers/pipeline_serializer.rb b/app/serializers/pipeline_serializer.rb index bfd6851647f..45c5a1d3e1c 100644 --- a/app/serializers/pipeline_serializer.rb +++ b/app/serializers/pipeline_serializer.rb @@ -42,6 +42,7 @@ class PipelineSerializer < BaseSerializer [ :cancelable_statuses, :latest_statuses_ordered_by_stage, + :latest_builds_report_results, :manual_actions, :retryable_builds, :scheduled_actions, diff --git a/app/serializers/prometheus_alert_entity.rb b/app/serializers/prometheus_alert_entity.rb index 413be511903..92905d2b389 100644 --- a/app/serializers/prometheus_alert_entity.rb +++ b/app/serializers/prometheus_alert_entity.rb @@ -7,6 +7,7 @@ class PrometheusAlertEntity < Grape::Entity expose :title expose :query expose :threshold + expose :runbook_url expose :operator do |prometheus_alert| prometheus_alert.computed_operator diff --git a/app/serializers/release_entity.rb b/app/serializers/release_entity.rb new file mode 100644 index 00000000000..6777b0f9780 --- /dev/null +++ b/app/serializers/release_entity.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class ReleaseEntity < Grape::Entity + expose :id + expose :tag # see https://gitlab.com/gitlab-org/gitlab/-/issues/36338 +end diff --git a/app/serializers/release_serializer.rb b/app/serializers/release_serializer.rb new file mode 100644 index 00000000000..05a13f71a6f --- /dev/null +++ b/app/serializers/release_serializer.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ReleaseSerializer < BaseSerializer + entity ReleaseEntity +end diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index c9fcbe14f2e..c224d0b4390 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -16,24 +16,8 @@ class SuggestionEntity < API::Entities::Suggestion expose :inapplicable_reason do |suggestion| next _("You don't have write access to the source branch.") unless can_apply?(suggestion) - next if suggestion.appliable? - case suggestion.inapplicable_reason - when :merge_request_merged - _("This merge request was merged. To apply this suggestion, edit this file directly.") - when :merge_request_closed - _("This merge request is closed. To apply this suggestion, edit this file directly.") - when :source_branch_deleted - _("Can't apply as the source branch was deleted.") - when :outdated - phrase = suggestion.single_line? ? 'this line was' : 'these lines were' - - _("Can't apply as %{phrase} changed in a more recent version.") % { phrase: phrase } - when :same_content - _("This suggestion already matches its content.") - else - _("Can't apply this suggestion.") - end + suggestion.inapplicable_reason end private diff --git a/app/serializers/test_report_summary_entity.rb b/app/serializers/test_report_summary_entity.rb index 5995ca007d6..bc73c49092f 100644 --- a/app/serializers/test_report_summary_entity.rb +++ b/app/serializers/test_report_summary_entity.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class TestReportSummaryEntity < TestReportEntity +class TestReportSummaryEntity < Grape::Entity + expose :total + expose :test_suites, using: TestSuiteSummaryEntity do |summary| summary.test_suites.values end diff --git a/app/serializers/triggered_pipeline_entity.rb b/app/serializers/triggered_pipeline_entity.rb index 47f51a6d76a..9fdadb322bf 100644 --- a/app/serializers/triggered_pipeline_entity.rb +++ b/app/serializers/triggered_pipeline_entity.rb @@ -24,8 +24,8 @@ class TriggeredPipelineEntity < Grape::Entity expose :details do expose :detailed_status, as: :status, with: DetailedStatusEntity - expose :ordered_stages, - as: :stages, using: StageEntity, + expose :stages, + using: StageEntity, if: -> (_, opts) { can_read_details? && expand?(opts) } end diff --git a/app/services/admin/propagate_integration_service.rb b/app/services/admin/propagate_integration_service.rb index e21bb03ed68..9a5ce58ee2c 100644 --- a/app/services/admin/propagate_integration_service.rb +++ b/app/services/admin/propagate_integration_service.rb @@ -96,7 +96,7 @@ module Admin # rubocop: disable CodeReuse/ActiveRecord def run_callbacks(batch) - if active_external_issue_tracker? + if integration.issue_tracker? Project.where(id: batch).update_all(has_external_issue_tracker: true) end @@ -106,10 +106,6 @@ module Admin end # rubocop: enable CodeReuse/ActiveRecord - def active_external_issue_tracker? - integration.issue_tracker? && !integration.default - end - def active_external_wiki? integration.type == 'ExternalWikiService' end diff --git a/app/services/alert_management/alerts/update_service.rb b/app/services/alert_management/alerts/update_service.rb index 0b7216cd9f8..18d615aa7e7 100644 --- a/app/services/alert_management/alerts/update_service.rb +++ b/app/services/alert_management/alerts/update_service.rb @@ -96,12 +96,12 @@ module AlertManagement end def handle_assignement(old_assignees) - assign_todo + assign_todo(old_assignees) add_assignee_system_note(old_assignees) end - def assign_todo - todo_service.assign_alert(alert, current_user) + def assign_todo(old_assignees) + todo_service.reassigned_assignable(alert, current_user, old_assignees) end def add_assignee_system_note(old_assignees) diff --git a/app/services/alert_management/create_alert_issue_service.rb b/app/services/alert_management/create_alert_issue_service.rb index 6ea3fd867ef..f16b106b748 100644 --- a/app/services/alert_management/create_alert_issue_service.rb +++ b/app/services/alert_management/create_alert_issue_service.rb @@ -15,10 +15,10 @@ module AlertManagement return error_no_permissions unless allowed? return error_issue_already_exists if alert.issue - result = create_issue - issue = result.payload[:issue] + result = create_incident + return result unless result.success? - return error(result.message, issue) if result.error? + issue = result.payload[:issue] return error(object_errors(alert), issue) unless associate_alert_with_issue(issue) SystemNoteService.new_alert_issue(alert, issue, user) @@ -36,35 +36,19 @@ module AlertManagement user.can?(:create_issue, project) end - def create_issue - label_result = find_or_create_incident_label - - # Create an unlabelled issue if we couldn't create the label - # due to a race condition. - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 - extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {} - - issue = Issues::CreateService.new( + def create_incident + ::IncidentManagement::Incidents::CreateService.new( project, user, title: alert_presenter.title, - description: alert_presenter.issue_description, - **extra_params + description: alert_presenter.issue_description ).execute - - return error(object_errors(issue), issue) unless issue.valid? - - success(issue) end def associate_alert_with_issue(issue) alert.update(issue_id: issue.id) end - def success(issue) - ServiceResponse.success(payload: { issue: issue }) - end - def error(message, issue = nil) ServiceResponse.error(payload: { issue: issue }, message: message) end @@ -83,10 +67,6 @@ module AlertManagement end end - def find_or_create_incident_label - IncidentManagement::CreateIncidentLabelService.new(project, user).execute - end - def object_errors(object) object.errors.full_messages.to_sentence end diff --git a/app/services/award_emojis/copy_service.rb b/app/services/award_emojis/copy_service.rb new file mode 100644 index 00000000000..2e500d4c697 --- /dev/null +++ b/app/services/award_emojis/copy_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# This service copies AwardEmoji from one Awardable to another. +# +# It expects the calling code to have performed the necessary authorization +# checks in order to allow the copy to happen. +module AwardEmojis + class CopyService + def initialize(from_awardable, to_awardable) + raise ArgumentError, 'Awardables must be different' if from_awardable == to_awardable + + @from_awardable = from_awardable + @to_awardable = to_awardable + end + + def execute + from_awardable.award_emoji.find_each do |award| + new_award = award.dup + new_award.awardable = to_awardable + new_award.save! + end + + ServiceResponse.success + end + + private + + attr_accessor :from_awardable, :to_awardable + end +end diff --git a/app/services/boards/issues/list_service.rb b/app/services/boards/issues/list_service.rb index e08509b84db..140420a32bd 100644 --- a/app/services/boards/issues/list_service.rb +++ b/app/services/boards/issues/list_service.rb @@ -45,6 +45,12 @@ module Boards # rubocop: enable CodeReuse/ActiveRecord def filter(issues) + # when grouping board issues by epics (used in board swimlanes) + # we need to get all issues in the board + # TODO: ignore hidden columns - + # https://gitlab.com/gitlab-org/gitlab/-/issues/233870 + return issues if params[:all_lists] + issues = without_board_labels(issues) unless list&.movable? || list&.closed? issues = with_list_label(issues) if list&.label? issues @@ -55,9 +61,17 @@ module Boards end def list - return @list if defined?(@list) + return unless params.key?(:id) + + strong_memoize(:list) do + id = params[:id] - @list = board.lists.find(params[:id]) if params.key?(:id) + if board.lists.loaded? + board.lists.find { |l| l.id == id } + else + board.lists.find(id) + end + end end def filter_params @@ -79,6 +93,8 @@ module Boards end def set_state + return if params[:all_lists] + params[:state] = list && list.closed? ? 'closed' : 'opened' end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 9e3c84d03ec..14e8683ebdf 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -130,7 +130,7 @@ module Boards def move_between_ids(move_params) ids = [move_params[:move_after_id], move_params[:move_before_id]] .map(&:to_i) - .map { |m| m.positive? ? m : nil } + .map { |m| m > 0 ? m : nil } ids.any? ? ids : nil end diff --git a/app/services/boards/lists/create_service.rb b/app/services/boards/lists/create_service.rb index 6f9a063cb16..9c7a165776e 100644 --- a/app/services/boards/lists/create_service.rb +++ b/app/services/boards/lists/create_service.rb @@ -7,34 +7,39 @@ module Boards def execute(board) List.transaction do - target = target(board) - position = next_position(board) - create_list(board, type, target, position) + case type + when :backlog + create_backlog(board) + else + target = target(board) + position = next_position(board) + + create_list(board, type, target, position) + end end end private def type - :label + # We don't ever expect to have more than one list + # type param at once. + if params.key?('backlog') + :backlog + else + :label + end end def target(board) strong_memoize(:target) do - available_labels_for(board).find(params[:label_id]) + available_labels.find(params[:label_id]) end end - def available_labels_for(board) - options = { include_ancestor_groups: true } - - if board.group_board? - options.merge!(group_id: parent.id, only_group_labels: true) - else - options[:project_id] = parent.id - end - - LabelsFinder.new(current_user, options).execute + def available_labels + ::Labels::AvailableLabelsService.new(current_user, parent, {}) + .available_labels end def next_position(board) @@ -49,6 +54,12 @@ module Boards def create_list_attributes(type, target, position) { type => target, list_type: type, position: position } end + + def create_backlog(board) + return board.lists.backlog.first if board.lists.backlog.exists? + + board.lists.create(list_type: :backlog, position: nil) + end end end end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index 07ce58b6851..e4c789c4597 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -8,7 +8,8 @@ module Boards board.lists.create(list_type: :backlog) end - board.lists.preload_associated_models + lists = board.lists.preload_associated_models + params[:list_id].present? ? lists.where(id: params[:list_id]) : lists # rubocop: disable CodeReuse/ActiveRecord end end end diff --git a/app/services/branches/create_service.rb b/app/services/branches/create_service.rb index 958dd5c9965..8684da701db 100644 --- a/app/services/branches/create_service.rb +++ b/app/services/branches/create_service.rb @@ -16,8 +16,9 @@ module Branches else error("Invalid reference name: #{ref}") end - rescue Gitlab::Git::PreReceiveError => ex - error(ex.message) + rescue Gitlab::Git::PreReceiveError => e + Gitlab::ErrorTracking.track_exception(e, pre_receive_message: e.raw_message, branch_name: branch_name, ref: ref) + error(e.message) end def success(branch) diff --git a/app/services/ci/build_report_result_service.rb b/app/services/ci/build_report_result_service.rb index 758ba1c73bf..ca66ad8249d 100644 --- a/app/services/ci/build_report_result_service.rb +++ b/app/services/ci/build_report_result_service.rb @@ -3,7 +3,6 @@ module Ci class BuildReportResultService def execute(build) - return unless Feature.enabled?(:build_report_summary, build.project) return unless build.has_test_reports? build.report_results.create!( diff --git a/app/services/ci/change_variable_service.rb b/app/services/ci/change_variable_service.rb new file mode 100644 index 00000000000..f515a335d54 --- /dev/null +++ b/app/services/ci/change_variable_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Ci + class ChangeVariableService < BaseContainerService + def execute + case params[:action] + when :create + container.variables.create(params[:variable_params]) + when :update + variable.tap do |target_variable| + target_variable.update(params[:variable_params].except(:key)) + end + when :destroy + variable.tap do |target_variable| + target_variable.destroy + end + end + end + + private + + def variable + params[:variable] || find_variable + end + + def find_variable + identifier = params[:variable_params].slice(:id).presence || params[:variable_params].slice(:key) + container.variables.find_by!(identifier) # rubocop:disable CodeReuse/ActiveRecord + end + end +end + +::Ci::ChangeVariableService.prepend_if_ee('EE::Ci::ChangeVariableService') diff --git a/app/services/ci/change_variables_service.rb b/app/services/ci/change_variables_service.rb new file mode 100644 index 00000000000..3337eb09411 --- /dev/null +++ b/app/services/ci/change_variables_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ci + class ChangeVariablesService < BaseContainerService + def execute + container.update(params) + end + end +end + +::Ci::ChangeVariablesService.prepend_if_ee('EE::Ci::ChangeVariablesService') diff --git a/app/services/ci/create_cross_project_pipeline_service.rb b/app/services/ci/create_cross_project_pipeline_service.rb index 1700312b941..23207d809d4 100644 --- a/app/services/ci/create_cross_project_pipeline_service.rb +++ b/app/services/ci/create_cross_project_pipeline_service.rb @@ -98,7 +98,7 @@ module Ci end def can_update_branch?(target_ref) - ::Gitlab::UserAccess.new(current_user, project: downstream_project).can_update_branch?(target_ref) + ::Gitlab::UserAccess.new(current_user, container: downstream_project).can_update_branch?(target_ref) end def downstream_project diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb index 9a6e103e5dd..cd3807e0495 100644 --- a/app/services/ci/create_job_artifacts_service.rb +++ b/app/services/ci/create_job_artifacts_service.rb @@ -25,7 +25,7 @@ module Ci if lsif?(artifact_type) headers[:ProcessLsif] = true - headers[:ProcessLsifReferences] = Feature.enabled?(:code_navigation_references, project, default_enabled: false) + headers[:ProcessLsifReferences] = Feature.enabled?(:code_navigation_references, project, default_enabled: true) end success(headers: headers) diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 2d7f5014aa9..70ad18e80eb 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -19,9 +19,13 @@ module Ci Gitlab::Ci::Pipeline::Chain::Limit::Size, Gitlab::Ci::Pipeline::Chain::Validate::External, Gitlab::Ci::Pipeline::Chain::Populate, + Gitlab::Ci::Pipeline::Chain::StopDryRun, Gitlab::Ci::Pipeline::Chain::Create, Gitlab::Ci::Pipeline::Chain::Limit::Activity, - Gitlab::Ci::Pipeline::Chain::Limit::JobActivity].freeze + Gitlab::Ci::Pipeline::Chain::Limit::JobActivity, + Gitlab::Ci::Pipeline::Chain::CancelPendingPipelines, + Gitlab::Ci::Pipeline::Chain::Metrics, + Gitlab::Ci::Pipeline::Chain::Pipeline::Process].freeze # Create a new pipeline in the specified project. # @@ -68,21 +72,14 @@ module Ci bridge: bridge, **extra_options(options)) - sequence = Gitlab::Ci::Pipeline::Chain::Sequence - .new(pipeline, command, SEQUENCE) + # Ensure we never persist the pipeline when dry_run: true + @pipeline.readonly! if command.dry_run? - sequence.build! do |pipeline, sequence| - schedule_head_pipeline_update + Gitlab::Ci::Pipeline::Chain::Sequence + .new(pipeline, command, SEQUENCE) + .build! - if sequence.complete? - cancel_pending_pipelines if project.auto_cancel_pending_pipelines? - pipeline_created_counter.increment(source: source) - - Ci::ProcessPipelineService - .new(pipeline) - .execute(nil, initial_process: true) - end - end + schedule_head_pipeline_update if pipeline.persisted? # If pipeline is not persisted, try to recover IID pipeline.reset_project_iid unless pipeline.persisted? || @@ -110,38 +107,14 @@ module Ci commit.try(:id) end - def cancel_pending_pipelines - Gitlab::OptimisticLocking.retry_lock(auto_cancelable_pipelines) do |cancelables| - cancelables.find_each do |cancelable| - cancelable.auto_cancel_running(pipeline) - end - end - end - - # rubocop: disable CodeReuse/ActiveRecord - def auto_cancelable_pipelines - project.ci_pipelines - .where(ref: pipeline.ref) - .where.not(id: pipeline.same_family_pipeline_ids) - .where.not(sha: project.commit(pipeline.ref).try(:id)) - .alive_or_scheduled - .with_only_interruptible_builds - end - # rubocop: enable CodeReuse/ActiveRecord - - def pipeline_created_counter - @pipeline_created_counter ||= Gitlab::Metrics - .counter(:pipelines_created_total, "Counter of pipelines created") - end - def schedule_head_pipeline_update pipeline.all_merge_requests.opened.each do |merge_request| UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) end end - def extra_options(content: nil) - { content: content } + def extra_options(content: nil, dry_run: false) + { content: content, dry_run: dry_run } end end end diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb index 29d40756ab4..4f1bf0447d2 100644 --- a/app/services/ci/create_web_ide_terminal_service.rb +++ b/app/services/ci/create_web_ide_terminal_service.rb @@ -32,7 +32,7 @@ module Ci Ci::ProcessPipelineService .new(pipeline) - .execute(nil, initial_process: true) + .execute pipeline_created_counter.increment(source: :webide) end diff --git a/app/services/ci/pipeline_processing/legacy_processing_service.rb b/app/services/ci/pipeline_processing/legacy_processing_service.rb deleted file mode 100644 index 56fbc7271da..00000000000 --- a/app/services/ci/pipeline_processing/legacy_processing_service.rb +++ /dev/null @@ -1,122 +0,0 @@ -# frozen_string_literal: true - -module Ci - module PipelineProcessing - class LegacyProcessingService - include Gitlab::Utils::StrongMemoize - - attr_reader :pipeline - - def initialize(pipeline) - @pipeline = pipeline - end - - def execute(trigger_build_ids = nil, initial_process: false) - success = process_stages_for_stage_scheduling - - # we evaluate dependent needs, - # only when the another job has finished - success = process_dag_builds_without_needs || success if initial_process - success = process_dag_builds_with_needs(trigger_build_ids) || success - - @pipeline.update_legacy_status - - success - end - - private - - def process_stages_for_stage_scheduling - stage_indexes_of_created_stage_scheduled_processables.flat_map do |index| - process_stage_for_stage_scheduling(index) - end.any? - end - - def process_stage_for_stage_scheduling(index) - current_status = status_for_prior_stages(index) - - return unless Ci::HasStatus::COMPLETED_STATUSES.include?(current_status) - - created_stage_scheduled_processables_in_stage(index).find_each.select do |build| - process_build(build, current_status) - end.any? - end - - def process_dag_builds_without_needs - created_processables.scheduling_type_dag.without_needs.each do |build| - process_build(build, 'success') - end - end - - def process_dag_builds_with_needs(trigger_build_ids) - return false unless trigger_build_ids.present? - - # we find processables that are dependent: - # 1. because of current dependency, - trigger_build_names = pipeline.processables.latest - .for_ids(trigger_build_ids).names - - # 2. does not have builds that not yet complete - incomplete_build_names = pipeline.processables.latest - .incomplete.names - - # Each found processable is guaranteed here to have completed status - created_processables - .scheduling_type_dag - .with_needs(trigger_build_names) - .without_needs(incomplete_build_names) - .find_each - .map(&method(:process_dag_build_with_needs)) - .any? - end - - def process_dag_build_with_needs(build) - current_status = status_for_build_needs(build.needs.map(&:name)) - - return unless Ci::HasStatus::COMPLETED_STATUSES.include?(current_status) - - process_build(build, current_status) - end - - def process_build(build, current_status) - Gitlab::OptimisticLocking.retry_lock(build) do |subject| - Ci::ProcessBuildService.new(project, subject.user) - .execute(subject, current_status) - end - end - - def status_for_prior_stages(index) - pipeline.processables.status_for_prior_stages(index, project: pipeline.project) - end - - def status_for_build_needs(needs) - pipeline.processables.status_for_names(needs, project: pipeline.project) - end - - # rubocop: disable CodeReuse/ActiveRecord - def stage_indexes_of_created_stage_scheduled_processables - created_stage_scheduled_processables.order(:stage_idx) - .pluck(Arel.sql('DISTINCT stage_idx')) - end - # rubocop: enable CodeReuse/ActiveRecord - - def created_stage_scheduled_processables_in_stage(index) - created_stage_scheduled_processables - .with_preloads - .for_stage(index) - end - - def created_stage_scheduled_processables - created_processables.scheduling_type_stage - end - - def created_processables - pipeline.processables.created - end - - def project - pipeline.project - end - end - end -end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 1f24dce0458..d84ef5fbb93 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -8,20 +8,14 @@ module Ci @pipeline = pipeline end - def execute(trigger_build_ids = nil, initial_process: false) + def execute increment_processing_counter update_retried - if ::Gitlab::Ci::Features.atomic_processing?(pipeline.project) - Ci::PipelineProcessing::AtomicProcessingService - .new(pipeline) - .execute - else - Ci::PipelineProcessing::LegacyProcessingService - .new(pipeline) - .execute(trigger_build_ids, initial_process: initial_process) - end + Ci::PipelineProcessing::AtomicProcessingService + .new(pipeline) + .execute end def metrics diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 3797ea1d96c..04d620d1d38 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -107,23 +107,15 @@ module Ci build.runner_id = runner.id build.runner_session_attributes = params[:session] if params[:session].present? - unless build.has_valid_build_dependencies? - build.drop!(:missing_dependency_failure) - return false - end - - unless build.supported_runner?(params.dig(:info, :features)) - build.drop!(:runner_unsupported) - return false - end + failure_reason, _ = pre_assign_runner_checks.find { |_, check| check.call(build, params) } - if build.archived? - build.drop!(:archived_failure) - return false + if failure_reason + build.drop!(failure_reason) + else + build.run! end - build.run! - true + !failure_reason end def scheduler_failure!(build) @@ -238,6 +230,14 @@ module Ci def job_queue_duration_seconds @job_queue_duration_seconds ||= Gitlab::Metrics.histogram(:job_queue_duration_seconds, 'Request handling execution time', {}, JOB_QUEUE_DURATION_SECONDS_BUCKETS) end + + def pre_assign_runner_checks + { + missing_dependency_failure: -> (build, _) { !build.has_valid_build_dependencies? }, + runner_unsupported: -> (build, params) { !build.supported_runner?(params.dig(:info, :features)) }, + archived_failure: -> (build, _) { build.archived? } + } + end end end diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 4229be6c7d7..2f52f0a39c1 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -22,12 +22,6 @@ module Ci needs += build.needs.map(&:name) end - # In a DAG, the dependencies may have already completed. Figure out - # which builds have succeeded and use them to update the pipeline. If we don't - # do this, then builds will be stuck in the created state since their dependencies - # will never run. - completed_build_ids = pipeline.find_successful_build_ids_by_names(needs) if needs.any? - pipeline.builds.latest.skipped.find_each do |skipped| retry_optimistic_lock(skipped) { |build| build.process } end @@ -38,7 +32,7 @@ module Ci Ci::ProcessPipelineService .new(pipeline) - .execute(completed_build_ids, initial_process: true) + .execute end end end diff --git a/app/services/clusters/aws/authorize_role_service.rb b/app/services/clusters/aws/authorize_role_service.rb index 6eafce0597e..fb620f77b9f 100644 --- a/app/services/clusters/aws/authorize_role_service.rb +++ b/app/services/clusters/aws/authorize_role_service.rb @@ -23,7 +23,9 @@ module Clusters @role = create_or_update_role! Response.new(:ok, credentials) - rescue *ERRORS + rescue *ERRORS => e + Gitlab::ErrorTracking.track_exception(e) + Response.new(:unprocessable_entity, {}) end diff --git a/app/services/clusters/parse_cluster_applications_artifact_service.rb b/app/services/clusters/parse_cluster_applications_artifact_service.rb index 6a0ca0ef9d0..b9b2953b6bd 100644 --- a/app/services/clusters/parse_cluster_applications_artifact_service.rb +++ b/app/services/clusters/parse_cluster_applications_artifact_service.rb @@ -5,7 +5,7 @@ module Clusters include Gitlab::Utils::StrongMemoize MAX_ACCEPTABLE_ARTIFACT_SIZE = 5.kilobytes - RELEASE_NAMES = %w[prometheus cilium].freeze + RELEASE_NAMES = %w[cilium].freeze def initialize(job, current_user) @job = job @@ -14,8 +14,6 @@ module Clusters end def execute(artifact) - return success unless Feature.enabled?(:cluster_applications_artifact, project) - raise ArgumentError, 'Artifact is not cluster_applications file type' unless artifact&.cluster_applications? return error(too_big_error_message, :bad_request) unless artifact.file.size < MAX_ACCEPTABLE_ARTIFACT_SIZE @@ -46,6 +44,8 @@ module Clusters releases = [] artifact.each_blob do |blob| + next if blob.empty? + releases.concat(Gitlab::Kubernetes::Helm::Parsers::ListV2.new(blob).releases) end diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index 03be87f4cc1..7bc3b267a12 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -63,7 +63,7 @@ class CohortsService overall_total = month_totals.first month_totals.map do |total| - { total: total, percentage: total.zero? ? 0 : 100 * total / overall_total } + { total: total, percentage: total == 0 ? 0 : 100 * total / overall_total } end end diff --git a/app/services/commits/change_service.rb b/app/services/commits/change_service.rb index 661e654406e..edb9f04ccd7 100644 --- a/app/services/commits/change_service.rb +++ b/app/services/commits/change_service.rb @@ -22,7 +22,9 @@ module Commits @branch_name, message, start_project: @start_project, - start_branch_name: @start_branch) + start_branch_name: @start_branch, + dry_run: @dry_run + ) rescue Gitlab::Git::Repository::CreateTreeError => ex act = action.to_s.dasherize type = @commit.change_type_title(current_user) diff --git a/app/services/commits/create_service.rb b/app/services/commits/create_service.rb index d80d9bebe9c..a1498da302e 100644 --- a/app/services/commits/create_service.rb +++ b/app/services/commits/create_service.rb @@ -21,6 +21,7 @@ module Commits @start_sha = params[:start_sha] @branch_name = params[:branch_name] @force = params[:force] || false + @dry_run = params[:dry_run] || false end def execute @@ -69,7 +70,7 @@ module Commits end def validate_permissions! - allowed = ::Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(@branch_name) + allowed = ::Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(@branch_name) unless allowed raise_error("You are not allowed to push into this branch") diff --git a/app/services/concerns/incident_management/settings.rb b/app/services/concerns/incident_management/settings.rb index 491bd4fa6bf..13a047ec106 100644 --- a/app/services/concerns/incident_management/settings.rb +++ b/app/services/concerns/incident_management/settings.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true + module IncidentManagement module Settings include Gitlab::Utils::StrongMemoize + delegate :send_email?, to: :incident_management_setting + def incident_management_setting strong_memoize(:incident_management_setting) do project.incident_management_setting || diff --git a/app/services/design_management/move_designs_service.rb b/app/services/design_management/move_designs_service.rb new file mode 100644 index 00000000000..de763caba2f --- /dev/null +++ b/app/services/design_management/move_designs_service.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module DesignManagement + class MoveDesignsService < DesignService + # @param user [User] The current user + # @param [Hash] params + # @option params [DesignManagement::Design] :current_design + # @option params [DesignManagement::Design] :previous_design (nil) + # @option params [DesignManagement::Design] :next_design (nil) + def initialize(user, params) + super(nil, user, params.merge(issue: nil)) + end + + def execute + return error(:no_focus) unless current_design.present? + return error(:cannot_move) unless ::Feature.enabled?(:reorder_designs, project, default_enabled: true) + return error(:cannot_move) unless current_user.can?(:move_design, current_design) + return error(:no_neighbors) unless neighbors.present? + return error(:not_distinct) unless all_distinct? + return error(:not_adjacent) if any_in_gap? + return error(:not_same_issue) unless all_same_issue? + + move_nulls_to_end + current_design.move_between(previous_design, next_design) + current_design.save! + success + end + + def error(message) + ServiceResponse.error(message: message) + end + + def success + ServiceResponse.success + end + + private + + delegate :issue, :project, to: :current_design + + def move_nulls_to_end + moved_records = current_design.class.move_nulls_to_end(issue.designs.in_creation_order) + return if moved_records == 0 + + current_design.reset + next_design&.reset + previous_design&.reset + end + + def neighbors + [previous_design, next_design].compact + end + + def all_distinct? + ids.uniq.size == ids.size + end + + def any_in_gap? + return false unless previous_design&.relative_position && next_design&.relative_position + + !previous_design.immediately_before?(next_design) + end + + def all_same_issue? + issue.designs.id_in(ids).count == ids.size + end + + def ids + @ids ||= [current_design, *neighbors].map(&:id) + end + + def current_design + params[:current_design] + end + + def previous_design + params[:previous_design] + end + + def next_design + params[:next_design] + end + end +end diff --git a/app/services/discussions/capture_diff_note_position_service.rb b/app/services/discussions/capture_diff_note_position_service.rb index 8f12470d9e8..4e8fd90a2e7 100644 --- a/app/services/discussions/capture_diff_note_position_service.rb +++ b/app/services/discussions/capture_diff_note_position_service.rb @@ -50,9 +50,9 @@ module Discussions merge_ref_head = merge_request.merge_ref_head return unless merge_ref_head - start_sha, base_sha = merge_ref_head.parent_ids + start_sha, _ = merge_ref_head.parent_ids new_diff_refs = Gitlab::Diff::DiffRefs.new( - base_sha: base_sha, + base_sha: start_sha, start_sha: start_sha, head_sha: merge_ref_head.id) diff --git a/app/services/discussions/resolve_service.rb b/app/services/discussions/resolve_service.rb index 946fb5f1372..cd5925cd9be 100644 --- a/app/services/discussions/resolve_service.rb +++ b/app/services/discussions/resolve_service.rb @@ -56,7 +56,7 @@ module Discussions def process_auto_merge return unless merge_request - return unless @resolved_count.positive? + return unless @resolved_count > 0 return unless discussions_ready_to_merge? AutoMergeProcessWorker.perform_async(merge_request.id) diff --git a/app/services/event_create_service.rb b/app/services/event_create_service.rb index ad36fe70b3a..3921dbefd06 100644 --- a/app/services/event_create_service.rb +++ b/app/services/event_create_service.rb @@ -100,25 +100,21 @@ class EventCreateService # @param [WikiPage::Meta] wiki_page_meta The event target # @param [User] author The event author # @param [Symbol] action One of the Event::WIKI_ACTIONS + # @param [String] fingerprint The de-duplication fingerprint # - # @return a tuple of event and either :found or :created - def wiki_event(wiki_page_meta, author, action) + # The fingerprint, if provided, should be sufficient to find duplicate events. + # Suitable values would be, for example, the current page SHA. + # + # @return [Event] the event + def wiki_event(wiki_page_meta, author, action, fingerprint) raise IllegalActionError, action unless Event::WIKI_ACTIONS.include?(action) - if duplicate = existing_wiki_event(wiki_page_meta, action) - return duplicate - end + Gitlab::UsageDataCounters::TrackUniqueActions.track_event(event_action: action, event_target: wiki_page_meta.class, author_id: author.id) - event = create_record_event(wiki_page_meta, author, action) - # Ensure that the event is linked in time to the metadata, for non-deletes - unless event.destroyed_action? - time_stamp = wiki_page_meta.updated_at - event.update_columns(updated_at: time_stamp, created_at: time_stamp) - end + duplicate = Event.for_wiki_meta(wiki_page_meta).for_fingerprint(fingerprint).first + return duplicate if duplicate.present? - Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: action, event_target: wiki_page_meta.class, author_id: author.id) - - event + create_record_event(wiki_page_meta, author, action, fingerprint.presence) end def approve_mr(merge_request, current_user) @@ -127,45 +123,38 @@ class EventCreateService private - def existing_wiki_event(wiki_page_meta, action) - if Event.actions.fetch(action) == Event.actions[:destroyed] - most_recent = Event.for_wiki_meta(wiki_page_meta).recent.first - return most_recent if most_recent.present? && Event.actions[most_recent.action] == Event.actions[action] - else - Event.for_wiki_meta(wiki_page_meta).created_at(wiki_page_meta.updated_at).first - end - end - - def create_record_event(record, current_user, status) + def create_record_event(record, current_user, status, fingerprint = nil) create_event(record.resource_parent, current_user, status, - target_id: record.id, target_type: record.class.name) + fingerprint: fingerprint, + target_id: record.id, + target_type: record.class.name) end # If creating several events, this method will insert them all in a single # statement # - # @param [[Eventable, Symbol]] a list of pairs of records and a valid status + # @param [[Eventable, Symbol, String]] a list of tuples of records, a valid status, and fingerprint # @param [User] the author of the event - def create_record_events(pairs, current_user) + def create_record_events(tuples, current_user) base_attrs = { created_at: Time.now.utc, updated_at: Time.now.utc, author_id: current_user.id } - attribute_sets = pairs.map do |record, status| + attribute_sets = tuples.map do |record, status, fingerprint| action = Event.actions[status] raise IllegalActionError, "#{status} is not a valid status" if action.nil? parent_attrs(record.resource_parent) .merge(base_attrs) - .merge(action: action, target_id: record.id, target_type: record.class.name) + .merge(action: action, fingerprint: fingerprint, target_id: record.id, target_type: record.class.name) end result = Event.insert_all(attribute_sets, returning: %w[id]) - pairs.each do |record, status| - Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: status, event_target: record.class, author_id: current_user.id) + tuples.each do |record, status, _| + Gitlab::UsageDataCounters::TrackUniqueActions.track_event(event_action: status, event_target: record.class, author_id: current_user.id) end result @@ -183,7 +172,7 @@ class EventCreateService new_event end - Gitlab::UsageDataCounters::TrackUniqueActions.track_action(event_action: :pushed, event_target: Project, author_id: current_user.id) + Gitlab::UsageDataCounters::TrackUniqueActions.track_event(event_action: :pushed, event_target: Project, author_id: current_user.id) Users::LastPushEventService.new(current_user) .cache_last_push_event(event) @@ -198,7 +187,11 @@ class EventCreateService ) attributes.merge!(parent_attrs(resource_parent)) - Event.create!(attributes) + if attributes[:fingerprint].present? + Event.safe_find_or_create_by!(attributes) + else + Event.create!(attributes) + end end def parent_attrs(resource_parent) diff --git a/app/services/git/process_ref_changes_service.rb b/app/services/git/process_ref_changes_service.rb index 6d1ff97016b..c012c61a337 100644 --- a/app/services/git/process_ref_changes_service.rb +++ b/app/services/git/process_ref_changes_service.rb @@ -75,8 +75,6 @@ module Git end def merge_request_branches_for(changes) - return if Feature.disabled?(:refresh_only_existing_merge_requests_on_push, default_enabled: true) - @merge_requests_branches ||= MergeRequests::PushedBranchesService.new(project, current_user, changes: changes).execute end end diff --git a/app/services/git/tag_push_service.rb b/app/services/git/tag_push_service.rb index 120c4cde94b..641fe8e3916 100644 --- a/app/services/git/tag_push_service.rb +++ b/app/services/git/tag_push_service.rb @@ -26,9 +26,5 @@ module Git def removing_tag? Gitlab::Git.blank_ref?(newrev) end - - def tag_name - Gitlab::Git.ref_name(ref) - end end end diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index b3937a10a70..f9de72f2d5f 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -41,7 +41,12 @@ module Git end def create_event_for(change) - event_service.execute(change.last_known_slug, change.page, change.event_action) + event_service.execute( + change.last_known_slug, + change.page, + change.event_action, + change.sha + ) end def event_service diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb index 14e622dd147..562c43487e9 100644 --- a/app/services/git/wiki_push_service/change.rb +++ b/app/services/git/wiki_push_service/change.rb @@ -33,6 +33,10 @@ module Git strip_extension(raw_change.old_path || raw_change.new_path) end + def sha + change[:newrev] + end + private attr_reader :raw_change, :change, :wiki diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index f2fb494500d..2bd571f60af 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -47,6 +47,19 @@ module Groups raise_transfer_error(:namespace_with_same_path) if namespace_with_same_path? raise_transfer_error(:group_contains_images) if group_projects_contain_registry_images? raise_transfer_error(:cannot_transfer_to_subgroup) if transfer_to_subgroup? + raise_transfer_error(:group_contains_npm_packages) if group_with_npm_packages? + end + + def group_with_npm_packages? + return false unless group.packages_feature_enabled? + + npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm).execute + + different_root_ancestor? && npm_packages.exists? + end + + def different_root_ancestor? + group.root_ancestor != new_parent_group&.root_ancestor end def group_is_already_root? @@ -144,7 +157,8 @@ module Groups same_parent_as_current: s_('TransferGroup|Group is already associated to the parent group.'), invalid_policies: s_("TransferGroup|You don't have enough permissions."), group_contains_images: s_('TransferGroup|Cannot update the path because there are projects under this group that contain Docker images in their Container Registry. Please remove the images from your projects first and try again.'), - cannot_transfer_to_subgroup: s_('TransferGroup|Cannot transfer group to one of its subgroup.') + cannot_transfer_to_subgroup: s_('TransferGroup|Cannot transfer group to one of its subgroup.'), + group_contains_npm_packages: s_('TransferGroup|Group contains projects with NPM packages.') }.freeze end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 948540619ae..81393681dc0 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -17,6 +17,8 @@ module Groups return false unless valid_share_with_group_lock_change? + return false unless valid_path_change_with_npm_packages? + before_assignment_hook(group, params) group.assign_attributes(params) @@ -36,6 +38,20 @@ module Groups private + def valid_path_change_with_npm_packages? + return true unless group.packages_feature_enabled? + return true if params[:path].blank? + return true if !group.has_parent? && group.path == params[:path] + + npm_packages = ::Packages::GroupPackagesFinder.new(current_user, group, package_type: :npm).execute + if npm_packages.exists? + group.errors.add(:path, s_('GroupSettings|cannot change when group contains projects with NPM packages')) + return + end + + true + end + def before_assignment_hook(group, params) # overridden in EE end diff --git a/app/services/import/github_service.rb b/app/services/import/github_service.rb index 0cf17568c78..a2923b1e4f9 100644 --- a/app/services/import/github_service.rb +++ b/app/services/import/github_service.rb @@ -33,7 +33,7 @@ module Import end def repo - @repo ||= client.repo(params[:repo_id].to_i) + @repo ||= client.repository(params[:repo_id].to_i) end def project_name diff --git a/app/services/incident_management/create_incident_label_service.rb b/app/services/incident_management/create_incident_label_service.rb index dbd0d78fa3c..595f5df184f 100644 --- a/app/services/incident_management/create_incident_label_service.rb +++ b/app/services/incident_management/create_incident_label_service.rb @@ -14,27 +14,9 @@ module IncidentManagement def execute label = Labels::FindOrCreateService .new(current_user, project, **LABEL_PROPERTIES) - .execute - - if label.invalid? - log_invalid_label_info(label) - return ServiceResponse.error(payload: { label: label }, message: full_error_message(label)) - end + .execute(skip_authorization: true) ServiceResponse.success(payload: { label: label }) end - - private - - def log_invalid_label_info(label) - log_info <<~TEXT.chomp - Cannot create incident label "#{label.title}" \ - for "#{label.project.full_name}": #{full_error_message(label)}. - TEXT - end - - def full_error_message(label) - label.errors.full_messages.to_sentence - end end end diff --git a/app/services/incident_management/create_issue_service.rb b/app/services/incident_management/create_issue_service.rb deleted file mode 100644 index 5e1e0863115..00000000000 --- a/app/services/incident_management/create_issue_service.rb +++ /dev/null @@ -1,93 +0,0 @@ -# frozen_string_literal: true - -module IncidentManagement - class CreateIssueService < BaseService - include Gitlab::Utils::StrongMemoize - - def initialize(project, params) - super(project, User.alert_bot, params) - end - - def execute - return error_with('setting disabled') unless incident_management_setting.create_issue? - return error_with('invalid alert') unless alert.valid? - - issue = create_issue - return error_with(issue_errors(issue)) unless issue.valid? - - success(issue: issue) - end - - private - - def create_issue - label_result = find_or_create_incident_label - - # Create an unlabelled issue if we couldn't create the label - # due to a race condition. - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 - extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {} - - Issues::CreateService.new( - project, - current_user, - title: issue_title, - description: issue_description, - **extra_params - ).execute - end - - def issue_title - alert.full_title - end - - def issue_description - horizontal_line = "\n\n---\n\n" - - [ - alert_summary, - alert_markdown, - issue_template_content - ].compact.join(horizontal_line) - end - - def find_or_create_incident_label - IncidentManagement::CreateIncidentLabelService.new(project, current_user).execute - end - - def alert_summary - alert.issue_summary_markdown - end - - def alert_markdown - alert.alert_markdown - end - - def alert - strong_memoize(:alert) do - Gitlab::Alerting::Alert.new(project: project, payload: params).present - end - end - - def issue_template_content - incident_management_setting.issue_template_content - end - - def incident_management_setting - strong_memoize(:incident_management_setting) do - project.incident_management_setting || - project.build_incident_management_setting - end - end - - def issue_errors(issue) - issue.errors.full_messages.to_sentence - end - - def error_with(message) - log_error(%{Cannot create incident issue for "#{project.full_name}": #{message}}) - - error(message) - end - end -end diff --git a/app/services/incident_management/incidents/create_service.rb b/app/services/incident_management/incidents/create_service.rb new file mode 100644 index 00000000000..7206eaf51b2 --- /dev/null +++ b/app/services/incident_management/incidents/create_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module IncidentManagement + module Incidents + class CreateService < BaseService + ISSUE_TYPE = 'incident' + + def initialize(project, current_user, title:, description:) + super(project, current_user) + + @title = title + @description = description + end + + def execute + issue = Issues::CreateService.new( + project, + current_user, + title: title, + description: description, + label_ids: [find_or_create_incident_label.id], + issue_type: ISSUE_TYPE + ).execute + + return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid? + + success(issue) + end + + private + + attr_reader :title, :description + + def find_or_create_incident_label + IncidentManagement::CreateIncidentLabelService + .new(project, current_user) + .execute + .payload[:label] + end + + def success(issue) + ServiceResponse.success(payload: { issue: issue }) + end + + def error(message, issue = nil) + ServiceResponse.error(payload: { issue: issue }, message: message) + end + end + end +end diff --git a/app/services/incident_management/pager_duty/create_incident_issue_service.rb b/app/services/incident_management/pager_duty/create_incident_issue_service.rb index ee0feb49e0d..0c9ca2c0add 100644 --- a/app/services/incident_management/pager_duty/create_incident_issue_service.rb +++ b/app/services/incident_management/pager_duty/create_incident_issue_service.rb @@ -12,46 +12,30 @@ module IncidentManagement def execute return forbidden unless webhook_available? - issue = create_issue - return error(issue.errors.full_messages.to_sentence, issue) unless issue.valid? - - success(issue) + create_incident end private alias_method :incident_payload, :params - def create_issue - label_result = find_or_create_incident_label - - # Create an unlabelled issue if we couldn't create the label - # due to a race condition. - # See https://gitlab.com/gitlab-org/gitlab-foss/issues/65042 - extra_params = label_result.success? ? { label_ids: [label_result.payload[:label].id] } : {} - - Issues::CreateService.new( + def create_incident + ::IncidentManagement::Incidents::CreateService.new( project, current_user, title: issue_title, - description: issue_description, - **extra_params + description: issue_description ).execute end def webhook_available? - Feature.enabled?(:pagerduty_webhook, project) && - incident_management_setting.pagerduty_active? + incident_management_setting.pagerduty_active? end def forbidden ServiceResponse.error(message: 'Forbidden', http_status: :forbidden) end - def find_or_create_incident_label - ::IncidentManagement::CreateIncidentLabelService.new(project, current_user).execute - end - def issue_title incident_payload['title'] end @@ -59,14 +43,6 @@ module IncidentManagement def issue_description Gitlab::IncidentManagement::PagerDuty::IncidentIssueDescription.new(incident_payload).to_s end - - def success(issue) - ServiceResponse.success(payload: { issue: issue }) - end - - def error(message, issue = nil) - ServiceResponse.error(payload: { issue: issue }, message: message) - end end end end diff --git a/app/services/incident_management/pager_duty/process_webhook_service.rb b/app/services/incident_management/pager_duty/process_webhook_service.rb index 5dd3186694a..fd8252f75fb 100644 --- a/app/services/incident_management/pager_duty/process_webhook_service.rb +++ b/app/services/incident_management/pager_duty/process_webhook_service.rb @@ -39,8 +39,7 @@ module IncidentManagement end def webhook_setting_active? - Feature.enabled?(:pagerduty_webhook, project) && - incident_management_setting.pagerduty_active? + incident_management_setting.pagerduty_active? end def valid_token?(token) diff --git a/app/services/issuable/clone/base_service.rb b/app/services/issuable/clone/base_service.rb index 0d1640924e5..b2f9c083b5b 100644 --- a/app/services/issuable/clone/base_service.rb +++ b/app/services/issuable/clone/base_service.rb @@ -24,12 +24,34 @@ module Issuable private + def copy_award_emoji + AwardEmojis::CopyService.new(original_entity, new_entity).execute + end + + def copy_notes + Notes::CopyService.new(current_user, original_entity, new_entity).execute + end + def update_new_entity - rewriters = [ContentRewriter, AttributesRewriter] + update_new_entity_description + update_new_entity_attributes + copy_award_emoji + copy_notes + end - rewriters.each do |rewriter| - rewriter.new(current_user, original_entity, new_entity).execute - end + def update_new_entity_description + rewritten_description = MarkdownContentRewriterService.new( + current_user, + original_entity.description, + original_entity.project, + new_parent + ).execute + + new_entity.update!(description: rewritten_description) + end + + def update_new_entity_attributes + AttributesRewriter.new(current_user, original_entity, new_entity).execute end def update_old_entity @@ -47,7 +69,7 @@ module Issuable end def new_parent - new_entity.project || new_entity.group + new_entity.resource_parent end def group diff --git a/app/services/issuable/clone/content_rewriter.rb b/app/services/issuable/clone/content_rewriter.rb deleted file mode 100644 index 67d2f9fd3fe..00000000000 --- a/app/services/issuable/clone/content_rewriter.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module Issuable - module Clone - class ContentRewriter < ::Issuable::Clone::BaseService - def initialize(current_user, original_entity, new_entity) - @current_user = current_user - @original_entity = original_entity - @new_entity = new_entity - @project = original_entity.project - end - - def execute - rewrite_description - rewrite_award_emoji(original_entity, new_entity) - rewrite_notes - end - - private - - def rewrite_description - new_entity.update(description: rewrite_content(original_entity.description)) - end - - def rewrite_notes - new_discussion_ids = {} - original_entity.notes_with_associations.find_each do |note| - new_note = note.dup - new_discussion_ids[note.discussion_id] ||= Discussion.discussion_id(new_note) - new_params = { - project: new_entity.project, - noteable: new_entity, - discussion_id: new_discussion_ids[note.discussion_id], - note: rewrite_content(new_note.note), - note_html: nil, - created_at: note.created_at, - updated_at: note.updated_at - } - - if note.system_note_metadata - new_params[:system_note_metadata] = note.system_note_metadata.dup - - # TODO: Implement copying of description versions when an issue is moved - # https://gitlab.com/gitlab-org/gitlab/issues/32300 - new_params[:system_note_metadata].description_version = nil - end - - new_note.update(new_params) - - rewrite_award_emoji(note, new_note) - end - end - - def rewrite_content(content) - return unless content - - rewriters = [Gitlab::Gfm::ReferenceRewriter, Gitlab::Gfm::UploadsRewriter] - - rewriters.inject(content) do |text, klass| - rewriter = klass.new(text, old_project, current_user) - rewriter.rewrite(new_parent) - end - end - - def rewrite_award_emoji(old_awardable, new_awardable) - old_awardable.award_emoji.each do |award| - new_award = award.dup - new_award.awardable = new_awardable - new_award.save - end - end - end - end -end diff --git a/app/services/issuable/common_system_notes_service.rb b/app/services/issuable/common_system_notes_service.rb index 195616857dc..84024cca68c 100644 --- a/app/services/issuable/common_system_notes_service.rb +++ b/app/services/issuable/common_system_notes_service.rb @@ -108,7 +108,7 @@ module Issuable end def milestone_changes_tracking_enabled? - ::Feature.enabled?(:track_resource_milestone_change_events, issuable.project) + ::Feature.enabled?(:track_resource_milestone_change_events, issuable.project, default_enabled: true) end def create_due_date_note diff --git a/app/services/issues/build_service.rb b/app/services/issues/build_service.rb index e62315de5f9..2de6ed9fa1c 100644 --- a/app/services/issues/build_service.rb +++ b/app/services/issues/build_service.rb @@ -66,7 +66,7 @@ module Issues def whitelisted_issue_params base_params = [:title, :description, :confidential] - admin_params = [:milestone_id] + admin_params = [:milestone_id, :issue_type] if can?(current_user, :admin_issue, project) params.slice(*(base_params + admin_params)) diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 8594808cd44..e431c766df8 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -33,6 +33,7 @@ module Issues notification_service.async.close_issue(issue, current_user, closed_via: closed_via) if notifications todo_service.close_issue(issue, current_user) + resolve_alert(issue) execute_hooks(issue, 'close') invalidate_cache_counts(issue, users: issue.assignees) issue.update_project_counter_caches @@ -58,6 +59,22 @@ module Issues SystemNoteService.change_status(issue, issue.project, current_user, issue.state, current_commit) end + def resolve_alert(issue) + return unless alert = issue.alert_management_alert + return if alert.resolved? + + if alert.resolve + SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: current_user).closed_alert_issue(issue) + else + Gitlab::AppLogger.warn( + message: 'Cannot resolve an associated Alert Management alert', + issue_id: issue.id, + alert_id: alert.id, + alert_errors: alert.errors.messages + ) + end + end + def store_first_mentioned_in_commit_at(issue, merge_request) metrics = issue.metrics return if metrics.nil? || metrics.first_mentioned_in_commit_at diff --git a/app/services/issues/reorder_service.rb b/app/services/issues/reorder_service.rb index 02c18d31b5e..c82ad6ea501 100644 --- a/app/services/issues/reorder_service.rb +++ b/app/services/issues/reorder_service.rb @@ -40,7 +40,7 @@ module Issues def move_between_ids ids = [params[:move_after_id], params[:move_before_id]] .map(&:to_i) - .map { |m| m.positive? ? m : nil } + .map { |m| m > 0 ? m : nil } ids.any? ? ids : nil end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 8d22f0edcdd..ac7baba3b7c 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -22,7 +22,7 @@ module Issues end def after_update(issue) - IssuesChannel.broadcast_to(issue, event: 'updated') if Feature.enabled?(:broadcast_issue_updates, issue.project) + IssuesChannel.broadcast_to(issue, event: 'updated') if Gitlab::ActionCable::Config.in_app? || Feature.enabled?(:broadcast_issue_updates, issue.project) end def handle_changes(issue, options) @@ -43,7 +43,7 @@ module Issues if issue.assignees != old_assignees create_assignee_note(issue, old_assignees) notification_service.async.reassigned_issue(issue, current_user, old_assignees) - todo_service.reassigned_issuable(issue, current_user, old_assignees) + todo_service.reassigned_assignable(issue, current_user, old_assignees) end if issue.previous_changes.include?('confidential') diff --git a/app/services/jira/requests/projects/list_service.rb b/app/services/jira/requests/projects/list_service.rb index 8ecfd358ffb..373c536974a 100644 --- a/app/services/jira/requests/projects/list_service.rb +++ b/app/services/jira/requests/projects/list_service.rb @@ -6,7 +6,7 @@ module Jira class ListService < Base extend ::Gitlab::Utils::Override - def initialize(jira_service, params: {}) + def initialize(jira_service, params = {}) super(jira_service, params) @query = params[:query] @@ -33,9 +33,9 @@ module Jira end def match_query?(jira_project) - query = query.to_s.downcase + downcase_query = query.to_s.downcase - jira_project&.key&.downcase&.include?(query) || jira_project&.name&.downcase&.include?(query) + jira_project&.key&.downcase&.include?(downcase_query) || jira_project&.name&.downcase&.include?(downcase_query) end def empty_payload diff --git a/app/services/jira_import/cloud_users_mapper_service.rb b/app/services/jira_import/cloud_users_mapper_service.rb new file mode 100644 index 00000000000..b1c7aac584f --- /dev/null +++ b/app/services/jira_import/cloud_users_mapper_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module JiraImport + class CloudUsersMapperService < UsersMapperService + private + + def url + "/rest/api/2/users?maxResults=#{MAX_USERS}&startAt=#{start_at.to_i}" + end + + def jira_user_id(jira_user) + jira_user['accountId'] + end + + def jira_user_name(jira_user) + jira_user['displayName'] + end + end +end diff --git a/app/services/jira_import/server_users_mapper_service.rb b/app/services/jira_import/server_users_mapper_service.rb new file mode 100644 index 00000000000..d38d134f55c --- /dev/null +++ b/app/services/jira_import/server_users_mapper_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module JiraImport + class ServerUsersMapperService < UsersMapperService + private + + def url + "/rest/api/2/user/search?username=''&maxResults=#{MAX_USERS}&startAt=#{start_at.to_i}" + end + + def jira_user_id(jira_user) + jira_user['key'] + end + + def jira_user_name(jira_user) + jira_user['name'] + end + end +end diff --git a/app/services/jira_import/start_import_service.rb b/app/services/jira_import/start_import_service.rb index f85f686c61a..88cfe684125 100644 --- a/app/services/jira_import/start_import_service.rb +++ b/app/services/jira_import/start_import_service.rb @@ -42,7 +42,7 @@ module JiraImport ServiceResponse.success(payload: { import_data: jira_import } ) rescue => ex - # in case project.save! raises an erorr + # in case project.save! raises an error Gitlab::ErrorTracking.track_exception(ex, project_id: project.id) jira_import&.do_fail!(error_message: ex.message) build_error_response(ex.message) diff --git a/app/services/jira_import/users_importer.rb b/app/services/jira_import/users_importer.rb index 579d3675073..9babd468d56 100644 --- a/app/services/jira_import/users_importer.rb +++ b/app/services/jira_import/users_importer.rb @@ -2,9 +2,7 @@ module JiraImport class UsersImporter - attr_reader :user, :project, :start_at, :result - - MAX_USERS = 50 + attr_reader :user, :project, :start_at def initialize(user, project, start_at) @project = project @@ -15,29 +13,43 @@ module JiraImport def execute Gitlab::JiraImport.validate_project_settings!(project, user: user) - return ServiceResponse.success(payload: nil) if users.blank? - - result = UsersMapper.new(project, users).execute - ServiceResponse.success(payload: result) + ServiceResponse.success(payload: mapped_users) rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => error - Gitlab::ErrorTracking.track_exception(error, project_id: project.id, request: url) - ServiceResponse.error(message: "There was an error when communicating to Jira: #{error.message}") + Gitlab::ErrorTracking.track_exception(error, project_id: project.id) + ServiceResponse.error(message: "There was an error when communicating to Jira") rescue Projects::ImportService::Error => error ServiceResponse.error(message: error.message) end private - def users - @users ||= client.get(url) + def mapped_users + users_mapper_service.execute + end + + def users_mapper_service + @users_mapper_service ||= user_mapper_service_factory end - def url - "/rest/api/2/users?maxResults=#{MAX_USERS}&startAt=#{start_at.to_i}" + def deployment_type + # TODO: use project.jira_service.deployment_type value when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged + @deployment_type ||= client.ServerInfo.all.deploymentType end def client @client ||= project.jira_service.client end + + def user_mapper_service_factory + # TODO: use deployment_type enum from jira service when https://gitlab.com/gitlab-org/gitlab/-/merge_requests/37003 is merged + case deployment_type.upcase + when JiraService::DEPLOYMENT_TYPES[:server] + ServerUsersMapperService.new(project.jira_service, start_at) + when JiraService::DEPLOYMENT_TYPES[:cloud] + CloudUsersMapperService.new(project.jira_service, start_at) + else + raise ArgumentError + end + end end end diff --git a/app/services/jira_import/users_mapper.rb b/app/services/jira_import/users_mapper.rb deleted file mode 100644 index c3cbeb157bd..00000000000 --- a/app/services/jira_import/users_mapper.rb +++ /dev/null @@ -1,30 +0,0 @@ -# frozen_string_literal: true - -module JiraImport - class UsersMapper - attr_reader :project, :jira_users - - def initialize(project, jira_users) - @project = project - @jira_users = jira_users - end - - def execute - jira_users.to_a.map do |jira_user| - { - jira_account_id: jira_user['accountId'], - jira_display_name: jira_user['displayName'], - jira_email: jira_user['emailAddress'] - }.merge(match_user(jira_user)) - end - end - - private - - # TODO: Matching user by email and displayName will be done as the part - # of follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/219023 - def match_user(jira_user) - { gitlab_id: nil, gitlab_username: nil, gitlab_name: nil } - end - end -end diff --git a/app/services/jira_import/users_mapper_service.rb b/app/services/jira_import/users_mapper_service.rb new file mode 100644 index 00000000000..b5997d77215 --- /dev/null +++ b/app/services/jira_import/users_mapper_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module JiraImport + class UsersMapperService + MAX_USERS = 50 + + attr_reader :jira_service, :start_at + + def initialize(jira_service, start_at) + @jira_service = jira_service + @start_at = start_at + end + + def execute + users.to_a.map do |jira_user| + { + jira_account_id: jira_user_id(jira_user), + jira_display_name: jira_user_name(jira_user), + jira_email: jira_user['emailAddress'] + }.merge(match_user(jira_user)) + end + end + + private + + def users + @users ||= client.get(url) + end + + def client + @client ||= jira_service.client + end + + def url + raise NotImplementedError + end + + def jira_user_id(jira_user) + raise NotImplementedError + end + + def jira_user_name(jira_user) + raise NotImplementedError + end + + # TODO: Matching user by email and displayName will be done as the part + # of follow-up issue: https://gitlab.com/gitlab-org/gitlab/-/issues/219023 + def match_user(jira_user) + { gitlab_id: nil, gitlab_username: nil, gitlab_name: nil } + end + end +end diff --git a/app/services/labels/available_labels_service.rb b/app/services/labels/available_labels_service.rb index 3b226f39d04..1d022740c44 100644 --- a/app/services/labels/available_labels_service.rb +++ b/app/services/labels/available_labels_service.rb @@ -30,7 +30,7 @@ module Labels end def filter_labels_ids_in_param(key) - ids = params[key].to_a + ids = Array.wrap(params[key]) return [] if ids.empty? # rubocop:disable CodeReuse/ActiveRecord @@ -39,12 +39,12 @@ module Labels ids.map(&:to_i) & existing_ids end - private - def available_labels @available_labels ||= LabelsFinder.new(current_user, finder_params).execute end + private + def finder_params params = { include_ancestor_groups: true } diff --git a/app/services/markdown_content_rewriter_service.rb b/app/services/markdown_content_rewriter_service.rb new file mode 100644 index 00000000000..bc6fd592eaa --- /dev/null +++ b/app/services/markdown_content_rewriter_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +# This service passes Markdown content through our GFM rewriter classes +# which rewrite references to GitLab objects and uploads within the content +# based on their visibility by the `target_parent`. +class MarkdownContentRewriterService + REWRITERS = [Gitlab::Gfm::ReferenceRewriter, Gitlab::Gfm::UploadsRewriter].freeze + + def initialize(current_user, content, source_parent, target_parent) + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39654#note_399095117 + raise ArgumentError, 'The rewriter classes require that `source_parent` is a `Project`' \ + unless source_parent.is_a?(Project) + + @current_user = current_user + @content = content.presence + @source_parent = source_parent + @target_parent = target_parent + end + + def execute + return unless content + + REWRITERS.inject(content) do |text, klass| + rewriter = klass.new(text, source_parent, current_user) + rewriter.rewrite(target_parent) + end + end + + private + + attr_reader :current_user, :content, :source_parent, :target_parent +end diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index bc681397039..f0c85ae03c9 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -14,3 +14,5 @@ module MergeRequests end end end + +MergeRequests::AfterCreateService.prepend_if_ee('EE::MergeRequests::AfterCreateService') diff --git a/app/services/merge_requests/conflicts/list_service.rb b/app/services/merge_requests/conflicts/list_service.rb index c6b3a6a1a69..30a493e91ce 100644 --- a/app/services/merge_requests/conflicts/list_service.rb +++ b/app/services/merge_requests/conflicts/list_service.rb @@ -8,7 +8,7 @@ module MergeRequests def can_be_resolved_by?(user) return false unless merge_request.source_project - access = ::Gitlab::UserAccess.new(user, project: merge_request.source_project) + access = ::Gitlab::UserAccess.new(user, container: merge_request.source_project) access.can_push_to_branch?(merge_request.source_branch) end diff --git a/app/services/merge_requests/ff_merge_service.rb b/app/services/merge_requests/ff_merge_service.rb index b3896d61a78..79011094e88 100644 --- a/app/services/merge_requests/ff_merge_service.rb +++ b/app/services/merge_requests/ff_merge_service.rb @@ -22,6 +22,7 @@ module MergeRequests ff_merge rescue Gitlab::Git::PreReceiveError => e + Gitlab::ErrorTracking.track_exception(e, pre_receive_message: e.raw_message, merge_request_id: merge_request&.id) raise MergeError, e.message rescue StandardError => e raise MergeError, "Something went wrong during merge: #{e.message}" diff --git a/app/services/merge_requests/mergeability_check_service.rb b/app/services/merge_requests/mergeability_check_service.rb index d3d661a3b75..a3c39fa2e32 100644 --- a/app/services/merge_requests/mergeability_check_service.rb +++ b/app/services/merge_requests/mergeability_check_service.rb @@ -125,7 +125,7 @@ module MergeRequests end def update_diff_discussion_positions! - return if Feature.disabled?(:merge_ref_head_comments, merge_request.target_project) + return if Feature.disabled?(:merge_ref_head_comments, merge_request.target_project, default_enabled: true) Discussions::CaptureDiffNotePositionsService.new(merge_request).execute end diff --git a/app/services/merge_requests/pushed_branches_service.rb b/app/services/merge_requests/pushed_branches_service.rb index afcf0f7678a..bbe75305d92 100644 --- a/app/services/merge_requests/pushed_branches_service.rb +++ b/app/services/merge_requests/pushed_branches_service.rb @@ -9,7 +9,7 @@ module MergeRequests def execute return [] if branch_names.blank? - source_branches = project.source_of_merge_requests.opened + source_branches = project.source_of_merge_requests.open_and_closed .from_source_branches(branch_names).pluck(:source_branch) target_branches = project.merge_requests.opened diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 29e0c22b155..cf02158b629 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -105,7 +105,7 @@ module MergeRequests def handle_assignees_change(merge_request, old_assignees) create_assignee_note(merge_request, old_assignees) notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees) - todo_service.reassigned_issuable(merge_request, current_user, old_assignees) + todo_service.reassigned_assignable(merge_request, current_user, old_assignees) end def create_branch_change_note(issuable, branch_type, old_branch, new_branch) diff --git a/app/services/metrics/dashboard/base_service.rb b/app/services/metrics/dashboard/base_service.rb index 5fa127d64b2..5be8ae62548 100644 --- a/app/services/metrics/dashboard/base_service.rb +++ b/app/services/metrics/dashboard/base_service.rb @@ -13,7 +13,7 @@ module Metrics STAGES::MetricEndpointInserter, STAGES::VariableEndpointInserter, STAGES::PanelIdsInserter, - STAGES::Sorter, + STAGES::TrackPanelType, STAGES::AlertsInserter, STAGES::UrlValidator ].freeze @@ -34,7 +34,7 @@ module Metrics # Returns an un-processed dashboard from the cache. def raw_dashboard - Gitlab::Metrics::Dashboard::Cache.fetch(cache_key) { get_raw_dashboard } + Gitlab::Metrics::Dashboard::Cache.for(project).fetch(cache_key) { get_raw_dashboard } end # Should return true if this dashboard service is for an out-of-the-box diff --git a/app/services/metrics/dashboard/clone_dashboard_service.rb b/app/services/metrics/dashboard/clone_dashboard_service.rb index a6bece391f2..d9bd9423a1b 100644 --- a/app/services/metrics/dashboard/clone_dashboard_service.rb +++ b/app/services/metrics/dashboard/clone_dashboard_service.rb @@ -9,12 +9,11 @@ module Metrics include Gitlab::Utils::StrongMemoize ALLOWED_FILE_TYPE = '.yml' - USER_DASHBOARDS_DIR = ::Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT + USER_DASHBOARDS_DIR = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT SEQUENCES = { ::Metrics::Dashboard::SystemDashboardService::DASHBOARD_PATH => [ ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::Sorter + ::Gitlab::Metrics::Dashboard::Stages::CustomMetricsInserter ].freeze, ::Metrics::Dashboard::SelfMonitoringDashboardService::DASHBOARD_PATH => [ @@ -22,8 +21,7 @@ module Metrics ].freeze, ::Metrics::Dashboard::ClusterDashboardService::DASHBOARD_PATH => [ - ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, - ::Gitlab::Metrics::Dashboard::Stages::Sorter + ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter ].freeze }.freeze @@ -112,7 +110,7 @@ module Metrics end def push_authorized? - Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch) + Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(branch) end def dashboard_template diff --git a/app/services/metrics/dashboard/cluster_dashboard_service.rb b/app/services/metrics/dashboard/cluster_dashboard_service.rb index bfd5abf1126..4a28e847fdd 100644 --- a/app/services/metrics/dashboard/cluster_dashboard_service.rb +++ b/app/services/metrics/dashboard/cluster_dashboard_service.rb @@ -9,12 +9,11 @@ module Metrics DASHBOARD_NAME = 'Cluster' # SHA256 hash of dashboard content - DASHBOARD_VERSION = '9349afc1d96329c08ab478ea0b77db94ee5cc2549b8c754fba67a7f424666b22' + DASHBOARD_VERSION = 'e1a4f8cc2c044cf32273af2cd775eb484729baac0995db687d81d92686bf588e' SEQUENCE = [ STAGES::ClusterEndpointInserter, - STAGES::PanelIdsInserter, - STAGES::Sorter + STAGES::PanelIdsInserter ].freeze class << self diff --git a/app/services/metrics/dashboard/custom_dashboard_service.rb b/app/services/metrics/dashboard/custom_dashboard_service.rb index 741738cc3af..f0f19bf2ba3 100644 --- a/app/services/metrics/dashboard/custom_dashboard_service.rb +++ b/app/services/metrics/dashboard/custom_dashboard_service.rb @@ -6,16 +6,13 @@ module Metrics module Dashboard class CustomDashboardService < ::Metrics::Dashboard::BaseService - DASHBOARD_ROOT = ".gitlab/dashboards" - class << self def valid_params?(params) params[:dashboard_path].present? end def all_dashboard_paths(project) - file_finder(project) - .list_files_for(DASHBOARD_ROOT) + project.repository.user_defined_metrics_dashboard_paths .map do |filepath| { path: filepath, @@ -27,13 +24,9 @@ module Metrics end end - def file_finder(project) - Gitlab::Template::Finders::RepoTemplateFinder.new(project, DASHBOARD_ROOT, '.yml') - end - # Grabs the filepath after the base directory. def name_for_path(filepath) - filepath.delete_prefix("#{DASHBOARD_ROOT}/") + filepath.delete_prefix("#{Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT}/") end end @@ -41,7 +34,7 @@ module Metrics # Searches the project repo for a custom-defined dashboard. def get_raw_dashboard - yml = self.class.file_finder(project).read(dashboard_path) + yml = Gitlab::Metrics::Dashboard::RepoDashboardFinder.read_dashboard(project, dashboard_path) load_yaml(yml) end diff --git a/app/services/metrics/dashboard/custom_metric_embed_service.rb b/app/services/metrics/dashboard/custom_metric_embed_service.rb index 22b592c7aa5..229bd17f5cf 100644 --- a/app/services/metrics/dashboard/custom_metric_embed_service.rb +++ b/app/services/metrics/dashboard/custom_metric_embed_service.rb @@ -75,7 +75,6 @@ module Metrics def panels [{ type: DEFAULT_PANEL_TYPE, - weight: DEFAULT_PANEL_WEIGHT, title: title, y_label: y_label, metrics: metrics.map(&:to_metric_hash) diff --git a/app/services/metrics/dashboard/dynamic_embed_service.rb b/app/services/metrics/dashboard/dynamic_embed_service.rb index ff540c30579..0b198ecbbe9 100644 --- a/app/services/metrics/dashboard/dynamic_embed_service.rb +++ b/app/services/metrics/dashboard/dynamic_embed_service.rb @@ -18,7 +18,7 @@ module Metrics # Determines whether the provided params are sufficient # to uniquely identify a panel from a yml-defined dashboard. # - # See https://docs.gitlab.com/ee/user/project/integrations/prometheus.html#defining-custom-dashboards-per-project + # See https://docs.gitlab.com/ee/operations/metrics/dashboards/index.html#defining-custom-dashboards-per-project # for additional info on defining custom dashboards. def valid_params?(params) [ diff --git a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb index 08d65413e1d..33c93b25c71 100644 --- a/app/services/metrics/dashboard/gitlab_alert_embed_service.rb +++ b/app/services/metrics/dashboard/gitlab_alert_embed_service.rb @@ -8,6 +8,7 @@ module Metrics module Dashboard class GitlabAlertEmbedService < ::Metrics::Dashboard::BaseEmbedService + include Gitlab::Metrics::Dashboard::Defaults include Gitlab::Utils::StrongMemoize SEQUENCE = [ @@ -63,7 +64,8 @@ module Metrics { title: prometheus_metric.title, y_label: prometheus_metric.y_label, - metrics: [prometheus_metric.to_metric_hash] + metrics: [prometheus_metric.to_metric_hash], + type: DEFAULT_PANEL_TYPE } end diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index 8e72a185406..b8c5c17c738 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -33,7 +33,7 @@ module Metrics def from_cache(project_id, user_id, grafana_url) project = Project.find(project_id) - user = User.find(user_id) + user = User.find(user_id) if user_id.present? new(project, user, grafana_url: grafana_url) end @@ -56,7 +56,7 @@ module Metrics end def cache_key(*args) - [project.id, current_user.id, grafana_url] + [project.id, current_user&.id, grafana_url] end # Required for ReactiveCaching; Usage overridden by diff --git a/app/services/metrics/dashboard/panel_preview_service.rb b/app/services/metrics/dashboard/panel_preview_service.rb new file mode 100644 index 00000000000..5b24d817fb6 --- /dev/null +++ b/app/services/metrics/dashboard/panel_preview_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Ingest YAML fragment with metrics dashboard panel definition +# https://docs.gitlab.com/ee/operations/metrics/dashboards/yaml.html#panel-panels-properties +# process it and returns renderable json version +module Metrics + module Dashboard + class PanelPreviewService + SEQUENCE = [ + ::Gitlab::Metrics::Dashboard::Stages::CommonMetricsInserter, + ::Gitlab::Metrics::Dashboard::Stages::MetricEndpointInserter, + ::Gitlab::Metrics::Dashboard::Stages::PanelIdsInserter, + ::Gitlab::Metrics::Dashboard::Stages::AlertsInserter, + ::Gitlab::Metrics::Dashboard::Stages::UrlValidator + ].freeze + + HANDLED_PROCESSING_ERRORS = [ + Gitlab::Metrics::Dashboard::Errors::DashboardProcessingError, + Gitlab::Config::Loader::Yaml::NotHashError, + Gitlab::Config::Loader::Yaml::DataTooLargeError, + Gitlab::Config::Loader::FormatError + ].freeze + + def initialize(project, panel_yaml, environment) + @project, @panel_yaml, @environment = project, panel_yaml, environment + end + + def execute + dashboard = ::Gitlab::Metrics::Dashboard::Processor.new(project, dashboard_structure, SEQUENCE, environment: environment).process + ServiceResponse.success(payload: dashboard[:panel_groups][0][:panels][0]) + rescue *HANDLED_PROCESSING_ERRORS => error + ServiceResponse.error(message: error.message) + end + + private + + attr_accessor :project, :panel_yaml, :environment + + def dashboard_structure + { + panel_groups: [ + { + panels: [panel_hash] + } + ] + } + end + + def panel_hash + ::Gitlab::Config::Loader::Yaml.new(panel_yaml).load_raw! + end + end + end +end diff --git a/app/services/metrics/dashboard/pod_dashboard_service.rb b/app/services/metrics/dashboard/pod_dashboard_service.rb index 8699189deac..c83f8618460 100644 --- a/app/services/metrics/dashboard/pod_dashboard_service.rb +++ b/app/services/metrics/dashboard/pod_dashboard_service.rb @@ -4,10 +4,28 @@ module Metrics module Dashboard class PodDashboardService < ::Metrics::Dashboard::PredefinedDashboardService DASHBOARD_PATH = 'config/prometheus/pod_metrics.yml' - DASHBOARD_NAME = 'Pod Health' + DASHBOARD_NAME = N_('K8s pod health') # SHA256 hash of dashboard content - DASHBOARD_VERSION = 'f12f641d2575d5dcb69e2c633ff5231dbd879ad35020567d8fc4e1090bfdb4b4' + DASHBOARD_VERSION = '3a91b32f91b2dd3d90275333c0ea3630b3f3f37c4296ede5b5eef59bf523d66b' + + SEQUENCE = [ + STAGES::MetricEndpointInserter, + STAGES::VariableEndpointInserter, + STAGES::PanelIdsInserter + ].freeze + + class << self + def all_dashboard_paths(_project) + [{ + path: DASHBOARD_PATH, + display_name: _(DASHBOARD_NAME), + default: false, + system_dashboard: false, + out_of_the_box_dashboard: out_of_the_box_dashboard? + }] + end + end private diff --git a/app/services/metrics/dashboard/predefined_dashboard_service.rb b/app/services/metrics/dashboard/predefined_dashboard_service.rb index c21083475f0..abdef66c2e0 100644 --- a/app/services/metrics/dashboard/predefined_dashboard_service.rb +++ b/app/services/metrics/dashboard/predefined_dashboard_service.rb @@ -12,8 +12,7 @@ module Metrics SEQUENCE = [ STAGES::MetricEndpointInserter, STAGES::VariableEndpointInserter, - STAGES::PanelIdsInserter, - STAGES::Sorter + STAGES::PanelIdsInserter ].freeze class << self @@ -30,6 +29,11 @@ module Metrics end end + # Returns an un-processed dashboard from the cache. + def raw_dashboard + Gitlab::Metrics::Dashboard::Cache.fetch(cache_key) { get_raw_dashboard } + end + private def dashboard_version diff --git a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb index f1f5cd7d77e..0651e569d07 100644 --- a/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb +++ b/app/services/metrics/dashboard/self_monitoring_dashboard_service.rb @@ -6,17 +6,16 @@ module Metrics module Dashboard class SelfMonitoringDashboardService < ::Metrics::Dashboard::PredefinedDashboardService DASHBOARD_PATH = 'config/prometheus/self_monitoring_default.yml' - DASHBOARD_NAME = N_('Default dashboard') + DASHBOARD_NAME = N_('Overview') # SHA256 hash of dashboard content - DASHBOARD_VERSION = '1dff3e3cb76e73c8e368823c98b34c61aec0d141978450dea195a3b3dc2415d6' + DASHBOARD_VERSION = '0f7ade2022e09f1a1da8e883cc95d84b9557e1e0e9b015c51eb964296aa73098' SEQUENCE = [ STAGES::CustomMetricsInserter, STAGES::MetricEndpointInserter, STAGES::VariableEndpointInserter, - STAGES::PanelIdsInserter, - STAGES::Sorter + STAGES::PanelIdsInserter ].freeze class << self @@ -29,7 +28,7 @@ module Metrics path: DASHBOARD_PATH, display_name: _(DASHBOARD_NAME), default: true, - system_dashboard: false, + system_dashboard: true, out_of_the_box_dashboard: out_of_the_box_dashboard? }] end diff --git a/app/services/metrics/dashboard/system_dashboard_service.rb b/app/services/metrics/dashboard/system_dashboard_service.rb index 5c3562b8ca0..29b8f23f40d 100644 --- a/app/services/metrics/dashboard/system_dashboard_service.rb +++ b/app/services/metrics/dashboard/system_dashboard_service.rb @@ -6,10 +6,10 @@ module Metrics module Dashboard class SystemDashboardService < ::Metrics::Dashboard::PredefinedDashboardService DASHBOARD_PATH = 'config/prometheus/common_metrics.yml' - DASHBOARD_NAME = N_('Default dashboard') + DASHBOARD_NAME = N_('Overview') # SHA256 hash of dashboard content - DASHBOARD_VERSION = '4685fe386c25b1a786b3be18f79bb2ee9828019003e003816284cdb634fa3e13' + DASHBOARD_VERSION = 'ce9ae27d2913f637de851d61099bc4151583eae68b1386a2176339ef6e653223' SEQUENCE = [ STAGES::CommonMetricsInserter, @@ -18,7 +18,6 @@ module Metrics STAGES::MetricEndpointInserter, STAGES::VariableEndpointInserter, STAGES::PanelIdsInserter, - STAGES::Sorter, STAGES::AlertsInserter ].freeze diff --git a/app/services/metrics/dashboard/update_dashboard_service.rb b/app/services/metrics/dashboard/update_dashboard_service.rb index d37d06a0222..d990e96ecb5 100644 --- a/app/services/metrics/dashboard/update_dashboard_service.rb +++ b/app/services/metrics/dashboard/update_dashboard_service.rb @@ -7,7 +7,7 @@ module Metrics include Stepable ALLOWED_FILE_TYPE = '.yml' - USER_DASHBOARDS_DIR = ::Metrics::Dashboard::CustomDashboardService::DASHBOARD_ROOT + USER_DASHBOARDS_DIR = ::Gitlab::Metrics::Dashboard::RepoDashboardFinder::DASHBOARD_ROOT steps :check_push_authorized, :check_branch_name, @@ -68,7 +68,7 @@ module Metrics end def push_authorized? - Gitlab::UserAccess.new(current_user, project: project).can_push_to_branch?(branch) + Gitlab::UserAccess.new(current_user, container: project).can_push_to_branch?(branch) end def valid_branch_name? diff --git a/app/services/notes/copy_service.rb b/app/services/notes/copy_service.rb new file mode 100644 index 00000000000..6e5b4596602 --- /dev/null +++ b/app/services/notes/copy_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# This service copies Notes from one Noteable to another. +# +# It expects the calling code to have performed the necessary authorization +# checks in order to allow the copy to happen. +module Notes + class CopyService + def initialize(current_user, from_noteable, to_noteable) + raise ArgumentError, 'Noteables must be different' if from_noteable == to_noteable + + @current_user = current_user + @from_noteable = from_noteable + @to_noteable = to_noteable + @from_project = from_noteable.project + @new_discussion_ids = {} + end + + def execute + from_noteable.notes_with_associations.find_each do |note| + copy_note(note) + end + + ServiceResponse.success + end + + private + + attr_reader :from_noteable, :to_noteable, :from_project, :current_user, :new_discussion_ids + + def copy_note(note) + new_note = note.dup + new_params = params_from_note(note, new_note) + new_note.update!(new_params) + + copy_award_emoji(note, new_note) + end + + def params_from_note(note, new_note) + new_discussion_ids[note.discussion_id] ||= Discussion.discussion_id(new_note) + rewritten_note = MarkdownContentRewriterService.new(current_user, note.note, from_project, to_noteable.resource_parent).execute + + new_params = { + project: to_noteable.project, + noteable: to_noteable, + discussion_id: new_discussion_ids[note.discussion_id], + note: rewritten_note, + note_html: nil, + created_at: note.created_at, + updated_at: note.updated_at + } + + if note.system_note_metadata + new_params[:system_note_metadata] = note.system_note_metadata.dup + + # TODO: Implement copying of description versions when an issue is moved + # https://gitlab.com/gitlab-org/gitlab/issues/32300 + new_params[:system_note_metadata].description_version = nil + end + + new_params + end + + def copy_award_emoji(from_note, to_note) + AwardEmojis::CopyService.new(from_note, to_note).execute + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 935dbfb72dd..4f2329a42f2 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -54,7 +54,8 @@ module Notes def when_saved(note) if note.part_of_discussion? && note.discussion.can_convert_to_discussion? - note.discussion.convert_to_discussion!(save: true) + note.discussion.convert_to_discussion!.save + note.clear_memoization(:discussion) end todo_service.new_note(note, current_user) @@ -66,13 +67,13 @@ module Notes Gitlab::Tracking.event('Notes::CreateService', 'execute', tracking_data_for(note)) end - if Feature.enabled?(:merge_ref_head_comments, project) && note.for_merge_request? && note.diff_note? && note.start_of_discussion? + if Feature.enabled?(:merge_ref_head_comments, project, default_enabled: true) && note.for_merge_request? && note.diff_note? && note.start_of_discussion? Discussions::CaptureDiffNotePositionService.new(note.noteable, note.diff_file&.paths).execute(note.discussion) end end def do_commands(note, update_params, message, only_commands) - return if quick_actions_service.commands_executed_count.to_i.zero? + return if quick_actions_service.commands_executed_count.to_i == 0 if update_params.present? quick_actions_service.apply_updates(update_params, note) diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index c670f01e502..36d9f1d7867 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -50,6 +50,11 @@ module Notes return if update_params.empty? return unless supported?(note) + # We need the `id` after the note is persisted + if update_params[:spend_time] + update_params[:spend_time][:note_id] = note.id + end + self.class.noteable_update_service(note).new(note.resource_parent, current_user, update_params).execute(note.noteable) end end diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb index 047848fd1a3..193d3080078 100644 --- a/app/services/notes/update_service.rb +++ b/app/services/notes/update_service.rb @@ -25,7 +25,7 @@ module Notes note.note = content end - unless only_commands + unless only_commands || note.for_personal_snippet? note.create_new_cross_references!(current_user) update_todos(note, old_mentioned_users) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index a4e935a8cf5..909a0033d12 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -66,6 +66,13 @@ class NotificationService mailer.access_token_about_to_expire_email(user).deliver_later end + # Notify the user when at least one of their personal access tokens has expired today + def access_token_expired(user) + return unless user.can?(:receive_notifications) + + mailer.access_token_expired_email(user).deliver_later + end + # Notify a user when a previously unknown IP or device is used to # sign in to their account def unknown_sign_in(user, ip, time) @@ -424,8 +431,8 @@ class NotificationService end def project_was_moved(project, old_path_with_namespace) - recipients = project.private? ? project.team.members_in_project_and_ancestors : project.team.members - recipients = notifiable_users(recipients, :mention, project: project) + recipients = project_moved_recipients(project) + recipients = notifiable_users(recipients, :custom, custom_action: :moved_project, project: project) recipients.each do |recipient| mailer.project_was_moved_email( @@ -705,6 +712,14 @@ class NotificationService recipients end + def project_moved_recipients(project) + finder = MembersFinder.new(project, nil, params: { + active_without_invites_and_requests: true, + owners_and_maintainers: true + }) + finder.execute.preload_user_and_notification_settings.map(&:user) + end + def project_maintainers_recipients(target, action:) NotificationRecipients::BuildService.build_project_maintainers_recipients(target, action: action) end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index 50a008843ad..505f45a7b21 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -3,21 +3,33 @@ module Packages module Maven class FindOrCreatePackageService < BaseService MAVEN_METADATA_FILE = 'maven-metadata.xml'.freeze + SNAPSHOT_TERM = '-SNAPSHOT'.freeze def execute - package = ::Packages::Maven::PackageFinder - .new(params[:path], current_user, project: project).execute + package = + ::Packages::Maven::PackageFinder.new(params[:path], current_user, project: project) + .execute unless package - if params[:file_name] == MAVEN_METADATA_FILE - # Maven uploads several files during `mvn deploy` in next order: - # - my-company/my-app/1.0-SNAPSHOT/my-app.jar - # - my-company/my-app/1.0-SNAPSHOT/my-app.pom - # - my-company/my-app/1.0-SNAPSHOT/maven-metadata.xml - # - my-company/my-app/maven-metadata.xml - # - # The last xml file does not have VERSION in URL because it contains - # information about all versions. + # Maven uploads several files during `mvn deploy` in next order: + # - my-company/my-app/1.0-SNAPSHOT/my-app.jar + # - my-company/my-app/1.0-SNAPSHOT/my-app.pom + # - my-company/my-app/1.0-SNAPSHOT/maven-metadata.xml + # - my-company/my-app/maven-metadata.xml + # + # The last xml file does not have VERSION in URL because it contains + # information about all versions. When uploading such file, we create + # a package with a version set to `nil`. The xml file with a version + # is only created and uploaded for snapshot versions. + # + # Gradle has a different upload order: + # - my-company/my-app/1.0-SNAPSHOT/maven-metadata.xml + # - my-company/my-app/1.0-SNAPSHOT/my-app.jar + # - my-company/my-app/1.0-SNAPSHOT/my-app.pom + # - my-company/my-app/maven-metadata.xml + # + # The first upload has to create the proper package (the one with the version set). + if params[:file_name] == MAVEN_METADATA_FILE && !params[:path]&.ends_with?(SNAPSHOT_TERM) package_name, version = params[:path], nil else package_name, _, version = params[:path].rpartition('/') @@ -30,8 +42,9 @@ module Packages build: params[:build] } - package = ::Packages::Maven::CreatePackageService - .new(project, current_user, package_params).execute + package = + ::Packages::Maven::CreatePackageService.new(project, current_user, package_params) + .execute end package diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index 6fec398fab0..59125669f7d 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -42,7 +42,7 @@ module Packages def valid_package_file? package_file && package_file.package&.nuget? && - package_file.file.size.positive? + package_file.file.size > 0 # rubocop:disable Style/ZeroLengthPredicate end def extract_metadata(file) diff --git a/app/services/packages/nuget/search_service.rb b/app/services/packages/nuget/search_service.rb index f7e09e11819..b95aa30bec1 100644 --- a/app/services/packages/nuget/search_service.rb +++ b/app/services/packages/nuget/search_service.rb @@ -21,8 +21,8 @@ module Packages @search_term = search_term @options = DEFAULT_OPTIONS.merge(options) - raise ArgumentError, 'negative per_page' if per_page.negative? - raise ArgumentError, 'negative padding' if padding.negative? + raise ArgumentError, 'negative per_page' if per_page < 0 + raise ArgumentError, 'negative padding' if padding < 0 end def execute diff --git a/app/services/personal_access_tokens/revoke_service.rb b/app/services/personal_access_tokens/revoke_service.rb new file mode 100644 index 00000000000..16ba42bd317 --- /dev/null +++ b/app/services/personal_access_tokens/revoke_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class RevokeService + attr_reader :token, :current_user + + def initialize(current_user = nil, params = { token: nil }) + @current_user = current_user + @token = params[:token] + end + + def execute + return ServiceResponse.error(message: 'Not permitted to revoke') unless revocation_permitted? + + if token.revoke! + ServiceResponse.success(message: success_message) + else + ServiceResponse.error(message: error_message) + end + end + + private + + def error_message + _("Could not revoke personal access token %{personal_access_token_name}.") % { personal_access_token_name: token.name } + end + + def success_message + _("Revoked personal access token %{personal_access_token_name}!") % { personal_access_token_name: token.name } + end + + def revocation_permitted? + Ability.allowed?(current_user, :revoke_token, token) + end + end +end diff --git a/app/services/product_analytics/build_graph_service.rb b/app/services/product_analytics/build_graph_service.rb new file mode 100644 index 00000000000..31f9f093bb9 --- /dev/null +++ b/app/services/product_analytics/build_graph_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module ProductAnalytics + class BuildGraphService + def initialize(project, params) + @project = project + @params = params + end + + def execute + graph = @params[:graph].to_sym + timerange = @params[:timerange].days + + results = product_analytics_events.count_by_graph(graph, timerange) + + { + id: graph, + keys: results.keys, + values: results.values + } + end + + private + + def product_analytics_events + @project.product_analytics_events + end + end +end diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index e08bc8efb15..f883c8c7bd8 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -58,10 +58,6 @@ module Projects AlertManagement::Alert.not_resolved.for_fingerprint(project, fingerprint).first end - def send_email? - incident_management_setting.send_email? - end - def process_incident_issues(alert) return if alert.issue diff --git a/app/services/projects/auto_devops/disable_service.rb b/app/services/projects/auto_devops/disable_service.rb index c90510c581d..e10668ac9bd 100644 --- a/app/services/projects/auto_devops/disable_service.rb +++ b/app/services/projects/auto_devops/disable_service.rb @@ -23,7 +23,7 @@ module Projects # for more context. # rubocop: disable CodeReuse/ActiveRecord def first_pipeline_failure? - auto_devops_pipelines.success.limit(1).count.zero? && + auto_devops_pipelines.success.limit(1).count == 0 && auto_devops_pipelines.failed.limit(1).count.nonzero? end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/projects/cleanup_service.rb b/app/services/projects/cleanup_service.rb index 04624b96bf0..4ced9feff00 100644 --- a/app/services/projects/cleanup_service.rb +++ b/app/services/projects/cleanup_service.rb @@ -22,7 +22,7 @@ module Projects apply_bfg_object_map! # Remove older objects that are no longer referenced - GitGarbageCollectWorker.new.perform(project.id, :gc) + GitGarbageCollectWorker.new.perform(project.id, :gc, "project_cleanup:gc:#{project.id}") # The cache may now be inaccurate, and holding onto it could prevent # bugs assuming the presence of some object from manifesting for some diff --git a/app/services/projects/container_repository/cleanup_tags_service.rb b/app/services/projects/container_repository/cleanup_tags_service.rb index c5809c11ea9..204a54ff23a 100644 --- a/app/services/projects/container_repository/cleanup_tags_service.rb +++ b/app/services/projects/container_repository/cleanup_tags_service.rb @@ -39,11 +39,8 @@ module Projects end def filter_by_name(tags) - # Technical Debt: https://gitlab.com/gitlab-org/gitlab/issues/207267 - # name_regex to be removed when container_expiration_policies is updated - # to have both regex columns - regex_delete = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_delete'] || params['name_regex']}\\z") - regex_retain = Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_keep']}\\z") + regex_delete = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_delete'] || params['name_regex']}\\z") + regex_retain = ::Gitlab::UntrustedRegexp.new("\\A#{params['name_regex_keep']}\\z") tags.select do |tag| # regex_retain will override any overlapping matches by regex_delete @@ -81,11 +78,11 @@ module Projects def valid_regex? %w(name_regex_delete name_regex name_regex_keep).each do |param_name| regex = params[param_name] - Gitlab::UntrustedRegexp.new(regex) unless regex.blank? + ::Gitlab::UntrustedRegexp.new(regex) unless regex.blank? end true rescue RegexpError => e - Gitlab::ErrorTracking.log_exception(e, project_id: project.id) + ::Gitlab::ErrorTracking.log_exception(e, project_id: project.id) false end end diff --git a/app/services/projects/container_repository/delete_tags_service.rb b/app/services/projects/container_repository/delete_tags_service.rb index 5d4059710bb..a23a6a369b2 100644 --- a/app/services/projects/container_repository/delete_tags_service.rb +++ b/app/services/projects/container_repository/delete_tags_service.rb @@ -6,65 +6,35 @@ module Projects LOG_DATA_BASE = { service_class: self.to_s }.freeze def execute(container_repository) + @container_repository = container_repository return error('access denied') unless can?(current_user, :destroy_container_image, project) - tag_names = params[:tags] - return error('not tags specified') if tag_names.blank? + @tag_names = params[:tags] + return error('not tags specified') if @tag_names.blank? - smart_delete(container_repository, tag_names) + delete_tags end private - # Delete tags by name with a single DELETE request. This is only supported - # by the GitLab Container Registry fork. See - # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23325 for details. - def fast_delete(container_repository, tag_names) - deleted_tags = tag_names.select do |name| - container_repository.delete_tag_by_name(name) - end - - deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags') + def delete_tags + delete_service.execute + .tap(&method(:log_response)) end - # Replace a tag on the registry with a dummy tag. - # This is a hack as the registry doesn't support deleting individual - # tags. This code effectively pushes a dummy image and assigns the tag to it. - # This way when the tag is deleted only the dummy image is affected. - # This is used to preverse compatibility with third-party registries that - # don't support fast delete. - # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion - def slow_delete(container_repository, tag_names) - # generates the blobs for the dummy image - dummy_manifest = container_repository.client.generate_empty_manifest(container_repository.path) - return error('could not generate manifest') if dummy_manifest.nil? - - deleted_tags = replace_tag_manifests(container_repository, dummy_manifest, tag_names) + def delete_service + fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true) - # Deletes the dummy image - # All created tag digests are the same since they all have the same dummy image. - # a single delete is sufficient to remove all tags with it - if deleted_tags.any? && container_repository.delete_tag_by_digest(deleted_tags.each_value.first) - success(deleted: deleted_tags.keys) + if fast_delete_enabled && @container_repository.client.supports_tag_delete? + ::Projects::ContainerRepository::Gitlab::DeleteTagsService.new(@container_repository, @tag_names) else - error('could not delete tags') + ::Projects::ContainerRepository::ThirdParty::DeleteTagsService.new(@container_repository, @tag_names) end end - def smart_delete(container_repository, tag_names) - fast_delete_enabled = Feature.enabled?(:container_registry_fast_tag_delete, default_enabled: true) - response = if fast_delete_enabled && container_repository.client.supports_tag_delete? - fast_delete(container_repository, tag_names) - else - slow_delete(container_repository, tag_names) - end - - response.tap { |r| log_response(r, container_repository) } - end - - def log_response(response, container_repository) + def log_response(response) log_data = LOG_DATA_BASE.merge( - container_repository_id: container_repository.id, + container_repository_id: @container_repository.id, message: 'deleted tags' ) @@ -76,26 +46,6 @@ module Projects log_error(log_data) end end - - # update the manifests of the tags with the new dummy image - def replace_tag_manifests(container_repository, dummy_manifest, tag_names) - deleted_tags = {} - - tag_names.each do |name| - digest = container_repository.client.put_tag(container_repository.path, name, dummy_manifest) - next unless digest - - deleted_tags[name] = digest - end - - # make sure the digests are the same (it should always be) - digests = deleted_tags.values.uniq - - # rubocop: disable CodeReuse/ActiveRecord - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new('multiple tag digests')) if digests.many? - - deleted_tags - end end end end diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb new file mode 100644 index 00000000000..18049648e26 --- /dev/null +++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + module Gitlab + class DeleteTagsService + include BaseServiceUtility + + def initialize(container_repository, tag_names) + @container_repository = container_repository + @tag_names = tag_names + end + + # Delete tags by name with a single DELETE request. This is only supported + # by the GitLab Container Registry fork. See + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23325 for details. + def execute + return success(deleted: []) if @tag_names.empty? + + deleted_tags = @tag_names.select do |name| + @container_repository.delete_tag_by_name(name) + end + + deleted_tags.any? ? success(deleted: deleted_tags) : error('could not delete tags') + end + end + end + end +end diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb new file mode 100644 index 00000000000..6504172109e --- /dev/null +++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Projects + module ContainerRepository + module ThirdParty + class DeleteTagsService + include BaseServiceUtility + + def initialize(container_repository, tag_names) + @container_repository = container_repository + @tag_names = tag_names + end + + # Replace a tag on the registry with a dummy tag. + # This is a hack as the registry doesn't support deleting individual + # tags. This code effectively pushes a dummy image and assigns the tag to it. + # This way when the tag is deleted only the dummy image is affected. + # This is used to preverse compatibility with third-party registries that + # don't support fast delete. + # See https://gitlab.com/gitlab-org/gitlab/issues/15737 for a discussion + def execute + return success(deleted: []) if @tag_names.empty? + + # generates the blobs for the dummy image + dummy_manifest = @container_repository.client.generate_empty_manifest(@container_repository.path) + return error('could not generate manifest') if dummy_manifest.nil? + + deleted_tags = replace_tag_manifests(dummy_manifest) + + # Deletes the dummy image + # All created tag digests are the same since they all have the same dummy image. + # a single delete is sufficient to remove all tags with it + if deleted_tags.any? && @container_repository.delete_tag_by_digest(deleted_tags.each_value.first) + success(deleted: deleted_tags.keys) + else + error('could not delete tags') + end + end + + private + + # update the manifests of the tags with the new dummy image + def replace_tag_manifests(dummy_manifest) + deleted_tags = {} + + @tag_names.each do |name| + digest = @container_repository.client.put_tag(@container_repository.path, name, dummy_manifest) + next unless digest + + deleted_tags[name] = digest + end + + # make sure the digests are the same (it should always be) + digests = deleted_tags.values.uniq + + # rubocop: disable CodeReuse/ActiveRecord + Gitlab::ErrorTracking.track_and_raise_for_dev_exception(ArgumentError.new('multiple tag digests')) if digests.many? + + deleted_tags + end + end + end + end +end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 6569277ad9d..33ed1151407 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -55,9 +55,11 @@ module Projects save_project_and_import_data - after_create_actions if @project.persisted? + Gitlab::ApplicationContext.with_context(related_class: "Projects::CreateService", project: @project) do + after_create_actions if @project.persisted? - import_schedule + import_schedule + end @project rescue ActiveRecord::RecordInvalid => e diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 2e949f2fc55..37487261f2c 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -31,7 +31,7 @@ module Projects attempt_destroy_transaction(project) system_hook_service.execute_hooks_for(project, :destroy) - log_info("Project \"#{project.full_path}\" was removed") + log_info("Project \"#{project.full_path}\" was deleted") current_user.invalidate_personal_projects_count diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index 6ac53b15ef9..bb660d47887 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -14,10 +14,10 @@ module Projects @valid_fork_targets ||= ForkTargetsFinder.new(@project, current_user).execute end - def valid_fork_target? + def valid_fork_target?(namespace = target_namespace) return true if current_user.admin? - valid_fork_targets.include?(target_namespace) + valid_fork_targets.include?(namespace) end private diff --git a/app/services/projects/prometheus/alerts/notify_service.rb b/app/services/projects/prometheus/alerts/notify_service.rb index ea557ebe20f..d32ead76d00 100644 --- a/app/services/projects/prometheus/alerts/notify_service.rb +++ b/app/services/projects/prometheus/alerts/notify_service.rb @@ -42,10 +42,6 @@ module Projects Gitlab::Utils::DeepSize.new(params).valid? end - def send_email? - incident_management_setting.send_email && firings.any? - end - def firings @firings ||= alerts_by_status('firing') end @@ -125,6 +121,8 @@ module Projects end def send_alert_email + return unless firings.any? + notification_service .async .prometheus_alerts_fired(project, firings) diff --git a/app/services/projects/propagate_service_template.rb b/app/services/projects/propagate_service_template.rb index b6465810fde..54d09b354a1 100644 --- a/app/services/projects/propagate_service_template.rb +++ b/app/services/projects/propagate_service_template.rb @@ -66,7 +66,7 @@ module Projects # rubocop: disable CodeReuse/ActiveRecord def run_callbacks(batch) - if active_external_issue_tracker? + if template.issue_tracker? Project.where(id: batch).update_all(has_external_issue_tracker: true) end @@ -76,10 +76,6 @@ module Projects end # rubocop: enable CodeReuse/ActiveRecord - def active_external_issue_tracker? - template.issue_tracker? && !template.default - end - def active_external_wiki? template.type == 'ExternalWikiService' end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 60e5b7e2639..0fb70feec86 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -55,10 +55,18 @@ module Projects raise TransferError.new(s_('TransferProject|Project cannot be transferred, because tags are present in its container registry')) end + if project.has_packages?(:npm) && !new_namespace_has_same_root?(project) + raise TransferError.new(s_("TransferProject|Root namespace can't be updated if project has NPM packages")) + end + attempt_transfer_transaction end # rubocop: enable CodeReuse/ActiveRecord + def new_namespace_has_same_root?(project) + new_namespace.root_ancestor == project.namespace.root_ancestor + end + def attempt_transfer_transaction Project.transaction do project.expire_caches_before_rename(@old_path) diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index 674071ad92a..88c17d502df 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -11,15 +11,20 @@ module Projects end def execute - if file_equals?(pages_config_file, pages_config_json) - return success(reload: false) + # If the pages were never deployed, we can't write out the config, as the + # directory would not exist. + # https://gitlab.com/gitlab-org/gitlab/-/issues/235139 + return success unless project.pages_deployed? + + unless file_equals?(pages_config_file, pages_config_json) + update_file(pages_config_file, pages_config_json) + reload_daemon end - update_file(pages_config_file, pages_config_json) - reload_daemon - success(reload: true) + success rescue => e - error(e.message) + Gitlab::ErrorTracking.track_exception(e) + error(e.message, pass_back: { exception: e }) end private diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 59389a0fa65..334f5993d15 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -136,7 +136,7 @@ module Projects def max_size max_pages_size = max_size_from_settings - return ::Gitlab::Pages::MAX_SIZE if max_pages_size.zero? + return ::Gitlab::Pages::MAX_SIZE if max_pages_size == 0 max_pages_size end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index d6c0d647468..fe2610f89fb 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -25,14 +25,8 @@ module Projects def update_mirror(remote_mirror) remote_mirror.update_start! - remote_mirror.ensure_remote! - # https://gitlab.com/gitlab-org/gitaly/-/issues/2670 - if Feature.disabled?(:gitaly_ruby_remote_branches_ls_remote, default_enabled: true) - repository.fetch_remote(remote_mirror.remote_name, ssh_auth: remote_mirror, no_tags: true) - end - response = remote_mirror.update_repository if response.divergent_refs.any? diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index 7b346c09635..a479d53a43a 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -6,8 +6,7 @@ module Projects SameFilesystemError = Class.new(Error) attr_reader :repository_storage_move - delegate :project, :destination_storage_name, to: :repository_storage_move - delegate :repository, to: :project + delegate :project, :source_storage_name, :destination_storage_name, to: :repository_storage_move def initialize(repository_storage_move) @repository_storage_move = repository_storage_move @@ -20,21 +19,22 @@ module Projects repository_storage_move.start! end - raise SameFilesystemError if same_filesystem?(repository.storage, destination_storage_name) + raise SameFilesystemError if same_filesystem?(source_storage_name, destination_storage_name) mirror_repositories - project.transaction do - mark_old_paths_for_archive - - repository_storage_move.finish! + repository_storage_move.transaction do + repository_storage_move.finish_replication! project.leave_pool_repository project.track_project_repository end + remove_old_paths enqueue_housekeeping + repository_storage_move.finish_cleanup! + ServiceResponse.success rescue StandardError => e @@ -91,36 +91,31 @@ module Projects end end - def mark_old_paths_for_archive - old_repository_storage = project.repository_storage - new_project_path = moved_path(project.disk_path) - - # Notice that the block passed to `run_after_commit` will run with `repository_storage_move` - # as its context - repository_storage_move.run_after_commit do - GitlabShellWorker.perform_async(:mv_repository, - old_repository_storage, - project.disk_path, - new_project_path) - - if project.wiki.repository_exists? - GitlabShellWorker.perform_async(:mv_repository, - old_repository_storage, - project.wiki.disk_path, - "#{new_project_path}.wiki") - end - - if project.design_repository.exists? - GitlabShellWorker.perform_async(:mv_repository, - old_repository_storage, - project.design_repository.disk_path, - "#{new_project_path}.design") - end + def remove_old_paths + Gitlab::Git::Repository.new( + source_storage_name, + "#{project.disk_path}.git", + nil, + nil + ).remove + + if project.wiki.repository_exists? + Gitlab::Git::Repository.new( + source_storage_name, + "#{project.wiki.disk_path}.git", + nil, + nil + ).remove end - end - def moved_path(path) - "#{path}+#{project.id}+moved+#{Time.current.to_i}" + if project.design_repository.exists? + Gitlab::Git::Repository.new( + source_storage_name, + "#{project.design_repository.disk_path}.git", + nil, + nil + ).remove + end end # The underlying FetchInternalRemote call uses a `git fetch` to move data diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 58c9bce963b..c9ba7cde199 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -142,7 +142,13 @@ module Projects end def update_pages_config - Projects::UpdatePagesConfigurationService.new(project).execute + return unless project.pages_deployed? + + if Feature.enabled?(:async_update_pages_config, project) + PagesUpdateConfigurationWorker.perform_async(project.id) + else + Projects::UpdatePagesConfigurationService.new(project).execute + end end def changing_pages_https_only? diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 2d0a78feb8e..c253154c1b7 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -32,7 +32,9 @@ module ResourceAccessTokens attr_reader :resource_type, :resource def feature_enabled? - ::Feature.enabled?(:resource_access_token, resource) + return false if ::Gitlab.com? + + ::Feature.enabled?(:resource_access_token, resource, default_enabled: true) end def has_permission_to_create? diff --git a/app/services/resource_events/base_change_timebox_service.rb b/app/services/resource_events/base_change_timebox_service.rb new file mode 100644 index 00000000000..5c83f7b12f7 --- /dev/null +++ b/app/services/resource_events/base_change_timebox_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ResourceEvents + class BaseChangeTimeboxService + attr_reader :resource, :user, :event_created_at + + def initialize(resource, user, created_at: Time.current) + @resource = resource + @user = user + @event_created_at = created_at + end + + def execute + create_event + + resource.expire_note_etag_cache + end + + private + + def create_event + raise NotImplementedError + end + + def build_resource_args + key = resource.class.name.foreign_key + + { + user_id: user.id, + created_at: event_created_at, + key => resource.id + } + end + end +end diff --git a/app/services/resource_events/change_milestone_service.rb b/app/services/resource_events/change_milestone_service.rb index 82c3e2acad5..dcdf87599ac 100644 --- a/app/services/resource_events/change_milestone_service.rb +++ b/app/services/resource_events/change_milestone_service.rb @@ -1,37 +1,30 @@ # frozen_string_literal: true module ResourceEvents - class ChangeMilestoneService - attr_reader :resource, :user, :event_created_at, :milestone, :old_milestone + class ChangeMilestoneService < BaseChangeTimeboxService + attr_reader :milestone, :old_milestone def initialize(resource, user, created_at: Time.current, old_milestone:) - @resource = resource - @user = user - @event_created_at = created_at + super(resource, user, created_at: created_at) + @milestone = resource&.milestone @old_milestone = old_milestone end - def execute - ResourceMilestoneEvent.create(build_resource_args) + private - resource.expire_note_etag_cache + def create_event + ResourceMilestoneEvent.create(build_resource_args) end - private - def build_resource_args action = milestone.blank? ? :remove : :add - key = resource.class.name.foreign_key - { - user_id: user.id, - created_at: event_created_at, - milestone_id: action == :add ? milestone&.id : old_milestone&.id, + super.merge({ state: ResourceMilestoneEvent.states[resource.state], - action: ResourceMilestoneEvent.actions[action], - key => resource.id - } + action: ResourceTimeboxEvent.actions[action], + milestone_id: milestone.blank? ? old_milestone&.id : milestone&.id + }) end end end diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 650dc197f8c..278cf389e07 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -70,7 +70,7 @@ class SearchService def per_page per_page_param = params[:per_page].to_i - return DEFAULT_PER_PAGE unless per_page_param.positive? + return DEFAULT_PER_PAGE unless per_page_param > 0 [MAX_PER_PAGE, per_page_param].min end diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb index 08106b04d18..c837b75f439 100644 --- a/app/services/service_desk_settings/update_service.rb +++ b/app/services/service_desk_settings/update_service.rb @@ -9,6 +9,8 @@ module ServiceDeskSettings params.delete(:project_key) end + params[:project_key] = nil if params[:project_key].blank? + if settings.update(params) success else diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index 4bbde3a9648..9191943caa7 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true class SubmitUsagePingService - URL = 'https://version.gitlab.com/usage_data' + PRODUCTION_URL = 'https://version.gitlab.com/usage_data' + STAGING_URL = 'https://gitlab-services-version-gitlab-com-staging.gs-staging.gitlab.org/usage_data' METRICS = %w[leader_issues instance_issues percentage_issues leader_notes instance_notes percentage_notes leader_milestones instance_milestones percentage_milestones @@ -13,28 +14,42 @@ class SubmitUsagePingService percentage_projects_prometheus_active leader_service_desk_issues instance_service_desk_issues percentage_service_desk_issues].freeze + SubmissionError = Class.new(StandardError) + def execute - return false unless Gitlab::CurrentSettings.usage_ping_enabled? - return false if User.single_user&.requires_usage_stats_consent? + return unless Gitlab::CurrentSettings.usage_ping_enabled? + return if User.single_user&.requires_usage_stats_consent? + + usage_data = Gitlab::UsageData.data(force_refresh: true) + + raise SubmissionError.new('Usage data is blank') if usage_data.blank? + + raw_usage_data = save_raw_usage_data(usage_data) response = Gitlab::HTTP.post( - URL, - body: Gitlab::UsageData.to_json(force_refresh: true), + url, + body: usage_data.to_json, allow_local_requests: true, headers: { 'Content-type' => 'application/json' } ) - store_metrics(response) + raise SubmissionError.new("Unsuccessful response code: #{response.code}") unless response.success? - true - rescue Gitlab::HTTP::Error => e - Gitlab::AppLogger.info("Unable to contact GitLab, Inc.: #{e}") + raw_usage_data.update_sent_at! if raw_usage_data - false + store_metrics(response) end private + def save_raw_usage_data(usage_data) + return unless Feature.enabled?(:save_raw_usage_data) + + RawUsageData.safe_find_or_create_by(recorded_at: usage_data[:recorded_at]) do |record| + record.payload = usage_data + end + end + def store_metrics(response) metrics = response['conv_index'] || response['dev_ops_score'] @@ -44,4 +59,13 @@ class SubmitUsagePingService metrics.slice(*METRICS) ) end + + # See https://gitlab.com/gitlab-org/gitlab/-/issues/233615 for details + def url + if Rails.env.production? + PRODUCTION_URL + else + STAGING_URL + end + end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index db5693960b2..6702596f17c 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -297,7 +297,7 @@ module SystemNoteService end def new_alert_issue(alert, issue, author) - ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).new_alert_issue(alert, issue) + ::SystemNotes::AlertManagementService.new(noteable: alert, project: alert.project, author: author).new_alert_issue(issue) end private diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb index 55a6a17bbca..f835376727a 100644 --- a/app/services/system_notes/alert_management_service.rb +++ b/app/services/system_notes/alert_management_service.rb @@ -12,7 +12,7 @@ module SystemNotes # # Returns the created Note object def change_alert_status(alert) - status = AlertManagement::Alert::STATUSES.key(alert.status).to_s.titleize + status = alert.state.to_s.titleize body = "changed the status to **#{status}**" create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) @@ -20,7 +20,6 @@ module SystemNotes # Called when an issue is created based on an AlertManagement::Alert # - # alert - AlertManagement::Alert object. # issue - Issue object. # # Example Note text: @@ -28,10 +27,25 @@ module SystemNotes # "created issue #17 for this alert" # # Returns the created Note object - def new_alert_issue(alert, issue) + def new_alert_issue(issue) body = "created issue #{issue.to_reference(project)} for this alert" create_note(NoteSummary.new(noteable, project, author, body, action: 'alert_issue_added')) end + + # Called when an AlertManagement::Alert is resolved due to the associated issue being closed + # + # issue - Issue object. + # + # Example Note text: + # + # "changed the status to Resolved by closing issue #17" + # + # Returns the created Note object + def closed_alert_issue(issue) + body = "changed the status to **Resolved** by closing issue #{issue.to_reference(project)}" + + create_note(NoteSummary.new(noteable, project, author, body, action: 'status')) + end end end diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 76261aa716e..7535db54130 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -341,7 +341,7 @@ module SystemNotes def state_change_tracking_enabled? noteable.respond_to?(:resource_state_events) && - ::Feature.enabled?(:track_resource_state_change_events, noteable.project) + ::Feature.enabled?(:track_resource_state_change_events, noteable.project, default_enabled: true) end end end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index ec15bdde8d7..a3db2ae7947 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -49,11 +49,11 @@ class TodoService todo_users.each(&:update_todos_count_cache) end - # When we reassign an issuable we should: + # When we reassign an assignable object (issuable, alert) we should: # - # * create a pending todo for new assignee if issuable is assigned + # * create a pending todo for new assignee if object is assigned # - def reassigned_issuable(issuable, current_user, old_assignees = []) + def reassigned_assignable(issuable, current_user, old_assignees = []) create_assignment_todo(issuable, current_user, old_assignees) end @@ -154,14 +154,6 @@ class TodoService resolve_todos_for_target(awardable, current_user) end - # When assigning an alert we should: - # - # * create a pending todo for new assignee if alert is assigned - # - def assign_alert(alert, current_user) - create_assignment_todo(alert, current_user, []) - end - # When user marks a target as todo def mark_todo(target, current_user) attributes = attributes_for_todo(target.project, target, current_user, Todo::MARKED) diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 621266f00e1..d0939d5a542 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -53,7 +53,13 @@ module Users current = current_authorizations_per_project fresh = fresh_access_levels_per_project - remove = current.each_with_object([]) do |(project_id, row), array| + # Delete projects that have more than one authorizations associated with + # the user. The correct authorization is added to the ``add`` array in the + # next stage. + remove = projects_with_duplicates + current.except!(*projects_with_duplicates) + + remove |= current.each_with_object([]) do |(project_id, row), array| # rows not in the new list or with a different access level should be # removed. if !fresh[project_id] || fresh[project_id] != row.access_level @@ -106,7 +112,7 @@ module Users end def current_authorizations - user.project_authorizations.select(:project_id, :access_level) + @current_authorizations ||= user.project_authorizations.select(:project_id, :access_level) end def fresh_authorizations @@ -116,5 +122,12 @@ module Users private attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback + + def projects_with_duplicates + @projects_with_duplicates ||= current_authorizations + .group_by(&:project_id) + .select { |project_id, authorizations| authorizations.count > 1 } + .keys + end end end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 91a26ff45b1..d6cb0729d6f 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -13,6 +13,7 @@ class WebHookService end end + REQUEST_BODY_SIZE_LIMIT = 25.megabytes GITLAB_EVENT_HEADER = 'X-Gitlab-Event' attr_accessor :hook, :data, :hook_name, :request_options @@ -53,17 +54,18 @@ class WebHookService http_status: response.code, message: response.to_s } - rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, Gitlab::HTTP::BlockedUrlError, Gitlab::HTTP::RedirectionTooDeep => e + rescue SocketError, OpenSSL::SSL::SSLError, Errno::ECONNRESET, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, Gitlab::HTTP::BlockedUrlError, Gitlab::HTTP::RedirectionTooDeep, Gitlab::Json::LimitedEncoder::LimitExceeded => e + execution_duration = Gitlab::Metrics::System.monotonic_time - start_time log_execution( trigger: hook_name, url: hook.url, request_data: data, response: InternalErrorResponse.new, - execution_duration: Gitlab::Metrics::System.monotonic_time - start_time, + execution_duration: execution_duration, error_message: e.to_s ) - Gitlab::AppLogger.error("WebHook Error => #{e}") + Gitlab::AppLogger.error("WebHook Error after #{execution_duration.to_i.seconds}s => #{e}") { status: :error, @@ -83,7 +85,7 @@ class WebHookService def make_request(url, basic_auth = false) Gitlab::HTTP.post(url, - body: data.to_json, + body: Gitlab::Json::LimitedEncoder.encode(data, limit: REQUEST_BODY_SIZE_LIMIT), headers: build_headers(hook_name), verify: hook.enable_ssl_verification, basic_auth: basic_auth, diff --git a/app/services/wiki_pages/base_service.rb b/app/services/wiki_pages/base_service.rb index 2967684f7bc..fd234630633 100644 --- a/app/services/wiki_pages/base_service.rb +++ b/app/services/wiki_pages/base_service.rb @@ -44,7 +44,9 @@ module WikiPages end def create_wiki_event(page) - response = WikiPages::EventCreateService.new(current_user).execute(slug_for_page(page), page, event_action) + response = WikiPages::EventCreateService + .new(current_user) + .execute(slug_for_page(page), page, event_action, fingerprint(page)) log_error(response.message) if response.error? end @@ -52,6 +54,10 @@ module WikiPages def slug_for_page(page) page.slug end + + def fingerprint(page) + page.sha + end end end diff --git a/app/services/wiki_pages/create_service.rb b/app/services/wiki_pages/create_service.rb index 63107445782..9702876effa 100644 --- a/app/services/wiki_pages/create_service.rb +++ b/app/services/wiki_pages/create_service.rb @@ -10,7 +10,11 @@ module WikiPages execute_hooks(page) end - page + if page.persisted? + ServiceResponse.success(payload: { page: page }) + else + ServiceResponse.error(message: _('Could not create wiki page'), payload: { page: page }) + end end def usage_counter_action diff --git a/app/services/wiki_pages/destroy_service.rb b/app/services/wiki_pages/destroy_service.rb index d59c27bb92a..ab5abe1c82b 100644 --- a/app/services/wiki_pages/destroy_service.rb +++ b/app/services/wiki_pages/destroy_service.rb @@ -21,5 +21,9 @@ module WikiPages def event_action :destroyed end + + def fingerprint(page) + page.wiki.repository.head_commit.sha + end end end diff --git a/app/services/wiki_pages/event_create_service.rb b/app/services/wiki_pages/event_create_service.rb index 0453c90d693..ebfc2414f9e 100644 --- a/app/services/wiki_pages/event_create_service.rb +++ b/app/services/wiki_pages/event_create_service.rb @@ -9,11 +9,11 @@ module WikiPages @author = author end - def execute(slug, page, action) + def execute(slug, page, action, event_fingerprint) event = Event.transaction do wiki_page_meta = WikiPage::Meta.find_or_create(slug, page) - ::EventCreateService.new.wiki_event(wiki_page_meta, author, action) + ::EventCreateService.new.wiki_event(wiki_page_meta, author, action, event_fingerprint) end ServiceResponse.success(payload: { event: event }) diff --git a/app/uploaders/ci/pipeline_artifact_uploader.rb b/app/uploaders/ci/pipeline_artifact_uploader.rb new file mode 100644 index 00000000000..d3a83c5d633 --- /dev/null +++ b/app/uploaders/ci/pipeline_artifact_uploader.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ci + class PipelineArtifactUploader < GitlabUploader + include ObjectStorage::Concern + + storage_options Gitlab.config.artifacts + + alias_method :upload, :model + + def store_dir + dynamic_segment + end + + private + + def dynamic_segment + Gitlab::HashedPath.new('pipelines', model.pipeline_id, 'artifacts', model.id, root_hash: model.project_id) + end + end +end diff --git a/app/uploaders/job_artifact_uploader.rb b/app/uploaders/job_artifact_uploader.rb index 400f0b3dcc6..47976c909e8 100644 --- a/app/uploaders/job_artifact_uploader.rb +++ b/app/uploaders/job_artifact_uploader.rb @@ -36,15 +36,10 @@ class JobArtifactUploader < GitlabUploader end def hashed_path - File.join(disk_hash[0..1], disk_hash[2..3], disk_hash, - model.created_at.utc.strftime('%Y_%m_%d'), model.job_id.to_s, model.id.to_s) + Gitlab::HashedPath.new(model.created_at.utc.strftime('%Y_%m_%d'), model.job_id, model.id, root_hash: model.project_id) end def legacy_path File.join(model.created_at.utc.strftime('%Y_%m'), model.project_id.to_s, model.job_id.to_s) end - - def disk_hash - @disk_hash ||= Digest::SHA2.hexdigest(model.project_id.to_s) - end end diff --git a/app/uploaders/object_storage.rb b/app/uploaders/object_storage.rb index 63b6197a04d..ac1f022c63f 100644 --- a/app/uploaders/object_storage.rb +++ b/app/uploaders/object_storage.rb @@ -169,10 +169,6 @@ module ObjectStorage object_store_options.connection.to_hash.deep_symbolize_keys end - def consolidated_settings? - object_store_options.fetch('consolidated_settings', false) - end - def remote_store_path object_store_options.remote_directory end @@ -193,14 +189,18 @@ module ObjectStorage File.join(self.root, TMP_UPLOAD_PATH) end + def object_store_config + ObjectStorage::Config.new(object_store_options) + end + def workhorse_remote_upload_options(has_length:, maximum_size: nil) return unless self.object_store_enabled? return unless self.direct_upload_enabled? id = [CarrierWave.generate_cache_id, SecureRandom.hex].join('-') upload_path = File.join(TMP_UPLOAD_PATH, id) - direct_upload = ObjectStorage::DirectUpload.new(self.object_store_credentials, remote_store_path, upload_path, - has_length: has_length, maximum_size: maximum_size, consolidated_settings: consolidated_settings?) + direct_upload = ObjectStorage::DirectUpload.new(self.object_store_config, upload_path, + has_length: has_length, maximum_size: maximum_size) direct_upload.to_hash.merge(ID: id) end @@ -283,6 +283,10 @@ module ObjectStorage self.class.object_store_credentials end + def fog_attributes + @fog_attributes ||= self.class.object_store_config.fog_attributes + end + # Set ACL of uploaded objects to not-public (fog-aws)[1] or no ACL at all # (fog-google). Value is ignored by other supported backends (fog-aliyun, # fog-openstack, fog-rackspace) diff --git a/app/uploaders/packages/package_file_uploader.rb b/app/uploaders/packages/package_file_uploader.rb index 20fcf0a7a32..28545b9fcdf 100644 --- a/app/uploaders/packages/package_file_uploader.rb +++ b/app/uploaders/packages/package_file_uploader.rb @@ -20,11 +20,6 @@ class Packages::PackageFileUploader < GitlabUploader private def dynamic_segment - File.join(disk_hash[0..1], disk_hash[2..3], disk_hash, - 'packages', model.package.id.to_s, 'files', model.id.to_s) - end - - def disk_hash - @disk_hash ||= Digest::SHA2.hexdigest(model.package.project_id.to_s) + Gitlab::HashedPath.new('packages', model.package.id, 'files', model.id, root_hash: model.package.project_id) end end diff --git a/app/validators/html_safety_validator.rb b/app/validators/html_safety_validator.rb index 29e7d445697..6ba009fa534 100644 --- a/app/validators/html_safety_validator.rb +++ b/app/validators/html_safety_validator.rb @@ -21,7 +21,7 @@ class HtmlSafetyValidator < ActiveModel::EachValidator end def self.error_message - _("cannot contain HTML/XML tags, including any word between angle brackets (<,>).") + _("cannot contain HTML/XML tags, including any word between angle brackets (<,>).") end private diff --git a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json index 1154a4c45b8..995f2ad6616 100644 --- a/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json +++ b/app/validators/json_schemas/security_ci_configuration_schemas/sast_ui_schema.json @@ -4,83 +4,48 @@ "field" : "SECURE_ANALYZERS_PREFIX", "label" : "Image prefix", "type": "string", - "default_value": "registry.gitlab.com/gitlab-org/security-products/analyzers", - "value": "" + "default_value": "", + "value": "", + "size": "MEDIUM", + "description": "Analyzer image's registry prefix (or Name of the registry providing the analyzers' image)" }, { "field" : "SAST_EXCLUDED_PATHS", "label" : "Excluded Paths", "type": "string", - "default_value": "spec, test, tests, tmp", - "value": "" + "default_value": "", + "value": "", + "size": "LARGE", + "description": "Comma-separated list of paths to be excluded from analyzer output. Patterns can be globs, file paths, or folder paths." }, { - "field" : "SECURE_ANALYZER_IMAGE_TAG", + "field" : "SAST_ANALYZER_IMAGE_TAG", "label" : "Image tag", "type": "string", - "options": [], - "default_value": "2", - "value": "" - }, - { - "field" : "SAST_DISABLED", - "label" : "Disable SAST", - "type": "options", - "options": [ - { - "value" :"true", - "label" : "true (disables SAST)" - }, - { - "value":"false", - "label":"false (enables SAST)" - } - ], - "default_value": "false", - "value": "" + "default_value": "", + "value": "", + "size": "SMALL", + "description": "Analyzer image's tag" } ], "pipeline": [ { "field" : "stage", "label" : "Stage", - "type": "dropdown", - "options": [ - { - "value" :"test", - "label" : "test" - }, - { - "value":"build", - "label":"build" - } - ], - "default_value": "test", - "value": "" - }, - { - "field" : "allow_failure", - "label" : "Allow Failure", - "type": "options", - "options": [ - { - "value" :"true", - "label" : "Allows pipeline failure" - }, - { - "value": "false", - "label": "Does not allow pipeline failure" - } - ], - "default_value": "true", - "value": "" + "type": "string", + "default_value": "", + "value": "", + "size": "MEDIUM", + "description": "Pipeline stage in which the scan jobs run" }, { - "field" : "rules", - "label" : "Rules", - "type": "multiline", + "field" : "SEARCH_MAX_DEPTH", + "label" : "Search maximum depth", + "type": "string", "default_value": "", - "value": "" + "value": "", + "size": "SMALL", + "description": "Maximum depth of language and framework detection" } ], "analyzers": [ diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 65a2f1d42e1..184249bcaba 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -9,14 +9,6 @@ = _('Gravatar enabled') .form-group - = f.label :namespace_storage_size_limit, class: 'label-bold' do - = _('Maximum namespace storage (MB)') - = f.number_field :namespace_storage_size_limit, class: 'form-control', min: 0 - %span.form-text.text-muted - = _('Includes repository storage, wiki storage, LFS objects, build artifacts and packages. 0 for unlimited.') - = link_to _('More information'), help_page_path('user/admin_area/settings/account_and_limit_settings', anchor: 'maximum-namespace-storage-size'), target: '_blank' - - .form-group = f.label :default_projects_limit, _('Default projects limit'), class: 'label-bold' = f.number_field :default_projects_limit, class: 'form-control', title: _('Maximum number of projects.'), data: { toggle: 'tooltip', container: 'body' } .form-group @@ -52,7 +44,7 @@ = f.check_box :user_default_external, class: 'form-check-input' = f.label :user_default_external, class: 'form-check-label' do = _('Newly registered users will by default be external') - .prepend-top-10 + .gl-mt-3 = _('Internal users') = f.text_field :user_default_internal_regex, placeholder: _('Regex pattern'), class: 'form-control gl-mt-2' .help-block @@ -68,5 +60,5 @@ = render_if_exists 'admin/application_settings/updating_name_disabled_for_users', form: f = render_if_exists 'admin/application_settings/availability_on_namespace_setting', form: f - - = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button' + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: 'btn btn-success qa-save-changes-button' diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index 410820dfb85..7051b790fb7 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -39,13 +39,13 @@ = f.label :default_artifacts_expire_in, _('Default artifacts expiration'), class: 'label-bold' = f.text_field :default_artifacts_expire_in, class: 'form-control' .form-text.text-muted - = _("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: <code>4 mins 2 sec</code>, <code>2h42min</code>.").html_safe + = html_escape(_("Set the default expiration time for each job's artifacts. 0 for unlimited. The default unit is in seconds, but you can define an alternative. For example: %{code_open}4 mins 2 sec%{code_close}, %{code_open}2h42min%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to icon('question-circle'), help_page_path('user/admin_area/settings/continuous_integration', anchor: 'default-artifacts-expiration-core-only') .form-group = f.label :archive_builds_in_human_readable, _('Archive jobs'), class: 'label-bold' = f.text_field :archive_builds_in_human_readable, class: 'form-control', placeholder: 'never' .form-text.text-muted - = _("Set the duration for which the jobs will be considered as old and expired. Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: <code>15 days</code>, <code>1 month</code>, <code>2 years</code>.").html_safe + = html_escape(_("Set the duration for which the jobs will be considered as old and expired. Once that time passes, the jobs will be archived and no longer able to be retried. Make it empty to never expire jobs. It has to be no less than 1 day, for example: %{code_open}15 days%{code_close}, %{code_open}1 month%{code_close}, %{code_open}2 years%{code_close}.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } .form-group .form-check = f.check_box :protected_ci_variables, class: 'form-check-input' diff --git a/app/views/admin/application_settings/_diff_limits.html.haml b/app/views/admin/application_settings/_diff_limits.html.haml index 137b7281e0f..16b7fbe1ab6 100644 --- a/app/views/admin/application_settings/_diff_limits.html.haml +++ b/app/views/admin/application_settings/_diff_limits.html.haml @@ -12,5 +12,5 @@ = link_to icon('question-circle'), help_page_path('user/admin_area/diff_limits', anchor: 'maximum-diff-patch-size') - - = f.submit _('Save changes'), class: 'btn btn-success' + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: 'btn btn-success' diff --git a/app/views/admin/application_settings/_eks.html.haml b/app/views/admin/application_settings/_eks.html.haml index d959b4f9b43..d74afcd3e64 100644 --- a/app/views/admin/application_settings/_eks.html.haml +++ b/app/views/admin/application_settings/_eks.html.haml @@ -9,7 +9,7 @@ = _('Amazon EKS integration allows you to provision EKS clusters from GitLab.') .settings-content - = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form' } do |f| + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-eks-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml index 73412133979..e82ed0db851 100644 --- a/app/views/admin/application_settings/_external_authorization_service_form.html.haml +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -47,5 +47,5 @@ .form-group = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold' = f.text_field :external_authorization_service_default_label, class: 'form-control' - - = f.submit 'Save changes', class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_initial_branch_name.html.haml b/app/views/admin/application_settings/_initial_branch_name.html.haml index e76374e88a8..acbf971e4b9 100644 --- a/app/views/admin/application_settings/_initial_branch_name.html.haml +++ b/app/views/admin/application_settings/_initial_branch_name.html.haml @@ -9,4 +9,6 @@ = f.text_field :default_branch_name, placeholder: 'master', class: 'form-control' %span.form-text.text-muted = (_("Changes affect new repositories only. If not specified, Git's default name %{branch_name_default} will be used.") % { branch_name_default: fallback_branch_name } ).html_safe - = f.submit _('Save changes'), class: 'gl-button btn-success' + + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: 'gl-button btn-success' diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index d35774d330d..f2011257b8c 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -8,7 +8,7 @@ %p = _('Allow rendering of PlantUML diagrams in Asciidoc documents.') .settings-content - = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f| + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) if expanded %fieldset diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index 417916d8c25..6f9d3a889cd 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -14,9 +14,12 @@ %a{ href: 'https://git-scm.com/docs/git-fsck', target: 'blank' } 'git fsck' in all project and wiki repositories to look for silent disk corruption issues. .form-group - = link_to 'Clear all repository checks', clear_repository_check_states_admin_application_settings_path, data: { confirm: 'This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?' }, method: :put, class: "btn btn-sm btn-remove" .form-text.text-muted If you got a lot of false alarms from repository checks you can choose to clear all repository check information from the database. + - clear_repository_checks_link = _('Clear all repository checks') + - clear_repository_checks_message = _('This will clear repository check states for ALL projects in the database. This cannot be undone. Are you sure?') + .gl-display-flex.gl-justify-content-end + = link_to clear_repository_checks_link, clear_repository_check_states_admin_application_settings_path, data: { confirm: clear_repository_checks_message }, method: :put, class: "btn btn-sm btn-remove" .sub-section %h4 Housekeeping @@ -53,4 +56,5 @@ .form-text.text-muted Number of Git pushes after which 'git gc' is run. - = f.submit 'Save changes', class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_mirrors_form.html.haml b/app/views/admin/application_settings/_repository_mirrors_form.html.haml index 8ec9b3c528a..3b1d1eceb9c 100644 --- a/app/views/admin/application_settings/_repository_mirrors_form.html.haml +++ b/app/views/admin/application_settings/_repository_mirrors_form.html.haml @@ -14,4 +14,5 @@ = render_if_exists 'admin/application_settings/mirror_settings', form: f - = f.submit _('Save changes'), class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_static_objects.html.haml b/app/views/admin/application_settings/_repository_static_objects.html.haml index 03aa48b2282..9bc751adc8b 100644 --- a/app/views/admin/application_settings/_repository_static_objects.html.haml +++ b/app/views/admin/application_settings/_repository_static_objects.html.haml @@ -15,4 +15,5 @@ %span.form-text.text-muted#static_objects_external_storage_auth_token_help_block = _('A secure token that identifies an external storage request.') - = f.submit _('Save changes'), class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index ecae720cd49..ee55529621b 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -22,5 +22,5 @@ = f.text_field attribute[:name], class: 'form-text-input', value: attribute[:value] = f.label attribute[:label], attribute[:label], class: 'label-bold form-check-label' %br - - = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success qa-save-changes-button" diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 0972e10e12c..505be869620 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -57,5 +57,5 @@ = f.label :sign_in_text, class: 'label-bold' = f.text_area :sign_in_text, class: 'form-control', rows: 4 .form-text.text-muted Markdown enabled - - = f.submit 'Save changes', class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_signup.html.haml b/app/views/admin/application_settings/_signup.html.haml index d8495c82af1..3b88696dc51 100644 --- a/app/views/admin/application_settings/_signup.html.haml +++ b/app/views/admin/application_settings/_signup.html.haml @@ -67,5 +67,5 @@ = f.label :after_sign_up_text, class: 'label-bold' = f.text_area :after_sign_up_text, class: 'form-control', rows: 4 .form-text.text-muted Markdown enabled - - = f.submit 'Save changes', class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_snowplow.html.haml b/app/views/admin/application_settings/_snowplow.html.haml index a2597433270..3216d7b7a9a 100644 --- a/app/views/admin/application_settings/_snowplow.html.haml +++ b/app/views/admin/application_settings/_snowplow.html.haml @@ -8,8 +8,7 @@ %p = _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') } .settings-content - - = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f| + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) if expanded %fieldset diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index 23cda0334a2..7650526dfc0 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -16,7 +16,7 @@ .settings-content - = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form' } do |f| + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-sourcegraph-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) %fieldset diff --git a/app/views/admin/application_settings/_terminal.html.haml b/app/views/admin/application_settings/_terminal.html.haml index 654aed54a15..25d23ea7a84 100644 --- a/app/views/admin/application_settings/_terminal.html.haml +++ b/app/views/admin/application_settings/_terminal.html.haml @@ -8,5 +8,5 @@ .form-text.text-muted Maximum time for web terminal websocket connection (in seconds). 0 for unlimited. - - = f.submit 'Save changes', class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index 19e7ab7c99a..a6d03ac1dde 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -15,5 +15,5 @@ = f.text_area :terms, class: 'form-control', rows: 8 .form-text.text-muted = _("Markdown enabled") - - = f.submit _("Save changes"), class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _("Save changes"), class: "btn btn-success" diff --git a/app/views/admin/application_settings/_third_party_offers.html.haml b/app/views/admin/application_settings/_third_party_offers.html.haml index 256b1f74bfa..0ed7341986d 100644 --- a/app/views/admin/application_settings/_third_party_offers.html.haml +++ b/app/views/admin/application_settings/_third_party_offers.html.haml @@ -8,8 +8,7 @@ %p = _('Control the display of third party offers.') .settings-content - - = form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f| + = form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-third-party-offers-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) if expanded %fieldset diff --git a/app/views/admin/application_settings/_visibility_and_access.html.haml b/app/views/admin/application_settings/_visibility_and_access.html.haml index 3c4fc75dbee..28208d923db 100644 --- a/app/views/admin/application_settings/_visibility_and_access.html.haml +++ b/app/views/admin/application_settings/_visibility_and_access.html.haml @@ -66,5 +66,5 @@ .form-group = f.label field_name, "#{type.upcase} SSH keys", class: 'label-bold' = f.select field_name, key_restriction_options_for_select(type), {}, class: 'form-control' - - = f.submit _('Save changes'), class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/admin/application_settings/ci/_header.html.haml b/app/views/admin/application_settings/ci/_header.html.haml index fe86284ba2f..aa1a6256986 100644 --- a/app/views/admin/application_settings/ci/_header.html.haml +++ b/app/views/admin/application_settings/ci/_header.html.haml @@ -8,13 +8,13 @@ = expanded ? _('Collapse') : _('Expand') %p - = _('Environment variables are applied to all project environments in this instance via the Runner. You can use environment variables for passwords, secret keys, etc. Make variables available to the running application by prepending the variable key with <code>K8S_SECRET_</code>. You can set variables to be:').html_safe + = html_escape(_('Environment variables are applied to environments via the Runner. You can use environment variables for passwords, secret keys, etc. Make variables available to the running application by prepending the variable key with %{code_open}K8S_SECRET_%{code_close}. You can set variables to be:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %ul %li - = _('<code>Protected</code> to expose them to protected branches or tags only.').html_safe + = html_escape(_('%{code_open}Protected%{code_close} variables are only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li - = _('<code>Masked</code> to prevent the values from being displayed in job logs (must match certain regexp requirements).').html_safe + = html_escape(_('%{code_open}Masked%{code_close} variables are hidden in job logs (though they must match certain regexp requirements to do so).')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %p = link_to _('More information'), help_page_path('ci/variables/README', anchor: 'instance-level-cicd-environment-variables') diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index fd3f04fefd1..788dc0b0f1b 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -13,7 +13,7 @@ .settings-content = render 'visibility_and_access' -%section.settings.qa-account-and-limit-settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-account-limit.no-animate#js-account-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'account_and_limit_settings_content' } } .settings-header %h4 = _('Account and limit') @@ -101,8 +101,8 @@ = s_('IDE|Live Preview') %span.form-text.text-muted = s_('IDE|Allow live previews of JavaScript projects in the Web IDE using CodeSandbox Live Preview.') - - = f.submit _('Save changes'), class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success" - if Feature.enabled?(:maintenance_mode) %section.settings.no-animate#js-maintenance-mode-toggle{ class: ('expanded' if expanded_by_default?) } @@ -116,11 +116,10 @@ .settings-content #js-maintenance-mode-settings -- if Feature.enabled?(:instance_level_integrations) - = render_if_exists 'admin/application_settings/elasticsearch_form' - = render 'admin/application_settings/plantuml' - = render 'admin/application_settings/sourcegraph' - = render_if_exists 'admin/application_settings/slack' - = render 'admin/application_settings/third_party_offers' - = render 'admin/application_settings/snowplow' - = render 'admin/application_settings/eks' += render_if_exists 'admin/application_settings/elasticsearch_form' += render 'admin/application_settings/plantuml' += render 'admin/application_settings/sourcegraph' += render_if_exists 'admin/application_settings/slack' += render 'admin/application_settings/third_party_offers' += render 'admin/application_settings/snowplow' += render 'admin/application_settings/eks' diff --git a/app/views/admin/application_settings/integrations.html.haml b/app/views/admin/application_settings/integrations.html.haml index cca0240462f..b5dae424b46 100644 --- a/app/views/admin/application_settings/integrations.html.haml +++ b/app/views/admin/application_settings/integrations.html.haml @@ -2,29 +2,19 @@ - page_title _('Integrations') - @content_class = 'limit-container-width' unless fluid_layout -- if Feature.enabled?(:instance_level_integrations) - - if show_admin_integrations_moved? - .gl-alert.gl-alert-info.js-admin-integrations-moved.mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::ADMIN_INTEGRATIONS_MOVED, dismiss_endpoint: user_callouts_path } } - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') - %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') - .gl-alert-body - %h4.gl-alert-title= s_('AdminSettings|Some settings have moved') - = s_('AdminSettings|Elasticsearch, PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.') - .gl-alert-actions - = link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info new-gl-button' +- if show_admin_integrations_moved? + .gl-alert.gl-alert-info.js-admin-integrations-moved.mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::ADMIN_INTEGRATIONS_MOVED, dismiss_endpoint: user_callouts_path } } + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } + = sprite_icon('close', css_class: 'gl-icon') + .gl-alert-body + %h4.gl-alert-title= s_('AdminSettings|Some settings have moved') + = html_escape_once(s_('AdminSettings|Elasticsearch, PlantUML, Slack application, Third party offers, Snowplow, Amazon EKS have moved to Settings > General.')).html_safe + .gl-alert-actions + = link_to s_('AdminSettings|Go to General Settings'), general_admin_application_settings_path, class: 'btn gl-alert-action btn-info new-gl-button' - %h4= s_('AdminSettings|Apply integration settings to all Projects') - %p - = s_('AdminSettings|Integrations configured here will automatically apply to all projects on this instance.') - = link_to _('Learn more'), '#' - = render 'shared/integrations/index', integrations: @integrations - -- else - = render_if_exists 'admin/application_settings/elasticsearch_form' - = render 'admin/application_settings/plantuml' - = render 'admin/application_settings/sourcegraph' - = render_if_exists 'admin/application_settings/slack' - = render 'admin/application_settings/third_party_offers' - = render 'admin/application_settings/snowplow' - = render 'admin/application_settings/eks' +%h4= s_('AdminSettings|Apply integration settings to all Projects') +%p + = s_('AdminSettings|Integrations configured here will automatically apply to all projects on this instance.') + = link_to _('Learn more'), '#' += render 'shared/integrations/index', integrations: @integrations diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index befe10ea510..181c54c2716 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -24,7 +24,7 @@ .settings-content = render 'grafana' -%section.settings.qa-performance-bar-settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-performance-bar.no-animate#js-performance-bar-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'performance_bar_settings_content' } } .settings-header %h4 = _('Profiling - Performance bar') diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 15149e46f9c..40fa86d8ea3 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -13,7 +13,7 @@ .settings-content = render 'performance' -%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'ip_limits_section' } } +%section.settings.as-ip-limits.no-animate#js-ip-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'ip_limits_content' } } .settings-header %h4 = _('User and IP Rate Limits') @@ -24,7 +24,7 @@ .settings-content = render 'ip_limits' -%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_section' } } +%section.settings.as-outbound.no-animate#js-outbound-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'outbound_requests_content' } } .settings-header %h4 = _('Outbound requests') diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 0ad76e56d0b..787760516ce 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -2,7 +2,7 @@ - page_title _("Preferences") - @content_class = "limit-container-width" unless fluid_layout -%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_section' } } +%section.settings.as-email.no-animate#js-email-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'email_content' } } .settings-header %h4 = _('Email') diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index 33a6715d424..18e093f7b2c 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -25,7 +25,7 @@ .settings-content = render partial: 'repository_mirrors_form' -%section.settings.qa-repository-storage-settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?) } +%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'repository_storage_settings_content' } } .settings-header %h4 = _('Repository storage') diff --git a/app/views/admin/broadcast_messages/_form.html.haml b/app/views/admin/broadcast_messages/_form.html.haml index 79d758cf10b..8a937bd66cf 100644 --- a/app/views/admin/broadcast_messages/_form.html.haml +++ b/app/views/admin/broadcast_messages/_form.html.haml @@ -1,5 +1,5 @@ .broadcast-message.broadcast-banner-message.alert-warning.js-broadcast-banner-message-preview.mt-2{ style: broadcast_message_style(@broadcast_message), class: ('hidden' unless @broadcast_message.banner? ) } - = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') + = sprite_icon('bullhorn', css_class:'vertical-align-text-top') .js-broadcast-message-preview - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) @@ -7,7 +7,7 @@ Your message here .d-flex.justify-content-center .broadcast-message.broadcast-notification-message.preview.js-broadcast-notification-message-preview.mt-2{ class: ('hidden' unless @broadcast_message.notification? ) } - = sprite_icon('bullhorn', size: 16, css_class:'vertical-align-text-top') + = sprite_icon('bullhorn', css_class:'vertical-align-text-top') .js-broadcast-message-preview - if @broadcast_message.message.present? = render_broadcast_message(@broadcast_message) diff --git a/app/views/admin/broadcast_messages/index.html.haml b/app/views/admin/broadcast_messages/index.html.haml index bca74f71c5c..a14d342bc14 100644 --- a/app/views/admin/broadcast_messages/index.html.haml +++ b/app/views/admin/broadcast_messages/index.html.haml @@ -37,8 +37,8 @@ = message.target_path %td = message.broadcast_type.capitalize - %td.gl-white-space-nowrap - = link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn' - = link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger' + %td.gl-white-space-nowrap.gl-display-flex + = link_to sprite_icon('pencil-square', css_class: 'gl-icon'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn btn-icon gl-button' + = link_to sprite_icon('remove', css_class: 'gl-icon'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-icon gl-button btn-danger ml-2' = paginate @broadcast_messages, theme: 'gitlab' diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 7c6c21bc509..271ab12037e 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,15 +1,15 @@ - breadcrumb_title _("Dashboard") - page_title _("Dashboard") -- if show_license_breakdown? - = render_if_exists 'admin/licenses/breakdown', license: @license - - if @notices - @notices.each do |notice| .js-vue-alert{ 'v-cloak': true, data: { variant: notice[:type], dismissible: true.to_s } } = notice[:message].html_safe +- if show_license_breakdown? + = render_if_exists 'admin/licenses/breakdown', license: @license + .admin-dashboard.gl-mt-3 .row .col-sm-4 diff --git a/app/views/admin/dashboard/stats.html.haml b/app/views/admin/dashboard/stats.html.haml index f7f2c717308..78707235cb5 100644 --- a/app/views/admin/dashboard/stats.html.haml +++ b/app/views/admin/dashboard/stats.html.haml @@ -2,7 +2,7 @@ %h3.my-4 = s_('AdminArea|Users statistics') -%table.table.gl-text-gray-700 +%table.table.gl-text-gray-500 %tr %td.p-3 = s_('AdminArea|Users without a Group and Project') diff --git a/app/views/admin/deploy_keys/index.html.haml b/app/views/admin/deploy_keys/index.html.haml index 4e9cfc13af0..3409e2ffc8a 100644 --- a/app/views/admin/deploy_keys/index.html.haml +++ b/app/views/admin/deploy_keys/index.html.haml @@ -1,11 +1,8 @@ - page_title _('Deploy Keys') - -%h3.page-title.deploy-keys-title - = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size } - .float-right - = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-sm btn-inverted' - - if @deploy_keys.any? + %h3.page-title.deploy-keys-title + = _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size } + = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn btn-success btn-md gl-button' .table-holder.deploy-keys-list %table.table %thead @@ -32,3 +29,5 @@ .float-right = link_to _('Edit'), edit_admin_deploy_key_path(deploy_key), class: 'btn btn-sm' = link_to _('Remove'), admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-sm btn-remove delete-key' +- else + = render 'shared/empty_states/deploy_keys' diff --git a/app/views/admin/groups/_group.html.haml b/app/views/admin/groups/_group.html.haml index bbeeb1be929..ab817b2ef6e 100644 --- a/app/views/admin/groups/_group.html.haml +++ b/app/views/admin/groups/_group.html.haml @@ -14,7 +14,7 @@ .description = markdown_field(group, :description) - .stats.gl-text-gray-700.gl-flex-shrink-0.gl-display-none.gl-display-sm-flex + .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-display-sm-flex %span.badge.badge-pill = storage_counter(group.storage_size) @@ -22,15 +22,15 @@ = render_if_exists 'admin/groups/marked_for_deletion_badge', group: group, css_class: 'gl-ml-5' %span.gl-ml-5 - = icon('bookmark') + = sprite_icon('bookmark', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(group.projects.count) %span.gl-ml-5 - = icon('users') + = sprite_icon('users', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(group.users.count) %span.gl-ml-5.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } - = visibility_level_icon(group.visibility_level, fw: false) + = visibility_level_icon(group.visibility_level) .controls.gl-flex-shrink-0.gl-ml-5 = link_to _('Edit'), admin_group_edit_path(group), id: "edit_#{dom_id(group)}", class: 'btn' diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index 4b0e0b9c697..6dd73698848 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -6,8 +6,8 @@ %h3.page-title = _('Group: %{group_name}') % { group_name: @group.full_name } - = link_to admin_group_edit_path(@group), class: "btn float-right", data: { qa_selector: 'edit_group_link' } do - %i.fa.fa-pencil-square-o + = link_to admin_group_edit_path(@group), class: "btn btn-default gl-button float-right", data: { qa_selector: 'edit_group_link' } do + = sprite_icon('pencil-square', css_class: 'gl-icon') = _('Edit') %hr .row @@ -74,7 +74,7 @@ - @projects.each do |project| %li %strong - = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project] + = link_to project.full_name, [:admin, project] %span.badge.badge-pill = storage_counter(project.statistics.storage_size) %span.float-right.light @@ -93,7 +93,7 @@ - shared_projects.each do |project| %li %strong - = link_to project.full_name, [:admin, project.namespace.becomes(Namespace), project] + = link_to project.full_name, [:admin, project] %span.badge.badge-pill = storage_counter(project.statistics.storage_size) %span.float-right.light @@ -106,13 +106,13 @@ = _('Add user(s) to the group:') .card-body.form-holder %p.light - - link_to_help = link_to(_("here"), help_page_path("user/permissions")) - = _('Read more about project permissions <strong>%{link_to_help}</strong>').html_safe % { link_to_help: link_to_help } + - help_link_open = '<strong><a href="%{help_url}">'.html_safe % { help_url: help_page_url("user/permissions") } + = html_escape(_('Read more about project permissions %{help_link_open}here%{help_link_close}')) % { help_link_open: help_link_open, help_link_close: '</a></strong>'.html_safe } = form_tag admin_group_members_update_path(@group), id: "new_project_member", class: "bulk_import", method: :put do %div = users_select_tag(:user_ids, multiple: true, email_user: true, skip_ldap: @group.ldap_synced?, scope: :all) - .prepend-top-10 + .gl-mt-3 = select_tag :access_level, options_for_select(GroupMember.access_level_roles), class: "project-access-select select2" %hr = button_tag _('Add users to group'), class: "btn btn-success" @@ -120,10 +120,12 @@ .card .card-header - = _("<strong>%{group_name}</strong> group members").html_safe % { group_name: @group.name } + = html_escape(_("%{group_name} group members")) % { group_name: "<strong>#{html_escape(@group.name)}</strong>".html_safe } %span.badge.badge-pill= @group.members.size .float-right - = link_to icon('pencil-square-o', text: _('Manage access')), group_group_members_path(@group), class: "btn btn-sm" + = link_to group_group_members_path(@group), class: 'btn btn-default gl-button btn-sm' do + = sprite_icon('pencil-square', css_class: 'gl-icon') + = _('Manage access') %ul.content-list.group-users-list.content-list.members-list = render partial: 'shared/members/member', collection: @members, as: :member, locals: { show_controls: false } .card-footer diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index 587bfba8d47..fbe37f6c509 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -7,7 +7,7 @@ %p #{ s_('HealthCheck|Access token is') } %code#health-check-token= Gitlab::CurrentSettings.health_check_access_token - .prepend-top-10 + .gl-mt-3 = button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path, method: :put, class: 'btn btn-default', data: { confirm: _('Are you sure you want to reset the health check token?') } diff --git a/app/views/admin/hook_logs/show.html.haml b/app/views/admin/hook_logs/show.html.haml index 4d534c59c40..a8ef19dcf46 100644 --- a/app/views/admin/hook_logs/show.html.haml +++ b/app/views/admin/hook_logs/show.html.haml @@ -1,9 +1,9 @@ - page_title _('Request details') %h3.page-title - Request details + = _("Request details") %hr -= link_to 'Resend Request', retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3" += link_to _("Resend Request"), retry_admin_hook_hook_log_path(@hook, @hook_log), method: :post, class: "btn btn-default float-right gl-ml-3" = render partial: 'shared/hook_logs/content', locals: { hook_log: @hook_log } diff --git a/app/views/admin/labels/destroy.js.haml b/app/views/admin/labels/destroy.js.haml index 394d3c11f31..b9b63829f25 100644 --- a/app/views/admin/labels/destroy.js.haml +++ b/app/views/admin/labels/destroy.js.haml @@ -1,2 +1,2 @@ -- if @labels.size.zero? +- if @labels.size == 0 $('.labels').load(document.URL + ' .nothing-here-block').hide().fadeIn(1000) diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 96337d357eb..bd3b2f40059 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -6,8 +6,8 @@ .js-remove-member-modal %h3.page-title = _('Project: %{name}') % { name: @project.full_name } - = link_to edit_project_path(@project), class: "btn btn-nr float-right" do - %i.fa.fa-pencil-square-o + = link_to edit_project_path(@project), class: "btn btn-default gl-button float-right" do + = sprite_icon('pencil-square', css_class: 'gl-icon') = _('Edit') %hr - if @project.last_repository_check_failed? @@ -178,8 +178,9 @@ = _('group members') %span.badge.badge-pill= @group_members.size .float-right - = link_to admin_group_path(@group), class: 'btn btn-sm' do - = icon('pencil-square-o', text: _('Manage access')) + = link_to admin_group_path(@group), class: 'btn btn-default gl-button btn-sm' do + = sprite_icon('pencil-square', css_class: 'gl-icon') + = _('Manage access') %ul.content-list.members-list = render partial: 'shared/members/member', collection: @group_members, as: :member, locals: { show_controls: false } .card-footer @@ -193,7 +194,9 @@ = _('project members') %span.badge.badge-pill= @project.users.size .float-right - = link_to icon('pencil-square-o', text: _('Manage access')), project_project_members_path(@project), class: "btn btn-sm" + = link_to project_project_members_path(@project), class: 'btn btn-default gl-button btn-sm' do + = sprite_icon('pencil-square', css_class: 'gl-icon') + = _('Manage access') %ul.content-list.project_members.members-list = render partial: 'shared/members/member', collection: @project_members, as: :member, locals: { show_controls: false } .card-footer diff --git a/app/views/admin/requests_profiles/index.html.haml b/app/views/admin/requests_profiles/index.html.haml index 6e1ac452d52..6c75dfe9733 100644 --- a/app/views/admin/requests_profiles/index.html.haml +++ b/app/views/admin/requests_profiles/index.html.haml @@ -11,7 +11,7 @@ - if @profiles.present? .gl-mt-3 - @profiles.each do |path, profiles| - .card.card-small + .card .card-header %code= path %ul.content-list diff --git a/app/views/admin/runners/_runner.html.haml b/app/views/admin/runners/_runner.html.haml index 5c834c2125f..0bbe73d6f7e 100644 --- a/app/views/admin/runners/_runner.html.haml +++ b/app/views/admin/runners/_runner.html.haml @@ -66,13 +66,13 @@ .btn-group.table-action-buttons .btn-group = link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do - = icon('pencil') + = sprite_icon('pencil') .btn-group - if runner.active? - = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do - = icon('pause') + = link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do + = sprite_icon('pause') - else - = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do + = link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do = sprite_icon('play') .btn-group = link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do diff --git a/app/views/admin/runners/show.html.haml b/app/views/admin/runners/show.html.haml index 0c2b9bab357..cecf3f137ed 100644 --- a/app/views/admin/runners/show.html.haml +++ b/app/views/admin/runners/show.html.haml @@ -28,7 +28,7 @@ %p You can't make this a shared Runner. %hr -.append-bottom-20 +.gl-mb-6 = render 'shared/runners/form', runner: @runner, runner_form_url: admin_runner_path(@runner), in_gitlab_com_admin_context: Gitlab.com? .row diff --git a/app/views/admin/services/index.html.haml b/app/views/admin/services/index.html.haml index ec343c38470..19a0b7466a2 100644 --- a/app/views/admin/services/index.html.haml +++ b/app/views/admin/services/index.html.haml @@ -18,7 +18,7 @@ = link_to edit_admin_application_settings_integration_path(service.to_param), class: 'gl-text-blue-300!' do %strong.has-tooltip{ title: s_('AdminSettings|Moved to integrations'), data: { container: 'body' } } = service.title - %td.gl-cursor-default.gl-text-gray-600 + %td.gl-cursor-default.gl-text-gray-400 = service.description %td - else diff --git a/app/views/admin/sessions/_signin_box.html.haml b/app/views/admin/sessions/_signin_box.html.haml index cb6c0a76e56..ab7eb8c79dc 100644 --- a/app/views/admin/sessions/_signin_box.html.haml +++ b/app/views/admin/sessions/_signin_box.html.haml @@ -7,7 +7,7 @@ = render_if_exists 'devise/sessions/new_kerberos_tab' - ldap_servers.each_with_index do |server, i| - .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) } + .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) } .login-body = render 'devise/sessions/new_ldap', server: server, hide_remember_me: true, submit_message: _('Enter Admin Mode') diff --git a/app/views/admin/users/_head.html.haml b/app/views/admin/users/_head.html.haml index 3403e9e5abf..a60dbd51935 100644 --- a/app/views/admin/users/_head.html.haml +++ b/app/views/admin/users/_head.html.haml @@ -12,10 +12,10 @@ .float-right - if impersonation_enabled? && @user != current_user && @user.can?(:log_in) - = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-nr btn-grouped btn-info", data: { qa_selector: 'impersonate_user_link' } - = link_to edit_admin_user_path(@user), class: "btn btn-nr btn-grouped" do - %i.fa.fa-pencil-square-o - Edit + = link_to 'Impersonate', impersonate_admin_user_path(@user), method: :post, class: "btn btn-info gl-button btn-grouped", data: { qa_selector: 'impersonate_user_link' } + = link_to edit_admin_user_path(@user), class: "btn btn-default gl-button btn-grouped" do + = sprite_icon('pencil-square', css_class: 'gl-icon') + = _('Edit') %hr %ul.nav-links.nav.nav-tabs = nav_link(path: 'users#show') do diff --git a/app/views/admin/users/_user_listing_note.html.haml b/app/views/admin/users/_user_listing_note.html.haml index b6c9bc43339..e5c43259b79 100644 --- a/app/views/admin/users/_user_listing_note.html.haml +++ b/app/views/admin/users/_user_listing_note.html.haml @@ -1,3 +1,3 @@ - if user.note.present? %span.has-tooltip.user-note{ title: user.note } - = sprite_icon('document', size: 16, css_class: 'gl-vertical-align-middle') + = sprite_icon('document', css_class: 'gl-vertical-align-middle') diff --git a/app/views/ci/deploy_freeze/_index.html.haml b/app/views/ci/deploy_freeze/_index.html.haml new file mode 100644 index 00000000000..fa4b3d5684e --- /dev/null +++ b/app/views/ci/deploy_freeze/_index.html.haml @@ -0,0 +1,2 @@ +#js-deploy-freeze-table{ data: { project_id: @project.id, timezone_data: timezone_data.to_json } } + diff --git a/app/views/ci/runner/_how_to_setup_runner.html.haml b/app/views/ci/runner/_how_to_setup_runner.html.haml index aca8aa5d341..6c2bd2a5d2f 100644 --- a/app/views/ci/runner/_how_to_setup_runner.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner.html.haml @@ -1,5 +1,5 @@ - link = link_to _("Install GitLab Runner"), 'https://docs.gitlab.com/runner/install/', target: '_blank' -.append-bottom-10 +.gl-mb-3 %h4= _("Set up a %{type} Runner manually") % { type: type } %ol @@ -13,7 +13,7 @@ = _("Use the following registration token during setup:") %code#registration_token= registration_token = clipboard_button(target: '#registration_token', title: _("Copy token"), class: "btn-transparent btn-clipboard") - .prepend-top-10.append-bottom-10 + .gl-mt-3.gl-mb-3 = button_to _("Reset runners registration token"), reset_token_url, method: :put, class: 'btn btn-default', data: { confirm: _("Are you sure you want to reset registration token?") } diff --git a/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml index 58d2ef5d5e6..42710039757 100644 --- a/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml +++ b/app/views/ci/runner/_how_to_setup_runner_automatically.html.haml @@ -1,4 +1,4 @@ -.append-bottom-10 +.gl-mb-3 %h4= _('Set up a %{type} Runner automatically') % { type: type } %p diff --git a/app/views/ci/status/_dropdown_graph_badge.html.haml b/app/views/ci/status/_dropdown_graph_badge.html.haml index d9d646c77d9..69cb41b1713 100644 --- a/app/views/ci/status/_dropdown_graph_badge.html.haml +++ b/app/views/ci/status/_dropdown_graph_badge.html.haml @@ -16,5 +16,5 @@ %span.ci-build-text.text-truncate.mw-70p.gl-pl-1-deprecated-no-really-do-not-use-me= subject.name - if status.has_action? - = link_to status.action_path, class: "ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do + = link_to status.action_path, class: "gl-button ci-action-icon-container ci-action-icon-wrapper js-ci-action-icon", method: status.action_method, data: { toggle: 'tooltip', title: status.action_title, container: 'body' } do = sprite_icon(status.action_icon, css_class: "icon-action-#{status.action_icon}") diff --git a/app/views/ci/variables/_content.html.haml b/app/views/ci/variables/_content.html.haml index 144d13565b2..bf695d871f8 100644 --- a/app/views/ci/variables/_content.html.haml +++ b/app/views/ci/variables/_content.html.haml @@ -1,3 +1,8 @@ -= _('Environment variables are applied to environments via the runner. They can be protected by only exposing them to protected branches or tags. Additionally, they can be masked so they are hidden in job logs, though they must match certain regexp requirements to do so. You can use environment variables for passwords, secret keys, or whatever you want.') -= _('You may also add variables that are made available to the running application by prepending the variable key with <code>K8S_SECRET_</code>.').html_safe += html_escape(_('Environment variables are applied to environments via the Runner. You can use environment variables for passwords, secret keys, etc. Make variables available to the running application by prepending the variable key with %{code_open}K8S_SECRET_%{code_close}. You can set variables to be:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } +%ul + %li + = html_escape(_('%{code_open}Protected%{code_close} variables are only exposed to protected branches or tags.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li + = html_escape(_('%{code_open}Masked%{code_close} variables are hidden in job logs (though they must match certain regexp requirements to do so).')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + = link_to _('More information'), help_page_path('ci/variables/README', anchor: 'custom-environment-variables') diff --git a/app/views/ci/variables/_url_query_variable_row.html.haml b/app/views/ci/variables/_url_query_variable_row.html.haml index 6672a8e5ea0..4c6eeb17c07 100644 --- a/app/views/ci/variables/_url_query_variable_row.html.haml +++ b/app/views/ci/variables/_url_query_variable_row.html.haml @@ -24,5 +24,5 @@ name: value_input_name, placeholder: s_('CiVariables|Input variable value') } = value - %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } - = icon('minus-circle') + %button.btn.btn-svg.btn-item-remove.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } + = sprite_icon('close') diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml index c69a3adb0e9..542a41c2f7d 100644 --- a/app/views/ci/variables/_variable_row.html.haml +++ b/app/views/ci/variables/_variable_row.html.haml @@ -60,5 +60,5 @@ value: is_masked, data: { default: is_masked_default.to_s } } = render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable - %button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } - = icon('minus-circle') + %button.btn.btn-svg.js-row-remove-button.ci-variable-row-remove-button.table-section{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') } + = sprite_icon('close') diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index d1681409a93..117bdbc06a1 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -11,7 +11,7 @@ = @cluster.provider_label %p - provider_link = link_to(@cluster.provider_label, @cluster.provider_management_url, target: '_blank', rel: 'noopener noreferrer') - = s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}').html_safe % { provider_link: provider_link } + = html_escape(s_('ClusterIntegration|Manage your Kubernetes cluster by visiting %{provider_link}')) % { provider_link: provider_link } .sub-section.form-group = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'cluster_management_form' } do |field| @@ -22,9 +22,10 @@ = project_select_tag('cluster[management_project_id]', class: 'hidden-filter-value', toggle_class: 'js-project-search js-project-filter js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', placeholder: _('Select project'), idAttribute: 'id', data: { order_by: 'last_activity_at', idattribute: 'id', simple_filter: true, allow_clear: true, include_groups: false, include_projects_in_subgroups: true, group_id: group_id, user_id: user_id }, value: @cluster.management_project_id) .text-muted - = s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes <code>cluster-admin</code> privileges.').html_safe + = html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank' - = field.submit _('Save changes'), class: 'btn btn-success' + .gl-display-flex.gl-justify-content-end + = field.submit _('Save changes'), class: 'btn btn-success' - if @cluster.managed? .sub-section.form-group @@ -32,7 +33,8 @@ = s_('ClusterIntegration|Clear cluster cache') %p = s_("ClusterIntegration|Clear the local cache of namespace and service accounts. This is necessary if your integration has become out of sync. The cache is repopulated during the next CI job that requires namespace and service accounts.") - = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary') + .gl-display-flex.gl-justify-content-end + = link_to(s_('ClusterIntegration|Clear cluster cache'), clusterable.clear_cluster_cache_path(@cluster), method: :delete, class: 'btn btn-primary') .sub-section.form-group %h4.text-danger diff --git a/app/views/clusters/clusters/_banner.html.haml b/app/views/clusters/clusters/_banner.html.haml index e9ad0c6a4e0..3461831eda2 100644 --- a/app/views/clusters/clusters/_banner.html.haml +++ b/app/views/clusters/clusters/_banner.html.haml @@ -7,16 +7,16 @@ %span.gl-ml-2= s_('ClusterIntegration|Kubernetes cluster is being created...') .hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body = s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.') .hidden.js-cluster-authentication-failure.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' } - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') %button.js-close-banner.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body = s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.') diff --git a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml index 3869ca6591c..54f6fa91cf1 100644 --- a/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml +++ b/app/views/clusters/clusters/_gcp_signup_offer_banner.html.haml @@ -3,10 +3,9 @@ %button.close.js-close{ type: "button" } × .gcp-signup-offer--content .gcp-signup-offer--icon.gl-mr-3 - = sprite_icon("information", size: 16) + = sprite_icon("information") .gcp-signup-offer--copy %h4= s_('ClusterIntegration|Did you know?') %p= s_('ClusterIntegration|Every new Google Cloud Platform (GCP) account receives $300 in credit upon %{sign_up_link}. In partnership with Google, GitLab is able to offer an additional $200 for both new and existing GCP accounts to get started with GitLab\'s Google Kubernetes Engine Integration.').html_safe % { sign_up_link: link } %a.btn.btn-default{ href: 'https://cloud.google.com/partners/partnercredit/?pcn_code=0014M00001h35gDQAQ#contact-form', target: '_blank', rel: 'noopener noreferrer' } = s_("ClusterIntegration|Apply for credit") - diff --git a/app/views/clusters/clusters/_gitlab_integration_form.html.haml b/app/views/clusters/clusters/_gitlab_integration_form.html.haml index 160964b532a..87af74a398f 100644 --- a/app/views/clusters/clusters/_gitlab_integration_form.html.haml +++ b/app/views/clusters/clusters/_gitlab_integration_form.html.haml @@ -1,36 +1,3 @@ = form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-integration-form' } do |field| = form_errors(@cluster) - .form-group - .d-flex.align-items-center - %h4.pr-2.m-0 - = s_('ClusterIntegration|GitLab Integration') - %label.gl-mb-0.js-cluster-enable-toggle-area{ title: s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.'), data: { toggle: 'tooltip', container: 'body' } } - = render "shared/buttons/project_feature_toggle", is_checked: @cluster.enabled?, label: s_("ClusterIntegration|Toggle Kubernetes cluster"), disabled: !can?(current_user, :update_cluster, @cluster), data: { qa_selector: 'integration_status_toggle' } do - = field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'} - - .form-group - %h5= s_('ClusterIntegration|Environment scope') - = field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope') - - environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain') - - environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url } - .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe } - - .form-group - %h5= s_('ClusterIntegration|Base domain') - = field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus', data: { qa_selector: 'base_domain_field' } - .form-text.text-muted - - auto_devops_url = help_page_path('topics/autodevops/index') - - auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url } - = s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe } - %span{ :class => ["js-ingress-domain-help-text", ("hide" unless @cluster.application_ingress_external_ip.present?)] } - = s_('ClusterIntegration|Alternatively') - %code{ :class => "js-ingress-domain-snippet" } - = s_('ClusterIntegration|%{external_ip}.nip.io').html_safe % { external_ip: @cluster.application_ingress_external_ip } - = s_('ClusterIntegration| can be used instead of a custom domain.') - - custom_domain_url = help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint') - - custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url } - = s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe } - - - if can?(current_user, :update_cluster, @cluster) - .form-group - = field.submit _('Save changes'), class: 'btn btn-success', data: { qa_selector: 'save_changes_button' } + #js-cluster-integration-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) } diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml index fcb5d4402d6..e211851b939 100644 --- a/app/views/clusters/clusters/_provider_details_form.html.haml +++ b/app/views/clusters/clusters/_provider_details_form.html.haml @@ -48,5 +48,5 @@ - if cluster.allow_user_defined_namespace? = render('clusters/clusters/namespace', platform_field: platform_field) - .form-group + .form-group.gl-display-flex.gl-justify-content-end = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index ffa99f06593..6ac852af2db 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -35,7 +35,8 @@ deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), cloud_run_help_path: help_page_path('user/project/clusters/add_remove_clusters.md', anchor: 'cloud-run-for-anthos'), manage_prometheus_path: manage_prometheus_path, - cluster_id: @cluster.id } } + cluster_id: @cluster.id, + cilium_help_path: help_page_path('user/clusters/applications.md', anchor: 'install-cilium-using-gitlab-cicd')} } .js-cluster-application-notice .flash-container diff --git a/app/views/dashboard/_activities.html.haml b/app/views/dashboard/_activities.html.haml index 2db3e35250f..d617ee0e4cc 100644 --- a/app/views/dashboard/_activities.html.haml +++ b/app/views/dashboard/_activities.html.haml @@ -1,8 +1,8 @@ .nav-block.activities = render 'shared/event_filter' .controls - = link_to dashboard_projects_path(rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip', title: 'Subscribe' do - %i.fa.fa-rss + = link_to dashboard_projects_path(rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip', title: 'Subscribe' do + = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') .content_list .loading diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 5e78749fee2..fe91f9859f9 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -26,6 +26,7 @@ = nav_link(page: [explore_root_path, trending_explore_projects_path, starred_explore_projects_path, explore_projects_path]) do = link_to explore_root_path, data: {placement: 'right'} do = _("Explore projects") + = render_if_exists "dashboard/removed_projects_tab", removed_projects_count: @removed_projects_count - unless feature_project_list_filter_bar .nav-controls = render 'shared/projects/search_form' diff --git a/app/views/dashboard/projects/index.html.haml b/app/views/dashboard/projects/index.html.haml index 2e7eab87af3..54a5624c6dd 100644 --- a/app/views/dashboard/projects/index.html.haml +++ b/app/views/dashboard/projects/index.html.haml @@ -3,6 +3,14 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, dashboard_projects_url(rss_url_options), title: "All activity") +- if show_customize_homepage_banner?(@customize_homepage) + = content_for :customize_homepage_banner do + .d-none.d-md-block{ class: "gl-pt-6! gl-pb-2! #{(container_class unless @no_container)} #{@content_class}" } + .js-customize-homepage-banner{ data: { svg_path: image_path('illustrations/monitoring/getting_started.svg'), + preferences_behavior_path: profile_preferences_path(anchor: 'behavior'), + callouts_path: user_callouts_path, + callouts_feature_id: UserCalloutsHelper::CUSTOMIZE_HOMEPAGE } } + = render_dashboard_gold_trial(current_user) - page_title _("Projects") diff --git a/app/views/dashboard/projects/shared/_common.html.haml b/app/views/dashboard/projects/shared/_common.html.haml new file mode 100644 index 00000000000..aa55f5a4e9c --- /dev/null +++ b/app/views/dashboard/projects/shared/_common.html.haml @@ -0,0 +1,13 @@ +- @hide_top_links = true +- breadcrumb_title _("Projects") +- header_title _("Projects"), dashboard_projects_path + += render_dashboard_gold_trial(current_user) + += render "projects/last_push" += render 'dashboard/projects_head', project_tab_filter: :starred + +- if params[:filter_projects] || any_projects?(@projects) + = render 'projects' +- else + = render empty_page diff --git a/app/views/dashboard/projects/starred.html.haml b/app/views/dashboard/projects/starred.html.haml index 2924918aa4f..f1e8f262eed 100644 --- a/app/views/dashboard/projects/starred.html.haml +++ b/app/views/dashboard/projects/starred.html.haml @@ -1,14 +1 @@ -- @hide_top_links = true -- breadcrumb_title _("Projects") -- page_title _("Starred Projects") -- header_title _("Projects"), dashboard_projects_path - -= render_dashboard_gold_trial(current_user) - -= render "projects/last_push" -= render 'dashboard/projects_head', project_tab_filter: :starred - -- if params[:filter_projects] || any_projects?(@projects) - = render 'projects' -- else - = render 'starred_empty_state' += render partial: 'dashboard/projects/shared/common', locals: {page_title: _('Starred Projects'), empty_page: 'starred_empty_state'} diff --git a/app/views/devise/registrations/new.html.haml b/app/views/devise/registrations/new.html.haml index fb00e1b4384..afdf3c38567 100644 --- a/app/views/devise/registrations/new.html.haml +++ b/app/views/devise/registrations/new.html.haml @@ -3,7 +3,7 @@ .row .col-lg-7 %h1.mb-3.font-weight-bold.text-6.mt-0 - = _("Speed up your DevOps<br>with GitLab").html_safe + = html_escape(_("Speed up your DevOps%{br_tag}with GitLab")) % { br_tag: '<br/>'.html_safe } %p.text-3 = _("GitLab is a single application for the entire software development lifecycle. From project planning and source code management to CI/CD, monitoring, and security.") .col-lg-5.order-12 diff --git a/app/views/devise/sessions/_new_base.html.haml b/app/views/devise/sessions/_new_base.html.haml index 6e9efcb0597..8c0ca6d4345 100644 --- a/app/views/devise/sessions/_new_base.html.haml +++ b/app/views/devise/sessions/_new_base.html.haml @@ -1,7 +1,7 @@ = form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: 'new_user gl-show-field-errors', 'aria-live' => 'assertive'}) do |f| .form-group = f.label _('Username or email'), for: 'user_login', class: 'label-bold' - = f.text_field :login, class: 'form-control top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } + = f.text_field :login, value: @invite_email, class: 'form-control top', autofocus: 'autofocus', autocapitalize: 'off', autocorrect: 'off', required: true, title: _('This field is required.'), data: { qa_selector: 'login_field' } .form-group = f.label :password, class: 'label-bold' = f.password_field :password, class: 'form-control bottom', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' } diff --git a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml index 61271f4525c..5f7345f306d 100644 --- a/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml +++ b/app/views/devise/shared/_experimental_separate_sign_up_flow_box.html.haml @@ -28,7 +28,7 @@ = f.label :password, class: 'label-bold' = f.password_field :password, class: "form-control bottom", data: { qa_selector: 'new_user_password_field' }, required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length } %p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length } - - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? + - if Gitlab::CurrentSettings.current_application_settings.enforce_terms? && !experiment_enabled?(:terms_opt_in) .form-group = check_box_tag :terms_opt_in, '1', false, required: true, data: { qa_selector: 'new_user_accept_terms_checkbox' } = label_tag :terms_opt_in do @@ -41,5 +41,8 @@ = recaptcha_tags .submit-container.mt-3 = f.submit _("Register"), class: "btn-register btn btn-block btn-success mb-0 p-2", data: { qa_selector: 'new_user_register_button' } + - if experiment_enabled?(:terms_opt_in) + %p.gl-text-gray-500.gl-mt-5.gl-mb-0 + = html_escape(_("By clicking Register, I agree that I have read and accepted the GitLab %{linkStart}Terms of Use and Privacy Policy%{linkEnd}")) % { linkStart: "<a href='#{terms_path}' target='_blank' rel='noreferrer noopener'>".html_safe, linkEnd: '</a>'.html_safe } - if omniauth_enabled? && button_based_providers_enabled? = render 'devise/shared/experimental_separate_sign_up_flow_omniauth_box' diff --git a/app/views/devise/shared/_omniauth_box.html.haml b/app/views/devise/shared/_omniauth_box.html.haml index e99d0ac1105..6cf48f89876 100644 --- a/app/views/devise/shared/_omniauth_box.html.haml +++ b/app/views/devise/shared/_omniauth_box.html.haml @@ -1,6 +1,6 @@ - hide_remember_me = local_assigns.fetch(:hide_remember_me, false) -.omniauth-container.prepend-top-15 +.omniauth-container.gl-mt-5 %label.label-bold.d-block Sign in with - providers = enabled_button_based_providers diff --git a/app/views/devise/shared/_signin_box.html.haml b/app/views/devise/shared/_signin_box.html.haml index c0b005bac77..d217b47527a 100644 --- a/app/views/devise/shared/_signin_box.html.haml +++ b/app/views/devise/shared/_signin_box.html.haml @@ -7,7 +7,7 @@ = render_if_exists 'devise/sessions/new_kerberos_tab' - ldap_servers.each_with_index do |server, i| - .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain)) } + .login-box.tab-pane{ id: "#{server['provider_name']}", role: 'tabpanel', class: active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain)) } .login-body = render 'devise/sessions/new_ldap', server: server diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index 0735702ae5f..0da51d460e3 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -19,7 +19,7 @@ %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking username availability...') .form-group = f.label :email, class: 'label-bold' - = f.email_field :email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.") + = f.email_field :email, value: @invite_email, class: "form-control middle", data: { qa_selector: 'new_user_email_field' }, required: true, title: _("Please provide a valid email address.") .form-group = f.label :email_confirmation, class: 'label-bold' = f.email_field :email_confirmation, class: "form-control middle", data: { qa_selector: 'new_user_email_confirmation_field' }, required: true, title: _("Please retype the email address.") diff --git a/app/views/devise/shared/_tabs_ldap.html.haml b/app/views/devise/shared/_tabs_ldap.html.haml index eb14ad6006f..acd41fb011a 100644 --- a/app/views/devise/shared/_tabs_ldap.html.haml +++ b/app/views/devise/shared/_tabs_ldap.html.haml @@ -8,7 +8,7 @@ = render_if_exists "devise/shared/kerberos_tab" - ldap_servers.each_with_index do |server, i| %li.nav-item - = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i.zero? && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' } + = link_to server['label'], "##{server['provider_name']}", class: "nav-link #{active_when(i == 0 && form_based_auth_provider_has_active_class?(:ldapmain))}", data: { toggle: 'tab', qa_selector: 'ldap_tab' } = render_if_exists 'devise/shared/tab_smartcard' diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index d74cba984e8..7fbaa35d1d5 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -13,7 +13,7 @@ = _('Use one line per URI') - if Doorkeeper.configuration.native_redirect_uri %span.form-text.text-muted - = _('Use <code>%{native_redirect_uri}</code> for local tests').html_safe % { native_redirect_uri: Doorkeeper.configuration.native_redirect_uri } + = html_escape(_('Use %{native_redirect_uri} for local tests')) % { native_redirect_uri: tag.code(Doorkeeper.configuration.native_redirect_uri) } .form-group.form-check = f.check_box :confidential, class: 'form-check-input' diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index 051799ca13f..6f781a635ba 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -44,7 +44,7 @@ = link_to edit_oauth_application_path(application), class: "btn btn-transparent gl-mr-2" do %span.sr-only = _('Edit') - = icon('pencil') + = sprite_icon('pencil') = render 'delete_form', application: application, small: true - else .settings-message.text-center diff --git a/app/views/doorkeeper/authorizations/new.html.haml b/app/views/doorkeeper/authorizations/new.html.haml index 70abc1a267a..62e66486a3e 100644 --- a/app/views/doorkeeper/authorizations/new.html.haml +++ b/app/views/doorkeeper/authorizations/new.html.haml @@ -11,7 +11,7 @@ .text-warning %p = icon("exclamation-triangle fw") - = _('You are an admin, which means granting access to <strong>%{client_name}</strong> will allow them to interact with GitLab as an admin as well. Proceed with caution.').html_safe % { client_name: @pre_auth.client.name } + = html_escape(_('You are an admin, which means granting access to %{client_name} will allow them to interact with GitLab as an admin as well. Proceed with caution.')) % { client_name: tag.strong(@pre_auth.client.name) } %p - link_to_client = link_to(@pre_auth.client.name, @pre_auth.redirect_uri, target: '_blank', rel: 'noopener noreferrer') = _("An application called %{link_to_client} is requesting access to your GitLab account.").html_safe % { link_to_client: link_to_client } diff --git a/app/views/explore/groups/index.html.haml b/app/views/explore/groups/index.html.haml index fd86d07fc86..f36f30d3638 100644 --- a/app/views/explore/groups/index.html.haml +++ b/app/views/explore/groups/index.html.haml @@ -12,7 +12,7 @@ - if cookies[:explore_groups_landing_dismissed] != 'true' .explore-groups.landing.content-block.js-explore-groups-landing.hide - %button.dismiss-button{ type: 'button', 'aria-label' => _('Dismiss') }= sprite_icon('close', size: 16) + %button.dismiss-button{ type: 'button', 'aria-label' => _('Dismiss') }= sprite_icon('close') .svg-container = custom_icon('icon_explore_groups_splash') .inner-content diff --git a/app/views/explore/projects/_filter.html.haml b/app/views/explore/projects/_filter.html.haml index d00a3d266d8..6fc156cf4ed 100644 --- a/app/views/explore/projects/_filter.html.haml +++ b/app/views/explore/projects/_filter.html.haml @@ -5,8 +5,7 @@ .dropdown.js-project-filter-dropdown-wrap{ class: ('d-flex flex-grow-1 flex-shrink-1' if feature_project_list_filter_bar) } %button.dropdown-menu-toggle{ href: '#', "data-toggle" => "dropdown", 'data-display' => 'static' } - unless has_label - = icon('globe', class: 'mt-1') - %span.light.ml-3= _("Visibility:") + %span= _("Visibility:") - if params[:visibility_level].present? = visibility_level_label(params[:visibility_level].to_i) - else diff --git a/app/views/groups/_activities.html.haml b/app/views/groups/_activities.html.haml index 47e7e27de48..769455dc951 100644 --- a/app/views/groups/_activities.html.haml +++ b/app/views/groups/_activities.html.haml @@ -1,10 +1,9 @@ .nav-block.activities = render 'shared/event_filter', show_group_events: @group.supports_events? .controls - = link_to group_path(@group, rss_url_options), class: 'btn d-none d-sm-inline-block has-tooltip' , title: 'Subscribe' do - %i.fa.fa-rss + = link_to group_path(@group, rss_url_options), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' , title: 'Subscribe' do + = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') .content_list .loading .spinner.spinner-md - diff --git a/app/views/groups/_flash_messages.html.haml b/app/views/groups/_flash_messages.html.haml index d1fea0e60c6..fa1a9d2cca4 100644 --- a/app/views/groups/_flash_messages.html.haml +++ b/app/views/groups/_flash_messages.html.haml @@ -1,3 +1,2 @@ = content_for :flash_message do = render_if_exists 'shared/shared_runners_minutes_limit', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] - = render_if_exists 'shared/namespace_storage_limit_alert', namespace: @group, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 2cf94695482..97e48cdec8c 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -12,7 +12,7 @@ %h1.home-panel-title.gl-mt-3.gl-mb-2 = @group.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) } - = visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'}) + = visibility_level_icon(@group.visibility_level, options: {class: 'icon'}) .home-panel-metadata.d-flex.align-items-center.text-secondary %span = _("Group ID: %{group_id}") % { group_id: @group.id } diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 1e04b2761f6..eafee325500 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -15,7 +15,7 @@ .settings-content = render 'groups/settings/general' -%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_section' } } +%section.settings.gs-permissions.no-animate#js-permissions-settings{ class: ('expanded' if expanded), data: { qa_selector: 'permission_lfs_2fa_content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only{ role: 'button' } = _('Permissions, LFS, 2FA') diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index b9ea8316bbc..c8e58a50b18 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -1,84 +1,99 @@ -- page_title _("Group members") +- page_title _('Group members') - can_manage_members = can?(current_user, :admin_group_member, @group) - show_invited_members = can_manage_members && @invited_members.exists? -- pending_active = params[:search_invited].present? -- total_count = @members.count + @group.shared_with_group_links.count +- show_access_requests = can_manage_members && @requesters.exists? +- invited_active = params[:search_invited].present? || params[:invited_members_page].present? + +- form_item_label_css_class = 'label-bold gl-mr-2 gl-mb-0 gl-py-2 align-self-md-center' .js-remove-member-modal .project-members-page.gl-mt-3 %h4 - = _("Group members") + = _('Group members') %hr - if can_manage_members %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member") + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _('Invite member') %li.nav-tab{ role: 'presentation' } - %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _("Invite group") + %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab', qa_selector: 'invite_group_tab' }, role: 'tab' }= _('Invite group') .tab-content.gitlab-tab-content .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } = render_invite_member_for_group(@group, @group_member.access_level) .tab-pane{ id: 'invite-group-pane', role: 'tabpanel' } = render 'shared/members/invite_group', submit_url: group_group_links_path(@group), access_levels: GroupMember.access_level_roles, default_access_level: @group_member.access_level, group_link_field: 'shared_with_group_id', group_access_field: 'shared_group_access' - = render 'shared/members/requests', membership_source: @group, requesters: @requesters - = render_if_exists 'groups/group_members/ldap_sync' - %ul.nav-links.mobile-separator.nav.nav-tabs.clearfix + %ul.nav-links.mobile-separator.nav.nav-tabs %li.nav-item - = link_to "#existing_shares", class: ["nav-link", ("active" unless pending_active)] , 'data-toggle' => 'tab' do + = link_to '#tab-members', class: ['nav-link', ('active' unless invited_active)], data: { toggle: 'tab' } do %span - = _("Existing shares") - %span.badge.badge-pill= total_count + = _('Members') + %span.badge.badge-pill= @members.total_count + - if @group.shared_with_group_links.any? + %li.nav-item + = link_to '#tab-groups', class: ['nav-link'] , data: { toggle: 'tab', qa_selector: 'groups_list_tab' } do + %span + = _('Groups') + %span.badge.badge-pill= @group.shared_with_group_links.count - if show_invited_members %li.nav-item - = link_to "#invited_members", class: ["nav-link", ("active" if pending_active)], 'data-toggle' => 'tab' do + = link_to '#tab-invited-members', class: ['nav-link', ('active' if invited_active)], data: { toggle: 'tab' } do %span - = _("Pending") + = _('Invited') %span.badge.badge-pill= @invited_members.total_count - + - if show_access_requests + %li.nav-item + = link_to '#tab-access-requests', class: 'nav-link', data: { toggle: 'tab' } do + %span + = _('Access requests') + %span.badge.badge-pill= @requesters.count .tab-content - #existing_shares.tab-pane{ :class => ("active" unless pending_active) } - - if @group.shared_with_group_links.any? - .card.card-without-border - .d-flex.flex-column.flex-md-row.row-content-block.second-block - %span.flex-grow-1.align-self-md-center.col-form-label - = _("Groups with access to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - %ul.content-list.members-list{ data: { qa_selector: "groups_list" } } - - can_admin_member = can?(current_user, :admin_group_member, @group) - - @group.shared_with_group_links.each do |group_link| - = render 'shared/members/group', group_link: group_link, can_admin_member: can_admin_member, group_link_path: group_group_link_path(@group, group_link) + #tab-members.tab-pane{ class: ('active' unless invited_active) } .card.card-without-border - .d-flex.flex-column.flex-md-row.row-content-block.second-block - %span.flex-grow-1.align-self-md-center.col-form-label - = _("Members with access to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = form_tag group_group_members_path(@group), method: :get, class: 'form-inline user-search-form' do - .form-group.flex-grow - .position-relative.mr-md-2 - = search_field_tag :search, params[:search], { placeholder: _('Search'), class: 'form-control', spellcheck: false } - %button.user-search-btn.border-left{ type: "submit", "aria-label" => _("Submit search") } - = icon("search") - - if can_manage_members - = label_tag '2fa', '2FA', class: 'col-form-label label-bold pr-md-2' + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do + .gl-px-3.gl-py-2 + .search-control-wrap.gl-relative + = render 'shared/members/search_field' + - if can_manage_members + = render 'groups/group_members/tab_pane/form_item' do + = label_tag '2fa', _('2FA'), class: form_item_label_css_class = render 'shared/members/filter_2fa_dropdown' + = render 'groups/group_members/tab_pane/form_item' do + = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class = render 'shared/members/sort_dropdown' - %ul.content-list.members-list{ data: { qa_selector: "members_list" } } + %ul.content-list.members-list{ data: { qa_selector: 'members_list' } } = render partial: 'shared/members/member', collection: @members, as: :member - = paginate @members, theme: 'gitlab' - + = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil } + - if @group.shared_with_group_links.any? + #tab-groups.tab-pane + .card.card-without-border + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + %ul.content-list.members-list{ data: { qa_selector: 'groups_list' } } + - @group.shared_with_group_links.each do |group_link| + = render 'shared/members/group', group_link: group_link, can_admin_member: can_manage_members, group_link_path: group_group_link_path(@group, group_link) - if show_invited_members - #invited_members.tab-pane{ :class => ("active" if pending_active) } + #tab-invited-members.tab-pane{ class: ('active' if invited_active) } .card.card-without-border - .d-flex.flex-column.flex-md-row.row-content-block.second-block - %span.flex-grow-1 - = _("Members with pending access to %{strong_start}%{group_name}%{strong_end}").html_safe % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = form_tag group_group_members_path(@group), method: :get, class: 'form-inline user-search-form' do - .form-group - .position-relative.mr-md-2 - = search_field_tag :search_invited, params[:search_invited], { placeholder: _('Search'), class: 'form-control', spellcheck: false } - %button.user-search-btn.border-left{ type: "submit", "aria-label" => _("Submit search") } - = icon("search") + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do + = render 'shared/members/search_field', name: 'search_invited' %ul.content-list.members-list = render partial: 'shared/members/member', collection: @invited_members, as: :member - = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab' + = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil } + - if show_access_requests + #tab-access-requests.tab-pane + .card.card-without-border + = render 'groups/group_members/tab_pane/header' do + = render 'groups/group_members/tab_pane/title' do + = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + %ul.content-list.members-list + = render partial: 'shared/members/member', collection: @requesters, as: :member diff --git a/app/views/groups/group_members/tab_pane/_form_item.html.haml b/app/views/groups/group_members/tab_pane/_form_item.html.haml new file mode 100644 index 00000000000..9e57d3329d7 --- /dev/null +++ b/app/views/groups/group_members/tab_pane/_form_item.html.haml @@ -0,0 +1,2 @@ +.gl-px-3.gl-py-3.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row + = yield diff --git a/app/views/groups/group_members/tab_pane/_header.html.haml b/app/views/groups/group_members/tab_pane/_header.html.haml new file mode 100644 index 00000000000..a02bf90eddf --- /dev/null +++ b/app/views/groups/group_members/tab_pane/_header.html.haml @@ -0,0 +1,2 @@ +.gl-display-flex.gl-md-align-items-center.gl-flex-direction-column.gl-md-flex-direction-row.row-content-block.second-block + = yield diff --git a/app/views/groups/group_members/tab_pane/_title.html.haml b/app/views/groups/group_members/tab_pane/_title.html.haml new file mode 100644 index 00000000000..c1418a5f7c8 --- /dev/null +++ b/app/views/groups/group_members/tab_pane/_title.html.haml @@ -0,0 +1,2 @@ +%span.gl-flex-grow-1.gl-py-3.gl-pr-3 + = yield diff --git a/app/views/groups/issues.html.haml b/app/views/groups/issues.html.haml index 59432e5f015..1358e848154 100644 --- a/app/views/groups/issues.html.haml +++ b/app/views/groups/issues.html.haml @@ -4,7 +4,7 @@ = content_for :meta_tags do = auto_discovery_link_tag(:atom, safe_params.merge(rss_url_options).to_h, title: "#{@group.name} issues") -- if group_issues_count(state: 'all').zero? +- if group_issues_count(state: 'all') == 0 = render 'shared/empty_states/issues', project_select_button: true - else .top-area @@ -25,7 +25,7 @@ - if Feature.enabled?(:vue_issuables_list, @group) .js-issuables-list{ data: { endpoint: expose_url(api_v4_groups_issues_path(id: @group.id)), 'can-bulk-edit': @can_bulk_update.to_json, - 'empty-svg-path': image_path('illustrations/issues.svg'), + 'empty-state-meta': { svg_path: image_path('illustrations/issues.svg') }, 'sort-key': @sort } } - else = render 'shared/issues' diff --git a/app/views/groups/merge_requests.html.haml b/app/views/groups/merge_requests.html.haml index 1828f850d35..15e777f5c36 100644 --- a/app/views/groups/merge_requests.html.haml +++ b/app/views/groups/merge_requests.html.haml @@ -2,7 +2,7 @@ - page_title _("Merge Requests") -- if group_merge_requests_count(state: 'all').zero? +- if group_merge_requests_count(state: 'all') == 0 = render 'shared/empty_states/merge_requests', project_select_button: true - else .top-area diff --git a/app/views/groups/packages/_legacy_package_list.haml b/app/views/groups/packages/_legacy_package_list.haml new file mode 100644 index 00000000000..481a0dbb6e8 --- /dev/null +++ b/app/views/groups/packages/_legacy_package_list.haml @@ -0,0 +1,59 @@ +- sort_value = @sort +- sort_title = packages_sort_option_title(sort_value) + +- if @packages.any? + .d-flex.justify-content-end + .dropdown.inline.gl-mt-3.gl-mb-3.package-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static', 'qa-selector': 'sort-dropdown-button' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(sort_title_created_date, package_sort_path(sort: sort_value_recently_created), sort_title) + = sortable_item(sort_title_name, package_sort_path(sort: sort_value_name_desc), sort_title) + = sortable_item(sort_title_project_name, package_sort_path(sort: sort_value_project_name_desc), sort_title) + = sortable_item(sort_title_version, package_sort_path(sort: sort_value_version_desc), sort_title) + = sortable_item(sort_title_type, package_sort_path(sort: sort_value_type_desc), sort_title) + = packages_sort_direction_button(sort_value) + + .table-holder + .gl-responsive-table-row.table-row-header.bg-secondary-50.px-2.border-top{ role: 'row' } + .table-section.section-30{ role: 'rowheader' } + = _('Name') + .table-section.section-20{ role: 'rowheader' } + = _('Project') + .table-section.section-20{ role: 'rowheader' } + = _('Version') + .table-section.section-10{ role: 'rowheader' } + = _('Type') + .table-section.section-20{ role: 'rowheader' } + = _('Created') + - @packages.each do |package| + .gl-responsive-table-row{ data: { 'qa-selector': 'package-row' } } + .table-section.section-30 + .table-mobile-header{ role: "rowheader" }= _("Name") + .table-mobile-content.flex-truncate-parent + = link_to package.name, project_package_path(package.project, package), class: 'flex-truncate-child' + .table-section.section-20 + .table-mobile-header{ role: "rowheader" }= _("Project") + .table-mobile-content + = link_to_project(package.project) + .table-section.section-20 + .table-mobile-header{ role: "rowheader" }= _("Version") + .table-mobile-content + = package.version + .table-section.section-10 + .table-mobile-header{ role: "rowheader" }= _("Type") + .table-mobile-content + = package.package_type + .table-section.section-20 + .table-mobile-header{ role: "rowheader" }= _("Created") + .table-mobile-content + = time_ago_with_tooltip(package.created_at) + = paginate @packages, theme: "gitlab" +- else + .row.empty-state + .col-12 + = render 'shared/packages/no_packages' diff --git a/app/views/groups/packages/index.html.haml b/app/views/groups/packages/index.html.haml new file mode 100644 index 00000000000..b07c08f50ca --- /dev/null +++ b/app/views/groups/packages/index.html.haml @@ -0,0 +1,5 @@ +- page_title _("Packages") + +.row + .col-12 + #js-vue-packages-list{ data: packages_list_data('groups', @group) } diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index bf9d89da24a..555c4004a3f 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -15,7 +15,7 @@ .controls = link_to _('Members'), project_project_members_path(project), id: "edit_#{dom_id(project)}", class: "btn" = link_to _('Edit'), edit_project_path(project), id: "edit_#{dom_id(project)}", class: "btn" - = link_to _('Remove'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-remove" + = link_to _('Delete'), project, data: { confirm: remove_project_message(project)}, method: :delete, class: "btn btn-remove" .stats %span.badge.badge-pill diff --git a/app/views/groups/runners/_runner.html.haml b/app/views/groups/runners/_runner.html.haml index df615eb189a..07cbcd8401e 100644 --- a/app/views/groups/runners/_runner.html.haml +++ b/app/views/groups/runners/_runner.html.haml @@ -68,14 +68,14 @@ .btn-group.table-action-buttons .btn-group = link_to edit_group_runner_path(@group, runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do - = icon('pencil') + = sprite_icon('pencil') .btn-group - if runner.active? = link_to pause_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do - = icon('pause') + = sprite_icon('pause') - else = link_to resume_group_runner_path(@group, runner), method: :post, class: 'btn btn-default has-tooltip', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do - = icon('play') + = sprite_icon('play') - if runner.belongs_to_more_than_one_project? .btn-group .btn.btn-danger.has-tooltip{ 'aria-label' => 'Remove', 'data-container' => 'body', 'data-original-title' => _('Multi-project Runners cannot be removed'), 'data-placement' => 'top', disabled: 'disabled' } diff --git a/app/views/groups/settings/_advanced.html.haml b/app/views/groups/settings/_advanced.html.haml index 0df82898644..98f4acaa5e3 100644 --- a/app/views/groups/settings/_advanced.html.haml +++ b/app/views/groups/settings/_advanced.html.haml @@ -1,12 +1,12 @@ = render 'groups/settings/export', group: @group .sub-section - %h4.warning-title= s_('GroupSettings|Change group path') + %h4.warning-title= s_('GroupSettings|Change group URL') = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| = form_errors(@group) .form-group %p - = s_('GroupSettings|Changing group path can have unintended side effects.') + = s_('GroupSettings|Changing group URL can have unintended side effects.') = succeed '.' do = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' @@ -20,10 +20,10 @@ = f.text_field :path, placeholder: 'open-source', class: 'form-control', autofocus: local_assigns[:autofocus] || false, required: true, pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, - title: s_('GroupSettings|Please choose a group path with no special characters.'), + title: s_('GroupSettings|Please choose a group URL with no special characters.'), "data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}" - - = f.submit s_('GroupSettings|Change group path'), class: 'btn btn-warning' + .gl-display-flex.gl-justify-content-end + = f.submit s_('GroupSettings|Change group URL'), class: 'btn btn-warning' .sub-section %h4.warning-title= s_('GroupSettings|Transfer group') @@ -39,7 +39,8 @@ %li= s_('GroupSettings|You can only transfer the group to a group you manage.') %li= s_('GroupSettings|You will need to update your local repositories to point to the new location.') %li= s_("GroupSettings|If the parent group's visibility is lower than the group current visibility, visibility levels for subgroups and projects will be changed to match the new parent group's visibility.") - = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning' + .gl-display-flex.gl-justify-content-end + = f.submit s_('GroupSettings|Transfer group'), class: 'btn btn-warning' = render 'groups/settings/remove', group: @group = render_if_exists 'groups/settings/restore', group: @group diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index 94466b76ac8..af06cfff397 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -24,5 +24,6 @@ = link_to _('Download export'), download_export_group_path(group), rel: 'nofollow', method: :get, class: 'btn btn-default', data: { qa_selector: 'download_export_link' } - else - = link_to _('Export group'), export_group_path(group), - method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' } + .gl-display-flex.gl-justify-content-end + = link_to _('Export group'), export_group_path(group), + method: :post, class: 'btn btn-default', data: { qa_selector: 'export_group_link' } diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 0094104e07d..e43d49b229e 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -19,7 +19,7 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :group - .form-group.gl-mt-3.append-bottom-20 + .form-group.gl-mt-3.gl-mb-6 .avatar-container.rect-avatar.s90 = group_icon(@group, alt: '', class: 'avatar group-avatar s90') = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' @@ -29,5 +29,5 @@ = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group - - = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: 'btn btn-success mt-4 js-dirty-submit', data: { qa_selector: 'save_name_visibility_settings_button' } diff --git a/app/views/groups/settings/_pages_settings.html.haml b/app/views/groups/settings/_pages_settings.html.haml index 9e1932185da..b6cf05d96ab 100644 --- a/app/views/groups/settings/_pages_settings.html.haml +++ b/app/views/groups/settings/_pages_settings.html.haml @@ -1,5 +1,5 @@ = form_for @group, html: { multipart: true, class: 'gl-show-field-errors' }, authenticity_token: true do |f| = render_if_exists 'shared/pages/max_pages_size_input', form: f - .prepend-top-10 + .gl-mt-3 = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/groups/settings/_permanent_deletion.html.haml b/app/views/groups/settings/_permanent_deletion.html.haml index 155efc03ffe..063ff6dd132 100644 --- a/app/views/groups/settings/_permanent_deletion.html.haml +++ b/app/views/groups/settings/_permanent_deletion.html.haml @@ -5,5 +5,5 @@ = _('Removing this group also removes all child projects, including archived projects, and their resources.') %br %strong= _('Removed group can not be restored!') - - = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) } + .gl-display-flex.gl-justify-content-end + = button_to _('Remove group'), '#', class: 'btn btn-remove js-confirm-danger', data: { 'confirm-danger-message' => remove_group_message(group) } diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 507246d573e..86f49672d66 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -37,8 +37,9 @@ = render 'groups/settings/default_branch_protection', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group + = render_if_exists 'groups/settings/prevent_forking', f: f, group: @group = render 'groups/settings/two_factor_auth', f: f = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render_if_exists 'groups/member_lock_setting', f: f, group: @group - - = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: 'btn btn-success gl-mt-3 js-dirty-submit', data: { qa_selector: 'save_permissions_changes_button' } diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml index e7efc0237c8..2b5019222f8 100644 --- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -12,4 +12,4 @@ .form-text.text-muted = s_('GroupSettings|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' - = f.submit _('Save changes'), class: 'btn btn-success prepend-top-15' + = f.submit _('Save changes'), class: 'btn btn-success gl-mt-5' diff --git a/app/views/groups/sidebar/_packages.html.haml b/app/views/groups/sidebar/_packages.html.haml index 59061a048b3..54510d5df0c 100644 --- a/app/views/groups/sidebar/_packages.html.haml +++ b/app/views/groups/sidebar/_packages.html.haml @@ -1,16 +1,23 @@ -- if group_container_registry_nav? - = nav_link(controller: 'groups/registry/repositories') do - = link_to group_container_registries_path(@group), title: _('Container Registry') do +- packages_link = group_packages_list_nav? ? group_packages_path(@group) : group_container_registries_path(@group) + +- if group_packages_nav? + = nav_link(controller: ['groups/packages', 'groups/registry/repositories']) do + = link_to packages_link, title: _('Packages') do .nav-icon-container = sprite_icon('package') %span.nav-item-name = _('Packages & Registries') %ul.sidebar-sub-level-items - = nav_link(controller: 'groups/registry/repositories', html_options: { class: "fly-out-top-item" } ) do - = link_to group_container_registries_path(@group), title: _('Container Registry') do + = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do + = link_to packages_link, title: _('Packages & Registries') do %strong.fly-out-top-item-name = _('Packages & Registries') %li.divider.fly-out-top-item - = nav_link(controller: 'groups/registry/repositories') do - = link_to group_container_registries_path(@group), title: _('Container Registry') do - %span= _('Container Registry') + - if group_packages_list_nav? + = nav_link(controller: 'groups/packages') do + = link_to group_packages_path(@group), title: _('Packages') do + %span= _('Package Registry') + - if group_container_registry_nav? + = nav_link(controller: 'groups/registry/repositories') do + = link_to group_container_registries_path(@group), title: _('Container Registry') do + %span= _('Container Registry') diff --git a/app/views/help/instance_configuration/_ssh_info.html.haml b/app/views/help/instance_configuration/_ssh_info.html.haml index a7ee37b2784..2c6ada4d3f2 100644 --- a/app/views/help/instance_configuration/_ssh_info.html.haml +++ b/app/views/help/instance_configuration/_ssh_info.html.haml @@ -9,7 +9,7 @@ - if ssh_info.blank? %p - = _('SSH host keys are not available on this system. Please use <code>ssh-keyscan</code> command or contact your GitLab administrator for more information.').html_safe + = html_escape(_('SSH host keys are not available on this system. Please use %{ssh_keyscan} command or contact your GitLab administrator for more information.')) % { ssh_keyscan: tag.code('ssh-keyscan') } - else %p = _('Below are the fingerprints for the current instance SSH host keys.') diff --git a/app/views/help/ui.html.haml b/app/views/help/ui.html.haml deleted file mode 100644 index 5c216ee1ec0..00000000000 --- a/app/views/help/ui.html.haml +++ /dev/null @@ -1,524 +0,0 @@ -- page_title _("UI Development Kit"), _("Help") -- lorem = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed fermentum nisi sapien, non consequat lectus aliquam ultrices. Suspendisse sodales est euismod nunc condimentum, a consectetur diam ornare." -- link_classes = "flex-grow-1 mx-1 " - -.gitlab-ui-dev-kit - %h1 GitLab UI development kit - %p.light - Use page inspector in your browser to check element classes and structure - of examples below. - %hr - %ul - %li - = link_to 'Blocks', '#blocks' - %li - = link_to 'Lists', '#lists' - %li - = link_to 'Tables', '#tables' - %li - = link_to 'Nav', '#nav' - %li - = link_to 'Buttons', '#buttons' - %li - = link_to 'Dropdowns', '#dropdowns' - %li - = link_to 'Panels', '#panels' - %li - = link_to 'Alerts', '#alerts' - %li - = link_to 'Forms', '#forms' - %li - = link_to 'Files', '#file' - %li - = link_to 'Markdown', '#markdown' - - %h2#blocks Blocks - - .lead - Content block separated with botton border - %code .content-block - - .example - .content-block - %h4 Normal block inside content - = lorem - - .content-block - %h4 Second block - = lorem - - .lead - Gray content block with side padding using - %code .row-content-block - - .example - .row-content-block - %h4 Normal block inside content - = lorem - - .row-content-block.second-block - %h4 Second block - = lorem - - - .lead - Cover block for profile page with avatar, name and description - %code .cover-block - .example - .cover-block.user-cover-block - = render layout: 'users/cover_controls' do - = link_to '#', class: link_classes + 'btn btn-default' do - = icon('pencil') - = link_to '#', class: link_classes + 'btn btn-default' do - = icon('rss') - .avatar-holder - = image_tag avatar_icon_for_email('admin@example.com', 90), class: "avatar s90", alt: '' - .cover-title - John Smith - - .cover-desc.cgray - = lorem - - %h2#lists Lists - - .lead - Simple list using - %code .content-list - - .example - %ul.content-list - %li - One item - %li - One item - %li - One item - - .lead - List with avatar, title and description using - %code .content-list - - .example - %ul.content-list - %li - = image_tag 'no_avatar.png', class: 'avatar s40' - .title Title - .description Description - %li - = image_tag 'no_avatar.png', class: 'avatar s40' - .title Title - .description Description - %li - = image_tag 'no_avatar.png', class: 'avatar s40' - .title Title - .description Description - - .lead - List with hover effect - %code .hover-list - .example - %ul.hover-list - %li - One item - %li - One item - %li - One item - - .lead - List inside panel - .example - .card - .card-header Your list - %ul.content-list - %li - One item - %li - One item - %li - One item - - %h2#tables Tables - - .example - %table.table - %thead - %tr - %th # - %th First Name - %th Last Name - %th Username - %tbody - %tr - %td 1 - %td Mark - %td Otto - %td @mdo - %tr - %td 2 - %td Jacob - %td Thornton - %td @fat - %tr - %td 3 - %td Larry - %td the Bird - %td @twitter - - %h2#navs Navigation - - .lead - Holder for top page navigation. Includes navigation, search field, sorting and button - %code .top-area - - .example - .top-area - %ul.nav-links.nav.nav-tabs - %li.active - %a Open - %li - %a Closed - .nav-controls - = text_field_tag 'sample', nil, class: 'form-control' - .dropdown - %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } - %span Sort by name - = icon('chevron-down') - %ul.dropdown-menu - %li - = link_to 'Sort by date', '#' - - = link_to 'New issue', '#', class: 'btn btn-success btn-inverted' - - .lead - Only nav links without button and search - %code .nav-links - .example - %ul.nav-links - %li.active - %a Open - %li - %a Closed - - - %h2#buttons Buttons - - .example - %button.btn.btn-default{ :type => "button" } Secondary - %button.btn.btn-primary{ :type => "button" } Primary - %button.btn.btn-success{ :type => "button" } Success - %button.btn.btn-info{ :type => "button" } Info - %button.btn.btn-warning{ :type => "button" } Warning - %button.btn.btn-danger{ :type => "button" } Danger - %button.btn.btn-link{ :type => "button" } Link - - %h2#dropdowns Dropdowns - - .example - .clearfix - .dropdown.inline.float-left - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Dropdown - = icon('chevron-down') - %ul.dropdown-menu - %li - %a{ href: "#" } - Dropdown option - .dropdown.inline.float-right - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Dropdown - = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-right - %li - %a{ href: "#" } - Dropdown option - .example - %div - .dropdown.inline - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Dropdown - = icon('chevron-down') - %ul.dropdown-menu.dropdown-menu-selectable - %li - %a.is-active{ href: "#" } - Dropdown option - .example - %div - .dropdown.inline - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Dropdown - = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable - .dropdown-title - %span Dropdown title - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times') - .dropdown-input - %input.dropdown-input-field{ type: "search", placeholder: "Filter results" } - = icon('search') - .dropdown-content - %ul - %li - %a.is-active{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li.divider - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - .dropdown-footer - %strong Tip: - If an author is not a member of this project, you can still filter by their name while using the search field. - .dropdown.inline - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Dropdown loading - = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable.is-loading - .dropdown-title - %span Dropdown title - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times') - .dropdown-input - %input.dropdown-input-field{ type: "search", placeholder: "Filter results" } - = icon('search') - .dropdown-content - %ul - %li - %a.is-active{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li.divider - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - %li - %a{ href: "#" } - Dropdown option - .dropdown-footer - %strong Tip: - If an author is not a member of this project, you can still filter by their name while using the search field. - .dropdown-loading.text-center - .spinner.spinner-md.mt-8 - - .example - %div - .dropdown.inline - %button.dropdown-menu-toggle{ type: 'button', data: {toggle: 'dropdown' } } - Dropdown user - = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user - .dropdown-title - %span Dropdown title - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times') - .dropdown-input - %input.dropdown-input-field{ type: "search", placeholder: "Filter results" } - = icon('search') - .dropdown-content - %ul - %li - %a.dropdown-menu-user-link.is-active{ href: "#" } - = link_to_member_avatar(@user, size: 30) - %strong.dropdown-menu-user-full-name - = @user.name - .dropdown-menu-user-username - = @user.to_reference - - .example - %div - .dropdown.inline - %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Dropdown page 2 - = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-user.dropdown-menu-paging.is-page-two - .dropdown-page-one - .dropdown-title - %button.dropdown-title-button.dropdown-menu-back{ aria: { label: "Go back" } } - = icon('arrow-left') - %span Dropdown title - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times') - .dropdown-input - %input.dropdown-input-field{ type: "search", placeholder: "Filter results" } - = icon('search') - .dropdown-content - %ul - %li - %a.dropdown-menu-user-link.is-active{ href: "#" } - = link_to_member_avatar(@user, size: 30) - %strong.dropdown-menu-user-full-name - = @user.name - .dropdown-menu-user-username - = @user.to_reference - .dropdown-page-two - .dropdown-title - %button.dropdown-title-button.dropdown-menu-back{ aria: { label: "Go back" } } - = icon('arrow-left') - %span Create label - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times') - .dropdown-input - %input.dropdown-input-field{ type: "search", placeholder: "Name new label" } - .dropdown-content - %button.btn.btn-primary - Create - - .example - %div - .dropdown.inline - %button#js-project-dropdown.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown' } } - Projects - = icon('chevron-down') - .dropdown-menu.dropdown-select.dropdown-menu-selectable - .dropdown-title - %span Go to project - %button.dropdown-title-button.dropdown-menu-close{ aria: { label: "Close" } } - = icon('times') - .dropdown-input - %input.dropdown-input-field{ type: "search", placeholder: "Filter results" } - = icon('search') - .dropdown-content - .dropdown-loading.text-center - .spinner.spinner-md.mt-8 - - .example - %div - = dropdown_tag("Projects", options: { title: "Go to project", filter: true, placeholder: "Filter projects" }) - - %h2#panels Panels - - .row - .col-md-6 - .card.bg-success - .card-header Success - .card-body - = lorem - .card.bg-primary - .card-header Primary - .card-body - = lorem - .card.bg-info - .card-header Info - .card-body - = lorem - .col-md-6 - .card.bg-warning - .card-header Warning - .card-body - = lorem - .card.bg-danger - .card-header Danger - .card-body - = lorem - - %h2#alerts Alerts - - .row - .col-md-6 - .alert.alert-success - = lorem - .alert.alert-info - = lorem - .col-md-6 - .alert.alert-warning - = lorem - .alert.alert-danger - = lorem - - %h2#forms Forms - - .lead - Horizontal form when label rendered inline with input - %code form.horizontal-form - - .example - %form - .form-group.row - %label.col-sm-2.col-form-label{ :for => "inputEmail3" } Email - .col-sm-10 - %input#inputEmail3.form-control{ :placeholder => "Email", :type => "email" }/ - .form-group.row - %label.col-sm-2.col-form-label{ :for => "inputPassword3" } Password - .col-sm-10 - %input#inputPassword3.form-control{ :placeholder => "Password", :type => "password" }/ - .form-group.row - .offset-sm-2.col-sm-10 - .form-check - %input.form-check-input{ :type => "checkbox" }/ - %label.form-check-label - Remember me - .form-group.row - .offset-sm-2.col-sm-10 - %button.btn.btn-default{ :type => "submit" } Sign in - - .lead - Form when label rendered above input - %code form - - .example - %form - .form-group - %label{ :for => "exampleInputEmail1" } Email address - %input#exampleInputEmail1.form-control{ :placeholder => "Enter email", :type => "email" }/ - .form-group - %label{ :for => "exampleInputPassword1" } Password - %input#exampleInputPassword1.form-control{ :placeholder => "Password", :type => "password" }/ - .form-check - %input.form-check-input{ :type => "checkbox" }/ - %label.form-check-label - Remember me - %button.btn.btn-default{ :type => "submit" } Sign in - - %h2#file File - %h4 - %code .file-holder - - - blob = Snippet.new(content: "Wow\nSuch\nFile").blob - .example - .file-holder - .js-file-title.file-title - Awesome file - .file-actions - .btn-group - %a.btn Edit - %a.btn.btn-danger Remove - = render 'shared/file_highlight', blob: blob - - %h2#markdown Markdown - %h4 - %code .md - - Markdown rendering has a bit different css and presented in next UI elements: - - %ul - %li comment - %li issue, merge request description - %li wiki page - %li help page - - You can check how markdown rendered at #{link_to 'Markdown help page', help_page_path("user/markdown")}. diff --git a/app/views/import/_githubish_status.html.haml b/app/views/import/_githubish_status.html.haml index 9bf1f0c61bb..fca73f118b3 100644 --- a/app/views/import/_githubish_status.html.haml +++ b/app/views/import/_githubish_status.html.haml @@ -1,12 +1,15 @@ - provider = local_assigns.fetch(:provider) - extra_data = local_assigns.fetch(:extra_data, {}) - filterable = local_assigns.fetch(:filterable, true) +- paginatable = local_assigns.fetch(:paginatable, false) - provider_title = Gitlab::ImportSources.title(provider) #import-projects-mount-element{ data: { provider: provider, provider_title: provider_title, can_select_namespace: current_user.can_select_namespace?.to_s, ci_cd_only: has_ci_cd_only_params?.to_s, + namespaces_path: import_available_namespaces_path, repos_path: url_for([:status, :import, provider, format: :json]), jobs_path: url_for([:realtime_changes, :import, provider, format: :json]), import_path: url_for([:import, provider, format: :json]), - filterable: filterable.to_s }.merge(extra_data) } + filterable: filterable.to_s, + paginatable: paginatable.to_s }.merge(extra_data) } diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index a24a1c1fb05..b3ca1beb853 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -5,4 +5,4 @@ %i.fa.fa-bitbucket-square = _('Import projects from Bitbucket Server') -= render 'import/githubish_status', provider: 'bitbucket_server', extra_data: { reconfigure_path: configure_import_bitbucket_server_path } += render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, extra_data: { reconfigure_path: configure_import_bitbucket_server_path } diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 8ed9dc68bb3..cdc53520e93 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -18,7 +18,7 @@ %li %strong= _("Map a FogBugz account ID to a GitLab user") %p - = _('Selecting a GitLab user will add a link to the GitLab user in the descriptions of issues and comments (e.g. "By <a href="#">@johnsmith</a>"). It will also associate and/or assign these issues and comments with the selected user.').html_safe + = html_escape(_('Selecting a GitLab user will add a link to the GitLab user in the descriptions of issues and comments (e.g. "By %{link_open}@johnsmith%{link_close}"). It will also associate and/or assign these issues and comments with the selected user.')) % { link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe } .table-holder %table.table diff --git a/app/views/import/gitlab/status.html.haml b/app/views/import/gitlab/status.html.haml index 5513849be3d..ef803a36e79 100644 --- a/app/views/import/gitlab/status.html.haml +++ b/app/views/import/gitlab/status.html.haml @@ -1,7 +1,7 @@ - page_title _("GitLab.com import") - header_title _("Projects"), root_path %h3.page-title - = sprite_icon('heart', size: 16, css_class: 'gl-vertical-align-middle') + = sprite_icon('heart', css_class: 'gl-vertical-align-middle') = _('Import projects from GitLab.com') = render 'import/githubish_status', provider: 'gitlab', filterable: false diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index b667d2aa0d7..cd477c085f9 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -3,7 +3,7 @@ %h3.page-title.d-flex .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('tanuki', size: 16, css_class: 'gl-mr-2') + = sprite_icon('tanuki', css_class: 'gl-mr-2') = _('Import an exported GitLab project') %hr diff --git a/app/views/import/google_code/new.html.haml b/app/views/import/google_code/new.html.haml index 7a6ad28f0aa..7dec67191b9 100644 --- a/app/views/import/google_code/new.html.haml +++ b/app/views/import/google_code/new.html.haml @@ -19,31 +19,31 @@ = _("Make sure you're logged into the account that owns the projects you'd like to import.") %li %p - = _('Click the <strong>Select none</strong> button on the right, since we only need "Google Code Project Hosting".').html_safe + = html_escape(_('Click the %{strong_open}Select none%{strong_close} button on the right, since we only need "Google Code Project Hosting".')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li %p - = _('Scroll down to <strong>Google Code Project Hosting</strong> and enable the switch on the right.').html_safe + = html_escape(_('Scroll down to %{strong_open}Google Code Project Hosting%{strong_close} and enable the switch on the right.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li %p - = _('Choose <strong>Next</strong> at the bottom of the page.').html_safe + = html_escape(_('Choose %{strong_open}Next%{strong_close} at the bottom of the page.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li %p = _('Leave the "File type" and "Delivery method" options on their default values.') %li %p - = _('Choose <strong>Create archive</strong> and wait for archiving to complete.').html_safe + = html_escape(_('Choose %{strong_open}Create archive%{strong_close} and wait for archiving to complete.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li %p - = _('Click the <strong>Download</strong> button and wait for downloading to complete.').html_safe + = html_escape(_('Click the %{strong_open}Download%{strong_close} button and wait for downloading to complete.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li %p = _('Find the downloaded ZIP file and decompress it.') %li %p - = _('Find the newly extracted <code>Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json</code> file.').html_safe + = html_escape(_('Find the newly extracted %{code_open}Takeout/Google Code Project Hosting/GoogleCodeProjectHosting.json%{code_close} file.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li %p - = _('Upload <code>GoogleCodeProjectHosting.json</code> here:').html_safe + = html_escape(_('Upload %{code_open}GoogleCodeProjectHosting.json%{code_close} here:')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %p %input{ type: "file", name: "dump_file", id: "dump_file" } %li @@ -57,6 +57,6 @@ = label_tag :create_user_map_1 do = radio_button_tag :create_user_map, 1, false = _('Yes, let me map Google Code users to full names or GitLab users.') - %li - %p - = submit_tag _('Continue to the next step'), class: "btn btn-success" + + %span + = submit_tag _('Continue to the next step'), class: "btn btn-success" diff --git a/app/views/import/google_code/new_user_map.html.haml b/app/views/import/google_code/new_user_map.html.haml index 732ba95a63f..1f1bfda7ee4 100644 --- a/app/views/import/google_code/new_user_map.html.haml +++ b/app/views/import/google_code/new_user_map.html.haml @@ -9,24 +9,24 @@ %p = _("Customize how Google Code email addresses and usernames are imported into GitLab. In the next step, you'll be able to select the projects you want to import.") %p - = _("The user map is a JSON document mapping the Google Code users that participated on your projects to the way their email addresses and usernames will be imported into GitLab. You can change this by changing the value on the right hand side of <code>:</code>. Be sure to preserve the surrounding double quotes, other punctuation and the email address or username on the left hand side.").html_safe + = html_escape(_("The user map is a JSON document mapping the Google Code users that participated on your projects to the way their email addresses and usernames will be imported into GitLab. You can change this by changing the value on the right hand side of %{code_open}:%{code_close}. Be sure to preserve the surrounding double quotes, other punctuation and the email address or username on the left hand side.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %ul %li %strong= _("Default: Directly import the Google Code email address or username") %p - = _('<code>"johnsmith@example.com": "johnsm...@example.com"</code> will add "By johnsm...@example.com" to all issues and comments originally created by johnsmith@example.com. The email address or username is masked to ensure the user\'s privacy.').html_safe + = html_escape(_('%{code_open}"johnsmith@example.com": "johnsm...@example.com"%{code_close} will add "By johnsm...@example.com" to all issues and comments originally created by johnsmith@example.com. The email address or username is masked to ensure the user\'s privacy.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li %strong= _("Map a Google Code user to a GitLab user") %p - = _('<code>"johnsmith@example.com": "@johnsmith"</code> will add "By <a href="#">@johnsmith</a>" to all issues and comments originally created by johnsmith@example.com, and will set <a href="#">@johnsmith</a> as the assignee on all issues originally assigned to johnsmith@example.com.').html_safe + = html_escape(_('%{code_open}"johnsmith@example.com": "@johnsmith"%{code_close} will add "By %{link_open}@johnsmith%{link_close}" to all issues and comments originally created by johnsmith@example.com, and will set %{link_open}@johnsmith%{link_close} as the assignee on all issues originally assigned to johnsmith@example.com.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe } %li %strong= _("Map a Google Code user to a full name") %p - = _('<code>"johnsmith@example.com": "John Smith"</code> will add "By John Smith" to all issues and comments originally created by johnsmith@example.com.').html_safe + = html_escape(_('%{code_open}"johnsmith@example.com": "John Smith"%{code_close} will add "By John Smith" to all issues and comments originally created by johnsmith@example.com.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li %strong= _("Map a Google Code user to a full email address") %p - = _('<code>"johnsmith@example.com": "johnsmith@example.com"</code> will add "By <a href="#">johnsmith@example.com</a>" to all issues and comments originally created by johnsmith@example.com. By default, the email address or username is masked to ensure the user\'s privacy. Use this option if you want to show the full email address.').html_safe + = html_escape(_('%{code_open}"johnsmith@example.com": "johnsmith@example.com"%{code_close} will add "By %{link_open}johnsmith@example.com%{link_close}" to all issues and comments originally created by johnsmith@example.com. By default, the email address or username is masked to ensure the user\'s privacy. Use this option if you want to show the full email address.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe, link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe } .form-group.row .col-sm-12 diff --git a/app/views/import/google_code/status.html.haml b/app/views/import/google_code/status.html.haml index f322b7a956a..8d8754e1069 100644 --- a/app/views/import/google_code/status.html.haml +++ b/app/views/import/google_code/status.html.haml @@ -37,7 +37,7 @@ %td = link_to project.import_source, "https://code.google.com/p/#{project.import_source}", target: "_blank", rel: 'noopener noreferrer' %td - = link_to project.full_path, [project.namespace.becomes(Namespace), project] + = link_to project.full_path, project %td.job-status - case project.import_status - when 'finished' diff --git a/app/views/import/manifest/_form.html.haml b/app/views/import/manifest/_form.html.haml index b515ce084e4..3a5b5924c6a 100644 --- a/app/views/import/manifest/_form.html.haml +++ b/app/views/import/manifest/_form.html.haml @@ -18,6 +18,6 @@ = _('Import multiple repositories by uploading a manifest file.') = link_to icon('question-circle'), help_page_path('user/project/import/manifest') - .append-bottom-10 + .gl-mb-3 = submit_tag _('List available repositories'), class: 'btn btn-success' = link_to _('Cancel'), new_project_path, class: 'btn btn-cancel' diff --git a/app/views/import/manifest/status.html.haml b/app/views/import/manifest/status.html.haml index e85162ad1b4..c3e77554b09 100644 --- a/app/views/import/manifest/status.html.haml +++ b/app/views/import/manifest/status.html.haml @@ -1,42 +1,7 @@ - page_title _("Manifest import") - header_title _("Projects"), root_path -- provider = 'manifest' %h3.page-title = _('Manifest file import') -%p - = button_tag class: "btn btn-import btn-success js-import-all" do - = _('Import all repositories') - = icon("spinner spin", class: "loading-icon") - -.table-responsive - %table.table.import-jobs - %thead - %tr - %th= _('Repository URL') - %th= _('To GitLab') - %th= _('Status') - %tbody - - @already_added_projects.each do |project| - %tr{ id: "project_#{project.id}", class: project_status_css_class(project.import_status) } - %td - = project.import_url - %td - = link_to_project project - %td.job-status - = render 'import/project_status', project: project - - - @pending_repositories.each do |repository| - %tr{ id: "repo_#{repository[:id]}" } - %td - = repository[:url] - %td.import-target - = import_project_target(@group.full_path, repository[:path]) - %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do - = _('Import') - = icon("spinner spin", class: "loading-icon") - -.js-importer-status{ data: { jobs_import_path: url_for([:jobs, :import, provider]), - import_path: url_for([:import, provider]) } } += render 'import/githubish_status', provider: 'manifest' diff --git a/app/views/import/phabricator/new.html.haml b/app/views/import/phabricator/new.html.haml index 3dfc7c37d98..5f73a27dbd6 100644 --- a/app/views/import/phabricator/new.html.haml +++ b/app/views/import/phabricator/new.html.haml @@ -3,8 +3,10 @@ - breadcrumb_title title - header_title _("Projects"), root_path -%h3.page-title - = icon 'issues', text: _('Import tasks from Phabricator into issues') +%h3.page-title.d-flex + .gl-display-flex.gl-align-items-center.gl-justify-content-center + = sprite_icon('issues', css_class: 'gl-mr-2') + = _('Import tasks from Phabricator into issues') = render 'import/shared/errors' diff --git a/app/views/instance_statistics/dev_ops_score/_card.html.haml b/app/views/instance_statistics/dev_ops_score/_card.html.haml index c63bd96a175..dd6e5c0f108 100644 --- a/app/views/instance_statistics/dev_ops_score/_card.html.haml +++ b/app/views/instance_statistics/dev_ops_score/_card.html.haml @@ -18,8 +18,8 @@ = number_to_percentage(card.percentage_score, precision: 1) .board-card-buttons - if card.blog - %a{ href: card.blog } - = icon('info-circle', 'aria-hidden' => 'true') + %a.btn-svg{ href: card.blog } + = sprite_icon('information-o') - if card.docs - %a{ href: card.docs } - = icon('question-circle', 'aria-hidden' => 'true') + %a.btn-svg{ href: card.docs } + = sprite_icon('question-o') diff --git a/app/views/invites/show.html.haml b/app/views/invites/show.html.haml index 2bcd64d0690..283683511d7 100644 --- a/app/views/invites/show.html.haml +++ b/app/views/invites/show.html.haml @@ -2,28 +2,19 @@ %h3.page-title= _("Invitation") %p - You have been invited - - if inviter = @member.created_by - by + = _("You have been invited") + - inviter = @member.created_by + - if inviter + = _("by") = link_to inviter.name, user_url(inviter) - to join - - case @member.source - - when Project - - project = @member.source - project - %strong - = link_to project.full_name, project_url(project) - - when Group - - group = @member.source - group - %strong - = link_to group.name, group_url(group) - as #{@member.human_access}. + = _("to join %{source_name}") % { source_name: @invite_details[:title] } + %strong + = link_to @invite_details[:name], @invite_details[:url] + = _("as %{role}.") % { role: @member.human_access } - if member? %p - - member_source = @member.source.is_a?(Group) ? _("group") : _("project") - = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: member_source } + = _("However, you are already a member of this %{member_source}. Sign in using a different account to accept the invitation.") % { member_source: @invite_details[:title] } - if !current_user_matches_invite? %p @@ -32,7 +23,7 @@ - link_to_current_user = link_to(current_user.to_reference, user_url(current_user)) = _("Note that this invitation was sent to %{mail_to_invite_email}, but you are signed in as %{link_to_current_user} with email %{mail_to_current_user}.").html_safe % { mail_to_invite_email: mail_to_invite_email, mail_to_current_user: mail_to_current_user, link_to_current_user: link_to_current_user } -- unless member? +- if !member? .actions = link_to _("Accept invitation"), accept_invite_url(@token), method: :post, class: "btn btn-success" = link_to _("Decline"), decline_invite_url(@token), method: :post, class: "btn btn-danger gl-ml-3" diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index 07c271be2f0..be3f2fd74e4 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -6,7 +6,8 @@ .js-toast-message{ data: { message: value } } - elsif value %div{ class: "flash-#{key} mb-2" } - = sprite_icon(icons[key], size: 16, css_class: 'align-middle mr-1') unless icons[key].nil? + = sprite_icon(icons[key], css_class: 'align-middle mr-1') unless icons[key].nil? %span= value - %div{ class: "close-icon-wrapper js-close-icon" } - = sprite_icon('close', size: 16, css_class: 'close-icon') + - if %w(alert notice success).include?(key) + %div{ class: "close-icon-wrapper js-close-icon" } + = sprite_icon('close', css_class: 'close-icon') diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index d1311f17b72..b869298e99d 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -49,14 +49,17 @@ = favicon_link_tag favicon, id: 'favicon', data: { original_href: favicon }, type: 'image/png' + = render 'layouts/startup_css' - if user_application_theme == 'gl-dark' - = stylesheet_link_tag "application_dark", media: "all" + = stylesheet_link_tag_defer "application_dark" - else - = stylesheet_link_tag "application", media: "all" + = stylesheet_link_tag_defer "application" = stylesheet_link_tag "disable_animations", media: "all" if Rails.env.test? || Gitlab.config.gitlab['disable_animations'] - = stylesheet_link_tag 'performance_bar' if performance_bar_enabled? + = stylesheet_link_tag_defer 'performance_bar' if performance_bar_enabled? - = stylesheet_link_tag "highlight/themes/#{user_color_scheme}", media: "all" + = stylesheet_link_tag_defer "highlight/themes/#{user_color_scheme}" + + = render 'layouts/startup_css_activation' = Gon::Base.render_data(nonce: content_security_policy_nonce) @@ -70,6 +73,7 @@ = yield :page_specific_javascripts = webpack_controller_bundle_tags + = webpack_bundle_tag "chrome_84_icon_fix" if browser.chrome?([">=84", "<85"]) || browser.edge?([">=84", "<85"]) = yield :project_javascripts diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 72b88fa8f7f..3a543fef292 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -15,6 +15,8 @@ = render "shared/ping_consent" = render_account_recovery_regular_check = render_if_exists "layouts/header/ee_subscribable_banner" + = render_if_exists "shared/namespace_storage_limit_alert" + = yield :customize_homepage_banner - unless @hide_breadcrumbs = render "layouts/nav/breadcrumbs" .d-flex diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 81fe0798bd1..0c6932e59a9 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -1,5 +1,5 @@ .search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input", track_value: "" } } - = form_tag search_path, method: :get, class: 'form-inline' do |f| + = form_tag search_path, method: :get, class: 'form-inline' do |_f| .search-input-container .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } @@ -20,8 +20,8 @@ %a = _('Loading...') = dropdown_loading - = sprite_icon('search', size: 16, css_class: 'search-icon') - = sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input') + = sprite_icon('search', css_class: 'search-icon') + = sprite_icon('close', css_class: 'clear-icon js-clear-input') = hidden_field_tag :group_id, search_context.for_group? ? search_context.group.id : '', class: 'js-search-group-options', data: search_context.group_metadata = hidden_field_tag :project_id, search_context.for_project? ? search_context.project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: search_context.project_metadata diff --git a/app/views/layouts/_startup_css.haml b/app/views/layouts/_startup_css.haml new file mode 100644 index 00000000000..094038d39b0 --- /dev/null +++ b/app/views/layouts/_startup_css.haml @@ -0,0 +1,4 @@ +- return unless use_startup_css? + +%style{ type: "text/css" } + = Rails.application.assets_manifest.find_sources('startup/startup-general.css').first.to_s.html_safe diff --git a/app/views/layouts/_startup_css_activation.haml b/app/views/layouts/_startup_css_activation.haml new file mode 100644 index 00000000000..0b1cce06f47 --- /dev/null +++ b/app/views/layouts/_startup_css_activation.haml @@ -0,0 +1,7 @@ +- return unless use_startup_css? + += javascript_tag nonce: true do + :plain + document.querySelectorAll('link[media="print"]').forEach(linkTag => { + linkTag.addEventListener('load', function() {this.media='all'}, {once: true}); + }) diff --git a/app/views/layouts/devise_experimental_onboarding_issues.html.haml b/app/views/layouts/devise_experimental_onboarding_issues.html.haml new file mode 100644 index 00000000000..df2afbe60ae --- /dev/null +++ b/app/views/layouts/devise_experimental_onboarding_issues.html.haml @@ -0,0 +1,11 @@ +!!! 5 +%html.devise-layout-html.navless{ class: system_message_class } + = render "layouts/head" + %body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } + = render "layouts/header/logo_with_title" + = render "layouts/init_client_detection_flags" + .page-wrap + .container.signup-box-container.navless-container + = render "layouts/broadcast" + .content + = yield diff --git a/app/views/layouts/group.html.haml b/app/views/layouts/group.html.haml index 36b664e5888..8f4c89a9e77 100644 --- a/app/views/layouts/group.html.haml +++ b/app/views/layouts/group.html.haml @@ -3,6 +3,7 @@ - header_title group_title(@group) unless header_title - nav "group" - display_subscription_banner! +- display_namespace_storage_limit_alert! - @left_sidebar = true - content_for :page_specific_javascripts do diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index b4e25956f16..56b70c463d0 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -7,7 +7,7 @@ .title-container %h1.title %span.gl-sr-only GitLab - = link_to root_path, title: _('Dashboard'), id: 'logo' do + = link_to root_path, title: _('Dashboard'), id: 'logo', **tracking_attrs('main_navigation', 'click_gitlab_logo_link', 'navigation') do = brand_header_logo - logo_text = brand_header_logo_type - if logo_text.present? @@ -32,32 +32,47 @@ = render 'layouts/search' unless current_controller?(:search) %li.nav-item.d-inline-block.d-lg-none = link_to search_context.search_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('search', size: 16) + = sprite_icon('search') - if header_link?(:issues) = nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do - = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('issues', size: 16) + = link_to assigned_issues_dashboard_path, title: _('Issues'), class: 'dashboard-shortcuts-issues', aria: { label: _('Issues') }, + data: { qa_selector: 'issues_shortcut_button', toggle: 'tooltip', placement: 'bottom', + track_label: 'main_navigation', + track_event: 'click_issues_link', + track_property: 'navigation', + container: 'body' } do + = sprite_icon('issues') - issues_count = assigned_issuables_count(:issues) - %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count.zero?) } + %span.badge.badge-pill.issues-count.green-badge{ class: ('hidden' if issues_count == 0) } = number_with_delimiter(issues_count) - if header_link?(:merge_requests) = nav_link(path: 'dashboard#merge_requests', html_options: { class: "user-counter" }) do - = link_to assigned_mrs_dashboard_path, title: _('Merge requests'), class: 'dashboard-shortcuts-merge_requests', aria: { label: _('Merge requests') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('git-merge', size: 16) + = link_to assigned_mrs_dashboard_path, title: _('Merge requests'), class: 'dashboard-shortcuts-merge_requests', aria: { label: _('Merge requests') }, + data: { qa_selector: 'merge_requests_shortcut_button', toggle: 'tooltip', placement: 'bottom', + track_label: 'main_navigation', + track_event: 'click_merge_link', + track_property: 'navigation', + container: 'body' } do + = sprite_icon('git-merge') - merge_requests_count = assigned_issuables_count(:merge_requests) - %span.badge.badge-pill.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) } + %span.badge.badge-pill.merge-requests-count{ class: ('hidden' if merge_requests_count == 0) } = number_with_delimiter(merge_requests_count) - if header_link?(:todos) = nav_link(controller: 'dashboard/todos', html_options: { class: "user-counter" }) do - = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do - = sprite_icon('todo-done', size: 16) - %span.badge.badge-pill.todos-count{ class: ('hidden' if todos_pending_count.zero?) } + = link_to dashboard_todos_path, title: _('To-Do List'), aria: { label: _('To-Do List') }, class: 'shortcuts-todos', + data: { qa_selector: 'todos_shortcut_button', toggle: 'tooltip', placement: 'bottom', + track_label: 'main_navigation', + track_event: 'click_to_do_link', + track_property: 'navigation', + container: 'body' } do + = sprite_icon('todo-done') + %span.badge.badge-pill.todos-count{ class: ('hidden' if todos_pending_count == 0) } = todos_count_format(todos_pending_count) - %li.nav-item.header-help.dropdown.d-none.d-md-block + %li.nav-item.header-help.dropdown.d-none.d-md-block{ **tracking_attrs('main_navigation', 'click_question_mark_link', 'navigation') } = link_to help_path, class: 'header-help-dropdown-toggle', data: { toggle: "dropdown" } do %span.gl-sr-only = s_('Nav|Help') - = sprite_icon('question', size: 16) + = sprite_icon('question') = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/help_dropdown' @@ -84,5 +99,8 @@ = sprite_icon('ellipsis_h', size: 12, css_class: 'more-icon js-navbar-toggle-right') = sprite_icon('close', size: 12, css_class: 'close-icon js-navbar-toggle-left') +- if ::Feature.enabled?(:whats_new_drawer) + #whats-new-app + - if can?(current_user, :update_user_status, current_user) .js-set-status-modal-wrapper{ data: { current_emoji: current_user.status.present? ? current_user.status.emoji : '', current_message: current_user.status.present? ? current_user.status.message : '' } } diff --git a/app/views/layouts/header/_new_dropdown.haml b/app/views/layouts/header/_new_dropdown.haml index 4bfac76ec5b..0c989242194 100644 --- a/app/views/layouts/header/_new_dropdown.haml +++ b/app/views/layouts/header/_new_dropdown.haml @@ -1,6 +1,6 @@ %li.header-new.dropdown{ data: { track_label: "new_dropdown", track_event: "click_dropdown", track_value: "" } } = link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip qa-new-menu-toggle", id: "js-onboarding-new-project-link", title: _("New..."), ref: 'tooltip', aria: { label: _("New...") }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body', display: 'static' } do - = sprite_icon('plus-square', size: 16) + = sprite_icon('plus-square') = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right %ul diff --git a/app/views/layouts/nav/_breadcrumbs.html.haml b/app/views/layouts/nav/_breadcrumbs.html.haml index c344d3d484f..547d005a93e 100644 --- a/app/views/layouts/nav/_breadcrumbs.html.haml +++ b/app/views/layouts/nav/_breadcrumbs.html.haml @@ -2,11 +2,11 @@ - hide_top_links = @hide_top_links || false %nav.breadcrumbs{ role: "navigation", class: [container, @content_class] } - .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border && mr_tabs_position_enabled?) } + .breadcrumbs-container{ class: ("border-bottom-0" if @no_breadcrumb_border) } - if defined?(@left_sidebar) = button_tag class: 'toggle-mobile-nav', type: 'button' do %span.sr-only= _("Open sidebar") - = icon ('bars') + = sprite_icon('hamburger') .breadcrumbs-links.js-title-container{ data: { qa_selector: 'breadcrumb_links_content' } } %ul.list-unstyled.breadcrumbs-list.js-breadcrumbs-list - unless hide_top_links diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index e6cfd7d56bb..29cacbe4aff 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -18,7 +18,7 @@ = render "layouts/nav/groups_dropdown/show" - if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets]) - %li.header-more.dropdown + %li.header-more.dropdown{ **tracking_attrs('main_navigation', 'click_more_link', 'navigation') } %a{ href: "#", data: { toggle: "dropdown", qa_selector: 'more_dropdown' } } = _('More') = sprite_icon('angle-down', css_class: 'caret-down') diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index e72535b8824..7fb5fff1e05 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -223,7 +223,7 @@ %span.nav-item-name.qa-admin-settings-item = _('Settings') - %ul.sidebar-sub-level-items.qa-admin-sidebar-settings-submenu + %ul.sidebar-sub-level-items{ data: { qa_selector: 'admin_sidebar_settings_submenu_content' } } = nav_link(controller: [:application_settings, :integrations], html_options: { class: "fly-out-top-item" } ) do = link_to general_admin_application_settings_path do %strong.fly-out-top-item-name diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 909d72edb31..47dad21edd7 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,7 +1,7 @@ - issues_count = group_issues_count(state: 'opened') - merge_requests_count = group_merge_requests_count(state: 'opened') -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('groups_side_navigation', 'render', 'groups_side_navigation') } .nav-sidebar-inner-scroll .context-header = link_to group_path(@group), title: @group.name do @@ -85,7 +85,7 @@ %span = _('Milestones') - = render_if_exists 'layouts/nav/sidebar/iterations_link' + = render_if_exists 'layouts/nav/sidebar/group_iterations_link' - if group_sidebar_link?(:merge_requests) = nav_link(path: 'groups#merge_requests') do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 95d66786984..dadab554c02 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('user_side_navigation', 'render', 'user_side_navigation') } .nav-sidebar-inner-scroll .context-header = link_to profile_path, title: _('Profile Settings') do @@ -18,7 +18,7 @@ %strong.fly-out-top-item-name = _('Profile') = nav_link(controller: [:accounts, :two_factor_auths]) do - = link_to profile_account_path do + = link_to profile_account_path, data: { qa_selector: 'profile_account_link' } do .nav-icon-container = sprite_icon('account') %span.nav-item-name diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index d59c75de6d2..054311214ab 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -1,6 +1,5 @@ -.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?), **tracking_attrs('projects_side_navigation', 'render', 'projects_side_navigation') } .nav-sidebar-inner-scroll - - can_edit = can?(current_user, :admin_project, @project) .context-header = link_to project_path(@project), title: @project.name do .avatar-container.rect-avatar.s40.project-avatar @@ -121,6 +120,8 @@ %span = _('Milestones') + = render_if_exists 'layouts/nav/sidebar/project_iterations_link' + - if project_nav_tab?(:external_issue_tracker) - issue_tracker = @project.external_issue_tracker - if issue_tracker.is_a?(JiraService) && project_jira_issues_integration? @@ -221,17 +222,23 @@ %li.divider.fly-out-top-item - if project_nav_tab? :metrics_dashboards - = nav_link(controller: :environments, action: [:metrics, :metrics_redirect]) do - = link_to metrics_project_environments_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do + = nav_link(controller: :metrics_dashboard, action: [:show]) do + = link_to project_metrics_dashboard_path(@project), title: _('Metrics'), class: 'shortcuts-metrics', data: { qa_selector: 'operations_metrics_link' } do %span = _('Metrics') - if project_nav_tab?(:alert_management) = nav_link(controller: :alert_management) do - = link_to project_alert_management_index_path(@project), title: _('Alerts'), class: 'shortcuts-tracking qa-operations-tracking-link' do + = link_to project_alert_management_index_path(@project), title: _('Alerts') do %span = _('Alerts') + - if project_nav_tab?(:incidents) + = nav_link(controller: :incidents) do + = link_to project_incidents_path(@project), title: _('Incidents'), data: { qa_selector: 'operations_incidents_link' } do + %span + = _('Incidents') + - if project_nav_tab? :environments = render_if_exists "layouts/nav/sidebar/tracing_link" @@ -242,10 +249,16 @@ - if project_nav_tab?(:error_tracking) = nav_link(controller: :error_tracking) do - = link_to project_error_tracking_index_path(@project), title: _('Error Tracking'), class: 'shortcuts-tracking qa-operations-tracking-link' do + = link_to project_error_tracking_index_path(@project), title: _('Error Tracking') do %span = _('Error Tracking') + - if project_nav_tab?(:product_analytics) + = nav_link(controller: :product_analytics) do + = link_to project_product_analytics_path(@project), title: _('Product Analytics') do + %span + = _('Product Analytics') + - if project_nav_tab? :serverless = nav_link(controller: :functions) do = link_to project_serverless_functions_path(@project), title: _('Serverless') do diff --git a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml index 0931ccdf637..e9989abe5a0 100644 --- a/app/views/layouts/nav/sidebar/_project_packages_link.html.haml +++ b/app/views/layouts/nav/sidebar/_project_packages_link.html.haml @@ -1,16 +1,23 @@ -- if project_nav_tab? :container_registry - = nav_link controller: :repositories do - = link_to project_container_registry_index_path(@project) do +- packages_link = project_nav_tab?(:packages) ? project_packages_path(@project) : project_container_registry_index_path(@project) + +- if (project_nav_tab?(:packages) || project_nav_tab?(:container_registry)) + = nav_link controller: [:packages, :repositories] do + = link_to packages_link, data: { qa_selector: 'packages_link' } do .nav-icon-container = sprite_icon('package') %span.nav-item-name = _('Packages & Registries') %ul.sidebar-sub-level-items - = nav_link(controller: :repositories, html_options: { class: "fly-out-top-item" } ) do - = link_to project_container_registry_index_path(@project) do + = nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do + = link_to packages_link do %strong.fly-out-top-item-name = _('Packages & Registries') %li.divider.fly-out-top-item - = nav_link controller: :repositories do - = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do - %span= _('Container Registry') + - if project_nav_tab? :packages + = nav_link controller: :packages do + = link_to project_packages_path(@project), title: _('Package Registry') do + %span= _('Package Registry') + - if project_nav_tab? :container_registry + = nav_link controller: :repositories do + = link_to project_container_registry_index_path(@project), class: 'shortcuts-container-registry', title: _('Container Registry') do + %span= _('Container Registry') diff --git a/app/views/layouts/project.html.haml b/app/views/layouts/project.html.haml index 820cb9eea47..222ca02b1df 100644 --- a/app/views/layouts/project.html.haml +++ b/app/views/layouts/project.html.haml @@ -3,6 +3,7 @@ - header_title project_title(@project) unless header_title - nav "project" - display_subscription_banner! +- display_namespace_storage_limit_alert! - @left_sidebar = true - content_for :project_javascripts do diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb index 6a83d79fd61..dc399ef548d 100644 --- a/app/views/notify/_relabeled_issuable_email.text.erb +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -1,3 +1,3 @@ <%= 'Label'.pluralize(@label_names.size) %> added: <%= @label_names.to_sentence %> -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> +<%= url_for([issuable.project, issuable, { only_path: false }]) %> diff --git a/app/views/notify/access_token_about_to_expire_email.html.haml b/app/views/notify/access_token_about_to_expire_email.html.haml index d1923e324f7..240c7300c7f 100644 --- a/app/views/notify/access_token_about_to_expire_email.html.haml +++ b/app/views/notify/access_token_about_to_expire_email.html.haml @@ -4,4 +4,4 @@ = _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire } %p - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } - = _('You can create a new one or check them in your %{pat_link_start}Personal Access Tokens%{pat_link_end} settings').html_safe % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } + = html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/access_token_about_to_expire_email.text.erb b/app/views/notify/access_token_about_to_expire_email.text.erb index 5e6bd68d33f..edcec51aeb4 100644 --- a/app/views/notify/access_token_about_to_expire_email.text.erb +++ b/app/views/notify/access_token_about_to_expire_email.text.erb @@ -2,4 +2,4 @@ <%= _('One or more of your personal access tokens will expire in %{days_to_expire} days or less.') % { days_to_expire: @days_to_expire} %> -<%= _('You can create a new one or check them in your Personal Access Tokens settings %{pat_link}') % { pat_link: @target_url } %> +<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}') % { pat_link: @target_url } %> diff --git a/app/views/notify/access_token_expired_email.html.haml b/app/views/notify/access_token_expired_email.html.haml new file mode 100644 index 00000000000..b26431cce91 --- /dev/null +++ b/app/views/notify/access_token_expired_email.html.haml @@ -0,0 +1,7 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = _('One or more of your personal access tokens has expired.') +%p + - pat_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: @target_url } + = html_escape(_('You can create a new one or check them in your %{pat_link_start}personal access tokens%{pat_link_end} settings')) % { pat_link_start: pat_link_start, pat_link_end: '</a>'.html_safe } diff --git a/app/views/notify/access_token_expired_email.text.erb b/app/views/notify/access_token_expired_email.text.erb new file mode 100644 index 00000000000..d44f993d094 --- /dev/null +++ b/app/views/notify/access_token_expired_email.text.erb @@ -0,0 +1,5 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('One or more of your personal access tokens has expired.') %> + +<%= _('You can create a new one or check them in your personal access tokens settings %{pat_link}') % { pat_link: @target_url } %> diff --git a/app/views/notify/reassigned_issue_email.text.erb b/app/views/notify/reassigned_issue_email.text.erb index 7bf2e8e6ce3..dc0d8fc80b0 100644 --- a/app/views/notify/reassigned_issue_email.text.erb +++ b/app/views/notify/reassigned_issue_email.text.erb @@ -1,6 +1,6 @@ Reassigned Issue <%= @issue.iid %> -<%= url_for([@issue.project.namespace.becomes(Namespace), @issue.project, @issue, { only_path: false }]) %> +<%= url_for([@issue.project, @issue, { only_path: false }]) %> Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> to <%= "#{@issue.assignees.any? ? @issue.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/reassigned_merge_request_email.text.erb b/app/views/notify/reassigned_merge_request_email.text.erb index 82ec7aa0fa4..2b51f48db3a 100644 --- a/app/views/notify/reassigned_merge_request_email.text.erb +++ b/app/views/notify/reassigned_merge_request_email.text.erb @@ -1,6 +1,6 @@ Reassigned Merge Request <%= @merge_request.iid %> -<%= url_for([@merge_request.project.namespace.becomes(Namespace), @merge_request.project, @merge_request, { only_path: false }]) %> +<%= url_for([@merge_request.project, @merge_request, { only_path: false }]) %> Assignee changed <%= "from #{sanitize_name(@previous_assignees.map(&:name).to_sentence)}" if @previous_assignees.any? -%> to <%= "#{@merge_request.assignees.any? ? @merge_request.assignee_list : 'Unassigned'}" %> diff --git a/app/views/notify/service_desk_new_note_email.html.haml b/app/views/notify/service_desk_new_note_email.html.haml index 7c6be6688d0..824b4ab712e 100644 --- a/app/views/notify/service_desk_new_note_email.html.haml +++ b/app/views/notify/service_desk_new_note_email.html.haml @@ -1,5 +1,5 @@ - if Gitlab::CurrentSettings.email_author_in_body %div - #{link_to @note.author_name, user_url(@note.author)} wrote: + = _("%{author_link} wrote:").html_safe % { author_link: link_to(@note.author_name, user_url(@note.author)) } %div = markdown(@note.note, pipeline: :email, author: @note.author) diff --git a/app/views/notify/service_desk_new_note_email.text.erb b/app/views/notify/service_desk_new_note_email.text.erb index 208953a437d..79144fc1bf4 100644 --- a/app/views/notify/service_desk_new_note_email.text.erb +++ b/app/views/notify/service_desk_new_note_email.text.erb @@ -1,6 +1,6 @@ -New response for issue #<%= @issue.iid %>: +<%= _("New response for issue #%{issue_iid}:") % { issue_iid: @issue.iid } %> -Author: <%= sanitize_name(@note.author_name) %> +<%= _("Author: %{author_name}") % { author_name: sanitize_name(@note.author_name) } %> <%= @note.note %> <%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text'%><%# EE-specific end %> diff --git a/app/views/notify/service_desk_thank_you_email.html.haml b/app/views/notify/service_desk_thank_you_email.html.haml index a3407acd9ba..ee61db40f07 100644 --- a/app/views/notify/service_desk_thank_you_email.html.haml +++ b/app/views/notify/service_desk_thank_you_email.html.haml @@ -1,2 +1,2 @@ %p - Thank you for your support request! We are tracking your request as ticket ##{@issue.iid}, and will respond as soon as we can. + = _("Thank you for your support request! We are tracking your request as ticket #%{issue_iid}, and will respond as soon as we can.") % { issue_iid: @issue.iid } diff --git a/app/views/notify/service_desk_thank_you_email.text.erb b/app/views/notify/service_desk_thank_you_email.text.erb index 8281607a4a8..8b52219c83b 100644 --- a/app/views/notify/service_desk_thank_you_email.text.erb +++ b/app/views/notify/service_desk_thank_you_email.text.erb @@ -1,6 +1,6 @@ -Thank you for your support request! We are tracking your request as ticket #<%= @issue.iid %>, and will respond as soon as we can. +<%= _("Thank you for your support request! We are tracking your request as ticket #%{issue_iid}, and will respond as soon as we can.") % { issue_iid: @issue.iid } %> -To unsubscribe from this issue, please paste the following link into your browser: +<%= _("To unsubscribe from this issue, please paste the following link into your browser:") %> <%= @unsubscribe_url %> <%# EE-specific start %><%= render_if_exists 'layouts/mailer/additional_text' %><%# EE-specific end %> diff --git a/app/views/profiles/accounts/show.html.haml b/app/views/profiles/accounts/show.html.haml index ea2f888c129..20660e61f38 100644 --- a/app/views/profiles/accounts/show.html.haml +++ b/app/views/profiles/accounts/show.html.haml @@ -17,7 +17,7 @@ - if current_user.two_factor_enabled? = link_to _('Manage two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-info' - else - .append-bottom-10 + .gl-mb-3 = link_to _('Enable two-factor authentication'), profile_two_factor_auth_path, class: 'btn btn-success' %hr @@ -56,7 +56,7 @@ = render 'users/deletion_guidance', user: current_user %button#delete-account-button.btn.btn-danger.disabled{ data: { toggle: 'modal', - target: '#delete-account-modal' } } + target: '#delete-account-modal', qa_selector: 'delete_account_button' } } = s_('Profiles|Delete account') #delete-account-modal{ data: { action_url: user_registration_path, diff --git a/app/views/profiles/chat_names/new.html.haml b/app/views/profiles/chat_names/new.html.haml index 5bed9e0d771..2134ab2bec6 100644 --- a/app/views/profiles/chat_names/new.html.haml +++ b/app/views/profiles/chat_names/new.html.haml @@ -1,15 +1,14 @@ -%h3.page-title Authorization required +%h3.page-title + = _("Authorization required") %main{ :role => "main" } %p.h4 - Authorize - %strong.text-info= @chat_name_params[:chat_name] - to use your account? + = html_escape(_("Authorize %{user} to use your account?")) % { user: tag.strong(@chat_name_params[:chat_name]) } %hr .actions = form_tag profile_chat_names_path, method: :post do = hidden_field_tag :token, @chat_name_token.token - = submit_tag "Authorize", class: "btn btn-success wide float-left" + = submit_tag _("Authorize"), class: "btn btn-success wide float-left" = form_tag deny_profile_chat_names_path, method: :delete do = hidden_field_tag :token, @chat_name_token.token - = submit_tag "Deny", class: "btn btn-danger gl-ml-3" + = submit_tag _("Deny"), class: "btn btn-danger gl-ml-3" diff --git a/app/views/profiles/emails/index.html.haml b/app/views/profiles/emails/index.html.haml index fa7ab0666cc..a04ed87801a 100644 --- a/app/views/profiles/emails/index.html.haml +++ b/app/views/profiles/emails/index.html.haml @@ -60,4 +60,4 @@ = link_to profile_email_path(email), data: { confirm: _('Are you sure?'), qa_selector: 'delete_email_link'}, method: :delete, class: 'btn btn-sm btn-danger gl-ml-3' do %span.sr-only= _('Remove') - = icon('trash') + = sprite_icon('remove') diff --git a/app/views/profiles/gpg_keys/_key.html.haml b/app/views/profiles/gpg_keys/_key.html.haml index 7bbb0235cd8..e05f121c5d9 100644 --- a/app/views/profiles/gpg_keys/_key.html.haml +++ b/app/views/profiles/gpg_keys/_key.html.haml @@ -1,6 +1,6 @@ %li.key-list-item .float-left.gl-mr-3 - = icon 'key', class: "settings-list-icon d-none d-sm-block" + = sprite_icon('key', css_class: "settings-list-icon d-none d-sm-block gl-mt-4") .key-list-item-info - key.emails_with_verified_status.map do |email, verified| = render partial: 'shared/email_with_badge', locals: { email: email, verified: verified } diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index c9ab7b6fbd3..02b45853aa0 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -3,12 +3,12 @@ - if key.valid? - if key.expired? %span.d-inline-block.has-tooltip{ title: s_('Profiles|Your key has expired') } - = sprite_icon('warning-solid', size: 16, css_class: 'settings-list-icon d-none d-sm-block') + = sprite_icon('warning-solid', css_class: 'settings-list-icon d-none d-sm-block') - else - = sprite_icon('key', size: 16, css_class: 'settings-list-icon d-none d-sm-block ') + = sprite_icon('key', css_class: 'settings-list-icon d-none d-sm-block ') - else %span.d-inline-block.has-tooltip{ title: key.errors.full_messages.join(', ') } - = sprite_icon('warning-solid', size: 16, css_class: 'settings-list-icon d-none d-sm-block') + = sprite_icon('warning-solid', css_class: 'settings-list-icon d-none d-sm-block') .key-list-item-info.w-100.float-none = link_to path_to_key(key, is_admin), class: "title" do @@ -28,4 +28,4 @@ - if key.can_delete? = link_to path_to_key(key, is_admin), data: { confirm: _('Are you sure?')}, method: :delete, class: "btn btn-transparent gl-ml-3 align-baseline" do %span.sr-only= _('Remove') - = sprite_icon('remove', size: 16) + = sprite_icon('remove') diff --git a/app/views/profiles/preferences/_sourcegraph.html.haml b/app/views/profiles/preferences/_sourcegraph.html.haml index 7328d36b0fb..f3530da9a5f 100644 --- a/app/views/profiles/preferences/_sourcegraph.html.haml +++ b/app/views/profiles/preferences/_sourcegraph.html.haml @@ -4,7 +4,7 @@ .col-sm-12 %hr -.col-lg-4.profile-settings-sidebar +.col-lg-4.profile-settings-sidebar#integrations %h4.gl-mt-0 = s_('Preferences|Integrations') %p diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index bc1f2cb3072..54ca8788864 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -2,7 +2,7 @@ - @content_class = "limit-container-width" unless fluid_layout = form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row gl-mt-3 js-preferences-form' } do |f| - .col-lg-4.application-theme + .col-lg-4.application-theme#navigation-theme %h4.gl-mt-0 = s_('Preferences|Navigation theme') %p @@ -18,7 +18,7 @@ .col-sm-12 %hr - .col-lg-4.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar#syntax-highlighting-theme %h4.gl-mt-0 = s_('Preferences|Syntax highlighting theme') %p @@ -35,7 +35,7 @@ .col-sm-12 %hr - .col-lg-4.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar#behavior %h4.gl-mt-0 = s_('Preferences|Behavior') %p @@ -51,8 +51,10 @@ = s_('Preferences|Choose between fixed (max. 1280px) and fluid (%{percentage}) application layout.').html_safe % { percentage: '100%' } .form-group = f.label :dashboard, class: 'label-bold' do - = s_('Preferences|Default dashboard') + = s_('Preferences|Homepage content') = f.select :dashboard, dashboard_choices, {}, class: 'select2' + .form-text.text-muted + = s_('Preferences|Choose what content you want to see on your homepage.') = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific @@ -90,7 +92,7 @@ .col-sm-12 %hr - .col-lg-4.profile-settings-sidebar + .col-lg-4.profile-settings-sidebar#localization %h4.gl-mt-0 = _('Localization') %p diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index f4aa0b98e37..672f9c9a0c0 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -29,7 +29,7 @@ = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' %h5.gl-mt-0= s_("Profiles|Upload new avatar") - .gl-mt-2.append-bottom-10 + .gl-mt-2.gl-mb-3 %button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") %span.avatar-file-name.gl-ml-3.js-avatar-filename= s_("Profiles|No file chosen") = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' diff --git a/app/views/profiles/two_factor_auths/_codes.html.haml b/app/views/profiles/two_factor_auths/_codes.html.haml index 68cd4875a33..40272b6354c 100644 --- a/app/views/profiles/two_factor_auths/_codes.html.haml +++ b/app/views/profiles/two_factor_auths/_codes.html.haml @@ -2,11 +2,11 @@ - lose_2fa_message = _('Should you ever lose your phone or access to your one time password secret, each of these recovery codes can be used one time each to regain access to your account. Please save them in a safe place, or you %{b_start}will%{b_end} lose access to your account.') % { b_start:'<b>', b_end:'</b>' } = lose_2fa_message.html_safe -.codes.card +.codes.card{ data: { qa_selector: 'codes_content' } } %ul - @codes.each do |code| %li - %span.monospace= code + %span.monospace{ data: { qa_selector: 'code_content' } }= code .d-flex = link_to _('Proceed'), profile_account_path, class: 'btn btn-success gl-mr-3', data: { qa_selector: 'proceed_button' } diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index 0fde3e5fb10..bce43b16d27 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -28,7 +28,7 @@ - help_link_start = '<a href="%{url}" target="_blank">' % { url: help_page_path('user/profile/account/two_factor_authentication') } - register_2fa_token = _('Install a soft token authenticator like %{free_otp_link} or Google Authenticator from your application repository and use that app to scan this QR code. More information is available in the %{help_link_start}documentation%{help_link_end}.') % { free_otp_link:'<a href="https://freeotp.github.io/">FreeOTP</a>', help_link_start:help_link_start, help_link_end:'</a>' } = register_2fa_token.html_safe - .row.append-bottom-10 + .row.gl-mb-3 .col-md-4 = raw @qr_code .col-md-8 @@ -88,7 +88,7 @@ %tbody - @u2f_registrations.each do |registration| %tr - %td= registration.name.presence || _("<no name set>") + %td= registration.name.presence || html_escape_once(_("<no name set>")).html_safe %td= registration.created_at.to_date.to_s(:medium) %td= link_to _('Delete'), profile_u2f_registration_path(registration), method: :delete, class: "btn btn-danger float-right", data: { confirm: _('Are you sure you want to delete this device? This action cannot be undone.') } diff --git a/app/views/projects/_activity.html.haml b/app/views/projects/_activity.html.haml index 07faf5a66da..c47ca81c431 100644 --- a/app/views/projects/_activity.html.haml +++ b/app/views/projects/_activity.html.haml @@ -1,9 +1,14 @@ +- is_project_overview = local_assigns.fetch(:is_project_overview, false) + %div{ class: container_class } - .nav-block.d-none.d-sm-flex.activities + .nav-block.d-none.d-sm-flex.activities.gl-static = render 'shared/event_filter' - .controls - = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn d-none d-sm-inline-block has-tooltip' do - = icon('rss') + .controls.gl-display-flex + = link_to project_path(@project, rss_url_options), title: s_("ProjectActivityRSS|Subscribe"), class: 'btn gl-button btn-default btn-icon d-none d-sm-inline-flex has-tooltip' do + = sprite_icon('rss', css_class: 'qa-rss-icon gl-icon') + - if is_project_overview && can?(current_user, :download_code, @project) + .project-clone-holder.d-none.d-md-inline-flex.gl-ml-2 + = render "projects/buttons/clone", dropdown_class: 'dropdown-menu-right' .content_list.project-activity{ :"data-href" => activity_project_path(@project) } .loading diff --git a/app/views/projects/_export.html.haml b/app/views/projects/_export.html.haml index 7da15e0d8a5..41e13464b1e 100644 --- a/app/views/projects/_export.html.haml +++ b/app/views/projects/_export.html.haml @@ -26,5 +26,6 @@ = link_to _('Generate new export'), generate_new_export_project_path(project), method: :post, class: "btn btn-default" - else - = link_to _('Export project'), export_project_path(project), + .gl-display-flex.gl-justify-content-end + = link_to _('Export project'), export_project_path(project), method: :post, class: "btn btn-default", data: { qa_selector: 'export_project_link' } diff --git a/app/views/projects/_flash_messages.html.haml b/app/views/projects/_flash_messages.html.haml index ab8275ba5e4..f9222387e97 100644 --- a/app/views/projects/_flash_messages.html.haml +++ b/app/views/projects/_flash_messages.html.haml @@ -9,4 +9,3 @@ = render 'shared/auto_devops_implicitly_enabled_banner', project: project = render_if_exists 'projects/above_size_limit_warning', project: project = render_if_exists 'shared/shared_runners_minutes_limit', project: project, classes: [container_class, ("limit-container-width" unless fluid_layout)] - = render_if_exists 'shared/namespace_storage_limit_alert', namespace: project.namespace, classes: [container_class, ("limit-container-width" unless fluid_layout)] diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 9966baf78f4..94a2bdb3bcb 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -13,7 +13,7 @@ %h1.home-panel-title.gl-mt-3.gl-mb-2{ data: { qa_selector: 'project_name_content' } } = @project.name %span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) } - = visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'}) + = visibility_level_icon(@project.visibility_level, options: { class: 'icon' }) = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project .home-panel-metadata.d-flex.flex-wrap.text-secondary - if can?(current_user, :read_project, @project) @@ -24,7 +24,7 @@ = render 'shared/members/access_request_links', source: @project - if @project.tag_list.present? %span.home-panel-topic-list.mt-2.w-100.d-inline-flex - = sprite_icon('tag', size: 16, css_class: 'icon gl-mr-2') + = sprite_icon('tag', css_class: 'icon gl-mr-2') - @project.topics_to_show.each do |topic| - project_topics_classes = "badge badge-pill badge-secondary gl-mr-2" diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index bb278fbf311..dd7971f6db0 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -46,7 +46,7 @@ - if fogbugz_import_enabled? %div = link_to new_import_fogbugz_path, class: 'btn import_fogbugz', **tracking_attrs(track_label, 'click_button', 'fogbugz') do - = icon('bug', text: 'Fogbugz') + = icon('bug', text: 'FogBugz') - if gitea_import_enabled? %div @@ -56,8 +56,9 @@ - if git_import_enabled? %div - %button.btn.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') } - = icon('git', text: 'Repo by URL') + %button.btn.btn-svg.js-toggle-button.js-import-git-toggle-button{ type: "button", data: { toggle_open_class: 'active' }, **tracking_attrs(track_label, 'click_button', 'repo_url') } + = sprite_icon('link', css_class: 'gl-icon') + = _('Repo by URL') - if manifest_import_enabled? %div diff --git a/app/views/projects/_merge_request_settings.html.haml b/app/views/projects/_merge_request_settings.html.haml index 5ffdeef3558..e69972e8163 100644 --- a/app/views/projects/_merge_request_settings.html.haml +++ b/app/views/projects/_merge_request_settings.html.haml @@ -4,7 +4,7 @@ = render 'projects/merge_request_merge_options_settings', project: @project, form: form -- if Feature.enabled?(:squash_options, @project) +- if Feature.enabled?(:squash_options, @project, default_enabled: true) = render 'projects/merge_request_squash_options_settings', form: form = render 'projects/merge_request_merge_checks_settings', project: @project, form: form diff --git a/app/views/projects/_remove.html.haml b/app/views/projects/_remove.html.haml index 528d802261c..05eab3b3245 100644 --- a/app/views/projects/_remove.html.haml +++ b/app/views/projects/_remove.html.haml @@ -1,9 +1,10 @@ - return unless can?(current_user, :remove_project, project) +- confirm_phrase = s_('DeleteProject|Delete %{name}') % { name: project.full_name } .sub-section - %h4.danger-title= _('Remove project') + %h4.danger-title= _('Delete project') %p - %strong= _('Removing the project will delete its repository and all related resources including issues, merge requests etc.') + %strong= _('Deleting the project will delete its repository and all related resources including issues, merge requests etc.') %p - %strong= _('Removed projects cannot be restored!') - #js-confirm-project-remove{ data: { form_path: project_path(project), confirm_phrase: project.path, warning_message: remove_project_message(project) } } + %strong= _('Deleted projects cannot be restored!') + #js-project-delete-button{ data: { form_path: project_path(project), confirm_phrase: confirm_phrase } } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index e6842bbb939..7c08955983a 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -10,7 +10,8 @@ - if ::Gitlab::ServiceDesk.supported? .js-service-desk-setting-root{ data: { endpoint: project_service_desk_path(@project), enabled: "#{@project.service_desk_enabled}", - incoming_email: (@project.service_desk_address if @project.service_desk_enabled), + incoming_email: (@project.service_desk_incoming_address if @project.service_desk_enabled), + custom_email: (@project.service_desk_custom_address if @project.service_desk_enabled), selected_template: "#{@project.service_desk_setting&.issue_template_key}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", diff --git a/app/views/projects/_visibility_modal.html.haml b/app/views/projects/_visibility_modal.html.haml index 3ef93a40137..144f726572b 100644 --- a/app/views/projects/_visibility_modal.html.haml +++ b/app/views/projects/_visibility_modal.html.haml @@ -7,7 +7,7 @@ .modal-header %h3.page-title= _('Reduce this project’s visibility?') %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true }= sprite_icon("close", size: 16) + %span{ "aria-hidden": true }= sprite_icon("close") .modal-body %p - if @project.group @@ -23,8 +23,7 @@ = ("To confirm, type %{phrase_code}").html_safe % { phrase_code: '<code class="js-confirm-danger-match">%{phrase_name}</code>'.html_safe % { phrase_name: @project.full_path } } .form-group = text_field_tag 'confirm_path_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' - .form-actions.clearfix - .pull-right - %button.btn.btn-default{ type: "button", "data-dismiss": "modal" } - = _('Cancel') - = submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true + .form-actions.gl-display-flex.gl-justify-content-end + %button.btn.btn-default.gl-mr-4{ type: "button", "data-dismiss": "modal" } + = _('Cancel') + = submit_tag _('Reduce project visibility'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button", disabled: true diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index a2d6b2e18a9..2f3d0660caa 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -1,5 +1,5 @@ - page_title _("Blame"), @blob.path, @ref -- link_icon = icon("link") +- link_icon = sprite_icon("link", size: 12) #blob-content-holder.tree-holder = render "projects/blob/breadcrumb", blob: @blob, blame: true diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index b06ae31e73f..787dc3b030f 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -8,13 +8,13 @@ = sprite_icon('fork', size: 12) = ref - if current_action?(:edit) || current_action?(:update) - %span.pull-left.gl-mr-3 + %span.float-left.gl-mr-3 = text_field_tag 'file_path', (params[:file_path] || @path), class: 'form-control new-file-path js-file-path-name-input' = render 'template_selectors' - if current_action?(:new) || current_action?(:create) - %span.pull-left.gl-mr-3 + %span.float-left.gl-mr-3 \/ = text_field_tag 'file_name', params[:file_name], placeholder: "File name", required: true, class: 'form-control new-file-name js-file-path-name-input', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : '') @@ -40,7 +40,8 @@ = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2', tabindex: '-1' .file-editor.code - %pre.js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }= params[:content] || local_assigns[:blob_data] + .js-edit-mode-pane.qa-editor#editor{ data: { 'editor-loading': true } }< + %pre.editor-loading-content= params[:content] || local_assigns[:blob_data] - if local_assigns[:path] .js-edit-mode-pane#preview.hide .center diff --git a/app/views/projects/blob/_header_content.html.haml b/app/views/projects/blob/_header_content.html.haml index 32adfb320ff..30356348941 100644 --- a/app/views/projects/blob/_header_content.html.haml +++ b/app/views/projects/blob/_header_content.html.haml @@ -7,6 +7,8 @@ = copy_file_path_button(blob.path) %small.mr-1 + - if blob.mode == Blob::MODE_SYMLINK + = _('Symbolic link') << ' ·' = number_to_human_size(blob.raw_size) - if blob.stored_externally? && blob.external_storage == :lfs diff --git a/app/views/projects/blob/_template_selectors.html.haml b/app/views/projects/blob/_template_selectors.html.haml index ba8029ac32a..2aefcdc5762 100644 --- a/app/views/projects/blob/_template_selectors.html.haml +++ b/app/views/projects/blob/_template_selectors.html.haml @@ -7,6 +7,8 @@ = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-license-selector qa-license-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: licenses_for_select(@project), project: @project.name, fullname: @project.namespace.human_name } } ) .gitignore-selector.js-gitignore-selector-wrap.js-template-selector-wrap.hidden = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitignore-selector qa-gitignore-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitignore_names(@project) } } ) + .metrics-dashboard-selector.js-metrics-dashboard-selector-wrap.js-template-selector-wrap.hidden + = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-metrics-dashboard-selector qa-metrics-dashboard-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: metrics_dashboard_ymls(@project) } } ) #gitlab-ci-yml-selector.gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.js-template-selector-wrap.hidden = dropdown_tag(_("Apply a template"), options: { toggle_class: 'js-gitlab-ci-yml-selector qa-gitlab-ci-yml-dropdown', dropdown_class: 'dropdown-menu-selectable', filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls(@project) } } ) .dockerfile-selector.js-dockerfile-selector-wrap.js-template-selector-wrap.hidden diff --git a/app/views/projects/blob/edit.html.haml b/app/views/projects/blob/edit.html.haml index 1319c58eb38..7d072ba5899 100644 --- a/app/views/projects/blob/edit.html.haml +++ b/app/views/projects/blob/edit.html.haml @@ -1,8 +1,5 @@ - breadcrumb_title _("Repository") - page_title _("Edit"), @blob.path, @ref -- unless Feature.enabled?(:monaco_blobs) - - content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') - if @conflict .alert.alert-danger diff --git a/app/views/projects/blob/new.html.haml b/app/views/projects/blob/new.html.haml index 2420c4a4bd5..48ffd80aa9c 100644 --- a/app/views/projects/blob/new.html.haml +++ b/app/views/projects/blob/new.html.haml @@ -1,8 +1,5 @@ - breadcrumb_title _("Repository") - page_title _("New File"), @path.presence, @ref -- unless Feature.enabled?(:monaco_blobs) - - content_for :page_specific_javascripts do - = page_specific_javascript_tag('lib/ace.js') .editor-title-row %h3.page-title.blob-new-page-title diff --git a/app/views/projects/blob/viewers/_changelog.html.haml b/app/views/projects/blob/viewers/_changelog.html.haml index 46e3e7f798a..80ead53beff 100644 --- a/app/views/projects/blob/viewers/_changelog.html.haml +++ b/app/views/projects/blob/viewers/_changelog.html.haml @@ -1,4 +1,4 @@ -= icon('history fw') += sprite_icon('history', css_class: 'gl-mr-1 gl-vertical-align-text-bottom') = succeed '.' do To find the state of this project's repository at the time of any of these versions, check out = link_to "the tags", project_tags_path(viewer.project) diff --git a/app/views/projects/blob/viewers/_contributing.html.haml b/app/views/projects/blob/viewers/_contributing.html.haml index 546c064c06f..18559e2908f 100644 --- a/app/views/projects/blob/viewers/_contributing.html.haml +++ b/app/views/projects/blob/viewers/_contributing.html.haml @@ -1,4 +1,4 @@ -= icon('book fw') += sprite_icon('book') After you've reviewed these contribution guidelines, you'll be all set to - options = contribution_options(viewer.project) diff --git a/app/views/projects/blob/viewers/_dependency_manager.html.haml b/app/views/projects/blob/viewers/_dependency_manager.html.haml index 5970d41fdab..e6de420bbb1 100644 --- a/app/views/projects/blob/viewers/_dependency_manager.html.haml +++ b/app/views/projects/blob/viewers/_dependency_manager.html.haml @@ -1,6 +1,5 @@ -= icon('cubes fw') += sprite_icon('package') = succeed '.' do - This project manages its dependencies using - %strong= viewer.manager_name + = _("This project manages its dependencies using %{strong_start}%{manager_name}%{strong_end}").html_safe % { manager_name: viewer.manager_name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } -= link_to 'Learn more', viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' += link_to _('Learn more'), viewer.manager_url, target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/blob/viewers/_license.html.haml b/app/views/projects/blob/viewers/_license.html.haml index 7ac0e7bb579..d2bd90a898a 100644 --- a/app/views/projects/blob/viewers/_license.html.haml +++ b/app/views/projects/blob/viewers/_license.html.haml @@ -1,6 +1,6 @@ - license = viewer.license -= sprite_icon('scale', size: 16) += sprite_icon('scale') This project is licensed under the = succeed '.' do %strong= license.name diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml index ecbf6d9005d..9ec1d7d0d67 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml.html.haml @@ -8,4 +8,4 @@ - viewer.errors.messages.each do |error| %li= error.join(': ') -= link_to _('Learn more'), help_page_path('operations/metrics/dashboards/index.md', anchor: 'defining-custom-dashboards-per-project') += link_to _('Learn more'), help_page_path('operations/metrics/dashboards/index.md') diff --git a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml index 31a0d514444..aedfb64d3e4 100644 --- a/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml +++ b/app/views/projects/blob/viewers/_metrics_dashboard_yml_loading.html.haml @@ -1,4 +1,4 @@ = icon('spinner spin fw') = _('Metrics Dashboard YAML definition') + '…' -= link_to _('Learn more'), help_page_path('user/project/integrations/prometheus.md') += link_to _('Learn more'), help_page_path('operations/metrics/dashboards/yaml.md') diff --git a/app/views/projects/blob/viewers/_readme.html.haml b/app/views/projects/blob/viewers/_readme.html.haml index 6cbd26e2271..86f59146cda 100644 --- a/app/views/projects/blob/viewers/_readme.html.haml +++ b/app/views/projects/blob/viewers/_readme.html.haml @@ -1,4 +1,4 @@ -= icon('info-circle fw') += sprite_icon('information-o', css_class: 'gl-vertical-align-middle! gl-mr-2') = succeed '.' do To learn more about this project, read = link_to "the wiki", wiki_path(viewer.project.wiki) diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml index 828371e9656..f03b5cf2eff 100644 --- a/app/views/projects/branches/_panel.html.haml +++ b/app/views/projects/branches/_panel.html.haml @@ -7,7 +7,7 @@ - return unless branches.any? -.card.prepend-top-10 +.card.gl-mt-3 .card-header = panel_title %ul.content-list.all-branches.qa-all-branches diff --git a/app/views/projects/buttons/_dropdown.html.haml b/app/views/projects/buttons/_dropdown.html.haml index 33465953086..5effa5a9e92 100644 --- a/app/views/projects/buttons/_dropdown.html.haml +++ b/app/views/projects/buttons/_dropdown.html.haml @@ -8,9 +8,9 @@ - if show_menu .project-action-button.dropdown.inline< - %a.btn.dropdown-toggle.has-tooltip.qa-create-new-dropdown{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' } - = icon('plus') - = icon("caret-down") + %a.btn.btn-default.gl-button.dropdown-toggle.has-tooltip.qa-create-new-dropdown{ href: '#', title: _('Create new...'), 'data-toggle' => 'dropdown', 'data-container' => 'body', 'aria-label' => _('Create new...'), 'data-display' => 'static' } + = sprite_icon('plus', css_class: 'gl-icon') + = sprite_icon("chevron-down", css_class: 'gl-icon') %ul.dropdown-menu.dropdown-menu-right.project-home-dropdown - if can_create_issue || merge_project || can_create_project_snippet %li.dropdown-header= _('This project') diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 4c20ac84b24..23f9a6a8f6c 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -97,7 +97,7 @@ %td .float-right - if can?(current_user, :read_build, job) && job.artifacts? - = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build' do + = link_to download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), class: 'btn btn-build gl-button btn-icon btn-svg' do = sprite_icon('download') - if can?(current_user, :update_build, job) - if job.active? @@ -126,5 +126,5 @@ = link_to play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), class: 'btn btn-build' do = custom_icon('icon_play') - elsif job.retryable? - = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do - = icon('repeat') + = link_to retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do + = sprite_icon('repeat', css_class: 'gl-icon') diff --git a/app/views/projects/ci/lints/_create.html.haml b/app/views/projects/ci/lints/_create.html.haml index d65c06aa2a4..5cc89343ba3 100644 --- a/app/views/projects/ci/lints/_create.html.haml +++ b/app/views/projects/ci/lints/_create.html.haml @@ -4,6 +4,8 @@ %b= _("Status:") = _("syntax is correct") + = render "projects/ci/lints/lint_warnings", warnings: @warnings + .table-holder %table.table.table-bordered %thead @@ -11,33 +13,54 @@ %th= _("Parameter") %th= _("Value") %tbody - - @stages.each do |stage| - - @builds.select { |build| build[:stage] == stage }.each do |build| - - job = @jobs[build[:name].to_sym] - %tr - %td #{stage.capitalize} Job - #{build[:name]} - %td - %pre= job[:before_script].to_a.join('\n') - %pre= job[:script].to_a.join('\n') - %pre= job[:after_script].to_a.join('\n') + - if @dry_run + - @stages.each do |stage| + - stage.statuses.each do |job| + %tr + %td #{stage.name.capitalize} Job - #{job.name} + %td + %pre= job.options[:before_script].to_a.join('\n') + %pre= job.options[:script].to_a.join('\n') + %pre= job.options[:after_script].to_a.join('\n') + %br + %b= _("Tag list:") + = job.tag_list.to_a.join(", ") if job.is_a?(Ci::Build) + %br + %b= _("Environment:") + = job.options.dig(:environment, :name) + %br + %b= _("When:") + = job.when + - if job.allow_failure + %b= _("Allowed to fail") - %br - %b= _("Tag list:") - = build[:tag_list].to_a.join(", ") - %br - %b= _("Only policy:") - = job[:only].to_a.join(", ") - %br - %b= _("Except policy:") - = job[:except].to_a.join(", ") - %br - %b= _("Environment:") - = build[:environment] - %br - %b= _("When:") - = build[:when] - - if build[:allow_failure] - %b= _("Allowed to fail") + - else + - @stages.each do |stage| + - @builds.select { |build| build[:stage] == stage }.each do |build| + - job = @jobs[build[:name].to_sym] + %tr + %td #{stage.capitalize} Job - #{build[:name]} + %td + %pre= job[:before_script].to_a.join('\n') + %pre= job[:script].to_a.join('\n') + %pre= job[:after_script].to_a.join('\n') + %br + %b= _("Tag list:") + = build[:tag_list].to_a.join(", ") + %br + %b= _("Only policy:") + = job[:only].to_a.join(", ") + %br + %b= _("Except policy:") + = job[:except].to_a.join(", ") + %br + %b= _("Environment:") + = build[:environment] + %br + %b= _("When:") + = build[:when] + - if build[:allow_failure] + %b= _("Allowed to fail") - else .bs-callout.bs-callout-danger @@ -47,3 +70,5 @@ %pre - @errors.each do |message| %p= message + + = render "projects/ci/lints/lint_warnings", warnings: @warnings diff --git a/app/views/projects/ci/lints/_lint_warnings.html.haml b/app/views/projects/ci/lints/_lint_warnings.html.haml new file mode 100644 index 00000000000..0a5bb8f76ef --- /dev/null +++ b/app/views/projects/ci/lints/_lint_warnings.html.haml @@ -0,0 +1,6 @@ +- if warnings + - warnings.each do |warning| + .bs-callout.bs-callout-warning + %p + %b= _("Warning:") + = markdown(warning) diff --git a/app/views/projects/ci/lints/show.html.haml b/app/views/projects/ci/lints/show.html.haml index 7b87664961e..0c51c978bfe 100644 --- a/app/views/projects/ci/lints/show.html.haml +++ b/app/views/projects/ci/lints/show.html.haml @@ -3,7 +3,7 @@ - content_for :library_javascripts do = page_specific_javascript_tag('lib/ace.js') -%h2.pt-3.pb-3= _("Check your .gitlab-ci.yml") +%h2.pt-3.pb-3= _("Validate your GitLab CI configuration") .project-ci-linter = form_tag project_ci_lint_path(@project), method: :post do @@ -15,8 +15,12 @@ #ci-editor.ci-editor= @content = text_area_tag(:content, @content, class: 'hidden form-control span1', rows: 7, require: true) .col-sm-12 - .float-left.prepend-top-10 + .float-left.gl-mt-3 = submit_tag(_('Validate'), class: 'btn btn-success submit-yml') + - if Gitlab::Ci::Features.lint_creates_pipeline_with_dry_run?(@project) + = check_box_tag(:dry_run, 'true', params[:dry_run]) + = label_tag(:dry_run, _('Simulate a pipeline created for the default branch')) + = link_to icon('question-circle'), help_page_path('ci/lint', anchor: 'pipeline-simulation'), target: '_blank', rel: 'noopener noreferrer' .float-right.prepend-top-10 = button_tag(_('Clear'), type: 'button', class: 'btn btn-default clear-yml') diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index 52855d7ee12..f560127fefd 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -14,8 +14,8 @@ .settings-content - url = cleanup_namespace_project_settings_repository_path(@project.namespace, @project) = form_for @project, url: url, method: :post, authenticity_token: true, html: { class: 'js-requires-input' } do |f| - %fieldset.gl-mt-0.append-bottom-10 - .append-bottom-10 + %fieldset.gl-mt-0.gl-mb-3 + .gl-mb-3 %h5.gl-mt-0 = _("Upload object map") %button.btn.btn-default.js-choose-file{ type: "button" } @@ -25,5 +25,7 @@ = f.file_field :bfg_object_map, class: "hidden js-object-map-input", required: true .form-text.text-muted = _("The maximum file size allowed is %{size}.") % { size: number_to_human_size(Gitlab::CurrentSettings.max_attachment_size.megabytes) } - = f.submit _('Start cleanup'), class: 'btn btn-success' + + .gl-display-flex.gl-justify-content-end + = f.submit _('Start cleanup'), class: 'btn btn-success' diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index e71615dd1c5..4f5d69c614c 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -21,7 +21,7 @@ .modal-body - if description %p= description - = form_tag [type.underscore, @project.namespace.becomes(Namespace), @project, commit], method: :post, remote: false, class: "js-#{type}-form js-requires-input" do + = form_tag [type.underscore, @project, commit], method: :post, remote: false, class: "js-#{type}-form js-requires-input" do .form-group.branch = label_tag 'start_branch', branch_label, class: 'label-bold' diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 71cf6ca6922..29ee4a69e83 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -93,7 +93,7 @@ - if @merge_request .well-segment - = icon('info-circle fw') + = sprite_icon('information-o', css_class: 'gl-vertical-align-middle! gl-mr-2') - link_to_merge_request = link_to(@merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id)) = _('This commit is part of merge request %{link_to_merge_request}. Comments created here will be created in the context of that merge request.').html_safe % { link_to_merge_request: link_to_merge_request } diff --git a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml index d282ab4f520..e56579b162f 100644 --- a/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml +++ b/app/views/projects/commit/_same_user_different_email_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with a verified signature, but the committer email is <strong>not verified</strong> to belong to the same user.').html_safe + = html_escape(_('This commit was signed with a verified signature, but the committer email is %{strong_open}not verified%{strong_close} to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Unverified'), css_class: ['invalid'], icon: 'status_notfound_borderless', show_user: true } diff --git a/app/views/projects/commit/_unverified_signature_badge.html.haml b/app/views/projects/commit/_unverified_signature_badge.html.haml index 294f916d18f..0ce8e06382b 100644 --- a/app/views/projects/commit/_unverified_signature_badge.html.haml +++ b/app/views/projects/commit/_unverified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with an <strong>unverified</strong> signature.').html_safe + = html_escape(_('This commit was signed with an %{strong_open}unverified%{strong_close} signature.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Unverified'), css_class: 'invalid', icon: 'status_notfound_borderless' } diff --git a/app/views/projects/commit/diff_files.html.haml b/app/views/projects/commit/diff_files.html.haml new file mode 100644 index 00000000000..3a473be3840 --- /dev/null +++ b/app/views/projects/commit/diff_files.html.haml @@ -0,0 +1,3 @@ +- diff_files = diffs.diff_files + += render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: 'is-commit' } diff --git a/app/views/projects/commit/x509/_verified_signature_badge.html.haml b/app/views/projects/commit/x509/_verified_signature_badge.html.haml index 4964b1b8ee7..357ad467539 100644 --- a/app/views/projects/commit/x509/_verified_signature_badge.html.haml +++ b/app/views/projects/commit/x509/_verified_signature_badge.html.haml @@ -1,5 +1,5 @@ - title = capture do - = _('This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.').html_safe + = html_escape(_('This commit was signed with a %{strong_open}verified%{strong_close} signature and the committer email is verified to belong to the same user.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } - locals = { signature: signature, title: title, label: _('Verified'), css_class: 'valid', icon: 'status_success_borderless', show_user: true } diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index ab1d855a6e0..33fedde0cd1 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -10,12 +10,13 @@ - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } - commit = commit.present(current_user: current_user) - commit_status = commit.status_for(ref) +- collapsible = local_assigns.fetch(:collapsible, true) - link = commit_path(project, commit, merge_request: merge_request) - show_project_name = local_assigns.fetch(:show_project_name, false) -%li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } +%li{ class: ["commit flex-row", ("js-toggle-container" if collapsible)], id: "commit-#{commit.short_id}" } .avatar-cell.d-none.d-sm-block = author_avatar(commit, size: 40, has_tooltip: false) @@ -29,7 +30,7 @@ %span.commit-row-message.d-inline.d-sm-none · = commit.short_id - - if commit.description? + - if commit.description? && collapsible %button.text-expander.js-toggle-button = sprite_icon('ellipsis_h', size: 12) @@ -41,7 +42,7 @@ = render_if_exists 'projects/commits/project_namespace', show_project_name: show_project_name, project: project - if commit.description? - %pre.commit-row-description.js-toggle-content.gl-mb-3 + %pre{ class: ["commit-row-description gl-mb-3", (collapsible ? "js-toggle-content" : "d-block")] } = preserve(markdown_field(commit, :description)) .commit-actions.flex-row diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index e413bd78789..293500a6c31 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -1,8 +1,10 @@ - merge_request = local_assigns.fetch(:merge_request, nil) - project = local_assigns.fetch(:project) { merge_request&.project } - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } +- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) - commits = @commits +- context_commits = @context_commits - hidden = @hidden_commit_count - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, daily_commits| @@ -14,11 +16,26 @@ %ul.content-list.commit-list.flex-list = render partial: 'projects/commits/commit', collection: daily_commits, locals: { project: project, ref: ref, merge_request: merge_request } +- if context_commits.present? + %li.commit-header.js-commit-header + %span.font-weight-bold= n_("%d previously merged commit", "%d previously merged commits", context_commits.count) % context_commits.count + - if project.context_commits_enabled? && can_update_merge_request + %button.btn.btn-default.ml-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'false' } } + = _('Add/remove') + + %li.commits-row + %ul.content-list.commit-list.flex-list + = render partial: 'projects/commits/commit', collection: context_commits, locals: { project: project, ref: ref, merge_request: merge_request } + - if hidden > 0 %li.alert.alert-warning = n_('%s additional commit has been omitted to prevent performance issues.', '%s additional commits have been omitted to prevent performance issues.', hidden) % number_with_delimiter(hidden) -- if commits.size == 0 +- if project.context_commits_enabled? && can_update_merge_request && context_commits&.empty? + %button.btn.btn-default.mt-3.add-review-item-modal-trigger{ type: "button", data: { context_commits_empty: 'true' } } + = _('Add previously merged commits') + +- if commits.size == 0 && context_commits.nil? .mt-4.text-center .bold = _('Your search didn\'t match any commits.') diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 737e4f66dd2..28b5dc0cc67 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -26,8 +26,8 @@ = form_tag(project_commits_path(@project, @id), method: :get, class: 'commits-search-form js-signature-container', data: { 'signatures-path' => namespace_project_signatures_path }) do = search_field_tag :search, params[:search], { placeholder: _('Search by message'), id: 'commits-search', class: 'form-control search-text-input input-short gl-mt-3 gl-sm-mt-0 gl-min-w-full', spellcheck: false } .control.d-none.d-md-block - = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn' do - = icon("rss") + = link_to project_commits_path(@project, @ref, rss_url_options), title: _("Commits feed"), class: 'btn btn-svg' do + = sprite_icon('rss', css_class: 'qa-rss-icon') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/compare/_form.html.haml b/app/views/projects/compare/_form.html.haml index d10fa69ff47..768acac96c0 100644 --- a/app/views/projects/compare/_form.html.haml +++ b/app/views/projects/compare/_form.html.haml @@ -10,7 +10,7 @@ = hidden_field_tag :to, params[:to] = button_tag type: 'button', title: params[:to], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-to-dropdown", selected: params[:to], field_name: :to } do .dropdown-toggle-text.str-truncated.monospace.float-left= params[:to] || _("Select branch/tag") - = sprite_icon('chevron-down', size: 16, css_class: 'float-right') + = sprite_icon('chevron-down', css_class: 'float-right') = render 'shared/ref_dropdown' .compare-ellipsis.inline ... .form-group.dropdown.compare-form-group.from.js-compare-from-dropdown @@ -21,7 +21,7 @@ = hidden_field_tag :from, params[:from] = button_tag type: 'button', title: params[:from], class: "btn form-control compare-dropdown-toggle js-compare-dropdown has-tooltip", required: true, data: { refs_url: refs_project_path(@project), toggle: "dropdown", target: ".js-compare-from-dropdown", selected: params[:from], field_name: :from } do .dropdown-toggle-text.str-truncated.monospace.float-left= params[:from] || _("Select branch/tag") - = sprite_icon('chevron-down', size: 16, css_class: 'float-right') + = sprite_icon('chevron-down', css_class: 'float-right') = render 'shared/ref_dropdown' = button_tag s_("CompareBranches|Compare"), class: "btn btn-success commits-compare-btn" diff --git a/app/views/projects/compare/index.html.haml b/app/views/projects/compare/index.html.haml index 93ee1bed809..e45ea209e8c 100644 --- a/app/views/projects/compare/index.html.haml +++ b/app/views/projects/compare/index.html.haml @@ -8,9 +8,9 @@ %code.ref-name master - example_sha = capture do %code.ref-name 4eedf23 - = (_("Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.") % { master: example_master, sha: example_sha }).html_safe + = html_escape(_("Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request.")) % { master: example_master.html_safe, sha: example_sha.html_safe } %br - = (_("Changes are shown as if the <b>source</b> revision was being merged into the <b>target</b> revision.")).html_safe + = html_escape(_("Changes are shown as if the %{b_open}source%{b_close} revision was being merged into the %{b_open}target%{b_close} revision.")) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } .prepend-top-20 = render "form" diff --git a/app/views/projects/default_branch/_show.html.haml b/app/views/projects/default_branch/_show.html.haml index 38bec0361b0..b78535bbe2f 100644 --- a/app/views/projects/default_branch/_show.html.haml +++ b/app/views/projects/default_branch/_show.html.haml @@ -9,7 +9,7 @@ = _('Select the branch you want to set as the default for this project. All merge requests and commits will automatically be made against this branch unless you specify a different one.') .settings-content - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| + = form_for @project, remote: true, html: { multipart: true, anchor: 'default-branch-settings' }, authenticity_token: true do |f| %fieldset - if @project.empty_repo? .text-secondary @@ -28,4 +28,5 @@ = _("Issues referenced by merge requests and commits within the default branch will be closed automatically") = link_to icon('question-circle'), help_page_path('user/project/issues/managing_issues.md', anchor: 'disabling-automatic-issue-closing'), target: '_blank' - = f.submit 'Save changes', class: "btn btn-success" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success" diff --git a/app/views/projects/deploy_keys/edit.html.haml b/app/views/projects/deploy_keys/edit.html.haml index 7fa7036245c..805d4983002 100644 --- a/app/views/projects/deploy_keys/edit.html.haml +++ b/app/views/projects/deploy_keys/edit.html.haml @@ -3,7 +3,7 @@ %hr %div - = form_for [@project.namespace.becomes(Namespace), @project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f| + = form_for [@project, @deploy_key], include_id: false, html: { class: 'js-requires-input' } do |f| = render partial: 'shared/deploy_keys/form', locals: { form: f, deploy_key: @deploy_key } .form-actions = f.submit 'Save changes', class: 'btn-success btn' diff --git a/app/views/projects/deployments/_actions.haml b/app/views/projects/deployments/_actions.haml index c84c376d57b..5127d8b77d5 100644 --- a/app/views/projects/deployments/_actions.haml +++ b/app/views/projects/deployments/_actions.haml @@ -10,5 +10,5 @@ - actions.each do |action| - next unless can?(current_user, :update_build, action) %li - = link_to [:play, @project.namespace.becomes(Namespace), @project, action], method: :post, rel: 'nofollow' do + = link_to [:play, @project, action], method: :post, rel: 'nofollow' do %span= action.name diff --git a/app/views/projects/deployments/_confirm_rollback_modal.html.haml b/app/views/projects/deployments/_confirm_rollback_modal.html.haml index 9162827b501..3735ead1559 100644 --- a/app/views/projects/deployments/_confirm_rollback_modal.html.haml +++ b/app/views/projects/deployments/_confirm_rollback_modal.html.haml @@ -16,7 +16,7 @@ = s_('Environments|This action will run the job defined by %{environment_name} for commit %{commit_id}, putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?').html_safe % {commit_id: commit_sha, environment_name: @environment.name} .modal-footer = button_tag _('Cancel'), type: 'button', class: 'btn btn-cancel', data: { dismiss: 'modal' } - = link_to [:retry, @project.namespace.becomes(Namespace), @project, deployment.deployable], method: :post, class: 'btn btn-danger' do + = link_to [:retry, @project, deployment.deployable], method: :post, class: 'btn btn-danger' do - if deployment.last? = s_('Environments|Re-deploy') - else diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 4b76dde681e..6ba363e6555 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -3,6 +3,7 @@ - can_create_note = !@diff_notes_disabled && can?(current_user, :create_note, diffs.project) - diff_files = diffs.diff_files - diff_page_context = local_assigns.fetch(:diff_page_context, nil) +- load_diff_files_async = Feature.enabled?(:async_commit_diff_files, @project) && diff_page_context == "is-commit" .content-block.oneline-block.files-changed.diff-files-changed.js-diff-files-changed .files-changed-inner @@ -26,5 +27,12 @@ - if render_overflow_warning?(diffs) = render 'projects/diffs/warning', diff_files: diffs + .files{ data: { can_create_note: can_create_note } } - = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } + - if load_diff_files_async + - url = url_for(safe_params.merge(action: 'diff_files')) + .js-diffs-batch{ data: { diff_files_path: url } } + .text-center + %span.spinner.spinner-md + - else + = render partial: 'projects/diffs/file', collection: diff_files, as: :diff_file, locals: { project: diffs.project, environment: environment, diff_page_context: diff_page_context } diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 7395c16c38b..bd023e0442c 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -14,7 +14,7 @@ .file-actions.d-none.d-sm-block - if blob&.readable_text? = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do - = sprite_icon('comment', size: 16) + = sprite_icon('comment') \ - if editable_diff?(diff_file) - link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {} diff --git a/app/views/projects/diffs/_stats.html.haml b/app/views/projects/diffs/_stats.html.haml index 0e2a1165ad3..b438fbbf446 100644 --- a/app/views/projects/diffs/_stats.html.haml +++ b/app/views/projects/diffs/_stats.html.haml @@ -22,7 +22,7 @@ - diff_files.each do |diff_file| %li %a.diff-changed-file{ href: "##{hexdigest(diff_file.file_path)}", title: diff_file.new_path } - = sprite_icon(diff_file_changed_icon(diff_file), size: 16, css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon gl-mr-3") + = sprite_icon(diff_file_changed_icon(diff_file), css_class: "#{diff_file_changed_icon_color(diff_file)} diff-file-changed-icon gl-mr-3") %span.diff-changed-file-content.gl-mr-3 - if diff_file.file_path %strong.diff-changed-file-name diff --git a/app/views/projects/diffs/_warning.html.haml b/app/views/projects/diffs/_warning.html.haml index 2cc3d921abc..643d111fedd 100644 --- a/app/views/projects/diffs/_warning.html.haml +++ b/app/views/projects/diffs/_warning.html.haml @@ -9,4 +9,4 @@ = link_to _("Plain diff"), merge_request_path(@merge_request, format: :diff), class: "btn btn-sm" = link_to _("Email patch"), merge_request_path(@merge_request, format: :patch), class: "btn btn-sm" %p - = _("To preserve performance only <strong>%{display_size} of %{real_size}</strong> files are displayed.").html_safe % { display_size: diff_files.size, real_size: diff_files.real_size } + = html_escape(_("To preserve performance only %{strong_open}%{display_size} of %{real_size}%{strong_close} files are displayed.")) % { display_size: diff_files.size, real_size: diff_files.real_size, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index e63b615115a..bf978b01652 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -17,13 +17,14 @@ %p= _('Choose visibility level, enable/disable project features (issues, repository, wiki, snippets) and set permissions.') .settings-content - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| + = form_for @project, remote: true, html: { multipart: true, class: "sharing-permissions-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data_json(@project) .js-project-permissions-form - - if show_visibility_confirm_modal?(@project) - = render "visibility_modal" - = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } + .gl-display-flex.gl-justify-content-end + - if show_visibility_confirm_modal?(@project) + = render "visibility_modal" + = f.submit _('Save changes'), class: "btn btn-success #{('js-confirm-danger' if show_visibility_confirm_modal?(@project))}", data: { qa_selector: 'visibility_features_permissions_save_button', check_field_name: ("project[visibility_level]" if show_visibility_confirm_modal?(@project)), check_compare_value: @project.visibility_level } %section.qa-merge-request-settings.rspec-merge-request-settings.settings.merge-requests-feature.no-animate#js-merge-request-settings{ class: [('expanded' if expanded), ('hidden' if @project.project_feature.send(:merge_requests_access_level) == 0)] } .settings-header @@ -34,10 +35,11 @@ .settings-content = render_if_exists 'shared/promotions/promote_mr_features' - = form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| + = form_for @project, remote: true, html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } = render 'projects/merge_request_settings', form: f - = f.submit _('Save changes'), class: "btn btn-success qa-save-merge-request-changes rspec-save-merge-request-changes" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-succes qa-save-merge-request-changes rspec-save-merge-request-changes" = render_if_exists 'projects/merge_request_approvals_settings', expanded: expanded @@ -68,8 +70,9 @@ .sub-section %h4= _('Housekeeping') %p= _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') - = link_to _('Run housekeeping'), housekeeping_project_path(@project), - method: :post, class: "btn btn-default" + .gl-display-flex.gl-justify-content-end + = link_to _('Run housekeeping'), housekeeping_project_path(@project), + method: :post, class: "btn btn-default" = render 'export', project: @project @@ -77,7 +80,7 @@ .sub-section.rename-repository %h4.warning-title= _('Change path') = render 'projects/errors' - = form_for([@project.namespace.becomes(Namespace), @project]) do |f| + = form_for @project do |f| .form-group = f.label :path, _('Path'), class: 'label-bold' .form-group @@ -91,12 +94,13 @@ %li= _('You will need to update your local repositories to point to the new location.') - if @project.deployment_platform.present? %li= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') - = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button" + .gl-display-flex.gl-justify-content-end + = f.submit _('Change path'), class: "btn btn-warning qa-change-path-button" - if can?(current_user, :change_namespace, @project) .sub-section %h4.danger-title= _('Transfer project') - = form_for([@project.namespace.becomes(Namespace), @project], url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } ) do |f| + = form_for @project, url: transfer_project_path(@project), method: :put, remote: true, html: { class: 'js-project-transfer-form' } do |f| .form-group = label_tag :new_namespace_id, nil, class: 'label-bold' do %span= _('Select a new namespace') @@ -107,14 +111,15 @@ %li= _('You can only transfer the project to namespaces you manage.') %li= _('You will need to update your local repositories to point to the new location.') %li= _('Project visibility level will be changed to match namespace rules when transferring to a group.') - = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } + .gl-display-flex.gl-justify-content-end + = f.submit 'Transfer project', class: "btn btn-remove js-confirm-danger qa-transfer-button", data: { "confirm-danger-message" => transfer_project_message(@project) } - if @project.forked? && can?(current_user, :remove_fork_project, @project) .sub-section %h4.danger-title= _('Remove fork relationship') %p= remove_fork_project_description_message(@project) - = form_for([@project.namespace.becomes(Namespace), @project], url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' }) do |f| + = form_for @project, url: remove_fork_project_path(@project), method: :delete, remote: true, html: { class: 'transfer-project' } do |f| %p %strong= _('Once removed, the fork relationship cannot be restored and you will no longer be able to send merge requests to the source.') = button_to _('Remove fork relationship'), '#', class: "btn btn-remove js-confirm-danger", data: { "confirm-danger-message" => remove_fork_project_warning_message(@project) } diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml index 39eda493d69..0b8f9fe220d 100644 --- a/app/views/projects/environments/_form.html.haml +++ b/app/views/projects/environments/_form.html.haml @@ -6,7 +6,7 @@ - link_to_read_more = link_to(_("More information"), help_page_path("ci/environments/index.md")) = _("Environments allow you to track deployments of your application %{link_to_read_more}.").html_safe % { link_to_read_more: link_to_read_more } - = form_for [@project.namespace.becomes(Namespace), @project, @environment], html: { class: 'col-lg-9' } do |f| + = form_for [@project, @environment], html: { class: 'col-lg-9' } do |f| = form_errors(@environment) .form-group diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index d5249662dde..2e665a12a0a 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -65,7 +65,7 @@ %h4.state-title = _("You don't have any deployments right now.") %p.blank-state-text - = _("Define environments in the deploy stage(s) in <code>.gitlab-ci.yml</code> to track deployments here.").html_safe + = html_escape(_("Define environments in the deploy stage(s) in %{code_open}.gitlab-ci.yml%{code_close} to track deployments here.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } .text-center = link_to _("Read more"), help_page_path("ci/environments"), class: "btn btn-success" - else diff --git a/app/views/projects/forks/_fork_button.html.haml b/app/views/projects/forks/_fork_button.html.haml index eec02a50b85..dd49e8bdb4b 100644 --- a/app/views/projects/forks/_fork_button.html.haml +++ b/app/views/projects/forks/_fork_button.html.haml @@ -1,26 +1,20 @@ - avatar = namespace_icon(namespace, 100) - can_create_project = current_user.can?(:create_projects, namespace) -- if forked_project = namespace.find_fork_of(@project) - .bordered-box.fork-thumbnail.text-center.gl-ml-3.gl-mr-3.gl-mt-3.gl-mb-3.forked - = link_to project_path(forked_project) do - - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto") - - else - .avatar-container.s100.mx-auto - = image_tag(avatar, class: "avatar s100") - %h5.gl-mt-3 - = namespace.human_name -- else - .bordered-box.fork-thumbnail.text-center.gl-ml-3.gl-mr-3.gl-mt-3.gl-mb-3{ class: ("disabled" unless can_create_project) } - = link_to project_forks_path(@project, namespace_key: namespace.id), - method: "POST", - class: ("disabled has-tooltip" unless can_create_project), - title: (_('You have reached your project limit') unless can_create_project) do - - if /no_((\w*)_)*avatar/.match(avatar) - = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto") - - else - .avatar-container.s100.mx-auto - = image_tag(avatar, class: "avatar s100") - %h5.gl-mt-3{ data: { qa_selector: 'fork_namespace_content', qa_name: namespace.human_name } } - = namespace.human_name +.bordered-box.fork-thumbnail.text-center.gl-m-3{ class: ("disabled" unless can_create_project) } + - if /no_((\w*)_)*avatar/.match(avatar) + = group_icon(namespace, class: "avatar rect-avatar s100 identicon mx-auto") + - else + .avatar-container.s100.mx-auto.gl-mt-5 + = image_tag(avatar, class: "avatar s100") + %h5.gl-mt-3 + = namespace.human_name + - if forked_project = namespace.find_fork_of(@project) + = link_to _("Go to project"), project_path(forked_project), class: "btn" + - else + %div{ class: ('has-tooltip' unless can_create_project), + title: (_('You have reached your project limit') unless can_create_project) } + = link_to _("Select"), project_forks_path(@project, namespace_key: namespace.id), + data: { qa_selector: 'fork_namespace_button', qa_name: namespace.human_name }, + method: "POST", + class: ["btn btn-success", ("disabled" unless can_create_project)] diff --git a/app/views/projects/forks/error.html.haml b/app/views/projects/forks/error.html.haml index b37dba8b35d..5d527f1bcfb 100644 --- a/app/views/projects/forks/error.html.haml +++ b/app/views/projects/forks/error.html.haml @@ -2,7 +2,7 @@ - if @forked_project && !@forked_project.saved? .alert.alert-danger.alert-block %h4 - = sprite_icon('fork', size: 16) + = sprite_icon('fork') = _("Fork Error!") %p = _("You tried to fork %{link_to_the_project} but it failed for the following reason:").html_safe % { link_to_the_project: link_to_project(@project) } diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index 887081d0f35..45d314a1088 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -5,17 +5,14 @@ %h4.gl-mt-0 = _("Fork project") %p - = _("A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project.").html_safe + = _("A fork is a copy of a project.") + %br + = _('Forking a repository allows you to make changes without affecting the original project.') .col-lg-9 - - if @namespaces.present? + - if @own_namespace.present? .fork-thumbnail-container.js-fork-content %h5.gl-mt-0.gl-mb-0.gl-ml-3.gl-mr-3 = _("Select a namespace to fork the project") - - @namespaces.each do |namespace| - = render 'fork_button', namespace: namespace - - else - %strong - = _("No available namespaces to fork the project.") - %p.gl-mt-3 - = _("You must have permission to create a project in a namespace before forking.") + = render 'fork_button', namespace: @own_namespace + #fork-groups-mount-element{ data: { endpoint: new_project_fork_path(@project, format: :json), can_create_project: current_user.can_create_project?.to_s } } diff --git a/app/views/projects/hooks/edit.html.haml b/app/views/projects/hooks/edit.html.haml index e0ef0c0d3f9..f728ef5ac1a 100644 --- a/app/views/projects/hooks/edit.html.haml +++ b/app/views/projects/hooks/edit.html.haml @@ -7,7 +7,7 @@ = render 'shared/web_hooks/title_and_docs', hook: @hook .col-lg-9.gl-mb-3 - = form_for [@project.namespace.becomes(Namespace), @project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f| + = form_for [@project, @hook], as: :hook, url: project_hook_path(@project, @hook) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } %span>= f.submit 'Save changes', class: 'btn btn-success gl-mr-3' diff --git a/app/views/projects/hooks/index.html.haml b/app/views/projects/hooks/index.html.haml index 1845bd190d3..5c6a87ddb26 100644 --- a/app/views/projects/hooks/index.html.haml +++ b/app/views/projects/hooks/index.html.haml @@ -7,8 +7,9 @@ = render 'shared/web_hooks/title_and_docs', hook: @hook .col-lg-8.gl-mb-3 - = form_for @hook, as: :hook, url: polymorphic_path([@project.namespace.becomes(Namespace), @project, :hooks]) do |f| + = form_for @hook, as: :hook, url: polymorphic_path([@project, :hooks]) do |f| = render partial: 'shared/web_hooks/form', locals: { form: f, hook: @hook } - = f.submit 'Add webhook', class: 'btn btn-success' + .gl-display-flex.gl-justify-content-end + = f.submit 'Add webhook', class: 'btn btn-success' = render 'shared/web_hooks/index', hooks: @hooks, hook_class: @hook.class diff --git a/app/views/projects/incidents/index.html.haml b/app/views/projects/incidents/index.html.haml new file mode 100644 index 00000000000..3d66c254601 --- /dev/null +++ b/app/views/projects/incidents/index.html.haml @@ -0,0 +1,3 @@ +- page_title _('Incidents') + +#js-incidents{ data: incidents_data(@project) } diff --git a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml index a6f969f8b10..9b142b08574 100644 --- a/app/views/projects/issues/_alert_moved_from_service_desk.html.haml +++ b/app/views/projects/issues/_alert_moved_from_service_desk.html.haml @@ -3,8 +3,8 @@ - service_desk_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: service_desk_link_url } .hide.gl-alert.gl-alert-warning.js-alert-moved-from-service-desk-warning.gl-mt-5{ role: 'alert' } - = sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + = sprite_icon('warning', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body.gl-mr-3 = s_('This project does not have %{service_desk_link_start}Service Desk%{service_desk_link_end} enabled, so the user who created the issue will no longer receive email notifications about new activity.').html_safe % { service_desk_link_start: service_desk_link_start, service_desk_link_end: '</a>'.html_safe } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index bcc74e8d1d9..4273130bbc2 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -1,3 +1,5 @@ +- add_page_startup_api_call discussions_path(@issue) + - @gfm_form = true - content_for :note_actions do diff --git a/app/views/projects/issues/_form.html.haml b/app/views/projects/issues/_form.html.haml index 1be1087b36f..dcc8000c0c5 100644 --- a/app/views/projects/issues/_form.html.haml +++ b/app/views/projects/issues/_form.html.haml @@ -1,3 +1,3 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @issue], += form_for [@project, @issue], html: { class: 'issue-form common-note-form js-quick-submit js-requires-input' } do |f| = render 'shared/issuable/form', f: f, issuable: @issue diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index e7cd35497e8..ba9ab50cb3a 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -1,12 +1,12 @@ -# DANGER: Any changes to this file need to be reflected in issuables_list/components/issuable.vue! -%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue', qa_issue_title: issue.title } } +%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } } .issue-box - if @can_bulk_update .issue-check.hidden = check_box_tag dom_id(issue, "selected"), nil, false, 'data-id' => issue.id, class: "selected-issuable" .issuable-info-container .issuable-main-info - .issue-title.title.d-flex.align-items-center + .issue-title.title %span.issue-title-text.js-onboarding-issue-item{ dir: "auto" } - if issue.confidential? %span.has-tooltip{ title: _('Confidential') } @@ -30,7 +30,7 @@ %span.issuable-milestone.d-none.d-sm-inline-block = link_to project_issues_path(issue.project, milestone_title: issue.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(issue.milestone) } do - = icon('clock-o') + = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') = issue.milestone.title - if issue.due_date %span.issuable-due-date.d-none.d-sm-inline-block.has-tooltip{ class: "#{'cred' if issue.overdue?}", title: _('Due date') } diff --git a/app/views/projects/issues/_issue_estimate.html.haml b/app/views/projects/issues/_issue_estimate.html.haml index 46797d0f1a0..c49bf626f4e 100644 --- a/app/views/projects/issues/_issue_estimate.html.haml +++ b/app/views/projects/issues/_issue_estimate.html.haml @@ -3,5 +3,5 @@ - if issue.time_estimate > 0 %span.issuable-estimate.d-none.d-sm-inline-block.has-tooltip{ data: { container: 'body', qa_selector: 'issuable_estimate' }, title: _('Estimate') } - = sprite_icon('timer', size: 16, css_class: 'issue-estimate-icon') + = sprite_icon('timer', css_class: 'issue-estimate-icon') = Gitlab::TimeTrackingFormatter.output(issue.time_estimate) diff --git a/app/views/projects/issues/_issues.html.haml b/app/views/projects/issues/_issues.html.haml index c0383c57e63..1e24b08ece2 100644 --- a/app/views/projects/issues/_issues.html.haml +++ b/app/views/projects/issues/_issues.html.haml @@ -1,8 +1,13 @@ - if Feature.enabled?(:vue_issuables_list, @project) - .js-issuables-list{ data: { endpoint: expose_url(api_v4_projects_issues_path(id: @project.id)), + - data_endpoint = local_assigns.fetch(:data_endpoint, expose_path(api_v4_projects_issues_path(id: @project.id))) + - default_empty_state_meta = { create_issue_path: new_project_issue_path(@project), svg_path: image_path('illustrations/issues.svg') } + - data_empty_state_meta = local_assigns.fetch(:data_empty_state_meta, default_empty_state_meta) + - type = local_assigns.fetch(:type, '') + .js-issuables-list{ data: { endpoint: data_endpoint, + 'empty-state-meta': data_empty_state_meta.to_json, 'can-bulk-edit': @can_bulk_update.to_json, - 'empty-svg-path': image_path('illustrations/issues.svg'), - 'sort-key': @sort } } + 'sort-key': @sort, + 'type': type } } - else - empty_state_path = local_assigns.fetch(:empty_state_path, 'shared/empty_states/issues') %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position') } diff --git a/app/views/projects/issues/_service_desk_empty_state.html.haml b/app/views/projects/issues/_service_desk_empty_state.html.haml new file mode 100644 index 00000000000..4f004439f45 --- /dev/null +++ b/app/views/projects/issues/_service_desk_empty_state.html.haml @@ -0,0 +1,33 @@ +- service_desk_enabled = @project.service_desk_enabled? + +- can_edit_project_settings = can?(current_user, :admin_project, @project) +- title_text = _("Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab") + +- if Gitlab::ServiceDesk.supported? + .empty-state + .svg-content + = render 'shared/empty_states/icons/service_desk_empty_state.svg' + + .text-content + %h4= title_text + + - if can_edit_project_settings && service_desk_enabled + %p + = _("Have your users email") + %code= @project.service_desk_address + + %span= _("Those emails automatically become issues (with the comments becoming the email conversation) listed here.") + = link_to _('Read more'), help_page_path('user/project/service_desk') + + - if can_edit_project_settings && !service_desk_enabled + .text-center + = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success' +- else + .empty-state + .svg-content + = render 'shared/empty_states/icons/service_desk_setup.svg' + .text-content + %h4= _('Service Desk is enabled but not yet active') + %p + = _("You must set up incoming email before it becomes active.") + = link_to _('More information'), help_page_path('administration/incoming_email', anchor: 'set-it-up') diff --git a/app/views/projects/issues/_service_desk_info_content.html.haml b/app/views/projects/issues/_service_desk_info_content.html.haml index ddd8e545043..7fa2f3fab00 100644 --- a/app/views/projects/issues/_service_desk_info_content.html.haml +++ b/app/views/projects/issues/_service_desk_info_content.html.haml @@ -1,39 +1,23 @@ -- is_empty_state = @issues.blank? - service_desk_enabled = @project.service_desk_enabled? -- callout_selector = is_empty_state ? 'empty-state' : 'non-empty-state media' -- svg_path = !is_empty_state ? 'shared/empty_states/icons/service_desk_callout.svg' : 'shared/empty_states/icons/service_desk_empty_state.svg' - can_edit_project_settings = can?(current_user, :admin_project, @project) - title_text = _("Use Service Desk to connect with your users (e.g. to offer customer support) through email right inside GitLab") -- if Gitlab::ServiceDesk.supported? - %div{ class: "#{callout_selector}" } - .svg-content - = render svg_path +.non-empty-state.media + .svg-content + = render 'shared/empty_states/icons/service_desk_callout.svg' - %div{ class: is_empty_state ? "text-content" : "prepend-top-10 gl-ml-3" } - - if is_empty_state - %h4= title_text - - else - %h5= title_text + .gl-mt-3.gl-ml-3 + %h5= title_text - - if can_edit_project_settings && service_desk_enabled - %p - = _("Have your users email") - %code= @project.service_desk_address + - if can_edit_project_settings && service_desk_enabled + %p + = _("Have your users email") + %code= @project.service_desk_address - %span= _("Those emails automatically become issues (with the comments becoming the email conversation) listed here.") - = link_to _('Read more'), help_page_path('user/project/service_desk') + %span= _("Those emails automatically become issues (with the comments becoming the email conversation) listed here.") + = link_to _('Read more'), help_page_path('user/project/service_desk') - - if can_edit_project_settings && !service_desk_enabled - %div{ class: is_empty_state ? "text-center" : "prepend-top-10" } - = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success' -- else - .empty-state - .svg-content - = render 'shared/empty_states/icons/service_desk_setup.svg' - .text-content - %h4= _('Service Desk is enabled but not yet active') - %p - = _("You must set up incoming email before it becomes active.") - = link_to _('More information'), help_page_path('administration/incoming_email', anchor: 'set-it-up') + - if can_edit_project_settings && !service_desk_enabled + .gl-mt-3 + = link_to _("Turn on Service Desk"), edit_project_path(@project), class: 'btn btn-success' diff --git a/app/views/projects/issues/export_csv/_modal.html.haml b/app/views/projects/issues/export_csv/_modal.html.haml index 342c3ba27bb..793e43da935 100644 --- a/app/views/projects/issues/export_csv/_modal.html.haml +++ b/app/views/projects/issues/export_csv/_modal.html.haml @@ -8,7 +8,7 @@ .svg-content.import-export-svg-container = image_tag 'illustrations/export-import.svg', alt: _('Import/Export illustration'), class: 'illustration' %a.close{ href: '#', 'data-dismiss' => 'modal' } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('close', css_class: 'gl-icon') .modal-body .modal-subheader = icon('check', { class: 'checkmark' }) @@ -16,6 +16,6 @@ - issues_count = issuables_count_for_state(:issues, params[:state]) = n_('%d issue selected', '%d issues selected', issues_count) % issues_count .modal-text - = _('The CSV export will be created in the background. Once finished, it will be sent to <strong>%{email}</strong> in an attachment.').html_safe % { email: @current_user.notification_email } + = html_escape(_('The CSV export will be created in the background. Once finished, it will be sent to %{strong_open}%{email}%{strong_close} in an attachment.')) % { email: @current_user.notification_email, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } .modal-footer = link_to _('Export issues'), export_csv_project_issues_path(@project, request.query_parameters), method: :post, class: 'btn btn-success float-left', title: _('Export issues'), data: { track_label: "export_issues_csv", track_event: "click_button", track_value: "", qa_selector: "export_issues_button" } diff --git a/app/views/projects/issues/service_desk.html.haml b/app/views/projects/issues/service_desk.html.haml index 9b0b3ebc9e0..bd260bdf143 100644 --- a/app/views/projects/issues/service_desk.html.haml +++ b/app/views/projects/issues/service_desk.html.haml @@ -5,9 +5,11 @@ - content_for :breadcrumbs_extra do = render "projects/issues/nav_btns", show_export_button: false, show_rss_button: false -- support_bot_attrs = UserSerializer.new.represent(User.support_bot).to_json +- support_bot_attrs = { service_desk_enabled: @project.service_desk_enabled?, **UserSerializer.new.represent(User.support_bot) }.to_json -%div{ class: "js-service-desk-issues service-desk-issues", data: { support_bot: support_bot_attrs } } +- data_endpoint = "#{expose_path(api_v4_projects_issues_path(id: @project.id))}?author_id=#{User.support_bot.id}" + +%div{ class: "js-service-desk-issues service-desk-issues", data: { support_bot: support_bot_attrs, service_desk_meta: service_desk_meta(@project) } } .top-area = render 'shared/issuable/nav', type: :issues .nav-controls.d-block.d-sm-none @@ -15,7 +17,15 @@ - if @issues.present? = render 'shared/issuable/search_bar', type: :issues - = render 'service_desk_info_content' + - if Gitlab::ServiceDesk.supported? + = render 'service_desk_info_content' + -# TODO Remove empty_state_path once vue_issuables_list FF is removed. + -# https://gitlab.com/gitlab-org/gitlab/-/issues/235652 + -# `empty_state_path` is used to render the empty state in the HAML version of issuables list. .issues-holder - = render 'projects/issues/issues', empty_state_path: 'service_desk_info_content' + = render 'projects/issues/issues', + empty_state_path: 'service_desk_empty_state', + data_endpoint: data_endpoint, + data_empty_state_meta: service_desk_meta(@project), + type: 'service_desk' diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 2a0dc5e30b9..a7817ad5552 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -9,6 +9,7 @@ - can_reopen_issue = can?(current_user, :reopen_issue, @issue) - can_report_spam = @issue.submittable_as_spam_by?(current_user) - can_create_issue = show_new_issue_link?(@project) +- related_branches_path = related_branches_project_issue_path(@project, @issue) = render_if_exists "projects/issues/alert_blocked", issue: @issue, current_user: current_user = render "projects/issues/alert_moved_from_service_desk", issue: @issue @@ -16,11 +17,11 @@ .detail-page-header .detail-page-header-body .issuable-status-box.status-box.status-box-issue-closed{ class: issue_status_visibility(@issue, status_box: :closed) } - = sprite_icon('mobile-issue-close', size: 16, css_class: 'd-block d-sm-none') + = sprite_icon('mobile-issue-close', css_class: 'd-block d-sm-none') .d-none.d-sm-block = issue_closed_text(@issue, current_user) .issuable-status-box.status-box.status-box-open{ class: issue_status_visibility(@issue, status_box: :open) } - = sprite_icon('issue-open-m', size: 16, css_class: 'd-block d-sm-none') + = sprite_icon('issue-open-m', css_class: 'd-block d-sm-none') %span.d-none.d-sm-block Open .issuable-meta @@ -82,7 +83,8 @@ #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } } - if can?(current_user, :download_code, @project) - #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } + - add_page_startup_api_call related_branches_path + #related-branches{ data: { url: related_branches_path } } -# This element is filled in using JavaScript. .content-block.emoji-block.emoji-block-sticky diff --git a/app/views/projects/issues/verify.html.haml b/app/views/projects/issues/verify.html.haml index 6da7c317f3a..935a3493a37 100644 --- a/app/views/projects/issues/verify.html.haml +++ b/app/views/projects/issues/verify.html.haml @@ -1,5 +1,3 @@ -- form = [@project.namespace.becomes(Namespace), @project, @issue] - -= render layout: 'layouts/recaptcha_verification', locals: { spammable: @issue, form: form } do += render layout: 'layouts/recaptcha_verification', locals: { spammable: @issue } do = hidden_field_tag(:merge_request_to_resolve_discussions_of, params[:merge_request_to_resolve_discussions_of]) = hidden_field_tag(:discussion_to_resolve, params[:discussion_to_resolve]) diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index ba47712211d..8d8270847a3 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -8,7 +8,7 @@ #promote-label-modal = render 'shared/labels/nav', labels_or_filters: labels_or_filters, can_admin_label: can_admin_label - .labels-container.prepend-top-10 + .labels-container.gl-mt-3 - if can_admin_label && search.blank? %p.text-muted = _('Labels can be applied to issues and merge requests.') @@ -18,7 +18,7 @@ -# Only show it in the first page - hide = @available_labels.empty? || (params[:page].present? && params[:page] != '1') .prioritized-labels{ class: [('hide' if hide), ('is-not-draggable' unless can_admin_label)] } - %h5.prepend-top-10= _('Prioritized Labels') + %h5.gl-mt-3= _('Prioritized Labels') .content-list.manage-labels-list.js-prioritized-labels{ data: { url: set_priorities_project_labels_path(@project), sortable: can_admin_label } } #js-priority-labels-empty-state.priority-labels-empty-state{ class: "#{'hidden' unless @prioritized_labels.empty? && search.blank?}" } = render 'shared/empty_states/priority_labels' diff --git a/app/views/projects/merge_requests/_approvals_count.html.haml b/app/views/projects/merge_requests/_approvals_count.html.haml index 464cba1bb2d..449e75f26e0 100644 --- a/app/views/projects/merge_requests/_approvals_count.html.haml +++ b/app/views/projects/merge_requests/_approvals_count.html.haml @@ -6,7 +6,7 @@ - final_text = n_("%d approver", "%d approvers", total) % total - final_self_text = n_("%d approver (you've approved)", "%d approvers (you've approved)", total) % total - - approval_icon = sprite_icon((self_approved ? 'approval-solid' : 'approval'), size: 16, css_class: 'align-middle') + - approval_icon = sprite_icon((self_approved ? 'approval-solid' : 'approval'), css_class: 'align-middle') %li.d-none.d-sm-inline-block.has-tooltip.text-success{ title: self_approved ? final_self_text : final_text } = approval_icon diff --git a/app/views/projects/merge_requests/_awards_block.html.haml b/app/views/projects/merge_requests/_awards_block.html.haml index e4a7b9b7e62..e7577e13b68 100644 --- a/app/views/projects/merge_requests/_awards_block.html.haml +++ b/app/views/projects/merge_requests/_awards_block.html.haml @@ -1,6 +1,5 @@ .content-block.content-block-small.emoji-list-container.js-noteable-awards = render 'award_emoji/awards_block', awardable: @merge_request, inline: true do - - if mr_tabs_position_enabled? - .ml-auto.mt-auto.mb-auto - #js-vue-sort-issue-discussions - = render "projects/merge_requests/discussion_filter" + .ml-auto.mt-auto.mb-auto + #js-vue-sort-issue-discussions + = render "projects/merge_requests/discussion_filter" diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml index b414518b597..178e57b08b3 100644 --- a/app/views/projects/merge_requests/_commits.html.haml +++ b/app/views/projects/merge_requests/_commits.html.haml @@ -1,8 +1,18 @@ -- if @commits.empty? - .commits-empty - %h4 - There are no commits yet. +- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) + +- if @commits.empty? && @context_commits.empty? + .commits-empty.mt-5 = custom_icon ('illustration_no_commits') + %h4 + = _('There are no commits yet.') + - if @project&.context_commits_enabled? && can_update_merge_request + %p + = _('Push commits to the source branch or add previously merged commits to review them.') + %button.btn.btn-primary.add-review-item-modal-trigger{ type: "button", data: { commits_empty: 'true', context_commits_empty: 'true' } } + = _('Add previously merged commits') - else %ol#commits-list.list-unstyled = render "projects/commits/commits", merge_request: @merge_request + +- if @project&.context_commits_enabled? && can_update_merge_request && @merge_request.iid + .add-review-item-modal-wrapper{ data: { context_commits_path: context_commits_project_json_merge_request_url(@merge_request&.project, @merge_request, :json), target_branch: @merge_request.target_branch, merge_request_iid: @merge_request.iid, project_id: @merge_request.project.id } } diff --git a/app/views/projects/merge_requests/_form.html.haml b/app/views/projects/merge_requests/_form.html.haml index a7c9e54506d..a68a4318538 100644 --- a/app/views/projects/merge_requests/_form.html.haml +++ b/app/views/projects/merge_requests/_form.html.haml @@ -1,3 +1,3 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], += form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request, presenter: @mr_presenter diff --git a/app/views/projects/merge_requests/_how_to_merge.html.haml b/app/views/projects/merge_requests/_how_to_merge.html.haml index a2da0e707d3..df81e608c3e 100644 --- a/app/views/projects/merge_requests/_how_to_merge.html.haml +++ b/app/views/projects/merge_requests/_how_to_merge.html.haml @@ -32,12 +32,12 @@ - if @merge_request.for_fork? :preserve git fetch origin - git checkout "origin/#{h @merge_request.target_branch}" + git checkout "#{h @merge_request.target_branch}" git merge --no-ff "#{h @merge_request.source_project_path}-#{h @merge_request.source_branch}" - else :preserve git fetch origin - git checkout "origin/#{h @merge_request.target_branch}" + git checkout "#{h @merge_request.target_branch}" git merge --no-ff "#{h @merge_request.source_branch}" %p %strong Step 4. diff --git a/app/views/projects/merge_requests/_merge_request.html.haml b/app/views/projects/merge_requests/_merge_request.html.haml index d3e98bac7f9..ad0f4d03f9a 100644 --- a/app/views/projects/merge_requests/_merge_request.html.haml +++ b/app/views/projects/merge_requests/_merge_request.html.haml @@ -25,7 +25,7 @@ %span.issuable-milestone.d-none.d-sm-inline-block = link_to project_merge_requests_path(merge_request.project, milestone_title: merge_request.milestone.title), data: { html: 'true', toggle: 'tooltip', title: milestone_tooltip_due_date(merge_request.milestone) } do - = icon('clock-o') + = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') = merge_request.milestone.title - if merge_request.target_project.default_branch != merge_request.target_branch %span.project-ref-path.has-tooltip{ title: _('Target branch') } @@ -45,13 +45,13 @@ = _('MERGED') - elsif merge_request.closed? %li.issuable-status.d-none.d-sm-inline-block - = icon('ban') + = sprite_icon('cancel', css_class: 'gl-vertical-align-text-bottom') = _('CLOSED') = render 'shared/merge_request_pipeline_status', merge_request: merge_request - if merge_request.open? && merge_request.broken? %li.issuable-pipeline-broken.d-none.d-sm-flex = link_to merge_request_path(merge_request), class: "has-tooltip", title: _('Cannot be merged automatically') do - = icon('exclamation-triangle') + = sprite_icon('warning-solid') - if merge_request.assignees.any? %li.d-flex = render 'shared/issuable/assignees', project: merge_request.project, issuable: merge_request diff --git a/app/views/projects/merge_requests/_mr_box.html.haml b/app/views/projects/merge_requests/_mr_box.html.haml index ec78b040167..c38cf62b36c 100644 --- a/app/views/projects/merge_requests/_mr_box.html.haml +++ b/app/views/projects/merge_requests/_mr_box.html.haml @@ -1,6 +1,3 @@ -.detail-page-description{ class: ("py-2" if mr_tabs_position_enabled?) } - %h2.title.qa-title{ class: ("mb-0" if mr_tabs_position_enabled?) } +.detail-page-description.py-2 + %h2.title.qa-title.mb-0 = markdown_field(@merge_request, :title) - - - unless mr_tabs_position_enabled? - = render "projects/merge_requests/description" diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 72931448432..8aa4a935384 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -7,16 +7,15 @@ .alert.alert-danger The source project of this merge request has been removed. -.detail-page-header{ class: ("border-bottom-0 pt-0 pb-0" if mr_tabs_position_enabled?) } +.detail-page-header.border-bottom-0.pt-0.pb-0 .detail-page-header-body .issuable-status-box.status-box{ class: status_box_class(@merge_request) } - = sprite_icon(state_icon_name, size: 16, css_class: 'd-block d-sm-none') + = sprite_icon(state_icon_name, css_class: 'd-block d-sm-none') %span.d-none.d-sm-block = state_human_name .issuable-meta - - if @merge_request.discussion_locked? - .issuable-warning-icon.inline= sprite_icon('lock', size: 16, css_class: 'icon') + #js-issuable-header-warnings = issuable_meta(@merge_request, @project, "Merge request") %a.btn.btn-default.float-right.d-block.d-sm-none.gutter-toggle.issuable-gutter-toggle.js-sidebar-toggle{ href: "#" } diff --git a/app/views/projects/merge_requests/_widget.html.haml b/app/views/projects/merge_requests/_widget.html.haml index 16b08cbf648..64b14f8889c 100644 --- a/app/views/projects/merge_requests/_widget.html.haml +++ b/app/views/projects/merge_requests/_widget.html.haml @@ -1,6 +1,3 @@ -- if @merge_request.source_branch_exists? - = render "projects/merge_requests/how_to_merge" - = javascript_tag nonce: true do :plain window.gl = window.gl || {}; diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index 99537ba8152..874adb19734 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -1,7 +1,7 @@ %h3.page-title New Merge Request -= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| += form_for [@project, @merge_request], url: project_new_merge_request_path(@project), method: :get, html: { class: "merge-request-form js-requires-input" } do |f| - if params[:nav_source].present? = hidden_field_tag(:nav_source, params[:nav_source]) .hide.alert.alert-danger.mr-compare-errors diff --git a/app/views/projects/merge_requests/creations/_new_submit.html.haml b/app/views/projects/merge_requests/creations/_new_submit.html.haml index fdf0bfe8e50..79781e4a311 100644 --- a/app/views/projects/merge_requests/creations/_new_submit.html.haml +++ b/app/views/projects/merge_requests/creations/_new_submit.html.haml @@ -1,6 +1,6 @@ %h3.page-title New Merge Request -= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| += form_for [@project, @merge_request], html: { class: 'merge-request-form common-note-form js-requires-input js-quick-submit' } do |f| = render 'shared/issuable/form', f: f, issuable: @merge_request, commits: @commits, presenter: @mr_presenter = f.hidden_field :source_project_id = f.hidden_field :source_branch diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml index efc052ca791..c022d2c70d8 100644 --- a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml +++ b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml @@ -2,8 +2,10 @@ WARNING: Please keep changes up-to-date with the following files: - `assets/javascripts/diffs/components/commit_widget.vue` -#----------------------------------------------------------------- +- collapsible = local_assigns.fetch(:collapsible, true) + - if @commit - .info-well.d-none.d-sm-block.gl-mt-3 + .info-well.mw-100.mx-0 .well-segment %ul.blob-commit-info - = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true + = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true, collapsible: collapsible diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 03fa9758587..746d613934c 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -12,22 +12,17 @@ .merge-request{ data: { mr_action: mr_action, url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project), lock_version: @merge_request.lock_version } } = render "projects/merge_requests/mr_title" + - if @merge_request.source_branch_exists? + = render "projects/merge_requests/how_to_merge" + .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } = render "projects/merge_requests/mr_box" - - - unless mr_tabs_position_enabled? - = render "projects/merge_requests/widget" - = render "projects/merge_requests/awards_block" - .merge-request-tabs-holder{ class: ("js-tabs-affix" unless ENV['RAILS_ENV'] == 'test') } .merge-request-tabs-container %ul.merge-request-tabs.nav-tabs.nav.nav-links = render "projects/merge_requests/tabs/tab", class: "notes-tab", qa_selector: "notes_tab" do = tab_link_for @merge_request, :show, force_link: @commit.present? do - - if mr_tabs_position_enabled? - = _("Overview") - - else - = _("Discussion") + = _("Overview") %span.badge.badge-pill= @merge_request.related_notes.user.count - if @merge_request.source_project = render "projects/merge_requests/tabs/tab", name: "commits", class: "commits-tab" do @@ -43,11 +38,7 @@ = tab_link_for @merge_request, :diffs do = _("Changes") %span.badge.badge-pill= @merge_request.diff_size - - if mr_tabs_position_enabled? && show_tabs_feature_highlight? - .js-tabs-feature-highlight{ data: { dismiss_endpoint: user_callouts_path, feature_id: UserCalloutsHelper::TABS_POSITION_HIGHLIGHT } } .d-flex.flex-wrap.align-items-center.justify-content-lg-end - - unless mr_tabs_position_enabled? - = render "projects/merge_requests/discussion_filter" #js-vue-discussion-counter .tab-content#diff-notes-app @@ -59,18 +50,18 @@ %section.col-md-12 %script.js-notes-data{ type: "application/json" }= initial_notes_data(true).to_json.html_safe .issuable-discussion.js-vue-notes-event - - if mr_tabs_position_enabled? - - if @merge_request.description.present? - .detail-page-description - = render "projects/merge_requests/description" - = render "projects/merge_requests/widget" - = render "projects/merge_requests/awards_block" + - if @merge_request.description.present? + .detail-page-description + = render "projects/merge_requests/description" + = render "projects/merge_requests/widget" + = render "projects/merge_requests/awards_block" #js-vue-mr-discussions{ data: { notes_data: notes_data(@merge_request).to_json, noteable_data: serialize_issuable(@merge_request, serializer: 'noteable'), noteable_type: 'MergeRequest', target_type: 'merge_request', help_page_path: suggest_changes_help_path, - current_user_data: @current_user_data} } + current_user_data: @current_user_data, + is_locked: @merge_request.discussion_locked.to_s } } = render "projects/merge_requests/tabs/pane", name: "commits", id: "commits", class: "commits" do -# This tab is always loaded via AJAX diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index eeff91f631c..907af326ec5 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @milestone], += form_for [@project, @milestone], html: { class: 'milestone-form common-note-form js-quick-submit js-requires-input' } do |f| = form_errors(@milestone) .row diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index 5239af82ba6..99e626161c4 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -8,7 +8,7 @@ = render_if_exists 'shared/milestones/burndown', milestone: @milestone, project: @project -- if can?(current_user, :read_issue, @project) && @milestone.total_issues_count.zero? +- if can?(current_user, :read_issue, @project) && @milestone.total_issues_count == 0 .alert.alert-success.gl-mt-3 %span= _('Assign some issues to this milestone.') - elsif @milestone.complete? && @milestone.active? diff --git a/app/views/projects/mirrors/_instructions.html.haml b/app/views/projects/mirrors/_instructions.html.haml index 15c9076c1ab..97b04acea31 100644 --- a/app/views/projects/mirrors/_instructions.html.haml +++ b/app/views/projects/mirrors/_instructions.html.haml @@ -1,10 +1,10 @@ .account-well.gl-mt-3.gl-mb-3 %ul %li - = _('The repository must be accessible over <code>http://</code>, - <code>https://</code>, <code>ssh://</code> or <code>git://</code>.').html_safe - %li= _('When using the <code>http://</code> or <code>https://</code> protocols, please provide the exact URL to the repository. HTTP redirects will not be followed.').html_safe - %li= _('Include the username in the URL if required: <code>https://username@gitlab.company.com/group/project.git</code>.').html_safe + = html_escape(_('The repository must be accessible over %{code_open}http://%{code_close}, + %{code_open}https://%{code_close}, %{code_open}ssh://%{code_close} or %{code_open}git://%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li= html_escape(_('When using the %{code_open}http://%{code_close} or %{code_open}https://%{code_close} protocols, please provide the exact URL to the repository. HTTP redirects will not be followed.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li= html_escape(_('Include the username in the URL if required: %{code_open}https://username@gitlab.company.com/group/project.git%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li - minutes = Gitlab.config.gitlab_shell.git_timeout / 60 = _("The update action will time out after %{number_of_minutes} minutes. For big repositories, use a clone/push combination.") % { number_of_minutes: minutes } diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 38e4fbf73e0..2f55cce70dc 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -3,7 +3,7 @@ - mirror_settings_enabled = can?(current_user, :admin_remote_mirror, @project) - mirror_settings_class = "#{'expanded' if expanded} #{'js-mirror-settings' if mirror_settings_enabled}".strip -%section.settings.project-mirror-settings.no-animate#js-push-remote-settings{ class: mirror_settings_class, data: { qa_selector: 'mirroring_repositories_settings_section' } } +%section.settings.project-mirror-settings.no-animate#js-push-remote-settings{ class: mirror_settings_class, data: { qa_selector: 'mirroring_repositories_settings_content' } } .settings-header %h4= _('Mirroring repositories') %button.btn.js-settings-toggle @@ -27,16 +27,16 @@ = render 'projects/mirrors/mirror_repos_form', f: f - .form-check.append-bottom-10 + .form-check.gl-mb-3 = check_box_tag :only_protected_branches, '1', false, class: 'js-mirror-protected form-check-input' = label_tag :only_protected_branches, _('Only mirror protected branches'), class: 'form-check-label' = link_to icon('question-circle'), help_page_path('user/project/protected_branches'), target: '_blank' - .panel-footer + .panel-footer.gl-display-flex.gl-justify-content-end = f.submit _('Mirror repository'), class: 'btn btn-success js-mirror-submit qa-mirror-repository-button', name: :update_remote_mirror - else .gl-alert.gl-alert-info{ role: 'alert' } - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body = _('Mirror settings are only available to GitLab administrators.') @@ -70,9 +70,8 @@ .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error') %td - if mirror_settings_enabled - .btn-group.mirror-actions-group.pull-right{ role: 'group' } + .btn-group.mirror-actions-group.float-right{ role: 'group' } - if mirror.ssh_key_auth? = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'), qa_selector: 'copy_public_key_button') = render 'shared/remote_mirror_update_button', remote_mirror: mirror %button.js-delete-mirror.qa-delete-mirror.rspec-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o') - diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index 9b5b31bfc15..39ceaedab61 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -7,7 +7,7 @@ = rm_f.hidden_field :keep_divergent_refs, class: 'js-mirror-keep-divergent-refs-hidden' = render partial: 'projects/mirrors/ssh_host_keys', locals: { f: rm_f } = render partial: 'projects/mirrors/authentication_method', locals: { f: rm_f } - .form-check.append-bottom-10 + .form-check.gl-mb-3 = check_box_tag :keep_divergent_refs, '1', false, class: 'js-mirror-keep-divergent-refs form-check-input' = label_tag :keep_divergent_refs, _('Keep divergent refs'), class: 'form-check-label' = link_to icon('question-circle'), help_page_path('user/project/repository/repository_mirroring', anchor: 'keep-divergent-refs-core'), target: '_blank' diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index 236ede32d31..786918c4970 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -6,7 +6,7 @@ %button.btn.btn-inverted.btn-secondary.inline.js-detect-host-keys.gl-mr-3{ type: 'button', data: { qa_selector: 'detect_host_keys' } } .js-spinner.d-none.spinner.mr-1 = _('Detect host keys') - .fingerprint-ssh-info.js-fingerprint-ssh-info.prepend-top-10.append-bottom-10{ class: ('collapse' unless mirror.ssh_mirror_url?) } + .fingerprint-ssh-info.js-fingerprint-ssh-info.gl-mt-3.gl-mb-3{ class: ('collapse' unless mirror.ssh_mirror_url?) } %label.label-bold = _('Fingerprints') .fingerprints-list.js-fingerprints-list{ data: { qa_selector: 'fingerprints_list' } } diff --git a/app/views/projects/no_repo.html.haml b/app/views/projects/no_repo.html.haml index d5030a02cdd..0ab9d9c4005 100644 --- a/app/views/projects/no_repo.html.haml +++ b/app/views/projects/no_repo.html.haml @@ -22,4 +22,4 @@ - if can? current_user, :remove_project, @project .prepend-top-20 - = link_to _('Remove project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" + = link_to _('Delete project'), project_path(@project), data: { confirm: remove_project_message(@project)}, method: :delete, class: "btn btn-inverted btn-remove float-right" diff --git a/app/views/projects/notes/_actions.html.haml b/app/views/projects/notes/_actions.html.haml index d725098752d..66721a28e62 100644 --- a/app/views/projects/notes/_actions.html.haml +++ b/app/views/projects/notes/_actions.html.haml @@ -1,7 +1,7 @@ - access = note_max_access_for_user(note) - if note.has_special_role?(Note::SpecialRole::FIRST_TIME_CONTRIBUTOR) %span.note-role.note-role-special.has-tooltip{ title: _("This is the author's first Merge Request to this project.") } - = issuable_first_contribution_icon + = sprite_icon('first-contribution', css_class: 'gl-icon gl-vertical-align-top') - if access.nonzero? %span.note-role.user-access-role= Gitlab::Access.human_access(access) diff --git a/app/views/projects/packages/packages/_legacy_package_list.html.haml b/app/views/projects/packages/packages/_legacy_package_list.html.haml new file mode 100644 index 00000000000..43dbb5c3eee --- /dev/null +++ b/app/views/projects/packages/packages/_legacy_package_list.html.haml @@ -0,0 +1,60 @@ +- sort_value = @sort +- sort_title = packages_sort_option_title(sort_value) + +- if @packages.any? + .d-flex.justify-content-end + .dropdown.inline.gl-mt-3.gl-mb-3.package-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-menu-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(sort_title_created_date, package_sort_path(sort: sort_value_recently_created), sort_title) + = sortable_item(sort_title_name, package_sort_path(sort: sort_value_name_desc), sort_title) + = sortable_item(sort_title_version, package_sort_path(sort: sort_value_version_desc), sort_title) + = sortable_item(sort_title_type, package_sort_path(sort: sort_value_type_desc), sort_title) + = packages_sort_direction_button(sort_value) + + .table-holder + .gl-responsive-table-row.table-row-header.bg-secondary-50.px-2.border-top{ role: 'row' } + .table-section.section-30{ role: 'rowheader' } + = _('Name') + .table-section.section-20{ role: 'rowheader' } + = _('Version') + .table-section.section-20{ role: 'rowheader' } + = _('Type') + .table-section.section-20{ role: 'rowheader' } + = _('Created') + .table-section.section-10{ role: 'rowheader' } + - @packages.each do |package| + .gl-responsive-table-row.package-row.px-2{ data: { qa_selector: "package_row" } } + .table-section.section-30 + .table-mobile-header{ role: "rowheader" }= _("Name") + .table-mobile-content.flex-truncate-parent + = link_to package.name, project_package_path(@project, package), class: 'flex-truncate-child', data: { qa_selector: "package_link" } + .table-section.section-20 + .table-mobile-header{ role: "rowheader" }= _("Version") + .table-mobile-content + = package.version + .table-section.section-20 + .table-mobile-header{ role: "rowheader" }= _("Type") + .table-mobile-content + = package.package_type + .table-section.section-20 + .table-mobile-header{ role: "rowheader" }= _("Created") + .table-mobile-content + = time_ago_with_tooltip(package.created_at) + .table-section.section-10 + .table-mobile-header{ role: "rowheader" } + .table-mobile-content + - if can_destroy_package + .float-right + = link_to project_package_path(@project, package), method: :delete, data: { confirm: _("Are you sure?") }, class: "btn btn-grouped btn-remove", title: _('Delete Package') do + = icon('trash') + = paginate @packages, theme: "gitlab" +- else + .row.empty-state + .col-12 + = render 'shared/packages/no_packages' diff --git a/app/views/projects/packages/packages/index.html.haml b/app/views/projects/packages/packages/index.html.haml new file mode 100644 index 00000000000..c81326f3760 --- /dev/null +++ b/app/views/projects/packages/packages/index.html.haml @@ -0,0 +1,5 @@ +- page_title _("Packages") + +.row + .col-12 + #js-vue-packages-list{ data: packages_list_data('projects', @project) } diff --git a/app/views/projects/packages/packages/show.html.haml b/app/views/projects/packages/packages/show.html.haml new file mode 100644 index 00000000000..a66ae466d9d --- /dev/null +++ b/app/views/projects/packages/packages/show.html.haml @@ -0,0 +1,25 @@ +- add_to_breadcrumbs _("Packages"), project_packages_path(@project) +- add_to_breadcrumbs @package.name, project_packages_path(@project) +- breadcrumb_title @package.version +- page_title _("Packages") + +.row + .col-12 + #js-vue-packages-detail{ data: { package: package_from_presenter(@package), + can_delete: can?(current_user, :destroy_package, @project).to_s, + destroy_path: project_package_path(@project, @package), + svg_path: image_path('illustrations/no-packages.svg'), + npm_path: package_registry_instance_url(:npm), + npm_help_path: help_page_path('user/packages/npm_registry/index'), + maven_path: package_registry_project_url(@project.id, :maven), + maven_help_path: help_page_path('user/packages/maven_repository/index'), + conan_path: package_registry_instance_url(:conan), + conan_help_path: help_page_path('user/packages/conan_repository/index'), + nuget_path: nuget_package_registry_url(@project.id), + nuget_help_path: help_page_path('user/packages/nuget_repository/index'), + pypi_path: pypi_registry_url(@project.id), + pypi_setup_path: package_registry_project_url(@project.id, :pypi), + pypi_help_path: help_page_path('user/packages/pypi_repository/index'), + composer_path: composer_registry_url(@project&.group&.id), + composer_help_path: help_page_path('user/packages/composer_repository/index'), + project_name: @project.name} } diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 08dcba2afd7..63dd7ca1def 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -18,6 +18,6 @@ - help_page = help_page_path('/user/project/pages/pages_access_control') - link_start = '<a href="%{url}" target="_blank" class="alert-link" rel="noopener noreferrer">'.html_safe % { url: help_page } - link_end = '</a>'.html_safe - = s_('GitLabPages|Access Control is enabled for this Pages website; only authorized users will be able to access it. To make your website publicly available, navigate to your project\'s %{strong_start}Settings > General > Visibility%{strong_end} and select %{strong_start}Everyone%{strong_end} in pages section. Read the %{link_start}documentation%{link_end} for more information.').html_safe % { link_start: link_start, link_end: link_end, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = html_escape_once(s_('GitLabPages|Access Control is enabled for this Pages website; only authorized users will be able to access it. To make your website publicly available, navigate to your project\'s %{strong_start}Settings > General > Visibility%{strong_end} and select %{strong_start}Everyone%{strong_end} in pages section. Read the %{link_start}documentation%{link_end} for more information.')).html_safe % { link_start: link_start, link_end: link_end, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } .card-footer.alert-primary = s_('GitLabPages|It may take up to 30 minutes before the site is available after the first deployment.') diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml index c116efe521a..af6de10b2a0 100644 --- a/app/views/projects/pages/_list.html.haml +++ b/app/views/projects/pages/_list.html.haml @@ -10,7 +10,7 @@ - if verification_enabled - tooltip, status = domain.unverified? ? [s_('GitLabPages|Unverified'), 'failed'] : [s_('GitLabPages|Verified'), 'success'] .domain-status.ci-status-icon.has-tooltip{ class: "ci-status-icon-#{status}", title: tooltip } - = sprite_icon("status_#{status}", size: 16 ) + = sprite_icon("status_#{status}" ) .domain-name = external_link(domain.url, domain.url) - if domain.certificate diff --git a/app/views/projects/pages/_pages_settings.html.haml b/app/views/projects/pages/_pages_settings.html.haml index 58eddf630f4..8aa02074205 100644 --- a/app/views/projects/pages/_pages_settings.html.haml +++ b/app/views/projects/pages/_pages_settings.html.haml @@ -1,4 +1,4 @@ -= form_for @project, url: namespace_project_pages_path(@project.namespace.becomes(Namespace), @project), html: { class: 'inline', title: pages_https_only_title } do |f| += form_for @project, url: project_pages_path(@project), html: { class: 'inline', title: pages_https_only_title } do |f| - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https = render_if_exists 'shared/pages/max_pages_size_input', form: f @@ -9,5 +9,5 @@ %strong = s_('GitLabPages|Force HTTPS (requires valid certificates)') - .prepend-top-10 + .gl-mt-3 = f.submit s_('GitLabPages|Save'), class: 'btn btn-success' diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml index fc69b390bde..8a01945ffac 100644 --- a/app/views/projects/pages/show.html.haml +++ b/app/views/projects/pages/show.html.haml @@ -25,4 +25,4 @@ = render 'destroy' - else .bs-callout.bs-callout-warning - = s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings > General > Visibility%{strong_end} page.').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = html_escape_once(s_('GitLabPages|GitLab Pages are disabled for this project. You can enable them on your project\'s %{strong_start}Settings > General > Visibility%{strong_end} page.')).html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml index 11c7e4a950b..16d949c416b 100644 --- a/app/views/projects/pages_domains/_certificate.html.haml +++ b/app/views/projects/pages_domains/_certificate.html.haml @@ -19,8 +19,8 @@ "aria-label": _("Automatic certificate management using Let's Encrypt") } = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" %span.toggle-icon - = sprite_icon("status_success_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-checked") - = sprite_icon("status_failed_borderless", size: 16, css_class: "toggle-icon-svg toggle-status-unchecked") + = sprite_icon("status_success_borderless", size: 18, css_class: "gl-text-blue-500 toggle-status-checked") + = sprite_icon("status_failed_borderless", size: 18, css_class: "gl-text-gray-400 toggle-status-unchecked") %p.text-secondary.mt-3 - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml index f5dc3ccc60e..0c3ab4f10a6 100644 --- a/app/views/projects/pages_domains/new.html.haml +++ b/app/views/projects/pages_domains/new.html.haml @@ -4,7 +4,7 @@ = _("New Pages Domain") = render 'projects/pages_domains/helper_text' %div - = form_for [@project.namespace.becomes(Namespace), @project, domain_presenter], html: { class: 'fieldset-form' } do |f| + = form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } .form-actions = f.submit _('Create New Domain'), class: "btn btn-success" diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index e1be7335a3f..20ecf948447 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -14,7 +14,7 @@ = _('Pages Domain') = render 'projects/pages_domains/helper_text' %div - = form_for [@project.namespace.becomes(Namespace), @project, domain_presenter], html: { class: 'fieldset-form' } do |f| + = form_for [@project, domain_presenter], html: { class: 'fieldset-form' } do |f| = render 'form', { f: f } .form-actions.d-flex.justify-content-between = f.submit _('Save Changes'), class: "btn btn-success" diff --git a/app/views/projects/pipeline_schedules/_form.html.haml b/app/views/projects/pipeline_schedules/_form.html.haml index 20cf2ed63b5..1a8229350d9 100644 --- a/app/views/projects/pipeline_schedules/_form.html.haml +++ b/app/views/projects/pipeline_schedules/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f| += form_for [@project, @schedule], as: :schedule, html: { id: "new-pipeline-schedule-form", class: "js-pipeline-schedule-form pipeline-schedule-form" } do |f| = form_errors(@schedule) .form-group.row .col-md-9 @@ -27,7 +27,7 @@ = render 'ci/variables/variable_row', form_field: 'schedule', variable: variable, only_key_value: true = render 'ci/variables/variable_row', form_field: 'schedule', only_key_value: true - if @schedule.variables.size > 0 - %button.btn.btn-info.btn-inverted.prepend-top-10.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } } + %button.btn.btn-info.btn-inverted.gl-mt-3.js-secret-value-reveal-button{ type: 'button', data: { secret_reveal_status: "#{@schedule.variables.size == 0}" } } - if @schedule.variables.size == 0 = n_('Hide value', 'Hide values', @schedule.variables.size) - else diff --git a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml index f48763cb544..ca71aa8a24d 100644 --- a/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml +++ b/app/views/projects/pipeline_schedules/_pipeline_schedule.html.haml @@ -33,8 +33,8 @@ = link_to take_ownership_pipeline_schedule_path(pipeline_schedule), method: :post, title: s_('PipelineSchedules|Take ownership'), class: 'btn' do = s_('PipelineSchedules|Take ownership') - if can?(current_user, :update_pipeline_schedule, pipeline_schedule) - = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn' do - = icon('pencil') + = link_to edit_pipeline_schedule_path(pipeline_schedule), title: _('Edit'), class: 'btn gl-display-flex' do + = sprite_icon('pencil') - if can?(current_user, :admin_pipeline_schedule, pipeline_schedule) = link_to pipeline_schedule_path(pipeline_schedule), title: _('Delete'), method: :delete, class: 'btn btn-remove', data: { confirm: _("Are you sure you want to delete this pipeline schedule?") } do - = icon('trash') + = sprite_icon('remove') diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 85902d51ab0..c54a19b8f61 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -7,8 +7,8 @@ .info-well .well-segment.pipeline-info - .icon-container - = icon('clock-o') + .icon-container.gl-vertical-align-text-bottom + = sprite_icon('clock') = pluralize @pipeline.total_size, "job" = @pipeline.ref_text - if @pipeline.duration @@ -35,7 +35,7 @@ %span.js-pipeline-url-failure.badge.badge-danger.has-tooltip{ title: @pipeline.failure_reason } error - if @pipeline.auto_devops_source? - - popover_title_text = _('This pipeline makes use of a predefined CI/CD configuration enabled by <b>Auto DevOps.</b>').html_safe + - popover_title_text = html_escape(_('This pipeline makes use of a predefined CI/CD configuration enabled by %{b_open}Auto DevOps.%{b_close}')) % { b_open: '<b>'.html_safe, b_close: '</b>'.html_safe } - popover_content_url = help_page_path('topics/autodevops/index.md') - popover_content_text = _('Learn more about Auto DevOps') %a.js-pipeline-url-autodevops.badge.badge-info.autodevops-badge{ href: "#", tabindex: "0", role: "button", data: { container: "body", diff --git a/app/views/projects/pipelines/_pipeline_warnings.html.haml b/app/views/projects/pipelines/_pipeline_warnings.html.haml new file mode 100644 index 00000000000..e27bd440462 --- /dev/null +++ b/app/views/projects/pipelines/_pipeline_warnings.html.haml @@ -0,0 +1,6 @@ +- if warnings.any? + - warnings.map(&:content).each do |warning| + .bs-callout.bs-callout-warning + %p + %b= _("Warning:") + = markdown(warning) diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index be947b42e25..4ae06e1e16f 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,4 +1,4 @@ -- test_reports_enabled = Feature.enabled?(:junit_pipeline_view, @project) +- return if pipeline_has_errors - dag_pipeline_tab_enabled = Feature.enabled?(:dag_pipeline_tab, @project, default_enabled: true) .tabs-holder @@ -10,7 +10,6 @@ %li.js-dag-tab-link = link_to dag_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-dag', action: 'dag', toggle: 'tab' }, class: 'dag-tab' do = _('DAG') - %span.badge-pill.gl-badge.sm.gl-bg-blue-500.gl-text-white.gl-ml-2= _('Beta') %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do = _('Jobs') @@ -20,11 +19,10 @@ = link_to failures_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-failures', action: 'failures', toggle: 'tab' }, class: 'failures-tab' do = _('Failed Jobs') %span.badge.badge-pill.js-failures-counter= @pipeline.failed_builds.count - - if test_reports_enabled - %li.js-tests-tab-link - = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do - = s_('TestReports|Tests') - %span.badge.badge-pill.js-test-report-badge-counter= Feature.enabled?(:build_report_summary, @project) ? @pipeline.test_report_summary.total_count : '' + %li.js-tests-tab-link + = link_to test_report_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-tests', action: 'test_report', toggle: 'tab' }, class: 'test-tab' do + = s_('TestReports|Tests') + %span.badge.badge-pill.js-test-report-badge-counter= @pipeline.test_report_summary.total[:count] = render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project .tab-content @@ -71,8 +69,8 @@ = build.present.callout_failure_message %td.responsive-table-cell.build-actions - if can?(current_user, :update_build, job) - = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build' do - = icon('repeat') + = link_to retry_project_job_path(build.project, build, return_to: request.original_url), method: :post, title: _('Retry'), class: 'btn btn-build gl-button btn-icon btn-default' do + = sprite_icon('repeat', css_class: 'gl-icon') - if can?(current_user, :read_build, job) %tr.build-trace-row.responsive-table-border-end %td @@ -83,10 +81,9 @@ - if dag_pipeline_tab_enabled #js-tab-dag.tab-pane - #js-pipeline-dag-vue{ data: { pipeline_data_path: dag_project_pipeline_path(@project, @pipeline), empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } + #js-pipeline-dag-vue{ data: { pipeline_project_path: @project.full_path, pipeline_iid: @pipeline.iid, empty_svg_path: image_path('illustrations/empty-state/empty-dag-md.svg'), dag_doc_path: help_page_path('ci/yaml/README.md', anchor: 'needs')} } #js-tab-tests.tab-pane - #js-pipeline-tests-detail{ data: { full_report_endpoint: test_report_project_pipeline_path(@project, @pipeline, format: :json), - summary_endpoint: Feature.enabled?(:build_report_summary, @project) ? summary_project_pipeline_tests_path(@project, @pipeline, format: :json) : '', - count_endpoint: test_reports_count_project_pipeline_path(@project, @pipeline, format: :json) } } + #js-pipeline-tests-detail{ data: { summary_endpoint: summary_project_pipeline_tests_path(@project, @pipeline, format: :json), + suite_endpoint: project_pipeline_test_path(@project, @pipeline, suite_name: ':suite_name', format: :json) } } = render_if_exists "projects/pipelines/tabs_content", pipeline: @pipeline, project: @project diff --git a/app/views/projects/pipelines/new.html.haml b/app/views/projects/pipelines/new.html.haml index a3e46a0939c..726bf9af223 100644 --- a/app/views/projects/pipelines/new.html.haml +++ b/app/views/projects/pipelines/new.html.haml @@ -6,37 +6,42 @@ = s_('Pipeline|Run Pipeline') %hr -= form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f| - = form_errors(@pipeline) - .form-group.row - .col-sm-12 - = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label' - = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch - = dropdown_tag(params[:ref] || @project.default_branch, - options: { toggle_class: 'js-branch-select wide monospace', - filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), - data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) - .form-text.text-muted - = s_("Pipeline|Existing branch name or tag") +- if Feature.enabled?(:new_pipeline_form) + #js-new-pipeline{ data: { project_id: @project.id, pipelines_path: project_pipelines_path(@project), ref_param: params[:ref] || @project.default_branch, var_param: params[:var].to_json, file_param: params[:file_var].to_json, ref_names: @project.repository.ref_names.to_json.html_safe, settings_link: project_settings_ci_cd_path(@project) } } - .col-sm-12.prepend-top-10.js-ci-variable-list-section - %label - = s_('Pipeline|Variables') - %ul.ci-variable-list - - if params[:var] - - params[:var].each do |variable| - = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable - - if params[:file_var] - - params[:file_var].each do |variable| - - variable.push("file") - = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable - = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true - .form-text.text-muted - = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe +- else + = form_for @pipeline, as: :pipeline, url: project_pipelines_path(@project), html: { id: "new-pipeline-form", class: "js-new-pipeline-form js-requires-input" } do |f| + = form_errors(@pipeline) + = pipeline_warnings(@pipeline) + .form-group.row + .col-sm-12 + = f.label :ref, s_('Pipeline|Run for'), class: 'col-form-label' + = hidden_field_tag 'pipeline[ref]', params[:ref] || @project.default_branch + = dropdown_tag(params[:ref] || @project.default_branch, + options: { toggle_class: 'js-branch-select wide monospace', + filter: true, dropdown_class: "dropdown-menu-selectable git-revision-dropdown", placeholder: s_("Pipeline|Search branches"), + data: { selected: params[:ref] || @project.default_branch, field_name: 'pipeline[ref]' } }) + .form-text.text-muted + = s_("Pipeline|Existing branch name or tag") - .form-actions - = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 - = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right' + .col-sm-12.gl-mt-3.js-ci-variable-list-section + %label + = s_('Pipeline|Variables') + %ul.ci-variable-list + - if params[:var] + - params[:var].each do |variable| + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable + - if params[:file_var] + - params[:file_var].each do |variable| + - variable.push("file") + = render 'ci/variables/url_query_variable_row', form_field: 'pipeline', variable: variable + = render 'ci/variables/variable_row', form_field: 'pipeline', only_key_value: true + .form-text.text-muted + = (s_("Pipeline|Specify variable values to be used in this run. The values specified in %{settings_link} will be used by default.") % {settings_link: settings_link}).html_safe --# haml-lint:disable InlineJavaScript -%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe + .form-actions + = f.submit s_('Pipeline|Run Pipeline'), class: 'btn btn-success js-variables-save-button', tabindex: 3 + = link_to _('Cancel'), project_pipelines_path(@project), class: 'btn btn-default float-right' + + -# haml-lint:disable InlineJavaScript + %script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe diff --git a/app/views/projects/pipelines/show.html.haml b/app/views/projects/pipelines/show.html.haml index 2b2133b8296..e1a606b1765 100644 --- a/app/views/projects/pipelines/show.html.haml +++ b/app/views/projects/pipelines/show.html.haml @@ -1,6 +1,7 @@ - add_to_breadcrumbs _('Pipelines'), project_pipelines_path(@project) - breadcrumb_title "##{@pipeline.id}" - page_title _('Pipeline') +- pipeline_has_errors = @pipeline.builds.empty? && @pipeline.yaml_errors.present? .js-pipeline-container{ data: { controller_action: "#{controller.action_name}" } } #js-pipeline-header-vue.pipeline-header-container @@ -8,7 +9,7 @@ - if @pipeline.commit.present? = render "projects/pipelines/info", commit: @pipeline.commit - - if @pipeline.builds.empty? && @pipeline.yaml_errors.present? + - if pipeline_has_errors .bs-callout.bs-callout-danger %h4= _('Found errors in your %{gitlab_ci_yml}:') % { gitlab_ci_yml: '.gitlab-ci.yml' } %ul @@ -17,7 +18,8 @@ - lint_link_url = project_ci_lint_path(@project) - lint_link_start = '<a href="%{url}">'.html_safe % { url: lint_link_url } = s_('You can also test your %{gitlab_ci_yml} in %{lint_link_start}CI Lint%{lint_link_end}').html_safe % { gitlab_ci_yml: '.gitlab-ci.yml', lint_link_start: lint_link_start, lint_link_end: '</a>'.html_safe } - - else - = render "projects/pipelines/with_tabs", pipeline: @pipeline + + = render "projects/pipelines/pipeline_warnings", warnings: @pipeline.warning_messages + = render "projects/pipelines/with_tabs", pipeline: @pipeline, pipeline_has_errors: pipeline_has_errors .js-pipeline-details-vue{ data: { endpoint: project_pipeline_path(@project, @pipeline, format: :json) } } diff --git a/app/views/projects/product_analytics/_graph.html.haml b/app/views/projects/product_analytics/_graph.html.haml new file mode 100644 index 00000000000..fd81a248005 --- /dev/null +++ b/app/views/projects/product_analytics/_graph.html.haml @@ -0,0 +1,6 @@ +- graph = local_assigns.fetch(:graph) + +%h3 + = graph[:id] + +.js-project-analytics-chart{ "data-chart-data": graph.to_json, "data-chart-id": graph[:id] } diff --git a/app/views/projects/product_analytics/_links.html.haml b/app/views/projects/product_analytics/_links.html.haml new file mode 100644 index 00000000000..0797c5baf91 --- /dev/null +++ b/app/views/projects/product_analytics/_links.html.haml @@ -0,0 +1,10 @@ +.mb-3 + %ul.nav-links + = nav_link(path: 'product_analytics#index') do + = link_to _('Events'), project_product_analytics_path(@project) + = nav_link(path: 'product_analytics#graphs') do + = link_to 'Graphs', graphs_project_product_analytics_path(@project) + = nav_link(path: 'product_analytics#test') do + = link_to _('Test'), test_project_product_analytics_path(@project) + = nav_link(path: 'product_analytics#setup') do + = link_to _('Setup'), setup_project_product_analytics_path(@project) diff --git a/app/views/projects/product_analytics/_tracker.html.erb b/app/views/projects/product_analytics/_tracker.html.erb new file mode 100644 index 00000000000..dbb96f19e22 --- /dev/null +++ b/app/views/projects/product_analytics/_tracker.html.erb @@ -0,0 +1,10 @@ +;(function(p,l,o,w,i,n,g){if(!p[i]){p.GlobalSnowplowNamespace=p.GlobalSnowplowNamespace||[]; +p.GlobalSnowplowNamespace.push(i);p[i]=function(){(p[i].q=p[i].q||[]).push(arguments) +};p[i].q=p[i].q||[];n=l.createElement(o);g=l.getElementsByTagName(o)[0];n.async=1; +n.src=w;g.parentNode.insertBefore(n,g)}}(window,document,"script","<%= product_analytics_tracker_url -%>","snowplow<%= @random -%>")); +snowplow<%= @random -%>("newTracker", "sp", "<%= product_analytics_tracker_collector_url -%>", { + appId: "<%= @project_id -%>", + platform: "<%= @platform -%>", + eventMethod: "get" +}); +snowplow<%= @random -%>('trackPageView'); diff --git a/app/views/projects/product_analytics/graphs.html.haml b/app/views/projects/product_analytics/graphs.html.haml new file mode 100644 index 00000000000..89286061594 --- /dev/null +++ b/app/views/projects/product_analytics/graphs.html.haml @@ -0,0 +1,12 @@ +- page_title _('Product Analytics') + += render 'links' + +%p + = _('Showing graphs based on events of the last %{timerange} days.') % { timerange: @timerange } + +- @graphs.each_slice(2) do |pair| + .row.append-bottom-10 + - pair.each do |graph| + .col-md-6{ id: graph[:id] } + = render 'graph', graph: graph diff --git a/app/views/projects/product_analytics/index.html.haml b/app/views/projects/product_analytics/index.html.haml new file mode 100644 index 00000000000..386f9265179 --- /dev/null +++ b/app/views/projects/product_analytics/index.html.haml @@ -0,0 +1,16 @@ +- page_title _('Product Analytics') + += render 'links' + +- if @events.any? + %p + - if @events.total_count > @events.size + = _('Number of events for this project: %{total_count}.') % { total_count: number_with_delimiter(@events.total_count) } + %ol + - @events.each do |event| + %li + %code= event.as_json_wo_empty +- else + .empty-state + .text-content + = _('There are currently no events.') diff --git a/app/views/projects/product_analytics/setup.html.haml b/app/views/projects/product_analytics/setup.html.haml new file mode 100644 index 00000000000..e1819c7d74b --- /dev/null +++ b/app/views/projects/product_analytics/setup.html.haml @@ -0,0 +1,12 @@ +- page_title _('Product Analytics') + += render 'links' + +%p + = _('Copy the code below to implement tracking in your application:') + +%pre + = render "tracker" + +%p.hint + = _('A platform value can be web, mob or app.') diff --git a/app/views/projects/product_analytics/test.html.haml b/app/views/projects/product_analytics/test.html.haml new file mode 100644 index 00000000000..60d897ee138 --- /dev/null +++ b/app/views/projects/product_analytics/test.html.haml @@ -0,0 +1,16 @@ +- page_title _('Product Analytics') + += render 'links' + +%p + = _('This page sends a payload. Go back to the events page to see a newly created event.') + +- if @event + %p + = _('Last item before this page loaded in your browser:') + + %code + = @event.as_json_wo_empty + +:javascript + #{render 'tracker'} diff --git a/app/views/projects/project_members/_groups.html.haml b/app/views/projects/project_members/_groups.html.haml index 353c36d0fed..39ef1e52a0d 100644 --- a/app/views/projects/project_members/_groups.html.haml +++ b/app/views/projects/project_members/_groups.html.haml @@ -1,6 +1,6 @@ .card.project-members-groups .card-header - = _("Groups with access to <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(@project.name, tags: []) } + = html_escape(_("Groups with access to %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(@project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %span.badge.badge-pill= group_links.size %ul.content-list.members-list - can_admin_member = can?(current_user, :admin_project_member, @project) diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index 5d8005b2e2a..4b3fdf8d0b1 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -4,7 +4,7 @@ .card .card-header.flex-project-members-panel %span.flex-project-title - = _("Members of <strong>%{project_name}</strong>").html_safe % { project_name: sanitize(project.name, tags: []) } + = html_escape(_("Members of %{strong_open}%{project_name}%{strong_close}")) % { project_name: sanitize(project.name, tags: []), strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %span.badge.badge-pill= members.total_count = form_tag project_project_members_path(project), method: :get, class: 'form-inline user-search-form flex-users-form' do .form-group @@ -12,6 +12,7 @@ = search_field_tag :search, params[:search], { placeholder: _('Find existing members by name'), class: 'form-control', spellcheck: false } %button.user-search-btn{ type: "submit", "aria-label" => _("Submit search") } = icon("search") + = label_tag :sort_by, _('Sort by'), class: 'col-form-label label-bold px-2' = render 'shared/members/sort_dropdown' %ul.content-list.members-list{ data: { qa_selector: 'members_list' } } = render partial: 'shared/members/member', collection: members, as: :member diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index ba964e5cd37..9a1e997fce7 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -11,7 +11,7 @@ %p= share_project_description(@project) - else %p - = _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe + = html_escape(_("Members can be added by project %{i_open}Maintainers%{i_close} or %{i_open}Owners%{i_close}")) % { i_open: '<i>'.html_safe, i_close: '</i>'.html_safe } .light - if can_admin_project_members && project_can_be_shared? diff --git a/app/views/projects/protected_branches/_update_protected_branch.html.haml b/app/views/projects/protected_branches/_update_protected_branch.html.haml index 74bfaa9ff80..b2ec98be056 100644 --- a/app/views/projects/protected_branches/_update_protected_branch.html.haml +++ b/app/views/projects/protected_branches/_update_protected_branch.html.haml @@ -1,10 +1 @@ -%td - = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", protected_branch.merge_access_levels.first.access_level - = dropdown_tag( (protected_branch.merge_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', - data: { field_name: "allowed_to_merge_#{protected_branch.id}", access_level_id: protected_branch.merge_access_levels.first.id }}) -%td - = hidden_field_tag "allowed_to_push_#{protected_branch.id}", protected_branch.push_access_levels.first.access_level - = dropdown_tag( (protected_branch.push_access_levels.first.humanize || 'Select') , - options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', - data: { field_name: "allowed_to_push_#{protected_branch.id}", access_level_id: protected_branch.push_access_levels.first.id }}) += render 'shared/projects/protected_branches/update_protected_branch', protected_branch: protected_branch diff --git a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml index f84c7b39733..7131e9925b3 100644 --- a/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_create_protected_branch.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| += form_for [@project, @protected_branch], html: { class: 'new-protected-branch js-new-protected-branch' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-branches-settings' } .card .card-header diff --git a/app/views/projects/protected_branches/shared/_index.html.haml b/app/views/projects/protected_branches/shared/_index.html.haml index 63748d8d85f..f27936703de 100644 --- a/app/views/projects/protected_branches/shared/_index.html.haml +++ b/app/views/projects/protected_branches/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expanded_by_default? -%section.qa-protected-branches-settings.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded) } +%section.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_branches_settings_content' } } .settings-header %h4 Protected Branches diff --git a/app/views/projects/protected_branches/shared/_protected_branch.html.haml b/app/views/projects/protected_branches/shared/_protected_branch.html.haml index 4ca6ebe9c78..d62e9513d56 100644 --- a/app/views/projects/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/projects/protected_branches/shared/_protected_branch.html.haml @@ -20,4 +20,4 @@ - if can_admin_project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning" + = link_to 'Unprotect', [@project, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], data: { confirm: 'Branch will be writable for developers. Are you sure?' }, method: :delete, class: "btn btn-warning" diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index 8a6ae53a7c4..dc7514badb6 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| += form_for [@project, @protected_tag], html: { class: 'new-protected-tag js-new-protected-tag' } do |f| %input{ type: 'hidden', name: 'update_section', value: 'js-protected-tags-settings' } .card .card-header @@ -24,5 +24,5 @@ .create_access_levels-container = yield :create_access_levels - .card-footer - = f.submit 'Protect', class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' } + .card-footer.gl-display-flex.gl-justify-content-end + = f.submit _('Protect'), class: 'btn-success btn', disabled: true, data: { qa_selector: 'protect_tag_button' } diff --git a/app/views/projects/protected_tags/shared/_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_protected_tag.html.haml index b0563163c9c..71c29f9b7b6 100644 --- a/app/views/projects/protected_tags/shared/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_protected_tag.html.haml @@ -19,4 +19,4 @@ - if can? current_user, :admin_project, @project %td - = link_to 'Unprotect', [@project.namespace.becomes(Namespace), @project, protected_tag, { update_section: 'js-protected-tags-settings' }], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' + = link_to 'Unprotect', [@project, protected_tag, { update_section: 'js-protected-tags-settings' }], data: { confirm: 'Tag will be writable for developers. Are you sure?' }, method: :delete, class: 'btn btn-warning' diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 92680a70da2..74b6e981c00 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -6,11 +6,12 @@ = link_to _("%{token}...") % { token: runner.short_sha }, project_runner_path(@project, runner), class: 'commit-sha has-tooltip', title: _("Partial token for reference only") - if runner.locked? - = icon('lock', class: 'has-tooltip', title: _('Locked to current projects')) + %span.has-tooltip{ title: _('Locked to current projects') } + = sprite_icon('lock') %small.edit-runner - = link_to edit_project_runner_path(@project, runner) do - %i.fa.fa-edit.btn + = link_to edit_project_runner_path(@project, runner), class: 'btn btn-edit' do + = sprite_icon('pencil') - else %span.commit-sha = runner.short_sha @@ -27,7 +28,7 @@ - runner_project = @project.runner_projects.find_by(runner_id: runner) # rubocop: disable CodeReuse/ActiveRecord = link_to _('Disable for this project'), project_runner_project_path(@project, runner_project), data: { confirm: _("Are you sure?") }, method: :delete, class: 'btn btn-danger btn-sm' - elsif runner.project_type? - = form_for [@project.namespace.becomes(Namespace), @project, @project.runner_projects.new] do |f| + = form_for [@project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id = f.submit _('Enable for this project'), class: 'btn btn-sm' .float-right diff --git a/app/views/projects/runners/_shared_runners.html.haml b/app/views/projects/runners/_shared_runners.html.haml index 080b2c0b0e9..8a17ca3c670 100644 --- a/app/views/projects/runners/_shared_runners.html.haml +++ b/app/views/projects/runners/_shared_runners.html.haml @@ -15,7 +15,7 @@ = _('Enable shared Runners') for this project -- if @shared_runners_count.zero? +- if @shared_runners_count == 0 = _('This GitLab instance does not provide any shared Runners yet. Instance administrators can register shared Runners in the admin area.') - else %h4.underlined-title #{_('Available shared Runners:')} #{@shared_runners_count} diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index b21965915a2..383c187b398 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -7,7 +7,8 @@ .serverless-functions-page.js-serverless-functions-page{ data: { status_path: status_path, installed: @installed, clusters_path: clusters_path, - help_path: help_page_path('user/project/clusters/serverless/index') } } + help_path: help_page_path('user/project/clusters/serverless/index'), + empty_image_path: image_path('illustrations/empty-state/empty-serverless-lg.svg') } } %div{ class: [('limit-container-width' unless fluid_layout)] } .js-serverless-survey-banner{ data: { user_name: current_user.name, user_email: current_user.email } } @@ -15,5 +16,5 @@ .js-serverless-functions-notice .flash-container - .top-area.adjust.d-flex.justify-content-center + .top-area.adjust.d-flex.justify-content-center.gl-border-none .serverless-functions-table#js-serverless-functions diff --git a/app/views/projects/services/_form.html.haml b/app/views/projects/services/_form.html.haml index 2e49e74a9b3..24b47f6e4b6 100644 --- a/app/views/projects/services/_form.html.haml +++ b/app/views/projects/services/_form.html.haml @@ -3,7 +3,7 @@ .row.gl-mt-3.gl-mb-3 .col-lg-4 - %h4.gl-mt-0 + %h3.page-title.gl-mt-0 = @service.title - [true, false].each do |value| - hide_class = 'd-none' if @service.operating? != value diff --git a/app/views/projects/services/alerts/_top.html.haml b/app/views/projects/services/alerts/_top.html.haml index ebc93978832..e3bcb6bd3a0 100644 --- a/app/views/projects/services/alerts/_top.html.haml +++ b/app/views/projects/services/alerts/_top.html.haml @@ -1,7 +1,7 @@ .row .col-lg-12 - .gl-alert.gl-alert-info.js-alerts-moved-alert{ role: 'alert' } - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert.gl-alert-info{ role: 'alert' } + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body = _('You can now manage alert endpoint configuration in the Alerts section on the Operations settings page. Fields on this page have been deprecated.') .gl-alert-actions diff --git a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml index cf73a7055c6..9d81fda68cb 100644 --- a/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_detailed_help.html.haml @@ -1,5 +1,5 @@ -- pretty_name = @project&.full_name || _('<project name>') -- run_actions_text = s_("ProjectService|Perform common operations on GitLab project: %{project_name}") % { project_name: pretty_name } +- pretty_name = html_escape(@project&.full_name) || html_escape_once(_('<project name>')).html_safe +- run_actions_text = html_escape(s_("ProjectService|Perform common operations on GitLab project: %{project_name}")) % { project_name: pretty_name } %p= s_("ProjectService|To set up this service:") %ul.list-unstyled.indent-list @@ -7,13 +7,13 @@ 1. = link_to 'https://docs.mattermost.com/developer/slash-commands.html#enabling-custom-commands', target: '_blank', rel: 'noopener noreferrer nofollow' do Enable custom slash commands - = sprite_icon('external-link', size: 16) + = sprite_icon('external-link') on your Mattermost installation %li 2. = link_to 'https://docs.mattermost.com/developer/slash-commands.html#set-up-a-custom-command', target: '_blank', rel: 'noopener noreferrer nofollow' do Add a slash command - = sprite_icon('external-link', size: 16) + = sprite_icon('external-link') in your Mattermost team with these options: %hr @@ -21,7 +21,7 @@ .form-group = label_tag :display_name, _('Display name'), class: 'col-12 col-form-label label-bold' .col-12.input-group - = text_field_tag :display_name, "GitLab / #{pretty_name}", class: 'form-control form-control-sm', readonly: 'readonly' + = text_field_tag :display_name, "GitLab / #{pretty_name}".html_safe, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#display_name', class: 'input-group-text') diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index cc005dd69b7..1005d9f7990 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -6,7 +6,7 @@ = s_("MattermostService|This service allows users to perform common operations on this project by entering slash commands in Mattermost.") = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do = _("View documentation") - = sprite_icon('external-link', size: 16) + = sprite_icon('external-link') %p.inline = s_("MattermostService|See list of available commands in Mattermost after setting up this service, by entering") %kbd.inline /<trigger> help diff --git a/app/views/projects/services/prometheus/_metrics.html.haml b/app/views/projects/services/prometheus/_metrics.html.haml index 9f5160f3dd5..79f5e846bd7 100644 --- a/app/views/projects/services/prometheus/_metrics.html.haml +++ b/app/views/projects/services/prometheus/_metrics.html.haml @@ -5,7 +5,7 @@ .col-lg-3 %p = s_('PrometheusService|Common metrics are automatically monitored based on a library of metrics from popular exporters.') - = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus_library/index'), target: '_blank', rel: "noopener noreferrer" + = link_to s_('PrometheusService|More information'), help_page_path('user/project/integrations/prometheus'), target: '_blank', rel: "noopener noreferrer" .col-lg-9 .card.js-panel-monitored-metrics{ data: { active_metrics: active_common_project_prometheus_metrics_path(project, :json), metrics_help_path: help_page_path('user/project/integrations/prometheus_library/index') } } diff --git a/app/views/projects/services/prometheus/_top.html.haml b/app/views/projects/services/prometheus/_top.html.haml index 338414be5ab..0238a45b75f 100644 --- a/app/views/projects/services/prometheus/_top.html.haml +++ b/app/views/projects/services/prometheus/_top.html.haml @@ -2,8 +2,8 @@ .row .col-lg-12 - .gl-alert.gl-alert-info.js-alerts-moved-alert{ role: 'alert' } - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + .gl-alert.gl-alert-info{ role: 'alert' } + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') .gl-alert-body = s_('AlertSettings|You can now set up alert endpoints for manually configured Prometheus instances in the Alerts section on the Operations settings page. Alert endpoint fields on this page have been deprecated.') .gl-alert-actions diff --git a/app/views/projects/services/slack/_help.haml b/app/views/projects/services/slack/_help.haml index d7ea1b270f5..1fd448020a0 100644 --- a/app/views/projects/services/slack/_help.haml +++ b/app/views/projects/services/slack/_help.haml @@ -3,14 +3,14 @@ .info-well .well-segment - %p= s_('SlackIntegration|This service send notifications about projects\' events to Slack channels. To set up this service:') + %p= s_('SlackIntegration|This service sends notifications about project events to Slack channels. To set up this service:') %ol %li - = s_('SlackIntegration|%{webhooks_link_start}Add an incoming webhook%{webhooks_link_end} in your Slack team. The default channel can be overridden for each event.').html_safe % { webhooks_link_start: webhooks_link_start, webhooks_link_end: '</a>'.html_safe } + = html_escape(s_('SlackIntegration|%{webhooks_link_start}Add an incoming webhook%{webhooks_link_end} in your Slack team. The default channel can be overridden for each event.')) % { webhooks_link_start: webhooks_link_start.html_safe, webhooks_link_end: '</a>'.html_safe } %li - = s_('SlackIntegration|Paste the <strong>Webhook URL</strong> into the field below.').html_safe + = html_escape(s_('SlackIntegration|Paste the %{strong_open}Webhook URL%{strong_close} into the field below.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li - = s_('SlackIntegration|Select events below to enable notifications. The <strong>Slack channel names</strong> and <strong>Slack username</strong> fields are optional.').html_safe + = html_escape(s_('SlackIntegration|Select events below to enable notifications. The %{strong_open}Slack channel names%{strong_close} and %{strong_open}Slack username%{strong_close} fields are optional.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %p.mt-3.mb-0 - = s_('SlackIntegration|<strong>Note:</strong> Usernames and private channels are not supported.').html_safe + = html_escape(s_('SlackIntegration|%{strong_open}Note:%{strong_close} Usernames and private channels are not supported.')) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } = link_to _('Learn more'), help_page_path('user/project/integrations/slack') diff --git a/app/views/projects/services/slack_slash_commands/_help.html.haml b/app/views/projects/services/slack_slash_commands/_help.html.haml index 0cf78d4f681..86486d95eb7 100644 --- a/app/views/projects/services/slack_slash_commands/_help.html.haml +++ b/app/views/projects/services/slack_slash_commands/_help.html.haml @@ -1,5 +1,5 @@ -- pretty_name = @project&.full_name || _('<project name>') -- run_actions_text = s_("ProjectService|Perform common operations on GitLab project: %{project_name}") % { project_name: pretty_name } +- pretty_name = @project&.full_name || _('<project name>') +- run_actions_text = html_escape_once(s_("ProjectService|Perform common operations on GitLab project: %{project_name}") % { project_name: pretty_name }) .info-well .well-segment @@ -7,7 +7,7 @@ = s_("SlackService|This service allows users to perform common operations on this project by entering slash commands in Slack.") = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do = _("View documentation") - = sprite_icon('external-link', size: 16) + = sprite_icon('external-link') %p.inline = s_("SlackService|See list of available commands in Slack after setting up this service, by entering") %kbd.inline /<command> help @@ -18,7 +18,7 @@ 1. = link_to 'https://my.slack.com/services/new/slash-commands', target: '_blank', rel: 'noreferrer noopener nofollow' do Add a slash command - = sprite_icon('external-link', size: 16) + = sprite_icon('external-link') in your Slack team with these options: %hr @@ -67,7 +67,7 @@ .form-group = label_tag :autocomplete_description, _('Autocomplete description'), class: 'col-12 col-form-label label-bold' .col-12.input-group - = text_field_tag :autocomplete_description, run_actions_text, class: 'form-control form-control-sm', readonly: 'readonly' + = text_field_tag :autocomplete_description, run_actions_text.html_safe, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append = clipboard_button(target: '#autocomplete_description', class: 'input-group-text') @@ -89,6 +89,6 @@ %ul.list-unstyled.indent-list %li - = s_("SlackService|2. Paste the <strong>Token</strong> into the field below").html_safe + = html_escape(s_("SlackService|2. Paste the %{strong_open}Token%{strong_close} into the field below")) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } %li - = s_("SlackService|3. Select the <strong>Active</strong> checkbox, press <strong>Save changes</strong> and start using GitLab inside Slack!").html_safe + = html_escape(s_("SlackService|3. Select the %{strong_open}Active%{strong_close} checkbox, press %{strong_open}Save changes%{strong_close} and start using GitLab inside Slack!")) % { strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } diff --git a/app/views/projects/settings/_archive.html.haml b/app/views/projects/settings/_archive.html.haml index 3307c3775ec..cbeedbd080c 100644 --- a/app/views/projects/settings/_archive.html.haml +++ b/app/views/projects/settings/_archive.html.haml @@ -1,6 +1,6 @@ - return unless can?(current_user, :archive_project, @project) -.sub-section +.sub-section{ data: { qa_selector: 'archive_project_content' } } %h4.warning-title - if @project.archived? = _('Unarchive project') @@ -13,6 +13,7 @@ method: :post, class: "btn btn-success" - else %p= _("Archiving the project will make it entirely read only. It is hidden from the dashboard and doesn't show up in searches. %{strong_start}The repository cannot be committed to, and no issues, comments, or other entities can be created.%{strong_end}").html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = link_to _('Archive project'), archive_project_path(@project), + .gl-display-flex.gl-justify-content-end + = link_to _('Archive project'), archive_project_path(@project), data: { confirm: _("Are you sure that you want to archive this project?"), qa_selector: 'archive_project_link' }, method: :post, class: "btn btn-warning" diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 3ffa029a25d..50f80fd1e2f 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| += form_for [@project], remote: true, html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' } = form_errors(@project) @@ -31,7 +31,7 @@ = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project - .form-group.gl-mt-3.append-bottom-20 + .form-group.gl-mt-3.gl-mb-3 .avatar-container.s90 = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90') = f.label :avatar, _('Project avatar'), class: 'label-bold d-block' @@ -40,5 +40,5 @@ %hr = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' - - = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button" + .gl-display-flex.gl-justify-content-end + = f.submit _('Save changes'), class: "btn btn-success mt-4 qa-save-naming-topics-avatar-button" diff --git a/app/views/projects/settings/access_tokens/index.html.haml b/app/views/projects/settings/access_tokens/index.html.haml index 4992288a8c8..100eb5991dc 100644 --- a/app/views/projects/settings/access_tokens/index.html.haml +++ b/app/views/projects/settings/access_tokens/index.html.haml @@ -10,8 +10,9 @@ = page_title %p = _('You can generate an access token scoped to this project for each application to use the GitLab API.') - %p - = _('You can also use project access tokens to authenticate against Git over HTTP.') + -# Commented out until https://gitlab.com/gitlab-org/gitlab/-/issues/219551 is fixed + -# %p + -# = _('You can also use project access tokens to authenticate against Git over HTTP.') .col-lg-8 - if @new_project_access_token diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index 7284b4bb55d..b4c9e51f53a 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -34,7 +34,7 @@ - elsif !has_base_domain %p.settings-message.text-center = s_('CICD|You must add a %{base_domain_link_start}base domain%{link_end} to your %{kubernetes_cluster_link_start}Kubernetes cluster%{link_end} in order for your deployment strategy to work.').html_safe % { base_domain_link_start: base_domain_link_start, kubernetes_cluster_link_start: kubernetes_cluster_link_start, link_end: link_end } - %label.prepend-top-10 + %label.gl-mt-3 %strong= s_('CICD|Deployment strategy') .form-check = form.radio_button :deploy_strategy, 'continuous', class: 'form-check-input' @@ -54,4 +54,4 @@ = s_('CICD|Automatic deployment to staging, manual deployment to production') = link_to icon('question-circle'), help_page_path('topics/autodevops/customize.md', anchor: 'incremental-rollout-to-production-premium'), target: '_blank' - = f.submit _('Save changes'), class: "btn btn-success prepend-top-15", data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), class: "btn btn-success gl-mt-5", data: { qa_selector: 'save_changes_button' } diff --git a/app/views/projects/settings/ci_cd/_form.html.haml b/app/views/projects/settings/ci_cd/_form.html.haml index e8e5a5f0256..a0dd06e3304 100644 --- a/app/views/projects/settings/ci_cd/_form.html.haml +++ b/app/views/projects/settings/ci_cd/_form.html.haml @@ -7,7 +7,7 @@ %h5.gl-mt-0 = _("Git strategy for pipelines") %p - = _("Choose between <code>clone</code> or <code>fetch</code> to get the recent application code").html_safe + = html_escape(_("Choose between %{code_open}clone%{code_close} or %{code_open}fetch%{code_close} to get the recent application code")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to icon('question-circle'), help_page_path('ci/pipelines/settings', anchor: 'git-strategy'), target: '_blank' .form-check = f.radio_button :build_allow_git_fetch, 'false', { class: 'form-check-input' } @@ -54,7 +54,7 @@ = f.label :ci_config_path, _('Custom CI configuration path'), class: 'label-bold' = f.text_field :ci_config_path, class: 'form-control', placeholder: '.gitlab-ci.yml' %p.form-text.text-muted - = _("The path to the CI configuration file. Defaults to <code>.gitlab-ci.yml</code>").html_safe + = html_escape(_("The path to the CI configuration file. Defaults to %{code_open}.gitlab-ci.yml%{code_close}")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } = link_to icon('question-circle'), help_page_path('ci/pipelines/settings', anchor: 'custom-ci-configuration-path'), target: '_blank' %hr diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index b5452fcca55..8e3be5fa086 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -74,3 +74,22 @@ = link_to _('More information'), help_page_path('user/packages/container_registry/index', anchor: 'cleanup-policy', target: '_blank', rel: 'noopener noreferrer') .settings-content = render 'projects/registry/settings/index' + +- if can?(current_user, :create_freeze_period, @project) + %section.settings.no-animate#js-deploy-freeze-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _("Deploy freezes") + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + - freeze_period_docs = help_page_path('user/project/releases/index', anchor: 'prevent-unintentional-releases-by-setting-a-deploy-freeze') + - freeze_period_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: freeze_period_docs } + = html_escape(s_('DeployFreeze|Specify times when deployments are not allowed for an environment. The %{filename} file must be updated to make deployment jobs aware of the %{freeze_period_link_start}freeze period%{freeze_period_link_end}.')) % { freeze_period_link_start: freeze_period_link_start, freeze_period_link_end: '</a>'.html_safe, filename: tag.code('gitlab-ci.yml') } + + - cron_syntax_url = 'https://crontab.guru/' + - cron_syntax_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: cron_syntax_url } + = s_('DeployFreeze|You can specify deploy freezes using only %{cron_syntax_link_start}cron syntax%{cron_syntax_link_end}.').html_safe % { cron_syntax_link_start: cron_syntax_link_start, cron_syntax_link_end: "</a>".html_safe } + + .settings-content + = render 'ci/deploy_freeze/index' diff --git a/app/views/projects/settings/integrations/show.html.haml b/app/views/projects/settings/integrations/show.html.haml index d9068bde847..18c6cb31874 100644 --- a/app/views/projects/settings/integrations/show.html.haml +++ b/app/views/projects/settings/integrations/show.html.haml @@ -4,9 +4,9 @@ - if show_webhooks_moved_alert? .gl-alert.gl-alert-info.js-webhooks-moved-alert.gl-mt-3{ role: 'alert', data: { feature_id: UserCalloutsHelper::WEBHOOKS_MOVED, dismiss_endpoint: user_callouts_path } } - = sprite_icon('information-o', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') + = sprite_icon('information-o', css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title') %button.js-close.gl-alert-dismiss{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body = _('Webhooks have moved. They can now be found under the Settings menu.') .gl-alert-actions diff --git a/app/views/projects/settings/operations/_error_tracking.html.haml b/app/views/projects/settings/operations/_error_tracking.html.haml index 393b1f9d21a..62b344b38f1 100644 --- a/app/views/projects/settings/operations/_error_tracking.html.haml +++ b/app/views/projects/settings/operations/_error_tracking.html.haml @@ -5,12 +5,12 @@ %section.settings.no-animate.js-error-tracking-settings .settings-header %h3{ :class => "h4" } - = _('Error Tracking') + = _('Error tracking') %button.btn.js-settings-toggle{ type: 'button' } = _('Expand') %p = _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.') - = link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('More information'), help_page_path('operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content .js-error-tracking-form{ data: { list_projects_endpoint: project_error_tracking_projects_path(@project, format: :json), operations_settings_endpoint: project_settings_operations_path(@project), diff --git a/app/views/projects/settings/operations/_metrics_dashboard.html.haml b/app/views/projects/settings/operations/_metrics_dashboard.html.haml index edbada8444a..056d3e8102b 100644 --- a/app/views/projects/settings/operations/_metrics_dashboard.html.haml +++ b/app/views/projects/settings/operations/_metrics_dashboard.html.haml @@ -1,5 +1,5 @@ .js-operation-settings{ data: { operations_settings_endpoint: project_settings_operations_path(@project), - help_page: help_page_path('user/project/operations/dashboard_settings'), + help_page: help_page_path('operations/metrics/dashboards/settings'), external_dashboard: { url: metrics_external_dashboard_url, - help_page: help_page_path('user/project/operations/linking_to_an_external_dashboard') }, + help_page: help_page_path('operations/metrics/dashboards/settings') }, dashboard_timezone: { setting: metrics_dashboard_timezone.upcase } } } diff --git a/app/views/projects/snippets/verify.html.haml b/app/views/projects/snippets/verify.html.haml index eb56f03b3f4..3c4f08e1df7 100644 --- a/app/views/projects/snippets/verify.html.haml +++ b/app/views/projects/snippets/verify.html.haml @@ -1,4 +1,2 @@ -- form = [@project.namespace.becomes(Namespace), @project, @snippet.becomes(Snippet)] - -= render 'layouts/recaptcha_verification', spammable: @snippet, form: form += render 'layouts/recaptcha_verification', spammable: @snippet diff --git a/app/views/projects/starrers/index.html.haml b/app/views/projects/starrers/index.html.haml index e55ed99f643..97996562e2c 100644 --- a/app/views/projects/starrers/index.html.haml +++ b/app/views/projects/starrers/index.html.haml @@ -22,7 +22,7 @@ = link_to filter_starrer_path(sort: value), class: ("is-active" if @sort == value) do = title - if @starrers.size > 0 - .row.prepend-top-10 + .row.gl-mt-3 = render partial: 'starrer', collection: @starrers, as: :starrer = paginate @starrers, theme: 'gitlab' - else diff --git a/app/views/projects/static_site_editor/show.html.haml b/app/views/projects/static_site_editor/show.html.haml index 8d2649be588..2d817912335 100644 --- a/app/views/projects/static_site_editor/show.html.haml +++ b/app/views/projects/static_site_editor/show.html.haml @@ -1 +1 @@ -#static-site-editor{ data: @config.payload } +#static-site-editor{ data: @config.payload.merge({ merge_requests_illustration_path: image_path('illustrations/merge_requests.svg') }) } diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index 59c7d0401d1..c8a6168edfc 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -37,6 +37,6 @@ - if can?(current_user, :admin_tag, @project) = link_to edit_project_tag_release_path(@project, tag.name), class: 'btn btn-edit has-tooltip', title: s_('TagsPage|Edit release notes'), data: { container: "body" } do - = icon("pencil") + = sprite_icon("pencil") = link_to project_tag_path(@project, tag.name), class: "btn btn-remove remove-row has-tooltip gl-ml-3 #{protected_tag?(@project, tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: tag.name }, container: 'body' }, remote: true do = icon("trash-o") diff --git a/app/views/projects/tags/index.html.haml b/app/views/projects/tags/index.html.haml index e3d3f2226a8..e0def8cf155 100644 --- a/app/views/projects/tags/index.html.haml +++ b/app/views/projects/tags/index.html.haml @@ -26,8 +26,8 @@ - if can?(current_user, :admin_tag, @project) = link_to new_project_tag_path(@project), class: 'btn btn-success new-tag-btn', data: { qa_selector: "new_tag_button" } do = s_('TagsPage|New tag') - = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn d-none d-sm-inline-block has-tooltip' do - = icon("rss") + = link_to project_tags_path(@project, rss_url_options), title: _("Tags feed"), class: 'btn btn-svg d-none d-sm-inline-block has-tooltip' do + = sprite_icon('rss', css_class: 'qa-rss-icon') = render_if_exists 'projects/commits/mirror_status' diff --git a/app/views/projects/tags/show.html.haml b/app/views/projects/tags/show.html.haml index edb0577cebd..ff973e2922f 100644 --- a/app/views/projects/tags/show.html.haml +++ b/app/views/projects/tags/show.html.haml @@ -42,18 +42,18 @@ - if @tag.has_signature? = render partial: 'projects/commit/signature', object: @tag.signature - if can?(current_user, :admin_tag, @project) - = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-edit controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do - = icon("pencil") - = link_to project_tree_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse files') do - = sprite_icon('folder-open') - = link_to project_commits_path(@project, @tag.name), class: 'btn controls-item has-tooltip', title: s_('TagsPage|Browse commits') do - = icon('history') + = link_to edit_project_tag_release_path(@project, @tag.name), class: 'btn btn-icon btn-edit gl-button controls-item has-tooltip', title: s_('TagsPage|Edit release notes') do + = sprite_icon("pencil", css_class: 'gl-icon') + = link_to project_tree_path(@project, @tag.name), class: 'btn btn-icon gl-button controls-item has-tooltip', title: s_('TagsPage|Browse files') do + = sprite_icon('folder-open', css_class: 'gl-icon') + = link_to project_commits_path(@project, @tag.name), class: 'btn btn-icon gl-button controls-item has-tooltip', title: s_('TagsPage|Browse commits') do + = sprite_icon('history', css_class: 'gl-icon') .btn-container.controls-item = render 'projects/buttons/download', project: @project, ref: @tag.name - if can?(current_user, :admin_tag, @project) .btn-container.controls-item-full - = link_to project_tag_path(@project, @tag.name), class: "btn btn-remove remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do - %i.fa.fa-trash-o + = link_to project_tag_path(@project, @tag.name), class: "btn btn-icon btn-danger gl-button remove-row has-tooltip #{protected_tag?(@project, @tag) ? 'disabled' : ''}", title: s_('TagsPage|Delete tag'), method: :delete, data: { confirm: s_('TagsPage|Deleting the %{tag_name} tag cannot be undone. Are you sure?') % { tag_name: @tag.name } } do + = sprite_icon('remove', css_class: 'gl-icon') - if @tag.message.present? %pre.wrap{ data: { qa_selector: 'tag_message_content' } } diff --git a/app/views/projects/triggers/_form.html.haml b/app/views/projects/triggers/_form.html.haml index a9abfac239c..dec71cdb56a 100644 --- a/app/views/projects/triggers/_form.html.haml +++ b/app/views/projects/triggers/_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @trigger], html: { class: 'gl-show-field-errors' } do |f| += form_for [@project, @trigger], html: { class: 'gl-show-field-errors' } do |f| = form_errors(@trigger) - if @trigger.token diff --git a/app/views/registrations/welcome.html.haml b/app/views/registrations/welcome.html.haml index bc8d7ed10ef..ef3e0b1b4c0 100644 --- a/app/views/registrations/welcome.html.haml +++ b/app/views/registrations/welcome.html.haml @@ -1,6 +1,6 @@ - content_for(:page_title, _('Welcome to GitLab %{name}!') % { name: current_user.name }) .text-center.mb-3 - = _('In order to tailor your experience with GitLab we<br>would like to know a bit more about you.').html_safe + = html_escape(_('In order to tailor your experience with GitLab we%{br_tag}would like to know a bit more about you.')) % { br_tag: '<br/>'.html_safe } .signup-box.p-3.mb-2 .signup-body = form_for(current_user, url: users_sign_up_update_registration_path, html: { class: 'new_new_user gl-show-field-errors', 'aria-live' => 'assertive' }) do |f| diff --git a/app/views/search/_form.html.haml b/app/views/search/_form.html.haml index dc75918eb93..b29707d391d 100644 --- a/app/views/search/_form.html.haml +++ b/app/views/search/_form.html.haml @@ -11,7 +11,7 @@ = search_field_tag :search, params[:search], placeholder: _("Search for projects, issues, etc."), class: "form-control search-text-input js-search-input", id: "dashboard_search", autofocus: true, spellcheck: false = icon("search", class: "search-icon") %button.search-clear.js-search-clear{ class: ("hidden" if !params[:search].present?), type: "button", tabindex: "-1" } - = icon("times-circle") + = sprite_icon('clear') %span.sr-only = _("Clear search") - unless params[:snippets].eql? 'true' diff --git a/app/views/search/_results.html.haml b/app/views/search/_results.html.haml index 8ada8c875f7..79f01c61833 100644 --- a/app/views/search/_results.html.haml +++ b/app/views/search/_results.html.haml @@ -8,7 +8,7 @@ = search_entries_info(@search_objects, @scope, @search_term) - unless @show_snippets - if @project - - link_to_project = link_to(@project.full_name, [@project.namespace.becomes(Namespace), @project], class: 'ml-md-1') + - link_to_project = link_to(@project.full_name, @project, class: 'ml-md-1') - if @scope == 'blobs' - repository_ref = params[:repository_ref].to_s.presence || @project.default_branch = s_("SearchCodeResults|in") @@ -22,7 +22,7 @@ = _("in group %{link_to_group}").html_safe % { link_to_group: link_to_group } = render_if_exists 'shared/promotions/promote_advanced_search' - .results.prepend-top-10 + .results.gl-mt-3 - if @scope == 'commits' %ul.content-list.commit-list = render partial: "search/results/commit", collection: @search_objects diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index b88e9a75053..2f6024c3f2b 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -1,7 +1,7 @@ .search-result-row %h4 = confidential_icon(issue) - = link_to namespace_project_issue_path(issue.project.namespace.becomes(Namespace), issue.project, issue) do + = link_to project_issue_path(issue.project, issue) do %span.term.str-truncated= issue.title - if issue.closed? %span.badge.badge-danger.gl-ml-2= _("Closed") diff --git a/app/views/search/results/_merge_request.html.haml b/app/views/search/results/_merge_request.html.haml index 45b6cb06753..680c2ea0208 100644 --- a/app/views/search/results/_merge_request.html.haml +++ b/app/views/search/results/_merge_request.html.haml @@ -1,6 +1,6 @@ .search-result-row %h4 - = link_to namespace_project_merge_request_path(merge_request.target_project.namespace.becomes(Namespace), merge_request.target_project, merge_request) do + = link_to project_merge_request_path(merge_request.target_project, merge_request) do %span.term.str-truncated= merge_request.title - if merge_request.merged? %span.badge.badge-primary.gl-ml-2= _("Merged") diff --git a/app/views/search/results/_milestone.html.haml b/app/views/search/results/_milestone.html.haml index 3201f1a7815..53c2d380bc5 100644 --- a/app/views/search/results/_milestone.html.haml +++ b/app/views/search/results/_milestone.html.haml @@ -1,6 +1,6 @@ .search-result-row %h4 - = link_to namespace_project_milestone_path(milestone.project.namespace.becomes(Namespace), milestone.project, milestone) do + = link_to project_milestone_path(milestone.project, milestone) do %span.term.str-truncated= milestone.title - if milestone.description.present? diff --git a/app/views/search/results/_note.html.haml b/app/views/search/results/_note.html.haml index b67bc71941a..a83b003a516 100644 --- a/app/views/search/results/_note.html.haml +++ b/app/views/search/results/_note.html.haml @@ -4,7 +4,7 @@ .search-result-row %h5.note-search-caption.str-truncated - = sprite_icon('comment', size: 16, css_class: 'gl-vertical-align-text-bottom') + = sprite_icon('comment', css_class: 'gl-vertical-align-text-bottom') = link_to_member(project, note.author, avatar: false) - link_to_project = link_to(project.full_name, project) = _("commented on %{link_to_project}").html_safe % { link_to_project: link_to_project } diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 869890cdf31..18eaccb46b2 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -2,6 +2,10 @@ - page_title @search_term - @hide_breadcrumbs = true +- if @search_results + - page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term }) + - page_card_attributes("Namespace" => @group&.full_path, "Project" => @project&.full_path) + .page-title-holder.d-sm-flex.align-items-sm-center %h1.page-title< = _('Search') diff --git a/app/views/sent_notifications/unsubscribe.html.haml b/app/views/sent_notifications/unsubscribe.html.haml index 7aeecf26c39..2ad29707c9f 100644 --- a/app/views/sent_notifications/unsubscribe.html.haml +++ b/app/views/sent_notifications/unsubscribe.html.haml @@ -3,7 +3,7 @@ - noteable_text = show_unsubscribe_title?(noteable) ? %(#{noteable.title} (#{noteable.to_reference})) : %(#{noteable.to_reference}) - show_project_path = can_read_project?(@sent_notification.project) - project_path = show_project_path ? @sent_notification.project.full_name : _("GitLab / Unsubscribe") -- noteable_url = show_project_path ? url_for([@sent_notification.project.namespace.becomes(Namespace), @sent_notification.project, noteable]) : breadcrumb_title_link +- noteable_url = show_project_path ? url_for([@sent_notification.project, noteable]) : breadcrumb_title_link - page_title _('Unsubscribe'), noteable_text, noteable_type.pluralize, project_path %h3.page-title diff --git a/app/views/shared/_broadcast_message.html.haml b/app/views/shared/_broadcast_message.html.haml index 3e889900981..e313946a968 100644 --- a/app/views/shared/_broadcast_message.html.haml +++ b/app/views/shared/_broadcast_message.html.haml @@ -3,7 +3,7 @@ %div{ class: "broadcast-message #{'alert-warning' if is_banner} broadcast-#{message.broadcast_type}-message #{opts[:preview] && 'preview'} js-broadcast-notification-#{message.id} gl-display-flex", style: broadcast_message_style(message), dir: 'auto' } .flex-grow-1.text-right.pr-2 - = sprite_icon('bullhorn', size: 16, css_class: 'vertical-align-text-top') + = sprite_icon('bullhorn', css_class: 'vertical-align-text-top') %div{ class: !fluid_layout && 'container-limited' } = render_broadcast_message(message) .flex-grow-1.text-right{ style: 'flex-basis: 0' } diff --git a/app/views/shared/_check_recovery_settings.html.haml b/app/views/shared/_check_recovery_settings.html.haml index e3de34a5ab9..7ac90e5af03 100644 --- a/app/views/shared/_check_recovery_settings.html.haml +++ b/app/views/shared/_check_recovery_settings.html.haml @@ -1,6 +1,6 @@ .gl-alert.gl-alert-warning.js-recovery-settings-callout{ role: 'alert', data: { feature_id: "account_recovery_regular_check", dismiss_endpoint: user_callouts_path, defer_links: "true" } } %button.js-close.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon') + = sprite_icon('close', css_class: 'gl-icon') .gl-alert-body - account_link_start = '<a class="deferred-link" href="%{url}">'.html_safe % { url: profile_account_path } = _("Please ensure your account's %{account_link_start}recovery settings%{account_link_end} are up to date.").html_safe % { account_link_start: account_link_start, account_link_end: '</a>'.html_safe } diff --git a/app/views/shared/_confirm_fork_modal.html.haml b/app/views/shared/_confirm_fork_modal.html.haml index db50ea41387..f2a193e0bbc 100644 --- a/app/views/shared/_confirm_fork_modal.html.haml +++ b/app/views/shared/_confirm_fork_modal.html.haml @@ -1,4 +1,4 @@ -#modal-confirm-fork.modal.qa-confirm-fork-modal +#modal-confirm-fork.modal{ data: { qa_selector: 'confirm_fork_modal' } } .modal-dialog .modal-content .modal-header @@ -9,4 +9,4 @@ %p= _("You're not allowed to %{tag_start}edit%{tag_end} files in this project directly. Please fork this project, make your changes there, and submit a merge request.") % { tag_start: '', tag_end: ''} .modal-footer = link_to _('Cancel'), '#', class: "btn btn-cancel", "data-dismiss" => "modal" - = link_to _('Fork project'), fork_path, class: 'btn btn-success', method: :post + = link_to _('Fork project'), fork_path, class: 'btn btn-success', data: { qa_selector: 'fork_project_button' }, method: :post diff --git a/app/views/shared/_confirm_modal.html.haml b/app/views/shared/_confirm_modal.html.haml index ecb462205b0..dc95bcdc756 100644 --- a/app/views/shared/_confirm_modal.html.haml +++ b/app/views/shared/_confirm_modal.html.haml @@ -17,5 +17,5 @@ .form-group = text_field_tag 'confirm_name_input', '', class: 'form-control js-confirm-danger-input qa-confirm-input' - .form-actions + .form-actions.gl-display-flex.gl-justify-content-end = submit_tag _('Confirm'), class: "btn btn-danger js-confirm-danger-submit qa-confirm-button" diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml index 25c841d2344..ffc34ff34c3 100644 --- a/app/views/shared/_delete_label_modal.html.haml +++ b/app/views/shared/_delete_label_modal.html.haml @@ -8,7 +8,7 @@ .modal-body %p - = _('<strong>%{label_name}</strong> <span>will be permanently deleted from %{subject_name}. This cannot be undone.</span>').html_safe % { label_name: label.name, subject_name: label.subject_name } + = html_escape(_('%{label_name} %{span_open}will be permanently deleted from %{subject_name}. This cannot be undone.%{span_close}')) % { label_name: tag.strong(label.name), subject_name: label.subject_name, span_open: '<span>'.html_safe, span_close: '</span>'.html_safe } .modal-footer %a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }= _('Cancel') diff --git a/app/views/shared/_field.html.haml b/app/views/shared/_field.html.haml deleted file mode 100644 index 076c87400e0..00000000000 --- a/app/views/shared/_field.html.haml +++ /dev/null @@ -1,29 +0,0 @@ -- name = field[:name] -- title = field[:title] || name.humanize -- value = @service.send(name) -- type = field[:type] -- placeholder = field[:placeholder] -- autocomplete = field[:autocomplete] -- required = field[:required] -- choices = field[:choices] -- default_choice = field[:default_choice] -- help = field[:help] - -.form-group.row - - if type == "password" && value.present? - = form.label name, _("Enter new %{field_title}") % { field_title: title.downcase }, class: "col-form-label col-sm-2" - - else - = form.label name, title, class: "col-form-label col-sm-2" - .col-sm-10 - - if type == 'text' - = form.text_field name, class: "form-control", autocomplete: autocomplete, placeholder: placeholder, required: required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - - elsif type == 'textarea' - = form.text_area name, rows: 5, class: "form-control", placeholder: placeholder, required: required - - elsif type == 'checkbox' - = form.check_box name - - elsif type == 'select' - = form.select name, options_for_select(choices, value || default_choice), {}, { class: "form-control"} - - elsif type == 'password' - = form.password_field name, autocomplete: "new-password", placeholder: placeholder, class: "form-control", required: value.blank? && required, data: { qa_selector: "#{name.downcase.gsub('\s', '')}_field" } - - if help - %span.form-text.text-muted= help diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index b9952d6832f..a99c992af49 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -1,7 +1,7 @@ .file-content.code.js-syntax-highlight .line-numbers - if blob.data.present? - - link_icon = icon('link') + - link_icon = sprite_icon('link', size: 12) - link = blob_link if defined?(blob_link) - blob.data.each_line.each_with_index do |_, index| - offset = defined?(first_line_number) ? first_line_number : 1 diff --git a/app/views/shared/_group_form.html.haml b/app/views/shared/_group_form.html.haml index 09b9cd448bb..d497937833a 100644 --- a/app/views/shared/_group_form.html.haml +++ b/app/views/shared/_group_form.html.haml @@ -31,11 +31,11 @@ = _('Group path is already taken. Suggestions: ') %span.gl-path-suggestions %p.validation-success.gl-field-success.field-validation.hide= _('Group path is available.') - %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group path availability...') + %p.validation-pending.gl-field-error-ignore.field-validation.hide= _('Checking group URL availability...') - if @group.persisted? - .alert.alert-warning.prepend-top-10 - = _('Changing group path can have unintended side effects.') + .alert.alert-warning.gl-mt-3 + = _('Changing group URL can have unintended side effects.') = succeed '.' do = link_to _('Learn more'), help_page_path('user/group/index', anchor: 'changing-a-groups-path'), target: '_blank' diff --git a/app/views/shared/_import_form.html.haml b/app/views/shared/_import_form.html.haml index b2ea45d6f1a..36d8aab6d53 100644 --- a/app/views/shared/_import_form.html.haml +++ b/app/views/shared/_import_form.html.haml @@ -26,8 +26,8 @@ .well-segment %ul %li - = _('The repository must be accessible over <code>http://</code>, <code>https://</code> or <code>git://</code>.').html_safe - %li= _('When using the <code>http://</code> or <code>https://</code> protocols, please provide the exact URL to the repository. HTTP redirects will not be followed.').html_safe + = html_escape(_('The repository must be accessible over %{code_open}http://%{code_close}, %{code_open}https://%{code_close} or %{code_open}git://%{code_close}.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } + %li= html_escape(_('When using the %{code_open}http://%{code_close} or %{code_open}https://%{code_close} protocols, please provide the exact URL to the repository. HTTP redirects will not be followed.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } %li = _('If your HTTP repository is not publicly accessible, add your credentials.') %li diff --git a/app/views/shared/_issuable_meta_data.html.haml b/app/views/shared/_issuable_meta_data.html.haml index d704eae2090..3eb27f002ef 100644 --- a/app/views/shared/_issuable_meta_data.html.haml +++ b/app/views/shared/_issuable_meta_data.html.haml @@ -11,15 +11,15 @@ - if upvotes > 0 %li.issuable-upvotes.d-none.d-sm-block.has-tooltip{ title: _('Upvotes') } - = sprite_icon('thumb-up', size: 16, css_class: "vertical-align-middle") + = sprite_icon('thumb-up', css_class: "vertical-align-middle") = upvotes - if downvotes > 0 %li.issuable-downvotes.d-none.d-sm-block.has-tooltip{ title: _('Downvotes') } - = sprite_icon('thumb-down', size: 16, css_class: "vertical-align-middle") + = sprite_icon('thumb-down', css_class: "vertical-align-middle") = downvotes %li.issuable-comments.d-none.d-sm-block - = link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count.zero?)], title: _('Comments') do - = sprite_icon('comments', size: 16, css_class: 'gl-vertical-align-text-bottom') + = link_to issuable_path, class: ['has-tooltip', ('no-comments' if note_count == 0)], title: _('Comments') do + = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = note_count diff --git a/app/views/shared/_issues.html.haml b/app/views/shared/_issues.html.haml index a21dcabb485..0f38d0e3b39 100644 --- a/app/views/shared/_issues.html.haml +++ b/app/views/shared/_issues.html.haml @@ -1,5 +1,5 @@ - if @issues.to_a.any? - .card.card-small.card-without-border + .card.card-without-border %ul.content-list.issues-list.issuable-list{ class: ("manual-ordering" if @sort == 'relative_position'), data: { group_full_path: @group&.full_path } } = render partial: 'projects/issues/issue', collection: @issues = paginate @issues, theme: "gitlab" diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index c3818b9f7ae..c7c36d79fa0 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -2,7 +2,7 @@ - if defined?(@merge_request) && @merge_request.discussion_locked? .issuable-note-warning - = sprite_icon('lock', size: 16, css_class: 'icon') + = sprite_icon('lock', css_class: 'icon') %span = _('This merge request is locked.') = _('Only project members can comment.') diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml index 700ec4b606f..d280df8b370 100644 --- a/app/views/shared/_merge_requests.html.haml +++ b/app/views/shared/_merge_requests.html.haml @@ -1,5 +1,5 @@ - if @merge_requests.to_a.any? - .card.card-small.card-without-border + .card.card-without-border %ul.content-list.mr-list.issuable-list = render partial: 'projects/merge_requests/merge_request', collection: @merge_requests diff --git a/app/views/shared/_new_project_item_select.html.haml b/app/views/shared/_new_project_item_select.html.haml index ffa61c9d1a9..47fb38d979d 100644 --- a/app/views/shared/_new_project_item_select.html.haml +++ b/app/views/shared/_new_project_item_select.html.haml @@ -1,7 +1,7 @@ - if any_projects?(@projects) .project-item-select-holder.btn-group %a.btn.btn-success.new-project-item-link.qa-new-project-item-link{ href: '', data: { label: local_assigns[:label], type: local_assigns[:type] } } - = icon('spinner spin') + = loading_icon(color: 'light') = project_select_tag :project_path, class: "project-item-select", data: { include_groups: local_assigns[:include_groups], order_by: 'last_activity_at', relative_path: local_assigns[:path], with_shared: local_assigns[:with_shared], include_projects_in_subgroups: local_assigns[:include_projects_in_subgroups] }, with_feature_enabled: local_assigns[:with_feature_enabled] - %button.btn.btn-success.new-project-item-select-button.qa-new-project-item-select-button - = icon('caret-down') + %button.btn.btn-success.new-project-item-select-button.qa-new-project-item-select-button.gl-p-0 + = sprite_icon('chevron-down') diff --git a/app/views/shared/_no_ssh.html.haml b/app/views/shared/_no_ssh.html.haml index 2b04e3e1c98..abf39fdc644 100644 --- a/app/views/shared/_no_ssh.html.haml +++ b/app/views/shared/_no_ssh.html.haml @@ -1,8 +1,8 @@ - if show_no_ssh_key_message? %div{ class: 'no-ssh-key-message gl-alert gl-alert-warning', role: 'alert' } - = sprite_icon('warning', size: 16, css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') + = sprite_icon('warning', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title') %button{ class: 'gl-alert-dismiss hide-no-ssh-message', type: 'button', 'aria-label': _('Dismiss') } - = sprite_icon('close', size: 16, css_class: 'gl-icon s16') + = sprite_icon('close', css_class: 'gl-icon s16') .gl-alert-body = s_("MissingSSHKeyWarningLink|You won't be able to pull or push project code via SSH until you add an SSH key to your profile").html_safe .gl-alert-actions diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml index 30255e18f04..624cc99440c 100644 --- a/app/views/shared/_outdated_browser.html.haml +++ b/app/views/shared/_outdated_browser.html.haml @@ -1,6 +1,6 @@ - if outdated_browser? .gl-alert.gl-alert-danger.outdated-browser{ :role => "alert" } - = sprite_icon('error', size: 16, css_class: "gl-alert-icon gl-alert-icon-no-title gl-icon") + = sprite_icon('error', css_class: "gl-alert-icon gl-alert-icon-no-title gl-icon") .gl-alert-body - if browser.ie? && browser.version.to_i == 11 - feedback_link_url = 'https://gitlab.com/gitlab-org/gitlab/issues/197987' diff --git a/app/views/shared/_service_settings.html.haml b/app/views/shared/_service_settings.html.haml index 7d93dca22f5..2425bcf61d9 100644 --- a/app/views/shared/_service_settings.html.haml +++ b/app/views/shared/_service_settings.html.haml @@ -11,28 +11,3 @@ - if @admin_integration .js-vue-admin-integration-settings{ data: integration_form_data(@admin_integration) } .js-vue-integration-settings{ data: integration_form_data(integration) } - - - if show_service_trigger_events?(integration) - .form-group.row - %label.col-form-label.col-sm-2= _('Trigger') - - .col-sm-10 - - integration.configurable_events.each do |event| - .form-group - .form-check - = form.check_box service_event_field_name(event), class: 'form-check-input' - = form.label service_event_field_name(event), class: 'form-check-label' do - %strong - = event.humanize - - - field = integration.event_field(event) - - - if field - = form.text_field field[:name], class: "form-control", placeholder: field[:placeholder] - - %p.text-muted - = integration.class.event_description(event) - - - unless integration_form_refactor? - - integration.global_fields.each do |field| - = render 'shared/field', form: form, field: field diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index 1431966c83d..9d1970093b8 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -4,5 +4,5 @@ %span.collapse-text= _("Collapse sidebar") = button_tag class: 'close-nav-button', type: 'button' do - = sprite_icon('close', size: 16) + = sprite_icon('close') %span.collapse-text= _("Close sidebar") diff --git a/app/views/shared/_zen.html.haml b/app/views/shared/_zen.html.haml index 914409d0e65..66e0ecadb65 100644 --- a/app/views/shared/_zen.html.haml +++ b/app/views/shared/_zen.html.haml @@ -15,5 +15,5 @@ qa_selector: qa_selector } - else = text_area_tag attr, current_text, data: { qa_selector: qa_selector }, class: classes, placeholder: placeholder - %a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-700{ href: "#" } - = sprite_icon('compress', size: 16) + %a.zen-control.zen-control-leave.js-zen-leave.gl-text-gray-500{ href: "#" } + = sprite_icon('compress') diff --git a/app/views/shared/access_tokens/_table.html.haml b/app/views/shared/access_tokens/_table.html.haml index 55231cb9429..ceac4d1820d 100644 --- a/app/views/shared/access_tokens/_table.html.haml +++ b/app/views/shared/access_tokens/_table.html.haml @@ -42,7 +42,7 @@ = _('In %{time_to_now}') % { time_to_now: distance_of_time_in_words_to_now(token.expires_at) } - else %span.token-never-expires-label= _('Never') - %td= token.scopes.present? ? token.scopes.join(', ') : _('<no scopes selected>') + %td= token.scopes.present? ? token.scopes.join(', ') : html_escape_once(_('<no scopes selected>')).html_safe %td= link_to _('Revoke'), revoke_route_helper.call(token), method: :put, class: 'btn btn-danger float-right qa-revoke-button', data: { confirm: _('Are you sure you want to revoke this %{type}? This action cannot be undone.') % { type: type } } - else .settings-message.text-center diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index b68c7cd4d52..7a4c495e177 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -32,7 +32,7 @@ - else .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } } .boards-app-loading.w-100.text-center{ "v-if" => "loading" } - = icon("spinner spin 2x") + = loading_icon(css_class: 'gl-mb-3') %board{ "v-cloak" => "true", "v-for" => "list in state.lists", "ref" => "board", diff --git a/app/views/shared/boards/components/sidebar/_due_date.html.haml b/app/views/shared/boards/components/sidebar/_due_date.html.haml index 117d56b30f5..d8ed3b13bf1 100644 --- a/app/views/shared/boards/components/sidebar/_due_date.html.haml +++ b/app/views/shared/boards/components/sidebar/_due_date.html.haml @@ -1,9 +1,9 @@ .block.due_date - .title + .title.gl-h-5.gl-display-flex.gl-align-items-center = _("Due date") - if can_admin_issue? - = icon("spinner spin", class: "block-loading") - = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link float-right" + = loading_icon(css_class: 'gl-ml-2 block-loading') + = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link gl-ml-auto" .value .value-content %span.no-value{ "v-if" => "!issue.dueDate" } diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 58ffa3942ef..61f3ebcdba4 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -1,9 +1,9 @@ .block.labels - .title + .title.gl-h-5.gl-display-flex.gl-align-items-center = _("Labels") - if can_admin_issue? - = icon("spinner spin", class: "block-loading") - = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link float-right" + = loading_icon(css_class: 'gl-ml-2 block-loading') + = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link gl-ml-auto" .value.issuable-show-labels.dont-hide %span.no-value{ "v-if" => "issue.labels && issue.labels.length === 0" } = _("None") diff --git a/app/views/shared/boards/components/sidebar/_milestone.html.haml b/app/views/shared/boards/components/sidebar/_milestone.html.haml index b15d60002fc..510e05ce888 100644 --- a/app/views/shared/boards/components/sidebar/_milestone.html.haml +++ b/app/views/shared/boards/components/sidebar/_milestone.html.haml @@ -1,9 +1,9 @@ .block.milestone - .title + .title.gl-h-5.gl-display-flex.gl-align-items-center = _("Milestone") - if can_admin_issue? - = icon("spinner spin", class: "block-loading") - = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link float-right" + = loading_icon(css_class: 'gl-ml-2 block-loading') + = link_to _("Edit"), "#", class: "js-sidebar-dropdown-toggle edit-link gl-ml-auto" .value %span.no-value{ "v-if" => "!issue.milestone" } = _("None") diff --git a/app/views/shared/buttons/_project_feature_toggle.html.haml b/app/views/shared/buttons/_project_feature_toggle.html.haml index 0f630786455..321fbee1b35 100644 --- a/app/views/shared/buttons/_project_feature_toggle.html.haml +++ b/app/views/shared/buttons/_project_feature_toggle.html.haml @@ -12,5 +12,5 @@ - if yield.present? = yield %span.toggle-icon - = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked') - = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked') + = sprite_icon('status_success_borderless', size: 18, css_class: 'gl-text-blue-500 toggle-status-checked') + = sprite_icon('status_failed_borderless', size: 18, css_class: 'gl-text-gray-400 toggle-status-unchecked') diff --git a/app/views/shared/deploy_keys/_index.html.haml b/app/views/shared/deploy_keys/_index.html.haml index 358075b9e44..f2f577383f8 100644 --- a/app/views/shared/deploy_keys/_index.html.haml +++ b/app/views/shared/deploy_keys/_index.html.haml @@ -1,5 +1,5 @@ - expanded = expanded_by_default? -%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings' } } +%section.qa-deploy-keys-settings.settings.no-animate#js-deploy-keys-settings{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_keys_settings_content' } } .settings-header %h4= _('Deploy Keys') %button.btn.js-settings-toggle{ type: 'button' } diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml index 4e569050827..815967b0372 100644 --- a/app/views/shared/deploy_keys/_project_group_form.html.haml +++ b/app/views/shared/deploy_keys/_project_group_form.html.haml @@ -1,4 +1,4 @@ -= form_for [@project.namespace.becomes(Namespace), @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f| += form_for [@project.namespace, @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input container" } do |f| = form_errors(@deploy_keys.new_key) .form-group.row = f.label :title, class: "label-bold" @@ -20,5 +20,5 @@ %p.light.gl-mb-0 = _('Allow this key to push to repository as well? (Default only allows pull access.)') - .form-group.row - = f.submit "Add key", class: "btn-success btn" + .form-group.row.gl-display-flex.gl-justify-content-end + = f.submit _("Add key"), class: "btn-success btn" diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index 8d74e12e943..1eda439c9a5 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -45,5 +45,5 @@ = label_tag ("deploy_token_write_package_registry"), 'write_package_registry', class: 'label-bold form-check-label' .text-secondary= s_('DeployTokens|Allows write access to the package registry') - .gl-mt-3 + .gl-mt-3.gl-display-flex.gl-justify-content-end = f.submit s_('DeployTokens|Create deploy token'), class: 'btn btn-success qa-create-deploy-token' diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index 8203b378297..540b9b0054f 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expand_deploy_tokens_section?(@new_deploy_token) -%section.qa-deploy-tokens-settings.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings' } } +%section.qa-deploy-tokens-settings.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } } .settings-header %h4= s_('DeployTokens|Deploy Tokens') %button.btn.js-settings-toggle.qa-expand-deploy-keys{ type: 'button' } diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index d4e20805a2a..ad73442807e 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -23,7 +23,7 @@ In #{distance_of_time_in_words_to_now(token.expires_at)} - else %span.token-never-expires-label= _('Never') - %td= token.scopes.present? ? token.scopes.join(", ") : _('<no scopes selected>') + %td= token.scopes.present? ? token.scopes.join(", ") : html_escape_once(_('<no scopes selected>')).html_safe %td= link_to s_('DeployTokens|Revoke'), "#", class: "btn btn-danger float-right", data: { toggle: "modal", target: "#revoke-modal-#{token.id}"} = render 'shared/deploy_tokens/revoke_modal', token: token, group_or_project: group_or_project - else diff --git a/app/views/shared/empty_states/_deploy_keys.html.haml b/app/views/shared/empty_states/_deploy_keys.html.haml new file mode 100644 index 00000000000..da34b866aa6 --- /dev/null +++ b/app/views/shared/empty_states/_deploy_keys.html.haml @@ -0,0 +1,9 @@ +.empty-state.gl-display-flex.gl-flex-direction-column.gl-flex-wrap.gl-text-center + .gl-flex-grow-0.gl-flex-shrink-0 + .svg-250.svg-content + = image_tag 'illustrations/empty-state/empty-deploy-keys-lg.svg' + .gl-flex-grow-0.gl-flex-shrink-0 + .text-content.gl-mx-auto.gl-my-0.gl-p-5 + %h4.h4= _('Deploy keys allow read-only or read-write (if enabled) access to your repository') + %p= _('Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.') + = link_to _('New deploy key'), new_admin_deploy_key_path, class: 'btn btn-success btn-md gl-button' diff --git a/app/views/shared/empty_states/_issues.html.haml b/app/views/shared/empty_states/_issues.html.haml index eb5637acca0..3fd64291fb2 100644 --- a/app/views/shared/empty_states/_issues.html.haml +++ b/app/views/shared/empty_states/_issues.html.haml @@ -20,7 +20,7 @@ = _("To widen your search, change or remove filters above") - if show_new_issue_link?(@project) .text-center - = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", id: "new_issue_body_link" + = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success" - elsif is_opened_state && opened_issues_count == 0 && closed_issues_count > 0 %h4.text-center = _("There are no open issues") @@ -28,7 +28,7 @@ = _("To keep this project going, create a new issue") - if show_new_issue_link?(@project) .text-center - = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success", id: "new_issue_body_link" + = link_to _("New issue"), new_project_issue_path(@project), class: "btn btn-success" - elsif is_closed_state && opened_issues_count > 0 && closed_issues_count == 0 %h4.text-center = _("There are no closed issues") @@ -46,6 +46,16 @@ - if show_import_button = render 'projects/issues/import_csv/button', type: :text + %hr + %p.gl-text-center.gl-mb-0 + %strong + = s_('JiraService|Using Jira for issue tracking?') + %p.gl-text-center.gl-mb-0 + - jira_docs_link_url = help_page_url('user/project/integrations/jira', anchor: 'view-jira-issues-premium') + - jira_docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: jira_docs_link_url } + = html_escape(s_('JiraService|%{jira_docs_link_start}Enable the Jira integration%{jira_docs_link_end} to view your Jira issues in GitLab.')) % { jira_docs_link_start: jira_docs_link_start.html_safe, jira_docs_link_end: '</a>'.html_safe } + %p.gl-text-center.gl-mb-0.gl-text-gray-500 + = s_('JiraService|This feature requires a Premium plan.') - else %h4.text-center= _("There are no issues to show") diff --git a/app/views/shared/empty_states/_merge_requests.html.haml b/app/views/shared/empty_states/_merge_requests.html.haml index be5b1c6b6ce..837c3afc796 100644 --- a/app/views/shared/empty_states/_merge_requests.html.haml +++ b/app/views/shared/empty_states/_merge_requests.html.haml @@ -20,7 +20,7 @@ = _("To widen your search, change or remove filters above") .text-center - if can_create_merge_request - = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link" + = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request") - elsif is_opened_state && opened_merged_count == 0 && closed_merged_count > 0 %h4.text-center = _("There are no open merge requests") @@ -28,7 +28,7 @@ = _("To keep this project going, create a new merge request") .text-center - if can_create_merge_request - = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request"), id: "new_merge_request_body_link" + = link_to _("New merge request"), project_new_merge_request_path(@project), class: "btn btn-success", title: _("New merge request") - elsif is_closed_state && opened_merged_count > 0 && closed_merged_count == 0 %h4.text-center = _("There are no closed merge requests") diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index 5dac400bd5e..164773f9b60 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -16,14 +16,14 @@ .description = markdown_field(group, :description) - .stats.gl-text-gray-700.gl-flex-shrink-0 + .stats.gl-text-gray-500.gl-flex-shrink-0 %span.gl-ml-5 - = icon('bookmark') + = sprite_icon('bookmark', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(group.projects.non_archived.count) %span.gl-ml-5 - = icon('users') + = sprite_icon('users', css_class: 'gl-vertical-align-text-bottom') = number_with_delimiter(group.users.count) %span.gl-ml-5.visibility-icon.has-tooltip{ data: { container: 'body', placement: 'left' }, title: visibility_icon_description(group) } - = visibility_level_icon(group.visibility_level, fw: false) + = visibility_level_icon(group.visibility_level) diff --git a/app/views/shared/integrations/_form.html.haml b/app/views/shared/integrations/_form.html.haml index 4ec7f286c7a..5826cb280bd 100644 --- a/app/views/shared/integrations/_form.html.haml +++ b/app/views/shared/integrations/_form.html.haml @@ -1,14 +1,15 @@ - integration = local_assigns.fetch(:integration) -%h3.page-title - = integration.title - -%p= integration.description - -= form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors fieldset-form integration-settings-form js-integration-settings-form', data: { 'can-test' => integration.can_test?, 'test-url' => scoped_test_integration_path(integration) } } do |form| - = render 'shared/service_settings', form: form, integration: integration - - - if integration.editable? - .footer-block.row-content-block - = service_save_button - = link_to _('Cancel'), scoped_integration_path(integration), class: 'btn btn-cancel' +.row.gl-mt-3 + .col-lg-4 + %h3.page-title.gl-mt-0 + = integration.title + + .col-lg-8 + = form_for integration, as: :service, url: scoped_integration_path(integration), method: :put, html: { class: 'gl-show-field-errors integration-settings-form js-integration-settings-form', data: { 'can-test' => integration.can_test?, 'test-url' => scoped_test_integration_path(integration) } } do |form| + = render 'shared/service_settings', form: form, integration: integration + + - if integration.editable? + .footer-block.row-content-block + = service_save_button + = link_to _('Cancel'), scoped_integration_path(integration), class: 'btn btn-cancel' diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index cec865ec8de..8e46db6dea2 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -6,5 +6,5 @@ - issuable.assignees.take(render_count).each do |assignee| # rubocop: disable CodeReuse/ActiveRecord = link_to_member(@project, assignee, name: false, title: "Assigned to :name") -- if more_assignees_count.positive? - %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees", qa_selector: 'avatar_counter' } } +#{more_assignees_count} +- if more_assignees_count > 0 + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', 'original-title' => "+#{more_assignees_count} more assignees", qa_selector: 'avatar_counter_content' } } +#{more_assignees_count} diff --git a/app/views/shared/issuable/_bulk_update_sidebar.html.haml b/app/views/shared/issuable/_bulk_update_sidebar.html.haml index ec7ff127ed5..0c15d20bfe0 100644 --- a/app/views/shared/issuable/_bulk_update_sidebar.html.haml +++ b/app/views/shared/issuable/_bulk_update_sidebar.html.haml @@ -4,23 +4,24 @@ %aside.issues-bulk-update.js-right-sidebar.right-sidebar{ "aria-live" => "polite", data: { 'signed-in': current_user.present? } } .issuable-sidebar.hidden - = form_tag [:bulk_update, @project.namespace.becomes(Namespace), @project, type], method: :post, class: "bulk-update" do + = form_tag [:bulk_update, @project, type], method: :post, class: "bulk-update" do .block.issuable-sidebar-header .filter-item.inline.update-issues-btn.float-left = button_tag _('Update all'), class: "btn update-selected-issues btn-info", disabled: true = button_tag _('Cancel'), class: "btn btn-default js-bulk-update-menu-hide float-right" - .block - .title - = _('Status') - .filter-item - = dropdown_tag(_("Select status"), options: { toggle_class: "js-issue-status", title: _("Change status"), dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: _("Status") } } ) do - %ul - %li - %a{ href: "#", data: { id: "reopen" } } - = _('Open') - %li - %a{ href: "#", data: { id: "close" } } - = _('Closed') + - if params[:state] != 'merged' + .block + .title + = _('Status') + .filter-item + = dropdown_tag(_("Select status"), options: { toggle_class: "js-issue-status", title: _("Change status"), dropdown_class: "dropdown-menu-status dropdown-menu-selectable", data: { field_name: "update[state_event]", default_label: _("Status") } } ) do + %ul + %li + %a{ href: "#", data: { id: "reopen" } } + = _('Open') + %li + %a{ href: "#", data: { id: "close" } } + = _('Closed') .block .title = _('Assignee') diff --git a/app/views/shared/issuable/_feed_buttons.html.haml b/app/views/shared/issuable/_feed_buttons.html.haml index 4fed95e2607..86c2e243718 100644 --- a/app/views/shared/issuable/_feed_buttons.html.haml +++ b/app/views/shared/issuable/_feed_buttons.html.haml @@ -1,4 +1,4 @@ -= link_to safe_params.merge(rss_url_options), class: 'btn has-tooltip js-rss-button', data: { container: 'body' }, title: _('Subscribe to RSS feed') do - = sprite_icon('rss') += link_to safe_params.merge(rss_url_options), class: 'btn btn-svg has-tooltip', data: { container: 'body', testid: 'rss-feed-link' }, title: _('Subscribe to RSS feed') do + = sprite_icon('rss', css_class: 'qa-rss-icon') = link_to safe_params.merge(calendar_url_options), class: 'btn has-tooltip', data: { container: 'body' }, title: _('Subscribe to calendar') do = sprite_icon('calendar') diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index f54457b8b33..86cd2923fac 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -9,7 +9,7 @@ .alert.alert-danger Someone edited the #{issuable.class.model_name.human.downcase} the same time you did. Please check out - = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), target: "_blank", rel: 'noopener noreferrer' + = link_to "the #{issuable.class.model_name.human.downcase}", polymorphic_path([@project, issuable]), target: "_blank", rel: 'noopener noreferrer' and make sure your changes will not unintentionally remove theirs = render 'shared/issuable/form/branch_chooser', issuable: issuable, form: form @@ -63,11 +63,11 @@ .row-content-block{ class: (is_footer ? "footer-block" : "middle-block") } .float-right - if issuable.new_record? - = link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class]), class: 'btn btn-cancel' + = link_to 'Cancel', polymorphic_path([@project, issuable.class]), class: 'btn btn-cancel' - else - if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project) - = link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable], params: { destroy_confirm: true }), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped' - = link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel' + = link_to 'Delete', polymorphic_path([@project, issuable], params: { destroy_confirm: true }), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" }, method: :delete, class: 'btn btn-danger btn-grouped' + = link_to 'Cancel', polymorphic_path([@project, issuable]), class: 'btn btn-grouped btn-cancel' %span.gl-mr-3 - if issuable.new_record? @@ -76,11 +76,14 @@ = form.submit 'Save changes', class: 'btn btn-success' - if !issuable.persisted? && !issuable.project.empty_repo? && (guide_url = issuable.project.present.contribution_guide_path) - .inline.prepend-top-10 + .inline.gl-mt-3 Please review the %strong= link_to('contribution guidelines', guide_url) for this project. = render_if_exists 'shared/issuable/remove_approver' +- if issuable.respond_to?(:issue_type) + = form.hidden_field :issue_type + = form.hidden_field :lock_version diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 0b5700e5413..3f3b9146e71 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -20,7 +20,8 @@ .issues-other-filters.filtered-search-wrapper.d-flex.flex-column.flex-md-row .filtered-search-box - if type != :boards_modal && type != :boards - = dropdown_tag(_('Recent searches'), + - text = tag.span(sprite_icon('history'), class: "d-md-none") + tag.span(_('Recent searches'), class: "d-none d-md-inline") + = dropdown_tag(text, options: { wrapper_class: "filtered-search-history-dropdown-wrapper", toggle_class: "btn filtered-search-history-dropdown-toggle-button", dropdown_class: "filtered-search-history-dropdown", @@ -173,7 +174,7 @@ = render 'shared/issuable/board_create_list_dropdown', board: board - if @project #js-add-issues-btn.gl-ml-3{ data: { can_admin_list: can?(current_user, :admin_list, @project) } } - - if Feature.enabled?(:boards_with_swimlanes, @group) + - if current_user && Feature.enabled?(:boards_with_swimlanes, @group) #js-board-epics-swimlanes-toggle #js-toggle-focus-btn - elsif is_not_boards_modal_or_productivity_analytics && show_sorting_dropdown diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 00113b2c2c0..6f31d7290b7 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -32,7 +32,7 @@ - milestone = issuable_sidebar[:milestone] || {} .block.milestone{ data: { qa_selector: 'milestone_block' } } .sidebar-collapsed-icon.has-tooltip{ title: sidebar_milestone_tooltip_label(milestone), data: { container: 'body', html: 'true', placement: 'left', boundary: 'viewport' } } - = icon('clock-o', 'aria-hidden': 'true') + = sprite_icon('clock') %span.milestone-title.collapse-truncated-title - if milestone.present? = milestone[:title] diff --git a/app/views/shared/issuable/form/_branch_chooser.html.haml b/app/views/shared/issuable/form/_branch_chooser.html.haml index 1823c5279e5..30a1f0febc3 100644 --- a/app/views/shared/issuable/form/_branch_chooser.html.haml +++ b/app/views/shared/issuable/form/_branch_chooser.html.haml @@ -9,7 +9,7 @@ .form-group.row.d-flex.gl-pl-3-deprecated-no-really-do-not-use-me.gl-pr-3-deprecated-no-really-do-not-use-me.branch-selector .align-self-center %span - = _('From <code>%{source_title}</code> into').html_safe % { source_title: source_title } + = html_escape(_('From %{code_open}%{source_title}%{code_close} into')) % { source_title: source_title, code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - if issuable.new_record? %code#js-target-branch-title= target_title @@ -17,7 +17,9 @@ = link_to _('Change branches'), mr_change_branches_path(issuable) - elsif issuable.for_fork? %code= issuable.target_project_path + ":" - - unless issuable.new_record? + - if issuable.merged? + %code= target_title + - unless issuable.new_record? || issuable.merged? %span.dropdown.gl-ml-2.d-inline-block = form.hidden_field(:target_branch, { class: 'target_branch js-target-branch-select ref-name mw-xl', diff --git a/app/views/shared/issuable/form/_merge_params.html.haml b/app/views/shared/issuable/form/_merge_params.html.haml index 6f1023474a1..5c5c8c816d3 100644 --- a/app/views/shared/issuable/form/_merge_params.html.haml +++ b/app/views/shared/issuable/form/_merge_params.html.haml @@ -27,5 +27,5 @@ Squash commits when merge request is accepted. = link_to icon('question-circle'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank' - if project.squash_always? - .gl-text-gray-600 + .gl-text-gray-400 = _('Required in this project.') diff --git a/app/views/shared/issuable/form/_title.html.haml b/app/views/shared/issuable/form/_title.html.haml index 355a6627b8f..98c9f73fa3a 100644 --- a/app/views/shared/issuable/form/_title.html.haml +++ b/app/views/shared/issuable/form/_title.html.haml @@ -3,6 +3,13 @@ - form = local_assigns.fetch(:form) - no_issuable_templates = issuable_templates(issuable).empty? - div_class = no_issuable_templates ? 'col-sm-10' : 'col-sm-7 col-lg-8' +- toggle_wip_link_start = '<a href="" class="js-toggle-wip">' +- toggle_wip_link_end = '</a>' +- draft_snippet = '<code>Draft:</code>'.html_safe +- wip_snippet = '<code>WIP:</code>'.html_safe +- draft_or_wip_snippet = '<code>Draft/WIP</code>'.html_safe +- add_wip_text = (_('%{link_start}Start the title with %{draft_snippet} or %{wip_snippet}%{link_end} to prevent a merge request that is a work in progress from being merged before it\'s ready.') % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_snippet: draft_snippet, wip_snippet: wip_snippet } ).html_safe +- remove_wip_text = (_('%{link_start}Remove the %{draft_or_wip_snippet} prefix%{link_end} from the title to allow this merge request to be merged when it\'s ready.' ) % { link_start: toggle_wip_link_start, link_end: toggle_wip_link_end, draft_or_wip_snippet: draft_or_wip_snippet } ).html_safe %div{ class: div_class } = form.text_field :title, required: true, maxlength: 255, autofocus: true, @@ -11,23 +18,12 @@ - if issuable.respond_to?(:work_in_progress?) .form-text.text-muted .js-wip-explanation - %a.js-toggle-wip{ href: '' } - Remove the - %code WIP: - prefix from the title - to allow this - %strong Work In Progress - merge request to be merged when it's ready. + = remove_wip_text .js-no-wip-explanation - if has_wip_commits - It looks like you have some WIP commits in this branch. + = _('It looks like you have some draft commits in this branch.') %br - %a.js-toggle-wip{ href: '' } - Start the title with - %code WIP: - to prevent a - %strong Work In Progress - merge request from being merged before it's ready. + = add_wip_text - if no_issuable_templates && can?(current_user, :push_code, issuable.project) = render 'shared/issuable/form/default_templates' diff --git a/app/views/shared/members/_filter_2fa_dropdown.html.haml b/app/views/shared/members/_filter_2fa_dropdown.html.haml index 44ea844028e..a2bc5e9ecdf 100644 --- a/app/views/shared/members/_filter_2fa_dropdown.html.haml +++ b/app/views/shared/members/_filter_2fa_dropdown.html.haml @@ -1,6 +1,6 @@ - filter = params[:two_factor] || 'everyone' - filter_options = { 'everyone' => _('Everyone'), 'enabled' => _('Enabled'), 'disabled' => _('Disabled') } -.dropdown.inline.member-filter-2fa-dropdown.pr-md-2 +.dropdown.inline.member-filter-2fa-dropdown = dropdown_toggle(filter_options[filter], { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index 1d7d18d2ab6..f59e0f92c60 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -46,4 +46,4 @@ class: 'btn btn-remove m-0 ml-sm-2 align-self-center' do %span.d-block.d-sm-none = _("Delete") - = icon('trash', class: 'd-none d-sm-block') + = sprite_icon('remove', css_class: 'd-none d-sm-block') diff --git a/app/views/shared/members/_invite_group.html.haml b/app/views/shared/members/_invite_group.html.haml index 27c930bcbb5..a2fb33aa757 100644 --- a/app/views/shared/members/_invite_group.html.haml +++ b/app/views/shared/members/_invite_group.html.haml @@ -14,7 +14,7 @@ .select-wrapper = select_tag group_access_field, options_for_select(access_levels, default_access_level), data: { qa_selector: 'group_access_field' }, class: "form-control select-control" = icon('chevron-down') - .form-text.text-muted.append-bottom-10 + .form-text.text-muted.gl-mb-3 - permissions_docs_path = help_page_path('user/permissions') - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } diff --git a/app/views/shared/members/_invite_member.html.haml b/app/views/shared/members/_invite_member.html.haml index d3a1c85e285..284d7fdb6da 100644 --- a/app/views/shared/members/_invite_member.html.haml +++ b/app/views/shared/members/_invite_member.html.haml @@ -14,7 +14,7 @@ .select-wrapper = select_tag :access_level, options_for_select(access_levels, default_access_level), class: "form-control project-access-select select-control" = icon('chevron-down') - .form-text.text-muted.append-bottom-10 + .form-text.text-muted.gl-mb-3 - permissions_docs_path = help_page_path('user/permissions') - link_start = %q{<a href="%{url}">}.html_safe % { url: permissions_docs_path } = _("%{link_start}Read more%{link_end} about role permissions").html_safe % { link_start: link_start, link_end: '</a>'.html_safe } diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 79dc3043e8d..fa71f4dc9b9 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -33,7 +33,7 @@ - if source.instance_of?(Group) && source != @group · - = link_to source.full_name, source, class: "member-group-link" + = link_to source.full_name, source, class: "gl-display-inline-block inline-link" .cgray - if member.request? @@ -62,7 +62,7 @@ - if show_controls && member.source == current_resource - if member.can_resend_invite? - = link_to sprite_icon('paper-airplane', size: 16), polymorphic_path([:resend_invite, member]), + = link_to sprite_icon('paper-airplane'), polymorphic_path([:resend_invite, member]), method: :post, class: 'btn btn-default align-self-center mr-sm-2', title: _('Resend invite') @@ -113,18 +113,17 @@ - if member.can_remove? - if current_user == user - = link_to icon('sign-out', text: _('Leave')), polymorphic_path([:leave, member.source, :members]), - method: :delete, - data: { confirm: leave_confirmation_message(member.source) }, - class: "btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}" + = link_to polymorphic_path([:leave, member.source, :members]), method: :delete, data: { confirm: leave_confirmation_message(member.source) }, class: "btn gl-button btn-svg btn-danger align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}" do + = sprite_icon('leave', css_class: 'gl-icon') + = _('Leave') - else %button{ data: { member_path: member_path(member.member), message: remove_member_message(member), is_access_request: member.request?.to_s, qa_selector: 'delete_member_button' }, - class: "js-remove-member-button btn btn-remove align-self-center m-0 #{'ml-sm-2' unless force_mobile_view}", + class: "js-remove-member-button btn gl-button btn-danger align-self-center m-0 #{'ml-sm-2 btn-icon' unless force_mobile_view}", title: remove_member_title(member) } %span{ class: ('d-block d-sm-none' unless force_mobile_view) } = _("Delete") - unless force_mobile_view - = icon('trash', class: 'd-none d-sm-block') + = sprite_icon('remove', css_class: 'd-none d-sm-block gl-icon') = render_if_exists 'shared/members/ee/override_member_buttons', group: @group, member: member, user: user, action: :edit, can_override: member.can_override? - else %span.member-access-text.user-access-role= member.human_access diff --git a/app/views/shared/members/_search_field.html.haml b/app/views/shared/members/_search_field.html.haml new file mode 100644 index 00000000000..e70cb063324 --- /dev/null +++ b/app/views/shared/members/_search_field.html.haml @@ -0,0 +1,6 @@ +- name = local_assigns.fetch(:name, :search) + +.search-control-wrap.gl-relative + = search_field_tag name, params[name], { placeholder: _('Search'), class: 'form-control', spellcheck: false } + %button.user-search-btn.border-left.gl-display-flex.gl-align-items-center.gl-justify-content-center{ type: 'submit', 'aria': { label: _('Submit search') } } + = sprite_icon('search') diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml index 50a55565c3c..606d3bcdfa8 100644 --- a/app/views/shared/members/_sort_dropdown.html.haml +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -1,4 +1,3 @@ -= label_tag :sort_by, 'Sort by', class: 'col-form-label label-bold px-2' .dropdown.inline.qa-user-sort-dropdown = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable diff --git a/app/views/shared/milestones/_deprecation_message.html.haml b/app/views/shared/milestones/_deprecation_message.html.haml deleted file mode 100644 index 27cd6d75232..00000000000 --- a/app/views/shared/milestones/_deprecation_message.html.haml +++ /dev/null @@ -1,15 +0,0 @@ -.banner-callout.compact.milestone-deprecation-message.js-milestone-deprecation-message.prepend-top-20 - .banner-graphic= image_tag 'illustrations/milestone_removing-page.svg' - .banner-body.gl-ml-3.gl-mr-3 - %h5.banner-title.gl-mt-0= _('This page will be removed in a future release.') - %p.milestone-banner-text= _('Use group milestones to manage issues from multiple projects in the same milestone.') - = button_tag _('Promote these project milestones into a group milestone.'), class: 'btn btn-link js-popover-link text-align-left milestone-banner-link' - .milestone-banner-buttons.prepend-top-20= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-default', target: '_blank' - - %template.js-milestone-deprecation-message-template - .milestone-popover-body - %ol.milestone-popover-instructions-list.gl-mb-0 - %li= _('Click any <strong>project name</strong> in the project list below to navigate to the project milestone.').html_safe - %li= _('Click the <strong>Promote</strong> button in the top right corner to promote it to a group milestone.').html_safe - %hr.popover-hr - .milestone-popover-footer= link_to _('Learn more'), help_page_url('user/project/milestones/index', anchor: 'promoting-project-milestones-to-group-milestones'), class: 'btn btn-link prepend-left-0', target: '_blank' diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index ae5bf9572bd..4ef8a9dd842 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -15,7 +15,7 @@ .text-tertiary.gl-mb-2 = milestone_date_range(milestone) - recent_releases, total_count, more_count = recent_releases_with_counts(milestone) - - unless total_count.zero? + - unless total_count == 0 .text-tertiary.gl-mb-2.milestone-release-links = sprite_icon("rocket", size: 12) = n_('Release', 'Releases', total_count) diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 7fd657ec2dd..bdacdb23141 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -140,11 +140,11 @@ .block.releases .sidebar-collapsed-icon.has-tooltip{ title: milestone_releases_tooltip_text(milestone), data: { container: 'body', placement: 'left', boundary: 'viewport' } } %strong - = sprite_icon("rocket", size: 16) + = sprite_icon("rocket") %span= total_count .title.hide-collapsed= n_('Release', 'Releases', total_count) .hide-collapsed - - if total_count.zero? + - if total_count == 0 .no-value= s_('MilestoneSidebar|None') - else .font-weight-bold diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml index abd5d8cd9db..51c1ee0c4d1 100644 --- a/app/views/shared/notes/_hints.html.haml +++ b/app/views/shared/notes/_hints.html.haml @@ -12,7 +12,7 @@ %span.uploading-container %span.uploading-progress-container.hide - = sprite_icon('media', size: 16, css_class: 'gl-icon gl-vertical-align-text-bottom') + = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') %span.attaching-file-message -# Populated by app/assets/javascripts/dropzone_input.js %span.uploading-progress 0% @@ -20,7 +20,7 @@ %span.uploading-error-container.hide %span.uploading-error-icon - = sprite_icon('media', size: 16, css_class: 'gl-icon gl-vertical-align-text-bottom') + = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') %span.uploading-error-message -# Populated by app/assets/javascripts/dropzone_input.js %button.retry-uploading-link{ type: 'button' }= _("Try again") @@ -28,7 +28,7 @@ %button.attach-new-file.markdown-selector{ type: 'button' }= _("attach a new file") %button.btn.markdown-selector.button-attach-file.btn-link{ type: 'button', tabindex: '-1' } - = sprite_icon('media', size: 16) + = sprite_icon('media') %span.text-attach-file<> = _("Attach a file") diff --git a/app/views/shared/notes/_note.html.haml b/app/views/shared/notes/_note.html.haml index 95450a5df3c..da665f17975 100644 --- a/app/views/shared/notes/_note.html.haml +++ b/app/views/shared/notes/_note.html.haml @@ -63,7 +63,8 @@ - if note.system .system-note-commit-list-toggler.hide = _("Toggle commit list") - %i.fa.fa-angle-down + = sprite_icon('chevron-down', css_class: 'js-chevron-down gl-ml-1 gl-vertical-align-text-bottom') + = sprite_icon('chevron-up', css_class: 'js-chevron-up gl-ml-1 gl-vertical-align-text-bottom gl-display-none') - if note.attachment.url .note-attachment - if note.attachment.image? diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index fa103ad447a..2e98b06ec4a 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -25,8 +25,8 @@ - elsif discussion_locked .disabled-comment.text-center.gl-mt-3 %span.issuable-note-warning - = sprite_icon('lock', size: 16, css_class: 'icon') + = sprite_icon('lock', css_class: 'icon') %span - = _("This %{issuable} is locked. Only <strong>project members</strong> can comment.").html_safe % { issuable: issuable.class.to_s.titleize.downcase } + = html_escape(_("This %{issuable} is locked. Only %{strong_open}project members%{strong_close} can comment.")) % { issuable: issuable.class.to_s.titleize.downcase, strong_open: '<strong>'.html_safe, strong_close: '</strong>'.html_safe } -# haml-lint:disable InlineJavaScript %script.js-notes-data{ type: "application/json" }= initial_notes_data(autocomplete).to_json.html_safe diff --git a/app/views/shared/notifications/_button.html.haml b/app/views/shared/notifications/_button.html.haml index 2b3e986a841..f2c7ab648c0 100644 --- a/app/views/shared/notifications/_button.html.haml +++ b/app/views/shared/notifications/_button.html.haml @@ -17,16 +17,16 @@ .js-notification-toggle-btns %div{ class: ("btn-group" if notification_setting.custom?) } - if notification_setting.custom? - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } - = icon("bell", class: "js-notification-loading") + %button.dropdown-new.btn.btn-defaul.btn-icon.gl-button.has-tooltip.notifications-btn.text-left#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), display: 'static' } } + = sprite_icon("notifications", css_class: "js-notification-loading") = notification_title(notification_setting.level) %button.btn.dropdown-toggle.d-flex{ data: { toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } = icon('caret-down') .sr-only Toggle dropdown - else - %button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } + %button.dropdown-new.btn.btn-default.btn-icon.gl-button.has-tooltip.notifications-btn#notifications-button{ type: "button", title: button_title, class: "#{btn_class}", "aria-label" => aria_label, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } } .float-left - = icon("bell", class: "js-notification-loading") + = sprite_icon("notifications", css_class: "js-notification-loading") = notification_title(notification_setting.level) .float-right = icon("caret-down") diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml index 9bd08c2296f..51b7da7dee8 100644 --- a/app/views/shared/notifications/_custom_notifications.html.haml +++ b/app/views/shared/notifications/_custom_notifications.html.haml @@ -23,7 +23,6 @@ #{ paragraph.html_safe } .col-lg-8 - notification_setting.email_events.each_with_index do |event, index| - - next if notification_event_disabled?(event) - field_id = "#{notifications_menu_identifier("modal", notification_setting)}_notification_setting[#{event}]" .form-group .form-check{ class: ("gl-mt-0" if index == 0) } diff --git a/app/views/shared/packages/_no_packages.html.haml b/app/views/shared/packages/_no_packages.html.haml new file mode 100644 index 00000000000..ae5c2cfd378 --- /dev/null +++ b/app/views/shared/packages/_no_packages.html.haml @@ -0,0 +1,7 @@ +.svg-content= image_tag 'illustrations/no-packages.svg' +.text-content + %h4.text-center= _('There are no packages yet') + %p + - no_packages_url = help_page_path('administration/packages/index') + - no_packages_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: no_packages_url } + = _('Learn how to %{no_packages_link_start}publish and share your packages%{no_packages_link_end} with GitLab.').html_safe % { no_packages_link_start: no_packages_link_start, no_packages_link_end: '</a>'.html_safe } diff --git a/app/views/shared/projects/_archived.html.haml b/app/views/shared/projects/_archived.html.haml index fad93d14390..f24fe3a8b89 100644 --- a/app/views/shared/projects/_archived.html.haml +++ b/app/views/shared/projects/_archived.html.haml @@ -1,3 +1,3 @@ - if project.archived - %span.d-flex.badge.badge-warning + %span.d-flex.badge-pill.gl-badge.badge-warning.gl-ml-3 = _('archived') diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 626e94e0202..115d0c9a7c5 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -26,7 +26,7 @@ = image_tag avatar_icon_for_user(project.creator, 48), class: "avatar s48", alt:'' - else = project_icon(project, alt: '', class: 'avatar project-avatar s48', width: 48, height: 48) - .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project', qa_project_name: project.name } } + .project-details.d-sm-flex.flex-sm-fill.align-items-center{ data: { qa_selector: 'project_content', qa_project_name: project.name } } .flex-wrapper .d-flex.align-items-center.flex-wrap.project-title %h2.d-flex.gl-mt-3 @@ -40,7 +40,7 @@ = project.name %span.metadata-info.visibility-icon.gl-mr-3.gl-mt-3.text-secondary.has-tooltip{ data: { container: 'body', placement: 'top' }, title: visibility_icon_description(project) } - = visibility_level_icon(project.visibility_level, fw: true) + = visibility_level_icon(project.visibility_level) - if explore_projects_tab? && project_license_name(project) %span.metadata-info.d-inline-flex.align-items-center.gl-mr-3.gl-mt-3 @@ -51,7 +51,7 @@ -# haml-lint:disable UnnecessaryStringOutput = ' ' # prevent haml from eating the space between elements .metadata-info.gl-mt-3 - %span.user-access-role.d-block= Gitlab::Access.human_access(access) + %span.user-access-role.d-block{ data: { qa_selector: 'user_role_content' } }= Gitlab::Access.human_access(access) - if !explore_projects_tab? .metadata-info.gl-mt-3 @@ -64,6 +64,8 @@ .description.d-none.d-sm-block.gl-mr-3 = markdown_field(project, :description) + = render_if_exists 'shared/projects/removed', project: project + .controls.d-flex.flex-sm-column.align-items-center.align-items-sm-end.flex-wrap.flex-shrink-0.text-secondary{ class: css_controls_class.join(" ") } .icon-container.d-flex.align-items-center - if show_pipeline_status_icon diff --git a/app/views/shared/projects/_search_bar.html.haml b/app/views/shared/projects/_search_bar.html.haml index c1f2eaba284..a745da32110 100644 --- a/app/views/shared/projects/_search_bar.html.haml +++ b/app/views/shared/projects/_search_bar.html.haml @@ -14,7 +14,7 @@ .filtered-search-box-input-container.pl-2 = render 'shared/projects/search_form', admin_view: false, search_form_placeholder: _("Search projects...") %button.btn.btn-secondary{ type: 'submit', form: 'project-filter-form' } - = sprite_icon('search', size: 16, css_class: 'search-icon ') + = sprite_icon('search', css_class: 'search-icon ') .filtered-search-dropdown.flex-row.align-items-center.mb-2.m-sm-0#filtered-search-visibility-dropdown{ class: flex_grow_and_shrink_xs } .filtered-search-dropdown-label.p-0.pl-sm-3.font-weight-bold %span @@ -25,4 +25,3 @@ %span = _("Sort by") = render 'shared/projects/sort_dropdown' - diff --git a/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml new file mode 100644 index 00000000000..eafc402f210 --- /dev/null +++ b/app/views/shared/projects/protected_branches/_update_protected_branch.html.haml @@ -0,0 +1,35 @@ +- merge_access_levels = protected_branch.merge_access_levels.for_role +- push_access_levels = protected_branch.push_access_levels.for_role + +- user_merge_access_levels = protected_branch.merge_access_levels.for_user +- user_push_access_levels = protected_branch.push_access_levels.for_user + +- group_merge_access_levels = protected_branch.merge_access_levels.for_group +- group_push_access_levels = protected_branch.push_access_levels.for_group + +%td.merge_access_levels-container + = hidden_field_tag "allowed_to_merge_#{protected_branch.id}", merge_access_levels.first&.access_level + = dropdown_tag( (merge_access_levels.first&.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-merge qa-allowed-to-merge', dropdown_class: 'dropdown-menu-selectable js-allowed-to-merge-container capitalize-header', + data: { field_name: "allowed_to_merge_#{protected_branch.id}", preselected_items: access_levels_data(merge_access_levels) }}) + - if user_merge_access_levels.any? + %p.small + = _('The following %{user} can also merge into this branch: %{branch}') % { user: 'user'.pluralize(user_merge_access_levels.size), branch: user_merge_access_levels.map(&:humanize).to_sentence } + + - if group_merge_access_levels.any? + %p.small + = _('Members of %{group} can also merge into this branch: %{branch}') % { group: (group_merge_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_merge_access_levels.map(&:humanize).to_sentence } + +%td.push_access_levels-container + = hidden_field_tag "allowed_to_push_#{protected_branch.id}", push_access_levels.first&.access_level + = dropdown_tag( (push_access_levels.first&.humanize || 'Select') , + options: { toggle_class: 'js-allowed-to-push', dropdown_class: 'dropdown-menu-selectable js-allowed-to-push-container capitalize-header', + data: { field_name: "allowed_to_push_#{protected_branch.id}", preselected_items: access_levels_data(push_access_levels) }}) + - if user_push_access_levels.any? + %p.small + = _('The following %{user} can also push to this branch: %{branch}') % { user: 'user'.pluralize(user_push_access_levels.size), branch: user_push_access_levels.map(&:humanize).to_sentence } + + - if group_push_access_levels.any? + %p.small + = _('Members of %{group} can also push to this branch: %{branch}') % { group: (group_push_access_levels.size > 1 ? 'these groups' : 'this group'), branch: group_push_access_levels.map(&:humanize).to_sentence } + diff --git a/app/views/shared/promotions/_promote_servicedesk.html.haml b/app/views/shared/promotions/_promote_servicedesk.html.haml index f7f65c34c75..fbac5ef0bbd 100644 --- a/app/views/shared/promotions/_promote_servicedesk.html.haml +++ b/app/views/shared/promotions/_promote_servicedesk.html.haml @@ -5,9 +5,9 @@ .svg-container = custom_icon('icon_service_desk') .user-callout-copy - -# haml-lint:disable NoPlainNodes %h4 - Improve customer support with GitLab Service Desk. + = _("Improve customer support with GitLab Service Desk.") %p - GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email. - = link_to 'Read more', help_page_path('user/project/service_desk.md'), target: '_blank' + = _("GitLab Service Desk is a simple way to allow people to create issues in your GitLab instance without needing their own user account. It provides a unique email address for end users to create issues in a project, and replies can be sent either through the GitLab interface or by email. End users will only see the thread through email.") + = link_to _('Read more'), help_page_path('user/project/service_desk.md'), target: '_blank' + diff --git a/app/views/shared/snippets/_embed.html.haml b/app/views/shared/snippets/_embed.html.haml index f1abd3a2ce4..f698e1a301b 100644 --- a/app/views/shared/snippets/_embed.html.haml +++ b/app/views/shared/snippets/_embed.html.haml @@ -5,17 +5,17 @@ %strong.file-title-name %a.gitlab-embedded-snippets-title{ href: url_for(only_path: false, overwrite_params: nil) } - = @blob.name + = blob.name %small - = number_to_human_size(@blob.raw_size) + = number_to_human_size(blob.size) %a.gitlab-logo-wrapper{ href: url_for(only_path: false, overwrite_params: nil), title: 'view on gitlab' } %img.gitlab-logo{ src: image_url('ext_snippet_icons/logo.svg'), alt: "GitLab logo" } .file-actions.d-none.d-sm-block .btn-group{ role: "group" }< - = embedded_raw_snippet_button + = embedded_raw_snippet_button(@snippet, blob) - = embedded_snippet_download_button + = embedded_snippet_download_button(@snippet, blob) %article.file-holder.snippet-file-content - = render 'projects/blob/viewer', viewer: @blob.simple_viewer, load_async: false, external_embed: true + = render 'projects/blob/viewer', viewer: blob.simple_viewer, load_async: false, external_embed: true diff --git a/app/views/shared/snippets/_form.html.haml b/app/views/shared/snippets/_form.html.haml index 7f307f33b51..81277b50d13 100644 --- a/app/views/shared/snippets/_form.html.haml +++ b/app/views/shared/snippets/_form.html.haml @@ -29,7 +29,8 @@ .js-file-title.file-title-flex-parent = f.text_field :file_name, placeholder: s_("Snippets|Give your file a name to add code highlighting, e.g. example.rb for Ruby"), class: 'form-control js-snippet-file-name', data: { qa_selector: 'file_name_field' } .file-content.code - %pre#editor{ data: { 'editor-loading': true } }= @snippet.content + #editor{ data: { 'editor-loading': true } }< + %pre.editor-loading-content= @snippet.content = f.hidden_field :content, class: 'snippet-file-content' .form-group diff --git a/app/views/shared/snippets/_header.html.haml b/app/views/shared/snippets/_header.html.haml index 36b6bfd061f..d6019e45b25 100644 --- a/app/views/shared/snippets/_header.html.haml +++ b/app/views/shared/snippets/_header.html.haml @@ -3,7 +3,7 @@ .snippet-box.has-tooltip.inline.gl-mr-2{ title: snippet_visibility_level_description(@snippet.visibility_level, @snippet), data: { container: "body" } } %span.sr-only = visibility_level_label(@snippet.visibility_level) - = visibility_level_icon(@snippet.visibility_level, fw: false) + = visibility_level_icon(@snippet.visibility_level) %span.creator Authored = time_ago_with_tooltip(@snippet.created_at, placement: 'bottom', html_class: 'snippet_updated_ago') diff --git a/app/views/shared/snippets/_snippet.html.haml b/app/views/shared/snippets/_snippet.html.haml index b2c9a74b177..25e31fd519b 100644 --- a/app/views/shared/snippets/_snippet.html.haml +++ b/app/views/shared/snippets/_snippet.html.haml @@ -10,13 +10,13 @@ %ul.controls %li - = link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count.zero?) do - = sprite_icon('comments', size: 16, css_class: 'gl-vertical-align-text-bottom') + = link_to gitlab_snippet_path(snippet, anchor: 'notes'), class: ('no-comments' if notes_count == 0) do + = sprite_icon('comments', css_class: 'gl-vertical-align-text-bottom') = notes_count %li %span.sr-only = visibility_level_label(snippet.visibility_level) - = visibility_level_icon(snippet.visibility_level, fw: false) + = visibility_level_icon(snippet.visibility_level) .snippet-info #{snippet.to_reference} · diff --git a/app/views/shared/snippets/show.js.haml b/app/views/shared/snippets/show.js.haml index d552c1a723b..23cebc97f63 100644 --- a/app/views/shared/snippets/show.js.haml +++ b/app/views/shared/snippets/show.js.haml @@ -1,2 +1,2 @@ document.write('#{escape_javascript(stylesheet_link_tag("#{stylesheet_url 'snippets'}"))}'); -document.write('#{escape_javascript(render('shared/snippets/embed'))}'); +document.write('#{escape_javascript(render(partial: 'shared/snippets/embed', collection: @blobs, as: :blob))}'); diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index ce85cbd7f07..0f6188fa334 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -72,6 +72,12 @@ %strong Wiki Page events %p.text-muted.ml-1 This URL will be triggered when a wiki page is created/updated + %li + = form.check_box :deployment_events, class: 'form-check-input' + = form.label :deployment_events, class: 'list-label form-check-label ml-1' do + %strong= s_('Webhooks|Deployment events') + %p.text-muted.ml-1 + = s_('Webhooks|This URL will be triggered when a deployment is finished/failed/canceled') .form-group = form.label :enable_ssl_verification, 'SSL verification', class: 'label-bold checkbox' .form-check diff --git a/app/views/shared/wikis/_form.html.haml b/app/views/shared/wikis/_form.html.haml index 92b9207aaa4..4d64521f9b0 100644 --- a/app/views/shared/wikis/_form.html.haml +++ b/app/views/shared/wikis/_form.html.haml @@ -59,7 +59,7 @@ - link_example = '[[page-slug]]' - else - link_example = '[Link Title](page-slug)' - = (s_('WikiMarkdownTip|To link to a (new) page, simply type <code class="js-markup-link-example">%{link_example}</code>') % { link_example: link_example }).html_safe + = html_escape(s_('WikiMarkdownTip|To link to a (new) page, simply type %{link_example}')) % { link_example: tag.code(link_example, class: 'js-markup-link-example') } = succeed '.' do - markdown_link = link_to s_("WikiMarkdownDocs|documentation"), help_page_path('user/markdown', anchor: 'wiki-specific-markdown') = (s_("WikiMarkdownDocs|More examples are in the %{docs_link}") % { docs_link: markdown_link }).html_safe diff --git a/app/views/shared/wikis/_sidebar.html.haml b/app/views/shared/wikis/_sidebar.html.haml index cddf19fbc8e..54f285671a1 100644 --- a/app/views/shared/wikis/_sidebar.html.haml +++ b/app/views/shared/wikis/_sidebar.html.haml @@ -2,11 +2,11 @@ .sidebar-container .block.wiki-sidebar-header.gl-mb-3.w-100 %a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" } - = sprite_icon('chevron-double-lg-right', size: 16, css_class: 'gl-icon') + = sprite_icon('chevron-double-lg-right', css_class: 'gl-icon') - git_access_url = wiki_path(@wiki, action: :git_access) = link_to git_access_url, class: active_nav_link?(path: 'wikis#git_access') ? 'active' : '', data: { qa_selector: 'clone_repository_link' } do - = sprite_icon('download', size: 16, css_class: 'gl-mr-2') + = sprite_icon('download', css_class: 'gl-mr-2') %span= _("Clone repository") .blocks-container diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index 0e5f32ed859..21e829d86a6 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,4 +1,4 @@ -%li +%li{ data: { qa_selector: 'wiki_directory_content' } } = wiki_directory.slug %ul = render wiki_directory.pages, context: context diff --git a/app/views/snippets/verify.html.haml b/app/views/snippets/verify.html.haml index cb623ccab57..3c4f08e1df7 100644 --- a/app/views/snippets/verify.html.haml +++ b/app/views/snippets/verify.html.haml @@ -1,4 +1,2 @@ -- form = [@snippet.becomes(Snippet)] - -= render 'layouts/recaptcha_verification', spammable: @snippet, form: form += render 'layouts/recaptcha_verification', spammable: @snippet diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index 6f3f4c4981c..a83b55379da 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -6,13 +6,13 @@ %script#js-register-u2f-setup{ type: "text/template" } - if current_user.two_factor_otp_enabled? - .row.append-bottom-10 + .row.gl-mb-3 .col-md-4 %button#js-setup-u2f-device.btn.btn-info.btn-block= _("Set up new U2F device") .col-md-8 %p= _("Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left.") - else - .row.append-bottom-10 + .row.gl-mb-3 .col-md-4 %button#js-setup-u2f-device.btn.btn-info.btn-block{ disabled: true }= _("Set up new U2F device") .col-md-8 @@ -28,11 +28,11 @@ %a.btn.btn-warning#js-token-2fa-try-again= _("Try again?") %script#js-register-u2f-registered{ type: "text/template" } - .row.append-bottom-10 + .row.gl-mb-3 .col-md-12 %p= _("Your device was successfully set up! Give it a name and register it with the GitLab server.") = form_tag(create_u2f_profile_two_factor_auth_path, method: :post) do - .row.append-bottom-10 + .row.gl-mb-3 .col-md-3 = text_field_tag 'u2f_registration[name]', nil, class: 'form-control', placeholder: _("Pick a name") .col-md-3 diff --git a/app/views/users/_deletion_guidance.html.haml b/app/views/users/_deletion_guidance.html.haml index 0024801dbf6..507fe126acb 100644 --- a/app/views/users/_deletion_guidance.html.haml +++ b/app/views/users/_deletion_guidance.html.haml @@ -6,6 +6,6 @@ - link_start = '<a href="%{url}">'.html_safe % { url: help_page_path("user/profile/account/delete_account", anchor: "associated-records") } = _('Certain user content will be moved to a system-wide "Ghost User" in order to maintain content for posterity. For further information, please refer to the %{link_start}user account deletion documentation.%{link_end}').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } - personal_projects_count = user.personal_projects.count - - unless personal_projects_count.zero? + - unless personal_projects_count == 0 %li = n_('%d personal project will be removed and cannot be restored.', '%d personal projects will be removed and cannot be restored.', personal_projects_count) % personal_projects_count diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index a5197a9950b..2f44a57c388 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -1,12 +1,12 @@ %h4.prepend-top-20 - = _("Contributions for <strong>%{calendar_date}</strong>").html_safe % { calendar_date: @calendar_date.to_s(:medium) } + = html_escape(_("Contributions for %{calendar_date}")) % { calendar_date: tag.strong(@calendar_date.to_s(:medium)) } - if @events.any? %ul.bordered-list - @events.sort_by(&:created_at).each do |event| %li %span.light - %i.fa.fa-clock-o + = sprite_icon('clock', css_class: 'gl-vertical-align-text-bottom') = event.created_at.to_time.in_time_zone.strftime('%-I:%M%P') - if event.visible_to_user?(current_user) - if event.push_action? @@ -20,7 +20,7 @@ - if event.note? = link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title - elsif event.target - = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title + = link_to event.target.to_reference, [event.project, event.target], class: 'has-tooltip', title: event.target_title = s_('UserProfile|at') %strong diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index d2f7ff91f0d..e1d1df9de1a 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -14,7 +14,7 @@ = render layout: 'users/cover_controls' do - if @user == current_user = link_to profile_path, class: link_classes + 'btn btn-default has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile' do - = icon('pencil') + = sprite_icon('pencil') - elsif current_user - if @user.abuse_report %button{ class: link_classes + 'btn btn-danger mr-1', title: s_('UserProfile|Already reported for abuse'), @@ -25,8 +25,8 @@ title: s_('UserProfile|Report abuse'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = icon('exclamation-circle') - if can?(current_user, :read_user_profile, @user) - = link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do - = icon('rss') + = link_to user_path(@user, rss_url_options), class: link_classes + 'btn btn-svg btn-default has-tooltip', title: s_('UserProfile|Subscribe'), 'aria-label': 'Subscribe' do + = sprite_icon('rss', css_class: 'qa-rss-icon') - if current_user && current_user.admin? = link_to [:admin, @user], class: link_classes + 'btn btn-default', title: s_('UserProfile|View user in admin area'), data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do @@ -55,12 +55,12 @@ .cover-desc.cgray.mb-1.mb-sm-2 - unless @user.location.blank? .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline.mb-1.mb-sm-0 - = sprite_icon('location', size: 16, css_class: 'vertical-align-sub fgray') + = sprite_icon('location', css_class: 'vertical-align-sub fgray') %span.vertical-align-middle = @user.location - unless work_information(@user).blank? .profile-link-holder.middle-dot-divider-sm.d-block.d-sm-inline - = sprite_icon('work', size: 16, css_class: 'vertical-align-middle fgray') + = sprite_icon('work', css_class: 'vertical-align-middle fgray') %span.vertical-align-middle = work_information(@user) .cover-desc.cgray.mb-1.mb-sm-2 @@ -84,7 +84,7 @@ = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'text-link' - if @user.bio.present? .cover-desc.cgray - %p.profile-user-bio + .profile-user-bio = markdown(@user.bio_html) @@ -160,16 +160,12 @@ .loading.hide .spinner.spinner-md - - if profile_tabs.empty? - .row - .col-12 - .svg-content - = image_tag 'illustrations/profile_private_mode.svg' - .col-12.text-center - .text-content - %h4 - - if @user.blocked? - = s_('UserProfile|This user is blocked') - - else - = s_('UserProfile|This user has a private profile') - + - if profile_tabs.empty? + .svg-content + = image_tag 'illustrations/profile_private_mode.svg' + .text-content.text-center + %h4 + - if @user.blocked? + = s_('UserProfile|This user is blocked') + - else + = s_('UserProfile|This user has a private profile') diff --git a/app/views/users/terms/index.html.haml b/app/views/users/terms/index.html.haml index bc4861d6ae8..175fde1c862 100644 --- a/app/views/users/terms/index.html.haml +++ b/app/views/users/terms/index.html.haml @@ -9,7 +9,7 @@ = button_to accept_term_path(@term, redirect_params), class: 'btn btn-success gl-ml-3', data: { qa_selector: 'accept_terms_button' } do = _('Accept terms') - else - .pull-right + .float-right = link_to root_path, class: 'btn btn-success gl-ml-3' do = _('Continue') - if can?(current_user, :decline_terms, @term) diff --git a/app/workers/admin_email_worker.rb b/app/workers/admin_email_worker.rb index c84ac60d777..8d589c03259 100644 --- a/app/workers/admin_email_worker.rb +++ b/app/workers/admin_email_worker.rb @@ -18,7 +18,7 @@ class AdminEmailWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def send_repository_check_mail repository_check_failed_count = Project.where(last_repository_check_failed: true).count - return if repository_check_failed_count.zero? + return if repository_check_failed_count == 0 RepositoryCheckMailer.notify(repository_check_failed_count).deliver_now end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 5148772c881..2c871c55f0a 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -5,7 +5,7 @@ --- - :name: authorized_project_update:authorized_project_update_project_create :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -13,7 +13,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_project_group_link_create :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -21,7 +21,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_over_user_range :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -29,7 +29,7 @@ :tags: [] - :name: authorized_project_update:authorized_project_update_user_refresh_with_low_urgency :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -37,87 +37,87 @@ :tags: [] - :name: auto_devops:auto_devops_disable :feature_category: :auto_devops - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: auto_merge:auto_merge_process :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_cpu_spin :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_db_spin :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_kill :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_leak_mem :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: chaos:chaos_sleep :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: container_repository:cleanup_container_repository :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: container_repository:delete_container_repository :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:admin_email :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:authorized_project_update_periodic_recalculate :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -125,79 +125,79 @@ :tags: [] - :name: cronjob:ci_archive_traces_cron :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:container_expiration_policy :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:environments_auto_stop_cron :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:expire_build_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:gitlab_usage_ping :feature_category: :collection - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:import_export_project_cleanup :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:import_stuck_project_import_jobs :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:issue_due_scheduler :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:jira_import_stuck_jira_import_jobs :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:metrics_dashboard_schedule_annotations_prune :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -205,167 +205,175 @@ :tags: [] - :name: cronjob:namespaces_prune_aggregation_schedules :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pages_domain_removal_cron :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pages_domain_ssl_renewal_cron :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pages_domain_verification_cron :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:partition_creation :feature_category: :database - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true :tags: [] +- :name: cronjob:personal_access_tokens_expired_notification + :feature_category: :authentication_and_authorization + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: cronjob:personal_access_tokens_expiring :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:pipeline_schedule :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:prune_old_events :feature_category: :users - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:prune_web_hook_logs :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:remove_expired_group_links :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:remove_expired_members :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:remove_unreferenced_lfs_objects :feature_category: :git_lfs - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:repository_archive_cache :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:repository_check_dispatch :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:requests_profiles :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:schedule_migrate_external_diffs :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:stuck_ci_jobs :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:stuck_export_jobs :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:stuck_merge_jobs :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:trending_projects :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:update_container_registry_info :feature_category: :container_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -373,11 +381,11 @@ :tags: [] - :name: cronjob:users_create_statistics :feature_category: :users - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: cronjob:x509_issuer_crl_check :feature_category: :source_code_management @@ -389,27 +397,27 @@ :tags: [] - :name: deployment:deployments_finished :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: deployment:deployments_forward_deployment :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: deployment:deployments_success :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_configure_istio :feature_category: :kubernetes_management @@ -417,7 +425,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_install_app :feature_category: :kubernetes_management @@ -425,7 +433,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_patch_app :feature_category: :kubernetes_management @@ -433,7 +441,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_provision :feature_category: :kubernetes_management @@ -441,15 +449,15 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_update_app :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_upgrade_app :feature_category: :kubernetes_management @@ -457,7 +465,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_app_installation :feature_category: :kubernetes_management @@ -465,15 +473,15 @@ :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_app_update :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:cluster_wait_for_ingress_ip_address :feature_category: :kubernetes_management @@ -481,23 +489,23 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_activate_service :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_deactivate_service :feature_category: :kubernetes_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_uninstall :feature_category: :kubernetes_management @@ -505,7 +513,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_applications_wait_for_uninstall_app :feature_category: :kubernetes_management @@ -513,7 +521,7 @@ :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_app :feature_category: :kubernetes_management @@ -521,7 +529,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_project_namespace :feature_category: :kubernetes_management @@ -529,7 +537,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:clusters_cleanup_service_account :feature_category: :kubernetes_management @@ -537,7 +545,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gcp_cluster:wait_for_cluster_creation :feature_category: :kubernetes_management @@ -545,7 +553,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_diff_note :feature_category: :importers @@ -553,7 +561,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_issue :feature_category: :importers @@ -561,7 +569,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_lfs_object :feature_category: :importers @@ -569,7 +577,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_note :feature_category: :importers @@ -577,7 +585,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_import_pull_request :feature_category: :importers @@ -585,103 +593,103 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_refresh_import_jid :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_finish_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_base_data :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_issues_and_diff_notes :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_lfs_objects :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_notes :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_pull_requests :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_importer:github_import_stage_import_repository :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_migrator :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_project_migrate :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_project_rollback :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: hashed_storage:hashed_storage_rollbacker :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: incident_management:clusters_applications_check_prometheus_health :feature_category: :incident_management @@ -693,175 +701,175 @@ :tags: [] - :name: incident_management:incident_management_pager_duty_process_incident :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: incident_management:incident_management_process_alert :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: incident_management:incident_management_process_prometheus_alert :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_advance_stage :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_import_issue :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_finish_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_attachments :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_issues :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_labels :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_import_notes :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: jira_importer:jira_import_stage_start_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: mail_scheduler:mail_scheduler_issue_due :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: mail_scheduler:mail_scheduler_notification_service :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_create :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_destroy :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_join :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_pool:object_pool_schedule_join :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_storage:object_storage_background_move :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: object_storage:object_storage_migrate_uploads :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: package_repositories:packages_nuget_extraction :feature_category: :package_registry - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_background:archive_trace :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_background:ci_build_report_result :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -869,15 +877,15 @@ :tags: [] - :name: pipeline_background:ci_build_trace_chunk_flush :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_background:ci_daily_build_group_report_results :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -885,7 +893,7 @@ :tags: [] - :name: pipeline_background:ci_pipeline_success_unlock_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -893,7 +901,7 @@ :tags: [] - :name: pipeline_background:ci_ref_delete_unlock_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -901,7 +909,7 @@ :tags: [] - :name: pipeline_cache:expire_job_cache :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 3 @@ -909,7 +917,7 @@ :tags: [] - :name: pipeline_cache:expire_pipeline_cache :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 @@ -917,152 +925,152 @@ :tags: [] - :name: pipeline_creation:create_pipeline :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 4 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_creation:run_pipeline_schedule :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 4 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:build_coverage :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:build_trace_sections :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:ci_create_cross_project_pipeline :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:ci_pipeline_bridge_status :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:pipeline_metrics :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:pipeline_notification :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_default:pipeline_update_ci_ref_status :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_hooks:build_hooks :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_hooks:pipeline_hooks :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:build_finished :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: - :requires_disk_io - :name: pipeline_processing:build_queue :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:build_success :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:ci_build_prepare :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:ci_build_schedule :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:ci_resource_groups_assign_resource_from_resource_group :feature_category: :continuous_delivery - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:pipeline_process :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: pipeline_processing:pipeline_update :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 @@ -1070,7 +1078,7 @@ :tags: [] - :name: pipeline_processing:stage_update :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 @@ -1078,7 +1086,7 @@ :tags: [] - :name: pipeline_processing:update_head_pipeline_for_merge_request :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 @@ -1086,71 +1094,71 @@ :tags: [] - :name: repository_check:repository_check_batch :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_check:repository_check_clear :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_check:repository_check_single_repository :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_confidential_issue :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_entity_leave :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_group_private :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_private_features :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: todos_destroyer:todos_destroyer_project_private :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: unassign_issuables:members_destroyer_unassign_issuables :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1158,7 +1166,7 @@ :tags: [] - :name: update_namespace_statistics:namespaces_root_statistics :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1166,7 +1174,7 @@ :tags: [] - :name: update_namespace_statistics:namespaces_schedule_aggregation :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1174,7 +1182,7 @@ :tags: [] - :name: authorized_keys :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 @@ -1182,7 +1190,7 @@ :tags: [] - :name: authorized_projects :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 @@ -1190,11 +1198,11 @@ :tags: [] - :name: background_migration :feature_category: :database - :has_external_dependencies: + :has_external_dependencies: :urgency: :throttled :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: chat_notification :feature_category: :chatops @@ -1202,11 +1210,11 @@ :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: create_commit_signature :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 @@ -1214,91 +1222,91 @@ :tags: [] - :name: create_evidence :feature_category: :release_evidence - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: create_note_diff_file :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: default - :feature_category: - :has_external_dependencies: - :urgency: - :resource_boundary: + :feature_category: + :has_external_dependencies: + :urgency: + :resource_boundary: :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_diff_files :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_merged_branches :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_stored_files :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: delete_user :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: design_management_new_version :feature_category: :design_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :memory :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: detect_repository_languages :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: email_receiver :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: emails_on_push :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: error_tracking_issue_link :feature_category: :error_tracking @@ -1306,23 +1314,23 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: expire_build_instance_artifacts :feature_category: :continuous_integration - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: export_csv :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: external_service_reactive_caching :feature_category: :not_owned @@ -1330,99 +1338,107 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: file_hook :feature_category: :integrations + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] +- :name: flush_counter_increments + :feature_category: :not_owned :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: true :tags: [] - :name: git_garbage_collect :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: github_import_advance_stage :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: gitlab_shell :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: group_destroy :feature_category: :subgroups - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: group_export :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: group_import :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: import_issues_csv :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: invalid_gpg_signature_update :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: irker :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: mailers - :feature_category: - :has_external_dependencies: - :urgency: - :resource_boundary: + :feature_category: + :has_external_dependencies: + :urgency: + :resource_boundary: :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: merge :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 5 @@ -1430,7 +1446,7 @@ :tags: [] - :name: merge_request_mergeability_check :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1438,7 +1454,7 @@ :tags: [] - :name: metrics_dashboard_prune_old_annotations :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 @@ -1446,87 +1462,95 @@ :tags: [] - :name: migrate_external_diffs :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: namespaceless_project_destroy :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: new_issue :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: new_merge_request :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: new_note :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: pages :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pages_domain_ssl_renewal :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: pages_domain_verification :feature_category: :pages - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: + :tags: [] +- :name: pages_update_configuration + :feature_category: :pages + :has_external_dependencies: + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true :tags: [] - :name: phabricator_import_import_tasks :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: post_receive :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 5 - :idempotent: + :idempotent: :tags: [] - :name: process_commit :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 3 @@ -1534,35 +1558,35 @@ :tags: [] - :name: project_cache :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_daily_statistics :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_destroy :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_export :feature_category: :importers - :has_external_dependencies: + :has_external_dependencies: :urgency: :throttled :resource_boundary: :memory :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_service :feature_category: :integrations @@ -1570,11 +1594,11 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: project_update_repository_storage :feature_category: :gitaly - :has_external_dependencies: + :has_external_dependencies: :urgency: :throttled :resource_boundary: :unknown :weight: 1 @@ -1582,7 +1606,7 @@ :tags: [] - :name: prometheus_create_default_alerts :feature_category: :incident_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 1 @@ -1590,59 +1614,59 @@ :tags: [] - :name: propagate_integration :feature_category: :integrations - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 :idempotent: true :tags: [] - :name: propagate_service_template - :feature_category: :source_code_management - :has_external_dependencies: + :feature_category: :integrations + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: reactive_caching :feature_category: :not_owned - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :cpu :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: rebase :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: remote_mirror_notification :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: repository_cleanup :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_fork :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_import :feature_category: :importers @@ -1650,15 +1674,15 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_remove_remote :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: repository_update_remote_mirror :feature_category: :source_code_management @@ -1666,51 +1690,51 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: self_monitoring_project_create :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: self_monitoring_project_delete :feature_category: :metrics - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 2 - :idempotent: + :idempotent: :tags: [] - :name: service_desk_email_receiver :feature_category: :issue_tracking - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: system_hook_push :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: update_external_pull_requests :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: update_highest_role :feature_category: :authentication_and_authorization - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :unknown :weight: 2 @@ -1718,27 +1742,27 @@ :tags: [] - :name: update_merge_requests :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :high :resource_boundary: :cpu :weight: 3 - :idempotent: + :idempotent: :tags: [] - :name: update_project_statistics :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: upload_checksum :feature_category: :geo_replication - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: web_hook :feature_category: :integrations @@ -1746,11 +1770,11 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: + :idempotent: :tags: [] - :name: x509_certificate_revoke :feature_category: :source_code_management - :has_external_dependencies: + :has_external_dependencies: :urgency: :low :resource_boundary: :unknown :weight: 1 diff --git a/app/workers/concerns/application_worker.rb b/app/workers/concerns/application_worker.rb index 9c942228111..30dec5159a2 100644 --- a/app/workers/concerns/application_worker.rb +++ b/app/workers/concerns/application_worker.rb @@ -10,6 +10,7 @@ module ApplicationWorker include Sidekiq::Worker # rubocop:disable Cop/IncludeSidekiqWorker include WorkerAttributes include WorkerContext + include Gitlab::SidekiqVersioning::Worker LOGGING_EXTRA_KEY = 'extra' diff --git a/app/workers/flush_counter_increments_worker.rb b/app/workers/flush_counter_increments_worker.rb new file mode 100644 index 00000000000..b7e3c0c134d --- /dev/null +++ b/app/workers/flush_counter_increments_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Invoked by CounterAttribute concern when incrementing counter +# attributes. The method `flush_increments_to_database!` that +# this worker uses is itself idempotent as it runs with exclusive +# lease to ensure that only one instance at the time can flush +# increments from Redis to the database. +class FlushCounterIncrementsWorker + include ApplicationWorker + + feature_category_not_owned! + urgency :low + deduplicate :until_executing, including_scheduled: true + + idempotent! + + def perform(model_name, model_id, attribute) + return unless self.class.const_defined?(model_name) + + model_class = model_name.constantize + model = model_class.find_by_id(model_id) + return unless model + + model.flush_increments_to_database!(attribute) + end +end diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index f2222c7be5e..6e4feea1b26 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -11,6 +11,7 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker LEASE_TIMEOUT = 86400 def perform(project_id, task = :gc, lease_key = nil, lease_uuid = nil) + lease_key ||= "git_gc:#{task}:#{project_id}" project = Project.find(project_id) active_uuid = get_lease_uuid(lease_key) @@ -26,14 +27,20 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker task = task.to_sym - ::Projects::GitDeduplicationService.new(project).execute if task == :gc + if task == :gc + ::Projects::GitDeduplicationService.new(project).execute + cleanup_orphan_lfs_file_references(project) + end gitaly_call(task, project.repository.raw_repository) # Refresh the branch cache in case garbage collection caused a ref lookup to fail flush_ref_caches(project) if task == :gc - project.repository.expire_statistics_caches if task != :pack_refs + if task != :pack_refs + project.repository.expire_statistics_caches + Projects::UpdateStatisticsService.new(project, nil, statistics: [:repository_size, :lfs_objects_size]).execute + end # In case pack files are deleted, release libgit2 cache and open file # descriptors ASAP instead of waiting for Ruby garbage collection @@ -86,6 +93,13 @@ class GitGarbageCollectWorker # rubocop:disable Scalability/IdempotentWorker raise Gitlab::Git::CommandError.new(e) end + def cleanup_orphan_lfs_file_references(project) + return unless Feature.enabled?(:cleanup_lfs_during_gc, project) + return if Gitlab::Database.read_only? # GitGarbageCollectWorker may be run on a Geo secondary + + ::Gitlab::Cleanup::OrphanLfsFileReferences.new(project, dry_run: false, logger: logger).run! + end + def flush_ref_caches(project) project.repository.after_create_branch project.repository.branch_names diff --git a/app/workers/gitlab/import/advance_stage.rb b/app/workers/gitlab/import/advance_stage.rb index 3f34437294e..9fc03efe9d0 100644 --- a/app/workers/gitlab/import/advance_stage.rb +++ b/app/workers/gitlab/import/advance_stage.rb @@ -41,7 +41,7 @@ module Gitlab # complete the work fast enough. waiter.wait(BLOCKING_WAIT_TIME) - next unless waiter.jobs_remaining.positive? + next unless waiter.jobs_remaining > 0 new_waiters[waiter.key] = waiter.jobs_remaining end diff --git a/app/workers/gitlab_usage_ping_worker.rb b/app/workers/gitlab_usage_ping_worker.rb index 9f0cf1728dd..a696c6e746a 100644 --- a/app/workers/gitlab_usage_ping_worker.rb +++ b/app/workers/gitlab_usage_ping_worker.rb @@ -1,32 +1,24 @@ # frozen_string_literal: true class GitlabUsagePingWorker # rubocop:disable Scalability/IdempotentWorker + LEASE_KEY = 'gitlab_usage_ping_worker:ping' LEASE_TIMEOUT = 86400 include ApplicationWorker - # rubocop:disable Scalability/CronWorkerContext - # This worker does not perform work scoped to a context - include CronjobQueue - # rubocop:enable Scalability/CronWorkerContext + include CronjobQueue # rubocop:disable Scalability/CronWorkerContext + include Gitlab::ExclusiveLeaseHelpers feature_category :collection - - # Retry for up to approximately three hours then give up. - sidekiq_options retry: 10, dead: false + sidekiq_options retry: 3, dead: false + sidekiq_retry_in { |count| (count + 1) * 8.hours.to_i } def perform # Multiple Sidekiq workers could run this. We should only do this at most once a day. - return unless try_obtain_lease - - # Splay the request over a minute to avoid thundering herd problems. - sleep(rand(0.0..60.0).round(3)) - - SubmitUsagePingService.new.execute - end - - private + in_lock(LEASE_KEY, ttl: LEASE_TIMEOUT) do + # Splay the request over a minute to avoid thundering herd problems. + sleep(rand(0.0..60.0).round(3)) - def try_obtain_lease - Gitlab::ExclusiveLease.new('gitlab_usage_ping_worker:ping', timeout: LEASE_TIMEOUT).try_obtain + SubmitUsagePingService.new.execute + end end end diff --git a/app/workers/incident_management/process_alert_worker.rb b/app/workers/incident_management/process_alert_worker.rb index 5d5c10014f8..59464b81d1b 100644 --- a/app/workers/incident_management/process_alert_worker.rb +++ b/app/workers/incident_management/process_alert_worker.rb @@ -16,10 +16,10 @@ module IncidentManagement alert = find_alert(alert_id) return unless alert - new_issue = create_issue_for(alert) - return unless new_issue&.persisted? + result = create_issue_for(alert) + return if result.success? - link_issue_with_alert(alert, new_issue.id) + log_warning(alert, result) end private @@ -28,29 +28,20 @@ module IncidentManagement AlertManagement::Alert.find_by_id(alert_id) end - def parsed_payload(alert) - if alert.prometheus? - alert.payload - else - Gitlab::Alerting::NotificationPayloadParser.call(alert.payload.to_h, alert.project) - end - end - def create_issue_for(alert) - IncidentManagement::CreateIssueService - .new(alert.project, parsed_payload(alert)) + AlertManagement::CreateAlertIssueService + .new(alert, User.alert_bot) .execute - .dig(:issue) end - def link_issue_with_alert(alert, issue_id) - return if alert.update(issue_id: issue_id) + def log_warning(alert, result) + issue_id = result.payload[:issue]&.id Gitlab::AppLogger.warn( - message: 'Cannot link an Issue with Alert', + message: 'Cannot process an Incident', issue_id: issue_id, alert_id: alert.id, - alert_errors: alert.errors.messages + errors: result.message ) end end diff --git a/app/workers/pages_update_configuration_worker.rb b/app/workers/pages_update_configuration_worker.rb new file mode 100644 index 00000000000..d0904db6b42 --- /dev/null +++ b/app/workers/pages_update_configuration_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class PagesUpdateConfigurationWorker + include ApplicationWorker + + idempotent! + feature_category :pages + + def perform(project_id) + project = Project.find_by_id(project_id) + return unless project + + result = Projects::UpdatePagesConfigurationService.new(project).execute + + # The ConfigurationService swallows all exceptions and wraps them in a status + # we need to keep this while the feature flag still allows running this + # service within a request. + # But we might as well take advantage of sidekiq retries here. + # We should let the service raise after we remove the feature flag + # https://gitlab.com/gitlab-org/gitlab/-/issues/230695 + raise result[:exception] if result[:exception] + end +end diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb index d699e32c1a0..aefa4bc4223 100644 --- a/app/workers/pages_worker.rb +++ b/app/workers/pages_worker.rb @@ -14,12 +14,10 @@ class PagesWorker # rubocop:disable Scalability/IdempotentWorker # rubocop: disable CodeReuse/ActiveRecord def deploy(build_id) build = Ci::Build.find_by(id: build_id) - result = Projects::UpdatePagesService.new(build.project, build).execute - if result[:status] == :success - result = Projects::UpdatePagesConfigurationService.new(build.project).execute + update_contents = Projects::UpdatePagesService.new(build.project, build).execute + if update_contents[:status] == :success + Projects::UpdatePagesConfigurationService.new(build.project).execute end - - result end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/partition_creation_worker.rb b/app/workers/partition_creation_worker.rb index 9101623d93a..b833e818b32 100644 --- a/app/workers/partition_creation_worker.rb +++ b/app/workers/partition_creation_worker.rb @@ -8,8 +8,6 @@ class PartitionCreationWorker idempotent! def perform - Gitlab::AppLogger.info("Checking state of dynamic postgres partitions") - Gitlab::Database::Partitioning::PartitionCreator.new.create_partitions end end diff --git a/app/workers/personal_access_tokens/expired_notification_worker.rb b/app/workers/personal_access_tokens/expired_notification_worker.rb new file mode 100644 index 00000000000..c1b1f1a461d --- /dev/null +++ b/app/workers/personal_access_tokens/expired_notification_worker.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module PersonalAccessTokens + class ExpiredNotificationWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + include CronjobQueue + + feature_category :authentication_and_authorization + + def perform(*args) + return unless Feature.enabled?(:expired_pat_email_notification) + + notification_service = NotificationService.new + + User.with_personal_access_tokens_expired_today.find_each do |user| + with_context(user: user) do + Gitlab::AppLogger.info "#{self.class}: Notifying User #{user.id} about an expired token" + + notification_service.access_token_expired(user) + + user.personal_access_tokens.without_impersonation.expired_today_and_not_notified.update_all(after_expiry_notification_delivered: true) + end + end + end + end +end diff --git a/app/workers/pipeline_process_worker.rb b/app/workers/pipeline_process_worker.rb index cd7c82d3117..f0929b92bd0 100644 --- a/app/workers/pipeline_process_worker.rb +++ b/app/workers/pipeline_process_worker.rb @@ -10,11 +10,13 @@ class PipelineProcessWorker # rubocop:disable Scalability/IdempotentWorker loggable_arguments 1 # rubocop: disable CodeReuse/ActiveRecord - def perform(pipeline_id, build_ids = nil) + # `_build_ids` is deprecated and will be removed in 14.0 + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/232806 + def perform(pipeline_id, _build_ids = nil) Ci::Pipeline.find_by(id: pipeline_id).try do |pipeline| Ci::ProcessPipelineService .new(pipeline) - .execute(build_ids) + .execute end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/workers/pipeline_update_worker.rb b/app/workers/pipeline_update_worker.rb index 267caa5bedd..7db4ab8fe0b 100644 --- a/app/workers/pipeline_update_worker.rb +++ b/app/workers/pipeline_update_worker.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +# This worker is deprecated and will be removed in 14.0 +# See: https://gitlab.com/gitlab-org/gitlab/-/issues/232806 class PipelineUpdateWorker include ApplicationWorker include PipelineQueue @@ -9,7 +11,7 @@ class PipelineUpdateWorker idempotent! - def perform(pipeline_id) - Ci::Pipeline.find_by_id(pipeline_id)&.update_legacy_status + def perform(_pipeline_id) + # no-op end end diff --git a/app/workers/propagate_service_template_worker.rb b/app/workers/propagate_service_template_worker.rb index f3a6bda1821..37d5ccb656d 100644 --- a/app/workers/propagate_service_template_worker.rb +++ b/app/workers/propagate_service_template_worker.rb @@ -4,7 +4,7 @@ class PropagateServiceTemplateWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker - feature_category :source_code_management + feature_category :integrations LEASE_TIMEOUT = 4.hours.to_i |