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' ...@@ -67,7 +67,7 @@ gem 'u2f', '~> 0.2.1'
gem 'validates_hostname', '~> 1.0.6' gem 'validates_hostname', '~> 1.0.6'
gem 'rubyzip', '~> 1.3.0', require: 'zip' gem 'rubyzip', '~> 1.3.0', require: 'zip'
# GitLab Pages letsencrypt support # GitLab Pages letsencrypt support
gem 'acme-client', '~> 2.0.2' gem 'acme-client', '~> 2.0.5'
# Browser detection # Browser detection
gem 'browser', '~> 2.5' gem 'browser', '~> 2.5'
......
...@@ -4,7 +4,7 @@ GEM ...@@ -4,7 +4,7 @@ GEM
RedCloth (4.3.2) RedCloth (4.3.2)
abstract_type (0.0.7) abstract_type (0.0.7)
ace-rails-ap (4.1.2) ace-rails-ap (4.1.2)
acme-client (2.0.2) acme-client (2.0.5)
faraday (~> 0.9, >= 0.9.1) faraday (~> 0.9, >= 0.9.1)
actioncable (5.2.3) actioncable (5.2.3)
actionpack (= 5.2.3) actionpack (= 5.2.3)
...@@ -1131,7 +1131,7 @@ PLATFORMS ...@@ -1131,7 +1131,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
RedCloth (~> 4.3.2) RedCloth (~> 4.3.2)
ace-rails-ap (~> 4.1.0) ace-rails-ap (~> 4.1.0)
acme-client (~> 2.0.2) acme-client (~> 2.0.5)
activerecord-explain-analyze (~> 0.1) activerecord-explain-analyze (~> 0.1)
acts-as-taggable-on (~> 6.0) acts-as-taggable-on (~> 6.0)
addressable (~> 2.7) addressable (~> 2.7)
......
...@@ -59,21 +59,25 @@ export default { ...@@ -59,21 +59,25 @@ export default {
</script> </script>
<template> <template>
<div> <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') }} {{ __('This feature requires local storage to be enabled') }}
</div> </div>
<ul v-else-if="hasItems"> <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 <button
type="button" type="button"
class="filtered-search-history-dropdown-item" class="filtered-search-history-dropdown-item js-dropdown-button"
@click="onItemActivated(item.text)" @click="onItemActivated(item.text)"
> >
<span> <span>
<span <span
v-for="(token, tokenIndex) in item.tokens" v-for="(token, tokenIndex) in item.tokens"
:key="`dropdown-token-${tokenIndex}`" :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.prefix }}</span>
<span class="name">{{ token.operator }}</span> <span class="name">{{ token.operator }}</span>
...@@ -88,6 +92,7 @@ export default { ...@@ -88,6 +92,7 @@ export default {
<li class="divider"></li> <li class="divider"></li>
<li> <li>
<button <button
ref="clearButton"
type="button" type="button"
class="filtered-search-history-clear-button" class="filtered-search-history-clear-button"
@click="onRequestClearRecentSearches($event)" @click="onRequestClearRecentSearches($event)"
...@@ -96,6 +101,8 @@ export default { ...@@ -96,6 +101,8 @@ export default {
</button> </button>
</li> </li>
</ul> </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> </div>
</template> </template>
import _ from 'underscore'; import _ from 'underscore';
import { spriteIcon } from './lib/utils/common_utils'; import { spriteIcon } from './lib/utils/common_utils';
const FLASH_TYPES = {
ALERT: 'alert',
NOTICE: 'notice',
SUCCESS: 'success',
WARNING: 'warning',
};
const hideFlash = (flashEl, fadeTransition = true) => { const hideFlash = (flashEl, fadeTransition = true) => {
if (fadeTransition) { if (fadeTransition) {
Object.assign(flashEl.style, { Object.assign(flashEl.style, {
...@@ -59,7 +66,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => { ...@@ -59,7 +66,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
* additional action or link on banner next to message * additional action or link on banner next to message
* *
* @param {String} message Flash message text * @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} parent Reference to parent element under which Flash needs to appear
* @param {Object} actonConfig Map of config to show action on banner * @param {Object} actonConfig Map of config to show action on banner
* @param {String} href URL to which action config should point to (default: '#') * @param {String} href URL to which action config should point to (default: '#')
...@@ -69,7 +76,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => { ...@@ -69,7 +76,7 @@ const removeFlashClickListener = (flashEl, fadeTransition) => {
*/ */
const createFlash = function createFlash( const createFlash = function createFlash(
message, message,
type = 'alert', type = FLASH_TYPES.ALERT,
parent = document, parent = document,
actionConfig = null, actionConfig = null,
fadeTransition = true, fadeTransition = true,
...@@ -102,5 +109,12 @@ const createFlash = function createFlash( ...@@ -102,5 +109,12 @@ const createFlash = function createFlash(
return flashContainer; return flashContainer;
}; };
export { createFlash as default, createFlashEl, createAction, hideFlash, removeFlashClickListener }; export {
createFlash as default,
createFlashEl,
createAction,
hideFlash,
removeFlashClickListener,
FLASH_TYPES,
};
window.Flash = createFlash; window.Flash = createFlash;
...@@ -29,7 +29,7 @@ export default { ...@@ -29,7 +29,7 @@ export default {
</script> </script>
<template> <template>
<div :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon"> <div ref="identicon" :class="[sizeClass, identiconBackgroundClass]" class="avatar identicon">
{{ identiconTitle }} {{ identiconTitle }}
</div> </div>
</template> </template>
...@@ -65,14 +65,14 @@ export default { ...@@ -65,14 +65,14 @@ export default {
<div class="issuable-note-warning"> <div class="issuable-note-warning">
<icon v-if="!isLockedAndConfidential" :name="warningIcon" :size="16" class="icon inline" /> <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> <span v-html="confidentialAndLockedDiscussionText"></span>
{{ {{
__("People without permission will never get a notification and won't be able to comment.") __("People without permission will never get a notification and won't be able to comment.")
}} }}
</span> </span>
<span v-else-if="isConfidential"> <span v-else-if="isConfidential" ref="confidential">
{{ __('This is a confidential issue.') }} {{ __('This is a confidential issue.') }}
{{ __('People without permission will never get a notification.') }} {{ __('People without permission will never get a notification.') }}
<gl-link :href="confidentialIssueDocsPath" target="_blank"> <gl-link :href="confidentialIssueDocsPath" target="_blank">
...@@ -80,7 +80,7 @@ export default { ...@@ -80,7 +80,7 @@ export default {
</gl-link> </gl-link>
</span> </span>
<span v-else-if="isLocked"> <span v-else-if="isLocked" ref="locked">
{{ __('This issue is locked.') }} {{ __('This issue is locked.') }}
{{ __('Only project members can comment.') }} {{ __('Only project members can comment.') }}
<gl-link :href="lockedIssueDocsPath" target="_blank"> <gl-link :href="lockedIssueDocsPath" target="_blank">
......
...@@ -47,7 +47,7 @@ export default { ...@@ -47,7 +47,7 @@ export default {
:img-size="40" :img-size="40"
/> />
</div> </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">
<div class="note-header-info"> <div class="note-header-info">
<a :href="getUserData.path"> <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({ ...@@ -260,6 +260,14 @@ git_data_dirs({
For more information on Gitaly server configuration, see our [Gitaly documentation](index.md#3-gitaly-server-configuration). 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 #### GitLab
When Praefect is running, it should be exposed as a storage to GitLab. This 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: ...@@ -311,4 +319,5 @@ Here are common errors and potential causes:
- **GRPC::Unavailable (14:failed to connect to all addresses)** - **GRPC::Unavailable (14:failed to connect to all addresses)**
- GitLab was unable to reach Praefect. - GitLab was unable to reach Praefect.
- **GRPC::Unavailable (14:all SubCons are in TransientFailure...)** - **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: ...@@ -84,6 +84,10 @@ Example response:
"repos_max_capacity": 25, "repos_max_capacity": 25,
"container_repositories_max_capacity": 10, "container_repositories_max_capacity": 10,
"verification_max_capacity": 100, "verification_max_capacity": 100,
"selective_sync_type": "namespaces",
"selective_sync_shards": [],
"selective_sync_namespace_ids": [1, 25],
"minimum_reverification_interval": 7,
"clone_protocol": "http", "clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit", "web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit",
"_links": { "_links": {
...@@ -104,6 +108,10 @@ Example response: ...@@ -104,6 +108,10 @@ Example response:
"repos_max_capacity": 25, "repos_max_capacity": 25,
"container_repositories_max_capacity": 10, "container_repositories_max_capacity": 10,
"verification_max_capacity": 100, "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, "sync_object_storage": true,
"clone_protocol": "http", "clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit", "web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit",
...@@ -142,6 +150,10 @@ Example response: ...@@ -142,6 +150,10 @@ Example response:
"repos_max_capacity": 25, "repos_max_capacity": 25,
"container_repositories_max_capacity": 10, "container_repositories_max_capacity": 10,
"verification_max_capacity": 100, "verification_max_capacity": 100,
"selective_sync_type": "namespaces",
"selective_sync_shards": [],
"selective_sync_namespace_ids": [1, 25],
"minimum_reverification_interval": 7,
"clone_protocol": "http", "clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit", "web_edit_url": "https://primary.example.com/admin/geo/nodes/1/edit",
"_links": { "_links": {
...@@ -174,6 +186,10 @@ PUT /geo_nodes/:id ...@@ -174,6 +186,10 @@ PUT /geo_nodes/:id
| `verification_max_capacity` | integer | no | Control the maximum concurrency of verification for this node. | | `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. | | `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. | | `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: Example response:
...@@ -190,6 +206,10 @@ Example response: ...@@ -190,6 +206,10 @@ Example response:
"repos_max_capacity": 25, "repos_max_capacity": 25,
"container_repositories_max_capacity": 10, "container_repositories_max_capacity": 10,
"verification_max_capacity": 100, "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, "sync_object_storage": true,
"clone_protocol": "http", "clone_protocol": "http",
"web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit", "web_edit_url": "https://primary.example.com/admin/geo/nodes/2/edit",
......
...@@ -660,6 +660,118 @@ GET /groups?search=foobar ...@@ -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 **(STARTER)**
Group audit events can be accessed via the [Group Audit Events API](audit_events.md#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] "" ...@@ -627,7 +627,7 @@ msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "1 user" msgid "1 user"
msgid_plural "%d users" msgid_plural "%{num} users"
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
...@@ -772,6 +772,9 @@ msgstr "" ...@@ -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." msgid "A fork is a copy of a project.<br />Forking a repository allows you to make changes without affecting the original project."
msgstr "" 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." msgid "A member of the abuse team will review your report as soon as possible."
msgstr "" msgstr ""
...@@ -5406,6 +5409,9 @@ msgstr "" ...@@ -5406,6 +5409,9 @@ msgstr ""
msgid "Create a Mattermost team for this group" msgid "Create a Mattermost team for this group"
msgstr "" 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." msgid "Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies."
msgstr "" msgstr ""
...@@ -8930,6 +8936,9 @@ msgstr "" ...@@ -8930,6 +8936,9 @@ msgstr ""
msgid "Get a free instance review" msgid "Get a free instance review"
msgstr "" msgstr ""
msgid "Get started"
msgstr ""
msgid "Get started with error tracking" msgid "Get started with error tracking"
msgstr "" msgstr ""
...@@ -9395,6 +9404,9 @@ msgstr "" ...@@ -9395,6 +9404,9 @@ msgstr ""
msgid "Group name" msgid "Group name"
msgstr "" msgstr ""
msgid "Group name (Your organization)"
msgstr ""
msgid "Group overview" msgid "Group overview"
msgstr "" msgstr ""
...@@ -18463,6 +18475,9 @@ msgstr "" ...@@ -18463,6 +18475,9 @@ msgstr ""
msgid "Thank you for your report. A GitLab administrator will look into it shortly." msgid "Thank you for your report. A GitLab administrator will look into it shortly."
msgstr "" msgstr ""
msgid "Thanks for your purchase!"
msgstr ""
msgid "Thanks! Don't show me this again" msgid "Thanks! Don't show me this again"
msgstr "" msgstr ""
...@@ -21068,6 +21083,9 @@ msgstr "" ...@@ -21068,6 +21083,9 @@ msgstr ""
msgid "Welcome to GitLab %{name}!" msgid "Welcome to GitLab %{name}!"
msgstr "" msgstr ""
msgid "Welcome to GitLab, %{first_name}!"
msgstr ""
msgid "Welcome to the Guided GitLab Tour" msgid "Welcome to the Guided GitLab Tour"
msgstr "" msgstr ""
...@@ -21382,6 +21400,9 @@ msgstr "" ...@@ -21382,6 +21400,9 @@ msgstr ""
msgid "You can also upload existing files from your computer using the instructions below." msgid "You can also upload existing files from your computer using the instructions below."
msgstr "" msgstr ""
msgid "You can always edit this later"
msgstr ""
msgid "You can apply your Trial to your Personal account or create a New Group." msgid "You can apply your Trial to your Personal account or create a New Group."
msgstr "" msgstr ""
...@@ -21556,6 +21577,9 @@ msgstr "" ...@@ -21556,6 +21577,9 @@ msgstr ""
msgid "You have reached your project limit" msgid "You have reached your project limit"
msgstr "" 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" msgid "You haven't added any issues to your project yet"
msgstr "" msgstr ""
...@@ -21715,6 +21739,9 @@ msgstr "" ...@@ -21715,6 +21739,9 @@ msgstr ""
msgid "Your GPG keys (%{count})" msgid "Your GPG keys (%{count})"
msgstr "" 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." 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 "" msgstr ""
......
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import eventHub from '~/filtered_search/event_hub'; import eventHub from '~/filtered_search/event_hub';
import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue'; import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue';
import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys';
const createComponent = propsData => { describe('Recent Searches Dropdown Content', () => {
const Component = Vue.extend(RecentSearchesDropdownContent); let wrapper;
return new Component({ const findLocalStorageNote = () => wrapper.find({ ref: 'localStorageNote' });
el: document.createElement('div'), const findDropdownItems = () => wrapper.findAll({ ref: 'dropdownItem' });
propsData, const findDropdownNote = () => wrapper.find({ ref: 'dropdownNote' });
});
}; const createComponent = props => {
wrapper = shallowMount(RecentSearchesDropdownContent, {
// Remove all the newlines and whitespace from the formatted markup propsData: {
const trimMarkupWhitespace = text => text.replace(/(\n|\s)+/gm, ' ').trim(); allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
items: [],
describe('RecentSearchesDropdownContent', () => { isLocalStorageAvailable: false,
const propsDataWithoutItems = { ...props,
items: [], },
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(), });
};
const propsDataWithItems = {
items: ['foo', 'author:@root label:~foo bar'],
allowedKeys: IssuableFilteredSearchTokenKeys.getKeys(),
}; };
let vm;
afterEach(() => { afterEach(() => {
if (vm) { wrapper.destroy();
vm.$destroy(); wrapper = null;
}
}); });
describe('with no items', () => { describe('when local storage is not available', () => {
let el;
beforeEach(() => { beforeEach(() => {
vm = createComponent(propsDataWithoutItems); createComponent();
el = vm.$el;
}); });
it('should render empty state', () => { it('renders a note about enabling local storage', () => {
expect(el.querySelector('.dropdown-info-note')).toBeDefined(); expect(findLocalStorageNote().exists()).toBe(true);
const items = el.querySelectorAll('.filtered-search-history-dropdown-item');
expect(items.length).toEqual(propsDataWithoutItems.items.length);
}); });
});
describe('with items', () => {
let el;
beforeEach(() => { it('does not render dropdown items', () => {
vm = createComponent(propsDataWithItems); expect(findDropdownItems().exists()).toBe(false);
el = vm.$el;
}); });
it('should render clear recent searches button', () => { it('does not render dropdownNote', () => {
expect(el.querySelector('.filtered-search-history-clear-button')).toBeDefined(); expect(findDropdownNote().exists()).toBe(false);
}); });
});
it('should render recent search items', () => { describe('when localStorage is available and items array is not empty', () => {
const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); let onRecentSearchesItemSelectedSpy;
let onRequestClearRecentSearchesSpy;
expect(items.length).toEqual(propsDataWithItems.items.length);
expect( beforeAll(() => {
trimMarkupWhitespace( onRecentSearchesItemSelectedSpy = jest.fn();
items[0].querySelector('.filtered-search-history-dropdown-search-token').textContent, onRequestClearRecentSearchesSpy = jest.fn();
), eventHub.$on('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
).toEqual('foo'); eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
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');
}); });
});
describe('if isLocalStorageAvailable is `false`', () => {
let el;
beforeEach(() => { beforeEach(() => {
const props = Object.assign({ isLocalStorageAvailable: false }, propsDataWithItems); createComponent({
items: ['foo', 'author:@root label:~foo bar'],
vm = createComponent(props); isLocalStorageAvailable: true,
el = vm.$el; });
}); });
it('should render an info note', () => { afterAll(() => {
const note = el.querySelector('.dropdown-info-note'); eventHub.$off('recentSearchesItemSelected', onRecentSearchesItemSelectedSpy);
const items = el.querySelectorAll('.filtered-search-history-dropdown-item'); eventHub.$off('requestClearRecentSearchesSpy', onRequestClearRecentSearchesSpy);
});
expect(note).toBeDefined(); it('does not render a note about enabling local storage', () => {
expect(note.innerText.trim()).toBe('This feature requires local storage to be enabled'); expect(findLocalStorageNote().exists()).toBe(false);
expect(items.length).toEqual(propsDataWithoutItems.items.length);
}); });
});
describe('computed', () => { it('does not render dropdownNote', () => {
describe('processedItems', () => { expect(findDropdownNote().exists()).toBe(false);
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('with no items', () => { it('renders a correct amount of dropdown items', () => {
vm = createComponent(propsDataWithoutItems); expect(findDropdownItems()).toHaveLength(2);
const { processedItems } = vm; });
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('emits recentSearchesItemSelected on dropdown item click', () => {
it('with items', () => { findDropdownItems()
vm = createComponent(propsDataWithItems); .at(0)
const { hasItems } = vm; .find('.js-dropdown-button')
.trigger('click');
expect(hasItems).toEqual(true); expect(onRecentSearchesItemSelectedSpy).toHaveBeenCalledWith('foo');
}); });
it('with no items', () => { it('emits requestClearRecentSearches on Clear resent searches button', () => {
vm = createComponent(propsDataWithoutItems); wrapper.find({ ref: 'clearButton' }).trigger('click');
const { hasItems } = vm;
expect(hasItems).toEqual(false); expect(onRequestClearRecentSearchesSpy).toHaveBeenCalled();
});
}); });
}); });
describe('methods', () => { describe('when locale storage is available and items array is empty', () => {
describe('onItemActivated', () => { beforeEach(() => {
let onRecentSearchesItemSelectedSpy; createComponent({
isLocalStorageAvailable: true,
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('onRequestClearRecentSearches', () => { it('does not render a note about enabling local storage', () => {
let onRequestClearRecentSearchesSpy; expect(findLocalStorageNote().exists()).toBe(false);
});
beforeEach(() => {
onRequestClearRecentSearchesSpy = jest.fn();
eventHub.$on('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
vm = createComponent(propsDataWithItems);
});
afterEach(() => {
eventHub.$off('requestClearRecentSearches', onRequestClearRecentSearchesSpy);
});
it('emits event', () => { it('does not render dropdown items', () => {
expect(onRequestClearRecentSearchesSpy).not.toHaveBeenCalled(); expect(findDropdownItems().exists()).toBe(false);
vm.onRequestClearRecentSearches({ stopPropagation: () => {} }); });
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 { shallowMount } from '@vue/test-utils';
import component from '~/vue_shared/components/code_block.vue'; import CodeBlock from '~/vue_shared/components/code_block.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('Code Block', () => { describe('Code Block', () => {
const Component = Vue.extend(component); let wrapper;
let vm;
afterEach(() => { const createComponent = () => {
vm.$destroy(); wrapper = shallowMount(CodeBlock, {
}); propsData: {
code: 'test-code',
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,
}); });
};
expect(vm.$el.querySelector('code').textContent).toEqual(code); afterEach(() => {
wrapper.destroy();
wrapper = null;
}); });
it('escapes XSS injections', () => { it('matches snapshot', () => {
const code = 'CCC&lt;img src=x onerror=alert(document.domain)&gt;'; createComponent();
vm = mountComponent(Component, {
code,
});
expect(vm.$el.querySelector('code').textContent).toEqual(code); expect(wrapper.element).toMatchSnapshot();
}); });
}); });
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import identiconComponent from '~/vue_shared/components/identicon.vue'; import IdenticonComponent from '~/vue_shared/components/identicon.vue';
const createComponent = sizeClass => { describe('Identicon', () => {
const Component = Vue.extend(identiconComponent); let wrapper;
return new Component({ const createComponent = () => {
propsData: { wrapper = shallowMount(IdenticonComponent, {
entityId: 1, propsData: {
entityName: 'entity-name', entityId: 1,
sizeClass, entityName: 'entity-name',
}, sizeClass: 's40',
}).$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');
});
}); });
};
describe('identiconTitle', () => { afterEach(() => {
it('should return first letter of entity title in uppercase', () => { wrapper.destroy();
vm.entityName = 'dummy-group'; wrapper = null;
expect(vm.identiconTitle).toBeDefined();
expect(vm.identiconTitle).toBe('D');
});
});
}); });
describe('template', () => { it('matches snapshot', () => {
it('should render identicon', () => { createComponent();
const vm = createComponent();
expect(vm.$el.nodeName).toBe('DIV'); expect(wrapper.element).toMatchSnapshot();
expect(vm.$el.classList.contains('identicon')).toBeTruthy(); });
expect(vm.$el.classList.contains('s40')).toBeTruthy();
expect(vm.$el.classList.contains('bg2')).toBeTruthy();
vm.$destroy();
});
it('should render identicon with provided sizing class', () => { it('adds a correct class to identicon', () => {
const vm = createComponent('s32'); createComponent();
expect(vm.$el.classList.contains('s32')).toBeTruthy(); expect(wrapper.find({ ref: 'identicon' }).classes()).toContain('bg2');
vm.$destroy();
});
}); });
}); });
// 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 { shallowMount } from '@vue/test-utils';
import mountComponent from 'helpers/vue_mount_component_helper'; import IssueWarning from '~/vue_shared/components/issue/issue_warning.vue';
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) { const findIcon = () => wrapper.find(Icon);
// Replace newlines with a space then replace multiple spaces with one space const findLockedBlock = () => wrapper.find({ ref: 'locked' });
return string const findConfidentialBlock = () => wrapper.find({ ref: 'confidential' });
.trim() const findLockedAndConfidentialBlock = () => wrapper.find({ ref: 'lockedAndConfidential' });
.replace(/\n/g, ' ')
.replace(/\s\s+/g, ' ');
}
describe('Issue Warning Component', () => { const createComponent = props => {
describe('isLocked', () => { wrapper = shallowMount(IssueWarning, {
it('should render locked issue warning information', () => { propsData: {
const props = { ...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when issue is locked but not confidential', () => {
beforeEach(() => {
createComponent({
isLocked: true, isLocked: true,
lockedIssueDocsPath: 'docs/issues/locked', lockedIssueDocsPath: 'locked-path',
}; isConfidential: false,
const vm = mountComponent(IssueWarning, props); });
});
expect(
vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'), it('renders information about locked issue', () => {
).toMatch(/lock$/); expect(findLockedBlock().exists()).toBe(true);
expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( expect(findLockedBlock().element).toMatchSnapshot();
'This issue is locked. Only project members can comment. Learn more', });
);
expect(vm.$el.querySelector('a').href).toContain(props.lockedIssueDocsPath); 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', () => { describe('when issue is confidential but not locked', () => {
it('should render confidential issue warning information', () => { beforeEach(() => {
const props = { createComponent({
isLocked: false,
isConfidential: true, isConfidential: true,
confidentialIssueDocsPath: '/docs/issues/confidential', confidentialIssueDocsPath: 'confidential-path',
}; });
const vm = mountComponent(IssueWarning, props); });
expect( it('renders information about confidential issue', () => {
vm.$el.querySelector('.icon use').getAttributeNS('http://www.w3.org/1999/xlink', 'href'), expect(findConfidentialBlock().exists()).toBe(true);
).toMatch(/eye-slash$/); expect(findConfidentialBlock().element).toMatchSnapshot();
expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( });
'This is a confidential issue. People without permission will never get a notification. Learn more',
); it('renders warning icon', () => {
expect(vm.$el.querySelector('a').href).toContain(props.confidentialIssueDocsPath); 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', () => { describe('when issue is locked and confidential', () => {
it('should render locked and confidential issue warning information', () => { beforeEach(() => {
const vm = mountComponent(IssueWarning, { createComponent({
isLocked: true, isLocked: true,
isConfidential: 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(); it('does not render information about confidential issue', () => {
expect(formatWarning(vm.$el.querySelector('span').textContent)).toEqual( expect(findConfidentialBlock().exists()).toBe(false);
"This issue is confidential and locked. People without permission will never get a notification and won't be able to comment.",
);
}); });
}); });
}); });
// 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 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 = { const MOCK_DATA = {
canApply: true,
suggestion: { suggestion: {
id: 1, id: 1,
diff_lines: [ diff_lines: [
...@@ -42,60 +42,45 @@ const MOCK_DATA = { ...@@ -42,60 +42,45 @@ const MOCK_DATA = {
helpPagePath: 'path_to_docs', helpPagePath: 'path_to_docs',
}; };
const lines = selectDiffLines(MOCK_DATA.suggestion.diff_lines);
const newLines = lines.filter(line => line.type === 'new');
describe('Suggestion Diff component', () => { describe('Suggestion Diff component', () => {
let vm; let wrapper;
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();
});
it('renders the oldLineNumber', () => { const createComponent = () => {
const fromLine = vm.$el.querySelector('.old_line').innerHTML; wrapper = shallowMount(SuggestionDiffComponent, {
propsData: {
expect(parseInt(fromLine, 10)).toBe(lines[0].old_line); ...MOCK_DATA,
},
}); });
};
it('renders the oldLineContent', () => { beforeEach(() => {
const fromContent = vm.$el.querySelector('.line_content.old').innerHTML; createComponent();
});
expect(fromContent.includes(lines[0].text)).toBe(true);
});
it('renders new lines', () => { afterEach(() => {
const newLinesElements = vm.$el.querySelectorAll('.line_holder.new'); wrapper.destroy();
wrapper = null;
});
newLinesElements.forEach((line, i) => { it('matches snapshot', () => {
expect(newLinesElements[i].innerHTML.includes(newLines[i].new_line)).toBe(true); expect(wrapper.element).toMatchSnapshot();
expect(newLinesElements[i].innerHTML.includes(newLines[i].text)).toBe(true);
});
});
}); });
describe('applySuggestion', () => { it('renders a correct amount of suggestion diff rows', () => {
it('emits apply event when applySuggestion is called', () => { expect(wrapper.findAll(SuggestionDiffRow)).toHaveLength(3);
const callback = () => {}; });
jest.spyOn(vm, '$emit').mockImplementation(() => {});
vm.applySuggestion(callback);
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 { shallowMount, createLocalVue } from '@vue/test-utils';
import issuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue'; import Vuex from 'vuex';
import createStore from '~/notes/stores'; import IssuePlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import { userDataMock } from '../../../notes/mock_data'; import { userDataMock } from '../../../notes/mock_data';
describe('issue placeholder system note component', () => { const localVue = createLocalVue();
let store; localVue.use(Vuex);
let vm;
const getters = {
beforeEach(() => { getUserData: () => userDataMock,
const Component = Vue.extend(issuePlaceholderNote); };
store = createStore();
store.dispatch('setUserData', userDataMock); describe('Issue placeholder note component', () => {
vm = new Component({ let wrapper;
store,
propsData: { note: { body: 'Foo' } }, const findNote = () => wrapper.find({ ref: 'note' });
}).$mount();
}); const createComponent = (isIndividual = false) => {
wrapper = shallowMount(IssuePlaceholderNote, {
localVue,
store: new Vuex.Store({
getters,
}),
propsData: {
note: {
body: 'Foo',
individual_note: isIndividual,
},
},
});
};
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
wrapper = null;
}); });
describe('user information', () => { it('matches snapshot', () => {
it('should render user avatar with link', () => { createComponent();
expect(vm.$el.querySelector('.user-avatar-link').getAttribute('href')).toEqual(
userDataMock.path,
);
expect(vm.$el.querySelector('.user-avatar-link img').getAttribute('src')).toEqual( expect(wrapper.element).toMatchSnapshot();
`${userDataMock.avatar_url}?width=40`,
);
});
}); });
describe('note content', () => { it('does not add "discussion" class to individual notes', () => {
it('should render note header information', () => { createComponent(true);
expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual(
userDataMock.path,
);
expect( expect(findNote().classes()).not.toContain('discussion');
vm.$el.querySelector('.note-header-info .note-headline-light').textContent.trim(), });
).toEqual(`@${userDataMock.username}`);
});
it('should render note body', () => { it('adds "discussion" class to non-individual notes', () => {
expect(vm.$el.querySelector('.note-text p').textContent.trim()).toEqual('Foo'); createComponent();
});
expect(findNote().classes()).toContain('discussion');
}); });
}); });
import Vue from 'vue'; import { shallowMount } from '@vue/test-utils';
import mountComponent from 'helpers/vue_mount_component_helper'; import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import placeholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
describe('placeholder system note component', () => { describe('Placeholder system note component', () => {
let PlaceholderSystemNote; let wrapper;
let vm;
beforeEach(() => { const createComponent = () => {
PlaceholderSystemNote = Vue.extend(placeholderSystemNote); wrapper = shallowMount(PlaceholderSystemNote, {
}); propsData: {
note: { body: 'This is a placeholder' },
},
});
};
afterEach(() => { afterEach(() => {
vm.$destroy(); wrapper.destroy();
wrapper = null;
}); });
it('should render system note placeholder with plain text', () => { it('matches snapshot', () => {
vm = mountComponent(PlaceholderSystemNote, { createComponent();
note: { body: 'This is a placeholder' },
});
expect(vm.$el.tagName).toEqual('LI'); expect(wrapper.element).toMatchSnapshot();
expect(vm.$el.querySelector('.timeline-content em').textContent.trim()).toEqual(
'This is a placeholder',
);
}); });
}); });
...@@ -737,10 +737,10 @@ ...@@ -737,10 +737,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.90.0.tgz#e6fe0ca3d353fcdbd792c10d82444383c33f539d" resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.90.0.tgz#e6fe0ca3d353fcdbd792c10d82444383c33f539d"
integrity sha512-6UikaIMGosmrDAd6Lf3QIJWDM4FwhoGIN+CJuFcWeHDMbZT69LnTabuGfvOQmp2+nlI68baRTSDufaux7m9Ajw== integrity sha512-6UikaIMGosmrDAd6Lf3QIJWDM4FwhoGIN+CJuFcWeHDMbZT69LnTabuGfvOQmp2+nlI68baRTSDufaux7m9Ajw==
"@gitlab/ui@^8.20.0": "@gitlab/ui@^8.21.0":
version "8.20.0" version "8.21.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-8.20.0.tgz#98c0db6ddaa6b3bf8e5dcd1ae869df1819bc4186" resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-8.21.0.tgz#30869847251d525c8402487cea16886b43f134e9"
integrity sha512-Gjq7030E1Swo4HSXUrc9pKxFsmlS3pX/uq/67hGghAtFI8svJWWmCBLnSDHUVgjEW+DdmOn3hqqTWRPNaXGtOw== integrity sha512-CLtpvF11aNOt+ttdE4xZTGM3sg124U/nYPrmGkre5FLcLqmSoK1LmRkflLHMUAwHWXz7CvxmSDZa2YshC3SsyQ==
dependencies: dependencies:
"@babel/standalone" "^7.0.0" "@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.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