Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-10-16 09:09:06 +00:00
parent e94d662e5a
commit ed7568cc80
90 changed files with 1256 additions and 587 deletions

89
.gitpod.yml Normal file
View file

@ -0,0 +1,89 @@
image: registry.gitlab.com/gitlab-org/gitlab-development-kit/gitpod-workspace:gitpod-workspace-image
tasks:
- name: GDK
command: gp sync-await gdk-copied && cd /workspace/gitlab-development-kit && gdk help
- init: |
echo "$(date) Copying GDK" | tee -a /workspace/startup.log
mv $HOME/.rvm-workspace /workspace/.rvm
cp -r $HOME/gitlab-development-kit /workspace/
(
set -e
cd /workspace/gitlab-development-kit
[[ ! -L /workspace/gitlab-development-kit/gitlab ]] && ln -fs /workspace/gitlab /workspace/gitlab-development-kit/gitlab
# make webpack static, prevents that GitLab tries to connect to localhost webpack from browser outside the workspace
echo "webpack:" >> gdk.yml
echo " static: true" >> gdk.yml
# reconfigure GDK
echo "$(date) Reconfiguring GDK" | tee -a /workspace/startup.log
gdk reconfigure
# run DB migrations
echo "$(date) Running DB migrations" | tee -a /workspace/startup.log
make gitlab-db-migrate
cd -
# stop GDK
echo "$(date) Stopping GDK" | tee -a /workspace/startup.log
gdk stop
echo "$(date) GDK stopped" | tee -a /workspace/startup.log
)
command: |
(
set -e
gp sync-done gdk-copied
SECONDS=0
cd /workspace/gitlab-development-kit
# update GDK
if [ "$GITLAB_UPDATE_GDK" == true ]; then
echo "$(date) Updating GDK" | tee -a /workspace/startup.log
gdk update
fi
# start GDK
echo "$(date) Starting GDK" | tee -a /workspace/startup.log
export RAILS_HOSTS=$(gp url 3000 | sed -e 's+^http[s]*://++')
gdk start
# Run DB migrations
if [ "$GITLAB_RUN_DB_MIGRATIONS" == true ]; then
make gitlab-db-migrate
fi
# Fix DB key
if [ "$GITLAB_FIX_DB_KEY" = true ]; then
echo "$(date) Fixing DB key" | tee -a /workspace/startup.log
cd gitlab
# see https://gitlab.com/gitlab-org/gitlab-foss/-/issues/56403#note_132515069
printf 'ApplicationSetting.last.update_column(:runners_registration_token_encrypted, nil)\nexit\n' | bundle exec rails c
cd -
fi
# Waiting for GitLab ...
gp await-port 3000
printf "Waiting for GitLab at $(gp url 3000) ..."
until $(curl -sNL $(gp url 3000) | grep -q "GitLab"); do printf '.'; sleep 5; done && echo ""
# Give Gitpod a few more seconds to set up everything ...
sleep 5
printf "$(date) GitLab is up (took ~%.1f minutes)\n" "$((10*$SECONDS/60))e-1" | tee -a /workspace/startup.log
gp preview $(gp url 3000) || true
)
ports:
- port: 3000 # rails-web
onOpen: ignore
- port: 3010 # gitlab-pages
onOpen: ignore
- port: 3808 # webpack
onOpen: ignore
- port: 5000 # auto_devops
onOpen: ignore
- port: 5778 # jaeger
onOpen: ignore
- port: 9000 # object_store / minio
onOpen: ignore
vscode:
extensions:
- rebornix.ruby@0.27.0:QyGBeRyslOfdRgOPRGm6PQ==
- wingrunr21.vscode-ruby@0.27.0:beIqQUhLRuJ5Vao4B2Lyng==
- karunamurti.haml@1.1.0:twCwOYt3/Ttfb3+iwblPDA==
- octref.vetur@0.25.0:UofirBhedyhdx/jCnPeJDg==
- dbaeumer.vscode-eslint@2.1.3:1NRvj3UKNTNwmYjptmUmIw==
- GitLab.gitlab-workflow@3.3.0:50q1byIi4M01G9qrTCCAYQ==

11
.theia/settings.json Normal file
View file

@ -0,0 +1,11 @@
{
"ruby.codeCompletion": "rcodetools",
"ruby.format": "standard",
"ruby.intellisense": "rubyLocate",
"ruby.useBundler": true,
"ruby.useLanguageServer": true,
"ruby.lint": {
"rubocop": true,
"useBundler": true
}
}

View file

@ -1 +1 @@
05cd75fb57f06f29978e6cc0da3f7bc35d85859f
3bdd23173595a931aac476ad0c07c702c30f4391

View file

@ -14,7 +14,6 @@ export default function initGFMInput($els) {
milestones: enableGFM,
mergeRequests: enableGFM,
labels: enableGFM,
vulnerabilities: enableGFM,
});
});
}

View file

