diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-23 21:08:53 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-01-23 21:08:53 +0300 |
commit | d933bc5a8738d24898c5a82cc72ee9bd050425e6 (patch) | |
tree | 6d4c5ffedc32dc82c3fd6e4e3031f7981505655a | |
parent | 3f9e1b261121f4dbd045341241f81b47356c99cf (diff) |
Add latest changes from gitlab-org/gitlab@master
31 files changed, 746 insertions, 426 deletions
diff --git a/.gitlab/issue_templates/Coding style proposal.md b/.gitlab/issue_templates/Coding style proposal.md deleted file mode 100644 index 95f0fb5f366..00000000000 --- a/.gitlab/issue_templates/Coding style proposal.md +++ /dev/null @@ -1,16 +0,0 @@ -## 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 diff --git a/.gitlab/merge_request_templates/New static analysis check.md b/.gitlab/merge_request_templates/New static analysis check.md new file mode 100644 index 00000000000..b89b6f7dbc4 --- /dev/null +++ b/.gitlab/merge_request_templates/New static analysis check.md @@ -0,0 +1,27 @@ +## 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' diff --git a/Gemfile.lock b/Gemfile.lock index 8c4b1660e6b..1061cfebac4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue index fa2609a3176..e2909333d74 100644 --- a/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue +++ b/app/assets/javascripts/filtered_search/components/recent_searches_dropdown_content.vue @@ -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> diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 2c3320b5e79..347f7b450ff 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -1,6 +1,13 @@ 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; diff --git a/app/assets/javascripts/vue_shared/components/identicon.vue b/app/assets/javascripts/vue_shared/components/identicon.vue index d42f0d8192c..9dd61c8eada 100644 --- a/app/assets/javascripts/vue_shared/components/identicon.vue +++ b/app/assets/javascripts/vue_shared/components/identicon.vue @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue index 47f0851f650..b5d3f3685bc 100644 --- a/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue +++ b/app/assets/javascripts/vue_shared/components/issue/issue_warning.vue @@ -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"> diff --git a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue index af02b8969ee..69afd711797 100644 --- a/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/placeholder_note.vue @@ -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"> diff --git a/changelogs/unreleased/pokstad1-docs-praefect-conn-checker.yml b/changelogs/unreleased/pokstad1-docs-praefect-conn-checker.yml new file mode 100644 index 00000000000..baf42d8abb7 --- /dev/null +++ b/changelogs/unreleased/pokstad1-docs-praefect-conn-checker.yml @@ -0,0 +1,5 @@ +--- +title: Update Praefect docs for subcommand +merge_request: 23255 +author: +type: added diff --git a/changelogs/unreleased/selective-geo-sync.yml b/changelogs/unreleased/selective-geo-sync.yml new file mode 100644 index 00000000000..618f5e050ca --- /dev/null +++ b/changelogs/unreleased/selective-geo-sync.yml @@ -0,0 +1,5 @@ +--- +title: Add selective sync support to Geo Nodes API update endpoint +merge_request: 22828 +author: Rajendra Kadam +type: added diff --git a/changelogs/unreleased/sh-update-acme-client.yml b/changelogs/unreleased/sh-update-acme-client.yml new file mode 100644 index 00000000000..72eb3a55226 --- /dev/null +++ b/changelogs/unreleased/sh-update-acme-client.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade acme-client to v2.0.5 +merge_request: 23498 +author: +type: other diff --git a/doc/administration/gitaly/praefect.md b/doc/administration/gitaly/praefect.md index 72c3f996841..7ccf15434c4 100644 --- a/doc/administration/gitaly/praefect.md +++ b/doc/administration/gitaly/praefect.md @@ -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. diff --git a/doc/api/geo_nodes.md b/doc/api/geo_nodes.md index f54694ed15b..47fa67dd3f9 100644 --- a/doc/api/geo_nodes.md +++ b/doc/api/geo_nodes.md @@ -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", diff --git a/doc/api/groups.md b/doc/api/groups.md index f4dfefe3cb7..de8490fa1f4 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -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) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index cd984e981a1..2755a061498 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/package.json b/package.json index 85f054ea3dc..0378d5501bb 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@babel/plugin-syntax-import-meta": "^7.2.0", "@babel/preset-env": "^7.6.2", "@gitlab/svgs": "^1.90.0", - "@gitlab/ui": "^8.20.0", + "@gitlab/ui": "^8.21.0", "@gitlab/visual-review-tools": "1.5.1", "@sentry/browser": "^5.10.2", "@sourcegraph/code-host-integration": "0.0.21", diff --git a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js index 2543fb8768b..c0851096d8e 100644 --- a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -1,201 +1,125 @@ -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); }); }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap new file mode 100644 index 00000000000..5347d1efc48 --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/code_block_spec.js.snap @@ -0,0 +1,16 @@ +// 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> +`; diff --git a/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap new file mode 100644 index 00000000000..72370cb5b52 --- /dev/null +++ b/spec/frontend/vue_shared/components/__snapshots__/identicon_spec.js.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Identicon matches snapshot 1`] = ` +<div + class="avatar identicon s40 bg2" +> + + E + +</div> +`; diff --git a/spec/frontend/vue_shared/components/code_block_spec.js b/spec/frontend/vue_shared/components/code_block_spec.js index 6b91a20ff76..0d21dd94f7c 100644 --- a/spec/frontend/vue_shared/components/code_block_spec.js +++ b/spec/frontend/vue_shared/components/code_block_spec.js @@ -1,33 +1,25 @@ -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<img src=x onerror=alert(document.domain)>'; - - vm = mountComponent(Component, { - code, - }); + it('matches snapshot', () => { + createComponent(); - expect(vm.$el.querySelector('code').textContent).toEqual(code); + expect(wrapper.element).toMatchSnapshot(); }); }); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js index 0b3dbb61c96..5e8b013d480 100644 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ b/spec/frontend/vue_shared/components/identicon_spec.js @@ -1,65 +1,33 @@ -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'); }); }); diff --git a/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap b/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap new file mode 100644 index 00000000000..49b18d3e106 --- /dev/null +++ b/spec/frontend/vue_shared/components/issue/__snapshots__/issue_warning_spec.js.snap @@ -0,0 +1,62 @@ +// 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> +`; diff --git a/spec/frontend/vue_shared/components/issue/issue_warning_spec.js b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js index 7bb054b4e6c..891c70bcb5c 100644 --- a/spec/frontend/vue_shared/components/issue/issue_warning_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_warning_spec.js @@ -1,65 +1,105 @@ -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); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap new file mode 100644 index 00000000000..29ac754de49 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap @@ -0,0 +1,28 @@ +// 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> +`; diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js index 3c5e7500ba7..162ac495385 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_spec.js @@ -1,9 +1,9 @@ -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, + }, + ], + ]); }); }); diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap new file mode 100644 index 00000000000..f3ce03796f9 --- /dev/null +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_note_spec.js.snap @@ -0,0 +1,62 @@ +// 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> +`; diff --git a/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_system_note_spec.js.snap b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_system_note_spec.js.snap new file mode 100644 index 00000000000..10c33269107 --- /dev/null +++ b/spec/frontend/vue_shared/components/notes/__snapshots__/placeholder_system_note_spec.js.snap @@ -0,0 +1,15 @@ +// 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> +`; diff --git a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js index 45f131194ca..0f30b50da0b 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_note_spec.js @@ -1,51 +1,55 @@ -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'); }); }); diff --git a/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js index 81c5cd6a057..de6ab43bc41 100644 --- a/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/placeholder_system_note_spec.js @@ -1,27 +1,25 @@ -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(); }); }); diff --git a/yarn.lock b/yarn.lock index 8aab584c343..c19710186e8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" |