Commit 82fa8a3d authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 232655bf
...@@ -10,6 +10,11 @@ import { ...@@ -10,6 +10,11 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import httpStatusCodes from '~/lib/utils/http_status'; 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 boardsStore from '../stores/boards_store';
import BoardForm from './board_form.vue'; import BoardForm from './board_form.vue';
...@@ -88,8 +93,9 @@ export default { ...@@ -88,8 +93,9 @@ export default {
}, },
data() { data() {
return { return {
loading: true,
hasScrollFade: false, hasScrollFade: false,
loadingBoards: 0,
loadingRecentBoards: false,
scrollFadeInitialized: false, scrollFadeInitialized: false,
boards: [], boards: [],
recentBoards: [], recentBoards: [],
...@@ -102,6 +108,12 @@ export default { ...@@ -102,6 +108,12 @@ export default {
}; };
}, },
computed: { computed: {
parentType() {
return this.groupId ? 'group' : 'project';
},
loading() {
return this.loadingRecentBoards && this.loadingBoards;
},
currentPage() { currentPage() {
return this.state.currentPage; return this.state.currentPage;
}, },
...@@ -147,49 +159,71 @@ export default { ...@@ -147,49 +159,71 @@ export default {
return; return;
} }
const recentBoardsPromise = new Promise((resolve, reject) => 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,
}));
},
});
this.loadingRecentBoards = true;
boardsStore boardsStore
.recentBoards() .recentBoards()
.then(resolve) .then(res => {
this.recentBoards = res.data;
})
.catch(err => { .catch(err => {
/** /**
* If user is unauthorized we'd still want to resolve the * If user is unauthorized we'd still want to resolve the
* request to display all boards. * request to display all boards.
*/ */
if (err.response.status === httpStatusCodes.UNAUTHORIZED) { if (err?.response?.status === httpStatusCodes.UNAUTHORIZED) {
resolve({ data: [] }); // recent boards are empty this.recentBoards = []; // recent boards are empty
return; return;
} }
reject(err); throw err;
}),
);
Promise.all([boardsStore.allBoards(), recentBoardsPromise])
.then(([allBoards, recentBoards]) => [allBoards.data, recentBoards.data])
.then(([allBoardsJson, recentBoardsJson]) => {
this.loading = false;
this.boards = allBoardsJson;
this.recentBoards = recentBoardsJson;
}) })
.then(() => this.$nextTick()) // Wait for boards list in DOM .then(() => this.$nextTick()) // Wait for boards list in DOM
.then(() => { .then(() => {
this.setScrollFade(); this.setScrollFade();
}) })
.catch(() => { .catch(() => {})
this.loading = false; .finally(() => {
this.loadingRecentBoards = false;
}); });
}, },
isScrolledUp() { isScrolledUp() {
const { content } = this.$refs; const { content } = this.$refs;
if (!content) {
return false;
}
const currentPosition = this.contentClientHeight + content.scrollTop; const currentPosition = this.contentClientHeight + content.scrollTop;
return content && currentPosition < this.maxPosition; return currentPosition < this.maxPosition;
}, },
initScrollFade() { initScrollFade() {
this.scrollFadeInitialized = true;
const { content } = this.$refs; const { content } = this.$refs;
if (!content) {
return;
}
this.scrollFadeInitialized = true;
this.contentClientHeight = content.clientHeight; this.contentClientHeight = content.clientHeight;
this.maxPosition = content.scrollHeight; this.maxPosition = content.scrollHeight;
}, },
......
...@@ -98,6 +98,7 @@ export default () => { ...@@ -98,6 +98,7 @@ export default () => {
listsEndpoint: this.listsEndpoint, listsEndpoint: this.listsEndpoint,
bulkUpdatePath: this.bulkUpdatePath, bulkUpdatePath: this.bulkUpdatePath,
boardId: this.boardId, boardId: this.boardId,
fullPath: $boardApp.dataset.fullPath,
}); });
boardsStore.rootPath = this.boardsEndpoint; boardsStore.rootPath = this.boardsEndpoint;
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import BoardsSelector from '~/boards/components/boards_selector.vue'; import BoardsSelector from '~/boards/components/boards_selector.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => { export default () => {
const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher');
return new Vue({ return new Vue({
...@@ -9,6 +17,7 @@ export default () => { ...@@ -9,6 +17,7 @@ export default () => {
components: { components: {
BoardsSelector, BoardsSelector,
}, },
apolloProvider,
data() { data() {
const { dataset } = boardsSwitcherElement; const { dataset } = boardsSwitcherElement;
......
fragment BoardFragment on Board {
id,
name
}
#import "ee_else_ce/boards/queries/board.fragment.graphql"
query group_boards($fullPath: ID!) {
group(fullPath: $fullPath) {
boards {
edges {
node {
...BoardFragment
}
}
}
}
}
#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 = { ...@@ -45,7 +45,14 @@ const boardsStore = {
}, },
multiSelect: { list: [] }, multiSelect: { list: [] },
setEndpoints({ boardsEndpoint, listsEndpoint, bulkUpdatePath, boardId, recentBoardsEndpoint }) { setEndpoints({
boardsEndpoint,
listsEndpoint,
bulkUpdatePath,
boardId,
recentBoardsEndpoint,
fullPath,
}) {
const listsEndpointGenerate = `${listsEndpoint}/generate.json`; const listsEndpointGenerate = `${listsEndpoint}/generate.json`;
this.state.endpoints = { this.state.endpoints = {
boardsEndpoint, boardsEndpoint,
...@@ -53,6 +60,7 @@ const boardsStore = { ...@@ -53,6 +60,7 @@ const boardsStore = {
listsEndpoint, listsEndpoint,
listsEndpointGenerate, listsEndpointGenerate,
bulkUpdatePath, bulkUpdatePath,
fullPath,
recentBoardsEndpoint: `${recentBoardsEndpoint}.json`, recentBoardsEndpoint: `${recentBoardsEndpoint}.json`,
}; };
}, },
...@@ -542,10 +550,6 @@ const boardsStore = { ...@@ -542,10 +550,6 @@ const boardsStore = {
return axios.post(endpoint); return axios.post(endpoint);
}, },
allBoards() {
return axios.get(this.generateBoardsPath());
},
recentBoards() { recentBoards() {
return axios.get(this.state.endpoints.recentBoardsEndpoint); return axios.get(this.state.endpoints.recentBoardsEndpoint);
}, },
......
...@@ -165,6 +165,16 @@ export default { ...@@ -165,6 +165,16 @@ export default {
showContainerRegistryPublicNote() { showContainerRegistryPublicNote() {
return this.visibilityLevel === visibilityOptions.PUBLIC; 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: { watch: {
...@@ -225,6 +235,7 @@ export default { ...@@ -225,6 +235,7 @@ export default {
<div> <div>
<div class="project-visibility-setting"> <div class="project-visibility-setting">
<project-setting-row <project-setting-row
ref="project-visibility-settings"
:help-path="visibilityHelpPath" :help-path="visibilityHelpPath"
:label="s__('ProjectSettings|Project visibility')" :label="s__('ProjectSettings|Project visibility')"
> >
...@@ -270,6 +281,7 @@ export default { ...@@ -270,6 +281,7 @@ export default {
</div> </div>
<div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings"> <div :class="{ 'highlight-changes': highlightChangesClass }" class="project-feature-settings">
<project-setting-row <project-setting-row
ref="issues-settings"
:label="s__('ProjectSettings|Issues')" :label="s__('ProjectSettings|Issues')"
:help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')" :help-text="s__('ProjectSettings|Lightweight issue tracking system for this project')"
> >
...@@ -280,8 +292,9 @@ export default { ...@@ -280,8 +292,9 @@ export default {
/> />
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
ref="repository-settings"
:label="s__('ProjectSettings|Repository')" :label="s__('ProjectSettings|Repository')"
:help-text="s__('ProjectSettings|View and edit files in this project')" :help-text="repositoryHelpText"
> >
<project-feature-setting <project-feature-setting
v-model="repositoryAccessLevel" v-model="repositoryAccessLevel"
...@@ -291,6 +304,7 @@ export default { ...@@ -291,6 +304,7 @@ export default {
</project-setting-row> </project-setting-row>
<div class="project-feature-setting-group"> <div class="project-feature-setting-group">
<project-setting-row <project-setting-row
ref="merge-request-settings"
:label="s__('ProjectSettings|Merge requests')" :label="s__('ProjectSettings|Merge requests')"
:help-text="s__('ProjectSettings|Submit changes to be merged upstream')" :help-text="s__('ProjectSettings|Submit changes to be merged upstream')"
> >
...@@ -302,6 +316,7 @@ export default { ...@@ -302,6 +316,7 @@ export default {
/> />
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
ref="fork-settings"
:label="s__('ProjectSettings|Forks')" :label="s__('ProjectSettings|Forks')"
:help-text=" :help-text="
s__('ProjectSettings|Allow users to make copies of your repository to a new project') s__('ProjectSettings|Allow users to make copies of your repository to a new project')
...@@ -315,6 +330,7 @@ export default { ...@@ -315,6 +330,7 @@ export default {
/> />
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
ref="pipeline-settings"
:label="s__('ProjectSettings|Pipelines')" :label="s__('ProjectSettings|Pipelines')"
:help-text="s__('ProjectSettings|Build, test, and deploy your changes')" :help-text="s__('ProjectSettings|Build, test, and deploy your changes')"
> >
...@@ -327,6 +343,7 @@ export default { ...@@ -327,6 +343,7 @@ export default {
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
v-if="registryAvailable" v-if="registryAvailable"
ref="container-registry-settings"
:help-path="registryHelpPath" :help-path="registryHelpPath"
:label="s__('ProjectSettings|Container registry')" :label="s__('ProjectSettings|Container registry')"
:help-text=" :help-text="
...@@ -348,6 +365,7 @@ export default { ...@@ -348,6 +365,7 @@ export default {
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
v-if="lfsAvailable" v-if="lfsAvailable"
ref="git-lfs-settings"
:help-path="lfsHelpPath" :help-path="lfsHelpPath"
:label="s__('ProjectSettings|Git Large File Storage')" :label="s__('ProjectSettings|Git Large File Storage')"
:help-text=" :help-text="
...@@ -362,6 +380,7 @@ export default { ...@@ -362,6 +380,7 @@ export default {
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
v-if="packagesAvailable" v-if="packagesAvailable"
ref="package-settings"
:help-path="packagesHelpPath" :help-path="packagesHelpPath"
:label="s__('ProjectSettings|Packages')" :label="s__('ProjectSettings|Packages')"
:help-text=" :help-text="
...@@ -376,6 +395,7 @@ export default { ...@@ -376,6 +395,7 @@ export default {
</project-setting-row> </project-setting-row>
</div> </div>
<project-setting-row <project-setting-row
ref="wiki-settings"
:label="s__('ProjectSettings|Wiki')" :label="s__('ProjectSettings|Wiki')"
:help-text="s__('ProjectSettings|Pages for project documentation')" :help-text="s__('ProjectSettings|Pages for project documentation')"
> >
...@@ -386,6 +406,7 @@ export default { ...@@ -386,6 +406,7 @@ export default {
/> />
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
ref="snippet-settings"
:label="s__('ProjectSettings|Snippets')" :label="s__('ProjectSettings|Snippets')"
:help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')" :help-text="s__('ProjectSettings|Share code pastes with others out of Git repository')"
> >
...@@ -397,6 +418,7 @@ export default { ...@@ -397,6 +418,7 @@ export default {
</project-setting-row> </project-setting-row>
<project-setting-row <project-setting-row
v-if="pagesAvailable && pagesAccessControlEnabled" v-if="pagesAvailable && pagesAccessControlEnabled"
ref="pages-settings"
:help-path="pagesHelpPath" :help-path="pagesHelpPath"
:label="s__('ProjectSettings|Pages')" :label="s__('ProjectSettings|Pages')"
:help-text=" :help-text="
...@@ -410,7 +432,7 @@ export default { ...@@ -410,7 +432,7 @@ export default {
/> />
</project-setting-row> </project-setting-row>
</div> </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"> <label class="js-emails-disabled">
<input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" /> <input :value="emailsDisabled" type="hidden" name="project[emails_disabled]" />
<input v-model="emailsDisabled" type="checkbox" /> <input v-model="emailsDisabled" type="checkbox" />
......
<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 { ...@@ -212,6 +212,8 @@ export default {
return new MRWidgetService(this.getServiceEndpoints(store)); return new MRWidgetService(this.getServiceEndpoints(store));
}, },
checkStatus(cb, isRebased) { checkStatus(cb, isRebased) {
if (document.visibilityState !== 'visible') return Promise.resolve();
return this.service return this.service
.checkStatus() .checkStatus()
.then(({ data }) => { .then(({ data }) => {
......
...@@ -13,6 +13,7 @@ module BoardsHelper ...@@ -13,6 +13,7 @@ module BoardsHelper
disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s, disabled: (!can?(current_user, :create_non_backlog_issues, board)).to_s,
issue_link_base: build_issue_link_base, issue_link_base: build_issue_link_base,
root_path: root_path, root_path: root_path,
full_path: full_path,
bulk_update_path: @bulk_issues_path, bulk_update_path: @bulk_issues_path,
default_avatar: image_path(default_avatar), default_avatar: image_path(default_avatar),
time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s, time_tracking_limit_to_hours: Gitlab::CurrentSettings.time_tracking_limit_to_hours.to_s,
...@@ -20,6 +21,14 @@ module BoardsHelper ...@@ -20,6 +21,14 @@ module BoardsHelper
} }
end end
def full_path
if board.group_board?
@group.full_path
else
@project.full_path
end
end
def build_issue_link_base def build_issue_link_base
if board.group_board? if board.group_board?
"#{group_path(@board.group)}/:project_path/issues" "#{group_path(@board.group)}/:project_path/issues"
......
...@@ -313,6 +313,7 @@ class ProjectPolicy < BasePolicy ...@@ -313,6 +313,7 @@ class ProjectPolicy < BasePolicy
enable :daily_statistics enable :daily_statistics
enable :admin_operations enable :admin_operations
enable :read_deploy_token enable :read_deploy_token
enable :create_deploy_token
end end
rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror rule { (mirror_available & can?(:admin_project)) | admin }.enable :admin_remote_mirror
......
---
title: Update project's permission settings description to reflect actual permissions
merge_request: 25523
author:
type: other
---
title: Added Blob Description Edit component in Vue
merge_request: 26762
author:
type: added
---
title: Update charts documentation and common_metrics.yml to enable data formatting
merge_request: 26048
author:
type: added
---
title: Optimize event counters query performance in usage data
merge_request: 26444
author:
type: performance
---
title: Add api endpoint to create deploy tokens
merge_request: 25270
author:
type: added
---
title: Enable client-side GRPC keepalive for Gitaly
merge_request: 26536
author:
type: changed
---
title: Replace undefined severity with unknown severity for vulnerabilities
merge_request: 26305
author:
type: other
...@@ -59,5 +59,7 @@ Rails.application.configure do ...@@ -59,5 +59,7 @@ Rails.application.configure do
config.active_record.migration_error = false config.active_record.migration_error = false
config.active_record.verbose_query_logs = false config.active_record.verbose_query_logs = false
config.action_view.cache_template_loading = true config.action_view.cache_template_loading = true
config.middleware.delete BetterErrors::Middleware
end end
end end
...@@ -17,6 +17,8 @@ panel_groups: ...@@ -17,6 +17,8 @@ panel_groups:
- title: "Latency" - title: "Latency"
type: "area-chart" type: "area-chart"
y_label: "Latency (ms)" y_label: "Latency (ms)"
y_axis:
format: milliseconds
weight: 1 weight: 1
metrics: metrics:
- id: response_metrics_nginx_ingress_latency_pod_average - id: response_metrics_nginx_ingress_latency_pod_average
...@@ -26,6 +28,8 @@ panel_groups: ...@@ -26,6 +28,8 @@ panel_groups:
- title: "HTTP Error Rate" - title: "HTTP Error Rate"
type: "area-chart" type: "area-chart"
y_label: "HTTP Errors (%)" y_label: "HTTP Errors (%)"
y_axis:
format: percentHundred
weight: 1 weight: 1
metrics: metrics:
- id: response_metrics_nginx_ingress_http_error_rate - id: response_metrics_nginx_ingress_http_error_rate
...@@ -138,6 +142,8 @@ panel_groups: ...@@ -138,6 +142,8 @@ panel_groups:
- title: "HTTP Error Rate (Errors / Sec)" - title: "HTTP Error Rate (Errors / Sec)"
type: "area-chart" type: "area-chart"
y_label: "HTTP 500 Errors / Sec" y_label: "HTTP 500 Errors / Sec"
y_axis:
precision: 0
weight: 1 weight: 1
metrics: metrics:
- id: response_metrics_nginx_http_error_rate - id: response_metrics_nginx_http_error_rate
...@@ -150,6 +156,8 @@ panel_groups: ...@@ -150,6 +156,8 @@ panel_groups:
- title: "Memory Usage (Total)" - title: "Memory Usage (Total)"
type: "area-chart" type: "area-chart"
y_label: "Total Memory Used (GB)" y_label: "Total Memory Used (GB)"
y_axis:
format: "gibibytes"
weight: 4 weight: 4
metrics: metrics:
- id: system_metrics_kubernetes_container_memory_total - id: system_metrics_kubernetes_container_memory_total
...@@ -168,6 +176,8 @@ panel_groups: ...@@ -168,6 +176,8 @@ panel_groups:
- title: "Memory Usage (Pod average)" - title: "Memory Usage (Pod average)"
type: "line-chart" type: "line-chart"
y_label: "Memory Used per Pod (MB)" y_label: "Memory Used per Pod (MB)"
y_axis:
format: "mebibytes"
weight: 2 weight: 2
metrics: metrics:
- id: system_metrics_kubernetes_container_memory_average - id: system_metrics_kubernetes_container_memory_average
...@@ -177,6 +187,8 @@ panel_groups: ...@@ -177,6 +187,8 @@ panel_groups:
- title: "Canary: Memory Usage (Pod Average)" - title: "Canary: Memory Usage (Pod Average)"
type: "line-chart" type: "line-chart"
y_label: "Memory Used per Pod (MB)" y_label: "Memory Used per Pod (MB)"
y_axis:
format: "mebibytes"
weight: 2 weight: 2
metrics: metrics:
- id: system_metrics_kubernetes_container_memory_average_canary - id: system_metrics_kubernetes_container_memory_average_canary
...@@ -206,6 +218,8 @@ panel_groups: ...@@ -206,6 +218,8 @@ panel_groups:
- title: "Knative function invocations" - title: "Knative function invocations"
type: "area-chart" type: "area-chart"
y_label: "Invocations" y_label: "Invocations"
y_axis:
precision: 0
weight: 1 weight: 1
metrics: metrics:
- id: system_metrics_knative_function_invocation_count - id: system_metrics_knative_function_invocation_count
......
# 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
# 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
# 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 @@ ...@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm" enable_extension "pg_trgm"
...@@ -1616,6 +1616,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do ...@@ -1616,6 +1616,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.string "target_type" t.string "target_type"
t.bigint "group_id" t.bigint "group_id"
t.index ["action"], name: "index_events_on_action" 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 ["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 ["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)" 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 ...@@ -2206,6 +2207,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do
t.integer "duplicated_to_id" t.integer "duplicated_to_id"
t.integer "promoted_to_epic_id" t.integer "promoted_to_epic_id"
t.integer "health_status", limit: 2 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 ["author_id"], name: "index_issues_on_author_id"
t.index ["closed_by_id"], name: "index_issues_on_closed_by_id" t.index ["closed_by_id"], name: "index_issues_on_closed_by_id"
t.index ["confidential"], name: "index_issues_on_confidential" t.index ["confidential"], name: "index_issues_on_confidential"
...@@ -4454,6 +4456,7 @@ ActiveRecord::Schema.define(version: 2020_03_09_105539) do ...@@ -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 ["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 ["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 ["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 ["last_edited_by_id"], name: "index_vulnerabilities_on_last_edited_by_id"
t.index ["milestone_id"], name: "index_vulnerabilities_on_milestone_id" t.index ["milestone_id"], name: "index_vulnerabilities_on_milestone_id"
t.index ["project_id"], name: "index_vulnerabilities_on_project_id" t.index ["project_id"], name: "index_vulnerabilities_on_project_id"
......
...@@ -72,6 +72,43 @@ Example response: ...@@ -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 ## Group deploy tokens
These endpoints require group maintainer access or higher. 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 ...@@ -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) - group: Response metrics (NGINX Ingress)
metrics: metrics:
- title: "Throughput" - title: "Throughput"
y_label: "Requests / Sec" y_axis:
name: "Requests / Sec"
format: "number"
precision: 2
queries: queries:
- id: response_metrics_nginx_ingress_throughput_status_code - 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)' 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 ...@@ -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, 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 consult the [EC2 pricing page](https://aws.amazon.com/ec2/pricing/) for more
information on the cost. 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 - **S3**: We will use S3 to store backups, artifacts, LFS objects, etc. See the
[Amazon S3 pricing](https://aws.amazon.com/s3/pricing/). [Amazon S3 pricing](https://aws.amazon.com/s3/pricing/).
- **ELB**: A Classic Load Balancer will be used to route requests to the - **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: ...@@ -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. 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**. 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). 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 ...@@ -7,9 +7,9 @@ type: howto
GitLab can be configured to require confirmation of a user's email address when GitLab can be configured to require confirmation of a user's email address when
the user signs up. When this setting is enabled: 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. 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. 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 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. ...@@ -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 You can send confirmation emails during sign-up and require that users confirm
their email address. If this setting is selected: 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. 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. After 30 days, they will be unable to log in and access GitLab features.
![Email confirmation](img/email_confirmation_v12_7.png) ![Email confirmation](img/email_confirmation_v12_7.png)
......
...@@ -356,6 +356,31 @@ dast: ...@@ -356,6 +356,31 @@ dast:
The DAST job does not require the project's repository to be present when running, so by default 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`. [`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 ## Reports
The DAST job can emit various reports. The DAST job can emit various reports.
......
...@@ -206,8 +206,11 @@ For example: ...@@ -206,8 +206,11 @@ For example:
- type: area-chart - type: area-chart
title: "Chart Title" title: "Chart Title"
y_label: "Y-Axis" y_label: "Y-Axis"
y_axis:
format: number
precision: 0
metrics: metrics:
- id: metric_of_ages - id: my_metric_id
query_range: 'http_requests_total' query_range: 'http_requests_total'
label: "Instance: {{instance}}, method: {{method}}" label: "Instance: {{instance}}, method: {{method}}"
unit: "count" unit: "count"
...@@ -276,9 +279,18 @@ The following tables outline the details of expected properties. ...@@ -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`. | | `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. | | `title` | string | yes | Heading for the panel. |
| `y_label` | string | no, but highly encouraged | Y-Axis label 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. | | `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`. | | `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:** **Metrics (`metrics`) properties:**
| Property | Type | Required | Description | | Property | Type | Required | Description |
...@@ -297,7 +309,7 @@ When a static label is used and a query returns multiple time series, then all t ...@@ -297,7 +309,7 @@ When a static label is used and a query returns multiple time series, then all t
```yaml ```yaml
metrics: metrics:
- id: metric_of_ages - id: my_metric_id
query_range: 'http_requests_total' query_range: 'http_requests_total'
label: "Time Series" label: "Time Series"
unit: "count" unit: "count"
...@@ -311,7 +323,7 @@ For labels to be more explicit, using variables that reflect time series labels ...@@ -311,7 +323,7 @@ For labels to be more explicit, using variables that reflect time series labels
```yaml ```yaml
metrics: metrics:
- id: metric_of_ages - id: my_metric_id
query_range: 'http_requests_total' query_range: 'http_requests_total'
label: "Instance: {{instance}}, method: {{method}}" label: "Instance: {{instance}}, method: {{method}}"
unit: "count" unit: "count"
...@@ -325,7 +337,7 @@ There is also a shorthand value for dynamic dashboard labels that make use of on ...@@ -325,7 +337,7 @@ There is also a shorthand value for dynamic dashboard labels that make use of on
```yaml ```yaml
metrics: metrics:
- id: metric_of_ages - id: my_metric_id
query_range: 'http_requests_total' query_range: 'http_requests_total'
label: "Method" label: "Method"
unit: "count" unit: "count"
...@@ -351,6 +363,9 @@ panel_groups: ...@@ -351,6 +363,9 @@ panel_groups:
- type: area-chart # or line-chart - type: area-chart # or line-chart
title: 'Area Chart Title' title: 'Area Chart Title'
y_label: "Y-Axis" y_label: "Y-Axis"
y_axis:
format: number
precision: 0
metrics: metrics:
- id: area_http_requests_total - id: area_http_requests_total
query_range: 'http_requests_total' query_range: 'http_requests_total'
......
# 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 ...@@ -4,6 +4,17 @@ module API
class DeployTokens < Grape::API class DeployTokens < Grape::API
include PaginationParams 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 desc 'Return all deploy tokens' do
detail 'This feature was introduced in GitLab 12.9.' detail 'This feature was introduced in GitLab 12.9.'
success Entities::DeployToken success Entities::DeployToken
...@@ -33,6 +44,27 @@ module API ...@@ -33,6 +44,27 @@ module API
present paginate(user_project.deploy_tokens), with: Entities::DeployToken present paginate(user_project.deploy_tokens), with: Entities::DeployToken
end 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 end
params do params do
......
# frozen_string_literal: true
module API
module Entities
class DeployTokenWithToken < Entities::DeployToken
expose :token
end
end
end
# 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 ...@@ -28,7 +28,7 @@ module Gitlab
class BatchCounter class BatchCounter
FALLBACK = -1 FALLBACK = -1
MIN_REQUIRED_BATCH_SIZE = 2_000 MIN_REQUIRED_BATCH_SIZE = 1_250
MAX_ALLOWED_LOOPS = 10_000 MAX_ALLOWED_LOOPS = 10_000
SLEEP_TIME_IN_SECONDS = 0.01 # 10 msec sleep SLEEP_TIME_IN_SECONDS = 0.01 # 10 msec sleep
# Each query should take <<500ms https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22705 # Each query should take <<500ms https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22705
......
...@@ -42,7 +42,7 @@ module Gitlab ...@@ -42,7 +42,7 @@ module Gitlab
klass = stub_class(name) klass = stub_class(name)
addr = stub_address(storage) addr = stub_address(storage)
creds = stub_creds(storage) creds = stub_creds(storage)
klass.new(addr, creds, interceptors: interceptors) klass.new(addr, creds, interceptors: interceptors, channel_args: channel_args)
end end
end end
end end
...@@ -54,6 +54,16 @@ module Gitlab ...@@ -54,6 +54,16 @@ module Gitlab
end end
private_class_method :interceptors 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 def self.stub_cert_paths
cert_paths = Dir["#{OpenSSL::X509::DEFAULT_CERT_DIR}/*"] cert_paths = Dir["#{OpenSSL::X509::DEFAULT_CERT_DIR}/*"]
cert_paths << OpenSSL::X509::DEFAULT_CERT_FILE if File.exist? OpenSSL::X509::DEFAULT_CERT_FILE cert_paths << OpenSSL::X509::DEFAULT_CERT_FILE if File.exist? OpenSSL::X509::DEFAULT_CERT_FILE
......
...@@ -15422,6 +15422,9 @@ msgstr "" ...@@ -15422,6 +15422,9 @@ msgstr ""
msgid "ProjectSettings|View and edit files in this project" msgid "ProjectSettings|View and edit files in this project"
msgstr "" 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" msgid "ProjectSettings|When conflicts arise the user is given the option to rebase"
msgstr "" msgstr ""
...@@ -18174,6 +18177,9 @@ msgstr "" ...@@ -18174,6 +18177,9 @@ msgstr ""
msgid "Snippets|Optionally add a description about what your snippet does or how to use it..." msgid "Snippets|Optionally add a description about what your snippet does or how to use it..."
msgstr "" msgstr ""
msgid "Snippets|Optionally add a description about what your snippet does or how to use it…"
msgstr ""
msgid "Snowplow" msgid "Snowplow"
msgstr "" msgstr ""
......
...@@ -25,7 +25,9 @@ ...@@ -25,7 +25,9 @@
"items": { "items": {
"type": "string" "type": "string"
} }
}
}, },
"additionalProperties": false "token": {
"type": "string"
}
}
} }
\ No newline at end of file
{
"type": "object",
"required": [],
"properties": {
"name": { "type": "string" },
"precision": { "type": "number" },
"format": { "type": "string" }
},
"additionalProperties": false
}
...@@ -9,6 +9,7 @@ ...@@ -9,6 +9,7 @@
"title": { "type": "string" }, "title": { "type": "string" },
"type": { "type": "string" }, "type": { "type": "string" },
"y_label": { "type": "string" }, "y_label": { "type": "string" },
"y_axis": { "$ref": "axis.json" },
"weight": { "type": "number" }, "weight": { "type": "number" },
"metrics": { "metrics": {
"type": "array", "type": "array",
......
...@@ -3,6 +3,8 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -3,6 +3,8 @@ import axios from '~/lib/utils/axios_utils';
import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer'; import BalsamiqViewer from '~/blob/balsamiq/balsamiq_viewer';
import ClassSpecHelper from '../../helpers/class_spec_helper'; import ClassSpecHelper from '../../helpers/class_spec_helper';
jest.mock('sql.js');
describe('BalsamiqViewer', () => { describe('BalsamiqViewer', () => {
const mockArrayBuffer = new ArrayBuffer(10); const mockArrayBuffer = new ArrayBuffer(10);
let balsamiqViewer; let balsamiqViewer;
...@@ -34,22 +36,22 @@ describe('BalsamiqViewer', () => { ...@@ -34,22 +36,22 @@ describe('BalsamiqViewer', () => {
}); });
it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => { it('should call `axios.get` on `endpoint` param with responseType set to `arraybuffer', () => {
spyOn(axios, 'get').and.returnValue(requestSuccess); jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
spyOn(bv, 'renderFile').and.stub(); jest.spyOn(bv, 'renderFile').mockReturnValue();
bv.loadFile(endpoint); bv.loadFile(endpoint);
expect(axios.get).toHaveBeenCalledWith( expect(axios.get).toHaveBeenCalledWith(
endpoint, endpoint,
jasmine.objectContaining({ expect.objectContaining({
responseType: 'arraybuffer', responseType: 'arraybuffer',
}), }),
); );
}); });
it('should call `renderFile` on request success', done => { it('should call `renderFile` on request success', done => {
spyOn(axios, 'get').and.returnValue(requestSuccess); jest.spyOn(axios, 'get').mockReturnValue(requestSuccess);
spyOn(bv, 'renderFile').and.callFake(() => {}); jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint) bv.loadFile(endpoint)
.then(() => { .then(() => {
...@@ -60,8 +62,8 @@ describe('BalsamiqViewer', () => { ...@@ -60,8 +62,8 @@ describe('BalsamiqViewer', () => {
}); });
it('should not call `renderFile` on request failure', done => { it('should not call `renderFile` on request failure', done => {
spyOn(axios, 'get').and.returnValue(Promise.reject()); jest.spyOn(axios, 'get').mockReturnValue(Promise.reject());
spyOn(bv, 'renderFile'); jest.spyOn(bv, 'renderFile').mockImplementation(() => {});
bv.loadFile(endpoint) bv.loadFile(endpoint)
.then(() => { .then(() => {
...@@ -80,19 +82,21 @@ describe('BalsamiqViewer', () => { ...@@ -80,19 +82,21 @@ describe('BalsamiqViewer', () => {
let previews; let previews;
beforeEach(() => { beforeEach(() => {
viewer = jasmine.createSpyObj('viewer', ['appendChild']); viewer = {
appendChild: jest.fn(),
};
previews = [document.createElement('ul'), document.createElement('ul')]; previews = [document.createElement('ul'), document.createElement('ul')];
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', [ balsamiqViewer = {
'initDatabase', initDatabase: jest.fn(),
'getPreviews', getPreviews: jest.fn(),
'renderPreview', renderPreview: jest.fn(),
]); };
balsamiqViewer.viewer = viewer; balsamiqViewer.viewer = viewer;
balsamiqViewer.getPreviews.and.returnValue(previews); balsamiqViewer.getPreviews.mockReturnValue(previews);
balsamiqViewer.renderPreview.and.callFake(preview => preview); balsamiqViewer.renderPreview.mockImplementation(preview => preview);
viewer.appendChild.and.callFake(containerElement => { viewer.appendChild.mockImplementation(containerElement => {
container = containerElement; container = containerElement;
}); });
...@@ -108,7 +112,7 @@ describe('BalsamiqViewer', () => { ...@@ -108,7 +112,7 @@ describe('BalsamiqViewer', () => {
}); });
it('should call .renderPreview for each preview', () => { it('should call .renderPreview for each preview', () => {
const allArgs = balsamiqViewer.renderPreview.calls.allArgs(); const allArgs = balsamiqViewer.renderPreview.mock.calls;
expect(allArgs.length).toBe(2); expect(allArgs.length).toBe(2);
...@@ -132,19 +136,15 @@ describe('BalsamiqViewer', () => { ...@@ -132,19 +136,15 @@ describe('BalsamiqViewer', () => {
}); });
describe('initDatabase', () => { describe('initDatabase', () => {
let database;
let uint8Array; let uint8Array;
let data; let data;
beforeEach(() => { beforeEach(() => {
uint8Array = {}; uint8Array = {};
database = {};
data = 'data'; data = 'data';
balsamiqViewer = {}; balsamiqViewer = {};
window.Uint8Array = jest.fn();
spyOn(window, 'Uint8Array').and.returnValue(uint8Array); window.Uint8Array.mockReturnValue(uint8Array);
spyOn(sqljs, 'Database').and.returnValue(database);
BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data); BalsamiqViewer.prototype.initDatabase.call(balsamiqViewer, data);
}); });
...@@ -158,7 +158,7 @@ describe('BalsamiqViewer', () => { ...@@ -158,7 +158,7 @@ describe('BalsamiqViewer', () => {
}); });
it('should set .database', () => { it('should set .database', () => {
expect(balsamiqViewer.database).toBe(database); expect(balsamiqViewer.database).not.toBe(null);
}); });
}); });
...@@ -168,15 +168,17 @@ describe('BalsamiqViewer', () => { ...@@ -168,15 +168,17 @@ describe('BalsamiqViewer', () => {
let getPreviews; let getPreviews;
beforeEach(() => { beforeEach(() => {
database = jasmine.createSpyObj('database', ['exec']); database = {
exec: jest.fn(),
};
thumbnails = [{ values: [0, 1, 2] }]; thumbnails = [{ values: [0, 1, 2] }];
balsamiqViewer = { balsamiqViewer = {
database, database,
}; };
spyOn(BalsamiqViewer, 'parsePreview').and.callFake(preview => preview.toString()); jest.spyOn(BalsamiqViewer, 'parsePreview').mockImplementation(preview => preview.toString());
database.exec.and.returnValue(thumbnails); database.exec.mockReturnValue(thumbnails);
getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer); getPreviews = BalsamiqViewer.prototype.getPreviews.call(balsamiqViewer);
}); });
...@@ -186,7 +188,7 @@ describe('BalsamiqViewer', () => { ...@@ -186,7 +188,7 @@ describe('BalsamiqViewer', () => {
}); });
it('should call .parsePreview for each value', () => { it('should call .parsePreview for each value', () => {
const allArgs = BalsamiqViewer.parsePreview.calls.allArgs(); const allArgs = BalsamiqViewer.parsePreview.mock.calls;
expect(allArgs.length).toBe(3); expect(allArgs.length).toBe(3);
...@@ -207,7 +209,9 @@ describe('BalsamiqViewer', () => { ...@@ -207,7 +209,9 @@ describe('BalsamiqViewer', () => {
let getResource; let getResource;
beforeEach(() => { beforeEach(() => {
database = jasmine.createSpyObj('database', ['exec']); database = {
exec: jest.fn(),
};
resourceID = 4; resourceID = 4;
resource = ['resource']; resource = ['resource'];
...@@ -215,7 +219,7 @@ describe('BalsamiqViewer', () => { ...@@ -215,7 +219,7 @@ describe('BalsamiqViewer', () => {
database, database,
}; };
database.exec.and.returnValue(resource); database.exec.mockReturnValue(resource);
getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID); getResource = BalsamiqViewer.prototype.getResource.call(balsamiqViewer, resourceID);
}); });
...@@ -241,14 +245,18 @@ describe('BalsamiqViewer', () => { ...@@ -241,14 +245,18 @@ describe('BalsamiqViewer', () => {
innerHTML = '<a>innerHTML</a>'; innerHTML = '<a>innerHTML</a>';
previewElement = { previewElement = {
outerHTML: '<p>outerHTML</p>', outerHTML: '<p>outerHTML</p>',
classList: jasmine.createSpyObj('classList', ['add']), classList: {
add: jest.fn(),
},
}; };
preview = {}; preview = {};
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['renderTemplate']); balsamiqViewer = {
renderTemplate: jest.fn(),
};
spyOn(document, 'createElement').and.returnValue(previewElement); jest.spyOn(document, 'createElement').mockReturnValue(previewElement);
balsamiqViewer.renderTemplate.and.returnValue(innerHTML); balsamiqViewer.renderTemplate.mockReturnValue(innerHTML);
renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview); renderPreview = BalsamiqViewer.prototype.renderPreview.call(balsamiqViewer, preview);
}); });
...@@ -290,10 +298,12 @@ describe('BalsamiqViewer', () => { ...@@ -290,10 +298,12 @@ describe('BalsamiqViewer', () => {
</div> </div>
`; `;
balsamiqViewer = jasmine.createSpyObj('balsamiqViewer', ['getResource']); balsamiqViewer = {
getResource: jest.fn(),
};
spyOn(BalsamiqViewer, 'parseTitle').and.returnValue(name); jest.spyOn(BalsamiqViewer, 'parseTitle').mockReturnValue(name);
balsamiqViewer.getResource.and.returnValue(resource); balsamiqViewer.getResource.mockReturnValue(resource);
renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview); renderTemplate = BalsamiqViewer.prototype.renderTemplate.call(balsamiqViewer, preview);
}); });
...@@ -306,7 +316,7 @@ describe('BalsamiqViewer', () => { ...@@ -306,7 +316,7 @@ describe('BalsamiqViewer', () => {
expect(BalsamiqViewer.parseTitle).toHaveBeenCalledWith(resource); 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, '')); expect(renderTemplate.replace(/\s/g, '')).toEqual(template.replace(/\s/g, ''));
}); });
}); });
...@@ -318,7 +328,7 @@ describe('BalsamiqViewer', () => { ...@@ -318,7 +328,7 @@ describe('BalsamiqViewer', () => {
beforeEach(() => { beforeEach(() => {
preview = ['{}', '{ "id": 1 }']; preview = ['{}', '{ "id": 1 }'];
spyOn(JSON, 'parse').and.callThrough(); jest.spyOn(JSON, 'parse');
parsePreview = BalsamiqViewer.parsePreview(preview); parsePreview = BalsamiqViewer.parsePreview(preview);
}); });
...@@ -337,7 +347,7 @@ describe('BalsamiqViewer', () => { ...@@ -337,7 +347,7 @@ describe('BalsamiqViewer', () => {
beforeEach(() => { beforeEach(() => {
title = { values: [['{}', '{}', '{"name":"name"}']] }; title = { values: [['{}', '{}', '{"name":"name"}']] };
spyOn(JSON, 'parse').and.callThrough(); jest.spyOn(JSON, 'parse');
parseTitle = BalsamiqViewer.parseTitle(title); parseTitle = BalsamiqViewer.parseTitle(title);
}); });
......
...@@ -440,23 +440,6 @@ describe('boardsStore', () => { ...@@ -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', () => { describe('recentBoards', () => {
const url = `${endpoints.recentBoardsEndpoint}.json`; const url = `${endpoints.recentBoardsEndpoint}.json`;
......
import Vue from 'vue'; import { nextTick } from 'vue';
import { mount } from '@vue/test-utils'; 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 { TEST_HOST } from 'spec/test_constants';
import BoardsSelector from '~/boards/components/boards_selector.vue'; import BoardsSelector from '~/boards/components/boards_selector.vue';
import boardsStore from '~/boards/stores/boards_store'; import boardsStore from '~/boards/stores/boards_store';
...@@ -8,7 +8,8 @@ import boardsStore from '~/boards/stores/boards_store'; ...@@ -8,7 +8,8 @@ import boardsStore from '~/boards/stores/boards_store';
const throttleDuration = 1; const throttleDuration = 1;
function boardGenerator(n) { 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}`; const name = `board${id}`;
return { return {
...@@ -34,8 +35,17 @@ describe('BoardsSelector', () => { ...@@ -34,8 +35,17 @@ describe('BoardsSelector', () => {
const getDropdownItems = () => wrapper.findAll('.js-dropdown-item'); const getDropdownItems = () => wrapper.findAll('.js-dropdown-item');
const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header'); const getDropdownHeaders = () => wrapper.findAll('.dropdown-bold-header');
const getLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => { beforeEach(() => {
const $apollo = {
queries: {
boards: {
loading: false,
},
},
};
boardsStore.setEndpoints({ boardsStore.setEndpoints({
boardsEndpoint: '', boardsEndpoint: '',
recentBoardsEndpoint: '', recentBoardsEndpoint: '',
...@@ -45,7 +55,13 @@ describe('BoardsSelector', () => { ...@@ -45,7 +55,13 @@ describe('BoardsSelector', () => {
}); });
allBoardsResponse = Promise.resolve({ allBoardsResponse = Promise.resolve({
data: boards, data: {
group: {
boards: {
edges: boards.map(board => ({ node: board })),
},
},
},
}); });
recentBoardsResponse = Promise.resolve({ recentBoardsResponse = Promise.resolve({
data: recentBoards, data: recentBoards,
...@@ -54,8 +70,7 @@ describe('BoardsSelector', () => { ...@@ -54,8 +70,7 @@ describe('BoardsSelector', () => {
boardsStore.allBoards = jest.fn(() => allBoardsResponse); boardsStore.allBoards = jest.fn(() => allBoardsResponse);
boardsStore.recentBoards = jest.fn(() => recentBoardsResponse); boardsStore.recentBoards = jest.fn(() => recentBoardsResponse);
const Component = Vue.extend(BoardsSelector); wrapper = mount(BoardsSelector, {
wrapper = mount(Component, {
propsData: { propsData: {
throttleDuration, throttleDuration,
currentBoard: { currentBoard: {
...@@ -77,13 +92,18 @@ describe('BoardsSelector', () => { ...@@ -77,13 +92,18 @@ describe('BoardsSelector', () => {
scopedIssueBoardFeatureEnabled: true, scopedIssueBoardFeatureEnabled: true,
weights: [], weights: [],
}, },
mocks: { $apollo },
attachToDocument: true, 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 // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time
wrapper.find(GlDropdown).vm.$emit('show'); wrapper.find(GlDropdown).vm.$emit('show');
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => Vue.nextTick());
}); });
afterEach(() => { afterEach(() => {
...@@ -91,9 +111,39 @@ describe('BoardsSelector', () => { ...@@ -91,9 +111,39 @@ describe('BoardsSelector', () => {
wrapper = null; wrapper = null;
}); });
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 loading spinner', () => {
expect(getDropdownHeaders()).toHaveLength(0);
expect(getDropdownItems()).toHaveLength(0);
expect(getLoadingIcon().exists()).toBe(true);
});
});
describe('loaded', () => {
beforeEach(() => {
return Promise.all([allBoardsResponse, recentBoardsResponse]).then(() => nextTick());
});
it('hides loading spinner', () => {
expect(getLoadingIcon().exists()).toBe(false);
});
describe('filtering', () => { describe('filtering', () => {
beforeEach(() => {
wrapper.setData({
boards,
});
return nextTick();
});
it('shows all boards without filtering', () => { it('shows all boards without filtering', () => {
expect(getDropdownItems().length).toBe(boards.length + recentBoards.length); expect(getDropdownItems()).toHaveLength(boards.length + recentBoards.length);
}); });
it('shows only matching boards when filtering', () => { it('shows only matching boards when filtering', () => {
...@@ -102,16 +152,16 @@ describe('BoardsSelector', () => { ...@@ -102,16 +152,16 @@ describe('BoardsSelector', () => {
fillSearchBox(filterTerm); fillSearchBox(filterTerm);
return Vue.nextTick().then(() => { return nextTick().then(() => {
expect(getDropdownItems().length).toBe(expectedCount); expect(getDropdownItems()).toHaveLength(expectedCount);
}); });
}); });
it('shows message if there are no matching boards', () => { it('shows message if there are no matching boards', () => {
fillSearchBox('does not exist'); fillSearchBox('does not exist');
return Vue.nextTick().then(() => { return nextTick().then(() => {
expect(getDropdownItems().length).toBe(0); expect(getDropdownItems()).toHaveLength(0);
expect(wrapper.text().includes('No matching boards found')).toBe(true); expect(wrapper.text().includes('No matching boards found')).toBe(true);
}); });
}); });
...@@ -119,9 +169,13 @@ describe('BoardsSelector', () => { ...@@ -119,9 +169,13 @@ describe('BoardsSelector', () => {
describe('recent boards section', () => { describe('recent boards section', () => {
it('shows only when boards are greater than 10', () => { it('shows only when boards are greater than 10', () => {
const expectedCount = 2; // Recent + All wrapper.setData({
boards,
});
expect(getDropdownHeaders().length).toBe(expectedCount); return nextTick().then(() => {
expect(getDropdownHeaders()).toHaveLength(2);
});
}); });
it('does not show when boards are less than 10', () => { it('does not show when boards are less than 10', () => {
...@@ -129,8 +183,8 @@ describe('BoardsSelector', () => { ...@@ -129,8 +183,8 @@ describe('BoardsSelector', () => {
boards: boards.slice(0, 5), boards: boards.slice(0, 5),
}); });
return Vue.nextTick().then(() => { return nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0); expect(getDropdownHeaders()).toHaveLength(0);
}); });
}); });
...@@ -139,16 +193,17 @@ describe('BoardsSelector', () => { ...@@ -139,16 +193,17 @@ describe('BoardsSelector', () => {
recentBoards: [], recentBoards: [],
}); });
return Vue.nextTick().then(() => { return nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0); expect(getDropdownHeaders()).toHaveLength(0);
}); });
}); });
it('does not show when search is active', () => { it('does not show when search is active', () => {
fillSearchBox('Random string'); fillSearchBox('Random string');
return Vue.nextTick().then(() => { return nextTick().then(() => {
expect(getDropdownHeaders().length).toBe(0); expect(getDropdownHeaders()).toHaveLength(0);
});
}); });
}); });
}); });
......
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]);
});
});
});
});
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);
});
});
// 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>
`;
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', () => { ...@@ -259,16 +259,40 @@ describe('mrWidgetOptions', () => {
describe('methods', () => { describe('methods', () => {
describe('checkStatus', () => { 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.service, 'checkStatus').mockReturnValue(returnPromise(mockData));
jest.spyOn(vm.mr, 'setData').mockImplementation(() => {}); jest.spyOn(vm.mr, 'setData').mockImplementation(() => {});
jest.spyOn(vm, 'handleNotification').mockImplementation(() => {}); jest.spyOn(vm, 'handleNotification').mockImplementation(() => {});
let isCbExecuted = false; isCbExecuted = false;
const cb = () => { cb = () => {
isCbExecuted = true; 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); vm.checkStatus(cb);
return vm.$nextTick().then(() => { return vm.$nextTick().then(() => {
......
...@@ -52,7 +52,7 @@ describe ProjectPolicy do ...@@ -52,7 +52,7 @@ describe ProjectPolicy do
admin_snippet admin_project_member admin_note admin_wiki admin_project admin_snippet admin_project_member admin_note admin_wiki admin_project
admin_commit_status admin_build admin_container_image admin_commit_status admin_build admin_container_image
admin_pipeline admin_environment admin_deployment destroy_release add_cluster admin_pipeline admin_environment admin_deployment destroy_release add_cluster
daily_statistics read_deploy_token daily_statistics read_deploy_token create_deploy_token
] ]
end end
......
...@@ -133,4 +133,57 @@ describe API::DeployTokens do ...@@ -133,4 +133,57 @@ describe API::DeployTokens do
end end
end 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 end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment