Commit b31247f3 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '239861-mini-and-big-pipeline-graph-dropdown' into 'master'

Move pipelines CSS to page page_bundles

See merge request gitlab-org/gitlab!44767
parents 20fa0acb 64975fd2
......@@ -172,7 +172,7 @@ gem 'diffy', '~> 3.3'
gem 'diff_match_patch', '~> 0.1.0'
# Application server
gem 'rack', '~> 2.0.9'
gem 'rack', '~> 2.1.4'
# https://github.com/sharpstone/rack-timeout/blob/master/README.md#rails-apps-manually
gem 'rack-timeout', '~> 0.5.1', require: 'rack/timeout/base'
......
......@@ -852,7 +852,7 @@ GEM
public_suffix (4.0.3)
pyu-ruby-sasl (0.0.3.3)
raabro (1.1.6)
rack (2.0.9)
rack (2.1.4)
rack-accept (0.4.5)
rack (>= 0.4)
rack-attack (6.3.0)
......@@ -1423,7 +1423,7 @@ DEPENDENCIES
prometheus-client-mmap (~> 0.12.0)
pry-byebug (~> 3.9.0)
pry-rails (~> 0.3.9)
rack (~> 2.0.9)
rack (~> 2.1.4)
rack-attack (~> 6.3.0)
rack-cors (~> 1.0.6)
rack-oauth2 (~> 1.9.3)
......
import { masks } from 'dateformat';
import { formatDate } from '~/lib/utils/datetime_utility';
const { isoDate } = masks;
/**
* Takes an array of items and returns one item per month with the average of the `count`s from that month
* @param {Array} items
* @param {Number} items[index].count value to be averaged
* @param {String} items[index].recordedAt item dateTime time stamp to be collected into a month
* @param {Object} options
* @param {Object} options.shouldRound an option to specify whether the retuned averages should be rounded
* @return {Array} items collected into [month, average],
* where month is a dateTime string representing the first of the given month
* and average is the average of the count
*/
export function getAverageByMonth(items = [], options = {}) {
const { shouldRound = false } = options;
const itemsMap = items.reduce((memo, item) => {
const { count, recordedAt } = item;
const date = new Date(recordedAt);
const month = formatDate(new Date(date.getFullYear(), date.getMonth(), 1), isoDate);
if (memo[month]) {
const { sum, recordCount } = memo[month];
return { ...memo, [month]: { sum: sum + count, recordCount: recordCount + 1 } };
}
return { ...memo, [month]: { sum: count, recordCount: 1 } };
}, {});
return Object.keys(itemsMap).map(month => {
const { sum, recordCount } = itemsMap[month];
const avg = sum / recordCount;
if (shouldRound) {
return [month, Math.round(avg)];
}
return [month, avg];
});
}
......@@ -3,9 +3,7 @@ import isEmojiUnicodeSupported from '../emoji/support';
import { initEmojiMap, getEmojiInfo, emojiFallbackImageSrc, emojiImageTag } from '../emoji';
class GlEmoji extends HTMLElement {
constructor() {
super();
connectedCallback() {
this.initialize();
}
initialize() {
......
......@@ -24,10 +24,10 @@ export default {
},
inject: {
autoDevopsHelpPath: {
type: String,
default: '',
},
externalEndpointHelpPath: {
type: String,
default: '',
},
},
data() {
......
......@@ -122,67 +122,20 @@ export default {
</script>
<template>
<li :class="{ 'js-toggle-container': collapsible }" class="commit flex-row">
<div class="d-flex align-items-center align-self-start">
<input
v-if="isSelectable"
class="mr-2"
type="checkbox"
:checked="checked"
@change="$emit('handleCheckboxChange', $event.target.checked)"
/>
<user-avatar-link
:link-href="authorUrl"
:img-src="authorAvatar"
:img-alt="authorName"
:img-size="40"
class="avatar-cell d-none d-sm-block"
/>
</div>
<div class="commit-detail flex-list">
<div class="commit-content qa-commit-content">
<a
:href="commit.commit_url"
class="commit-row-message item-title"
v-html="commit.title_html"
></a>
<span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
<gl-button
v-if="commit.description_html && collapsible"
class="js-toggle-button"
size="small"
icon="ellipsis_h"
:aria-label="__('Toggle commit description')"
/>
<div class="committer">
<a
:href="authorUrl"
:class="authorClass"
:data-user-id="authorId"
v-text="authorName"
></a>
{{ s__('CommitWidget|authored') }}
<time-ago-tooltip :time="commit.authored_date" />
</div>
<pre
v-if="commit.description_html"
:class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
class="commit-row-description gl-mb-3 text-dark"
v-html="commitDescription"
></pre>
</div>
<div class="commit-actions flex-row d-none d-sm-flex">
<li :class="{ 'js-toggle-container': collapsible }" class="commit">
<div
class="d-block d-sm-flex flex-row-reverse justify-content-between align-items-start flex-lg-row-reverse"
>
<div
class="commit-actions flex-row d-none d-sm-flex align-items-start flex-wrap justify-content-end"
>
<div v-if="commit.signature_html" v-html="commit.signature_html"></div>
<commit-pipeline-status
v-if="commit.pipeline_status_path"
:endpoint="commit.pipeline_status_path"
class="d-inline-flex"
class="d-inline-flex mb-2"
/>
<gl-button-group class="gl-ml-4" data-testid="commit-sha-group">
<gl-button-group class="gl-ml-4 gl-mb-4" data-testid="commit-sha-group">
<gl-button label class="gl-font-monospace" v-text="commit.short_id" />
<clipboard-button
:text="commit.id"
......@@ -226,6 +179,62 @@ export default {
</gl-button-group>
</div>
</div>
<div>
<div class="d-flex float-left align-items-center align-self-start">
<input
v-if="isSelectable"
class="mr-2"
type="checkbox"
:checked="checked"
@change="$emit('handleCheckboxChange', $event.target.checked)"
/>
<user-avatar-link
:link-href="authorUrl"
:img-src="authorAvatar"
:img-alt="authorName"
:img-size="40"
class="avatar-cell d-none d-sm-block"
/>
</div>
<div class="commit-detail flex-list">
<div class="commit-content qa-commit-content">
<a
:href="commit.commit_url"
class="commit-row-message item-title"
v-html="commit.title_html"
></a>
<span class="commit-row-message d-block d-sm-none">&middot; {{ commit.short_id }}</span>
<gl-button
v-if="commit.description_html && collapsible"
class="js-toggle-button"
size="small"
icon="ellipsis_h"
:aria-label="__('Toggle commit description')"
/>
<div class="committer">
<a
:href="authorUrl"
:class="authorClass"
:data-user-id="authorId"
v-text="authorName"
></a>
{{ s__('CommitWidget|authored') }}
<time-ago-tooltip :time="commit.authored_date" />
</div>
</div>
</div>
</div>
</div>
<div>
<pre
v-if="commit.description_html"
:class="{ 'js-toggle-content': collapsible, 'd-block': !collapsible }"
class="commit-row-description gl-mb-3 text-dark"
v-html="commitDescription"
></pre>
</div>
</li>
</template>
<script>
import { GlFormInput, GlFormSelect } from '@gitlab/ui';
import { __ } from '~/locale';
import { PERCENT_ROLLOUT_GROUP_ID } from '../../constants';
import ParameterFormGroup from './parameter_form_group.vue';
export default {
components: {
GlFormInput,
GlFormSelect,
ParameterFormGroup,
},
props: {
strategy: {
required: true,
type: Object,
},
},
i18n: {
percentageDescription: __('Enter a whole number between 0 and 100'),
percentageInvalid: __('Percent rollout must be a whole number between 0 and 100'),
percentageLabel: __('Percentage'),
stickinessDescription: __('Consistency guarantee method'),
stickinessLabel: __('Based on'),
},
stickinessOptions: [
{
value: 'DEFAULT',
text: __('Available ID'),
},
{
value: 'USERID',
text: __('User ID'),
},
{
value: 'SESSIONID',
text: __('Session ID'),
},
{
value: 'RANDOM',
text: __('Random'),
},
],
computed: {
isValid() {
const percentageNum = Number(this.percentage);
return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100;
},
percentage() {
return this.strategy?.parameters?.rollout ?? '100';
},
stickiness() {
return this.strategy?.parameters?.stickiness ?? this.$options.stickinessOptions[0].value;
},
},
methods: {
onPercentageChange(value) {
this.$emit('change', {
parameters: {
groupId: PERCENT_ROLLOUT_GROUP_ID,
rollout: value,
stickiness: this.stickiness,
},
});
},
onStickinessChange(value) {
this.$emit('change', {
parameters: {
groupId: PERCENT_ROLLOUT_GROUP_ID,
rollout: this.percentage,
stickiness: value,
},
});
},
},
};
</script>
<template>
<div class="gl-display-flex">
<div class="gl-mr-7" data-testid="strategy-flexible-rollout-percentage">
<parameter-form-group
:label="$options.i18n.percentageLabel"
:description="isValid ? $options.i18n.percentageDescription : ''"
:invalid-feedback="$options.i18n.percentageInvalid"
:state="isValid"
>
<template #default="{ inputId }">
<div class="gl-display-flex gl-align-items-center">
<gl-form-input
:id="inputId"
:value="percentage"
:state="isValid"
class="rollout-percentage gl-text-right gl-w-9"
type="number"
min="0"
max="100"
@input="onPercentageChange"
/>
<span class="ml-1">%</span>
</div>
</template>
</parameter-form-group>
</div>
<div class="gl-mr-7" data-testid="strategy-flexible-rollout-stickiness">
<parameter-form-group
:label="$options.i18n.stickinessLabel"
:description="$options.i18n.stickinessDescription"
>
<template #default="{ inputId }">
<gl-form-select
:id="inputId"
:value="stickiness"
:options="$options.stickinessOptions"
@change="onStickinessChange"
/>
</template>
</parameter-form-group>
</div>
</div>
</template>
......@@ -49,7 +49,7 @@ export default {
:state="hasUserLists"
:invalid-feedback="$options.translations.rolloutUserListNoListError"
:label="$options.translations.rolloutUserListLabel"
:description="$options.translations.rolloutUserListDescription"
:description="hasUserLists ? $options.translations.rolloutUserListDescription : ''"
>
<template #default="{ inputId }">
<gl-form-select
......
......@@ -15,7 +15,7 @@ export default {
type: Object,
},
},
translations: {
i18n: {
rolloutPercentageDescription: __('Enter a whole number between 0 and 100'),
rolloutPercentageInvalid: s__(
'FeatureFlags|Percent rollout must be a whole number between 0 and 100',
......@@ -24,10 +24,11 @@ export default {
},
computed: {
isValid() {
return Number(this.percentage) >= 0 && Number(this.percentage) <= 100;
const percentageNum = Number(this.percentage);
return Number.isInteger(percentageNum) && percentageNum >= 0 && percentageNum <= 100;
},
percentage() {
return this.strategy?.parameters?.percentage ?? '';
return this.strategy?.parameters?.percentage ?? '100';
},
},
methods: {
......@@ -44,9 +45,9 @@ export default {
</script>
<template>
<parameter-form-group
:label="$options.translations.rolloutPercentageLabel"
:description="$options.translations.rolloutPercentageDescription"
:invalid-feedback="$options.translations.rolloutPercentageInvalid"
:label="$options.i18n.rolloutPercentageLabel"
:description="isValid ? $options.i18n.rolloutPercentageDescription : ''"
:invalid-feedback="$options.i18n.rolloutPercentageInvalid"
:state="isValid"
>
<template #default="{ inputId }">
......
<script>
import Vue from 'vue';
import { isNumber } from 'lodash';
import { GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui';
import { GlAlert, GlButton, GlFormSelect, GlFormGroup, GlIcon, GlLink, GlToken } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import { EMPTY_PARAMETERS, STRATEGY_SELECTIONS } from '../constants';
import {
EMPTY_PARAMETERS,
STRATEGY_SELECTIONS,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
} from '../constants';
import NewEnvironmentsDropdown from './new_environments_dropdown.vue';
import StrategyParameters from './strategy_parameters.vue';
export default {
components: {
GlAlert,
GlButton,
GlFormGroup,
GlFormSelect,
......@@ -51,13 +56,13 @@ export default {
i18n: {
allEnvironments: __('All environments'),
environmentsLabel: __('Environments'),
rolloutUserListLabel: s__('FeatureFlag|List'),
rolloutUserListDescription: s__('FeatureFlag|Select a user list'),
rolloutUserListNoListError: s__('FeatureFlag|There are no configured user lists'),
strategyTypeDescription: __('Select strategy activation method.'),
strategyTypeDescription: __('Select strategy activation method'),
strategyTypeLabel: s__('FeatureFlag|Type'),
environmentsSelectDescription: s__(
'FeatureFlag|Select the environment scope for this feature flag.',
'FeatureFlag|Select the environment scope for this feature flag',
),
considerFlexibleRollout: s__(
'FeatureFlags|Consider using the more flexible "Percent rollout" strategy instead.',
),
},
......@@ -85,6 +90,9 @@ export default {
filteredEnvironments() {
return this.environments.filter(e => !e.shouldBeDestroyed);
},
isPercentUserRollout() {
return this.formStrategy.name === ROLLOUT_STRATEGY_PERCENT_ROLLOUT;
},
},
methods: {
addEnvironment(environment) {
......@@ -121,73 +129,84 @@ export default {
};
</script>
<template>
<div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6">
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap">
<div class="mr-5">
<gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId">
<p class="gl-display-inline-block ">{{ $options.i18n.strategyTypeDescription }}</p>
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
<gl-icon name="question" />
</gl-link>
<gl-form-select
:id="strategyTypeId"
:value="formStrategy.name"
:options="$options.strategies"
@change="onStrategyTypeChange"
<div>
<gl-alert v-if="isPercentUserRollout" variant="tip" :dismissible="false">
{{ $options.i18n.considerFlexibleRollout }}
</gl-alert>
<div class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-100 gl-py-6">
<div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row flex-md-wrap">
<div class="mr-5">
<gl-form-group :label="$options.i18n.strategyTypeLabel" :label-for="strategyTypeId">
<template #description>
{{ $options.i18n.strategyTypeDescription }}
<gl-link :href="strategyTypeDocsPagePath" target="_blank">
<gl-icon name="question" />
</gl-link>
</template>
<gl-form-select
:id="strategyTypeId"
:value="formStrategy.name"
:options="$options.strategies"
@change="onStrategyTypeChange"
/>
</gl-form-group>
</div>
<div data-testid="strategy">
<strategy-parameters
:strategy="strategy"
:user-lists="userLists"
@change="onStrategyChange"
/>
</gl-form-group>
</div>
</div>
<div data-testid="strategy">
<strategy-parameters
:strategy="strategy"
:user-lists="userLists"
@change="onStrategyChange"
/>
<div
class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto"
>
<gl-button
data-testid="delete-strategy-button"
variant="danger"
icon="remove"
@click="$emit('delete')"
/>
</div>
</div>
<div
class="align-self-end align-self-md-stretch order-first offset-md-0 order-md-0 gl-ml-auto"
>
<gl-button
data-testid="delete-strategy-button"
variant="danger"
icon="remove"
@click="$emit('delete')"
/>
</div>
</div>
<label class="gl-display-block" :for="environmentsDropdownId">{{
$options.i18n.environmentsLabel
}}</label>
<p class="gl-display-inline-block">{{ $options.i18n.environmentsSelectDescription }}</p>
<gl-link :href="environmentsScopeDocsPath" target="_blank">
<gl-icon name="question" />
</gl-link>
<div class="gl-display-flex gl-flex-direction-column">
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center"
>
<new-environments-dropdown
:id="environmentsDropdownId"
:endpoint="endpoint"
class="gl-mr-3"
@add="addEnvironment"
/>
<span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3">
{{ $options.i18n.allEnvironments }}
</span>
<div v-else class="gl-display-flex gl-align-items-center">
<gl-token
v-for="environment in filteredEnvironments"
:key="environment.id"
class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
@close="removeScope(environment)"
>
{{ environment.environmentScope }}
</gl-token>
<label class="gl-display-block" :for="environmentsDropdownId">{{
$options.i18n.environmentsLabel
}}</label>
<div class="gl-display-flex gl-flex-direction-column">
<div
class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row align-items-start gl-md-align-items-center"
>
<new-environments-dropdown
:id="environmentsDropdownId"
:endpoint="endpoint"
class="gl-mr-3"
@add="addEnvironment"
/>
<span v-if="appliesToAllEnvironments" class="text-secondary gl-mt-3 mt-md-0 ml-md-3">
{{ $options.i18n.allEnvironments }}
</span>
<div v-else class="gl-display-flex gl-align-items-center">
<gl-token
v-for="environment in filteredEnvironments"
:key="environment.id"
class="gl-mt-3 gl-mr-3 mt-md-0 mr-md-0 ml-md-2 rounded-pill"
@close="removeScope(environment)"
>
{{ environment.environmentScope }}
</gl-token>
</div>
</div>
</div>
<span class="gl-display-inline-block gl-py-3">
{{ $options.i18n.environmentsSelectDescription }}
</span>
<gl-link :href="environmentsScopeDocsPath" target="_blank">
<gl-icon name="question" />
</gl-link>
</div>
</div>
</template>
<script>
import {
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
} from '../constants';
import Default from './strategies/default.vue';
import FlexibleRollout from './strategies/flexible_rollout.vue';
import PercentRollout from './strategies/percent_rollout.vue';
import UsersWithId from './strategies/users_with_id.vue';
import GitlabUserList from './strategies/gitlab_user_list.vue';
const STRATEGIES = Object.freeze({
[ROLLOUT_STRATEGY_ALL_USERS]: Default,
[ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: FlexibleRollout,
[ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: PercentRollout,
[ROLLOUT_STRATEGY_USER_ID]: UsersWithId,
[ROLLOUT_STRATEGY_GITLAB_USER_LIST]: GitlabUserList,
......
......@@ -3,6 +3,7 @@ import { s__ } from '~/locale';
export const ROLLOUT_STRATEGY_ALL_USERS = 'default';
export const ROLLOUT_STRATEGY_PERCENT_ROLLOUT = 'gradualRolloutUserId';
export const ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT = 'flexibleRollout';
export const ROLLOUT_STRATEGY_USER_ID = 'userWithId';
export const ROLLOUT_STRATEGY_GITLAB_USER_LIST = 'gitlabUserList';
......@@ -34,6 +35,10 @@ export const STRATEGY_SELECTIONS = [
value: ROLLOUT_STRATEGY_ALL_USERS,
text: s__('FeatureFlags|All users'),
},
{
value: ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
text: s__('FeatureFlags|Percent rollout'),
},
{
value: ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
text: s__('FeatureFlags|Percent of users'),
......
......@@ -2,6 +2,7 @@ import { s__, n__, sprintf } from '~/locale';
import {
ALL_ENVIRONMENTS_NAME,
ROLLOUT_STRATEGY_ALL_USERS,
ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT,
ROLLOUT_STRATEGY_PERCENT_ROLLOUT,
ROLLOUT_STRATEGY_USER_ID,
ROLLOUT_STRATEGY_GITLAB_USER_LIST,
......@@ -12,6 +13,23 @@ const badgeTextByType = {
name: s__('FeatureFlags|All Users'),
parameters: null,
},
[ROLLOUT_STRATEGY_FLEXIBLE_ROLLOUT]: {
name: s__('FeatureFlags|Percent rollout'),
parameters: ({ parameters: { rollout, stickiness } }) => {
switch (stickiness) {
case 'USERID':
return sprintf(s__('FeatureFlags|%{percent} by user ID'), { percent: `${rollout}%` });
case 'SESSIONID':
return sprintf(s__('FeatureFlags|%{percent} by session ID'), { percent: `${rollout}%` });
case 'RANDOM':
return sprintf(s__('FeatureFlags|%{percent} randomly'), { percent: `${rollout}%` });
default:
return sprintf(s__('FeatureFlags|%{percent} by available ID'), {
percent: `${rollout}%`,
});
}
},
},
[ROLLOUT_STRATEGY_PERCENT_ROLLOUT]: {
name: s__('FeatureFlags|Percent of users'),
parameters: ({ parameters: { percentage } }) => `${percentage}%`,
......
......@@ -7,7 +7,6 @@ import CommitMessageField from './message_field.vue';
import Actions from './actions.vue';
import SuccessMessage from './success_message.vue';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
import consts from '../../stores/modules/commit/constants';
import { createUnexpectedCommitError } from '../../lib/errors';
export default {
......@@ -45,12 +44,11 @@ export default {
return this.currentActivityView === leftSidebarViews.commit.name;
},
commitErrorPrimaryAction() {
if (!this.lastCommitError?.canCreateBranch) {
return undefined;
}
const { primaryAction } = this.lastCommitError || {};
return {
text: __('Create new branch'),
button: primaryAction ? { text: primaryAction.text } : undefined,
callback: primaryAction?.callback?.bind(this, this.$store) || (() => {}),
};
},
},
......@@ -78,9 +76,6 @@ export default {
commit() {
return this.commitChanges();
},
forceCreateNewBranch() {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commit());
},
handleCompactState() {
if (this.lastCommitMsg) {
this.isCompact = false;
......@@ -188,9 +183,9 @@ export default {
ref="commitErrorModal"
modal-id="ide-commit-error-modal"
:title="lastCommitError.title"
:action-primary="commitErrorPrimaryAction"
:action-primary="commitErrorPrimaryAction.button"
:action-cancel="{ text: __('Cancel') }"
@ok="forceCreateNewBranch"
@ok="commitErrorPrimaryAction.callback"
>
<div v-safe-html="lastCommitError.messageHTML"></div>
</gl-modal>
......
import { escape } from 'lodash';
import { __ } from '~/locale';
import consts from '../stores/modules/commit/constants';
const CODEOWNERS_REGEX = /Push.*protected branches.*CODEOWNERS/;
const BRANCH_CHANGED_REGEX = /changed.*since.*start.*edit/;
const BRANCH_ALREADY_EXISTS = /branch.*already.*exists/;
export const createUnexpectedCommitError = () => ({
const createNewBranchAndCommit = store =>
store
.dispatch('commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH)
.then(() => store.dispatch('commit/commitChanges'));
export const createUnexpectedCommitError = message => ({
title: __('Unexpected error'),
messageHTML: __('Could not commit. An unexpected error occurred.'),
canCreateBranch: false,
messageHTML: escape(message) || __('Could not commit. An unexpected error occurred.'),
});
export const createCodeownersCommitError = message => ({
title: __('CODEOWNERS rule violation'),
messageHTML: escape(message),
canCreateBranch: true,
primaryAction: {
text: __('Create new branch'),
callback: createNewBranchAndCommit,
},
});
export const createBranchChangedCommitError = message => ({
title: __('Branch changed'),
messageHTML: `${escape(message)}<br/><br/>${__('Would you like to create a new branch?')}`,
canCreateBranch: true,
primaryAction: {
text: __('Create new branch'),
callback: createNewBranchAndCommit,
},
});
export const branchAlreadyExistsCommitError = message => ({
title: __('Branch already exists'),
messageHTML: `${escape(message)}<br/><br/>${__(
'Would you like to try auto-generating a branch name?',
)}`,
primaryAction: {
text: __('Create new branch'),
callback: store =>
store.dispatch('commit/addSuffixToBranchName').then(() => createNewBranchAndCommit(store)),
},
});
export const parseCommitError = e => {
......@@ -33,7 +57,9 @@ export const parseCommitError = e => {
return createCodeownersCommitError(message);
} else if (BRANCH_CHANGED_REGEX.test(message)) {
return createBranchChangedCommitError(message);
} else if (BRANCH_ALREADY_EXISTS.test(message)) {
return branchAlreadyExistsCommitError(message);
}
return createUnexpectedCommitError();
return createUnexpectedCommitError(message);
};
......@@ -6,6 +6,7 @@ import {
PERMISSION_CREATE_MR,
PERMISSION_PUSH_CODE,
} from '../constants';
import { addNumericSuffix } from '~/ide/utils';
import Api from '~/api';
export const activeFile = state => state.openFiles.find(file => file.active) || null;
......@@ -167,10 +168,7 @@ export const getAvailableFileName = (state, getters) => path => {
let newPath = path;
while (getters.entryExists(newPath)) {
newPath = newPath.replace(
/([ _-]?)(\d*)(\..+?$|$)/,
(_, before, number, after) => `${before || '_'}${Number(number) + 1}${after}`,
);
newPath = addNumericSuffix(newPath);
}
return newPath;
......
......@@ -8,6 +8,7 @@ import consts from './constants';
import { leftSidebarViews } from '../../../constants';
import eventHub from '../../../eventhub';
import { parseCommitError } from '../../../lib/errors';
import { addNumericSuffix } from '~/ide/utils';
export const updateCommitMessage = ({ commit }, message) => {
commit(types.UPDATE_COMMIT_MESSAGE, message);
......@@ -17,11 +18,8 @@ export const discardDraft = ({ commit }) => {
commit(types.UPDATE_COMMIT_MESSAGE, '');
};
export const updateCommitAction = ({ commit, getters }, commitAction) => {
commit(types.UPDATE_COMMIT_ACTION, {
commitAction,
});
commit(types.TOGGLE_SHOULD_CREATE_MR, !getters.shouldHideNewMrOption);
export const updateCommitAction = ({ commit }, commitAction) => {
commit(types.UPDATE_COMMIT_ACTION, { commitAction });
};
export const toggleShouldCreateMR = ({ commit }) => {
......@@ -32,6 +30,12 @@ export const updateBranchName = ({ commit }, branchName) => {
commit(types.UPDATE_NEW_BRANCH_NAME, branchName);
};
export const addSuffixToBranchName = ({ commit, state }) => {
const newBranchName = addNumericSuffix(state.newBranchName, true);
commit(types.UPDATE_NEW_BRANCH_NAME, newBranchName);
};
export const setLastCommitMessage = ({ commit, rootGetters }, data) => {
const { currentProject } = rootGetters;
const commitStats = data.stats
......@@ -107,7 +111,7 @@ export const updateFilesAfterCommit = ({ commit, dispatch, rootState, rootGetter
export const commitChanges = ({ commit, state, getters, dispatch, rootState, rootGetters }) => {
// Pull commit options out because they could change
// During some of the pre and post commit processing
const { shouldCreateMR, isCreatingNewBranch, branchName } = getters;
const { shouldCreateMR, shouldHideNewMrOption, isCreatingNewBranch, branchName } = getters;
const newBranch = state.commitAction !== consts.COMMIT_TO_CURRENT_BRANCH;
const stageFilesPromise = rootState.stagedFiles.length
? Promise.resolve()
......@@ -167,7 +171,7 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo
commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true });
}, 5000);
if (shouldCreateMR) {
if (shouldCreateMR && !shouldHideNewMrOption) {
const { currentProject } = rootGetters;
const targetBranch = isCreatingNewBranch
? rootState.currentBranchId
......
......@@ -10,9 +10,7 @@ export default {
Object.assign(state, { commitAction });
},
[types.UPDATE_NEW_BRANCH_NAME](state, newBranchName) {
Object.assign(state, {
newBranchName,
});
Object.assign(state, { newBranchName });
},
[types.UPDATE_LOADING](state, submitCommitLoading) {
Object.assign(state, {
......
......@@ -139,6 +139,34 @@ export function getFileEOL(content = '') {
return content.includes('\r\n') ? 'CRLF' : 'LF';
}
/**
* Adds or increments the numeric suffix to a filename/branch name.
* Retains underscore or dash before the numeric suffix if it already exists.
*
* Examples:
* hello -> hello-1
* hello-2425 -> hello-2425
* hello.md -> hello-1.md
* hello_2.md -> hello_3.md
* hello_ -> hello_1
* master-patch-22432 -> master-patch-22433
* patch_332 -> patch_333
*
* @param {string} filename File name or branch name
* @param {number} [randomize] Should randomize the numeric suffix instead of auto-incrementing?
*/
export function addNumericSuffix(filename, randomize = false) {
return filename.replace(/([ _-]?)(\d*)(\..+?$|$)/, (_, before, number, after) => {
const n = randomize
? Math.random()
.toString()
.substring(2, 7)
.slice(-5)
: Number(number) + 1;
return `${before || '-'}${n}${after}`;
});
}
export const measurePerformance = (
mark,
measureName,
......
......@@ -16,6 +16,7 @@ import {
GlEmptyState,
} from '@gitlab/ui';
import Api from '~/api';
import Tracking from '~/tracking';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
......@@ -41,6 +42,7 @@ import {
TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID,
INCIDENT_DETAILS_PATH,
trackIncidentCreateNewOptions,
} from '../constants';
const tdClass =
......@@ -58,6 +60,7 @@ const initialPaginationState = {
};
export default {
trackIncidentCreateNewOptions,
i18n: I18N,
statusTabs: INCIDENT_STATUS_TABS,
fields: [
......@@ -335,6 +338,11 @@ export default {
navigateToIncidentDetails({ iid }) {
return visitUrl(joinPaths(this.issuePath, INCIDENT_DETAILS_PATH, iid));
},
navigateToCreateNewIncident() {
const { category, action } = this.$options.trackIncidentCreateNewOptions;
Tracking.event(category, action);
this.redirecting = true;
},
handlePageChange(page) {
const { startCursor, endCursor } = this.incidents.pageInfo;
......@@ -458,7 +466,7 @@ export default {
category="primary"
variant="success"
:href="newIncidentPath"
@click="redirecting = true"
@click="navigateToCreateNewIncident"
>
{{ $options.i18n.createIncidentBtnLabel }}
</gl-button>
......
/* eslint-disable @gitlab/require-i18n-strings */
import { s__, __ } from '~/locale';
export const I18N = {
......@@ -34,6 +35,14 @@ export const INCIDENT_STATUS_TABS = [
},
];
/**
* Tracks snowplow event when user clicks create new incident
*/
export const trackIncidentCreateNewOptions = {
category: 'Incident Management',
action: 'create_incident_button_clicks',
};
export const DEFAULT_PAGE_SIZE = 20;
export const TH_CREATED_AT_TEST_ID = { 'data-testid': 'incident-management-created-at-sort' };
export const TH_SEVERITY_TEST_ID = { 'data-testid': 'incident-management-severity-sort' };
......
<script>
import $ from 'jquery';
import { GlSafeHtmlDirective as SafeHtml } from '@gitlab/ui';
import '~/behaviors/markdown/render_gfm';
export default {
directives: {
SafeHtml,
},
props: {
issuable: {
type: Object,
required: true,
},
},
mounted() {
this.renderGFM();
},
methods: {
renderGFM() {
$(this.$refs.gfmContainer).renderGFM();
},
},
};
</script>
<template>
<div class="description">
<div ref="gfmContainer" v-safe-html="issuable.descriptionHtml" class="md"></div>
</div>
</template>
<script>
import $ from 'jquery';
import { GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui';
import Autosave from '~/autosave';
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import eventHub from '../event_hub';
export default {
components: {
GlForm,
GlFormGroup,
GlFormInput,
MarkdownField,
},
props: {
issuable: {
type: Object,
required: true,
},
enableAutocomplete: {
type: Boolean,
required: true,
},
descriptionPreviewPath: {
type: String,
required: true,
},
descriptionHelpPath: {
type: String,
required: true,
},
},
data() {
const { title, description } = this.issuable;
return {
title,
description,
};
},
created() {
eventHub.$on('update.issuable', this.resetAutosave);
eventHub.$on('close.form', this.resetAutosave);
},
mounted() {
this.initAutosave();
},
beforeDestroy() {
eventHub.$off('update.issuable', this.resetAutosave);
eventHub.$off('close.form', this.resetAutosave);
},
methods: {
initAutosave() {
const { titleInput, descriptionInput } = this.$refs;
if (!titleInput || !descriptionInput) return;
this.autosaveTitle = new Autosave($(titleInput.$el), [
document.location.pathname,
document.location.search,
'title',
]);
this.autosaveDescription = new Autosave($(descriptionInput.$el), [
document.location.pathname,
document.location.search,
'description',
]);
},
resetAutosave() {
this.autosaveTitle.reset();
this.autosaveDescription.reset();
},
},
};
</script>
<template>
<gl-form>
<gl-form-group
data-testid="title"
:label="__('Title')"
:label-sr-only="true"
label-for="issuable-title"
class="col-12"
>
<gl-form-input
id="issuable-title"
ref="titleInput"
v-model.trim="title"
:placeholder="__('Title')"
:aria-label="__('Title')"
:autofocus="true"
class="qa-title-input"
/>
</gl-form-group>
<gl-form-group
data-testid="description"
:label="__('Description')"
:label-sr-only="true"
label-for="issuable-description"
class="col-12 common-note-form"
>
<markdown-field
:markdown-preview-path="descriptionPreviewPath"
:markdown-docs-path="descriptionHelpPath"
:enable-autocomplete="enableAutocomplete"
:textarea-value="description"
>
<template #textarea>
<textarea
id="issuable-description"
ref="descriptionInput"
v-model="description"
:data-supports-quick-actions="enableAutocomplete"
:aria-label="__('Description')"
:placeholder="__('Write a comment or drag your files here…')"
class="note-textarea js-gfm-input js-autosize markdown-area
qa-description-textarea"
dir="auto"
></textarea>
</template>
</markdown-field>
</gl-form-group>
<div data-testid="actions" class="col-12 gl-mt-3 gl-mb-3 clearfix">
<slot
name="edit-form-actions"
:issuable-title="title"
:issuable-description="description"
></slot>
</div>
</gl-form>
</template>
<script>
import {
GlIcon,
GlButton,
GlIntersectionObserver,
GlTooltipDirective,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
export default {
components: {
GlIcon,
GlButton,
GlIntersectionObserver,
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
props: {
issuable: {
type: Object,
required: true,
},
statusBadgeClass: {
type: String,
required: true,
},
statusIcon: {
type: String,
required: true,
},
enableEdit: {
type: Boolean,
required: true,
},
},
data() {
return {
stickyTitleVisible: false,
};
},
methods: {
handleTitleAppear() {
this.stickyTitleVisible = false;
},
handleTitleDisappear() {
this.stickyTitleVisible = true;
},
},
};
</script>
<template>
<div>
<div class="title-container">
<h2 v-safe-html="issuable.titleHtml" class="title qa-title" dir="auto"></h2>
<gl-button
v-if="enableEdit"
v-gl-tooltip.bottom
:title="__('Edit title and description')"
icon="pencil"
class="btn-edit js-issuable-edit qa-edit-button"
@click="$emit('edit-issuable', $event)"
/>
</div>
<gl-intersection-observer @appear="handleTitleAppear" @disappear="handleTitleDisappear">
<transition name="issuable-header-slide">
<div
v-if="stickyTitleVisible"
class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3"
data-testid="header"
>
<div
class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-mx-auto gl-px-5"
>
<p
data-testid="status"
class="issuable-status-box status-box gl-my-0"
:class="statusBadgeClass"
>
<gl-icon :name="statusIcon" class="gl-display-block d-sm-none gl-h-6!" />
<span class="gl-display-none d-sm-block"><slot name="status-badge"></slot></span>
</p>
<p
class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0"
:title="issuable.title"
>
{{ issuable.title }}
</p>
</div>
</div>
</transition>
</gl-intersection-observer>
</div>
</template>
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();
......@@ -14,12 +14,12 @@ export default {
},
computed: {
...mapState(['composerHelpPath']),
...mapGetters(['composerRegistryInclude', 'composerPackageInclude']),
...mapGetters(['composerRegistryInclude', 'composerPackageInclude', 'groupExists']),
},
i18n: {
registryInclude: s__('PackageRegistry|composer.json registry include'),
registryInclude: s__('PackageRegistry|Add composer registry'),
copyRegistryInclude: s__('PackageRegistry|Copy registry include'),
packageInclude: s__('PackageRegistry|composer.json require package include'),
packageInclude: s__('PackageRegistry|Install package version'),
copyPackageInclude: s__('PackageRegistry|Copy require package include'),
infoLine: s__(
'PackageRegistry|For more information on Composer packages in GitLab, %{linkStart}see the documentation.%{linkEnd}',
......@@ -32,31 +32,33 @@ export default {
<template>
<div>
<h3 class="gl-font-lg">{{ __('Installation') }}</h3>
<div v-if="groupExists">
<h3 class="gl-font-lg">{{ __('Installation') }}</h3>
<code-instruction
:label="$options.i18n.registryInclude"
:instruction="composerRegistryInclude"
:copy-text="$options.i18n.copyRegistryInclude"
:tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND"
:tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
data-testid="registry-include"
/>
<code-instruction
:label="$options.i18n.registryInclude"
:instruction="composerRegistryInclude"
:copy-text="$options.i18n.copyRegistryInclude"
:tracking-action="$options.trackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND"
:tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
data-testid="registry-include"
/>
<code-instruction
:label="$options.i18n.packageInclude"
:instruction="composerPackageInclude"
:copy-text="$options.i18n.copyPackageInclude"
:tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND"
:tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
data-testid="package-include"
/>
<span data-testid="help-text">
<gl-sprintf :message="$options.i18n.infoLine">
<template #link="{ content }">
<gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
<code-instruction
:label="$options.i18n.packageInclude"
:instruction="composerPackageInclude"
:copy-text="$options.i18n.copyPackageInclude"
:tracking-action="$options.trackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND"
:tracking-label="$options.TrackingLabels.CODE_INSTRUCTION"
data-testid="package-include"
/>
<span data-testid="help-text">
<gl-sprintf :message="$options.i18n.infoLine">
<template #link="{ content }">
<gl-link :href="composerHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
</div>
</div>
</template>
......@@ -102,11 +102,12 @@ repository = ${pypiSetupPath}
username = __token__
password = <your personal access token>`;
export const composerRegistryInclude = ({ composerPath }) => {
const base = { type: 'composer', url: composerPath };
return JSON.stringify(base);
};
export const composerPackageInclude = ({ packageEntity }) => {
const base = { [packageEntity.name]: packageEntity.version };
return JSON.stringify(base);
};
export const composerRegistryInclude = ({ composerPath, composerConfigRepositoryName }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`composer config repositories.${composerConfigRepositoryName} '{"type": "composer", "url": "${composerPath}"}'`;
export const composerPackageInclude = ({ packageEntity }) =>
// eslint-disable-next-line @gitlab/require-i18n-strings
`composer req ${[packageEntity.name]}:${packageEntity.version}`;
export const groupExists = ({ groupListUrl }) => groupListUrl.length > 0;
<script>
import { GlIcon } from '@gitlab/ui';
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
import { featureAccessLevelNone } from '../constants';
export default {
components: {
GlIcon,
projectFeatureToggle,
},
model: {
prop: 'value',
event: 'change',
},
props: {
name: {
type: String,
......@@ -34,7 +34,6 @@ export default {
default: false,
},
},
computed: {
featureEnabled() {
return this.value !== 0;
......@@ -51,7 +50,6 @@ export default {
return this.disabledInput || !this.featureEnabled || this.displayOptions.length < 2;
},
},
methods: {
toggleFeature(featureEnabled) {
if (featureEnabled === false || this.options.length < 1) {
......@@ -96,7 +94,11 @@ export default {
{{ optionName }}
</option>
</select>
<i aria-hidden="true" class="fa fa-chevron-down"> </i>
<gl-icon
name="chevron-down"
aria-hidden="true"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
/>
</div>
</div>
</template>
<script>
import { GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import { GlIcon, GlSprintf, GlLink, GlFormCheckbox } from '@gitlab/ui';
import settingsMixin from 'ee_else_ce/pages/projects/shared/permissions/mixins/settings_pannel_mixin';
import { s__ } from '~/locale';
......@@ -22,6 +22,7 @@ export default {
projectFeatureSetting,
projectFeatureToggle,
projectSettingRow,
GlIcon,
GlSprintf,
GlLink,
GlFormCheckbox,
......@@ -325,7 +326,12 @@ export default {
>{{ s__('ProjectSettings|Public') }}</option
>
</select>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
<gl-icon
name="chevron-down"
aria-hidden="true"
data-hidden="true"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
/>
</div>
</div>
<span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
......@@ -540,7 +546,12 @@ export default {
>{{ featureAccessLevelEveryone[1] }}</option
>
</select>
<i aria-hidden="true" data-hidden="true" class="fa fa-chevron-down"></i>
<gl-icon
name="chevron-down"
aria-hidden="true"
data-hidden="true"
class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
/>
</div>
</div>
</project-setting-row>
......
<script>
import { GlButton, GlFormSelect, GlToggle, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import { __ } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import eventHub from '../event_hub';
export default {
name: 'ServiceDeskSetting',
directives: {
tooltip,
},
components: {
ClipboardButton,
GlButton,
......
......@@ -2,6 +2,7 @@
/* eslint-disable vue/no-v-html */
import { GlTooltipDirective, GlLink, GlButton, GlButtonGroup, GlLoadingIcon } from '@gitlab/ui';
import defaultAvatarUrl from 'images/no_avatar.png';
import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import { sprintf, s__ } from '~/locale';
import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue';
import TimeagoTooltip from '../../vue_shared/components/time_ago_tooltip.vue';
......@@ -9,7 +10,6 @@ import CiIcon from '../../vue_shared/components/ci_icon.vue';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import pathLastCommitQuery from '../queries/path_last_commit.query.graphql';
export default {
components: {
......
import Vue from 'vue';
import PathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql';
import { escapeFileUrl, joinPaths, webIDEUrl } from '../lib/utils/url_utility';
import createRouter from './router';
import App from './components/app.vue';
......@@ -18,6 +19,10 @@ export default function setupVueRepositoryList() {
const { dataset } = el;
const { projectPath, projectShortPath, ref, escapedRef, fullName } = dataset;
const router = createRouter(projectPath, escapedRef);
const pathRegex = /-\/tree\/[^/]+\/(.+$)/;
const matches = window.location.href.match(pathRegex);
const currentRoutePath = matches ? matches[1] : '';
apolloProvider.clients.defaultClient.cache.writeData({
data: {
......@@ -29,6 +34,43 @@ export default function setupVueRepositoryList() {
},
});
const initLastCommitApp = () =>
new Vue({
el: document.getElementById('js-last-commit'),
router,
apolloProvider,
render(h) {
return h(LastCommit, {
props: {
currentPath: this.$route.params.path,
},
});
},
});
if (window.gl.startup_graphql_calls) {
const query = window.gl.startup_graphql_calls.find(
call => call.operationName === 'pathLastCommit',
);
query.fetchCall
.then(res => res.json())
.then(res => {
apolloProvider.clients.defaultClient.writeQuery({
query: PathLastCommitQuery,
data: res.data,
variables: {
projectPath,
ref,
path: currentRoutePath,
},
});
})
.catch(() => {})
.finally(() => initLastCommitApp());
} else {
initLastCommitApp();
}
router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName);
});
......@@ -77,20 +119,6 @@ export default function setupVueRepositoryList() {
});
}
// eslint-disable-next-line no-new
new Vue({
el: document.getElementById('js-last-commit'),
router,
apolloProvider,
render(h) {
return h(LastCommit, {
props: {
currentPath: this.$route.params.path,
},
});
},
});
const treeHistoryLinkEl = document.getElementById('js-tree-history-link');
const { historyLink } = treeHistoryLinkEl.dataset;
......
<script>
import $ from 'jquery';
import { difference, union } from 'lodash';
import { mapState, mapActions } from 'vuex';
import flash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
......@@ -26,47 +25,49 @@ export default {
'projectIssuesPath',
'projectPath',
],
data: () => ({
labelsSelectInProgress: false,
}),
computed: {
...mapState(['selectedLabels']),
},
mounted() {
this.setInitialState({
data() {
return {
isLabelsSelectInProgress: false,
selectedLabels: this.initiallySelectedLabels,
});
};
},
methods: {
...mapActions(['setInitialState', 'replaceSelectedLabels']),
handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown');
},
handleUpdateSelectedLabels(labels) {
handleUpdateSelectedLabels(dropdownLabels) {
const currentLabelIds = this.selectedLabels.map(label => label.id);
const userAddedLabelIds = labels.filter(label => label.set).map(label => label.id);
const userRemovedLabelIds = labels.filter(label => !label.set).map(label => label.id);
const userAddedLabelIds = dropdownLabels.filter(label => label.set).map(label => label.id);
const userRemovedLabelIds = dropdownLabels.filter(label => !label.set).map(label => label.id);
const issuableLabels = difference(
union(currentLabelIds, userAddedLabelIds),
userRemovedLabelIds,
);
const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds);
this.labelsSelectInProgress = true;
this.updateSelectedLabels(labelIds);
},
handleLabelRemove(labelId) {
const currentLabelIds = this.selectedLabels.map(label => label.id);
const labelIds = difference(currentLabelIds, [labelId]);
this.updateSelectedLabels(labelIds);
},
updateSelectedLabels(labelIds) {
this.isLabelsSelectInProgress = true;
axios({
data: {
[this.issuableType]: {
label_ids: issuableLabels,
label_ids: labelIds,
},
},
method: 'put',
url: this.labelsUpdatePath,
})
.then(({ data }) => this.replaceSelectedLabels(data.labels))
.then(({ data }) => {
this.selectedLabels = data.labels;
})
.catch(() => flash(__('An error occurred while updating labels.')))
.finally(() => {
this.labelsSelectInProgress = false;
this.isLabelsSelectInProgress = false;
});
},
},
......@@ -76,6 +77,7 @@ export default {
<template>
<labels-select
class="block labels js-labels-block"
:allow-label-remove="true"
:allow-label-create="allowLabelCreate"
:allow-label-edit="allowLabelEdit"
:allow-multiselect="true"
......@@ -86,11 +88,12 @@ export default {
:labels-fetch-path="labelsFetchPath"
:labels-filter-base-path="projectIssuesPath"
:labels-manage-path="labelsManagePath"
:labels-select-in-progress="labelsSelectInProgress"
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
......
import $ from 'jquery';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking.vue';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
......@@ -17,11 +16,9 @@ import createDefaultClient from '~/lib/graphql';
import { isInIssuePage, isInIncidentPage, parseBoolean } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
Vue.use(Translate);
Vue.use(VueApollo);
Vue.use(Vuex);
function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-options')) {
return JSON.parse(sidebarOptEl.innerHTML);
......@@ -94,8 +91,6 @@ export function mountSidebarLabels() {
return false;
}
const labelsStore = new Vuex.Store(labelsSelectModule());
return new Vue({
el,
provide: {
......@@ -105,7 +100,6 @@ export function mountSidebarLabels() {
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
},
store: labelsStore,
render: createElement => createElement(SidebarLabels),
});
}
......
......@@ -4,6 +4,7 @@ import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import { GlSafeHtmlDirective } from '@gitlab/ui';
import { sprintf, s__, __ } from '~/locale';
import Project from '~/pages/projects/project';
import SmartInterval from '~/smart_interval';
......@@ -52,6 +53,9 @@ export default {
// False positive i18n lint: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/25
// eslint-disable-next-line @gitlab/require-i18n-strings
name: 'MRWidget',
directives: {
SafeHtml: GlSafeHtmlDirective,
},
components: {
Loading,
'mr-widget-header': WidgetHeader,
......@@ -510,7 +514,7 @@ export default {
</mr-widget-alert-message>
<mr-widget-alert-message v-if="mr.mergeError" type="danger">
{{ mergeError }}
<span v-safe-html="mergeError"></span>
</mr-widget-alert-message>
<source-branch-removal-status v-if="shouldRenderSourceBranchRemovalStatus" />
......
......@@ -8,11 +8,13 @@ import {
import { generateBadges } from 'ee_else_ce/vue_shared/components/members/utils';
import { __ } from '~/locale';
import { AVATAR_SIZE } from '../constants';
import { glEmojiTag } from '~/emoji';
export default {
name: 'UserAvatar',
avatarSize: AVATAR_SIZE,
orphanedUserLabel: __('Orphaned member'),
safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] },
components: {
GlAvatarLink,
GlAvatarLabeled,
......@@ -38,6 +40,12 @@ export default {
badges() {
return generateBadges(this.member, this.isCurrentUser).filter(badge => badge.show);
},
statusEmoji() {
return this.user?.status?.emoji;
},
},
methods: {
glEmojiTag,
},
};
</script>
......@@ -60,6 +68,9 @@ export default {
:entity-id="user.id"
>
<template #meta>
<div v-if="statusEmoji" class="gl-p-1">
<span v-safe-html:[$options.safeHtmlConfig]="glEmojiTag(statusEmoji)"></span>
</div>
<div v-for="badge in badges" :key="badge.text" class="gl-p-1">
<gl-badge size="sm" :variant="badge.variant">
{{ badge.text }}
......
......@@ -38,8 +38,8 @@ export const FIELDS = [
{
key: 'maxRole',
label: __('Max role'),
thClass: 'col-meta',
tdClass: 'col-meta',
thClass: 'col-max-role',
tdClass: 'col-max-role',
},
{
key: 'expiration',
......
<script>
import { mapState } from 'vuex';
import { GlTable } from '@gitlab/ui';
import { GlTable, GlBadge } from '@gitlab/ui';
import { FIELDS } from '../constants';
import initUserPopovers from '~/user_popovers';
import MemberAvatar from './member_avatar.vue';
......@@ -9,17 +9,20 @@ import CreatedAt from './created_at.vue';
import ExpiresAt from './expires_at.vue';
import MemberActionButtons from './member_action_buttons.vue';
import MembersTableCell from './members_table_cell.vue';
import RoleDropdown from './role_dropdown.vue';
export default {
name: 'MembersTable',
components: {
GlTable,
GlBadge,
MemberAvatar,
CreatedAt,
ExpiresAt,
MembersTableCell,
MemberSource,
MemberActionButtons,
RoleDropdown,
},
computed: {
...mapState(['members', 'tableFields']),
......@@ -77,6 +80,13 @@ export default {
<expires-at :date="expiresAt" />
</template>
<template #cell(maxRole)="{ item: member }">
<members-table-cell #default="{ permissions }" :member="member">
<role-dropdown v-if="permissions.canUpdate" :member="member" />
<gl-badge v-else>{{ member.accessLevel.stringValue }}</gl-badge>
</members-table-cell>
</template>
<template #cell(actions)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser, permissions }" :member="member">
<member-action-buttons
......
......@@ -33,7 +33,7 @@ export default {
return MEMBER_TYPES.user;
},
isDirectMember() {
return this.member.source?.id === this.sourceId;
return this.isGroup || this.member.source?.id === this.sourceId;
},
isCurrentUser() {
return this.member.user?.id === this.currentUserId;
......@@ -44,6 +44,9 @@ export default {
canResend() {
return Boolean(this.member.invite?.canResend);
},
canUpdate() {
return !this.isCurrentUser && this.isDirectMember && this.member.canUpdate;
},
},
render() {
return this.$scopedSlots.default({
......@@ -53,6 +56,7 @@ export default {
permissions: {
canRemove: this.canRemove,
canResend: this.canResend,
canUpdate: this.canUpdate,
},
});
},
......
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
export default {
name: 'RoleDropdown',
components: {
GlDropdown,
GlDropdownItem,
},
props: {
member: {
type: Object,
required: true,
},
},
data() {
return {
isDesktop: false,
};
},
mounted() {
this.isDesktop = bp.isDesktop();
},
methods: {
handleSelect() {
// Vuex action will be called here to make API request and update `member.accessLevel`
},
},
};
</script>
<template>
<gl-dropdown
:right="!isDesktop"
:text="member.accessLevel.stringValue"
:header-text="__('Change permissions')"
>
<gl-dropdown-item
v-for="(value, name) in member.validRoles"
:key="value"
is-check-item
:is-checked="value === member.accessLevel.integerValue"
@click="handleSelect"
>
{{ name }}
</gl-dropdown-item>
</gl-dropdown>
</template>
......@@ -8,8 +8,20 @@ export default {
components: {
GlLabel,
},
props: {
disableLabels: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
...mapState(['selectedLabels', 'allowScopedLabels', 'labelsFilterBasePath']),
...mapState([
'selectedLabels',
'allowLabelRemove',
'allowScopedLabels',
'labelsFilterBasePath',
]),
},
methods: {
labelFilterUrl(label) {
......@@ -42,7 +54,10 @@ export default {
:background-color="label.color"
:target="labelFilterUrl(label)"
:scoped="scopedLabel(label)"
:show-close-button="allowLabelRemove"
:disabled="disableLabels"
tooltip-placement="top"
@close="$emit('onLabelRemove', label.id)"
/>
</template>
</div>
......
......@@ -28,6 +28,11 @@ export default {
DropdownValueCollapsed,
},
props: {
allowLabelRemove: {
type: Boolean,
required: false,
default: false,
},
allowLabelEdit: {
type: Boolean,
required: true,
......@@ -130,6 +135,7 @@ export default {
mounted() {
this.setInitialState({
variant: this.variant,
allowLabelRemove: this.allowLabelRemove,
allowLabelEdit: this.allowLabelEdit,
allowLabelCreate: this.allowLabelCreate,
allowMultiselect: this.allowMultiselect,
......@@ -252,7 +258,10 @@ export default {
:allow-label-edit="allowLabelEdit"
:labels-select-in-progress="labelsSelectInProgress"
/>
<dropdown-value>
<dropdown-value
:disable-labels="labelsSelectInProgress"
@onLabelRemove="$emit('onLabelRemove', $event)"
>
<slot></slot>
</dropdown-value>
<dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
......
......@@ -54,8 +54,5 @@ export const createLabel = ({ state, dispatch }, label) => {
});
};
export const replaceSelectedLabels = ({ commit }, selectedLabels) =>
commit(types.REPLACE_SELECTED_LABELS, selectedLabels);
export const updateSelectedLabels = ({ commit }, labels) =>
commit(types.UPDATE_SELECTED_LABELS, { labels });
......@@ -15,7 +15,6 @@ export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
export const REPLACE_SELECTED_LABELS = 'REPLACE_SELECTED_LABELS';
export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
......@@ -57,10 +57,6 @@ export default {
state.labelCreateInProgress = false;
},
[types.REPLACE_SELECTED_LABELS](state, selectedLabels = []) {
state.selectedLabels = selectedLabels;
},
[types.UPDATE_SELECTED_LABELS](state, { labels }) {
// Find the label to update from all the labels
// and change `set` prop value to represent their current state.
......
......@@ -15,6 +15,7 @@ export default () => ({
// UI Flags
variant: '',
allowLabelRemove: false,
allowLabelCreate: false,
allowLabelEdit: false,
allowScopedLabels: false,
......
<script>
import { isString } from 'lodash';
import {
GlDeprecatedDropdown,
GlDeprecatedDropdownDivider,
GlDeprecatedDropdownItem,
} from '@gitlab/ui';
import { GlDropdown, GlDropdownDivider, GlDropdownItem } from '@gitlab/ui';
const isValidItem = item =>
isString(item.eventName) && isString(item.title) && isString(item.description);
export default {
components: {
GlDeprecatedDropdown,
GlDeprecatedDropdownDivider,
GlDeprecatedDropdownItem,
GlDropdown,
GlDropdownDivider,
GlDropdownItem,
},
props: {
......@@ -32,7 +28,7 @@ export default {
variant: {
type: String,
required: false,
default: 'secondary',
default: 'default',
},
},
......@@ -61,8 +57,8 @@ export default {
</script>
<template>
<gl-deprecated-dropdown
:menu-class="`dropdown-menu-selectable ${menuClass}`"
<gl-dropdown
:menu-class="menuClass"
split
:text="dropdownToggleText"
:variant="variant"
......@@ -70,20 +66,20 @@ export default {
@click="triggerEvent"
>
<template v-for="(item, itemIndex) in actionItems">
<gl-deprecated-dropdown-item
<gl-dropdown-item
:key="item.eventName"
:active="selectedItem === item"
active-class="is-active"
:is-check-item="true"
:is-checked="selectedItem === item"
@click="changeSelectedItem(item)"
>
<strong>{{ item.title }}</strong>
<div>{{ item.description }}</div>
</gl-deprecated-dropdown-item>
</gl-dropdown-item>
<gl-deprecated-dropdown-divider
<gl-dropdown-divider
v-if="itemIndex < actionItems.length - 1"
:key="`${item.eventName}-divider`"
/>
</template>
</gl-deprecated-dropdown>
</gl-dropdown>
</template>
......@@ -8,11 +8,8 @@
@import './pages/commits';
@import './pages/deploy_keys';
@import './pages/detail_page';
@import './pages/diff';
@import './pages/editor';
@import './pages/environment_logs';
@import './pages/error_list';
@import './pages/error_tracking_list';
@import './pages/events';
@import './pages/experience_level';
@import './pages/experimental_separate_sign_up';
......
......@@ -70,3 +70,4 @@
@import 'framework/spinner';
@import 'framework/card';
@import 'framework/editor-lite';
@import 'framework/diffs';
......@@ -267,6 +267,7 @@
}
}
}
//.view.swipe
.view.onion-skin {
.onion-skin-frame {
......@@ -335,6 +336,7 @@
}
}
}
//.view.onion-skin
}
......@@ -961,15 +963,13 @@ table.code {
.frame.click-to-comment,
.btn-transparent.image-diff-overlay-add-comment {
position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg')
$image-comment-cursor-left-offset $image-comment-cursor-top-offset,
cursor: image-url('illustrations/image_comment_light_cursor.svg') $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor
// scss-lint:disable DuplicateProperty
cursor: image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x)
$image-comment-cursor-left-offset $image-comment-cursor-top-offset,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator {
......@@ -1078,85 +1078,6 @@ table.code {
position: relative;
}
.diff-tree-list {
position: -webkit-sticky;
position: sticky;
$top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px;
top: $top-pos;
max-height: calc(100vh - #{$top-pos});
z-index: 202;
.with-system-header & {
top: $top-pos + $system-header-height;
}
.with-system-header.with-performance-bar & {
top: $top-pos + $system-header-height + $performance-bar-height;
}
.with-performance-bar & {
$performance-bar-top-pos: $performance-bar-height + $top-pos;
top: $performance-bar-top-pos;
max-height: calc(100vh - #{$performance-bar-top-pos});
}
.drag-handle {
bottom: 16px;
transform: translateX(10px);
}
}
.diff-files-holder {
flex: 1;
min-width: 0;
z-index: 201;
}
.compare-versions-container {
min-width: 0;
}
.tree-list-holder {
height: 100%;
.file-row {
margin-left: 0;
margin-right: 0;
}
}
.tree-list-scroll {
max-height: 100%;
padding-bottom: $grid-size;
overflow-y: scroll;
overflow-x: auto;
}
.tree-list-search {
flex: 0 0 34px;
.form-control {
padding-left: 30px;
}
}
.tree-list-icon {
top: 50%;
left: 10px;
transform: translateY(-50%);
&,
svg {
fill: $gl-text-color-tertiary;
}
}
.tree-list-clear-icon {
right: 10px;
left: auto;
line-height: 0;
}
.discussion-collapsible {
margin: 0 $gl-padding $gl-padding 71px;
......@@ -1172,30 +1093,6 @@ table.code {
}
}
@media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files {
@include fixed-width-container;
flex-direction: column;
.diff-tree-list {
position: relative;
top: 0;
// !important is required to override inline styles of resizable sidebar
width: 100% !important;
}
.tree-list-holder {
max-height: calc(50px + 50vh);
padding-right: 0;
}
}
.discussion-collapsible {
margin: $gl-padding;
margin-top: 0;
}
}
.image-diff-overlay,
.image-diff-overlay-add-comment {
top: 0;
......@@ -1218,3 +1115,15 @@ table.code {
display: none;
}
}
@media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files {
@include fixed-width-container;
flex-direction: column;
}
.discussion-collapsible {
margin: $gl-padding;
margin-top: 0;
}
}
@import 'page_bundles/mixins_and_variables_and_functions';
.error-list {
.dropdown {
min-width: auto;
}
.sort-control {
.btn {
padding-right: 2rem;
......@@ -17,7 +23,7 @@
min-height: 68px;
&:last-child {
background-color: $gray-10;
background-color: var(--gray-10, $gray-10);
&::before {
content: none !important;
......
......@@ -226,6 +226,14 @@ $colors: (
.solarized-dark {
@include color-scheme('solarized-dark'); }
.none {
.line_content.header {
button {
color: $gray-900;
}
}
}
.diff-wrap-lines .line_content {
white-space: normal;
min-height: 19px;
......
@import 'mixins_and_variables_and_functions';
.compare-versions-container {
min-width: 0;
}
.diff-files-holder {
flex: 1;
min-width: 0;
z-index: 201;
}
.diff-tree-list {
position: -webkit-sticky;
position: sticky;
$top-pos: $header-height + $mr-tabs-height + $mr-version-controls-height + 15px;
top: $top-pos;
max-height: calc(100vh - #{$top-pos});
z-index: 202;
.with-system-header & {
top: $top-pos + $system-header-height;
}
.with-system-header.with-performance-bar & {
top: $top-pos + $system-header-height + $performance-bar-height;
}
.with-performance-bar & {
$performance-bar-top-pos: $performance-bar-height + $top-pos;
top: $performance-bar-top-pos;
max-height: calc(100vh - #{$performance-bar-top-pos});
}
.drag-handle {
bottom: 16px;
transform: translateX(10px);
}
}
.tree-list-holder {
height: 100%;
.file-row {
margin-left: 0;
margin-right: 0;
}
}
.tree-list-scroll {
max-height: 100%;
padding-bottom: $grid-size;
overflow-y: scroll;
overflow-x: auto;
}
.tree-list-search {
flex: 0 0 34px;
.form-control {
padding-left: 30px;
}
}
.tree-list-icon {
top: 50%;
left: 10px;
transform: translateY(-50%);
&,
svg {
fill: var(--gray-400, $gray-400);
}
}
.tree-list-clear-icon {
right: 10px;
left: auto;
line-height: 0;
}
@media (max-width: map-get($grid-breakpoints, md)-1) {
.diffs .files {
.diff-tree-list {
position: relative;
top: 0;
// !important is required to override inline styles of resizable sidebar
width: 100% !important;
}
.tree-list-holder {
max-height: calc(50px + 50vh);
padding-right: 0;
}
}
}
.error-list {
.dropdown {
min-width: auto;
}
}
......@@ -216,6 +216,10 @@
width: px-to-rem(150px);
}
.col-max-role {
width: px-to-rem(175px);
}
.col-expiration {
width: px-to-rem(200px);
}
......
......@@ -171,7 +171,7 @@ class Admin::UsersController < Admin::ApplicationController
# restore username to keep form action url.
user.username = params[:id]
format.html { render "edit" }
format.json { render json: [result[:message]], status: result[:status] }
format.json { render json: [result[:message]], status: :internal_server_error }
end
end
end
......
......@@ -45,7 +45,7 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli
if result[:status] == :success
head :ok
else
render json: { message: result[:message] }, status: result[:status]
render json: { message: result[:message] }, status: :internal_server_error
end
end
......
query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
project(fullPath: $projectPath) {
__typename
repository {
__typename
tree(path: $path, ref: $ref) {
__typename
lastCommit {
__typename
sha
title
titleHtml
......@@ -13,15 +17,20 @@ query pathLastCommit($projectPath: ID!, $path: String, $ref: String!) {
authorName
authorGravatar
author {
__typename
name
avatarUrl
webPath
}
signatureHtml
pipelines(ref: $ref, first: 1) {
__typename
edges {
__typename
node {
__typename
detailedStatus {
__typename
detailsPath
icon
tooltip
......
......@@ -44,6 +44,7 @@ module ResolvesMergeRequests
author: [:author],
merged_at: [:metrics],
commit_count: [:metrics],
diff_stats_summary: [:metrics],
approved_by: [:approved_by_users],
milestone: [:milestone],
head_pipeline: [:merge_request_diff, { head_pipeline: [:merge_request] }]
......
......@@ -7,22 +7,22 @@ module Types
graphql_name 'DetailedStatus'
field :group, GraphQL::STRING_TYPE, null: false,
description: 'Group of the pipeline status'
description: 'Group of the status'
field :icon, GraphQL::STRING_TYPE, null: false,
description: 'Icon of the pipeline status'
description: 'Icon of the status'
field :favicon, GraphQL::STRING_TYPE, null: false,
description: 'Favicon of the pipeline status'
field :details_path, GraphQL::STRING_TYPE, null: false,
description: 'Path of the details for the pipeline status'
description: 'Favicon of the status'
field :details_path, GraphQL::STRING_TYPE, null: true,
description: 'Path of the details for the status'
field :has_details, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates if the pipeline status has further details',
description: 'Indicates if the status has further details',
method: :has_details?
field :label, GraphQL::STRING_TYPE, null: false,
description: 'Label of the pipeline status'
description: 'Label of the status'
field :text, GraphQL::STRING_TYPE, null: false,
description: 'Text of the pipeline status'
description: 'Text of the status'
field :tooltip, GraphQL::STRING_TYPE, null: false,
description: 'Tooltip associated with the pipeline status',
description: 'Tooltip associated with the status',
method: :status_tooltip
field :action, Types::Ci::StatusActionType, null: true,
description: 'Action information for the status. This includes method, button title, icon, path, and title',
......
......@@ -12,6 +12,9 @@ module Types
description: 'Size of the group'
field :jobs, Ci::JobType.connection_type, null: true,
description: 'Jobs in group'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the group',
resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
end
end
end
......@@ -10,6 +10,9 @@ module Types
description: 'Name of the job'
field :needs, JobType.connection_type, null: true,
description: 'Builds that must complete before the jobs run'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the job',
resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
end
end
end
......@@ -10,6 +10,9 @@ module Types
description: 'Name of the stage'
field :groups, Ci::GroupType.connection_type, null: true,
description: 'Group of jobs for the stage'
field :detailed_status, Types::Ci::DetailedStatusType, null: true,
description: 'Detailed status of the stage',
resolve: -> (obj, _args, ctx) { obj.detailed_status(ctx[:current_user]) }
end
end
end
......@@ -170,6 +170,12 @@ module Types
end
def diff_stats_summary
metrics = object.metrics
if metrics && metrics.added_lines && metrics.removed_lines
return { additions: metrics.added_lines, deletions: metrics.removed_lines, file_count: object.merge_request_diff&.files_count || 0 }
end
nil_stats = { additions: 0, deletions: 0, file_count: 0 }
return nil_stats unless object.diff_stats.present?
......
......@@ -34,6 +34,10 @@ module PackagesHelper
expose_url(api_v4_group___packages_composer_packages_path(id: group_id, format: '.json'))
end
def composer_config_repository_name(group_id)
"#{Gitlab.config.gitlab.host}/#{group_id}"
end
def packages_list_data(type, resource)
{
resource_id: resource.id,
......
......@@ -471,7 +471,8 @@ module ProjectsHelper
labels: :read_label,
issues: :read_issue,
project_members: :read_project_member,
wiki: :read_wiki
wiki: :read_wiki,
feature_flags: :read_feature_flag
}
end
......@@ -482,7 +483,8 @@ module ProjectsHelper
:read_environment,
:read_issue,
:read_sentry_issue,
:read_cluster
:read_cluster,
:read_feature_flag
].any? do |ability|
can?(current_user, ability, project)
end
......@@ -561,7 +563,11 @@ module ProjectsHelper
end
def sidebar_operations_link_path(project = @project)
metrics_project_environments_path(project) if can?(current_user, :read_environment, project)
if can?(current_user, :read_environment, project)
metrics_project_environments_path(project)
else
project_feature_flags_path(project)
end
end
def project_last_activity(project)
......@@ -754,6 +760,7 @@ module ProjectsHelper
logs
product_analytics
metrics_dashboard
feature_flags
tracings
]
end
......
# frozen_string_literal: true
module StartupjsHelper
def page_startup_graphql_calls
@graphql_startup_calls
end
def add_page_startup_graphql_call(query, variables = {})
@graphql_startup_calls ||= []
file_location = File.join(Rails.root, "app/graphql/queries/#{query}.query.graphql")
return unless File.exist?(file_location)
query_str = File.read(file_location)
@graphql_startup_calls << { query: query_str, variables: variables }
end
end
# frozen_string_literal: true
module Ci
class DeletedObject < ApplicationRecord
extend Gitlab::Ci::Model
mount_uploader :file, DeletedObjectUploader
scope :ready_for_destruction, ->(limit) do
where('pick_up_at < ?', Time.current).limit(limit)
end
scope :lock_for_destruction, ->(limit) do
ready_for_destruction(limit)
.select(:id)
.order(:pick_up_at)
.lock('FOR UPDATE SKIP LOCKED')
end
def self.bulk_import(artifacts)
attributes = artifacts.each.with_object([]) do |artifact, accumulator|
record = artifact.to_deleted_object_attrs
accumulator << record if record[:store_dir] && record[:file]
end
self.insert_all(attributes) if attributes.any?
end
def delete_file_from_storage
file.remove!
true
rescue => exception
Gitlab::ErrorTracking.track_exception(exception)
false
end
end
end
......@@ -290,6 +290,15 @@ module Ci
max_size&.megabytes.to_i
end
def to_deleted_object_attrs
{
file_store: file_store,
store_dir: file.store_dir.to_s,
file: file_identifier,
pick_up_at: expire_at || Time.current
}
end
private
def set_size
......
......@@ -841,6 +841,25 @@ module Ci
end
end
def build_with_artifacts_in_self_and_descendants(name)
builds_in_self_and_descendants
.ordered_by_pipeline # find job in hierarchical order
.with_downloadable_artifacts
.find_by_name(name)
end
def builds_in_self_and_descendants
Ci::Build.latest.where(pipeline: self_and_descendants)
end
# Without using `unscoped`, caller scope is also included into the query.
# Using `unscoped` here will be redundant after Rails 6.1
def self_and_descendants
::Gitlab::Ci::PipelineObjectHierarchy
.new(self.class.unscoped.where(id: id), options: { same_project: true })
.base_and_descendants
end
def bridge_triggered?
source_bridge.present?
end
......
......@@ -74,8 +74,8 @@ class Commit
sha[0..MIN_SHA_LENGTH]
end
def diff_safe_lines
Gitlab::Git::DiffCollection.default_limits[:max_lines]
def diff_safe_lines(project: nil)
Gitlab::Git::DiffCollection.default_limits(project: project)[:max_lines]
end
def diff_hard_limit_files(project: nil)
......
......@@ -48,6 +48,7 @@ class CommitStatus < ApplicationRecord
scope :ordered_by_stage, -> { order(stage_idx: :asc) }
scope :latest_ordered, -> { latest.ordered.includes(project: :namespace) }
scope :retried_ordered, -> { retried.ordered.includes(project: :namespace) }
scope :ordered_by_pipeline, -> { order(pipeline_id: :asc) }
scope :before_stage, -> (index) { where('stage_idx < ?', index) }
scope :for_stage, -> (index) { where(stage_idx: index) }
scope :after_stage, -> (index) { where('stage_idx > ?', index) }
......
......@@ -76,6 +76,7 @@ class Group < Namespace
validate :visibility_level_allowed_by_projects
validate :visibility_level_allowed_by_sub_groups
validate :visibility_level_allowed_by_parent
validate :two_factor_authentication_allowed
validates :variables, variable_duplicates: true
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
......@@ -589,6 +590,16 @@ class Group < Namespace
errors.add(:visibility_level, "#{visibility} is not allowed since there are sub-groups with higher visibility.")
end
def two_factor_authentication_allowed
return unless has_parent?
return unless require_two_factor_authentication
ancestor_settings = ancestors.find_by(parent_id: nil).namespace_settings
return if ancestor_settings.allow_mfa_for_subgroups
errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group'))
end
def members_from_self_and_ancestor_group_shares
group_group_link_table = GroupGroupLink.arel_table
group_member_table = GroupMember.arel_table
......
......@@ -4,6 +4,7 @@ class NamespaceSetting < ApplicationRecord
belongs_to :namespace, inverse_of: :namespace_settings
validate :default_branch_name_content
validate :allow_mfa_for_group
NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze
......@@ -16,6 +17,12 @@ class NamespaceSetting < ApplicationRecord
errors.add(:default_branch_name, "can not be an empty string")
end
end
def allow_mfa_for_group
if namespace&.subgroup? && allow_mfa_for_subgroups == false
errors.add(:allow_mfa_for_subgroups, _('is not allowed since the group is not top-level group.'))
end
end
end
NamespaceSetting.prepend_if_ee('EE::NamespaceSetting')
......@@ -951,7 +951,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_ref(ref)
return unless latest_pipeline
latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
end
def latest_successful_build_for_sha(job_name, sha)
......@@ -960,7 +960,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_sha(sha)
return unless latest_pipeline
latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
latest_pipeline.build_with_artifacts_in_self_and_descendants(job_name)
end
def latest_successful_build_for_ref!(job_name, ref = default_branch)
......
......@@ -27,7 +27,7 @@ class ConfluenceService < Service
end
def description
s_('ConfluenceService|Connect a Confluence Cloud Workspace to your GitLab project')
s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab')
end
def detailed_description
......
......@@ -16,7 +16,7 @@ class PackagistService < Service
end
def description
'Update your project on Packagist, the main Composer repository'
s_('Integrations|Update your projects on Packagist, the main Composer repository')
end
def self.to_param
......
# frozen_string_literal: true
module Ci
class DeleteObjectsService
TransactionInProgressError = Class.new(StandardError)
TRANSACTION_MESSAGE = "can't perform network calls inside a database transaction"
BATCH_SIZE = 100
RETRY_IN = 10.minutes
def execute
objects = load_next_batch
destroy_everything(objects)
end
def remaining_batches_count(max_batch_count:)
Ci::DeletedObject
.ready_for_destruction(max_batch_count * BATCH_SIZE)
.size
.fdiv(BATCH_SIZE)
.ceil
end
private
# rubocop: disable CodeReuse/ActiveRecord
def load_next_batch
# `find_by_sql` performs a write in this case and we need to wrap it in
# a transaction to stick to the primary database.
Ci::DeletedObject.transaction do
Ci::DeletedObject.find_by_sql([
next_batch_sql, new_pick_up_at: RETRY_IN.from_now
])
end
end
# rubocop: enable CodeReuse/ActiveRecord
def next_batch_sql
<<~SQL.squish
UPDATE "ci_deleted_objects"
SET "pick_up_at" = :new_pick_up_at
WHERE "ci_deleted_objects"."id" IN (#{locked_object_ids_sql})
RETURNING *
SQL
end
def locked_object_ids_sql
Ci::DeletedObject.lock_for_destruction(BATCH_SIZE).to_sql
end
def destroy_everything(objects)
raise TransactionInProgressError, TRANSACTION_MESSAGE if transaction_open?
return unless objects.any?
deleted = objects.select(&:delete_file_from_storage)
Ci::DeletedObject.id_in(deleted.map(&:id)).delete_all
end
def transaction_open?
Ci::DeletedObject.connection.transaction_open?
end
end
end
......@@ -7,7 +7,7 @@ module Members
def execute(source)
return error(s_('AddMember|No users specified.')) if params[:user_ids].blank?
user_ids = params[:user_ids].split(',').uniq
user_ids = params[:user_ids].split(',').uniq.flatten
return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if
user_limit && user_ids.size > user_limit
......
# frozen_string_literal: true
class DeletedObjectUploader < GitlabUploader
include ObjectStorage::Concern
storage_options Gitlab.config.artifacts
def store_dir
model.store_dir
end
end
......@@ -25,8 +25,8 @@
= link_to _('Remove user & report'), admin_abuse_report_path(abuse_report, remove_user: true),
data: { confirm: _("USER %{user} WILL BE REMOVED! Are you sure?") % { user: user.name } }, remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-danger js-remove-tr"
- if user && !user.blocked?
= link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "btn btn-sm btn-block"
= link_to _('Block user'), block_admin_user_path(user), data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')}, method: :put, class: "gl-button btn btn-sm btn-block"
- else
.btn.btn-sm.disabled.btn-block
= _('Already blocked')
= link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "btn btn-sm btn-block btn-close js-remove-tr"
= link_to _('Remove report'), [:admin, abuse_report], remote: true, method: :delete, class: "gl-button btn btn-sm btn-block btn-close js-remove-tr"
......@@ -40,5 +40,5 @@
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
.form-actions
= f.submit 'Submit', class: "btn btn-success wide"
= link_to "Cancel", admin_applications_path, class: "btn btn-cancel"
= f.submit 'Submit', class: "gl-button btn btn-success wide"
= link_to "Cancel", admin_applications_path, class: "gl-button btn btn-cancel"
......@@ -4,7 +4,7 @@
%p.light
System OAuth applications don't belong to any user and can only be managed by admins
%hr
%p= link_to 'New application', new_admin_application_path, class: 'btn btn-success'
%p= link_to 'New application', new_admin_application_path, class: 'gl-button btn btn-success'
%table.table
%thead
%tr
......@@ -23,6 +23,6 @@
%td= @application_counts[application.id].to_i
%td= application.trusted? ? 'Y': 'N'
%td= application.confidential? ? 'Y': 'N'
%td= link_to 'Edit', edit_admin_application_path(application), class: 'btn btn-link'
%td= link_to 'Edit', edit_admin_application_path(application), class: 'gl-button btn btn-link'
%td= render 'delete_form', application: application
= paginate @applications, theme: 'gitlab'
......@@ -13,7 +13,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "application_id", type: "text", autocomplete: 'off', value: @application.uid, readonly: true }
.input-group-append
= clipboard_button(target: '#application_id', title: _("Copy ID"), class: "btn btn btn-default")
= clipboard_button(target: '#application_id', title: _("Copy ID"), class: "gl-button btn btn-default")
%tr
%td
= _('Secret')
......@@ -22,7 +22,7 @@
.input-group
%input.label.label-monospace.monospace{ id: "secret", type: "text", autocomplete: 'off', value: @application.secret, readonly: true }
.input-group-append
= clipboard_button(target: '#secret', title: _("Copy secret"), class: "btn btn btn-default")
= clipboard_button(target: '#secret', title: _("Copy secret"), class: "gl-button btn btn-default")
%tr
%td
= _('Callback URL')
......@@ -45,5 +45,5 @@
= render "shared/tokens/scopes_list", token: @application
.form-actions
= link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide float-left'
= link_to 'Edit', edit_admin_application_path(@application), class: 'gl-button btn btn-primary wide float-left'
= render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger gl-ml-3'
......@@ -26,7 +26,7 @@
- @metric.cards.each do |card|
= render 'card', card: card
.devops-steps.d-none.d-lg-block.d-xl-block
.devops-steps.d-none.d-lg-block
- @metric.idea_to_production_steps.each_with_index do |step, index|
.devops-step{ class: "devops-#{score_level(step.percentage_score)}-score" }
= custom_icon("i2p_step_#{index + 1}")
......
......@@ -29,10 +29,10 @@
.gl-alert-body
= render 'shared/group_tips'
.form-actions
= f.submit _('Create group'), class: "btn btn-success"
= link_to _('Cancel'), admin_groups_path, class: "btn btn-cancel"
= f.submit _('Create group'), class: "gl-button btn btn-success"
= link_to _('Cancel'), admin_groups_path, class: "gl-button btn btn-cancel"
- else
.form-actions
= f.submit _('Save changes'), class: "btn btn-success", data: { qa_selector: 'save_changes_button' }
= link_to _('Cancel'), admin_group_path(@group), class: "btn btn-cancel"
= f.submit _('Save changes'), class: "gl-button btn btn-success", data: { qa_selector: 'save_changes_button' }
= link_to _('Cancel'), admin_group_path(@group), class: "gl-button btn btn-cancel"
......@@ -10,7 +10,7 @@
= search_field_tag :name, project_name, class: "form-control search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: 'Search by name', data: { qa_selector: 'group_search_field' }
= sprite_icon('search', css_class: 'search-icon')
= render "shared/groups/dropdown", options_hash: admin_groups_sort_options_hash
= link_to new_admin_group_path, class: "btn btn-success" do
= link_to new_admin_group_path, class: "gl-button btn btn-success" do
= _('New group')
%ul.content-list
= render @groups
......
......@@ -115,7 +115,7 @@
.gl-mt-3
= select_tag :access_level, options_for_select(@group.access_level_roles), class: "project-access-select select2"
%hr
= button_tag _('Add users to group'), class: "btn btn-success"
= button_tag _('Add users to group'), class: "gl-button btn btn-success"
= render 'shared/members/requests', membership_source: @group, requesters: @requesters, force_mobile_view: true
.card
......
......@@ -9,7 +9,7 @@
%code#health-check-token= Gitlab::CurrentSettings.health_check_access_token
.gl-mt-3
= button_to _("Reset health check access token"), reset_health_check_token_admin_application_settings_path,
method: :put, class: 'btn btn-default',
method: :put, class: 'gl-button btn btn-default',
data: { confirm: _('Are you sure you want to reset the health check token?') }
%p.light
#{ _('Health information can be retrieved from the following endpoints. More information is available') }
......
......@@ -14,5 +14,5 @@
= f.text_field :extern_uid, class: 'form-control', required: true
.form-actions
= f.submit _('Save changes'), class: "btn btn-success"
= f.submit _('Save changes'), class: "gl-button btn btn-success"
......@@ -4,9 +4,9 @@
%td
= identity.extern_uid
%td
= link_to edit_admin_user_identity_path(@user, identity), class: 'btn btn-sm btn-grouped' do
= link_to edit_admin_user_identity_path(@user, identity), class: 'gl-button btn btn-sm btn-grouped' do
= _("Edit")
= link_to [:admin, @user, identity], method: :delete,
class: 'btn btn-sm btn-danger',
class: 'gl-button btn btn-sm btn-danger',
data: { confirm: _("Are you sure you want to remove this identity?") } do
= _('Delete')
......@@ -3,7 +3,7 @@
- page_title _("Identities"), @user.name, _("Users")
= render 'admin/users/head'
= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right btn btn-success'
= link_to _('New identity'), new_admin_user_identity_path, class: 'float-right gl-button btn btn-success'
- if @identities.present?
.table-holder
%table.table
......
......@@ -30,8 +30,8 @@
= dropdown_content
= dropdown_loading
= render 'shared/projects/dropdown'
= link_to new_project_path, class: 'btn btn-success' do
= link_to new_project_path, class: 'gl-button btn btn-success' do
New Project
= button_tag "Search", class: "btn btn-primary btn-search hide"
= button_tag "Search", class: "gl-button btn btn-primary btn-search hide"
= render 'projects'
......@@ -149,7 +149,7 @@
.form-group.row
.offset-sm-3.col-sm-9
= f.submit _('Transfer'), class: 'btn btn-primary'
= f.submit _('Transfer'), class: 'gl-button btn btn-primary'
.card.repository-check
.card-header
......@@ -169,7 +169,7 @@
= link_to sprite_icon('question-o'), help_page_path('administration/repository_checks')
.form-group
= f.submit _('Trigger repository check'), class: 'btn btn-primary'
= f.submit _('Trigger repository check'), class: 'gl-button btn btn-primary'
.col-md-6
- if @group
......
......@@ -65,15 +65,15 @@
.table-section.table-button-footer.section-10
.btn-group.table-action-buttons
.btn-group
= link_to admin_runner_path(runner), class: 'btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
= link_to admin_runner_path(runner), class: 'gl-button btn btn-default has-tooltip', title: _('Edit'), ref: 'tooltip', aria: { label: _('Edit') }, data: { placement: 'top', container: 'body'} do
= sprite_icon('pencil')
.btn-group
- if runner.active?
= link_to [:pause, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= link_to [:pause, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip', title: _('Pause'), ref: 'tooltip', aria: { label: _('Pause') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= sprite_icon('pause')
- else
= link_to [:resume, :admin, runner], method: :get, class: 'btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
= link_to [:resume, :admin, runner], method: :get, class: 'gl-button btn btn-default btn-svg has-tooltip gl-px-3', title: _('Resume'), ref: 'tooltip', aria: { label: _('Resume') }, data: { placement: 'top', container: 'body'} do
= sprite_icon('play')
.btn-group
= link_to [:admin, runner], method: :delete, class: 'btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= link_to [:admin, runner], method: :delete, class: 'gl-button btn btn-danger has-tooltip', title: _('Remove'), ref: 'tooltip', aria: { label: _('Remove') }, data: { placement: 'top', container: 'body', confirm: _('Are you sure?') } do
= sprite_icon('close')
......@@ -48,7 +48,7 @@
.filtered-search-box
= dropdown_tag(_('Recent searches'),
options: { wrapper_class: 'filtered-search-history-dropdown-wrapper',
toggle_class: 'btn filtered-search-history-dropdown-toggle-button',
toggle_class: 'gl-button btn filtered-search-history-dropdown-toggle-button',
dropdown_class: 'filtered-search-history-dropdown',
content_class: 'filtered-search-history-dropdown-content' }) do
.js-filtered-search-history-dropdown{ data: { full_path: admin_runners_path } }
......@@ -60,7 +60,7 @@
#js-dropdown-hint.filtered-search-input-dropdown-menu.dropdown-menu.hint-dropdown
%ul.filter-dropdown{ data: { dynamic: true, dropdown: true } }
%li.filter-dropdown-item{ data: {hint: "#{'{{hint}}'}", tag: "#{'{{tag}}'}", action: "#{'{{hint === \'search\' ? \'submit\' : \'\' }}'}" } }
= button_tag class: %w[btn btn-link] do
= button_tag class: %w[gl-button btn btn-link] do
-# Encapsulate static class name `{{icon}}` inside #{} to bypass
-# haml lint's ClassAttributeWithStaticValue
%svg
......@@ -78,21 +78,21 @@
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_STATUSES.each do |status|
%li.filter-dropdown-item{ data: { value: status } }
= button_tag class: %w[btn btn-link] do
= button_tag class: %w[gl-button btn btn-link] do
= status.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[btn btn-link] do
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-admin-runner-type.filtered-search-input-dropdown-menu.dropdown-menu
%ul{ data: { dropdown: true } }
- Ci::Runner::AVAILABLE_TYPES.each do |runner_type|
%li.filter-dropdown-item{ data: { value: runner_type } }
= button_tag class: %w[btn btn-link] do
= button_tag class: %w[gl-button btn btn-link] do
= runner_type.titleize
#js-dropdown-runner-tag.filtered-search-input-dropdown-menu.dropdown-menu
......
......@@ -49,7 +49,7 @@
= project.full_name
%td
.float-right
= link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'btn btn-danger btn-sm'
= link_to 'Disable', admin_namespace_project_runner_project_path(project.namespace, project, runner_project), method: :delete, class: 'gl-button btn btn-danger btn-sm'
%table.table.unassigned-projects
%thead
......@@ -73,7 +73,7 @@
.float-right
= form_for project.runner_projects.new, url: admin_namespace_project_runner_projects_path(project.namespace, project), method: :post do |f|
= f.hidden_field :runner_id, value: @runner.id
= f.submit 'Enable', class: 'btn btn-sm'
= f.submit 'Enable', class: 'gl-button btn btn-sm'
= paginate_without_count @projects
.col-md-6
......
......@@ -16,7 +16,7 @@
- text, status = @domain.unverified? ? [_('Unverified'), 'badge-danger'] : [_('Verified'), 'badge-success']
.badge{ class: status }
= text
= link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "btn has-tooltip", title: _("Retry verification")
= link_to sprite_icon("redo"), verify_admin_serverless_domain_path(@domain.id), method: :post, class: "gl-button btn has-tooltip", title: _("Retry verification")
.col-sm-6
= f.label :serverless_domain_dns, _('DNS'), class: 'label-bold'
......@@ -65,7 +65,7 @@
%span.form-text.text-muted
= _("Upload a private key for your certificate")
= f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
= f.submit @domain.persisted? ? _('Save changes') : _('Add domain'), class: "gl-button btn btn-success js-serverless-domain-submit", disabled: @domain.persisted?
- if @domain.persisted?
%button.gl-button.btn.btn-danger{ type: 'button', data: { toggle: 'modal', target: "#modal-delete-domain" } }
= _('Delete domain')
......@@ -88,7 +88,7 @@
= _("You are about to delete %{domain} from your instance. This domain will no longer be available to any Knative application.").html_safe % { domain: "<code>#{@domain.domain}</code>".html_safe }
.modal-footer
%a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' }
%a{ href: '#', data: { dismiss: 'modal' }, class: 'gl-button btn btn-default' }
= _('Cancel')
= link_to _('Delete domain'),
......
......@@ -4,4 +4,4 @@
= password_field_tag 'user[password]', nil, class: 'form-control', required: true, title: _('This field is required.'), data: { qa_selector: 'password_field' }
.submit-container.move-submit-down
= submit_tag _('Enter Admin Mode'), class: 'btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
= submit_tag _('Enter Admin Mode'), class: 'gl-button btn btn-success', data: { qa_selector: 'enter_admin_mode_button' }
......@@ -6,4 +6,4 @@
= _("Enter the code from the two-factor app on your mobile device. If you've lost your device, you may enter one of your recovery codes.")
.submit-container.move-submit-down
= submit_tag 'Verify code', class: 'btn btn-success'
= submit_tag 'Verify code', class: 'gl-button btn btn-success'
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