Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
232655bf32
commit
82fa8a3d1e
|
|
@ -10,6 +10,11 @@ import {
|
|||
} from '@gitlab/ui';
|
||||
|
||||
import httpStatusCodes from '~/lib/utils/http_status';
|
||||
|
||||
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
|
||||
import projectQuery from '../queries/project_boards.query.graphql';
|
||||
import groupQuery from '../queries/group_boards.query.graphql';
|
||||
|
||||
import boardsStore from '../stores/boards_store';
|
||||
import BoardForm from './board_form.vue';
|
||||
|
||||
|
|
@ -88,8 +93,9 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
hasScrollFade: false,
|
||||
loadingBoards: 0,
|
||||
loadingRecentBoards: false,
|
||||
scrollFadeInitialized: false,
|
||||
boards: [],
|
||||
recentBoards: [],
|
||||
|
|
@ -102,6 +108,12 @@ export default {
|
|||
};
|
||||
},
|
||||
computed: {
|
||||
parentType() {
|
||||
return this.groupId ? 'group' : 'project';
|
||||
},
|
||||
loading() {
|
||||
return this.loadingRecentBoards && this.loadingBoards;
|
||||
},
|
||||
currentPage() {
|
||||
return this.state.currentPage;
|
||||
},
|
||||
|
|
@ -147,49 +159,71 @@ export default {
|
|||
return;
|
||||
}
|
||||
|
||||
const recentBoardsPromise = new Promise((resolve, reject) =>
|
||||
boardsStore
|
||||
.recentBoards()
|
||||
.then(resolve)
|
||||
.catch(err => {
|
||||
/**
|
||||
* If user is unauthorized we'd still want to resolve the
|
||||
* request to display all boards.
|
||||
*/
|
||||
if (err.response.status === httpStatusCodes.UNAUTHORIZED) {
|
||||
resolve({ data: [] }); // recent boards are empty
|
||||
return;
|
||||
}
|
||||
reject(err);
|
||||
}),
|
||||
);
|
||||
this.$apollo.addSmartQuery('boards', {
|
||||
variables() {
|
||||
return { fullPath: this.state.endpoints.fullPath };
|
||||
},
|
||||
query() {
|
||||
return this.groupId ? groupQuery : projectQuery;
|
||||
},
|
||||
loadingKey: 'loadingBoards',
|
||||
update(data) {
|
||||
if (!data?.[this.parentType]) {
|
||||
return [];
|
||||
}
|
||||
return data[this.parentType].boards.edges.map(({ node }) => ({
|
||||
id: getIdFromGraphQLId(node.id),
|
||||
name: node.name,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
Promise.all([boardsStore.allBoards(), recentBoardsPromise])
|
||||
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
|
||||
.then(([allBoardsJson, recentBoardsJson]) => {
|
||||
this.loading = false;
|
||||
this.boards = allBoardsJson;
|
||||
this.recentBoards = recentBoardsJson;
|
||||
this.loadingRecentBoards = true;
|
||||
boardsStore
|
||||
.recentBoards()
|
||||
.then(res => {
|
||||
this.recentBoards = res.data;
|
||||
})
|
||||
.catch(err => {
|
||||
/**
|
||||
* If user is unauthorized we'd still want to resolve the
|
||||
* request to display all boards.
|
||||
*/
|
||||
if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
|
||||
this.recentBoards = []; // recent boards are empty
|
||||
return;
|
||||
}
|
||||
throw err;
|
||||
})
|
||||
.then(() => this.$nextTick()) // Wait for boards list in DOM
|
||||
.then(() => {
|
||||
this.setScrollFade();
|
||||
})
|
||||
.catch(() => {
|
||||
this.loading = false;
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.loadingRecentBoards = false;
|
||||
});
|
||||
},
|
||||
isScrolledUp() {
|
||||
const { content } = this.$refs;
|
||||
|
||||
if (!content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentPosition = this.contentClientHeight + content.scrollTop;
|
||||
|
||||
return content && currentPosition < this.maxPosition;
|
||||
return currentPosition < this.maxPosition;
|
||||
},
|
||||
initScrollFade() {
|
||||
this.scrollFadeInitialized = true;
|
||||
|
||||
const { content } = this.$refs;
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scrollFadeInitialized = true;
|
||||
|
||||
this.contentClientHeight = content.clientHeight;
|
||||
this.maxPosition = content.scrollHeight;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -98,6 +98,7 @@ export default () => {
|
|||
listsEndpoint: this.listsEndpoint,
|
||||
bulkUpdatePath: this.bulkUpdatePath,
|
||||
boardId: this.boardId,
|
||||
fullPath: $boardApp.dataset.fullPath,
|
||||
});
|
||||
boardsStore.rootPath = this.boardsEndpoint;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import createDefaultClient from '~/lib/graphql';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import BoardsSelector from '~/boards/components/boards_selector.vue';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const apolloProvider = new VueApollo({
|
||||
defaultClient: createDefaultClient(),
|
||||
});
|
||||
|
||||
export default () => {
|
||||
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
|
||||
return new Vue({
|
||||
|
|
@ -9,6 +17,7 @@ export default () => {
|
|||
components: {
|
||||
BoardsSelector,
|
||||
},
|
||||
apolloProvider,
|
||||
data() {
|
||||
const { dataset } = boardsSwitcherElement;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,4 @@
|
|||
fragment BoardFragment on Board {
|
||||
id,
|
||||
name
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#import "ee_else_ce/boards/queries/board.fragment.graphql"
|
||||
|
||||
query group_boards($fullPath: ID!) {
|
||||
group(fullPath: $fullPath) {
|
||||
boards {
|
||||
edges {
|
||||
node {
|
||||
...BoardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
#import "ee_else_ce/boards/queries/board.fragment.graphql"
|
||||
|
||||
query project_boards($fullPath: ID!) {
|
||||
project(fullPath: $fullPath) {
|
||||
boards {
|
||||
edges {
|
||||
node {
|
||||
...BoardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -45,7 +45,14 @@ const boardsStore = {
|
|||
},
|
||||
multiSelect: { list: [] },
|
||||
|
||||
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) {
|
||||
setEndpoints({
|
||||
boardsEndpoint,
|
||||
listsEndpoint,
|
||||
bulkUpdatePath,
|
||||
boardId,
|
||||
recentBoardsEndpoint,
|
||||
fullPath,
|
||||
}) {
|
||||
const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
|
||||
this.state.endpoints = {
|
||||
boardsEndpoint,
|
||||
|
|
@ -53,6 +60,7 @@ const boardsStore = {
|
|||
listsEndpoint,
|
||||
listsEndpointGenerate,
|
||||
bulkUpdatePath,
|
||||
fullPath,
|
||||
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
|
||||
};
|
||||
},
|
||||
|
|
@ -542,10 +550,6 @@ const boardsStore = {
|
|||
return axios.post(endpoint);
|
||||
},
|
||||
|
||||
allBoards() {
|
||||
return axios.get(this.generateBoardsPath());
|
||||
},
|
||||
|
||||
recentBoards() {
|
||||
return axios.get(this.state.endpoints.recentBoardsEndpoint);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -165,6 +165,16 @@ export default {
|
|||
showContainerRegistryPublicNote() {
|
||||
return this.visibilityLevel === visibilityOptions.PUBLIC;
|
||||
},
|
||||
|
||||
repositoryHelpText() {
|
||||
if (this.visibilityLevel === visibilityOptions.PRIVATE) {
|
||||
return s__('ProjectSettings|View and edit files in this project');
|
||||
}
|
||||
|
||||
return s__(
|
||||
'ProjectSettings|View and edit files in this project. Non-project members will only have read access',
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
|
|
@ -225,6 +235,7 @@ export default {
|
|||
<div>
|
||||
<div class="project-visibility-setting">
|
||||
<project-setting-row
|
||||
ref="project-visibility-settings"
|
||||
:help-path="visibilityHelpPath"
|
||||
:label="s__('ProjectSettings|Project visibility')"
|
||||
>
|
||||
|
|
@ -270,6 +281,7 @@ export default {
|
|||
</div>
|
||||
<div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
|
||||
<project-setting-row
|
||||
ref="issues-settings"
|
||||
:label="s__('ProjectSettings|Issues')"
|
||||
:help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')"
|
||||
>
|
||||
|
|
@ -280,8 +292,9 @@ export default {
|
|||
/>
|
||||
</project-setting-row>
|
||||
<project-setting-row
|
||||
ref="repository-settings"
|
||||
:label="s__('ProjectSettings|Repository')"
|
||||
:help-text="s__('ProjectSettings|View and edit files in this project')"
|
||||
:help-text="repositoryHelpText"
|
||||
>
|
||||
<project-feature-setting
|
||||
v-model="repositoryAccessLevel"
|
||||
|
|
@ -291,6 +304,7 @@ export default {
|
|||
</project-setting-row>
|
||||
<div class="project-feature-setting-group">
|
||||
<project-setting-row
|
||||
ref="merge-request-settings"
|
||||
:label="s__('ProjectSettings|Merge requests')"
|
||||
:help-text="s__('ProjectSettings|Submit changes to be merged upstream')"
|
||||
>
|
||||
|
|
@ -302,6 +316,7 @@ export default {
|
|||
/>
|
||||
</project-setting-row>
|
||||
<project-setting-row
|
||||
ref="fork-settings"
|
||||
:label="s__('ProjectSettings|Forks')"
|
||||
:help-text="
|
||||
s__('ProjectSettings|Allow users to make copies of your repository to a new project')
|
||||
|
|
@ -315,6 +330,7 @@ export default {
|
|||
/>
|
||||
</project-setting-row>
|
||||
<project-setting-row
|
||||
ref="pipeline-settings"
|
||||
:label="s__('ProjectSettings|Pipelines')"
|
||||
:help-text="s__('ProjectSettings|Build, test, and deploy your changes')"
|
||||
>
|
||||
|
|
@ -327,6 +343,7 @@ export default {
|
|||
</project-setting-row>
|
||||
<project-setting-row
|
||||
v-if="registryAvailable"
|
||||
ref="container-registry-settings"
|
||||
:help-path="registryHelpPath"
|
||||
:label="s__('ProjectSettings|Container registry')"
|
||||
:help-text="
|
||||
|
|
@ -348,6 +365,7 @@ export default {
|
|||
</project-setting-row>
|
||||
<project-setting-row
|
||||
v-if="lfsAvailable"
|
||||
ref="git-lfs-settings"
|
||||
:help-path="lfsHelpPath"
|
||||
:label="s__('ProjectSettings|Git Large File Storage')"
|
||||
:help-text="
|
||||
|
|
@ -362,6 +380,7 @@ export default {
|
|||
</project-setting-row>
|
||||
<project-setting-row
|
||||
v-if="packagesAvailable"
|
||||
ref="package-settings"
|
||||
:help-path="packagesHelpPath"
|
||||
:label="s__('ProjectSettings|Packages')"
|
||||
:help-text="
|
||||
|
|
@ -376,6 +395,7 @@ export default {
|
|||
</project-setting-row>
|
||||
</div>
|
||||
<project-setting-row
|
||||
ref="wiki-settings"
|
||||
:label="s__('ProjectSettings|Wiki')"
|
||||
:help-text="s__('ProjectSettings|Pages for project documentation')"
|
||||
>
|
||||
|
|
@ -386,6 +406,7 @@ export default {
|
|||
/>
|
||||
</project-setting-row>
|
||||
<project-setting-row
|
||||
ref="snippet-settings"
|
||||
:label="s__('ProjectSettings|Snippets')"
|
||||
:help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')"
|
||||
>
|
||||
|
|
@ -397,6 +418,7 @@ export default {
|
|||
</project-setting-row>
|
||||
<project-setting-row
|
||||
v-if="pagesAvailable && pagesAccessControlEnabled"
|
||||
ref="pages-settings"
|
||||
:help-path="pagesHelpPath"
|
||||
:label="s__('ProjectSettings|Pages')"
|
||||
:help-text="
|
||||
|
|
@ -410,7 +432,7 @@ export default {
|
|||
/>
|
||||
</project-setting-row>
|
||||
</div>
|
||||
<project-setting-row v-if="canDisableEmails" class="mb-3">
|
||||
<project-setting-row v-if="canDisableEmails" ref="email-settings" class="mb-3">
|
||||
<label class="js-emails-disabled">
|
||||
<input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
|
||||
<input v-model="emailsDisabled" type="checkbox" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
<script>
|
||||
import { GlFormInput } from '@gitlab/ui';
|
||||
import MarkdownField from '~/vue_shared/components/markdown/field.vue';
|
||||
import setupCollapsibleInputs from '~/snippet/collapsible_input';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlFormInput,
|
||||
MarkdownField,
|
||||
},
|
||||
props: {
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false,
|
||||
},
|
||||
markdownPreviewPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
markdownDocsPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
text: this.description,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
setupCollapsibleInputs();
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div class="form-group js-description-input">
|
||||
<label>{{ s__('Snippets|Description (optional)') }}</label>
|
||||
<div class="js-collapsible-input">
|
||||
<div class="js-collapsed" :class="{ 'd-none': text }">
|
||||
<gl-form-input
|
||||
class="form-control"
|
||||
:placeholder="
|
||||
s__(
|
||||
'Snippets|Optionally add a description about what your snippet does or how to use it…',
|
||||
)
|
||||
"
|
||||
data-qa-selector="description_placeholder"
|
||||
/>
|
||||
</div>
|
||||
<markdown-field
|
||||
class="js-expanded"
|
||||
:class="{ 'd-none': !text }"
|
||||
:markdown-preview-path="markdownPreviewPath"
|
||||
:markdown-docs-path="markdownDocsPath"
|
||||
>
|
||||
<textarea
|
||||
id="snippet-description"
|
||||
slot="textarea"
|
||||
v-model="text"
|
||||
class="note-textarea js-gfm-input js-autosize markdown-area
|
||||
qa-description-textarea"
|
||||
dir="auto"
|
||||
data-supports-quick-actions="false"
|
||||
:aria-label="__('Description')"
|
||||
:placeholder="__('Write a comment or drag your files here…')"
|
||||
>
|
||||
</textarea>
|
||||
</markdown-field>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -212,6 +212,8 @@ export default {
|
|||
return new MRWidgetService(this.getServiceEndpoints(store));
|
||||
},
|
||||
checkStatus(cb, isRebased) {
|
||||
if (document.visibilityState !== 'visible') return Promise.resolve();
|
||||
|
||||
return this.service
|
||||
.checkStatus()
|
||||
.then(({ data }) => {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ module BoardsHelper
|
|||
disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s,
|
||||
issue_link_base: build_issue_link_base,
|
||||
root_path: root_path,
|
||||
full_path: full_path,
|
||||
bulk_update_path: @bulk_issues_path,
|
||||
default_avatar: image_path(default_avatar),
|
||||
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
|
||||
|
|
@ -20,6 +21,14 @@ module BoardsHelper
|
|||
}
|
||||
end
|
||||
|
||||
def full_path
|
||||
if board.group_board?
|
||||
@group.full_path
|
||||
else
|
||||
@project.full_path
|
||||
end
|
||||
end
|
||||
|
||||
def build_issue_link_base
|
||||
if board.group_board?
|
||||
"#{group_path(@board.group)}/:project_path/issues"
|
||||
|
|
|
|||
|
|
@ -313,6 +313,7 @@ class ProjectPolicy < BasePolicy
|
|||
enable :daily_statistics
|
||||
enable :admin_operations
|
||||
enable :read_deploy_token
|
||||
enable :create_deploy_token
|
||||
end
|
||||
|
||||
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Update project's permission settings description to reflect actual permissions
|
||||
merge_request: 25523
|
||||
author:
|
||||
type: other
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Added Blob Description Edit component in Vue
|
||||
merge_request: 26762
|
||||
author:
|
||||
type: added
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Update charts documentation and common_metrics.yml to enable data formatting
|
||||
merge_request: 26048
|
||||
author:
|
||||
type: added
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Optimize event counters query performance in usage data
|
||||
merge_request: 26444
|
||||
author:
|
||||
type: performance
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add api endpoint to create deploy tokens
|
||||
merge_request: 25270
|
||||
author:
|
||||
type: added
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Enable client-side GRPC keepalive for Gitaly
|
||||
merge_request: 26536
|
||||
author:
|
||||
type: changed
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Replace undefined severity with unknown severity for vulnerabilities
|
||||
merge_request: 26305
|
||||
author:
|
||||
type: other
|
||||
|
|
@ -59,5 +59,7 @@ Rails.application.configure do
|
|||
config.active_record.migration_error = false
|
||||
config.active_record.verbose_query_logs = false
|
||||
config.action_view.cache_template_loading = true
|
||||
|
||||
config.middleware.delete BetterErrors::Middleware
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ panel_groups:
|
|||
- title: "Latency"
|
||||
type: "area-chart"
|
||||
y_label: "Latency (ms)"
|
||||
y_axis:
|
||||
format: milliseconds
|
||||
weight: 1
|
||||
metrics:
|
||||
- id: response_metrics_nginx_ingress_latency_pod_average
|
||||
|
|
@ -26,6 +28,8 @@ panel_groups:
|
|||
- title: "HTTP Error Rate"
|
||||
type: "area-chart"
|
||||
y_label: "HTTP Errors (%)"
|
||||
y_axis:
|
||||
format: percentHundred
|
||||
weight: 1
|
||||
metrics:
|
||||
- id: response_metrics_nginx_ingress_http_error_rate
|
||||
|
|
@ -138,6 +142,8 @@ panel_groups:
|
|||
- title: "HTTP Error Rate (Errors / Sec)"
|
||||
type: "area-chart"
|
||||
y_label: "HTTP 500 Errors / Sec"
|
||||
y_axis:
|
||||
precision: 0
|
||||
weight: 1
|
||||
metrics:
|
||||
- id: response_metrics_nginx_http_error_rate
|
||||
|
|
@ -150,6 +156,8 @@ panel_groups:
|
|||
- title: "Memory Usage (Total)"
|
||||
type: "area-chart"
|
||||
y_label: "Total Memory Used (GB)"
|
||||
y_axis:
|
||||
format: "gibibytes"
|
||||
weight: 4
|
||||
metrics:
|
||||
- id: system_metrics_kubernetes_container_memory_total
|
||||
|
|
@ -168,6 +176,8 @@ panel_groups:
|
|||
- title: "Memory Usage (Pod average)"
|
||||
type: "line-chart"
|
||||
y_label: "Memory Used per Pod (MB)"
|
||||
y_axis:
|
||||
format: "mebibytes"
|
||||
weight: 2
|
||||
metrics:
|
||||
- id: system_metrics_kubernetes_container_memory_average
|
||||
|
|
@ -177,6 +187,8 @@ panel_groups:
|
|||
- title: "Canary: Memory Usage (Pod Average)"
|
||||
type: "line-chart"
|
||||
y_label: "Memory Used per Pod (MB)"
|
||||
y_axis:
|
||||
format: "mebibytes"
|
||||
weight: 2
|
||||
metrics:
|
||||
- id: system_metrics_kubernetes_container_memory_average_canary
|
||||
|
|
@ -206,6 +218,8 @@ panel_groups:
|
|||
- title: "Knative function invocations"
|
||||
type: "area-chart"
|
||||
y_label: "Invocations"
|
||||
y_axis:
|
||||
precision: 0
|
||||
weight: 1
|
||||
metrics:
|
||||
- id: system_metrics_knative_function_invocation_count
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddIndexOnAuthorIdAndCreatedAtToEvents < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index :events, [:author_id, :created_at]
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index :events, [:author_id, :created_at]
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddIndexOnAuthorIdAndIdAndCreatedAtToIssues < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index :issues, [:author_id, :id, :created_at]
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index :issues, [:author_id, :id, :created_at]
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class UpdateVulnerabilitySeverityColumn < ActiveRecord::Migration[6.0]
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
BATCH_SIZE = 1_000
|
||||
INTERVAL = 2.minutes
|
||||
|
||||
def up
|
||||
# create temporary index for undefined vulnerabilities
|
||||
add_concurrent_index(:vulnerabilities, :id, where: 'severity = 0', name: 'undefined_vulnerability')
|
||||
|
||||
return unless Gitlab.ee?
|
||||
|
||||
migration = Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel
|
||||
migration_name = migration.to_s.demodulize
|
||||
relation = migration::Vulnerability.undefined_severity
|
||||
queue_background_migration_jobs_by_range_at_intervals(relation,
|
||||
migration_name,
|
||||
INTERVAL,
|
||||
batch_size: BATCH_SIZE)
|
||||
end
|
||||
|
||||
def down
|
||||
# no-op
|
||||
# This migration can not be reversed because we can not know which records had undefined severity
|
||||
end
|
||||
end
|
||||
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2020_03_09_105539) do
|
||||
ActiveRecord::Schema.define(version: 2020_03_06_170531) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_trgm"
|
||||
|
|
@ -1616,6 +1616,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
|
|||
t.string "target_type"
|
||||
t.bigint "group_id"
|
||||
t.index ["action"], name: "index_events_on_action"
|
||||
t.index ["author_id", "created_at"], name: "index_events_on_author_id_and_created_at"
|
||||
t.index ["author_id", "project_id"], name: "index_events_on_author_id_and_project_id"
|
||||
t.index ["created_at", "author_id"], name: "analytics_index_events_on_created_at_and_author_id"
|
||||
t.index ["group_id"], name: "index_events_on_group_id_partial", where: "(group_id IS NOT NULL)"
|
||||
|
|
@ -2206,6 +2207,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
|
|||
t.integer "duplicated_to_id"
|
||||
t.integer "promoted_to_epic_id"
|
||||
t.integer "health_status", limit: 2
|
||||
t.index ["author_id", "id", "created_at"], name: "index_issues_on_author_id_and_id_and_created_at"
|
||||
t.index ["author_id"], name: "index_issues_on_author_id"
|
||||
t.index ["closed_by_id"], name: "index_issues_on_closed_by_id"
|
||||
t.index ["confidential"], name: "index_issues_on_confidential"
|
||||
|
|
@ -4454,6 +4456,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
|
|||
t.index ["dismissed_by_id"], name: "index_vulnerabilities_on_dismissed_by_id"
|
||||
t.index ["due_date_sourcing_milestone_id"], name: "index_vulnerabilities_on_due_date_sourcing_milestone_id"
|
||||
t.index ["epic_id"], name: "index_vulnerabilities_on_epic_id"
|
||||
t.index ["id"], name: "undefined_vulnerability", where: "(severity = 0)"
|
||||
t.index ["last_edited_by_id"], name: "index_vulnerabilities_on_last_edited_by_id"
|
||||
t.index ["milestone_id"], name: "index_vulnerabilities_on_milestone_id"
|
||||
t.index ["project_id"], name: "index_vulnerabilities_on_project_id"
|
||||
|
|
|
|||
|
|
@ -72,6 +72,43 @@ Example response:
|
|||
]
|
||||
```
|
||||
|
||||
### Create a project deploy token
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/21811) in GitLab 12.9.
|
||||
|
||||
Creates a new deploy token for a project.
|
||||
|
||||
```
|
||||
POST /projects/:id/deploy_tokens
|
||||
```
|
||||
|
||||
| Attribute | Type | Required | Description |
|
||||
| --------- | ---- | -------- | ----------- |
|
||||
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
|
||||
| `name` | string | yes | New deploy token's name |
|
||||
| `expires_at` | datetime | no | Expiration date for the deploy token. Does not expire if no value is provided. |
|
||||
| `username` | string | no | Username for deploy token. Default is `gitlab+deploy-token-{n}` |
|
||||
| `scopes` | array of strings | yes | Indicates the deploy token scopes. Must be at least one of `read_repository` or `read_registry`. |
|
||||
|
||||
```shell
|
||||
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" --data '{"name": "My deploy token", "expires_at": "2021-01-01", "username": "custom-user", "scopes": ["read_repository"]}' "https://gitlab.example.com/api/v4/projects/5/deploy_tokens/"
|
||||
```
|
||||
|
||||
Example response:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"name": "My deploy token",
|
||||
"username": "custom-user",
|
||||
"expires_at": "2021-01-01T00:00:00.000Z",
|
||||
"token": "jMRvtPNxrn3crTAGukpZ",
|
||||
"scopes": [
|
||||
"read_repository"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Group deploy tokens
|
||||
|
||||
These endpoints require group maintainer access or higher.
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ The requirement for adding a new metric is to make each query to have an unique
|
|||
- group: Response metrics (NGINX Ingress)
|
||||
metrics:
|
||||
- title: "Throughput"
|
||||
y_label: "Requests / Sec"
|
||||
y_axis:
|
||||
name: "Requests / Sec"
|
||||
format: "number"
|
||||
precision: 2
|
||||
queries:
|
||||
- id: response_metrics_nginx_ingress_throughput_status_code
|
||||
query_range: 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[2m])) by (status_code)'
|
||||
|
|
|
|||
|
|
@ -52,8 +52,6 @@ Here's a list of the AWS services we will use, with links to pricing information
|
|||
will apply. If you want to run it on a dedicated or reserved instance,
|
||||
consult the [EC2 pricing page](https://aws.amazon.com/ec2/pricing/) for more
|
||||
information on the cost.
|
||||
- **EBS**: We will also use an EBS volume to store the Git data. See the
|
||||
[Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/).
|
||||
- **S3**: We will use S3 to store backups, artifacts, LFS objects, etc. See the
|
||||
[Amazon S3 pricing](https://aws.amazon.com/s3/pricing/).
|
||||
- **ELB**: A Classic Load Balancer will be used to route requests to the
|
||||
|
|
@ -524,7 +522,7 @@ Let's create an EC2 instance where we'll install Gitaly:
|
|||
1. Click **Review and launch** followed by **Launch** if you're happy with your settings.
|
||||
1. Finally, acknowledge that you have access to the selected private key file or create a new one. Click **Launch Instances**.
|
||||
|
||||
> **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above.
|
||||
> **Optional:** Instead of storing configuration _and_ repository data on the root volume, you can also choose to add an additional EBS volume for repository storage. Follow the same guidance as above. See the [Amazon EBS pricing](https://aws.amazon.com/ebs/pricing/).
|
||||
|
||||
Now that we have our EC2 instance ready, follow the [documentation to install GitLab and set up Gitaly on its own server](../../administration/gitaly/index.md#running-gitaly-on-its-own-server).
|
||||
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ type: howto
|
|||
GitLab can be configured to require confirmation of a user's email address when
|
||||
the user signs up. When this setting is enabled:
|
||||
|
||||
- For GitLab 12.1 and earlier, the user is unable to sign in until they confirm their
|
||||
- For GitLab 12.7 and earlier, the user is unable to sign in until they confirm their
|
||||
email address.
|
||||
- For GitLab 12.2 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
|
||||
- For GitLab 12.8 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
|
||||
After 30 days, they will be unable to log in and access GitLab features.
|
||||
|
||||
In **Admin Area > Settings** (`/admin/application_settings/general`), go to the section
|
||||
|
|
|
|||
|
|
@ -39,9 +39,9 @@ email domains to prevent malicious users from creating accounts.
|
|||
You can send confirmation emails during sign-up and require that users confirm
|
||||
their email address. If this setting is selected:
|
||||
|
||||
- For GitLab 12.1 and earlier, the user is unable to sign in until they confirm their
|
||||
- For GitLab 12.7 and earlier, the user is unable to sign in until they confirm their
|
||||
email address.
|
||||
- For GitLab 12.2 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
|
||||
- For GitLab 12.8 and later, the user [has 30 days to confirm their email address](https://gitlab.com/gitlab-org/gitlab-foss/-/merge_requests/31245).
|
||||
After 30 days, they will be unable to log in and access GitLab features.
|
||||
|
||||

|
||||
|
|
|
|||
|
|
@ -356,6 +356,31 @@ dast:
|
|||
The DAST job does not require the project's repository to be present when running, so by default
|
||||
[`GIT_STRATEGY`](../../../ci/yaml/README.md#git-strategy) is set to `none`.
|
||||
|
||||
## Running DAST in an offline air-gapped installation
|
||||
|
||||
DAST can be executed on an offline air-gapped GitLab Ultimate installation using the following process:
|
||||
|
||||
1. Host the DAST image `registry.gitlab.com/gitlab-org/security-products/dast:latest` in your local
|
||||
Docker container registry.
|
||||
1. Add the following configuration to your `.gitlab-ci.yml` file. You must replace `image` to refer
|
||||
to the DAST Docker image hosted on your local Docker container registry:
|
||||
|
||||
```yaml
|
||||
include:
|
||||
- template: DAST.gitlab-ci.yml
|
||||
|
||||
dast:
|
||||
image: registry.example.com/namespace/dast:latest
|
||||
script:
|
||||
- export DAST_WEBSITE=${DAST_WEBSITE:-$(cat environment_url.txt)}
|
||||
- /analyze -t $DAST_WEBSITE --auto-update-addons false -z"-silent"
|
||||
```
|
||||
|
||||
The option `--auto-update-addons false` instructs ZAP not to update add-ons.
|
||||
|
||||
The option `-z` passes the quoted `-silent` parameter to ZAP. The `-silent` parameter ensures ZAP
|
||||
does not make any unsolicited requests including checking for updates.
|
||||
|
||||
## Reports
|
||||
|
||||
The DAST job can emit various reports.
|
||||
|
|
|
|||
|
|
@ -203,14 +203,17 @@ For example:
|
|||
panel_groups:
|
||||
- group: 'Group Title'
|
||||
panels:
|
||||
- type: area-chart
|
||||
title: "Chart Title"
|
||||
y_label: "Y-Axis"
|
||||
metrics:
|
||||
- id: metric_of_ages
|
||||
query_range: 'http_requests_total'
|
||||
label: "Instance: {{instance}}, method: {{method}}"
|
||||
unit: "count"
|
||||
- type: area-chart
|
||||
title: "Chart Title"
|
||||
y_label: "Y-Axis"
|
||||
y_axis:
|
||||
format: number
|
||||
precision: 0
|
||||
metrics:
|
||||
- id: my_metric_id
|
||||
query_range: 'http_requests_total'
|
||||
label: "Instance: {{instance}}, method: {{method}}"
|
||||
unit: "count"
|
||||
```
|
||||
|
||||
The above sample dashboard would display a single area chart. Each file should
|
||||
|
|
@ -276,9 +279,18 @@ The following tables outline the details of expected properties.
|
|||
| `type` | enum | no, defaults to `area-chart` | Specifies the chart type to use, can be: `area-chart`, `line-chart` or `anomaly-chart`. |
|
||||
| `title` | string | yes | Heading for the panel. |
|
||||
| `y_label` | string | no, but highly encouraged | Y-Axis label for the panel. |
|
||||
| `y_axis` | string | no | Y-Axis configuration for the panel. |
|
||||
| `weight` | number | no, defaults to order in file | Order to appear within the grouping. Lower number means higher priority, which will be higher on the page. Numbers do not need to be consecutive. |
|
||||
| `metrics` | array | yes | The metrics which should be displayed in the panel. Any number of metrics can be displayed when `type` is `area-chart` or `line-chart`, whereas only 3 can be displayed when `type` is `anomaly-chart`. |
|
||||
|
||||
**Axis (`panels[].y_axis`) properties:**
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ----------- | ------ | ------------------------- | -------------------------------------------------------------------- |
|
||||
| `name` | string | no, but highly encouraged | Y-Axis label for the panel, it will replace `y_label` if set. |
|
||||
| `format` | string | no, defaults to `number` | Unit format used. See the [full list of units](prometheus_units.md). |
|
||||
| `precision` | number | no, defaults to `2` | Number of decimals to display in the number. |
|
||||
|
||||
**Metrics (`metrics`) properties:**
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
|
|
@ -297,7 +309,7 @@ When a static label is used and a query returns multiple time series, then all t
|
|||
|
||||
```yaml
|
||||
metrics:
|
||||
- id: metric_of_ages
|
||||
- id: my_metric_id
|
||||
query_range: 'http_requests_total'
|
||||
label: "Time Series"
|
||||
unit: "count"
|
||||
|
|
@ -311,7 +323,7 @@ For labels to be more explicit, using variables that reflect time series labels
|
|||
|
||||
```yaml
|
||||
metrics:
|
||||
- id: metric_of_ages
|
||||
- id: my_metric_id
|
||||
query_range: 'http_requests_total'
|
||||
label: "Instance: {{instance}}, method: {{method}}"
|
||||
unit: "count"
|
||||
|
|
@ -325,7 +337,7 @@ There is also a shorthand value for dynamic dashboard labels that make use of on
|
|||
|
||||
```yaml
|
||||
metrics:
|
||||
- id: metric_of_ages
|
||||
- id: my_metric_id
|
||||
query_range: 'http_requests_total'
|
||||
label: "Method"
|
||||
unit: "count"
|
||||
|
|
@ -351,6 +363,9 @@ panel_groups:
|
|||
- type: area-chart # or line-chart
|
||||
title: 'Area Chart Title'
|
||||
y_label: "Y-Axis"
|
||||
y_axis:
|
||||
format: number
|
||||
precision: 0
|
||||
metrics:
|
||||
- id: area_http_requests_total
|
||||
query_range: 'http_requests_total'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
# Unit formats reference
|
||||
|
||||
> [Introduced](https://gitlab.com/gitlab-org/gitlab/issues/201999) in GitLab 12.9.
|
||||
|
||||
You can select units to format your charts by adding `format` to your
|
||||
[axis configuration](prometheus.md#dashboard-yaml-properties).
|
||||
|
||||
## Numbers
|
||||
|
||||
For generic data, numbers are formatted according to the current locale.
|
||||
|
||||
Formats: `number`
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Data | Displayed |
|
||||
| --------- | --------- |
|
||||
| `10` | 1 |
|
||||
| `1000` | 1,000 |
|
||||
| `1000000` | 1,000,000 |
|
||||
|
||||
## Percentage
|
||||
|
||||
For percentage data, format numbers in the chart with a `%` symbol.
|
||||
|
||||
Formats supported: `percent`, `percentHundred`
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Format | Data | Displayed |
|
||||
| ---------------- | ----- | --------- |
|
||||
| `percent` | `0.5` | 50% |
|
||||
| `percent` | `1` | 100% |
|
||||
| `percent` | `2` | 200% |
|
||||
| `percentHundred` | `50` | 50% |
|
||||
| `percentHundred` | `100` | 100% |
|
||||
| `percentHundred` | `200` | 200% |
|
||||
|
||||
## Duration
|
||||
|
||||
For time durations, format numbers in the chart with a time unit symbol.
|
||||
|
||||
Formats supported: `milliseconds`, `seconds`
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Format | Data | Displayed |
|
||||
| -------------- | ------ | --------- |
|
||||
| `milliseconds` | `10` | 10ms |
|
||||
| `milliseconds` | `500` | 100ms |
|
||||
| `milliseconds` | `1000` | 1000ms |
|
||||
| `seconds` | `10` | 10s |
|
||||
| `seconds` | `500` | 500s |
|
||||
| `seconds` | `1000` | 1000s |
|
||||
|
||||
## Digital (Metric)
|
||||
|
||||
Converts a number of bytes using metric prefixes. It scales to
|
||||
use the unit that's the best fit.
|
||||
|
||||
Formats supported:
|
||||
|
||||
- `decimalBytes`
|
||||
- `kilobytes`
|
||||
- `megabytes`
|
||||
- `gigabytes`
|
||||
- `terabytes`
|
||||
- `petabytes`
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Format | Data | Displayed |
|
||||
| -------------- | --------- | --------- |
|
||||
| `decimalBytes` | `1` | 1B |
|
||||
| `decimalBytes` | `1000` | 1kB |
|
||||
| `decimalBytes` | `1000000` | 1MB |
|
||||
| `kilobytes` | `1` | 1kB |
|
||||
| `kilobytes` | `1000` | 1MB |
|
||||
| `kilobytes` | `1000000` | 1GB |
|
||||
| `megabytes` | `1` | 1MB |
|
||||
| `megabytes` | `1000` | 1GB |
|
||||
| `megabytes` | `1000000` | 1TB |
|
||||
|
||||
## Digital (IEC)
|
||||
|
||||
Converts a number of bytes using binary prefixes. It scales to
|
||||
use the unit that's the best fit.
|
||||
|
||||
Formats supported:
|
||||
|
||||
- `bytes`
|
||||
- `kibibytes`
|
||||
- `mebibytes`
|
||||
- `gibibytes`
|
||||
- `tebibytes`
|
||||
- `pebibytes`
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Format | Data | Displayed |
|
||||
| ----------- | ------------- | --------- |
|
||||
| `bytes` | `1` | 1B |
|
||||
| `bytes` | `1024` | 1KiB |
|
||||
| `bytes` | `1024 * 1024` | 1MiB |
|
||||
| `kibibytes` | `1` | 1KiB |
|
||||
| `kibibytes` | `1024` | 1MiB |
|
||||
| `kibibytes` | `1024 * 1024` | 1GiB |
|
||||
| `mebibytes` | `1` | 1MiB |
|
||||
| `mebibytes` | `1024` | 1GiB |
|
||||
| `mebibytes` | `1024 * 1024` | 1TiB |
|
||||
|
|
@ -4,6 +4,17 @@ module API
|
|||
class DeployTokens < Grape::API
|
||||
include PaginationParams
|
||||
|
||||
helpers do
|
||||
def scope_params
|
||||
scopes = params.delete(:scopes)
|
||||
|
||||
result_hash = {}
|
||||
result_hash[:read_registry] = scopes.include?('read_registry')
|
||||
result_hash[:read_repository] = scopes.include?('read_repository')
|
||||
result_hash
|
||||
end
|
||||
end
|
||||
|
||||
desc 'Return all deploy tokens' do
|
||||
detail 'This feature was introduced in GitLab 12.9.'
|
||||
success Entities::DeployToken
|
||||
|
|
@ -33,6 +44,27 @@ module API
|
|||
|
||||
present paginate(user_project.deploy_tokens), with: Entities::DeployToken
|
||||
end
|
||||
|
||||
params do
|
||||
requires :name, type: String, desc: "New deploy token's name"
|
||||
requires :expires_at, type: DateTime, desc: 'Expiration date for the deploy token. Does not expire if no value is provided.'
|
||||
requires :username, type: String, desc: 'Username for deploy token. Default is `gitlab+deploy-token-{n}`'
|
||||
requires :scopes, type: Array[String], values: ::DeployToken::AVAILABLE_SCOPES.map(&:to_s),
|
||||
desc: 'Indicates the deploy token scopes. Must be at least one of "read_repository" or "read_registry".'
|
||||
end
|
||||
desc 'Create a project deploy token' do
|
||||
detail 'This feature was introduced in GitLab 12.9'
|
||||
success Entities::DeployTokenWithToken
|
||||
end
|
||||
post ':id/deploy_tokens' do
|
||||
authorize!(:create_deploy_token, user_project)
|
||||
|
||||
deploy_token = ::Projects::DeployTokens::CreateService.new(
|
||||
user_project, current_user, scope_params.merge(declared(params, include_missing: false, include_parent_namespaces: false))
|
||||
).execute
|
||||
|
||||
present deploy_token, with: Entities::DeployTokenWithToken
|
||||
end
|
||||
end
|
||||
|
||||
params do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module API
|
||||
module Entities
|
||||
class DeployTokenWithToken < Entities::DeployToken
|
||||
expose :token
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# frozen_string_literal: true
|
||||
# rubocop:disable Style/Documentation
|
||||
|
||||
module Gitlab
|
||||
module BackgroundMigration
|
||||
class RemoveUndefinedVulnerabilitySeverityLevel
|
||||
def perform(start_id, stop_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel.prepend_if_ee('EE::Gitlab::BackgroundMigration::RemoveUndefinedVulnerabilitySeverityLevel')
|
||||
|
|
@ -28,7 +28,7 @@ module Gitlab
|
|||
|
||||
class BatchCounter
|
||||
FALLBACK = -1
|
||||
MIN_REQUIRED_BATCH_SIZE = 2_000
|
||||
MIN_REQUIRED_BATCH_SIZE = 1_250
|
||||
MAX_ALLOWED_LOOPS = 10_000
|
||||
SLEEP_TIME_IN_SECONDS = 0.01 # 10 msec sleep
|
||||
# Each query should take <<500ms https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22705
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ module Gitlab
|
|||
klass = stub_class(name)
|
||||
addr = stub_address(storage)
|
||||
creds = stub_creds(storage)
|
||||
klass.new(addr, creds, interceptors: interceptors)
|
||||
klass.new(addr, creds, interceptors: interceptors, channel_args: channel_args)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -54,6 +54,16 @@ module Gitlab
|
|||
end
|
||||
private_class_method :interceptors
|
||||
|
||||
def self.channel_args
|
||||
# These values match the go Gitaly client
|
||||
# https://gitlab.com/gitlab-org/gitaly/-/blob/bf9f52bc/client/dial.go#L78
|
||||
{
|
||||
'grpc.keepalive_time_ms': 20000,
|
||||
'grpc.keepalive_permit_without_calls': 1
|
||||
}
|
||||
end
|
||||
private_class_method :channel_args
|
||||
|
||||
def self.stub_cert_paths
|
||||
cert_paths = Dir["#{OpenSSL::X509::DEFAULT_CERT_DIR}/*"]
|
||||
cert_paths << OpenSSL::X509::DEFAULT_CERT_FILE if File.exist? OpenSSL::X509::DEFAULT_CERT_FILE
|
||||
|
|
|
|||
|
|
@ -15422,6 +15422,9 @@ msgstr ""
|
|||
msgid "ProjectSettings|View and edit files in this project"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectSettings|View and edit files in this project. Non-project members will only have read access"
|
||||
msgstr ""
|
||||
|
||||
msgid "ProjectSettings|When conflicts arise the user is given the option to rebase"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -18174,6 +18177,9 @@ msgstr ""
|
|||
msgid "Snippets|Optionally add a description about what your snippet does or how to use it..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Snippets|Optionally add a description about what your snippet does or how to use it…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Snowplow"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@
|
|||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": [],
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"precision": { "type": "number" },
|
||||
"format": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
"title": { "type": "string" },
|
||||
"type": { "type": "string" },
|
||||
"y_label": { "type": "string" },
|
||||
"y_axis": { "$ref": "axis.json" },
|
||||
"weight": { "type": "number" },
|
||||
"metrics": {
|
||||
"type": "array",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import axios from '~/lib/utils/axios_utils';
|
|||
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
|
||||
import ClassSpecHelper from '../../helpers/class_spec_helper';
|
||||
|
||||
jest.mock('sql.js');
|
||||
|
||||
describe('BalsamiqViewer', () => {
|
||||
const mockArrayBuffer = new ArrayBuffer(10);
|
||||
let balsamiqViewer;
|
||||
|
|
@ -34,22 +36,22 @@ describe('BalsamiqViewer', () => {
|
|||
});
|
||||
|
||||
it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
|
||||
spyOn(axios, 'get').and.returnValue(requestSuccess);
|
||||
spyOn(bv, 'renderFile').and.stub();
|
||||
jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
|
||||
jest.spyOn(bv, 'renderFile').mockReturnValue();
|
||||
|
||||
bv.loadFile(endpoint);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
endpoint,
|
||||
jasmine.objectContaining({
|
||||
expect.objectContaining({
|
||||
responseType: 'arraybuffer',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call `renderFile` on request success', done => {
|
||||
spyOn(axios, 'get').and.returnValue(requestSuccess);
|
||||
spyOn(bv, 'renderFile').and.callFake(() => {});
|
||||
jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
|
||||
jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
|
||||
|
||||
bv.loadFile(endpoint)
|
||||
.then(() => {
|
||||
|
|
@ -60,8 +62,8 @@ describe('BalsamiqViewer', () => {
|
|||
});
|
||||
|
||||
it('should not call `renderFile` on request failure', done => {
|
||||
spyOn(axios, 'get').and.returnValue(Promise.reject());
|
||||
spyOn(bv, 'renderFile');
|
||||
jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
|
||||
jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
|
||||
|
||||
bv.loadFile(endpoint)
|
||||
.then(() => {
|
||||
|
|
@ -80,19 +82,21 @@ describe('BalsamiqViewer', () => {
|
|||
let previews;
|
||||
|
||||
beforeEach(() => {
|
||||
viewer = jasmine.createSpyObj('viewer', ['appendChild']);
|
||||
viewer = {
|
||||
appendChild: jest.fn(),
|
||||
};
|
||||
previews = [document.createElement('ul'), document.createElement('ul')];
|
||||
|
||||
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', [
|
||||
'initDatabase',
|
||||
'getPreviews',
|
||||
'renderPreview',
|
||||
]);
|
||||
balsamiqViewer = {
|
||||
initDatabase: jest.fn(),
|
||||
getPreviews: jest.fn(),
|
||||
renderPreview: jest.fn(),
|
||||
};
|
||||
balsamiqViewer.viewer = viewer;
|
||||
|
||||
balsamiqViewer.getPreviews.and.returnValue(previews);
|
||||
balsamiqViewer.renderPreview.and.callFake(preview => preview);
|
||||
viewer.appendChild.and.callFake(containerElement => {
|
||||
balsamiqViewer.getPreviews.mockReturnValue(previews);
|
||||
balsamiqViewer.renderPreview.mockImplementation(preview => preview);
|
||||
viewer.appendChild.mockImplementation(containerElement => {
|
||||
container = containerElement;
|
||||
});
|
||||
|
||||
|
|
@ -108,7 +112,7 @@ describe('BalsamiqViewer', () => {
|
|||
});
|
||||
|
||||
it('should call .renderPreview for each preview', () => {
|
||||
const allArgs = balsamiqViewer.renderPreview.calls.allArgs();
|
||||
const allArgs = balsamiqViewer.renderPreview.mock.calls;
|
||||
|
||||
expect(allArgs.length).toBe(2);
|
||||
|
||||
|
|
@ -132,19 +136,15 @@ describe('BalsamiqViewer', () => {
|
|||
});
|
||||
|
||||
describe('initDatabase', () => {
|
||||
let database;
|
||||
let uint8Array;
|
||||
let data;
|
||||
|
||||
beforeEach(() => {
|
||||
uint8Array = {};
|
||||
database = {};
|
||||
data = 'data';
|
||||
|
||||
balsamiqViewer = {};
|
||||
|
||||
spyOn(window, 'Uint8Array').and.returnValue(uint8Array);
|
||||
spyOn(sqljs, 'Database').and.returnValue(database);
|
||||
window.Uint8Array = jest.fn();
|
||||
window.Uint8Array.mockReturnValue(uint8Array);
|
||||
|
||||
BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
|
||||
});
|
||||
|
|
@ -158,7 +158,7 @@ describe('BalsamiqViewer', () => {
|
|||
});
|
||||
|
||||
it('should set .database', () => {
|
||||
expect(balsamiqViewer.database).toBe(database);
|
||||
expect(balsamiqViewer.database).not.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -168,15 +168,17 @@ describe('BalsamiqViewer', () => {
|
|||
let getPreviews;
|
||||
|
||||
beforeEach(() => {
|
||||
database = jasmine.createSpyObj('database', ['exec']);
|
||||
database = {
|
||||
exec: jest.fn(),
|
||||
};
|
||||
thumbnails = [{ values: [0, 1, 2] }];
|
||||
|
||||
balsamiqViewer = {
|
||||
database,
|
||||
};
|
||||
|
||||
spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString());
|
||||
database.exec.and.returnValue(thumbnails);
|
||||
jest.spyOn(BalsamiqViewer, 'parsePreview').mockImplementation(preview => preview.toString());
|
||||
database.exec.mockReturnValue(thumbnails);
|
||||
|
||||
getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
|
||||
});
|
||||
|
|
@ -186,7 +188,7 @@ describe('BalsamiqViewer', () => {
|
|||
});
|
||||
|
||||
it('should call .parsePreview for each value', () => {
|
||||
const allArgs = BalsamiqViewer.parsePreview.calls.allArgs();
|
||||
const allArgs = BalsamiqViewer.parsePreview.mock.calls;
|
||||
|
||||
expect(allArgs.length).toBe(3);
|
||||
|
||||
|
|
@ -207,7 +209,9 @@ describe('BalsamiqViewer', () => {
|
|||
let getResource;
|
||||
|
||||
beforeEach(() => {
|
||||
database = jasmine.createSpyObj('database', ['exec']);
|
||||
database = {
|
||||
exec: jest.fn(),
|
||||
};
|
||||
resourceID = 4;
|
||||
resource = ['resource'];
|
||||
|
||||
|
|
@ -215,7 +219,7 @@ describe('BalsamiqViewer', () => {
|
|||
database,
|
||||
};
|
||||
|
||||
database.exec.and.returnValue(resource);
|
||||
database.exec.mockReturnValue(resource);
|
||||
|
||||
getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
|
||||
});
|
||||
|
|
@ -241,14 +245,18 @@ describe('BalsamiqViewer', () => {
|
|||
innerHTML = '<a>innerHTML</a>';
|
||||
previewElement = {
|
||||
outerHTML: '<p>outerHTML</p>',
|
||||
classList: jasmine.createSpyObj('classList', ['add']),
|
||||
classList: {
|
||||
add: jest.fn(),
|
||||
},
|
||||
};
|
||||
preview = {};
|
||||
|
||||
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']);
|
||||
balsamiqViewer = {
|
||||
renderTemplate: jest.fn(),
|
||||
};
|
||||
|
||||
spyOn(document, 'createElement').and.returnValue(previewElement);
|
||||
balsamiqViewer.renderTemplate.and.returnValue(innerHTML);
|
||||
jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
|
||||
balsamiqViewer.renderTemplate.mockReturnValue(innerHTML);
|
||||
|
||||
renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
|
||||
});
|
||||
|
|
@ -290,10 +298,12 @@ describe('BalsamiqViewer', () => {
|
|||
</div>
|
||||
`;
|
||||
|
||||
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']);
|
||||
balsamiqViewer = {
|
||||
getResource: jest.fn(),
|
||||
};
|
||||
|
||||
spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name);
|
||||
balsamiqViewer.getResource.and.returnValue(resource);
|
||||
jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
|
||||
balsamiqViewer.getResource.mockReturnValue(resource);
|
||||
|
||||
renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
|
||||
});
|
||||
|
|
@ -306,7 +316,7 @@ describe('BalsamiqViewer', () => {
|
|||
expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource);
|
||||
});
|
||||
|
||||
it('should return the template string', function() {
|
||||
it('should return the template string', () => {
|
||||
expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
|
||||
});
|
||||
});
|
||||
|
|
@ -318,7 +328,7 @@ describe('BalsamiqViewer', () => {
|
|||
beforeEach(() => {
|
||||
preview = ['{}', '{ "id": 1 }'];
|
||||
|
||||
spyOn(JSON, 'parse').and.callThrough();
|
||||
jest.spyOn(JSON, 'parse');
|
||||
|
||||
parsePreview = BalsamiqViewer.parsePreview(preview);
|
||||
});
|
||||
|
|
@ -337,7 +347,7 @@ describe('BalsamiqViewer', () => {
|
|||
beforeEach(() => {
|
||||
title = { values: [['{}', '{}', '{"name":"name"}']] };
|
||||
|
||||
spyOn(JSON, 'parse').and.callThrough();
|
||||
jest.spyOn(JSON, 'parse');
|
||||
|
||||
parseTitle = BalsamiqViewer.parseTitle(title);
|
||||
});
|
||||
|
|
@ -440,23 +440,6 @@ describe('boardsStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('allBoards', () => {
|
||||
const url = `${endpoints.boardsEndpoint}.json`;
|
||||
|
||||
it('makes a request to fetch all boards', () => {
|
||||
axiosMock.onGet(url).replyOnce(200, dummyResponse);
|
||||
const expectedResponse = expect.objectContaining({ data: dummyResponse });
|
||||
|
||||
return expect(boardsStore.allBoards()).resolves.toEqual(expectedResponse);
|
||||
});
|
||||
|
||||
it('fails for error response', () => {
|
||||
axiosMock.onGet(url).replyOnce(500);
|
||||
|
||||
return expect(boardsStore.allBoards()).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('recentBoards', () => {
|
||||
const url = `${endpoints.recentBoardsEndpoint}.json`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Vue from 'vue';
|
||||
import { nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { GlDropdown } from '@gitlab/ui';
|
||||
import { GlDropdown, GlLoadingIcon } from '@gitlab/ui';
|
||||
import { TEST_HOST } from 'spec/test_constants';
|
||||
import BoardsSelector from '~/boards/components/boards_selector.vue';
|
||||
import boardsStore from '~/boards/stores/boards_store';
|
||||
|
|
@ -8,7 +8,8 @@ import boardsStore from '~/boards/stores/boards_store';
|
|||
const throttleDuration = 1;
|
||||
|
||||
function boardGenerator(n) {
|
||||
return new Array(n).fill().map((board, id) => {
|
||||
return new Array(n).fill().map((board, index) => {
|
||||
const id = `${index}`;
|
||||
const name = `board${id}`;
|
||||
|
||||
return {
|
||||
|
|
@ -34,8 +35,17 @@ describe('BoardsSelector', () => {
|
|||
|
||||
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
|
||||
const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header');
|
||||
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
|
||||
|
||||
beforeEach(() => {
|
||||
const $apollo = {
|
||||
queries: {
|
||||
boards: {
|
||||
loading: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
boardsStore.setEndpoints({
|
||||
boardsEndpoint: '',
|
||||
recentBoardsEndpoint: '',
|
||||
|
|
@ -45,7 +55,13 @@ describe('BoardsSelector', () => {
|
|||
});
|
||||
|
||||
allBoardsResponse = Promise.resolve({
|
||||
data: boards,
|
||||
data: {
|
||||
group: {
|
||||
boards: {
|
||||
edges: boards.map(board => ({ node: board })),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
recentBoardsResponse = Promise.resolve({
|
||||
data: recentBoards,
|
||||
|
|
@ -54,8 +70,7 @@ describe('BoardsSelector', () => {
|
|||
boardsStore.allBoards = jest.fn(() => allBoardsResponse);
|
||||
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
|
||||
|
||||
const Component = Vue.extend(BoardsSelector);
|
||||
wrapper = mount(Component, {
|
||||
wrapper = mount(BoardsSelector, {
|
||||
propsData: {
|
||||
throttleDuration,
|
||||
currentBoard: {
|
||||
|
|
@ -77,13 +92,18 @@ describe('BoardsSelector', () => {
|
|||
scopedIssueBoardFeatureEnabled: true,
|
||||
weights: [],
|
||||
},
|
||||
mocks: { $apollo },
|
||||
attachToDocument: true,
|
||||
});
|
||||
|
||||
wrapper.vm.$apollo.addSmartQuery = jest.fn((_, options) => {
|
||||
wrapper.setData({
|
||||
[options.loadingKey]: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
|
||||
wrapper.find(GlDropdown).vm.$emit('show');
|
||||
|
||||
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -91,64 +111,99 @@ describe('BoardsSelector', () => {
|
|||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
it('shows all boards without filtering', () => {
|
||||
expect(getDropdownItems().length).toBe(boards.length + recentBoards.length);
|
||||
describe('loading', () => {
|
||||
// we are testing loading state, so don't resolve responses until after the tests
|
||||
afterEach(() => {
|
||||
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
|
||||
});
|
||||
|
||||
it('shows only matching boards when filtering', () => {
|
||||
const filterTerm = 'board1';
|
||||
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
|
||||
|
||||
fillSearchBox(filterTerm);
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(getDropdownItems().length).toBe(expectedCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows message if there are no matching boards', () => {
|
||||
fillSearchBox('does not exist');
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(getDropdownItems().length).toBe(0);
|
||||
expect(wrapper.text().includes('No matching boards found')).toBe(true);
|
||||
});
|
||||
it('shows loading spinner', () => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
expect(getDropdownItems()).toHaveLength(0);
|
||||
expect(getLoadingIcon().exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recent boards section', () => {
|
||||
it('shows only when boards are greater than 10', () => {
|
||||
const expectedCount = 2; // Recent + All
|
||||
|
||||
expect(getDropdownHeaders().length).toBe(expectedCount);
|
||||
describe('loaded', () => {
|
||||
beforeEach(() => {
|
||||
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
|
||||
});
|
||||
|
||||
it('does not show when boards are less than 10', () => {
|
||||
wrapper.setData({
|
||||
boards: boards.slice(0, 5),
|
||||
it('hides loading spinner', () => {
|
||||
expect(getLoadingIcon().exists()).toBe(false);
|
||||
});
|
||||
|
||||
describe('filtering', () => {
|
||||
beforeEach(() => {
|
||||
wrapper.setData({
|
||||
boards,
|
||||
});
|
||||
|
||||
return nextTick();
|
||||
});
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(getDropdownHeaders().length).toBe(0);
|
||||
it('shows all boards without filtering', () => {
|
||||
expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
|
||||
});
|
||||
|
||||
it('shows only matching boards when filtering', () => {
|
||||
const filterTerm = 'board1';
|
||||
const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length;
|
||||
|
||||
fillSearchBox(filterTerm);
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownItems()).toHaveLength(expectedCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows message if there are no matching boards', () => {
|
||||
fillSearchBox('does not exist');
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownItems()).toHaveLength(0);
|
||||
expect(wrapper.text().includes('No matching boards found')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show when recentBoards api returns empty array', () => {
|
||||
wrapper.setData({
|
||||
recentBoards: [],
|
||||
describe('recent boards section', () => {
|
||||
it('shows only when boards are greater than 10', () => {
|
||||
wrapper.setData({
|
||||
boards,
|
||||
});
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(getDropdownHeaders().length).toBe(0);
|
||||
it('does not show when boards are less than 10', () => {
|
||||
wrapper.setData({
|
||||
boards: boards.slice(0, 5),
|
||||
});
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show when search is active', () => {
|
||||
fillSearchBox('Random string');
|
||||
it('does not show when recentBoards api returns empty array', () => {
|
||||
wrapper.setData({
|
||||
recentBoards: [],
|
||||
});
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
expect(getDropdownHeaders().length).toBe(0);
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not show when search is active', () => {
|
||||
fillSearchBox('Random string');
|
||||
|
||||
return nextTick().then(() => {
|
||||
expect(getDropdownHeaders()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,124 @@
|
|||
import { mount, shallowMount } from '@vue/test-utils';
|
||||
|
||||
import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue';
|
||||
import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue';
|
||||
|
||||
describe('Project Feature Settings', () => {
|
||||
const defaultProps = {
|
||||
name: 'Test',
|
||||
options: [[1, 1], [2, 2], [3, 3], [4, 4], [5, 5]],
|
||||
value: 1,
|
||||
disabledInput: false,
|
||||
};
|
||||
let wrapper;
|
||||
|
||||
const mountComponent = customProps => {
|
||||
const propsData = { ...defaultProps, ...customProps };
|
||||
return shallowMount(projectFeatureSetting, { propsData });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('Hidden name input', () => {
|
||||
it('should set the hidden name input if the name exists', () => {
|
||||
expect(wrapper.find({ name: 'Test' }).props().value).toBe(1);
|
||||
});
|
||||
|
||||
it('should not set the hidden name input if the name does not exist', () => {
|
||||
wrapper.setProps({ name: null });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ name: 'Test' }).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature toggle', () => {
|
||||
it('should enable the feature toggle if the value is not 0', () => {
|
||||
expect(wrapper.find(projectFeatureToggle).props().value).toBe(true);
|
||||
});
|
||||
|
||||
it('should enable the feature toggle if the value is less than 0', () => {
|
||||
wrapper.setProps({ value: -1 });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find(projectFeatureToggle).props().value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable the feature toggle if the value is 0', () => {
|
||||
wrapper.setProps({ value: 0 });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find(projectFeatureToggle).props().value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable the feature toggle if disabledInput is set', () => {
|
||||
wrapper.setProps({ disabledInput: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find(projectFeatureToggle).props().disabledInput).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit a change event when the feature toggle changes', () => {
|
||||
// Needs to be fully mounted to be able to trigger the click event on the internal button
|
||||
wrapper = mount(projectFeatureSetting, { propsData: defaultProps });
|
||||
|
||||
expect(wrapper.emitted().change).toBeUndefined();
|
||||
wrapper
|
||||
.find(projectFeatureToggle)
|
||||
.find('button')
|
||||
.trigger('click');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted().change.length).toBe(1);
|
||||
expect(wrapper.emitted().change[0]).toEqual([0]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Project repo select', () => {
|
||||
it.each`
|
||||
disabledInput | value | options | isDisabled
|
||||
${true} | ${0} | ${[[1, 1]]} | ${true}
|
||||
${true} | ${1} | ${[[1, 1], [2, 2], [3, 3]]} | ${true}
|
||||
${false} | ${0} | ${[[1, 1], [2, 2], [3, 3]]} | ${true}
|
||||
${false} | ${1} | ${[[1, 1]]} | ${true}
|
||||
${false} | ${1} | ${[[1, 1], [2, 2], [3, 3]]} | ${false}
|
||||
`(
|
||||
'should set disabled to $isDisabled when disabledInput is $disabledInput, the value is $value and options are $options',
|
||||
({ disabledInput, value, options, isDisabled }) => {
|
||||
wrapper.setProps({ disabledInput, value, options });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
if (isDisabled) {
|
||||
expect(wrapper.find('select').attributes().disabled).toEqual('disabled');
|
||||
} else {
|
||||
expect(wrapper.find('select').attributes().disabled).toBeUndefined();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should emit the change when a new option is selected', () => {
|
||||
expect(wrapper.emitted().change).toBeUndefined();
|
||||
wrapper
|
||||
.findAll('option')
|
||||
.at(1)
|
||||
.trigger('change');
|
||||
|
||||
return wrapper.vm.$nextTick().then(() => {
|
||||
expect(wrapper.emitted().change.length).toBe(1);
|
||||
expect(wrapper.emitted().change[0]).toEqual([2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import projectSettingRow from '~/pages/projects/shared/permissions/components/project_setting_row.vue';
|
||||
|
||||
describe('Project Setting Row', () => {
|
||||
let wrapper;
|
||||
|
||||
const mountComponent = (customProps = {}) => {
|
||||
const propsData = { ...customProps };
|
||||
return shallowMount(projectSettingRow, { propsData });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('should show the label if it is set', () => {
|
||||
wrapper.setProps({ label: 'Test label' });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('label').text()).toEqual('Test label');
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the label if it is not set', () => {
|
||||
expect(wrapper.find('label').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('should show the help icon with the correct help path if it is set', () => {
|
||||
wrapper.setProps({ label: 'Test label', helpPath: '/123' });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
const link = wrapper.find('a');
|
||||
|
||||
expect(link.exists()).toBe(true);
|
||||
expect(link.attributes().href).toEqual('/123');
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the help icon if no help path is set', () => {
|
||||
wrapper.setProps({ label: 'Test label' });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('a').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the help text if it is set', () => {
|
||||
wrapper.setProps({ helpText: 'Test text' });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('span').text()).toEqual('Test text');
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the help text if it is set', () => {
|
||||
expect(wrapper.find('span').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,434 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
import settingsPanel from '~/pages/projects/shared/permissions/components/settings_panel.vue';
|
||||
import {
|
||||
featureAccessLevel,
|
||||
visibilityLevelDescriptions,
|
||||
visibilityOptions,
|
||||
} from '~/pages/projects/shared/permissions/constants';
|
||||
|
||||
const defaultProps = {
|
||||
currentSettings: {
|
||||
visibilityLevel: 10,
|
||||
requestAccessEnabled: true,
|
||||
issuesAccessLevel: 20,
|
||||
repositoryAccessLevel: 20,
|
||||
forkingAccessLevel: 20,
|
||||
mergeRequestsAccessLevel: 20,
|
||||
buildsAccessLevel: 20,
|
||||
wikiAccessLevel: 20,
|
||||
snippetsAccessLevel: 20,
|
||||
pagesAccessLevel: 10,
|
||||
containerRegistryEnabled: true,
|
||||
lfsEnabled: true,
|
||||
emailsDisabled: false,
|
||||
packagesEnabled: true,
|
||||
},
|
||||
canDisableEmails: true,
|
||||
canChangeVisibilityLevel: true,
|
||||
allowedVisibilityOptions: [0, 10, 20],
|
||||
visibilityHelpPath: '/help/public_access/public_access',
|
||||
registryAvailable: false,
|
||||
registryHelpPath: '/help/user/packages/container_registry/index',
|
||||
lfsAvailable: true,
|
||||
lfsHelpPath: '/help/workflow/lfs/manage_large_binaries_with_git_lfs',
|
||||
pagesAvailable: true,
|
||||
pagesAccessControlEnabled: false,
|
||||
pagesAccessControlForced: false,
|
||||
pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control-core',
|
||||
packagesAvailable: false,
|
||||
packagesHelpPath: '/help/user/packages/index',
|
||||
};
|
||||
|
||||
describe('Settings Panel', () => {
|
||||
let wrapper;
|
||||
|
||||
const mountComponent = customProps => {
|
||||
const propsData = { ...defaultProps, ...customProps };
|
||||
return shallowMount(settingsPanel, { propsData });
|
||||
};
|
||||
|
||||
const overrideCurrentSettings = (currentSettingsProps, extraProps = {}) => {
|
||||
return mountComponent({
|
||||
...extraProps,
|
||||
currentSettings: {
|
||||
...defaultProps.currentSettings,
|
||||
...currentSettingsProps,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
wrapper = mountComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('Project Visibility', () => {
|
||||
it('should set the project visibility help path', () => {
|
||||
expect(wrapper.find({ ref: 'project-visibility-settings' }).props().helpPath).toBe(
|
||||
defaultProps.visibilityHelpPath,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not disable the visibility level dropdown', () => {
|
||||
wrapper.setProps({ canChangeVisibilityLevel: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(
|
||||
wrapper.find('[name="project[visibility_level]"]').attributes().disabled,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable the visibility level dropdown', () => {
|
||||
wrapper.setProps({ canChangeVisibilityLevel: false });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find('[name="project[visibility_level]"]').attributes().disabled).toBe(
|
||||
'disabled',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it.each`
|
||||
option | allowedOptions | disabled
|
||||
${visibilityOptions.PRIVATE} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${false}
|
||||
${visibilityOptions.PRIVATE} | ${[visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${true}
|
||||
${visibilityOptions.INTERNAL} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${false}
|
||||
${visibilityOptions.INTERNAL} | ${[visibilityOptions.PRIVATE, visibilityOptions.PUBLIC]} | ${true}
|
||||
${visibilityOptions.PUBLIC} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL, visibilityOptions.PUBLIC]} | ${false}
|
||||
${visibilityOptions.PUBLIC} | ${[visibilityOptions.PRIVATE, visibilityOptions.INTERNAL]} | ${true}
|
||||
`(
|
||||
'sets disabled to $disabled for the visibility option $option when given $allowedOptions',
|
||||
({ option, allowedOptions, disabled }) => {
|
||||
wrapper.setProps({ allowedVisibilityOptions: allowedOptions });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
const attributeValue = wrapper
|
||||
.find(`[name="project[visibility_level]"] option[value="${option}"]`)
|
||||
.attributes().disabled;
|
||||
|
||||
if (disabled) {
|
||||
expect(attributeValue).toBe('disabled');
|
||||
} else {
|
||||
expect(attributeValue).toBeUndefined();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should set the visibility level description based upon the selected visibility level', () => {
|
||||
wrapper.find('[name="project[visibility_level]"]').setValue(visibilityOptions.INTERNAL);
|
||||
|
||||
expect(wrapper.find({ ref: 'project-visibility-settings' }).text()).toContain(
|
||||
visibilityLevelDescriptions[visibilityOptions.INTERNAL],
|
||||
);
|
||||
});
|
||||
|
||||
it('should show the request access checkbox if the visibility level is not private', () => {
|
||||
wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.INTERNAL });
|
||||
|
||||
expect(wrapper.find('[name="project[request_access_enabled]"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not show the request access checkbox if the visibility level is private', () => {
|
||||
wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
|
||||
|
||||
expect(wrapper.find('[name="project[request_access_enabled]"]').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Repository', () => {
|
||||
it('should set the repository help text when the visibility level is set to private', () => {
|
||||
wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE });
|
||||
|
||||
expect(wrapper.find({ ref: 'repository-settings' }).props().helpText).toEqual(
|
||||
'View and edit files in this project',
|
||||
);
|
||||
});
|
||||
|
||||
it('should set the repository help text with a read access warning when the visibility level is set to non-private', () => {
|
||||
wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PUBLIC });
|
||||
|
||||
expect(wrapper.find({ ref: 'repository-settings' }).props().helpText).toEqual(
|
||||
'View and edit files in this project. Non-project members will only have read access',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Merge requests', () => {
|
||||
it('should enable the merge requests access level input when the repository is enabled', () => {
|
||||
wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.EVERYONE });
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[name="project[project_feature_attributes][merge_requests_access_level]"]')
|
||||
.props().disabledInput,
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('should disable the merge requests access level input when the repository is disabled', () => {
|
||||
wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED });
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.find('[name="project[project_feature_attributes][merge_requests_access_level]"]')
|
||||
.props().disabledInput,
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Forks', () => {
|
||||
it('should enable the forking access level input when the repository is enabled', () => {
|
||||
wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.EVERYONE });
|
||||
|
||||
expect(
|
||||
wrapper.find('[name="project[project_feature_attributes][forking_access_level]"]').props()
|
||||
.disabledInput,
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('should disable the forking access level input when the repository is disabled', () => {
|
||||
wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED });
|
||||
|
||||
expect(
|
||||
wrapper.find('[name="project[project_feature_attributes][forking_access_level]"]').props()
|
||||
.disabledInput,
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pipelines', () => {
|
||||
it('should enable the builds access level input when the repository is enabled', () => {
|
||||
wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.EVERYONE });
|
||||
|
||||
expect(
|
||||
wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]').props()
|
||||
.disabledInput,
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('should disable the builds access level input when the repository is disabled', () => {
|
||||
wrapper = overrideCurrentSettings({ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED });
|
||||
|
||||
expect(
|
||||
wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]').props()
|
||||
.disabledInput,
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Container registry', () => {
|
||||
it('should show the container registry settings if the registry is available', () => {
|
||||
wrapper.setProps({ registryAvailable: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'container-registry-settings' }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the container registry settings if the registry is not available', () => {
|
||||
wrapper.setProps({ registryAvailable: false });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'container-registry-settings' }).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the container registry settings help path', () => {
|
||||
wrapper.setProps({ registryAvailable: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'container-registry-settings' }).props().helpPath).toBe(
|
||||
defaultProps.registryHelpPath,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the container registry public note if the visibility level is public and the registry is available', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ visibilityLevel: visibilityOptions.PUBLIC },
|
||||
{ registryAvailable: true },
|
||||
);
|
||||
|
||||
expect(wrapper.find({ ref: 'container-registry-settings' }).text()).toContain(
|
||||
'Note: the container registry is always visible when a project is public',
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide the container registry public note if the visibility level is private and the registry is available', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ visibilityLevel: visibilityOptions.PRIVATE },
|
||||
{ registryAvailable: true },
|
||||
);
|
||||
|
||||
expect(wrapper.find({ ref: 'container-registry-settings' }).text()).not.toContain(
|
||||
'Note: the container registry is always visible when a project is public',
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable the container registry input when the repository is enabled', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ repositoryAccessLevel: featureAccessLevel.EVERYONE },
|
||||
{ registryAvailable: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[name="project[container_registry_enabled]"]').props().disabledInput,
|
||||
).toEqual(false);
|
||||
});
|
||||
|
||||
it('should disable the container registry input when the repository is disabled', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
|
||||
{ registryAvailable: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('[name="project[container_registry_enabled]"]').props().disabledInput,
|
||||
).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git Large File Storage', () => {
|
||||
it('should show the LFS settings if LFS is available', () => {
|
||||
wrapper.setProps({ lfsAvailable: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'git-lfs-settings' }).exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the LFS settings if LFS is not available', () => {
|
||||
wrapper.setProps({ lfsAvailable: false });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'git-lfs-settings' }).exists()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the LFS settings help path', () => {
|
||||
expect(wrapper.find({ ref: 'git-lfs-settings' }).props().helpPath).toBe(
|
||||
defaultProps.lfsHelpPath,
|
||||
);
|
||||
});
|
||||
|
||||
it('should enable the LFS input when the repository is enabled', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ repositoryAccessLevel: featureAccessLevel.EVERYONE },
|
||||
{ lfsAvailable: true },
|
||||
);
|
||||
|
||||
expect(wrapper.find('[name="project[lfs_enabled]"]').props().disabledInput).toEqual(false);
|
||||
});
|
||||
|
||||
it('should disable the LFS input when the repository is disabled', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
|
||||
{ lfsAvailable: true },
|
||||
);
|
||||
|
||||
expect(wrapper.find('[name="project[lfs_enabled]"]').props().disabledInput).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Packages', () => {
|
||||
it('should show the packages settings if packages are available', () => {
|
||||
wrapper.setProps({ packagesAvailable: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'package-settings' }).exists()).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the packages settings if packages are not available', () => {
|
||||
wrapper.setProps({ packagesAvailable: false });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'package-settings' }).exists()).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should set the package settings help path', () => {
|
||||
wrapper.setProps({ packagesAvailable: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'package-settings' }).props().helpPath).toBe(
|
||||
defaultProps.packagesHelpPath,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should enable the packages input when the repository is enabled', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ repositoryAccessLevel: featureAccessLevel.EVERYONE },
|
||||
{ packagesAvailable: true },
|
||||
);
|
||||
|
||||
expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toEqual(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable the packages input when the repository is disabled', () => {
|
||||
wrapper = overrideCurrentSettings(
|
||||
{ repositoryAccessLevel: featureAccessLevel.NOT_ENABLED },
|
||||
{ packagesAvailable: true },
|
||||
);
|
||||
|
||||
expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toEqual(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pages', () => {
|
||||
it.each`
|
||||
pagesAvailable | pagesAccessControlEnabled | visibility
|
||||
${true} | ${true} | ${'show'}
|
||||
${true} | ${false} | ${'hide'}
|
||||
${false} | ${true} | ${'hide'}
|
||||
${false} | ${false} | ${'hide'}
|
||||
`(
|
||||
'should $visibility the page settings if pagesAvailable is $pagesAvailable and pagesAccessControlEnabled is $pagesAccessControlEnabled',
|
||||
({ pagesAvailable, pagesAccessControlEnabled, visibility }) => {
|
||||
wrapper.setProps({ pagesAvailable, pagesAccessControlEnabled });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'pages-settings' }).exists()).toBe(visibility === 'show');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('should set the pages settings help path', () => {
|
||||
wrapper.setProps({ pagesAvailable: true, pagesAccessControlEnabled: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'pages-settings' }).props().helpPath).toBe(
|
||||
defaultProps.pagesHelpPath,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Email notifications', () => {
|
||||
it('should show the disable email notifications input if emails an be disabled', () => {
|
||||
wrapper.setProps({ canDisableEmails: true });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'email-settings' }).exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should hide the disable email notifications input if emails cannot be disabled', () => {
|
||||
wrapper.setProps({ canDisableEmails: false });
|
||||
|
||||
return wrapper.vm.$nextTick(() => {
|
||||
expect(wrapper.find({ ref: 'email-settings' }).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Snippet Description Edit component rendering matches the snapshot 1`] = `
|
||||
<div
|
||||
class="form-group js-description-input"
|
||||
>
|
||||
<label>
|
||||
Description (optional)
|
||||
</label>
|
||||
|
||||
<div
|
||||
class="js-collapsible-input"
|
||||
>
|
||||
<div
|
||||
class="js-collapsed d-none"
|
||||
>
|
||||
<gl-form-input-stub
|
||||
class="form-control"
|
||||
data-qa-selector="description_placeholder"
|
||||
placeholder="Optionally add a description about what your snippet does or how to use it…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<markdown-field-stub
|
||||
addspacingclasses="true"
|
||||
canattachfile="true"
|
||||
class="js-expanded"
|
||||
enableautocomplete="true"
|
||||
helppagepath=""
|
||||
markdowndocspath="help/"
|
||||
markdownpreviewpath="foo/"
|
||||
note="[object Object]"
|
||||
quickactionsdocspath=""
|
||||
textareavalue=""
|
||||
>
|
||||
<textarea
|
||||
aria-label="Description"
|
||||
class="note-textarea js-gfm-input js-autosize markdown-area
|
||||
qa-description-textarea"
|
||||
data-supports-quick-actions="false"
|
||||
dir="auto"
|
||||
id="snippet-description"
|
||||
placeholder="Write a comment or drag your files here…"
|
||||
/>
|
||||
</markdown-field-stub>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
|
||||
describe('Snippet Description Edit component', () => {
|
||||
let wrapper;
|
||||
const defaultDescription = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
|
||||
const markdownPreviewPath = 'foo/';
|
||||
const markdownDocsPath = 'help/';
|
||||
|
||||
function createComponent(description = defaultDescription) {
|
||||
wrapper = shallowMount(SnippetDescriptionEdit, {
|
||||
propsData: {
|
||||
description,
|
||||
markdownPreviewPath,
|
||||
markdownDocsPath,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isHidden(sel) {
|
||||
return wrapper.find(sel).classes('d-none');
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('matches the snapshot', () => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('renders the field expanded when description exists', () => {
|
||||
expect(wrapper.find('.js-collapsed').classes('d-none')).toBe(true);
|
||||
expect(wrapper.find('.js-expanded').classes('d-none')).toBe(false);
|
||||
|
||||
expect(isHidden('.js-collapsed')).toBe(true);
|
||||
expect(isHidden('.js-expanded')).toBe(false);
|
||||
});
|
||||
|
||||
it('renders the field collapsed if there is no description yet', () => {
|
||||
createComponent('');
|
||||
|
||||
expect(isHidden('.js-collapsed')).toBe(false);
|
||||
expect(isHidden('.js-expanded')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -259,16 +259,40 @@ describe('mrWidgetOptions', () => {
|
|||
|
||||
describe('methods', () => {
|
||||
describe('checkStatus', () => {
|
||||
it('should tell service to check status', () => {
|
||||
let cb;
|
||||
let isCbExecuted;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(vm.service, 'checkStatus').mockReturnValue(returnPromise(mockData));
|
||||
jest.spyOn(vm.mr, 'setData').mockImplementation(() => {});
|
||||
jest.spyOn(vm, 'handleNotification').mockImplementation(() => {});
|
||||
|
||||
let isCbExecuted = false;
|
||||
const cb = () => {
|
||||
isCbExecuted = false;
|
||||
cb = () => {
|
||||
isCbExecuted = true;
|
||||
};
|
||||
});
|
||||
|
||||
it('should not tell service to check status if document is not visible', () => {
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'hidden',
|
||||
configurable: true,
|
||||
});
|
||||
vm.checkStatus(cb);
|
||||
|
||||
return vm.$nextTick().then(() => {
|
||||
expect(vm.service.checkStatus).not.toHaveBeenCalled();
|
||||
expect(vm.mr.setData).not.toHaveBeenCalled();
|
||||
expect(vm.handleNotification).not.toHaveBeenCalled();
|
||||
expect(isCbExecuted).toBeFalsy();
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
value: 'visible',
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should tell service to check status if document is visible', () => {
|
||||
vm.checkStatus(cb);
|
||||
|
||||
return vm.$nextTick().then(() => {
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe ProjectPolicy do
|
|||
admin_snippet admin_project_member admin_note admin_wiki admin_project
|
||||
admin_commit_status admin_build admin_container_image
|
||||
admin_pipeline admin_environment admin_deployment destroy_release add_cluster
|
||||
daily_statistics read_deploy_token
|
||||
daily_statistics read_deploy_token create_deploy_token
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -133,4 +133,57 @@ describe API::DeployTokens do
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /projects/:id/deploy_tokens' do
|
||||
let(:params) do
|
||||
{
|
||||
name: 'Foo',
|
||||
expires_at: 1.year.from_now,
|
||||
scopes: [
|
||||
'read_repository'
|
||||
],
|
||||
username: 'Bar'
|
||||
}
|
||||
end
|
||||
|
||||
subject do
|
||||
post api("/projects/#{project.id}/deploy_tokens", user), params: params
|
||||
response
|
||||
end
|
||||
|
||||
context 'when unauthenticated' do
|
||||
let(:user) { nil }
|
||||
|
||||
it { is_expected.to have_gitlab_http_status(:not_found) }
|
||||
end
|
||||
|
||||
context 'when authenticated as non-admin user' do
|
||||
before do
|
||||
project.add_developer(user)
|
||||
end
|
||||
|
||||
it { is_expected.to have_gitlab_http_status(:forbidden) }
|
||||
end
|
||||
|
||||
context 'when authenticated as maintainer' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
end
|
||||
|
||||
it 'creates the deploy token' do
|
||||
expect { subject }.to change { DeployToken.count }.by(1)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:created)
|
||||
expect(response).to match_response_schema('public_api/v4/deploy_token')
|
||||
end
|
||||
|
||||
context 'with an invalid scope' do
|
||||
before do
|
||||
params[:scopes] = %w[read_repository all_access]
|
||||
end
|
||||
|
||||
it { is_expected.to have_gitlab_http_status(:bad_request) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in New Issue