@ -4,7 +4,6 @@ import { escape, template } from 'lodash';
import SidebarMediator from '~/sidebar/sidebar_mediator';
import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache';
import axios from '~/lib/utils/axios_utils';
import { spriteIcon } from './lib/utils/common_utils';
import * as Emoji from '~/emoji';
@ -53,7 +52,6 @@ export const defaultAutocompleteConfig = {
milestones: true,
labels: true,
snippets: true,
vulnerabilities: true,
};
class GfmAutoComplete {
@ -61,7 +59,6 @@ class GfmAutoComplete {
this.dataSources = dataSources;
this.cachedData = {};
this.isLoadingData = {};
this.previousQuery = '';
}
setup(input, enableMap = defaultAutocompleteConfig) {
@ -557,7 +554,7 @@ class GfmAutoComplete {
}
getDefaultCallbacks() {
const self = this;
const fetchData = this.fetchData.bind(this);
return {
sorter(query, items, searchKey) {
@ -570,15 +567,7 @@ class GfmAutoComplete {
},
filter(query, data, searchKey) {
if (GfmAutoComplete.isLoading(data)) {
self.fetchData(this.$inputor, this.at);
return data;
}
if (
GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[this.at]) &&
self.previousQuery !== query
) {
self.fetchData(this.$inputor, this.at, query);
self.previousQuery = query;
fetchData(this.$inputor, this.at);
return data;
}
return $.fn.atwho.default.callbacks.filter(query, data, searchKey);
@ -626,22 +615,13 @@ class GfmAutoComplete {
};
}
fetchData($input, at, search) {
fetchData($input, at) {
if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true;
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
if (GfmAutoComplete.typesWithBackendFiltering.includes(GfmAutoComplete.atTypeMap[at])) {
axios
.get(dataSource, { params: { search } })
.then(({ data }) => {
this.loadData($input, at, data);
})
.catch(() => {
this.isLoadingData[at] = false;
});
} else if (this.cachedData[at]) {
if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
this.loadEmojiData($input, at).catch(() => {});
@ -727,8 +707,7 @@ class GfmAutoComplete {
// https://github.com/ichord/At.js
const atSymbolsWithBar = Object.keys(controllers)
.join('|')
.replace(/[$]/, '\\$&')
.replace(/[+]/, '\\+');
.replace(/[$]/, '\\$&');
const atSymbolsWithoutBar = Object.keys(controllers).join('');
const targetSubtext = subtext.split(GfmAutoComplete.regexSubtext).pop();
const resultantFlag = flag.replace(/[-[\]/{}()*+?.\\^$|]/g, '\\$&');
@ -759,12 +738,9 @@ GfmAutoComplete.atTypeMap = {
'~': 'labels',
'%': 'milestones',
'/': 'commands',
'+': 'vulnerabilities',
$: 'snippets',
};
GfmAutoComplete.typesWithBackendFiltering = ['vulnerabilities'];
function findEmoji(name) {
return Emoji.searchEmoji(name, { match: 'contains', raw: true }).sort((a, b) => {
if (a.index !== b.index) {

View file

@ -310,7 +310,7 @@ export default {
category="primary"
variant="success"
:href="newIncidentPath"
@click="redirecting = true"
@click="navigateToCreateNewIncident"
>
{{ $options.i18n.createIncidentBtnLabel }}
</gl-button>

View file

@ -34,6 +34,13 @@ export const INCIDENT_STATUS_TABS = [
},
];
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' };
export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
/**
* Tracks snowplow event when user clicks create new incident
*/
@ -43,16 +50,17 @@ export const trackIncidentCreateNewOptions = {
};
/**
* Tracks snowplow event when user views incident list
* Tracks snowplow event when user views incidents list
*/
export const trackIncidentListViewsOptions = {
category: 'Incident Management',
action: 'view_incidents_list',
};
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' };
export const TH_INCIDENT_SLA_TEST_ID = { 'data-testid': 'incident-management-sla' };
export const TH_PUBLISHED_TEST_ID = { 'data-testid': 'incident-management-published-sort' };
export const INCIDENT_DETAILS_PATH = 'incident';
/**
* Tracks snowplow event when user views incident details
*/
export const trackIncidentDetailsViewsOptions = {
category: 'Incident Management',
action: 'view_incident_details',
};

View file

@ -5,8 +5,10 @@ import HighlightBar from './highlight_bar.vue';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import Tracking from '~/tracking';
import getAlert from './graphql/queries/get_alert.graphql';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
export default {
components: {
@ -46,6 +48,15 @@ export default {
return this.$apollo.queries.alert.loading;
},
},
mounted() {
this.trackPageViews();
},
methods: {
trackPageViews() {
const { category, action } = trackIncidentDetailsViewsOptions;
Tracking.event(category, action);
},
},
};
</script>

View file

@ -13,6 +13,9 @@ export default () => {
modules: {
detail: createDetailModule(el.dataset),
},
featureFlags: {
graphqlIndividualReleasePage: Boolean(gon.features?.graphqlIndividualReleasePage),
},
});
return new Vue({

View file

@ -3,7 +3,13 @@ import api from '~/api';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__ } from '~/locale';
import { redirectTo } from '~/lib/utils/url_utility';
import { releaseToApiJson, apiJsonToRelease } from '~/releases/util';
import {
releaseToApiJson,
apiJsonToRelease,
gqClient,
convertOneReleaseGraphQLResponse,
} from '~/releases/util';
import oneReleaseQuery from '~/releases/queries/one_release.query.graphql';
export const initializeRelease = ({ commit, dispatch, getters }) => {
if (getters.isExistingRelease) {
@ -18,9 +24,29 @@ export const initializeRelease = ({ commit, dispatch, getters }) => {
return Promise.resolve();
};
export const fetchRelease = ({ commit, state }) => {
export const fetchRelease = ({ commit, state, rootState }) => {
commit(types.REQUEST_RELEASE);
if (rootState.featureFlags?.graphqlIndividualReleasePage) {
return gqClient
.query({
query: oneReleaseQuery,
variables: {
fullPath: state.projectPath,
tagName: state.tagName,
},
})
.then(response => {
const { data: release } = convertOneReleaseGraphQLResponse(response);
commit(types.RECEIVE_RELEASE_SUCCESS, release);
})
.catch(error => {
commit(types.RECEIVE_RELEASE_ERROR, error);
createFlash(s__('Release|Something went wrong while getting the release details'));
});
}
return api
.release(state.projectId, state.tagName)
.then(({ data }) => {

View file

@ -1,5 +1,6 @@
export default ({
projectId,
projectPath,
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,
@ -12,6 +13,7 @@ export default ({
defaultBranch = null,
}) => ({
projectId,
projectPath,
markdownDocsPath,
markdownPreviewPath,
updateReleaseApiDocsPath,

View file

@ -16,6 +16,5 @@ export default (initGFM = true) => {
milestones: initGFM,
labels: initGFM,
snippets: initGFM,
vulnerabilities: initGFM,
});
};

View file

@ -61,45 +61,46 @@ export default {
};
</script>
<template>
<div class="container gl-flex-grow-1 gl-display-flex gl-flex-direction-column">
<div
v-if="savedContentMeta"
class="gl-fixed gl-left-0 gl-right-0 gl-border-b-solid gl-border-b-1 gl-border-b-gray-100"
>
<div>
<div class="gl-border-b-solid gl-border-b-1 gl-border-b-gray-100">
<div class="container gl-py-4">
<gl-button
v-if="appData.returnUrl"
ref="returnToSiteButton"
class="gl-mr-5"
:href="appData.returnUrl"
>{{ $options.returnToSiteBtnText }}</gl-button
>
<strong>
{{ updatedFileDescription }}
</strong>
<div class="gl-display-flex">
<gl-button
v-if="appData.returnUrl"
ref="returnToSiteButton"
class="gl-mr-5 gl-align-self-start"
:href="appData.returnUrl"
>{{ $options.returnToSiteBtnText }}</gl-button
>
<strong class="gl-mt-2">
{{ updatedFileDescription }}
</strong>
</div>
</div>
</div>
<gl-empty-state
class="gl-my-9"
:title="savedContentMeta ? $options.title : $options.submittingTitle"
:primary-button-text="savedContentMeta && $options.primaryButtonText"
:primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url"
:svg-path="mergeRequestsIllustrationPath"
:svg-height="146"
>
<template #description>
<div v-if="savedContentMeta">
<p>{{ $options.mergeRequestInstructionsHeading }}</p>
<p>{{ $options.addTitleInstruction }}</p>
<p>{{ $options.addDescriptionInstruction }}</p>
<p>{{ $options.assignMergeRequestInstruction }}</p>
</div>
<div v-else>
<p>{{ $options.submittingNotePrimary }}</p>
<p>{{ $options.submittingNoteSecondary }}</p>
<gl-loading-icon size="xl" />
</div>
</template>
</gl-empty-state>
<div class="container">
<gl-empty-state
class="gl-my-7"
:title="savedContentMeta ? $options.title : $options.submittingTitle"
:primary-button-text="savedContentMeta && $options.primaryButtonText"
:primary-button-link="savedContentMeta && savedContentMeta.mergeRequest.url"
:svg-path="mergeRequestsIllustrationPath"
:svg-height="146"
>
<template #description>
<div v-if="savedContentMeta">
<p>{{ $options.mergeRequestInstructionsHeading }}</p>
<p>{{ $options.addTitleInstruction }}</p>
<p>{{ $options.addDescriptionInstruction }}</p>
<p>{{ $options.assignMergeRequestInstruction }}</p>
</div>
<div v-else>
<p>{{ $options.submittingNotePrimary }}</p>
<p>{{ $options.submittingNoteSecondary }}</p>
<gl-loading-icon size="xl" />
</div>
</template>
</gl-empty-state>
</div>
</div>
</template>

View file

@ -15,7 +15,7 @@ const markPrefix = `${marker}-${Date.now()}`;
const reHelpers = {
template: `.| |\\t|\\n(?!(\\n|${markPrefix}))`,
openTag: '<(?!iframe)[a-zA-Z]+.*?>',
openTag: '<(?!figure|iframe)[a-zA-Z]+.*?>',
closeTag: '</.+>',
};
const reTemplated = new RegExp(`(^${wrapPrefix}(${reHelpers.template})+?${wrapPostfix}$)`, 'gm');

View file

@ -178,7 +178,6 @@ export default {
milestones: this.enableAutocomplete,
labels: this.enableAutocomplete && !this.glFeatures.tributeAutocomplete,
snippets: this.enableAutocomplete,
vulnerabilities: this.enableAutocomplete,
},
true,
);

View file

@ -2,9 +2,14 @@ import { __ } from '~/locale';
export const CUSTOM_EVENTS = {
openAddImageModal: 'gl_openAddImageModal',
openInsertVideoModal: 'gl_openInsertVideoModal',
};
export const ALLOWED_VIDEO_ORIGINS = ['https://www.youtube.com'];
export const YOUTUBE_URL = 'https://www.youtube.com';
export const YOUTUBE_EMBED_URL = `${YOUTUBE_URL}/embed`;
export const ALLOWED_VIDEO_ORIGINS = [YOUTUBE_URL];
/* eslint-disable @gitlab/require-i18n-strings */
export const TOOLBAR_ITEM_CONFIGS = [
@ -25,6 +30,7 @@ export const TOOLBAR_ITEM_CONFIGS = [
{ icon: 'dash', command: 'HR', tooltip: __('Add a line') },
{ icon: 'table', event: 'openPopupAddTable', classes: 'tui-table', tooltip: __('Add a table') },
{ icon: 'doc-image', event: CUSTOM_EVENTS.openAddImageModal, tooltip: __('Insert an image') },
{ icon: 'live-preview', event: CUSTOM_EVENTS.openInsertVideoModal, tooltip: __('Insert video') },
{ isDivider: true },
{ icon: 'code', command: 'Code', tooltip: __('Insert inline code') },
{ icon: 'doc-code', command: 'CodeBlock', tooltip: __('Insert a code block') },
@ -42,3 +48,10 @@ export const EDITOR_PREVIEW_STYLE = 'horizontal';
export const IMAGE_TABS = { UPLOAD_TAB: 0, URL_TAB: 1 };
export const MAX_FILE_SIZE = 2097152; // 2Mb
export const VIDEO_ATTRIBUTES = {
width: '560',
height: '315',
frameBorder: '0',
allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
};

View file

@ -0,0 +1,91 @@
<script>
import { GlModal, GlFormGroup, GlFormInput, GlSprintf } from '@gitlab/ui';
import { isSafeURL } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import { YOUTUBE_URL, YOUTUBE_EMBED_URL } from '../constants';
export default {
components: {
GlModal,
GlFormGroup,
GlFormInput,
GlSprintf,
},
data() {
return {
url: null,
urlError: null,
description: __(
'If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}',
),
};
},
modalTitle: __('Insert a video'),
okTitle: __('Insert video'),
label: __('YouTube URL or ID'),
methods: {
show() {
this.urlError = null;
this.url = null;
this.$refs.modal.show();
},
onPrimary(event) {
this.submitURL(event);
},
submitURL(event) {
const url = this.generateUrl();
if (!url) {
event.preventDefault();
return;
}
this.$emit('insertVideo', url);
},
generateUrl() {
let { url } = this;
const reYouTubeId = /^[A-z0-9]*$/;
const reYouTubeUrl = RegExp(`${YOUTUBE_URL}/(embed/|watch\\?v=)([A-z0-9]+)`);
if (reYouTubeId.test(url)) {
url = `${YOUTUBE_EMBED_URL}/${url}`;
} else if (reYouTubeUrl.test(url)) {
url = `${YOUTUBE_EMBED_URL}/${reYouTubeUrl.exec(url)[2]}`;
}
if (!isSafeURL(url) || !reYouTubeUrl.test(url)) {
this.urlError = __('Please provide a valid YouTube URL or ID');
this.$refs.urlInput.$el.focus();
return null;
}
return url;
},
},
};
</script>
<template>
<gl-modal
ref="modal"
size="sm"
modal-id="insert-video-modal"
:title="$options.modalTitle"
:ok-title="$options.okTitle"
@primary="onPrimary"
>
<gl-form-group
:label="$options.label"
label-for="video-modal-url-input"
:state="!Boolean(urlError)"
:invalid-feedback="urlError"
>
<gl-form-input id="video-modal-url-input" ref="urlInput" v-model="url" />
<gl-sprintf slot="description" :message="description" class="text-gl-muted">
<template #id>
<strong>{{ __('0t1DgySidms') }}</strong>
</template>
</gl-sprintf>
</gl-form-group>
</gl-modal>
</template>

View file

@ -3,6 +3,7 @@ import 'codemirror/lib/codemirror.css';
import '@toast-ui/editor/dist/toastui-editor.css';
import AddImageModal from './modals/add_image/add_image_modal.vue';
import InsertVideoModal from './modals/insert_video_modal.vue';
import { EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, CUSTOM_EVENTS } from './constants';
import {
@ -12,6 +13,7 @@ import {
removeCustomEventListener,
addImage,
getMarkdown,
insertVideo,
} from './services/editor_service';
export default {
@ -21,6 +23,7 @@ export default {
toast => toast.Editor,
),
AddImageModal,
InsertVideoModal,
},
props: {
content: {
@ -63,6 +66,12 @@ export default {
editorInstance() {
return this.$refs.editor;
},
customEventListeners() {
return [
{ event: CUSTOM_EVENTS.openAddImageModal, listener: this.onOpenAddImageModal },
{ event: CUSTOM_EVENTS.openInsertVideoModal, listener: this.onOpenInsertVideoModal },
];
},
},
created() {
this.editorOptions = getEditorOptions(this.options);
@ -72,16 +81,16 @@ export default {
},
methods: {
addListeners(editorApi) {
addCustomEventListener(editorApi, CUSTOM_EVENTS.openAddImageModal, this.onOpenAddImageModal);
this.customEventListeners.forEach(({ event, listener }) => {
addCustomEventListener(editorApi, event, listener);
});
editorApi.eventManager.listen('changeMode', this.onChangeMode);
},
removeListeners() {
removeCustomEventListener(
this.editorApi,
CUSTOM_EVENTS.openAddImageModal,
this.onOpenAddImageModal,
);
this.customEventListeners.forEach(({ event, listener }) => {
removeCustomEventListener(this.editorApi, event, listener);
});
this.editorApi.eventManager.removeEventHandler('changeMode', this.onChangeMode);
},
@ -111,6 +120,12 @@ export default {
addImage(this.editorInstance, image);
},
onOpenInsertVideoModal() {
this.$refs.insertVideoModal.show();
},
onInsertVideo(url) {
insertVideo(this.editorInstance, url);
},
onChangeMode(newMode) {
this.$emit('modeChange', newMode);
},
@ -130,5 +145,6 @@ export default {
@load="onLoad"
/>
<add-image-modal ref="addImageModal" :image-root="imageRoot" @addImage="onAddImage" />
<insert-video-modal ref="insertVideoModal" @insertVideo="onInsertVideo" />
</div>
</template>

View file

@ -3,7 +3,7 @@ import { defaults } from 'lodash';
import ToolbarItem from '../toolbar_item.vue';
import buildHtmlToMarkdownRenderer from './build_html_to_markdown_renderer';
import buildCustomHTMLRenderer from './build_custom_renderer';
import { TOOLBAR_ITEM_CONFIGS } from '../constants';
import { TOOLBAR_ITEM_CONFIGS, VIDEO_ATTRIBUTES } from '../constants';
import sanitizeHTML from './sanitize_html';
const buildWrapper = propsData => {
@ -17,6 +17,23 @@ const buildWrapper = propsData => {
return instance.$el;
};
const buildVideoIframe = src => {
const wrapper = document.createElement('figure');
const iframe = document.createElement('iframe');
const videoAttributes = { ...VIDEO_ATTRIBUTES, src };
const wrapperClasses = ['gl-relative', 'gl-h-0', 'video_container'];
const iframeClasses = ['gl-absolute', 'gl-top-0', 'gl-left-0', 'gl-w-full', 'gl-h-full'];
wrapper.setAttribute('contenteditable', 'false');
wrapper.classList.add(...wrapperClasses);
iframe.classList.add(...iframeClasses);
Object.assign(iframe, videoAttributes);
wrapper.appendChild(iframe);
return wrapper;
};
export const generateToolbarItem = config => {
const { icon, classes, event, command, tooltip, isDivider } = config;
@ -44,6 +61,16 @@ export const removeCustomEventListener = (editorApi, event, handler) =>
export const addImage = ({ editor }, image) => editor.exec('AddImage', image);
export const insertVideo = ({ editor }, url) => {
const videoIframe = buildVideoIframe(url);
if (editor.isWysiwygMode()) {
editor.getSquire().insertElement(videoIframe);
} else {
editor.insertText(videoIframe.outerHTML);
}
};
export const getMarkdown = editorInstance => editorInstance.invoke('getMarkdown');
/**

View file

@ -11,8 +11,6 @@
@import './pages/editor';
@import './pages/environment_logs';
@import './pages/events';
@import './pages/experience_level';
@import './pages/experimental_separate_sign_up';
@import './pages/groups';
@import './pages/help';
@import './pages/import';

View file

@ -44,3 +44,11 @@
@include gl-line-height-20;
}
}
/**
* Styling below ensures that YouTube videos are displayed in the editor the same as they would in about.gitlab.com
* https://gitlab.com/gitlab-com/www-gitlab-com/-/blob/master/source/stylesheets/_base.scss#L977
*/
.video_container {
padding-bottom: 56.25%;
}

View file

@ -0,0 +1,96 @@
@import 'mixins_and_variables_and_functions';
.signup-page {
.page-wrap {
background-color: var(--gray-10, $gray-10);
}
.signup-box-container {
max-width: 960px;
}
.signup-box {
background-color: var(--white, $white);
box-shadow: 0 0 0 1px var(--border-color, $border-color);
border-radius: $border-radius;
}
.form-control {
&:active,
&:focus {
background-color: var(--white, $white);
}
}
.devise-errors {
h2 {
font-size: $gl-font-size;
color: var(--red-700, $red-700);
}
}
.omniauth-divider {
&::before,
&::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--gray-100, $gray-100);
margin: $gl-padding-24 0;
}
&::before {
margin-right: $gl-padding;
}
&::after {
margin-left: $gl-padding;
}
}
.omniauth-btn {
width: 48%;
@include media-breakpoint-down(md) {
width: 100%;
}
img {
width: $default-icon-size;
height: $default-icon-size;
}
}
.decline-page {
width: 350px;
}
}
.signup-page[data-page^='registrations:experience_levels'] {
$card-shadow-color: rgba(var(--black, $black), 0.2);
.page-wrap {
background-color: var(--white, $white);
}
.card-deck {
max-width: 828px;
}
.card {
transition: box-shadow 0.3s ease-in-out;
}
.card:hover {
box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color;
}
@media (min-width: $breakpoint-sm) {
.card-deck .card {
margin: 0 $gl-spacing-scale-3;
}
}
.stretched-link:hover {
text-decoration: none;
}
}

View file

@ -1,29 +0,0 @@
.signup-page[data-page^='registrations:experience_levels'] {
$card-shadow-color: rgba($black, 0.2);
.page-wrap {
background-color: $white;
}
.card-deck {
max-width: 828px;
}
.card {
transition: box-shadow 0.3s ease-in-out;
}
.card:hover {
box-shadow: 0 $gl-spacing-scale-3 $gl-spacing-scale-5 $card-shadow-color;
}
@media (min-width: $breakpoint-sm) {
.card-deck .card {
margin: 0 $gl-spacing-scale-3;
}
}
.stretched-link:hover {
text-decoration: none;
}
}

View file

@ -1,64 +0,0 @@
.signup-page {
.page-wrap {
background-color: $gray-light;
}
.signup-box-container {
max-width: 960px;
}
.signup-box {
background-color: $white;
box-shadow: 0 0 0 1px $border-color;
border-radius: $border-radius;
}
.form-control {
&:active,
&:focus {
background-color: $white;
}
}
.devise-errors {
h2 {
font-size: $gl-font-size;
color: $red-700;
}
}
.omniauth-divider {
&::before,
&::after {
content: '';
flex: 1;
border-bottom: 1px solid $gray-dark;
margin: $gl-padding-24 0;
}
&::before {
margin-right: $gl-padding;
}
&::after {
margin-left: $gl-padding;
}
}
.omniauth-btn {
width: 48%;
@include media-breakpoint-down(md) {
width: 100%;
}
img {
width: $default-icon-size;
height: $default-icon-size;
}
}
.decline-page {
width: 350px;
}
}

View file

@ -150,7 +150,7 @@ module IssuableCollections
common_attributes + [:project, project: :namespace]
when 'MergeRequest'
common_attributes + [
:target_project, :latest_merge_request_diff, :approvals, :approved_by_users,
:target_project, :latest_merge_request_diff, :approvals, :approved_by_users, :reviewers,
source_project: :route, head_pipeline: :project, target_project: :namespace
]
end

View file

@ -105,7 +105,7 @@ module MembershipActions
# rubocop: enable CodeReuse/ActiveRecord
def resend_invite
member = membershipable.members.find(params[:id])
member = membershipable_members.find(params[:id])
if member.invite?
member.resend_invite
@ -122,6 +122,10 @@ module MembershipActions
raise NotImplementedError
end
def membershipable_members
raise NotImplementedError
end
def root_params_key
case membershipable
when Namespace

View file

@ -71,6 +71,10 @@ class Groups::GroupMembersController < Groups::ApplicationController
def filter_params
params.permit(:two_factor, :search).merge(sort: @sort)
end
def membershipable_members
group.members
end
end
Groups::GroupMembersController.prepend_if_ee('EE::Groups::GroupMembersController')

View file

@ -39,7 +39,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
private
def autocomplete_service
@autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user, params)
@autocomplete_service ||= ::Projects::AutocompleteService.new(@project, current_user)
end
def target

View file

@ -5,11 +5,11 @@ class Projects::PipelinesController < Projects::ApplicationController
include Analytics::UniqueVisitsHelper
before_action :whitelist_query_limiting, only: [:create, :retry]
before_action :pipeline, except: [:index, :new, :create, :charts]
before_action :pipeline, except: [:index, :new, :create, :charts, :config_variables]
before_action :set_pipeline_path, only: [:show]
before_action :authorize_read_pipeline!
before_action :authorize_read_build!, only: [:index]
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_create_pipeline!, only: [:new, :create, :config_variables]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
push_frontend_feature_flag(:filter_pipelines_search, project, default_enabled: true)
@ -209,6 +209,14 @@ class Projects::PipelinesController < Projects::ApplicationController
end
end
def config_variables
respond_to do |format|
format.json do
render json: Ci::ListConfigVariablesService.new(@project).execute(params[:sha])
end
end
end
private
def serialize_pipelines

View file

@ -57,6 +57,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController
def filter_params
params.permit(:search).merge(sort: @sort)
end
def membershipable_members
project.members
end
end
Projects::ProjectMembersController.prepend_if_ee('EE::Projects::ProjectMembersController')

View file

@ -9,6 +9,7 @@ class Projects::ReleasesController < Projects::ApplicationController
push_frontend_feature_flag(:graphql_release_data, project, default_enabled: true)
push_frontend_feature_flag(:graphql_milestone_stats, project, default_enabled: true)
push_frontend_feature_flag(:graphql_releases_page, project, default_enabled: true)
push_frontend_feature_flag(:graphql_individual_release_page, project)
end
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new

View file

@ -397,7 +397,7 @@ class IssuableFinder
elsif params.filter_by_any_assignee?
items.assigned
elsif params.assignee
items.assigned_to(params.assignee)
items_assigned_to(items, params.assignee)
elsif params.assignee_id? || params.assignee_username? # assignee not found
items.none
else
@ -405,6 +405,10 @@ class IssuableFinder
end
end
def items_assigned_to(items, user)
items.assigned_to(user)
end
def by_negated_assignee(items)
# We want CE users to be able to say "Issues not assigned to either PersonA nor PersonB"
if not_params.assignees.present?

View file

@ -145,6 +145,10 @@ class MergeRequestsFinder < IssuableFinder
.execute(items)
end
# rubocop: enable CodeReuse/Finder
def items_assigned_to(items, user)
MergeRequest.from_union([super, items.reviewer_assigned_to(user)])
end
end
MergeRequestsFinder.prepend_if_ee('EE::MergeRequestsFinder')

View file

@ -159,7 +159,6 @@ module NotesHelper
members: autocomplete,
issues: autocomplete,
mergeRequests: autocomplete,
vulnerabilities: autocomplete,
epics: autocomplete,
milestones: autocomplete,
labels: autocomplete

View file

@ -29,6 +29,14 @@ module ReleasesHelper
end
end
def data_for_show_page
{
project_id: @project.id,
project_path: @project.full_path,
tag_name: @release.tag
}
end
def data_for_edit_release_page
new_edit_pages_shared_data.merge(
tag_name: @release.tag,
@ -48,6 +56,7 @@ module ReleasesHelper
def new_edit_pages_shared_data
{
project_id: @project.id,
project_path: @project.full_path,
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
update_release_api_docs_path: help_page_path('api/releases/index.md', anchor: 'update-a-release'),

View file

@ -22,7 +22,11 @@ module Clusters
validate :has_at_least_one_log_enabled?
def chart
'stable/fluentd'
'fluentd/fluentd'
end
def repository
'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
def install_command

View file

@ -46,7 +46,11 @@ module Clusters
end
def chart
'stable/nginx-ingress'
"#{name}/nginx-ingress"
end
def repository
'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
def values
@ -60,6 +64,7 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
repository: repository,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,

View file

@ -51,7 +51,11 @@ module Clusters
end
def chart
'stable/prometheus'
"#{name}/prometheus"
end
def repository
'https://gitlab-org.gitlab.io/cluster-integration/helm-stable-archive'
end
def service_name
@ -65,6 +69,7 @@ module Clusters
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name: name,
repository: repository,
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
@ -76,6 +81,7 @@ module Clusters
def patch_command(values)
::Gitlab::Kubernetes::Helm::PatchCommand.new(
name: name,
repository: repository,
version: version,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,

View file

@ -22,7 +22,7 @@ module Mentionable
def self.default_pattern
strong_memoize(:default_pattern) do
issue_pattern = Issue.reference_pattern
link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic, Vulnerability].map(&:link_reference_pattern).compact)
link_patterns = Regexp.union([Issue, Commit, MergeRequest, Epic].map(&:link_reference_pattern).compact)
reference_pattern(link_patterns, issue_pattern)
end
end

View file

@ -302,6 +302,10 @@ class MergeRequest < ApplicationRecord
includes(:metrics)
end
scope :reviewer_assigned_to, ->(user) do
where("EXISTS (SELECT TRUE FROM merge_request_reviewers WHERE user_id = ? AND merge_request_id = merge_requests.id)", user.id)
end
after_save :keep_around_commit, unless: :importing?
alias_attribute :project, :target_project

View file

@ -1340,7 +1340,8 @@ class Project < ApplicationRecord
end
def find_or_initialize_services
available_services_names = Service.available_services_names - disabled_services
available_services_names =
Service.available_services_names + Service.project_specific_services_names - disabled_services
available_services_names.map do |service_name|
find_or_initialize_service(service_name)
@ -2514,6 +2515,10 @@ class Project < ApplicationRecord
ci_config_path.presence || Ci::Pipeline::DEFAULT_CONFIG_PATH
end
def ci_config_for(sha)
repository.gitlab_ci_yml_for(sha, ci_config_path_or_default)
end
def enabled_group_deploy_keys
return GroupDeployKey.none unless group

View file

@ -208,6 +208,10 @@ class Service < ApplicationRecord
DEV_SERVICE_NAMES
end
def self.project_specific_services_names
[]
end
def self.available_services_types
available_services_names.map { |service_name| "#{service_name}_service".camelize }
end

View file

@ -1,21 +1,7 @@
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
# It reserves '+' as a reference prefix, but the table does not exist in FOSS
class Vulnerability < ApplicationRecord
include IgnorableColumns
def self.link_reference_pattern
nil
end
def self.reference_prefix
'+'
end
def self.reference_prefix_escaped
'&plus;'
end
end
Vulnerability.prepend_if_ee('EE::Vulnerability')

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Ci
class ListConfigVariablesService < ::BaseService
def execute(sha)
config = project.ci_config_for(sha)
return {} unless config
result = Gitlab::Ci::YamlProcessor.new(config).execute
result.valid? ? result.variables_with_data : {}
end
end
end

View file

@ -1,5 +1,6 @@
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
- add_page_specific_style 'page_bundles/experimental_separate_sign_up'
= render "layouts/head"
%body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"

View file

@ -1,5 +1,6 @@
!!! 5
%html.devise-layout-html.navless{ class: system_message_class }
- add_page_specific_style 'page_bundles/experimental_separate_sign_up'
= render "layouts/head"
%body.ui-indigo.signup-page{ class: "#{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } }
= render "layouts/header/logo_with_title"

View file

@ -2,4 +2,4 @@
- page_title @release.name
- page_description @release.description_html
#js-show-release-page{ data: { project_id: @project.id, tag_name: @release.tag } }
#js-show-release-page{ data: data_for_show_page }

View file

@ -0,0 +1,5 @@
---
title: Add the ability to insert a YouTube video
merge_request: 44102
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Snowplow tracking of Incident details views
merge_request: 45011
author:
type: added

View file

@ -0,0 +1,5 @@
---
title: Allow re-sending invite to minimal access user
merge_request: 44936
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: Class and markup cleanup to prevent SVG header bar overlap in Static Site Editor
merge_request: 45334
author:
type: fixed

View file

@ -0,0 +1,5 @@
---
title: "GitLab-managed apps: Use GitLab's repo as replacement for the Helm stable repo"
merge_request: 44875
author:
type: other

View file

@ -179,6 +179,7 @@ module Gitlab
config.assets.precompile << "page_bundles/environments.css"
config.assets.precompile << "page_bundles/error_tracking_details.css"
config.assets.precompile << "page_bundles/error_tracking_index.css"
config.assets.precompile << "page_bundles/experimental_separate_sign_up.css"
config.assets.precompile << "page_bundles/ide.css"
config.assets.precompile << "page_bundles/issues_list.css"
config.assets.precompile << "page_bundles/jira_connect.css"

View file

@ -0,0 +1,7 @@
---
name: graphql_individual_release_page
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/44779
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/263522
type: development
group: group::release management
default_enabled: false

View file

@ -0,0 +1,6 @@
# frozen_string_literal: true
if Rails.env.development? && ENV['GITPOD_WORKSPACE_ID'].present?
gitpod_host = URI(`gp url 3000`.strip).host
Rails.application.config.hosts += [gitpod_host]
end

View file

@ -7,6 +7,7 @@ resources :pipelines, only: [:index, :new, :create, :show, :destroy] do
scope '(*ref)', constraints: { ref: Gitlab::PathRegex.git_reference_regex } do
get :latest, action: :show, defaults: { latest: true }
end
get :config_variables
end
member do

View file

@ -425,7 +425,6 @@ GFM recognizes the following:
| merge request | `!123` | `namespace/project!123` | `project!123` |
| snippet | `$123` | `namespace/project$123` | `project$123` |
| epic **(ULTIMATE)** | `&123` | `group1/subgroup&123` | |
| vulnerability **(ULTIMATE)** | `+123` | `namespace/project+123` | `project+123` |
| label by ID | `~123` | `namespace/project~123` | `project~123` |
| one-word label by name | `~bug` | `namespace/project~bug` | `project~bug` |
| multi-word label by name | `~"feature request"` | `namespace/project~"feature request"` | `project~"feature request"` |

View file

@ -1,5 +1,7 @@
---
description: 'Understand and explore the user permission levels in GitLab, and what features each of them grants you access to.'
stage: Manage
group: Access
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers
---
# Permissions
@ -42,17 +44,17 @@ or an instance administrator, who receives all permissions. For more information
The following table depicts the various user permission levels in a project.
| Action | Guest | Reporter | Developer |Maintainer| Owner* |
| Action | Guest | Reporter | Developer |Maintainer| Owner (*10*) |
|---------------------------------------------------|---------|------------|-------------|----------|--------|
| Download project | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| Leave comments | ✓ | ✓ | ✓ | ✓ | ✓ |
| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View allowed and denied licenses **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View License Compliance reports **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View Security reports **(ULTIMATE)** | ✓ (*3*) | ✓ | ✓ | ✓ | ✓ |
| View Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View License list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View licenses in Dependency list **(ULTIMATE)** | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View [Design Management](project/issues/design_management.md) pages | ✓ | ✓ | ✓ | ✓ | ✓ |
| View [Design Management](project/issues/design_management.md) pages | ✓ | ✓ | ✓ | ✓ | ✓ |
| View project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| Pull project code | ✓ (*1*) | ✓ | ✓ | ✓ | ✓ |
| View GitLab Pages protected by [access control](project/pages/introduction.md#gitlab-pages-access-control) | ✓ | ✓ | ✓ | ✓ | ✓ |
@ -63,10 +65,14 @@ The following table depicts the various user permission levels in a project.
| Create new issue | ✓ | ✓ | ✓ | ✓ | ✓ |
| See related issues | ✓ | ✓ | ✓ | ✓ | ✓ |
| Create confidential issue | ✓ | ✓ | ✓ | ✓ | ✓ |
| View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ |
| View [Releases](project/releases/index.md) | ✓ (*6*) | ✓ | ✓ | ✓ | ✓ |
| View requirements **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Insights **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Issue analytics **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Merge Request analytics **(STARTER)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Value Stream analytics | ✓ | ✓ | ✓ | ✓ | ✓ |
| Manage user-starred metrics dashboards (*7*) | ✓ | ✓ | ✓ | ✓ | ✓ |
| View confidential issues | (*2*) | ✓ | ✓ | ✓ | ✓ |
| Assign issues | | ✓ | ✓ | ✓ | ✓ |
| Label issues | | ✓ | ✓ | ✓ | ✓ |
| Set issue weight | | ✓ | ✓ | ✓ | ✓ |
@ -79,14 +85,15 @@ The following table depicts the various user permission levels in a project.
| See a container registry | | ✓ | ✓ | ✓ | ✓ |
| See environments | | ✓ | ✓ | ✓ | ✓ |
| See a list of merge requests | | ✓ | ✓ | ✓ | ✓ |
| View project statistics | | | ✓ | ✓ | ✓ |
| View CI/CD analytics | | ✓ | ✓ | ✓ | ✓ |
| View Code Review analytics **(STARTER)** | | ✓ | ✓ | ✓ | ✓ |
| View Repository analytics | | ✓ | ✓ | ✓ | ✓ |
| View Error Tracking list | | ✓ | ✓ | ✓ | ✓ |
| Create new merge request | | ✓ | ✓ | ✓ | ✓ |
| View metrics dashboard annotations | | ✓ | ✓ | ✓ | ✓ |
| Create/edit requirements **(ULTIMATE)** | | ✓ | ✓ | ✓ | ✓ |
| Pull [packages](packages/index.md) | | ✓ | ✓ | ✓ | ✓ |
| Publish [packages](packages/index.md) | | | ✓ | ✓ | ✓ |
| Delete [packages](packages/index.md) | | | | ✓ | ✓ |
| Create/edit/delete a Cleanup policy | | | ✓ | ✓ | ✓ |
| Upload [Design Management](project/issues/design_management.md) files | | | ✓ | ✓ | ✓ |
| Create/edit/delete [Releases](project/releases/index.md)| | | ✓ | ✓ | ✓ |
@ -99,9 +106,12 @@ The following table depicts the various user permission levels in a project.
| Lock merge request threads | | | ✓ | ✓ | ✓ |
| Approve merge requests (*9*) | | | ✓ | ✓ | ✓ |
| Manage/Accept merge requests | | | ✓ | ✓ | ✓ |
| View project statistics | | | ✓ | ✓ | ✓ |
| Create new environments | | | ✓ | ✓ | ✓ |
| Stop environments | | | ✓ | ✓ | ✓ |
| Enable Review Apps | | | ✓ | ✓ | ✓ |
| View Pods logs | | | ✓ | ✓ | ✓ |
| Read Terraform state | | | ✓ | ✓ | ✓ |
| Add tags | | | ✓ | ✓ | ✓ |
| Cancel and retry jobs | | | ✓ | ✓ | ✓ |
| Create or update commit status | | | ✓ (*5*) | ✓ | ✓ |
@ -123,6 +133,7 @@ The following table depicts the various user permission levels in a project.
| Manage Feature Flags **(PREMIUM)** | | | ✓ | ✓ | ✓ |
| Create/edit/delete metrics dashboard annotations | | | ✓ | ✓ | ✓ |
| Run CI/CD pipeline against a protected branch | | | ✓ (*5*) | ✓ | ✓ |
| Delete [packages](packages/index.md) | | | | ✓ | ✓ |
| Request a CVE ID **(FREE ONLY)** | | | | ✓ | ✓ |
| Use environment terminals | | | | ✓ | ✓ |
| Run Web IDE's Interactive Web Terminals **(ULTIMATE ONLY)** | | | | ✓ | ✓ |
@ -133,6 +144,7 @@ The following table depicts the various user permission levels in a project.
| Enable/disable tag protections | | | | ✓ | ✓ |
| Edit project settings | | | | ✓ | ✓ |
| Edit project badges | | | | ✓ | ✓ |
| Export project | | | | ✓ | ✓ |
| Share (invite) projects with groups | | | | ✓ (*8*) | ✓ (*8*)|
| Add deploy keys to project | | | | ✓ | ✓ |
| Configure project hooks | | | | ✓ | ✓ |
@ -144,8 +156,6 @@ The following table depicts the various user permission levels in a project.
| Remove GitLab Pages | | | | ✓ | ✓ |
| Manage clusters | | | | ✓ | ✓ |
| Manage Project Operations | | | | ✓ | ✓ |
| View Pods logs | | | ✓ | ✓ | ✓ |
| Read Terraform state | | | ✓ | ✓ | ✓ |
| Manage Terraform state | | | | ✓ | ✓ |
| Manage license policy **(ULTIMATE)** | | | | ✓ | ✓ |
| Edit comments (posted by any user) | | | | ✓ | ✓ |
@ -160,22 +170,12 @@ The following table depicts the various user permission levels in a project.
| Remove fork relationship | | | | | ✓ |
| Delete project | | | | | ✓ |
| Archive project | | | | | ✓ |
| Export project | | | | ✓ | ✓ |
| Delete issues | | | | | ✓ |
| Delete pipelines | | | | | ✓ |
| Delete merge request | | | | | ✓ |
| Disable notification emails | | | | | ✓ |
| Force push to protected branches (*4*) | | | | | |
| Remove protected branches (*4*) | | | | | |
| View CI\CD analytics | | ✓ | ✓ | ✓ | ✓ |
| View Code Review analytics **(STARTER)** | | ✓ | ✓ | ✓ | ✓ |
| View Insights **(ULTIMATE)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Issue analytics **(PREMIUM)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Merge Request analytics **(STARTER)** | ✓ | ✓ | ✓ | ✓ | ✓ |
| View Repository analytics | | ✓ | ✓ | ✓ | ✓ |
| View Value Stream analytics | ✓ | ✓ | ✓ | ✓ | ✓ |
\* Owner permission is only available at the group or personal namespace level (and for instance admins) and is inherited by its projects.
1. Guest users are able to perform this action on public and internal projects, but not private projects. This doesn't apply to [external users](#external-users) where explicit access must be given even if the project is internal.
1. Guest users can only view the confidential issues they created themselves.
@ -187,6 +187,7 @@ The following table depicts the various user permission levels in a project.
1. When [Share Group Lock](./group/index.md#share-with-group-lock) is enabled the project can't be shared with other groups. It does not affect group with group sharing.
1. For information on eligible approvers for merge requests, see
[Eligible approvers](project/merge_requests/merge_request_approvals.md#eligible-approvers).
1. Owner permission is only available at the group or personal namespace level (and for instance admins) and is inherited by its projects.
## Project features permissions

View file

@ -115,6 +115,17 @@ company and a new feature has been added to the company product.
1. You edit the file right there and click **Submit changes**.
1. A new merge request is automatically created and you assign it to your colleague for review.
## Videos
> - Support for embedding YouTube videos through the WYSIWYG editor [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216642) in GitLab 13.5.
You can embed YouTube videos on the WYSIWYG mode by clicking the video icon (**{live-preview}**).
The following URL/ID formats are supported:
- YouTube watch URL (e.g. `https://www.youtube.com/watch?v=0t1DgySidms`)
- YouTube embed URL (e.g. `https://www.youtube.com/embed/0t1DgySidms`)
- YouTube video ID (e.g. `0t1DgySidms`)
## Limitations
- The Static Site Editor still cannot be quickly added to existing Middleman sites. Follow this [epic](https://gitlab.com/groups/gitlab-org/-/epics/2784) for updates.

View file

@ -119,7 +119,7 @@ module Banzai
# Yields the link's URL and inner HTML whenever the node is a valid <a> tag.
def yield_valid_link(node)
link = unescape_link(node.attr('href').to_s)
link = CGI.unescape(node.attr('href').to_s)
inner_html = node.inner_html
return unless link.force_encoding('UTF-8').valid_encoding?
@ -127,10 +127,6 @@ module Banzai
yield link, inner_html
end
def unescape_link(href)
CGI.unescape(href)
end
def replace_text_when_pattern_matches(node, index, pattern)
return unless node.text =~ pattern

View file

@ -1,22 +0,0 @@
# frozen_string_literal: true
module Banzai
module Filter
# The actual filter is implemented in the EE mixin
class VulnerabilityReferenceFilter < IssuableReferenceFilter
self.reference_type = :vulnerability
def self.object_class
Vulnerability
end
private
def project
context[:project]
end
end
end
end
Banzai::Filter::VulnerabilityReferenceFilter.prepend_if_ee('EE::Banzai::Filter::VulnerabilityReferenceFilter')

View file

@ -1,16 +0,0 @@
# frozen_string_literal: true
module Banzai
module ReferenceParser
# The actual parser is implemented in the EE mixin
class VulnerabilityParser < IssuableParser
self.reference_type = :vulnerability
def records_for_nodes(_nodes)
{}
end
end
end
end
Banzai::ReferenceParser::VulnerabilityParser.prepend_if_ee('::EE::Banzai::ReferenceParser::VulnerabilityParser')

View file

@ -54,6 +54,10 @@ module Gitlab
root.variables_value
end
def variables_with_data
root.variables_entry.value_with_data
end
def stages
root.stages_value
end

View file

@ -10,16 +10,32 @@ module Gitlab
class Variables < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
ALLOWED_VALUE_DATA = %i[value description].freeze
validations do
validates :config, variables: true
validates :config, variables: { allowed_value_data: ALLOWED_VALUE_DATA }
end
def value
Hash[@config.map { |key, value| [key.to_s, expand_value(value)[:value]] }]
end
def self.default(**)
{}
end
def value
Hash[@config.map { |key, value| [key.to_s, value.to_s] }]
def value_with_data
Hash[@config.map { |key, value| [key.to_s, expand_value(value)] }]
end
private
def expand_value(value)
if value.is_a?(Hash)
{ value: value[:value].to_s, description: value[:description] }
else
{ value: value.to_s, description: nil }
end
end
end
end

View file

@ -99,6 +99,10 @@ module Gitlab
@ci_config&.to_hash&.to_yaml
end
def variables_with_data
@ci_config.variables_with_data
end
private
def variables

View file

@ -50,6 +50,12 @@ module Gitlab
variables.values.flatten(1).all?(&method(:validate_alphanumeric))
end
def validate_string_or_hash_value_variables(variables, allowed_value_data)
variables.is_a?(Hash) &&
variables.keys.all?(&method(:validate_alphanumeric)) &&
variables.values.all? { |value| validate_string_or_hash_value_variable(value, allowed_value_data) }
end
def validate_alphanumeric(value)
validate_string(value) || validate_integer(value)
end
@ -62,6 +68,14 @@ module Gitlab
value.is_a?(String) || value.is_a?(Symbol)
end
def validate_string_or_hash_value_variable(value, allowed_value_data)
if value.is_a?(Hash)
(value.keys - allowed_value_data).empty? && value.values.all?(&method(:validate_alphanumeric))
else
validate_alphanumeric(value)
end
end
def validate_regexp(value)
Gitlab::UntrustedRegexp::RubySyntax.valid?(value)
end

View file

@ -274,6 +274,8 @@ module Gitlab
def validate_each(record, attribute, value)
if options[:array_values]
validate_key_array_values(record, attribute, value)
elsif options[:allowed_value_data]
validate_key_hash_values(record, attribute, value, options[:allowed_value_data])
else
validate_key_values(record, attribute, value)
end
@ -290,6 +292,12 @@ module Gitlab
record.errors.add(attribute, 'should be a hash of key value pairs, value can be an array')
end
end
def validate_key_hash_values(record, attribute, value, allowed_value_data)
unless validate_string_or_hash_value_variables(value, allowed_value_data)
record.errors.add(attribute, 'should be a hash of key value pairs, value can be a hash')
end
end
end
class ExpressionValidator < ActiveModel::EachValidator

View file

@ -4,7 +4,7 @@ module Gitlab
# Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor
REFERABLES = %i(user issue label milestone mentioned_user mentioned_group mentioned_project
merge_request snippet commit commit_range directly_addressed_user epic iteration vulnerability).freeze
merge_request snippet commit commit_range directly_addressed_user epic iteration).freeze
attr_accessor :project, :current_user, :author
# This counter is increased by a number of references filtered out by
# banzai reference exctractor. Note that this counter is stateful and
@ -38,7 +38,7 @@ module Gitlab
end
REFERABLES.each do |type|
define_method(type.to_s.pluralize) do
define_method("#{type}s") do
@references[type] ||= references(type)
end
end

View file

@ -1051,6 +1051,9 @@ msgstr ""
msgid "0 for unlimited, only effective with remote storage enabled."
msgstr ""
msgid "0t1DgySidms"
msgstr ""
msgid "1 %{type} addition"
msgid_plural "%{count} %{type} additions"
msgstr[0] ""
@ -13549,6 +13552,9 @@ msgstr ""
msgid "If enabled, access to projects will be validated on an external service using their classification label."
msgstr ""
msgid "If the YouTube URL is https://www.youtube.com/watch?v=0t1DgySidms then the video ID is %{id}"
msgstr ""
msgid "If the number of active users exceeds the user limit, you will be charged for the number of %{users_over_license_link} at your next license reconciliation."
msgstr ""
@ -14021,6 +14027,9 @@ msgstr ""
msgid "Insert a quote"
msgstr ""
msgid "Insert a video"
msgstr ""
msgid "Insert an image"
msgstr ""
@ -14036,6 +14045,9 @@ msgstr ""
msgid "Insert suggestion"
msgstr ""
msgid "Insert video"
msgstr ""
msgid "Insights"
msgstr ""
@ -19546,6 +19558,9 @@ msgstr ""
msgid "Please provide a valid URL"
msgstr ""
msgid "Please provide a valid YouTube URL or ID"
msgstr ""
msgid "Please provide a valid email address."
msgstr ""
@ -30300,6 +30315,9 @@ msgstr ""
msgid "YouTube"
msgstr ""
msgid "YouTube URL or ID"
msgstr ""
msgid "Your %{host} account was signed in to from a new location"
msgstr ""

View file

@ -1148,4 +1148,84 @@ RSpec.describe Projects::PipelinesController do
}
end
end
describe 'GET config_variables.json' do
let(:result) { YAML.dump(ci_config) }
before do
stub_gitlab_ci_yml_for_sha(sha, result)
end
context 'when sending a valid sha' do
let(:sha) { 'master' }
let(:ci_config) do
{
variables: {
KEY1: { value: 'val 1', description: 'description 1' }
},
test: {
stage: 'test',
script: 'echo'
}
}
end
it 'returns variable list' do
get_config_variables
expect(response).to have_gitlab_http_status(:ok)
expect(json_response['KEY1']).to eq({ 'value' => 'val 1', 'description' => 'description 1' })
end
end
context 'when sending an invalid sha' do
let(:sha) { 'invalid-sha' }
let(:ci_config) { nil }
it 'returns empty json' do
get_config_variables
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq({})
end
end
context 'when sending an invalid config' do
let(:sha) { 'master' }
let(:ci_config) do
{
variables: {
KEY1: { value: 'val 1', description: 'description 1' }
},
test: {
stage: 'invalid',
script: 'echo'
}
}
end
it 'returns empty result' do
get_config_variables
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to eq({})
end
end
private
def stub_gitlab_ci_yml_for_sha(sha, result)
allow_any_instance_of(Repository)
.to receive(:gitlab_ci_yml_for)
.with(sha, '.gitlab-ci.yml')
.and_return(result)
end
def get_config_variables
get :config_variables, params: { namespace_id: project.namespace,
project_id: project,
sha: sha },
format: :json
end
end
end

View file

@ -573,4 +573,19 @@ RSpec.describe Projects::ProjectMembersController do
end
end
end
describe 'POST resend_invite' do
let(:member) { create(:project_member, project: project) }
before do
project.add_maintainer(user)
sign_in(user)
end
it 'is successful' do
post :resend_invite, params: { namespace_id: project.namespace, project_id: project, id: member }
expect(response).to have_gitlab_http_status(:found)
end
end
end

View file

@ -52,20 +52,29 @@ RSpec.describe 'Dashboard Merge Requests' do
end
context 'merge requests exist' do
let_it_be(:author_user) { create(:user) }
let(:label) { create(:label) }
let!(:assigned_merge_request) do
create(:merge_request,
assignees: [current_user],
source_project: project,
author: create(:user))
author: author_user)
end
let!(:review_requested_merge_request) do
create(:merge_request,
reviewers: [current_user],
source_branch: 'review',
source_project: project,
author: author_user)
end
let!(:assigned_merge_request_from_fork) do
create(:merge_request,
source_branch: 'markdown', assignees: [current_user],
target_project: public_project, source_project: forked_project,
author: create(:user))
author: author_user)
end
let!(:authored_merge_request) do
@ -94,7 +103,7 @@ RSpec.describe 'Dashboard Merge Requests' do
create(:merge_request,
source_branch: 'fix',
source_project: project,
author: create(:user))
author: author_user)
end
before do
@ -111,6 +120,10 @@ RSpec.describe 'Dashboard Merge Requests' do
expect(page).not_to have_content(labeled_merge_request.title)
end
it 'shows review requested merge requests' do
expect(page).to have_content(review_requested_merge_request.title)
end
it 'shows authored merge requests', :js do
reset_filters
input_filtered_search("author:=#{current_user.to_reference}")

View file

@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe 'User views Release', :js do
let(:project) { create(:project, :repository) }
let(:user) { create(:user) }
let(:graphql_feature_flag) { true }
let(:release) do
create(:release,
@ -14,6 +15,8 @@ RSpec.describe 'User views Release', :js do
end
before do
stub_feature_flags(graphql_individual_release_page: graphql_feature_flag)
project.add_developer(user)
sign_in(user)
@ -23,23 +26,35 @@ RSpec.describe 'User views Release', :js do
it_behaves_like 'page meta description', 'Lorem ipsum dolor sit amet'
it 'renders the breadcrumbs' do
within('.breadcrumbs') do
expect(page).to have_content("#{project.creator.name} #{project.name} Releases #{release.name}")
shared_examples 'release page' do
it 'renders the breadcrumbs' do
within('.breadcrumbs') do
expect(page).to have_content("#{project.creator.name} #{project.name} Releases #{release.name}")
expect(page).to have_link(project.creator.name, href: user_path(project.creator))
expect(page).to have_link(project.name, href: project_path(project))
expect(page).to have_link('Releases', href: project_releases_path(project))
expect(page).to have_link(release.name, href: project_release_path(project, release))
expect(page).to have_link(project.creator.name, href: user_path(project.creator))
expect(page).to have_link(project.name, href: project_path(project))
expect(page).to have_link('Releases', href: project_releases_path(project))
expect(page).to have_link(release.name, href: project_release_path(project, release))
end
end
it 'renders the release details' do
within('.release-block') do
expect(page).to have_content(release.name)
expect(page).to have_content(release.tag)
expect(page).to have_content(release.commit.short_id)
expect(page).to have_content('Lorem ipsum dolor sit amet')
end
end
end
it 'renders the release details' do
within('.release-block') do
expect(page).to have_content(release.name)
expect(page).to have_content(release.tag)
expect(page).to have_content(release.commit.short_id)
expect(page).to have_content('Lorem ipsum dolor sit amet')
end
describe 'when the graphql_individual_release_page feature flag is enabled' do
it_behaves_like 'release page'
end
describe 'when the graphql_individual_release_page feature flag is disabled' do
let(:graphql_feature_flag) { false }
it_behaves_like 'release page'
end
end

View file

@ -333,6 +333,8 @@ RSpec.describe MergeRequestsFinder do
end
context 'assignee filtering' do
let_it_be(:user3) { create(:user) }
let(:issuables) { described_class.new(user, params).execute }
it_behaves_like 'assignee ID filter' do
@ -351,7 +353,6 @@ RSpec.describe MergeRequestsFinder do
merge_request3.assignees = [user2, user3]
end
let_it_be(:user3) { create(:user) }
let(:params) { { assignee_username: [user2.username, user3.username] } }
let(:expected_issuables) { [merge_request3] }
end
@ -366,7 +367,6 @@ RSpec.describe MergeRequestsFinder do
end
it_behaves_like 'no assignee filter' do
let_it_be(:user3) { create(:user) }
let(:expected_issuables) { [merge_request4, merge_request5] }
end
@ -374,30 +374,54 @@ RSpec.describe MergeRequestsFinder do
let(:expected_issuables) { [merge_request1, merge_request2, merge_request3] }
end
context 'filtering by group milestone' do
let(:group_milestone) { create(:milestone, group: group) }
context 'with just reviewers' do
it_behaves_like 'assignee username filter' do
before do
merge_request4.reviewers = [user3]
merge_request4.assignees = []
end
before do
merge_request1.update!(milestone: group_milestone)
merge_request2.update!(milestone: group_milestone)
let(:params) { { assignee_username: [user3.username] } }
let(:expected_issuables) { [merge_request4] }
end
end
it 'returns merge requests assigned to that group milestone' do
params = { milestone_title: group_milestone.title }
context 'with an additional reviewer' do
it_behaves_like 'assignee username filter' do
before do
merge_request3.assignees = [user3]
merge_request4.reviewers = [user3]
end
let(:params) { { assignee_username: [user3.username] } }
let(:expected_issuables) { [merge_request3, merge_request4] }
end
end
end
context 'filtering by group milestone' do
let(:group_milestone) { create(:milestone, group: group) }
before do
merge_request1.update!(milestone: group_milestone)
merge_request2.update!(milestone: group_milestone)
end
it 'returns merge requests assigned to that group milestone' do
params = { milestone_title: group_milestone.title }
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1, merge_request2)
end
context 'using NOT' do
let(:params) { { not: { milestone_title: group_milestone.title } } }
it 'returns MRs not assigned to that group milestone' do
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request1, merge_request2)
end
context 'using NOT' do
let(:params) { { not: { milestone_title: group_milestone.title } } }
it 'returns MRs not assigned to that group milestone' do
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request3, merge_request4, merge_request5)
end
expect(merge_requests).to contain_exactly(merge_request3, merge_request4, merge_request5)
end
end
end

View file

@ -8,228 +8,15 @@ import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete
import { TEST_HOST } from 'helpers/test_constants';
import { getJSONFixture } from 'helpers/fixtures';
import waitForPromises from 'jest/helpers/wait_for_promises';
import MockAdapter from 'axios-mock-adapter';
import AjaxCache from '~/lib/utils/ajax_cache';
import axios from '~/lib/utils/axios_utils';
const labelsFixture = getJSONFixture('autocomplete_sources/labels.json');
describe('GfmAutoComplete', () => {
const fetchDataMock = { fetchData: jest.fn() };
let gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call(fetchDataMock);
const gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
fetchData: () => {},
});
let atwhoInstance;
let sorterValue;
let filterValue;
describe('.typesWithBackendFiltering', () => {
it('should contain vulnerabilities', () => {
expect(GfmAutoComplete.typesWithBackendFiltering).toContain('vulnerabilities');
});
});
describe('DefaultOptions.filter', () => {
let items;
beforeEach(() => {
jest.spyOn(fetchDataMock, 'fetchData');
jest.spyOn($.fn.atwho.default.callbacks, 'filter').mockImplementation(() => {});
});
describe('assets loading', () => {
beforeEach(() => {
atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' };
items = ['loading'];
filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, '', items);
});
it('should call the fetchData function without query', () => {
expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+');
});
it('should not call the default atwho filter', () => {
expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
});
it('should return the passed unfiltered items', () => {
expect(filterValue).toEqual(items);
});
});
describe('backend filtering', () => {
beforeEach(() => {
atwhoInstance = { setting: {}, $inputor: 'inputor', at: '+' };
items = [];
});
describe('when previous query is different from current one', () => {
beforeEach(() => {
gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
previousQuery: 'oldquery',
...fetchDataMock,
});
filterValue = gfmAutoCompleteCallbacks.filter.call(atwhoInstance, 'newquery', items);
});
it('should call the fetchData function with query', () => {
expect(fetchDataMock.fetchData).toHaveBeenCalledWith('inputor', '+', 'newquery');
});
it('should not call the default atwho filter', () => {
expect($.fn.atwho.default.callbacks.filter).not.toHaveBeenCalled();
});
it('should return the passed unfiltered items', () => {
expect(filterValue).toEqual(items);
});
});
describe('when previous query is not different from current one', () => {
beforeEach(() => {
gfmAutoCompleteCallbacks = GfmAutoComplete.prototype.getDefaultCallbacks.call({
previousQuery: 'oldquery',
...fetchDataMock,
});
filterValue = gfmAutoCompleteCallbacks.filter.call(
atwhoInstance,
'oldquery',
items,
'searchKey',
);
});
it('should not call the fetchData function', () => {
expect(fetchDataMock.fetchData).not.toHaveBeenCalled();
});
it('should call the default atwho filter', () => {
expect($.fn.atwho.default.callbacks.filter).toHaveBeenCalledWith(
'oldquery',
items,
'searchKey',
);
});
});
});
});
describe('fetchData', () => {
const { fetchData } = GfmAutoComplete.prototype;
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
jest.spyOn(axios, 'get');
jest.spyOn(AjaxCache, 'retrieve');
});
afterEach(() => {
mock.restore();
});
describe('already loading data', () => {
beforeEach(() => {
const context = {
isLoadingData: { '+': true },
dataSources: {},
cachedData: {},
};
fetchData.call(context, {}, '+', '');
});
it('should not call either axios nor AjaxCache', () => {
expect(axios.get).not.toHaveBeenCalled();
expect(AjaxCache.retrieve).not.toHaveBeenCalled();
});
});
describe('backend filtering', () => {
describe('data is not in cache', () => {
let context;
beforeEach(() => {
context = {
isLoadingData: { '+': false },
dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
cachedData: {},
};
});
it('should call axios with query', () => {
fetchData.call(context, {}, '+', 'query');
expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
params: { search: 'query' },
});
});
it.each([200, 500])('should set the loading state', async responseStatus => {
mock.onGet('vulnerabilities_autocomplete_url').replyOnce(responseStatus);
fetchData.call(context, {}, '+', 'query');
expect(context.isLoadingData['+']).toBe(true);
await waitForPromises();
expect(context.isLoadingData['+']).toBe(false);
});
});
describe('data is in cache', () => {
beforeEach(() => {
const context = {
isLoadingData: { '+': false },
dataSources: { vulnerabilities: 'vulnerabilities_autocomplete_url' },
cachedData: { '+': [{}] },
};
fetchData.call(context, {}, '+', 'query');
});
it('should anyway call axios with query ignoring cache', () => {
expect(axios.get).toHaveBeenCalledWith('vulnerabilities_autocomplete_url', {
params: { search: 'query' },
});
});
});
});
describe('frontend filtering', () => {
describe('data is not in cache', () => {
beforeEach(() => {
const context = {
isLoadingData: { '#': false },
dataSources: { issues: 'issues_autocomplete_url' },
cachedData: {},
};
fetchData.call(context, {}, '#', 'query');
});
it('should call AjaxCache', () => {
expect(AjaxCache.retrieve).toHaveBeenCalledWith('issues_autocomplete_url', true);
});
});
describe('data is in cache', () => {
beforeEach(() => {
const context = {
isLoadingData: { '#': false },
dataSources: { issues: 'issues_autocomplete_url' },
cachedData: { '#': [{}] },
loadData: () => {},
};
fetchData.call(context, {}, '#', 'query');
});
it('should not call AjaxCache', () => {
expect(AjaxCache.retrieve).not.toHaveBeenCalled();
});
});
});
});
describe('DefaultOptions.sorter', () => {
describe('assets loading', () => {
@ -333,7 +120,7 @@ describe('GfmAutoComplete', () => {
const defaultMatcher = (context, flag, subtext) =>
gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext);
const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$', '+'];
const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$'];
const otherFlags = ['/', ':'];
const flags = flagsUseDefaultMatcher.concat(otherFlags);
@ -367,6 +154,7 @@ describe('GfmAutoComplete', () => {
'я',
'.',
"'",
'+',
'-',
'_',
];

View file

@ -10,6 +10,8 @@ import {
TH_CREATED_AT_TEST_ID,
TH_SEVERITY_TEST_ID,
TH_PUBLISHED_TEST_ID,
trackIncidentCreateNewOptions,
trackIncidentListViewsOptions,
} from '~/incidents/constants';
import mockIncidents from '../mocks/incidents.json';
@ -291,4 +293,25 @@ describe('Incidents List', () => {
expect(columnHeader().attributes('aria-sort')).toBe(nextSort);
});
});
describe('Snowplow tracking', () => {
beforeEach(() => {
mountComponent({
data: { incidents: { list: mockIncidents }, incidentsCount: {} },
loading: false,
});
});
it('should track incident list views', () => {
const { category, action } = trackIncidentListViewsOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
it('should track incident creation events', async () => {
findCreateIncidentBtn().vm.$emit('click');
await wrapper.vm.$nextTick();
const { category, action } = trackIncidentCreateNewOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
});

View file

@ -6,6 +6,8 @@ import { descriptionProps } from '../../mock_data';
import DescriptionComponent from '~/issue_show/components/description.vue';
import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue';
import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
import Tracking from '~/tracking';
import { trackIncidentDetailsViewsOptions } from '~/incidents/constants';
const mockAlert = {
__typename: 'AlertManagementAlert',
@ -97,4 +99,16 @@ describe('Incident Tabs component', () => {
expect(findDescriptionComponent().props()).toMatchObject(descriptionProps);
});
});
describe('Snowplow tracking', () => {
beforeEach(() => {
jest.spyOn(Tracking, 'event');
mountComponent();
});
it('should track incident details views', () => {
const { category, action } = trackIncidentDetailsViewsOptions;
expect(Tracking.event).toHaveBeenCalledWith(category, action);
});
});
});

View file

@ -34,6 +34,12 @@ describe('Release detail actions', () => {
isExistingRelease: true,
};
const rootState = {
featureFlags: {
graphqlIndividualReleasePage: false,
},
};
state = {
...createState({
projectId: '18',
@ -44,6 +50,7 @@ describe('Release detail actions', () => {
updateReleaseApiDocsPath: 'path/to/api/docs',
}),
...getters,
...rootState,
...updates,
};
};
@ -154,7 +161,7 @@ describe('Release detail actions', () => {
});
it(`shows a flash message`, () => {
return actions.fetchRelease({ commit: jest.fn(), state }).then(() => {
return actions.fetchRelease({ commit: jest.fn(), state, rootState: state }).then(() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
'Something went wrong while getting the release details',

View file

@ -4,6 +4,7 @@ import {
removeCustomEventListener,
registerHTMLToMarkdownRenderer,
addImage,
insertVideo,
getMarkdown,
getEditorOptions,
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
@ -19,11 +20,21 @@ describe('Editor Service', () => {
let mockInstance;
let event;
let handler;
const parseHtml = str => {
const wrapper = document.createElement('div');
wrapper.innerHTML = str;
return wrapper.firstChild;
};
beforeEach(() => {
mockInstance = {
eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
editor: { exec: jest.fn() },
editor: {
exec: jest.fn(),
isWysiwygMode: jest.fn(),
getSquire: jest.fn(),
insertText: jest.fn(),
},
invoke: jest.fn(),
toMarkOptions: {
renderer: {
@ -89,6 +100,38 @@ describe('Editor Service', () => {
});
});
describe('insertVideo', () => {
const mockUrl = 'some/url';
const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`;
const mockInsertElement = jest.fn();
beforeEach(() =>
mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }),
);
describe('WYSIWYG mode', () => {
it('calls the insertElement method on the squire instance with an iFrame element', () => {
mockInstance.editor.isWysiwygMode.mockReturnValue(true);
insertVideo(mockInstance, mockUrl);
expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith(
parseHtml(htmlString),
);
});
});
describe('Markdown mode', () => {
it('calls the insertText method on the editor instance with the iFrame element HTML', () => {
mockInstance.editor.isWysiwygMode.mockReturnValue(false);
insertVideo(mockInstance, mockUrl);
expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString);
});
});
});
describe('getMarkdown', () => {
it('calls the invoke method on the instance', () => {
getMarkdown(mockInstance);

View file

@ -0,0 +1,44 @@
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
describe('Insert Video Modal', () => {
let wrapper;
const findModal = () => wrapper.find(GlModal);
const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
const triggerInsertVideo = url => {
const preventDefault = jest.fn();
findUrlInput().vm.$emit('input', url);
findModal().vm.$emit('primary', { preventDefault });
};
beforeEach(() => {
wrapper = shallowMount(InsertVideoModal);
});
afterEach(() => wrapper.destroy());
describe('when content is loaded', () => {
it('renders a modal component', () => {
expect(findModal().exists()).toBe(true);
});
it('renders an input to add a URL', () => {
expect(findUrlInput().exists()).toBe(true);
});
});
describe('insert video', () => {
it.each`
url | emitted
${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]}
${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]}
${'::youtube.com/invalid/url'} | ${undefined}
`('formats the url correctly', ({ url, emitted }) => {
triggerInsertVideo(url);
expect(wrapper.emitted('insertVideo')).toEqual(emitted);
});
});
});

View file

@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
import {
EDITOR_TYPES,
EDITOR_HEIGHT,
@ -12,6 +13,7 @@ import {
addCustomEventListener,
removeCustomEventListener,
addImage,
insertVideo,
registerHTMLToMarkdownRenderer,
getEditorOptions,
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
@ -21,6 +23,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service',
addCustomEventListener: jest.fn(),
removeCustomEventListener: jest.fn(),
addImage: jest.fn(),
insertVideo: jest.fn(),
registerHTMLToMarkdownRenderer: jest.fn(),
getEditorOptions: jest.fn(),
}));
@ -32,6 +35,7 @@ describe('Rich Content Editor', () => {
const imageRoot = 'path/to/root/';
const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
const findInsertVideoModal = () => wrapper.find(InsertVideoModal);
const buildWrapper = () => {
wrapper = shallowMount(RichContentEditor, {
@ -122,6 +126,14 @@ describe('Rich Content Editor', () => {
);
});
it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
expect(addCustomEventListener).toHaveBeenCalledWith(
wrapper.vm.editorApi,
CUSTOM_EVENTS.openInsertVideoModal,
wrapper.vm.onOpenInsertVideoModal,
);
});
it('registers HTML to markdown renderer', () => {
expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi);
});
@ -141,6 +153,16 @@ describe('Rich Content Editor', () => {
wrapper.vm.onOpenAddImageModal,
);
});
it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
wrapper.vm.$destroy();
expect(removeCustomEventListener).toHaveBeenCalledWith(
wrapper.vm.editorApi,
CUSTOM_EVENTS.openInsertVideoModal,
wrapper.vm.onOpenInsertVideoModal,
);
});
});
describe('add image modal', () => {
@ -161,4 +183,23 @@ describe('Rich Content Editor', () => {
expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage);
});
});
describe('insert video modal', () => {
beforeEach(() => {
buildWrapper();
});
it('renders an insertVideoModal component', () => {
expect(findInsertVideoModal().exists()).toBe(true);
});
it('calls the onInsertVideo method when the insertVideo event is emitted', () => {
const mockUrl = 'https://www.youtube.com/embed/someId';
const mockInstance = { exec: jest.fn() };
wrapper.vm.$refs.editor = mockInstance;
findInsertVideoModal().vm.$emit('insertVideo', mockUrl);
expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl);
});
});
});

View file

@ -64,6 +64,7 @@ RSpec.describe ReleasesHelper do
describe '#data_for_edit_release_page' do
it 'has the needed data to display the "edit release" page' do
keys = %i(project_id
project_path
tag_name
markdown_preview_path
markdown_docs_path
@ -80,6 +81,7 @@ RSpec.describe ReleasesHelper do
describe '#data_for_new_release_page' do
it 'has the needed data to display the "new release" page' do
keys = %i(project_id
project_path
releases_page_path
markdown_preview_path
markdown_docs_path
@ -92,5 +94,15 @@ RSpec.describe ReleasesHelper do
expect(helper.data_for_new_release_page.keys).to match_array(keys)
end
end
describe '#data_for_show_page' do
it 'has the needed data to display the individual "release" page' do
keys = %i(project_id
project_path
tag_name)
expect(helper.data_for_show_page.keys).to match_array(keys)
end
end
end
end

View file

@ -3,56 +3,109 @@
require 'spec_helper'
RSpec.describe Gitlab::Ci::Config::Entry::Variables do
let(:entry) { described_class.new(config) }
subject { described_class.new(config) }
describe 'validations' do
context 'when entry config value is correct' do
let(:config) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
describe '#value' do
it 'returns hash with key value strings' do
expect(entry.value).to eq config
end
context 'with numeric keys and values in the config' do
let(:config) { { 10 => 20 } }
it 'converts numeric key and numeric value into strings' do
expect(entry.value).to eq('10' => '20')
end
end
end
describe '#errors' do
it 'does not append errors' do
expect(entry.errors).to be_empty
end
end
describe '#valid?' do
it 'is valid' do
expect(entry).to be_valid
end
shared_examples 'valid config' do
describe '#value' do
it 'returns hash with key value strings' do
expect(subject.value).to eq result
end
end
context 'when entry value is not correct' do
let(:config) { [:VAR, 'test'] }
describe '#errors' do
it 'saves errors' do
expect(entry.errors)
.to include /should be a hash of key value pairs/
end
describe '#errors' do
it 'does not append errors' do
expect(subject.errors).to be_empty
end
end
describe '#valid?' do
it 'is not valid' do
expect(entry).not_to be_valid
end
describe '#valid?' do
it 'is valid' do
expect(subject).to be_valid
end
end
end
shared_examples 'invalid config' do
describe '#valid?' do
it 'is not valid' do
expect(subject).not_to be_valid
end
end
describe '#errors' do
it 'saves errors' do
expect(subject.errors)
.to include /should be a hash of key value pairs/
end
end
end
context 'when entry config value has key-value pairs' do
let(:config) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
let(:result) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'valid config'
end
context 'with numeric keys and values in the config' do
let(:config) { { 10 => 20 } }
let(:result) do
{ '10' => '20' }
end
it_behaves_like 'valid config'
end
context 'when entry config value has key-value pair and hash' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', description: 'variable 1' },
'VARIABLE_2' => 'value 2' }
end
let(:result) do
{ 'VARIABLE_1' => 'value 1', 'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'valid config'
end
context 'when entry value is an array' do
let(:config) { [:VAR, 'test'] }
it_behaves_like 'invalid config'
end
context 'when entry value has hash with other key-pairs' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', hello: 'variable 1' },
'VARIABLE_2' => 'value 2' }
end
it_behaves_like 'invalid config'
end
context 'when entry config value has hash with nil description' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1', description: nil } }
end
it_behaves_like 'invalid config'
end
context 'when entry config value has hash without description' do
let(:config) do
{ 'VARIABLE_1' => { value: 'value 1' } }
end
let(:result) do
{ 'VARIABLE_1' => 'value 1' }
end
it_behaves_like 'valid config'
end
end

View file

@ -2465,13 +2465,13 @@ module Gitlab
context 'returns errors if variables is not a map' do
let(:config) { YAML.dump({ variables: "test", rspec: { script: "test" } }) }
it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs'
it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash'
end
context 'returns errors if variables is not a map of key-value strings' do
let(:config) { YAML.dump({ variables: { test: false }, rspec: { script: "test" } }) }
it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs'
it_behaves_like 'returns errors', 'variables config should be a hash of key value pairs, value can be a hash'
end
context 'returns errors if job when is not on_success, on_failure or always' do

View file

@ -296,7 +296,7 @@ RSpec.describe Gitlab::ReferenceExtractor do
end
it 'returns all supported prefixes' do
expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & + *iteration:))
expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ & *iteration:))
end
it 'does not allow one prefix for multiple referables if not allowed specificly' do

View file

@ -25,7 +25,7 @@ RSpec.describe Clusters::Applications::Fluentd do
it 'is initialized with fluentd arguments' do
expect(subject.name).to eq('fluentd')
expect(subject.chart).to eq('stable/fluentd')
expect(subject.chart).to eq('fluentd/fluentd')
expect(subject.version).to eq('2.4.0')
expect(subject).to be_rbac
end

View file

@ -135,7 +135,7 @@ RSpec.describe Clusters::Applications::Ingress do
it 'is initialized with ingress arguments' do
expect(subject.name).to eq('ingress')
expect(subject.chart).to eq('stable/nginx-ingress')
expect(subject.chart).to eq('ingress/nginx-ingress')
expect(subject.version).to eq('1.40.2')
expect(subject).to be_rbac
expect(subject.files).to eq(ingress.files)

View file

@ -152,7 +152,7 @@ RSpec.describe Clusters::Applications::Prometheus do
it 'is initialized with 3 arguments' do
expect(subject.name).to eq('prometheus')
expect(subject.chart).to eq('stable/prometheus')
expect(subject.chart).to eq('prometheus/prometheus')
expect(subject.version).to eq('10.4.1')
expect(subject).to be_rbac
expect(subject.files).to eq(prometheus.files)
@ -240,7 +240,7 @@ RSpec.describe Clusters::Applications::Prometheus do
it 'is initialized with 3 arguments' do
expect(patch_command.name).to eq('prometheus')
expect(patch_command.chart).to eq('stable/prometheus')
expect(patch_command.chart).to eq('prometheus/prometheus')
expect(patch_command.version).to eq('10.4.1')
expect(patch_command.files).to eq(prometheus.files)
end

View file

@ -5509,12 +5509,13 @@ RSpec.describe Project do
describe '#find_or_initialize_services' do
it 'returns only enabled services' do
allow(Service).to receive(:available_services_names).and_return(%w[prometheus pushover teamcity])
allow(Service).to receive(:project_specific_services_names).and_return(%w[asana])
allow(subject).to receive(:disabled_services).and_return(%w[prometheus])
services = subject.find_or_initialize_services
expect(services.count).to eq(2)
expect(services.map(&:title)).to eq(['JetBrains TeamCity CI', 'Pushover'])
expect(services.count).to eq(3)
expect(services.map(&:title)).to eq(['Asana', 'JetBrains TeamCity CI', 'Pushover'])
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Ci::ListConfigVariablesService do
let_it_be(:project) { create(:project, :repository) }
let(:service) { described_class.new(project) }
let(:result) { YAML.dump(ci_config) }
subject { service.execute(sha) }
before do
stub_gitlab_ci_yml_for_sha(sha, result)
end
context 'when sending a valid sha' do
let(:sha) { 'master' }
let(:ci_config) do
{
variables: {
KEY1: { value: 'val 1', description: 'description 1' },
KEY2: { value: 'val 2', description: '' },
KEY3: { value: 'val 3' },
KEY4: 'val 4'
},
test: {
stage: 'test',
script: 'echo'
}
}
end
it 'returns variable list' do
expect(subject['KEY1']).to eq({ value: 'val 1', description: 'description 1' })
expect(subject['KEY2']).to eq({ value: 'val 2', description: '' })
expect(subject['KEY3']).to eq({ value: 'val 3', description: nil })
expect(subject['KEY4']).to eq({ value: 'val 4', description: nil })
end
end
context 'when sending an invalid sha' do
let(:sha) { 'invalid-sha' }
let(:ci_config) { nil }
it 'returns empty json' do
expect(subject).to eq({})
end
end
context 'when sending an invalid config' do
let(:sha) { 'master' }
let(:ci_config) do
{
variables: {
KEY1: { value: 'val 1', description: 'description 1' }
},
test: {
stage: 'invalid',
script: 'echo'
}
}
end
it 'returns empty result' do
expect(subject).to eq({})
end
end
private
def stub_gitlab_ci_yml_for_sha(sha, result)
allow_any_instance_of(Repository)
.to receive(:gitlab_ci_yml_for)
.with(sha, '.gitlab-ci.yml')
.and_return(result)
end
end