summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard.vue165
-rw-r--r--app/assets/javascripts/notes/components/note_body.vue6
-rw-r--r--app/assets/javascripts/notes/stores/actions.js48
-rw-r--r--app/assets/javascripts/notes/stores/utils.js7
-rw-r--r--app/assets/javascripts/operation_settings/components/external_dashboard.vue57
-rw-r--r--app/assets/javascripts/operation_settings/index.js26
-rw-r--r--app/assets/javascripts/pages/profiles/keys/index.js2
-rw-r--r--app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js42
-rw-r--r--app/assets/javascripts/pages/projects/settings/operations/show/index.js2
-rw-r--r--app/assets/javascripts/profile/profile.js11
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js4
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_create.js3
-rw-r--r--app/assets/javascripts/protected_branches/protected_branch_edit.js3
-rw-r--r--app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue4
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue20
-rw-r--r--app/assets/stylesheets/framework/common.scss24
-rw-r--r--app/assets/stylesheets/framework/header.scss7
-rw-r--r--app/assets/stylesheets/framework/modal.scss6
-rw-r--r--app/assets/stylesheets/framework/typography.scss4
-rw-r--r--app/assets/stylesheets/framework/variables.scss8
-rw-r--r--app/assets/stylesheets/pages/notes.scss5
-rw-r--r--app/controllers/omniauth_callbacks_controller.rb7
-rw-r--r--app/controllers/profiles/preferences_controller.rb4
-rw-r--r--app/controllers/profiles_controller.rb1
-rw-r--r--app/controllers/projects/environments_controller.rb1
-rw-r--r--app/controllers/projects/settings/operations_controller.rb4
-rw-r--r--app/finders/issuable_finder.rb26
-rw-r--r--app/finders/issues_finder.rb4
-rw-r--r--app/finders/projects_finder.rb8
-rw-r--r--app/models/concerns/has_status.rb4
-rw-r--r--app/models/project.rb28
-rw-r--r--app/models/remote_mirror.rb4
-rw-r--r--app/models/user.rb11
-rw-r--r--app/models/user_preference.rb4
-rw-r--r--app/policies/group_policy.rb6
-rw-r--r--app/uploaders/import_export_uploader.rb4
-rw-r--r--app/views/layouts/nav/sidebar/_group.html.haml1
-rw-r--r--app/views/profiles/preferences/show.html.haml26
-rw-r--r--app/views/profiles/show.html.haml12
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/mirrors/_disabled_mirror_badge.html.haml1
-rw-r--r--app/views/projects/mirrors/_mirror_repos.html.haml30
-rw-r--r--app/views/projects/settings/operations/_external_dashboard.html.haml2
-rw-r--r--app/views/projects/settings/operations/show.html.haml1
-rw-r--r--app/views/shared/_remote_mirror_update_button.html.haml2
46 files changed, 495 insertions, 156 deletions
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 33f6afc9c2d..ff1e1805948 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -1,5 +1,12 @@
<script>
-import { GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
+import {
+ GlButton,
+ GlDropdown,
+ GlDropdownItem,
+ GlModal,
+ GlModalDirective,
+ GlLink,
+} from '@gitlab/ui';
import _ from 'underscore';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
@@ -23,12 +30,21 @@ export default {
GraphGroup,
EmptyState,
Icon,
+ GlButton,
GlDropdown,
GlDropdownItem,
GlLink,
+ GlModal,
+ },
+ directives: {
+ GlModalDirective,
},
-
props: {
+ externalDashboardPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
hasMetrics: {
type: Boolean,
required: false,
@@ -96,6 +112,19 @@ export default {
type: Boolean,
required: true,
},
+ customMetricsAvailable: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ customMetricsPath: {
+ type: String,
+ required: true,
+ },
+ validateQueryPath: {
+ type: String,
+ required: true,
+ },
},
data() {
return {
@@ -105,8 +134,14 @@ export default {
elWidth: 0,
selectedTimeWindow: '',
selectedTimeWindowKey: '',
+ formIsValid: null,
};
},
+ computed: {
+ canAddMetrics() {
+ return this.customMetricsAvailable && this.customMetricsPath.length;
+ },
+ },
created() {
this.service = new MonitoringService({
metricsEndpoint: this.metricsEndpoint,
@@ -187,11 +222,20 @@ export default {
this.state = 'unableToConnect';
});
},
+ hideAddMetricModal() {
+ this.$refs.addMetricModal.hide();
+ },
onSidebarMutation() {
setTimeout(() => {
this.elWidth = this.$el.clientWidth;
}, sidebarAnimationDuration);
},
+ setFormValidity(isValid) {
+ this.formIsValid = isValid;
+ },
+ submitCustomMetricsForm() {
+ this.$refs.customMetricsForm.submit();
+ },
activeTimeWindow(key) {
return this.timeWindows[key] === this.selectedTimeWindow;
},
@@ -199,47 +243,96 @@ export default {
return `?time_window=${key}`;
},
},
+ addMetric: {
+ title: s__('Metrics|Add metric'),
+ modalId: 'add-metric',
+ },
};
</script>
<template>
- <div v-if="!showEmptyState" class="prometheus-graphs prepend-top-default">
- <div
- v-if="environmentsEndpoint"
- class="dropdowns d-flex align-items-center justify-content-between"
- >
- <div class="d-flex align-items-center">
- <strong>{{ s__('Metrics|Environment') }}</strong>
- <gl-dropdown
- class="prepend-left-10 js-environments-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="currentEnvironmentName"
- :disabled="store.environmentsData.length === 0"
- >
- <gl-dropdown-item
- v-for="environment in store.environmentsData"
- :key="environment.id"
- :href="environment.metrics_path"
- :active="environment.name === currentEnvironmentName"
- active-class="is-active"
- >{{ environment.name }}</gl-dropdown-item
+ <div v-if="!showEmptyState" class="prometheus-graphs">
+ <div class="gl-p-3 border-bottom bg-gray-light d-flex justify-content-between">
+ <div
+ v-if="environmentsEndpoint"
+ class="dropdowns d-flex align-items-center justify-content-between"
+ >
+ <div class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Environment') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-environments-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="currentEnvironmentName"
+ :disabled="store.environmentsData.length === 0"
+ >
+ <gl-dropdown-item
+ v-for="environment in store.environmentsData"
+ :key="environment.id"
+ :active="environment.name === currentEnvironmentName"
+ active-class="is-active"
+ >{{ environment.name }}</gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
+ <div v-if="showTimeWindowDropdown" class="d-flex align-items-center">
+ <strong>{{ s__('Metrics|Show last') }}</strong>
+ <gl-dropdown
+ class="prepend-left-10 js-time-window-dropdown"
+ toggle-class="dropdown-menu-toggle"
+ :text="selectedTimeWindow"
>
- </gl-dropdown>
+ <gl-dropdown-item
+ v-for="(value, key) in timeWindows"
+ :key="key"
+ :active="activeTimeWindow(key)"
+ ><gl-link :href="setTimeWindowParameter(key)">{{ value }}</gl-link></gl-dropdown-item
+ >
+ </gl-dropdown>
+ </div>
</div>
- <div v-if="showTimeWindowDropdown" class="d-flex align-items-center">
- <strong>{{ s__('Metrics|Show last') }}</strong>
- <gl-dropdown
- class="prepend-left-10 js-time-window-dropdown"
- toggle-class="dropdown-menu-toggle"
- :text="selectedTimeWindow"
- >
- <gl-dropdown-item
- v-for="(value, key) in timeWindows"
- :key="key"
- :active="activeTimeWindow(key)"
- ><gl-link :href="setTimeWindowParameter(key)">{{ value }}</gl-link></gl-dropdown-item
+ <div class="d-flex">
+ <div v-if="isEE && canAddMetrics">
+ <gl-button
+ v-gl-modal-directive="$options.addMetric.modalId"
+ class="js-add-metric-button text-success border-success"
+ >
+ {{ $options.addMetric.title }}
+ </gl-button>
+ <gl-modal
+ ref="addMetricModal"
+ :modal-id="$options.addMetric.modalId"
+ :title="$options.addMetric.title"
>
- </gl-dropdown>
+ <form ref="customMetricsForm" :action="customMetricsPath" method="post">
+ <custom-metrics-form-fields
+ :validate-query-path="validateQueryPath"
+ form-operation="post"
+ @formValidation="setFormValidity"
+ />
+ </form>
+ <div slot="modal-footer">
+ <gl-button @click="hideAddMetricModal">
+ {{ __('Cancel') }}
+ </gl-button>
+ <gl-button
+ :disabled="!formIsValid"
+ variant="success"
+ @click="submitCustomMetricsForm"
+ >
+ {{ __('Save changes') }}
+ </gl-button>
+ </div>
+ </gl-modal>
+ </div>
+ <gl-button
+ v-if="externalDashboardPath.length"
+ class="js-external-dashboard-link prepend-left-8"
+ variant="primary"
+ :href="externalDashboardPath"
+ >
+ {{ __('View full dashboard') }}
+ <icon name="external-link" />
+ </gl-button>
</div>
</div>
<graph-group
diff --git a/app/assets/javascripts/notes/components/note_body.vue b/app/assets/javascripts/notes/components/note_body.vue
index 8ddd5b8514a..88454c3fb4c 100644
--- a/app/assets/javascripts/notes/components/note_body.vue
+++ b/app/assets/javascripts/notes/components/note_body.vue
@@ -83,10 +83,12 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
- applySuggestion({ suggestionId, flashContainer, callback }) {
+ applySuggestion({ suggestionId, flashContainer, callback = () => {} }) {
const { discussion_id: discussionId, id: noteId } = this.note;
- this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
+ return this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer }).then(
+ callback,
+ );
},
},
};
diff --git a/app/assets/javascripts/notes/stores/actions.js b/app/assets/javascripts/notes/stores/actions.js
index 970e6551092..63658d49a05 100644
--- a/app/assets/javascripts/notes/stores/actions.js
+++ b/app/assets/javascripts/notes/stores/actions.js
@@ -142,6 +142,23 @@ export const createNewNote = ({ commit, dispatch }, { endpoint, data }) =>
export const removePlaceholderNotes = ({ commit }) => commit(types.REMOVE_PLACEHOLDER_NOTES);
+export const resolveDiscussion = ({ state, dispatch, getters }, { discussionId }) => {
+ const discussion = utils.findNoteObjectById(state.discussions, discussionId);
+ const isResolved = getters.isDiscussionResolved(discussionId);
+
+ if (!discussion) {
+ return Promise.reject();
+ } else if (isResolved) {
+ return Promise.resolve();
+ }
+
+ return dispatch('toggleResolveNote', {
+ endpoint: discussion.resolve_path,
+ isResolved,
+ discussion: true,
+ });
+};
+
export const toggleResolveNote = ({ commit, dispatch }, { endpoint, isResolved, discussion }) =>
service
.toggleResolveNote(endpoint, isResolved)
@@ -251,11 +268,20 @@ export const saveNote = ({ commit, dispatch }, noteData) => {
const { errors } = res;
const commandsChanges = res.commands_changes;
- if (hasQuickActions && errors && Object.keys(errors).length) {
- eTagPoll.makeRequest();
+ if (errors && Object.keys(errors).length) {
+ /*
+ The following reply means that quick actions have been successfully applied:
+
+ {"commands_changes":{},"valid":false,"errors":{"commands_only":["Commands applied"]}}
+ */
+ if (hasQuickActions) {
+ eTagPoll.makeRequest();
- $('.js-gfm-input').trigger('clear-commands-cache.atwho');
- Flash(__('Commands applied'), 'notice', noteData.flashContainer);
+ $('.js-gfm-input').trigger('clear-commands-cache.atwho');
+ Flash(__('Commands applied'), 'notice', noteData.flashContainer);
+ } else {
+ throw new Error(__('Failed to save comment!'));
+ }
}
if (commandsChanges) {
@@ -420,15 +446,13 @@ export const updateResolvableDiscussonsCounts = ({ commit }) =>
commit(types.UPDATE_RESOLVABLE_DISCUSSIONS_COUNTS);
export const submitSuggestion = (
- { commit },
- { discussionId, noteId, suggestionId, flashContainer, callback },
-) => {
+ { commit, dispatch },
+ { discussionId, noteId, suggestionId, flashContainer },
+) =>
service
.applySuggestion(suggestionId)
- .then(() => {
- commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId });
- callback();
- })
+ .then(() => commit(types.APPLY_SUGGESTION, { discussionId, noteId, suggestionId }))
+ .then(() => dispatch('resolveDiscussion', { discussionId }).catch(() => {}))
.catch(err => {
const defaultMessage = __(
'Something went wrong while applying the suggestion. Please try again.',
@@ -436,9 +460,7 @@ export const submitSuggestion = (
const flashMessage = err.response.data ? `${err.response.data.message}.` : defaultMessage;
Flash(__(flashMessage), 'alert', flashContainer);
- callback();
});
-};
export const convertToDiscussion = ({ commit }, noteId) =>
commit(types.CONVERT_TO_DISCUSSION, noteId);
diff --git a/app/assets/javascripts/notes/stores/utils.js b/app/assets/javascripts/notes/stores/utils.js
index 029fde348fb..ed4cef4a917 100644
--- a/app/assets/javascripts/notes/stores/utils.js
+++ b/app/assets/javascripts/notes/stores/utils.js
@@ -2,7 +2,8 @@ import AjaxCache from '~/lib/utils/ajax_cache';
import { trimFirstCharOfLineContent } from '~/diffs/store/utils';
import { sprintf, __ } from '~/locale';
-const REGEX_QUICK_ACTIONS = /^\/\w+.*$/gm;
+// factory function because global flag makes RegExp stateful
+const createQuickActionsRegex = () => /^\/\w+.*$/gm;
export const findNoteObjectById = (notes, id) => notes.filter(n => n.id === id)[0];
@@ -27,9 +28,9 @@ export const getQuickActionText = note => {
return text;
};
-export const hasQuickActions = note => REGEX_QUICK_ACTIONS.test(note);
+export const hasQuickActions = note => createQuickActionsRegex().test(note);
-export const stripQuickActions = note => note.replace(REGEX_QUICK_ACTIONS, '').trim();
+export const stripQuickActions = note => note.replace(createQuickActionsRegex(), '').trim();
export const prepareDiffLines = diffLines =>
diffLines.map(line => ({ ...trimFirstCharOfLineContent(line) }));
diff --git a/app/assets/javascripts/operation_settings/components/external_dashboard.vue b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
new file mode 100644
index 00000000000..0a87d193b72
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/components/external_dashboard.vue
@@ -0,0 +1,57 @@
+<script>
+import { GlButton, GlFormGroup, GlFormInput, GlLink } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlButton,
+ GlFormGroup,
+ GlFormInput,
+ GlLink,
+ },
+ props: {
+ externalDashboardPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ externalDashboardHelpPagePath: {
+ type: String,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <section class="settings expanded">
+ <div class="settings-header">
+ <h4 class="js-section-header">
+ {{ s__('ExternalMetrics|External Dashboard') }}
+ </h4>
+ <p class="js-section-sub-header">
+ {{
+ s__(
+ 'ExternalMetrics|Add a button to the metrics dashboard linking directly to your existing external dashboards.',
+ )
+ }}
+ <gl-link :href="externalDashboardHelpPagePath">{{ __('Learn more') }}</gl-link>
+ </p>
+ </div>
+ <div class="settings-content">
+ <form>
+ <gl-form-group
+ :label="s__('ExternalMetrics|Full dashboard URL')"
+ :description="s__('ExternalMetrics|Enter the URL of the dashboard you want to link to')"
+ >
+ <gl-form-input
+ :value="externalDashboardPath"
+ placeholder="https://my-org.gitlab.io/my-dashboards"
+ />
+ </gl-form-group>
+ <gl-button variant="success">
+ {{ __('Save Changes') }}
+ </gl-button>
+ </form>
+ </div>
+ </section>
+</template>
diff --git a/app/assets/javascripts/operation_settings/index.js b/app/assets/javascripts/operation_settings/index.js
new file mode 100644
index 00000000000..1171f3ece9f
--- /dev/null
+++ b/app/assets/javascripts/operation_settings/index.js
@@ -0,0 +1,26 @@
+import Vue from 'vue';
+import ExternalDashboardForm from './components/external_dashboard.vue';
+
+export default () => {
+ /**
+ * This check can be removed when we remove
+ * the :grafana_dashboard_link feature flag
+ */
+ if (!gon.features.grafanaDashboardLink) {
+ return null;
+ }
+
+ const el = document.querySelector('.js-operation-settings');
+
+ return new Vue({
+ el,
+ render(createElement) {
+ return createElement(ExternalDashboardForm, {
+ props: {
+ ...el.dataset,
+ expanded: false,
+ },
+ });
+ },
+ });
+};
diff --git a/app/assets/javascripts/pages/profiles/keys/index.js b/app/assets/javascripts/pages/profiles/keys/index.js
index 1cd3ee1dfdb..d3dcd21f456 100644
--- a/app/assets/javascripts/pages/profiles/keys/index.js
+++ b/app/assets/javascripts/pages/profiles/keys/index.js
@@ -2,6 +2,8 @@ import AddSshKeyValidation from '~/profile/add_ssh_key_validation';
document.addEventListener('DOMContentLoaded', () => {
const input = document.querySelector('.js-add-ssh-key-validation-input');
+ if (!input) return;
+
const warning = document.querySelector('.js-add-ssh-key-validation-warning');
const originalSubmit = input.form.querySelector('.js-add-ssh-key-validation-original-submit');
const confirmSubmit = warning.querySelector('.js-add-ssh-key-validation-confirm-submit');
diff --git a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
index c1f6edf2f27..a20a0526f12 100644
--- a/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
+++ b/app/assets/javascripts/pages/projects/pipeline_schedules/shared/components/timezone_dropdown.js
@@ -1,4 +1,10 @@
-const defaultTimezone = 'UTC';
+const defaultTimezone = { name: 'UTC', offset: 0 };
+const defaults = {
+ $inputEl: null,
+ $dropdownEl: null,
+ onSelectTimezone: null,
+ displayFormat: item => item.name,
+};
export const formatUtcOffset = offset => {
const parsed = parseInt(offset, 10);
@@ -11,23 +17,28 @@ export const formatUtcOffset = offset => {
export const formatTimezone = item => `[UTC ${formatUtcOffset(item.offset)}] ${item.name}`;
-const defaults = {
- $inputEl: null,
- $dropdownEl: null,
- onSelectTimezone: null,
+export const findTimezoneByIdentifier = (tzList = [], identifier = null) => {
+ if (tzList && tzList.length && identifier && identifier.length) {
+ return tzList.find(tz => tz.identifier === identifier) || null;
+ }
+ return null;
};
export default class TimezoneDropdown {
- constructor({ $dropdownEl, $inputEl, onSelectTimezone } = defaults) {
+ constructor({ $dropdownEl, $inputEl, onSelectTimezone, displayFormat } = defaults) {
this.$dropdown = $dropdownEl;
this.$dropdownToggle = this.$dropdown.find('.dropdown-toggle-text');
this.$input = $inputEl;
this.timezoneData = this.$dropdown.data('data');
+ this.onSelectTimezone = onSelectTimezone;
+ this.displayFormat = displayFormat || defaults.displayFormat;
+
+ this.initialTimezone =
+ findTimezoneByIdentifier(this.timezoneData, this.$input.val()) || defaultTimezone;
+
this.initDefaultTimezone();
this.initDropdown();
-
- this.onSelectTimezone = onSelectTimezone;
}
initDropdown() {
@@ -35,7 +46,7 @@ export default class TimezoneDropdown {
data: this.timezoneData,
filterable: true,
selectable: true,
- toggleLabel: item => item.name,
+ toggleLabel: this.displayFormat,
search: {
fields: ['name'],
},
@@ -43,20 +54,17 @@ export default class TimezoneDropdown {
text: item => formatTimezone(item),
});
- this.setDropdownToggle();
+ this.setDropdownToggle(this.displayFormat(this.initialTimezone));
}
initDefaultTimezone() {
- const initialValue = this.$input.val();
-
- if (!initialValue) {
- this.$input.val(defaultTimezone);
+ if (!this.$input.val()) {
+ this.$input.val(defaultTimezone.name);
}
}
- setDropdownToggle() {
- const initialValue = this.$input.val();
- this.$dropdownToggle.text(initialValue);
+ setDropdownToggle(dropdownText) {
+ this.$dropdownToggle.text(dropdownText);
}
updateInputValue({ selectedObj, e }) {
diff --git a/app/assets/javascripts/pages/projects/settings/operations/show/index.js b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
index 73c745179be..5270a7924ec 100644
--- a/app/assets/javascripts/pages/projects/settings/operations/show/index.js
+++ b/app/assets/javascripts/pages/projects/settings/operations/show/index.js
@@ -1,5 +1,7 @@
import mountErrorTrackingForm from '~/error_tracking_settings';
+import mountOperationSettings from '~/operation_settings';
document.addEventListener('DOMContentLoaded', () => {
mountErrorTrackingForm();
+ mountOperationSettings();
});
diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js
index deacff5abe7..6e3800021b4 100644
--- a/app/assets/javascripts/profile/profile.js
+++ b/app/assets/javascripts/profile/profile.js
@@ -2,6 +2,9 @@ import $ from 'jquery';
import axios from '~/lib/utils/axios_utils';
import flash from '../flash';
import { parseBoolean } from '~/lib/utils/common_utils';
+import TimezoneDropdown, {
+ formatTimezone,
+} from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown';
export default class Profile {
constructor({ form } = {}) {
@@ -10,6 +13,14 @@ export default class Profile {
this.setRepoRadio();
this.bindEvents();
this.initAvatarGlCrop();
+
+ this.$inputEl = $('#user_timezone');
+
+ this.timezoneDropdown = new TimezoneDropdown({
+ $inputEl: this.$inputEl,
+ $dropdownEl: $('.js-timezone-dropdown'),
+ displayFormat: selectedItem => formatTimezone(selectedItem),
+ });
}
initAvatarGlCrop() {
diff --git a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
index 40a873833e1..41e295387ae 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_access_dropdown.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export default class ProtectedBranchAccessDropdown {
constructor(options) {
this.options = options;
@@ -15,7 +17,7 @@ export default class ProtectedBranchAccessDropdown {
if ($el.is('.is-active')) {
return item.text;
}
- return 'Select';
+ return __('Select');
},
clicked(options) {
options.e.preventDefault();
diff --git a/app/assets/javascripts/protected_branches/protected_branch_create.js b/app/assets/javascripts/protected_branches/protected_branch_create.js
index 48343c8ba0a..16ecd5523d6 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_create.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_create.js
@@ -2,6 +2,7 @@ import $ from 'jquery';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
import CreateItemDropdown from '../create_item_dropdown';
import AccessorUtilities from '../lib/utils/accessor';
+import { __ } from '~/locale';
export default class ProtectedBranchCreate {
constructor() {
@@ -35,7 +36,7 @@ export default class ProtectedBranchCreate {
this.createItemDropdown = new CreateItemDropdown({
$dropdown: $protectedBranchDropdown,
- defaultToggleLabel: 'Protected Branch',
+ defaultToggleLabel: __('Protected Branch'),
fieldName: 'protected_branch[name]',
onSelect: this.onSelectCallback,
getData: ProtectedBranchCreate.getProtectedBranches,
diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js
index 5bc08f60d16..08d8c9919dd 100644
--- a/app/assets/javascripts/protected_branches/protected_branch_edit.js
+++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js
@@ -1,6 +1,7 @@
import flash from '../flash';
import axios from '../lib/utils/axios_utils';
import ProtectedBranchAccessDropdown from './protected_branch_access_dropdown';
+import { __ } from '~/locale';
export default class ProtectedBranchEdit {
constructor(options) {
@@ -68,7 +69,7 @@ export default class ProtectedBranchEdit {
this.$allowedToPushDropdown.enable();
flash(
- 'Failed to update branch!',
+ __('Failed to update branch!'),
'alert',
document.querySelector('.js-protected-branches-list'),
);
diff --git a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
index c0659a0173a..10e2c8453e2 100644
--- a/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
+++ b/app/assets/javascripts/set_status_modal/set_status_modal_wrapper.vue
@@ -178,7 +178,7 @@ export default {
/>
<div ref="userStatusForm" class="form-group position-relative m-0">
<div class="input-group">
- <span class="input-group-btn">
+ <span class="input-group-prepend">
<button
ref="toggleEmojiMenuButton"
v-gl-tooltip.bottom
@@ -211,7 +211,7 @@ export default {
@keyup.enter.prevent
@click="hideEmojiMenu"
/>
- <span v-show="isDirty" class="input-group-btn">
+ <span v-show="isDirty" class="input-group-append">
<button
v-gl-tooltip.bottom
:title="s__('SetStatusModal|Clear status')"
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
index f5a1ff2f6fd..f5fa68308bc 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
@@ -94,8 +94,8 @@ export default {
</script>
<template>
- <div v-if="hasPipeline || hasCIError" class="ci-widget media js-ci-widget">
- <template v-if="hasCIError">
+ <div class="ci-widget media js-ci-widget">
+ <template v-if="!hasPipeline || hasCIError">
<div
class="add-border ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-default"
>
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
index c5a2aa1f2af..32783b85df4 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -1,8 +1,10 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
+import { GlButton, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
- components: { Icon },
+ components: { Icon, GlButton, GlLoadingIcon },
+ directives: { 'gl-tooltip': GlTooltipDirective },
props: {
canApply: {
type: Boolean,
@@ -21,7 +23,6 @@ export default {
},
data() {
return {
- isAppliedSuccessfully: false,
isApplying: false,
};
},
@@ -47,14 +48,19 @@ export default {
</a>
</div>
<span v-if="isApplied" class="badge badge-success">{{ __('Applied') }}</span>
- <button
- v-if="canApply"
- type="button"
- class="btn qa-apply-btn"
+ <div v-if="isApplying" class="d-flex align-items-center text-secondary">
+ <gl-loading-icon class="d-flex-center mr-2" />
+ <span>{{ __('Applying suggestion') }}</span>
+ </div>
+ <gl-button
+ v-else-if="canApply"
+ v-gl-tooltip.viewport="__('This also resolves the discussion')"
+ class="btn-inverted qa-apply-btn"
:disabled="isApplying"
+ variant="success"
@click="applySuggestion"
>
{{ __('Apply suggestion') }}
- </button>
+ </gl-button>
</div>
</template>
diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss
index dffd5e70edb..2b499e50ea3 100644
--- a/app/assets/stylesheets/framework/common.scss
+++ b/app/assets/stylesheets/framework/common.scss
@@ -48,6 +48,10 @@
color: $brand-info;
}
+.bg-gray-light {
+ background-color: $gray-light;
+}
+
.text-break-word {
word-break: break-all;
}
@@ -446,19 +450,13 @@ img.emoji {
}
/** COMMON SPACING CLASSES **/
-.gl-pl-0 { padding-left: 0; }
-.gl-pl-1 { padding-left: #{0.5 * $grid-size}; }
-.gl-pl-2 { padding-left: $grid-size; }
-.gl-pl-3 { padding-left: #{2 * $grid-size}; }
-.gl-pl-4 { padding-left: #{3 * $grid-size}; }
-.gl-pl-5 { padding-left: #{4 * $grid-size}; }
-
-.gl-pr-0 { padding-right: 0; }
-.gl-pr-1 { padding-right: #{0.5 * $grid-size}; }
-.gl-pr-2 { padding-right: $grid-size; }
-.gl-pr-3 { padding-right: #{2 * $grid-size}; }
-.gl-pr-4 { padding-right: #{3 * $grid-size}; }
-.gl-pr-5 { padding-right: #{4 * $grid-size}; }
+@each $index, $padding in $spacing-scale {
+ #{'.gl-p-#{$index}'} { padding: $padding; }
+ #{'.gl-pl-#{$index}'} { padding-left: $padding; }
+ #{'.gl-pr-#{$index}'} { padding-right: $padding; }
+ #{'.gl-pt-#{$index}'} { padding-top: $padding; }
+ #{'.gl-pb-#{$index}'} { padding-bottom: $padding; }
+}
/**
* Removes browser specific clear icon from input fields in
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index a6179e2a96e..a57cd6737f8 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -581,10 +581,15 @@
.emoji-menu-toggle-button {
@include emoji-menu-toggle-button;
+ padding: $gl-vert-padding $gl-btn-padding;
}
.input-group {
- height: 34px;
+ &,
+ .input-group-prepend,
+ .input-group-append {
+ height: $input-height;
+ }
}
}
diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss
index 323a3dbecd5..e2bb1eb67c0 100644
--- a/app/assets/stylesheets/framework/modal.scss
+++ b/app/assets/stylesheets/framework/modal.scss
@@ -66,12 +66,6 @@
margin-top: $grid-size;
}
}
-
- @include media-breakpoint-up(sm) {
- .btn:nth-child(1) {
- margin-left: auto;
- }
- }
}
body.modal-open {
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index 244b414d334..7c152efd9c7 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -473,3 +473,7 @@ textarea {
/* stylelint-enable */
.lh-100 { line-height: 1; }
+
+wbr {
+ display: inline-block;
+}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index da1f196afdb..4a034e1e547 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -11,6 +11,14 @@ $default-transition-duration: 0.15s;
$contextual-sidebar-width: 220px;
$contextual-sidebar-collapsed-width: 50px;
$toggle-sidebar-height: 48px;
+$spacing-scale: (
+ 0: 0,
+ 1: #{0.5 * $grid-size},
+ 2: $grid-size,
+ 3: #{2 * $grid-size},
+ 4: #{3 * $grid-size},
+ 5: #{4 * $grid-size}
+);
/*
* Color schema
diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss
index 09f75cd827f..f2b67a693c3 100644
--- a/app/assets/stylesheets/pages/notes.scss
+++ b/app/assets/stylesheets/pages/notes.scss
@@ -471,6 +471,11 @@ $note-form-margin-left: 72px;
vertical-align: top;
white-space: normal;
+ // Fixes subpixel rounding issue https://gitlab.com/gitlab-org/gitlab-ce/issues/53973
+ // background-color is needed for dark code preference
+ padding-bottom: 1px;
+ background-color: $white-light;
+
&.parallel {
border-width: 1px;
diff --git a/app/controllers/omniauth_callbacks_controller.rb b/app/controllers/omniauth_callbacks_controller.rb
index d9b3b4bbbd9..2a8dd997d04 100644
--- a/app/controllers/omniauth_callbacks_controller.rb
+++ b/app/controllers/omniauth_callbacks_controller.rb
@@ -86,7 +86,8 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
log_audit_event(current_user, with: oauth['provider'])
identity_linker ||= auth_module::IdentityLinker.new(current_user, oauth)
- identity_linker.link
+
+ link_identity(identity_linker)
if identity_linker.changed?
redirect_identity_linked
@@ -100,6 +101,10 @@ class OmniauthCallbacksController < Devise::OmniauthCallbacksController
end
end
+ def link_identity(identity_linker)
+ identity_linker.link
+ end
+
def redirect_identity_exists
redirect_to after_sign_in_path_for(current_user)
end
diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb
index 0e30df1b15b..62f98d9e549 100644
--- a/app/controllers/profiles/preferences_controller.rb
+++ b/app/controllers/profiles/preferences_controller.rb
@@ -44,7 +44,9 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:project_view,
:theme_id,
:first_day_of_week,
- :preferred_language
+ :preferred_language,
+ :time_display_relative,
+ :time_format_in_24h
]
end
end
diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb
index b9c52618d4b..d3746248bd3 100644
--- a/app/controllers/profiles_controller.rb
+++ b/app/controllers/profiles_controller.rb
@@ -106,6 +106,7 @@ class ProfilesController < Profiles::ApplicationController
:organization,
:private_profile,
:include_private_contributions,
+ :timezone,
status: [:emoji, :message]
)
end
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index d8812c023ca..5a4adea497b 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -14,6 +14,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
push_frontend_feature_flag(:metrics_time_window)
push_frontend_feature_flag(:environment_metrics_use_prometheus_endpoint)
push_frontend_feature_flag(:environment_metrics_show_multiple_dashboards)
+ push_frontend_feature_flag(:grafana_dashboard_link)
end
def index
diff --git a/app/controllers/projects/settings/operations_controller.rb b/app/controllers/projects/settings/operations_controller.rb
index 5cfb0ac307d..b5c77e5bbf4 100644
--- a/app/controllers/projects/settings/operations_controller.rb
+++ b/app/controllers/projects/settings/operations_controller.rb
@@ -5,6 +5,10 @@ module Projects
class OperationsController < Projects::ApplicationController
before_action :authorize_update_environment!
+ before_action do
+ push_frontend_feature_flag(:grafana_dashboard_link)
+ end
+
helper_method :error_tracking_setting
def show
diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb
index f1dd040515f..52b6e828cfa 100644
--- a/app/finders/issuable_finder.rb
+++ b/app/finders/issuable_finder.rb
@@ -29,6 +29,7 @@
# updated_after: datetime
# updated_before: datetime
# attempt_group_search_optimizations: boolean
+# attempt_project_search_optimizations: boolean
#
class IssuableFinder
prepend FinderWithCrossProjectAccess
@@ -184,7 +185,6 @@ class IssuableFinder
@project = project
end
- # rubocop: disable CodeReuse/ActiveRecord
def projects
return @projects if defined?(@projects)
@@ -192,17 +192,25 @@ class IssuableFinder
projects =
if current_user && params[:authorized_only].presence && !current_user_related?
- current_user.authorized_projects
+ current_user.authorized_projects(min_access_level)
elsif group
- finder_options = { include_subgroups: params[:include_subgroups], only_owned: true }
- GroupProjectsFinder.new(group: group, current_user: current_user, options: finder_options).execute # rubocop: disable CodeReuse/Finder
+ find_group_projects
else
- ProjectsFinder.new(current_user: current_user).execute # rubocop: disable CodeReuse/Finder
+ Project.public_or_visible_to_user(current_user, min_access_level)
end
- @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
+ @projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil) # rubocop: disable CodeReuse/ActiveRecord
+ end
+
+ def find_group_projects
+ return Project.none unless group
+
+ if params[:include_subgroups]
+ Project.where(namespace_id: group.self_and_descendants) # rubocop: disable CodeReuse/ActiveRecord
+ else
+ group.projects
+ end.public_or_visible_to_user(current_user, min_access_level)
end
- # rubocop: enable CodeReuse/ActiveRecord
def search
params[:search].presence
@@ -570,4 +578,8 @@ class IssuableFinder
scope = params[:scope]
scope == 'created_by_me' || scope == 'authored' || scope == 'assigned_to_me'
end
+
+ def min_access_level
+ ProjectFeature.required_minimum_access_level(klass)
+ end
end
diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb
index e6a82f55856..58a01d598ba 100644
--- a/app/finders/issues_finder.rb
+++ b/app/finders/issues_finder.rb
@@ -48,9 +48,9 @@ class IssuesFinder < IssuableFinder
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR EXISTS (SELECT TRUE FROM issue_assignees WHERE user_id = :user_id AND issue_id = issues.id)
- OR issues.project_id IN(:project_ids)))',
+ OR EXISTS (:authorizations)))',
user_id: current_user.id,
- project_ids: current_user.authorized_projects(CONFIDENTIAL_ACCESS_LEVEL).select(:id))
+ authorizations: current_user.authorizations_for_projects(min_access_level: CONFIDENTIAL_ACCESS_LEVEL, related_project_column: "issues.project_id"))
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb
index 93d3c991846..23b731b1aed 100644
--- a/app/finders/projects_finder.rb
+++ b/app/finders/projects_finder.rb
@@ -62,7 +62,7 @@ class ProjectsFinder < UnionFinder
collection = by_personal(collection)
collection = by_starred(collection)
collection = by_trending(collection)
- collection = by_visibilty_level(collection)
+ collection = by_visibility_level(collection)
collection = by_tags(collection)
collection = by_search(collection)
collection = by_archived(collection)
@@ -71,12 +71,11 @@ class ProjectsFinder < UnionFinder
collection
end
- # rubocop: disable CodeReuse/ActiveRecord
def collection_with_user
if owned_projects?
current_user.owned_projects
elsif min_access_level?
- current_user.authorized_projects.where('project_authorizations.access_level >= ?', params[:min_access_level])
+ current_user.authorized_projects(params[:min_access_level])
else
if private_only?
current_user.authorized_projects
@@ -85,7 +84,6 @@ class ProjectsFinder < UnionFinder
end
end
end
- # rubocop: enable CodeReuse/ActiveRecord
# Builds a collection for an anonymous user.
def collection_without_user
@@ -131,7 +129,7 @@ class ProjectsFinder < UnionFinder
end
# rubocop: disable CodeReuse/ActiveRecord
- def by_visibilty_level(items)
+ def by_visibility_level(items)
params[:visibility_level].present? ? items.where(visibility_level: params[:visibility_level]) : items
end
# rubocop: enable CodeReuse/ActiveRecord
diff --git a/app/models/concerns/has_status.rb b/app/models/concerns/has_status.rb
index 8882f48c281..78bcce2f592 100644
--- a/app/models/concerns/has_status.rb
+++ b/app/models/concerns/has_status.rb
@@ -66,6 +66,10 @@ module HasStatus
def all_state_names
state_machines.values.flat_map(&:states).flat_map { |s| s.map(&:name) }
end
+
+ def completed_statuses
+ COMPLETED_STATUSES.map(&:to_sym)
+ end
end
included do
diff --git a/app/models/project.rb b/app/models/project.rb
index da72186c8a0..61d245478ca 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -464,10 +464,12 @@ class Project < ApplicationRecord
# Returns a collection of projects that is either public or visible to the
# logged in user.
- def self.public_or_visible_to_user(user = nil)
+ def self.public_or_visible_to_user(user = nil, min_access_level = nil)
+ min_access_level = nil if user&.admin?
+
if user
where('EXISTS (?) OR projects.visibility_level IN (?)',
- user.authorizations_for_projects,
+ user.authorizations_for_projects(min_access_level: min_access_level),
Gitlab::VisibilityLevel.levels_for_user(user))
else
public_to_user
@@ -477,30 +479,32 @@ class Project < ApplicationRecord
# project features may be "disabled", "internal", "enabled" or "public". If "internal",
# they are only available to team members. This scope returns projects where
# the feature is either public, enabled, or internal with permission for the user.
+ # Note: this scope doesn't enforce that the user has access to the projects, it just checks
+ # that the user has access to the feature. It's important to use this scope with others
+ # that checks project authorizations first.
#
# This method uses an optimised version of `with_feature_access_level` for
# logged in users to more efficiently get private projects with the given
# feature.
def self.with_feature_available_for_user(feature, user)
visible = [ProjectFeature::ENABLED, ProjectFeature::PUBLIC]
- min_access_level = ProjectFeature.required_minimum_access_level(feature)
if user&.admin?
with_feature_enabled(feature)
elsif user
+ min_access_level = ProjectFeature.required_minimum_access_level(feature)
column = ProjectFeature.quoted_access_level_column(feature)
with_project_feature
- .where(
- "(projects.visibility_level > :private AND (#{column} IS NULL OR #{column} >= (:public_visible) OR (#{column} = :private_visible AND EXISTS(:authorizations))))"\
- " OR (projects.visibility_level = :private AND (#{column} IS NULL OR #{column} >= :private_visible) AND EXISTS(:authorizations))",
- {
- private: Gitlab::VisibilityLevel::PRIVATE,
- public_visible: ProjectFeature::ENABLED,
- private_visible: ProjectFeature::PRIVATE,
- authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
- })
+ .where("#{column} IS NULL OR #{column} IN (:public_visible) OR (#{column} = :private_visible AND EXISTS (:authorizations))",
+ {
+ public_visible: visible,
+ private_visible: ProjectFeature::PRIVATE,
+ authorizations: user.authorizations_for_projects(min_access_level: min_access_level)
+ })
else
+ # This has to be added to include features whose value is nil in the db
+ visible << nil
with_feature_access_level(feature, visible)
end
end
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index cbfc1a7c1b2..af705b29f7a 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -133,6 +133,10 @@ class RemoteMirror < ApplicationRecord
end
alias_method :enabled?, :enabled
+ def disabled?
+ !enabled?
+ end
+
def updated_since?(timestamp)
last_update_started_at && last_update_started_at > timestamp && !update_failed?
end
diff --git a/app/models/user.rb b/app/models/user.rb
index 43039f3760e..60f69659a6b 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -230,6 +230,9 @@ class User < ApplicationRecord
delegate :notes_filter_for, to: :user_preference
delegate :set_notes_filter, to: :user_preference
delegate :first_day_of_week, :first_day_of_week=, to: :user_preference
+ delegate :timezone, :timezone=, to: :user_preference
+ delegate :time_display_relative, :time_display_relative=, to: :user_preference
+ delegate :time_format_in_24h, :time_format_in_24h=, to: :user_preference
accepts_nested_attributes_for :user_preference, update_only: true
@@ -757,11 +760,15 @@ class User < ApplicationRecord
# Typically used in conjunction with projects table to get projects
# a user has been given access to.
+ # The param `related_project_column` is the column to compare to the
+ # project_authorizations. By default is projects.id
#
# Example use:
# `Project.where('EXISTS(?)', user.authorizations_for_projects)`
- def authorizations_for_projects(min_access_level: nil)
- authorizations = project_authorizations.select(1).where('project_authorizations.project_id = projects.id')
+ def authorizations_for_projects(min_access_level: nil, related_project_column: 'projects.id')
+ authorizations = project_authorizations
+ .select(1)
+ .where("project_authorizations.project_id = #{related_project_column}")
return authorizations unless min_access_level.present?
diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb
index 282b192167f..f1326f4c8cb 100644
--- a/app/models/user_preference.rb
+++ b/app/models/user_preference.rb
@@ -10,6 +10,10 @@ class UserPreference < ApplicationRecord
validates :issue_notes_filter, :merge_request_notes_filter, inclusion: { in: NOTES_FILTERS.values }, presence: true
+ default_value_for :timezone, value: Time.zone.tzinfo.name, allows_nil: false
+ default_value_for :time_display_relative, value: true, allows_nil: false
+ default_value_for :time_format_in_24h, value: false, allows_nil: false
+
class << self
def notes_filters
{
diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb
index eb2e536e8e9..ea86858181d 100644
--- a/app/policies/group_policy.rb
+++ b/app/policies/group_policy.rb
@@ -129,6 +129,10 @@ class GroupPolicy < BasePolicy
def access_level
return GroupMember::NO_ACCESS if @user.nil?
- @access_level ||= @subject.max_member_access_for_user(@user)
+ @access_level ||= lookup_access_level!
+ end
+
+ def lookup_access_level!
+ @subject.max_member_access_for_user(@user)
end
end
diff --git a/app/uploaders/import_export_uploader.rb b/app/uploaders/import_export_uploader.rb
index 716922bc017..104d5d3b3dd 100644
--- a/app/uploaders/import_export_uploader.rb
+++ b/app/uploaders/import_export_uploader.rb
@@ -7,10 +7,6 @@ class ImportExportUploader < AttachmentUploader
EXTENSION_WHITELIST
end
- def move_to_store
- true
- end
-
def move_to_cache
false
end
diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml
index b950e53639a..c2116ec63dd 100644
--- a/app/views/layouts/nav/sidebar/_group.html.haml
+++ b/app/views/layouts/nav/sidebar/_group.html.haml
@@ -46,6 +46,7 @@
= _('Contribution Analytics')
= render_if_exists 'layouts/nav/group_insights_link'
+ = render_if_exists 'groups/sidebar/dependency_proxy' # EE-specific
= render_if_exists "layouts/nav/ee/epic_link", group: @group
diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml
index bfe1c3ddf33..58f2eb229ba 100644
--- a/app/views/profiles/preferences/show.html.haml
+++ b/app/views/profiles/preferences/show.html.haml
@@ -82,5 +82,31 @@
= f.label :first_day_of_week, class: 'label-bold' do
= _('First day of the week')
= f.select :first_day_of_week, first_day_of_week_choices_with_default, {}, class: 'form-control'
+ - if Feature.enabled?(:user_time_settings)
+ .col-sm-12
+ %hr
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_('Preferences|Time preferences')
+ %p= s_('Preferences|These settings will update how dates and times are displayed for you.')
+ .col-lg-8
+ .form-group
+ %h5= s_('Preferences|Time format')
+ .checkbox-icon-inline-wrapper.form-check
+ - time_format_label = capture do
+ = s_('Preferences|Display time in 24-hour format')
+ = f.check_box :time_format_in_24h, class: 'form-check-input'
+ = f.label :time_format_in_24h do
+ = time_format_label
+ %h5= s_('Preferences|Time display')
+ .checkbox-icon-inline-wrapper.form-check
+ - time_display_label = capture do
+ = s_('Preferences|Use relative times')
+ = f.check_box :time_display_relative, class: 'form-check-input'
+ = f.label :time_display_relative do
+ = time_display_label
+ .text-muted
+ = s_('Preferences|For example: 30 mins ago.')
+ .col-lg-4.profile-settings-sidebar
+ .col-lg-8
.form-group
= f.submit _('Save changes'), class: 'btn btn-success'
diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml
index 02c750a92c3..45b6453b5ff 100644
--- a/app/views/profiles/show.html.haml
+++ b/app/views/profiles/show.html.haml
@@ -64,6 +64,18 @@
prepend: emoji_button,
append: reset_message_button,
placeholder: s_("Profiles|What's your status?")
+ - if Feature.enabled?(:user_time_settings)
+ %hr
+ .row.user-time-preferences
+ .col-lg-4.profile-settings-sidebar
+ %h4.prepend-top-0= s_("Profiles|Time settings")
+ %p= s_("Profiles|You can set your current timezone here")
+ .col-lg-8
+ -# TODO: might need an entry in user/profile.md to describe some of these settings
+ -# https://gitlab.com/gitlab-org/gitlab-ce/issues/60070
+ %h5= ("Time zone")
+ = dropdown_tag(_("Select a timezone"), options: { toggle_class: 'btn js-timezone-dropdown input-lg', title: _("Select a timezone"), filter: true, placeholder: s_("OfSearchInADropdown|Filter"), data: { data: timezone_data } } )
+ %input.hidden{ :type => 'hidden', :id => 'user_timezone', :name => 'user[timezone]', value: @user.timezone }
%hr
.row
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 715c36fa9aa..d55afee4523 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -79,7 +79,7 @@
= render_if_exists 'projects/issues/related_issues'
- #js-related-merge-requests{ data: { endpoint: expose_url(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
+ #js-related-merge-requests{ data: { endpoint: expose_path(api_v4_projects_issues_related_merge_requests_path(id: @project.id, issue_iid: @issue.iid)), project_namespace: @project.namespace.path, project_path: @project.path } }
- if can?(current_user, :download_code, @project)
#related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } }
diff --git a/app/views/projects/mirrors/_disabled_mirror_badge.html.haml b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
new file mode 100644
index 00000000000..356cb43f07f
--- /dev/null
+++ b/app/views/projects/mirrors/_disabled_mirror_badge.html.haml
@@ -0,0 +1 @@
+.badge.badge-warning.qa-disabled-mirror-badge{ data: { toggle: 'tooltip', html: 'true' }, title: _('Disabled mirrors can only be enabled by instance owners. It is recommended that you delete them.') }= _('Disabled')
diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml
index 0cd00d3e708..73e2a4ffb8b 100644
--- a/app/views/projects/mirrors/_mirror_repos.html.haml
+++ b/app/views/projects/mirrors/_mirror_repos.html.haml
@@ -49,17 +49,19 @@
%tbody.js-mirrors-table-body
= render_if_exists 'projects/mirrors/table_pull_row'
- @project.remote_mirrors.each_with_index do |mirror, index|
- - if mirror.enabled
- %tr.qa-mirrored-repository-row
- %td.qa-mirror-repository-url= mirror.safe_url
- %td= _('Push')
- %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
- %td
- - if mirror.last_error.present?
- .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
- %td
- .btn-group.mirror-actions-group.pull-right{ role: 'group' }
- - if mirror.ssh_key_auth?
- = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
- = render 'shared/remote_mirror_update_button', remote_mirror: mirror
- %button.js-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
+ - next if mirror.new_record?
+ %tr.qa-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?) }
+ %td.qa-mirror-repository-url= mirror.safe_url
+ %td= _('Push')
+ %td.qa-mirror-last-update-at= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never')
+ %td
+ - if mirror.disabled?
+ = render 'projects/mirrors/disabled_mirror_badge'
+ - if mirror.last_error.present?
+ .badge.mirror-error-badge{ data: { toggle: 'tooltip', html: 'true' }, title: html_escape(mirror.last_error.try(:strip)) }= _('Error')
+ %td
+ .btn-group.mirror-actions-group.pull-right{ role: 'group' }
+ - if mirror.ssh_key_auth?
+ = clipboard_button(text: mirror.ssh_public_key, class: 'btn btn-default', title: _('Copy SSH public key'))
+ = render 'shared/remote_mirror_update_button', remote_mirror: mirror
+ %button.js-delete-mirror.qa-delete-mirror.btn.btn-danger{ type: 'button', data: { mirror_id: mirror.id, toggle: 'tooltip', container: 'body' }, title: _('Remove') }= icon('trash-o')
diff --git a/app/views/projects/settings/operations/_external_dashboard.html.haml b/app/views/projects/settings/operations/_external_dashboard.html.haml
new file mode 100644
index 00000000000..2fbb9195a04
--- /dev/null
+++ b/app/views/projects/settings/operations/_external_dashboard.html.haml
@@ -0,0 +1,2 @@
+.js-operation-settings{ data: { external_dashboard: { path: '',
+ help_page_path: help_page_path('user/project/operations/link_to_external_dashboard') } } }
diff --git a/app/views/projects/settings/operations/show.html.haml b/app/views/projects/settings/operations/show.html.haml
index 6f777305a54..edc2c58a8ed 100644
--- a/app/views/projects/settings/operations/show.html.haml
+++ b/app/views/projects/settings/operations/show.html.haml
@@ -4,4 +4,5 @@
= render_if_exists 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking', expanded: true
+= render 'projects/settings/operations/external_dashboard'
= render_if_exists 'projects/settings/operations/tracing'
diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml
index 721a2af8069..8da2ae5111a 100644
--- a/app/views/shared/_remote_mirror_update_button.html.haml
+++ b/app/views/shared/_remote_mirror_update_button.html.haml
@@ -1,6 +1,6 @@
- if remote_mirror.update_in_progress?
%button.btn.disabled{ type: 'button', data: { toggle: 'tooltip', container: 'body' }, title: _('Updating') }
= icon("refresh spin")
-- else
+- elsif remote_mirror.enabled?
= link_to update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: "btn qa-update-now-button", data: { toggle: 'tooltip', container: 'body' }, title: _('Update now') do
= icon("refresh")