diff options
Diffstat (limited to 'app')
171 files changed, 1858 insertions, 588 deletions
diff --git a/app/assets/javascripts/group_avatar.js b/app/assets/javascripts/avatar_picker.js index dcda625f587..d38e0b4abaa 100644 --- a/app/assets/javascripts/group_avatar.js +++ b/app/assets/javascripts/avatar_picker.js @@ -1,11 +1,12 @@ import $ from 'jquery'; -export default function groupAvatar() { - $('.js-choose-group-avatar-button').on('click', function onClickGroupAvatar() { +export default function initAvatarPicker() { + $('.js-choose-avatar-button').on('click', function onClickAvatar() { const form = $(this).closest('form'); - return form.find('.js-group-avatar-input').click(); + return form.find('.js-avatar-input').click(); }); - $('.js-group-avatar-input').on('change', function onChangeAvatarInput() { + + $('.js-avatar-input').on('change', function onChangeAvatarInput() { const form = $(this).closest('form'); const filename = $(this) .val() diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index dd92d3c8552..2edb6723ada 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -119,7 +119,17 @@ class ListIssue { } const projectPath = this.project ? this.project.path : ''; - return Vue.http.patch(`${this.path}.json`, data); + return Vue.http.patch(`${this.path}.json`, data).then(({ body = {} } = {}) => { + /** + * Since post implementation of Scoped labels, server can reject + * same key-ed labels. To keep the UI and server Model consistent, + * we're just assigning labels that server echo's back to us when we + * PATCH the said object. + */ + if (body) { + this.labels = body.labels; + } + }); } } diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 67fcdd082a2..03dea1ec0a5 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -41,6 +41,9 @@ export default class ContextualSidebar { this.toggleCollapsedSidebar(value, true); } }); + this.$page.on('transitionstart transitionend', () => { + $(document).trigger('content.resize'); + }); $(window).on('resize', () => _.debounce(this.render(), 100)); } diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue index bb66ab36283..41670b45798 100644 --- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue +++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue @@ -48,10 +48,13 @@ export default { noteableType: this.noteableType, noteTargetLine: this.noteTargetLine, diffViewType: this.diffViewType, - diffFile: this.getDiffFileByHash(this.diffFileHash), + diffFile: this.diffFile, linePosition: this.linePosition, }; }, + diffFile() { + return this.getDiffFileByHash(this.diffFileHash); + }, }, mounted() { if (this.isLoggedIn) { @@ -102,6 +105,7 @@ export default { :line-code="line.line_code" :line="line" :help-page-path="helpPagePath" + :diff-file="diffFile" save-button-title="Comment" class="diff-comment-form" @handleFormUpdateAddToReview="addToReview" diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 0c2e87521d9..efd03ec952f 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,6 +1,7 @@ import _ from 'underscore'; import { getParameterByName, getUrlParamsArray } from '~/lib/utils/common_utils'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import recentSearchesStorageKeys from 'ee_else_ce/filtered_search/recent_searches_storage_keys'; import { visitUrl } from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; @@ -36,10 +37,11 @@ export default class FilteredSearchManager { this.tokensContainer = this.container.querySelector('.tokens-container'); this.filteredSearchTokenKeys = filteredSearchTokenKeys; this.stateFiltersSelector = stateFiltersSelector; - this.recentsStorageKeyNames = { - issues: 'issue-recent-searches', - merge_requests: 'merge-request-recent-searches', - }; + + const { multipleAssignees } = this.filteredSearchInput.dataset; + if (multipleAssignees && this.filteredSearchTokenKeys.enableMultipleAssignees) { + this.filteredSearchTokenKeys.enableMultipleAssignees(); + } this.recentSearchesStore = new RecentSearchesStore({ isLocalStorageAvailable: RecentSearchesService.isAvailable(), @@ -51,7 +53,7 @@ export default class FilteredSearchManager { const fullPath = this.searchHistoryDropdownElement ? this.searchHistoryDropdownElement.dataset.fullPath : 'project'; - const recentSearchesKey = `${fullPath}-${this.recentsStorageKeyNames[this.page]}`; + const recentSearchesKey = `${fullPath}-${recentSearchesStorageKeys[this.page]}`; this.recentSearchesService = new RecentSearchesService(recentSearchesKey); } diff --git a/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js new file mode 100644 index 00000000000..7e9b809e9b2 --- /dev/null +++ b/app/assets/javascripts/filtered_search/recent_searches_storage_keys.js @@ -0,0 +1,4 @@ +export default { + issues: 'issue-recent-searches', + merge_requests: 'merge-request-recent-searches', +}; diff --git a/app/assets/javascripts/gl_field_errors.js b/app/assets/javascripts/gl_field_errors.js index d5d5954ce6a..c4fd719c8d0 100644 --- a/app/assets/javascripts/gl_field_errors.js +++ b/app/assets/javascripts/gl_field_errors.js @@ -15,7 +15,7 @@ export default class GlFieldErrors { initValidators() { // register selectors here as needed - const validateSelectors = [':text', ':password', '[type=email]'] + const validateSelectors = [':text', ':password', '[type=email]', '[type=url]', '[type=number]'] .map(selector => `input${selector}`) .join(','); diff --git a/app/assets/javascripts/helpers/monitor_helper.js b/app/assets/javascripts/helpers/monitor_helper.js new file mode 100644 index 00000000000..2c2a04d5b5e --- /dev/null +++ b/app/assets/javascripts/helpers/monitor_helper.js @@ -0,0 +1,17 @@ +/* eslint-disable import/prefer-default-export */ + +export const makeDataSeries = (queryResults, defaultConfig) => + queryResults.reduce((acc, result) => { + const data = result.values.filter(([, value]) => !Number.isNaN(value)); + if (!data.length) { + return acc; + } + const relevantMetric = defaultConfig.name.toLowerCase().replace(' ', '_'); + const name = result.metric[relevantMetric]; + const series = { data }; + if (name) { + series.name = `${defaultConfig.name}: ${name}`; + } + + return acc.concat({ ...defaultConfig, ...series }); + }, []); diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index d6673cf0421..80a6ab9598a 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -23,7 +23,7 @@ export default { type: Object, required: true, }, - mouseOver: { + dropdownOpen: { type: Boolean, required: true, }, @@ -92,8 +92,9 @@ export default { <new-dropdown :type="file.type" :path="file.path" - :mouse-over="mouseOver" + :is-open="dropdownOpen" class="prepend-left-8" + v-on="$listeners" /> </div> </template> diff --git a/app/assets/javascripts/ide/components/new_dropdown/index.vue b/app/assets/javascripts/ide/components/new_dropdown/index.vue index 593a9162a06..27d24fa5e1d 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/index.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/index.vue @@ -21,38 +21,29 @@ export default { required: false, default: '', }, - mouseOver: { + isOpen: { type: Boolean, - required: true, + required: false, + default: false, }, }, - data() { - return { - dropdownOpen: false, - }; - }, watch: { - dropdownOpen() { + isOpen() { this.$nextTick(() => { this.$refs.dropdownMenu.scrollIntoView({ block: 'nearest', }); }); }, - mouseOver() { - if (!this.mouseOver) { - this.dropdownOpen = false; - } - }, }, methods: { ...mapActions(['createTempEntry', 'openNewEntryModal', 'deleteEntry']), createNewItem(type) { this.openNewEntryModal({ type, path: this.path }); - this.dropdownOpen = false; + this.$emit('toggle', false); }, openDropdown() { - this.dropdownOpen = !this.dropdownOpen; + this.$emit('toggle', !this.isOpen); }, }, modalTypes, @@ -63,7 +54,7 @@ export default { <div class="ide-new-btn"> <div :class="{ - show: dropdownOpen, + show: isOpen, }" class="dropdown d-flex" > diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 451c8030e16..5ae73b2fc9c 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -24,7 +24,13 @@ export default { ...mapState(['pipelinesEmptyStateSvgPath', 'links']), ...mapGetters(['currentProject']), ...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']), - ...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']), + ...mapState('pipelines', [ + 'isLoadingPipeline', + 'hasLoadedPipeline', + 'latestPipeline', + 'stages', + 'isLoadingJobs', + ]), ciLintText() { return sprintf( __('You can test your .gitlab-ci.yml in %{linkStart}CI Lint%{linkEnd}.'), @@ -36,7 +42,7 @@ export default { ); }, showLoadingIcon() { - return this.isLoadingPipeline && this.latestPipeline === null; + return this.isLoadingPipeline && !this.hasLoadedPipeline; }, }, created() { @@ -51,7 +57,7 @@ export default { <template> <div class="ide-pipeline"> <gl-loading-icon v-if="showLoadingIcon" :size="2" class="prepend-top-default" /> - <template v-else-if="latestPipeline !== null"> + <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> <ci-icon :status="latestPipeline.details.status" :size="24" /> <span class="prepend-left-8"> @@ -62,7 +68,7 @@ export default { </span> </header> <empty-state - v-if="latestPipeline === false" + v-if="!latestPipeline" :help-page-path="links.ciHelpPagePath" :empty-state-svg-path="pipelinesEmptyStateSvgPath" :can-set-ci="true" diff --git a/app/assets/javascripts/ide/stores/actions/merge_request.js b/app/assets/javascripts/ide/stores/actions/merge_request.js index 362ced248a1..1273e375859 100644 --- a/app/assets/javascripts/ide/stores/actions/merge_request.js +++ b/app/assets/javascripts/ide/stores/actions/merge_request.js @@ -4,10 +4,11 @@ import service from '../../services'; import * as types from '../mutation_types'; import { activityBarViews } from '../../constants'; -export const getMergeRequestsForBranch = ({ commit }, { projectId, branchId } = {}) => +export const getMergeRequestsForBranch = ({ commit, state }, { projectId, branchId } = {}) => service .getProjectMergeRequests(`${projectId}`, { source_branch: branchId, + source_project_id: state.projects[projectId].id, order_by: 'created_at', per_page: 1, }) diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js index b4be100cb07..eaaa82cb339 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/mutations.js @@ -10,6 +10,7 @@ export default { }, [types.RECEIVE_LASTEST_PIPELINE_SUCCESS](state, pipeline) { state.isLoadingPipeline = false; + state.hasLoadedPipeline = true; if (pipeline) { state.latestPipeline = { @@ -34,7 +35,7 @@ export default { }; }); } else { - state.latestPipeline = false; + state.latestPipeline = null; } }, [types.REQUEST_JOBS](state, id) { diff --git a/app/assets/javascripts/ide/stores/modules/pipelines/state.js b/app/assets/javascripts/ide/stores/modules/pipelines/state.js index 8651e267b53..8dfa0ec491f 100644 --- a/app/assets/javascripts/ide/stores/modules/pipelines/state.js +++ b/app/assets/javascripts/ide/stores/modules/pipelines/state.js @@ -1,5 +1,6 @@ export default () => ({ isLoadingPipeline: true, + hasLoadedPipeline: false, isLoadingJobs: false, latestPipeline: null, stages: [], diff --git a/app/assets/javascripts/jobs/components/empty_state.vue b/app/assets/javascripts/jobs/components/empty_state.vue index 668fcf3d673..04f910b6b80 100644 --- a/app/assets/javascripts/jobs/components/empty_state.vue +++ b/app/assets/javascripts/jobs/components/empty_state.vue @@ -49,7 +49,7 @@ export default { <div class="text-content"> <h4 class="js-job-empty-state-title text-center">{{ title }}</h4> - <p v-if="content" class="js-job-empty-state-content">{{ content }}</p> + <p v-if="content" class="js-job-empty-state-content text-center">{{ content }}</p> <div v-if="action" class="text-center"> <gl-link diff --git a/app/assets/javascripts/jobs/components/job_app.vue b/app/assets/javascripts/jobs/components/job_app.vue index dbadd224251..0670e2b06b9 100644 --- a/app/assets/javascripts/jobs/components/job_app.vue +++ b/app/assets/javascripts/jobs/components/job_app.vue @@ -15,6 +15,7 @@ import ErasedBlock from './erased_block.vue'; import Log from './job_log.vue'; import LogTopBar from './job_log_controllers.vue'; import StuckBlock from './stuck_block.vue'; +import UnmetPrerequisitesBlock from './unmet_prerequisites_block.vue'; import Sidebar from './sidebar.vue'; import { sprintf } from '~/locale'; import delayedJobMixin from '../mixins/delayed_job_mixin'; @@ -32,6 +33,7 @@ export default { Log, LogTopBar, StuckBlock, + UnmetPrerequisitesBlock, Sidebar, GlLoadingIcon, SharedRunner: () => import('ee_component/jobs/components/shared_runner_limit_block.vue'), @@ -48,6 +50,11 @@ export default { required: false, default: null, }, + deploymentHelpUrl: { + type: String, + required: false, + default: null, + }, endpoint: { type: String, required: true, @@ -82,6 +89,7 @@ export default { ]), ...mapGetters([ 'headerTime', + 'hasUnmetPrerequisitesFailure', 'shouldRenderCalloutMessage', 'shouldRenderTriggeredLabel', 'hasEnvironment', @@ -210,7 +218,10 @@ export default { /> </div> - <callout v-if="shouldRenderCalloutMessage" :message="job.callout_message" /> + <callout + v-if="shouldRenderCalloutMessage && !hasUnmetPrerequisitesFailure" + :message="job.callout_message" + /> </header> <!-- EO Header Section --> @@ -223,6 +234,12 @@ export default { :runners-path="runnerSettingsUrl" /> + <unmet-prerequisites-block + v-if="hasUnmetPrerequisitesFailure" + class="js-job-failed" + :help-path="deploymentHelpUrl" + /> + <shared-runner v-if="shouldRenderSharedRunnerLimitWarning" class="js-shared-runner-limit" diff --git a/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue new file mode 100644 index 00000000000..25a8da84873 --- /dev/null +++ b/app/assets/javascripts/jobs/components/unmet_prerequisites_block.vue @@ -0,0 +1,30 @@ +<script> +import { GlLink } from '@gitlab/ui'; +/** + * Renders Unmet Prerequisites block for job's view. + */ +export default { + components: { + GlLink, + }, + props: { + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="bs-callout bs-callout-danger"> + <p class="js-failed-unmet-prerequisites append-bottom-0"> + {{ + s__(`Job|This job failed because the necessary resources were not successfully created.`) + }} + + <gl-link :href="helpPath" class="js-help-path"> + <strong> {{ __('More information') }} </strong> + </gl-link> + </p> + </div> +</template> diff --git a/app/assets/javascripts/jobs/index.js b/app/assets/javascripts/jobs/index.js index a32e945627c..25132449458 100644 --- a/app/assets/javascripts/jobs/index.js +++ b/app/assets/javascripts/jobs/index.js @@ -12,6 +12,7 @@ export default () => { render(createElement) { return createElement('job-app', { props: { + deploymentHelpUrl: element.dataset.deploymentHelpUrl, runnerHelpUrl: element.dataset.runnerHelpUrl, runnerSettingsUrl: element.dataset.runnerSettingsUrl, endpoint: element.dataset.endpoint, diff --git a/app/assets/javascripts/jobs/store/getters.js b/app/assets/javascripts/jobs/store/getters.js index 73c1cbc3a99..406b1a2e375 100644 --- a/app/assets/javascripts/jobs/store/getters.js +++ b/app/assets/javascripts/jobs/store/getters.js @@ -3,6 +3,9 @@ import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; export const headerTime = state => (state.job.started ? state.job.started : state.job.created_at); +export const hasUnmetPrerequisitesFailure = state => + state.job && state.job.failure_reason && state.job.failure_reason === 'unmet_prerequisites'; + export const shouldRenderCalloutMessage = state => !_.isEmpty(state.job.status) && !_.isEmpty(state.job.callout_message); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index cca4927c115..7d21a216443 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -11,6 +11,7 @@ import CreateLabelDropdown from './create_label'; import flash from './flash'; import ModalStore from './boards/stores/modal_store'; import boardsStore from './boards/stores/boards_store'; +import { isEE } from '~/lib/utils/common_utils'; export default class LabelsSelect { constructor(els, options = {}) { @@ -86,8 +87,9 @@ export default class LabelsSelect { return this.value; }) .get(); + const scopedLabels = $dropdown.data('scopedLabels'); + const scopedLabelsDocumentationLink = $dropdown.data('scopedLabelsDocumentationLink'); const { handleClick } = options; - $sidebarLabelTooltip.tooltip(); if ($dropdown.closest('.dropdown').find('.dropdown-new-label').length) { @@ -132,8 +134,49 @@ export default class LabelsSelect { template = LabelsSelect.getLabelTemplate({ labels: data.labels, issueUpdateURL, + enableScopedLabels: scopedLabels, + scopedLabelsDocumentationLink, }); labelCount = data.labels.length; + + // EE Specific + if (isEE) { + /** + * For Scoped labels, the last label selected with the + * same key will be applied to the current issueable. + * + * If these are the labels - priority::1, priority::2; and if + * we apply them in the same order, only priority::2 will stick + * with the issuable. + * + * In the current dropdown implementation, we keep track of all + * the labels selected via a hidden DOM element. Since a User + * can select priority::1 and priority::2 at the same time, the + * DOM will have 2 hidden input and the dropdown will show both + * the items selected but in reality server only applied + * priority::2. + * + * We find all the labels then find all the labels server accepted + * and then remove the excess ones. + */ + const toRemoveIds = Array.from( + $form.find(`input[type="hidden"][name="${fieldName}"]`), + ) + .map(el => el.value) + .map(Number); + + data.labels.forEach(label => { + const index = toRemoveIds.indexOf(label.id); + toRemoveIds.splice(index, 1); + }); + + toRemoveIds.forEach(id => { + $form + .find(`input[type="hidden"][name="${fieldName}"][value="${id}"]`) + .last() + .remove(); + }); + } } else { template = '<span class="no-value">None</span>'; } @@ -358,6 +401,7 @@ export default class LabelsSelect { } else { if (!$dropdown.hasClass('js-filter-bulk-update')) { saveLabelData(); + $dropdown.data('glDropdown').clearMenu(); } } } @@ -471,19 +515,62 @@ export default class LabelsSelect { // so best approach is to use traditional way of // concatenation // see: http://2ality.com/2016/05/template-literal-whitespace.html#joining-arrays - const tpl = _.template( + + const labelTemplate = _.template( [ - '<% _.each(labels, function(label){ %>', '<a href="<%- issueUpdateURL.slice(0, issueUpdateURL.lastIndexOf("/")) %>?label_name[]=<%- encodeURIComponent(label.title) %>">', - '<span class="badge label has-tooltip color-label" title="<%- label.description %>" style="background-color: <%- label.color %>; color: <%- label.text_color %>;">', + '<span class="badge label has-tooltip color-label" <%= linkAttrs %> title="<%= tooltipTitleTemplate({ label, isScopedLabel, enableScopedLabels, escapeStr }) %>" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;">', '<%- label.title %>', '</span>', '</a>', + ].join(''), + ); + + const infoIconTemplate = _.template( + [ + '<a href="<%= scopedLabelsDocumentationLink %>" class="label scoped-label" target="_blank" rel="noopener">', + '<i class="fa fa-question-circle" style="background-color: <%= escapeStr(label.color) %>; color: <%= escapeStr(label.text_color) %>;"></i>', + '</a>', + ].join(''), + ); + + const tooltipTitleTemplate = _.template( + [ + '<% if (isScopedLabel(label) && enableScopedLabels) { %>', + "<span class='font-weight-bold scoped-label-tooltip-title'>Scoped label</span>", + '<br />', + '<%= escapeStr(label.description) %>', + '<% } else { %>', + '<%= escapeStr(label.description) %>', + '<% } %>', + ].join(''), + ); + + const isScopedLabel = label => label.title.indexOf('::') !== -1; + + const tpl = _.template( + [ + '<% _.each(labels, function(label){ %>', + '<% if (isScopedLabel(label) && enableScopedLabels) { %>', + '<span class="d-inline-block position-relative scoped-label-wrapper">', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: \'data-html="true"\' }) %>', + '<%= infoIconTemplate({ label, scopedLabelsDocumentationLink, escapeStr }) %>', + '</span>', + '<% } else { %>', + '<%= labelTemplate({ label, issueUpdateURL, isScopedLabel, enableScopedLabels, tooltipTitleTemplate, escapeStr, linkAttrs: "" }) %>', + '<% } %>', '<% }); %>', ].join(''), ); - return tpl(tplData); + return tpl({ + ...tplData, + labelTemplate, + infoIconTemplate, + tooltipTitleTemplate, + isScopedLabel, + escapeStr: _.escape, + }); } bindEvents() { diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue index d453dc1fdb7..b0bbe272d1f 100644 --- a/app/assets/javascripts/monitoring/components/charts/area.vue +++ b/app/assets/javascripts/monitoring/components/charts/area.vue @@ -5,6 +5,7 @@ import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; import { getSvgIconPathContent } from '~/lib/utils/icon_utils'; import Icon from '~/vue_shared/components/icon.vue'; import { chartHeight, graphTypes, lineTypes } from '../../constants'; +import { makeDataSeries } from '~/helpers/monitor_helper'; let debouncedResize; @@ -63,7 +64,7 @@ export default { }, computed: { chartData() { - return this.graphData.queries.map(query => { + return this.graphData.queries.reduce((acc, query) => { const { appearance } = query; const lineType = appearance && appearance.line && appearance.line.type @@ -74,9 +75,8 @@ export default { ? appearance.line.width : undefined; - return { + const series = makeDataSeries(query.result, { name: this.formatLegendLabel(query), - data: this.concatenateResults(query.result), lineStyle: { type: lineType, width: lineWidth, @@ -87,8 +87,10 @@ export default { ? appearance.area.opacity : undefined, }, - }; - }); + }); + + return acc.concat(series); + }, []); }, chartOptions() { return { @@ -175,9 +177,6 @@ export default { this.setSvg('scroll-handle'); }, methods: { - concatenateResults(results) { - return results.reduce((acc, result) => acc.concat(result.values), []); - }, formatLegendLabel(query) { return `${query.label}`; }, diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue index 57d6b181bd7..471323bfc83 100644 --- a/app/assets/javascripts/notes/components/note_form.vue +++ b/app/assets/javascripts/notes/components/note_form.vue @@ -61,6 +61,11 @@ export default { required: false, default: null, }, + diffFile: { + type: Object, + required: false, + default: null, + }, helpPagePath: { type: String, required: false, @@ -102,9 +107,42 @@ export default { } return '#'; }, + diffParams() { + if (this.diffFile) { + return { + filePath: this.diffFile.file_path, + refs: this.diffFile.diff_refs, + }; + } else if (this.note && this.note.position) { + return { + filePath: this.note.position.new_path, + refs: this.note.position, + }; + } else if (this.discussion && this.discussion.diff_file) { + return { + filePath: this.discussion.diff_file.file_path, + refs: this.discussion.diff_file.diff_refs, + }; + } + + return null; + }, markdownPreviewPath() { const notable = this.getNoteableDataByProp('preview_note_path'); - return mergeUrlParams({ preview_suggestions: true }, notable); + + const previewSuggestions = this.line && this.diffParams; + const params = previewSuggestions + ? { + preview_suggestions: previewSuggestions, + line: this.line.new_line, + file_path: this.diffParams.filePath, + base_sha: this.diffParams.refs.base_sha, + start_sha: this.diffParams.refs.start_sha, + head_sha: this.diffParams.refs.head_sha, + } + : {}; + + return mergeUrlParams(params, notable); }, markdownDocsPath() { return this.getNotesDataByProp('markdownDocsPath'); @@ -234,8 +272,8 @@ export default { placeholder="Write a comment or drag your files here…" @keydown.meta.enter="handleKeySubmit()" @keydown.ctrl.enter="handleKeySubmit()" - @keydown.up="editMyLastNote()" - @keydown.esc="cancelHandler(true)" + @keydown.exact.up="editMyLastNote()" + @keydown.exact.esc="cancelHandler(true)" @input="onInput" ></textarea> </markdown-field> diff --git a/app/assets/javascripts/pages/admin/groups/edit/index.js b/app/assets/javascripts/pages/admin/groups/edit/index.js index d3d125a1859..ad7276132b9 100644 --- a/app/assets/javascripts/pages/admin/groups/edit/index.js +++ b/app/assets/javascripts/pages/admin/groups/edit/index.js @@ -1,3 +1,3 @@ -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; -document.addEventListener('DOMContentLoaded', groupAvatar); +document.addEventListener('DOMContentLoaded', initAvatarPicker); diff --git a/app/assets/javascripts/pages/admin/groups/new/index.js b/app/assets/javascripts/pages/admin/groups/new/index.js index 21f1ce222ac..6de740ee9ce 100644 --- a/app/assets/javascripts/pages/admin/groups/new/index.js +++ b/app/assets/javascripts/pages/admin/groups/new/index.js @@ -1,9 +1,9 @@ import BindInOut from '../../../../behaviors/bind_in_out'; import Group from '../../../../group'; -import groupAvatar from '../../../../group_avatar'; +import initAvatarPicker from '~/avatar_picker'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); new Group(); // eslint-disable-line no-new - groupAvatar(); + initAvatarPicker(); }); diff --git a/app/assets/javascripts/pages/groups/edit/index.js b/app/assets/javascripts/pages/groups/edit/index.js index 01ef445c901..d036ff07d89 100644 --- a/app/assets/javascripts/pages/groups/edit/index.js +++ b/app/assets/javascripts/pages/groups/edit/index.js @@ -1,4 +1,4 @@ -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; import TransferDropdown from '~/groups/transfer_dropdown'; import initConfirmDangerModal from '~/confirm_danger_modal'; import initSettingsPanels from '~/settings_panels'; @@ -9,7 +9,7 @@ import groupsSelect from '~/groups_select'; import projectSelect from '~/project_select'; document.addEventListener('DOMContentLoaded', () => { - groupAvatar(); + initAvatarPicker(); new TransferDropdown(); // eslint-disable-line no-new initConfirmDangerModal(); initSettingsPanels(); diff --git a/app/assets/javascripts/pages/groups/labels/edit/index.js b/app/assets/javascripts/pages/groups/labels/edit/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/groups/labels/edit/index.js +++ b/app/assets/javascripts/pages/groups/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/groups/labels/new/index.js b/app/assets/javascripts/pages/groups/labels/new/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/groups/labels/new/index.js +++ b/app/assets/javascripts/pages/groups/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/groups/new/index.js b/app/assets/javascripts/pages/groups/new/index.js index b2f275dc5ea..57b53eb9e5d 100644 --- a/app/assets/javascripts/pages/groups/new/index.js +++ b/app/assets/javascripts/pages/groups/new/index.js @@ -1,9 +1,9 @@ import BindInOut from '~/behaviors/bind_in_out'; import Group from '~/group'; -import groupAvatar from '~/group_avatar'; +import initAvatarPicker from '~/avatar_picker'; document.addEventListener('DOMContentLoaded', () => { BindInOut.initAll(); new Group(); // eslint-disable-line no-new - groupAvatar(); + initAvatarPicker(); }); diff --git a/app/assets/javascripts/pages/projects/edit/index.js b/app/assets/javascripts/pages/projects/edit/index.js index 899d5925956..278c35d3846 100644 --- a/app/assets/javascripts/pages/projects/edit/index.js +++ b/app/assets/javascripts/pages/projects/edit/index.js @@ -3,7 +3,7 @@ import initSettingsPanels from '~/settings_panels'; import setupProjectEdit from '~/project_edit'; import initConfirmDangerModal from '~/confirm_danger_modal'; import mountBadgeSettings from '~/pages/shared/mount_badge_settings'; -import fileUpload from '~/lib/utils/file_upload'; +import initAvatarPicker from '~/avatar_picker'; import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectPermissionsSettings from '../shared/permissions'; @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { setupProjectEdit(); // Initialize expandable settings panels initSettingsPanels(); - fileUpload('.js-choose-project-avatar-button', '.js-project-avatar-input'); + initAvatarPicker(); initProjectPermissionsSettings(); initConfirmDangerModal(); mountBadgeSettings(PROJECT_BADGE); diff --git a/app/assets/javascripts/pages/projects/labels/edit/index.js b/app/assets/javascripts/pages/projects/labels/edit/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/projects/labels/edit/index.js +++ b/app/assets/javascripts/pages/projects/labels/edit/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/pages/projects/labels/new/index.js b/app/assets/javascripts/pages/projects/labels/new/index.js index fa81ad914ba..83d6ac9fd14 100644 --- a/app/assets/javascripts/pages/projects/labels/new/index.js +++ b/app/assets/javascripts/pages/projects/labels/new/index.js @@ -1,3 +1,3 @@ -import Labels from '~/labels'; +import Labels from 'ee_else_ce/labels'; document.addEventListener('DOMContentLoaded', () => new Labels()); diff --git a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue index 706e6ca19c3..57125c78cf6 100644 --- a/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue +++ b/app/assets/javascripts/sidebar/components/todo_toggle/todo.vue @@ -50,6 +50,9 @@ export default { buttonLabel() { return this.isTodo ? MARK_TEXT : TODO_TEXT; }, + buttonTooltip() { + return !this.collapsed ? undefined : this.buttonLabel; + }, collapsedButtonIconClasses() { return this.isTodo ? 'todo-undone' : ''; }, @@ -69,7 +72,7 @@ export default { <button v-tooltip :class="buttonClasses" - :title="buttonLabel" + :title="buttonTooltip" :aria-label="buttonLabel" :data-issuable-id="issuableId" :data-issuable-type="issuableType" diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index 0cbcdbf2eb4..1bfa91500cb 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -39,7 +39,7 @@ export default { }, data() { return { - mouseOver: false, + dropdownOpen: false, }; }, computed: { @@ -123,8 +123,8 @@ export default { return this.$router.currentRoute.path === `/project${this.file.url}`; }, - toggleHover(over) { - this.mouseOver = over; + toggleDropdown(val) { + this.dropdownOpen = val; }, }, }; @@ -140,8 +140,7 @@ export default { class="file-row" role="button" @click="clickFile" - @mouseover="toggleHover(true)" - @mouseout="toggleHover(false)" + @mouseleave="toggleDropdown(false)" > <div class="file-row-name-container"> <span ref="textOutput" :style="levelIndentation" class="file-row-name str-truncated"> @@ -160,7 +159,8 @@ export default { :is="extraComponent" v-if="extraComponent && !(hideExtraOnTree && file.type === 'tree')" :file="file" - :mouse-over="mouseOver" + :dropdown-open="dropdownOpen" + @toggle="toggleDropdown($event)" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js new file mode 100644 index 00000000000..d1aba99ac22 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/lib/utils/diff_utils.js @@ -0,0 +1,20 @@ +/* eslint-disable import/prefer-default-export */ + +function trimFirstCharOfLineContent(text) { + if (!text) { + return text; + } + + return text.replace(/^( |\+|-)/, ''); +} + +function cleanSuggestionLine(line = {}) { + return { + ...line, + text: trimFirstCharOfLineContent(line.text), + }; +} + +export function selectDiffLines(lines) { + return lines.filter(line => line.type !== 'match').map(line => cleanSuggestionLine(line)); +} diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index eccf73e227c..0f3b3568414 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -76,6 +76,7 @@ export default { hasSuggestion: false, markdownPreviewLoading: false, previewMarkdown: false, + suggestions: this.note.suggestions || [], }; }, computed: { @@ -109,9 +110,6 @@ export default { } return lineNumber; }, - suggestions() { - return this.note.suggestions || []; - }, lineType() { return this.line ? this.line.type : ''; }, @@ -175,6 +173,7 @@ export default { this.referencedCommands = data.references.commands; this.referencedUsers = data.references.users; this.hasSuggestion = data.references.suggestions && data.references.suggestions.length; + this.suggestions = data.references.suggestions; } this.$nextTick() diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index cc6ecdb0395..a5a5b2ef415 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -38,7 +38,7 @@ export default { ].join('\n'); }, mdSuggestion() { - return ['```suggestion', `{text}`, '```'].join('\n'); + return ['```suggestion:-0+0', `{text}`, '```'].join('\n'); }, }, mounted() { diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue index a351ca62c94..2eb4ec12a4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff.vue @@ -1,24 +1,14 @@ <script> import SuggestionDiffHeader from './suggestion_diff_header.vue'; +import SuggestionDiffRow from './suggestion_diff_row.vue'; +import { selectDiffLines } from '../lib/utils/diff_utils'; export default { components: { SuggestionDiffHeader, + SuggestionDiffRow, }, props: { - newLines: { - type: Array, - required: true, - }, - fromContent: { - type: String, - required: false, - default: '', - }, - fromLine: { - type: Number, - required: true, - }, suggestion: { type: Object, required: true, @@ -33,6 +23,11 @@ export default { required: true, }, }, + computed: { + lines() { + return selectDiffLines(this.suggestion.diff_lines); + }, + }, methods: { applySuggestion(callback) { this.$emit('apply', { suggestionId: this.suggestion.id, callback }); @@ -52,22 +47,11 @@ export default { /> <table class="mb-3 md-suggestion-diff js-syntax-highlight code"> <tbody> - <!-- Old Line --> - <tr class="line_holder old"> - <td class="diff-line-num old_line qa-old-diff-line-number old">{{ fromLine }}</td> - <td class="diff-line-num new_line old"></td> - <td class="line_content old"> - <span>{{ fromContent }}</span> - </td> - </tr> - <!-- New Line(s) --> - <tr v-for="(line, key) of newLines" :key="key" class="line_holder new"> - <td class="diff-line-num old_line new"></td> - <td class="diff-line-num new_line qa-new-diff-line-number new">{{ line.lineNumber }}</td> - <td class="line_content new"> - <span>{{ line.content }}</span> - </td> - </tr> + <suggestion-diff-row + v-for="(line, index) of lines" + :key="`${index}-${line.text}`" + :line="line" + /> </tbody> </table> </div> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue new file mode 100644 index 00000000000..cafd3a515ea --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_row.vue @@ -0,0 +1,32 @@ +<script> +export default { + name: 'SuggestionDiffRow', + props: { + line: { + type: Object, + required: true, + }, + }, + computed: { + lineType() { + return this.line.type; + }, + }, +}; +</script> + +<template> + <tr class="line_holder" :class="lineType"> + <td class="diff-line-num old_line" :class="lineType"> + {{ line.old_line }} + </td> + <td class="diff-line-num new_line" :class="lineType"> + {{ line.new_line }} + </td> + <td class="line_content" :class="lineType"> + <span v-if="line.text">{{ line.text }}</span> + <!-- TODO: replace this hack with zero-width whitespace when we have rich_text from BE --> + <span v-else>​</span> + </td> + </tr> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue index 177d78cb904..8d3705e1e4a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue @@ -6,16 +6,6 @@ import Flash from '~/flash'; export default { components: { SuggestionDiff }, props: { - fromLine: { - type: Number, - required: false, - default: 0, - }, - fromContent: { - type: String, - required: false, - default: '', - }, lineType: { type: String, required: false, @@ -71,41 +61,19 @@ export default { suggestionElements.forEach((suggestionEl, i) => { const suggestionParentEl = suggestionEl.parentElement; - const newLines = this.extractNewLines(suggestionParentEl); - const diffComponent = this.generateDiff(newLines, i); + const diffComponent = this.generateDiff(i); diffComponent.$mount(suggestionParentEl); }); this.isRendered = true; }, - extractNewLines(suggestionEl) { - // extracts the suggested lines from the markdown - // calculates a line number for each line - - const newLines = suggestionEl.querySelectorAll('.line'); - const fromLine = this.suggestions.length ? this.suggestions[0].from_line : this.fromLine; - const lines = []; - - newLines.forEach((line, i) => { - const content = `${line.innerText}\n`; - const lineNumber = fromLine + i; - lines.push({ content, lineNumber }); - }); - - return lines; - }, - generateDiff(newLines, suggestionIndex) { - // generates the diff <suggestion-diff /> component - // all `suggestion` markdown will be swapped out by this component - + generateDiff(suggestionIndex) { const { suggestions, disabled, helpPagePath } = this; const suggestion = suggestions && suggestions[suggestionIndex] ? suggestions[suggestionIndex] : {}; - const fromContent = suggestion.from_content || this.fromContent; - const fromLine = suggestion.from_line || this.fromLine; const SuggestionDiffComponent = Vue.extend(SuggestionDiff); const suggestionDiff = new SuggestionDiffComponent({ - propsData: { newLines, fromLine, fromContent, disabled, suggestion, helpPagePath }, + propsData: { disabled, suggestion, helpPagePath }, }); suggestionDiff.$on('apply', ({ suggestionId, callback }) => { diff --git a/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue new file mode 100644 index 00000000000..1f3d248e991 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/resizable_chart/resizable_chart_container.vue @@ -0,0 +1,40 @@ +<script> +import { debounceByAnimationFrame } from '~/lib/utils/common_utils'; +import $ from 'jquery'; + +export default { + data() { + return { + width: 0, + height: 0, + }; + }, + beforeDestroy() { + this.contentResizeHandler.off('content.resize', this.debouncedResize); + window.removeEventListener('resize', this.debouncedResize); + }, + created() { + this.debouncedResize = debounceByAnimationFrame(this.onResize); + + // Handle when we explicictly trigger a custom resize event + this.contentResizeHandler = $(document).on('content.resize', this.debouncedResize); + + // Handle window resize + window.addEventListener('resize', this.debouncedResize); + }, + methods: { + onResize() { + // Slot dimensions + const { clientWidth, clientHeight } = this.$refs.chartWrapper; + this.width = clientWidth; + this.height = clientHeight; + }, + }, +}; +</script> + +<template> + <div ref="chartWrapper"> + <slot :width="width" :height="height"> </slot> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index f66e81b1e08..9c258c4651f 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -75,6 +75,16 @@ export default { required: false, default: false, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { hiddenInputName() { @@ -123,7 +133,12 @@ export default { @onValueClick="handleCollapsedValueClick" /> <dropdown-title :can-edit="canEdit" /> - <dropdown-value :labels="context.labels" :label-filter-base-path="labelFilterBasePath"> + <dropdown-value + :labels="context.labels" + :label-filter-base-path="labelFilterBasePath" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" + > <slot></slot> </dropdown-value> <div v-if="canEdit" class="selectbox js-selectbox" style="display: none;"> @@ -142,6 +157,8 @@ export default { :namespace="namespace" :labels="context.labels" :show-extra-options="!showCreate" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + :enable-scoped-labels="enableScopedLabels" /> <div class="dropdown-menu dropdown-select dropdown-menu-paging diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue index 498b507d11d..1eed8907bb7 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -31,6 +31,16 @@ export default { type: Boolean, required: true, }, + enableScopedLabels: { + type: Boolean, + require: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + require: false, + default: '#', + }, }, computed: { dropdownToggleText() { @@ -61,6 +71,8 @@ export default { :data-labels="labelsPath" :data-namespace-path="namespace" :data-show-any="showExtraOptions" + :data-scoped-labels="enableScopedLabels" + :data-scoped-labels-documentation-link="scopedLabelsDocumentationLink" type="button" class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal" data-toggle="dropdown" diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue index 6faf3fafad1..ddc488adbcb 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -1,9 +1,11 @@ <script> -import tooltip from '~/vue_shared/directives/tooltip'; +import DropdownValueScopedLabel from './dropdown_value_scoped_label.vue'; +import DropdownValueRegularLabel from './dropdown_value_regular_label.vue'; export default { - directives: { - tooltip, + components: { + DropdownValueScopedLabel, + DropdownValueRegularLabel, }, props: { labels: { @@ -14,6 +16,16 @@ export default { type: String, required: true, }, + enableScopedLabels: { + type: Boolean, + required: false, + default: false, + }, + scopedLabelsDocumentationLink: { + type: String, + required: false, + default: '#', + }, }, computed: { isEmpty() { @@ -30,6 +42,12 @@ export default { backgroundColor: label.color, }; }, + scopedLabelsDescription({ description = '' }) { + return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`; + }, + showScopedLabels({ title = '' }) { + return this.enableScopedLabels && title.indexOf('::') !== -1; + }, }, }; </script> @@ -44,17 +62,24 @@ export default { <span v-if="isEmpty" class="text-secondary"> <slot>{{ __('None') }}</slot> </span> - <a v-for="label in labels" v-else :key="label.id" :href="labelFilterUrl(label)"> - <span - v-tooltip - :style="labelStyle(label)" - :title="label.description" - class="badge color-label" - data-placement="bottom" - data-container="body" - > - {{ label.title }} - </span> - </a> + + <template v-for="label in labels" v-else> + <dropdown-value-scoped-label + v-if="showScopedLabels(label)" + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + :scoped-labels-documentation-link="scopedLabelsDocumentationLink" + /> + + <dropdown-value-regular-label + v-else + :key="label.id" + :label="label" + :label-filter-url="labelFilterUrl(label)" + :label-style="labelStyle(label)" + /> + </template> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue new file mode 100644 index 00000000000..282b181f11e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_regular_label.vue @@ -0,0 +1,35 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <a ref="regularLabelRef" :href="labelFilterUrl"> + <span :style="labelStyle" class="badge color-label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.regularLabelRef" placement="top" boundary="viewport"> + {{ label.description }} + </gl-tooltip> + </a> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue new file mode 100644 index 00000000000..ad5a86de166 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_scoped_label.vue @@ -0,0 +1,47 @@ +<script> +import { GlLink, GlTooltip } from '@gitlab/ui'; + +export default { + components: { + GlTooltip, + GlLink, + }, + props: { + label: { + type: Object, + required: true, + }, + labelStyle: { + type: Object, + required: true, + }, + scopedLabelsDocumentationLink: { + type: String, + required: true, + }, + labelFilterUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <span class="d-inline-block position-relative scoped-label-wrapper"> + <a :href="labelFilterUrl"> + <span :ref="`labelTitleRef`" :style="labelStyle" class="badge color-label label"> + {{ label.title }} + </span> + <gl-tooltip :target="() => $refs.labelTitleRef" placement="top" boundary="viewport"> + <span class="font-weight-bold scoped-label-tooltip-title">{{ __('Scoped label') }}</span + ><br /> + {{ label.description }} + </gl-tooltip> + </a> + + <gl-link :href="scopedLabelsDocumentationLink" target="_blank" class="label scoped-label" + ><i class="fa fa-question-circle" :style="labelStyle"></i + ></gl-link> + </span> +</template> diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index 0d8e4afa76f..643b20c56bc 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -28,6 +28,10 @@ background-color: $red-100; border-color: $red-200; color: $red-700; + + a { + color: $red-700; + } } .bs-callout-warning { diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index b90db135b4a..efcd35a2e0e 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -287,7 +287,7 @@ list-style: none; padding: 0 1px; - a, + a:not(.btn), button, .menu-item { @include dropdown-link; @@ -351,6 +351,10 @@ // Expects up to 3 digits on the badge margin-right: 40px; } + + .dropdown-menu-content { + padding: $dropdown-item-padding-y $dropdown-item-padding-x; + } } .droplab-dropdown { diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index c36c15a85be..1c23c14c2de 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -204,8 +204,10 @@ label { margin-top: #{$grid-size / 2}; } -.gl-field-error { +.gl-field-error, +.invalid-feedback { color: $red-500; + font-size: $gl-font-size; } .gl-show-field-errors { diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 02364180c35..ebc5226bf87 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -154,11 +154,34 @@ .swipe-wrap { overflow: hidden; - border-left: 1px solid $gl-gray-400; + border-right: 1px solid $gl-gray-400; position: absolute; display: block; top: 13px; right: 7px; + + &.left-oriented { + /* only for commit view (different swipe viewer) */ + border-right: 0; + border-left: 1px solid $gl-gray-400; + } + } + + .frame { + top: 0; + right: 0; + + &.old-diff { + /* only for commit / compare view */ + position: absolute; + } + + &.deleted { + margin: 0; + display: block; + top: 13px; + right: 7px; + } } .swipe-bar { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 6415d902ca6..9be3f8138a0 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -110,6 +110,16 @@ font-size: 0; margin-bottom: -5px; } + + .scoped-label-wrapper { + .color-label { + padding-right: $gl-padding-24; + } + + .scoped-label { + right: 12px; + } + } } .right-sidebar { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 6f98b4f7f13..e7fd7fab32b 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -402,3 +402,39 @@ .priority-labels-empty-state .svg-content img { max-width: $priority-label-empty-state-width; } + +.scoped-label-tooltip-title { + color: $indigo-300; +} + +.scoped-label-wrapper { + &.label-link .color-label a { + color: inherit; + } + + .color-label { + padding-right: $gl-padding-24; + } + + .scoped-label { + position: absolute; + top: 4px; + right: 8px; + padding: 0; + margin: 0; + line-height: $gl-line-height; + } +} + +// Label inside title of Delete Label Modal +.modal-header .page-title { + .scoped-label-wrapper { + .scoped-label { + line-height: 20px; + } + + span.color-label { + padding-right: $gl-padding-24; + } + } +} diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 7f8b8ea8100..f7ebd84dc1c 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -595,7 +595,6 @@ color: $gl-text-color; } - .git-merge-container { justify-content: space-between; flex: 1; @@ -805,7 +804,8 @@ } } -.merge-request-tabs-holder { +.merge-request-tabs-holder, +.epic-tabs-holder { top: $header-height; z-index: 250; background-color: $white-light; @@ -823,11 +823,6 @@ @include media-breakpoint-down(xs) { right: 0; } - - .merge-request-tabs-container { - padding-left: $gl-padding; - padding-right: $gl-padding; - } } .nav-links { @@ -835,11 +830,21 @@ } } -.with-performance-bar .merge-request-tabs-holder { - top: $header-height + $performance-bar-height; +.merge-request-tabs-holder.affix .merge-request-tabs-container, +.epic-tabs-holder.affix .epic-tabs-container { + padding-left: $gl-padding; + padding-right: $gl-padding; } -.merge-request-tabs { +.with-performance-bar { + .merge-request-tabs-holder, + .epic-tabs-holder { + top: $header-height + $performance-bar-height; + } +} + +.merge-request-tabs, +.epic-tabs { display: flex; flex-wrap: nowrap; margin-bottom: 0; @@ -847,7 +852,8 @@ } .limit-container-width { - .merge-request-tabs-container { + .merge-request-tabs-container, + .epic-tabs-container { max-width: $limited-layout-width; margin-left: auto; margin-right: auto; @@ -860,7 +866,8 @@ } } -.merge-request-tabs-container { +.merge-request-tabs-container, +.epic-tabs-container { display: flex; justify-content: space-between; @@ -878,10 +885,9 @@ } .limit-container-width:not(.container-limited) { - .merge-request-tabs-holder:not(.affix) { - .merge-request-tabs-container { - max-width: $limited-layout-width - ($gl-padding * 2); - } + .merge-request-tabs-holder:not(.affix) .merge-request-tabs-container, + .epic-tabs-holder:not(.affix) .epic-tabs-container { + max-width: $limited-layout-width - ($gl-padding * 2); } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 0c334e919de..fd07415a52f 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -44,7 +44,6 @@ $note-form-margin-left: 72px; border: 1px solid $border-color; border-radius: $border-radius-default; margin: $gl-padding 0; - overflow: auto; &.system-note, &.note-form { diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 792c618fd40..79558b8604f 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -67,6 +67,10 @@ } } +.classification-label { + background-color: $red-500; +} + .toggle-wrapper { margin-top: 5px; } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index ab792cf7403..b681949ab36 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -124,7 +124,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController end def visible_application_setting_attributes - ApplicationSettingsHelper.visible_attributes + [ + [ + *::ApplicationSettingsHelper.visible_attributes, + *::ApplicationSettingsHelper.external_authorization_service_attributes, :domain_blacklist_file, disabled_oauth_sign_in_sources: [], import_sources: [], diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 8ef3b6502df..85aeecbf90b 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -7,6 +7,9 @@ module IssuableActions included do before_action :authorize_destroy_issuable!, only: :destroy before_action :authorize_admin_issuable!, only: :bulk_update + before_action only: :show do + push_frontend_feature_flag(:scoped_labels, default_enabled: true) + end end def permitted_keys diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb index c529aabf797..6d6e0cc6c7f 100644 --- a/app/controllers/concerns/issuable_collections.rb +++ b/app/controllers/concerns/issuable_collections.rb @@ -100,6 +100,7 @@ module IssuableCollections if @project options[:project_id] = @project.id + options[:attempt_project_search_optimizations] = true elsif @group options[:group_id] = @group.id options[:include_subgroups] = true diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb index f72d25fc54c..2a9729b6ffd 100644 --- a/app/controllers/concerns/preview_markdown.rb +++ b/app/controllers/concerns/preview_markdown.rb @@ -20,7 +20,7 @@ module PreviewMarkdown body: view_context.markdown(result[:text], markdown_params), references: { users: result[:users], - suggestions: result[:suggestions], + suggestions: SuggestionSerializer.new.represent_diff(result[:suggestions]), commands: view_context.markdown(result[:commands]) } } diff --git a/app/controllers/concerns/project_unauthorized.rb b/app/controllers/concerns/project_unauthorized.rb index f59440dbc59..d42363b8b17 100644 --- a/app/controllers/concerns/project_unauthorized.rb +++ b/app/controllers/concerns/project_unauthorized.rb @@ -1,10 +1,21 @@ # frozen_string_literal: true module ProjectUnauthorized - extend ActiveSupport::Concern - - # EE would override this def project_unauthorized_proc - # no-op + lambda do |project| + if project + label = project.external_authorization_classification_label + rejection_reason = nil + + unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, label) + rejection_reason = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label) + rejection_reason ||= _('External authorization denied access to this project') + end + + if rejection_reason + access_denied!(rejection_reason) + end + end + end end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 75329b05a6f..1a97b39d3ae 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -46,7 +46,10 @@ class DashboardController < Dashboard::ApplicationController end def check_filters_presence! - @no_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) } + no_scalar_filters_set = finder_type.scalar_params.none? { |k| params.key?(k) } + no_array_filters_set = finder_type.array_params.none? { |k, _| params.key?(k) } + + @no_filters_set = no_scalar_filters_set && no_array_filters_set return unless @no_filters_set diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 0192b1c253e..87b8ef03313 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -124,8 +124,8 @@ class GroupsController < Groups::ApplicationController flash[:notice] = "Group '#{@group.name}' was successfully transferred." redirect_to group_path(@group) else - flash.now[:alert] = service.error - render :edit + flash[:alert] = service.error + redirect_to edit_group_path(@group) end end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 6504fd6c08a..781eac7f080 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -88,4 +88,10 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end + + def allow_gitaly_ref_name_caching + ::Gitlab::GitalyClient.allow_ref_name_caching do + yield + end + end end diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb index 2510a31c9b3..a49ede04de7 100644 --- a/app/controllers/projects/commits_controller.rb +++ b/app/controllers/projects/commits_controller.rb @@ -14,6 +14,8 @@ class Projects::CommitsController < Projects::ApplicationController before_action :validate_ref!, except: :commits_root before_action :set_commits, except: :commits_root + around_action :allow_gitaly_ref_name_caching + def commits_root redirect_to project_commits_path(@project, @project.default_branch) end diff --git a/app/controllers/projects/environments/prometheus_api_controller.rb b/app/controllers/projects/environments/prometheus_api_controller.rb new file mode 100644 index 00000000000..fd3320637b0 --- /dev/null +++ b/app/controllers/projects/environments/prometheus_api_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Projects::Environments::PrometheusApiController < Projects::ApplicationController + before_action :authorize_read_prometheus! + before_action :environment + + def proxy + result = Prometheus::ProxyService.new( + environment, + request.method, + params[:proxy_path], + params.permit! + ).execute + + if result.nil? + return render status: :accepted, json: { + status: _('processing'), + message: _('Not ready yet. Try again later.') + } + end + + if result[:status] == :success + render status: result[:http_status], json: result[:body] + else + render( + status: result[:http_status] || :bad_request, + json: { status: result[:status], message: result[:message] } + ) + end + end + + private + + def environment + @environment ||= project.environments.find(params[:id]) + end +end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index e9cd475a199..301449cfa90 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -10,6 +10,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] before_action :verify_api_request!, only: :terminal_websocket_authorize before_action :expire_etag_cache, only: [:index] + before_action only: [:metrics, :additional_metrics] do + push_frontend_feature_flag(:metrics_time_window) + end def index @environments = project.environments @@ -114,7 +117,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController terminal = environment.terminals.try(:first) if terminal set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.terminal_websocket(terminal) + render json: Gitlab::Workhorse.channel_websocket(terminal) else render html: 'Not found', status: :not_found end @@ -146,7 +149,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController def additional_metrics respond_to do |format| format.json do - additional_metrics = environment.additional_metrics || {} + additional_metrics = environment.additional_metrics(*metrics_params) || {} render json: additional_metrics, status: additional_metrics.any? ? :ok : :no_content end @@ -186,6 +189,13 @@ class Projects::EnvironmentsController < Projects::ApplicationController @environment ||= project.environments.find(params[:id]) end + def metrics_params + return unless Feature.enabled?(:metrics_time_window, project) + return unless params[:start].present? || params[:end].present? + + params.require([:start, :end]).values_at(:start, :end) + end + def search_environment_names return [] unless params[:query] diff --git a/app/controllers/projects/git_http_client_controller.rb b/app/controllers/projects/git_http_client_controller.rb index 55d5fce9214..85628dd32d8 100644 --- a/app/controllers/projects/git_http_client_controller.rb +++ b/app/controllers/projects/git_http_client_controller.rb @@ -98,10 +98,8 @@ class Projects::GitHttpClientController < Projects::ApplicationController def repo_type parse_repo_path unless defined?(@repo_type) - # When there a project did not exist, the parsed repo_type would be empty. - # In that case, we want to continue with a regular project repository. As we - # could create the project if the user pushing is allowed to do so. - @repo_type || Gitlab::GlRepository::PROJECT + + @repo_type end def handle_basic_authentication(login, password) diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 35cc32d3e63..2a4933e7bc2 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -157,7 +157,7 @@ class Projects::JobsController < Projects::ApplicationController # GET .../terminal.ws : implemented in gitlab-workhorse def terminal_websocket_authorize set_workhorse_internal_api_content_type - render json: Gitlab::Workhorse.terminal_websocket(@build.terminal_specification) + render json: Gitlab::Workhorse.channel_websocket(@build.terminal_specification) end private diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 34cb0416965..39ba2a651d4 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -16,6 +16,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authenticate_user!, only: [:assign_related_issues] before_action :check_user_can_push_to_source_branch!, only: [:rebase] + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + def index @merge_requests = @issuables @@ -315,9 +317,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def serializer - ::Gitlab::GitalyClient.allow_ref_name_caching do - MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) - end + MergeRequestSerializer.new(current_user: current_user, project: merge_request.project) end def define_edit_vars diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index c306ba3ffcf..22c4b8eef1f 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -8,6 +8,8 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_create_pipeline!, only: [:new, :create] before_action :authorize_update_pipeline!, only: [:retry, :cancel] + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + wrap_parameters Ci::Pipeline POLLING_INTERVAL = 10_000 @@ -148,12 +150,10 @@ class Projects::PipelinesController < Projects::ApplicationController private def serialize_pipelines - ::Gitlab::GitalyClient.allow_ref_name_caching do - PipelineSerializer - .new(project: @project, current_user: @current_user) - .with_pagination(request, response) - .represent(@pipelines, disable_coverage: true, preload: true) - end + PipelineSerializer + .new(project: @project, current_user: @current_user) + .with_pagination(request, response) + .represent(@pipelines, disable_coverage: true, preload: true) end def render_show diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 4eeaeb860ee..3b4215b766e 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -23,7 +23,7 @@ class Projects::RepositoriesController < Projects::ApplicationController append_sha = false if @filename == shortname end - send_git_archive @repository, ref: @ref, format: params[:format], append_sha: append_sha + send_git_archive @repository, ref: @ref, path: params[:path], format: params[:format], append_sha: append_sha rescue => ex logger.error("#{self.class.name}: #{ex}") git_not_found! diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index da2420633ef..88910c91763 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -16,7 +16,10 @@ class Projects::WikisController < Projects::ApplicationController end def pages - @wiki_pages = Kaminari.paginate_array(@project_wiki.pages).page(params[:page]) + @wiki_pages = Kaminari.paginate_array( + @project_wiki.pages(sort: params[:sort], direction: params[:direction]) + ).page(params[:page]) + @wiki_entries = WikiPage.group_by_directory(@wiki_pages) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index f76e6663995..632be29df72 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -26,6 +26,8 @@ class ProjectsController < Projects::ApplicationController before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] before_action :event_filter, only: [:show, :activity] + around_action :allow_gitaly_ref_name_caching, only: [:index, :show] + layout :determine_layout def index @@ -343,6 +345,7 @@ class ProjectsController < Projects::ApplicationController :container_registry_enabled, :default_branch, :description, + :external_authorization_classification_label, :import_url, :issues_tracker, :issues_tracker_id, diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 6eab8c5ee51..64c88505a16 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -53,7 +53,6 @@ class IssuableFinder assignee_username author_id author_username - label_name milestone_title my_reaction_emoji search @@ -84,7 +83,7 @@ class IssuableFinder # https://www.postgresql.org/docs/current/static/queries-with.html items = by_search(items) - items = sort(items) unless use_cte_for_count? + items = sort(items) items end @@ -92,7 +91,6 @@ class IssuableFinder def filter_items(items) items = by_project(items) items = by_group(items) - items = by_subquery(items) items = by_scope(items) items = by_created_at(items) items = by_updated_at(items) @@ -132,10 +130,12 @@ class IssuableFinder # This does not apply when we are using a CTE for the search, as the labels # GROUP BY is inside the subquery in that case, so we set labels_count to 1. # - # We always use CTE when searching in Groups if the feature flag is enabled, - # but never when searching in Projects. + # Groups and projects have separate feature flags to suggest the use + # of a CTE. The CTE will not be used if the sort doesn't support it, + # but will always be used for the counts here as we ignore sorting + # anyway. labels_count = label_names.any? ? label_names.count : 1 - labels_count = 1 if use_cte_for_count? + labels_count = 1 if use_cte_for_search? finder.execute.reorder(nil).group(:state).count.each do |key, value| counts[count_key(key)] += value / labels_count @@ -309,15 +309,14 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - def use_subquery_for_search? - strong_memoize(:use_subquery_for_search) do - !force_cte? && attempt_group_search_optimizations? - end - end + def use_cte_for_search? + strong_memoize(:use_cte_for_search) do + next false unless search + next false unless Gitlab::Database.postgresql? + # Only simple unsorted & simple sorts can use CTE + next false if params[:sort].present? && !params[:sort].in?(klass.simple_sorts.keys) - def use_cte_for_count? - strong_memoize(:use_cte_for_count) do - force_cte? && attempt_group_search_optimizations? + attempt_group_search_optimizations? || attempt_project_search_optimizations? end end @@ -332,12 +331,15 @@ class IssuableFinder end def attempt_group_search_optimizations? - search && - Gitlab::Database.postgresql? && - params[:attempt_group_search_optimizations] && + params[:attempt_group_search_optimizations] && Feature.enabled?(:attempt_group_search_optimizations, default_enabled: true) end + def attempt_project_search_optimizations? + params[:attempt_project_search_optimizations] && + Feature.enabled?(:attempt_project_search_optimizations) + end + def count_key(value) Array(value).last.to_sym end @@ -408,20 +410,11 @@ class IssuableFinder end # rubocop: enable CodeReuse/ActiveRecord - # Wrap projects and groups in a subquery if the conditions are met. - def by_subquery(items) - if use_subquery_for_search? - klass.where(id: items.select(:id)) # rubocop: disable CodeReuse/ActiveRecord - else - items - end - end - # rubocop: disable CodeReuse/ActiveRecord def by_search(items) return items unless search - if use_cte_for_count? + if use_cte_for_search? cte = Gitlab::SQL::RecursiveCTE.new(klass.table_name) cte << items diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 84689ff5dc7..29947bc94d5 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -40,7 +40,8 @@ class MergeRequestsFinder < IssuableFinder items = by_commit(super) items = by_source_branch(items) items = by_wip(items) - by_target_branch(items) + items = by_target_branch(items) + by_source_project_id(items) end private @@ -74,6 +75,16 @@ class MergeRequestsFinder < IssuableFinder items.where(target_branch: target_branch) end + def source_project_id + @source_project_id ||= params[:source_project_id].presence + end + + def by_source_project_id(items) + return items unless source_project_id + + items.where(source_project_id: source_project_id) + end + def by_wip(items) if params[:wip] == 'yes' items.where(wip_match(items.arel_table)) diff --git a/app/graphql/gitlab_schema.rb b/app/graphql/gitlab_schema.rb index 06d26309b5b..53efd9042b1 100644 --- a/app/graphql/gitlab_schema.rb +++ b/app/graphql/gitlab_schema.rb @@ -1,13 +1,44 @@ # frozen_string_literal: true class GitlabSchema < GraphQL::Schema + # Took our current most complicated query in use, issues.graphql, + # with a complexity of 19, and added a 20 point buffer to it. + # These values will evolve over time. + DEFAULT_MAX_COMPLEXITY = 40 + AUTHENTICATED_COMPLEXITY = 50 + ADMIN_COMPLEXITY = 60 + use BatchLoader::GraphQL use Gitlab::Graphql::Authorize use Gitlab::Graphql::Present use Gitlab::Graphql::Connections + use Gitlab::Graphql::Tracing + + query_analyzer Gitlab::Graphql::QueryAnalyzers::LogQueryComplexity.analyzer query(Types::QueryType) default_max_page_size 100 + + max_complexity DEFAULT_MAX_COMPLEXITY + mutation(Types::MutationType) + + def self.execute(query_str = nil, **kwargs) + kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context]) + + super(query_str, **kwargs) + end + + def self.max_query_complexity(ctx) + current_user = ctx&.fetch(:current_user, nil) + + if current_user&.admin + ADMIN_COMPLEXITY + elsif current_user + AUTHENTICATED_COMPLEXITY + else + DEFAULT_MAX_COMPLEXITY + end + end end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb index 2b2ea64c00b..8c8b8a82d3e 100644 --- a/app/graphql/types/base_field.rb +++ b/app/graphql/types/base_field.rb @@ -3,5 +3,14 @@ module Types class BaseField < GraphQL::Schema::Field prepend Gitlab::Graphql::Authorize + + DEFAULT_COMPLEXITY = 1 + + def initialize(*args, **kwargs, &block) + # complexity is already defaulted to 1, but let's make it explicit + kwargs[:complexity] ||= DEFAULT_COMPLEXITY + + super(*args, **kwargs, &block) + end end end diff --git a/app/graphql/types/ci/pipeline_type.rb b/app/graphql/types/ci/pipeline_type.rb index 18696293b97..de7d6570a3e 100644 --- a/app/graphql/types/ci/pipeline_type.rb +++ b/app/graphql/types/ci/pipeline_type.rb @@ -3,10 +3,12 @@ module Types module Ci class PipelineType < BaseObject - expose_permissions Types::PermissionTypes::Ci::Pipeline - graphql_name 'Pipeline' + authorize :read_pipeline + + expose_permissions Types::PermissionTypes::Ci::Pipeline + field :id, GraphQL::ID_TYPE, null: false field :iid, GraphQL::ID_TYPE, null: false diff --git a/app/graphql/types/issue_type.rb b/app/graphql/types/issue_type.rb index 5ad3ea52930..adb137dfee3 100644 --- a/app/graphql/types/issue_type.rb +++ b/app/graphql/types/issue_type.rb @@ -2,10 +2,12 @@ module Types class IssueType < BaseObject - expose_permissions Types::PermissionTypes::Issue - graphql_name 'Issue' + authorize :read_issue + + expose_permissions Types::PermissionTypes::Issue + present_using IssuePresenter field :iid, GraphQL::ID_TYPE, null: false @@ -15,16 +17,14 @@ module Types field :author, Types::UserType, null: false, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }, - authorize: :read_user + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } field :assignees, Types::UserType.connection_type, null: true field :labels, Types::LabelType.connection_type, null: true field :milestone, Types::MilestoneType, null: true, - resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find }, - authorize: :read_milestone + resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Milestone, obj.milestone_id).find } field :due_date, Types::TimeType, null: true field :confidential, GraphQL::BOOLEAN_TYPE, null: false diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index 1ed27a14e33..120ffe0dfde 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -2,12 +2,14 @@ module Types class MergeRequestType < BaseObject + graphql_name 'MergeRequest' + + authorize :read_merge_request + expose_permissions Types::PermissionTypes::MergeRequest present_using MergeRequestPresenter - graphql_name 'MergeRequest' - field :id, GraphQL::ID_TYPE, null: false field :iid, GraphQL::ID_TYPE, null: false field :title, GraphQL::STRING_TYPE, null: false @@ -48,7 +50,7 @@ module Types field :downvotes, GraphQL::INT_TYPE, null: false field :subscribed, GraphQL::BOOLEAN_TYPE, method: :subscribed?, null: false - field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline, authorize: :read_pipeline + field :head_pipeline, Types::Ci::PipelineType, null: true, method: :actual_head_pipeline field :pipelines, Types::Ci::PipelineType.connection_type, resolver: Resolvers::MergeRequestPipelinesResolver end diff --git a/app/graphql/types/milestone_type.rb b/app/graphql/types/milestone_type.rb index af31b572c9a..2772fbec86f 100644 --- a/app/graphql/types/milestone_type.rb +++ b/app/graphql/types/milestone_type.rb @@ -4,6 +4,8 @@ module Types class MilestoneType < BaseObject graphql_name 'Milestone' + authorize :read_milestone + field :description, GraphQL::STRING_TYPE, null: true field :title, GraphQL::STRING_TYPE, null: false field :state, GraphQL::STRING_TYPE, null: false diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index b96c2f3afb2..fbb4eddd13c 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -2,10 +2,12 @@ module Types class ProjectType < BaseObject - expose_permissions Types::PermissionTypes::Project - graphql_name 'Project' + authorize :read_project + + expose_permissions Types::PermissionTypes::Project + field :id, GraphQL::ID_TYPE, null: false field :full_path, GraphQL::ID_TYPE, null: false @@ -67,14 +69,12 @@ module Types field :merge_requests, Types::MergeRequestType.connection_type, null: true, - resolver: Resolvers::MergeRequestsResolver, - authorize: :read_merge_request + resolver: Resolvers::MergeRequestsResolver field :merge_request, Types::MergeRequestType, null: true, - resolver: Resolvers::MergeRequestsResolver.single, - authorize: :read_merge_request + resolver: Resolvers::MergeRequestsResolver.single field :issues, Types::IssueType.connection_type, @@ -88,7 +88,7 @@ module Types field :pipelines, Types::Ci::PipelineType.connection_type, - null: false, + null: true, resolver: Resolvers::ProjectPipelinesResolver end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index 472fe5d6ec2..0f655ab9d03 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -7,8 +7,7 @@ module Types field :project, Types::ProjectType, null: true, resolver: Resolvers::ProjectResolver, - description: "Find a project", - authorize: :read_project + description: "Find a project" field :metadata, Types::MetadataType, null: true, diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb index a13e65207df..6b53554314b 100644 --- a/app/graphql/types/user_type.rb +++ b/app/graphql/types/user_type.rb @@ -4,6 +4,8 @@ module Types class UserType < BaseObject graphql_name 'User' + authorize :read_user + present_using UserPresenter field :name, GraphQL::STRING_TYPE, null: false diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index e635f608237..50b5edf7da4 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -119,6 +119,39 @@ module ApplicationSettingsHelper options_for_select(options, selected) end + def external_authorization_description + _("If enabled, access to projects will be validated on an external service"\ + " using their classification label.") + end + + def external_authorization_timeout_help_text + _("Time in seconds GitLab will wait for a response from the external "\ + "service. When the service does not respond in time, access will be "\ + "denied.") + end + + def external_authorization_url_help_text + _("When leaving the URL blank, classification labels can still be "\ + "specified without disabling cross project features or performing "\ + "external authorization checks.") + end + + def external_authorization_client_certificate_help_text + _("The X509 Certificate to use when mutual TLS is required to communicate "\ + "with the external authorization service. If left blank, the server "\ + "certificate is still validated when accessing over HTTPS.") + end + + def external_authorization_client_key_help_text + _("The private key to use when a client certificate is provided. This value "\ + "is encrypted at rest.") + end + + def external_authorization_client_pass_help_text + _("The passphrase required to decrypt the private key. This is optional "\ + "and the value is encrypted at rest.") + end + def visible_attributes [ :admin_notification_email, @@ -237,6 +270,18 @@ module ApplicationSettingsHelper ] end + def external_authorization_service_attributes + [ + :external_auth_client_cert, + :external_auth_client_key, + :external_auth_client_key_pass, + :external_authorization_service_default_label, + :external_authorization_service_enabled, + :external_authorization_service_timeout, + :external_authorization_service_url + ] + end + def expanded_by_default? Rails.env.test? end diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index d6fff1c36da..7e631053b54 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -77,7 +77,7 @@ module BlobHelper project, ref, path, - label: "Replace", + label: _("Replace"), action: "replace", btn_class: "default", modal_type: "upload" @@ -89,7 +89,7 @@ module BlobHelper project, ref, path, - label: "Delete", + label: _("Delete"), action: "delete", btn_class: "remove", modal_type: "remove" @@ -101,14 +101,14 @@ module BlobHelper end def leave_edit_message - "Leave edit mode?\nAll unsaved changes will be lost." + _("Leave edit mode? All unsaved changes will be lost.") end def editing_preview_title(filename) if Gitlab::MarkupHelper.previewable?(filename) - 'Preview' + _('Preview') else - 'Preview changes' + _('Preview changes') end end @@ -201,14 +201,14 @@ module BlobHelper return if blob.empty? return if blob.binary? || blob.stored_externally? - title = 'Open raw' + title = _('Open raw') link_to icon('file-code-o'), blob_raw_path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } end def download_blob_button(blob) return if blob.empty? - title = 'Download' + title = _('Download') link_to sprite_icon('download'), blob_raw_path(inline: false), download: @path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: title, data: { container: 'body' } end diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index 3c8caec3fe5..a5fe6bb8f07 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -4,12 +4,12 @@ module BuildsHelper def build_summary(build, skip: false) if build.has_trace? if skip - link_to "View job trace", pipeline_job_url(build.pipeline, build) + link_to _("View job trace"), pipeline_job_url(build.pipeline, build) else build.trace.html(last_lines: 10).html_safe end else - "No job trace" + _("No job trace") end end @@ -31,7 +31,7 @@ module BuildsHelper def build_failed_issue_options { - title: "Job Failed ##{@build.id}", + title: _("Job Failed #%{build_id}") % { build_id: @build.id }, description: project_job_url(@project, @build) } end diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index 494c754e7d5..03adbfa204f 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -21,7 +21,7 @@ module ButtonHelper # See http://clipboardjs.com/#usage def clipboard_button(data = {}) css_class = data[:class] || 'btn-clipboard btn-transparent' - title = data[:title] || 'Copy to clipboard' + title = data[:title] || _('Copy to clipboard') button_text = data[:button_text] || '' hide_tooltip = data[:hide_tooltip] || false hide_button_icon = data[:hide_button_icon] || false diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 5705ee54cee..8b3d270e873 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -4,8 +4,7 @@ module FormHelper def form_errors(model, type: 'form') return unless model.errors.any? - pluralized = 'error'.pluralize(model.errors.count) - headline = "The #{type} contains the following #{pluralized}:" + headline = n_('The %{type} contains the following error:', 'The %{type} contains the following errors:', model.errors.count) % { type: type } content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:h4, headline) << @@ -24,7 +23,7 @@ module FormHelper title: 'Select assignee', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee', - placeholder: 'Search users', + placeholder: _('Search users'), data: { first_user: current_user&.username, null_user: true, diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index bd53add80ca..e91e8f85515 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -46,7 +46,7 @@ module LabelsHelper if block_given? link_to link, class: css_class, &block else - link_to render_colored_label(label, tooltip: tooltip), link, class: css_class + render_label(label, tooltip: tooltip, link: link, css: css_class) end end @@ -78,19 +78,33 @@ module LabelsHelper end end - def render_colored_label(label, label_suffix = '', tooltip: true) + def render_label(label, tooltip: true, link: nil, css: nil) + # if scoped label is used then EE wraps label tag with scoped label + # doc link + html = render_colored_label(label, tooltip: tooltip) + html = link_to(html, link, class: css) if link + + html + end + + def render_colored_label(label, label_suffix: '', tooltip: true, title: nil) text_color = text_color_for_bg(label.color) + title ||= tooltip ? label_tooltip_title(label) : '' # Intentionally not using content_tag here so that this method can be called # by LabelReferenceFilter span = %(<span class="badge color-label #{"has-tooltip" if tooltip}" ) + - %(style="background-color: #{label.color}; color: #{text_color}" ) + - %(title="#{escape_once(label.description)}" data-container="body">) + + %(data-html="true" style="background-color: #{label.color}; color: #{text_color}" ) + + %(title="#{escape_once(title)}" data-container="body">) + %(#{escape_once(label.name)}#{label_suffix}</span>) span.html_safe end + def label_tooltip_title(label) + label.description + end + def suggested_colors [ '#0033CC', @@ -181,8 +195,8 @@ module LabelsHelper def label_deletion_confirm_text(label) case label - when GroupLabel then 'Remove this label? This will affect all projects within the group. Are you sure?' - when ProjectLabel then 'Remove this label? Are you sure?' + when GroupLabel then _('Remove this label? This will affect all projects within the group. Are you sure?') + when ProjectLabel then _('Remove this label? Are you sure?') end end @@ -231,6 +245,37 @@ module LabelsHelper labels.sort_by(&:title) end + def label_dropdown_data(project, opts = {}) + { + toggle: "dropdown", + field_name: opts[:field_name] || "label_name[]", + show_no: "true", + show_any: "true", + project_id: project&.try(:id), + namespace_path: project&.try(:namespace)&.try(:full_path), + project_path: project&.try(:path) + }.merge(opts) + end + + def sidebar_label_dropdown_data(issuable_type, issuable_sidebar) + label_dropdown_data(nil, { + default_label: "Labels", + field_name: "#{issuable_type}[label_names][]", + ability_name: issuable_type, + namespace_path: issuable_sidebar[:namespace_path], + project_path: issuable_sidebar[:project_path], + issue_update: issuable_sidebar[:issuable_json_path], + labels: issuable_sidebar[:project_labels_path], + display: 'static' + }) + end + + def label_from_hash(hash) + klass = hash[:group_id] ? GroupLabel : ProjectLabel + + klass.new(hash.slice(:color, :description, :title, :group_id, :project_id)) + end + # Required for Banzai::Filter::LabelReferenceFilter - module_function :render_colored_label, :text_color_for_bg, :escape_once + module_function :render_colored_label, :text_color_for_bg, :escape_once, :label_tooltip_title end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index f2abb241753..2ac90eb8d9f 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -299,6 +299,20 @@ module ProjectsHelper }.to_json end + def directory? + @path.present? + end + + def external_classification_label_help_message + default_label = ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + + s_( + "ExternalAuthorizationService|When no classification label is set the "\ + "default label `%{default_label}` will be used." + ) % { default_label: default_label } + end + private def get_project_nav_tabs(project, current_user) diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 69520e33774..a62c00df60b 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -30,7 +30,7 @@ module SearchHelper to = collection.offset_value + collection.to_a.size count = collection.total_count - "Showing #{from} - #{to} of #{count} #{scope.humanize(capitalize: false)} for \"#{term}\"" + s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"") % { from: from, to: to, count: count, scope: scope.humanize(capitalize: false), term: term } end def find_project_for_result_blob(projects, result) @@ -59,31 +59,31 @@ module SearchHelper # Autocomplete results for various settings pages def default_autocomplete [ - { category: "Settings", label: "User settings", url: profile_path }, - { category: "Settings", label: "SSH Keys", url: profile_keys_path }, - { category: "Settings", label: "Dashboard", url: root_path } + { category: "Settings", label: _("User settings"), url: profile_path }, + { category: "Settings", label: _("SSH Keys"), url: profile_keys_path }, + { category: "Settings", label: _("Dashboard"), url: root_path } ] end # Autocomplete results for settings pages, for admins def default_autocomplete_admin [ - { category: "Settings", label: "Admin Section", url: admin_root_path } + { category: "Settings", label: _("Admin Section"), url: admin_root_path } ] end # Autocomplete results for internal help pages def help_autocomplete [ - { category: "Help", label: "API Help", url: help_page_path("api/README") }, - { category: "Help", label: "Markdown Help", url: help_page_path("user/markdown") }, - { category: "Help", label: "Permissions Help", url: help_page_path("user/permissions") }, - { category: "Help", label: "Public Access Help", url: help_page_path("public_access/public_access") }, - { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks/README") }, - { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh/README") }, - { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks/system_hooks") }, - { category: "Help", label: "Webhooks Help", url: help_page_path("user/project/integrations/webhooks") }, - { category: "Help", label: "Workflow Help", url: help_page_path("workflow/README") } + { category: "Help", label: _("API Help"), url: help_page_path("api/README") }, + { category: "Help", label: _("Markdown Help"), url: help_page_path("user/markdown") }, + { category: "Help", label: _("Permissions Help"), url: help_page_path("user/permissions") }, + { category: "Help", label: _("Public Access Help"), url: help_page_path("public_access/public_access") }, + { category: "Help", label: _("Rake Tasks Help"), url: help_page_path("raketasks/README") }, + { category: "Help", label: _("SSH Keys Help"), url: help_page_path("ssh/README") }, + { category: "Help", label: _("System Hooks Help"), url: help_page_path("system_hooks/system_hooks") }, + { category: "Help", label: _("Webhooks Help"), url: help_page_path("user/project/integrations/webhooks") }, + { category: "Help", label: _("Workflow Help"), url: help_page_path("workflow/README") } ] end @@ -93,16 +93,16 @@ module SearchHelper ref = @ref || @project.repository.root_ref [ - { category: "In this project", label: "Files", url: project_tree_path(@project, ref) }, - { category: "In this project", label: "Commits", url: project_commits_path(@project, ref) }, - { category: "In this project", label: "Network", url: project_network_path(@project, ref) }, - { category: "In this project", label: "Graph", url: project_graph_path(@project, ref) }, - { category: "In this project", label: "Issues", url: project_issues_path(@project) }, - { category: "In this project", label: "Merge Requests", url: project_merge_requests_path(@project) }, - { category: "In this project", label: "Milestones", url: project_milestones_path(@project) }, - { category: "In this project", label: "Snippets", url: project_snippets_path(@project) }, - { category: "In this project", label: "Members", url: project_project_members_path(@project) }, - { category: "In this project", label: "Wiki", url: project_wikis_path(@project) } + { category: "In this project", label: _("Files"), url: project_tree_path(@project, ref) }, + { category: "In this project", label: _("Commits"), url: project_commits_path(@project, ref) }, + { category: "In this project", label: _("Network"), url: project_network_path(@project, ref) }, + { category: "In this project", label: _("Graph"), url: project_graph_path(@project, ref) }, + { category: "In this project", label: _("Issues"), url: project_issues_path(@project) }, + { category: "In this project", label: _("Merge Requests"), url: project_merge_requests_path(@project) }, + { category: "In this project", label: _("Milestones"), url: project_milestones_path(@project) }, + { category: "In this project", label: _("Snippets"), url: project_snippets_path(@project) }, + { category: "In this project", label: _("Members"), url: project_project_members_path(@project) }, + { category: "In this project", label: _("Wiki"), url: project_wikis_path(@project) } ] else [] @@ -162,7 +162,7 @@ module SearchHelper opts = { id: "filtered-search-#{type}", - placeholder: 'Search or filter results...', + placeholder: _('Search or filter results...'), data: { 'username-params' => UserSerializer.new.represent(@users) }, diff --git a/app/helpers/tree_helper.rb b/app/helpers/tree_helper.rb index c5bab877c00..4690b6ffbe1 100644 --- a/app/helpers/tree_helper.rb +++ b/app/helpers/tree_helper.rb @@ -86,17 +86,17 @@ module TreeHelper end def edit_in_new_fork_notice_now - "You're not allowed to make changes to this project directly." + - " A fork of this project is being created that you can make changes in, so you can submit a merge request." + _("You're not allowed to make changes to this project directly. "\ + "A fork of this project is being created that you can make changes in, so you can submit a merge request.") end def edit_in_new_fork_notice - "You're not allowed to make changes to this project directly." + - " A fork of this project has been created that you can make changes in, so you can submit a merge request." + _("You're not allowed to make changes to this project directly. "\ + "A fork of this project has been created that you can make changes in, so you can submit a merge request.") end def edit_in_new_fork_notice_action(action) - edit_in_new_fork_notice + " Try to #{action} this file again." + edit_in_new_fork_notice + _(" Try to %{action} this file again.") % { action: action } end def commit_in_fork_help diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 712f0f808dd..9deb783d289 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -42,11 +42,11 @@ module VisibilityLevelHelper def group_visibility_level_description(level) case level when Gitlab::VisibilityLevel::PRIVATE - "The group and its projects can only be viewed by members." + _("The group and its projects can only be viewed by members.") when Gitlab::VisibilityLevel::INTERNAL - "The group and any internal projects can be viewed by any logged in user." + _("The group and any internal projects can be viewed by any logged in user.") when Gitlab::VisibilityLevel::PUBLIC - "The group and any public projects can be viewed without any authentication." + _("The group and any public projects can be viewed without any authentication.") end end @@ -54,20 +54,20 @@ module VisibilityLevelHelper case level when Gitlab::VisibilityLevel::PRIVATE if snippet.is_a? ProjectSnippet - "The snippet is visible only to project members." + _("The snippet is visible only to project members.") else - "The snippet is visible only to me." + _("The snippet is visible only to me.") end when Gitlab::VisibilityLevel::INTERNAL - "The snippet is visible to any logged in user." + _("The snippet is visible to any logged in user.") when Gitlab::VisibilityLevel::PUBLIC - "The snippet can be accessed without any authentication." + _("The snippet can be accessed without any authentication.") end end def restricted_visibility_level_description(level) level_name = Gitlab::VisibilityLevel.level_name(level) - "#{level_name.capitalize} visibility has been restricted by the administrator." + _("%{level_name} visibility has been restricted by the administrator.") % { level_name: level_name.capitalize } end def disallowed_visibility_level_description(level, form_model) diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index 647f34e57ed..edd48f82729 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -47,4 +47,24 @@ module WikiHelper def wiki_attachment_upload_url expose_url(api_v4_projects_wikis_attachments_path(id: @project.id)) end + + def wiki_sort_controls(project, sort, direction) + sort ||= ProjectWiki::TITLE_ORDER + link_class = 'btn btn-default has-tooltip reverse-sort-btn qa-reverse-sort' + reversed_direction = direction == 'desc' ? 'asc' : 'desc' + icon_class = direction == 'desc' ? 'highest' : 'lowest' + + link_to(project_wikis_pages_path(project, sort: sort, direction: reversed_direction), + type: 'button', class: link_class, title: _('Sort direction')) do + sprite_icon("sort-#{icon_class}", size: 16) + end + end + + def wiki_sort_title(key) + if key == ProjectWiki::CREATED_AT_ORDER + s_("Wiki|Created date") + else + s_("Wiki|Title") + end + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 9e91e4ab4b9..d28a12413bf 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -213,6 +213,40 @@ class ApplicationSetting < ApplicationRecord validate :terms_exist, if: :enforce_terms? + validates :external_authorization_service_default_label, + presence: true, + if: :external_authorization_service_enabled + + validates :external_authorization_service_url, + url: true, allow_blank: true, + if: :external_authorization_service_enabled + + validates :external_authorization_service_timeout, + numericality: { greater_than: 0, less_than_or_equal_to: 10 }, + if: :external_authorization_service_enabled + + validates :external_auth_client_key, + presence: true, + if: -> (setting) { setting.external_auth_client_cert.present? } + + validates_with X509CertificateCredentialsValidator, + certificate: :external_auth_client_cert, + pkey: :external_auth_client_key, + pass: :external_auth_client_key_pass, + if: -> (setting) { setting.external_auth_client_cert.present? } + + attr_encrypted :external_auth_client_key, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + + attr_encrypted :external_auth_client_key_pass, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_truncated, + algorithm: 'aes-256-gcm', + encode: true + before_validation :ensure_uuid! before_validation :strip_sentry_values @@ -223,4 +257,11 @@ class ApplicationSetting < ApplicationRecord reset_memoized_terms end after_commit :expire_performance_bar_allowed_user_ids_cache, if: -> { previous_changes.key?('performance_bar_allowed_group_id') } + + def self.create_from_defaults + super + rescue ActiveRecord::RecordNotUnique + # We already have an ApplicationSetting record, so just return it. + current_without_cache + end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1bd517641ac..b8a76e662b0 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -83,8 +83,13 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } scope :with_artifacts_archive, ->() do - where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', - '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) + if Feature.enabled?(:ci_enable_legacy_artifacts) + where('(artifacts_file IS NOT NULL AND artifacts_file <> ?) OR EXISTS (?)', + '', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) + else + where('EXISTS (?)', + Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive) + end end scope :with_existing_job_artifacts, ->(query) do @@ -135,6 +140,8 @@ module Ci where("EXISTS (?)", matcher) end + ## + # TODO: Remove these mounters when we remove :ci_enable_legacy_artifacts feature flag mount_uploader :legacy_artifacts_file, LegacyArtifactUploader, mount_on: :artifacts_file mount_uploader :legacy_artifacts_metadata, LegacyArtifactUploader, mount_on: :artifacts_metadata @@ -775,7 +782,7 @@ module Ci private def erase_old_artifacts! - # TODO: To be removed once we get rid of + # TODO: To be removed once we get rid of ci_enable_legacy_artifacts feature flag remove_artifacts_file! remove_artifacts_metadata! save diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 061eff090f5..80dbb150085 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -6,6 +6,8 @@ module Ci class BuildRunnerSession < ApplicationRecord extend Gitlab::Ci::Model + TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com'.freeze + self.table_name = 'ci_builds_runner_session' belongs_to :build, class_name: 'Ci::Build', inverse_of: :runner_session @@ -14,11 +16,21 @@ module Ci validates :url, url: { protocols: %w(https) } def terminal_specification - return {} unless url.present? + wss_url = Gitlab::UrlHelpers.as_wss(self.url) + return {} unless wss_url.present? + + wss_url = "#{wss_url}/exec" + channel_specification(wss_url, TERMINAL_SUBPROTOCOL) + end + + private + + def channel_specification(url, subprotocol) + return {} if subprotocol.blank? || url.blank? { - subprotocols: ['terminal.gitlab.com'].freeze, - url: "#{url}/exec".sub("https://", "wss://"), + subprotocols: Array(subprotocol), + url: url, headers: { Authorization: [authorization.presence] }.compact, ca_pem: certificate.presence } diff --git a/app/models/concerns/artifact_migratable.rb b/app/models/concerns/artifact_migratable.rb index cbd63ba8876..7c9f579b480 100644 --- a/app/models/concerns/artifact_migratable.rb +++ b/app/models/concerns/artifact_migratable.rb @@ -13,7 +13,7 @@ module ArtifactMigratable end def artifacts? - !artifacts_expired? && artifacts_file.exists? + !artifacts_expired? && artifacts_file&.exists? end def artifacts_metadata? @@ -43,4 +43,16 @@ module ArtifactMigratable def artifacts_size read_attribute(:artifacts_size).to_i + job_artifacts.sum(:size).to_i end + + def legacy_artifacts_file + return unless Feature.enabled?(:ci_enable_legacy_artifacts) + + super + end + + def legacy_artifacts_metadata + return unless Feature.enabled?(:ci_enable_legacy_artifacts) + + super + end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 51a8395c013..17f94b4bd9b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -172,6 +172,10 @@ module Issuable fuzzy_search(query, matched_columns) end + def simple_sorts + super.except('name_asc', 'name_desc') + end + def sort_by_attribute(method, excluded_labels: []) sorted = case method.to_s diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index a29e80fe0c1..decbbbd87f2 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -51,7 +51,7 @@ module PrometheusAdapter end def build_query_args(*args) - args.map(&:id) + args.map { |arg| arg.respond_to?(:id) ? arg.id : arg } end end end diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index 1ab3b3ddc46..1e09cd89550 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -29,6 +29,40 @@ # However, it will enqueue a background worker to call `#calculate_reactive_cache` # and set an initial cache lifetime of ten minutes. # +# The background worker needs to find or generate the object on which +# `with_reactive_cache` was called. +# The default behaviour can be overridden by defining a custom +# `reactive_cache_worker_finder`. +# Otherwise the background worker will use the class name and primary key to get +# the object using the ActiveRecord find_by method. +# +# class Bar +# include ReactiveCaching +# +# self.reactive_cache_key = ->() { ["bar", "thing"] } +# self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } +# +# def self.from_cache(var1, var2) +# # This method will be called by the background worker with "bar1" and +# # "bar2" as arguments. +# new(var1, var2) +# end +# +# def initialize(var1, var2) +# # ... +# end +# +# def calculate_reactive_cache +# # Expensive operation here. The return value of this method is cached +# end +# +# def result +# with_reactive_cache("bar1", "bar2") do |data| +# # ... +# end +# end +# end +# # Each time the background job completes, it stores the return value of # `#calculate_reactive_cache`. It is also re-enqueued to run again after # `reactive_cache_refresh_interval`, so keeping the stored value up to date. @@ -52,6 +86,7 @@ module ReactiveCaching class_attribute :reactive_cache_key class_attribute :reactive_cache_lifetime class_attribute :reactive_cache_refresh_interval + class_attribute :reactive_cache_worker_finder # defaults self.reactive_cache_lease_timeout = 2.minutes @@ -59,6 +94,10 @@ module ReactiveCaching self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 10.minutes + self.reactive_cache_worker_finder = ->(id, *_args) do + find_by(primary_key => id) + end + def calculate_reactive_cache(*args) raise NotImplementedError end diff --git a/app/models/concerns/sortable.rb b/app/models/concerns/sortable.rb index 29e48f0c5f7..df1a9e3fe6e 100644 --- a/app/models/concerns/sortable.rb +++ b/app/models/concerns/sortable.rb @@ -21,19 +21,21 @@ module Sortable class_methods do def order_by(method) - case method.to_s - when 'created_asc' then order_created_asc - when 'created_date' then order_created_desc - when 'created_desc' then order_created_desc - when 'id_asc' then order_id_asc - when 'id_desc' then order_id_desc - when 'name_asc' then order_name_asc - when 'name_desc' then order_name_desc - when 'updated_asc' then order_updated_asc - when 'updated_desc' then order_updated_desc - else - all - end + simple_sorts.fetch(method.to_s, -> { all }).call + end + + def simple_sorts + { + 'created_asc' => -> { order_created_asc }, + 'created_date' => -> { order_created_desc }, + 'created_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 } + } end private diff --git a/app/models/environment.rb b/app/models/environment.rb index 25373c7a1f7..fa29a83e517 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -170,8 +170,10 @@ class Environment < ApplicationRecord prometheus_adapter.query(:environment, self) if has_metrics? end - def additional_metrics - prometheus_adapter.query(:additional_metrics_environment, self) if has_metrics? + def additional_metrics(*args) + return unless has_metrics? + + prometheus_adapter.query(:additional_metrics_environment, self, *args.map(&:to_f)) end # rubocop: disable CodeReuse/ServiceClass diff --git a/app/models/global_label.rb b/app/models/global_label.rb index c5b2492bbf6..572cb12b26a 100644 --- a/app/models/global_label.rb +++ b/app/models/global_label.rb @@ -4,7 +4,7 @@ class GlobalLabel attr_accessor :title, :labels alias_attribute :name, :title - delegate :color, :text_color, :description, to: :@first_label + delegate :color, :text_color, :description, :scoped_label?, to: :@first_label def for_display @first_label diff --git a/app/models/group.rb b/app/models/group.rb index c77586c4cdc..ac66815705c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -228,22 +228,21 @@ class Group < Namespace def has_owner?(user) return false unless user - members_with_parents.owners.where(user_id: user).any? + members_with_parents.owners.exists?(user_id: user) end def has_maintainer?(user) return false unless user - members_with_parents.maintainers.where(user_id: user).any? + members_with_parents.maintainers.exists?(user_id: user) end # @deprecated alias_method :has_master?, :has_maintainer? # Check if user is a last owner of the group. - # Parent owners are ignored for nested groups. def last_owner?(user) - owners.include?(user) && owners.size == 1 + has_owner?(user) && members_with_parents.owners.size == 1 end def ldap_synced? diff --git a/app/models/issue.rb b/app/models/issue.rb index 97c6dcc4745..eb4c87e05d5 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -230,7 +230,13 @@ class Issue < ApplicationRecord def visible_to_user?(user = nil) return false unless project && project.feature_available?(:issues, user) - user ? readable_by?(user) : publicly_visible? + return publicly_visible? unless user + + return false unless readable_by?(user) + + user.full_private_access? || + ::Gitlab::ExternalAuthorization.access_allowed?( + user, project.external_authorization_classification_label) end def check_for_spam? @@ -298,7 +304,7 @@ class Issue < ApplicationRecord # Returns `true` if this Issue is visible to everybody. def publicly_visible? - project.public? && !confidential? + project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled? end def expire_etag_cache diff --git a/app/models/project.rb b/app/models/project.rb index e2869fc2ad5..97e287d3fa2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -2062,6 +2062,11 @@ class Project < ApplicationRecord fetch_branch_allows_collaboration(user, branch_name) end + def external_authorization_classification_label + super || ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + end + def licensed_features [] end diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 268706a6aea..23ddd708396 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -13,6 +13,11 @@ class ProjectWiki CouldNotCreateWikiError = Class.new(StandardError) SIDEBAR = '_sidebar' + TITLE_ORDER = 'title' + CREATED_AT_ORDER = 'created_at' + DIRECTION_DESC = 'desc' + DIRECTION_ASC = 'asc' + # Returns a string describing what went wrong after # an operation fails. attr_reader :error_message @@ -82,8 +87,15 @@ class ProjectWiki # Returns an Array of GitLab WikiPage instances or an # empty Array if this Wiki has no pages. - def pages(limit: 0) - wiki.pages(limit: limit).map { |page| WikiPage.new(self, page, true) } + def pages(limit: 0, sort: nil, direction: DIRECTION_ASC) + sort ||= TITLE_ORDER + direction_desc = direction == DIRECTION_DESC + + wiki.pages( + limit: limit, sort: sort, direction_desc: direction_desc + ).map do |page| + WikiPage.new(self, page, true) + end end # Finds a page within the repository based on a tile diff --git a/app/models/repository.rb b/app/models/repository.rb index 574ce12b309..51ab2247a03 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -299,13 +299,14 @@ class Repository end end - def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:) + def archive_metadata(ref, storage_path, format = "tar.gz", append_sha:, path: nil) raw_repository.archive_metadata( ref, storage_path, project.path, format, - append_sha: append_sha + append_sha: append_sha, + path: path ) end diff --git a/app/models/user.rb b/app/models/user.rb index b426d100537..259889995d3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -159,7 +159,7 @@ class User < ApplicationRecord # Validations # # Note: devise :validatable above adds validations for :email and :password - validates :name, presence: true + validates :name, presence: true, length: { maximum: 128 } validates :email, confirmation: true validates :notification_email, presence: true validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email } diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 64daba81dcf..909da4316d0 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -28,16 +28,15 @@ class WikiPage def self.group_by_directory(pages) return [] if pages.blank? - pages.sort_by { |page| [page.directory, page.slug] } - .group_by(&:directory) - .map do |dir, pages| - if dir.present? - WikiDirectory.new(dir, pages) - else - pages - end - end - .flatten + pages.each_with_object([]) do |page, grouped_pages| + next grouped_pages << page unless page.directory.present? + + directory = grouped_pages.find { |dir| dir.slug == page.directory } + + next directory.pages << page if directory + + grouped_pages << WikiDirectory.new(page.directory, [page]) + end end def self.unhyphenize(name) diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 72de04203a6..5dd2279ef99 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -22,6 +22,13 @@ class BasePolicy < DeclarativePolicy::Base Gitlab::CurrentSettings.current_application_settings.restricted_visibility_levels.include?(Gitlab::VisibilityLevel::PUBLIC) end - # This is prevented in some cases in `gitlab-ee` + condition(:external_authorization_enabled, scope: :global, score: 0) do + ::Gitlab::ExternalAuthorization.perform_check? + end + + rule { external_authorization_enabled & ~full_private_access }.policy do + prevent :read_cross_project + end + rule { default }.enable :read_cross_project end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 75825c8fac0..ba38af9c529 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -89,6 +89,15 @@ class ProjectPolicy < BasePolicy ::Gitlab::CurrentSettings.current_application_settings.mirror_available end + with_scope :subject + condition(:classification_label_authorized, score: 32) do + ::Gitlab::ExternalAuthorization.access_allowed?( + @user, + @subject.external_authorization_classification_label, + @subject.full_path + ) + end + # We aren't checking `:read_issue` or `:read_merge_request` in this case # because it could be possible for a user to see an issuable-iid # (`:read_issue_iid` or `:read_merge_request_iid`) but then wouldn't be @@ -204,6 +213,7 @@ class ProjectPolicy < BasePolicy enable :read_merge_request enable :read_sentry_issue enable :read_release + enable :read_prometheus end # We define `:public_user_access` separately because there are cases in gitlab-ee @@ -416,6 +426,25 @@ class ProjectPolicy < BasePolicy rule { ~can_have_multiple_clusters & has_clusters }.prevent :add_cluster + rule { ~can?(:read_cross_project) & ~classification_label_authorized }.policy do + # Preventing access here still allows the projects to be listed. Listing + # projects doesn't check the `:read_project` ability. But instead counts + # on the `project_authorizations` table. + # + # All other actions should explicitly check read project, which would + # trigger the `classification_label_authorized` condition. + # + # `:read_project_for_iids` is not prevented by this condition, as it is + # used for cross-project reference checks. + prevent :guest_access + prevent :public_access + prevent :public_user_access + prevent :reporter_access + prevent :developer_access + prevent :maintainer_access + prevent :owner_access + end + private def team_member? diff --git a/app/presenters/ci/bridge_presenter.rb b/app/presenters/ci/bridge_presenter.rb new file mode 100644 index 00000000000..ee11cffe355 --- /dev/null +++ b/app/presenters/ci/bridge_presenter.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Ci + class BridgePresenter < CommitStatusPresenter + def detailed_status + @detailed_status ||= subject.detailed_status(user) + end + end +end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 9ddce0d2c80..62c26809eeb 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -45,6 +45,8 @@ class BuildDetailsEntity < JobEntity erase_project_job_path(project, build) end + expose :failure_reason, if: -> (*) { build.failed? } + expose :terminal_path, if: -> (*) { can_create_build_terminal? } do |build| terminal_project_job_path(project, build) end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index c3f7d4651fb..914ad628a99 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -42,6 +42,6 @@ class IssueEntity < IssuableEntity end expose :preview_note_path do |issue| - preview_markdown_path(issue.project, quick_actions_target_type: 'Issue', quick_actions_target_id: issue.iid) + preview_markdown_path(issue.project, target_type: 'Issue', target_id: issue.iid) end end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index d673f8ae896..4831eb32c96 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -235,7 +235,7 @@ class MergeRequestWidgetEntity < IssuableEntity end expose :preview_note_path do |merge_request| - preview_markdown_path(merge_request.project, quick_actions_target_type: 'MergeRequest', quick_actions_target_id: merge_request.iid) + preview_markdown_path(merge_request.project, target_type: 'MergeRequest', target_id: merge_request.iid) end expose :merge_commit_path do |merge_request| diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb index 4d0d4da10be..2dd62e19e29 100644 --- a/app/serializers/suggestion_entity.rb +++ b/app/serializers/suggestion_entity.rb @@ -3,6 +3,8 @@ class SuggestionEntity < API::Entities::Suggestion include RequestAwareEntity + unexpose :from_line, :to_line, :from_content, :to_content + expose :diff_lines, using: DiffLineEntity expose :current_user do expose :can_apply do |suggestion| Ability.allowed?(current_user, :apply_suggestion, suggestion) diff --git a/app/serializers/suggestion_serializer.rb b/app/serializers/suggestion_serializer.rb new file mode 100644 index 00000000000..010344f9fcd --- /dev/null +++ b/app/serializers/suggestion_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SuggestionSerializer < BaseSerializer + entity SuggestionEntity + + def represent_diff(resource) + represent(resource, { only: [:diff_lines] }) + end +end diff --git a/app/services/application_settings/update_service.rb b/app/services/application_settings/update_service.rb index 9146eb96533..7eeaf8aade1 100644 --- a/app/services/application_settings/update_service.rb +++ b/app/services/application_settings/update_service.rb @@ -2,9 +2,17 @@ module ApplicationSettings class UpdateService < ApplicationSettings::BaseService + include ValidatesClassificationLabel + attr_reader :params, :application_setting def execute + validate_classification_label(application_setting, :external_authorization_service_default_label) + + if application_setting.errors.any? + return false + end + update_terms(@params.delete(:terms)) if params.key?(:performance_bar_allowed_group_path) diff --git a/app/services/clusters/applications/base_helm_service.rb b/app/services/clusters/applications/base_helm_service.rb index c38b2656260..adaa68b1efb 100644 --- a/app/services/clusters/applications/base_helm_service.rb +++ b/app/services/clusters/applications/base_helm_service.rb @@ -13,16 +13,20 @@ module Clusters def log_error(error) meta = { - exception: error.class.name, error_code: error.respond_to?(:error_code) ? error.error_code : nil, service: self.class.name, app_id: app.id, project_ids: app.cluster.project_ids, - group_ids: app.cluster.group_ids, - message: error.message + group_ids: app.cluster.group_ids } - logger.error(meta) + logger_meta = meta.merge( + exception: error.class.name, + message: error.message, + backtrace: Gitlab::Profiler.clean_backtrace(error.backtrace) + ) + + logger.error(logger_meta) Gitlab::Sentry.track_acceptable_exception(error, extra: meta) end diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb index 0b9822b1909..0cba9bf1b8a 100644 --- a/app/services/concerns/suggestible.rb +++ b/app/services/concerns/suggestible.rb @@ -2,10 +2,17 @@ module Suggestible extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize # This translates into limiting suggestion changes to `suggestion:-100+100`. MAX_LINES_CONTEXT = 100.freeze + def diff_lines + strong_memoize(:diff_lines) do + Gitlab::Diff::SuggestionDiff.new(self).diff_lines + end + end + def fetch_from_content diff_file.new_blob_lines_between(from_line, to_line).join end diff --git a/app/services/concerns/validates_classification_label.rb b/app/services/concerns/validates_classification_label.rb new file mode 100644 index 00000000000..ebcf5c24ff8 --- /dev/null +++ b/app/services/concerns/validates_classification_label.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module ValidatesClassificationLabel + def validate_classification_label(record, attribute_name) + return unless ::Gitlab::ExternalAuthorization.enabled? + return unless classification_label_change?(record, attribute_name) + + new_label = params[attribute_name].presence + new_label ||= ::Gitlab::CurrentSettings.current_application_settings + .external_authorization_service_default_label + + unless ::Gitlab::ExternalAuthorization.access_allowed?(current_user, new_label) + reason = rejection_reason_for_label(new_label) + message = s_('ClassificationLabelUnavailable|is unavailable: %{reason}') % { reason: reason } + record.errors.add(attribute_name, message) + end + end + + def rejection_reason_for_label(label) + reason_from_service = ::Gitlab::ExternalAuthorization.rejection_reason(current_user, label).presence + reason_from_service || _("Access to '%{classification_label}' not allowed") % { classification_label: label } + end + + def classification_label_change?(record, attribute_name) + params.key?(attribute_name) || record.new_record? + end +end diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb index 8c8acce5ca5..019cd047ae9 100644 --- a/app/services/groups/base_service.rb +++ b/app/services/groups/base_service.rb @@ -7,5 +7,11 @@ module Groups def initialize(group, user, params = {}) @group, @current_user, @params = group, user, params.dup end + + private + + def remove_unallowed_params + # overridden in EE + end end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 99ead467f74..74aad3b1c94 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -8,6 +8,8 @@ module Groups end def execute + remove_unallowed_params + @group = Group.new(params) after_build_hook(@group, params) diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 787445180f0..73e1e00dc33 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -6,6 +6,7 @@ module Groups def execute reject_parent_id! + remove_unallowed_params return false unless valid_visibility_level_change?(group, params[:visibility_level]) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 04dfcfbc22d..7a4ccf0d178 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -107,12 +107,13 @@ class IssuableBaseService < BaseService @labels_service ||= ::Labels::AvailableLabelsService.new(current_user, parent, params) end - def process_label_ids(attributes, existing_label_ids: nil) + def process_label_ids(attributes, existing_label_ids: nil, extra_label_ids: []) label_ids = attributes.delete(:label_ids) add_label_ids = attributes.delete(:add_label_ids) remove_label_ids = attributes.delete(:remove_label_ids) new_label_ids = existing_label_ids || label_ids || [] + new_label_ids |= extra_label_ids if add_label_ids.blank? && remove_label_ids.blank? new_label_ids = label_ids if label_ids @@ -147,7 +148,7 @@ class IssuableBaseService < BaseService params.delete(:state_event) params[:author] ||= current_user - params[:label_ids] = issuable.label_ids.to_a + process_label_ids(params) + params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a) issuable.assign_attributes(params) diff --git a/app/services/merge_requests/add_todo_when_build_fails_service.rb b/app/services/merge_requests/add_todo_when_build_fails_service.rb index 79c43b8e7d5..d3ef892875b 100644 --- a/app/services/merge_requests/add_todo_when_build_fails_service.rb +++ b/app/services/merge_requests/add_todo_when_build_fails_service.rb @@ -7,7 +7,7 @@ module MergeRequests def execute(commit_status) return if commit_status.allow_failure? || commit_status.retried? - commit_status_merge_requests(commit_status) do |merge_request| + pipeline_merge_requests(commit_status.pipeline) do |merge_request| todo_service.merge_request_build_failed(merge_request) end end @@ -16,7 +16,7 @@ module MergeRequests # build is retried # def close(commit_status) - commit_status_merge_requests(commit_status) do |merge_request| + pipeline_merge_requests(commit_status.pipeline) do |merge_request| todo_service.merge_request_build_retried(merge_request) end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 3e208241da5..8a9e5ebb014 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -55,15 +55,7 @@ module MergeRequests end def create_pipeline_for(merge_request, user) - return unless Feature.enabled?(:ci_merge_request_pipeline, - merge_request.source_project, - default_enabled: true) - - ## - # UpdateMergeRequestsWorker could be retried by an exception. - # MR pipelines should not be recreated in such case. - return if merge_request.merge_request_pipeline_exists? - return if merge_request.has_no_commits? + return unless can_create_pipeline_for?(merge_request) create_detached_merge_request_pipeline(merge_request, user) end @@ -80,6 +72,16 @@ module MergeRequests end end + def can_create_pipeline_for?(merge_request) + ## + # UpdateMergeRequestsWorker could be retried by an exception. + # pipelines for merge request should not be recreated in such case. + return false if merge_request.merge_request_pipeline_exists? + return false if merge_request.has_no_commits? + + true + end + def can_use_merge_request_ref?(merge_request) Feature.enabled?(:ci_use_merge_request_ref, project, default_enabled: true) && !merge_request.for_fork? @@ -97,22 +99,11 @@ module MergeRequests # rubocop: enable CodeReuse/ActiveRecord def pipeline_merge_requests(pipeline) - merge_requests_for(pipeline.ref).each do |merge_request| + pipeline.all_merge_requests.opened.each do |merge_request| next unless pipeline == merge_request.head_pipeline yield merge_request end end - - def commit_status_merge_requests(commit_status) - merge_requests_for(commit_status.ref).each do |merge_request| - pipeline = merge_request.head_pipeline - - next unless pipeline - next unless pipeline.sha == commit_status.sha - - yield merge_request - end - end end end diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb index c1655c38095..7386530f45f 100644 --- a/app/services/preview_markdown_service.rb +++ b/app/services/preview_markdown_service.rb @@ -17,7 +17,7 @@ class PreviewMarkdownService < BaseService private def explain_quick_actions(text) - return text, [] unless %w(Issue MergeRequest Commit).include?(commands_target_type) + return text, [] unless %w(Issue MergeRequest Commit).include?(target_type) quick_actions_service = QuickActions::InterpretService.new(project, current_user) quick_actions_service.explain(text, find_commands_target) @@ -30,22 +30,34 @@ class PreviewMarkdownService < BaseService end def find_suggestions(text) - return [] unless params[:preview_suggestions] + return [] unless preview_sugestions? - Banzai::SuggestionsParser.parse(text) + position = Gitlab::Diff::Position.new(new_path: params[:file_path], + new_line: params[:line].to_i, + base_sha: params[:base_sha], + head_sha: params[:head_sha], + start_sha: params[:start_sha]) + + Gitlab::Diff::SuggestionsParser.parse(text, position: position, project: project) + end + + def preview_sugestions? + params[:preview_suggestions] && + target_type == 'MergeRequest' && + Ability.allowed?(current_user, :download_code, project) end def find_commands_target QuickActions::TargetService .new(project, current_user) - .execute(commands_target_type, commands_target_id) + .execute(target_type, target_id) end - def commands_target_type - params[:quick_actions_target_type] + def target_type + params[:target_type] end - def commands_target_id - params[:quick_actions_target_id] + def target_id + params[:target_id] end end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index d03137b63b2..3723c5ef7d7 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -2,6 +2,8 @@ module Projects class CreateService < BaseService + include ValidatesClassificationLabel + def initialize(user, params) @current_user, @params = user, params.dup @skip_wiki = @params.delete(:skip_wiki) @@ -45,6 +47,8 @@ module Projects relations_block&.call(@project) yield(@project) if block_given? + validate_classification_label(@project, :external_authorization_classification_label) + # If the block added errors, don't try to save the project return @project if @project.errors.any? diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 6856009b395..bc36bb8659d 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -3,6 +3,7 @@ module Projects class UpdateService < BaseService include UpdateVisibilityLevel + include ValidatesClassificationLabel ValidationError = Class.new(StandardError) @@ -14,6 +15,8 @@ module Projects yield if block_given? + validate_classification_label(project, :external_authorization_classification_label) + # If the block added errors, don't try to save the project return update_failed! if project.errors.any? diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb new file mode 100644 index 00000000000..c5d2b84878b --- /dev/null +++ b/app/services/prometheus/proxy_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Prometheus + class ProxyService < BaseService + include ReactiveCaching + include Gitlab::Utils::StrongMemoize + + self.reactive_cache_key = ->(service) { service.cache_key } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_lifetime = 1.minute + self.reactive_cache_worker_finder = ->(_id, *args) { from_cache(*args) } + + attr_accessor :proxyable, :method, :path, :params + + PROXY_SUPPORT = { + 'query' => { + method: ['GET'], + params: %w(query time timeout) + }, + 'query_range' => { + method: ['GET'], + params: %w(query start end step timeout) + } + }.freeze + + def self.from_cache(proxyable_class_name, proxyable_id, method, path, params) + proxyable_class = begin + proxyable_class_name.constantize + rescue NameError + nil + end + return unless proxyable_class + + proxyable = proxyable_class.find(proxyable_id) + + new(proxyable, method, path, params) + end + + # proxyable can be any model which responds to .prometheus_adapter + # like Environment. + def initialize(proxyable, method, path, params) + @proxyable = proxyable + @path = path + + # Convert ActionController::Parameters to hash because reactive_cache_worker + # does not play nice with ActionController::Parameters. + @params = filter_params(params, path).to_hash + + @method = method + end + + def id + nil + end + + def execute + return cannot_proxy_response unless can_proxy? + return no_prometheus_response unless can_query? + + with_reactive_cache(*cache_key) do |result| + result + end + end + + def calculate_reactive_cache(proxyable_class_name, proxyable_id, method, path, params) + return no_prometheus_response unless can_query? + + response = prometheus_client_wrapper.proxy(path, params) + + success(http_status: response.code, body: response.body) + rescue Gitlab::PrometheusClient::Error => err + service_unavailable_response(err) + end + + def cache_key + [@proxyable.class.name, @proxyable.id, @method, @path, @params] + end + + private + + def service_unavailable_response(exception) + error(exception.message, :service_unavailable) + end + + def no_prometheus_response + error('No prometheus server found', :service_unavailable) + end + + def cannot_proxy_response + error('Proxy support for this API is not available currently') + end + + def prometheus_adapter + strong_memoize(:prometheus_adapter) do + @proxyable.prometheus_adapter + end + end + + def prometheus_client_wrapper + prometheus_adapter&.prometheus_client_wrapper + end + + def can_query? + prometheus_adapter&.can_query? + end + + def filter_params(params, path) + params.slice(*PROXY_SUPPORT.dig(path, :params)) + end + + def can_proxy? + PROXY_SUPPORT.dig(@path, :method)&.include?(@method) + end + end +end diff --git a/app/uploaders/legacy_artifact_uploader.rb b/app/uploaders/legacy_artifact_uploader.rb index a9afc104ed1..fac3c3dcb8f 100644 --- a/app/uploaders/legacy_artifact_uploader.rb +++ b/app/uploaders/legacy_artifact_uploader.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +## +# TODO: Remove this uploader when we remove :ci_enable_legacy_artifacts feature flag +# See https://gitlab.com/gitlab-org/gitlab-ce/issues/58595 class LegacyArtifactUploader < GitlabUploader extend Workhorse::UploadPath include ObjectStorage::Concern diff --git a/app/uploaders/records_uploads.rb b/app/uploaders/records_uploads.rb index 9a243e07936..00b51f92b12 100644 --- a/app/uploaders/records_uploads.rb +++ b/app/uploaders/records_uploads.rb @@ -46,6 +46,10 @@ module RecordsUploads File.join(store_dir, filename.to_s) end + def filename + upload&.path ? File.basename(upload.path) : super + end + private # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/validators/x509_certificate_credentials_validator.rb b/app/validators/x509_certificate_credentials_validator.rb new file mode 100644 index 00000000000..d2f18e956c3 --- /dev/null +++ b/app/validators/x509_certificate_credentials_validator.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +# X509CertificateCredentialsValidator +# +# Custom validator to check if certificate-attribute was signed using the +# private key stored in an attrebute. +# +# This can be used as an `ActiveModel::Validator` as follows: +# +# validates_with X509CertificateCredentialsValidator, +# certificate: :client_certificate, +# pkey: :decrypted_private_key, +# pass: :decrypted_passphrase +# +# +# Required attributes: +# - certificate: The name of the accessor that returns the certificate to check +# - pkey: The name of the accessor that returns the private key +# Optional: +# - pass: The name of the accessor that returns the passphrase to decrypt the +# private key +class X509CertificateCredentialsValidator < ActiveModel::Validator + def initialize(*args) + super + + # We can't validate if we don't have a private key or certificate attributes + # in which case this validator is useless. + if options[:pkey].nil? || options[:certificate].nil? + raise 'Provide at least `certificate` and `pkey` attribute names' + end + end + + def validate(record) + unless certificate = read_certificate(record) + record.errors.add(options[:certificate], _('is not a valid X509 certificate.')) + end + + unless private_key = read_private_key(record) + record.errors.add(options[:pkey], _('could not read private key, is the passphrase correct?')) + end + + return if private_key.nil? || certificate.nil? + + unless certificate.public_key.fingerprint == private_key.public_key.fingerprint + record.errors.add(options[:pkey], _('private key does not match certificate.')) + end + end + + private + + def read_private_key(record) + OpenSSL::PKey.read(pkey(record).to_s, pass(record).to_s) + rescue OpenSSL::PKey::PKeyError, ArgumentError + # When the primary key could not be read, an ArgumentError is raised. + # This hapens when the passed key is not valid or the passphrase is incorrect + nil + end + + def read_certificate(record) + OpenSSL::X509::Certificate.new(certificate(record).to_s) + rescue OpenSSL::X509::CertificateError + nil + end + + # rubocop:disable GitlabSecurity/PublicSend + # + # Allowing `#public_send` here because we don't want the validator to really + # care about the names of the attributes or where they come from. + # + # The credentials are mostly stored encrypted so we need to go through the + # accessors to get the values, `read_attribute` bypasses those. + def certificate(record) + record.public_send(options[:certificate]) + end + + def pkey(record) + record.public_send(options[:pkey]) + end + + def pass(record) + return unless options[:pass] + + record.public_send(options[:pass]) + end + # rubocop:enable GitlabSecurity/PublicSend +end diff --git a/app/views/admin/application_settings/_external_authorization_service_form.html.haml b/app/views/admin/application_settings/_external_authorization_service_form.html.haml new file mode 100644 index 00000000000..01f6c7afe61 --- /dev/null +++ b/app/views/admin/application_settings/_external_authorization_service_form.html.haml @@ -0,0 +1,51 @@ +%section.settings.as-external-auth.no-animate#js-external-auth-settings{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _('External authentication') + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? 'Collapse' : 'Expand' + %p + = _('External Classification Policy Authorization') + .settings-content + + = form_for @application_setting, url: admin_application_settings_path(anchor: 'js-external-auth-settings'), html: { class: 'fieldset-form' } do |f| + = form_errors(@application_setting) + + %fieldset + .form-group + .form-check + = f.check_box :external_authorization_service_enabled, class: 'form-check-input' + = f.label :external_authorization_service_enabled, class: 'form-check-label' do + = _('Enable classification control using an external service') + %span.form-text.text-muted + = external_authorization_description + = link_to icon('question-circle'), help_page_path('user/admin_area/settings/external_authorization') + .form-group + = f.label :external_authorization_service_url, _('Service URL'), class: 'label-bold' + = f.text_field :external_authorization_service_url, class: 'form-control' + %span.form-text.text-muted + = external_authorization_url_help_text + .form-group + = f.label :external_authorization_service_timeout, _('External authorization request timeout'), class: 'label-bold' + = f.number_field :external_authorization_service_timeout, class: 'form-control', min: 0.001, max: 10, step: 0.001 + %span.form-text.text-muted + = external_authorization_timeout_help_text + = f.label :external_auth_client_cert, _('Client authentication certificate'), class: 'label-bold' + = f.text_area :external_auth_client_cert, class: 'form-control' + %span.form-text.text-muted + = external_authorization_client_certificate_help_text + .form-group + = f.label :external_auth_client_key, _('Client authentication key'), class: 'label-bold' + = f.text_area :external_auth_client_key, class: 'form-control' + %span.form-text.text-muted + = external_authorization_client_key_help_text + .form-group + = f.label :external_auth_client_key_pass, _('Client authentication key password'), class: 'label-bold' + = f.password_field :external_auth_client_key_pass, class: 'form-control' + %span.form-text.text-muted + = external_authorization_client_pass_help_text + .form-group + = f.label :external_authorization_service_default_label, _('Default classification label'), class: 'label-bold' + = f.text_field :external_authorization_service_default_label, class: 'form-control' + + = f.submit 'Save changes', class: "btn btn-success" diff --git a/app/views/admin/application_settings/show.html.haml b/app/views/admin/application_settings/show.html.haml index fc9dd29b8ca..31f18ba0d56 100644 --- a/app/views/admin/application_settings/show.html.haml +++ b/app/views/admin/application_settings/show.html.haml @@ -68,7 +68,7 @@ .settings-content = render 'terms' -= render_if_exists 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default? += render 'admin/application_settings/external_authorization_service_form', expanded: expanded_by_default? %section.settings.as-terminal.no-animate#js-terminal-settings{ class: ('expanded' if expanded_by_default?) } .settings-header diff --git a/app/views/admin/groups/_form.html.haml b/app/views/admin/groups/_form.html.haml index 5e05568e384..8fb38f6a690 100644 --- a/app/views/admin/groups/_form.html.haml +++ b/app/views/admin/groups/_form.html.haml @@ -8,7 +8,7 @@ .form-group.row.group-description-holder = f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2' .col-sm-10 - = render 'shared/choose_group_avatar_button', f: f + = render 'shared/choose_avatar_button', f: f = render 'shared/old_visibility_level', f: f, visibility_level: visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group, with_label: false diff --git a/app/views/clusters/clusters/gcp/_form.html.haml b/app/views/clusters/clusters/gcp/_form.html.haml index 00582e19662..3e0f8955081 100644 --- a/app/views/clusters/clusters/gcp/_form.html.haml +++ b/app/views/clusters/clusters/gcp/_form.html.haml @@ -7,25 +7,27 @@ - help_link_end = ' %{external_link_icon}</a>'.html_safe % { external_link_icon: external_link_icon } %p - - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') - = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page} + - link_to_help_page = link_to(s_('ClusterIntegration|help page'), + help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') + = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page } %p= link_to('Select a different Google account', @authorize_url) -= form_for @gcp_cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field| - = form_errors(@gcp_cluster) - .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') += bootstrap_form_for @gcp_cluster, html: { class: 'gl-show-field-errors js-gke-cluster-creation prepend-top-20', + data: { token: token_in_session } }, url: clusterable.create_gcp_clusters_path, as: :cluster do |field| + = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), + label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' - if has_multiple_clusters? - .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold' - = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope') + = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'), + class: 'label-bold' } do + = field.text_field :environment_scope, required: true, class: 'form-control', + title: 'Environment scope is required.', wrapper: false .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") = field.fields_for :provider_gcp, @gcp_cluster.provider_gcp do |provider_gcp_field| .form-group - = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), class: 'label-bold' + = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project'), + class: 'label-bold' .js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } } = provider_gcp_field.hidden_field :gcp_project_id .dropdown @@ -47,9 +49,9 @@ %p.form-text.text-muted = s_('ClusterIntegration|Learn more about %{help_link_start}zones%{help_link_end}.').html_safe % { help_link_start: help_link_start % { url: zones_link_url }, help_link_end: help_link_end } - .form-group - = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes'), class: 'label-bold' - = provider_gcp_field.text_field :num_nodes, class: 'form-control', placeholder: '3' + = provider_gcp_field.number_field :num_nodes, required: true, placeholder: '3', + title: s_('ClusterIntegration|Number of nodes must be a numerical value.'), + label: s_('ClusterIntegration|Number of nodes'), label_class: 'label-bold' .form-group = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type'), class: 'label-bold' @@ -64,13 +66,14 @@ = s_('ClusterIntegration|Learn more about %{help_link_start_machine_type}machine types%{help_link_end} and %{help_link_start_pricing}pricing%{help_link_end}.').html_safe % { help_link_start_machine_type: help_link_start % { url: machine_type_link_url }, help_link_start_pricing: help_link_start % { url: pricing_link_url }, help_link_end: help_link_end } .form-group - .form-check - = provider_gcp_field.check_box :legacy_abac, { class: 'form-check-input' }, false, true - = provider_gcp_field.label :legacy_abac, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' + = provider_gcp_field.check_box :legacy_abac, { label: s_('ClusterIntegration|RBAC-enabled cluster'), + label_class: 'label-bold' }, false, true + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', + anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' .form-group - = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true + = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), + class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 136f98d0126..27b11e8469f 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -1,40 +1,39 @@ -= form_for @user_cluster, url: clusterable.create_user_clusters_path, as: :cluster do |field| - = form_errors(@user_cluster) - .form-group - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') += bootstrap_form_for @user_cluster, html: { class: 'gl-show-field-errors' }, + url: clusterable.create_user_clusters_path, as: :cluster do |field| + = field.text_field :name, required: true, title: s_('ClusterIntegration|Cluster name is required.'), + label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold' - if has_multiple_clusters? - .form-group - = field.label :environment_scope, s_('ClusterIntegration|Environment scope'), class: 'label-bold' - = field.text_field :environment_scope, class: 'form-control', placeholder: s_('ClusterIntegration|Environment scope') - .form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster.") + = field.form_group :environment_scope, label: { text: s_('ClusterIntegration|Environment scope'), + class: 'label-bold' } do + = field.text_field :environment_scope, required: true, + title: 'Environment scope is required.', wrapper: false + .form-text.text-muted + = s_("ClusterIntegration|Choose which of your environments will use this cluster.") = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| - .form-group - = platform_kubernetes_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold' - = platform_kubernetes_field.text_field :api_url, class: 'form-control', placeholder: s_('ClusterIntegration|API URL') - - .form-group - = platform_kubernetes_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' - = platform_kubernetes_field.text_area :ca_cert, class: 'form-control', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') - - .form-group - = platform_kubernetes_field.label :token, s_('ClusterIntegration|Token'), class: 'label-bold' - = platform_kubernetes_field.text_field :token, class: 'form-control', placeholder: s_('ClusterIntegration|Service token'), autocomplete: 'off' + = platform_kubernetes_field.url_field :api_url, required: true, + title: s_('ClusterIntegration|API URL should be a valid http/https url.'), + label: s_('ClusterIntegration|API URL'), label_class: 'label-bold' + = platform_kubernetes_field.text_area :ca_cert, + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), + label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold' + = platform_kubernetes_field.text_field :token, required: true, + title: s_('ClusterIntegration|Service token is required.'), label: s_('ClusterIntegration|Service Token'), + autocomplete: 'off', label_class: 'label-bold' - if @user_cluster.allow_user_defined_namespace? - .form-group - = platform_kubernetes_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)'), class: 'label-bold' - = platform_kubernetes_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + = platform_kubernetes_field.text_field :namespace, + label: s_('ClusterIntegration|Project namespace (optional, unique)'), label_class: 'label-bold' - .form-group - .form-check - = platform_kubernetes_field.check_box :authorization_type, { class: 'form-check-input qa-rbac-checkbox' }, 'rbac', 'abac' - = platform_kubernetes_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') - = link_to _('More information'), help_page_path('user/project/clusters/index.md', anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' + = platform_kubernetes_field.form_group :authorization_type do + = platform_kubernetes_field.check_box :authorization_type, + { class: 'qa-rbac-checkbox', label: s_('ClusterIntegration|RBAC-enabled cluster'), + label_class: 'label-bold', inline: true }, 'rbac', 'abac' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + = link_to _('More information'), help_page_path('user/project/clusters/index.md', + anchor: 'role-based-access-control-rbac-core-only'), target: '_blank' .form-group = field.submit s_('ClusterIntegration|Add Kubernetes cluster'), class: 'btn btn-success' diff --git a/app/views/clusters/platforms/kubernetes/_form.html.haml b/app/views/clusters/platforms/kubernetes/_form.html.haml index b5ddca7ccb9..f9f8097cb38 100644 --- a/app/views/clusters/platforms/kubernetes/_form.html.haml +++ b/app/views/clusters/platforms/kubernetes/_form.html.haml @@ -1,58 +1,51 @@ -= form_for cluster, url: update_cluster_url_path, as: :cluster do |field| - = form_errors(cluster) - - .form-group - - if cluster.read_only_kubernetes_platform_fields? - %label.append-bottom-10{ for: 'cluster-name' } - = s_('ClusterIntegration|Kubernetes cluster name') - .input-group - %input.form-control.cluster-name.js-select-on-focus{ value: cluster.name, readonly: true } - %span.input-group-append - = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), class: 'input-group-text btn-default') - - else - = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold' - .input-group - = field.text_field :name, class: 'form-control', placeholder: s_('ClusterIntegration|Kubernetes cluster name') += bootstrap_form_for cluster, url: update_cluster_url_path, html: { class: 'gl-show-field-errors' }, + as: :cluster do |field| + - copy_name_btn = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), + class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields? + = field.text_field :name, class: 'js-select-on-focus cluster-name', required: true, + title: s_('ClusterIntegration|Cluster name is required.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + label: s_('ClusterIntegration|Kubernetes cluster name'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: copy_name_btn = field.fields_for :platform_kubernetes, platform do |platform_field| - .form-group - = platform_field.label :api_url, s_('ClusterIntegration|API URL') - .input-group - = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|API URL'), readonly: cluster.read_only_kubernetes_platform_fields? - - if cluster.read_only_kubernetes_platform_fields? - %span.input-group-append - = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), class: 'input-group-text btn-default') + - copy_api_url = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), + class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields? + = platform_field.text_field :api_url, class: 'js-select-on-focus', required: true, + title: s_('ClusterIntegration|API URL should be a valid http/https url.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + label: s_('ClusterIntegration|API URL'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: copy_api_url + + - copy_ca_cert_btn = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), + class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields? + = platform_field.text_area :ca_cert, class: 'js-select-on-focus', rows: '5', + readonly: cluster.read_only_kubernetes_platform_fields?, + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), + label: s_('ClusterIntegration|CA Certificate'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: copy_ca_cert_btn - .form-group - = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate') - .input-group - = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), readonly: cluster.read_only_kubernetes_platform_fields? - - if cluster.read_only_kubernetes_platform_fields? - %span.input-group-append.clipboard-addon - = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), class: 'input-group-text btn-blank') + - show_token_btn = (platform_field.button s_('ClusterIntegration|Show'), + type: 'button', class: 'js-show-cluster-token btn btn-default') + - copy_token_btn = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Service Token'), + class: 'input-group-text btn-default') unless !cluster.read_only_kubernetes_platform_fields? - .form-group - = platform_field.label :token, s_('ClusterIntegration|Token') - .input-group - = platform_field.text_field :token, class: 'form-control js-cluster-token js-select-on-focus', type: 'password', placeholder: s_('ClusterIntegration|Token'), readonly: cluster.read_only_kubernetes_platform_fields? - %span.input-group-append - %button.btn.btn-default.input-group-text.js-show-cluster-token{ type: 'button' } - = s_('ClusterIntegration|Show') - - if cluster.read_only_kubernetes_platform_fields? - = clipboard_button(text: platform.token, title: s_('ClusterIntegration|Copy Token'), class: 'btn-default') + = platform_field.text_field :token, type: 'password', class: 'js-select-on-focus js-cluster-token', + required: true, title: s_('ClusterIntegration|Service token is required.'), + readonly: cluster.read_only_kubernetes_platform_fields?, + label: s_('ClusterIntegration|Service Token'), label_class: 'label-bold', + input_group_class: 'gl-field-error-anchor', append: show_token_btn + copy_token_btn - if cluster.allow_user_defined_namespace? - .form-group - = platform_field.label :namespace, s_('ClusterIntegration|Project namespace (optional, unique)') - = platform_field.text_field :namespace, class: 'form-control', placeholder: s_('ClusterIntegration|Project namespace') + = platform_field.text_field :namespace, label: s_('ClusterIntegration|Project namespace (optional, unique)'), + label_class: 'label-bold' - .form-group - .form-check - = platform_field.check_box :authorization_type, { class: 'form-check-input', disabled: true }, 'rbac', 'abac' - = platform_field.label :authorization_type, s_('ClusterIntegration|RBAC-enabled cluster'), class: 'form-check-label label-bold' - .form-text.text-muted - = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') - = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') + = platform_field.form_group :authorization_type do + = platform_field.check_box :authorization_type, { disabled: true, label: s_('ClusterIntegration|RBAC-enabled cluster'), + label_class: 'label-bold', inline: true }, 'rbac', 'abac' + .form-text.text-muted + = s_('ClusterIntegration|Enable this setting if using role-based access control (RBAC).') + = s_('ClusterIntegration|This option will allow you to install applications on RBAC clusters.') .form-group = field.submit s_('ClusterIntegration|Save changes'), class: 'btn btn-success' diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 51dcc9d0cda..6269678079a 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -27,7 +27,7 @@ .form-group.group-description-holder.col-sm-12 = f.label :avatar, _("Group avatar"), class: 'label-bold' %div - = render 'shared/choose_group_avatar_button', f: f + = render 'shared/choose_avatar_button', f: f .form-group.col-sm-12 %label.label-bold diff --git a/app/views/groups/settings/_general.html.haml b/app/views/groups/settings/_general.html.haml index 9ed71d19d32..c382a1ed168 100644 --- a/app/views/groups/settings/_general.html.haml +++ b/app/views/groups/settings/_general.html.haml @@ -23,10 +23,10 @@ .avatar-container.rect-avatar.s90 = group_icon(@group, alt: '', class: 'avatar group-avatar s90') = f.label :avatar, _('Group avatar'), class: 'label-bold d-block' - = render 'shared/choose_group_avatar_button', f: f + = render 'shared/choose_avatar_button', f: f - if @group.avatar? %hr - = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-danger btn-inverted' + = link_to _('Remove avatar'), group_avatar_path(@group.to_param), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 1b2a4cd6780..26a1f1e119c 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -7,6 +7,7 @@ .alert-wrapper = render "layouts/broadcast" = render "layouts/header/read_only_banner" + = render "layouts/nav/classification_level_banner" = yield :flash_message = render "shared/ping_consent" - unless @hide_breadcrumbs diff --git a/app/views/layouts/nav/_classification_level_banner.html.haml b/app/views/layouts/nav/_classification_level_banner.html.haml new file mode 100644 index 00000000000..cc4caf079b8 --- /dev/null +++ b/app/views/layouts/nav/_classification_level_banner.html.haml @@ -0,0 +1,5 @@ +- if ::Gitlab::ExternalAuthorization.enabled? && @project + = content_for :header_content do + %span.badge.color-label.classification-label.has-tooltip{ title: s_('ExternalAuthorizationService|Classification label') } + = sprite_icon('lock-open', size: 8, css_class: 'inline') + = @project.external_authorization_classification_label diff --git a/app/views/projects/_classification_policy_settings.html.haml b/app/views/projects/_classification_policy_settings.html.haml new file mode 100644 index 00000000000..57c7a718d53 --- /dev/null +++ b/app/views/projects/_classification_policy_settings.html.haml @@ -0,0 +1,8 @@ +- if ::Gitlab::ExternalAuthorization.enabled? + .form-group + = f.label :external_authorization_classification_label, class: 'label-bold' do + = s_('ExternalAuthorizationService|Classification Label') + %span.light (optional) + = f.text_field :external_authorization_classification_label, class: "form-control" + %span.form-text.text-muted + = external_classification_label_help_message diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml index 6d6bd79bc3c..07b9378ba97 100644 --- a/app/views/projects/blob/viewers/_route_map.html.haml +++ b/app/views/projects/blob/viewers/_route_map.html.haml @@ -6,4 +6,4 @@ This Route Map is invalid: = viewer.validation_message -= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment') += link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml index a5f73fb0197..f11c047e85a 100644 --- a/app/views/projects/blob/viewers/_route_map_loading.html.haml +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -1,4 +1,4 @@ = icon('spinner spin fw') Validating Route Map… -= link_to 'Learn more', help_page_path('ci/environments', anchor: 'go-directly-from-source-files-to-public-pages-on-the-environment') += link_to 'Learn more', help_page_path('ci/environments', anchor: 'going-from-source-files-to-public-pages') diff --git a/app/views/projects/buttons/_download.html.haml b/app/views/projects/buttons/_download.html.haml index 4eb53faa6ff..acd63de2277 100644 --- a/app/views/projects/buttons/_download.html.haml +++ b/app/views/projects/buttons/_download.html.haml @@ -8,30 +8,20 @@ %span.sr-only= _('Select Archive Format') = sprite_icon("arrow-down") %ul.dropdown-menu.dropdown-menu-right{ role: 'menu' } - %li.dropdown-header - #{ _('Source code') } - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'zip'), rel: 'nofollow', download: '' do - %span= _('Download zip') - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.gz'), rel: 'nofollow', download: '' do - %span= _('Download tar.gz') - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar.bz2'), rel: 'nofollow', download: '' do - %span= _('Download tar.bz2') - %li - = link_to project_archive_path(project, id: tree_join(ref, archive_prefix), format: 'tar'), rel: 'nofollow', download: '' do - %span= _('Download tar') - + %li.dropdown-bold-header= _('Download source code') + %li.dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: nil + - if directory? + %li.separator + %li.dropdown-bold-header= _('Download this directory') + %li.dropdown-menu-content + = render 'projects/buttons/download_links', project: project, ref: ref, archive_prefix: archive_prefix, path: @path - if pipeline && pipeline.latest_builds_with_artifacts.any? - %li.dropdown-header Artifacts + %li.separator + %li.dropdown-bold-header= _('Download artifacts') - unless pipeline.latest? - - latest_pipeline = project.pipeline_for(ref) - %li - .unclickable= ci_status_for_statuseable(latest_pipeline) - %li.dropdown-header Previous Artifacts + %span.unclickable= ci_status_for_statuseable(project.pipeline_for(ref)) + %li.dropdown-header= _('Previous Artifacts') - pipeline.latest_builds_with_artifacts.each do |job| %li - = link_to latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' do - %span - #{s_('DownloadArtifacts|Download')} '#{job.name}' + = link_to job.name, latest_succeeded_project_artifacts_path(project, "#{ref}/download", job: job.name), rel: 'nofollow', download: '' diff --git a/app/views/projects/buttons/_download_links.html.haml b/app/views/projects/buttons/_download_links.html.haml new file mode 100644 index 00000000000..47a1704f946 --- /dev/null +++ b/app/views/projects/buttons/_download_links.html.haml @@ -0,0 +1,9 @@ +%ul + %li.d-inline-block.m-0.p-0 + = link_to 'zip', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'zip'), rel: 'nofollow', download: '', class: 'btn btn-primary btn-xs' + %li.d-inline-block.m-0.p-0 + = link_to 'tar.gz', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'tar.gz'), rel: 'nofollow', download: '', class: 'btn btn-xs' + %li.d-inline-block.m-0.p-0 + = link_to 'tar.bz2', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'tar.bz2'), rel: 'nofollow', download: '', class: 'btn btn-xs' + %li.d-inline-block.m-0.p-0 + = link_to 'tar', project_archive_path(project, id: tree_join(ref, archive_prefix), path: path, format: 'tar'), rel: 'nofollow', download: '', class: 'btn btn-xs' diff --git a/app/views/projects/diffs/_replaced_image_diff.html.haml b/app/views/projects/diffs/_replaced_image_diff.html.haml index 6dffc7c4390..566dfe798c6 100644 --- a/app/views/projects/diffs/_replaced_image_diff.html.haml +++ b/app/views/projects/diffs/_replaced_image_diff.html.haml @@ -35,10 +35,10 @@ .swipe.view.hide .swipe-frame - .frame.deleted + .frame.deleted.old-diff = image_tag(old_blob_raw_url, alt: diff_file.old_path, lazy: false) - .swipe-wrap - = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path } + .swipe-wrap.left-oriented + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "added old-diff js-image-frame #{class_name}", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.new_path } %span.swipe-bar %span.top-handle %span.bottom-handle diff --git a/app/views/projects/diffs/_single_image_diff.html.haml b/app/views/projects/diffs/_single_image_diff.html.haml index 454f814795a..daac543b939 100644 --- a/app/views/projects/diffs/_single_image_diff.html.haml +++ b/app/views/projects/diffs/_single_image_diff.html.haml @@ -10,5 +10,5 @@ .image.js-single-image{ data: diff_view_data } .wrap - single_class_name = diff_file.deleted_file? ? 'deleted' : 'added' - = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path } + = render partial: "projects/diffs/image_diff_frame", locals: { class_name: "#{single_class_name} #{class_name} old-diff js-image-frame", position: position, note_type: DiffNote.name, image_path: blob_raw_url, alt: diff_file.file_path } %p.image-info= number_to_human_size(blob.size) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index a6a8ca489a9..abf2fb7dc57 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -32,7 +32,7 @@ %span.light (optional) = f.text_area :description, class: "form-control", rows: 3, maxlength: 250 - = render_if_exists 'projects/classification_policy_settings', f: f + = render 'projects/classification_policy_settings', f: f = render_if_exists 'shared/repository_size_limit_setting', form: f, type: :project @@ -40,23 +40,16 @@ = f.label :tag_list, "Topics", class: 'label-bold' = f.text_field :tag_list, value: @project.tag_list.join(', '), maxlength: 2000, class: "form-control" %p.form-text.text-muted Separate topics with commas. - %fieldset.features - %h5.prepend-top-0= _("Project avatar") - .form-group - - if @project.avatar? - .avatar-container.rect-avatar.s160.append-bottom-15 - = project_icon(@project, alt: '', class: 'avatar project-avatar s160', width: 160, height: 160) - - if @project.avatar_in_git - %p.light - = _("Project avatar in repository: %{link}").html_safe % { link: @project.avatar_in_git } - .prepend-top-5.append-bottom-10 - %button.btn.js-choose-project-avatar-button{ type: 'button' }= _("Choose file...") - %span.file_name.prepend-left-default.js-filename= _("No file chosen") - = f.file_field :avatar, class: "js-project-avatar-input hidden" - .form-text.text-muted= _("The maximum file size allowed is 200KB.") - - if @project.avatar? - %hr - = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _("Avatar will be removed. Are you sure?") }, method: :delete, class: "btn btn-danger btn-inverted" + + .form-group.prepend-top-default.append-bottom-20 + .avatar-container.s90 + = project_icon(@project, alt: _('Project avatar'), class: 'avatar project-avatar s90') + = f.label :avatar, _('Project avatar'), class: 'label-bold d-block' + = render 'shared/choose_avatar_button', f: f + - if @project.avatar? + %hr + = link_to _('Remove avatar'), project_avatar_path(@project), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'btn btn-link' + = f.submit 'Save changes', class: "btn btn-success js-btn-success-general-project-settings" %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded) } diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 475bae887ec..81a53f22f67 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -8,6 +8,7 @@ %div{ class: container_class } #js-job-vue-app{ data: { endpoint: project_job_path(@project, @build, format: :json), + deployment_help_url: help_page_path('user/project/clusters/index.html', anchor: 'troubleshooting-failed-deployment-jobs'), runner_help_url: help_page_path('ci/runners/README.html', anchor: 'setting-maximum-job-timeout-for-a-runner'), runner_settings_url: project_runners_path(@build.project, anchor: 'js-runners-settings'), build_options: javascript_build_options } } diff --git a/app/views/projects/pipelines/charts.html.haml b/app/views/projects/pipelines/charts.html.haml index 9da42fe99ac..4d1d078661d 100644 --- a/app/views/projects/pipelines/charts.html.haml +++ b/app/views/projects/pipelines/charts.html.haml @@ -2,10 +2,6 @@ - page_title _('CI / CD Charts') %div{ class: container_class } - .sub-header-block - .oneline - = _("A collection of graphs regarding Continuous Integration") - #charts.ci-charts .row .col-md-6 diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index fac68a36e79..fe74dc122c3 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -17,10 +17,11 @@ = s_('CICD|The Auto DevOps pipeline will run if no alternative CI configuration file is found.') = link_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank' .card-footer.js-extra-settings{ class: auto_devops_enabled || 'hidden' } - %p.settings-message.text-center - - kubernetes_cluster_link = help_page_path('user/project/clusters/index') - - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } - = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } + - if @project.all_clusters.empty? + %p.settings-message.text-center + - kubernetes_cluster_link = help_page_path('user/project/clusters/index') + - kubernetes_cluster_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: kubernetes_cluster_link } + = s_('CICD|You must add a %{kubernetes_cluster_start}Kubernetes cluster integration%{kubernetes_cluster_end} to this project with a domain in order for your deployment strategy to work correctly.').html_safe % { kubernetes_cluster_start: kubernetes_cluster_start, kubernetes_cluster_end: '</a>'.html_safe } %label.prepend-top-10 %strong= s_('CICD|Deployment strategy') .form-check diff --git a/app/views/projects/wikis/pages.html.haml b/app/views/projects/wikis/pages.html.haml index 94267b6e0cf..77fdf7f001c 100644 --- a/app/views/projects/wikis/pages.html.haml +++ b/app/views/projects/wikis/pages.html.haml @@ -2,6 +2,7 @@ - add_to_breadcrumbs "Wiki", project_wiki_path(@project, :home) - breadcrumb_title s_("Wiki|Pages") - page_title s_("Wiki|Pages"), _("Wiki") +- sort_title = wiki_sort_title(params[:sort]) %div{ class: container_class } .wiki-page-header @@ -15,6 +16,18 @@ = icon('cloud-download') = _("Clone repository") + .dropdown.inline.wiki-sort-dropdown + .btn-group{ role: 'group' } + .btn-group{ role: 'group' } + %button.dropdown-toggle{ type: 'button', data: { toggle: 'dropdown', display: 'static' }, class: 'btn btn-default' } + = sort_title + = icon('chevron-down') + %ul.dropdown-menu.dropdown-menu-right.dropdown-menu-selectable.dropdown-menu-sort + %li + = sortable_item(s_("Wiki|Title"), project_wikis_pages_path(@project, sort: ProjectWiki::TITLE_ORDER), sort_title) + = sortable_item(s_("Wiki|Created date"), project_wikis_pages_path(@project, sort: ProjectWiki::CREATED_AT_ORDER), sort_title) + = wiki_sort_controls(@project, params[:sort], params[:direction]) + %ul.wiki-pages-list.content-list = render @wiki_entries, context: 'pages' diff --git a/app/views/shared/_choose_avatar_button.html.haml b/app/views/shared/_choose_avatar_button.html.haml new file mode 100644 index 00000000000..0d46d047134 --- /dev/null +++ b/app/views/shared/_choose_avatar_button.html.haml @@ -0,0 +1,4 @@ +%button.btn.js-choose-avatar-button{ type: 'button' }= _("Choose file…") +%span.file_name.js-avatar-filename= _("No file chosen") += f.file_field :avatar, class: "js-avatar-input hidden" +.form-text.text-muted= _("The maximum file size allowed is 200KB.") diff --git a/app/views/shared/_choose_group_avatar_button.html.haml b/app/views/shared/_choose_group_avatar_button.html.haml deleted file mode 100644 index 0552fe62090..00000000000 --- a/app/views/shared/_choose_group_avatar_button.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%button.btn.js-choose-group-avatar-button{ type: 'button' }= _("Choose File ...") -%span.file_name.js-avatar-filename= _("No file chosen") -= f.file_field :avatar, class: "js-group-avatar-input hidden" -.form-text.text-muted= _("The maximum file size allowed is 200KB.") diff --git a/app/views/shared/_delete_label_modal.html.haml b/app/views/shared/_delete_label_modal.html.haml index b96380923ac..dbd3bbb43af 100644 --- a/app/views/shared/_delete_label_modal.html.haml +++ b/app/views/shared/_delete_label_modal.html.haml @@ -2,7 +2,7 @@ .modal-dialog .modal-content .modal-header - %h3.page-title Delete #{render_colored_label(label, tooltip: false)} ? + %h3.page-title Delete #{render_label(label, tooltip: false)} ? %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } %span{ "aria-hidden": true } × diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index c5ea15a7f63..6651f12f6de 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -7,7 +7,7 @@ - if defined?(@project) = link_to_label(label, subject: @project, tooltip: false) - else - = render_colored_label(label, tooltip: false) + = render_label(label, tooltip: false) .label-description .append-right-default.prepend-left-default - if label.description.present? diff --git a/app/views/shared/boards/components/sidebar/_labels.html.haml b/app/views/shared/boards/components/sidebar/_labels.html.haml index 19159684420..c6eade3bbbc 100644 --- a/app/views/shared/boards/components/sidebar/_labels.html.haml +++ b/app/views/shared/boards/components/sidebar/_labels.html.haml @@ -21,13 +21,7 @@ %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-issue-board-sidebar{ type: "button", ":data-selected" => "selectedLabels", ":data-labels" => "issue.assignableLabelsEndpoint", - data: { toggle: "dropdown", - field_name: "issue[label_names][]", - show_no: "true", - show_any: "true", - project_id: @project&.try(:id), - namespace_path: @namespace_path, - project_path: @project.try(:path) } } + data: label_dropdown_data(@project, namespace_path: @namespace_path, field_name: "issue[label_names][]") } %span.dropdown-toggle-text {{ labelDropdownTitle }} = icon('chevron-down') diff --git a/app/views/shared/form_elements/_description.html.haml b/app/views/shared/form_elements/_description.html.haml index 25df2fe5cd6..b11cb8a3076 100644 --- a/app/views/shared/form_elements/_description.html.haml +++ b/app/views/shared/form_elements/_description.html.haml @@ -5,7 +5,7 @@ - supports_quick_actions = model.new_record? - if supports_quick_actions - - preview_url = preview_markdown_path(project, quick_actions_target_type: model.class.name) + - preview_url = preview_markdown_path(project, target_type: model.class.name) - else - preview_url = preview_markdown_path(project) diff --git a/app/views/shared/issuable/_label_dropdown.html.haml b/app/views/shared/issuable/_label_dropdown.html.haml index d5fb85ba0f3..f2c0c77a583 100644 --- a/app/views/shared/issuable/_label_dropdown.html.haml +++ b/app/views/shared/issuable/_label_dropdown.html.haml @@ -8,7 +8,7 @@ - classes = local_assigns.fetch(:classes, []) - selected = local_assigns.fetch(:selected, nil) - dropdown_title = local_assigns.fetch(:dropdown_title, "Filter by label") -- dropdown_data = {toggle: 'dropdown', field_name: "label_name[]", show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), labels: labels_filter_path_with_defaults, default_label: "Labels"} +- dropdown_data = label_dropdown_data(@project, labels: labels_filter_path_with_defaults, default_label: "Labels") - dropdown_data.merge!(data_options) - label_name = local_assigns.fetch(:label_name, "Labels") - no_default_styles = local_assigns.fetch(:no_default_styles, false) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9596c1df20e..0798b1da4b7 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -105,10 +105,8 @@ = link_to _('Edit'), '#', class: 'js-sidebar-dropdown-toggle edit-link float-right' .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| - = link_to sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label[:title]) do - %span.badge.color-label.has-tooltip{ style: "background-color: #{label[:color]}; color: #{label[:text_color]}", title: label[:description], data: { container: "body" } } - = label[:title] + - selected_labels.each do |label_hash| + = render_label(label_from_hash(label_hash), link: sidebar_label_filter_path(issuable_sidebar[:project_issuables_path], label_hash[:title])) - else %span.no-value = _('None') @@ -116,7 +114,7 @@ - selected_labels.each do |label| = hidden_field_tag "#{issuable_type}[label_names][]", label[:id], id: nil .dropdown - %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable_type}[label_names][]", ability_name: issuable_type, show_no: "true", show_any: "true", namespace_path: issuable_sidebar[:namespace_path], project_path: issuable_sidebar[:project_path], issue_update: issuable_sidebar[:issuable_json_path], labels: issuable_sidebar[:project_labels_path], display: 'static' } } + %button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: sidebar_label_dropdown_data(issuable_type, issuable_sidebar) } %span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) } = multi_label_name(selected_labels, "Labels") = icon('chevron-down', 'aria-hidden': 'true') diff --git a/app/views/shared/labels/_form.html.haml b/app/views/shared/labels/_form.html.haml index 7619d0a2e9c..743ee1435e8 100644 --- a/app/views/shared/labels/_form.html.haml +++ b/app/views/shared/labels/_form.html.haml @@ -4,7 +4,9 @@ .form-group.row = f.label :title, class: 'col-form-label col-sm-2' .col-sm-10 - = f.text_field :title, class: "form-control qa-label-title", required: true, autofocus: true + = f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true + = render_if_exists 'shared/labels/create_label_help_text' + .form-group.row = f.label :description, class: 'col-form-label col-sm-2' .col-sm-10 diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index eba64daaadc..5863f52aa78 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -21,8 +21,7 @@ %span.issuable-number= issuable.to_reference - labels.each do |label| - = link_to polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' }) do - - render_colored_label(label) + = render_label(label, link: polymorphic_path(issuable_type_args, { milestone_title: @milestone.title, label_name: label.title, state: 'all' })) %span.assignee-icon - assignees.each do |assignee| diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index 6797520650d..6b0640bd8cb 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -5,8 +5,7 @@ %li.is-not-draggable %span.label-row %span.label-name - = link_to milestones_label_path(options) do - - render_colored_label(label, tooltip: false) + = render_label(label, tooltip: false, link: milestones_label_path(options)) %span.prepend-description-left = markdown_field(label, :description) diff --git a/app/views/shared/notes/_form.html.haml b/app/views/shared/notes/_form.html.haml index 6a1eea85fde..d91bc6e57c9 100644 --- a/app/views/shared/notes/_form.html.haml +++ b/app/views/shared/notes/_form.html.haml @@ -1,7 +1,7 @@ - supports_autocomplete = local_assigns.fetch(:supports_autocomplete, true) - supports_quick_actions = note_supports_quick_actions?(@note) - if supports_quick_actions - - preview_url = preview_markdown_path(@project, quick_actions_target_type: @note.noteable_type, quick_actions_target_id: @note.noteable_id) + - preview_url = preview_markdown_path(@project, target_type: @note.noteable_type, target_id: @note.noteable_id) - else - preview_url = preview_markdown_path(@project) diff --git a/app/workers/reactive_caching_worker.rb b/app/workers/reactive_caching_worker.rb index 9ec8bcca4f3..b30864db802 100644 --- a/app/workers/reactive_caching_worker.rb +++ b/app/workers/reactive_caching_worker.rb @@ -3,7 +3,6 @@ class ReactiveCachingWorker include ApplicationWorker - # rubocop: disable CodeReuse/ActiveRecord def perform(class_name, id, *args) klass = begin class_name.constantize @@ -12,7 +11,9 @@ class ReactiveCachingWorker end return unless klass - klass.find_by(klass.primary_key => id).try(:exclusively_update_reactive_cache!, *args) + klass + .reactive_cache_worker_finder + .call(id, *args) + .try(:exclusively_update_reactive_cache!, *args) end - # rubocop: enable CodeReuse/ActiveRecord end |