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/assets | |
parent | 1ce776de4ae122aba3f349c02c17cebeaa8ecf07 (diff) | |
download | gitlab-ce-6e4e1050d9dba2b7b2523fdd1768823ab85feef4.tar.gz |
Add latest changes from gitlab-org/gitlab@13-3-stable-ee
Diffstat (limited to 'app/assets')
958 files changed, 16312 insertions, 5680 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; |