diff options
Diffstat (limited to 'app')
197 files changed, 2144 insertions, 2295 deletions
diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index a649c521405..136ffdf8b9d 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -14,6 +14,7 @@ const Api = { projectPath: '/api/:version/projects/:id', forkedProjectsPath: '/api/:version/projects/:id/forks', projectLabelsPath: '/:namespace_path/:project_path/-/labels', + projectUsersPath: '/api/:version/projects/:id/users', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', projectMergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', @@ -108,6 +109,20 @@ const Api = { }); }, + projectUsers(projectPath, query = '', options = {}) { + const url = Api.buildUrl(this.projectUsersPath).replace(':id', encodeURIComponent(projectPath)); + + return axios + .get(url, { + params: { + search: query, + per_page: 20, + ...options, + }, + }) + .then(({ data }) => data); + }, + // Return single project project(projectPath) { const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); diff --git a/app/assets/javascripts/boards/components/board_card.vue b/app/assets/javascripts/boards/components/board_card.vue index 179148b6887..faf722f61af 100644 --- a/app/assets/javascripts/boards/components/board_card.vue +++ b/app/assets/javascripts/boards/components/board_card.vue @@ -83,6 +83,7 @@ export default { }" :index="index" :data-issue-id="issue.id" + data-qa-selector="board_card" class="board-card p-3 rounded" @mousedown="mouseDown" @mousemove="mouseMove" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 03a8a92575e..de41698ca04 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -227,6 +227,7 @@ export default { <div :class="{ 'd-none': !list.isExpanded, 'd-flex flex-column': list.isExpanded }" class="board-list-component position-relative h-100" + data-qa-selector="board_list_cards_area" > <div v-if="loading" class="board-list-loading text-center" :aria-label="__('Loading issues')"> <gl-loading-icon /> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index 2ace0060c42..ba1fe9202fc 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -22,6 +22,8 @@ export default Vue.extend({ components: { AssigneeTitle, Assignees, + SidebarEpicsSelect: () => + import('ee_component/sidebar/components/sidebar_item_epics_select.vue'), RemoveBtn, Subscriptions, TimeTracker, diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue index b05de4538f2..7296426549a 100644 --- a/app/assets/javascripts/boards/components/boards_selector.vue +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -226,6 +226,7 @@ export default { <div class="boards-switcher js-boards-selector append-right-10"> <span class="boards-selector-wrapper js-boards-selector-wrapper"> <gl-dropdown + data-qa-selector="boards_dropdown" toggle-class="dropdown-menu-toggle js-dropdown-toggle" menu-class="flex-column dropdown-extended-height" :text="board.name" diff --git a/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue new file mode 100644 index 00000000000..d946594a069 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_card_list_item.vue @@ -0,0 +1,41 @@ +<script> +import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'StageCardListItem', + components: { + Icon, + GlButton, + }, + props: { + isActive: { + type: Boolean, + required: true, + }, + canEdit: { + type: Boolean, + default: false, + required: false, + }, + }, +}; +</script> + +<template> + <div :class="{ active: isActive }" class="stage-nav-item d-flex pl-4 pr-4 m-0 mb-1 ml-2 rounded"> + <slot></slot> + <div v-if="canEdit" class="dropdown"> + <gl-button + :title="__('More actions')" + class="more-actions-toggle btn btn-transparent p-0" + data-toggle="dropdown" + > + <icon css-classes="icon" name="ellipsis_v" /> + </gl-button> + <ul class="more-actions-dropdown dropdown-menu dropdown-open-left"> + <slot name="dropdown-options"></slot> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue new file mode 100644 index 00000000000..004d335f572 --- /dev/null +++ b/app/assets/javascripts/cycle_analytics/components/stage_nav_item.vue @@ -0,0 +1,88 @@ +<script> +import StageCardListItem from './stage_card_list_item.vue'; + +export default { + name: 'StageNavItem', + components: { + StageCardListItem, + }, + props: { + isDefaultStage: { + type: Boolean, + default: false, + required: false, + }, + isActive: { + type: Boolean, + default: false, + required: false, + }, + isUserAllowed: { + type: Boolean, + required: true, + }, + title: { + type: String, + required: true, + }, + value: { + type: String, + default: '', + required: false, + }, + canEdit: { + type: Boolean, + default: false, + required: false, + }, + }, + computed: { + hasValue() { + return this.value && this.value.length > 0; + }, + editable() { + return this.isUserAllowed && this.canEdit; + }, + }, +}; +</script> + +<template> + <li @click="$emit('select')"> + <stage-card-list-item :is-active="isActive" :can-edit="editable"> + <div class="stage-nav-item-cell stage-name p-0" :class="{ 'font-weight-bold': isActive }"> + {{ title }} + </div> + <div class="stage-nav-item-cell stage-median mr-4"> + <template v-if="isUserAllowed"> + <span v-if="hasValue">{{ value }}</span> + <span v-else class="stage-empty">{{ __('Not enough data') }}</span> + </template> + <template v-else> + <span class="not-available">{{ __('Not available') }}</span> + </template> + </div> + <template v-slot:dropdown-options> + <template v-if="isDefaultStage"> + <li> + <button type="button" class="btn-default btn-transparent"> + {{ __('Hide stage') }} + </button> + </li> + </template> + <template v-else> + <li> + <button type="button" class="btn-default btn-transparent"> + {{ __('Edit stage') }} + </button> + </li> + <li> + <button type="button" class="btn-danger danger"> + {{ __('Remove stage') }} + </button> + </li> + </template> + </template> + </stage-card-list-item> + </li> +</template> diff --git a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js index 671405602cc..b3ae47af750 100644 --- a/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js +++ b/app/assets/javascripts/cycle_analytics/cycle_analytics_bundle.js @@ -12,6 +12,7 @@ import stageComponent from './components/stage_component.vue'; import stageReviewComponent from './components/stage_review_component.vue'; import stageStagingComponent from './components/stage_staging_component.vue'; import stageTestComponent from './components/stage_test_component.vue'; +import stageNavItem from './components/stage_nav_item.vue'; import CycleAnalyticsService from './cycle_analytics_service'; import CycleAnalyticsStore from './cycle_analytics_store'; @@ -41,6 +42,7 @@ export default () => { import('ee_component/analytics/shared/components/projects_dropdown_filter.vue'), DateRangeDropdown: () => import('ee_component/analytics/shared/components/date_range_dropdown.vue'), + 'stage-nav-item': stageNavItem, }, mixins: [filterMixins], data() { diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 81da0754752..19b85710710 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -305,7 +305,7 @@ export default { <div v-show="showTreeList" :style="{ width: `${treeWidth}px` }" - class="diff-tree-list js-diff-tree-list" + class="diff-tree-list js-diff-tree-list mr-3" > <panel-resizer :size.sync="treeWidth" diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 925385fa98a..839ab542377 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -212,19 +212,18 @@ export default { </script> <template> - <td :colspan="colspan"> + <td :colspan="colspan" class="text-center"> <div class="content js-line-expansion-content"> <a v-if="canExpandUp" v-tooltip - class="cursor-pointer js-unfold unfold-icon" + class="cursor-pointer js-unfold unfold-icon d-inline-block pt-2 pb-2" data-placement="top" data-container="body" :title="__('Expand up')" @click="handleExpandLines(EXPAND_UP)" > - <!-- TODO: remove style & replace with correct icon, waiting for MR https://gitlab.com/gitlab-org/gitlab-design/issues/499 --> - <icon :size="12" name="expand-left" aria-hidden="true" style="transform: rotate(270deg);" /> + <icon :size="12" name="expand-up" aria-hidden="true" /> </a> <a class="mx-2 cursor-pointer js-unfold-all" @click="handleExpandLines()"> <span>{{ s__('Diffs|Show all lines') }}</span> @@ -232,14 +231,13 @@ export default { <a v-if="canExpandDown" v-tooltip - class="cursor-pointer js-unfold-down has-tooltip unfold-icon" + class="cursor-pointer js-unfold-down has-tooltip unfold-icon d-inline-block pt-2 pb-2" data-placement="top" data-container="body" :title="__('Expand down')" @click="handleExpandLines(EXPAND_DOWN)" > - <!-- TODO: remove style & replace with correct icon, waiting for MR https://gitlab.com/gitlab-org/gitlab-design/issues/499 --> - <icon :size="12" name="expand-left" aria-hidden="true" style="transform: rotate(90deg);" /> + <icon :size="12" name="expand-down" aria-hidden="true" /> </a> </div> </td> diff --git a/app/assets/javascripts/diffs/components/diff_file_header.vue b/app/assets/javascripts/diffs/components/diff_file_header.vue index 32fbeaaa905..69ec6ab8600 100644 --- a/app/assets/javascripts/diffs/components/diff_file_header.vue +++ b/app/assets/javascripts/diffs/components/diff_file_header.vue @@ -130,7 +130,7 @@ export default { return `\`${this.diffFile.file_path}\``; }, isFileRenamed() { - return this.diffFile.viewer.name === diffViewerModes.renamed; + return this.diffFile.renamed_file; }, isModeChanged() { return this.diffFile.viewer.name === diffViewerModes.mode_changed; diff --git a/app/assets/javascripts/event_tracking/issue_sidebar.js b/app/assets/javascripts/event_tracking/issue_sidebar.js new file mode 100644 index 00000000000..6909f82c66f --- /dev/null +++ b/app/assets/javascripts/event_tracking/issue_sidebar.js @@ -0,0 +1,2 @@ +export const initSidebarTracking = () => {}; +export const trackEvent = () => {}; diff --git a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue index 685d8a6b245..8b356ee6e97 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/actions.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/actions.vue @@ -41,10 +41,16 @@ export default { methods: { ...mapCommitActions(['updateCommitAction']), updateSelectedCommitAction() { - if (this.currentBranch && !this.currentBranch.can_push) { - this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); - } else if (this.containsStagedChanges) { + if (!this.currentBranch) { + return; + } + + const { can_push: canPush = false, default: isDefault = false } = this.currentBranch; + + if (canPush && !isDefault) { this.updateCommitAction(consts.COMMIT_TO_CURRENT_BRANCH); + } else { + this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH); } }, }, diff --git a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue index b2e7b15089c..daa44a42765 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/new_merge_request_option.vue @@ -1,43 +1,36 @@ <script> -import { mapGetters, createNamespacedHelpers } from 'vuex'; +import { createNamespacedHelpers } from 'vuex'; const { mapState: mapCommitState, - mapGetters: mapCommitGetters, mapActions: mapCommitActions, + mapGetters: mapCommitGetters, } = createNamespacedHelpers('commit'); export default { computed: { ...mapCommitState(['shouldCreateMR']), - ...mapCommitGetters(['isCommittingToCurrentBranch', 'isCommittingToDefaultBranch']), - ...mapGetters(['hasMergeRequest', 'isOnDefaultBranch']), - currentBranchHasMr() { - return this.hasMergeRequest && this.isCommittingToCurrentBranch; - }, - showNewMrOption() { - return ( - this.isCommittingToDefaultBranch || !this.currentBranchHasMr || this.isCommittingToNewBranch - ); - }, - }, - mounted() { - this.setShouldCreateMR(); + ...mapCommitGetters(['shouldHideNewMrOption']), }, methods: { - ...mapCommitActions(['toggleShouldCreateMR', 'setShouldCreateMR']), + ...mapCommitActions(['toggleShouldCreateMR']), }, }; </script> <template> - <div v-if="showNewMrOption"> + <fieldset v-if="!shouldHideNewMrOption"> <hr class="my-2" /> - <label class="mb-0"> - <input :checked="shouldCreateMR" type="checkbox" @change="toggleShouldCreateMR" /> + <label class="mb-0 js-ide-commit-new-mr"> + <input + :checked="shouldCreateMR" + type="checkbox" + data-qa-selector="start_new_mr_checkbox" + @change="toggleShouldCreateMR" + /> <span class="prepend-left-10"> {{ __('Start a new merge request') }} </span> </label> - </div> + </fieldset> </template> diff --git a/app/assets/javascripts/ide/stores/getters.js b/app/assets/javascripts/ide/stores/getters.js index 406903129db..85fd45358be 100644 --- a/app/assets/javascripts/ide/stores/getters.js +++ b/app/assets/javascripts/ide/stores/getters.js @@ -104,5 +104,8 @@ export const packageJson = state => state.entries[packageJsonPath]; export const isOnDefaultBranch = (_state, getters) => getters.currentProject && getters.currentProject.default_branch === getters.branchName; +export const canPushToBranch = (_state, getters) => + getters.currentBranch && getters.currentBranch.can_push; + // prevent babel-plugin-rewire from generating an invalid default during karma tests 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 ac34491c1ad..23caf2d48ed 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -18,34 +18,15 @@ export const discardDraft = ({ commit }) => { commit(types.UPDATE_COMMIT_MESSAGE, ''); }; -export const updateCommitAction = ({ commit, dispatch }, commitAction) => { +export const updateCommitAction = ({ commit, getters }, commitAction) => { commit(types.UPDATE_COMMIT_ACTION, { commitAction, }); - dispatch('setShouldCreateMR'); + commit(types.TOGGLE_SHOULD_CREATE_MR, !getters.shouldHideNewMrOption); }; export const toggleShouldCreateMR = ({ commit }) => { commit(types.TOGGLE_SHOULD_CREATE_MR); - commit(types.INTERACT_WITH_NEW_MR); -}; - -export const setShouldCreateMR = ({ - commit, - getters, - rootGetters, - state: { interactedWithNewMR }, -}) => { - const committingToExistingMR = - getters.isCommittingToCurrentBranch && - rootGetters.hasMergeRequest && - !rootGetters.isOnDefaultBranch; - - if ((getters.isCommittingToDefaultBranch && !interactedWithNewMR) || committingToExistingMR) { - commit(types.TOGGLE_SHOULD_CREATE_MR, false); - } else if (!interactedWithNewMR) { - commit(types.TOGGLE_SHOULD_CREATE_MR, true); - } }; export const updateBranchName = ({ commit }, branchName) => { diff --git a/app/assets/javascripts/ide/stores/modules/commit/getters.js b/app/assets/javascripts/ide/stores/modules/commit/getters.js index 64779e9e4df..de289e27199 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/getters.js +++ b/app/assets/javascripts/ide/stores/modules/commit/getters.js @@ -20,7 +20,7 @@ export const placeholderBranchName = (state, _, rootState) => )}`; export const branchName = (state, getters, rootState) => { - if (state.commitAction === consts.COMMIT_TO_NEW_BRANCH) { + if (getters.isCreatingNewBranch) { if (state.newBranchName === '') { return getters.placeholderBranchName; } @@ -48,11 +48,11 @@ export const preBuiltCommitMessage = (state, _, rootState) => { export const isCreatingNewBranch = state => state.commitAction === consts.COMMIT_TO_NEW_BRANCH; -export const isCommittingToCurrentBranch = state => - state.commitAction === consts.COMMIT_TO_CURRENT_BRANCH; - -export const isCommittingToDefaultBranch = (_state, getters, _rootState, rootGetters) => - getters.isCommittingToCurrentBranch && rootGetters.isOnDefaultBranch; +export const shouldHideNewMrOption = (_state, getters, _rootState, rootGetters) => + !getters.isCreatingNewBranch && + (rootGetters.hasMergeRequest || + (!rootGetters.hasMergeRequest && rootGetters.isOnDefaultBranch)) && + rootGetters.canPushToBranch; // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js index b81918156b0..7ad8f3570b7 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutation_types.js @@ -3,4 +3,3 @@ export const UPDATE_COMMIT_ACTION = 'UPDATE_COMMIT_ACTION'; export const UPDATE_NEW_BRANCH_NAME = 'UPDATE_NEW_BRANCH_NAME'; export const UPDATE_LOADING = 'UPDATE_LOADING'; export const TOGGLE_SHOULD_CREATE_MR = 'TOGGLE_SHOULD_CREATE_MR'; -export const INTERACT_WITH_NEW_MR = 'INTERACT_WITH_NEW_MR'; diff --git a/app/assets/javascripts/ide/stores/modules/commit/mutations.js b/app/assets/javascripts/ide/stores/modules/commit/mutations.js index 14957d283bb..73b618e250f 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/commit/mutations.js @@ -24,7 +24,4 @@ export default { shouldCreateMR: shouldCreateMR === undefined ? !state.shouldCreateMR : shouldCreateMR, }); }, - [types.INTERACT_WITH_NEW_MR](state) { - Object.assign(state, { interactedWithNewMR: true }); - }, }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/state.js b/app/assets/javascripts/ide/stores/modules/commit/state.js index 53647a7e3e3..259577e48e0 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/state.js +++ b/app/assets/javascripts/ide/stores/modules/commit/state.js @@ -3,6 +3,5 @@ export default () => ({ commitAction: '1', newBranchName: '', submitCommitLoading: false, - shouldCreateMR: false, - interactedWithNewMR: false, + shouldCreateMR: true, }); diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 04e86afb268..52200ce7847 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -129,7 +129,7 @@ export const commitActionForFile = file => { export const getCommitFiles = stagedFiles => stagedFiles.reduce((acc, file) => { - if (file.moved) return acc; + if (file.moved || file.type === 'tree') return acc; return acc.concat({ ...file, diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index 529b6386221..5a9dd91817e 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { initSidebarTracking } from 'ee_else_ce/event_tracking/issue_sidebar'; import issuableApp from './components/app.vue'; import { parseIssuableData } from './utils/parse_data'; import '../vue_shared/vue_resource_interceptor'; @@ -9,6 +10,9 @@ export default function initIssueableApp() { components: { issuableApp, }, + mounted() { + initSidebarTracking(); + }, render(createElement) { return createElement('issuable-app', { props: parseIssuableData(), diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 31c4a920bbe..6e8f63a10a4 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -732,6 +732,66 @@ export const NavigationType = { }; /** + * Method to perform case-insensitive search for a string + * within multiple properties and return object containing + * properties in case there are multiple matches or `null` + * if there's no match. + * + * Eg; Suppose we want to allow user to search using for a string + * within `iid`, `title`, `url` or `reference` props of a target object; + * + * const objectToSearch = { + * "iid": 1, + * "title": "Error omnis quos consequatur ullam a vitae sed omnis libero cupiditate. &3", + * "url": "/groups/gitlab-org/-/epics/1", + * "reference": "&1", + * }; + * + * Following is how we call searchBy and the return values it will yield; + * + * - `searchBy('omnis', objectToSearch);`: This will return `{ title: ... }` as our + * query was found within title prop we only return that. + * - `searchBy('1', objectToSearch);`: This will return `{ "iid": ..., "reference": ..., "url": ... }`. + * - `searchBy('https://gitlab.com/groups/gitlab-org/-/epics/1', objectToSearch);`: + * This will return `{ "url": ... }`. + * - `searchBy('foo', objectToSearch);`: This will return `null` as no property value + * matched with our query. + * + * You can learn more about behaviour of this method by referring to tests + * within `spec/javascripts/lib/utils/common_utils_spec.js`. + * + * @param {string} query String to search for + * @param {object} searchSpace Object containing properties to search in for `query` + */ +export const searchBy = (query = '', searchSpace = {}) => { + const targetKeys = searchSpace !== null ? Object.keys(searchSpace) : []; + + if (!query || !targetKeys.length) { + return null; + } + + const normalizedQuery = query.toLowerCase(); + const matches = targetKeys + .filter(item => { + const searchItem = `${searchSpace[item]}`.toLowerCase(); + + return ( + searchItem.indexOf(normalizedQuery) > -1 || + normalizedQuery.indexOf(searchItem) > -1 || + normalizedQuery === searchItem + ); + }) + .reduce((acc, prop) => { + const match = acc; + match[prop] = searchSpace[prop]; + + return acc; + }, {}); + + return Object.keys(matches).length ? matches : null; +}; + +/** * Checks if the given Label has a special syntax `::` in * it's title. * diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 1336b6a5461..7ead9d46fbb 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,5 +1,11 @@ import { join as joinPaths } from 'path'; +// Returns a decoded url parameter value +// - Treats '+' as '%20' +function decodeUrlParameter(val) { + return decodeURIComponent(val.replace(/\+/g, '%20')); +} + // Returns an array containing the value(s) of the // of the key passed as an argument export function getParameterValues(sParam, url = window.location) { @@ -30,7 +36,7 @@ export function mergeUrlParams(params, url) { .forEach(part => { if (part.length) { const kv = part.split('='); - merged[decodeURIComponent(kv[0])] = decodeURIComponent(kv.slice(1).join('=')); + merged[decodeUrlParameter(kv[0])] = decodeUrlParameter(kv.slice(1).join('=')); } }); } diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ba33d72b1f3..39f2097c174 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -9,7 +9,11 @@ import './commons'; import './behaviors'; // lib/utils -import { handleLocationHash, addSelectOnFocusBehaviour } from './lib/utils/common_utils'; +import { + handleLocationHash, + addSelectOnFocusBehaviour, + getCspNonceValue, +} from './lib/utils/common_utils'; import { localTimeAgo } from './lib/utils/datetime_utility'; import { getLocationHash, visitUrl } from './lib/utils/url_utility'; @@ -39,6 +43,17 @@ import 'ee_else_ce/main_ee'; window.jQuery = jQuery; window.$ = jQuery; +// Add nonce to jQuery script handler +jQuery.ajaxSetup({ + converters: { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings, func-names + 'text script': function(text) { + jQuery.globalEval(text, { nonce: getCspNonceValue() }); + return text; + }, + }, +}); + // inject test utilities if necessary if (process.env.NODE_ENV !== 'production' && gon && gon.test_env) { $.fx.off = true; diff --git a/app/assets/javascripts/members.js b/app/assets/javascripts/members.js index af2697444f2..d719fd8748d 100644 --- a/app/assets/javascripts/members.js +++ b/app/assets/javascripts/members.js @@ -17,6 +17,8 @@ export default class Members { } dropdownClicked(options) { + options.e.preventDefault(); + this.formSubmit(null, options.$el); } diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index 90c764587a3..cac10474d06 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -12,6 +12,9 @@ import { graphDataValidatorForValues } from '../../utils'; let debouncedResize; +// TODO: Remove this component in favor of the more general time_series.vue +// Please port all changes here to time_series.vue as well. + export default { components: { GlAreaChart, @@ -123,7 +126,7 @@ export default { }, }, series: this.scatterSeries, - dataZoom: this.dataZoomConfig, + dataZoom: [this.dataZoomConfig], }; }, dataZoomConfig() { diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue new file mode 100644 index 00000000000..02e7a7ba0a6 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -0,0 +1,342 @@ +<script> +import { __ } from '~/locale'; +import { mapState } from 'vuex'; +import { GlLink, GlButton } from '@gitlab/ui'; +import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts'; +import dateFormat from 'dateformat'; +import { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; +import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; +import Icon from '~/vue_shared/components/icon.vue'; +import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants'; +import { makeDataSeries } from '~/helpers/monitor_helper'; +import { graphDataValidatorForValues } from '../../utils'; + +let debouncedResize; + +export default { + components: { + GlAreaChart, + GlLineChart, + GlButton, + GlChartSeriesLabel, + GlLink, + Icon, + }, + inheritAttrs: false, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, + }, + deploymentData: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: false, + default: '', + }, + showBorder: { + type: Boolean, + required: false, + default: false, + }, + singleEmbed: { + type: Boolean, + required: false, + default: false, + }, + thresholds: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + tooltip: { + title: '', + content: [], + commitUrl: '', + isDeployment: false, + sha: '', + }, + width: 0, + height: chartHeight, + svgs: {}, + primaryColor: null, + }; + }, + computed: { + ...mapState('monitoringDashboard', ['exportMetricsToCsvEnabled']), + chartData() { + // Transforms & supplements query data to render appropriate labels & styles + // Input: [{ queryAttributes1 }, { queryAttributes2 }] + // Output: [{ seriesAttributes1 }, { seriesAttributes2 }] + return this.graphData.queries.reduce((acc, query) => { + const { appearance } = query; + const lineType = + appearance && appearance.line && appearance.line.type + ? appearance.line.type + : lineTypes.default; + const lineWidth = + appearance && appearance.line && appearance.line.width + ? appearance.line.width + : undefined; + const areaStyle = { + opacity: + appearance && appearance.area && typeof appearance.area.opacity === 'number' + ? appearance.area.opacity + : undefined, + }; + + const series = makeDataSeries(query.result, { + name: this.formatLegendLabel(query), + lineStyle: { + type: lineType, + width: lineWidth, + }, + showSymbol: false, + areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined, + }); + + return acc.concat(series); + }, []); + }, + chartOptions() { + return { + xAxis: { + name: __('Time'), + type: 'time', + axisLabel: { + formatter: date => dateFormat(date, dateFormats.timeOfDay), + }, + axisPointer: { + snap: true, + }, + }, + yAxis: { + name: this.yAxisLabel, + axisLabel: { + formatter: num => roundOffFloat(num, 3).toString(), + }, + }, + series: this.scatterSeries, + dataZoom: this.dataZoomConfig, + }; + }, + dataZoomConfig() { + const handleIcon = this.svgs['scroll-handle']; + + return handleIcon ? { handleIcon } : {}; + }, + earliestDatapoint() { + return this.chartData.reduce((acc, series) => { + const { data } = series; + const { length } = data; + if (!length) { + return acc; + } + + const [first] = data[0]; + const [last] = data[length - 1]; + const seriesEarliest = first < last ? first : last; + + return seriesEarliest < acc || acc === null ? seriesEarliest : acc; + }, null); + }, + glChartComponent() { + const chartTypes = { + 'area-chart': GlAreaChart, + 'line-chart': GlLineChart, + }; + return chartTypes[this.graphData.type] || GlAreaChart; + }, + isMultiSeries() { + return this.tooltip.content.length > 1; + }, + recentDeployments() { + return this.deploymentData.reduce((acc, deployment) => { + if (deployment.created_at >= this.earliestDatapoint) { + const { id, created_at, sha, ref, tag } = deployment; + acc.push({ + id, + createdAt: created_at, + sha, + commitUrl: `${this.projectPath}/commit/${sha}`, + tag, + tagUrl: tag ? `${this.tagsPath}/${ref.name}` : null, + ref: ref.name, + showDeploymentFlag: false, + }); + } + + return acc; + }, []); + }, + scatterSeries() { + return { + type: graphTypes.deploymentData, + data: this.recentDeployments.map(deployment => [deployment.createdAt, 0]), + symbol: this.svgs.rocket, + symbolSize: symbolSizes.default, + itemStyle: { + color: this.primaryColor, + }, + }; + }, + yAxisLabel() { + return `${this.graphData.y_label}`; + }, + csvText() { + const chartData = this.chartData[0].data; + const header = `timestamp,${this.graphData.y_label}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings + return chartData.reduce((csv, data) => { + const row = data.join(','); + return `${csv}${row}\r\n`; + }, header); + }, + downloadLink() { + const data = new Blob([this.csvText], { type: 'text/plain' }); + return window.URL.createObjectURL(data); + }, + }, + watch: { + containerWidth: 'onResize', + }, + beforeDestroy() { + window.removeEventListener('resize', debouncedResize); + }, + created() { + debouncedResize = debounceByAnimationFrame(this.onResize); + window.addEventListener('resize', debouncedResize); + this.setSvg('rocket'); + this.setSvg('scroll-handle'); + }, + methods: { + formatLegendLabel(query) { + return `${query.label}`; + }, + formatTooltipText(params) { + this.tooltip.title = dateFormat(params.value, dateFormats.default); + this.tooltip.content = []; + params.seriesData.forEach(dataPoint => { + const [xVal, yVal] = dataPoint.value; + this.tooltip.isDeployment = dataPoint.componentSubType === graphTypes.deploymentData; + if (this.tooltip.isDeployment) { + const [deploy] = this.recentDeployments.filter( + deployment => deployment.createdAt === xVal, + ); + this.tooltip.sha = deploy.sha.substring(0, 8); + this.tooltip.commitUrl = deploy.commitUrl; + } else { + const { seriesName, color } = dataPoint; + const value = yVal.toFixed(3); + this.tooltip.content.push({ + name: seriesName, + value, + color, + }); + } + }); + }, + setSvg(name) { + getSvgIconPathContent(name) + .then(path => { + if (path) { + this.$set(this.svgs, name, `path://${path}`); + } + }) + .catch(e => { + // eslint-disable-next-line no-console, @gitlab/i18n/no-non-i18n-strings + console.error('SVG could not be rendered correctly: ', e); + }); + }, + onChartUpdated(chart) { + [this.primaryColor] = chart.getOption().color; + }, + onResize() { + if (!this.$refs.chart) return; + const { width } = this.$refs.chart.$el.getBoundingClientRect(); + this.width = width; + }, + }, +}; +</script> + +<template> + <div + class="prometheus-graph col-12" + :class="[showBorder ? 'p-2' : 'p-0', { 'col-lg-6': !singleEmbed }]" + > + <div :class="{ 'prometheus-graph-embed w-100 p-3': showBorder }"> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title js-graph-title">{{ graphData.title }}</h5> + <gl-button + v-if="exportMetricsToCsvEnabled" + :href="downloadLink" + :title="__('Download CSV')" + :aria-label="__('Download CSV')" + style="margin-left: 200px;" + download="chart_metrics.csv" + > + {{ __('Download CSV') }} + </gl-button> + <div class="prometheus-graph-widgets js-graph-widgets"> + <slot></slot> + </div> + </div> + + <component + :is="glChartComponent" + ref="chart" + v-bind="$attrs" + :data="chartData" + :option="chartOptions" + :format-tooltip-text="formatTooltipText" + :thresholds="thresholds" + :width="width" + :height="height" + @updated="onChartUpdated" + > + <template v-if="tooltip.isDeployment"> + <template slot="tooltipTitle"> + {{ __('Deployed') }} + </template> + <div slot="tooltipContent" class="d-flex align-items-center"> + <icon name="commit" class="mr-2" /> + <gl-link :href="tooltip.commitUrl">{{ tooltip.sha }}</gl-link> + </div> + </template> + <template v-else> + <template slot="tooltipTitle"> + <div class="text-nowrap"> + {{ tooltip.title }} + </div> + </template> + <template slot="tooltipContent"> + <div + v-for="(content, key) in tooltip.content" + :key="key" + class="d-flex justify-content-between" + > + <gl-chart-series-label :color="isMultiSeries ? content.color : ''"> + {{ content.name }} + </gl-chart-series-label> + <div class="prepend-left-32"> + {{ content.value }} + </div> + </div> + </template> + </template> + </component> + </div> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index dfeeba238ca..d330ceb836c 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -14,9 +14,9 @@ import { __, s__ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams } from '~/lib/utils/url_utility'; import invalidUrl from '~/lib/utils/invalid_url'; -import MonitorAreaChart from './charts/area.vue'; +import PanelType from 'ee_else_ce/monitoring/components/panel_type.vue'; +import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; -import PanelType from './panel_type.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; import { sidebarAnimationDuration, timeWindows } from '../constants'; @@ -26,7 +26,7 @@ let sidebarMutationObserver; export default { components: { - MonitorAreaChart, + MonitorTimeSeriesChart, MonitorSingleStatChart, PanelType, GraphGroup, @@ -141,6 +141,16 @@ export default { required: false, default: false, }, + alertsEndpoint: { + type: String, + required: false, + default: null, + }, + prometheusAlertsAvailable: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -264,12 +274,12 @@ export default { showToast() { this.$toast.show(__('Link copied to clipboard')); }, + // TODO: END generateLink(group, title, yLabel) { const dashboard = this.currentDashboard || this.firstDashboard.path; - const params = { dashboard, group, title, y_label: yLabel }; + const params = _.pick({ dashboard, group, title, y_label: yLabel }, value => value != null); return mergeUrlParams(params, window.location.href); }, - // TODO: END hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, @@ -449,11 +459,13 @@ export default { :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" :graph-data="graphData" :dashboard-width="elWidth" + :alerts-endpoint="alertsEndpoint" + :prometheus-alerts-available="prometheusAlertsAvailable" :index="`${index}-${graphIndex}`" /> </template> <template v-else> - <monitor-area-chart + <monitor-time-series-chart v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" :key="graphIndex" :graph-data="graphData" @@ -461,7 +473,7 @@ export default { :thresholds="getGraphAlertValues(graphData.queries)" :container-width="elWidth" :project-path="projectPath" - group-id="monitor-area-chart" + group-id="monitor-time-series-chart" > <div class="d-flex align-items-center"> <alert-widget @@ -503,7 +515,7 @@ export default { </gl-dropdown-item> </gl-dropdown> </div> - </monitor-area-chart> + </monitor-time-series-chart> </template> </graph-group> </div> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index e3256147618..b516a82c170 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -2,7 +2,7 @@ import { mapActions, mapState } from 'vuex'; import { getParameterValues, removeParams } from '~/lib/utils/url_utility'; import GraphGroup from './graph_group.vue'; -import MonitorAreaChart from './charts/area.vue'; +import MonitorTimeSeriesChart from './charts/time_series.vue'; import { sidebarAnimationDuration } from '../constants'; import { getTimeDiff } from '../utils'; @@ -11,7 +11,7 @@ let sidebarMutationObserver; export default { components: { GraphGroup, - MonitorAreaChart, + MonitorTimeSeriesChart, }, props: { dashboardUrl: { @@ -92,7 +92,7 @@ export default { <template> <div class="metrics-embed" :class="{ 'd-inline-flex col-lg-6 p-0': isSingleChart }"> <div v-if="charts.length" class="row w-100 m-n2 pb-4"> - <monitor-area-chart + <monitor-time-series-chart v-for="graphData in charts" :key="graphData.title" :graph-data="graphData" diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 96f62bc85ee..73ff651d510 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -10,14 +10,14 @@ import { GlTooltipDirective, } from '@gitlab/ui'; import Icon from '~/vue_shared/components/icon.vue'; -import MonitorAreaChart from './charts/area.vue'; +import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; export default { components: { - MonitorAreaChart, MonitorSingleStatChart, + MonitorTimeSeriesChart, MonitorEmptyChart, Icon, GlDropdown, @@ -92,7 +92,7 @@ export default { v-if="isPanelType('single-stat') && graphDataHasMetrics" :graph-data="graphData" /> - <monitor-area-chart + <monitor-time-series-chart v-else-if="graphDataHasMetrics" :graph-data="graphData" :deployment-data="deploymentData" @@ -136,6 +136,6 @@ export default { </gl-dropdown-item> </gl-dropdown> </div> - </monitor-area-chart> + </monitor-time-series-chart> <monitor-empty-chart v-else :graph-title="graphData.title" /> </template> diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js index d7d89522732..13aba3d9f44 100644 --- a/app/assets/javascripts/monitoring/constants.js +++ b/app/assets/javascripts/monitoring/constants.js @@ -8,6 +8,10 @@ export const graphTypes = { deploymentData: 'scatter', }; +export const symbolSizes = { + default: 14, +}; + export const lineTypes = { default: 'solid', }; @@ -21,6 +25,11 @@ export const timeWindows = { oneWeek: __('1 week'), }; +export const dateFormats = { + timeOfDay: 'h:MM TT', + default: 'dd mmm yyyy, h:MMTT', +}; + export const secondsIn = { thirtyMinutes: 60 * 30, threeHours: 60 * 60 * 3, diff --git a/app/assets/javascripts/notes/stores/getters.js b/app/assets/javascripts/notes/stores/getters.js index 52410f18d4a..3d0ec8cd3a7 100644 --- a/app/assets/javascripts/notes/stores/getters.js +++ b/app/assets/javascripts/notes/stores/getters.js @@ -171,26 +171,33 @@ export const isLastUnresolvedDiscussion = (state, getters) => (discussionId, dif return lastDiscussionId === discussionId; }; -// Gets the ID of the discussion following the one provided, respecting order (diff or date) -// @param {Boolean} discussionId - id of the current discussion -// @param {Boolean} diffOrder - is ordered by diff? -export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { - const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); - const currentIndex = idsOrdered.indexOf(discussionId); - const slicedIds = idsOrdered.slice(currentIndex + 1, currentIndex + 2); +export const findUnresolvedDiscussionIdNeighbor = (state, getters) => ({ + discussionId, + diffOrder, + step, +}) => { + const ids = getters.unresolvedDiscussionsIdsOrdered(diffOrder); + const index = ids.indexOf(discussionId) + step; + + if (index < 0 && step < 0) { + return ids[ids.length - 1]; + } + + if (index === ids.length && step > 0) { + return ids[0]; + } - // Get the first ID if there is none after the currentIndex - return slicedIds.length ? idsOrdered.slice(currentIndex + 1, currentIndex + 2)[0] : idsOrdered[0]; + return ids[index]; }; -export const previousUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => { - const idsOrdered = getters.unresolvedDiscussionsIdsOrdered(diffOrder); - const currentIndex = idsOrdered.indexOf(discussionId); - const slicedIds = idsOrdered.slice(currentIndex - 1, currentIndex); +// Gets the ID of the discussion following the one provided, respecting order (diff or date) +// @param {Boolean} discussionId - id of the current discussion +// @param {Boolean} diffOrder - is ordered by diff? +export const nextUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => + getters.findUnresolvedDiscussionIdNeighbor({ discussionId, diffOrder, step: 1 }); - // Get the last ID if there is none after the currentIndex - return slicedIds.length ? slicedIds[0] : idsOrdered[idsOrdered.length - 1]; -}; +export const previousUnresolvedDiscussionId = (state, getters) => (discussionId, diffOrder) => + getters.findUnresolvedDiscussionIdNeighbor({ discussionId, diffOrder, step: -1 }); // @param {Boolean} diffOrder - is ordered by diff? export const firstUnresolvedDiscussionId = (state, getters) => diffOrder => { diff --git a/app/assets/javascripts/pages/projects/wikis/wikis.js b/app/assets/javascripts/pages/projects/wikis/wikis.js index 9b58d42b47d..d41199f6374 100644 --- a/app/assets/javascripts/pages/projects/wikis/wikis.js +++ b/app/assets/javascripts/pages/projects/wikis/wikis.js @@ -1,6 +1,5 @@ import bp from '../../../breakpoints'; -import { parseQueryStringIntoObject } from '../../../lib/utils/common_utils'; -import { mergeUrlParams, redirectTo } from '../../../lib/utils/url_utility'; +import { s__, sprintf } from '~/locale'; export default class Wikis { constructor() { @@ -12,32 +11,37 @@ export default class Wikis { sidebarToggles[i].addEventListener('click', e => this.handleToggleSidebar(e)); } - this.newWikiForm = document.querySelector('form.new-wiki-page'); - if (this.newWikiForm) { - this.newWikiForm.addEventListener('submit', e => this.handleNewWikiSubmit(e)); + this.isNewWikiPage = Boolean(document.querySelector('.js-new-wiki-page')); + this.editTitleInput = document.querySelector('form.wiki-form #wiki_title'); + this.commitMessageInput = document.querySelector('form.wiki-form #wiki_message'); + this.commitMessageI18n = this.isNewWikiPage + ? s__('WikiPageCreate|Create %{pageTitle}') + : s__('WikiPageEdit|Update %{pageTitle}'); + + if (this.editTitleInput) { + // Initialize the commit message on load + if (this.editTitleInput.value) this.setWikiCommitMessage(this.editTitleInput.value); + + // Set the commit message as the page title is changed + this.editTitleInput.addEventListener('keyup', e => this.handleWikiTitleChange(e)); } window.addEventListener('resize', () => this.renderSidebar()); this.renderSidebar(); } - handleNewWikiSubmit(e) { - if (!this.newWikiForm) return; - - const slugInput = this.newWikiForm.querySelector('#new_wiki_path'); - - const slug = slugInput.value; + handleWikiTitleChange(e) { + this.setWikiCommitMessage(e.target.value); + } - if (slug.length > 0) { - const wikisPath = slugInput.getAttribute('data-wikis-path'); + setWikiCommitMessage(rawTitle) { + let title = rawTitle; - // If the wiki is empty, we need to merge the current URL params to keep the "create" view. - const params = parseQueryStringIntoObject(window.location.search.substr(1)); - const url = mergeUrlParams(params, `${wikisPath}/${slug}`); - redirectTo(url); + // Replace hyphens with spaces + if (title) title = title.replace(/-+/g, ' '); - e.preventDefault(); - } + const newCommitMessage = sprintf(this.commitMessageI18n, { pageTitle: title }); + this.commitMessageInput.value = newCommitMessage; } handleToggleSidebar(e) { diff --git a/app/assets/javascripts/pipelines/components/pipeline_url.vue b/app/assets/javascripts/pipelines/components/pipeline_url.vue index e01080b04d6..a08f732dda7 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_url.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_url.vue @@ -67,7 +67,7 @@ export default { <span v-if="pipeline.flags.latest" v-gl-tooltip - :title="__('Latest pipeline for this branch')" + :title="__('Latest pipeline for the most recent commit on this branch')" class="js-pipeline-url-latest badge badge-success" > {{ __('latest') }} @@ -104,7 +104,7 @@ export default { v-gl-tooltip :title=" __( - 'The code of a detached pipeline is tested against the source branch instead of merged results', + 'Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.', ) " class="js-pipeline-url-detached badge badge-info" 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 03281aa1317..12ee1ce2f0c 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 @@ -38,7 +38,9 @@ export default { }, computed: { statusTitle() { - return sprintf(s__('Commits|Commit: %{commitText}'), { commitText: this.ciStatus.text }); + return sprintf(s__('PipelineStatusTooltip|Pipeline: %{ciStatus}'), { + ciStatus: this.ciStatus.text, + }); }, }, mounted() { diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue index efbf0a4e3cf..346dc470a59 100644 --- a/app/assets/javascripts/registry/components/app.vue +++ b/app/assets/javascripts/registry/components/app.vue @@ -1,10 +1,9 @@ <script> import { mapGetters, mapActions } from 'vuex'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui'; import store from '../stores'; import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; import CollapsibleContainer from './collapsible_container.vue'; -import SvgMessage from './svg_message.vue'; import { s__, sprintf } from '../../locale'; export default { @@ -12,8 +11,8 @@ export default { components: { clipboardButton, CollapsibleContainer, + GlEmptyState, GlLoadingIcon, - SvgMessage, }, props: { endpoint: { @@ -93,7 +92,9 @@ export default { this.setMainEndpoint(this.endpoint); }, mounted() { - this.fetchRepos(); + if (!this.characterError) { + this.fetchRepos(); + } }, methods: { ...mapActions(['setMainEndpoint', 'fetchRepos']), @@ -102,61 +103,63 @@ export default { </script> <template> <div> - <svg-message v-if="characterError" id="invalid-characters" :svg-path="containersErrorImage"> - <h4> - {{ s__('ContainerRegistry|Docker connection error') }} - </h4> - <p v-html="dockerConnectionErrorText"></p> - </svg-message> + <gl-empty-state + v-if="characterError" + :title="s__('ContainerRegistry|Docker connection error')" + :svg-path="containersErrorImage" + > + <template #description> + <p v-html="dockerConnectionErrorText"></p> + </template> + </gl-empty-state> - <gl-loading-icon v-else-if="isLoading && !characterError" size="md" class="prepend-top-16" /> + <gl-loading-icon v-else-if="isLoading" size="md" class="prepend-top-16" /> - <div v-else-if="!isLoading && !characterError && repos.length"> + <div v-else-if="!isLoading && repos.length"> <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> <p v-html="introText"></p> <collapsible-container v-for="item in repos" :key="item.id" :repo="item" /> </div> - <svg-message - v-else-if="!isLoading && !characterError && !repos.length" - id="no-container-images" + <gl-empty-state + v-else + :title="s__('ContainerRegistry|There are no container images stored for this project')" :svg-path="noContainersImage" + class="container-message" > - <h4> - {{ s__('ContainerRegistry|There are no container images stored for this project') }} - </h4> - <p v-html="noContainerImagesText"></p> - - <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> - <p> - {{ - s__( - 'ContainerRegistry|You can add an image to this registry with the following commands:', - ) - }} - </p> + <template #description> + <p class="js-no-container-images-text" v-html="noContainerImagesText"></p> + <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> + <p> + {{ + s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ) + }} + </p> - <div class="input-group append-bottom-10"> - <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerBuildCommand" - :title="s__('ContainerRegistry|Copy build command to clipboard')" - class="input-group-text" - /> - </span> - </div> + <div class="input-group append-bottom-10"> + <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerBuildCommand" + :title="s__('ContainerRegistry|Copy build command to clipboard')" + class="input-group-text" + /> + </span> + </div> - <div class="input-group"> - <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> - <span class="input-group-append"> - <clipboard-button - :text="dockerPushCommand" - :title="s__('ContainerRegistry|Copy push command to clipboard')" - class="input-group-text" - /> - </span> - </div> - </svg-message> + <div class="input-group"> + <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerPushCommand" + :title="s__('ContainerRegistry|Copy push command to clipboard')" + class="input-group-text" + /> + </span> + </div> + </template> + </gl-empty-state> </div> </template> diff --git a/app/assets/javascripts/registry/components/svg_message.vue b/app/assets/javascripts/registry/components/svg_message.vue deleted file mode 100644 index 617093e054e..00000000000 --- a/app/assets/javascripts/registry/components/svg_message.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -export default { - name: 'RegistrySvgMessage', - props: { - id: { - type: String, - required: true, - }, - svgPath: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <div :id="id" class="empty-state container-message"> - <div class="svg-content"> - <img :src="svgPath" class="flex-align-self-center" /> - </div> - <div class="text-content"> - <slot></slot> - </div> - </div> -</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue new file mode 100644 index 00000000000..71a1fc31315 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -0,0 +1,48 @@ +<script> +import { __, sprintf } from '~/locale'; + +export default { + props: { + user: { + type: Object, + required: true, + }, + imgSize: { + type: Number, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + assigneeAlt() { + return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); + }, + avatarUrl() { + return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + }, + isMergeRequest() { + return this.issuableType === 'merge_request'; + }, + hasMergeIcon() { + return this.isMergeRequest && !this.user.can_merge; + }, + }, +}; +</script> + +<template> + <span class="position-relative"> + <img + :alt="assigneeAlt" + :src="avatarUrl" + :width="imgSize" + :class="`s${imgSize}`" + class="avatar avatar-inline m-0" + /> + <i v-if="hasMergeIcon" aria-hidden="true" class="fa fa-exclamation-triangle merge-icon"></i> + </span> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue new file mode 100644 index 00000000000..6633a63d046 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -0,0 +1,83 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { joinPaths } from '~/lib/utils/url_utility'; +import AssigneeAvatar from './assignee_avatar.vue'; + +export default { + components: { + AssigneeAvatar, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + user: { + type: Object, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + tooltipPlacement: { + type: String, + default: 'bottom', + required: false, + }, + tooltipHasName: { + type: Boolean, + default: true, + required: false, + }, + issuableType: { + type: String, + default: 'issue', + required: false, + }, + }, + computed: { + cannotMerge() { + return this.issuableType === 'merge_request' && !this.user.can_merge; + }, + tooltipTitle() { + if (this.cannotMerge && this.tooltipHasName) { + return sprintf(__('%{userName} (cannot merge)'), { userName: this.user.name }); + } else if (this.cannotMerge) { + return __('Cannot merge'); + } else if (this.tooltipHasName) { + return this.user.name; + } + + return ''; + }, + tooltipOption() { + return { + container: 'body', + placement: this.tooltipPlacement, + boundary: 'viewport', + }; + }, + assigneeUrl() { + return joinPaths(`${this.rootPath}`, `${this.user.username}`); + }, + }, +}; +</script> + +<template> + <!-- must be `d-inline-block` or parent flex-basis causes width issues --> + <gl-link + v-gl-tooltip="tooltipOption" + :href="assigneeUrl" + :title="tooltipTitle" + class="d-inline-block" + > + <!-- use d-flex so that slot can be appropriately styled --> + <span class="d-flex"> + <assignee-avatar :user="user" :img-size="32" :issuable-type="issuableType" /> + <slot :user="user"></slot> + </span> + </gl-link> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index fa6b6bfaef1..63b93a80ead 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -1,5 +1,6 @@ <script> import { n__ } from '~/locale'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { name: 'AssigneeTitle', @@ -29,13 +30,23 @@ export default { return n__('Assignee', `%d Assignees`, assignees); }, }, + methods: { + trackEdit() { + trackEvent('click_edit_button', 'assignee'); + }, + }, }; </script> <template> <div class="title hide-collapsed"> {{ assigneeTitle }} <i v-if="loading" aria-hidden="true" class="fa fa-spinner fa-spin block-loading"></i> - <a v-if="editable" class="js-sidebar-dropdown-toggle edit-link float-right" href="#"> + <a + v-if="editable" + class="js-sidebar-dropdown-toggle edit-link float-right" + href="#" + @click.prevent="trackEdit" + > {{ __('Edit') }} </a> <a diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 631e2e28d4d..d9739e8d197 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -1,13 +1,14 @@ <script> -import { __, sprintf } from '~/locale'; -import tooltip from '~/vue_shared/directives/tooltip'; +import CollapsedAssigneeList from '../assignees/collapsed_assignee_list.vue'; +import UncollapsedAssigneeList from '../assignees/uncollapsed_assignee_list.vue'; export default { // name: 'Assignees' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings name: 'Assignees', - directives: { - tooltip, + components: { + CollapsedAssigneeList, + UncollapsedAssigneeList, }, props: { rootPath: { @@ -24,171 +25,34 @@ export default { }, issuableType: { type: String, - require: true, + required: false, default: 'issue', }, }, - data() { - return { - defaultRenderCount: 5, - defaultMaxCounter: 99, - showLess: true, - }; - }, computed: { - firstUser() { - return this.users[0]; - }, - hasMoreThanTwoAssignees() { - return this.users.length > 2; - }, - hasMoreThanOneAssignee() { - return this.users.length > 1; - }, - hasAssignees() { - return this.users.length > 0; - }, hasNoUsers() { return !this.users.length; }, - hasOneUser() { - return this.users.length === 1; - }, - renderShowMoreSection() { - return this.users.length > this.defaultRenderCount; - }, - numberOfHiddenAssignees() { - return this.users.length - this.defaultRenderCount; - }, - isHiddenAssignees() { - return this.numberOfHiddenAssignees > 0; - }, - hiddenAssigneesLabel() { - const { numberOfHiddenAssignees } = this; - return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees }); - }, - collapsedTooltipTitle() { - const maxRender = Math.min(this.defaultRenderCount, this.users.length); - const renderUsers = this.users.slice(0, maxRender); - const names = renderUsers.map(u => u.name); - - if (this.users.length > maxRender) { - names.push(`+ ${this.users.length - maxRender} more`); - } - - if (!this.users.length) { - const emptyTooltipLabel = __('Assignee(s)'); - names.push(emptyTooltipLabel); - } - - return names.join(', '); - }, - sidebarAvatarCounter() { - let counter = `+${this.users.length - 1}`; - - if (this.users.length > this.defaultMaxCounter) { - counter = `${this.defaultMaxCounter}+`; - } + sortedAssigness() { + const canMergeUsers = this.users.filter(user => user.can_merge); + const canNotMergeUsers = this.users.filter(user => !user.can_merge); - return counter; - }, - mergeNotAllowedTooltipMessage() { - const assigneesCount = this.users.length; - - if (this.issuableType !== 'merge_request' || assigneesCount === 0) { - return null; - } - - const cannotMergeCount = this.users.filter(u => u.can_merge === false).length; - const canMergeCount = assigneesCount - cannotMergeCount; - - if (canMergeCount === assigneesCount) { - // Everyone can merge - return null; - } else if (cannotMergeCount === assigneesCount && assigneesCount > 1) { - return __('No one can merge'); - } else if (assigneesCount === 1) { - return __('Cannot merge'); - } - - return sprintf(__('%{canMergeCount}/%{assigneesCount} can merge'), { - canMergeCount, - assigneesCount, - }); + return [...canMergeUsers, ...canNotMergeUsers]; }, }, methods: { assignSelf() { this.$emit('assign-self'); }, - toggleShowLess() { - this.showLess = !this.showLess; - }, - renderAssignee(index) { - return !this.showLess || (index < this.defaultRenderCount && this.showLess); - }, - avatarUrl(user) { - return user.avatar || user.avatar_url || gon.default_avatar_url; - }, - assigneeUrl(user) { - return `${this.rootPath}${user.username}`; - }, - assigneeAlt(user) { - return sprintf(__("%{userName}'s avatar"), { userName: user.name }); - }, - assigneeUsername(user) { - return `@${user.username}`; - }, - shouldRenderCollapsedAssignee(index) { - const firstTwo = this.users.length <= 2 && index <= 2; - - return index === 0 || firstTwo; - }, }, }; </script> <template> <div> - <div - v-tooltip - :class="{ 'multiple-users': hasMoreThanOneAssignee }" - :title="collapsedTooltipTitle" - class="sidebar-collapsed-icon sidebar-collapsed-user" - data-container="body" - data-placement="left" - data-boundary="viewport" - > - <i v-if="hasNoUsers" :aria-label="__('None')" class="fa fa-user"> </i> - <button - v-for="(user, index) in users" - v-if="shouldRenderCollapsedAssignee(index)" - :key="user.id" - type="button" - class="btn-link" - > - <img - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - width="24" - class="avatar avatar-inline s24" - /> - <span class="author"> {{ user.name }} </span> - </button> - <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button"> - <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> - </button> - </div> + <collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" /> + <div class="value hide-collapsed"> - <span - v-if="mergeNotAllowedTooltipMessage" - v-tooltip - :title="mergeNotAllowedTooltipMessage" - data-placement="left" - class="float-right cannot-be-merged" - > - <i aria-hidden="true" data-hidden="true" class="fa fa-exclamation-triangle"></i> - </span> <template v-if="hasNoUsers"> <span class="assign-yourself no-value qa-assign-yourself"> {{ __('None') }} @@ -200,51 +64,13 @@ export default { </template> </span> </template> - <template v-else-if="hasOneUser"> - <a :href="assigneeUrl(firstUser)" class="author-link bold"> - <img - :alt="assigneeAlt(firstUser)" - :src="avatarUrl(firstUser)" - width="32" - class="avatar avatar-inline s32" - /> - <span class="author"> {{ firstUser.name }} </span> - <span class="username"> {{ assigneeUsername(firstUser) }} </span> - </a> - </template> - <template v-else> - <div class="user-list"> - <div - v-for="(user, index) in users" - v-if="renderAssignee(index)" - :key="user.id" - class="user-item" - > - <a - :href="assigneeUrl(user)" - :data-title="user.name" - class="user-link has-tooltip" - data-container="body" - data-placement="bottom" - > - <img - :alt="assigneeAlt(user)" - :src="avatarUrl(user)" - width="32" - class="avatar avatar-inline s32" - /> - </a> - </div> - </div> - <div v-if="renderShowMoreSection" class="user-list-more"> - <button type="button" class="btn-link" @click="toggleShowLess"> - <template v-if="showLess"> - {{ hiddenAssigneesLabel }} - </template> - <template v-else>{{ __('- show less') }}</template> - </button> - </div> - </template> + + <uncollapsed-assignee-list + v-else + :users="sortedAssigness" + :root-path="rootPath" + :issuable-type="issuableType" + /> </div> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue new file mode 100644 index 00000000000..2f654409561 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee.vue @@ -0,0 +1,27 @@ +<script> +import AssigneeAvatar from './assignee_avatar.vue'; + +export default { + components: { + AssigneeAvatar, + }, + props: { + user: { + type: Object, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, +}; +</script> + +<template> + <button type="button" class="btn-link"> + <assignee-avatar :user="user" :img-size="24" :issuable-type="issuableType" /> + <span class="author"> {{ user.name }} </span> + </button> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue new file mode 100644 index 00000000000..5b4a43399ca --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -0,0 +1,121 @@ +<script> +import { __, sprintf } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; +import CollapsedAssignee from './collapsed_assignee.vue'; + +const DEFAULT_MAX_COUNTER = 99; +const DEFAULT_RENDER_COUNT = 5; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CollapsedAssignee, + }, + props: { + users: { + type: Array, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + computed: { + isMergeRequest() { + return this.issuableType === 'merge_request'; + }, + hasNoUsers() { + return !this.users.length; + }, + hasMoreThanOneAssignee() { + return this.users.length > 1; + }, + hasMoreThanTwoAssignees() { + return this.users.length > 2; + }, + allAssigneesCanMerge() { + return this.users.every(user => user.can_merge); + }, + sidebarAvatarCounter() { + if (this.users.length > DEFAULT_MAX_COUNTER) { + return `${DEFAULT_MAX_COUNTER}+`; + } + + return `+${this.users.length - 1}`; + }, + collapsedUsers() { + const collapsedLength = this.hasMoreThanTwoAssignees ? 1 : this.users.length; + + return this.users.slice(0, collapsedLength); + }, + tooltipTitleMergeStatus() { + if (!this.isMergeRequest) { + return ''; + } + + const mergeLength = this.users.filter(u => u.can_merge).length; + + if (mergeLength === this.users.length) { + return ''; + } else if (mergeLength > 0) { + return sprintf(__('%{mergeLength}/%{usersLength} can merge'), { + mergeLength, + usersLength: this.users.length, + }); + } + + return this.users.length === 1 ? __('cannot merge') : __('no one can merge'); + }, + tooltipTitle() { + const maxRender = Math.min(DEFAULT_RENDER_COUNT, this.users.length); + const renderUsers = this.users.slice(0, maxRender); + const names = renderUsers.map(u => u.name); + + if (!this.users.length) { + return __('Assignee(s)'); + } + + if (this.users.length > names.length) { + names.push(sprintf(__('+ %{amount} more'), { amount: this.users.length - names.length })); + } + + const text = names.join(', '); + + return this.tooltipTitleMergeStatus ? `${text} (${this.tooltipTitleMergeStatus})` : text; + }, + + tooltipOptions() { + return { container: 'body', placement: 'left', boundary: 'viewport' }; + }, + }, +}; +</script> + +<template> + <div + v-gl-tooltip="tooltipOptions" + :class="{ 'multiple-users': hasMoreThanOneAssignee }" + :title="tooltipTitle" + class="sidebar-collapsed-icon sidebar-collapsed-user" + > + <i v-if="hasNoUsers" :aria-label="__('None')" class="fa fa-user"> </i> + <collapsed-assignee + v-for="user in collapsedUsers" + :key="user.id" + :user="user" + :issuable-type="issuableType" + /> + <button v-if="hasMoreThanTwoAssignees" class="btn-link" type="button"> + <span class="avatar-counter sidebar-avatar-counter"> {{ sidebarAvatarCounter }} </span> + <i + v-if="isMergeRequest && !allAssigneesCanMerge" + aria-hidden="true" + class="fa fa-exclamation-triangle merge-icon" + ></i> + </button> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index be1e4811856..c6cc04a139f 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -29,7 +29,7 @@ export default { }, issuableType: { type: String, - require: true, + required: false, default: 'issue', }, }, diff --git a/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue new file mode 100644 index 00000000000..3a4623121f4 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/uncollapsed_assignee_list.vue @@ -0,0 +1,96 @@ +<script> +import { __, sprintf } from '~/locale'; +import AssigneeAvatarLink from './assignee_avatar_link.vue'; + +const DEFAULT_RENDER_COUNT = 5; + +export default { + components: { + AssigneeAvatarLink, + }, + props: { + users: { + type: Array, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + }, + data() { + return { + showLess: true, + }; + }, + computed: { + firstUser() { + return this.users[0]; + }, + hasOneUser() { + return this.users.length === 1; + }, + hiddenAssigneesLabel() { + const { numberOfHiddenAssignees } = this; + return sprintf(__('+ %{numberOfHiddenAssignees} more'), { numberOfHiddenAssignees }); + }, + renderShowMoreSection() { + return this.users.length > DEFAULT_RENDER_COUNT; + }, + numberOfHiddenAssignees() { + return this.users.length - DEFAULT_RENDER_COUNT; + }, + uncollapsedUsers() { + const uncollapsedLength = this.showLess + ? Math.min(this.users.length, DEFAULT_RENDER_COUNT) + : this.users.length; + return this.showLess ? this.users.slice(0, uncollapsedLength) : this.users; + }, + username() { + return `@${this.firstUser.username}`; + }, + }, + methods: { + toggleShowLess() { + this.showLess = !this.showLess; + }, + }, +}; +</script> + +<template> + <assignee-avatar-link + v-if="hasOneUser" + v-slot="{ user }" + tooltip-placement="left" + :tooltip-has-name="false" + :user="firstUser" + :root-path="rootPath" + :issuable-type="issuableType" + > + <div class="ml-2"> + <span class="author"> {{ user.name }} </span> + <span class="username"> {{ username }} </span> + </div> + </assignee-avatar-link> + <div v-else> + <div class="user-list"> + <div v-for="user in uncollapsedUsers" :key="user.id" class="user-item"> + <assignee-avatar-link :user="user" :root-path="rootPath" :issuable-type="issuableType" /> + </div> + </div> + <div v-if="renderShowMoreSection" class="user-list-more"> + <button type="button" class="btn-link" @click="toggleShowLess"> + <template v-if="showLess"> + {{ hiddenAssigneesLabel }} + </template> + <template v-else>{{ __('- show less') }}</template> + </button> + </div> + </div> +</template> 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 597b723a9d9..1c75b6148e8 100644 --- a/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/confidential/confidential_issue_sidebar.vue @@ -5,6 +5,7 @@ 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 { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { components: { @@ -51,6 +52,11 @@ export default { toggleForm() { this.edit = !this.edit; }, + onEditClick() { + this.toggleForm(); + + trackEvent('click_edit_button', 'confidentiality'); + }, updateConfidentialAttribute(confidential) { this.service .update('issue', { confidential }) @@ -82,7 +88,7 @@ export default { v-if="isEditable" class="float-right confidential-edit" href="#" - @click.prevent="toggleForm" + @click.prevent="onEditClick" > {{ __('Edit') }} </a> diff --git a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue index c5cfa92f3c8..ec2a7b93a98 100644 --- a/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/lock/lock_issue_sidebar.vue @@ -6,6 +6,7 @@ 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'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; export default { components: { @@ -65,7 +66,11 @@ export default { toggleForm() { this.mediator.store.isLockDialogOpen = !this.mediator.store.isLockDialogOpen; }, + onEditClick() { + this.toggleForm(); + trackEvent('click_edit_button', 'lock_issue'); + }, updateLockedAttribute(locked) { this.mediator.service .update(this.issuableType, { @@ -109,7 +114,7 @@ export default { v-if="isEditable" class="float-right lock-edit" type="button" - @click.prevent="toggleForm" + @click.prevent="onEditClick" > {{ __('Edit') }} </button> diff --git a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue index 0d1faceef11..1f5f19d1931 100644 --- a/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue +++ b/app/assets/javascripts/sidebar/components/subscriptions/subscriptions.vue @@ -4,6 +4,7 @@ import icon from '~/vue_shared/components/icon.vue'; import toggleButton from '~/vue_shared/components/toggle_button.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import eventHub from '../../event_hub'; +import { trackEvent } from 'ee_else_ce/event_tracking/issue_sidebar'; const ICON_ON = 'notifications'; const ICON_OFF = 'notifications-off'; @@ -63,6 +64,8 @@ export default { // Component event emission. this.$emit('toggleSubscription', this.id); + + trackEvent('toggle_button', 'notifications', this.subscribed ? 0 : 1); }, onClickCollapsedIcon() { this.$emit('toggleSidebar'); diff --git a/app/assets/javascripts/test_utils/simulate_drag.js b/app/assets/javascripts/test_utils/simulate_drag.js index be9ebc81c6b..c9bf234fcce 100644 --- a/app/assets/javascripts/test_utils/simulate_drag.js +++ b/app/assets/javascripts/test_utils/simulate_drag.js @@ -153,7 +153,11 @@ export default function simulateDrag(options) { if (progress >= 1) { if (options.ondragend) options.ondragend(); - simulateEvent(toEl, 'mouseup'); + + if (options.performDrop) { + simulateEvent(toEl, 'mouseup'); + } + clearInterval(dragInterval); window.SIMULATE_DRAG_ACTIVE = 0; } diff --git a/app/assets/javascripts/tracking.js b/app/assets/javascripts/tracking.js index 2d0b099cf0b..a852f937eec 100644 --- a/app/assets/javascripts/tracking.js +++ b/app/assets/javascripts/tracking.js @@ -15,8 +15,14 @@ const extractData = (el, opts = {}) => { }; export default class Tracking { + static trackable() { + return !['1', 'yes'].includes( + window.doNotTrack || navigator.doNotTrack || navigator.msDoNotTrack, + ); + } + static enabled() { - return typeof window.snowplow === 'function'; + return typeof window.snowplow === 'function' && this.trackable(); } static event(category = document.body.dataset.page, event = 'generic', data = {}) { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 33cedf78331..12c939aa70f 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -62,6 +62,8 @@ function UsersSelect(currentUser, els, options = {}) { options.showCurrentUser = $dropdown.data('currentUser'); options.todoFilter = $dropdown.data('todoFilter'); options.todoStateFilter = $dropdown.data('todoStateFilter'); + options.iid = $dropdown.data('iid'); + options.issuableType = $dropdown.data('issuableType'); showNullUser = $dropdown.data('nullUser'); defaultNullUser = $dropdown.data('nullUserDefault'); showMenuAbove = $dropdown.data('showMenuAbove'); @@ -239,7 +241,7 @@ function UsersSelect(currentUser, els, options = {}) { '<% if( avatar ) { %> <a class="author-link" href="/<%- username %>"> <img width="24" class="avatar avatar-inline s24" alt="" src="<%- avatar %>"> </a> <% } else { %> <i class="fa fa-user"></i> <% } %>', ); assigneeTemplate = _.template( - `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> + `<% if (username) { %> <a class="author-link bold" href="/<%- username %>"> <% if( avatar ) { %> <img width="32" class="avatar avatar-inline s32" alt="" src="<%- avatar %>"> <% } %> <span class="author"><%- name %></span> <span class="username"> @<%- username %> </span> </a> <% } else { %> <span class="no-value assign-yourself"> ${sprintf(s__('UsersSelect|No assignee - %{openingTag} assign yourself %{closingTag}'), { openingTag: '<a href="#" class="js-assign-yourself">', closingTag: '</a>', @@ -423,6 +425,8 @@ function UsersSelect(currentUser, els, options = {}) { const { $el, e, isMarking } = options; const user = options.selectedObj; + $el.tooltip('dispose'); + if ($dropdown.hasClass('js-multiselect')) { const isActive = $el.hasClass('is-active'); const previouslySelected = $dropdown @@ -570,20 +574,11 @@ function UsersSelect(currentUser, els, options = {}) { user.name, )}</a></li>`; } else { - img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />"; + // 0 margin, because it's now handled by a wrapper + img = "<img src='" + avatar + "' class='avatar avatar-inline m-0' width='32' />"; } - return ` - <li data-user-id=${user.id}> - <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'> - ${img} - <strong class='dropdown-menu-user-full-name'> - ${_.escape(user.name)} - </strong> - ${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''} - </a> - </li> - `; + return _this.renderRow(options.issuableType, user, selected, username, img); }, }); }; @@ -764,6 +759,11 @@ UsersSelect.prototype.users = function(query, options, callback) { author_id: options.authorId || null, skip_users: options.skipUsers || null, }; + + if (options.issuableType === 'merge_request') { + params.merge_request_iid = options.iid || null; + } + return axios.get(url, { params }).then(({ data }) => { callback(data); }); @@ -776,4 +776,44 @@ UsersSelect.prototype.buildUrl = function(url) { return url; }; +UsersSelect.prototype.renderRow = function(issuableType, user, selected, username, img) { + const tooltip = issuableType === 'merge_request' && !user.can_merge ? __('Cannot merge') : ''; + const tooltipClass = tooltip ? `has-tooltip` : ''; + const selectedClass = selected === true ? 'is-active' : ''; + const linkClasses = `${selectedClass} ${tooltipClass}`; + const tooltipAttributes = tooltip + ? `data-container="body" data-placement="left" data-title="${tooltip}"` + : ''; + + return ` + <li data-user-id=${user.id}> + <a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}> + ${this.renderRowAvatar(issuableType, user, img)} + <span class="d-flex flex-column overflow-hidden"> + <strong class="dropdown-menu-user-full-name"> + ${_.escape(user.name)} + </strong> + ${username ? `<span class="dropdown-menu-user-username">${username}</span>` : ''} + </span> + </a> + </li> + `; +}; + +UsersSelect.prototype.renderRowAvatar = function(issuableType, user, img) { + if (user.beforeDivider) { + return img; + } + + const mergeIcon = + issuableType === 'merge_request' && !user.can_merge + ? '<i class="fa fa-exclamation-triangle merge-icon"></i>' + : ''; + + return `<span class="position-relative mr-2"> + ${img} + ${mergeIcon} + </span>`; +}; + export default UsersSelect; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment.js b/app/assets/javascripts/visual_review_toolbar/components/comment.js deleted file mode 100644 index a03dc14b319..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/comment.js +++ /dev/null @@ -1,39 +0,0 @@ -import { nextView } from '../store'; -import { localStorage, COMMENT_BOX, LOGOUT, STORAGE_MR_ID, STORAGE_TOKEN } from '../shared'; -import { clearNote } from './note'; -import { buttonClearStyles } from './utils'; -import { addForm } from './wrapper'; -import { changeSelectedMr, selectedMrNote } from './comment_mr_note'; -import postComment from './comment_post'; -import { saveComment, getSavedComment } from './comment_storage'; - -const comment = state => { - const savedComment = getSavedComment(); - - return ` - <div> - <textarea id="${COMMENT_BOX}" name="${COMMENT_BOX}" rows="3" placeholder="Enter your feedback or idea" class="gitlab-input" aria-required="true">${savedComment}</textarea> - ${selectedMrNote(state)} - <p class="gitlab-metadata-note">Additional metadata will be included: browser, OS, current page, user agent, and viewport dimensions.</p> - </div> - <div class="gitlab-button-wrapper"> - <button class="gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="gitlab-comment-button"> Send feedback </button> - <button class="gitlab-button gitlab-button-secondary" style="${buttonClearStyles}" type="button" id="${LOGOUT}"> Log out </button> - </div> - `; -}; - -// This function is here becaause it is called only from the comment view -// If we reach a design where we can logout from multiple views, promote this -// to it's own package -const logoutUser = state => { - localStorage.removeItem(STORAGE_TOKEN); - localStorage.removeItem(STORAGE_MR_ID); - state.token = ''; - state.mergeRequestId = ''; - - clearNote(); - addForm(nextView(state, COMMENT_BOX)); -}; - -export { changeSelectedMr, comment, logoutUser, postComment, saveComment }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js b/app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js deleted file mode 100644 index da67763261c..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/comment_mr_note.js +++ /dev/null @@ -1,31 +0,0 @@ -import { nextView } from '../store'; -import { localStorage, CHANGE_MR_ID_BUTTON, COMMENT_BOX, STORAGE_MR_ID } from '../shared'; -import { clearNote } from './note'; -import { buttonClearStyles } from './utils'; -import { addForm } from './wrapper'; - -const selectedMrNote = state => { - const { mrUrl, projectPath, mergeRequestId } = state; - - const mrLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}`; - - return ` - <p class="gitlab-metadata-note"> - This posts to merge request <a class="gitlab-link" href="${mrLink}">!${mergeRequestId}</a>. - <button style="${buttonClearStyles}" type="button" id="${CHANGE_MR_ID_BUTTON}" class="gitlab-link gitlab-link-button">Change</button> - </p> - `; -}; - -const clearMrId = state => { - localStorage.removeItem(STORAGE_MR_ID); - state.mergeRequestId = ''; -}; - -const changeSelectedMr = state => { - clearMrId(state); - clearNote(); - addForm(nextView(state, COMMENT_BOX)); -}; - -export { changeSelectedMr, selectedMrNote }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment_post.js b/app/assets/javascripts/visual_review_toolbar/components/comment_post.js deleted file mode 100644 index ee5f2b62425..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/comment_post.js +++ /dev/null @@ -1,145 +0,0 @@ -import { BLACK, COMMENT_BOX, MUTED } from '../shared'; -import { clearSavedComment } from './comment_storage'; -import { clearNote, postError } from './note'; -import { selectCommentBox, selectCommentButton, selectNote, selectNoteContainer } from './utils'; - -const resetCommentButton = () => { - const commentButton = selectCommentButton(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Send feedback'; - commentButton.classList.replace('gitlab-button-secondary', 'gitlab-button-success'); - commentButton.style.opacity = 1; -}; - -const resetCommentBox = () => { - const commentBox = selectCommentBox(); - commentBox.style.pointerEvents = 'auto'; - commentBox.style.color = BLACK; -}; - -const resetCommentText = () => { - const commentBox = selectCommentBox(); - commentBox.value = ''; - clearSavedComment(); -}; - -const resetComment = () => { - resetCommentButton(); - resetCommentBox(); - resetCommentText(); -}; - -const confirmAndClear = feedbackInfo => { - const commentButton = selectCommentButton(); - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Feedback sent'; - noteContainer.style.visibility = 'visible'; - currentNote.insertAdjacentHTML('beforeend', feedbackInfo); - - setTimeout(resetComment, 1000); - setTimeout(clearNote, 6000); -}; - -const setInProgressState = () => { - const commentButton = selectCommentButton(); - const commentBox = selectCommentBox(); - - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - commentButton.innerText = 'Sending feedback'; - commentButton.classList.replace('gitlab-button-success', 'gitlab-button-secondary'); - commentButton.style.opacity = 0.5; - commentBox.style.color = MUTED; - commentBox.style.pointerEvents = 'none'; -}; - -const commentErrors = error => { - switch (error.status) { - case 401: - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - return 'Unauthorized. You may have entered an incorrect authentication token.'; - case 404: - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - return 'Not found. You may have entered an incorrect merge request ID.'; - default: - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - return `Your comment could not be sent. Please try again. Error: ${error.message}`; - } -}; - -const postComment = ({ - platform, - browser, - userAgent, - innerWidth, - innerHeight, - projectId, - projectPath, - mergeRequestId, - mrUrl, - token, -}) => { - // Clear any old errors - clearNote(COMMENT_BOX); - - setInProgressState(); - - const commentText = selectCommentBox().value.trim(); - // Get the href at the last moment to support SPAs - const { href } = window.location; - - if (!commentText) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - postError('Your comment appears to be empty.', COMMENT_BOX); - resetCommentBox(); - resetCommentButton(); - return; - } - - const detailText = ` - \n -<details> - <summary>Metadata</summary> - Posted from ${href} | ${platform} | ${browser} | ${innerWidth} x ${innerHeight}. - <br /><br /> - <em>User agent: ${userAgent}</em> -</details> - `; - - const url = ` - ${mrUrl}/api/v4/projects/${projectId}/merge_requests/${mergeRequestId}/discussions`; - - const body = `${commentText} ${detailText}`; - - fetch(url, { - method: 'POST', - headers: { - 'PRIVATE-TOKEN': token, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ body }), - }) - .then(response => { - if (response.ok) { - return response.json(); - } - - throw response; - }) - .then(data => { - const commentId = data.notes[0].id; - const feedbackLink = `${mrUrl}/${projectPath}/merge_requests/${mergeRequestId}#note_${commentId}`; - const feedbackInfo = `Feedback sent. View at <a class="gitlab-link" href="${feedbackLink}">${projectPath} !${mergeRequestId} (comment ${commentId})</a>`; - confirmAndClear(feedbackInfo); - }) - .catch(err => { - postError(commentErrors(err), COMMENT_BOX); - resetCommentBox(); - resetCommentButton(); - }); -}; - -export default postComment; diff --git a/app/assets/javascripts/visual_review_toolbar/components/comment_storage.js b/app/assets/javascripts/visual_review_toolbar/components/comment_storage.js deleted file mode 100644 index 49c9400437e..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/comment_storage.js +++ /dev/null @@ -1,20 +0,0 @@ -import { selectCommentBox } from './utils'; -import { sessionStorage, STORAGE_COMMENT } from '../shared'; - -const getSavedComment = () => sessionStorage.getItem(STORAGE_COMMENT) || ''; - -const saveComment = () => { - const currentComment = selectCommentBox(); - - // This may be added to any view via top-level beforeunload listener - // so let's skip if it does not apply - if (currentComment && currentComment.value) { - sessionStorage.setItem(STORAGE_COMMENT, currentComment.value); - } -}; - -const clearSavedComment = () => { - sessionStorage.removeItem(STORAGE_COMMENT); -}; - -export { getSavedComment, saveComment, clearSavedComment }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/form_elements.js b/app/assets/javascripts/visual_review_toolbar/components/form_elements.js deleted file mode 100644 index 608488a6fea..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/form_elements.js +++ /dev/null @@ -1,17 +0,0 @@ -import { REMEMBER_ITEM } from '../shared'; -import { buttonClearStyles } from './utils'; - -/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ -const rememberBox = (rememberText = 'Remember me') => ` - <div class="gitlab-checkbox-wrapper"> - <input type="checkbox" id="${REMEMBER_ITEM}" name="${REMEMBER_ITEM}" value="remember"> - <label for="${REMEMBER_ITEM}" class="gitlab-checkbox-label">${rememberText}</label> - </div> -`; - -const submitButton = buttonId => ` - <div class="gitlab-button-wrapper"> - <button class="gitlab-button-wide gitlab-button gitlab-button-success" style="${buttonClearStyles}" type="button" id="${buttonId}"> Submit </button> - </div> -`; -export { rememberBox, submitButton }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/index.js b/app/assets/javascripts/visual_review_toolbar/components/index.js deleted file mode 100644 index e88b3637ad8..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import { changeSelectedMr, comment, logoutUser, postComment, saveComment } from './comment'; -import { authorizeUser, login } from './login'; -import { addMr, mrForm } from './mr_id'; -import { note } from './note'; -import { selectContainer, selectForm } from './utils'; -import { buttonAndForm, toggleForm } from './wrapper'; - -export { - addMr, - authorizeUser, - buttonAndForm, - changeSelectedMr, - comment, - login, - logoutUser, - mrForm, - note, - postComment, - saveComment, - selectContainer, - selectForm, - toggleForm, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/login.js b/app/assets/javascripts/visual_review_toolbar/components/login.js deleted file mode 100644 index 20ab01bc690..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/login.js +++ /dev/null @@ -1,47 +0,0 @@ -import { nextView } from '../store'; -import { localStorage, LOGIN, TOKEN_BOX, STORAGE_TOKEN } from '../shared'; -import { clearNote, postError } from './note'; -import { rememberBox, submitButton } from './form_elements'; -import { selectRemember, selectToken } from './utils'; -import { addForm } from './wrapper'; - -const labelText = ` - Enter your <a class="gitlab-link" href="https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html">personal access token</a> -`; - -const login = ` - <div> - <label for="${TOKEN_BOX}" class="gitlab-label">${labelText}</label> - <input class="gitlab-input" type="password" id="${TOKEN_BOX}" name="${TOKEN_BOX}" autocomplete="current-password" aria-required="true"> - </div> - ${rememberBox()} - ${submitButton(LOGIN)} -`; - -const storeToken = (token, state) => { - const rememberMe = selectRemember().checked; - - if (rememberMe) { - localStorage.setItem(STORAGE_TOKEN, token); - } - - state.token = token; -}; - -const authorizeUser = state => { - // Clear any old errors - clearNote(TOKEN_BOX); - - const token = selectToken().value; - - if (!token) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - postError('Please enter your token.', TOKEN_BOX); - return; - } - - storeToken(token, state); - addForm(nextView(state, LOGIN)); -}; - -export { authorizeUser, login, storeToken }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/mr_id.js b/app/assets/javascripts/visual_review_toolbar/components/mr_id.js deleted file mode 100644 index 695b3af8aa0..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/mr_id.js +++ /dev/null @@ -1,63 +0,0 @@ -import { nextView } from '../store'; -import { MR_ID, MR_ID_BUTTON, STORAGE_MR_ID, localStorage } from '../shared'; -import { clearNote, postError } from './note'; -import { rememberBox, submitButton } from './form_elements'; -import { selectForm, selectMrBox, selectRemember } from './utils'; -import { addForm } from './wrapper'; - -/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ -const mrLabel = `Enter your merge request ID`; -/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ -const mrRememberText = `Remember this number`; - -const mrForm = ` - <div> - <label for="${MR_ID}" class="gitlab-label">${mrLabel}</label> - <input class="gitlab-input" type="text" pattern="[1-9][0-9]*" id="${MR_ID}" name="${MR_ID}" placeholder="e.g., 321" aria-required="true"> - </div> - ${rememberBox(mrRememberText)} - ${submitButton(MR_ID_BUTTON)} -`; - -const storeMR = (id, state) => { - const rememberMe = selectRemember().checked; - - if (rememberMe) { - localStorage.setItem(STORAGE_MR_ID, id); - } - - state.mergeRequestId = id; -}; - -const getFormError = (mrNumber, form) => { - if (!mrNumber) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - return 'Please enter your merge request ID number.'; - } - - if (!form.checkValidity()) { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - return 'Please remove any non-number values from the field.'; - } - - return null; -}; - -const addMr = state => { - // Clear any old errors - clearNote(MR_ID); - - const mrNumber = selectMrBox().value; - const form = selectForm(); - const formError = getFormError(mrNumber, form); - - if (formError) { - postError(formError, MR_ID); - return; - } - - storeMR(mrNumber, state); - addForm(nextView(state, MR_ID)); -}; - -export { addMr, mrForm, storeMR }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/note.js b/app/assets/javascripts/visual_review_toolbar/components/note.js deleted file mode 100644 index 9cddcb710f2..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/note.js +++ /dev/null @@ -1,35 +0,0 @@ -import { NOTE, NOTE_CONTAINER, RED } from '../shared'; -import { selectById, selectNote, selectNoteContainer } from './utils'; - -const note = ` - <div id="${NOTE_CONTAINER}" style="visibility: hidden;"> - <p id="${NOTE}" class="gitlab-message"></p> - </div> -`; - -const clearNote = inputId => { - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - - currentNote.innerText = ''; - currentNote.style.color = ''; - noteContainer.style.visibility = 'hidden'; - - if (inputId) { - const field = document.getElementById(inputId); - field.style.borderColor = ''; - } -}; - -const postError = (message, inputId) => { - const currentNote = selectNote(); - const noteContainer = selectNoteContainer(); - const field = selectById(inputId); - field.style.borderColor = RED; - currentNote.style.color = RED; - currentNote.innerText = message; - noteContainer.style.visibility = 'visible'; - setTimeout(clearNote.bind(null, inputId), 5000); -}; - -export { clearNote, note, postError }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/utils.js b/app/assets/javascripts/visual_review_toolbar/components/utils.js deleted file mode 100644 index 4ec9bd4a32a..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/utils.js +++ /dev/null @@ -1,51 +0,0 @@ -/* global document */ - -import { - COLLAPSE_BUTTON, - COMMENT_BOX, - COMMENT_BUTTON, - FORM, - FORM_CONTAINER, - MR_ID, - NOTE, - NOTE_CONTAINER, - REMEMBER_ITEM, - REVIEW_CONTAINER, - TOKEN_BOX, -} from '../shared'; - -// this style must be applied inline in a handful of components -/* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ -const buttonClearStyles = ` - -webkit-appearance: none; -`; - -// selector functions to abstract out a little -const selectById = id => document.getElementById(id); -const selectCollapseButton = () => document.getElementById(COLLAPSE_BUTTON); -const selectCommentBox = () => document.getElementById(COMMENT_BOX); -const selectCommentButton = () => document.getElementById(COMMENT_BUTTON); -const selectContainer = () => document.getElementById(REVIEW_CONTAINER); -const selectForm = () => document.getElementById(FORM); -const selectFormContainer = () => document.getElementById(FORM_CONTAINER); -const selectMrBox = () => document.getElementById(MR_ID); -const selectNote = () => document.getElementById(NOTE); -const selectNoteContainer = () => document.getElementById(NOTE_CONTAINER); -const selectRemember = () => document.getElementById(REMEMBER_ITEM); -const selectToken = () => document.getElementById(TOKEN_BOX); - -export { - buttonClearStyles, - selectById, - selectCollapseButton, - selectContainer, - selectCommentBox, - selectCommentButton, - selectForm, - selectFormContainer, - selectMrBox, - selectNote, - selectNoteContainer, - selectRemember, - selectToken, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper.js deleted file mode 100644 index fdf8ad7c41f..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/wrapper.js +++ /dev/null @@ -1,79 +0,0 @@ -import { CLEAR, FORM, FORM_CONTAINER, WHITE } from '../shared'; -import { - selectCollapseButton, - selectForm, - selectFormContainer, - selectNoteContainer, -} from './utils'; -import { collapseButton, commentIcon, compressIcon } from './wrapper_icons'; - -const form = content => ` - <form id="${FORM}" novalidate> - ${content} - </form> -`; - -const buttonAndForm = content => ` - <div id="${FORM_CONTAINER}" class="gitlab-form-open"> - ${collapseButton} - ${form(content)} - </div> -`; - -const addForm = nextForm => { - const formWrapper = selectForm(); - formWrapper.innerHTML = nextForm; -}; - -function toggleForm() { - const toggleButton = selectCollapseButton(); - const currentForm = selectForm(); - const formContainer = selectFormContainer(); - const noteContainer = selectNoteContainer(); - const OPEN = 'open'; - const CLOSED = 'closed'; - - /* - You may wonder why we spread the arrays before we reverse them. - In the immortal words of MDN, - Careful: reverse is destructive. It also changes the original array - */ - - const openButtonClasses = ['gitlab-collapse-closed', 'gitlab-collapse-open']; - const closedButtonClasses = [...openButtonClasses].reverse(); - const openContainerClasses = ['gitlab-wrapper-closed', 'gitlab-wrapper-open']; - const closedContainerClasses = [...openContainerClasses].reverse(); - - const stateVals = { - [OPEN]: { - buttonClasses: openButtonClasses, - containerClasses: openContainerClasses, - icon: compressIcon, - display: 'flex', - backgroundColor: WHITE, - }, - [CLOSED]: { - buttonClasses: closedButtonClasses, - containerClasses: closedContainerClasses, - icon: commentIcon, - display: 'none', - backgroundColor: CLEAR, - }, - }; - - const nextState = toggleButton.classList.contains('gitlab-collapse-open') ? CLOSED : OPEN; - const currentVals = stateVals[nextState]; - - formContainer.classList.replace(...currentVals.containerClasses); - formContainer.style.backgroundColor = currentVals.backgroundColor; - formContainer.classList.toggle('gitlab-form-open'); - currentForm.style.display = currentVals.display; - toggleButton.classList.replace(...currentVals.buttonClasses); - toggleButton.innerHTML = currentVals.icon; - - if (noteContainer && noteContainer.innerText.length > 0) { - noteContainer.style.display = currentVals.display; - } -} - -export { addForm, buttonAndForm, toggleForm }; diff --git a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js b/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js deleted file mode 100644 index b686fd4f5c2..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/components/wrapper_icons.js +++ /dev/null @@ -1,15 +0,0 @@ -import { buttonClearStyles } from './utils'; - -const commentIcon = ` - <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/comment</title><path d="M4 11.132l1.446-.964A1 1 0 0 1 6 10h5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v6.132zM6.303 12l-2.748 1.832A1 1 0 0 1 2 13V5a3 3 0 0 1 3-3h6a3 3 0 0 1 3 3v4a3 3 0 0 1-3 3H6.303z" id="gitlab-comment-icon"/></svg> -`; - -const compressIcon = ` - <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><title>icn/compress</title><path d="M5.27 12.182l-1.562 1.561a1 1 0 0 1-1.414 0h-.001a1 1 0 0 1 0-1.415l1.56-1.56L2.44 9.353a.5.5 0 0 1 .353-.854H7.09a.5.5 0 0 1 .5.5v4.294a.5.5 0 0 1-.853.353l-1.467-1.465zm6.911-6.914l1.464 1.464a.5.5 0 0 1-.353.854H8.999a.5.5 0 0 1-.5-.5V2.793a.5.5 0 0 1 .854-.354l1.414 1.415 1.56-1.561a1 1 0 1 1 1.415 1.414l-1.561 1.56z" id="gitlab-compress-icon"/></svg> -`; - -const collapseButton = ` - <button id='gitlab-collapse' style='${buttonClearStyles}' class='gitlab-button gitlab-button-secondary gitlab-collapse gitlab-collapse-open'>${compressIcon}</button> -`; - -export { commentIcon, compressIcon, collapseButton }; diff --git a/app/assets/javascripts/visual_review_toolbar/index.js b/app/assets/javascripts/visual_review_toolbar/index.js deleted file mode 100644 index 67b3fadd772..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import './styles/toolbar.css'; - -import { buttonAndForm, note, selectForm, selectContainer } from './components'; -import { REVIEW_CONTAINER } from './shared'; -import { eventLookup, getInitialView, initializeGlobalListeners, initializeState } from './store'; - -/* - - Welcome to the visual review toolbar files. A few useful notes: - - - These files build a static script that is served from our webpack - assets folder. (https://gitlab.com/assets/webpack/visual_review_toolbar.js) - - - To compile this file, run `yarn webpack-vrt`. - - - Vue is not used in these files because we do not want to ask users to - install another library at this time. It's all pure vanilla javascript. - -*/ - -window.addEventListener('load', () => { - initializeState(window, document); - - const mainContent = buttonAndForm(getInitialView()); - const container = document.createElement('div'); - container.setAttribute('id', REVIEW_CONTAINER); - container.insertAdjacentHTML('beforeend', note); - container.insertAdjacentHTML('beforeend', mainContent); - - document.body.insertBefore(container, document.body.firstChild); - - selectContainer().addEventListener('click', event => { - eventLookup(event.target.id)(); - }); - - selectForm().addEventListener('submit', event => { - // this is important to prevent the form from adding data - // as URL params and inadvertently revealing secrets - event.preventDefault(); - - const id = - event.target.querySelector('.gitlab-button-wrapper') && - event.target.querySelector('.gitlab-button-wrapper').getElementsByTagName('button')[0] && - event.target.querySelector('.gitlab-button-wrapper').getElementsByTagName('button')[0].id; - - // even if this is called with false, it's ok; it will get the default no-op - eventLookup(id)(); - }); - - initializeGlobalListeners(); -}); diff --git a/app/assets/javascripts/visual_review_toolbar/shared/constants.js b/app/assets/javascripts/visual_review_toolbar/shared/constants.js deleted file mode 100644 index 0d5b666ab0a..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/shared/constants.js +++ /dev/null @@ -1,56 +0,0 @@ -// component selectors -const CHANGE_MR_ID_BUTTON = 'gitlab-change-mr'; -const COLLAPSE_BUTTON = 'gitlab-collapse'; -const COMMENT_BOX = 'gitlab-comment'; -const COMMENT_BUTTON = 'gitlab-comment-button'; -const FORM = 'gitlab-form'; -const FORM_CONTAINER = 'gitlab-form-wrapper'; -const LOGIN = 'gitlab-login-button'; -const LOGOUT = 'gitlab-logout-button'; -const MR_ID = 'gitlab-submit-mr'; -const MR_ID_BUTTON = 'gitlab-submit-mr-button'; -const NOTE = 'gitlab-validation-note'; -const NOTE_CONTAINER = 'gitlab-note-wrapper'; -const REMEMBER_ITEM = 'gitlab-remember-item'; -const REVIEW_CONTAINER = 'gitlab-review-container'; -const TOKEN_BOX = 'gitlab-token'; - -// Storage keys -const STORAGE_PREFIX = '--gitlab'; // Using `--` to make the prefix more unique -const STORAGE_MR_ID = `${STORAGE_PREFIX}-merge-request-id`; -const STORAGE_TOKEN = `${STORAGE_PREFIX}-token`; -const STORAGE_COMMENT = `${STORAGE_PREFIX}-comment`; - -// colors — these are applied programmatically -// rest of styles belong in ./styles -const BLACK = 'rgba(46, 46, 46, 1)'; -const CLEAR = 'rgba(255, 255, 255, 0)'; -const MUTED = 'rgba(223, 223, 223, 0.5)'; -const RED = 'rgba(219, 59, 33, 1)'; -const WHITE = 'rgba(250, 250, 250, 1)'; - -export { - CHANGE_MR_ID_BUTTON, - COLLAPSE_BUTTON, - COMMENT_BOX, - COMMENT_BUTTON, - FORM, - FORM_CONTAINER, - LOGIN, - LOGOUT, - MR_ID, - MR_ID_BUTTON, - NOTE, - NOTE_CONTAINER, - REMEMBER_ITEM, - REVIEW_CONTAINER, - TOKEN_BOX, - STORAGE_MR_ID, - STORAGE_TOKEN, - STORAGE_COMMENT, - BLACK, - CLEAR, - MUTED, - RED, - WHITE, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/shared/index.js b/app/assets/javascripts/visual_review_toolbar/shared/index.js deleted file mode 100644 index d8ccb170592..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/shared/index.js +++ /dev/null @@ -1,55 +0,0 @@ -import { - CHANGE_MR_ID_BUTTON, - COLLAPSE_BUTTON, - COMMENT_BOX, - COMMENT_BUTTON, - FORM, - FORM_CONTAINER, - LOGIN, - LOGOUT, - MR_ID, - MR_ID_BUTTON, - NOTE, - NOTE_CONTAINER, - REMEMBER_ITEM, - REVIEW_CONTAINER, - TOKEN_BOX, - STORAGE_MR_ID, - STORAGE_TOKEN, - STORAGE_COMMENT, - BLACK, - CLEAR, - MUTED, - RED, - WHITE, -} from './constants'; - -import { localStorage, sessionStorage } from './storage_utils'; - -export { - localStorage, - sessionStorage, - CHANGE_MR_ID_BUTTON, - COLLAPSE_BUTTON, - COMMENT_BOX, - COMMENT_BUTTON, - FORM, - FORM_CONTAINER, - LOGIN, - LOGOUT, - MR_ID, - MR_ID_BUTTON, - NOTE, - NOTE_CONTAINER, - REMEMBER_ITEM, - REVIEW_CONTAINER, - TOKEN_BOX, - STORAGE_MR_ID, - STORAGE_TOKEN, - STORAGE_COMMENT, - BLACK, - CLEAR, - MUTED, - RED, - WHITE, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js b/app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js deleted file mode 100644 index 00456d3536e..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/shared/storage_utils.js +++ /dev/null @@ -1,42 +0,0 @@ -import { setUsingGracefulStorageFlag } from '../store/state'; - -const TEST_KEY = 'gitlab-storage-test'; - -const createStorageStub = () => { - const items = {}; - - return { - getItem(key) { - return items[key]; - }, - setItem(key, value) { - items[key] = value; - }, - removeItem(key) { - delete items[key]; - }, - }; -}; - -const hasStorageSupport = storage => { - // Support test taken from https://stackoverflow.com/a/11214467/1708147 - try { - storage.setItem(TEST_KEY, TEST_KEY); - storage.removeItem(TEST_KEY); - setUsingGracefulStorageFlag(true); - - return true; - } catch (err) { - setUsingGracefulStorageFlag(false); - return false; - } -}; - -const useGracefulStorage = storage => - // If a browser does not support local storage, let's return a graceful implementation. - hasStorageSupport(storage) ? storage : createStorageStub(); - -const localStorage = useGracefulStorage(window.localStorage); -const sessionStorage = useGracefulStorage(window.sessionStorage); - -export { localStorage, sessionStorage }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/events.js b/app/assets/javascripts/visual_review_toolbar/store/events.js deleted file mode 100644 index c9095c77ef1..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/events.js +++ /dev/null @@ -1,73 +0,0 @@ -import { - addMr, - authorizeUser, - changeSelectedMr, - logoutUser, - postComment, - saveComment, - toggleForm, -} from '../components'; - -import { - CHANGE_MR_ID_BUTTON, - COLLAPSE_BUTTON, - COMMENT_BUTTON, - LOGIN, - LOGOUT, - MR_ID_BUTTON, -} from '../shared'; - -import { state } from './state'; -import debounce from './utils'; - -const noop = () => {}; - -// State needs to be bound here to be acted on -// because these are called by click events and -// as such are called with only the `event` object -const eventLookup = id => { - switch (id) { - case CHANGE_MR_ID_BUTTON: - return () => { - saveComment(); - changeSelectedMr(state); - }; - case COLLAPSE_BUTTON: - return toggleForm; - case COMMENT_BUTTON: - return postComment.bind(null, state); - case LOGIN: - return authorizeUser.bind(null, state); - case LOGOUT: - return () => { - saveComment(); - logoutUser(state); - }; - case MR_ID_BUTTON: - return addMr.bind(null, state); - default: - return noop; - } -}; - -const updateWindowSize = wind => { - state.innerWidth = wind.innerWidth; - state.innerHeight = wind.innerHeight; -}; - -const initializeGlobalListeners = () => { - window.addEventListener('resize', debounce(updateWindowSize.bind(null, window), 200)); - window.addEventListener('beforeunload', event => { - if (state.usingGracefulStorage) { - // if there is no browser storage support, reloading will lose the comment; this way, the user will be warned - // we assign the return value because it is required by Chrome see: https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#Example, - event.preventDefault(); - /* eslint-disable-next-line no-param-reassign */ - event.returnValue = ''; - } - - saveComment(); - }); -}; - -export { eventLookup, initializeGlobalListeners }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/index.js b/app/assets/javascripts/visual_review_toolbar/store/index.js deleted file mode 100644 index 07c8dd6f1d2..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/index.js +++ /dev/null @@ -1,11 +0,0 @@ -import { eventLookup, initializeGlobalListeners } from './events'; -import { nextView, getInitialView, initializeState, setUsingGracefulStorageFlag } from './state'; - -export { - eventLookup, - getInitialView, - initializeGlobalListeners, - initializeState, - nextView, - setUsingGracefulStorageFlag, -}; diff --git a/app/assets/javascripts/visual_review_toolbar/store/state.js b/app/assets/javascripts/visual_review_toolbar/store/state.js deleted file mode 100644 index b7853bb0723..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/state.js +++ /dev/null @@ -1,95 +0,0 @@ -import { comment, login, mrForm } from '../components'; -import { localStorage, COMMENT_BOX, LOGIN, MR_ID, STORAGE_MR_ID, STORAGE_TOKEN } from '../shared'; - -const state = { - browser: '', - usingGracefulStorage: '', - innerWidth: '', - innerHeight: '', - mergeRequestId: '', - mrUrl: '', - platform: '', - projectId: '', - userAgent: '', - token: '', -}; - -// adapted from https://developer.mozilla.org/en-US/docs/Web/API/Window/navigator#Example_2_Browser_detect_and_return_an_index -const getBrowserId = sUsrAg => { - /* eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings */ - const aKeys = ['MSIE', 'Edge', 'Firefox', 'Safari', 'Chrome', 'Opera']; - let nIdx = aKeys.length - 1; - - for (nIdx; nIdx > -1 && sUsrAg.indexOf(aKeys[nIdx]) === -1; nIdx -= 1); - return aKeys[nIdx]; -}; - -const nextView = (appState, form = 'none') => { - const formsList = { - [COMMENT_BOX]: currentState => (currentState.token ? mrForm : login), - [LOGIN]: currentState => (currentState.mergeRequestId ? comment(currentState) : mrForm), - [MR_ID]: currentState => (currentState.token ? comment(currentState) : login), - none: currentState => { - if (!currentState.token) { - return login; - } - - if (currentState.token && !currentState.mergeRequestId) { - return mrForm; - } - - return comment(currentState); - }, - }; - - return formsList[form](appState); -}; - -const initializeState = (wind, doc) => { - const { - innerWidth, - innerHeight, - navigator: { platform, userAgent }, - } = wind; - - const browser = getBrowserId(userAgent); - - const scriptEl = doc.getElementById('review-app-toolbar-script'); - const { projectId, mergeRequestId, mrUrl, projectPath } = scriptEl.dataset; - - // This mutates our default state object above. It's weird but it makes the linter happy. - Object.assign(state, { - browser, - innerWidth, - innerHeight, - mergeRequestId, - mrUrl, - platform, - projectId, - projectPath, - userAgent, - }); - - return state; -}; - -const getInitialView = () => { - const token = localStorage.getItem(STORAGE_TOKEN); - const mrId = localStorage.getItem(STORAGE_MR_ID); - - if (token) { - state.token = token; - } - - if (mrId) { - state.mergeRequestId = mrId; - } - - return nextView(state); -}; - -const setUsingGracefulStorageFlag = flag => { - state.usingGracefulStorage = !flag; -}; - -export { initializeState, getInitialView, nextView, setUsingGracefulStorageFlag, state }; diff --git a/app/assets/javascripts/visual_review_toolbar/store/utils.js b/app/assets/javascripts/visual_review_toolbar/store/utils.js deleted file mode 100644 index 5cf145351b3..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/store/utils.js +++ /dev/null @@ -1,15 +0,0 @@ -const debounce = (fn, time) => { - let current; - - const debounced = () => { - if (current) { - clearTimeout(current); - } - - current = setTimeout(fn, time); - }; - - return debounced; -}; - -export default debounce; diff --git a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css b/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css deleted file mode 100644 index d1a8d66ef40..00000000000 --- a/app/assets/javascripts/visual_review_toolbar/styles/toolbar.css +++ /dev/null @@ -1,188 +0,0 @@ -/* - As a standalone script, the toolbar has its own css - */ - -#gitlab-collapse > * { - pointer-events: none; -} - -#gitlab-comment { - background-color: #fafafa; -} - -#gitlab-form { - display: flex; - flex-direction: column; - width: 100%; - margin-bottom: 0; -} - -#gitlab-note-wrapper { - display: flex; - flex-direction: column; - background-color: #fafafa; - border-radius: 4px; - margin-bottom: .5rem; - padding: 1rem; -} - -#gitlab-form-wrapper { - overflow: auto; - display: flex; - flex-direction: row-reverse; - border-radius: 4px; -} - -#gitlab-review-container { - max-width: 22rem; - max-height: 22rem; - overflow: auto; - display: flex; - flex-direction: column; - position: fixed; - bottom: 1rem; - right: 1rem; - 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-size: .8rem; - font-weight: 400; - color: #2e2e2e; - z-index: 9999; /* toolbar should always be on top */ -} - -.gitlab-wrapper-open { - max-width: 22rem; - max-height: 22rem; -} - -.gitlab-wrapper-closed { - max-width: 3.4rem; - max-height: 3.4rem; -} - -.gitlab-button { - cursor: pointer; - transition: background-color 100ms linear, border-color 100ms linear, color 100ms linear, box-shadow 100ms linear; -} - -.gitlab-button-secondary { - background: none #fafafa; - margin: 0 .5rem; - border: 1px solid #e3e3e3; -} - -.gitlab-button-secondary:hover { - background-color: #f0f0f0; - border-color: #e3e3e3; - color: #2e2e2e; -} - -.gitlab-button-secondary:active { - color: #2e2e2e; - background-color: #e1e1e1; - border-color: #dadada; -} - -.gitlab-button-success:hover { - color: #fff; - background-color: #137e3f; - border-color: #127339; -} - -.gitlab-button-success:active { - background-color: #168f48; - border-color: #12753a; - color: #fff; -} - -.gitlab-button-success { - background-color: #1aaa55; - border: 1px solid #168f48; - color: #fff; -} - -.gitlab-button-wide { - width: 100%; -} - -.gitlab-button-wrapper { - margin-top: 0.5rem; - display: flex; - align-items: baseline; - /* - this makes sure the hit enter to submit picks the correct button - on the comment view - */ - flex-direction: row-reverse; -} - -.gitlab-collapse { - width: 2.4rem; - height: 2.2rem; - margin-left: 1rem; - padding: .5rem; -} - -.gitlab-collapse-closed { - align-self: center; -} - -.gitlab-checkbox-label { - padding: 0 .2rem; -} - -.gitlab-checkbox-wrapper { - display: flex; - align-items: baseline; -} - -.gitlab-form-open { - padding: 1rem; - background-color: #fafafa; -} - -.gitlab-label { - font-weight: 600; - display: inline-block; - width: 100%; -} - -.gitlab-link { - color: #1b69b6; - text-decoration: none; - background-color: transparent; - background-image: none; -} - -.gitlab-link:hover { - text-decoration: underline; -} - -.gitlab-link-button { - border: none; - cursor: pointer; - padding: 0 .15rem; -} - -.gitlab-message { - padding: .25rem 0; - margin: 0; - line-height: 1.2rem; -} - -.gitlab-metadata-note { - font-size: .7rem; - line-height: 1rem; - color: #666; - margin-bottom: .5rem; -} - -.gitlab-input { - width: 100%; - border: 1px solid #dfdfdf; - border-radius: 4px; - padding: .1rem .2rem; - min-height: 2rem; - max-width: 17rem; -} 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 c7b064b8506..339e154affc 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 @@ -50,6 +50,7 @@ export default { startTag: '<span class="label-branch">', endTag: '</span>', }, + false, ); }, }, 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 0f55bebd3fc..7843409f4a7 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 @@ -87,9 +87,6 @@ export default class MergeRequestStore { this.allowCollaboration = data.allow_collaboration; this.sourceProjectId = data.source_project_id; this.targetProjectId = data.target_project_id; - this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled); - this.mergeTrainsCount = data.merge_trains_count || 0; - this.mergeTrainIndex = data.merge_train_index; // CI related this.hasCI = data.has_ci; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index fe5289ff371..f49e69c473b 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -146,6 +146,7 @@ export default { <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> <file-icon v-if="!showChangedIcon || file.type === 'tree'" + class="file-row-icon" :file-name="file.name" :loading="file.loading" :folder="isTree" @@ -223,13 +224,8 @@ export default { white-space: nowrap; } -.file-row-name svg { +.file-row-name .file-row-icon { margin-right: 2px; vertical-align: middle; } - -.file-row-name .loading-container { - display: inline-block; - margin-right: 4px; -} </style> diff --git a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue index df19906309c..f0aae20477b 100644 --- a/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue +++ b/app/assets/javascripts/vue_shared/components/recaptcha_modal.vue @@ -30,9 +30,16 @@ export default { }, mounted() { + if (window.recaptchaDialogCallback) { + throw new Error('recaptchaDialogCallback is already defined!'); + } window.recaptchaDialogCallback = this.submit.bind(this); }, + beforeDestroy() { + window.recaptchaDialogCallback = null; + }, + methods: { appendRecaptchaScript() { this.removeRecaptchaScript(); diff --git a/app/assets/javascripts/vue_shared/directives/autofocusonshow.js b/app/assets/javascripts/vue_shared/directives/autofocusonshow.js new file mode 100644 index 00000000000..4659ec20ceb --- /dev/null +++ b/app/assets/javascripts/vue_shared/directives/autofocusonshow.js @@ -0,0 +1,39 @@ +/** + * Input/Textarea Autofocus Directive for Vue + */ +export default { + /** + * Set focus when element is rendered, but + * is not visible, using IntersectionObserver + * + * @param {Element} el Target element + */ + inserted(el) { + if ('IntersectionObserver' in window) { + // Element visibility is dynamic, so we attach observer + el.visibilityObserver = new IntersectionObserver(entries => { + entries.forEach(entry => { + // Combining `intersectionRatio > 0` and + // element's `offsetParent` presence will + // deteremine if element is truely visible + if (entry.intersectionRatio > 0 && entry.target.offsetParent) { + entry.target.focus(); + } + }); + }); + + // Bind the observer. + el.visibilityObserver.observe(el, { root: document.documentElement }); + } + }, + /** + * Detach observer on unbind hook. + * + * @param {Element} el Target element + */ + unbind(el) { + if (el.visibilityObserver) { + el.visibilityObserver.disconnect(); + } + }, +}; diff --git a/app/assets/stylesheets/errors.scss b/app/assets/stylesheets/errors.scss index d287215096e..89029a58d1e 100644 --- a/app/assets/stylesheets/errors.scss +++ b/app/assets/stylesheets/errors.scss @@ -96,7 +96,7 @@ a { } .error-nav { - padding: 0; + padding: $gl-padding 0 0; text-align: center; li { diff --git a/app/assets/stylesheets/framework/badges.scss b/app/assets/stylesheets/framework/badges.scss index c6060161dec..c036267a7c8 100644 --- a/app/assets/stylesheets/framework/badges.scss +++ b/app/assets/stylesheets/framework/badges.scss @@ -1,6 +1,6 @@ .badge.badge-pill { font-weight: $gl-font-weight-normal; background-color: $badge-bg; - color: $gl-text-color-secondary; + color: $gray-800; vertical-align: baseline; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index f384a49e0ae..e9218dcec67 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -438,6 +438,7 @@ img.emoji { .w-3rem { width: 3rem; } .h-12em { height: 12em; } +.h-32-px { height: 32px;} .mw-460 { max-width: 460px; } .mw-6em { max-width: 6em; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 1bc597bd4ae..ca737c53318 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -131,7 +131,6 @@ > li:not(.d-none) a { @include media-breakpoint-down(xs) { margin-left: 0; - min-width: 100%; } } } @@ -233,7 +232,6 @@ .impersonation-btn, .impersonation-btn:hover { background-color: $white-light; - margin-left: 0; border-top-left-radius: 0; border-bottom-left-radius: 0; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 92190f8979e..9871771542d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -469,6 +469,7 @@ $link-active-background: rgba(0, 0, 0, 0.04); $link-hover-background: rgba(0, 0, 0, 0.06); $inactive-badge-background: rgba(0, 0, 0, 0.08); $sidebar-toggle-height: 60px; +$sidebar-toggle-width: 40px; $sidebar-milestone-toggle-bottom-margin: 10px; /* diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index ac3214a07d9..bdeac7e97c0 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -16,3 +16,16 @@ color: $dark-diff-match-bg; background: $dark-diff-match-color; } + +@mixin diff-expansion($background, $border, $link) { + background-color: $background; + + td { + border-top: 1px solid $border; + border-bottom: 1px solid $border; + } + + a { + color: $link; + } +} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index 16893dd047e..cbce0ba3f1e 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -111,6 +111,10 @@ $dark-il: #de935f; color: $dark-line-color; } + .line_expansion { + @include diff-expansion($dark-main-bg, $dark-border, $dark-na); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 37fe61b925c..1b61ffa37e3 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -111,6 +111,10 @@ $monokai-gi: #a6e22e; color: $monokai-text-color; } + .line_expansion { + @include diff-expansion($monokai-bg, $monokai-border, $monokai-k); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index b4217aac37a..a7ede266fb5 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -34,8 +34,11 @@ color: $gl-text-color; } -// Diff line + .line_expansion { + @include diff-expansion($gray-light, $white-normal, $gl-text-color); + } + // Diff line $none-over-bg: #ded7fc; $none-expanded-border: #e0e0e0; $none-expanded-bg: #e0e0e0; diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index a4e9eda22c9..6569f3abc8b 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -115,6 +115,10 @@ $solarized-dark-il: #2aa198; color: $solarized-dark-pre-color; } + .line_expansion { + @include diff-expansion($solarized-dark-line-bg, $solarized-dark-border, $solarized-dark-kd); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index b604d1ccb6c..4e74a9ea50a 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -122,6 +122,10 @@ $solarized-light-il: #2aa198; color: $solarized-light-pre-color; } + .line_expansion { + @include diff-expansion($solarized-light-line-bg, $solarized-light-border, $solarized-light-kd); + } + // Diff line .line_holder { &.match .line_content, diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index b3974df8639..973f94c63aa 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -101,24 +101,8 @@ pre.code, color: $white-code-color; } -// Expansion line .line_expansion { - background-color: $gray-light; - - td { - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; - text-align: center; - } - - a { - color: $blue-600; - } - - .unfold-icon { - display: inline-block; - padding: 8px 0; - } + @include diff-expansion($gray-light, $border-color, $blue-600); } // Diff line diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 343cca96851..e77a2d1e333 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -86,6 +86,9 @@ } .board { + // the next line cannot be replaced with .d-inline-block because it breaks display: none of SortableJS + // see https://gitlab.com/gitlab-org/gitlab-ce/issues/64828 + display: inline-block; width: calc(85vw - 15px); @include media-breakpoint-up(sm) { diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index 0f4bdb219a3..b88bd78cf3d 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -3,10 +3,6 @@ */ .container-message { - pre { - white-space: pre-line; - } - span .btn { margin: 0; } diff --git a/app/assets/stylesheets/pages/cycle_analytics.scss b/app/assets/stylesheets/pages/cycle_analytics.scss index 2b932d164a5..d80155a416d 100644 --- a/app/assets/stylesheets/pages/cycle_analytics.scss +++ b/app/assets/stylesheets/pages/cycle_analytics.scss @@ -51,27 +51,19 @@ } .stage-header { - width: 26%; - padding-left: $gl-padding; + width: 18.5%; } .median-header { - width: 14%; + width: 21.5%; } .event-header { width: 45%; - padding-left: $gl-padding; } .total-time-header { width: 15%; - text-align: right; - padding-right: $gl-padding; - } - - .stage-name { - font-weight: $gl-font-weight-bold; } } @@ -153,23 +145,13 @@ } .stage-nav-item { - display: flex; line-height: 65px; - border-top: 1px solid transparent; - border-bottom: 1px solid transparent; - border-right: 1px solid $border-color; - background-color: $gray-light; + border: 1px solid $border-color; &.active { - background-color: transparent; - border-right-color: transparent; - border-top-color: $border-color; - border-bottom-color: $border-color; - box-shadow: inset 2px 0 0 0 $blue-500; - - .stage-name { - font-weight: $gl-font-weight-bold; - } + background: $blue-50; + border-color: $blue-300; + box-shadow: inset 4px 0 0 0 $blue-500; } &:hover:not(.active) { @@ -178,24 +160,12 @@ cursor: pointer; } - &:first-child { - border-top: 0; - } - - &:last-child { - border-bottom: 0; - } - - .stage-nav-item-cell { - &.stage-median { - margin-left: auto; - margin-right: $gl-padding; - min-width: calc(35% - #{$gl-padding}); - } + .stage-nav-item-cell.stage-name { + width: 44.5%; } - .stage-name { - padding-left: 16px; + .stage-nav-item-cell.stage-median { + min-width: 43%; } .stage-empty, diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index ffb27e54f34..77a2fd6b876 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1032,7 +1032,6 @@ table.code { $top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; top: $header-height + $mr-tabs-height + $mr-version-controls-height + 10px; max-height: calc(100vh - #{$top-pos}); - padding-right: $gl-padding; z-index: 202; .with-performance-bar & { @@ -1043,7 +1042,7 @@ table.code { .drag-handle { bottom: 16px; - transform: translateX(-6px); + transform: translateX(10px); } } diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 66b4f3bad2b..0e844b0e4a5 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -126,6 +126,16 @@ } } +.assignee { + .merge-icon { + color: $orange-500; + position: absolute; + bottom: 0; + right: 0; + text-shadow: -1px -1px 0 $white-light, 1px -1px 0 $white-light, -1px 1px 0 $white-light, 1px 1px 0 $white-light; + } +} + .right-sidebar { position: fixed; top: $header-height; @@ -202,7 +212,6 @@ &.assignee { .author-link { display: block; - padding-left: 42px; position: relative; &:hover { @@ -210,12 +219,6 @@ text-decoration: underline; } } - - .avatar { - left: 0; - position: absolute; - top: 0; - } } } } @@ -354,13 +357,6 @@ margin-top: 0; } - .assignee .avatar { - float: left; - margin-right: 10px; - margin-bottom: 0; - margin-left: 0; - } - .assignee .user-list .avatar { margin: 0; } @@ -521,7 +517,12 @@ display: none; } + .merge-icon { + font-size: 10px; + } + .multiple-users { + position: relative; height: 24px; margin-bottom: 17px; margin-top: 4px; diff --git a/app/assets/stylesheets/pages/reports.scss b/app/assets/stylesheets/pages/reports.scss index 85e9f303dde..0fbf7033aa5 100644 --- a/app/assets/stylesheets/pages/reports.scss +++ b/app/assets/stylesheets/pages/reports.scss @@ -48,11 +48,6 @@ padding: $gl-padding-top $gl-padding; border-top: 1px solid $border-color; } - - .report-block-list-icon .loading-container { - position: relative; - left: -2px; - } } .report-block-container { diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss index 379df1c4db1..0b65b915abf 100644 --- a/app/assets/stylesheets/pages/wiki.scss +++ b/app/assets/stylesheets/pages/wiki.scss @@ -32,13 +32,11 @@ color: $gl-text-color-secondary; } - .git-access-header { - padding: $gl-padding 0 $gl-padding-top; - } - .git-clone-holder { - width: 100%; - padding-bottom: 40px; + .input-group-prepend, + .input-group-append { + background-color: transparent; + } } button.sidebar-toggle { @@ -48,19 +46,8 @@ display: block; } - @include media-breakpoint-up(sm) { - &.has-sidebar-toggle { - padding-right: 40px; - } - - .git-clone-holder { - width: 480px; - padding-bottom: $gl-padding; - } - - .nav-controls { - width: auto; - } + &.has-sidebar-toggle .git-access-header { + padding-right: $sidebar-toggle-width; } @include media-breakpoint-up(md) { @@ -105,10 +92,6 @@ padding: 0 $gl-padding; } - .block { - width: 100%; - } - a { color: $layout-link-gray; diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index f111c7ca8cc..30a567c3bef 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -36,7 +36,7 @@ class AutocompleteController < ApplicationController end def award_emojis - render json: AwardedEmojiFinder.new(current_user).execute + render json: AwardEmojis::CollectUserEmojiService.new(current_user).execute end def merge_request_target_branches diff --git a/app/controllers/concerns/invisible_captcha.rb b/app/controllers/concerns/invisible_captcha.rb index c9f66e5c194..45c0a5c58ef 100644 --- a/app/controllers/concerns/invisible_captcha.rb +++ b/app/controllers/concerns/invisible_captcha.rb @@ -41,9 +41,9 @@ module InvisibleCaptcha request_information = { message: message, env: :invisible_captcha_signup_bot_detected, - ip: request.ip, + remote_ip: request.ip, request_method: request.request_method, - fullpath: request.fullpath + path: request.fullpath } Gitlab::AuthLogger.error(request_information) diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index 3489ea78b77..8ea77b994de 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -2,8 +2,8 @@ module IssuableCollections extend ActiveSupport::Concern - include CookiesHelper include SortingHelper + include SortingPreference include Gitlab::IssuableMetadata include Gitlab::Utils::StrongMemoize @@ -127,47 +127,8 @@ module IssuableCollections 'opened' end - def set_sort_order - set_sort_order_from_user_preference || set_sort_order_from_cookie || default_sort_order - end - - def set_sort_order_from_user_preference - return unless current_user - return unless issuable_sorting_field - - user_preference = current_user.user_preference - - sort_param = params[:sort] - sort_param ||= user_preference[issuable_sorting_field] - - return sort_param if Gitlab::Database.read_only? - - if user_preference[issuable_sorting_field] != sort_param - user_preference.update(issuable_sorting_field => sort_param) - end - - sort_param - end - - # Implement issuable_sorting_field method on controllers - # to choose which column to store the sorting parameter. - def issuable_sorting_field - nil - end - - def set_sort_order_from_cookie - sort_param = params[:sort] if params[:sort].present? - # fallback to legacy cookie value for backward compatibility - sort_param ||= cookies['issuable_sort'] - sort_param ||= cookies[remember_sorting_key] - - sort_value = update_cookie_value(sort_param) - set_secure_cookie(remember_sorting_key, sort_value) - sort_value - end - - def remember_sorting_key - @remember_sorting_key ||= "#{collection_type.downcase}_sort" + def legacy_sort_cookie_name + 'issuable_sort' end def default_sort_order @@ -178,17 +139,6 @@ module IssuableCollections end end - # Update old values to the actual ones. - def update_cookie_value(value) - case value - when 'id_asc' then sort_value_oldest_created - when 'id_desc' then sort_value_recently_created - when 'downvotes_asc' then sort_value_popularity - when 'downvotes_desc' then sort_value_popularity - else value - end - end - def finder @finder ||= issuable_finder_for(finder_type) end diff --git a/app/controllers/concerns/issuable_collections_action.rb b/app/controllers/concerns/issuable_collections_action.rb index 4ad287c4a13..0a6f684a9fc 100644 --- a/app/controllers/concerns/issuable_collections_action.rb +++ b/app/controllers/concerns/issuable_collections_action.rb @@ -32,7 +32,7 @@ module IssuableCollectionsAction private - def issuable_sorting_field + def sorting_field case action_name when 'issues' Issue::SORTING_PREFERENCE_FIELD diff --git a/app/controllers/concerns/sorting_preference.rb b/app/controllers/concerns/sorting_preference.rb new file mode 100644 index 00000000000..a51b68147d5 --- /dev/null +++ b/app/controllers/concerns/sorting_preference.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module SortingPreference + include SortingHelper + include CookiesHelper + + def set_sort_order + set_sort_order_from_user_preference || set_sort_order_from_cookie || params[:sort] || default_sort_order + end + + # Implement sorting_field method on controllers + # to choose which column to store the sorting parameter. + def sorting_field + nil + end + + # Implement default_sort_order method on controllers + # to choose which default sort should be applied if + # sort param is not provided. + def default_sort_order + nil + end + + # Implement legacy_sort_cookie_name method on controllers + # to set sort from cookie for backwards compatibility. + def legacy_sort_cookie_name + nil + end + + private + + def set_sort_order_from_user_preference + return unless current_user + return unless sorting_field + + user_preference = current_user.user_preference + + sort_param = params[:sort] + sort_param ||= user_preference[sorting_field] + + return sort_param if Gitlab::Database.read_only? + + if user_preference[sorting_field] != sort_param + user_preference.update(sorting_field => sort_param) + end + + sort_param + end + + def set_sort_order_from_cookie + return unless legacy_sort_cookie_name + + sort_param = params[:sort] if params[:sort].present? + # fallback to legacy cookie value for backward compatibility + sort_param ||= cookies[legacy_sort_cookie_name] + sort_param ||= cookies[remember_sorting_key] + + sort_value = update_cookie_value(sort_param) + set_secure_cookie(remember_sorting_key, sort_value) + sort_value + end + + # Convert sorting_field to legacy cookie name for backwards compatibility + # :merge_requests_sort => 'mergerequest_sort' + # :issues_sort => 'issue_sort' + def remember_sorting_key + @remember_sorting_key ||= sorting_field + .to_s + .split('_')[0..-2] + .map(&:singularize) + .join('') + .concat('_sort') + end + + # Update old values to the actual ones. + def update_cookie_value(value) + case value + when 'id_asc' then sort_value_oldest_created + when 'id_desc' then sort_value_recently_created + when 'downvotes_asc' then sort_value_popularity + when 'downvotes_desc' then sort_value_popularity + else value + end + end +end diff --git a/app/controllers/concerns/toggle_award_emoji.rb b/app/controllers/concerns/toggle_award_emoji.rb index 97b343f8b1a..24d178781d6 100644 --- a/app/controllers/concerns/toggle_award_emoji.rb +++ b/app/controllers/concerns/toggle_award_emoji.rb @@ -7,12 +7,9 @@ module ToggleAwardEmoji authenticate_user! name = params.require(:name) - if awardable.user_can_award?(current_user) - awardable.toggle_award_emoji(name, current_user) - - todoable = to_todoable(awardable) - TodoService.new.new_award_emoji(todoable, current_user) if todoable + service = AwardEmojis::ToggleService.new(awardable, name, current_user).execute + if service[:status] == :success render json: { ok: true } else render json: { ok: false } @@ -21,18 +18,6 @@ module ToggleAwardEmoji private - def to_todoable(awardable) - case awardable - when Note - # we don't create todos for personal snippet comments for now - awardable.for_personal_snippet? ? nil : awardable.noteable - when MergeRequest, Issue - awardable - when Snippet - nil - end - end - def awardable raise NotImplementedError end diff --git a/app/controllers/dashboard/projects_controller.rb b/app/controllers/dashboard/projects_controller.rb index daeb8fda417..71f18694613 100644 --- a/app/controllers/dashboard/projects_controller.rb +++ b/app/controllers/dashboard/projects_controller.rb @@ -4,10 +4,12 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController include ParamsBackwardCompatibility include RendersMemberAccess include OnboardingExperimentHelper + include SortingHelper + include SortingPreference prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } before_action :set_non_archived_param - before_action :default_sorting + before_action :set_sorting before_action :projects, only: [:index] skip_cross_project_access_check :index, :starred @@ -59,11 +61,6 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController end end - def default_sorting - params[:sort] ||= 'latest_activity_desc' - @sort = params[:sort] - end - # rubocop: disable CodeReuse/ActiveRecord def load_projects(finder_params) @total_user_projects_count = ProjectsFinder.new(params: { non_public: true }, current_user: current_user).execute @@ -88,4 +85,17 @@ class Dashboard::ProjectsController < Dashboard::ApplicationController Events::RenderService.new(current_user).execute(@events, atom_request: request.format.atom?) end + + def set_sorting + params[:sort] = set_sort_order + @sort = params[:sort] + end + + def default_sort_order + sort_value_latest_activity + end + + def sorting_field + Project::SORTING_PREFERENCE_FIELD + end end diff --git a/app/controllers/explore/projects_controller.rb b/app/controllers/explore/projects_controller.rb index ef86d5f981a..271f2b4b57d 100644 --- a/app/controllers/explore/projects_controller.rb +++ b/app/controllers/explore/projects_controller.rb @@ -3,12 +3,13 @@ class Explore::ProjectsController < Explore::ApplicationController include ParamsBackwardCompatibility include RendersMemberAccess + include SortingHelper + include SortingPreference before_action :set_non_archived_param + before_action :set_sorting def index - params[:sort] ||= 'latest_activity_desc' - @sort = params[:sort] @projects = load_projects respond_to do |format| @@ -23,7 +24,6 @@ class Explore::ProjectsController < Explore::ApplicationController def trending params[:trending] = true - @sort = params[:sort] @projects = load_projects respond_to do |format| @@ -67,4 +67,17 @@ class Explore::ProjectsController < Explore::ApplicationController prepare_projects_for_rendering(projects) end # rubocop: enable CodeReuse/ActiveRecord + + def set_sorting + params[:sort] = set_sort_order + @sort = params[:sort] + end + + def default_sort_order + sort_value_latest_activity + end + + def sorting_field + Project::SORTING_PREFERENCE_FIELD + end end diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 5ecf4f114cf..da39d64c93d 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class JwtController < ApplicationController + skip_around_action :set_session_storage skip_before_action :authenticate_user! skip_before_action :verify_authenticity_token before_action :authenticate_project_or_user diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index b04ffe80db4..4125f44d00a 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -92,7 +92,7 @@ class Projects::BlobController < Projects::ApplicationController def diff apply_diff_view_cookie! - @form = Blobs::UnfoldPresenter.new(blob, params.to_unsafe_h) + @form = Blobs::UnfoldPresenter.new(blob, diff_params) # keep only json rendering when # https://gitlab.com/gitlab-org/gitlab-ce/issues/44988 is done @@ -239,4 +239,8 @@ class Projects::BlobController < Projects::ApplicationController def tree_path @path.rpartition('/').first end + + def diff_params + params.permit(:full, :since, :to, :bottom, :unfold, :offset, :indent) + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index bc9166b9df3..b7fd286bfe0 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -190,7 +190,7 @@ class Projects::IssuesController < Projects::ApplicationController protected - def issuable_sorting_field + def sorting_field Issue::SORTING_PREFERENCE_FIELD end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index f4d381244d9..f4cc0a5851b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -219,7 +219,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo alias_method :issuable, :merge_request alias_method :awardable, :merge_request - def issuable_sorting_field + def sorting_field MergeRequest::SORTING_PREFERENCE_FIELD end diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index db3b7c8b177..499d4918899 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -3,6 +3,7 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :whitelist_query_limiting, only: [:create, :retry] before_action :pipeline, except: [:index, :new, :create, :charts] + before_action :set_pipeline_path, only: [:show] before_action :authorize_read_pipeline! before_action :authorize_read_build!, only: [:index] before_action :authorize_create_pipeline!, only: [:new, :create] @@ -174,14 +175,36 @@ class Projects::PipelinesController < Projects::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def pipeline - @pipeline ||= project - .all_pipelines - .includes(user: :status) - .find_by!(id: params[:id]) - .present(current_user: current_user) + @pipeline ||= if params[:id].blank? && params[:latest] + latest_pipeline + else + project + .all_pipelines + .includes(builds: :tags, user: :status) + .find_by!(id: params[:id]) + .present(current_user: current_user) + end end # rubocop: enable CodeReuse/ActiveRecord + def set_pipeline_path + @pipeline_path ||= if params[:id].blank? && params[:latest] + latest_project_pipelines_path(@project, params['ref']) + else + project_pipeline_path(@project, @pipeline) + end + end + + def latest_pipeline + ref = params['ref'].presence || @project.default_branch + sha = @project.commit(ref)&.sha + + @project.ci_pipelines + .newest_first(ref: ref, sha: sha) + .first + &.present(current_user: current_user) + end + def whitelist_query_limiting # Also see https://gitlab.com/gitlab-org/gitlab-ce/issues/42343 Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42339') diff --git a/app/controllers/projects/starrers_controller.rb b/app/controllers/projects/starrers_controller.rb index e4093bed0ef..4efe956e973 100644 --- a/app/controllers/projects/starrers_controller.rb +++ b/app/controllers/projects/starrers_controller.rb @@ -5,11 +5,11 @@ class Projects::StarrersController < Projects::ApplicationController def index @starrers = UsersStarProjectsFinder.new(@project, params, current_user: @current_user).execute + @sort = params[:sort].presence || sort_value_name + @starrers = @starrers.preload_users.sort_by_attribute(@sort).page(params[:page]) @public_count = @project.starrers.with_public_profile.size @total_count = @project.starrers.size @private_count = @total_count - @public_count - @sort = params[:sort].presence || sort_value_name - @starrers = @starrers.sort_by_attribute(@sort).page(params[:page]) end private diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index d1914c35bd3..b187fdb2723 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -16,6 +16,10 @@ class Projects::WikisController < Projects::ApplicationController redirect_to(project_wiki_path(@project, @page)) end + def new + redirect_to project_wiki_path(@project, SecureRandom.uuid, random_title: true) + end + def pages @wiki_pages = Kaminari.paginate_array( @project_wiki.list_pages(sort: params[:sort], direction: params[:direction]) @@ -24,17 +28,25 @@ class Projects::WikisController < Projects::ApplicationController @wiki_entries = WikiPage.group_by_directory(@wiki_pages) end + # `#show` handles a number of scenarios: + # + # - If `id` matches a WikiPage, then show the wiki page. + # - If `id` is a file in the wiki repository, then send the file. + # - If we know the user wants to create a new page with the given `id`, + # then display a create form. + # - Otherwise show the empty wiki page and invite the user to create a page. def show - view_param = @project_wiki.empty? ? params[:view] : 'create' - if @page set_encoding_error unless valid_encoding? render 'show' elsif file_blob send_blob(@project_wiki.repository, file_blob) - elsif can?(current_user, :create_wiki, @project) && view_param == 'create' - @page = build_page(title: params[:id]) + elsif show_create_form? + # Assign a title to the WikiPage unless `id` is a randomly generated slug from #new + title = params[:id] unless params[:random_title].present? + + @page = build_page(title: title) render 'edit' else @@ -110,6 +122,15 @@ class Projects::WikisController < Projects::ApplicationController private + def show_create_form? + can?(current_user, :create_wiki, @project) && + @page.nil? && + # Always show the create form when the wiki has had at least one page created. + # Otherwise, we only show the form when the user has navigated from + # the 'empty wiki' page + (@project_wiki.exists? || params[:view] == 'create') + end + def load_project_wiki @project_wiki = load_wiki @@ -135,7 +156,7 @@ class Projects::WikisController < Projects::ApplicationController params.require(:wiki).permit(:title, :content, :format, :message, :last_commit_sha) end - def build_page(args) + def build_page(args = {}) WikiPage.new(@project_wiki).tap do |page| page.update_attributes(args) # rubocop:disable Rails/ActiveRecordAliases end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index e04cbf10470..5f335de4d6b 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -29,6 +29,7 @@ class ProjectsController < Projects::ApplicationController # Authorize before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] + before_action :authorize_archive_project!, only: [:archive, :unarchive] before_action :event_filter, only: [:show, :activity] layout :determine_layout @@ -164,8 +165,6 @@ class ProjectsController < Projects::ApplicationController end def archive - return access_denied! unless can?(current_user, :archive_project, @project) - ::Projects::UpdateService.new(@project, current_user, archived: true).execute respond_to do |format| @@ -174,8 +173,6 @@ class ProjectsController < Projects::ApplicationController end def unarchive - return access_denied! unless can?(current_user, :archive_project, @project) - ::Projects::UpdateService.new(@project, current_user, archived: false).execute respond_to do |format| diff --git a/app/finders/award_emojis_finder.rb b/app/finders/award_emojis_finder.rb new file mode 100644 index 00000000000..7320e035409 --- /dev/null +++ b/app/finders/award_emojis_finder.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class AwardEmojisFinder + attr_reader :awardable, :params + + def initialize(awardable, params = {}) + @awardable = awardable + @params = params + + validate_params + end + + def execute + awards = awardable.award_emoji + awards = by_name(awards) + awards = by_awarded_by(awards) + awards + end + + private + + def by_name(awards) + return awards unless params[:name] + + awards.named(params[:name]) + end + + def by_awarded_by(awards) + return awards unless params[:awarded_by] + + awards.awarded_by(params[:awarded_by]) + end + + def validate_params + return unless params.present? + + validate_name_param + validate_awarded_by_param + end + + def validate_name_param + return unless params[:name] + + raise ArgumentError, 'Invalid name param' unless params[:name].in?(Gitlab::Emoji.emojis_names) + end + + def validate_awarded_by_param + return unless params[:awarded_by] + + # awarded_by can be a `User`, or an ID + unless params[:awarded_by].is_a?(User) || params[:awarded_by].to_s.match(/\A\d+\Z/) + raise ArgumentError, 'Invalid awarded_by param' + end + end +end diff --git a/app/finders/awarded_emoji_finder.rb b/app/finders/awarded_emoji_finder.rb deleted file mode 100644 index f0cc17f3b26..00000000000 --- a/app/finders/awarded_emoji_finder.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# Class for retrieving information about emoji awarded _by_ a particular user. -class AwardedEmojiFinder - attr_reader :current_user - - # current_user - The User to generate the data for. - def initialize(current_user = nil) - @current_user = current_user - end - - def execute - return [] unless current_user - - # We want the resulting data set to be an Array containing the emoji names - # in descending order, based on how often they were awarded. - AwardEmoji - .award_counts_for_user(current_user) - .map { |name, _| { name: name } } - end -end diff --git a/app/graphql/mutations/award_emojis/add.rb b/app/graphql/mutations/award_emojis/add.rb index 8e050dd6d29..85f3eb065bb 100644 --- a/app/graphql/mutations/award_emojis/add.rb +++ b/app/graphql/mutations/award_emojis/add.rb @@ -10,14 +10,11 @@ module Mutations check_object_is_awardable!(awardable) - # TODO this will be handled by AwardEmoji::AddService - # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 - award = awardable.create_award_emoji(args[:name], current_user) + service = ::AwardEmojis::AddService.new(awardable, args[:name], current_user).execute { - award_emoji: (award if award.persisted?), - errors: errors_on_object(award) + award_emoji: (service[:award] if service[:status] == :success), + errors: service[:errors] || [] } end end diff --git a/app/graphql/mutations/award_emojis/remove.rb b/app/graphql/mutations/award_emojis/remove.rb index 3ba85e445b8..f8a3d0ce390 100644 --- a/app/graphql/mutations/award_emojis/remove.rb +++ b/app/graphql/mutations/award_emojis/remove.rb @@ -10,22 +10,11 @@ module Mutations check_object_is_awardable!(awardable) - # TODO this check can be removed once AwardEmoji services are available. - # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 - unless awardable.awarded_emoji?(args[:name], current_user) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, - 'You have not awarded emoji of type name to the awardable' - end - - # TODO this will be handled by AwardEmoji::DestroyService - # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 - awardable.remove_award_emoji(args[:name], current_user) + service = ::AwardEmojis::DestroyService.new(awardable, args[:name], current_user).execute { # Mutation response is always a `nil` award_emoji - errors: [] + errors: service[:errors] || [] } end end diff --git a/app/graphql/mutations/award_emojis/toggle.rb b/app/graphql/mutations/award_emojis/toggle.rb index c03902e8035..d822048f3a6 100644 --- a/app/graphql/mutations/award_emojis/toggle.rb +++ b/app/graphql/mutations/award_emojis/toggle.rb @@ -15,23 +15,15 @@ module Mutations check_object_is_awardable!(awardable) - # TODO this will be handled by AwardEmoji::ToggleService - # See https://gitlab.com/gitlab-org/gitlab-ce/issues/63372 and - # https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/29782 - award = awardable.toggle_award_emoji(args[:name], current_user) - - # Destroy returns a collection :( - award = award.first if award.is_a?(Array) - - errors = errors_on_object(award) + service = ::AwardEmojis::ToggleService.new(awardable, args[:name], current_user).execute toggled_on = awardable.awarded_emoji?(args[:name], current_user) { # For consistency with the AwardEmojis::Remove mutation, only return # the AwardEmoji if it was created and not destroyed - award_emoji: (award if toggled_on), - errors: errors, + award_emoji: (service[:award] if toggled_on), + errors: service[:errors] || [], toggled_on: toggled_on } end diff --git a/app/graphql/types/namespace_type.rb b/app/graphql/types/namespace_type.rb index f105e9e6e28..35a97b5ace0 100644 --- a/app/graphql/types/namespace_type.rb +++ b/app/graphql/types/namespace_type.rb @@ -19,6 +19,11 @@ module Types field :lfs_enabled, GraphQL::BOOLEAN_TYPE, null: true, method: :lfs_enabled? field :request_access_enabled, GraphQL::BOOLEAN_TYPE, null: true + field :root_storage_statistics, Types::RootStorageStatisticsType, + null: true, + description: 'The aggregated storage statistics. Only available for root namespaces', + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchRootStorageStatisticsLoader.new(obj.id).find } + field :projects, Types::ProjectType.connection_type, null: false, diff --git a/app/graphql/types/root_storage_statistics_type.rb b/app/graphql/types/root_storage_statistics_type.rb new file mode 100644 index 00000000000..a7498ee0a2e --- /dev/null +++ b/app/graphql/types/root_storage_statistics_type.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + class RootStorageStatisticsType < BaseObject + graphql_name 'RootStorageStatistics' + + authorize :read_statistics + + field :storage_size, GraphQL::INT_TYPE, null: false, description: 'The total storage in bytes' + field :repository_size, GraphQL::INT_TYPE, null: false, description: 'The git repository size in bytes' + field :lfs_objects_size, GraphQL::INT_TYPE, null: false, description: 'The LFS objects size in bytes' + field :build_artifacts_size, GraphQL::INT_TYPE, null: false, description: 'The CI artifacts size in bytes' + field :packages_size, GraphQL::INT_TYPE, null: false, description: 'The packages size in bytes' + field :wiki_size, GraphQL::INT_TYPE, null: false, description: 'The wiki size in bytes' + end +end diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index f2b5b82b013..144df676304 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -105,14 +105,13 @@ module CiStatusHelper path = pipelines_project_commit_path(project, commit, ref: ref) render_status_with_link( - 'commit', commit.status(ref), path, tooltip_placement: tooltip_placement, icon_size: 24) end - def render_status_with_link(type, status, path = nil, tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) + def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) klass = "ci-status-link ci-status-icon-#{status.dasherize} d-inline-flex #{cssclass}" title = "#{type.titleize}: #{ci_label_for_status(status)}" data = { toggle: 'tooltip', placement: tooltip_placement, container: container } diff --git a/app/helpers/import_helper.rb b/app/helpers/import_helper.rb index 3d494c3de6a..9122ad5b35a 100644 --- a/app/helpers/import_helper.rb +++ b/app/helpers/import_helper.rb @@ -45,17 +45,14 @@ module ImportHelper end def import_github_authorize_message - _('To import GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories:') + _('To connect GitHub repositories, you first need to authorize GitLab to access the list of your GitHub repositories.') end def import_github_personal_access_token_message - personal_access_token_link = link_to _('Personal Access Token'), 'https://github.com/settings/tokens' + link_url = 'https://github.com/settings/tokens' + link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: link_url } - if github_import_configured? - _('Alternatively, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link } - else - _('To import GitHub repositories, you can use a %{personal_access_token_link}. When you create your Personal Access Token, you will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { personal_access_token_link: personal_access_token_link } - end + _('Create and provide your GitHub %{link_start}Personal Access Token%{link_end}. You will need to select the <code>repo</code> scope, so we can display a list of your public and private repositories which are available to import.').html_safe % { link_start: link_start, link_end: '</a>'.html_safe } end def import_configure_github_admin_message diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index e2e007eee50..b88b25eb845 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -405,7 +405,11 @@ module IssuablesHelper placement: is_collapsed ? 'left' : nil, container: is_collapsed ? 'body' : nil, boundary: 'viewport', - is_collapsed: is_collapsed + is_collapsed: is_collapsed, + track_label: "right_sidebar", + track_property: "update_todo", + track_event: "click_button", + track_value: "" } end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 5678304ffcf..8855e0cdd70 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -106,9 +106,9 @@ module NotificationsHelper end end - def notification_setting_icon(notification_setting) + def notification_setting_icon(notification_setting = nil) sprite_icon( - notification_setting.disabled? ? "notifications-off" : "notifications", + !notification_setting.present? || notification_setting.disabled? ? "notifications-off" : "notifications", css_class: "icon notifications-icon js-notifications-icon" ) end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 5d292094a05..3683f2ea9a9 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -125,9 +125,8 @@ class Notify < BaseMailer def mail_thread(model, headers = {}) add_project_headers add_unsubscription_headers_and_links + add_model_headers(model) - headers["X-GitLab-#{model.class.name}-ID"] = model.id - headers["X-GitLab-#{model.class.name}-IID"] = model.iid if model.respond_to?(:iid) headers['X-GitLab-Reply-Key'] = reply_key @reason = headers['X-GitLab-NotificationReason'] @@ -196,6 +195,18 @@ class Notify < BaseMailer @reply_key ||= SentNotification.reply_key end + # This method applies threading headers to the email to identify + # the instance we are discussing. + # + # All model instances must have `#id`, and may implement `#iid`. + def add_model_headers(object) + # Use replacement so we don't strip the module. + prefix = "X-GitLab-#{object.class.name.gsub(/::/, '-')}" + + headers["#{prefix}-ID"] = object.id + headers["#{prefix}-IID"] = object.iid if object.respond_to?(:iid) + end + def add_project_headers return unless @project diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index 88c8cb40ccb..a312bd24e78 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -3,7 +3,12 @@ module Analytics module CycleAnalytics class ProjectStage < ApplicationRecord + include Analytics::CycleAnalytics::Stage + + validates :project, presence: true belongs_to :project + + alias_attribute :parent, :project end end end diff --git a/app/models/award_emoji.rb b/app/models/award_emoji.rb index e26162f6151..0ab302a0f3e 100644 --- a/app/models/award_emoji.rb +++ b/app/models/award_emoji.rb @@ -16,8 +16,10 @@ class AwardEmoji < ApplicationRecord participant :user - scope :downvotes, -> { where(name: DOWNVOTE_NAME) } - scope :upvotes, -> { where(name: UPVOTE_NAME) } + scope :downvotes, -> { named(DOWNVOTE_NAME) } + scope :upvotes, -> { named(UPVOTE_NAME) } + scope :named, -> (names) { where(name: names) } + scope :awarded_by, -> (users) { where(user: users) } after_save :expire_etag_cache after_destroy :expire_etag_cache diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3c0efca31db..7930bef5cf2 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -121,6 +121,8 @@ module Ci scope :scheduled_actions, ->() { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) } scope :ref_protected, -> { where(protected: true) } scope :with_live_trace, -> { where('EXISTS (?)', Ci::BuildTraceChunk.where('ci_builds.id = ci_build_trace_chunks.build_id').select(1)) } + scope :with_stale_live_trace, -> { with_live_trace.finished_before(12.hours.ago) } + scope :finished_before, -> (date) { finished.where('finished_at < ?', date) } scope :matches_tag_ids, -> (tag_ids) do matcher = ::ActsAsTaggableOn::Tagging diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 43ff874ac23..1c1c7a5ae7a 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -23,13 +23,17 @@ module Ci project_type: 3 } - RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour - UPDATE_DB_RUNNER_INFO_EVERY = 40.minutes + RUNNER_QUEUE_EXPIRY_TIME = 1.hour + + # This needs to be less than `ONLINE_CONTACT_TIMEOUT` + UPDATE_CONTACT_COLUMN_EVERY = (40.minutes..55.minutes).freeze + AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze AVAILABLE_STATUSES = %w[active paused online offline].freeze AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze + FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze ignore_column :is_shared @@ -46,7 +50,7 @@ module Ci scope :active, -> { where(active: true) } scope :paused, -> { where(active: false) } - scope :online, -> { where('contacted_at > ?', contact_time_deadline) } + scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) } # The following query using negation is cheaper than using `contacted_at <= ?` # because there are less runners online than have been created. The # resulting query is quickly finding online ones and then uses the regular @@ -56,6 +60,8 @@ module Ci scope :offline, -> { where.not(id: online) } scope :ordered, -> { order(id: :desc) } + scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } + # BACKWARD COMPATIBILITY: There are needed to maintain compatibility with `AVAILABLE_SCOPES` used by `lib/api/runners.rb` scope :deprecated_shared, -> { instance_type } scope :deprecated_specific, -> { project_type.or(group_type) } @@ -137,10 +143,18 @@ module Ci fuzzy_search(query, [:token, :description]) end - def self.contact_time_deadline + def self.online_contact_time_deadline ONLINE_CONTACT_TIMEOUT.ago end + def self.recent_queue_deadline + # we add queue expiry + online + # - contacted_at can be updated at any time within this interval + # we have always accurate `contacted_at` but it is stored in Redis + # and not persisted in database + (ONLINE_CONTACT_TIMEOUT + RUNNER_QUEUE_EXPIRY_TIME).ago + end + def self.order_by(order) if order == 'contacted_asc' order_contacted_at_asc @@ -174,7 +188,7 @@ module Ci end def online? - contacted_at && contacted_at > self.class.contact_time_deadline + contacted_at && contacted_at > self.class.online_contact_time_deadline end def status @@ -275,9 +289,7 @@ module Ci def persist_cached_data? # Use a random threshold to prevent beating DB updates. - # It generates a distribution between [40m, 80m]. - - contacted_at_max_age = UPDATE_DB_RUNNER_INFO_EVERY + Random.rand(UPDATE_DB_RUNNER_INFO_EVERY) + contacted_at_max_age = Random.rand(UPDATE_CONTACT_COLUMN_EVERY) real_contacted_at = read_attribute(:contacted_at) real_contacted_at.nil? || diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb new file mode 100644 index 00000000000..0c603c2d5e6 --- /dev/null +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + module Stage + extend ActiveSupport::Concern + + included do + validates :name, presence: true + validates :start_event_identifier, presence: true + validates :end_event_identifier, presence: true + validate :validate_stage_event_pairs + + enum start_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :start_event_identifier + enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier + + alias_attribute :custom_stage?, :custom + end + + def parent=(_) + raise NotImplementedError + end + + def parent + raise NotImplementedError + end + + def start_event + Gitlab::Analytics::CycleAnalytics::StageEvents[start_event_identifier].new(params_for_start_event) + end + + def end_event + Gitlab::Analytics::CycleAnalytics::StageEvents[end_event_identifier].new(params_for_end_event) + end + + def params_for_start_event + {} + end + + def params_for_end_event + {} + end + + def default_stage? + !custom + end + + # The model that is going to be queried, Issue or MergeRequest + def subject_model + start_event.object_type + end + + private + + def validate_stage_event_pairs + return if start_event_identifier.nil? || end_event_identifier.nil? + + unless pairing_rules.fetch(start_event.class, []).include?(end_event.class) + errors.add(:end_event, :not_allowed_for_the_given_start_event) + end + end + + def pairing_rules + Gitlab::Analytics::CycleAnalytics::StageEvents.pairing_rules + end + end + end +end diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index 14bc56f0eee..f229b42ade6 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -106,30 +106,6 @@ module Awardable end def awarded_emoji?(emoji_name, current_user) - award_emoji.where(name: emoji_name, user: current_user).exists? - end - - def create_award_emoji(name, current_user) - return unless emoji_awardable? - - award_emoji.create(name: normalize_name(name), user: current_user) - end - - def remove_award_emoji(name, current_user) - award_emoji.where(name: name, user: current_user).destroy_all # rubocop: disable DestroyAll - end - - def toggle_award_emoji(emoji_name, current_user) - if awarded_emoji?(emoji_name, current_user) - remove_award_emoji(emoji_name, current_user) - else - create_award_emoji(emoji_name, current_user) - end - end - - private - - def normalize_name(name) - Gitlab::Emoji.normalize_emoji_name(name) + award_emoji.named(emoji_name).awarded_by(current_user).exists? end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index e60b6497cb7..db46d7afbb9 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -186,16 +186,15 @@ module Issuable def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s - when 'downvotes_desc' then order_downvotes_desc - when 'label_priority' then order_labels_priority(excluded_labels: excluded_labels) - when 'label_priority_desc' then order_labels_priority('DESC', excluded_labels: excluded_labels) - when 'milestone', 'milestone_due_asc' then order_milestone_due_asc - when 'milestone_due_desc' then order_milestone_due_desc - when 'popularity', 'popularity_desc' then order_upvotes_desc - when 'popularity_asc' then order_upvotes_asc - when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) - when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels) - when 'upvotes_desc' then order_upvotes_desc + when 'downvotes_desc' then order_downvotes_desc + when 'label_priority', 'label_priority_asc' then order_labels_priority(excluded_labels: excluded_labels) + when 'label_priority_desc' then order_labels_priority('DESC', excluded_labels: excluded_labels) + when 'milestone', 'milestone_due_asc' then order_milestone_due_asc + when 'milestone_due_desc' then order_milestone_due_desc + when 'popularity_asc' then order_upvotes_asc + when 'popularity', 'popularity_desc', 'upvotes_desc' then order_upvotes_desc + when 'priority', 'priority_asc' then order_due_date_and_labels_priority(excluded_labels: excluded_labels) + when 'priority_desc' then order_due_date_and_labels_priority('DESC', excluded_labels: excluded_labels) else order_by(method) end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index df1a9e3fe6e..c4af1b1fab2 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -27,14 +27,18 @@ module Sortable def simple_sorts { 'created_asc' => -> { order_created_asc }, + 'created_at_asc' => -> { order_created_asc }, 'created_date' => -> { order_created_desc }, 'created_desc' => -> { order_created_desc }, + 'created_at_desc' => -> { order_created_desc }, 'id_asc' => -> { order_id_asc }, 'id_desc' => -> { order_id_desc }, 'name_asc' => -> { order_name_asc }, 'name_desc' => -> { order_name_desc }, 'updated_asc' => -> { order_updated_asc }, - 'updated_desc' => -> { order_updated_desc } + 'updated_at_asc' => -> { order_updated_asc }, + 'updated_desc' => -> { order_updated_desc }, + 'updated_at_desc' => -> { order_updated_desc } } end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 68586e7a1fd..bff5d348ca0 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -162,6 +162,14 @@ class Deployment < ApplicationRecord deployed_at&.to_time&.in_time_zone&.to_s(:medium) end + def deployed_by + # We use deployable's user if available because Ci::PlayBuildService + # does not update the deployment's user, just the one for the deployable. + # TODO: use deployment's user once https://gitlab.com/gitlab-org/gitlab-ce/issues/66442 + # is completed. + deployable&.user || user + end + private def ref_path diff --git a/app/models/issue.rb b/app/models/issue.rb index c5a18f0af0f..caea8eadd18 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -128,11 +128,10 @@ class Issue < ApplicationRecord def self.sort_by_attribute(method, excluded_labels: []) case method.to_s - when 'closest_future_date' then order_closest_future_date - when 'due_date' then order_due_date_asc - when 'due_date_asc' then order_due_date_asc - when 'due_date_desc' then order_due_date_desc - when 'relative_position' then order_relative_position_asc.with_order_id_desc + when 'closest_future_date', 'closest_future_date_asc' then order_closest_future_date + when 'due_date', 'due_date_asc' then order_due_date_asc + when 'due_date_desc' then order_due_date_desc + when 'relative_position', 'relative_position_asc' then order_relative_position_asc.with_order_id_desc else super end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index f6b19317c50..3d6f397e599 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -15,8 +15,8 @@ class GroupMember < Member default_scope { where(source_type: SOURCE_TYPE) } scope :of_groups, ->(groups) { where(source_id: groups.select(:id)) } - scope :count_users_by_group_id, -> { joins(:user).group(:source_id).count } + scope :of_ldap_type, -> { where(ldap: true) } after_create :update_two_factor_requirement, unless: :invite? after_destroy :update_two_factor_requirement, unless: :invite? diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 56c430013ee..ae9b2f14343 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -8,6 +8,8 @@ class Namespace::RootStorageStatistics < ApplicationRecord belongs_to :namespace has_one :route, through: :namespace + scope :for_namespace_ids, ->(namespace_ids) { where(namespace_id: namespace_ids) } + delegate :all_projects, to: :namespace def recalculate! diff --git a/app/models/project.rb b/app/models/project.rb index 8efe4b06f87..10679fb1f85 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -55,6 +55,8 @@ class Project < ApplicationRecord VALID_MIRROR_PORTS = [22, 80, 443].freeze VALID_MIRROR_PROTOCOLS = %w(http https ssh git).freeze + SORTING_PREFERENCE_FIELD = :projects_sort + cache_markdown_field :description, pipeline: :description delegate :feature_available?, :builds_enabled?, :wiki_enabled?, diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index c91add6439f..4a19e05bf76 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -85,6 +85,10 @@ class ProjectWiki list_pages(limit: 1).empty? end + def exists? + !empty? + end + # Lists wiki pages of the repository. # # limit - max number of pages returned by the method. diff --git a/app/models/repository.rb b/app/models/repository.rb index 6f63cd32da4..b957b9b0bdd 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -239,13 +239,13 @@ class Repository def branch_exists?(branch_name) return false unless raw_repository - branch_names.include?(branch_name) + branch_names_include?(branch_name) end def tag_exists?(tag_name) return false unless raw_repository - tag_names.include?(tag_name) + tag_names_include?(tag_name) end def ref_exists?(ref) @@ -565,10 +565,10 @@ class Repository end delegate :branch_names, to: :raw_repository - cache_method :branch_names, fallback: [] + cache_method_as_redis_set :branch_names, fallback: [] delegate :tag_names, to: :raw_repository - cache_method :tag_names, fallback: [] + cache_method_as_redis_set :tag_names, fallback: [] delegate :branch_count, :tag_count, :has_visible_content?, to: :raw_repository cache_method :branch_count, fallback: 0 @@ -1130,6 +1130,10 @@ class Repository @cache ||= Gitlab::RepositoryCache.new(self) end + def redis_set_cache + @redis_set_cache ||= Gitlab::RepositorySetCache.new(self) + end + def request_store_cache @request_store_cache ||= Gitlab::RepositoryCache.new(self, backend: Gitlab::SafeRequestStore) end diff --git a/app/models/users_star_project.rb b/app/models/users_star_project.rb index 3c7a805cc5c..c633e2d8b3d 100644 --- a/app/models/users_star_project.rb +++ b/app/models/users_star_project.rb @@ -17,6 +17,7 @@ class UsersStarProject < ApplicationRecord scope :by_project, -> (project) { where(project_id: project.id) } scope :with_visible_profile, -> (user) { joins(:user).merge(User.with_visible_profile(user)) } scope :with_public_profile, -> { joins(:user).merge(User.with_public_profile) } + scope :preload_users, -> { preload(:user) } class << self def sort_by_attribute(method) diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index c686e7763bb..5d2b74b17a2 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -124,6 +124,8 @@ class GroupPolicy < BasePolicy rule { developer & developer_maintainer_access }.enable :create_projects rule { create_projects_disabled }.prevent :create_projects + rule { owner | admin }.enable :read_statistics + def access_level return GroupMember::NO_ACCESS if @user.nil? diff --git a/app/policies/namespace/root_storage_statistics_policy.rb b/app/policies/namespace/root_storage_statistics_policy.rb new file mode 100644 index 00000000000..63fcaf20dfe --- /dev/null +++ b/app/policies/namespace/root_storage_statistics_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class Namespace::RootStorageStatisticsPolicy < BasePolicy + delegate { @subject.namespace } +end diff --git a/app/policies/namespace_policy.rb b/app/policies/namespace_policy.rb index 2babcb0a2d9..937666c7e54 100644 --- a/app/policies/namespace_policy.rb +++ b/app/policies/namespace_policy.rb @@ -11,6 +11,7 @@ class NamespacePolicy < BasePolicy enable :create_projects enable :admin_namespace enable :read_namespace + enable :read_statistics end rule { personal_project & ~can_create_personal_project }.prevent :create_projects diff --git a/app/presenters/blobs/unfold_presenter.rb b/app/presenters/blobs/unfold_presenter.rb index 4c6dd6895cf..a256dd05a4d 100644 --- a/app/presenters/blobs/unfold_presenter.rb +++ b/app/presenters/blobs/unfold_presenter.rb @@ -1,30 +1,34 @@ # frozen_string_literal: true -require 'gt_one_coercion' - module Blobs class UnfoldPresenter < BlobPresenter - include Virtus.model + include ActiveModel::Attributes + include ActiveModel::AttributeAssignment include Gitlab::Utils::StrongMemoize - attribute :full, Boolean, default: false - attribute :since, GtOneCoercion - attribute :to, Integer - attribute :bottom, Boolean - attribute :unfold, Boolean, default: true - attribute :offset, Integer - attribute :indent, Integer, default: 0 + attribute :full, :boolean, default: false + attribute :since, :integer, default: 1 + attribute :to, :integer, default: 1 + attribute :bottom, :boolean, default: false + attribute :unfold, :boolean, default: true + attribute :offset, :integer, default: 0 + attribute :indent, :integer, default: 0 + + alias_method :full?, :full + alias_method :bottom?, :bottom + alias_method :unfold?, :unfold def initialize(blob, params) + super(blob) + self.attributes = params + # Load all blob data first as we need to ensure they're all loaded first # so we can accurately show the rest of the diff when unfolding. load_all_blob_data - @subject = blob @all_lines = blob.data.lines - super(params) - self.attributes = prepare_attributes + handle_full_or_end! end # Returns an array of Gitlab::Diff::Line with match line added @@ -56,21 +60,18 @@ module Blobs private - def prepare_attributes - return attributes unless full? || to == -1 + def handle_full_or_end! + return unless full? || to == -1 - full_opts = { - since: 1, + self.since = 1 if full? + + self.attributes = { to: all_lines_size, bottom: false, unfold: false, offset: 0, indent: 0 } - - return full_opts if full? - - full_opts.merge(attributes.slice(:since)) end def all_lines_size diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 6e91317eb20..8b967459173 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -18,10 +18,10 @@ class DeploymentEntity < Grape::Entity end expose :created_at - expose :finished_at + expose :deployed_at expose :tag expose :last? - expose :user, using: UserEntity + expose :deployed_by, as: :user, using: UserEntity expose :deployable do |deployment, opts| deployment.deployable.yield_self do |deployable| diff --git a/app/serializers/deployment_serializer.rb b/app/serializers/deployment_serializer.rb index 3fd3e1b9cc8..b48037dd53f 100644 --- a/app/serializers/deployment_serializer.rb +++ b/app/serializers/deployment_serializer.rb @@ -4,7 +4,7 @@ class DeploymentSerializer < BaseSerializer entity DeploymentEntity def represent_concise(resource, opts = {}) - opts[:only] = [:iid, :id, :sha, :created_at, :finished_at, :tag, :last?, :id, ref: [:name]] + opts[:only] = [:iid, :id, :sha, :created_at, :deployed_at, :tag, :last?, :id, ref: [:name]] represent(resource, opts) end end diff --git a/app/serializers/issuable_sidebar_basic_entity.rb b/app/serializers/issuable_sidebar_basic_entity.rb index c02fd024345..058c707ef9d 100644 --- a/app/serializers/issuable_sidebar_basic_entity.rb +++ b/app/serializers/issuable_sidebar_basic_entity.rb @@ -4,6 +4,7 @@ class IssuableSidebarBasicEntity < Grape::Entity include RequestAwareEntity expose :id + expose :iid expose :type do |issuable| issuable.to_ability_name end diff --git a/app/serializers/merge_request_serializer.rb b/app/serializers/merge_request_serializer.rb index 8ad1df5dfe0..bd2e682a122 100644 --- a/app/serializers/merge_request_serializer.rb +++ b/app/serializers/merge_request_serializer.rb @@ -8,7 +8,7 @@ class MergeRequestSerializer < BaseSerializer entity ||= case opts[:serializer] when 'sidebar' - IssuableSidebarBasicEntity + MergeRequestSidebarBasicEntity when 'sidebar_extras' MergeRequestSidebarExtrasEntity when 'basic' diff --git a/app/serializers/merge_request_sidebar_basic_entity.rb b/app/serializers/merge_request_sidebar_basic_entity.rb new file mode 100644 index 00000000000..3c911bbe4c8 --- /dev/null +++ b/app/serializers/merge_request_sidebar_basic_entity.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class MergeRequestSidebarBasicEntity < IssuableSidebarBasicEntity + expose :current_user, if: lambda { |_issuable| current_user } do + expose :can_merge do |merge_request| + merge_request.can_be_merged_by?(current_user) + end + end +end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index 554b307d4f8..c8088608cb0 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -84,8 +84,10 @@ class MergeRequestWidgetEntity < Grape::Entity private + delegate :current_user, to: :request + def presenter(merge_request) @presenters ||= {} - @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: request.current_user) # rubocop: disable CodeReuse/Presenter + @presenters[merge_request] ||= MergeRequestPresenter.new(merge_request, current_user: current_user) # rubocop: disable CodeReuse/Presenter end end diff --git a/app/services/award_emojis/add_service.rb b/app/services/award_emojis/add_service.rb new file mode 100644 index 00000000000..eac15dabbf0 --- /dev/null +++ b/app/services/award_emojis/add_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module AwardEmojis + class AddService < AwardEmojis::BaseService + include Gitlab::Utils::StrongMemoize + + def execute + unless awardable.user_can_award?(current_user) + return error('User cannot award emoji to awardable', status: :forbidden) + end + + unless awardable.emoji_awardable? + return error('Awardable cannot be awarded emoji', status: :unprocessable_entity) + end + + award = awardable.award_emoji.create(name: name, user: current_user) + + if award.persisted? + TodoService.new.new_award_emoji(todoable, current_user) if todoable + success(award: award) + else + error(award.errors.full_messages, award: award) + end + end + + private + + def todoable + strong_memoize(:todoable) do + case awardable + when Note + # We don't create todos for personal snippet comments for now + awardable.noteable unless awardable.for_personal_snippet? + when MergeRequest, Issue + awardable + when Snippet + nil + end + end + end + end +end diff --git a/app/services/award_emojis/base_service.rb b/app/services/award_emojis/base_service.rb new file mode 100644 index 00000000000..a677d03a221 --- /dev/null +++ b/app/services/award_emojis/base_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module AwardEmojis + class BaseService < ::BaseService + attr_accessor :awardable, :name + + def initialize(awardable, name, current_user) + @awardable = awardable + @name = normalize_name(name) + + super(awardable.project, current_user) + end + + private + + def normalize_name(name) + Gitlab::Emoji.normalize_emoji_name(name) + end + + # Provide more error state data than what BaseService allows. + # - An array of errors + # - The `AwardEmoji` if present + def error(errors, award: nil, status: nil) + errors = Array.wrap(errors) + + super(errors.to_sentence.presence, status).merge({ + award: award, + errors: errors + }) + end + end +end diff --git a/app/services/award_emojis/collect_user_emoji_service.rb b/app/services/award_emojis/collect_user_emoji_service.rb new file mode 100644 index 00000000000..6cab23f3edf --- /dev/null +++ b/app/services/award_emojis/collect_user_emoji_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +# Class for retrieving information about emoji awarded _by_ a particular user. +module AwardEmojis + class CollectUserEmojiService + attr_reader :current_user + + # current_user - The User to generate the data for. + def initialize(current_user = nil) + @current_user = current_user + end + + def execute + return [] unless current_user + + # We want the resulting data set to be an Array containing the emoji names + # in descending order, based on how often they were awarded. + AwardEmoji + .award_counts_for_user(current_user) + .map { |name, _| { name: name } } + end + end +end diff --git a/app/services/award_emojis/destroy_service.rb b/app/services/award_emojis/destroy_service.rb new file mode 100644 index 00000000000..3789a8403bc --- /dev/null +++ b/app/services/award_emojis/destroy_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module AwardEmojis + class DestroyService < AwardEmojis::BaseService + def execute + unless awardable.user_can_award?(current_user) + return error('User cannot destroy emoji on the awardable', status: :forbidden) + end + + awards = AwardEmojisFinder.new(awardable, name: name, awarded_by: current_user).execute + + if awards.empty? + return error("User has not awarded emoji of type #{name} on the awardable", status: :forbidden) + end + + award = awards.destroy_all.first # rubocop: disable DestroyAll + + success(award: award) + end + end +end diff --git a/app/services/award_emojis/toggle_service.rb b/app/services/award_emojis/toggle_service.rb new file mode 100644 index 00000000000..812dd1c2889 --- /dev/null +++ b/app/services/award_emojis/toggle_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module AwardEmojis + class ToggleService < AwardEmojis::BaseService + def execute + if awardable.awarded_emoji?(name, current_user) + DestroyService.new(awardable, name, current_user).execute + else + AddService.new(awardable, name, current_user).execute + end + end + end +end diff --git a/app/services/ci/update_build_queue_service.rb b/app/services/ci/update_build_queue_service.rb index 9c589d910eb..31c7178c9e7 100644 --- a/app/services/ci/update_build_queue_service.rb +++ b/app/services/ci/update_build_queue_service.rb @@ -9,6 +9,10 @@ module Ci private def tick_for(build, runners) + if Feature.enabled?(:ci_update_queues_for_online_runners, build.project, default_enabled: true) + runners = runners.with_recent_runner_queue + end + runners.each do |runner| runner.pick_build!(build) end diff --git a/app/services/git/base_hooks_service.rb b/app/services/git/base_hooks_service.rb index 3fd38444196..47c308c8280 100644 --- a/app/services/git/base_hooks_service.rb +++ b/app/services/git/base_hooks_service.rb @@ -56,7 +56,7 @@ module Git return unless params.fetch(:create_pipelines, true) Ci::CreatePipelineService - .new(project, current_user, base_params) + .new(project, current_user, pipeline_params) .execute(:push, pipeline_options) end @@ -75,24 +75,29 @@ module Git ProjectCacheWorker.perform_async(project.id, file_types, [], false) end - def base_params + def pipeline_params { - oldrev: params[:oldrev], - newrev: params[:newrev], + before: params[:oldrev], + after: params[:newrev], ref: params[:ref], - push_options: params[:push_options] || {} + push_options: params[:push_options] || {}, + checkout_sha: Gitlab::DataBuilder::Push.checkout_sha( + project.repository, params[:newrev], params[:ref]) } end def push_data_params(commits:, with_changed_files: true) - base_params.merge( + { + oldrev: params[:oldrev], + newrev: params[:newrev], + ref: params[:ref], project: project, user: current_user, commits: commits, message: event_message, commits_count: commits_count, with_changed_files: with_changed_files - ) + } end def event_push_data diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 77c2224ee3b..2ab6e88599f 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -344,10 +344,7 @@ class IssuableBaseService < BaseService def toggle_award(issuable) award = params.delete(:emoji_award) - if award - todo_service.new_award_emoji(issuable, current_user) - issuable.toggle_award_emoji(award, current_user) - end + AwardEmojis::ToggleService.new(issuable, award, current_user).execute if award end def associations_before_update(issuable) diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 067510a8a0a..c6aae4c28f2 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -17,11 +17,9 @@ module MergeRequests end def execute_hooks(merge_request, action = 'open', old_rev: nil, old_associations: {}) - if merge_request.project - merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations) - merge_request.project.execute_hooks(merge_data, :merge_request_hooks) - merge_request.project.execute_services(merge_data, :merge_request_hooks) - end + merge_data = hook_data(merge_request, action, old_rev: old_rev, old_associations: old_associations) + merge_request.project.execute_hooks(merge_data, :merge_request_hooks) + merge_request.project.execute_services(merge_data, :merge_request_hooks) end def cleanup_environments(merge_request) diff --git a/app/services/self_monitoring/project/create_service.rb b/app/services/self_monitoring/project/create_service.rb deleted file mode 100644 index c925c6a1610..00000000000 --- a/app/services/self_monitoring/project/create_service.rb +++ /dev/null @@ -1,219 +0,0 @@ -# frozen_string_literal: true - -module SelfMonitoring - module Project - class CreateService < ::BaseService - include Stepable - include Gitlab::Utils::StrongMemoize - - VISIBILITY_LEVEL = Gitlab::VisibilityLevel::INTERNAL - PROJECT_NAME = 'GitLab Instance Administration' - PROJECT_DESCRIPTION = <<~HEREDOC - This project is automatically generated and will be used to help monitor this GitLab instance. - HEREDOC - - GROUP_NAME = 'GitLab Instance Administrators' - GROUP_PATH = 'gitlab-instance-administrators' - - steps :validate_admins, - :create_group, - :create_project, - :save_project_id, - :add_group_members, - :add_to_whitelist, - :add_prometheus_manual_configuration - - def initialize - super(nil) - end - - def execute - execute_steps - end - - private - - def validate_admins - unless instance_admins.any? - log_error('No active admin user found') - return error('No active admin user found') - end - - success - end - - def create_group - if project_created? - log_info(_('Instance administrators group already exists')) - @group = application_settings.instance_administration_project.owner - return success(group: @group) - end - - admin_user = group_owner - @group = ::Groups::CreateService.new(admin_user, create_group_params).execute - - if @group.persisted? - success(group: @group) - else - error('Could not create group') - end - end - - def create_project - if project_created? - log_info(_('Instance administration project already exists')) - @project = application_settings.instance_administration_project - return success(project: project) - end - - admin_user = group_owner - @project = ::Projects::CreateService.new(admin_user, create_project_params).execute - - if project.persisted? - success(project: project) - else - log_error(_("Could not create instance administration project. Errors: %{errors}") % { errors: project.errors.full_messages }) - error(_('Could not create project')) - end - end - - def save_project_id - return success if project_created? - - result = ApplicationSettings::UpdateService.new( - application_settings, - group_owner, - { instance_administration_project_id: @project.id } - ).execute - - if result - success - else - log_error(_("Could not save instance administration project ID, errors: %{errors}") % { errors: application_settings.errors.full_messages }) - error(_('Could not save project ID')) - end - end - - def add_group_members - members = @group.add_users(group_maintainers, Gitlab::Access::MAINTAINER) - errors = members.flat_map { |member| member.errors.full_messages } - - if errors.any? - log_error("Could not add admins as members to self-monitoring project. Errors: #{errors}") - error('Could not add admins as members') - else - success - end - end - - def add_to_whitelist - return success unless prometheus_enabled? - return success unless prometheus_listen_address.present? - - uri = parse_url(internal_prometheus_listen_address_uri) - return error(_('Prometheus listen_address is not a valid URI')) unless uri - - result = ApplicationSettings::UpdateService.new( - application_settings, - group_owner, - add_to_outbound_local_requests_whitelist: [uri.normalized_host] - ).execute - - if result - success - else - log_error(_("Could not add prometheus URL to whitelist, errors: %{errors}") % { errors: application_settings.errors.full_messages }) - error(_('Could not add prometheus URL to whitelist')) - end - end - - def add_prometheus_manual_configuration - return success unless prometheus_enabled? - return success unless prometheus_listen_address.present? - - service = project.find_or_initialize_service('prometheus') - - unless service.update(prometheus_service_attributes) - log_error("Could not save prometheus manual configuration for self-monitoring project. Errors: #{service.errors.full_messages}") - return error('Could not save prometheus manual configuration') - end - - success - end - - def application_settings - strong_memoize(:application_settings) do - Gitlab::CurrentSettings.expire_current_application_settings - Gitlab::CurrentSettings.current_application_settings - end - end - - def project_created? - application_settings.instance_administration_project.present? - end - - def parse_url(uri_string) - Addressable::URI.parse(uri_string) - rescue Addressable::URI::InvalidURIError, TypeError - end - - def prometheus_enabled? - Gitlab.config.prometheus.enable - rescue Settingslogic::MissingSetting - false - end - - def prometheus_listen_address - Gitlab.config.prometheus.listen_address - rescue Settingslogic::MissingSetting - end - - def instance_admins - @instance_admins ||= User.admins.active - end - - def group_owner - instance_admins.first - end - - def group_maintainers - # Exclude the first so that the group_owner is not added again as a member. - instance_admins - [group_owner] - end - - def create_group_params - { - name: GROUP_NAME, - path: "#{GROUP_PATH}-#{SecureRandom.hex(4)}", - visibility_level: VISIBILITY_LEVEL - } - end - - def create_project_params - { - initialize_with_readme: true, - visibility_level: VISIBILITY_LEVEL, - name: PROJECT_NAME, - description: PROJECT_DESCRIPTION, - namespace_id: @group.id - } - end - - def internal_prometheus_listen_address_uri - if prometheus_listen_address.starts_with?('http') - prometheus_listen_address - else - 'http://' + prometheus_listen_address - end - end - - def prometheus_service_attributes - { - api_url: internal_prometheus_listen_address_uri, - manual_configuration: true, - active: true - } - end - end - end -end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index e30debbbe75..ee7223d6349 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -598,11 +598,11 @@ module SystemNoteService end def zoom_link_added(issue, project, author) - create_note(NoteSummary.new(issue, project, author, _('a Zoom call was added to this issue'), action: 'pinned_embed')) + create_note(NoteSummary.new(issue, project, author, _('added a Zoom call to this issue'), action: 'pinned_embed')) end def zoom_link_removed(issue, project, author) - create_note(NoteSummary.new(issue, project, author, _('a Zoom call was removed from this issue'), action: 'pinned_embed')) + create_note(NoteSummary.new(issue, project, author, _('removed a Zoom call from this issue'), action: 'pinned_embed')) end private diff --git a/app/views/admin/applications/_delete_form.html.haml b/app/views/admin/applications/_delete_form.html.haml index 82781f6716d..86f09bf1cb0 100644 --- a/app/views/admin/applications/_delete_form.html.haml +++ b/app/views/admin/applications/_delete_form.html.haml @@ -1,4 +1,4 @@ - submit_btn_css ||= 'btn btn-link btn-remove btn-sm' = form_tag admin_application_path(application) do %input{ :name => "_method", :type => "hidden", :value => "delete" }/ - = submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css + = submit_tag 'Destroy', class: submit_btn_css, data: { confirm: _('Are you sure?') } diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml index 1249b98221f..fdaacb732c7 100644 --- a/app/views/ci/status/_icon.html.haml +++ b/app/views/ci/status/_icon.html.haml @@ -1,13 +1,10 @@ - status = local_assigns.fetch(:status) - size = local_assigns.fetch(:size, 16) -- type = local_assigns.fetch(:type, 'pipeline') - tooltip_placement = local_assigns.fetch(:tooltip_placement, "left") - path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil) - option_css_classes = local_assigns.fetch(:option_css_classes, '') - css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} has-tooltip #{option_css_classes}" - title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label} -- if type == 'commit' - - title = s_("PipelineStatusTooltip|Commit: %{ci_status}") % {ci_status: status.label} - if path = link_to path, class: css_classes, title: title, data: { placement: tooltip_placement } do diff --git a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml index 69cc510e9c1..9bc5e2ee42f 100644 --- a/app/views/doorkeeper/authorized_applications/_delete_form.html.haml +++ b/app/views/doorkeeper/authorized_applications/_delete_form.html.haml @@ -5,4 +5,4 @@ = form_tag path do %input{ :name => "_method", :type => "hidden", :value => "delete" }/ - = submit_tag _('Revoke'), onclick: "return confirm('#{_('Are you sure?')}')", class: 'btn btn-remove btn-sm' + = submit_tag _('Revoke'), class: 'btn btn-remove btn-sm', data: { confirm: _('Are you sure?') } diff --git a/app/views/errors/access_denied.html.haml b/app/views/errors/access_denied.html.haml index 46931b5932d..1ed7b56db1d 100644 --- a/app/views/errors/access_denied.html.haml +++ b/app/views/errors/access_denied.html.haml @@ -10,7 +10,7 @@ = message %p = s_('403|Please contact your GitLab administrator to get permission.') - .action-container.js-go-back{ style: 'display: none' } - %a{ href: 'javascript:history.back()', class: 'btn btn-success' } + .action-container.js-go-back{ hidden: true } + %button{ type: 'button', class: 'btn btn-success' } = s_('Go Back') = render "errors/footer" diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 72e5934574a..518c44cc687 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -1,30 +1,32 @@ -- title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import') +- title = _('Authenticate with GitHub') - page_title title - breadcrumb_title title - header_title _("Projects"), root_path -%h3.page-title - = icon 'github', text: _('Import repositories from GitHub') +%h2.page-title + = title -- if github_import_configured? - %p - = import_github_authorize_message +%p + = import_github_authorize_message - = link_to _('List your GitHub repositories'), status_import_github_path(ci_cd_only: params[:ci_cd_only]), class: 'btn btn-success' +- if github_import_configured? && !has_ci_cd_only_params? + = link_to icon('github', text: title), status_import_github_path, class: 'btn btn-success' %hr -%p - = import_github_personal_access_token_message +- unless github_import_configured? || has_ci_cd_only_params? + .bs-callout.bs-callout-info + = import_configure_github_admin_message -= form_tag personal_access_token_import_github_path, method: :post, class: 'form-inline' do += form_tag personal_access_token_import_github_path, method: :post do .form-group - = text_field_tag :personal_access_token, '', class: 'form-control append-right-8', placeholder: _('Personal Access Token'), size: 40 - = submit_tag _('List your GitHub repositories'), class: 'btn btn-success' + %label.label-bold= _('Personal Access Token') + = text_field_tag :personal_access_token, '', class: 'form-control', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' } + %span.form-text.text-muted + = import_github_personal_access_token_message = render_if_exists 'import/github/ci_cd_only' -- unless github_import_configured? - %hr - %p - = import_configure_github_admin_message + .form-actions.d-flex.justify-content-end + = link_to _('Cancel'), new_project_path, class: 'btn' + = submit_tag _('Authenticate'), class: 'btn btn-success ml-2' diff --git a/app/views/layouts/errors.html.haml b/app/views/layouts/errors.html.haml index 74484005b48..dc924a0e25d 100644 --- a/app/views/layouts/errors.html.haml +++ b/app/views/layouts/errors.html.haml @@ -14,6 +14,10 @@ var goBackElement = document.querySelector('.js-go-back'); if (goBackElement && history.length > 1) { - goBackElement.style.display = 'block'; + goBackElement.removeAttribute('hidden'); + + goBackElement.querySelector('button').addEventListener('click', function() { + history.back(); + }); } }()); diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 89f99472270..14c7b2428b2 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -4,6 +4,7 @@ - search_path_url = search_path(group_id: group.id) - else - search_path_url = search_path +- has_impersonation_link = header_link?(:admin_impersonation) %header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } } %a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content @@ -64,14 +65,14 @@ .dropdown-menu.dropdown-menu-right = render 'layouts/header/help_dropdown' - if header_link?(:user_dropdown) - %li.nav-item.header-user.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' } } + %li.nav-item.header-user.dropdown{ data: { track_label: "profile_dropdown", track_event: "click_dropdown", track_value: "", qa_selector: 'user_menu' }, class: ('mr-0' if has_impersonation_link) } = link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do = image_tag avatar_icon_for_user(current_user, 23), width: 23, height: 23, class: "header-user-avatar qa-user-avatar" = sprite_icon('angle-down', css_class: 'caret-down') .dropdown-menu.dropdown-menu-right = render 'layouts/header/current_user_dropdown' - - if header_link?(:admin_impersonation) - %li.nav-item.impersonation + - if has_impersonation_link + %li.nav-item.impersonation.ml-0 = link_to admin_impersonation_path, class: 'nav-link impersonation-btn', method: :delete, title: _('Stop impersonation'), aria: { label: _('Stop impersonation') }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do = icon('user-secret') - if header_link?(:sign_in) diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 65ef9690062..08a39fc4f58 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -31,7 +31,7 @@ = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.feed-token-reset = label_tag :feed_token, s_('AccessTokens|Feed token'), class: "label-bold" - = text_field_tag :feed_token, current_user.feed_token, class: 'form-control', readonly: true, onclick: 'this.select()' + = text_field_tag :feed_token, current_user.feed_token, class: 'form-control js-select-on-focus', readonly: true %p.form-text.text-muted - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :feed_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any RSS or calendar URLs currently in use will stop working.') } - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can read activity and issue RSS feeds or your calendar feed as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } @@ -49,7 +49,7 @@ = s_('AccessTokens|It cannot be used to access any other data.') .col-lg-8.incoming-email-token-reset = label_tag :incoming_email_token, s_('AccessTokens|Incoming email token'), class: "label-bold" - = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control', readonly: true, onclick: 'this.select()' + = text_field_tag :incoming_email_token, current_user.incoming_email_token, class: 'form-control js-select-on-focus', readonly: true %p.form-text.text-muted - reset_link = link_to s_('AccessTokens|reset it'), [:reset, :incoming_email_token, :profile], method: :put, data: { confirm: s_('AccessTokens|Are you sure? Any issue email addresses currently in use will stop working.') } - reset_message = s_('AccessTokens|Keep this token secret. Anyone who gets ahold of it can create issues as if they were you. You should %{link_reset_it} if that ever happens.') % { link_reset_it: reset_link } diff --git a/app/views/projects/commit/_ajax_signature.html.haml b/app/views/projects/commit/_ajax_signature.html.haml index ae9aef5a9b0..e1bf0940f59 100644 --- a/app/views/projects/commit/_ajax_signature.html.haml +++ b/app/views/projects/commit/_ajax_signature.html.haml @@ -1,2 +1,2 @@ - if commit.has_signature? - %a{ href: 'javascript:void(0)', tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } } + %button{ tabindex: 0, class: commit_signature_badge_classes('js-loading-gpg-badge'), data: { toggle: 'tooltip', placement: 'top', title: _('GPG signature (loading...)'), 'commit-sha' => commit.sha } } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 1331fa179fc..cbd998c60ef 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -24,5 +24,5 @@ = link_to(_('Learn more about signing commits'), help_page_path('user/project/repository/gpg_signed_commits/index.md'), class: 'gpg-popover-help-link') -%a{ href: 'javascript:void(0)', tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } +%button{ tabindex: 0, class: css_classes, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = label diff --git a/app/views/projects/cycle_analytics/show.html.haml b/app/views/projects/cycle_analytics/show.html.haml index 59f0afd59e6..2b594c125f4 100644 --- a/app/views/projects/cycle_analytics/show.html.haml +++ b/app/views/projects/cycle_analytics/show.html.haml @@ -34,40 +34,29 @@ {{ n__('Last %d day', 'Last %d days', 90) }} .stage-panel-container .card.stage-panel - .card-header + .card-header.border-bottom-0 %nav.col-headers %ul - %li.stage-header - %span.stage-name + %li.stage-header.pl-5 + %span.stage-name.font-weight-bold {{ s__('ProjectLifecycle|Stage') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The phase of the development lifecycle."), "aria-hidden" => "true" } %li.median-header - %span.stage-name + %span.stage-name.font-weight-bold {{ __('Median') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The value lying at the midpoint of a series of observed values. E.g., between 3, 5, 9, the median is 5. Between 3, 5, 7, 8, the median is (5+7)/2 = 6."), "aria-hidden" => "true" } - %li.event-header - %span.stage-name + %li.event-header.pl-3 + %span.stage-name.font-weight-bold {{ currentStage ? __(currentStage.legend) : __('Related Issues') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The collection of events added to the data gathered for that stage."), "aria-hidden" => "true" } - %li.total-time-header - %span.stage-name + %li.total-time-header.pr-5.text-right + %span.stage-name.font-weight-bold {{ __('Total Time') }} %i.has-tooltip.fa.fa-question-circle{ "data-placement" => "top", title: _("The time taken by each data entry gathered by that stage."), "aria-hidden" => "true" } .stage-panel-body %nav.stage-nav %ul - %li.stage-nav-item{ ':class' => '{ active: stage.active }', '@click' => 'selectStage(stage)', "v-for" => "stage in state.stages" } - .stage-nav-item-cell.stage-name - {{ stage.title }} - .stage-nav-item-cell.stage-median - %template{ "v-if" => "stage.isUserAllowed" } - %span{ "v-if" => "stage.value" } - {{ stage.value }} - %span.stage-empty{ "v-else" => true } - {{ __('Not enough data') }} - %template{ "v-else" => true } - %span.not-available - {{ __('Not available') }} + %stage-nav-item{ "v-for" => "stage in state.stages", ":key" => '`ca-stage-title-${stage.title}`', '@select' => 'selectStage(stage)', ":title" => "stage.title", ":is-user-allowed" => "stage.isUserAllowed", ":value" => "stage.value", ":is-active" => "stage.active" } .section.stage-events %template{ "v-if" => "isLoadingStage" } = icon("spinner spin") diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index a11e23b6daa..752be02443c 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -15,10 +15,10 @@ .flex-truncate-child = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: 'build-link' do #{deployment.deployable.name} (##{deployment.deployable.id}) - - if deployment.user + - if deployment.deployed_by %div by - = user_avatar(user: deployment.user, size: 20, css_class: "mr-0 float-none") + = user_avatar(user: deployment.deployed_by, size: 20, css_class: "mr-0 float-none") .table-section.section-15{ role: 'gridcell' } .table-mobile-header{ role: 'rowheader' }= _("Created") diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml new file mode 100644 index 00000000000..42631fca5e8 --- /dev/null +++ b/app/views/projects/pages_domains/_certificate.html.haml @@ -0,0 +1,18 @@ +- if @domain.auto_ssl_enabled? + - if @domain.enabled? + - if @domain.certificate_text + %pre + = @domain.certificate_text + - else + .bs-callout.bs-callout-info + = _("GitLab is obtaining a Let's Encrypt SSL certificate for this domain. This process can take some time. Please try again later.") + - else + .bs-callout.bs-callout-warning + = _("A Let's Encrypt SSL certificate can not be obtained until your domain is verified.") +- else + - if @domain.certificate_text + %pre + = @domain.certificate_text + - else + .light + = _("missing") diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml index e9019175219..d0b54946f7e 100644 --- a/app/views/projects/pages_domains/show.html.haml +++ b/app/views/projects/pages_domains/show.html.haml @@ -60,9 +60,4 @@ %td = _("Certificate") %td - - if @domain.certificate_text - %pre - = @domain.certificate_text - - else - .light - = _("missing") + = render 'certificate' diff --git a/app/views/projects/pipelines/_info.html.haml b/app/views/projects/pipelines/_info.html.haml index 2b56ada8b73..53bb3c7487d 100644 --- a/app/views/projects/pipelines/_info.html.haml +++ b/app/views/projects/pipelines/_info.html.haml @@ -21,7 +21,7 @@ .icon-container = sprite_icon('flag') - if @pipeline.latest? - %span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for this branch") } + %span.js-pipeline-url-latest.badge.badge-success.has-tooltip{ title: _("Latest pipeline for the most recent commit on this branch") } latest - if @pipeline.has_yaml_errors? %span.js-pipeline-url-yaml.badge.badge-danger.has-tooltip{ title: @pipeline.yaml_errors } @@ -43,7 +43,7 @@ } } Auto DevOps - if @pipeline.detached_merge_request_pipeline? - %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: "The code of a detached pipeline is tested against the source branch instead of merged results" } + %span.js-pipeline-url-mergerequest.badge.badge-info.has-tooltip{ title: _('Pipelines for merge requests are configured. A detached pipeline runs in the context of the merge request, and not against the merged result. Learn more on the documentation for Pipelines for Merged Results.') } detached - if @pipeline.stuck? %span.js-pipeline-url-stuck.badge.badge-warning diff --git a/app/views/projects/pipelines/_with_tabs.html.haml b/app/views/projects/pipelines/_with_tabs.html.haml index c04f076a3ab..56995ffbcee 100644 --- a/app/views/projects/pipelines/_with_tabs.html.haml +++ b/app/views/projects/pipelines/_with_tabs.html.haml @@ -1,7 +1,7 @@ .tabs-holder %ul.pipelines-tabs.nav-links.no-top.no-bottom.mobile-separator.nav.nav-tabs %li.js-pipeline-tab-link - = link_to project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do + = link_to @pipeline_path, data: { target: '#js-tab-pipeline', action: 'pipelines', toggle: 'tab' }, class: 'pipeline-tab' do = _('Pipeline') %li.js-builds-tab-link = link_to builds_project_pipeline_path(@project, @pipeline), data: { target: '#js-tab-builds', action: 'builds', toggle: 'tab' }, class: 'builds-tab' do diff --git a/app/views/projects/serverless/functions/index.html.haml b/app/views/projects/serverless/functions/index.html.haml index 9c69aedfbfc..bac6c76684b 100644 --- a/app/views/projects/serverless/functions/index.html.haml +++ b/app/views/projects/serverless/functions/index.html.haml @@ -14,5 +14,5 @@ .js-serverless-functions-notice .flash-container - .top-area.adjust + .top-area.adjust.d-flex.justify-content-center .serverless-functions-table#js-serverless-functions diff --git a/app/views/projects/wikis/_form.html.haml b/app/views/projects/wikis/_form.html.haml index 858731b2dda..a153f527ee0 100644 --- a/app/views/projects/wikis/_form.html.haml +++ b/app/views/projects/wikis/_form.html.haml @@ -1,8 +1,8 @@ -- commit_message = @page.persisted? ? s_("WikiPageEdit|Update %{page_title}") : s_("WikiPageCreate|Create %{page_title}") -- commit_message = commit_message % { page_title: @page.title } +- form_classes = 'wiki-form common-note-form prepend-top-default js-quick-submit' +- form_classes += ' js-new-wiki-page' unless @page.persisted? = form_for [@project.namespace.becomes(Namespace), @project, @page], method: @page.persisted? ? :put : :post, - html: { class: 'wiki-form common-note-form prepend-top-default js-quick-submit' }, + html: { class: form_classes }, data: { uploads_path: uploads_path } do |f| = form_errors(@page) @@ -12,12 +12,14 @@ .form-group.row .col-sm-12= f.label :title, class: 'control-label-full-width' .col-sm-12 - = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title - - if @page.persisted? - %span.d-inline-block.mw-100.prepend-top-5 - = icon('lightbulb-o') + = f.text_field :title, class: 'form-control qa-wiki-title-textbox', value: @page.title, required: true, autofocus: !@page.persisted?, placeholder: _('Wiki|Page title') + %span.d-inline-block.mw-100.prepend-top-5 + = icon('lightbulb-o') + - if @page.persisted? = s_("WikiEditPageTip|Tip: You can move this page by adding the path to the beginning of the title.") = link_to icon('question-circle'), help_page_path('user/project/wiki/index', anchor: 'moving-a-wiki-page'), target: '_blank' + - else + = s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.") .form-group.row .col-sm-12= f.label :format, class: 'control-label-full-width' .col-sm-12 @@ -43,7 +45,7 @@ .form-group.row .col-sm-12= f.label :commit_message, class: 'control-label-full-width' - .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: commit_message + .col-sm-12= f.text_field :message, class: 'form-control qa-wiki-message-textbox', rows: 18, value: nil .form-actions - if @page && @page.persisted? diff --git a/app/views/projects/wikis/_main_links.html.haml b/app/views/projects/wikis/_main_links.html.haml index 643b51e01d1..2e1e176c42a 100644 --- a/app/views/projects/wikis/_main_links.html.haml +++ b/app/views/projects/wikis/_main_links.html.haml @@ -1,9 +1,9 @@ - if (@page && @page.persisted?) - if can?(current_user, :create_wiki, @project) - = link_to '#modal-new-wiki', class: "add-new-wiki btn btn-success", "data-toggle" => "modal" do + = link_to project_wikis_new_path(@project), class: "add-new-wiki btn btn-success", role: "button" do = s_("Wiki|New page") - = link_to project_wiki_history_path(@project, @page), class: "btn" do + = link_to project_wiki_history_path(@project, @page), class: "btn", role: "button" do = s_("Wiki|Page history") - if can?(current_user, :create_wiki, @project) && @page.latest? && @valid_encoding - = link_to project_wiki_edit_path(@project, @page), class: "btn js-wiki-edit" do + = link_to project_wiki_edit_path(@project, @page), class: "btn js-wiki-edit", role: "button" do = _("Edit") diff --git a/app/views/projects/wikis/_new.html.haml b/app/views/projects/wikis/_new.html.haml deleted file mode 100644 index 2c675c0de9c..00000000000 --- a/app/views/projects/wikis/_new.html.haml +++ /dev/null @@ -1,18 +0,0 @@ -#modal-new-wiki.modal - .modal-dialog - .modal-content - .modal-header - %h3.page-title= s_("WikiNewPageTitle|New Wiki Page") - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } - %span{ "aria-hidden": true } × - .modal-body - %form.new-wiki-page - .form-group - = label_tag :new_wiki_path do - %span= s_("WikiPage|Page slug") - = text_field_tag :new_wiki_path, nil, placeholder: s_("WikiNewPagePlaceholder|how-to-setup"), class: 'form-control', required: true, :'data-wikis-path' => project_wikis_path(@project), autofocus: true - %span.d-inline-block.mw-100.prepend-top-5 - = icon('lightbulb-o') - = s_("WikiNewPageTip|Tip: You can specify the full path for the new file. We will automatically create any missing directories.") - .form-actions - = button_tag s_("Wiki|Create page"), class: "build-new-wiki btn btn-success" diff --git a/app/views/projects/wikis/_sidebar.html.haml b/app/views/projects/wikis/_sidebar.html.haml index 28353927135..83d145444d8 100644 --- a/app/views/projects/wikis/_sidebar.html.haml +++ b/app/views/projects/wikis/_sidebar.html.haml @@ -1,6 +1,6 @@ %aside.right-sidebar.right-sidebar-expanded.wiki-sidebar.js-wiki-sidebar.js-right-sidebar{ data: { "offset-top" => "50", "spy" => "affix" } } .sidebar-container - .block.wiki-sidebar-header.append-bottom-default + .block.wiki-sidebar-header.append-bottom-default.w-100 %a.gutter-toggle.float-right.d-block.d-sm-block.d-md-none.js-sidebar-wiki-toggle{ href: "#" } = icon('angle-double-right') @@ -10,14 +10,12 @@ %span= _("Clone repository") .blocks-container - .block.block-first + .block.block-first.w-100 - if @sidebar_page = render_wiki_content(@sidebar_page) - else %ul.wiki-pages = render @sidebar_wiki_entries, context: 'sidebar' - .block + .block.w-100 = link_to project_wikis_pages_path(@project), class: 'btn btn-block' do = s_("Wiki|More Pages") - -= render 'projects/wikis/new' diff --git a/app/views/projects/wikis/edit.html.haml b/app/views/projects/wikis/edit.html.haml index e8b59a3b8c4..9ccf5acfefc 100644 --- a/app/views/projects/wikis/edit.html.haml +++ b/app/views/projects/wikis/edit.html.haml @@ -5,7 +5,7 @@ = wiki_page_errors(@error) -.wiki-page-header.top-area.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') @@ -13,16 +13,13 @@ %h2.wiki-page-title - if @page.persisted? = link_to @page.human_title, project_wiki_path(@project, @page) - - else - = @page.human_title - %span.light - · - - if @page.persisted? + %span.light + · = s_("Wiki|Edit Page") - - else - = s_("Wiki|Create Page") + - else + = s_("Wiki|Create New Page") - .nav-controls + .nav-controls.pb-md-3.pb-lg-0 - if @page.persisted? = link_to project_wiki_history_path(@project, @page), class: "btn" do = s_("Wiki|Page history") diff --git a/app/views/projects/wikis/git_access.html.haml b/app/views/projects/wikis/git_access.html.haml index 009133be117..6972eda9bb7 100644 --- a/app/views/projects/wikis/git_access.html.haml +++ b/app/views/projects/wikis/git_access.html.haml @@ -1,15 +1,17 @@ - @content_class = "limit-container-width" unless fluid_layout - page_title s_("WikiClone|Git Access"), _("Wiki") -.wiki-page-header.top-area.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle.py-3.flex-column.flex-lg-row %button.btn.btn-default.d-block.d-sm-block.d-md-none.float-right.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') - .git-access-header - = _("Clone repository") - %strong= @project_wiki.full_path + .git-access-header.w-100.d-flex.flex-column.justify-content-center + %span + = _("Clone repository") + %strong= @project_wiki.full_path - = render "shared/clone_panel", project: @project_wiki + .pt-3.pt-lg-0.w-100 + = render "shared/clone_panel", project: @project_wiki .wiki-git-access %h3= s_("WikiClone|Install Gollum") diff --git a/app/views/projects/wikis/history.html.haml b/app/views/projects/wikis/history.html.haml index f8468ef9a78..d3a55c53649 100644 --- a/app/views/projects/wikis/history.html.haml +++ b/app/views/projects/wikis/history.html.haml @@ -1,6 +1,6 @@ - page_title _("History"), @page.human_title, _("Wiki") -.wiki-page-header.top-area.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index f7999c3f1bd..275dc5dbd23 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -5,13 +5,13 @@ - sort_title = wiki_sort_title(params[:sort]) %div{ class: container_class } - .wiki-page-header.top-area + .wiki-page-header.top-area.flex-column.flex-lg-row .nav-text.flex-fill %h2.wiki-page-title = s_("Wiki|Wiki Pages") - .nav-controls + .nav-controls.pb-md-3.pb-lg-0 = link_to project_wikis_git_access_path(@project), class: 'btn' do = icon('cloud-download') = _("Clone repository") diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml index 1d649886331..c6197fe576e 100644 --- a/app/views/projects/wikis/show.html.haml +++ b/app/views/projects/wikis/show.html.haml @@ -4,7 +4,7 @@ - page_title @page.human_title, _("Wiki") - add_to_breadcrumbs _("Wiki"), project_wiki_path(@project, :home) -.wiki-page-header.top-area.has-sidebar-toggle +.wiki-page-header.top-area.has-sidebar-toggle.flex-column.flex-lg-row %button.btn.btn-default.sidebar-toggle.js-sidebar-wiki-toggle{ role: "button", type: "button" } = icon('angle-double-left') @@ -15,7 +15,7 @@ = (_("Last edited by %{name}") % { name: "<strong>#{@page.last_version.author_name}</strong>" }).html_safe #{time_ago_with_tooltip(@page.last_version.authored_date)} - .nav-controls + .nav-controls.pb-md-3.pb-lg-0 = render 'main_links' - if @page.historical? diff --git a/app/views/shared/boards/_show.html.haml b/app/views/shared/boards/_show.html.haml index 1be230eedb9..4f0be117035 100644 --- a/app/views/shared/boards/_show.html.haml +++ b/app/views/shared/boards/_show.html.haml @@ -16,7 +16,7 @@ #board-app.boards-app.position-relative{ "v-cloak" => "true", data: board_data, ":class" => "{ 'is-compact': detailIssueVisible }" } = render 'shared/issuable/search_bar', type: :boards, board: board - .boards-list.w-100.py-3.px-2.text-nowrap + .boards-list.w-100.py-3.px-2.text-nowrap{ data: { qa_selector: "boards_list" } } .boards-app-loading.w-100.text-center{ "v-if" => "loading" } = icon("spinner spin 2x") %board{ "v-cloak" => "true", diff --git a/app/views/shared/boards/components/_board.html.haml b/app/views/shared/boards/components/_board.html.haml index 6c0613605eb..ffa24d1c041 100644 --- a/app/views/shared/boards/components/_board.html.haml +++ b/app/views/shared/boards/components/_board.html.haml @@ -1,7 +1,7 @@ -.board.d-inline-block.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', - ":data-id" => "list.id" } +.board.h-100.px-2.align-top.ws-normal{ ":class" => '{ "is-draggable": !list.preset, "is-expandable": list.isExpandable, "is-collapsed": !list.isExpanded, "board-type-assignee": list.type === "assignee" }', + ":data-id" => "list.id", data: { qa_selector: "board_list" } } .board-inner.d-flex.flex-column.position-relative.h-100.rounded - %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }" } + %header.board-header{ ":class" => '{ "has-border": list.label && list.label.color, "position-relative": list.isExpanded, "position-absolute position-top-0 position-left-0 w-100 h-100": !list.isExpanded }', ":style" => "{ borderTopColor: (list.label && list.label.color ? list.label.color : null) }", data: { qa_selector: "board_list_header" } } %h3.board-title.m-0.d-flex.js-board-handle{ ":class" => '{ "user-can-drag": (!disabled && !list.preset), "border-bottom-0": !list.isExpanded }' } .board-title-caret.no-drag{ "v-if": "list.isExpandable", diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 825088a58e7..837707707a9 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -38,7 +38,7 @@ = _('Milestone') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "milestone", track_event: "click_edit_button", track_value: "" } .value.hide-collapsed - if milestone.present? = link_to milestone[:title], milestone[:web_url], class: "bold has-tooltip", title: sidebar_milestone_remaining_days(milestone), data: { container: "body", html: 'true', boundary: 'viewport' } @@ -66,7 +66,7 @@ = _('Due date') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right', data: { track_label: "right_sidebar", track_property: "due_date", track_event: "click_edit_button", track_value: "" } .value.hide-collapsed %span.value-content - if issuable_sidebar[:due_date] @@ -102,7 +102,7 @@ = _('Labels') = icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true') - if can_edit_issuable - = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right' + = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link qa-edit-link-labels float-right', data: { track_label: "right_sidebar", track_property: "labels", track_event: "click_edit_button", track_value: "" } .value.issuable-show-labels.dont-hide.hide-collapsed.qa-labels-block{ class: ("has-labels" if selected_labels.any?) } - if selected_labels.any? - selected_labels.each do |label_hash| @@ -139,7 +139,9 @@ - if signed_in - if issuable_sidebar[:project_emails_disabled] .block.js-emails-disabled - = notification_description(:owner_disabled) + .sidebar-collapsed-icon.has-tooltip{ title: notification_description(:owner_disabled), data: { placement: "left", container: "body", boundary: 'viewport' } } + = notification_setting_icon + .hide-collapsed= notification_description(:owner_disabled) - else .js-sidebar-subscriptions-entry-point @@ -160,7 +162,7 @@ = custom_icon('icon_arrow_right') .dropdown.sidebar-move-issue-dropdown.hide-collapsed %button.btn.btn-default.btn-block.js-sidebar-dropdown-toggle.js-move-issue{ type: 'button', - data: { toggle: 'dropdown', display: 'static' } } + data: { toggle: 'dropdown', display: 'static', track_label: "right_sidebar", track_property: "move_issue", track_event: "click_button", track_value: "" } } = _('Move issue') .dropdown-menu.dropdown-menu-selectable.dropdown-extended-height = dropdown_title(_('Move issue')) diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index ab01094ed6e..1dc538826dc 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -20,6 +20,8 @@ placeholder: _('Search users'), data: { first_user: issuable_sidebar.dig(:current_user, :username), current_user: true, + iid: issuable_sidebar[:iid], + issuable_type: issuable_type, project_id: issuable_sidebar[:project_id], author_id: issuable_sidebar[:author_id], field_name: "#{issuable_type}[assignee_ids][]", diff --git a/app/views/shared/members/_group.html.haml b/app/views/shared/members/_group.html.haml index e83ca5eaab8..42a823e3a8d 100644 --- a/app/views/shared/members/_group.html.haml +++ b/app/views/shared/members/_group.html.haml @@ -32,7 +32,7 @@ %ul - Gitlab::Access.options.each do |role, role_id| %li - = link_to role, "javascript:void(0)", + = link_to role, '#', class: ("is-active" if group_link.group_access == role_id), data: { id: role_id, el_id: dom_id } .clearable-input.member-form-control.d-sm-inline-block diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 331283f7eec..6762f211a80 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -82,7 +82,7 @@ %ul - member.valid_level_roles.each do |role, role_id| %li - = link_to role, "javascript:void(0)", + = link_to role, '#', class: ("is-active" if member.access_level == role_id), data: { id: role_id, el_id: dom_id(member) } = render_if_exists 'shared/members/ee/revert_ldap_group_sync_option', diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index b7474d891dc..573ed36d7f4 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -89,7 +89,7 @@ - if pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project) - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref) %span.icon-wrapper.pipeline-status - = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), type: 'commit', tooltip_placement: 'top', path: pipeline_path + = render 'ci/status/icon', status: project.commit.last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path .updated-note %span = _('Updated') diff --git a/app/workers/ci/archive_traces_cron_worker.rb b/app/workers/ci/archive_traces_cron_worker.rb index 75e68d0233a..ef2da729705 100644 --- a/app/workers/ci/archive_traces_cron_worker.rb +++ b/app/workers/ci/archive_traces_cron_worker.rb @@ -10,7 +10,7 @@ module Ci # Archive stale live traces which still resides in redis or database # This could happen when ArchiveTraceWorker sidekiq jobs were lost by receiving SIGKILL # More details in https://gitlab.com/gitlab-org/gitlab-ce/issues/36791 - Ci::Build.finished.with_live_trace.find_each(batch_size: 100) do |build| + Ci::Build.with_stale_live_trace.find_each(batch_size: 100) do |build| Ci::ArchiveTraceService.new.execute(build, worker_name: self.class.name) end end diff --git a/app/workers/git_garbage_collect_worker.rb b/app/workers/git_garbage_collect_worker.rb index 489d6215774..5499e12e49b 100644 --- a/app/workers/git_garbage_collect_worker.rb +++ b/app/workers/git_garbage_collect_worker.rb @@ -24,7 +24,7 @@ class GitGarbageCollectWorker task = task.to_sym - ::Projects::GitDeduplicationService.new(project).execute + ::Projects::GitDeduplicationService.new(project).execute if task == :gc gitaly_call(task, project.repository.raw_repository) |