Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-12-05 21:07:51 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2019-12-05 21:07:51 +0300
commit6a7cc8c14727f6fac64a5be6838764d8d5d41468 (patch)
tree97c8a3c2f180d26f0f8f0baaa3230352b8ef1efb /app
parent872319738757edc0483346c75a2407f7019b963f (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/boards/services/board_service.js98
-rw-r--r--app/assets/javascripts/notes/components/notes_app.vue11
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/components/loading.vue29
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue66
-rw-r--r--app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js7
-rw-r--r--app/assets/stylesheets/pages/merge_requests.scss4
-rw-r--r--app/controllers/projects/merge_requests_controller.rb1
-rw-r--r--app/controllers/projects/pipelines_controller.rb1
-rw-r--r--app/helpers/gitlab_routing_helper.rb8
-rw-r--r--app/helpers/search_helper.rb7
-rw-r--r--app/models/ci/pipeline.rb6
-rw-r--r--app/models/ci/pipeline_enums.rb13
-rw-r--r--app/models/project_wiki.rb2
-rw-r--r--app/serializers/merge_request_widget_entity.rb5
-rw-r--r--app/views/ci/variables/_variable_row.html.haml14
-rw-r--r--app/views/clusters/clusters/_form.html.haml8
-rw-r--r--app/views/shared/buttons/_project_feature_toggle.html.haml16
17 files changed, 143 insertions, 153 deletions
diff --git a/app/assets/javascripts/boards/services/board_service.js b/app/assets/javascripts/boards/services/board_service.js
deleted file mode 100644
index 03369febb4a..00000000000
--- a/app/assets/javascripts/boards/services/board_service.js
+++ /dev/null
@@ -1,98 +0,0 @@
-/* eslint-disable class-methods-use-this */
-/**
- * This file is intended to be deleted.
- * The existing functions will removed one by one in favor of using the board store directly.
- * see https://gitlab.com/gitlab-org/gitlab-foss/issues/61621
- */
-
-import boardsStore from '~/boards/stores/boards_store';
-
-export default class BoardService {
- generateBoardsPath(id) {
- return boardsStore.generateBoardsPath(id);
- }
-
- generateIssuesPath(id) {
- return boardsStore.generateIssuesPath(id);
- }
-
- static generateIssuePath(boardId, id) {
- return boardsStore.generateIssuePath(boardId, id);
- }
-
- all() {
- return boardsStore.all();
- }
-
- generateDefaultLists() {
- return boardsStore.generateDefaultLists();
- }
-
- createList(entityId, entityType) {
- return boardsStore.createList(entityId, entityType);
- }
-
- updateList(id, position, collapsed) {
- return boardsStore.updateList(id, position, collapsed);
- }
-
- destroyList(id) {
- return boardsStore.destroyList(id);
- }
-
- getIssuesForList(id, filter = {}) {
- return boardsStore.getIssuesForList(id, filter);
- }
-
- moveIssue(id, fromListId = null, toListId = null, moveBeforeId = null, moveAfterId = null) {
- return boardsStore.moveIssue(id, fromListId, toListId, moveBeforeId, moveAfterId);
- }
-
- moveMultipleIssues({
- ids,
- fromListId = null,
- toListId = null,
- moveBeforeId = null,
- moveAfterId = null,
- }) {
- return boardsStore.moveMultipleIssues({ ids, fromListId, toListId, moveBeforeId, moveAfterId });
- }
-
- newIssue(id, issue) {
- return boardsStore.newIssue(id, issue);
- }
-
- getBacklog(data) {
- return boardsStore.getBacklog(data);
- }
-
- bulkUpdate(issueIds, extraData = {}) {
- return boardsStore.bulkUpdate(issueIds, extraData);
- }
-
- static getIssueInfo(endpoint) {
- return boardsStore.getIssueInfo(endpoint);
- }
-
- static toggleIssueSubscription(endpoint) {
- return boardsStore.toggleIssueSubscription(endpoint);
- }
-
- allBoards() {
- return boardsStore.allBoards();
- }
-
- recentBoards() {
- return boardsStore.recentBoards();
- }
-
- createBoard(board) {
- return boardsStore.createBoard(board);
- }
-
- deleteBoard({ id }) {
- return boardsStore.deleteBoard({ id });
- }
-}
-
-window.BoardService = BoardService;
diff --git a/app/assets/javascripts/notes/components/notes_app.vue b/app/assets/javascripts/notes/components/notes_app.vue
index 9d1de4ef8a0..7df99610132 100644
--- a/app/assets/javascripts/notes/components/notes_app.vue
+++ b/app/assets/javascripts/notes/components/notes_app.vue
@@ -1,6 +1,6 @@
<script>
-import { __ } from '~/locale';
import { mapGetters, mapActions } from 'vuex';
+import { __ } from '~/locale';
import { getLocationHash, doesHashExistInUrl } from '../../lib/utils/url_utility';
import Flash from '../../flash';
import * as constants from '../constants';
@@ -71,6 +71,9 @@ export default {
'userCanReply',
'discussionTabCounter',
]),
+ discussionTabCounterText() {
+ return this.isLoading ? '' : this.discussionTabCounter;
+ },
noteableType() {
return this.noteableData.noteableType;
},
@@ -95,9 +98,9 @@ export default {
this.fetchNotes();
}
},
- allDiscussions() {
- if (this.discussionsCount && !this.isLoading) {
- this.discussionsCount.textContent = this.discussionTabCounter;
+ discussionTabCounterText(val) {
+ if (this.discussionsCount) {
+ this.discussionsCount.textContent = val;
}
},
},
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/loading.vue b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
new file mode 100644
index 00000000000..78dc28ee92b
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/loading.vue
@@ -0,0 +1,29 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+};
+</script>
+
+<template>
+ <div class="prepend-top-default">
+ <div class="mr-widget-heading p-3">
+ <gl-skeleton-loader :width="577" :height="12">
+ <rect width="86" height="12" rx="2" />
+ <rect x="96" width="300" height="12" rx="2" />
+ </gl-skeleton-loader>
+ </div>
+ <div class="mr-widget-heading mr-widget-workflow p-3">
+ <gl-skeleton-loader :width="577" :height="72">
+ <rect width="120" height="12" rx="2" />
+ <rect y="20" width="300" height="12" rx="2" />
+ <rect y="40" width="60" height="12" rx="2" />
+ <rect y="40" x="68" width="100" height="12" rx="2" />
+ <rect y="60" width="40" height="12" rx="2" />
+ </gl-skeleton-loader>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 363fe226f15..31dbddbd21a 100644
--- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -7,6 +7,7 @@ import MRWidgetStore from 'ee_else_ce/vue_merge_request_widget/stores/mr_widget_
import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service';
import stateMaps from 'ee_else_ce/vue_merge_request_widget/stores/state_maps';
import createFlash from '../flash';
+import Loading from './components/loading.vue';
import WidgetHeader from './components/mr_widget_header.vue';
import WidgetMergeHelp from './components/mr_widget_merge_help.vue';
import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue';
@@ -44,6 +45,7 @@ export default {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
name: 'MRWidget',
components: {
+ Loading,
'mr-widget-header': WidgetHeader,
'mr-widget-merge-help': WidgetMergeHelp,
MrWidgetPipelineContainer,
@@ -80,12 +82,12 @@ export default {
},
},
data() {
- const store = new MRWidgetStore(this.mrData || window.gl.mrWidgetData);
- const service = this.createService(store);
+ const store = this.mrData && new MRWidgetStore(this.mrData);
+
return {
mr: store,
- state: store.state,
- service,
+ state: store && store.state,
+ service: store && this.createService(store),
};
},
computed: {
@@ -133,29 +135,58 @@ export default {
}
},
},
- created() {
- this.initPolling();
- this.bindEventHubListeners();
- eventHub.$on('mr.discussion.updated', this.checkStatus);
- },
mounted() {
- this.setFaviconHelper();
- this.initDeploymentsPolling();
-
- if (this.shouldRenderMergedPipeline) {
- this.initPostMergeDeploymentsPolling();
+ if (gon && gon.features && gon.features.asyncMrWidget) {
+ MRWidgetService.fetchInitialData()
+ .then(({ data }) => this.initWidget(data))
+ .catch(() =>
+ createFlash(__('Unable to load the merge request widget. Try reloading the page.')),
+ );
+ } else {
+ this.initWidget();
}
},
beforeDestroy() {
eventHub.$off('mr.discussion.updated', this.checkStatus);
- this.pollingInterval.destroy();
- this.deploymentsInterval.destroy();
+ if (this.pollingInterval) {
+ this.pollingInterval.destroy();
+ }
+
+ if (this.deploymentsInterval) {
+ this.deploymentsInterval.destroy();
+ }
if (this.postMergeDeploymentsInterval) {
this.postMergeDeploymentsInterval.destroy();
}
},
methods: {
+ initWidget(data = {}) {
+ if (this.mr) {
+ this.mr.setData({ ...window.gl.mrWidgetData, ...data });
+ } else {
+ this.mr = new MRWidgetStore({ ...window.gl.mrWidgetData, ...data });
+ }
+
+ if (!this.state) {
+ this.state = this.mr.state;
+ }
+
+ if (!this.service) {
+ this.service = this.createService(this.mr);
+ }
+
+ this.setFaviconHelper();
+ this.initDeploymentsPolling();
+
+ if (this.shouldRenderMergedPipeline) {
+ this.initPostMergeDeploymentsPolling();
+ }
+
+ this.initPolling();
+ this.bindEventHubListeners();
+ eventHub.$on('mr.discussion.updated', this.checkStatus);
+ },
getServiceEndpoints(store) {
return {
mergePath: store.mergePath,
@@ -319,7 +350,7 @@ export default {
};
</script>
<template>
- <div class="mr-state-widget prepend-top-default">
+ <div v-if="mr" class="mr-state-widget prepend-top-default">
<mr-widget-header :mr="mr" />
<mr-widget-pipeline-container
v-if="shouldRenderPipelines"
@@ -377,4 +408,5 @@ export default {
:is-post-merge="true"
/>
</div>
+ <loading v-else />
</template>
diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
index 8a229d80954..d22cb4ced80 100644
--- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
+++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js
@@ -61,4 +61,11 @@ export default class MRWidgetService {
static fetchMetrics(metricsUrl) {
return axios.get(`${metricsUrl}.json`);
}
+
+ static fetchInitialData() {
+ return Promise.all([
+ axios.get(window.gl.mrWidgetData.merge_request_cached_widget_path),
+ axios.get(window.gl.mrWidgetData.merge_request_widget_path),
+ ]).then(axios.spread((res, cachedRes) => ({ data: Object.assign(res.data, cachedRes.data) })));
+ }
}
diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss
index eb7d162e38c..c023c9e5cbd 100644
--- a/app/assets/stylesheets/pages/merge_requests.scss
+++ b/app/assets/stylesheets/pages/merge_requests.scss
@@ -51,6 +51,10 @@
position: relative;
border: 1px solid $border-color;
border-radius: $border-radius-default;
+
+ .gl-skeleton-loader {
+ display: block;
+ }
}
.mr-widget-extension {
diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb
index 844f1d04679..07f568e2a04 100644
--- a/app/controllers/projects/merge_requests_controller.rb
+++ b/app/controllers/projects/merge_requests_controller.rb
@@ -25,6 +25,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
before_action do
push_frontend_feature_flag(:vue_issuable_sidebar, @project.group)
push_frontend_feature_flag(:release_search_filter, @project, default_enabled: true)
+ push_frontend_feature_flag(:async_mr_widget, @project)
end
around_action :allow_gitaly_ref_name_caching, only: [:index, :show, :discussions]
diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb
index 4d35353d5f5..e3ef8f3f2ff 100644
--- a/app/controllers/projects/pipelines_controller.rb
+++ b/app/controllers/projects/pipelines_controller.rb
@@ -11,7 +11,6 @@ class Projects::PipelinesController < Projects::ApplicationController
before_action :authorize_create_pipeline!, only: [:new, :create]
before_action :authorize_update_pipeline!, only: [:retry, :cancel]
before_action do
- push_frontend_feature_flag(:hide_dismissed_vulnerabilities)
push_frontend_feature_flag(:junit_pipeline_view)
end
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index 38ca12e6f90..3810041b2af 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -195,7 +195,7 @@ module GitlabRoutingHelper
end
def snippet_path(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
+ if snippet.type == "ProjectSnippet"
application_url_helpers.project_snippet_path(snippet.project, snippet, *args)
else
new_args = snippet_query_params(snippet, *args)
@@ -204,7 +204,7 @@ module GitlabRoutingHelper
end
def snippet_url(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
+ if snippet.type == "ProjectSnippet"
application_url_helpers.project_snippet_url(snippet.project, snippet, *args)
else
new_args = snippet_query_params(snippet, *args)
@@ -213,7 +213,7 @@ module GitlabRoutingHelper
end
def raw_snippet_path(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
+ if snippet.type == "ProjectSnippet"
application_url_helpers.raw_project_snippet_path(snippet.project, snippet, *args)
else
new_args = snippet_query_params(snippet, *args)
@@ -222,7 +222,7 @@ module GitlabRoutingHelper
end
def raw_snippet_url(snippet, *args)
- if snippet.is_a?(ProjectSnippet)
+ if snippet.type == "ProjectSnippet"
application_url_helpers.raw_project_snippet_url(snippet.project, snippet, *args)
else
new_args = snippet_query_params(snippet, *args)
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 777fe82e4c0..a89fea4b7b8 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -31,13 +31,14 @@ module SearchHelper
from = collection.offset_value + 1
to = collection.offset_value + collection.to_a.size
count = collection.total_count
+ term_element = "<span>&nbsp;<code>#{h(term)}</code>&nbsp;</span>".html_safe
search_entries_info_template(collection) % {
from: from,
to: to,
count: count,
scope: search_entries_scope_label(scope, count),
- term: term
+ term_element: term_element
}
end
@@ -72,9 +73,9 @@ module SearchHelper
def search_entries_info_template(collection)
if collection.total_pages > 1
- s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for \"%{term}\"")
+ s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe
else
- s_("SearchResults|Showing %{count} %{scope} for \"%{term}\"")
+ s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 5821cc1a1a5..c3292d7524e 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -204,7 +204,7 @@ module Ci
end
scope :internal, -> { where(source: internal_sources) }
- scope :ci_sources, -> { where(config_source: ci_sources_values) }
+ scope :ci_sources, -> { where(config_source: ::Ci::PipelineEnums.ci_config_sources_values) }
scope :for_user, -> (user) { where(user: user) }
scope :for_sha, -> (sha) { where(sha: sha) }
scope :for_source_sha, -> (source_sha) { where(source_sha: source_sha) }
@@ -315,10 +315,6 @@ module Ci
sources.reject { |source| source == "external" }.values
end
- def self.ci_sources_values
- config_sources.values_at(:repository_source, :auto_devops_source, :unknown_source)
- end
-
def self.bridgeable_statuses
::Ci::Pipeline::AVAILABLE_STATUSES - %w[created preparing pending]
end
diff --git a/app/models/ci/pipeline_enums.rb b/app/models/ci/pipeline_enums.rb
index 859abc4a0d5..ac930f63abf 100644
--- a/app/models/ci/pipeline_enums.rb
+++ b/app/models/ci/pipeline_enums.rb
@@ -35,9 +35,20 @@ module Ci
{
unknown_source: nil,
repository_source: 1,
- auto_devops_source: 2
+ auto_devops_source: 2,
+ remote_source: 4,
+ external_project_source: 5
}
end
+
+ def self.ci_config_sources_values
+ config_sources.values_at(
+ :unknown_source,
+ :repository_source,
+ :auto_devops_source,
+ :remote_source,
+ :external_project_source)
+ end
end
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index f02ccd9e55e..48c96203921 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -58,7 +58,7 @@ class ProjectWiki
end
def wiki_base_path
- [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/wikis'].join('')
+ [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/-', '/wikis'].join('')
end
# Returns the Gitlab::Git::Wiki object.
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index eda7a36c2ee..2a81931c49f 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -3,6 +3,9 @@
class MergeRequestWidgetEntity < Grape::Entity
include RequestAwareEntity
+ expose :id
+ expose :iid
+
expose :source_project_full_path do |merge_request|
merge_request.source_project&.full_path
end
@@ -65,6 +68,8 @@ class MergeRequestWidgetEntity < Grape::Entity
end
def as_json(options = {})
+ return super(options) if Feature.enabled?(:async_mr_widget)
+
super(options)
.merge(MergeRequestPollCachedWidgetEntity.new(object, **@options.opts_hash).as_json(options))
.merge(MergeRequestPollWidgetEntity.new(object, **@options.opts_hash).as_json(options))
diff --git a/app/views/ci/variables/_variable_row.html.haml b/app/views/ci/variables/_variable_row.html.haml
index ed9b3ab1940..4244556a24a 100644
--- a/app/views/ci/variables/_variable_row.html.haml
+++ b/app/views/ci/variables/_variable_row.html.haml
@@ -44,31 +44,21 @@
.ci-variable-body-item.ci-variable-protected-item.table-section.section-20.mr-0.border-top-0
.append-right-default
= s_("CiVariable|Protected")
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_protected}",
- "aria-label": s_("CiVariable|Toggle protected") }
+ = render "shared/buttons/project_feature_toggle", is_checked: is_protected, label: s_("CiVariable|Toggle protected") do
%input{ type: "hidden",
class: 'js-ci-variable-input-protected js-project-feature-toggle-input',
name: protected_input_name,
value: is_protected,
data: { default: is_protected_default.to_s } }
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
.ci-variable-body-item.ci-variable-masked-item.table-section.section-20.mr-0.border-top-0
.append-right-default
= s_("CiVariable|Masked")
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}",
- "aria-label": s_("CiVariable|Toggle masked") }
+ = render "shared/buttons/project_feature_toggle", is_checked: is_masked, label: s_("CiVariable|Toggle masked"), class_list: "js-project-feature-toggle project-feature-toggle qa-variable-masked" do
%input{ type: "hidden",
class: 'js-ci-variable-input-masked js-project-feature-toggle-input',
name: masked_input_name,
value: is_masked,
data: { default: is_masked_default.to_s } }
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
= render_if_exists 'ci/variables/environment_scope', form_field: form_field, variable: variable
%button.js-row-remove-button.ci-variable-row-remove-button.table-section.section-5.border-top-0{ type: 'button', 'aria-label': s_('CiVariables|Remove variable row') }
= icon('minus-circle')
diff --git a/app/views/clusters/clusters/_form.html.haml b/app/views/clusters/clusters/_form.html.haml
index 3d0266a2d5b..f9085b781fb 100644
--- a/app/views/clusters/clusters/_form.html.haml
+++ b/app/views/clusters/clusters/_form.html.haml
@@ -3,14 +3,8 @@
.form-group
%h5= s_('ClusterIntegration|Integration status')
%label.append-bottom-0.js-cluster-enable-toggle-area
- %button{ type: 'button',
- class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
- "aria-label": s_("ClusterIntegration|Toggle Kubernetes cluster"),
- disabled: !can?(current_user, :update_cluster, @cluster) }
+ = render "shared/buttons/project_feature_toggle", is_checked: @cluster.enabled?, label: s_("ClusterIntegration|Toggle Kubernetes cluster"), disabled: !can?(current_user, :update_cluster, @cluster) do
= field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
- %span.toggle-icon
- = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
- = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
.form-text.text-muted= s_('ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.')
.form-group
diff --git a/app/views/shared/buttons/_project_feature_toggle.html.haml b/app/views/shared/buttons/_project_feature_toggle.html.haml
new file mode 100644
index 00000000000..0f630786455
--- /dev/null
+++ b/app/views/shared/buttons/_project_feature_toggle.html.haml
@@ -0,0 +1,16 @@
+- class_list ||= "js-project-feature-toggle project-feature-toggle"
+- data ||= nil
+- disabled ||= false
+- is_checked ||= false
+- label ||= nil
+
+%button{ type: 'button',
+ class: "#{class_list} #{'is-disabled' if disabled} #{'is-checked' if is_checked}",
+ "aria-label": label,
+ disabled: disabled,
+ data: data }
+ - if yield.present?
+ = yield
+ %span.toggle-icon
+ = sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
+ = sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')