Commit 552786bc authored by Shinya Maeda's avatar Shinya Maeda

Merge branch '263484-integration-descriptions-should-be-less-project-level-specific' into 'master'

Update integration descriptions to not be project-specific

See merge request gitlab-org/gitlab!44893
parents 6190e2e5 2cb69ca5
......@@ -290,7 +290,7 @@ gem 'gitlab_chronic_duration', '~> 0.10.6.2'
gem 'rack-proxy', '~> 0.6.0'
gem 'sassc-rails', '~> 2.1.0'
gem 'terser', '~> 1.0'
gem 'gitlab-terser', '1.0.1.1'
gem 'addressable', '~> 2.7'
gem 'font-awesome-rails', '~> 4.7'
......
......@@ -452,6 +452,8 @@ GEM
rubocop-performance (~> 1.5.2)
rubocop-rails (~> 2.5)
rubocop-rspec (~> 1.36)
gitlab-terser (1.0.1.1)
execjs (>= 0.3.0, < 3)
gitlab_chronic_duration (0.10.6.2)
numerizer (~> 0.2)
gitlab_omniauth-ldap (2.1.1)
......@@ -1130,8 +1132,6 @@ GEM
temple (0.8.2)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
terser (1.0.1)
execjs (>= 0.3.0, < 3)
test-prof (0.12.0)
text (1.3.1)
thin (1.7.2)
......@@ -1333,6 +1333,7 @@ DEPENDENCIES
gitlab-puma_worker_killer (~> 0.1.1.gitlab.1)
gitlab-sidekiq-fetcher (= 0.5.2)
gitlab-styles (~> 4.3.0)
gitlab-terser (= 1.0.1.1)
gitlab_chronic_duration (~> 0.10.6.2)
gitlab_omniauth-ldap (~> 2.1.1)
gon (~> 6.2)
......@@ -1482,7 +1483,6 @@ DEPENDENCIES
stackprof (~> 0.2.15)
state_machines-activerecord (~> 0.6.0)
sys-filesystem (~> 1.1.6)
terser (~> 1.0)
test-prof (~> 0.12.0)
thin (~> 1.7.0)
timecop (~> 0.9.1)
......
<script>
import * as Sentry from '@sentry/browser';
import {
GlAlert,
GlBadge,
......@@ -12,6 +11,7 @@ import {
GlButton,
GlSafeHtmlDirective,
} from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper';
import { s__ } from '~/locale';
import alertQuery from '../graphql/queries/details.query.graphql';
import sidebarStatusQuery from '../graphql/queries/sidebar_status.query.graphql';
......@@ -30,6 +30,7 @@ import AlertSidebar from './alert_sidebar.vue';
import AlertMetrics from './alert_metrics.vue';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import AlertSummaryRow from './alert_summary_row.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const containerEl = document.querySelector('.page-with-contextual-sidebar');
......@@ -76,6 +77,7 @@ export default {
SystemNote,
AlertMetrics,
},
mixins: [glFeatureFlagsMixin()],
inject: {
projectPath: {
default: '',
......@@ -147,6 +149,15 @@ export default {
this.$router.replace({ name: 'tab', params: { tabId } });
},
},
environmentName() {
return this.shouldDisplayEnvironment && this.alert?.environment?.name;
},
environmentPath() {
return this.shouldDisplayEnvironment && this.alert?.environment?.path;
},
shouldDisplayEnvironment() {
return this.glFeatures.exposeEnvironmentPathInAlertDetails;
},
},
mounted() {
this.trackPageViews();
......@@ -299,19 +310,18 @@ export default {
</span>
</alert-summary-row>
<alert-summary-row
v-if="alert.environment"
v-if="environmentName"
:label="`${s__('AlertManagement|Environment')}:`"
>
<gl-link
v-if="alert.environmentUrl"
v-if="environmentPath"
class="gl-display-inline-block"
data-testid="environmentUrl"
:href="alert.environmentUrl"
target="_blank"
data-testid="environmentPath"
:href="environmentPath"
>
{{ alert.environment }}
{{ environmentName }}
</gl-link>
<span v-else data-testid="environment">{{ alert.environment }}</span>
<span v-else data-testid="environmentName">{{ environmentName }}</span>
</alert-summary-row>
<alert-summary-row
v-if="alert.startedAt"
......
<script>
import Vue from 'vue';
import Vuex from 'vuex';
import * as Sentry from '@sentry/browser';
import * as Sentry from '~/sentry/wrapper';
Vue.use(Vuex);
......
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import produce from 'immer';
import { defaultDataIdFromObject } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import createRouter from './router';
import AlertDetails from './components/alert_details.vue';
import sidebarStatusQuery from './graphql/queries/sidebar_status.query.graphql';
import createRouter from './router';
Vue.use(VueApollo);
......
......@@ -11,6 +11,10 @@ fragment AlertDetailItem on AlertManagementAlert {
updatedAt
endedAt
hosts
environment {
name
path
}
details
runbook
todos {
......
<script>
import * as Sentry from '@sentry/browser';
import * as Sentry from '~/sentry/wrapper';
import { s__ } from '~/locale';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format';
......
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];
});
}
......@@ -572,7 +572,7 @@ export class AwardsHandler {
}
findMatchingEmojiElements(query) {
const emojiMatches = this.emoji.searchEmoji(query).map(({ name }) => name);
const emojiMatches = this.emoji.searchEmoji(query, { match: 'fuzzy' }).map(({ name }) => name);
const $emojiElements = $('.emoji-menu-list:not(.frequent-emojis) [data-name]');
const $matchingElements = $emojiElements.filter(
(i, elm) => emojiMatches.indexOf(elm.dataset.name) >= 0,
......
......@@ -24,10 +24,10 @@ export default {
},
inject: {
autoDevopsHelpPath: {
type: String,
default: '',
},
externalEndpointHelpPath: {
type: String,
default: '',
},
},
data() {
......
import * as Sentry from '@sentry/browser';
import * as Sentry from '~/sentry/wrapper';
import Poll from '~/lib/utils/poll';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as flash } from '~/flash';
......
......@@ -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>
import { uniq } from 'lodash';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import emojiAliases from 'emojis/aliases.json';
import axios from '../lib/utils/axios_utils';
......@@ -67,49 +66,111 @@ export function isEmojiNameValid(name) {
return validEmojiNames.indexOf(name) >= 0;
}
export function getValidEmojiUnicodeValues() {
return Object.values(emojiMap).map(({ e }) => e);
}
export function getValidEmojiDescriptions() {
return Object.values(emojiMap).map(({ d }) => d);
}
/**
* Search emoji by name or alias. Returns a normalized, deduplicated list of
* names.
* Retrieves an emoji by name or alias.
*
* Calling with an empty filter returns an empty array.
* Note: `initEmojiMap` must have been called and completed before this method
* can safely be called.
*
* @param {String}
* @returns {Array}
* @param {String} query The emoji name
* @param {Boolean} fallback If true, a fallback emoji will be returned if the
* named emoji does not exist. Defaults to false.
* @returns {Object} The matching emoji.
*/
export function queryEmojiNames(filter) {
const matches = fuzzaldrinPlus.filter(validEmojiNames, filter);
return uniq(matches.map(name => normalizeEmojiName(name)));
export function getEmoji(query, fallback = false) {
if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
}
const lowercaseQuery = query.toLowerCase();
const name = normalizeEmojiName(lowercaseQuery);
if (name in emojiMap) {
return emojiMap[name];
}
if (fallback) {
return emojiMap.grey_question;
}
return null;
}
const searchMatchers = {
fuzzy: (value, query) => fuzzaldrinPlus.score(value, query) > 0, // Fuzzy matching compares using a fuzzy matching library
contains: (value, query) => value.indexOf(query.toLowerCase()) >= 0, // Contains matching compares by indexOf
exact: (value, query) => value === query.toLowerCase(), // Exact matching compares by equality
};
const searchPredicates = {
name: (matcher, query) => emoji => matcher(emoji.name, query), // Search by name
alias: (matcher, query) => emoji => emoji.aliases.some(v => matcher(v, query)), // Search by alias
description: (matcher, query) => emoji => matcher(emoji.d, query), // Search by description
unicode: (matcher, query) => emoji => emoji.e === query, // Search by unicode value (always exact)
};
/**
* Searches emoji by name, alias, description, and unicode value and returns an
* array of matches.
* Searches emoji by name, aliases, description, and unicode value and returns
* an array of matches.
*
* Behavior is undefined if `opts.fields` is empty or if `opts.match` is fuzzy
* and the query is empty.
*
* Note: `initEmojiMap` must have been called and completed before this method
* can safely be called.
*
* @param {String} query The search query
* @returns {Object[]} A list of emoji that match the query
* @param {String} query Search query.
* @param {Object} opts Search options (optional).
* @param {String[]} opts.fields Fields to search. Choices are 'name', 'alias',
* 'description', and 'unicode' (value). Default is all (four) fields.
* @param {String} opts.match Search method to use. Choices are 'exact',
* 'contains', or 'fuzzy'. All methods are case-insensitive. Exact matching (the
* default) compares by equality. Contains matching compares by indexOf. Fuzzy
* matching compares using a fuzzy matching library.
* @param {Boolean} opts.fallback If true, a fallback emoji will be returned if
* the result set is empty. Defaults to false.
* @returns {Object[]} A list of emoji that match the query.
*/
export function searchEmoji(query) {
if (!emojiMap)
export function searchEmoji(query, opts) {
if (!emojiMap) {
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('The emoji map is uninitialized or initialization has not completed');
}
const {
fields = ['name', 'alias', 'description', 'unicode'],
match = 'exact',
fallback = false,
} = opts || {};
const matches = s => fuzzaldrinPlus.score(s, query) > 0;
// Search emoji
return Object.values(emojiMap).filter(
emoji =>
// by name
matches(emoji.name) ||
// by alias
emoji.aliases.some(matches) ||
// by description
matches(emoji.d) ||
// by unicode value
query === emoji.e,
// optimization for an exact match in name and alias
if (match === 'exact' && new Set([...fields, 'name', 'alias']).size === 2) {
const emoji = getEmoji(query, fallback);
return emoji ? [emoji] : [];
}
const matcher = searchMatchers[match] || searchMatchers.exact;
const predicates = fields.map(f => searchPredicates[f](matcher, query));
const results = Object.values(emojiMap).filter(emoji =>
predicates.some(predicate => predicate(emoji)),
);
// Fallback to question mark for unknown emojis
if (fallback && results.length === 0) {
return [emojiMap.grey_question];
}
return results;
}
let emojiCategoryMap;
......@@ -136,16 +197,10 @@ export function getEmojiCategoryMap() {
}
export function getEmojiInfo(query) {
let name = normalizeEmojiName(query);
let emojiInfo = emojiMap[name];
// Fallback to question mark for unknown emojis
if (!emojiInfo) {
name = 'grey_question';
emojiInfo = emojiMap[name];
}
return { ...emojiInfo, name };
return searchEmoji(query, {
fields: ['name', 'alias'],
fallback: true,
})[0];
}
export function emojiFallbackImageSrc(inputName) {
......
import * as Sentry from '@sentry/browser';
import { escape } from 'lodash';
import * as Sentry from '~/sentry/wrapper';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
......
......@@ -191,8 +191,7 @@ class GfmAutoComplete {
}
return tmpl;
},
// eslint-disable-next-line no-template-curly-in-string
insertTpl: ':${name}:',
insertTpl: GfmAutoComplete.Emoji.insertTemplateFunction,
skipSpecialCharacterTest: true,
data: GfmAutoComplete.defaultLoadingData,
callbacks: {
......@@ -612,12 +611,7 @@ class GfmAutoComplete {
} else if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
Emoji.initEmojiMap()
.then(() => {
this.loadData($input, at, Emoji.getValidEmojiNames());
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
})
.catch(() => {});
this.loadEmojiData($input, at).catch(() => {});
} else if (dataSource) {
AjaxCache.retrieve(dataSource, true)
.then(data => {
......@@ -640,6 +634,18 @@ class GfmAutoComplete {
return $input.trigger('keyup');
}
async loadEmojiData($input, at) {
await Emoji.initEmojiMap();
this.loadData($input, at, [
...Emoji.getValidEmojiNames(),
...Emoji.getValidEmojiDescriptions(),
...Emoji.getValidEmojiUnicodeValues(),
]);
GfmAutoComplete.glEmojiTag = Emoji.glEmojiTag;
}
clearCache() {
this.cachedData = {};
}
......@@ -708,12 +714,16 @@ GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
// Emoji
GfmAutoComplete.glEmojiTag = null;
GfmAutoComplete.Emoji = {
insertTemplateFunction(value) {
const { name = value.name } = Emoji.searchEmoji(value.name, { match: 'contains' })[0] || {};
return `:${name}:`;
},
templateFunction(name) {
// glEmojiTag helper is loaded on-demand in fetchData()
if (GfmAutoComplete.glEmojiTag) {
return `<li>${name} ${GfmAutoComplete.glEmojiTag(name)}</li>`;
}
return `<li>${name}</li>`;
if (!GfmAutoComplete.glEmojiTag) return `<li>${name}</li>`;
const emoji = Emoji.searchEmoji(name, { match: 'contains' })[0];
return `<li>${name} ${GfmAutoComplete.glEmojiTag(emoji?.name || name)}</li>`;
},
};
// Team Members
......
......@@ -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' };
......
......@@ -11,6 +11,8 @@ export default {
GlTab,
AlertsSettingsForm,
PagerDutySettingsForm,
ServiceLevelAgreementForm: () =>
import('ee_component/incidents_settings/components/service_level_agreement_form.vue'),
},
tabs: INTEGRATION_TABS_CONFIG,
i18n: I18N_INTEGRATION_TABS,
......@@ -45,6 +47,7 @@ export default {
>
<component :is="tab.component" class="gl-pt-3" :data-testid="`${tab.component}-tab`" />
</gl-tab>
<service-level-agreement-form />
</gl-tabs>
</div>
</section>
......
......@@ -21,6 +21,9 @@ export default () => {
pagerdutyWebhookUrl,
pagerdutyResetKeyPath,
autoCloseIncident,
slaActive,
slaMinutes,
slaFeatureAvailable,
},
} = el;
......@@ -40,6 +43,11 @@ export default () => {
active: parseBoolean(pagerdutyActive),
webhookUrl: pagerdutyWebhookUrl,
},
serviceLevelAgreementSettings: {
active: parseBoolean(slaActive),
minutes: slaMinutes,
available: parseBoolean(slaFeatureAvailable),
},
},
render(createElement) {
return createElement(SettingsTabs);
......
<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();
import * as Sentry from '@sentry/browser';
import * as Sentry from '~/sentry/wrapper';
import { sanitize } from '~/lib/dompurify';
// We currently load + parse the data from the issue app and related merge request
......
export const BYTES_IN_KIB = 1024;
export const BYTES_IN_KB = 1000;
export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
......
import { BYTES_IN_KIB } from './constants';
import { BYTES_IN_KIB, BYTES_IN_KB } from './constants';
import { sprintf, __ } from '~/locale';
/**
......@@ -34,6 +34,18 @@ export function formatRelevantDigits(number) {
return formattedNumber;
}
/**
* Utility function that calculates KB of the given bytes.
* Note: This method calculates KiloBytes as opposed to
* Kibibytes. For Kibibytes, bytesToKiB should be used.
*
* @param {Number} number bytes
* @return {Number} KiB
*/
export function bytesToKB(number) {
return number / BYTES_IN_KB;
}
/**
* Utility function that calculates KiB of the given bytes.
*
......
import * as Sentry from '@sentry/browser';
import * as Sentry from '~/sentry/wrapper';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
......
......@@ -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;
......@@ -70,14 +70,18 @@ export default {
</script>
<template>
<div :data-for="name" class="project-feature-controls">
<div
:data-for="name"
class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0"
>
<input v-if="name" :name="name" :value="value" type="hidden" />
<project-feature-toggle
class="gl-flex-grow-0 gl-mr-3"
:value="featureEnabled"
:disabled-input="disabledInput"
@change="toggleFeature"
/>
<div class="select-wrapper">
<div class="select-wrapper gl-flex-fill-1">
<select
:disabled="displaySelectInput"
class="form-control project-repo-select select-control"
......
......@@ -292,14 +292,16 @@ export default {
<template>
<div>
<div class="project-visibility-setting">
<div
class="project-visibility-setting gl-border-1 gl-border-solid gl-border-gray-100 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5"
>
<project-setting-row
ref="project-visibility-settings"
:help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')"
>
<div class="project-feature-controls">
<div class="select-wrapper">
<div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0">
<div class="select-wrapper gl-flex-fill-1">
<select
v-model="visibilityLevel"
:disabled="!canChangeVisibilityLevel"
......@@ -327,7 +329,7 @@ export default {
</div>
</div>
<span class="form-text text-muted">{{ visibilityLevelDescription }}</span>
<label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="request-access">
<label v-if="visibilityLevel !== visibilityOptions.PRIVATE" class="gl-line-height-28">
<input
:value="requestAccessEnabled"
type="hidden"
......@@ -338,7 +340,10 @@ export default {
</label>
</project-setting-row>
</div>
<div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
<div
:class="{ 'highlight-changes': highlightChangesClass }"
class="gl-border-1 gl-border-solid gl-border-t-none gl-border-gray-100 gl-mb-5 gl-py-3 gl-px-7 gl-sm-pr-5 gl-sm-pl-5 gl-bg-gray-10"
>
<project-setting-row
ref="issues-settings"
:label="s__('ProjectSettings|Issues')"
......@@ -361,7 +366,7 @@ export default {
name="project[project_feature_attributes][repository_access_level]"
/>
</project-setting-row>
<div class="project-feature-setting-group">
<div class="project-feature-setting-group gl-pl-7 gl-sm-pl-5">
<project-setting-row
ref="merge-request-settings"
:label="s__('ProjectSettings|Merge requests')"
......@@ -516,8 +521,8 @@ export default {
)
"
>
<div class="project-feature-controls">
<div class="select-wrapper">
<div class="project-feature-controls gl-display-flex gl-align-items-center gl-my-3 gl-mx-0">
<div class="select-wrapper gl-flex-fill-1">
<select
v-model="metricsDashboardAccessLevel"
:disabled="metricsOptionsDropdownEnabled"
......
......@@ -126,7 +126,7 @@ export default {
};
</script>
<template>
<div class="ci-job-component">
<div class="ci-job-component" data-qa-selector="job_item_container">
<gl-link
v-if="status.has_details"
v-gl-tooltip="{ boundary, placement: 'bottom' }"
......@@ -156,6 +156,7 @@ export default {
:tooltip-text="status.action.title"
:link="status.action.path"
:action-icon="status.action.icon"
data-qa-selector="action_button"
@pipelineActionRequestComplete="pipelineActionRequestComplete"
/>
</div>
......
import * as Sentry from '@sentry/browser';
import * as Sentry from '~/sentry/wrapper';
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import { deprecatedCreateFlash as createFlash } from '~/flash';
......
<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;
......
import * as Sentry from '@sentry/browser';
import $ from 'jquery';
import * as Sentry from '~/sentry/wrapper';
import { __ } from '~/locale';
const IGNORE_ERRORS = [
......
// Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
// export * from '@sentry/browser';
export function init(...args) {
return args;
}
export function setUser(...args) {
return args;
}
export function captureException(...args) {
return args;
}
export function captureMessage(...args) {
return args;
}
export function withScope(fn) {
fn({
setTag(...args) {
return args;
},
});
}
<script>
import { GlIcon } from '@gitlab/ui';
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'TimeTrackingCollapsedState',
......@@ -9,7 +8,7 @@ export default {
GlIcon,
},
directives: {
tooltip,
GlTooltip: GlTooltipDirective,
},
props: {
showComparisonState: {
......@@ -97,14 +96,7 @@ export default {
</script>
<template>
<div
v-tooltip
:title="tooltipText"
class="sidebar-collapsed-icon"
data-container="body"
data-placement="left"
data-boundary="viewport"
>
<div v-gl-tooltip:body.viewport.left :title="tooltipText" class="sidebar-collapsed-icon">
<gl-icon name="timer" />
<div class="time-tracking-collapsed-summary">
<div :class="divClass">
......
......@@ -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" />
......
......@@ -7,9 +7,11 @@ import {
convertToSentenceCase,
splitCamelCase,
} from '~/lib/utils/text_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const thClass = 'gl-bg-transparent! gl-border-1! gl-border-b-solid! gl-border-gray-200!';
const tdClass = 'gl-border-gray-100! gl-p-5!';
const allowedFields = [
'iid',
'title',
......@@ -22,17 +24,15 @@ const allowedFields = [
'description',
'endedAt',
'details',
'environment',
'hosts',
];
const isAllowed = fieldName => allowedFields.includes(fieldName);
export default {
components: {
GlLoadingIcon,
GlTable,
},
mixins: [glFeatureFlagsMixin()],
props: {
alert: {
type: Object,
......@@ -60,14 +60,23 @@ export default {
},
],
computed: {
flaggedAllowedFields() {
return this.shouldDisplayEnvironment ? [...allowedFields, 'environment'] : allowedFields;
},
items() {
if (!this.alert) {
return [];
}
return reduce(
this.alert,
(allowedItems, value, fieldName) => {
if (isAllowed(fieldName)) {
(allowedItems, fieldValue, fieldName) => {
if (this.isAllowed(fieldName)) {
let value;
if (fieldName === 'environment') {
value = fieldValue?.name;
} else {
value = fieldValue;
}
return [...allowedItems, { fieldName, value }];
}
return allowedItems;
......@@ -75,6 +84,14 @@ export default {
[],
);
},
shouldDisplayEnvironment() {
return this.glFeatures.exposeEnvironmentPathInAlertDetails;
},
},
methods: {
isAllowed(fieldName) {
return this.flaggedAllowedFields.includes(fieldName);
},
},
};
</script>
......
......@@ -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>
......@@ -32,8 +32,8 @@ export default {
uploadImageTab: null,
};
},
modalTitle: __('Image Details'),
okTitle: __('Insert'),
modalTitle: __('Image details'),
okTitle: __('Insert image'),
urlTabTitle: __('By URL'),
urlLabel: __('Image URL'),
descriptionLabel: __('Description'),
......
<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>
......@@ -59,7 +59,7 @@ export default {
</script>
<template>
<label class="toggle-wrapper">
<label class="gl-mt-2">
<input v-if="name" :name="name" :value="value" type="hidden" />
<button
type="button"
......
......@@ -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;
}
}
......@@ -95,6 +95,78 @@
}
}
.group-home-panel {
margin-top: $gl-padding;
margin-bottom: $gl-padding;
.home-panel-avatar {
width: $home-panel-title-row-height;
height: $home-panel-title-row-height;
flex-shrink: 0;
flex-basis: $home-panel-title-row-height;
}
.home-panel-title {
font-size: 20px;
line-height: $gl-line-height-24;
font-weight: bold;
.icon {
vertical-align: -1px;
}
.home-panel-topic-list {
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
.icon {
position: relative;
top: 3px;
margin-right: $gl-padding-4;
}
}
}
.home-panel-title-row {
@include media-breakpoint-down(sm) {
.home-panel-avatar {
width: $home-panel-avatar-mobile-size;
height: $home-panel-avatar-mobile-size;
flex-basis: $home-panel-avatar-mobile-size;
.avatar {
font-size: 20px;
line-height: 46px;
}
}
.home-panel-title {
margin-top: 4px;
margin-bottom: 2px;
font-size: $gl-font-size;
line-height: $gl-font-size-large;
}
.home-panel-topic-list,
.home-panel-metadata {
font-size: $gl-font-size-small;
}
}
}
.home-panel-metadata {
font-weight: normal;
font-size: 14px;
line-height: $gl-btn-line-height;
}
.home-panel-description {
@include media-breakpoint-up(md) {
font-size: $gl-font-size-large;
}
}
}
.home-panel-buttons {
.home-panel-action-button {
vertical-align: top;
......
......@@ -247,6 +247,7 @@
.label-badge {
color: $gray-900;
display: inline-block;
font-weight: $gl-font-weight-normal;
padding: $gl-padding-4 $gl-padding-8;
border-radius: $border-radius-default;
......
......@@ -216,6 +216,10 @@
width: px-to-rem(150px);
}
.col-max-role {
width: px-to-rem(175px);
}
.col-expiration {
width: px-to-rem(200px);
}
......
.alert_holder {
margin: -16px;
.alert-link {
font-weight: $gl-font-weight-normal;
}
}
.new_project,
.edit-project,
.import-project {
......@@ -67,38 +59,7 @@
}
}
.classification-label {
background-color: $red-500;
}
.toggle-wrapper {
margin-top: 5px;
}
.project-feature-row > .toggle-wrapper {
margin: 10px 0;
}
.project-visibility-setting,
.project-feature-settings {
border: 1px solid $border-color;
padding: 10px 32px;
@include media-breakpoint-down(xs) {
padding: 10px 20px;
}
}
.project-visibility-setting .request-access {
line-height: 2;
}
.project-feature-settings {
background: $gray-lighter;
border-top: 0;
margin-bottom: 16px;
}
// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components
.project-repo-select {
transition: background 2s ease-out;
......@@ -113,63 +74,31 @@
}
}
// INFO Scoped to project_feature_setting and settings_panel components in app/assets/javascripts/pages/projects/shared/permissions/components
.project-feature-controls {
display: flex;
align-items: center;
margin: $gl-padding-8 0;
max-width: 432px;
.toggle-wrapper {
flex: 0;
margin-right: 10px;
}
.select-wrapper {
flex: 1;
}
}
// INFO Scoped to settings_panel component in app/assets/javascripts/pages/projects/shared/permissions/components
.project-feature-setting-group {
padding-left: 32px;
.project-feature-controls {
max-width: 400px;
}
@include media-breakpoint-down(xs) {
padding-left: 20px;
}
}
.group-home-panel,
.project-home-panel {
margin-top: $gl-padding;
margin-bottom: $gl-padding;
.home-panel-avatar {
width: $home-panel-title-row-height;
height: $home-panel-title-row-height;
flex-shrink: 0;
flex-basis: $home-panel-title-row-height;
}
.home-panel-title {
font-size: 20px;
line-height: $gl-line-height-24;
font-weight: bold;
.icon {
vertical-align: -1px;
}
.home-panel-topic-list {
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
.icon {
position: relative;
top: 3px;
margin-right: $gl-padding-4;
}
}
}
......@@ -201,24 +130,6 @@
}
}
.home-panel-metadata {
font-weight: normal;
font-size: 14px;
line-height: $gl-btn-line-height;
.home-panel-license {
.btn {
line-height: 0;
border-width: 0;
}
}
.access-request-link {
padding-left: $gl-padding-8;
border-left: 1px solid $gl-text-color-secondary;
}
}
.home-panel-description {
@include media-breakpoint-up(md) {
font-size: $gl-font-size-large;
......
......@@ -3,7 +3,7 @@ class Admin::InstanceReviewController < Admin::ApplicationController
feature_category :instance_statistics
def index
redirect_to("#{EE::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}")
redirect_to("#{::Gitlab::SubscriptionPortal::SUBSCRIPTIONS_URL}/instance_review?#{instance_review_params}")
end
def instance_review_params
......
......@@ -80,18 +80,17 @@ class HelpController < ApplicationController
def documentation_url
return unless documentation_base_url
@documentation_url ||= [
documentation_base_url.chomp('/'),
version_segment,
'ee',
"#{@path}.html"
].compact.join('/')
@documentation_url ||= Gitlab::Utils.append_path(documentation_base_url, documentation_file_path)
end
def documentation_base_url
@documentation_base_url ||= Gitlab::CurrentSettings.current_application_settings.help_page_documentation_base_url.presence
end
def documentation_file_path
@documentation_file_path ||= [version_segment, 'ee', "#{@path}.html"].compact.join('/')
end
def version_segment
return if Gitlab.pre_release?
......
......@@ -10,5 +10,6 @@ class Projects::AlertManagementController < Projects::ApplicationController
def details
@alert_id = params[:id]
push_frontend_feature_flag(:expose_environment_path_in_alert_details, @project)
end
end
......@@ -4,7 +4,8 @@ class Projects::JobsController < Projects::ApplicationController
include SendFileUpload
include ContinueParams
before_action :build, except: [:index]
before_action :find_job_as_build, except: [:index, :play]
before_action :find_job_as_processable, only: [:play]
before_action :authorize_read_build!
before_action :authorize_update_build!,
except: [:index, :show, :status, :raw, :trace, :erase]
......@@ -44,10 +45,10 @@ class Projects::JobsController < Projects::ApplicationController
# rubocop: enable CodeReuse/ActiveRecord
def trace
build.trace.read do |stream|
@build.trace.read do |stream|
respond_to do |format|
format.json do
build.trace.being_watched!
@build.trace.being_watched!
build_trace = Ci::BuildTrace.new(
build: @build,
......@@ -72,8 +73,13 @@ class Projects::JobsController < Projects::ApplicationController
def play
return respond_422 unless @build.playable?
build = @build.play(current_user, play_params[:job_variables_attributes])
redirect_to build_path(build)
job = @build.play(current_user, play_params[:job_variables_attributes])
if job.is_a?(Ci::Bridge)
redirect_to pipeline_path(job.pipeline)
else
redirect_to build_path(job)
end
end
def cancel
......@@ -117,7 +123,7 @@ class Projects::JobsController < Projects::ApplicationController
send_params: raw_send_params,
redirect_params: raw_redirect_params)
else
build.trace.read do |stream|
@build.trace.read do |stream|
if stream.file?
workhorse_set_content_type!
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
......@@ -149,19 +155,19 @@ class Projects::JobsController < Projects::ApplicationController
private
def authorize_update_build!
return access_denied! unless can?(current_user, :update_build, build)
return access_denied! unless can?(current_user, :update_build, @build)
end
def authorize_erase_build!
return access_denied! unless can?(current_user, :erase_build, build)
return access_denied! unless can?(current_user, :erase_build, @build)
end
def authorize_use_build_terminal!
return access_denied! unless can?(current_user, :create_build_terminal, build)
return access_denied! unless can?(current_user, :create_build_terminal, @build)
end
def authorize_create_proxy_build!
return access_denied! unless can?(current_user, :create_build_service_proxy, build)
return access_denied! unless can?(current_user, :create_build_service_proxy, @build)
end
def verify_api_request!
......@@ -186,14 +192,22 @@ class Projects::JobsController < Projects::ApplicationController
end
def trace_artifact_file
@trace_artifact_file ||= build.job_artifacts_trace&.file
@trace_artifact_file ||= @build.job_artifacts_trace&.file
end
def build
@build ||= project.builds.find(params[:id])
def find_job_as_build
@build = project.builds.find(params[:id])
.present(current_user: current_user)
end
def find_job_as_processable
if ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
@build = project.processables.find(params[:id])
else
find_job_as_build
end
end
def build_path(build)
project_job_path(build.project, build)
end
......@@ -208,10 +222,10 @@ class Projects::JobsController < Projects::ApplicationController
end
def build_service_specification
build.service_specification(service: params['service'],
port: params['port'],
path: params['path'],
subprotocols: proxy_subprotocol)
@build.service_specification(service: params['service'],
port: params['port'],
path: params['path'],
subprotocols: proxy_subprotocol)
end
def proxy_subprotocol
......
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
......
......@@ -68,6 +68,11 @@ module Types
null: true,
description: 'Timestamp the alert ended'
field :environment,
Types::EnvironmentType,
null: true,
description: 'Environment for the alert'
field :event_count,
GraphQL::INT_TYPE,
null: true,
......
......@@ -5,6 +5,8 @@ module Types
graphql_name 'Environment'
description 'Describes where code is deployed for a project'
present_using ::EnvironmentPresenter
authorize :read_environment
field :name, GraphQL::STRING_TYPE, null: false,
......@@ -16,6 +18,10 @@ module Types
field :state, GraphQL::STRING_TYPE, null: false,
description: 'State of the environment, for example: available/stopped'
field :path, GraphQL::STRING_TYPE, null: true,
description: 'The path to the environment. Will always return null ' \
'if `expose_environment_path_in_alert_details` feature flag is disabled'
field :metrics_dashboard, Types::Metrics::DashboardType, null: true,
description: 'Metrics dashboard schema for the environment',
resolver: Resolvers::Metrics::DashboardResolver
......@@ -23,6 +29,6 @@ module Types
field :latest_opened_most_severe_alert,
Types::AlertManagement::AlertType,
null: true,
description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
description: 'The most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned'
end
end
......@@ -212,6 +212,10 @@ module ApplicationHelper
Gitlab::CurrentSettings.current_application_settings.help_page_support_url.presence || promo_url + '/getting-help/'
end
def instance_review_permitted?
::Gitlab::CurrentSettings.instance_review_permitted? && current_user&.admin?
end
def static_objects_external_storage_enabled?
Gitlab::CurrentSettings.static_objects_external_storage_enabled?
end
......
......@@ -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,
......
# 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
......@@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord
ignore_column :instance_statistics_visibility_private, remove_with: '13.6', remove_after: '2020-10-22'
ignore_column :snowplow_iglu_registry_url, remove_with: '13.6', remove_after: '2020-11-22'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
'Admin Area > Settings > Metrics and profiling > Metrics - Grafana'
......@@ -437,6 +438,14 @@ class ApplicationSetting < ApplicationRecord
!!(sourcegraph_url =~ /\Ahttps:\/\/(www\.)?sourcegraph\.com/)
end
def instance_review_permitted?
users_count = Rails.cache.fetch('limited_users_count', expires_in: 1.day) do
::User.limit(INSTANCE_REVIEW_MIN_USERS + 1).count(:all)
end
users_count >= INSTANCE_REVIEW_MIN_USERS
end
def self.create_from_defaults
check_schema!
......
......@@ -27,7 +27,7 @@ module Ci
# rubocop:enable Cop/ActiveRecordSerialize
state_machine :status do
after_transition created: :pending do |bridge|
after_transition [:created, :manual] => :pending do |bridge|
next unless bridge.downstream_project
bridge.run_after_commit do
......@@ -46,6 +46,10 @@ module Ci
event :scheduled do
transition all => :scheduled
end
event :actionize do
transition created: :manual
end
end
def self.retry(bridge, current_user)
......@@ -126,9 +130,27 @@ module Ci
false
end
def playable?
return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
action? && !archived? && manual?
end
def action?
false
return false unless ::Gitlab::Ci::Features.manual_bridges_enabled?(project)
%w[manual].include?(self.when)
end
# rubocop: disable CodeReuse/ServiceClass
# We don't need it but we are taking `job_variables_attributes` parameter
# to make it consistent with `Ci::Build#play` method.
def play(current_user, job_variables_attributes = nil)
Ci::PlayBridgeService
.new(project, current_user)
.execute(self)
end
# rubocop: enable CodeReuse/ServiceClass
def artifacts?
false
......@@ -185,6 +207,10 @@ module Ci
[]
end
def target_revision_ref
downstream_pipeline_params.dig(:target_revision, :ref)
end
private
def cross_project_params
......
......@@ -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)
......
......@@ -4,6 +4,7 @@ class Environment < ApplicationRecord
include Gitlab::Utils::StrongMemoize
include ReactiveCaching
include FastDestroyAll::Helpers
include Presentable
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
......
......@@ -51,3 +51,5 @@ module IncidentManagement
end
end
end
IncidentManagement::ProjectIncidentManagementSetting.prepend_if_ee('EE::IncidentManagement::ProjectIncidentManagementSetting')
......@@ -298,6 +298,7 @@ class Project < ApplicationRecord
# bulk that doesn't involve loading the rows into memory. As a result we're
# still using `dependent: :destroy` here.
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :processables, class_name: 'Ci::Processable', inverse_of: :project
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project
......
......@@ -37,7 +37,11 @@ module Terraform
end
def latest_file
versioning_enabled ? latest_version&.file : file
if versioning_enabled?
latest_version&.file
else
latest_version&.file || file
end
end
def locked?
......@@ -46,13 +50,56 @@ module Terraform
def update_file!(data, version:)
if versioning_enabled?
new_version = versions.build(version: version)
new_version.assign_attributes(created_by_user: locked_by_user, file: data)
new_version.save!
create_new_version!(data: data, version: version)
elsif latest_version.present?
migrate_legacy_version!(data: data, version: version)
else
self.file = data
save!
end
end
private
##
# If a Terraform state was created before versioning support was
# introduced, it will have a single version record whose file
# uses a legacy naming scheme in object storage. To update
# these states and versions to use the new behaviour, we must do
# the following when creating the next version:
#
# * Read the current, non-versioned file from the old location.
# * Update the :versioning_enabled flag, which determines the
# naming scheme
# * Resave the existing file with the updated name and location,
# using a version number one prior to the new version
# * Create the new version as normal
#
# This migration only needs to happen once for each state, from
# then on the state will behave as if it was always versioned.
#
# The code can be removed in the next major version (14.0), after
# which any states that haven't been migrated will need to be
# recreated: https://gitlab.com/gitlab-org/gitlab/-/issues/258960
def migrate_legacy_version!(data:, version:)
current_file = latest_version.file.read
current_version = parse_serial(current_file) || version - 1
update!(versioning_enabled: true)
reload_latest_version.update!(version: current_version, file: CarrierWaveStringFile.new(current_file))
create_new_version!(data: data, version: version)
end
def create_new_version!(data:, version:)
new_version = versions.build(version: version, created_by_user: locked_by_user)
new_version.assign_attributes(file: data)
new_version.save!
end
def parse_serial(file)
Gitlab::Json.parse(file)["serial"]
rescue JSON::ParserError
end
end
end
# frozen_string_literal: true
module Ci
class BridgePolicy < CommitStatusPolicy
condition(:can_update_downstream_branch) do
::Gitlab::UserAccess.new(@user, container: @subject.downstream_project)
.can_update_branch?(@subject.target_revision_ref)
end
rule { can_update_downstream_branch }.enable :play_job
end
end
......@@ -60,6 +60,8 @@ module Ci
rule { can?(:update_build) & terminal }.enable :create_build_terminal
rule { can?(:update_build) }.enable :play_job
rule { is_web_ide_terminal & can?(:create_web_ide_terminal) & (admin | owner_of_job) }.policy do
enable :read_web_ide_terminal
enable :update_web_ide_terminal
......
# frozen_string_literal: true
class EnvironmentPresenter < Gitlab::View::Presenter::Delegated
include ActionView::Helpers::UrlHelper
presents :environment
def path
if Feature.enabled?(:expose_environment_path_in_alert_details, project)
project_environment_path(project, self)
end
end
end
......@@ -2,6 +2,7 @@
class LabelPresenter < Gitlab::View::Presenter::Delegated
presents :label
delegate :name, :full_name, to: :label_subject, prefix: :subject
def edit_path
case label
......@@ -39,8 +40,8 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated
label.is_a?(ProjectLabel)
end
def subject_name
label.subject.name
def label_subject
@label_subject ||= label.subject
end
private
......
# frozen_string_literal: true
module Ci
class PlayBridgeService < ::BaseService
def execute(bridge)
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, bridge)
bridge.tap do |bridge|
bridge.user = current_user
bridge.enqueue!
end
end
end
end
......@@ -3,9 +3,7 @@
module Ci
class PlayBuildService < ::BaseService
def execute(build, job_variables_attributes = nil)
unless can?(current_user, :update_build, build)
raise Gitlab::Access::AccessDeniedError
end
raise Gitlab::Access::AccessDeniedError unless can?(current_user, :play_job, build)
# Try to enqueue the build, otherwise create a duplicate.
#
......
......@@ -9,12 +9,12 @@ module Ci
end
def execute(stage)
stage.builds.manual.each do |build|
next unless build.playable?
stage.processables.manual.each do |processable|
next unless processable.playable?
build.play(current_user)
processable.play(current_user)
rescue Gitlab::Access::AccessDeniedError
logger.error(message: 'Unable to play manual action', build_id: build.id)
logger.error(message: 'Unable to play manual action', processable_id: processable.id)
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
......
......@@ -7,6 +7,8 @@ module Users
end
def execute(user)
return error('An internal user cannot be blocked', 403) if user.internal?
if user.block
after_block_hook(user)
success
......
......@@ -2,12 +2,22 @@
module Terraform
class VersionedStateUploader < StateUploader
delegate :terraform_state, to: :model
def filename
"#{model.version}.tfstate"
if terraform_state.versioning_enabled?
"#{model.version}.tfstate"
else
"#{model.uuid}.tfstate"
end
end
def store_dir
Gitlab::HashedPath.new(model.uuid, root_hash: project_id)
if terraform_state.versioning_enabled?
Gitlab::HashedPath.new(model.uuid, root_hash: project_id)
else
project_id.to_s
end
end
end
end
......@@ -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}")
......
......@@ -182,7 +182,7 @@
%li Access Git repositories
%br
= link_to 'Unblock user', unblock_admin_user_path(@user), method: :put, class: "btn gl-button btn-info", data: { confirm: 'Are you sure?' }
- else
- elsif !@user.internal?
.card.border-warning
.card-header.bg-warning.text-white
Block this user
......
......@@ -24,5 +24,14 @@
%td= subscription.created_at
%td= link_to 'Remove', jira_connect_subscription_path(subscription), class: 'remove-subscription'
%p
%strong Browser limitations:
Adding a namespace currently works only in browsers that allow cross site cookies. Please make sure to use
%a{ href: 'https://www.mozilla.org/en-US/firefox/', target: '_blank', rel: 'noopener noreferrer' } Firefox
or
%a{ href: 'https://www.google.com/chrome/index.html', target: '_blank', rel: 'noopener noreferrer' } Google Chrome
or enable cross-site cookies in your browser when adding a namespace.
%a{ href: 'https://gitlab.com/gitlab-org/gitlab/-/issues/263509', target: '_blank', rel: 'noopener noreferrer' } Learn more
= page_specific_javascript_tag('jira_connect.js')
- add_page_specific_style 'page_bundles/jira_connect'
......@@ -69,7 +69,8 @@
= Gon::Base.render_data(nonce: content_security_policy_nonce)
= javascript_include_tag locale_path unless I18n.locale == :en
= webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
-# Temporarily commented out to investigate performance: https://gitlab.com/gitlab-org/gitlab/-/issues/251179
-# = webpack_bundle_tag "sentry" if Gitlab.config.sentry.enabled
= webpack_bundle_tag 'performance_bar' if performance_bar_enabled?
= yield :page_specific_javascripts
......
- return unless page_startup_api_calls.present?
- return unless page_startup_api_calls.present? || page_startup_graphql_calls.present?
= javascript_tag nonce: true do
:plain
var gl = window.gl || {};
gl.startup_calls = #{page_startup_api_calls.to_json};
gl.startup_graphql_calls = #{page_startup_graphql_calls.to_json};
if (gl.startup_calls && window.fetch) {
Object.keys(gl.startup_calls).forEach(apiCall => {
// fetch won’t send cookies in older browsers, unless you set the credentials init option.
......@@ -14,3 +16,21 @@
};
});
}
if (gl.startup_graphql_calls && window.fetch) {
const url = `#{api_graphql_url}`
const opts = {
method: "POST",
headers: { "Content-Type": "application/json", 'X-CSRF-Token': "#{form_authenticity_token}" },
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
operationName: call.query.match(/^query (.+)\(/)[1],
fetchCall: fetch(url, {
...opts,
credentials: 'same-origin',
body: JSON.stringify(call)
})
}))
}
......@@ -46,7 +46,7 @@
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
%li.d-md-none
= render 'shared/user_dropdown_contributing_link'
= render_if_exists 'shared/user_dropdown_instance_review'
= render 'shared/user_dropdown_instance_review'
- if Gitlab.com_but_not_canary?
%li.d-md-none
= link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
......
......@@ -17,7 +17,7 @@
- if current_user_menu?(:help) || current_user_menu?(:settings) || current_user_menu?(:profile)
%li
= render 'shared/user_dropdown_contributing_link'
= render_if_exists 'shared/user_dropdown_instance_review'
= render 'shared/user_dropdown_instance_review'
- if Gitlab.com_but_not_canary?
%li
= link_to _("Switch to GitLab Next"), "https://next.gitlab.com/"
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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