Commit d933bc5a authored by GitLab Bot's avatar GitLab Bot

Add latest changes from gitlab-org/gitlab@master

parent 3f9e1b26
## Description of the proposal
<!--
Please describe the proposal and add a link to the source (for example, http://www.betterspecs.org/).
-->
- [ ] Mention the proposal in the next backend weekly call and the #backend channel to encourage contribution
- [ ] Proceed with the proposal once 50% of the maintainers have weighed in, and 80% of their votes are :+1:
- [ ] Once approved, mention it again in the next backend weekly call and the #backend channel
/label ~"development guidelines"
/label ~"Style decision"
/label ~documentation
/cc @gitlab-org/maintainers/rails-backend
## Description of the proposal
<!--
Please describe the proposal and add a link to the source (for example, http://www.betterspecs.org/).
-->
### Check-list
- [ ] Make sure this MR enables a static analysis check rule for new usage but
ignores current offenses
- [ ] Create a follow-up issue to fix the current offenses as a separate iteration: ISSUE_LINK
- [ ] Mention this proposal in the relevant Slack channels (e.g. `#development`, `#backend`, `#frontend`)
- [ ] If there is a choice to make between two potential styles, set up an emoji vote in the MR:
- CHOICE_A: :a:
- CHOICE_B: :b:
- Vote yourself for both choices so that people know these are the choices
- [ ] The MR doesn't have significant objections, and is getting a majority of :+1: vs :-1: (remember that [we don't need to reach a consensus](https://about.gitlab.com/handbook/values/#collaboration-is-not-consensus))
- [ ] (If applicable) One style is getting a majority of vote (compared to the other choice)
- [ ] (If applicable) Update the MR with the chosen style
- [ ] Follow the [review process](https://docs.gitlab.com/ee/development/code_review.html) as usual
- [ ] Once approved and merged by a maintainer, mention it again:
- [ ] In the relevant Slack channels (e.g. `#development`, `#backend`, `#frontend`)
- [ ] (Optional depending on the impact of the change) In the Engineering Week in Review
/label ~"Engineering Productivity" ~"Style decision" ~"development guidelines" ~"static analysis"
/cc @gitlab-org/maintainers/rails-backend
......@@ -67,7 +67,7 @@ gem 'u2f', '~> 0.2.1'
gem 'validates_hostname', '~> 1.0.6'
gem 'rubyzip', '~> 1.3.0', require: 'zip'
# GitLab Pages letsencrypt support
gem 'acme-client', '~> 2.0.2'
gem 'acme-client', '~> 2.0.5'
# Browser detection
gem 'browser', '~> 2.5'
......
......@@ -4,7 +4,7 @@ GEM
RedCloth (4.3.2)
abstract_type (0.0.7)
ace-rails-ap (4.1.2)
acme-client (2.0.2)
acme-client (2.0.5)
faraday (~> 0.9, >= 0.9.1)
actioncable (5.2.3)
actionpack (= 5.2.3)
......@@ -1131,7 +1131,7 @@ PLATFORMS
DEPENDENCIES
RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.1.0)
acme-client (~> 2.0.2)
acme-client (~> 2.0.5)
activerecord-explain-analyze (~> 0.1)
acts-as-taggable-on (~> 6.0)
addressable (~> 2.7)
......
......@@ -59,21 +59,25 @@ export default {
</script>
<template>
<div>
<div v-if="!isLocalStorageAvailable" class="dropdown-info-note">
<div v-if="!isLocalStorageAvailable" ref="localStorageNote" class="dropdown-info-note">
{{ __('This feature requires local storage to be enabled') }}
</div>
<ul v-else-if="hasItems">
<li v-for="(item, index) in processedItems" :key="`processed-items-${index}`">
<li
v-for="(item, index) in processedItems"
ref="dropdownItem"
:key="`processed-items-${index}`"
>
<button
type="button"
class="filtered-search-history-dropdown-item"
class="filtered-search-history-dropdown-item js-dropdown-button"
@click="onItemActivated(item.text)"
>
<span>
<span
v-for="(token, tokenIndex) in item.tokens"
:key="`dropdown-token-${tokenIndex}`"
class="filtered-search-history-dropdown-token"
class="filtered-search-history-dropdown-token js-dropdown-token"
>
<span class="name">{{ token.prefix }}</span>
<span class="name">{{ token.operator }}</span>
......@@ -88,6 +92,7 @@ export default {
<li class="divider"></li>
<li>
<button
ref="clearButton"
type="button"
class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)"
......@@ -96,6 +101,8 @@ export default {
</button>
</li>
</ul>
<div v-else class="dropdown-info-note">{{ __("You don't have any recent searches") }}</div>
<div v-else ref="dropdownNote" class="dropdown-info-note">
{{ __("You don't have any recent searches") }}
</div>
</div>
</template>
import _ from 'underscore';
import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
ALERT: 'alert',
NOTICE: 'notice',
SUCCESS: 'success',
WARNING: 'warning',
};
const hideFlash = (flashEl, fadeTransition = true) => {
if (fadeTransition) {
Object.assign(flashEl.style, {
......@@ -59,7 +66,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
* additional action or link on banner next to message
*
* @param {String} message Flash message text
* @param {String} type Type of Flash, it can be `notice` or `alert` (default)
* @param {String} type Type of Flash, it can be `notice`, `success`, `warning` or `alert` (default)
* @param {Object} parent Reference to parent element under which Flash needs to appear
* @param {Object} actonConfig Map of config to show action on banner
* @param {String} href URL to which action config should point to (default: '#')
......@@ -69,7 +76,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
*/
const createFlash = function createFlash(
message,
type = 'alert',
type = FLASH_TYPES.ALERT,
parent = document,
actionConfig = null,
fadeTransition = true,
......@@ -102,5 +109,12 @@ const createFlash = function createFlash(
return flashContainer;
};
export { createFlash as default, createFlashEl, createAction, hideFlash, removeFlashClickListener };
export {
createFlash as default,
createFlashEl,
createAction,
hideFlash,
removeFlashClickListener,
FLASH_TYPES,
};
window.Flash = createFlash;
......@@ -29,7 +29,7 @@ export default {
</script>
<template>
<div :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
<div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
{{ identiconTitle }}
</div>
</template>
......@@ -65,14 +65,14 @@ export default {
<div class="issuable-note-warning">
<icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" />
<span v-if="isLockedAndConfidential">
<span v-if="isLockedAndConfidential" ref="lockedAndConfidential">
<span v-html="confidentialAndLockedDiscussionText"></span>
{{
__("People without permission will never get a notification and won't be able to comment.")
}}
</span>
<span v-else-if="isConfidential">
<span v-else-if="isConfidential" ref="confidential">
{{ __('This is a confidential issue.') }}
{{ __('People without permission will never get a notification.') }}
<gl-link :href="confidentialIssueDocsPath" target="_blank">
......@@ -80,7 +80,7 @@ export default {
</gl-link>
</span>
<span v-else-if="isLocked">
<span v-else-if="isLocked" ref="locked">
{{ __('This issue is locked.') }}
{{ __('Only project members can comment.') }}
<gl-link :href="lockedIssueDocsPath" target="_blank">
......
......@@ -47,7 +47,7 @@ export default {
:img-size="40"
/>
</div>
<div :class="{ discussion: !note.individual_note }" class="timeline-content">
<div ref="note" :class="{ discussion: !note.individual_note }" class="timeline-content">
<div class="note-header">
<div class="note-header-info">
<a :href="getUserData.path">
......
---
title: Update Praefect docs for subcommand
merge_request: 23255
author:
type: added
---
title: Add selective sync support to Geo Nodes API update endpoint
merge_request: 22828
author: Rajendra Kadam
type: added
---
title: Upgrade acme-client to v2.0.5
merge_request: 23498
author:
type: other
......@@ -260,6 +260,14 @@ git_data_dirs({
For more information on Gitaly server configuration, see our [Gitaly documentation](index.md#3-gitaly-server-configuration).
When all Gitaly servers are configured, you can run the Praefect connection
checker to verify Praefect can connect to all Gitaly servers in the Praefect
config:
```shell
sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.toml dial-nodes
```
#### GitLab
When Praefect is running, it should be exposed as a storage to GitLab. This
......@@ -311,4 +319,5 @@ Here are common errors and potential causes:
- **GRPC::Unavailable (14:failed to connect to all addresses)**
- GitLab was unable to reach Praefect.
- **GRPC::Unavailable (14:all SubCons are in TransientFailure...)**
- Praefect cannot reach one or more of its child Gitaly nodes.
- Praefect cannot reach one or more of its child Gitaly nodes. Try running
the Praefect connection checker to diagnose.
......@@ -84,6 +84,10 @@ Example response:
"repos_max_capacity": 25,
"container_repositories_max_capacity": 10,
"verification_max_capacity": 100,
"selective_sync_type": "namespaces",
"selective_sync_shards": [],
"selective_sync_namespace_ids": [1, 25],
"minimum_reverification_interval": 7,
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit",
"_links": {
......@@ -104,6 +108,10 @@ Example response:
"repos_max_capacity": 25,
"container_repositories_max_capacity": 10,
"verification_max_capacity": 100,
"selective_sync_type": "namespaces",
"selective_sync_shards": [],
"selective_sync_namespace_ids": [1, 25],
"minimum_reverification_interval": 7,
"sync_object_storage": true,
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit",
......@@ -142,6 +150,10 @@ Example response:
"repos_max_capacity": 25,
"container_repositories_max_capacity": 10,
"verification_max_capacity": 100,
"selective_sync_type": "namespaces",
"selective_sync_shards": [],
"selective_sync_namespace_ids": [1, 25],
"minimum_reverification_interval": 7,
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit",
"_links": {
......@@ -174,6 +186,10 @@ PUT /geo_nodes/:id
| `verification_max_capacity` | integer | no | Control the maximum concurrency of verification for this node. |
| `container_repositories_max_capacity` | integer | no | Control the maximum concurrency of container repository sync for this node. |
| `sync_object_storage` | boolean | no | Flag indicating if the secondary Geo node will replicate blobs in Object Storage. |
| `selective_sync_type` | string | no | Limit syncing to only specific groups or shards. Valid values: `"namespaces"`, `"shards"`, or `null`. |
| `selective_sync_shards` | array | no | The repository storage for the projects synced if `selective_sync_type` == `shards`. |
| `selective_sync_namespace_ids` | array | no | The IDs of groups that should be synced, if `selective_sync_type` == `namespaces`. |
| `minimum_reverification_interval` | integer | no | The interval (in days) in which the repository verification is valid. Once expired, it will be reverified. This has no effect when set on a secondary node. |
Example response:
......@@ -190,6 +206,10 @@ Example response:
"repos_max_capacity": 25,
"container_repositories_max_capacity": 10,
"verification_max_capacity": 100,
"selective_sync_type": "namespaces",
"selective_sync_shards": [],
"selective_sync_namespace_ids": [1, 25],
"minimum_reverification_interval": 7,
"sync_object_storage": true,
"clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit",
......
......@@ -660,6 +660,118 @@ GET /groups?search=foobar
]
```
## Hooks
Also called Group Hooks and Webhooks.
These are different from [System Hooks](system_hooks.md) that are system wide and [Project Hooks](projects.md#hooks) that are limited to one project.
### List group hooks
Get a list of group hooks
```
GET /groups/:id/hooks
```
| Attribute | Type | Required | Description |
| --------- | --------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
### Get group hook
Get a specific hook for a group.
| Attribute | Type | Required | Description |
| --------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
| `hook_id` | integer | yes | The ID of a group hook |
```
GET /groups/:id/hooks/:hook_id
```
```json
{
"id": 1,
"url": "http://example.com/hook",
"group_id": 3,
"push_events": true,
"issues_events": true,
"confidential_issues_events": true,
"merge_requests_events": true,
"tag_push_events": true,
"note_events": true,
"job_events": true,
"pipeline_events": true,
"wiki_page_events": true,
"enable_ssl_verification": true,
"created_at": "2012-10-12T17:04:47Z"
}
```
### Add group hook
Adds a hook to a specified group.
```
POST /groups/:id/hooks
```
| Attribute | Type | Required | Description |
| -----------------------------| -------------- | ---------| ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
| `url` | string | yes | The hook URL |
| `push_events` | boolean | no | Trigger hook on push events |
| `issues_events` | boolean | no | Trigger hook on issues events |
| `confidential_issues_events` | boolean | no | Trigger hook on confidential issues events |
| `merge_requests_events` | boolean | no | Trigger hook on merge requests events |
| `tag_push_events` | boolean | no | Trigger hook on tag push events |
| `note_events` | boolean | no | Trigger hook on note events |
| `job_events` | boolean | no | Trigger hook on job events |
| `pipeline_events` | boolean | no | Trigger hook on pipeline events |
| `wiki_page_events` | boolean | no | Trigger hook on wiki events |
| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook |
| `token` | string | no | Secret token to validate received payloads; this will not be returned in the response |
### Edit group hook
Edits a hook for a specified group.
```
PUT /groups/:id/hooks/:hook_id
```
| Attribute | Type | Required | Description |
| ---------------------------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
| `hook_id` | integer | yes | The ID of the group hook |
| `url` | string | yes | The hook URL |
| `push_events` | boolean | no | Trigger hook on push events |
| `issues_events` | boolean | no | Trigger hook on issues events |
| `confidential_issues_events` | boolean | no | Trigger hook on confidential issues events |
| `merge_requests_events` | boolean | no | Trigger hook on merge requests events |
| `tag_push_events` | boolean | no | Trigger hook on tag push events |
| `note_events` | boolean | no | Trigger hook on note events |
| `job_events` | boolean | no | Trigger hook on job events |
| `pipeline_events` | boolean | no | Trigger hook on pipeline events |
| `wiki_events` | boolean | no | Trigger hook on wiki events |
| `enable_ssl_verification` | boolean | no | Do SSL verification when triggering the hook |
| `token` | string | no | Secret token to validate received payloads; this will not be returned in the response |
### Delete group hook
Removes a hook from a group. This is an idempotent method and can be called multiple times.
Either the hook is available or not.
```
DELETE /groups/:id/hooks/:hook_id
```
| Attribute | Type | Required | Description |
| --------- | -------------- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) |
| `hook_id` | integer | yes | The ID of the group hook. |
## Group Audit Events **(STARTER)**
Group audit events can be accessed via the [Group Audit Events API](audit_events.md#group-audit-events-starter)
......
......@@ -627,7 +627,7 @@ msgstr[0] ""
msgstr[1] ""
msgid "1 user"
msgid_plural "%d users"
msgid_plural "%{num} users"
msgstr[0] ""
msgstr[1] ""
......@@ -772,6 +772,9 @@ msgstr ""
msgid "A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project."
msgstr ""
msgid "A group represents your organization in GitLab."
msgstr ""
msgid "A member of the abuse team will review your report as soon as possible."
msgstr ""
......@@ -5406,6 +5409,9 @@ msgstr ""
msgid "Create a Mattermost team for this group"
msgstr ""
msgid "Create a group for your organization"
msgstr ""
msgid "Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies."
msgstr ""
......@@ -8930,6 +8936,9 @@ msgstr ""
msgid "Get a free instance review"
msgstr ""
msgid "Get started"
msgstr ""
msgid "Get started with error tracking"
msgstr ""
......@@ -9395,6 +9404,9 @@ msgstr ""
msgid "Group name"
msgstr ""
msgid "Group name (Your organization)"
msgstr ""
msgid "Group overview"
msgstr ""
......@@ -18463,6 +18475,9 @@ msgstr ""
msgid "Thank you for your report. A GitLab administrator will look into it shortly."
msgstr ""
msgid "Thanks for your purchase!"
msgstr ""
msgid "Thanks! Don't show me this again"
msgstr ""
......@@ -21068,6 +21083,9 @@ msgstr ""
msgid "Welcome to GitLab %{name}!"
msgstr ""
msgid "Welcome to GitLab, %{first_name}!"
msgstr ""
msgid "Welcome to the Guided GitLab Tour"
msgstr ""
......@@ -21382,6 +21400,9 @@ msgstr ""
msgid "You can also upload existing files from your computer using the instructions below."
msgstr ""
msgid "You can always edit this later"
msgstr ""
msgid "You can apply your Trial to your Personal account or create a New Group."
msgstr ""
......@@ -21556,6 +21577,9 @@ msgstr ""
msgid "You have reached your project limit"
msgstr ""
msgid "You have successfully purchased a %{plan} plan subscription for %{seats}. You’ll receive a receipt via email."
msgstr ""
msgid "You haven't added any issues to your project yet"
msgstr ""
......@@ -21715,6 +21739,9 @@ msgstr ""
msgid "Your GPG keys (%{count})"
msgstr ""
msgid "Your GitLab group"
msgstr ""
msgid "Your Gitlab Gold trial will last 30 days after which point you can keep your free Gitlab account forever. We just need some additional information to activate your trial."
msgstr ""
......
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
const createComponent = propsData => {
const Component = Vue.extend(RecentSearchesDropdownContent);
return new Component({
el: document.createElement('div'),
propsData,
});
};
// Remove all the newlines and whitespace from the formatted markup
const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim();
describe('RecentSearchesDropdownContent', () => {
const propsDataWithoutItems = {
items: [],
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
};
const propsDataWithItems = {
items: ['foo', 'author:@root label:~foo bar'],
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
describe('Recent Searches Dropdown Content', () => {
let wrapper;
const findLocalStorageNote = () => wrapper.find({ ref: 'localStorageNote' });
const findDropdownItems = () => wrapper.findAll({ ref: 'dropdownItem' });
const findDropdownNote = () => wrapper.find({ ref: 'dropdownNote' });
const createComponent = props => {
wrapper = shallowMount(RecentSearchesDropdownContent, {
propsData: {
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
items: [],
isLocalStorageAvailable: false,
...props,
},
});
};
let vm;
afterEach(() => {
if (vm) {
vm.$destroy();
}
wrapper.destroy();
wrapper = null;
});
describe('with no items', () => {
let el;
describe('when local storage is not available', () => {
beforeEach(() => {
vm = createComponent(propsDataWithoutItems);
el = vm.$el;
createComponent();
});
it('should render empty state', () => {
expect(el.querySelector('.dropdown-info-note')).toBeDefined();
const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
expect(items.length).toEqual(propsDataWithoutItems.items.length);
it('renders a note about enabling local storage', () => {
expect(findLocalStorageNote().exists()).toBe(true);
});
});
describe('with items', () => {
let el;
beforeEach(() => {
vm = createComponent(propsDataWithItems);
el = vm.$el;
it('does not render dropdown items', () => {
expect(findDropdownItems().exists()).toBe(false);
});
it('should render clear recent searches button', () => {
expect(el.querySelector('.filtered-search-history-clear-button')).toBeDefined();
it('does not render dropdownNote', () => {
expect(findDropdownNote().exists()).toBe(false);
});
});
it('should render recent search items', () => {
const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
expect(items.length).toEqual(propsDataWithItems.items.length);
describe('when localStorage is available and items array is not empty', () => {
let onRecentSearchesItemSelectedSpy;
let onRequestClearRecentSearchesSpy;
expect(
trimMarkupWhitespace(
items[0].querySelector('.filtered-search-history-dropdown-search-token').textContent,
),
).toEqual('foo');
const item1Tokens = items[1].querySelectorAll('.filtered-search-history-dropdown-token');
expect(item1Tokens.length).toEqual(2);
expect(item1Tokens[0].querySelector('.name').textContent).toEqual('author:');
expect(item1Tokens[0].querySelector('.value').textContent).toEqual('@root');
expect(item1Tokens[1].querySelector('.name').textContent).toEqual('label:');
expect(item1Tokens[1].querySelector('.value').textContent).toEqual('~foo');
expect(
trimMarkupWhitespace(
items[1].querySelector('.filtered-search-history-dropdown-search-token').textContent,
),
).toEqual('bar');
beforeAll(() => {
onRecentSearchesItemSelectedSpy = jest.fn();
onRequestClearRecentSearchesSpy = jest.fn();
eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
});
});
describe('if isLocalStorageAvailable is `false`', () => {
let el;
beforeEach(() => {
const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems);
vm = createComponent(props);
el = vm.$el;
createComponent({
items: ['foo', 'author:@root label:~foo bar'],
isLocalStorageAvailable: true,
});
});
it('should render an info note', () => {
const note = el.querySelector('.dropdown-info-note');
const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
afterAll(() => {
eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
eventHub.$off('requestClearRecentSearchesSpy', onRequestClearRecentSearchesSpy);
});
expect(note).toBeDefined();
expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled');
expect(items.length).toEqual(propsDataWithoutItems.items.length);
it('does not render a note about enabling local storage', () => {
expect(findLocalStorageNote().exists()).toBe(false);
});
});
describe('computed', () => {
describe('processedItems', () => {
it('with items', () => {
vm = createComponent(propsDataWithItems);
const { processedItems } = vm;
expect(processedItems.length).toEqual(2);
expect(processedItems[0].text).toEqual(propsDataWithItems.items[0]);
expect(processedItems[0].tokens).toEqual([]);
expect(processedItems[0].searchToken).toEqual('foo');
expect(processedItems[1].text).toEqual(propsDataWithItems.items[1]);
expect(processedItems[1].tokens.length).toEqual(2);
expect(processedItems[1].tokens[0].prefix).toEqual('author:');
expect(processedItems[1].tokens[0].suffix).toEqual('@root');
expect(processedItems[1].tokens[1].prefix).toEqual('label:');
expect(processedItems[1].tokens[1].suffix).toEqual('~foo');
expect(processedItems[1].searchToken).toEqual('bar');
});
it('does not render dropdownNote', () => {
expect(findDropdownNote().exists()).toBe(false);
});
it('with no items', () => {
vm = createComponent(propsDataWithoutItems);
const { processedItems } = vm;
it('renders a correct amount of dropdown items', () => {
expect(findDropdownItems()).toHaveLength(2);
});
expect(processedItems.length).toEqual(0);
});
it('expect second dropdown to have 2 tokens', () => {
expect(
findDropdownItems()
.at(1)
.findAll('.js-dropdown-token'),
).toHaveLength(2);
});
describe('hasItems', () => {
it('with items', () => {
vm = createComponent(propsDataWithItems);
const { hasItems } = vm;
it('emits recentSearchesItemSelected on dropdown item click', () => {
findDropdownItems()
.at(0)
.find('.js-dropdown-button')
.trigger('click');
expect(hasItems).toEqual(true);
});
expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('foo');
});
it('with no items', () => {
vm = createComponent(propsDataWithoutItems);
const { hasItems } = vm;
it('emits requestClearRecentSearches on Clear resent searches button', () => {
wrapper.find({ ref: 'clearButton' }).trigger('click');
expect(hasItems).toEqual(false);
});
expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled();
});
});
describe('methods', () => {
describe('onItemActivated', () => {
let onRecentSearchesItemSelectedSpy;
beforeEach(() => {
onRecentSearchesItemSelectedSpy = jest.fn();
eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
vm = createComponent(propsDataWithItems);
});
afterEach(() => {
eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
});
it('emits event', () => {
expect(onRecentSearchesItemSelectedSpy).not.toHaveBeenCalled();
vm.onItemActivated('something');
expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('something');
describe('when locale storage is available and items array is empty', () => {
beforeEach(() => {
createComponent({
isLocalStorageAvailable: true,
});
});
describe('onRequestClearRecentSearches', () => {
let onRequestClearRecentSearchesSpy;
beforeEach(() => {
onRequestClearRecentSearchesSpy = jest.fn();
eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
vm = createComponent(propsDataWithItems);
});
afterEach(() => {
eventHub.$off('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
});
it('does not render a note about enabling local storage', () => {
expect(findLocalStorageNote().exists()).toBe(false);
});
it('emits event', () => {
expect(onRequestClearRecentSearchesSpy).not.toHaveBeenCalled();
vm.onRequestClearRecentSearches({ stopPropagation: () => {} });
it('does not render dropdown items', () => {
expect(findDropdownItems().exists()).toBe(false);
});
expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled();
});
it('renders dropdown note', () => {
expect(findDropdownNote().exists()).toBe(true);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Code Block matches snapshot 1`] = `
<pre
class="code-block rounded"
>
<code
class="d-block"
>
test-code
</code>
</pre>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Identicon matches snapshot 1`] = `
<div
class="avatar identicon s40 bg2"
>
E
</div>
`;
import Vue from 'vue';
import component from '~/vue_shared/components/code_block.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
import { shallowMount } from '@vue/test-utils';
import CodeBlock from '~/vue_shared/components/code_block.vue';
describe('Code Block', () => {
const Component = Vue.extend(component);
let vm;
let wrapper;
afterEach(() => {
vm.$destroy();
});
it('renders a code block with the provided code', () => {
const code =
"Failure/Error: is_expected.to eq(3)\n\n expected: 3\n got: -1\n\n (compared using ==)\n./spec/test_spec.rb:12:in `block (4 levels) in \u003ctop (required)\u003e'";
vm = mountComponent(Component, {
code,
const createComponent = () => {
wrapper = shallowMount(CodeBlock, {
propsData: {
code: 'test-code',
},
});
};
expect(vm.$el.querySelector('code').textContent).toEqual(code);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('escapes XSS injections', () => {
const code = 'CCC&lt;img src=x onerror=alert(document.domain)&gt;';
vm = mountComponent(Component, {
code,
});
it('matches snapshot', () => {
createComponent();
expect(vm.$el.querySelector('code').textContent).toEqual(code);
expect(wrapper.element).toMatchSnapshot();
});
});
import Vue from 'vue';
import identiconComponent from '~/vue_shared/components/identicon.vue';
const createComponent = sizeClass => {
const Component = Vue.extend(identiconComponent);
return new Component({
propsData: {
entityId: 1,
entityName: 'entity-name',
sizeClass,
},
}).$mount();
};
describe('IdenticonComponent', () => {
describe('computed', () => {
let vm;
beforeEach(() => {
vm = createComponent();
});
afterEach(() => {
vm.$destroy();
});
describe('identiconBackgroundClass', () => {
it('should return bg class based on entityId', () => {
vm.entityId = 4;
expect(vm.identiconBackgroundClass).toBeDefined();
expect(vm.identiconBackgroundClass).toBe('bg5');
});
import { shallowMount } from '@vue/test-utils';
import IdenticonComponent from '~/vue_shared/components/identicon.vue';
describe('Identicon', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(IdenticonComponent, {
propsData: {
entityId: 1,
entityName: 'entity-name',
sizeClass: 's40',
},
});
};
describe('identiconTitle', () => {
it('should return first letter of entity title in uppercase', () => {
vm.entityName = 'dummy-group';
expect(vm.identiconTitle).toBeDefined();
expect(vm.identiconTitle).toBe('D');
});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('template', () => {
it('should render identicon', () => {
const vm = createComponent();
it('matches snapshot', () => {
createComponent();
expect(vm.$el.nodeName).toBe('DIV');
expect(vm.$el.classList.contains('identicon')).toBeTruthy();
expect(vm.$el.classList.contains('s40')).toBeTruthy();
expect(vm.$el.classList.contains('bg2')).toBeTruthy();
vm.$destroy();
});
expect(wrapper.element).toMatchSnapshot();
});
it('should render identicon with provided sizing class', () => {
const vm = createComponent('s32');
it('adds a correct class to identicon', () => {
createComponent();
expect(vm.$el.classList.contains('s32')).toBeTruthy();
vm.$destroy();
});
expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Issue Warning Component when issue is confidential but not locked renders information about confidential issue 1`] = `
<span>
This is a confidential issue.
People without permission will never get a notification.
<gl-link-stub
href="confidential-path"
target="_blank"
>
Learn more
</gl-link-stub>
</span>
`;
exports[`Issue Warning Component when issue is locked and confidential renders information about locked and confidential issue 1`] = `
<span>
<span>
This issue is
<a
href=""
rel="noopener noreferrer"
target="_blank"
>
confidential
</a>
and
<a
href=""
rel="noopener noreferrer"
target="_blank"
>
locked
</a>
.
</span>
People without permission will never get a notification and won't be able to comment.
</span>
`;
exports[`Issue Warning Component when issue is locked but not confidential renders information about locked issue 1`] = `
<span>
This issue is locked.
Only project members can comment.
<gl-link-stub
href="locked-path"
target="_blank"
>
Learn more
</gl-link-stub>
</span>
`;
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import issueWarning from '~/vue_shared/components/issue/issue_warning.vue';
import { shallowMount } from '@vue/test-utils';
import IssueWarning from '~/vue_shared/components/issue/issue_warning.vue';
import Icon from '~/vue_shared/components/icon.vue';
const IssueWarning = Vue.extend(issueWarning);
describe('Issue Warning Component', () => {
let wrapper;
function formatWarning(string) {
// Replace newlines with a space then replace multiple spaces with one space
return string
.trim()
.replace(/\n/g, ' ')
.replace(/\s\s+/g, ' ');
}
const findIcon = () => wrapper.find(Icon);
const findLockedBlock = () => wrapper.find({ ref: 'locked' });
const findConfidentialBlock = () => wrapper.find({ ref: 'confidential' });
const findLockedAndConfidentialBlock = () => wrapper.find({ ref: 'lockedAndConfidential' });
describe('Issue Warning Component', () => {
describe('isLocked', () => {
it('should render locked issue warning information', () => {
const props = {
const createComponent = props => {
wrapper = shallowMount(IssueWarning, {
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when issue is locked but not confidential', () => {
beforeEach(() => {
createComponent({
isLocked: true,
lockedIssueDocsPath: 'docs/issues/locked',
};
const vm = mountComponent(IssueWarning, props);
expect(
vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
).toMatch(/lock$/);
expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual(
'This issue is locked. Only project members can comment. Learn more',
);
expect(vm.$el.querySelector('a').href).toContain(props.lockedIssueDocsPath);
lockedIssueDocsPath: 'locked-path',
isConfidential: false,
});
});
it('renders information about locked issue', () => {
expect(findLockedBlock().exists()).toBe(true);
expect(findLockedBlock().element).toMatchSnapshot();
});
it('renders warning icon', () => {
expect(findIcon().exists()).toBe(true);
});
it('does not render information about locked and confidential issue', () => {
expect(findLockedAndConfidentialBlock().exists()).toBe(false);
});
it('does not render information about confidential issue', () => {
expect(findConfidentialBlock().exists()).toBe(false);
});
});
describe('isConfidential', () => {
it('should render confidential issue warning information', () => {
const props = {
describe('when issue is confidential but not locked', () => {
beforeEach(() => {
createComponent({
isLocked: false,
isConfidential: true,
confidentialIssueDocsPath: '/docs/issues/confidential',
};
const vm = mountComponent(IssueWarning, props);
expect(
vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'),
).toMatch(/eye-slash$/);
expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual(
'This is a confidential issue. People without permission will never get a notification. Learn more',
);
expect(vm.$el.querySelector('a').href).toContain(props.confidentialIssueDocsPath);
confidentialIssueDocsPath: 'confidential-path',
});
});
it('renders information about confidential issue', () => {
expect(findConfidentialBlock().exists()).toBe(true);
expect(findConfidentialBlock().element).toMatchSnapshot();
});
it('renders warning icon', () => {
expect(wrapper.find(Icon).exists()).toBe(true);
});
it('does not render information about locked issue', () => {
expect(findLockedBlock().exists()).toBe(false);
});
it('does not render information about locked and confidential issue', () => {
expect(findLockedAndConfidentialBlock().exists()).toBe(false);
});
});
describe('isLocked and isConfidential', () => {
it('should render locked and confidential issue warning information', () => {
const vm = mountComponent(IssueWarning, {
describe('when issue is locked and confidential', () => {
beforeEach(() => {
createComponent({
isLocked: true,
isConfidential: true,
});
});
it('renders information about locked and confidential issue', () => {
expect(findLockedAndConfidentialBlock().exists()).toBe(true);
expect(findLockedAndConfidentialBlock().element).toMatchSnapshot();
});
it('does not render warning icon', () => {
expect(wrapper.find(Icon).exists()).toBe(false);
});
it('does not render information about locked issue', () => {
expect(findLockedBlock().exists()).toBe(false);
});
expect(vm.$el.querySelector('.icon')).toBeFalsy();
expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual(
"This issue is confidential and locked. People without permission will never get a notification and won't be able to comment.",
);
it('does not render information about confidential issue', () => {
expect(findConfidentialBlock().exists()).toBe(false);
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Suggestion Diff component matches snapshot 1`] = `
<div
class="md-suggestion"
>
<suggestion-diff-header-stub
class="qa-suggestion-diff-header js-suggestion-diff-header"
helppagepath="path_to_docs"
/>
<table
class="mb-3 md-suggestion-diff js-syntax-highlight code"
>
<tbody>
<suggestion-diff-row-stub
line="[object Object]"
/>
<suggestion-diff-row-stub
line="[object Object]"
/>
<suggestion-diff-row-stub
line="[object Object]"
/>
</tbody>
</table>
</div>
`;
import Vue from 'vue';
import { shallowMount } from '@vue/test-utils';
import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion_diff.vue';
import { selectDiffLines } from '~/vue_shared/components/lib/utils/diff_utils';
import SuggestionDiffHeader from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
import SuggestionDiffRow from '~/vue_shared/components/markdown/suggestion_diff_row.vue';
const MOCK_DATA = {
canApply: true,
suggestion: {
id: 1,
diff_lines: [
......@@ -42,60 +42,45 @@ const MOCK_DATA = {
helpPagePath: 'path_to_docs',
};
const lines = selectDiffLines(MOCK_DATA.suggestion.diff_lines);
const newLines = lines.filter(line => line.type === 'new');
describe('Suggestion Diff component', () => {
let vm;
beforeEach(done => {
const Component = Vue.extend(SuggestionDiffComponent);
vm = new Component({
propsData: MOCK_DATA,
}).$mount();
Vue.nextTick(done);
});
describe('init', () => {
it('renders a suggestion header', () => {
expect(vm.$el.querySelector('.js-suggestion-diff-header')).not.toBeNull();
});
it('renders a diff table with syntax highlighting', () => {
expect(vm.$el.querySelector('.md-suggestion-diff.js-syntax-highlight.code')).not.toBeNull();
});
let wrapper;
it('renders the oldLineNumber', () => {
const fromLine = vm.$el.querySelector('.old_line').innerHTML;
expect(parseInt(fromLine, 10)).toBe(lines[0].old_line);
const createComponent = () => {
wrapper = shallowMount(SuggestionDiffComponent, {
propsData: {
...MOCK_DATA,
},
});
};
it('renders the oldLineContent', () => {
const fromContent = vm.$el.querySelector('.line_content.old').innerHTML;
expect(fromContent.includes(lines[0].text)).toBe(true);
});
beforeEach(() => {
createComponent();
});
it('renders new lines', () => {
const newLinesElements = vm.$el.querySelectorAll('.line_holder.new');
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
newLinesElements.forEach((line, i) => {
expect(newLinesElements[i].innerHTML.includes(newLines[i].new_line)).toBe(true);
expect(newLinesElements[i].innerHTML.includes(newLines[i].text)).toBe(true);
});
});
it('matches snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
describe('applySuggestion', () => {
it('emits apply event when applySuggestion is called', () => {
const callback = () => {};
jest.spyOn(vm, '$emit').mockImplementation(() => {});
vm.applySuggestion(callback);
it('renders a correct amount of suggestion diff rows', () => {
expect(wrapper.findAll(SuggestionDiffRow)).toHaveLength(3);
});
expect(vm.$emit).toHaveBeenCalledWith('apply', { suggestionId: vm.suggestion.id, callback });
});
it('emits apply event on sugestion diff header apply', () => {
wrapper.find(SuggestionDiffHeader).vm.$emit('apply', 'test-event');
expect(wrapper.emitted('apply')).toBeDefined();
expect(wrapper.emitted('apply')).toEqual([
[
{
callback: 'test-event',
suggestionId: 1,
},
],
]);
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Issue placeholder note component matches snapshot 1`] = `
<timeline-entry-item-stub
class="note note-wrapper being-posted fade-in-half"
>
<div
class="timeline-icon"
>
<user-avatar-link-stub
imgalt=""
imgcssclasses=""
imgsize="40"
imgsrc="mock_path"
linkhref="/root"
tooltipplacement="top"
tooltiptext=""
username=""
/>
</div>
<div
class="timeline-content discussion"
>
<div
class="note-header"
>
<div
class="note-header-info"
>
<a
href="/root"
>
<span
class="d-none d-sm-inline-block bold"
>
Root
</span>
<span
class="note-headline-light"
>
@root
</span>
</a>
</div>
</div>
<div
class="note-body"
>
<div
class="note-text md"
>
<p>
Foo
</p>
</div>
</div>
</div>
</timeline-entry-item-stub>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Placeholder system note component matches snapshot 1`] = `
<timeline-entry-item-stub
class="note system-note being-posted fade-in-half"
>
<div
class="timeline-content"
>
<em>
This is a placeholder
</em>
</div>
</timeline-entry-item-stub>
`;
import Vue from 'vue';
import issuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import createStore from '~/notes/stores';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import { userDataMock } from '../../../notes/mock_data';
describe('issue placeholder system note component', () => {
let store;
let vm;
beforeEach(() => {
const Component = Vue.extend(issuePlaceholderNote);
store = createStore();
store.dispatch('setUserData', userDataMock);
vm = new Component({
store,
propsData: { note: { body: 'Foo' } },
}).$mount();
});
const localVue = createLocalVue();
localVue.use(Vuex);
const getters = {
getUserData: () => userDataMock,
};
describe('Issue placeholder note component', () => {
let wrapper;
const findNote = () => wrapper.find({ ref: 'note' });
const createComponent = (isIndividual = false) => {
wrapper = shallowMount(IssuePlaceholderNote, {
localVue,
store: new Vuex.Store({
getters,
}),
propsData: {
note: {
body: 'Foo',
individual_note: isIndividual,
},
},
});
};
afterEach(() => {
vm.$destroy();
wrapper.destroy();
wrapper = null;
});
describe('user information', () => {
it('should render user avatar with link', () => {
expect(vm.$el.querySelector('.user-avatar-link').getAttribute('href')).toEqual(
userDataMock.path,
);
it('matches snapshot', () => {
createComponent();
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual(
`${userDataMock.avatar_url}?width=40`,
);
});
expect(wrapper.element).toMatchSnapshot();
});
describe('note content', () => {
it('should render note header information', () => {
expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual(
userDataMock.path,
);
it('does not add "discussion" class to individual notes', () => {
createComponent(true);
expect(
vm.$el.querySelector('.note-header-info .note-headline-light').textContent.trim(),
).toEqual(`@${userDataMock.username}`);
});
expect(findNote().classes()).not.toContain('discussion');
});
it('should render note body', () => {
expect(vm.$el.querySelector('.note-text p').textContent.trim()).toEqual('Foo');
});
it('adds "discussion" class to non-individual notes', () => {
createComponent();
expect(findNote().classes()).toContain('discussion');
});
});
import Vue from 'vue';
import mountComponent from 'helpers/vue_mount_component_helper';
import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import { shallowMount } from '@vue/test-utils';
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
describe('placeholder system note component', () => {
let PlaceholderSystemNote;
let vm;
describe('Placeholder system note component', () => {
let wrapper;
beforeEach(() => {
PlaceholderSystemNote = Vue.extend(placeholderSystemNote);
});
const createComponent = () => {
wrapper = shallowMount(PlaceholderSystemNote, {
propsData: {
note: { body: 'This is a placeholder' },
},
});
};
afterEach(() => {
vm.$destroy();
wrapper.destroy();
wrapper = null;
});
it('should render system note placeholder with plain text', () => {
vm = mountComponent(PlaceholderSystemNote, {
note: { body: 'This is a placeholder' },
});
it('matches snapshot', () => {
createComponent();
expect(vm.$el.tagName).toEqual('LI');
expect(vm.$el.querySelector('.timeline-content em').textContent.trim()).toEqual(
'This is a placeholder',
);
expect(wrapper.element).toMatchSnapshot();
});
});
......@@ -737,10 +737,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.90.0.tgz#e6fe0ca3d353fcdbd792c10d82444383c33f539d"
integrity sha512-6UikaIMGosmrDAd6Lf3QIJWDM4FwhoGIN+CJuFcWeHDMbZT69LnTabuGfvOQmp2+nlI68baRTSDufaux7m9Ajw==
"@gitlab/ui@^8.20.0":
version "8.20.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-8.20.0.tgz#98c0db6ddaa6b3bf8e5dcd1ae869df1819bc4186"
integrity sha512-Gjq7030E1Swo4HSXUrc9pKxFsmlS3pX/uq/67hGghAtFI8svJWWmCBLnSDHUVgjEW+DdmOn3hqqTWRPNaXGtOw==
"@gitlab/ui@^8.21.0":
version "8.21.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-8.21.0.tgz#30869847251d525c8402487cea16886b43f134e9"
integrity sha512-CLtpvF11aNOt+ttdE4xZTGM3sg124U/nYPrmGkre5FLcLqmSoK1LmRkflLHMUAwHWXz7CvxmSDZa2YshC3SsyQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"
......
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