Commit 382d194c authored by Constance Okoghenun's avatar Constance Okoghenun

Resolved conflicts in config/webpack.config.js

parents 63fee787 f290e2ea
......@@ -5,12 +5,12 @@ import Vue from 'vue';
import Flash from '~/flash';
import { __ } from '~/locale';
import '~/vue_shared/models/label';
import FilteredSearchBoards from './filtered_search_boards';
import eventHub from './eventhub';
import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import/first
import './models/issue';
import './models/label';
import './models/list';
import './models/milestone';
import './models/assignee';
......
<script>
import LabelsSelect from '~/labels_select';
import LoadingIcon from '../../loading_icon.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import DropdownButton from './dropdown_button.vue';
import DropdownHiddenInput from './dropdown_hidden_input.vue';
import DropdownHeader from './dropdown_header.vue';
import DropdownSearchInput from './dropdown_search_input.vue';
import DropdownFooter from './dropdown_footer.vue';
import DropdownCreateLabel from './dropdown_create_label.vue';
export default {
components: {
LoadingIcon,
DropdownTitle,
DropdownValue,
DropdownValueCollapsed,
DropdownButton,
DropdownHiddenInput,
DropdownHeader,
DropdownSearchInput,
DropdownFooter,
DropdownCreateLabel,
},
props: {
showCreate: {
type: Boolean,
required: false,
default: false,
},
abilityName: {
type: String,
required: true,
},
context: {
type: Object,
required: true,
},
namespace: {
type: String,
required: false,
default: '',
},
updatePath: {
type: String,
required: false,
default: '',
},
labelsPath: {
type: String,
required: true,
},
labelsWebUrl: {
type: String,
required: false,
default: '',
},
labelFilterBasePath: {
type: String,
required: false,
default: '',
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
hiddenInputName() {
return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]';
},
},
mounted() {
this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
handleClick: this.handleClick,
});
},
methods: {
handleClick(label) {
this.$emit('onLabelClick', label);
},
},
};
</script>
<template>
<div class="block labels">
<dropdown-value-collapsed
v-if="showCreate"
:labels="context.labels"
/>
<dropdown-title
:can-edit="canEdit"
/>
<dropdown-value
:labels="context.labels"
:label-filter-base-path="labelFilterBasePath"
>
<slot></slot>
</dropdown-value>
<div
v-if="canEdit"
class="selectbox"
style="display: none;"
>
<dropdown-hidden-input
v-for="label in context.labels"
:key="label.id"
:name="hiddenInputName"
:label="label"
/>
<div class="dropdown">
<dropdown-button
:ability-name="abilityName"
:field-name="hiddenInputName"
:update-path="updatePath"
:labels-path="labelsPath"
:namespace="namespace"
:labels="context.labels"
:show-extra-options="!showCreate"
/>
<div
class="dropdown-menu dropdown-select dropdown-menu-paging
dropdown-menu-labels dropdown-menu-selectable"
>
<div class="dropdown-page-one">
<dropdown-header v-if="showCreate" />
<dropdown-search-input/>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
<dropdown-footer
v-if="showCreate"
:labels-web-url="labelsWebUrl"
/>
</div>
<dropdown-create-label
v-if="showCreate"
/>
</div>
</div>
</div>
</div>
</template>
<script>
import { __, s__, sprintf } from '~/locale';
export default {
props: {
abilityName: {
type: String,
required: true,
},
fieldName: {
type: String,
required: true,
},
updatePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
namespace: {
type: String,
required: true,
},
labels: {
type: Array,
required: true,
},
showExtraOptions: {
type: Boolean,
required: true,
},
},
computed: {
dropdownToggleText() {
if (this.labels.length === 0) {
return __('Label');
}
if (this.labels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: this.labels[0].title,
remainingLabelCount: this.labels.length - 1,
});
}
return this.labels[0].title;
},
},
};
</script>
<template>
<button
type="button"
ref="dropdownButton"
class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
data-toggle="dropdown"
:class="{ 'js-extra-options': showExtraOptions }"
:data-ability-name="abilityName"
:data-field-name="fieldName"
:data-issue-update="updatePath"
:data-labels="labelsPath"
:data-namespace-path="namespace"
:data-show-any="showExtraOptions"
>
<span class="dropdown-toggle-text">
{{ dropdownToggleText }}
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down"
data-hidden="true"
>
</i>
</button>
</template>
<script>
export default {
created() {
this.suggestedColors = gon.suggested_label_colors;
},
};
</script>
<template>
<div class="dropdown-page-two dropdown-new-label">
<div class="dropdown-title">
<button
type="button"
class="dropdown-title-button dropdown-menu-back"
:aria-label="__('Go back')"
>
<i
aria-hidden="true"
class="fa fa-arrow-left"
data-hidden="true"
>
</i>
</button>
{{ __('Create new label') }}
<button
type="button"
class="dropdown-title-button dropdown-menu-close"
:aria-label="__('Close')"
>
<i
aria-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
data-hidden="true"
>
</i>
</button>
</div>
<div class="dropdown-content">
<div class="dropdown-labels-error js-label-error"></div>
<input
id="new_label_name"
type="text"
class="default-dropdown-input"
:placeholder="__('Name new label')"
/>
<div class="suggest-colors suggest-colors-dropdown">
<a
v-for="(color, index) in suggestedColors"
href="#"
:key="index"
:data-color="color"
:style="{
backgroundColor: color,
}"
>
&nbsp;
</a>
</div>
<div class="dropdown-label-color-input">
<div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div>
<input
id="new_label_color"
type="text"
class="default-dropdown-input"
:placeholder="__('Assign custom color like #FF0000')"
/>
</div>
<div class="clearfix">
<button
type="button"
class="btn btn-primary pull-left js-new-label-btn disabled"
>
{{ __('Create') }}
</button>
<button
type="button"
class="btn btn-default pull-right js-cancel-label-btn"
>
{{ __('Cancel') }}
</button>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
labelsWebUrl: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="dropdown-footer">
<ul class="dropdown-footer-list">
<li>
<a
href="#"
class="dropdown-toggle-page"
>
{{ __('Create new label') }}
</a>
</li>
<li>
<a
data-is-link="true"
class="dropdown-external-link"
:href="labelsWebUrl"
>
{{ __('Manage labels') }}
</a>
</li>
</ul>
</div>
</template>
<script>
export default {};
</script>
<template>
<div class="dropdown-title">
<span>{{ __('Assign labels') }}</span>
<button
type="button"
class="dropdown-title-button dropdown-menu-close"
:aria-label="__('Close')"
>
<i
aria-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
data-hidden="true"
>
</i>
</button>
</div>
</template>
<script>
export default {
props: {
name: {
type: String,
required: true,
},
label: {
type: Object,
required: true,
},
},
};
</script>
<template>
<input
type="hidden"
:name="name"
:value="label.id"
/>
</template>
<script>
export default {};
</script>
<template>
<div class="dropdown-input">
<input
autocomplete="off"
class="dropdown-input-field"
type="search"
:placeholder="__('Search')"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
data-hidden="true"
>
</i>
<i
aria-hidden="true"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
role="button"
>
</i>
</div>
</template>
<script>
export default {
props: {
canEdit: {
type: Boolean,
required: true,
},
},
};
</script>
<template>
<div class="title hide-collapsed append-bottom-10">
{{ __('Labels') }}
<template v-if="canEdit">
<i
aria-hidden="true"
class="fa fa-spinner fa-spin block-loading"
data-hidden="true"
>
</i>
<button
type="button"
class="edit-link btn btn-blank pull-right js-sidebar-dropdown-toggle"
>
{{ __('Edit') }}
</button>
</template>
</div>
</template>
<script>
import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
labels: {
type: Array,
required: true,
},
labelFilterBasePath: {
type: String,
required: true,
},
},
computed: {
isEmpty() {
return this.labels.length === 0;
},
},
methods: {
labelFilterUrl(label) {
return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
},
labelStyle(label) {
return {
color: label.textColor,
backgroundColor: label.color,
};
},
},
};
</script>
<template>
<div class="hide-collapsed value issuable-show-labels">
<span
v-if="isEmpty"
class="text-secondary"
>
<slot>{{ __('None') }}</slot>
</span>
<a
v-else
v-for="label in labels"
:key="label.id"
:href="labelFilterUrl(label)"
>
<span
v-tooltip
class="label color-label"
data-placement="bottom"
data-container="body"
:style="labelStyle(label)"
:title="label.description"
>
{{ label.title }}
</span>
</a>
</div>
</template>
<script>
import { s__, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
export default {
directives: {
tooltip,
},
props: {
labels: {
type: Array,
required: true,
},
},
computed: {
labelsList() {
const labelsString = this.labels.slice(0, 5).map(label => label.title).join(', ');
if (this.labels.length > 5) {
return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), {
labelsString,
remainingLabelCount: this.labels.length - 5,
});
}
return labelsString;
},
},
};
</script>
<template>
<div
v-tooltip
class="sidebar-collapsed-icon"
data-placement="left"
data-container="body"
:title="labelsList"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-tags"
>
</i>
<span>{{ labels.length }}</span>
</div>
</template>
/* eslint-disable no-unused-vars, space-before-function-paren */
class ListLabel {
constructor (obj) {
constructor(obj) {
this.id = obj.id;
this.title = obj.title;
this.type = obj.type;
......
module LabelsHelper
extend self
include ActionView::Helpers::TagHelper
def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
......
class Badge < ActiveRecord::Base
# This structure sets the placeholders that the urls
# can have. This hash also sets which action to ask when
# the placeholder is found.
PLACEHOLDERS = {
'project_path' => :full_path,
'project_id' => :id,
'default_branch' => :default_branch,
'commit_sha' => ->(project) { project.commit&.sha }
}.freeze
# This regex is built dynamically using the keys from the PLACEHOLDER struct.
# So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
default_scope { order_created_at_asc }
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX }
validates :type, presence: true
def rendered_link_url(project = nil)
build_rendered_url(link_url, project)
end
def rendered_image_url(project = nil)
build_rendered_url(image_url, project)
end
private
def build_rendered_url(url, project = nil)
return url unless valid? && project
Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg|
replace_placeholder_action(PLACEHOLDERS[arg], project)
end
end
# The action param represents the :symbol or Proc to call in order
# to retrieve the return value from the project.
# This method checks if it is a Proc and use the call method, and if it is
# a symbol just send the action
def replace_placeholder_action(action, project)
return unless project
action.is_a?(Proc) ? action.call(project) : project.public_send(action) # rubocop:disable GitlabSecurity/PublicSend
end
end
class GroupBadge < Badge
belongs_to :group
validates :group, presence: true
end
class ProjectBadge < Badge
belongs_to :project
validates :project, presence: true
def rendered_link_url(project = nil)
project ||= self.project
super
end
def rendered_image_url(project = nil)
project ||= self.project
super
end
end
......@@ -39,6 +39,7 @@ class Group < Namespace
# We cannot simply set `has_many :audit_events, as: :entity, dependent: :destroy`
# here since Group inherits from Namespace, the entity_type would be set to `Namespace`.
has_many :audit_events, -> { where(entity_type: Group) }, foreign_key: 'entity_id'
has_many :badges, class_name: 'GroupBadge'
accepts_nested_attributes_for :variables, allow_destroy: true
......
......@@ -226,6 +226,8 @@ class Project < ActiveRecord::Base
has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
......@@ -1772,6 +1774,17 @@ class Project < ActiveRecord::Base
.set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
end
def badges
return project_badges unless group
group_badges_rel = GroupBadge.where(group: group.self_and_ancestors)
union = Gitlab::SQL::Union.new([project_badges.select(:id),
group_badges_rel.select(:id)])
Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
private
def storage
......
module Badges
class BaseService
protected
attr_accessor :params
def initialize(params = {})
@params = params.dup
end
end
end
module Badges
class BuildService < Badges::BaseService
# returns the created badge
def execute(source)
if source.is_a?(Group)
GroupBadge.new(params.merge(group: source))
else
ProjectBadge.new(params.merge(project: source))
end
end
end
end
module Badges
class CreateService < Badges::BaseService
# returns the created badge
def execute(source)
badge = Badges::BuildService.new(params).execute(source)
badge.tap { |b| b.save }
end
end
end
module Badges
class UpdateService < Badges::BaseService
# returns the updated badge
def execute(badge)
if params.present?
badge.update_attributes(params)
end
badge
end
end
end
# UrlValidator
#
# Custom validator for URLs.
#
# By default, only URLs for the HTTP(S) protocols will be considered valid.
# Provide a `:protocols` option to configure accepted protocols.
#
# Also, this validator can help you validate urls with placeholders inside.
# Usually, if you have a url like 'http://www.example.com/%{project_path}' the
# URI parser will reject that URL format. Provide a `:placeholder_regex` option
# to configure accepted placeholders.
#
# Example:
#
# class User < ActiveRecord::Base
# validates :personal_url, url: true
#
# validates :ftp_url, url: { protocols: %w(ftp) }
#
# validates :git_url, url: { protocols: %w(http https ssh git) }
#
# validates :placeholder_url, url: { placeholder_regex: /(project_path|project_id|default_branch)/ }
# end
#
class UrlPlaceholderValidator < UrlValidator
def validate_each(record, attribute, value)
placeholder_regex = self.options[:placeholder_regex]
value = value.gsub(/%{#{placeholder_regex}}/, 'foo') if placeholder_regex && value
super(record, attribute, value)
end
end
......@@ -30,6 +30,12 @@
%br
= render "shared/mirror_status"
.project-badges
- @project.badges.each do |badge|
- badge_link_url = badge.rendered_link_url(@project)
%a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' }
%img{ src: badge.rendered_image_url(@project), alt: badge_link_url }
.project-repo-buttons
.count-buttons
= render 'projects/buttons/star'
......
......@@ -5,7 +5,6 @@
- content_for :page_specific_javascripts do
= webpack_bundle_tag('common_vue')
= webpack_bundle_tag('service_desk')
.project-edit-container
%section.settings.general-settings.no-animate{ class: ('expanded' if expanded) }
......
---
title: Implemented badge API endpoints
merge_request: 17082
author:
type: added
......@@ -28,6 +28,7 @@ module Gitlab
# This is a nice reference article on autoloading/eager loading:
# http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload
config.eager_load_paths.push(*%W[#{config.root}/lib
#{config.root}/app/models/badges
#{config.root}/app/models/hooks
#{config.root}/app/models/members
#{config.root}/app/models/project_services
......
......@@ -57,7 +57,6 @@ function generateEntries() {
// EE-only
ide: 'ee/ide/index.js',
service_desk: 'ee/projects/settings_service_desk/service_desk_bundle.js',
};
return Object.assign(manualEntries, autoEntries);
......
class CreateBadges < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :badges do |t|
t.string :link_url, null: false
t.string :image_url, null: false
t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: true
t.integer :group_id, index: true, null: true
t.string :type, null: false
t.timestamps_with_timezone null: false
end
add_foreign_key :badges, :namespaces, column: :group_id, on_delete: :cascade
end
end
......@@ -241,6 +241,19 @@ ActiveRecord::Schema.define(version: 20180301084653) do
add_index "award_emoji", ["awardable_type", "awardable_id"], name: "index_award_emoji_on_awardable_type_and_awardable_id", using: :btree
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
create_table "badges", force: :cascade do |t|
t.string "link_url", null: false
t.string "image_url", null: false
t.integer "project_id"
t.integer "group_id"
t.string "type", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
end
add_index "badges", ["group_id"], name: "index_badges_on_group_id", using: :btree
add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree
create_table "board_assignees", force: :cascade do |t|
t.integer "board_id", null: false
t.integer "assignee_id", null: false
......@@ -2501,6 +2514,8 @@ ActiveRecord::Schema.define(version: 20180301084653) do
add_foreign_key "approvals", "merge_requests", name: "fk_310d714958", on_delete: :cascade
add_foreign_key "approver_groups", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "badges", "projects", on_delete: :cascade
add_foreign_key "board_assignees", "boards", on_delete: :cascade
add_foreign_key "board_assignees", "users", column: "assignee_id", on_delete: :cascade
add_foreign_key "board_labels", "boards", on_delete: :cascade
......
......@@ -27,6 +27,7 @@ following locations:
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
- [Group Access Requests](access_requests.md)
- [Group Badges](group_badges.md)
- [Group Members](members.md)
- [Issues](issues.md)
- [Issue Boards](boards.md)
......@@ -48,6 +49,7 @@ following locations:
- [Pipeline Schedules](pipeline_schedules.md)
- [Projects](projects.md) including setting Webhooks
- [Project Access Requests](access_requests.md)
- [Project Badges](project_badges.md)
- [Project import/export](project_import_export.md)
- [Project Members](members.md)
- [Project Snippets](project_snippets.md)
......
# Group badges API
## Placeholder tokens
Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are:
- **%{project_path}**: will be replaced by the project path.
- **%{project_id}**: will be replaced by the project id.
- **%{default_branch}**: will be replaced by the project default branch.
- **%{commit_sha}**: will be replaced by the last project's commit sha.
Because these enpoints aren't inside a project's context, the information used to replace the placeholders will be
from the first group's project by creation date. If the group hasn't got any project the original URL with the placeholders will be returned.
## List all badges of a group
Gets a list of a group's badges.
```
GET /groups/:id/badges
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges
```
Example response:
```json
[
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
},
{
"id": 2,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
},
]
```
## Get a badge of a group
Gets a badge of a group.
```
GET /groups/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
}
```
## Add a badge to a group
Adds a badge to a group.
```
POST /groups/:id/badges
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `link_url` | string | yes | URL of the badge link |
| `image_url` | string | yes | URL of the badge image |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/groups/:id/badges
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge1",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge1",
"kind": "group"
}
```
## Edit a badge of a group
Updates a badge of a group.
```
PUT /groups/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
| `link_url` | string | no | URL of the badge link |
| `image_url` | string | no | URL of the badge image |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
}
```
## Remove a badge from a group
Removes a badge from a group.
```
DELETE /groups/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
```
## Preview a badge from a group
Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation.
```
GET /groups/:id/badges/render
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `link_url` | string | yes | URL of the badge link|
| `image_url` | string | yes | URL of the badge image |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge
```
Example response:
```json
{
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
}
```
......@@ -582,3 +582,7 @@ And to switch pages add:
```
[ce-15142]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15142
## Group badges
Read more in the [Group Badges](group_badges.md) documentation.
# Project badges API
## Placeholder tokens
Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are:
- **%{project_path}**: will be replaced by the project path.
- **%{project_id}**: will be replaced by the project id.
- **%{default_branch}**: will be replaced by the project default branch.
- **%{commit_sha}**: will be replaced by the last project's commit sha.
## List all badges of a project
Gets a list of a project's badges and its group badges.
```
GET /projects/:id/badges
```
| 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 |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges
```
Example response:
```json
[
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "project"
},
{
"id": 2,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
},
]
```
## Get a badge of a project
Gets a badge of a project.
```
GET /projects/:id/badges/:badge_id
```
| 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 |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "project"
}
```
## Add a badge to a project
Adds a badge to a project.
```
POST /projects/:id/badges
```
| 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 |
| `link_url` | string | yes | URL of the badge link |
| `image_url` | string | yes | URL of the badge image |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/projects/:id/badges
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge1",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge1",
"kind": "project"
}
```
## Edit a badge of a project
Updates a badge of a project.
```
PUT /projects/:id/badges/:badge_id
```
| 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 |
| `badge_id` | integer | yes | The badge ID |
| `link_url` | string | no | URL of the badge link |
| `image_url` | string | no | URL of the badge image |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "project"
}
```
## Remove a badge from a project
Removes a badge from a project. Only project's badges will be removed by using this endpoint.
```
DELETE /projects/:id/badges/:badge_id
```
| 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 |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
```
## Preview a badge from a project
Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation.
```
GET /projects/:id/badges/render
```
| 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 |
| `link_url` | string | yes | URL of the badge link|
| `image_url` | string | yes | URL of the badge image |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge
```
Example response:
```json
{
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
}
```
......@@ -1450,3 +1450,7 @@ POST /projects/:id/mirror/pull
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) |
## Project badges
Read more in the [Project Badges](project_badges.md) documentation.
<script>
/* global ListLabel */
/* global BoardService */
import Flash from '~/flash';
import modal from '~/vue_shared/components/modal.vue';
import BoardLabelsSelect from '~/vue_shared/components/sidebar/labels_select/base.vue';
import { visitUrl } from '~/lib/utils/url_utility';
import BoardMilestoneSelect from './milestone_select.vue';
import BoardWeightSelect from './weight_select.vue';
import BoardLabelsSelect from './labels_select.vue';
import AssigneeSelect from './assignee_select.vue';
window.gl = window.gl || {};
......@@ -143,6 +144,22 @@ export default {
}
},
methods: {
handleLabelClick(label) {
if (label.isAny) {
this.board.labels = [];
} else if (!this.board.labels.find(l => l.id === label.id)) {
this.board.labels.push(new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
textColor: label.text_color,
}));
} else {
let labels = this.board.labels;
labels = labels.filter(selected => selected.id !== label.id);
this.board.labels = labels;
}
},
submit() {
if (this.board.name.length === 0) return;
this.isLoading = true;
......@@ -251,10 +268,14 @@ export default {
/>
<board-labels-select
:board="board"
:can-edit="canAdminBoard"
ability-name="issue"
:context="board"
:labels-path="labelsPath"
/>
:can-edit="canAdminBoard"
@onLabelClick="handleLabelClick"
>
{{ __('Any Label') }}
</board-labels-select>
<assignee-select
any-user-text="Any assignee"
......
<script>
/* global ListLabel */
import LabelsSelect from '~/labels_select';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
components: {
loadingIcon,
},
props: {
board: {
type: Object,
required: true,
},
labelsPath: {
type: String,
required: true,
},
canEdit: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
labelIds() {
return this.board.labels.map(label => label.id);
},
isEmpty() {
return this.board.labels.length === 0;
},
},
mounted() {
this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
handleClick: this.handleClick,
});
},
methods: {
labelStyle(label) {
return {
color: label.textColor,
backgroundColor: label.color,
};
},
handleClick(label) {
if (label.isAny) {
this.board.labels = [];
} else if (!this.board.labels.find(l => l.id === label.id)) {
this.board.labels.push(new ListLabel({
id: label.id,
title: label.title,
color: label.color[0],
textColor: label.text_color,
}));
} else {
let labels = this.board.labels;
labels = labels.filter(selected => selected.id !== label.id);
this.board.labels = labels;
}
},
},
};
</script>
<template>
<div class="block labels">
<div class="title append-bottom-10">
Labels
<button
v-if="canEdit"
type="button"
class="edit-link btn btn-blank pull-right"
>
Edit
</button>
</div>
<div class="value issuable-show-labels">
<span
v-if="isEmpty"
class="text-secondary"
>
Any Label
</span>
<a
v-else
href="#"
v-for="label in board.labels"
:key="label.id"
>
<span
class="label color-label"
:style="labelStyle(label)"
>
{{ label.title }}
</span>
</a>
</div>
<div
class="selectbox"
style="display: none"
>
<input
type="hidden"
name="label_id[]"
v-for="labelId in labelIds"
:key="labelId"
:value="labelId"
/>
<div class="dropdown">
<button
ref="dropdownButton"
:data-labels="labelsPath"
class="dropdown-menu-toggle wide js-label-select
js-multiselect js-extra-options js-board-config-modal"
data-field-name="label_id[]"
:data-show-any="true"
data-toggle="dropdown"
type="button"
>
<span class="dropdown-toggle-text">
Label
</span>
<i
aria-hidden="true"
class="fa fa-chevron-down"
data-hidden="true"
>
</i>
</button>
<div
class="dropdown-menu dropdown-select
dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable"
>
<div class="dropdown-input">
<input
autocomplete="off"
class="dropdown-input-field"
placeholder="Search"
type="search"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
data-hidden="true"
>
</i>
<i
aria-hidden="true"
class="fa fa-times dropdown-input-clear js-dropdown-input-clear"
data-hidden="true"
role="button"
>
</i>
</div>
<div class="dropdown-content"></div>
<div class="dropdown-loading">
<loading-icon />
</div>
</div>
</div>
</div>
</div>
</template>
......@@ -4,6 +4,7 @@ import UsersSelect from '~/users_select';
import UserCallout from '~/user_callout';
import groupsSelect from '~/groups_select';
import ApproversSelect from 'ee/approvers_select';
import initServiceDesk from 'ee/projects/settings_service_desk';
document.addEventListener('DOMContentLoaded', () => {
new UsersSelect();
......@@ -12,4 +13,5 @@ document.addEventListener('DOMContentLoaded', () => {
new UserCallout({ className: 'js-service-desk-callout' });
new UserCallout({ className: 'js-mr-approval-callout' });
new ApproversSelect();
initServiceDesk();
});
......@@ -2,7 +2,7 @@ import Vue from 'vue';
import { convertPermissionToBoolean } from '~/lib/utils/common_utils';
import serviceDeskRoot from './components/service_desk_root.vue';
document.addEventListener('DOMContentLoaded', () => {
export default () => {
const serviceDeskRootElement = document.querySelector('.js-service-desk-setting-root');
if (serviceDeskRootElement) {
// eslint-disable-next-line no-new
......@@ -32,4 +32,4 @@ document.addEventListener('DOMContentLoaded', () => {
},
});
}
});
};
......@@ -119,6 +119,7 @@ module API
mount ::API::AccessRequests
mount ::API::Applications
mount ::API::AwardEmoji
mount ::API::Badges
mount ::API::Boards
mount ::API::Branches
mount ::API::BroadcastMessages
......
module API
class Badges < Grape::API
include PaginationParams
before { authenticate_non_get! }
helpers ::API::Helpers::BadgesHelpers
helpers do
def find_source_if_admin(source_type)
source = find_source(source_type, params[:id])
authorize_admin_source!(source_type, source)
source
end
end
%w[group project].each do |source_type|
params do
requires :id, type: String, desc: "The ID of a #{source_type}"
end
resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do
desc "Gets a list of #{source_type} badges viewable by the authenticated user." do
detail 'This feature was introduced in GitLab 10.6.'
success Entities::Badge
end
params do
use :pagination
end
get ":id/badges" do
source = find_source(source_type, params[:id])
present_badges(source, paginate(source.badges))
end
desc "Preview a badge from a #{source_type}." do
detail 'This feature was introduced in GitLab 10.6.'
success Entities::BasicBadgeDetails
end
params do
requires :link_url, type: String, desc: 'URL of the badge link'
requires :image_url, type: String, desc: 'URL of the badge image'
end
get ":id/badges/render" do
authenticate!
source = find_source_if_admin(source_type)
badge = ::Badges::BuildService.new(declared_params(include_missing: false))
.execute(source)
if badge.valid?
present_badges(source, badge, with: Entities::BasicBadgeDetails)
else
render_validation_error!(badge)
end
end
desc "Gets a badge of a #{source_type}." do
detail 'This feature was introduced in GitLab 10.6.'
success Entities::Badge
end
params do
requires :badge_id, type: Integer, desc: 'The badge ID'
end
get ":id/badges/:badge_id" do
source = find_source(source_type, params[:id])
badge = find_badge(source)
present_badges(source, badge)
end
desc "Adds a badge to a #{source_type}." do
detail 'This feature was introduced in GitLab 10.6.'
success Entities::Badge
end
params do
requires :link_url, type: String, desc: 'URL of the badge link'
requires :image_url, type: String, desc: 'URL of the badge image'
end
post ":id/badges" do
source = find_source_if_admin(source_type)
badge = ::Badges::CreateService.new(declared_params(include_missing: false)).execute(source)
if badge.persisted?
present_badges(source, badge)
else
render_validation_error!(badge)
end
end
desc "Updates a badge of a #{source_type}." do
detail 'This feature was introduced in GitLab 10.6.'
success Entities::Badge
end
params do
optional :link_url, type: String, desc: 'URL of the badge link'
optional :image_url, type: String, desc: 'URL of the badge image'
end
put ":id/badges/:badge_id" do
source = find_source_if_admin(source_type)
badge = ::Badges::UpdateService.new(declared_params(include_missing: false))
.execute(find_badge(source))
if badge.valid?
present_badges(source, badge)
else
render_validation_error!(badge)
end
end
desc 'Removes a badge from a project or group.' do
detail 'This feature was introduced in GitLab 10.6.'
end
params do
requires :badge_id, type: Integer, desc: 'The badge ID'
end
delete ":id/badges/:badge_id" do
source = find_source_if_admin(source_type)
badge = find_badge(source)
if badge.is_a?(GroupBadge) && source.is_a?(Project)
error!('To delete a Group badge please use the Group endpoint', 403)
end
destroy_conditionally!(badge)
end
end
end
end
end
......@@ -1238,6 +1238,24 @@ module API
expose :project_id
end
class BasicBadgeDetails < Grape::Entity
expose :link_url
expose :image_url
expose :rendered_link_url do |badge, options|
badge.rendered_link_url(options.fetch(:project, nil))
end
expose :rendered_image_url do |badge, options|
badge.rendered_image_url(options.fetch(:project, nil))
end
end
class Badge < BasicBadgeDetails
expose :id
expose :kind do |badge|
badge.type == 'ProjectBadge' ? 'project' : 'group'
end
end
def self.prepend_entity(klass, with: nil)
if with.nil?
raise ArgumentError, 'You need to pass either the :with or :namespace option!'
......
module API
module Helpers
module BadgesHelpers
include ::API::Helpers::MembersHelpers
def find_badge(source)
source.badges.find(params[:badge_id])
end
def present_badges(source, records, options = {})
entity_type = options[:with] || Entities::Badge
badge_params = badge_source_params(source).merge(with: entity_type)
present records, badge_params
end
def badge_source_params(source)
project = if source.is_a?(Project)
source
else
GroupProjectsFinder.new(group: source, current_user: current_user).execute.first
end
{ project: project }
end
end
end
end
......@@ -20,6 +20,7 @@ module Gitlab
gon.sprite_icons = IconsHelper.sprite_icon_path
gon.sprite_file_icons = IconsHelper.sprite_file_icons_path
gon.test_env = Rails.env.test?
gon.suggested_label_colors = LabelsHelper.suggested_colors
if current_user
gon.current_user_id = current_user.id
......
......@@ -65,6 +65,7 @@ project_tree:
- :create_access_levels
- :project_feature
- :custom_attributes
- :project_badges
# Only include the following attributes for the models specified.
included_attributes:
......@@ -133,6 +134,8 @@ excluded_attributes:
- :when
push_event_payload:
- :event_id
project_badges:
- :group_id
methods:
labels:
......@@ -155,3 +158,5 @@ methods:
- :action
push_event_payload:
- :action
project_badges:
- :type
......@@ -16,7 +16,8 @@ module Gitlab
priorities: :label_priorities,
auto_devops: :project_auto_devops,
label: :project_label,
custom_attributes: 'ProjectCustomAttribute' }.freeze
custom_attributes: 'ProjectCustomAttribute',
project_badges: 'Badge' }.freeze
USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze
......
module Gitlab
class StringPlaceholderReplacer
# This method accepts the following paras
# - string: the string to be analyzed
# - placeholder_regex: i.e. /%{project_path|project_id|default_branch|commit_sha}/
# - block: this block will be called with each placeholder found in the string using
# the placeholder regex. If the result of the block is nil, the original
# placeholder will be returned.
def self.replace_string_placeholders(string, placeholder_regex = nil, &block)
return string if string.blank? || placeholder_regex.blank? || !block_given?
replace_placeholders(string, placeholder_regex, &block)
end
class << self
private
# If the result of the block is nil, then the placeholder is returned
def replace_placeholders(string, placeholder_regex, &block)
string.gsub(/%{(#{placeholder_regex})}/) do |arg|
yield($~[1]) || arg
end
end
end
end
end
FactoryBot.define do
trait :base_badge do
link_url { generate(:url) }
image_url { generate(:url) }
end
factory :project_badge, traits: [:base_badge], class: ProjectBadge do
project
end
factory :group_badge, aliases: [:badge], traits: [:base_badge], class: GroupBadge do
group
end
end
......@@ -9,8 +9,8 @@ import axios from '~/lib/utils/axios_utils';
import '~/boards/models/assignee';
import eventHub from '~/boards/eventhub';
import '~/vue_shared/models/label';
import '~/boards/models/list';
import '~/boards/models/label';
import '~/boards/stores/boards_store';
import boardCard from '~/boards/components/board_card.vue';
import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data';
......
......@@ -6,8 +6,9 @@ import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import '~/vue_shared/models/label';
import '~/boards/models/issue';
import '~/boards/models/label';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service';
......
......@@ -24,6 +24,37 @@ describe('board_form.vue', () => {
});
describe('methods', () => {
describe('handleLabelClick', () => {
const label = {
id: 1,
title: 'Foo',
color: ['#BADA55'],
text_color: '#FFFFFF',
};
it('initializes `board.labels` as empty array when `label.isAny` is `true`', () => {
const labelIsAny = { isAny: true };
vm.handleLabelClick(labelIsAny);
expect(Array.isArray(vm.board.labels)).toBe(true);
expect(vm.board.labels.length).toBe(0);
});
it('adds provided `label` to board.labels', () => {
vm.handleLabelClick(label);
expect(vm.board.labels.length).toBe(1);
expect(vm.board.labels[0].id).toBe(label.id);
vm.handleLabelClick(label);
});
it('filters board.labels to exclude provided `label` if it is already present in `board.labels`', () => {
const label2 = Object.assign({}, label, { id: 2 });
vm.handleLabelClick(label);
vm.handleLabelClick(label2);
expect(vm.board.labels.length).toBe(1);
expect(vm.board.labels[0].id).toBe(label2.id);
});
});
describe('cancel', () => {
it('resets currentPage', (done) => {
vm.cancel();
......
/* global BoardService */
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import '~/labels_select';
import LabelsSelect from 'ee/boards/components/labels_select.vue';
import IssuableContext from '~/issuable_context';
let vm;
function selectedText() {
return vm.$el.querySelector('.value').innerText.trim();
}
function activeDropdownItem(index) {
const items = document.querySelectorAll('.is-active');
if (!items[index]) return '';
return items[index].innerText.trim();
}
const label = {
id: '1',
title: 'Testing',
color: 'red',
description: 'testing;',
};
const label2 = {
id: 2,
title: 'Still Testing',
color: 'red',
description: 'testing;',
};
describe('LabelsSelect', () => {
let mock;
beforeEach((done) => {
setFixtures('<div class="test-container"></div>');
mock = new MockAdapter(axios);
mock.onGet('/some/path').reply(200, [
label,
label2,
]);
// eslint-disable-next-line no-new
new IssuableContext();
const propsData = {
board: {
labels: [],
},
canEdit: true,
labelsPath: '/some/path',
};
const Component = Vue.extend(LabelsSelect);
vm = new Component({
propsData,
}).$mount('.test-container');
Vue.nextTick(done);
});
afterEach(() => {
mock.restore();
});
describe('canEdit', () => {
it('hides Edit button', (done) => {
vm.canEdit = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeFalsy();
done();
});
});
it('shows Edit button if true', (done) => {
vm.canEdit = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.edit-link')).toBeTruthy();
done();
});
});
});
describe('selected', () => {
it('shows Any Label', () => {
expect(selectedText()).toContain('Any Label');
});
it('shows single label', (done) => {
vm.board.labels = [label];
Vue.nextTick(() => {
expect(selectedText()).toContain(label.title);
done();
});
});
it('shows multiple labels', (done) => {
vm.board.labels = [label, label2];
Vue.nextTick(() => {
expect(selectedText()).toContain(label.title);
expect(selectedText()).toContain(label2.title);
done();
});
});
});
describe('clicking dropdown items', () => {
it('sets No labels', (done) => {
vm.board.labels = [label];
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[0].click();
});
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual('Any Label');
expect(vm.board.labels).toEqual([]);
done();
});
});
it('sets value', (done) => {
vm.$el.querySelector('.edit-link').click();
setTimeout(() => {
vm.$el.querySelectorAll('li a')[1].click();
});
setTimeout(() => {
expect(activeDropdownItem(0)).toEqual(label.title);
expect(vm.board.labels[0].title).toEqual(label.title);
done();
});
});
});
});
......@@ -4,8 +4,9 @@
import Vue from 'vue';
import _ from 'underscore';
import '~/vue_shared/models/label';
import '~/boards/models/issue';
import '~/boards/models/label';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/stores/boards_store';
......
......@@ -3,8 +3,8 @@
/* global ListIssue */
import Vue from 'vue';
import '~/vue_shared/models/label';
import '~/boards/models/issue';
import '~/boards/models/label';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service';
......
......@@ -6,8 +6,8 @@
import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import _ from 'underscore';
import '~/vue_shared/models/label';
import '~/boards/models/issue';
import '~/boards/models/label';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/services/board_service';
......
/* global ListIssue */
import '~/vue_shared/models/label';
import '~/boards/models/issue';
import '~/boards/models/label';
import '~/boards/models/list';
import '~/boards/models/assignee';
import '~/boards/stores/modal_store';
......
import Vue from 'vue';
import LabelsSelect from '~/labels_select';
import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue';
import { mockConfig, mockLabels } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = (config = mockConfig) => {
const Component = Vue.extend(baseComponent);
return mountComponent(Component, config);
};
describe('BaseComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('hiddenInputName', () => {
it('returns correct string when showCreate prop is `true`', () => {
expect(vm.hiddenInputName).toBe('issue[label_names][]');
});
it('returns correct string when showCreate prop is `false`', () => {
const mockConfigNonEditable = Object.assign({}, mockConfig, { showCreate: false });
const vmNonEditable = createComponent(mockConfigNonEditable);
expect(vmNonEditable.hiddenInputName).toBe('label_id[]');
vmNonEditable.$destroy();
});
});
});
describe('methods', () => {
describe('handleClick', () => {
it('emits onLabelClick event with label and list of labels as params', () => {
spyOn(vm, '$emit');
vm.handleClick(mockLabels[0]);
expect(vm.$emit).toHaveBeenCalledWith('onLabelClick', mockLabels[0]);
});
});
});
describe('mounted', () => {
it('creates LabelsSelect object and assigns it to `labelsDropdon` as prop', () => {
expect(vm.labelsDropdown instanceof LabelsSelect).toBe(true);
});
});
describe('template', () => {
it('renders component container element with classes `block labels`', () => {
expect(vm.$el.classList.contains('block')).toBe(true);
expect(vm.$el.classList.contains('labels')).toBe(true);
});
it('renders `.selectbox` element', () => {
expect(vm.$el.querySelector('.selectbox')).not.toBeNull();
expect(vm.$el.querySelector('.selectbox').getAttribute('style')).toBe('display: none;');
});
it('renders `.dropdown` element', () => {
expect(vm.$el.querySelector('.dropdown')).not.toBeNull();
});
it('renders `.dropdown-menu` element', () => {
const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu');
expect(dropdownMenuEl).not.toBeNull();
expect(dropdownMenuEl.querySelector('.dropdown-page-one')).not.toBeNull();
expect(dropdownMenuEl.querySelector('.dropdown-content')).not.toBeNull();
expect(dropdownMenuEl.querySelector('.dropdown-loading')).not.toBeNull();
});
});
});
import Vue from 'vue';
import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue';
import { mockConfig, mockLabels } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const componentConfig = Object.assign({}, mockConfig, {
fieldName: 'label_id[]',
labels: mockLabels,
showExtraOptions: false,
});
const createComponent = (config = componentConfig) => {
const Component = Vue.extend(dropdownButtonComponent);
return mountComponent(Component, config);
};
describe('DropdownButtonComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('dropdownToggleText', () => {
it('returns text as `Label` when `labels` prop is empty array', () => {
const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] });
const vmEmptyLabels = createComponent(mockEmptyLabels);
expect(vmEmptyLabels.dropdownToggleText).toBe('Label');
vmEmptyLabels.$destroy();
});
it('returns first label name with remaining label count when `labels` prop has more than one item', () => {
const mockMoreLabels = Object.assign({}, componentConfig, {
labels: mockLabels.concat(mockLabels),
});
const vmMoreLabels = createComponent(mockMoreLabels);
expect(vmMoreLabels.dropdownToggleText).toBe('Foo Label +1 more');
vmMoreLabels.$destroy();
});
it('returns first label name when `labels` prop has only one item present', () => {
expect(vm.dropdownToggleText).toBe('Foo Label');
});
});
});
describe('template', () => {
it('renders component container element of type `button`', () => {
expect(vm.$el.nodeName).toBe('BUTTON');
});
it('renders component container element with required data attributes', () => {
expect(vm.$el.dataset.abilityName).toBe(vm.abilityName);
expect(vm.$el.dataset.fieldName).toBe(vm.fieldName);
expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath);
expect(vm.$el.dataset.labels).toBe(vm.labelsPath);
expect(vm.$el.dataset.namespacePath).toBe(vm.namespace);
expect(vm.$el.dataset.showAny).not.toBeDefined();
});
it('renders dropdown toggle text element', () => {
const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text');
expect(dropdownToggleTextEl).not.toBeNull();
expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label');
});
it('renders dropdown button icon', () => {
const dropdownIconEl = vm.$el.querySelector('i.fa');
expect(dropdownIconEl).not.toBeNull();
expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true);
});
});
});
import Vue from 'vue';
import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue';
import { mockSuggestedColors } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(dropdownCreateLabelComponent);
return mountComponent(Component);
};
describe('DropdownCreateLabelComponent', () => {
let vm;
beforeEach(() => {
gon.suggested_label_colors = mockSuggestedColors;
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('created', () => {
it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => {
expect(vm.suggestedColors.length).toBe(mockSuggestedColors.length);
});
});
describe('template', () => {
it('renders component container element with classes `dropdown-page-two dropdown-new-label`', () => {
expect(vm.$el.classList.contains('dropdown-page-two', 'dropdown-new-label')).toBe(true);
});
it('renders `Go back` button on component header', () => {
const backButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-back');
expect(backButtonEl).not.toBe(null);
expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null);
});
it('renders component header element', () => {
const headerEl = vm.$el.querySelector('.dropdown-title');
expect(headerEl.innerText.trim()).toContain('Create new label');
});
it('renders `Close` button on component header', () => {
const closeButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close');
expect(closeButtonEl).not.toBe(null);
expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null);
});
it('renders `Name new label` input element', () => {
expect(vm.$el.querySelector('.dropdown-labels-error.js-label-error')).not.toBe(null);
expect(vm.$el.querySelector('input#new_label_name.default-dropdown-input')).not.toBe(null);
});
it('renders suggested colors list elements', () => {
const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown');
expect(colorsListContainerEl).not.toBe(null);
expect(colorsListContainerEl.querySelectorAll('a').length).toBe(mockSuggestedColors.length);
const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0];
expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0]);
expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 51, 204);');
});
it('renders color input element', () => {
expect(vm.$el.querySelector('.dropdown-label-color-input')).not.toBe(null);
expect(vm.$el.querySelector('.dropdown-label-color-preview.js-dropdown-label-color-preview')).not.toBe(null);
expect(vm.$el.querySelector('input#new_label_color.default-dropdown-input')).not.toBe(null);
});
it('renders component action buttons', () => {
const createBtnEl = vm.$el.querySelector('button.js-new-label-btn');
const cancelBtnEl = vm.$el.querySelector('button.js-cancel-label-btn');
expect(createBtnEl).not.toBe(null);
expect(createBtnEl.innerText.trim()).toBe('Create');
expect(cancelBtnEl.innerText.trim()).toBe('Cancel');
});
});
});
import Vue from 'vue';
import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue';
import { mockConfig } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = (labelsWebUrl = mockConfig.labelsWebUrl) => {
const Component = Vue.extend(dropdownFooterComponent);
return mountComponent(Component, {
labelsWebUrl,
});
};
describe('DropdownFooterComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders `Create new label` link element', () => {
const createLabelEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page');
expect(createLabelEl).not.toBeNull();
expect(createLabelEl.innerText.trim()).toBe('Create new label');
});
it('renders `Manage labels` link element', () => {
const manageLabelsEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-external-link');
expect(manageLabelsEl).not.toBeNull();
expect(manageLabelsEl.getAttribute('href')).toBe(vm.labelsWebUrl);
expect(manageLabelsEl.innerText.trim()).toBe('Manage labels');
});
});
});
import Vue from 'vue';
import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(dropdownHeaderComponent);
return mountComponent(Component);
};
describe('DropdownHeaderComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders header text element', () => {
const headerEl = vm.$el.querySelector('.dropdown-title span');
expect(headerEl.innerText.trim()).toBe('Assign labels');
});
it('renders `Close` button element', () => {
const closeBtnEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close');
expect(closeBtnEl).not.toBeNull();
expect(closeBtnEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBeNull();
});
});
});
import Vue from 'vue';
import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue';
import { mockLabels } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = (name = 'label_id[]', label = mockLabels[0]) => {
const Component = Vue.extend(dropdownHiddenInputComponent);
return mountComponent(Component, {
name,
label,
});
};
describe('DropdownHiddenInputComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders input element of type `hidden`', () => {
expect(vm.$el.nodeName).toBe('INPUT');
expect(vm.$el.getAttribute('type')).toBe('hidden');
expect(vm.$el.getAttribute('name')).toBe(vm.name);
expect(vm.$el.getAttribute('value')).toBe(`${vm.label.id}`);
});
});
});
import Vue from 'vue';
import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = () => {
const Component = Vue.extend(dropdownSearchInputComponent);
return mountComponent(Component);
};
describe('DropdownSearchInputComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders input element with type `search`', () => {
const inputEl = vm.$el.querySelector('input.dropdown-input-field');
expect(inputEl).not.toBeNull();
expect(inputEl.getAttribute('type')).toBe('search');
});
it('renders search icon element', () => {
expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull();
});
it('renders clear search icon element', () => {
expect(vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull();
});
});
});
import Vue from 'vue';
import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = (canEdit = true) => {
const Component = Vue.extend(dropdownTitleComponent);
return mountComponent(Component, {
canEdit,
});
};
describe('DropdownTitleComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('template', () => {
it('renders title text', () => {
expect(vm.$el.classList.contains('title', 'hide-collapsed')).toBe(true);
expect(vm.$el.innerText.trim()).toContain('Labels');
});
it('renders spinner icon element', () => {
expect(vm.$el.querySelector('.fa-spinner.fa-spin.block-loading')).not.toBeNull();
});
it('renders `Edit` button element', () => {
const editBtnEl = vm.$el.querySelector('button.edit-link.js-sidebar-dropdown-toggle');
expect(editBtnEl).not.toBeNull();
expect(editBtnEl.innerText.trim()).toBe('Edit');
});
});
});
import Vue from 'vue';
import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
import { mockLabels } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = (labels = mockLabels) => {
const Component = Vue.extend(dropdownValueCollapsedComponent);
return mountComponent(Component, {
labels,
});
};
describe('DropdownValueCollapsedComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('labelsList', () => {
it('returns empty text when `labels` prop is empty array', () => {
const vmEmptyLabels = createComponent([]);
expect(vmEmptyLabels.labelsList).toBe('');
vmEmptyLabels.$destroy();
});
it('returns labels names separated by coma when `labels` prop has more than one item', () => {
const vmMoreLabels = createComponent(mockLabels.concat(mockLabels));
expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label');
vmMoreLabels.$destroy();
});
it('returns labels names separated by coma with remaining labels count and `and more` phrase when `labels` prop has more than five items', () => {
const mockMoreLabels = Object.assign([], mockLabels);
for (let i = 0; i < 6; i += 1) {
mockMoreLabels.unshift(mockLabels[0]);
}
const vmMoreLabels = createComponent(mockMoreLabels);
expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label, Foo Label, Foo Label, Foo Label, and 2 more');
vmMoreLabels.$destroy();
});
it('returns first label name when `labels` prop has only one item present', () => {
expect(vm.labelsList).toBe('Foo Label');
});
});
});
describe('template', () => {
it('renders component container element with tooltip`', () => {
expect(vm.$el.dataset.placement).toBe('left');
expect(vm.$el.dataset.container).toBe('body');
expect(vm.$el.dataset.originalTitle).toBe(vm.labelsList);
});
it('renders tags icon element', () => {
expect(vm.$el.querySelector('.fa-tags')).not.toBeNull();
});
it('renders labels count', () => {
expect(vm.$el.querySelector('span').innerText.trim()).toBe(`${vm.labels.length}`);
});
});
});
import Vue from 'vue';
import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue';
import { mockConfig, mockLabels } from './mock_data';
import mountComponent from '../../../../helpers/vue_mount_component_helper';
const createComponent = (
labels = mockLabels,
labelFilterBasePath = mockConfig.labelFilterBasePath,
) => {
const Component = Vue.extend(dropdownValueComponent);
return mountComponent(Component, {
labels,
labelFilterBasePath,
});
};
describe('DropdownValueComponent', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('computed', () => {
describe('isEmpty', () => {
it('returns true if `labels` prop is empty', () => {
const vmEmptyLabels = createComponent([]);
expect(vmEmptyLabels.isEmpty).toBe(true);
vmEmptyLabels.$destroy();
});
it('returns false if `labels` prop is empty', () => {
expect(vm.isEmpty).toBe(false);
});
});
});
describe('methods', () => {
describe('labelFilterUrl', () => {
it('returns URL string starting with labelFilterBasePath and encoded label.title', () => {
expect(vm.labelFilterUrl({
title: 'Foo bar',
})).toBe('/gitlab-org/my-project/issues?label_name[]=Foo%20bar');
});
});
describe('labelStyle', () => {
it('returns object with `color` & `backgroundColor` properties from label.textColor & label.color', () => {
const label = {
textColor: '#FFFFFF',
color: '#BADA55',
};
const styleObj = vm.labelStyle(label);
expect(styleObj.color).toBe(label.textColor);
expect(styleObj.backgroundColor).toBe(label.color);
});
});
});
describe('template', () => {
it('renders component container element with classes `hide-collapsed value issuable-show-labels`', () => {
expect(vm.$el.classList.contains('hide-collapsed', 'value', 'issuable-show-labels')).toBe(true);
});
it('render slot content inside component when `labels` prop is empty', () => {
const vmEmptyLabels = createComponent([]);
expect(vmEmptyLabels.$el.querySelector('.text-secondary').innerText.trim()).toBe(mockConfig.emptyValueText);
vmEmptyLabels.$destroy();
});
it('renders label element with filter URL', () => {
expect(vm.$el.querySelector('a').getAttribute('href')).toBe('/gitlab-org/my-project/issues?label_name[]=Foo%20Label');
});
it('renders label element with tooltip and styles based on label details', () => {
const labelEl = vm.$el.querySelector('a span.label.color-label');
expect(labelEl).not.toBeNull();
expect(labelEl.dataset.placement).toBe('bottom');
expect(labelEl.dataset.container).toBe('body');
expect(labelEl.dataset.originalTitle).toBe(mockLabels[0].description);
expect(labelEl.getAttribute('style')).toBe('background-color: rgb(186, 218, 85);');
expect(labelEl.innerText.trim()).toBe(mockLabels[0].title);
});
});
});
export const mockLabels = [
{
id: 26,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
},
];
export const mockSuggestedColors = [
'#0033CC',
'#428BCA',
'#44AD8E',
'#A8D695',
'#5CB85C',
'#69D100',
'#004E00',
'#34495E',
'#7F8C8D',
'#A295D6',
'#5843AD',
'#8E44AD',
'#FFECDB',
'#AD4363',
'#D10069',
'#CC0033',
'#FF0000',
'#D9534F',
'#D1D100',
'#F0AD4E',
'#AD8D43',
];
export const mockConfig = {
showCreate: true,
abilityName: 'issue',
context: {
labels: mockLabels,
},
namespace: 'gitlab-org',
updatePath: '/gitlab-org/my-project/issue/1',
labelsPath: '/gitlab-org/my-project/labels.json',
labelsWebUrl: '/gitlab-org/my-project/labels',
labelFilterBasePath: '/gitlab-org/my-project/issues',
canEdit: true,
suggestedColors: mockSuggestedColors,
emptyValueText: 'None',
};
......@@ -307,6 +307,7 @@ project:
- fork_network
- custom_attributes
- lfs_file_locks
- project_badges
award_emoji:
- awardable
- user
......@@ -326,3 +327,5 @@ epic_issues:
- epic
lfs_file_locks:
- user
project_badges:
- project
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -129,6 +129,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do
expect(@project.custom_attributes.count).to eq(2)
end
it 'has badges' do
expect(@project.project_badges.count).to eq(2)
end
it 'restores the correct service' do
expect(CustomIssueTrackerService.first).not_to be_nil
end
......
......@@ -184,6 +184,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
expect(saved_project_json['custom_attributes'].count).to eq(2)
end
it 'has badges' do
expect(saved_project_json['project_badges'].count).to eq(2)
end
it 'does not complain about non UTF-8 characters in MR diff files' do
ActiveRecord::Base.connection.execute("UPDATE merge_request_diff_files SET diff = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'")
......@@ -293,6 +297,9 @@ describe Gitlab::ImportExport::ProjectTreeSaver do
create(:project_custom_attribute, project: project)
create(:project_custom_attribute, project: project)
create(:project_badge, project: project)
create(:project_badge, project: project)
project
end
......
......@@ -557,3 +557,12 @@ LfsFileLock:
- user_id
- project_id
- created_at
Badge:
- id
- link_url
- image_url
- project_id
- group_id
- created_at
- updated_at
- type
require 'spec_helper'
describe Gitlab::StringPlaceholderReplacer do
describe '.render_url' do
it 'returns the nil if the string is blank' do
expect(described_class.replace_string_placeholders(nil, /whatever/)).to be_blank
end
it 'returns the string if the placeholder regex' do
expect(described_class.replace_string_placeholders('whatever')).to eq 'whatever'
end
it 'returns the string if no block given' do
expect(described_class.replace_string_placeholders('whatever', /whatever/)).to eq 'whatever'
end
context 'when all params are valid' do
let(:string) { '%{path}/%{id}/%{branch}' }
let(:regex) { /(path|id)/ }
it 'replaces each placeholders with the block result' do
result = described_class.replace_string_placeholders(string, regex) do |arg|
'WHATEVER'
end
expect(result).to eq 'WHATEVER/WHATEVER/%{branch}'
end
it 'does not replace the placeholder if the block result is nil' do
result = described_class.replace_string_placeholders(string, regex) do |arg|
arg == 'path' ? nil : 'WHATEVER'
end
expect(result).to eq '%{path}/WHATEVER/%{branch}'
end
end
end
end
require 'spec_helper'
describe Badge do
let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' }
describe 'validations' do
# Requires the let variable url_sym
shared_examples 'placeholder url' do
let(:badge) { build(:badge) }
it 'allows url with http protocol' do
badge[url_sym] = 'http://www.example.com'
expect(badge).to be_valid
end
it 'allows url with https protocol' do
badge[url_sym] = 'https://www.example.com'
expect(badge).to be_valid
end
it 'cannot be empty' do
badge[url_sym] = ''
expect(badge).not_to be_valid
end
it 'cannot be nil' do
badge[url_sym] = nil
expect(badge).not_to be_valid
end
it 'accept badges placeholders' do
badge[url_sym] = placeholder_url
expect(badge).to be_valid
end
it 'sanitize url' do
badge[url_sym] = 'javascript:alert(1)'
expect(badge).not_to be_valid
end
end
context 'link_url format' do
let(:url_sym) { :link_url }
it_behaves_like 'placeholder url'
end
context 'image_url format' do
let(:url_sym) { :image_url }
it_behaves_like 'placeholder url'
end
end
shared_examples 'rendered_links' do
it 'should use the project information to populate the url placeholders' do
stub_project_commit_info(project)
expect(badge.public_send("rendered_#{method}", project)).to eq "http://www.example.com/#{project.full_path}/#{project.id}/master/whatever"
end
it 'returns the url if the project used is nil' do
expect(badge.public_send("rendered_#{method}", nil)).to eq placeholder_url
end
def stub_project_commit_info(project)
allow(project).to receive(:commit).and_return(double('Commit', sha: 'whatever'))
allow(project).to receive(:default_branch).and_return('master')
end
end
context 'methods' do
let(:badge) { build(:badge, link_url: placeholder_url, image_url: placeholder_url) }
let!(:project) { create(:project) }
context '#rendered_link_url' do
let(:method) { :link_url }
it_behaves_like 'rendered_links'
end
context '#rendered_image_url' do
let(:method) { :image_url }
it_behaves_like 'rendered_links'
end
end
end
require 'spec_helper'
describe GroupBadge do
describe 'associations' do
it { is_expected.to belong_to(:group) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:group) }
end
end
require 'spec_helper'
describe ProjectBadge do
let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' }
describe 'associations' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
end
shared_examples 'rendered_links' do
it 'should use the badge project information to populate the url placeholders' do
stub_project_commit_info(project)
expect(badge.public_send("rendered_#{method}")).to eq "http://www.example.com/#{project.full_path}/#{project.id}/master/whatever"
end
def stub_project_commit_info(project)
allow(project).to receive(:commit).and_return(double('Commit', sha: 'whatever'))
allow(project).to receive(:default_branch).and_return('master')
end
end
context 'methods' do
let(:badge) { build(:project_badge, link_url: placeholder_url, image_url: placeholder_url) }
let!(:project) { badge.project }
context '#rendered_link_url' do
let(:method) { :link_url }
it_behaves_like 'rendered_links'
end
context '#rendered_image_url' do
let(:method) { :image_url }
it_behaves_like 'rendered_links'
end
end
end
......@@ -19,6 +19,7 @@ describe Group do
it { is_expected.to have_one(:chat_team) }
it { is_expected.to have_many(:custom_attributes).class_name('GroupCustomAttribute') }
it { is_expected.to have_many(:audit_events).dependent(false) }
it { is_expected.to have_many(:badges).class_name('GroupBadge') }
describe '#members & #requesters' do
let(:requester) { create(:user) }
......
......@@ -81,6 +81,7 @@ describe Project do
it { is_expected.to have_many(:members_and_requesters) }
it { is_expected.to have_many(:clusters) }
it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') }
it { is_expected.to have_many(:project_badges).class_name('ProjectBadge') }
it { is_expected.to have_many(:lfs_file_locks) }
context 'after initialized' do
......@@ -3764,4 +3765,36 @@ describe Project do
end.not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError
end
end
describe '#badges' do
let(:project_group) { create(:group) }
let(:project) { create(:project, path: 'avatar', namespace: project_group) }
before do
create_list(:project_badge, 2, project: project)
create(:group_badge, group: project_group)
end
it 'returns the project and the project group badges' do
create(:group_badge, group: create(:group))
expect(Badge.count).to eq 4
expect(project.badges.count).to eq 3
end
if Group.supports_nested_groups?
context 'with nested_groups' do
let(:parent_group) { create(:group) }
before do
create_list(:group_badge, 2, group: project_group)
project_group.update(parent: parent_group)
end
it 'returns the project and the project nested groups badges' do
expect(project.badges.count).to eq 5
end
end
end
end
end
require 'spec_helper'
describe API::Badges do
let(:master) { create(:user, username: 'master_user') }
let(:developer) { create(:user) }
let(:access_requester) { create(:user) }
let(:stranger) { create(:user) }
let(:project_group) { create(:group) }
let(:project) { setup_project }
let!(:group) { setup_group }
shared_context 'source helpers' do
def get_source(source_type)
source_type == 'project' ? project : group
end
end
shared_examples 'GET /:sources/:id/badges' do |source_type|
include_context 'source helpers'
let(:source) { get_source(source_type) }
context "with :sources == #{source_type.pluralize}" do
it_behaves_like 'a 404 response when source is private' do
let(:route) { get api("/#{source_type.pluralize}/#{source.id}/badges", stranger) }
end
%i[master developer access_requester stranger].each do |type|
context "when authenticated as a #{type}" do
it 'returns 200' do
user = public_send(type)
badges_count = source_type == 'project' ? 3 : 2
get api("/#{source_type.pluralize}/#{source.id}/badges", user)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.size).to eq(badges_count)
end
end
end
it 'avoids N+1 queries' do
# Establish baseline
get api("/#{source_type.pluralize}/#{source.id}/badges", master)
control = ActiveRecord::QueryRecorder.new do
get api("/#{source_type.pluralize}/#{source.id}/badges", master)
end
project.add_developer(create(:user))
expect do
get api("/#{source_type.pluralize}/#{source.id}/badges", master)
end.not_to exceed_query_limit(control)
end
end
end
shared_examples 'GET /:sources/:id/badges/:badge_id' do |source_type|
include_context 'source helpers'
let(:source) { get_source(source_type) }
context "with :sources == #{source_type.pluralize}" do
it_behaves_like 'a 404 response when source is private' do
let(:route) { get api("/#{source_type.pluralize}/#{source.id}/badges/#{developer.id}", stranger) }
end
context 'when authenticated as a non-member' do
%i[master developer access_requester stranger].each do |type|
let(:badge) { source.badges.first }
context "as a #{type}" do
it 'returns 200' do
user = public_send(type)
get api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user)
expect(response).to have_gitlab_http_status(200)
expect(json_response['id']).to eq(badge.id)
expect(json_response['link_url']).to eq(badge.link_url)
expect(json_response['rendered_link_url']).to eq(badge.rendered_link_url)
expect(json_response['image_url']).to eq(badge.image_url)
expect(json_response['rendered_image_url']).to eq(badge.rendered_image_url)
expect(json_response['kind']).to eq source_type
end
end
end
end
end
end
shared_examples 'POST /:sources/:id/badges' do |source_type|
include_context 'source helpers'
let(:source) { get_source(source_type) }
let(:example_url) { 'http://www.example.com' }
let(:example_url2) { 'http://www.example1.com' }
context "with :sources == #{source_type.pluralize}" do
it_behaves_like 'a 404 response when source is private' do
let(:route) do
post api("/#{source_type.pluralize}/#{source.id}/badges", stranger),
link_url: example_url, image_url: example_url2
end
end
context 'when authenticated as a non-member or member with insufficient rights' do
%i[access_requester stranger developer].each do |type|
context "as a #{type}" do
it 'returns 403' do
user = public_send(type)
post api("/#{source_type.pluralize}/#{source.id}/badges", user),
link_url: example_url, image_url: example_url2
expect(response).to have_gitlab_http_status(403)
end
end
end
end
context 'when authenticated as a master/owner' do
it 'creates a new badge' do
expect do
post api("/#{source_type.pluralize}/#{source.id}/badges", master),
link_url: example_url, image_url: example_url2
expect(response).to have_gitlab_http_status(201)
end.to change { source.badges.count }.by(1)
expect(json_response['link_url']).to eq(example_url)
expect(json_response['image_url']).to eq(example_url2)
expect(json_response['kind']).to eq source_type
end
end
it 'returns 400 when link_url is not given' do
post api("/#{source_type.pluralize}/#{source.id}/badges", master),
link_url: example_url
expect(response).to have_gitlab_http_status(400)
end
it 'returns 400 when image_url is not given' do
post api("/#{source_type.pluralize}/#{source.id}/badges", master),
image_url: example_url2
expect(response).to have_gitlab_http_status(400)
end
it 'returns 400 when link_url or image_url is not valid' do
post api("/#{source_type.pluralize}/#{source.id}/badges", master),
link_url: 'whatever', image_url: 'whatever'
expect(response).to have_gitlab_http_status(400)
end
end
end
shared_examples 'PUT /:sources/:id/badges/:badge_id' do |source_type|
include_context 'source helpers'
let(:source) { get_source(source_type) }
context "with :sources == #{source_type.pluralize}" do
let(:badge) { source.badges.first }
let(:example_url) { 'http://www.example.com' }
let(:example_url2) { 'http://www.example1.com' }
it_behaves_like 'a 404 response when source is private' do
let(:route) do
put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", stranger),
link_url: example_url
end
end
context 'when authenticated as a non-member or member with insufficient rights' do
%i[access_requester stranger developer].each do |type|
context "as a #{type}" do
it 'returns 403' do
user = public_send(type)
put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user),
link_url: example_url
expect(response).to have_gitlab_http_status(403)
end
end
end
end
context 'when authenticated as a master/owner' do
it 'updates the member' do
put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master),
link_url: example_url, image_url: example_url2
expect(response).to have_gitlab_http_status(200)
expect(json_response['link_url']).to eq(example_url)
expect(json_response['image_url']).to eq(example_url2)
expect(json_response['kind']).to eq source_type
end
end
it 'returns 400 when link_url or image_url is not valid' do
put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master),
link_url: 'whatever', image_url: 'whatever'
expect(response).to have_gitlab_http_status(400)
end
end
end
shared_examples 'DELETE /:sources/:id/badges/:badge_id' do |source_type|
include_context 'source helpers'
let(:source) { get_source(source_type) }
context "with :sources == #{source_type.pluralize}" do
let(:badge) { source.badges.first }
it_behaves_like 'a 404 response when source is private' do
let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", stranger) }
end
context 'when authenticated as a non-member or member with insufficient rights' do
%i[access_requester developer stranger].each do |type|
context "as a #{type}" do
it 'returns 403' do
user = public_send(type)
delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user)
expect(response).to have_gitlab_http_status(403)
end
end
end
end
context 'when authenticated as a master/owner' do
it 'deletes the badge' do
expect do
delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master)
expect(response).to have_gitlab_http_status(204)
end.to change { source.badges.count }.by(-1)
end
it_behaves_like '412 response' do
let(:request) { api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master) }
end
end
it 'returns 404 if badge does not exist' do
delete api("/#{source_type.pluralize}/#{source.id}/badges/123", master)
expect(response).to have_gitlab_http_status(404)
end
end
end
shared_examples 'GET /:sources/:id/badges/render' do |source_type|
include_context 'source helpers'
let(:source) { get_source(source_type) }
let(:example_url) { 'http://www.example.com' }
let(:example_url2) { 'http://www.example1.com' }
context "with :sources == #{source_type.pluralize}" do
it_behaves_like 'a 404 response when source is private' do
let(:route) do
get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", stranger)
end
end
context 'when authenticated as a non-member or member with insufficient rights' do
%i[access_requester stranger developer].each do |type|
context "as a #{type}" do
it 'returns 403' do
user = public_send(type)
get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", user)
expect(response).to have_gitlab_http_status(403)
end
end
end
end
context 'when authenticated as a master/owner' do
it 'gets the rendered badge values' do
get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", master)
expect(response).to have_gitlab_http_status(200)
expect(json_response.keys).to contain_exactly('link_url', 'rendered_link_url', 'image_url', 'rendered_image_url')
expect(json_response['link_url']).to eq(example_url)
expect(json_response['image_url']).to eq(example_url2)
expect(json_response['rendered_link_url']).to eq(example_url)
expect(json_response['rendered_image_url']).to eq(example_url2)
end
end
it 'returns 400 when link_url is not given' do
get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}", master)
expect(response).to have_gitlab_http_status(400)
end
it 'returns 400 when image_url is not given' do
get api("/#{source_type.pluralize}/#{source.id}/badges/render?image_url=#{example_url}", master)
expect(response).to have_gitlab_http_status(400)
end
it 'returns 400 when link_url or image_url is not valid' do
get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=whatever&image_url=whatever", master)
expect(response).to have_gitlab_http_status(400)
end
end
end
context 'when deleting a badge' do
context 'and the source is a project' do
it 'cannot delete badges owned by the project group' do
delete api("/projects/#{project.id}/badges/#{project_group.badges.first.id}", master)
expect(response).to have_gitlab_http_status(403)
end
end
end
describe 'Endpoints' do
%w(project group).each do |source_type|
it_behaves_like 'GET /:sources/:id/badges', source_type
it_behaves_like 'GET /:sources/:id/badges/:badge_id', source_type
it_behaves_like 'GET /:sources/:id/badges/render', source_type
it_behaves_like 'POST /:sources/:id/badges', source_type
it_behaves_like 'PUT /:sources/:id/badges/:badge_id', source_type
it_behaves_like 'DELETE /:sources/:id/badges/:badge_id', source_type
end
end
def setup_project
create(:project, :public, :access_requestable, creator_id: master.id, namespace: project_group) do |project|
project.add_developer(developer)
project.add_master(master)
project.request_access(access_requester)
project.project_badges << build(:project_badge, project: project)
project.project_badges << build(:project_badge, project: project)
project_group.badges << build(:group_badge, group: group)
end
end
def setup_group
create(:group, :public, :access_requestable) do |group|
group.add_developer(developer)
group.add_owner(master)
group.request_access(access_requester)
group.badges << build(:group_badge, group: group)
group.badges << build(:group_badge, group: group)
end
end
end
require 'spec_helper'
describe UrlPlaceholderValidator do
let(:validator) { described_class.new(attributes: [:link_url], **options) }
let!(:badge) { build(:badge) }
let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' }
subject { validator.validate_each(badge, :link_url, badge.link_url) }
describe '#validates_each' do
context 'with no options' do
let(:options) { {} }
it 'allows http and https protocols by default' do
expect(validator.send(:default_options)[:protocols]).to eq %w(http https)
end
it 'checks that the url structure is valid' do
badge.link_url = placeholder_url
subject
expect(badge.errors.empty?).to be false
end
end
context 'with placeholder regex' do
let(:options) { { placeholder_regex: /(project_path|project_id|commit_sha|default_branch)/ } }
it 'checks that the url is valid and obviate placeholders that match regex' do
badge.link_url = placeholder_url
subject
expect(badge.errors.empty?).to be true
end
end
end
end
require 'spec_helper'
describe UrlValidator do
let(:validator) { described_class.new(attributes: [:link_url], **options) }
let!(:badge) { build(:badge) }
subject { validator.validate_each(badge, :link_url, badge.link_url) }
describe '#validates_each' do
context 'with no options' do
let(:options) { {} }
it 'allows http and https protocols by default' do
expect(validator.send(:default_options)[:protocols]).to eq %w(http https)
end
it 'checks that the url structure is valid' do
badge.link_url = 'http://www.google.es/%{whatever}'
subject
expect(badge.errors.empty?).to be false
end
end
context 'with protocols' do
let(:options) { { protocols: %w(http) } }
it 'allows urls with the defined protocols' do
badge.link_url = 'http://www.example.com'
subject
expect(badge.errors.empty?).to be true
end
it 'add error if the url protocol does not match the selected ones' do
badge.link_url = 'https://www.example.com'
subject
expect(badge.errors.empty?).to be false
end
end
end
end
require 'spec_helper'
describe 'projects/_home_panel' do
let(:project) { create(:project, :public) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
let(:notification_settings) do
user&.notification_settings_for(project)
......@@ -35,4 +36,55 @@ describe 'projects/_home_panel' do
expect(rendered).not_to have_selector('.notification_dropdown')
end
end
context 'when project' do
let!(:user) { create(:user) }
let(:badges) { project.badges }
context 'has no badges' do
it 'should not render any badge' do
render
expect(rendered).to have_selector('.project-badges')
expect(rendered).not_to have_selector('.project-badges > a')
end
end
shared_examples 'show badges' do
it 'should render the all badges' do
render
expect(rendered).to have_selector('.project-badges a')
badges.each do |badge|
expect(rendered).to have_link(href: badge.rendered_link_url)
end
end
end
context 'only has group badges' do
before do
create(:group_badge, group: project.group)
end
it_behaves_like 'show badges'
end
context 'only has project badges' do
before do
create(:project_badge, project: project)
end
it_behaves_like 'show badges'
end
context 'has both group and project badges' do
before do
create(:project_badge, project: project)
create(:group_badge, group: project.group)
end
it_behaves_like 'show badges'
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