Commit f857808f authored by Alessio Caiazza's avatar Alessio Caiazza

Merge remote-tracking branch 'origin/master' into ac-update-master

parents d411f1c5 5b905654
......@@ -35,7 +35,6 @@ eslint-report.html
/config/gitlab.yml
/config/gitlab_ci.yml
/config/Gitlab.gitlab-license
/config/initializers/rack_attack.rb
/config/initializers/smtp_settings.rb
/config/initializers/relative_url.rb
/config/resque.yml
......@@ -92,3 +91,4 @@ jsdoc/
webpack-dev-server.json
/.nvimrc
.solargraph.yml
apollo.config.js
......@@ -43,6 +43,7 @@
"Consul",
"Debian",
"DevOps",
"Docker",
"Elasticsearch",
"Facebook",
"GDK",
......
......@@ -4,36 +4,27 @@ import {
GlAlert,
GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlSprintf,
GlTabs,
GlTab,
GlButton,
GlTable,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import query from '../graphql/queries/details.query.graphql';
import { fetchPolicies } from '~/lib/graphql';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
ALERTS_SEVERITY_LABELS,
trackAlertsDetailsViewsOptions,
trackAlertStatusUpdateOptions,
} from '../constants';
import updateAlertStatus from '../graphql/mutations/update_alert_status.graphql';
import { ALERTS_SEVERITY_LABELS, trackAlertsDetailsViewsOptions } from '../constants';
import createIssueQuery from '../graphql/mutations/create_issue_from_alert.graphql';
import { visitUrl, joinPaths } from '~/lib/utils/url_utility';
import Tracking from '~/tracking';
import { toggleContainerClasses } from '~/lib/utils/dom_utils';
import AlertSidebar from './alert_sidebar.vue';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
export default {
statuses: {
TRIGGERED: s__('AlertManagement|Triggered'),
ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
RESOLVED: s__('AlertManagement|Resolved'),
},
i18n: {
errorMsg: s__(
'AlertManagement|There was an error displaying the alert. Please refresh the page to try again.',
......@@ -49,13 +40,12 @@ export default {
GlIcon,
GlLoadingIcon,
GlSprintf,
GlDropdown,
GlDropdownItem,
GlTab,
GlTabs,
GlButton,
GlTable,
TimeAgoTooltip,
AlertSidebar,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -98,6 +88,8 @@ export default {
isErrorDismissed: false,
createIssueError: '',
issueCreationInProgress: false,
sidebarCollapsed: false,
sidebarErrorMessage: '',
};
},
computed: {
......@@ -115,31 +107,26 @@ export default {
},
mounted() {
this.trackPageViews();
toggleContainerClasses(containerEl, {
'issuable-bulk-update-sidebar': true,
'right-sidebar-expanded': true,
});
},
methods: {
dismissError() {
this.isErrorDismissed = true;
this.sidebarErrorMessage = '';
},
updateAlertStatus(status) {
this.$apollo
.mutate({
mutation: updateAlertStatus,
variables: {
iid: this.alertId,
status: status.toUpperCase(),
projectPath: this.projectPath,
},
})
.then(() => {
this.trackStatusUpdate(status);
})
.catch(() => {
createFlash(
s__(
'AlertManagement|There was an error while updating the status of the alert. Please try again.',
),
);
});
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
toggleContainerClasses(containerEl, {
'right-sidebar-collapsed': this.sidebarCollapsed,
'right-sidebar-expanded': !this.sidebarCollapsed,
});
},
handleAlertSidebarError(errorMessage) {
this.errored = true;
this.sidebarErrorMessage = errorMessage;
},
createIssue() {
this.issueCreationInProgress = true;
......@@ -172,17 +159,14 @@ export default {
const { category, action } = trackAlertsDetailsViewsOptions;
Tracking.event(category, action);
},
trackStatusUpdate(status) {
const { category, action, label } = trackAlertStatusUpdateOptions;
Tracking.event(category, action, { label, property: status });
},
},
};
</script>
<template>
<div>
<gl-alert v-if="showErrorMsg" variant="danger" @dismiss="dismissError">
{{ $options.i18n.errorMsg }}
{{ sidebarErrorMessage || $options.i18n.errorMsg }}
</gl-alert>
<gl-alert
v-if="createIssueError"
......@@ -243,6 +227,16 @@ export default {
{{ s__('AlertManagement|Create issue') }}
</gl-button>
</div>
<gl-button
:aria-label="__('Toggle sidebar')"
category="primary"
variant="default"
class="d-sm-none position-absolute toggle-sidebar-mobile-button"
type="button"
@click="toggleSidebar"
>
<i class="fa fa-angle-double-left"></i>
</gl-button>
</div>
<div
v-if="alert"
......@@ -250,24 +244,6 @@ export default {
>
<h2 data-testid="title">{{ alert.title }}</h2>
</div>
<gl-dropdown :text="$options.statuses[alert.status]" class="gl-absolute gl-right-0" right>
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
data-testid="statusDropdownItem"
class="gl-vertical-align-middle"
@click="updateAlertStatus(label)"
>
<span class="d-flex">
<gl-icon
class="flex-shrink-0 append-right-4"
:class="{ invisible: label.toUpperCase() !== alert.status }"
name="mobile-issue-close"
/>
{{ label }}
</span>
</gl-dropdown-item>
</gl-dropdown>
<gl-tabs v-if="alert" data-testid="alertDetailsTabs">
<gl-tab data-testid="overviewTab" :title="$options.i18n.overviewTitle">
<ul class="pl-4 mb-n1">
......@@ -306,6 +282,13 @@ export default {
</gl-table>
</gl-tab>
</gl-tabs>
<alert-sidebar
:project-path="projectPath"
:alert="alert"
:sidebar-collapsed="sidebarCollapsed"
@toggle-sidebar="toggleSidebar"
@alert-sidebar-error="handleAlertSidebarError"
/>
</div>
</div>
</template>
......@@ -10,7 +10,7 @@ import {
GlDropdownItem,
GlTabs,
GlTab,
GlBadge,
GlDeprecatedBadge as GlBadge,
} from '@gitlab/ui';
import createFlash from '~/flash';
import { s__ } from '~/locale';
......@@ -32,7 +32,7 @@ import Tracking from '~/tracking';
const tdClass = 'table-col d-flex d-md-table-cell align-items-center';
const bodyTrClass =
'gl-border-1 gl-border-t-solid gl-border-gray-100 hover-bg-blue-50 hover-gl-cursor-pointer hover-gl-border-b-solid hover-gl-border-blue-200';
'gl-border-1 gl-border-t-solid gl-border-gray-100 gl-hover-bg-blue-50 gl-hover-cursor-pointer gl-hover-border-b-solid gl-hover-border-blue-200';
const findDefaultSortColumn = () => document.querySelector('.js-started-at');
export default {
......@@ -52,14 +52,14 @@ export default {
sortable: true,
},
{
key: 'startTime',
key: 'startedAt',
label: s__('AlertManagement|Start time'),
thClass: 'js-started-at',
tdClass,
sortable: true,
},
{
key: 'endTime',
key: 'endedAt',
label: s__('AlertManagement|End time'),
tdClass,
sortable: true,
......@@ -72,7 +72,7 @@ export default {
sortable: false,
},
{
key: 'eventsCount',
key: 'eventCount',
label: s__('AlertManagement|Events'),
thClass: 'text-right gl-pr-9 w-3rem',
tdClass: `${tdClass} text-md-right`,
......@@ -164,7 +164,7 @@ export default {
errored: false,
isAlertDismissed: false,
isErrorAlertDismissed: false,
sort: 'START_TIME_ASC',
sort: 'STARTED_AT_ASC',
statusFilter: this.$options.statusTabs[4].filters,
};
},
......@@ -199,7 +199,7 @@ export default {
const sortDirection = sortDesc ? 'DESC' : 'ASC';
const sortColumn = convertToSnakeCase(sortBy).toUpperCase();
if (sortBy !== 'startTime') {
if (sortBy !== 'startedAt') {
findDefaultSortColumn().ariaSort = 'none';
}
this.sort = `${sortColumn}_${sortDirection}`;
......@@ -294,15 +294,15 @@ export default {
</div>
</template>
<template #cell(startTime)="{ item }">
<template #cell(startedAt)="{ item }">
<time-ago v-if="item.startedAt" :time="item.startedAt" />
</template>
<template #cell(endTime)="{ item }">
<template #cell(endedAt)="{ item }">
<time-ago v-if="item.endedAt" :time="item.endedAt" />
</template>
<!-- TODO: Remove after: https://gitlab.com/gitlab-org/gitlab/-/issues/218467 -->
<template #cell(eventsCount)="{ item }">
<template #cell(eventCount)="{ item }">
{{ item.eventCount }}
</template>
......
<script>
import SidebarHeader from './sidebar/sidebar_header.vue';
import SidebarTodo from './sidebar/sidebar_todo.vue';
import SidebarStatus from './sidebar/sidebar_status.vue';
export default {
components: {
SidebarHeader,
SidebarTodo,
SidebarStatus,
},
props: {
sidebarCollapsed: {
type: Boolean,
required: true,
},
projectPath: {
type: String,
required: true,
},
alert: {
type: Object,
required: true,
},
},
computed: {
sidebarCollapsedClass() {
return this.sidebarCollapsed ? 'right-sidebar-collapsed' : 'right-sidebar-expanded';
},
},
methods: {
handleAlertSidebarError(errorMessage) {
this.$emit('alert-sidebar-error', errorMessage);
},
},
};
</script>
<template>
<aside :class="sidebarCollapsedClass" class="right-sidebar alert-sidebar">
<div class="issuable-sidebar js-issuable-update">
<sidebar-header
:sidebar-collapsed="sidebarCollapsed"
@toggle-sidebar="$emit('toggle-sidebar')"
/>
<sidebar-todo v-if="sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" />
<sidebar-status
:project-path="projectPath"
:alert="alert"
@toggle-sidebar="$emit('toggle-sidebar')"
@alert-sidebar-error="handleAlertSidebarError"
/>
<!-- TODO: Remove after adding extra attribute blocks to sidebar -->
<div class="block"></div>
</div>
</aside>
</template>
<script>
import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
import SidebarTodo from './sidebar_todo.vue';
export default {
components: {
ToggleSidebar,
SidebarTodo,
},
props: {
sidebarCollapsed: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<div class="block">
<span class="issuable-header-text hide-collapsed float-left">
{{ __('Quick actions') }}
</span>
<toggle-sidebar
:collapsed="sidebarCollapsed"
css-classes="float-right"
@toggle="$emit('toggle-sidebar')"
/>
<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
<template v-if="false">
<sidebar-todo v-if="!sidebarCollapsed" :sidebar-collapsed="sidebarCollapsed" />
</template>
</div>
</template>
<script>
import {
GlIcon,
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlTooltip,
GlButton,
GlSprintf,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
import { trackAlertStatusUpdateOptions } from '../../constants';
import updateAlertStatus from '../../graphql/mutations/update_alert_status.graphql';
export default {
statuses: {
TRIGGERED: s__('AlertManagement|Triggered'),
ACKNOWLEDGED: s__('AlertManagement|Acknowledged'),
RESOLVED: s__('AlertManagement|Resolved'),
},
components: {
GlIcon,
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlTooltip,
GlButton,
GlSprintf,
},
props: {
projectPath: {
type: String,
required: true,
},
alert: {
type: Object,
required: true,
},
isEditable: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
isDropdownShowing: false,
isUpdating: false,
};
},
computed: {
dropdownClass() {
return this.isDropdownShowing ? 'show' : 'd-none';
},
},
methods: {
hideDropdown() {
this.isDropdownShowing = false;
},
toggleFormDropdown() {
this.isDropdownShowing = !this.isDropdownShowing;
const { dropdown } = this.$refs.dropdown.$refs;
if (dropdown && this.isDropdownShowing) {
dropdown.show();
}
},
isSelected(status) {
return this.alert.status === status;
},
updateAlertStatus(status) {
this.isUpdating = true;
this.$apollo
.mutate({
mutation: updateAlertStatus,
variables: {
iid: this.alert.iid,
status: status.toUpperCase(),
projectPath: this.projectPath,
},
})
.then(() => {
this.trackStatusUpdate(status);
this.hideDropdown();
})
.catch(() => {
this.$emit(
'alert-sidebar-error',
s__(
'AlertManagement|There was an error while updating the status of the alert. Please try again.',
),
);
})
.finally(() => {
this.isUpdating = false;
});
},
trackStatusUpdate(status) {
const { category, action, label } = trackAlertStatusUpdateOptions;
Tracking.event(category, action, { label, property: status });
},
},
};
</script>
<template>
<div class="block alert-status">
<div ref="status" class="sidebar-collapsed-icon" @click="$emit('toggle-sidebar')">
<gl-icon name="status" :size="14" />
<gl-loading-icon v-if="isUpdating" />
</div>
<gl-tooltip :target="() => $refs.status" boundary="viewport" placement="left">
<gl-sprintf :message="s__('AlertManagement|Alert status: %{status}')">
<template #status>
{{ alert.status.toLowerCase() }}
</template>
</gl-sprintf>
</gl-tooltip>
<div class="hide-collapsed">
<p class="title gl-display-flex justify-content-between">
{{ s__('AlertManagement|Status') }}
<a
v-if="isEditable"
ref="editButton"
class="btn-link"
href="#"
@click="toggleFormDropdown"
@keydown.esc="hideDropdown"
>
{{ s__('AlertManagement|Edit') }}
</a>
</p>
<div class="dropdown dropdown-menu-selectable" :class="dropdownClass">
<gl-dropdown
ref="dropdown"
:text="$options.statuses[alert.status]"
class="w-100"
toggle-class="dropdown-menu-toggle"
variant="outline-default"
@keydown.esc.native="hideDropdown"
@hide="hideDropdown"
>
<div class="dropdown-title">
<span class="alert-title">{{ s__('AlertManagement|Assign status') }}</span>
<gl-button
:aria-label="__('Close')"
variant="link"
class="dropdown-title-button dropdown-menu-close"
icon="close"
@click="hideDropdown"
/>
</div>
<div class="dropdown-content dropdown-body">
<gl-dropdown-item
v-for="(label, field) in $options.statuses"
:key="field"
data-testid="statusDropdownItem"
class="gl-vertical-align-middle"
:active="label.toUpperCase() === alert.status"
:active-class="'is-active'"
@click="updateAlertStatus(label)"
>
{{ label }}
</gl-dropdown-item>
</div>
</gl-dropdown>
</div>
<gl-loading-icon v-if="isUpdating" :inline="true" />
<p
v-else-if="!isDropdownShowing"
class="value m-0"
:class="{ 'no-value': !$options.statuses[alert.status] }"
>
<span v-if="$options.statuses[alert.status]" class="gl-text-gray-700">{{
$options.statuses[alert.status]
}}</span>
<span v-else>
{{ s__('AlertManagement|None') }}
</span>
</p>
</div>
</div>
</template>
<script>
import Todo from '~/sidebar/components/todo_toggle/todo.vue';
export default {
components: {
Todo,
},
props: {
sidebarCollapsed: {
type: Boolean,
required: true,
},
},
};
</script>
<!-- TODO: Implement after or as part of: https://gitlab.com/gitlab-org/gitlab/-/issues/215946 -->
<template>
<div v-if="false" :class="{ 'block todo': sidebarCollapsed }">
<todo
:collapsed="sidebarCollapsed"
:issuable-id="1"
:is-todo="false"
:is-action-active="false"
issuable-type="alert"
@toggleTodo="() => {}"
/>
</div>
</template>
......@@ -36,7 +36,7 @@ class ListIssue {
}
findLabel(findLabel) {
return this.labels.find(label => label.id === findLabel.id);
return boardsStore.findIssueLabel(this, findLabel);
}
removeLabel(removeLabel) {
......@@ -50,9 +50,7 @@ class ListIssue {
}
addAssignee(assignee) {
if (!this.findAssignee(assignee)) {
this.assignees.push(new ListAssignee(assignee));
}
boardsStore.addIssueAssignee(this, assignee);
}
findAssignee(findAssignee) {
......
......@@ -93,10 +93,7 @@ class List {
}
update() {
const collapsed = !this.isExpanded;
return boardsStore.updateList(this.id, this.position, collapsed).catch(() => {
// TODO: handle request error
});
return boardsStore.updateListFunc(this);
}
nextPage() {
......@@ -114,13 +111,7 @@ class List {
}
newIssue(issue) {
this.addIssue(issue, null, 0);
this.issuesSize += 1;
return boardsStore
.newIssue(this.id, issue)
.then(res => res.data)
.then(data => this.onNewIssueResponse(issue, data));
return boardsStore.newListIssue(this, issue);
}
createIssues(data) {
......@@ -138,12 +129,7 @@ class List {
}
moveIssue(issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
this.issues.splice(oldIndex, 1);
this.issues.splice(newIndex, 0, issue);
boardsStore.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
// TODO: handle request error
});
boardsStore.moveListIssues(this, issue, oldIndex, newIndex, moveBeforeId, moveAfterId);
}
moveMultipleIssues({ issues, oldIndicies, newIndex, moveBeforeId, moveAfterId }) {
......@@ -182,7 +168,7 @@ class List {
}
findIssue(id) {
return this.issues.find(issue => issue.id === id);
return boardsStore.findListIssue(this, id);
}
removeMultipleIssues(removeIssues) {
......@@ -201,16 +187,7 @@ class List {
}
removeIssue(removeIssue) {
this.issues = this.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) {
this.issuesSize -= 1;
issue.removeLabel(this.label);
}
return !matchesRemove;
});
return boardsStore.removeListIssues(this, removeIssue);
}
getTypeInfo(type) {
......
......@@ -133,6 +133,10 @@ const boardsStore = {
path: '',
});
},
findIssueLabel(issue, findLabel) {
return issue.labels.find(label => label.id === findLabel.id);
},
addListIssue(list, issue, listFrom, newIndex) {
let moveBeforeId = null;
let moveAfterId = null;
......@@ -177,6 +181,10 @@ const boardsStore = {
}
}
},
findListIssue(list, id) {
return list.issues.find(issue => issue.id === id);
},
welcomeIsHidden() {
return parseBoolean(Cookies.get('issue_board_welcome_hidden'));
},
......@@ -243,6 +251,19 @@ const boardsStore = {
}
},
removeListIssues(list, removeIssue) {
list.issues = list.issues.filter(issue => {
const matchesRemove = removeIssue.id === issue.id;
if (matchesRemove) {
list.issuesSize -= 1;
issue.removeLabel(list.label);
}
return !matchesRemove;
});
},
startMoving(list, issue) {
Object.assign(this.moving, { list, issue });
},
......@@ -516,6 +537,13 @@ const boardsStore = {
});
},
updateListFunc(list) {
const collapsed = !list.isExpanded;
return this.updateList(list.id, list.position, collapsed).catch(() => {
// TODO: handle request error
});
},
destroyList(id) {
return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
},
......@@ -591,6 +619,15 @@ const boardsStore = {
});
},
moveListIssues(list, issue, oldIndex, newIndex, moveBeforeId, moveAfterId) {
list.issues.splice(oldIndex, 1);
list.issues.splice(newIndex, 0, issue);
this.moveIssue(issue.id, null, null, moveBeforeId, moveAfterId).catch(() => {
// TODO: handle request error
});
},
moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId }) {
return axios.put(this.generateMultiDragPath(this.state.endpoints.boardId), {
from_list_id: fromListId,
......@@ -607,6 +644,15 @@ const boardsStore = {
});
},
newListIssue(list, issue) {
list.addIssue(issue, null, 0);
list.issuesSize += 1;
return this.newIssue(list.id, issue)
.then(res => res.data)
.then(data => list.onNewIssueResponse(issue, data));
},
getBacklog(data) {
return axios.get(
mergeUrlParams(
......@@ -616,6 +662,12 @@ const boardsStore = {
);
},
addIssueAssignee(issue, assignee) {
if (!issue.findAssignee(assignee)) {
issue.assignees.push(new ListAssignee(assignee));
}
},
bulkUpdate(issueIds, extraData = {}) {
const data = {
update: Object.assign(extraData, {
......
......@@ -8,8 +8,9 @@ import identicon from '../../vue_shared/components/identicon.vue';
import loadingButton from '../../vue_shared/components/loading_button.vue';
import UninstallApplicationButton from './uninstall_application_button.vue';
import UninstallApplicationConfirmationModal from './uninstall_application_confirmation_modal.vue';
import UpdateApplicationConfirmationModal from './update_application_confirmation_modal.vue';
import { APPLICATION_STATUS } from '../constants';
import { APPLICATION_STATUS, ELASTIC_STACK } from '../constants';
export default {
components: {
......@@ -18,6 +19,7 @@ export default {
GlLink,
UninstallApplicationButton,
UninstallApplicationConfirmationModal,
UpdateApplicationConfirmationModal,
},
directives: {
GlModalDirective,
......@@ -233,6 +235,17 @@ export default {
return label;
},
updatingNeedsConfirmation() {
if (this.version) {
const majorVersion = parseInt(this.version.split('.')[0], 10);
if (!Number.isNaN(majorVersion)) {
return this.id === ELASTIC_STACK && majorVersion < 3;
}
}
return false;
},
isUpdating() {
// Since upgrading is handled asynchronously on the backend we need this check to prevent any delay on the frontend
return this.status === APPLICATION_STATUS.UPDATING;
......@@ -248,6 +261,12 @@ export default {
title: this.title,
});
},
updateModalId() {
return `update-${this.id}`;
},
uninstallModalId() {
return `uninstall-${this.id}`;
},
},
watch: {
updateSuccessful(updateSuccessful) {
......@@ -268,7 +287,7 @@ export default {
params: this.installApplicationRequestParams,
});
},
updateClicked() {
updateConfirmed() {
eventHub.$emit('updateApplication', {
id: this.id,
params: this.installApplicationRequestParams,
......@@ -356,14 +375,36 @@ export default {
>
{{ updateFailureDescription }}
</div>
<loading-button
v-if="updateAvailable || updateFailed || isUpdating"
class="btn btn-primary js-cluster-application-update-button mt-2"
:loading="isUpdating"
:disabled="isUpdating"
:label="updateButtonLabel"
@click="updateClicked"
/>
<template v-if="updateAvailable || updateFailed || isUpdating">
<template v-if="updatingNeedsConfirmation">
<loading-button
v-gl-modal-directive="updateModalId"
class="btn btn-primary js-cluster-application-update-button mt-2"
:loading="isUpdating"
:disabled="isUpdating"
:label="updateButtonLabel"
data-qa-selector="update_button_with_confirmation"
:data-qa-application="id"
/>
<update-application-confirmation-modal
:application="id"
:application-title="title"
@confirm="updateConfirmed()"
/>
</template>
<loading-button
v-else
class="btn btn-primary js-cluster-application-update-button mt-2"
:loading="isUpdating"
:disabled="isUpdating"
:label="updateButtonLabel"
data-qa-selector="update_button"
:data-qa-application="id"
@click="updateConfirmed"
/>
</template>
</div>
</div>
<div
......@@ -389,7 +430,7 @@ export default {
/>
<uninstall-application-button
v-if="displayUninstallButton"
v-gl-modal-directive="'uninstall-' + id"
v-gl-modal-directive="uninstallModalId"
:status="status"
data-qa-selector="uninstall_button"
:data-qa-application="id"
......
<script>
import { GlModal } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
import { ELASTIC_STACK } from '../constants';
const CUSTOM_APP_WARNING_TEXT = {
[ELASTIC_STACK]: s__(
'ClusterIntegration|Your Elasticsearch cluster will be re-created during this upgrade. Your logs will be re-indexed, and you will lose historical logs from hosts terminated in the last 30 days.',
),
};
export default {
components: {
GlModal,
},
props: {
application: {
type: String,
required: true,
},
applicationTitle: {
type: String,
required: true,
},
},
computed: {
title() {
return sprintf(s__('ClusterIntegration|Update %{appTitle}'), {
appTitle: this.applicationTitle,
});
},
warningText() {
return sprintf(
s__('ClusterIntegration|You are about to update %{appTitle} on your cluster.'),
{
appTitle: this.applicationTitle,
},
);
},
customAppWarningText() {
return CUSTOM_APP_WARNING_TEXT[this.application];
},
modalId() {
return `update-${this.application}`;
},
},
methods: {
confirmUpdate() {
this.$emit('confirm');
},
},
};
</script>
<template>
<gl-modal
ok-variant="danger"
cancel-variant="light"
:ok-title="title"
:modal-id="modalId"
:title="title"
@ok="confirmUpdate()"
>
{{ warningText }} <span v-html="customAppWarningText"></span>
</gl-modal>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlBadge, GlLink, GlLoadingIcon, GlPagination, GlTable } from '@gitlab/ui';
import {
GlDeprecatedBadge as GlBadge,
GlLink,
GlLoadingIcon,
GlPagination,
GlTable,
} from '@gitlab/ui';
import tooltip from '~/vue_shared/directives/tooltip';
import { CLUSTER_TYPES, STATUSES } from '../constants';
import { __, sprintf } from '~/locale';
......
......@@ -8,10 +8,10 @@ export const severityLevel = {
export const severityLevelVariant = {
[severityLevel.FATAL]: 'danger',
[severityLevel.ERROR]: 'dark',
[severityLevel.ERROR]: 'neutral',
[severityLevel.WARNING]: 'warning',
[severityLevel.INFO]: 'info',
[severityLevel.DEBUG]: 'light',
[severityLevel.DEBUG]: 'muted',
};
export const errorStatus = {
......
......@@ -363,16 +363,10 @@ export default {
<h2 class="text-truncate">{{ error.title }}</h2>
</tooltip-on-truncate>
<template v-if="error.tags">
<gl-badge
v-if="error.tags.level"
:variant="errorSeverityVariant"
class="rounded-pill mr-2"
>
<gl-badge v-if="error.tags.level" :variant="errorSeverityVariant" class="mr-2">
{{ errorLevel }}
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="light" class="rounded-pill"
>{{ error.tags.logger }}
</gl-badge>
<gl-badge v-if="error.tags.logger" variant="muted">{{ error.tags.logger }} </gl-badge>
</template>
<ul>
<li v-if="error.gitlabCommit">
......
......@@ -75,7 +75,7 @@ export default {
>
{{ stage.name }}
</strong>
<div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 prepend-left-4">
<div v-if="!stage.isLoading || stage.jobs.length" class="append-right-8 gl-ml-2">
<span class="badge badge-pill"> {{ jobsCount }} </span>
</div>
<icon :name="collapseIcon" class="ide-stage-collapse-icon" />
......
......@@ -119,7 +119,7 @@ export default {
>
<icon :size="18" name="retry" class="m-auto" />
</button>
<div class="position-relative w-100 prepend-left-4">
<div class="position-relative w-100 gl-ml-2">
<input
:value="path || '/'"
type="text"
......
......@@ -12,3 +12,16 @@ export const canScrollUp = ({ scrollTop }, margin = 0) => scrollTop > margin;
export const canScrollDown = ({ scrollTop, offsetHeight, scrollHeight }, margin = 0) =>
scrollTop + offsetHeight < scrollHeight - margin;
export const toggleContainerClasses = (containerEl, classList) => {
if (containerEl) {
// eslint-disable-next-line array-callback-return
Object.entries(classList).map(([key, value]) => {
if (value) {
containerEl.classList.add(key);
} else {
containerEl.classList.remove(key);
}
});
}
};
......@@ -234,11 +234,7 @@ export default {
class="alert-current-setting cursor-pointer d-flex"
@click="showModal"
>
<gl-badge
:variant="isFiring ? 'danger' : 'secondary'"
pill
class="d-flex-center text-truncate"
>
<gl-badge :variant="isFiring ? 'danger' : 'neutral'" class="d-flex-center text-truncate">
<gl-icon name="warning" :size="16" class="flex-shrink-0" />
<span class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me">
<gl-sprintf
......
......@@ -238,7 +238,7 @@ export default {
<icon
v-gl-tooltip="$options.alertQueryText.descriptionTooltip"
name="question"
class="prepend-left-4"
class="gl-ml-2"
/>
</div>
</template>
......
......@@ -6,8 +6,9 @@ import {
GlResizeObserverDirective,
GlIcon,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
GlNewDropdown as GlDropdown,
GlNewDropdownItem as GlDropdownItem,
GlNewDropdownDivider as GlDropdownDivider,
GlModal,
GlModalDirective,
GlTooltip,
......@@ -28,6 +29,7 @@ import MonitorStackedColumnChart from './charts/stacked_column.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
import AlertWidget from './alert_widget.vue';
import { timeRangeToUrl, downloadCSVOptions, generateLinkToChartOptions } from '../utils';
import { isSafeURL } from '~/lib/utils/url_utility';
const events = {
timeRangeZoom: 'timerangezoom',
......@@ -43,6 +45,7 @@ export default {
GlTooltip,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlModal,
},
directives: {
......@@ -118,6 +121,9 @@ export default {
metricsSavedToDb(state, getters) {
return getters[`${this.namespace}/metricsSavedToDb`];
},
selectedDashboard(state, getters) {
return getters[`${this.namespace}/selectedDashboard`];
},
}),
title() {
return this.graphData?.title || '';
......@@ -266,6 +272,9 @@ export default {
this.$delete(this.allAlerts, alertPath);
}
},
safeUrl(url) {
return isSafeURL(url) ? url : '#';
},
},
panelTypes,
};
......@@ -305,14 +314,13 @@ export default {
<div class="d-flex align-items-center">
<gl-dropdown
v-gl-tooltip
toggle-class="btn btn-transparent border-0"
toggle-class="shadow-none border-0"
data-qa-selector="prometheus_widgets_dropdown"
right
no-caret
:title="__('More actions')"
>
<template slot="button-content">
<gl-icon name="ellipsis_v" class="text-secondary" />
<gl-icon name="ellipsis_v" class="dropdown-icon text-secondary" />
</template>
<gl-dropdown-item
v-if="expandBtnAvailable"
......@@ -363,6 +371,23 @@ export default {
>
{{ __('Alerts') }}
</gl-dropdown-item>
<template v-if="graphData.links.length">
<gl-dropdown-divider />
<gl-dropdown-item
v-for="(link, index) in graphData.links"
:key="index"
:href="safeUrl(link.url)"
class="text-break"
>{{ link.title }}</gl-dropdown-item
>
</template>
<template v-if="selectedDashboard && selectedDashboard.can_edit">
<gl-dropdown-divider />
<gl-dropdown-item ref="manageLinksItem" :href="selectedDashboard.project_blob_path">{{
s__('Metrics|Manage chart links')
}}</gl-dropdown-item>
</template>
</gl-dropdown>
</div>
</div>
......
......@@ -3,6 +3,7 @@ import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { NOT_IN_DB_PREFIX } from '../constants';
import { isSafeURL } from '~/lib/utils/url_utility';
export const gqClient = createGqClient(
{},
......@@ -137,6 +138,23 @@ const mapYAxisToViewModel = ({
};
};
/**
* Maps a link to its view model, expects an url and
* (optionally) a title.
*
* Unsafe URLs are ignored.
*
* @param {Object} Link
* @returns {Object} Link object with a `title` and `url`.
*
*/
const mapLinksToViewModel = ({ url = null, title = '' } = {}) => {
return {
title: title || String(url),
url: url && isSafeURL(url) ? String(url) : '#',
};
};
/**
* Maps a metrics panel to its view model
*
......@@ -152,6 +170,7 @@ const mapPanelToViewModel = ({
y_label,
y_axis = {},
metrics = [],
links = [],
max_value,
}) => {
// Both `x_axis.name` and `x_label` are supported for now
......@@ -171,6 +190,7 @@ const mapPanelToViewModel = ({
yAxis,
xAxis,
maxValue: max_value,
links: links.map(mapLinksToViewModel),
metrics: mapToMetricsViewModel(metrics, yAxis.name),
};
};
......
......@@ -110,8 +110,8 @@ export default {
<gl-new-dropdown
:text="dropdownText"
:disabled="hasSearchParam"
toggle-class="gl-py-3"
class="gl-dropdown w-100 mt-2 mt-sm-0"
toggle-class="gl-py-3 gl-border-0"
class="w-100 mt-2 mt-sm-0"
>
<gl-new-dropdown-header>
{{ __('Search by author') }}
......
......@@ -3,6 +3,7 @@ import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
export default {
setInitialData({ commit }, data) {
......@@ -16,10 +17,8 @@ export default {
},
fetchAuthors({ dispatch, state }, author = null) {
const { projectId } = state;
const path = '/autocomplete/users.json';
return axios
.get(path, {
.get(joinPaths(gon.relative_url_root || '', '/autocomplete/users.json'), {
params: {
project_id: projectId,
active: true,
......
......@@ -30,7 +30,7 @@ export default {
<gl-sprintf :message="__('by %{user}')">
<template #user>
<user-avatar-link
class="prepend-left-4"
class="gl-ml-2"
:link-href="author.webUrl"
:img-src="author.avatarUrl"
:img-alt="userImageAltDescription"
......
......@@ -144,7 +144,7 @@ export default {
<div class="d-flex flex-column align-items-start flex-shrink-0 mr-4 mb-3 js-issues-container">
<span class="mb-1">
{{ __('Issues') }}
<gl-badge pill variant="light" class="font-weight-bold">{{ totalIssuesCount }}</gl-badge>
<gl-badge variant="muted" size="sm">{{ totalIssuesCount }}</gl-badge>
</span>
<div class="d-flex">
<gl-link v-if="openIssuesPath" ref="openIssuesLink" :href="openIssuesPath">
......
......@@ -147,8 +147,11 @@ export default {
class="mr-1 position-relative text-secondary"
/><span class="position-relative">{{ fullPath }}</span>
</component>
<!-- eslint-disable-next-line @gitlab/vue-require-i18n-strings -->
<gl-badge v-if="lfsOid" variant="default" class="label-lfs ml-1">LFS</gl-badge>
<!-- eslint-disable @gitlab/vue-require-i18n-strings -->
<gl-badge v-if="lfsOid" variant="muted" size="sm" class="ml-1" data-qa-selector="label-lfs"
>LFS</gl-badge
>
<!-- eslint-enable @gitlab/vue-require-i18n-strings -->
<template v-if="isSubmodule">
@ <gl-link :href="submoduleTreeUrl" class="commit-sha">{{ shortSha }}</gl-link>
</template>
......
......@@ -64,7 +64,7 @@ export default {
:title="buttonTitle"
:loading="isLoading"
:disabled="isActionInProgress"
:class="`btn btn-default btn-sm inline prepend-left-4 ${containerClasses}`"
:class="`btn btn-default btn-sm inline gl-ml-2 ${containerClasses}`"
@click="$emit('click')"
>
<span class="d-inline-flex align-items-baseline">
......
......@@ -187,7 +187,7 @@ h3.popover-header {
// Add to .label so that old system notes that are saved to the db
// will still receive the correct styling
.badge,
.badge:not(.gl-badge),
.label {
padding: 4px 5px;
font-size: 12px;
......
.badge.badge-pill {
.badge.badge-pill:not(.gl-badge) {
font-weight: $gl-font-weight-normal;
background-color: $badge-bg;
color: $gray-800;
......
......@@ -405,7 +405,6 @@ img.emoji {
.prepend-top-16 { margin-top: 16px; }
.prepend-top-20 { margin-top: 20px; }
.prepend-top-32 { margin-top: 32px; }
.prepend-left-4 { margin-left: 4px; }
.prepend-left-5 { margin-left: 5px; }
.prepend-left-8 { margin-left: 8px; }
.prepend-left-10 { margin-left: 10px; }
......
......@@ -39,4 +39,14 @@
width: 100%;
}
}
.toggle-sidebar-mobile-button {
right: 0;
}
.dropdown-menu-toggle {
&:hover {
background-color: $white;
}
}
}
......@@ -14,11 +14,6 @@
padding-left: 1.25rem;
@include gl-py-5;
@include gl-outline-none;
border: 0; // Remove cell border styling so that we can set border styling per row
&.event-count {
@include gl-pr-9;
}
&.alert-title {
@include gl-pointer-events-none;
......@@ -30,10 +25,14 @@
font-weight: $gl-font-weight-bold;
color: $gl-gray-600;
}
}
&:last-child {
td {
@include gl-border-0;
@include media-breakpoint-up(md) {
tr {
&:last-child {
td {
@include gl-border-0;
}
}
}
}
......@@ -41,21 +40,31 @@
@include media-breakpoint-down(sm) {
.alert-management-table {
.table-col {
min-height: 68px;
tr {
border-top: 0;
&:last-child {
background-color: $gray-10;
.table-col {
min-height: 68px;
&::before {
content: none !important;
}
&:last-child {
background-color: $gray-10;
&::before {
content: none !important;
}
div {
width: 100% !important;
padding: 0 !important;
div {
width: 100% !important;
padding: 0 !important;
}
}
}
&:hover {
background-color: $white;
border-color: $white;
border-bottom-style: none;
}
}
}
}
......
......@@ -16,7 +16,7 @@ module IntegrationsActions
def update
saved = integration.update(service_params[:service])
overwrite = ActiveRecord::Type::Boolean.new.cast(params[:overwrite])
overwrite = Gitlab::Utils.to_boolean(params[:overwrite])
respond_to do |format|
format.html do
......
......@@ -3,7 +3,7 @@
class Projects::AlertManagementController < Projects::ApplicationController
before_action :authorize_read_alert_management_alert!
before_action do
push_frontend_feature_flag(:alert_management_create_alert_issue)
push_frontend_feature_flag(:alert_management_create_alert_issue, project)
push_frontend_feature_flag(:alert_assignee, project)
end
......
......@@ -10,7 +10,7 @@ class Projects::DiscussionsController < Projects::ApplicationController
before_action :authorize_resolve_discussion!, only: [:resolve, :unresolve]
def resolve
Discussions::ResolveService.new(project, current_user, merge_request: merge_request).execute(discussion)
Discussions::ResolveService.new(project, current_user, one_or_more_discussions: discussion).execute
render_discussion
end
......
......@@ -5,6 +5,10 @@ class SearchController < ApplicationController
include SearchHelper
include RendersCommits
SCOPE_PRELOAD_METHOD = {
projects: :with_web_entity_associations
}.freeze
around_action :allow_gitaly_ref_name_caching
skip_before_action :authenticate_user!
......@@ -28,7 +32,7 @@ class SearchController < ApplicationController
@scope = search_service.scope
@show_snippets = search_service.show_snippets?
@search_results = search_service.search_results
@search_objects = search_service.search_objects
@search_objects = search_service.search_objects(preload_method)
render_commits if @scope == 'commits'
eager_load_user_status if @scope == 'users'
......@@ -64,6 +68,10 @@ class SearchController < ApplicationController
private
def preload_method
SCOPE_PRELOAD_METHOD[@scope.to_sym]
end
def search_term_valid?
unless search_service.valid_query_length?
flash[:alert] = t('errors.messages.search_chars_too_long', count: SearchService::SEARCH_CHAR_LIMIT)
......
# frozen_string_literal: true
class ResourceMilestoneEventFinder
include FinderMethods
MAX_PER_PAGE = 100
attr_reader :params, :current_user, :eventable
def initialize(current_user, eventable, params = {})
@current_user = current_user
@eventable = eventable
@params = params
end
def execute
Kaminari.paginate_array(visible_events)
end
private
def visible_events
@visible_events ||= visible_to_user(events)
end
def events
@events ||= eventable.resource_milestone_events.include_relations.page(page).per(per_page)
end
def visible_to_user(events)
events.select { |event| visible_for_user?(event) }
end
def visible_for_user?(event)
milestone = event_milestones[event.milestone_id]
return if milestone.blank?
parent = milestone.parent
parent_availabilities[key_for_parent(parent)]
end
def parent_availabilities
@parent_availabilities ||= relevant_parents.to_h do |parent|
[key_for_parent(parent), Ability.allowed?(current_user, :read_milestone, parent)]
end
end
def key_for_parent(parent)
"#{parent.class.name}_#{parent.id}"
end
def event_milestones
@milestones ||= events.map(&:milestone).uniq.to_h do |milestone|
[milestone.id, milestone]
end
end
def relevant_parents
@relevant_parents ||= event_milestones.map { |_id, milestone| milestone.parent }
end
def per_page
[params[:per_page], MAX_PER_PAGE].compact.min
end
def page
params[:page] || 1
end
end
......@@ -6,16 +6,16 @@ module Types
graphql_name 'AlertManagementAlertSort'
description 'Values for sorting alerts'
value 'START_TIME_ASC', 'Start time by ascending order', value: :start_time_asc
value 'START_TIME_DESC', 'Start time by descending order', value: :start_time_desc
value 'END_TIME_ASC', 'End time by ascending order', value: :end_time_asc
value 'END_TIME_DESC', 'End time by descending order', value: :end_time_desc
value 'STARTED_AT_ASC', 'Start time by ascending order', value: :started_at_asc
value 'STARTED_AT_DESC', 'Start time by descending order', value: :started_at_desc
value 'ENDED_AT_ASC', 'End time by ascending order', value: :ended_at_asc
value 'ENDED_AT_DESC', 'End time by descending order', value: :ended_at_desc
value 'CREATED_TIME_ASC', 'Created time by ascending order', value: :created_at_asc
value 'CREATED_TIME_DESC', 'Created time by descending order', value: :created_at_desc
value 'UPDATED_TIME_ASC', 'Created time by ascending order', value: :updated_at_asc
value 'UPDATED_TIME_DESC', 'Created time by descending order', value: :updated_at_desc
value 'EVENTS_COUNT_ASC', 'Events count by ascending order', value: :events_count_asc
value 'EVENTS_COUNT_DESC', 'Events count by descending order', value: :events_count_desc
value 'EVENT_COUNT_ASC', 'Events count by ascending order', value: :event_count_asc
value 'EVENT_COUNT_DESC', 'Events count by descending order', value: :event_count_desc
value 'SEVERITY_ASC', 'Severity by ascending order', value: :severity_asc
value 'SEVERITY_DESC', 'Severity by descending order', value: :severity_desc
value 'STATUS_ASC', 'Status by ascending order', value: :status_asc
......
# frozen_string_literal: true
module Types
class ReleaseAssetsType < BaseObject
graphql_name 'ReleaseAssets'
authorize :read_release
alias_method :release, :object
present_using ReleasePresenter
field :assets_count, GraphQL::INT_TYPE, null: true,
description: 'Number of assets of the release'
field :links, Types::ReleaseLinkType.connection_type, null: true,
description: 'Asset links of the release'
field :sources, Types::ReleaseSourceType.connection_type, null: true,
description: 'Sources of the release'
end
end
# frozen_string_literal: true
module Types
class ReleaseLinkType < BaseObject
graphql_name 'ReleaseLink'
authorize :read_release
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the link'
field :name, GraphQL::STRING_TYPE, null: true,
description: 'Name of the link'
field :url, GraphQL::STRING_TYPE, null: true,
description: 'URL of the link'
field :external, GraphQL::BOOLEAN_TYPE, null: true, method: :external?,
description: 'Indicates the link points to an external resource'
end
end
# frozen_string_literal: true
module Types
class ReleaseSourceType < BaseObject
graphql_name 'ReleaseSource'
authorize :read_release_sources
field :format, GraphQL::STRING_TYPE, null: true,
description: 'Format of the source'
field :url, GraphQL::STRING_TYPE, null: true,
description: 'Download URL of the source'
end
end
......@@ -23,6 +23,8 @@ module Types
description: 'Timestamp of when the release was created'
field :released_at, Types::TimeType, null: true,
description: 'Timestamp of when the release was released'
field :assets, Types::ReleaseAssetsType, null: true, method: :itself,
description: 'Assets of the release'
field :milestones, Types::MilestoneType.connection_type, null: true,
description: 'Milestones associated to the release'
......
......@@ -205,7 +205,7 @@ module IssuablesHelper
author_output
end
output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip prepend-left-4', title: _('1st contribution!'))
output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip gl-ml-2', title: _('1st contribution!'))
output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "d-none d-sm-none d-md-inline-block prepend-left-8")
output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "d-md-none")
......
# frozen_string_literal: true
module MilestonesRoutingHelper
module TimeboxesRoutingHelper
def milestone_path(milestone, *args)
if milestone.group_milestone?
group_milestone_path(milestone.group, milestone, *args)
......@@ -17,3 +17,5 @@ module MilestonesRoutingHelper
end
end
end
TimeboxesRoutingHelper.prepend_if_ee('EE::TimeboxesRoutingHelper')
......@@ -167,6 +167,17 @@ module VisibilityLevelHelper
[requested_level, max_allowed_visibility_level(form_model)].min
end
def available_visibility_levels(form_model)
Gitlab::VisibilityLevel.values.reject do |level|
disallowed_visibility_level?(form_model, level) ||
restricted_visibility_levels.include?(level)
end
end
def snippets_selected_visibility_level(visibility_levels, selected)
visibility_levels.find { |level| level == selected } || visibility_levels.min
end
def multiple_visibility_levels_restricted?
restricted_visibility_levels.many? # rubocop: disable CodeReuse/ActiveRecord
end
......
......@@ -102,7 +102,7 @@ module AlertManagement
scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) }
scope :order_events_count, -> (sort_order) { order(events: sort_order) }
scope :order_event_count, -> (sort_order) { order(events: sort_order) }
scope :order_severity, -> (sort_order) { order(severity: sort_order) }
scope :order_status, -> (sort_order) { order(status: sort_order) }
......@@ -110,12 +110,12 @@ module AlertManagement
def self.sort_by_attribute(method)
case method.to_s
when 'start_time_asc' then order_start_time(:asc)
when 'start_time_desc' then order_start_time(:desc)
when 'end_time_asc' then order_end_time(:asc)
when 'end_time_desc' then order_end_time(:desc)
when 'events_count_asc' then order_events_count(:asc)
when 'events_count_desc' then order_events_count(:desc)
when 'started_at_asc' then order_start_time(:asc)
when 'started_at_desc' then order_start_time(:desc)
when 'ended_at_asc' then order_end_time(:asc)
when 'ended_at_desc' then order_end_time(:desc)
when 'event_count_asc' then order_event_count(:asc)
when 'event_count_desc' then order_event_count(:desc)
when 'severity_asc' then order_severity(:asc)
when 'severity_desc' then order_severity(:desc)
when 'status_asc' then order_status(:asc)
......
......@@ -13,6 +13,11 @@ module Ci
message: "(%{value}) has already been taken"
}
validates :encrypted_value, length: {
maximum: 1024,
too_long: 'The encrypted value of the provided variable exceeds %{count} bytes. Variables over 700 characters risk exceeding the limit.'
}
scope :unprotected, -> { where(protected: false) }
after_commit { self.class.invalidate_memory_cache(:ci_instance_variable_data) }
......
......@@ -97,13 +97,21 @@ module Clusters
application.status_reason = status_reason if status_reason
end
before_transition any => [:installed, :updated] do |application, _|
# When installing any application we are also performing an update
# of tiller (see Gitlab::Kubernetes::Helm::ClientCommand) so
# therefore we need to reflect that in the database.
unless ::Gitlab::Kubernetes::Helm.local_tiller_enabled?
application.cluster.application_helm.update!(version: Gitlab::Kubernetes::Helm::HELM_VERSION)
before_transition any => [:installed, :updated] do |application, transition|
unless ::Gitlab::Kubernetes::Helm.local_tiller_enabled? || application.is_a?(Clusters::Applications::Helm)
if transition.event == :make_externally_installed
# If an application is externally installed
# We assume the helm application is externally installed too
helm = application.cluster.application_helm || application.cluster.build_application_helm
helm.make_externally_installed!
else
# When installing any application we are also performing an update
# of tiller (see Gitlab::Kubernetes::Helm::ClientCommand) so
# therefore we need to reflect that in the database.
application.cluster.application_helm.update!(version: Gitlab::Kubernetes::Helm::HELM_VERSION)
end
end
end
......
......@@ -23,7 +23,10 @@ module ResolvableDiscussion
:last_note
)
delegate :potentially_resolvable?, to: :first_note
delegate :potentially_resolvable?,
:noteable_id,
:noteable_type,
to: :first_note
delegate :resolved_at,
:resolved_by,
......@@ -79,7 +82,7 @@ module ResolvableDiscussion
return false unless current_user
return false unless resolvable?
current_user == self.noteable.author ||
current_user == self.noteable.try(:author) ||
current_user.can?(:resolve_note, self.project)
end
......
......@@ -7,6 +7,7 @@ module Timebox
include CacheMarkdownField
include Gitlab::SQL::Pattern
include IidRoutes
include Referable
include StripAttribute
TimeboxStruct = Struct.new(:title, :name, :id) do
......@@ -122,6 +123,35 @@ module Timebox
end
end
##
# Returns the String necessary to reference a Timebox in Markdown. Group
# timeboxes only support name references, and do not support cross-project
# references.
#
# format - Symbol format to use (default: :iid, optional: :name)
#
# Examples:
#
# Milestone.first.to_reference # => "%1"
# Iteration.first.to_reference(format: :name) # => "*iteration:\"goal\""
# Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1"
# Iteration.first.to_reference(same_namespace_project) # => "gitlab-foss*iteration:1"
#
def to_reference(from = nil, format: :name, full: false)
format_reference = timebox_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
"#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end
end
def reference_link_text(from = nil)
self.class.reference_prefix + self.title
end
def title=(value)
write_attribute(:title, sanitize_title(value)) if value.present?
end
......@@ -162,6 +192,20 @@ module Timebox
private
def timebox_format_reference(format = :iid)
raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
if group_timebox? && format == :iid
raise ArgumentError, _('Cannot refer to a group %{timebox_type} by an internal id!') % { timebox_type: timebox_name }
end
if format == :name && !name.include?('"')
%("#{name}")
else
iid
end
end
# Timebox titles must be unique across project and group timeboxes
def uniqueness_of_title
if project
......
......@@ -21,6 +21,7 @@ class Event < ApplicationRecord
LEFT = 9 # User left project
DESTROYED = 10
EXPIRED = 11 # User left project due to expiry
APPROVED = 12
ACTIONS = HashWithIndifferentAccess.new(
created: CREATED,
......@@ -33,7 +34,8 @@ class Event < ApplicationRecord
joined: JOINED,
left: LEFT,
destroyed: DESTROYED,
expired: EXPIRED
expired: EXPIRED,
approved: APPROVED
).freeze
WIKI_ACTIONS = [CREATED, UPDATED, DESTROYED].freeze
......
......@@ -344,6 +344,10 @@ class Issue < ApplicationRecord
previous_changes['updated_at']&.first || updated_at
end
def banzai_render_context(field)
super.merge(label_url_method: :project_issues_url)
end
def design_collection
@design_collection ||= ::DesignManagement::DesignCollection.new(self)
end
......
# frozen_string_literal: true
class Iteration < ApplicationRecord
include Timebox
self.table_name = 'sprints'
attr_accessor :skip_future_date_validation
......@@ -15,9 +13,6 @@ class Iteration < ApplicationRecord
include AtomicInternalId
has_many :issues, foreign_key: 'sprint_id'
has_many :merge_requests, foreign_key: 'sprint_id'
belongs_to :project
belongs_to :group
......@@ -33,6 +28,12 @@ class Iteration < ApplicationRecord
scope :upcoming, -> { with_state(:upcoming) }
scope :started, -> { with_state(:started) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date is not NULL or due_date is not NULL')
.where('start_date is NULL or start_date <= ?', end_date)
.where('due_date is NULL or due_date >= ?', start_date)
end
state_machine :state_enum, initial: :upcoming do
event :start do
transition upcoming: :started
......@@ -62,6 +63,14 @@ class Iteration < ApplicationRecord
else iterations.upcoming
end
end
def reference_prefix
'*iteration:'
end
def reference_pattern
nil
end
end
def state
......@@ -72,6 +81,10 @@ class Iteration < ApplicationRecord
self.state_enum = STATE_ENUM_MAP[value]
end
def resource_parent
group || project
end
private
def start_or_due_dates_changed?
......@@ -98,3 +111,5 @@ class Iteration < ApplicationRecord
end
end
end
Iteration.prepend_if_ee('EE::Iteration')
......@@ -88,6 +88,9 @@ class MergeRequest < ApplicationRecord
has_many :deployments,
through: :deployment_merge_requests
has_many :draft_notes
has_many :reviews, inverse_of: :merge_request
KNOWN_MERGE_PARAMS = [
:auto_merge_strategy,
:should_remove_source_branch,
......@@ -541,13 +544,21 @@ class MergeRequest < ApplicationRecord
merge_request_diffs.where.not(id: merge_request_diff.id)
end
# Overwritten in EE
def note_positions_for_paths(paths, _user = nil)
def note_positions_for_paths(paths, user = nil)
positions = notes.new_diff_notes.joins(:note_diff_file)
.where('note_diff_files.old_path IN (?) OR note_diff_files.new_path IN (?)', paths, paths)
.positions
Gitlab::Diff::PositionCollection.new(positions, diff_head_sha)
collection = Gitlab::Diff::PositionCollection.new(positions, diff_head_sha)
return collection unless user
positions = draft_notes
.authored_by(user)
.positions
.select { |pos| paths.include?(pos.file_path) }
collection.concat(positions)
end
def preloads_discussion_diff_highlighting?
......@@ -1568,6 +1579,10 @@ class MergeRequest < ApplicationRecord
deployments.visible.includes(:environment).order(id: :desc).limit(10)
end
def banzai_render_context(field)
super.merge(label_url_method: :project_merge_requests_url)
end
private
def with_rebase_lock
......
......@@ -2,7 +2,6 @@
class Milestone < ApplicationRecord
include Sortable
include Referable
include Timebox
include Milestoneish
include FromUnion
......@@ -122,35 +121,6 @@ class Milestone < ApplicationRecord
}
end
##
# Returns the String necessary to reference a Milestone in Markdown. Group
# milestones only support name references, and do not support cross-project
# references.
#
# format - Symbol format to use (default: :iid, optional: :name)
#
# Examples:
#
# Milestone.first.to_reference # => "%1"
# Milestone.first.to_reference(format: :name) # => "%\"goal\""
# Milestone.first.to_reference(cross_namespace_project) # => "gitlab-org/gitlab-foss%1"
# Milestone.first.to_reference(same_namespace_project) # => "gitlab-foss%1"
#
def to_reference(from = nil, format: :name, full: false)
format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}"
if project
"#{project.to_reference_base(from, full: full)}#{reference}"
else
reference
end
end
def reference_link_text(from = nil)
self.class.reference_prefix + self.title
end
def for_display
self
end
......@@ -185,20 +155,6 @@ class Milestone < ApplicationRecord
private
def milestone_format_reference(format = :iid)
raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
if group_milestone? && format == :iid
raise ArgumentError, _('Cannot refer to a group milestone by an internal id!')
end
if format == :name && !name.include?('"')
%("#{name}")
else
iid
end
end
def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
......
......@@ -72,6 +72,7 @@ class Note < ApplicationRecord
belongs_to :author, class_name: "User"
belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User'
belongs_to :review, inverse_of: :notes
has_many :todos
......@@ -350,8 +351,10 @@ class Note < ApplicationRecord
self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
end
def confidential?
confidential || noteable.try(:confidential?)
def confidential?(include_noteable: false)
return true if confidential
include_noteable && noteable.try(:confidential?)
end
def editable?
......@@ -520,7 +523,7 @@ class Note < ApplicationRecord
end
def banzai_render_context(field)
super.merge(noteable: noteable, system_note: system?)
super.merge(noteable: noteable, system_note: system?, label_url_method: noteable_label_url_method)
end
def retrieve_upload(_identifier, paths)
......@@ -603,6 +606,10 @@ class Note < ApplicationRecord
errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT
end
def noteable_label_url_method
for_merge_request? ? :project_merge_requests_url : :project_issues_url
end
end
Note.prepend_if_ee('EE::Note')
......@@ -329,6 +329,7 @@ class Project < ApplicationRecord
has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove'
has_many :webide_pipelines, -> { webide_source }, class_name: 'Ci::Pipeline', inverse_of: :project
has_many :reviews, inverse_of: :project
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
......@@ -508,6 +509,7 @@ class Project < ApplicationRecord
left_outer_joins(:pages_metadatum)
.where(project_pages_metadata: { project_id: nil })
end
scope :with_api_entity_associations, -> {
preload(:project_feature, :route, :tags,
group: :ip_restrictions, namespace: [:route, :owner])
......@@ -527,6 +529,10 @@ class Project < ApplicationRecord
# Used by Projects::CleanupService to hold a map of rewritten object IDs
mount_uploader :bfg_object_map, AttachmentUploader
def self.with_web_entity_associations
preload(:project_feature, :route, :creator, :group, namespace: [:route, :owner])
end
def self.eager_load_namespace_and_owner
includes(namespace: :owner)
end
......@@ -2072,21 +2078,6 @@ class Project < ApplicationRecord
end
end
def change_repository_storage(new_repository_storage_key)
return if repository_read_only?
return if repository_storage == new_repository_storage_key
raise ArgumentError unless ::Gitlab.config.repositories.storages.key?(new_repository_storage_key)
storage_move = repository_storage_moves.create!(
source_storage_name: repository_storage,
destination_storage_name: new_repository_storage_key
)
storage_move.schedule!
self.repository_read_only = true
end
def pushes_since_gc
Gitlab::Redis::SharedState.with { |redis| redis.get(pushes_since_gc_redis_shared_state_key).to_i }
end
......
......@@ -18,6 +18,7 @@ class ProjectRepositoryStorageMove < ApplicationRecord
on: :create,
presence: true,
inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
validate :project_repository_writable, on: :create
state_machine initial: :initial do
event :schedule do
......@@ -36,7 +37,9 @@ class ProjectRepositoryStorageMove < ApplicationRecord
transition [:initial, :scheduled, :started] => :failed
end
after_transition initial: :scheduled do |storage_move, _|
after_transition initial: :scheduled do |storage_move|
storage_move.project.update_column(:repository_read_only, true)
storage_move.run_after_commit do
ProjectUpdateRepositoryStorageWorker.perform_async(
storage_move.project_id,
......@@ -46,6 +49,17 @@ class ProjectRepositoryStorageMove < ApplicationRecord
end
end
after_transition started: :finished do |storage_move|
storage_move.project.update_columns(
repository_read_only: false,
repository_storage: storage_move.destination_storage_name
)
end
after_transition started: :failed do |storage_move|
storage_move.project.update_column(:repository_read_only, false)
end
state :initial, value: 1
state :scheduled, value: 2
state :started, value: 3
......@@ -55,4 +69,10 @@ class ProjectRepositoryStorageMove < ApplicationRecord
scope :order_created_at_desc, -> { order(created_at: :desc) }
scope :with_projects, -> { includes(project: :route) }
private
def project_repository_writable
errors.add(:project, _('is read only')) if project&.repository_read_only?
end
end
......@@ -9,6 +9,8 @@ class ResourceMilestoneEvent < ResourceEvent
validate :exactly_one_issuable
scope :include_relations, -> { includes(:user, milestone: [:project, :group]) }
enum action: {
add: 1,
remove: 2
......@@ -26,4 +28,12 @@ class ResourceMilestoneEvent < ResourceEvent
def milestone_title
milestone&.title
end
def milestone_parent
milestone&.parent
end
def issuable
issue || merge_request
end
end
......@@ -28,9 +28,17 @@ class SnippetInputAction
def to_commit_action
{
action: action&.to_sym,
previous_path: previous_path,
previous_path: build_previous_path,
file_path: file_path,
content: content
}
end
private
def build_previous_path
return previous_path unless update_action?
previous_path.presence || file_path
end
end
......@@ -5,7 +5,7 @@ class SnippetInputActionCollection
attr_reader :actions
delegate :empty?, to: :actions
delegate :empty?, :any?, :[], to: :actions
def initialize(actions = [])
@actions = actions.map { |action| SnippetInputAction.new(action) }
......
......@@ -180,6 +180,8 @@ class User < ApplicationRecord
has_one :user_highest_role
has_one :user_canonical_email
has_many :reviews, foreign_key: :author_id, inverse_of: :author
#
# Validations
#
......
# frozen_string_literal: true
module Releases
class LinkPolicy < BasePolicy
delegate { @subject.release.project }
end
end
# frozen_string_literal: true
module Releases
class SourcePolicy < BasePolicy
delegate { @subject.project }
rule { can?(:public_access) | can?(:reporter_access) }.policy do
enable :read_release_sources
end
rule { ~can?(:read_release) }.prevent :read_release_sources
end
end
......@@ -5,7 +5,7 @@ class ReleasePresenter < Gitlab::View::Presenter::Delegated
presents :release
delegate :project, :tag, to: :release
delegate :project, :tag, :assets_count, to: :release
def commit_path
return unless release.commit && can_download_code?
......
......@@ -114,23 +114,21 @@ module Admin
integration.type == 'ExternalWikiService'
end
# rubocop: disable CodeReuse/ActiveRecord
def project_ids_without_integration
Project.connection.select_values(
<<-SQL
SELECT id
FROM projects
WHERE NOT EXISTS (
SELECT true
FROM services
WHERE services.project_id = projects.id
AND services.type = #{ActiveRecord::Base.connection.quote(integration.type)}
)
AND projects.pending_delete = false
AND projects.archived = false
LIMIT #{BATCH_SIZE}
SQL
)
services = Service
.select('1')
.where('services.project_id = projects.id')
.where(type: integration.type)
Project
.where('NOT EXISTS (?)', services)
.where(pending_delete: false)
.where(archived: false)
.limit(BATCH_SIZE)
.pluck(:id)
end
# rubocop: enable CodeReuse/ActiveRecord
def service_hash
@service_hash ||= integration.to_service_hash
......
......@@ -36,7 +36,7 @@ module Ci
def code_navigation_enabled?
strong_memoize(:code_navigation_enabled) do
Feature.enabled?(:code_navigation)
Feature.enabled?(:code_navigation, job.project)
end
end
......
......@@ -2,8 +2,34 @@
module Discussions
class ResolveService < Discussions::BaseService
def execute(one_or_more_discussions)
Array(one_or_more_discussions).each { |discussion| resolve_discussion(discussion) }
include Gitlab::Utils::StrongMemoize
def initialize(project, user = nil, params = {})
@discussions = Array.wrap(params.fetch(:one_or_more_discussions))
@follow_up_issue = params[:follow_up_issue]
raise ArgumentError, 'Discussions must be all for the same noteable' \
unless noteable_is_same?
super
end
def execute
discussions.each(&method(:resolve_discussion))
end
private
attr_accessor :discussions, :follow_up_issue
def noteable_is_same?
return true unless discussions.size > 1
# Perform this check without fetching extra records
discussions.all? do |discussion|
discussion.noteable_type == first_discussion.noteable_type &&
discussion.noteable_id == first_discussion.noteable_id
end
end
def resolve_discussion(discussion)
......@@ -11,16 +37,18 @@ module Discussions
discussion.resolve!(current_user)
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request)
MergeRequests::ResolvedDiscussionNotificationService.new(project, current_user).execute(merge_request) if merge_request
SystemNoteService.discussion_continued_in_issue(discussion, project, current_user, follow_up_issue) if follow_up_issue
end
def merge_request
params[:merge_request]
def first_discussion
@first_discussion ||= discussions.first
end
def follow_up_issue
params[:follow_up_issue]
def merge_request
strong_memoize(:merge_request) do
first_discussion.noteable if first_discussion.for_merge_request?
end
end
end
end
......@@ -38,9 +38,8 @@ module Issues
return if discussions_to_resolve.empty?
Discussions::ResolveService.new(project, current_user,
merge_request: merge_request_to_resolve_discussions_of,
follow_up_issue: issue)
.execute(discussions_to_resolve)
one_or_more_discussions: discussions_to_resolve,
follow_up_issue: issue).execute
end
private
......
......@@ -36,7 +36,7 @@ module Notes
return unless @note.project
note_data = hook_data
hooks_scope = @note.confidential? ? :confidential_note_hooks : :note_hooks
hooks_scope = @note.confidential?(include_noteable: true) ? :confidential_note_hooks : :note_hooks
@note.project.execute_hooks(note_data, hooks_scope)
@note.project.execute_services(note_data, hooks_scope)
......
# frozen_string_literal: true
module Projects
class LsifDataService
attr_reader :file, :project, :commit_id, :docs,
:doc_ranges, :ranges, :def_refs, :hover_refs
CACHE_EXPIRE_IN = 1.hour
def initialize(file, project, commit_id)
@file = file
@project = project
@commit_id = commit_id
fetch_data!
end
def execute(path)
doc_id = find_doc_id(docs, path)
dir_absolute_path = docs[doc_id]&.delete_suffix(path)
doc_ranges[doc_id]&.map do |range_id|
location, ref_id = ranges[range_id].values_at('loc', 'ref_id')
line_data, column_data = location
{
start_line: line_data.first,
end_line: line_data.last,
start_char: column_data.first,
end_char: column_data.last,
definition_url: definition_url_for(def_refs[ref_id], dir_absolute_path),
hover: highlighted_hover(hover_refs[ref_id])
}
end
end
private
def fetch_data
Rails.cache.fetch("project:#{project.id}:lsif:#{commit_id}", expires_in: CACHE_EXPIRE_IN) do
data = nil
file.open do |stream|
Zlib::GzipReader.wrap(stream) do |gz_stream|
data = Gitlab::Json.parse(gz_stream.read)
end
end
data
end
end
def fetch_data!
data = fetch_data
@docs = data['docs']
@doc_ranges = data['doc_ranges']
@ranges = data['ranges']
@def_refs = data['def_refs']
@hover_refs = data['hover_refs']
end
def find_doc_id(docs, path)
docs.reduce(nil) do |doc_id, (id, doc_path)|
next doc_id unless doc_path =~ /#{path}$/
if doc_id.nil? || docs[doc_id].size > doc_path.size
doc_id = id
end
doc_id
end
end
def definition_url_for(ref_id, dir_absolute_path)
return unless range = ranges[ref_id]
def_doc_id, location = range.values_at('doc_id', 'loc')
localized_doc_url = docs[def_doc_id].delete_prefix(dir_absolute_path)
# location is stored as [[start_line, end_line], [start_char, end_char]]
start_line = location.first.first
line_anchor = "L#{start_line + 1}"
definition_ref_path = [commit_id, localized_doc_url].join('/')
Gitlab::Routing.url_helpers.project_blob_path(project, definition_ref_path, anchor: line_anchor)
end
def highlighted_hover(hovers)
hovers&.map do |hover|
# Documentation for a method which is added as comments on top of the method
# is stored as a raw string value in LSIF file
next { value: hover } unless hover.is_a?(Hash)
value = Gitlab::Highlight.highlight(nil, hover['value'], language: hover['language'])
{ language: hover['language'], value: value }
end
end
end
end
......@@ -26,7 +26,7 @@ module Projects
def propagate_projects_with_template
loop do
batch = Project.uncached { project_ids_batch }
batch = Project.uncached { project_ids_without_integration }
bulk_create_from_template(batch) unless batch.empty?
......@@ -50,23 +50,21 @@ module Projects
end
end
def project_ids_batch
Project.connection.select_values(
<<-SQL
SELECT id
FROM projects
WHERE NOT EXISTS (
SELECT true
FROM services
WHERE services.project_id = projects.id
AND services.type = #{ActiveRecord::Base.connection.quote(template.type)}
)
AND projects.pending_delete = false
AND projects.archived = false
LIMIT #{BATCH_SIZE}
SQL
)
# rubocop: disable CodeReuse/ActiveRecord
def project_ids_without_integration
services = Service
.select('1')
.where('services.project_id = projects.id')
.where(type: template.type)
Project
.where('NOT EXISTS (?)', services)
.where(pending_delete: false)
.where(archived: false)
.limit(BATCH_SIZE)
.pluck(:id)
end
# rubocop: enable CodeReuse/ActiveRecord
def bulk_insert(klass, columns, values_array)
items_to_insert = values_array.map { |array| Hash[columns.zip(array)] }
......
......@@ -24,7 +24,7 @@ module Projects
mark_old_paths_for_archive
repository_storage_move.finish!
project.update!(repository_storage: destination_storage_name, repository_read_only: false)
project.leave_pool_repository
project.track_project_repository
end
......@@ -34,10 +34,7 @@ module Projects
ServiceResponse.success
rescue StandardError => e
project.transaction do
repository_storage_move.do_fail!
project.update!(repository_read_only: false)
end
repository_storage_move.do_fail!
Gitlab::ErrorTracking.track_exception(e, project_path: project.full_path)
......
......@@ -13,8 +13,12 @@ module Projects
ensure_wiki_exists if enabling_wiki?
if changing_storage_size?
project.change_repository_storage(params.delete(:repository_storage))
if changing_repository_storage?
storage_move = project.repository_storage_moves.build(
source_storage_name: project.repository_storage,
destination_storage_name: params.delete(:repository_storage)
)
storage_move.schedule
end
yield if block_given?
......@@ -145,10 +149,11 @@ module Projects
project.previous_changes.include?(:pages_https_only)
end
def changing_storage_size?
def changing_repository_storage?
new_repository_storage = params[:repository_storage]
new_repository_storage && project.repository.exists? &&
project.repository_storage != new_repository_storage &&
can?(current_user, :change_repository_storage, project)
end
end
......
......@@ -72,11 +72,11 @@ module Snippets
message
end
def files_to_commit
snippet_files.to_commit_actions.presence || build_actions_from_params
def files_to_commit(snippet)
snippet_files.to_commit_actions.presence || build_actions_from_params(snippet)
end
def build_actions_from_params
def build_actions_from_params(snippet)
raise NotImplementedError
end
end
......
......@@ -43,9 +43,7 @@ module Snippets
def create_params
return params if snippet_files.empty?
first_file = snippet_files.actions.first
params.merge(content: first_file.content, file_name: first_file.file_path)
params.merge(content: snippet_files[0].content, file_name: snippet_files[0].file_path)
end
def save_and_commit
......@@ -88,7 +86,7 @@ module Snippets
message: 'Initial commit'
}
@snippet.snippet_repository.multi_files_action(current_user, files_to_commit, commit_attrs)
@snippet.snippet_repository.multi_files_action(current_user, files_to_commit(@snippet), commit_attrs)
end
def move_temporary_files
......@@ -99,7 +97,7 @@ module Snippets
end
end
def build_actions_from_params
def build_actions_from_params(_snippet)
[{ file_path: params[:file_name], content: params[:content] }]
end
end
......
......@@ -7,11 +7,13 @@ module Snippets
UpdateError = Class.new(StandardError)
def execute(snippet)
return invalid_params_error(snippet) unless valid_params?
if visibility_changed?(snippet) && !visibility_allowed?(snippet, visibility_level)
return forbidden_visibility_error(snippet)
end
snippet.assign_attributes(params)
update_snippet_attributes(snippet)
spam_check(snippet, current_user)
if save_and_commit(snippet)
......@@ -29,6 +31,19 @@ module Snippets
visibility_level && visibility_level.to_i != snippet.visibility_level
end
def update_snippet_attributes(snippet)
# We can remove the following condition once
# https://gitlab.com/gitlab-org/gitlab/-/issues/217801
# is implemented.
# Once we can perform different operations through this service
# we won't need to keep track of the `content` and `file_name` fields
if snippet_files.any?
params.merge!(content: snippet_files[0].content, file_name: snippet_files[0].file_path)
end
snippet.assign_attributes(params)
end
def save_and_commit(snippet)
return false unless snippet.save
......@@ -81,13 +96,7 @@ module Snippets
message: 'Update snippet'
}
snippet.snippet_repository.multi_files_action(current_user, snippet_files(snippet), commit_attrs)
end
def snippet_files(snippet)
[{ previous_path: snippet.file_name_on_repo,
file_path: params[:file_name],
content: params[:content] }]
snippet.snippet_repository.multi_files_action(current_user, files_to_commit(snippet), commit_attrs)
end
# Because we are removing repositories we don't want to remove
......@@ -99,7 +108,13 @@ module Snippets
end
def committable_attributes?
(params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present?
(params.stringify_keys.keys & COMMITTABLE_ATTRIBUTES).present? || snippet_files.any?
end
def build_actions_from_params(snippet)
[{ previous_path: snippet.file_name_on_repo,
file_path: params[:file_name],
content: params[:content] }]
end
end
end
......@@ -12,7 +12,7 @@
%br.clearfix
- if @broadcast_messages.any?
%table.table
%table.table.table-responsive
%thead
%tr
%th Status
......@@ -37,7 +37,7 @@
= message.target_path
%td
= message.broadcast_type.capitalize
%td
%td.gl-white-space-nowrap
= link_to sprite_icon('pencil-square'), edit_admin_broadcast_message_path(message), title: 'Edit', class: 'btn'
= link_to sprite_icon('remove'), admin_broadcast_message_path(message), method: :delete, remote: true, title: 'Remove', class: 'js-remove-tr btn btn-danger'
......
......@@ -4,7 +4,7 @@
.hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' }
%span.spinner.spinner-dark.spinner-sm{ 'aria-label': 'Loading' }
%span.prepend-left-4= s_('ClusterIntegration|Kubernetes cluster is being created...')
%span.gl-ml-2= s_('ClusterIntegration|Kubernetes cluster is being created...')
.hidden.row.js-cluster-api-unreachable.gl-alert.gl-alert-warning{ role: 'alert' }
= sprite_icon('warning', size: 16, css_class: 'gl-icon gl-alert-icon gl-alert-icon-no-title')
......
......@@ -11,7 +11,7 @@
.d-inline-flex.align-items-baseline
%h1.home-panel-title.prepend-top-8.append-bottom-5
= @group.name
%span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@group) }
= visibility_level_icon(@group.visibility_level, fw: false, options: {class: 'icon'})
.home-panel-metadata.d-flex.align-items-center.text-secondary
%span
......
%p.details
#{link_to @issue.author_name, user_url(@issue.author)} created an issue:
#{link_to @issue.author_name, user_url(@issue.author)} created an issue #{link_to @issue.to_reference(full: false), issue_url(@issue)}:
- if @issue.assignees.any?
%p
......
......@@ -12,7 +12,7 @@
.d-inline-flex.align-items-baseline
%h1.home-panel-title.prepend-top-8.append-bottom-5{ data: { qa_selector: 'project_name_content' } }
= @project.name
%span.visibility-icon.text-secondary.prepend-left-4.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
%span.visibility-icon.text-secondary.gl-ml-2.has-tooltip{ data: { container: 'body' }, title: visibility_icon_description(@project) }
= visibility_level_icon(@project.visibility_level, fw: false, options: {class: 'icon'})
= render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project
.home-panel-metadata.d-flex.flex-wrap.text-secondary
......
......@@ -37,7 +37,7 @@
= f.label :active, s_('PipelineSchedules|Activated'), class: 'label-bold'
%div
= f.check_box :active, required: false, value: @schedule.active?
= _('Active')
= f.label :active, _('Active'), class: 'gl-font-weight-normal'
.footer-block.row-content-block
= f.submit _('Save pipeline schedule'), class: 'btn btn-success', tabindex: 3
= link_to _('Cancel'), pipeline_schedules_path(@project), class: 'btn btn-cancel'
......@@ -3,10 +3,10 @@
%li.flex-row.allow-wrap
.row-main-content
= icon('tag')
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name prepend-left-4'
= link_to tag.name, project_tag_path(@project, tag.name), class: 'item-title ref-name gl-ml-2'
- if protected_tag?(@project, tag)
%span.badge.badge-success.prepend-left-4
%span.badge.badge-success.gl-ml-2
= s_('TagsPage|protected')
- if tag.message.present?
......
- Gitlab::VisibilityLevel.values.each do |level|
- disallowed = disallowed_visibility_level?(form_model, level)
- restricted = restricted_visibility_levels.include?(level)
- next if disallowed || restricted
- available_visibility_levels = available_visibility_levels(form_model)
- selected_level = snippets_selected_visibility_level(available_visibility_levels, selected_level)
- available_visibility_levels.each do |level|
.form-check
= form.radio_button model_method, level, checked: (selected_level == level), class: 'form-check-input', data: { track_label: "blank_project", track_event: "activate_form_input", track_property: "#{model_method}_#{level}", track_value: "", qa_selector: "#{visibility_level_label(level).downcase}_radio" }
= form.label "#{model_method}_#{level}", class: 'form-check-label' do
......
......@@ -8,4 +8,4 @@
- personal_projects_count = user.personal_projects.count
- unless personal_projects_count.zero?
%li
= n_('personal project will be removed and cannot be restored', '%d personal projects will be removed and cannot be restored', personal_projects_count)
= n_('%d personal project will be removed and cannot be restored.', '%d personal projects will be removed and cannot be restored.', personal_projects_count) % personal_projects_count
---
title: Fix 'Active' checkbox text in Pipeline Schedule form to be a label
merge_request: 27054
author: Jonston Chan
type: fixed
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment